diff --git a/packages/viewer/src/chrome/ViewerShell.test.tsx b/packages/viewer/src/chrome/ViewerShell.test.tsx index 6e522cf..a98f8d5 100644 --- a/packages/viewer/src/chrome/ViewerShell.test.tsx +++ b/packages/viewer/src/chrome/ViewerShell.test.tsx @@ -23,6 +23,14 @@ describe('ViewerShell', () => { expect(screen.queryByRole('heading', { level: 1 })).not.toBeInTheDocument(); }); + it('leaves the main landmark to the page it is embedded in', () => { + const { unmount } = render( {}} />); + expect(screen.getByRole('main')).toBeInTheDocument(); + unmount(); + render( {}} titleAs="h2" />); + expect(screen.queryByRole('main')).not.toBeInTheDocument(); + }); + it('lists Overview first, then agent views, as tabs', () => { render( {}} />); const tabs = within(screen.getByRole('tablist')).getAllByRole('tab'); diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index a198e65..0c1eed2 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -93,10 +93,11 @@ export function ViewerShell({ diagram: LaidOutDiagram; theme: ThemeChoice; onToggleTheme: () => void; - /** h2 when the shell sits in a page that has its own h1 (the website) */ + /** h2 when the shell sits in a page that has its own h1 and main landmark (the website); its main is then a plain div */ titleAs?: 'h1' | 'h2'; }) { const { draft } = diagram; + const Main = Title === 'h1' ? 'main' : 'div'; const [inspectorCollapsed, setInspectorCollapsed] = useState(() => innerWidth < INSPECTOR_BREAKPOINT); const shell = useRef(null); // Presentation (F): the stage alone, full screen where the browser allows it, stepping through the views. @@ -159,7 +160,7 @@ export function ViewerShell({ )} {!presenting && } -
+
present(false)} />}
{!presenting && setInspectorCollapsed((v) => !v)} />} -
+
); diff --git a/site/app/docs/page.tsx b/site/app/docs/page.tsx index 0007dbd..3937096 100644 --- a/site/app/docs/page.tsx +++ b/site/app/docs/page.tsx @@ -71,7 +71,7 @@ function Transcript() { return (
A repair round, as the agent sees it
-
+      
         {prompt('validate')}
         {broken.map(line)}
          
diff --git a/site/app/docs/schema/page.tsx b/site/app/docs/schema/page.tsx
index aed1024..2e21c48 100644
--- a/site/app/docs/schema/page.tsx
+++ b/site/app/docs/schema/page.tsx
@@ -48,7 +48,7 @@ function Table({ section, placement }: { section: SchemaSection; placement: Plac
           Set source.url on the diagram and every evidence entry links to <url>/<file>#L<line>.
         

)} -
+
diff --git a/site/app/globals.css b/site/app/globals.css index 681aae9..e25bd44 100644 --- a/site/app/globals.css +++ b/site/app/globals.css @@ -12,7 +12,8 @@ --font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, monospace; --color-accent: var(--site-accent); - --text-mega: clamp(60px, 13.9vw, 200px); + /* The floor lets the longest title, "Architecture.", fit a 320px screen. */ + --text-mega: clamp(44px, 13.9vw, 200px); --text-mega--line-height: 0.92; --text-mega--letter-spacing: -0.05em; --text-mega--font-weight: 600; @@ -73,3 +74,10 @@ body { [data-theme='light'] .lockup-dark { display: none; } + +/* An async edge's dashes running toward its head: one dash-and-gap per loop. */ +@keyframes dash-flow { + to { + stroke-dashoffset: -9; + } +} diff --git a/site/app/not-found.tsx b/site/app/not-found.tsx new file mode 100644 index 0000000..1843b8f --- /dev/null +++ b/site/app/not-found.tsx @@ -0,0 +1,64 @@ +import type { Metadata } from 'next'; +import Link from 'next/link'; +import { NodeCard } from '@stackmap/viewer/src/card/NodeCard'; + +// Next marks the 404 response noindex itself. +export const metadata: Metadata = { title: 'Page not found' }; + +const WAYS = [ + { href: '/', eyebrow: 'Start over', label: 'Home' }, + { href: '/examples', eyebrow: 'Browse', label: 'Examples' }, + { href: '/docs', eyebrow: 'Read', label: 'Quick start' }, +]; + +// A request card whose route runs out at an empty, dashed slot: the page this address would have been. +function Dangling() { + return ( + + ); +} + +export default function NotFound() { + return ( +
+
+
+

404

+

Off the map.

+

Nothing lives at this address. The link may be old, or mistyped.

+
+ + +
+
+ ); +} diff --git a/site/components/docs/prose.tsx b/site/components/docs/prose.tsx index f3d8a19..c408b00 100644 --- a/site/components/docs/prose.tsx +++ b/site/components/docs/prose.tsx @@ -13,7 +13,7 @@ export const docs = { export function Code({ children }: { children: ReactNode }) { return ( - {children} + {children} ); } diff --git a/site/components/kinds/Parts.tsx b/site/components/kinds/Parts.tsx index 7d83ca5..c451fac 100644 --- a/site/components/kinds/Parts.tsx +++ b/site/components/kinds/Parts.tsx @@ -1,5 +1,5 @@ import type { CSSProperties, ReactNode } from 'react'; -import { INFRA_TYPES, STATE_TYPES, type DiagramKind, type DiagramNode, type NodeType } from '@stackmap/core'; +import { INFRA_TYPES, KIND_LABELS, STATE_TYPES, type DiagramKind, type DiagramNode, type NodeType } from '@stackmap/core'; import { NodeCard } from '@stackmap/viewer/src/card/NodeCard'; import { StepCard } from '@stackmap/viewer/src/card/StepCard'; import { TypeChip } from '@/components/ui/TypeChip'; @@ -189,17 +189,20 @@ const LABELS: Partial> = { export function Parts({ kind, page }: { kind: DiagramKind; page: KindPage }) { return ( -
    - {page.parts.map((p, i) => ( -
  • - -

    {p.title}

    -

    {p.text}

    -
  • - ))} -
+
+

{KIND_LABELS[kind]}, part by part

+
    + {page.parts.map((p, i) => ( +
  • + +

    {p.title}

    +

    {p.text}

    +
  • + ))} +
+
); } diff --git a/site/components/landing/LandingMotion.tsx b/site/components/landing/LandingMotion.tsx index 31d2554..e4a4993 100644 --- a/site/components/landing/LandingMotion.tsx +++ b/site/components/landing/LandingMotion.tsx @@ -4,8 +4,6 @@ import { useGSAP } from '@gsap/react'; import { gsap } from 'gsap'; import { CustomEase } from 'gsap/CustomEase'; import { ScrollTrigger } from 'gsap/ScrollTrigger'; -import { TWEENS as DESKTOP } from './desktop/timelines'; -import { TWEENS as MOBILE } from './mobile/timelines'; import { sceneExtras, sceneTimeline, type Tween } from './timeline'; gsap.registerPlugin(useGSAP, ScrollTrigger, CustomEase); @@ -165,9 +163,11 @@ function animate(root: HTMLElement, tweens: Tween[]) { }; } +// Each composition's timelines load only once its widths match with motion allowed: the other one's, +// or both under reduced motion, never download. const COMPOSITIONS = { - desktop: { rootId: 'lp-d', media: '(min-width: 768px)', tweens: DESKTOP }, - phone: { rootId: 'lp-m', media: '(max-width: 767px)', tweens: MOBILE }, + desktop: { rootId: 'lp-d', media: '(min-width: 768px)', tweens: () => import('./desktop/timelines').then((m) => m.TWEENS) }, + phone: { rootId: 'lp-m', media: '(max-width: 767px)', tweens: () => import('./mobile/timelines').then((m) => m.TWEENS) }, }; /** Scrubs one composition, desktop or phone, while its widths match and motion is allowed. */ @@ -178,7 +178,10 @@ export function LandingMotion({ composition }: { composition: keyof typeof COMPO if (!root) return; root.querySelector('[data-mark-route]')?.classList.add('drawn'); const mm = gsap.matchMedia(); - mm.add(`${media} and (prefers-reduced-motion: no-preference)`, () => animate(root, tweens)); + mm.add(`${media} and (prefers-reduced-motion: no-preference)`, (ctx) => { + // Added to the context once loaded, so leaving the widths reverts it like any other; skipped if they already have. + void tweens().then((t) => ctx.isReverted || ctx.add(() => animate(root, t))); + }); return () => mm.revert(); }, [rootId, media, tweens]); return null; diff --git a/site/components/landing/desktop.css b/site/components/landing/desktop.css index fc1b990..e9a1da7 100644 --- a/site/components/landing/desktop.css +++ b/site/components/landing/desktop.css @@ -123,7 +123,8 @@ .ib:focus-visible, .pbtn:focus-visible, .lnk:focus-visible, .chip-b:focus-visible, .tab:focus-visible {outline:2px solid var(--sm-text);outline-offset:2px} .pbtn {display:inline-flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 18px;border:0;border-radius:9999px;background:var(--sm-primary);color:var(--sm-primary-text);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;white-space:nowrap;transition:transform 120ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms ease} .pbtn:active {transform:scale(0.96)} - .lnk {display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 14px;border-radius:12px;color:var(--sm-text-muted);text-decoration:none;font-size:14px;transition:background-color 150ms ease, color 150ms ease} + .lnk {display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 14px;border-radius:12px;color:var(--sm-text-muted);text-decoration:none;font-size:14px;transition:background-color 150ms ease, color 150ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1)} + .lnk:active {transform:scale(0.97)} .lnk:hover {background:var(--sm-page);color:var(--sm-text)} .kbd {display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 6px;border-radius:6px;box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-family:var(--font-mono);font-size:12px;color:var(--sm-text-muted);background:var(--sm-panel)} .badge {display:inline-flex;align-items:center;height:24px;padding:0 10px;border-radius:9999px;box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-size:13px;color:var(--sm-text)} @@ -360,7 +361,8 @@ .kdef {position:absolute;left:0;top:0;white-space:nowrap;font-size:22px;line-height:1.4;color:var(--sm-text-muted);opacity:0} .kdef.on {opacity:1} .kpills {position:absolute;right:96px;top:330px;display:flex;gap:4px;padding:5px;border-radius:9999px} - .kpill {height:36px;padding:0 14px;border:0;border-radius:9999px;background:transparent;color:var(--sm-text-muted);font-size:14px;cursor:pointer;transition:background-color 150ms ease, color 150ms ease} + .kpill {height:36px;padding:0 14px;border:0;border-radius:9999px;background:transparent;color:var(--sm-text-muted);font-size:14px;cursor:pointer;transition:background-color 150ms ease, color 150ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1)} + .kpill:active {transform:scale(0.97)} .kpill:hover {color:var(--sm-text)} .kpill[aria-pressed="true"] {background:var(--sm-primary);color:var(--sm-primary-text)} .kmore {position:absolute;right:96px;top:176px;font-size:14px} diff --git a/site/components/landing/mobile.css b/site/components/landing/mobile.css index ec85355..1245af9 100644 --- a/site/components/landing/mobile.css +++ b/site/components/landing/mobile.css @@ -123,7 +123,8 @@ .ib:focus-visible, .pbtn:focus-visible, .lnk:focus-visible, .chip-b:focus-visible, .tab:focus-visible {outline:2px solid var(--sm-text);outline-offset:2px} .pbtn {display:inline-flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 18px;border:0;border-radius:9999px;background:var(--sm-primary);color:var(--sm-primary-text);font-size:14px;font-weight:500;text-decoration:none;cursor:pointer;white-space:nowrap;transition:transform 120ms cubic-bezier(0.23, 1, 0.32, 1), opacity 150ms ease} .pbtn:active {transform:scale(0.96)} - .lnk {display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 14px;border-radius:12px;color:var(--sm-text-muted);text-decoration:none;font-size:14px;transition:background-color 150ms ease, color 150ms ease} + .lnk {display:inline-flex;align-items:center;gap:8px;height:40px;padding:0 14px;border-radius:12px;color:var(--sm-text-muted);text-decoration:none;font-size:14px;transition:background-color 150ms ease, color 150ms ease, transform 120ms cubic-bezier(0.23, 1, 0.32, 1)} + .lnk:active {transform:scale(0.97)} .lnk:hover {background:var(--sm-page);color:var(--sm-text)} .kbd {display:inline-flex;align-items:center;justify-content:center;min-width:22px;height:22px;padding:0 6px;border-radius:6px;box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-family:var(--font-mono);font-size:12px;color:var(--sm-text-muted);background:var(--sm-panel)} .badge {display:inline-flex;align-items:center;height:24px;padding:0 10px;border-radius:9999px;box-shadow:inset 0 0 0 1px var(--sm-panel-border);font-size:13px;color:var(--sm-text)} diff --git a/site/components/ui/CodeBlock.tsx b/site/components/ui/CodeBlock.tsx index c4a5909..8e3daa9 100644 --- a/site/components/ui/CodeBlock.tsx +++ b/site/components/ui/CodeBlock.tsx @@ -33,7 +33,7 @@ export function CodeBlock({ file, code, copy, maxLines, className = '' }: { file {file} {copy && } -
+      
         
           {lines.map((l, i) => (
             
diff --git a/site/e2e/smoke.spec.ts b/site/e2e/smoke.spec.ts
index 21cbc74..ad21dfe 100644
--- a/site/e2e/smoke.spec.ts
+++ b/site/e2e/smoke.spec.ts
@@ -18,8 +18,41 @@ for (const route of ROUTES) {
     await page.waitForLoadState('networkidle');
     expect(errors).toEqual([]);
   });
+
+  test(`${route} shares with a title, description and image`, async ({ page }) => {
+    await page.goto(route);
+    for (const p of ['og:title', 'og:description', 'og:url', 'og:image']) await expect(page.locator(`meta[property="${p}"]`), p).toHaveAttribute('content', /\S/);
+    const image = await page.locator('meta[property="og:image"]').getAttribute('content');
+    expect((await page.request.get(new URL(image!).pathname)).status()).toBe(200);
+  });
+
+  test(`${route} never scrolls sideways on a 320px phone`, async ({ browser }) => {
+    const page = await browser.newPage({ viewport: { width: 320, height: 700 }, reducedMotion: 'reduce' });
+    await page.goto(route);
+    expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBeLessThanOrEqual(320);
+    await page.close();
+  });
+
+  test(`${route} never skips a heading level`, async ({ page }) => {
+    await page.goto(route);
+    // Headings the reader can reach: the other composition's copy is display:none on the landing.
+    const levels = await page.locator('h1, h2, h3, h4, h5, h6').evaluateAll((hs) =>
+      hs.filter((h) => h.getClientRects().length || h.closest('.sr-only, .sr')).map((h) => Number(h.tagName[1])),
+    );
+    levels.forEach((l, i) => expect(l, `heading ${i} after h${levels[i - 1]}`).toBeLessThanOrEqual((levels[i - 1] ?? 0) + 1));
+  });
 }
 
+test('an unknown address gets the 404 page, kept out of the index', async ({ page }) => {
+  const res = await page.goto('/no/such/page');
+  expect(res?.status()).toBe(404);
+  await expect(page.getByRole('heading', { level: 1, name: 'Off the map.' })).toBeVisible();
+  await expect(page).toHaveTitle(/^Page not found ยท stackmap$/);
+  await expect(page.locator('meta[name="robots"]')).toHaveAttribute('content', /noindex/);
+  await page.getByRole('navigation', { name: 'Ways back' }).getByRole('link', { name: /Examples/ }).click();
+  await expect(page).toHaveURL(/\/examples$/);
+});
+
 test('with reduced motion every landing scene is its resting frame, nothing pinned', async ({ page }) => {
   await page.goto('/');
   await expect(page.locator('.lp-d')).not.toHaveClass(/live/);
diff --git a/site/lib/seo.ts b/site/lib/seo.ts
index d426173..12ef731 100644
--- a/site/lib/seo.ts
+++ b/site/lib/seo.ts
@@ -7,8 +7,14 @@ export const SITE_NAME = 'stackmap';
 export const DESCRIPTION = 'Interactive system diagrams your coding agent writes, as one offline HTML file.';
 export const REPO = 'https://github.com/hyamero/stackmap';
 
-// Next merges metadata shallowly: a page that sets openGraph replaces the layout's, so pages spread this.
-export const OPEN_GRAPH = { type: 'website', siteName: SITE_NAME, locale: 'en_US' } as const;
+// Next merges metadata shallowly: a page that sets openGraph replaces the layout's, and with it the root's
+// opengraph-image file, so pages spread this and it names the image (and its alt.txt) again.
+export const OPEN_GRAPH = {
+  type: 'website' as const,
+  siteName: SITE_NAME,
+  locale: 'en_US',
+  images: [{ url: '/opengraph-image.png', width: 1200, height: 630, alt: 'stackmap: every layer of your stack, on one map' }],
+};
 
 /** Every static page; the sitemap test fails when a page.tsx is added without its route here. */
 export const ROUTES = ['/', '/docs', '/docs/schema', '/examples'] as const;