Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions packages/viewer/src/chrome/ViewerShell.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />);
expect(screen.getByRole('main')).toBeInTheDocument();
unmount();
render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} titleAs="h2" />);
expect(screen.queryByRole('main')).not.toBeInTheDocument();
});

it('lists Overview first, then agent views, as tabs', () => {
render(<ViewerShell diagram={commerceApiLayout} theme="light" onToggleTheme={() => {}} />);
const tabs = within(screen.getByRole('tablist')).getAllByRole('tab');
Expand Down
7 changes: 4 additions & 3 deletions packages/viewer/src/chrome/ViewerShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
// Presentation (F): the stage alone, full screen where the browser allows it, stepping through the views.
Expand Down Expand Up @@ -159,7 +160,7 @@ export function ViewerShell({
</header>
)}
{!presenting && <ViewCaption />}
<main className={`flex min-h-0 flex-1 gap-4 @max-md:gap-2 ${presenting ? '' : 'px-8 pt-4 pb-6 @max-md:px-3 @max-md:pt-3 @max-md:pb-3'}`}>
<Main className={`flex min-h-0 flex-1 gap-4 @max-md:gap-2 ${presenting ? '' : 'px-8 pt-4 pb-6 @max-md:px-3 @max-md:pt-3 @max-md:pb-3'}`}>
<section
id={DIAGRAM_ID}
role="tabpanel"
Expand All @@ -179,7 +180,7 @@ export function ViewerShell({
{presenting && <PresentBar onExit={() => present(false)} />}
</section>
{!presenting && <Inspector collapsed={inspectorCollapsed} onToggle={() => setInspectorCollapsed((v) => !v)} />}
</main>
</Main>
</div>
</ExploreProvider>
);
Expand Down
2 changes: 1 addition & 1 deletion site/app/docs/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ function Transcript() {
return (
<div data-theme="dark" className="mt-5 overflow-hidden rounded-[14px] bg-page text-fg-muted shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">
<div className="flex h-[42px] items-center px-[18px] font-mono text-[12.5px] shadow-[inset_0_-1px_0_var(--sm-panel-border)]">A repair round, as the agent sees it</div>
<pre className="m-0 overflow-x-auto px-5 pt-4 pb-[18px] font-mono text-code leading-[1.75]">
<pre tabIndex={0} className="m-0 overflow-x-auto px-5 pt-4 pb-[18px] font-mono text-code leading-[1.75] focus-visible:-outline-offset-2">
{prompt('validate')}
{broken.map(line)}
<span className="block"> </span>
Expand Down
2 changes: 1 addition & 1 deletion site/app/docs/schema/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ function Table({ section, placement }: { section: SchemaSection; placement: Plac
Set <Code>source.url</Code> on the diagram and every evidence entry links to <Code>&lt;url&gt;/&lt;file&gt;#L&lt;line&gt;</Code>.
</p>
)}
<div className="-mx-5 overflow-x-auto px-5 md:mx-0 md:px-0">
<div tabIndex={0} className="-mx-5 overflow-x-auto px-5 focus-visible:-outline-offset-2 md:mx-0 md:px-0">
<table className="mt-[18px] w-full min-w-[560px] border-collapse text-sm">
<thead>
<tr>
Expand Down
10 changes: 9 additions & 1 deletion site/app/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
64 changes: 64 additions & 0 deletions site/app/not-found.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div aria-hidden="true" className="relative h-[140px] overflow-hidden rounded-2xl bg-stage sm:h-[190px] lg:h-[232px] shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">
<div className="absolute top-1/2 left-1/2 h-[120px] w-[520px] -translate-1/2 scale-[0.62] sm:scale-[0.8] lg:scale-100">
<div className="absolute top-7 left-0">
<NodeCard node={{ id: 'you', type: 'client', card: { title: 'Your request', subtitle: 'GET this address' } }} />
</div>
<svg width="520" height="120" viewBox="0 0 520 120" className="absolute inset-0 overflow-visible">
<path className="motion-safe:animate-[dash-flow_1.2s_linear_infinite]" d="M 280 60 H 392" fill="none" stroke="var(--sm-edge)" strokeWidth="1.25" strokeDasharray="5 4" />
<path d="M383 56 L390 60 L383 64" fill="none" stroke="var(--sm-edge)" strokeWidth="1.25" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<div className="absolute top-7 left-[400px] grid h-16 w-[120px] place-items-center rounded-2xl border border-dashed border-[var(--sm-group-border)] font-mono text-[15px] text-fg-muted">
404
</div>
</div>
</div>
);
}

export default function NotFound() {
return (
<main data-theme="light" className="bg-page text-fg">
<div className="mx-auto grid max-w-[1440px] gap-12 px-5 pt-28 pb-28 md:px-10 lg:grid-cols-[minmax(0,1fr)_560px] lg:items-end lg:pt-[120px] xl:px-24">
<div>
<p className="font-mono text-sm text-fg-muted">404</p>
<h1 className="mt-3 text-mega">Off the map.</h1>
<p className="mt-6 max-w-[620px] text-lede text-fg-muted">Nothing lives at this address. The link may be old, or mistyped.</p>
</div>
<Dangling />
<nav aria-label="Ways back" className="grid gap-3 sm:grid-cols-3 lg:col-span-2">
{WAYS.map((w) => (
<Link
key={w.href}
href={w.href}
className="flex items-center justify-between rounded-2xl bg-panel px-6 py-[22px] text-fg no-underline shadow-[inset_0_0_0_1px_var(--sm-panel-border)] transition-[box-shadow,scale] duration-150 ease-out hover:shadow-[inset_0_0_0_1.5px_var(--sm-text)] active:scale-[0.98]"
>
<span>
<small className="block text-[13px] text-fg-muted">{w.eyebrow}</small>
<b className="mt-0.5 block text-[19px] font-semibold tracking-[-0.015em]">{w.label}</b>
</span>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 12h14M13 6l6 6-6 6" />
</svg>
</Link>
))}
</nav>
</div>
</main>
);
}
2 changes: 1 addition & 1 deletion site/components/docs/prose.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export const docs = {

export function Code({ children }: { children: ReactNode }) {
return (
<code className="rounded-md bg-panel px-1.5 py-px font-mono text-[0.88em] whitespace-nowrap shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">{children}</code>
<code className="rounded-md bg-panel px-1.5 py-px font-mono text-[0.88em] [overflow-wrap:anywhere] box-decoration-clone shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">{children}</code>
);
}

Expand Down
29 changes: 16 additions & 13 deletions site/components/kinds/Parts.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -189,17 +189,20 @@ const LABELS: Partial<Record<DiagramKind, ReactNode[]>> = {

export function Parts({ kind, page }: { kind: DiagramKind; page: KindPage }) {
return (
<ul className="m-0 mt-28 grid list-none grid-cols-1 gap-6 p-0 sm:grid-cols-2 lg:grid-cols-4">
{page.parts.map((p, i) => (
<li key={p.title}>
<div aria-hidden="true" className="relative h-[150px] overflow-hidden rounded-2xl bg-stage shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">
{VISUALS[kind][i]}
{LABELS[kind]?.[i]}
</div>
<h3 className="mt-4 text-[17px] font-semibold tracking-[-0.015em]">{p.title}</h3>
<p className="mt-1.5 text-sm leading-[1.55] text-fg-muted">{p.text}</p>
</li>
))}
</ul>
<section className="mt-28">
<h2 className="sr-only">{KIND_LABELS[kind]}, part by part</h2>
<ul className="m-0 grid list-none grid-cols-1 gap-6 p-0 sm:grid-cols-2 lg:grid-cols-4">
{page.parts.map((p, i) => (
<li key={p.title}>
<div aria-hidden="true" className="relative h-[150px] overflow-hidden rounded-2xl bg-stage shadow-[inset_0_0_0_1px_var(--sm-panel-border)]">
{VISUALS[kind][i]}
{LABELS[kind]?.[i]}
</div>
<h3 className="mt-4 text-[17px] font-semibold tracking-[-0.015em]">{p.title}</h3>
<p className="mt-1.5 text-sm leading-[1.55] text-fg-muted">{p.text}</p>
</li>
))}
</ul>
</section>
);
}
13 changes: 8 additions & 5 deletions site/components/landing/LandingMotion.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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. */
Expand All @@ -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;
Expand Down
6 changes: 4 additions & 2 deletions site/components/landing/desktop.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down Expand Up @@ -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}
Expand Down
3 changes: 2 additions & 1 deletion site/components/landing/mobile.css
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
2 changes: 1 addition & 1 deletion site/components/ui/CodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export function CodeBlock({ file, code, copy, maxLines, className = '' }: { file
<span className="font-mono">{file}</span>
{copy && <CopyButton text={code} label={`Copy ${file}`} className="size-8" />}
</div>
<pre className="m-0 overflow-x-auto px-5 pt-4 pb-[18px] font-mono text-code text-fg-muted">
<pre tabIndex={0} className="m-0 overflow-x-auto px-5 pt-4 pb-[18px] font-mono text-code text-fg-muted focus-visible:-outline-offset-2">
<code>
{lines.map((l, i) => (
<span key={i} className="block whitespace-pre">
Expand Down
33 changes: 33 additions & 0 deletions site/e2e/smoke.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
Expand Down
10 changes: 8 additions & 2 deletions site/lib/seo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading