From 75c2f23c1bf87d9b8737e28214e1b05707040a8c Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 21:34:16 +0800 Subject: [PATCH 1/7] refactor(viewer): add a static scene and an embeddable title level for the website --- .../viewer/src/canvas/StaticScene.test.tsx | 29 +++++++++++++++++ packages/viewer/src/canvas/StaticScene.tsx | 32 +++++++++++++++++++ .../viewer/src/chrome/ViewerShell.test.tsx | 6 ++++ packages/viewer/src/chrome/ViewerShell.tsx | 5 ++- .../viewer/src/explore/ExploreContext.tsx | 2 ++ 5 files changed, 73 insertions(+), 1 deletion(-) create mode 100644 packages/viewer/src/canvas/StaticScene.test.tsx create mode 100644 packages/viewer/src/canvas/StaticScene.tsx diff --git a/packages/viewer/src/canvas/StaticScene.test.tsx b/packages/viewer/src/canvas/StaticScene.test.tsx new file mode 100644 index 0000000..7d40d81 --- /dev/null +++ b/packages/viewer/src/canvas/StaticScene.test.tsx @@ -0,0 +1,29 @@ +import { renderToString } from 'react-dom/server'; +import { describe, expect, it } from 'vitest'; +import { commerceApiLayout } from '../samples/commerce-api.layout'; +import { toScene } from './scene'; +import { StaticScene } from './StaticScene'; + +describe('StaticScene', () => { + it('renders every card and connection without the explorer or a camera', () => { + const html = renderToString(); + const doc = new DOMParser().parseFromString(html, 'text/html'); + expect(doc.querySelectorAll('.sm-card')).toHaveLength(commerceApiLayout.draft.nodes.length); + expect(doc.querySelectorAll('[data-edge-id]').length).toBeGreaterThanOrEqual(commerceApiLayout.draft.edges.length); + }); + + it('sizes itself to the diagram content, offset to its origin', () => { + const { content } = toScene(commerceApiLayout); + const html = renderToString(); + const root = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild as HTMLElement; + expect(root.style.width).toBe(`${content.width}px`); + expect(root.style.height).toBe(`${content.height}px`); + expect((root.firstElementChild as HTMLElement).style.transform).toBe(`translate(${-content.x}px, ${-content.y}px)`); + }); + + it('keeps its cards out of the tab order', () => { + const html = renderToString(); + const root = new DOMParser().parseFromString(html, 'text/html').body.firstElementChild!; + expect(root.hasAttribute('inert')).toBe(true); + }); +}); diff --git a/packages/viewer/src/canvas/StaticScene.tsx b/packages/viewer/src/canvas/StaticScene.tsx new file mode 100644 index 0000000..0ba861d --- /dev/null +++ b/packages/viewer/src/canvas/StaticScene.tsx @@ -0,0 +1,32 @@ +import { useMemo } from 'react'; +import type { LaidOutDiagram } from '@stackmap/core'; +import type { Emphasis } from '../explore/emphasis'; +import { DispatchProvider } from '../explore/ExploreContext'; +import { toScene } from './scene'; +import { SceneLayers } from './SceneLayers'; +import type { Camera } from './useZoom'; +import { CameraProvider } from './ViewportContext'; + +const noop = () => {}; +const still: Camera = { zoomIn: noop, zoomOut: noop, fit: noop, centerOn: noop, fitRect: noop, panBy: noop, ensureVisible: noop }; +const calm: Emphasis = { active: false, nodes: new Map(), edges: new Map() }; + +/** + * The diagram as drawn at rest, at its natural size: no explorer, camera or motion, and nothing that + * reads the page (so it renders on a server). Inert, since its cards would otherwise be buttons that do nothing. + */ +export function StaticScene({ diagram }: { diagram: LaidOutDiagram }) { + const scene = useMemo(() => toScene(diagram), [diagram]); + const { content } = scene; + return ( + + +
+
+ +
+
+
+
+ ); +} diff --git a/packages/viewer/src/chrome/ViewerShell.test.tsx b/packages/viewer/src/chrome/ViewerShell.test.tsx index 20047d9..6e522cf 100644 --- a/packages/viewer/src/chrome/ViewerShell.test.tsx +++ b/packages/viewer/src/chrome/ViewerShell.test.tsx @@ -17,6 +17,12 @@ describe('ViewerShell', () => { expect(screen.getByText('6 nodes · 9 connections')).toBeInTheDocument(); }); + it('titles the diagram one level down when embedded in a page with its own h1', () => { + const { container } = render( {}} titleAs="h2" />); + expect(within(container.querySelector('header')!).getByRole('heading', { level: 2, name: 'Commerce API' })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { level: 1 })).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 6ff148a..7a01ab9 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -88,10 +88,13 @@ export function ViewerShell({ diagram, theme, onToggleTheme, + titleAs: Title = 'h1', }: { diagram: LaidOutDiagram; theme: ThemeChoice; onToggleTheme: () => void; + /** h2 when the shell sits in a page that has its own h1 (the website) */ + titleAs?: 'h1' | 'h2'; }) { const { draft } = diagram; const [inspectorCollapsed, setInspectorCollapsed] = useState(() => innerWidth < INSPECTOR_BREAKPOINT); @@ -141,7 +144,7 @@ export function ViewerShell({ {!presenting && (
-

{draft.title}

+ {draft.title} (null); // `dispatch` never changes; cards read it from here so explorer state changes don't re-render them all. const DispatchContext = createContext | null>(null); +/** For scenes drawn without an explorer (StaticScene): cards dispatch into this instead. */ +export const DispatchProvider = DispatchContext.Provider; export function useExploreDispatch(): Dispatch { const d = useContext(DispatchContext); From 851da7cf18eec588e226bb2af4bb77d7cb733f5d Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 21:37:52 +0800 Subject: [PATCH 2/7] docs(site): lay out the skill examples and generate the docs repair round and example catalog --- site/content/content.test.ts | 2 +- site/content/docs/quick-start.json | 26 +++++++++++ site/lib/catalog.ts | 70 ++++++++++++++++++++++++++++++ site/lib/data/data.test.ts | 34 +++++++++++++-- site/lib/data/diagrams.ts | 43 +++++++++++++++--- site/lib/data/paths.ts | 2 + site/lib/data/receipt.ts | 25 ++++++++++- site/lib/site-data.ts | 4 +- site/scripts/build-data.ts | 11 ++--- site/tests/catalog.test.ts | 42 ++++++++++++++++++ 10 files changed, 240 insertions(+), 19 deletions(-) create mode 100644 site/content/docs/quick-start.json create mode 100644 site/lib/catalog.ts create mode 100644 site/tests/catalog.test.ts diff --git a/site/content/content.test.ts b/site/content/content.test.ts index 94f600c..84fed91 100644 --- a/site/content/content.test.ts +++ b/site/content/content.test.ts @@ -13,7 +13,7 @@ describe('site content', () => { expect(files('examples')).toEqual(EXAMPLES); }); - for (const [sub, file] of [...EXAMPLES.map((f) => ['examples', f]), ['demo', 'commerce-api.json']] as const) { + for (const [sub, file] of [...EXAMPLES.map((f) => ['examples', f]), ['demo', 'commerce-api.json'], ['docs', 'quick-start.json']] as const) { it(`${sub}/${file} validates with no diagnostics`, () => { const result = validateDiagram(read(sub, file)); expect(result.diagnostics).toEqual([]); diff --git a/site/content/docs/quick-start.json b/site/content/docs/quick-start.json new file mode 100644 index 0000000..b03069f --- /dev/null +++ b/site/content/docs/quick-start.json @@ -0,0 +1,26 @@ +{ + "kind": "architecture", + "title": "Bookshop", + "direction": "DOWN", + "groups": [{ "id": "data", "label": "Data tier" }], + "nodes": [ + { + "id": "api", + "type": "service", + "card": { "title": "shop-api", "subtitle": "REST API" }, + "evidence": [{ "file": "services/api/src/server.ts", "line": 12 }] + }, + { + "id": "db", + "type": "database", + "group": "data", + "card": { + "title": "Orders DB", + "subtitle": "PostgreSQL", + "brand": "postgresql" + } + } + ], + "edges": [{ "id": "api-db", "from": "api", "to": "db" }], + "views": [{ "id": "state", "label": "State", "nodes": ["db"] }] +} diff --git a/site/lib/catalog.ts b/site/lib/catalog.ts new file mode 100644 index 0000000..515f076 --- /dev/null +++ b/site/lib/catalog.ts @@ -0,0 +1,70 @@ +import { DIAGRAM_KINDS, type DiagramKind } from '@stackmap/core'; + +/** Where a diagram comes from: the viewer's gallery samples, the agent-written examples, or the skill's own examples. */ +export type Source = 'gallery' | 'examples' | 'skill'; + +export interface Entry { + source: Source; + key: string; + kind: DiagramKind; + /** agent-written examples only: the request the agent was given in the skill's eval */ + prompt?: string; +} + +export const id = (e: Pick) => `${e.source}-${e.key}`; + +const gallery = (key: string, kind: DiagramKind): Entry => ({ source: 'gallery', key, kind }); +const agent = (key: string, kind: DiagramKind, prompt: string): Entry => ({ source: 'examples', key, kind, prompt }); + +/** The /examples page, in the canvas's order: the gallery by kind, with each agent-written example beside its kind. */ +export const EXAMPLES: Entry[] = [ + gallery('release-delivery', 'workflow'), + gallery('web-app', 'architecture'), + gallery('production-deployment', 'architecture'), + agent( + 'food-delivery', + 'architecture', + 'Draw our food-delivery platform. Customers use an iOS app and a React web app. Both go through an API gateway (Kong) to four services: accounts, restaurants, orders and a dispatch service that assigns couriers. Orders and accounts use a shared Postgres cluster (one primary, two read replicas); restaurants has its own MongoDB. Orders publishes events to Kafka; dispatch and a notifications worker consume them. Notifications sends push through Firebase and SMS through Twilio. Sessions and rate limits live in Redis. Payments go from orders to Stripe. Auth is Auth0. Add a view for the order path.', + ), + gallery('incident-response', 'workflow'), + gallery('agent-tool-call', 'workflow'), + gallery('agent-run', 'lifecycle'), + gallery('deployment-release', 'lifecycle'), + agent( + 'repo-architecture', + 'dataflow', + 'Make an architecture diagram of this repository: what the packages are, what each one does, and how data moves between them from an agent-written diagram JSON to the HTML file a user opens. Back it with evidence from the code.', + ), + gallery('event-stream', 'dataflow'), + gallery('product-analytics', 'dataflow'), + agent( + 'ml-feature-platform', + 'dataflow', + 'Show how data flows through our ML feature platform. Product databases (Postgres) are captured with Debezium CDC into Kafka. A Flink job computes streaming features and writes them to Redis (online store) and to a Delta Lake on S3 (offline store). Airflow runs nightly Spark jobs over the lake to build training sets, which a training pipeline on SageMaker consumes; models are registered in MLflow. The prediction service reads online features from Redis and loads models from MLflow. Also note that raw Kafka topics are archived to S3 for replay.', + ), + gallery('cache-miss', 'sequence'), + gallery('async-job', 'sequence'), + agent('production-vpc', 'architecture', 'Turn this Mermaid into a stackmap diagram.'), + agent( + 'bookshop', + 'architecture', + "Update the bookshop diagram: we added a search service (search-api) that the shop API queries over HTTP, backed by Elasticsearch. Put Stripe into a new 'Third parties' group, and add search to the checkout path view since the storefront uses it before buying.", + ), +]; + +/** The skill's example for each kind, by file name without `.json`. */ +export const SKILL_EXAMPLE: Record = { + architecture: 'web-app.architecture', + dataflow: 'clickstream.dataflow', + workflow: 'release.workflow', + lifecycle: 'job.lifecycle', + sequence: 'checkout.sequence', +}; + +/** A kind's page: its gallery samples, then the skill's example, then what agents wrote. */ +export function kindEntries(kind: DiagramKind): Entry[] { + const of = EXAMPLES.filter((e) => e.kind === kind); + return [...of.filter((e) => e.source === 'gallery'), { source: 'skill', key: SKILL_EXAMPLE[kind], kind }, ...of.filter((e) => e.source === 'examples')]; +} + +export const KINDS = DIAGRAM_KINDS; diff --git a/site/lib/data/data.test.ts b/site/lib/data/data.test.ts index e49163a..649add5 100644 --- a/site/lib/data/data.test.ts +++ b/site/lib/data/data.test.ts @@ -5,9 +5,9 @@ import { pathToFileURL } from 'node:url'; import { describe, expect, it } from 'vitest'; import { GALLERY } from '@stackmap/core/gallery'; import { deliverCommand } from '../../../packages/cli/src/commands'; -import { layoutSite } from './diagrams'; +import { layoutSite, readSources } from './diagrams'; import { CLI_PACKAGE, DEMO, VIEWER_TEMPLATE } from './paths'; -import { deliverReceipt, readTemplate } from './receipt'; +import { deliverReceipt, readTemplate, repairRound } from './receipt'; import { schemaReference } from './schema-ref'; import { installCommands, readCliVersion } from './version'; @@ -32,8 +32,10 @@ describe('layoutSite', () => { const d = await layoutSite(); expect(Object.keys(d.gallery).sort()).toEqual(Object.keys(GALLERY).sort()); expect(Object.keys(d.examples).sort()).toEqual(['bookshop', 'food-delivery', 'ml-feature-platform', 'production-vpc', 'repo-architecture']); + expect(Object.keys(d.skill).sort()).toEqual(['checkout.sequence', 'clickstream.dataflow', 'job.lifecycle', 'release.workflow', 'web-app.architecture']); + expect(d.quickStart.draft.nodes.map((n) => n.id)).toEqual(['api', 'db']); expect(Object.keys(d.demo.nodes)).toHaveLength(11); - for (const diagram of [d.demo, ...Object.values(d.gallery), ...Object.values(d.examples)]) { + for (const diagram of [d.demo, d.quickStart, ...Object.values(d.gallery), ...Object.values(d.examples), ...Object.values(d.skill)]) { expect(diagram.bounds.width).toBeGreaterThan(0); } }, 60_000); @@ -48,6 +50,32 @@ describe('layoutSite', () => { }); }); +describe('sources', () => { + it('prints each skill example and the quick start as the agent writes them, without the $schema pin', () => { + const src = readSources(); + expect(Object.keys(src.skill).sort()).toEqual(['checkout.sequence', 'clickstream.dataflow', 'job.lifecycle', 'release.workflow', 'web-app.architecture']); + for (const text of [...Object.values(src.skill), src.quickStart]) { + expect(text).not.toContain('$schema'); + expect(() => JSON.parse(text)).not.toThrow(); + } + expect(JSON.parse(src.skill['web-app.architecture']!)).toMatchObject({ kind: 'architecture', title: 'Bookshop' }); + }); +}); + +describe('repairRound', () => { + it('is what `stackmap validate` prints for the demo with a mistyped node, then for the demo', async () => { + const round = await repairRound(); + expect(round.broken).toEqual([ + 'error refs/unknown-node /edges/6/to', + ' Unknown node "orders-db"', + ' fix: use "orders"', + ' fix: add node "orders-db" or remove the edge', + '✗ 1 error', + ]); + expect(round.clean).toEqual(['✓ valid: no diagnostics']); + }); +}); + describe('deliverReceipt', () => { it('matches what `stackmap deliver` prints for the demo', async () => { const template = readTemplate(); diff --git a/site/lib/data/diagrams.ts b/site/lib/data/diagrams.ts index 0736656..2502439 100644 --- a/site/lib/data/diagrams.ts +++ b/site/lib/data/diagrams.ts @@ -5,14 +5,20 @@ import type { LaidOutDiagram } from '@stackmap/core'; import { GALLERY } from '@stackmap/core/gallery'; import { layoutDiagram } from '@stackmap/layout'; import { validateDiagram } from '@stackmap/schema'; -import { DEMO, EXAMPLES_DIR } from './paths'; +import { DEMO, EXAMPLES_DIR, QUICK_START, SKILL_EXAMPLES_DIR } from './paths'; export interface SiteDiagrams { demo: LaidOutDiagram; gallery: Record; examples: Record; + /** the skill's own example per kind, keyed by file name without `.json` */ + skill: Record; + quickStart: LaidOutDiagram; } +const jsonFiles = (dir: URL) => readdirSync(dir).filter((f) => f.endsWith('.json')).sort(); +const stem = (file: string) => file.replace(/\.json$/, ''); + // Content is held to the bar the skill holds agents to: any diagnostic, warnings included, stops the build. async function layoutFile(file: URL): Promise { const result = validateDiagram(JSON.parse(readFileSync(file, 'utf8'))); @@ -23,12 +29,37 @@ async function layoutFile(file: URL): Promise { return layoutDiagram(result.diagram); } +async function layoutDir(dir: URL): Promise> { + const out: Record = {}; + for (const file of jsonFiles(dir)) out[stem(file)] = await layoutFile(new URL(file, dir)); + return out; +} + export async function layoutSite({ examplesDir = EXAMPLES_DIR, demo = DEMO }: { examplesDir?: URL; demo?: URL } = {}): Promise { const gallery: Record = {}; for (const [name, draft] of Object.entries(GALLERY)) gallery[name] = await layoutDiagram(draft); - const examples: Record = {}; - for (const file of readdirSync(examplesDir).filter((f) => f.endsWith('.json')).sort()) { - examples[file.replace(/\.json$/, '')] = await layoutFile(new URL(file, examplesDir)); - } - return { demo: await layoutFile(demo), gallery, examples }; + return { + demo: await layoutFile(demo), + gallery, + examples: await layoutDir(examplesDir), + skill: await layoutDir(SKILL_EXAMPLES_DIR), + quickStart: await layoutFile(QUICK_START), + }; +} + +export interface SiteSources { + skill: Record; + quickStart: string; +} + +// The pages print these as the agent writes them. The skill's files pin a schema version, which the page +// adds back from the CLI version where it shows one, so the text here never goes stale. +function source(file: URL): string { + const { $schema: _, ...rest } = JSON.parse(readFileSync(file, 'utf8')) as Record; + return JSON.stringify(rest, null, 2); +} + +export function readSources(): SiteSources { + const skill = Object.fromEntries(jsonFiles(SKILL_EXAMPLES_DIR).map((f) => [stem(f), source(new URL(f, SKILL_EXAMPLES_DIR))])); + return { skill, quickStart: source(QUICK_START) }; } diff --git a/site/lib/data/paths.ts b/site/lib/data/paths.ts index 0a0e118..4726595 100644 --- a/site/lib/data/paths.ts +++ b/site/lib/data/paths.ts @@ -4,4 +4,6 @@ export const CLI_PACKAGE = new URL('packages/cli/package.json', repo); export const VIEWER_TEMPLATE = new URL('packages/viewer/dist/index.html', repo); export const EXAMPLES_DIR = new URL('site/content/examples/', repo); export const DEMO = new URL('site/content/demo/commerce-api.json', repo); +export const QUICK_START = new URL('site/content/docs/quick-start.json', repo); +export const SKILL_EXAMPLES_DIR = new URL('skill/examples/', repo); export const GENERATED_DIR = new URL('site/generated/', repo); diff --git a/site/lib/data/receipt.ts b/site/lib/data/receipt.ts index 0f71409..8fe7dca 100644 --- a/site/lib/data/receipt.ts +++ b/site/lib/data/receipt.ts @@ -1,7 +1,9 @@ import { createHash } from 'node:crypto'; -import { existsSync, readFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { buildHtml } from '../../../packages/cli/src/commands'; +import { buildHtml, validateCommand } from '../../../packages/cli/src/commands'; import { DEMO, VIEWER_TEMPLATE } from './paths'; export interface Receipt { @@ -27,3 +29,22 @@ export async function deliverReceipt(template: string, demo: URL = DEMO): Promis bytes: Buffer.byteLength(html), }; } + +export interface RepairRound { + broken: string[]; + clean: string[]; +} + +const lines = (stdout: string) => stdout.trimEnd().split('\n'); + +/** The docs' repair round: `stackmap validate` on the demo with one edge pointing at a mistyped node, then on the demo. */ +export async function repairRound(demo: URL = DEMO): Promise { + const draft = JSON.parse(readFileSync(demo, 'utf8')) as { edges: { id: string; to: string }[] }; + const edge = draft.edges.find((e) => e.id === 'e-api-orders'); + if (!edge) throw new Error('demo diagram lost its e-api-orders edge, which the docs repair round breaks'); + edge.to = 'orders-db'; + const file = join(mkdtempSync(join(tmpdir(), 'stackmap-site-')), 'diagram.json'); + writeFileSync(file, JSON.stringify(draft)); + const [broken, clean] = await Promise.all([validateCommand(file, { json: false }), validateCommand(fileURLToPath(demo), { json: false })]); + return { broken: lines(broken.stdout), clean: lines(clean.stdout) }; +} diff --git a/site/lib/site-data.ts b/site/lib/site-data.ts index 006252b..60e20b4 100644 --- a/site/lib/site-data.ts +++ b/site/lib/site-data.ts @@ -1,4 +1,4 @@ import site from '@/generated/site.json'; -import type { Receipt } from './data/receipt'; +import type { Receipt, RepairRound } from './data/receipt'; -export const SITE: { version: string; install: { skill: string; cli: string }; receipt: Receipt } = site; +export const SITE: { version: string; install: { skill: string; cli: string }; receipt: Receipt; repair: RepairRound } = site; diff --git a/site/scripts/build-data.ts b/site/scripts/build-data.ts index bc496a7..6875645 100644 --- a/site/scripts/build-data.ts +++ b/site/scripts/build-data.ts @@ -1,18 +1,19 @@ // Runs before `next build`: every diagram, number and command the site shows comes from here. import { mkdirSync, writeFileSync } from 'node:fs'; -import { layoutSite } from '../lib/data/diagrams'; +import { layoutSite, readSources } from '../lib/data/diagrams'; import { GENERATED_DIR } from '../lib/data/paths'; -import { deliverReceipt, readTemplate } from '../lib/data/receipt'; +import { deliverReceipt, readTemplate, repairRound } from '../lib/data/receipt'; import { schemaReference } from '../lib/data/schema-ref'; import { installCommands, readCliVersion } from '../lib/data/version'; const version = readCliVersion(); -const [diagrams, receipt] = await Promise.all([layoutSite(), deliverReceipt(readTemplate())]); +const [diagrams, receipt, repair] = await Promise.all([layoutSite(), deliverReceipt(readTemplate()), repairRound()]); mkdirSync(GENERATED_DIR, { recursive: true }); const write = (name: string, value: unknown) => writeFileSync(new URL(name, GENERATED_DIR), `${JSON.stringify(value)}\n`); -write('site.json', { version, install: installCommands(version), receipt }); +write('site.json', { version, install: installCommands(version), receipt, repair }); write('diagrams.json', diagrams); write('schema.json', schemaReference()); +write('sources.json', readSources()); -console.log(`site data: stackmap ${version} · ${Object.keys(diagrams.gallery).length + Object.keys(diagrams.examples).length} diagrams · receipt ${receipt.sha256.slice(0, 12)}…`); +console.log(`site data: stackmap ${version} · ${Object.keys(diagrams.gallery).length + Object.keys(diagrams.examples).length + Object.keys(diagrams.skill).length} diagrams · receipt ${receipt.sha256.slice(0, 12)}…`); diff --git a/site/tests/catalog.test.ts b/site/tests/catalog.test.ts new file mode 100644 index 0000000..13ed569 --- /dev/null +++ b/site/tests/catalog.test.ts @@ -0,0 +1,42 @@ +import { readdirSync, readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; +import { GALLERY } from '@stackmap/core/gallery'; +import { DIAGRAM_KINDS } from '@stackmap/core'; +import { EXAMPLES, id, kindEntries, SKILL_EXAMPLE } from '../lib/catalog'; + +const kindOf = (dir: string, file: string) => (JSON.parse(readFileSync(new URL(`${dir}${file}`, import.meta.url), 'utf8')) as { kind: string }).kind; +const jsonStems = (dir: string) => + readdirSync(new URL(dir, import.meta.url)) + .filter((f) => f.endsWith('.json')) + .map((f) => f.replace(/\.json$/, '')); + +describe('example catalog', () => { + it('holds the sixteen diagrams the page promises: every gallery sample and every agent-written example', () => { + expect(EXAMPLES).toHaveLength(16); + expect(EXAMPLES.filter((e) => e.source === 'gallery').map((e) => e.key).sort()).toEqual(Object.keys(GALLERY).sort()); + expect(EXAMPLES.filter((e) => e.source === 'examples').map((e) => e.key).sort()).toEqual(jsonStems('../content/examples/').sort()); + expect(new Set(EXAMPLES.map(id)).size).toBe(16); + }); + + it('files each diagram under the kind it declares', () => { + for (const e of EXAMPLES) { + const kind = e.source === 'gallery' ? GALLERY[e.key as keyof typeof GALLERY].kind : kindOf('../content/examples/', `${e.key}.json`); + expect(e.kind, e.key).toBe(kind); + } + }); + + it('carries the eval request for exactly the agent-written examples', () => { + for (const e of EXAMPLES) expect(!!e.prompt, e.key).toBe(e.source === 'examples'); + }); + + it('names a skill example of the right kind for every kind', () => { + expect(Object.keys(SKILL_EXAMPLE).sort()).toEqual([...DIAGRAM_KINDS].sort()); + for (const [kind, file] of Object.entries(SKILL_EXAMPLE)) expect(kindOf('../../skill/examples/', `${file}.json`)).toBe(kind); + }); + + it('puts the skill example on its kind page between the samples and the agent-written ones', () => { + const arch = kindEntries('architecture'); + expect(arch.map((e) => e.source)).toEqual(['gallery', 'gallery', 'skill', 'examples', 'examples', 'examples']); + for (const kind of DIAGRAM_KINDS) expect(kindEntries(kind).every((e) => e.kind === kind)).toBe(true); + }); +}); From 811c2bca9929cfda2cf1f9b21ea74a0911d81143 Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 21:43:50 +0800 Subject: [PATCH 3/7] chore: ignore only the root docs folder, not every docs directory --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 6f57d7f..4d6ec5c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -docs/ +/docs/ .agents/ .superpowers/ node_modules/ From fcb0f8875db28d8be26da914cfdf5575149ab2bf Mon Sep 17 00:00:00 2001 From: Joseph Dale Banares Date: Wed, 30 Sep 2026 21:46:05 +0800 Subject: [PATCH 4/7] docs(site): add the quick start and schema reference pages --- site/app/docs/page.tsx | 187 +++++++++++++++++++++++++ site/app/docs/schema/page.tsx | 125 +++++++++++++++++ site/app/globals.css | 3 + site/components/diagram/FitDiagram.tsx | 46 ++++++ site/components/diagram/Thumbnail.tsx | 35 +++++ site/components/docs/DocsShell.tsx | 110 +++++++++++++++ site/components/docs/Toc.tsx | 47 +++++++ site/components/docs/prose.tsx | 23 +++ site/components/site/Nav.tsx | 5 +- site/components/ui/CodeBlock.tsx | 60 ++++++++ site/components/ui/CopyButton.tsx | 37 +++++ site/components/ui/TypeChip.tsx | 20 +++ site/components/ui/icons.tsx | 23 +++ site/lib/data/diagrams.ts | 3 +- site/lib/data/json-format.test.ts | 16 +++ site/lib/data/json-format.ts | 33 +++++ site/lib/diagrams.ts | 17 +++ site/lib/schema-page.ts | 68 +++++++++ site/lib/seo.ts | 2 +- site/lib/words.ts | 7 + site/tests/schema-page.test.ts | 30 ++++ 21 files changed, 892 insertions(+), 5 deletions(-) create mode 100644 site/app/docs/page.tsx create mode 100644 site/app/docs/schema/page.tsx create mode 100644 site/components/diagram/FitDiagram.tsx create mode 100644 site/components/diagram/Thumbnail.tsx create mode 100644 site/components/docs/DocsShell.tsx create mode 100644 site/components/docs/Toc.tsx create mode 100644 site/components/docs/prose.tsx create mode 100644 site/components/ui/CodeBlock.tsx create mode 100644 site/components/ui/CopyButton.tsx create mode 100644 site/components/ui/TypeChip.tsx create mode 100644 site/components/ui/icons.tsx create mode 100644 site/lib/data/json-format.test.ts create mode 100644 site/lib/data/json-format.ts create mode 100644 site/lib/diagrams.ts create mode 100644 site/lib/schema-page.ts create mode 100644 site/lib/words.ts create mode 100644 site/tests/schema-page.test.ts diff --git a/site/app/docs/page.tsx b/site/app/docs/page.tsx new file mode 100644 index 0000000..6535dab --- /dev/null +++ b/site/app/docs/page.tsx @@ -0,0 +1,187 @@ +import type { Metadata } from 'next'; +import { BRAND_SLUGS, INFRA_TYPES, STATE_TYPES } from '@stackmap/core'; +import { FitDiagram } from '@/components/diagram/FitDiagram'; +import { DocsShell, NextLink } from '@/components/docs/DocsShell'; +import { Ask, Code, docs } from '@/components/docs/prose'; +import { CodeBlock, Command } from '@/components/ui/CodeBlock'; +import { TypeChip } from '@/components/ui/TypeChip'; +import { spell } from '@/lib/words'; +import { QUICK_START, SOURCES } from '@/lib/diagrams'; +import { OPEN_GRAPH } from '@/lib/seo'; +import { SITE } from '@/lib/site-data'; + +export const metadata: Metadata = { + title: 'Quick start', + description: 'Install the stackmap skill, ask your coding agent for a diagram, and open the one HTML file it delivers.', + alternates: { canonical: '/docs' }, + openGraph: { ...OPEN_GRAPH, url: '/docs' }, +}; + +const TOC = [ + { id: 'quick-start', label: 'Quick start' }, + { id: 'json', label: 'What the agent writes' }, + { id: 'cli', label: 'The CLI' }, +]; + +const CLI = [ + { usage: 'validate diagram.json', flags: '[--json]', does: 'Lists every problem with a code, its evidence and the allowed fixes, including card text that won’t fit.' }, + { usage: 'deliver diagram.json', flags: '[-o out.html]', does: 'Validates, lays out and writes one offline HTML file. The same JSON always gives the same file, byte for byte.' }, + { + usage: 'serve diagram.json', + flags: '[--port 4400]', + does: 'A live viewer that reloads on every save. It binds 127.0.0.1 only, keeps the last good version when a save is invalid, and keeps your camera and selection across reloads.', + }, +]; + +const EXIT_CODES = [ + { code: 0, meaning: 'OK. Warnings are allowed.' }, + { code: 1, meaning: 'The diagram has errors.' }, + { code: 2, meaning: 'A usage, file or internal error. Never a stack trace.' }, +]; + +// The agent's file pins the schema to the installed CLI, so the example does too. +const json = SOURCES.quickStart.replace('{\n', `{\n "$schema": "https://unpkg.com/@hyamero/stackmap@${SITE.version}/dist/stackmap.schema.json",\n`); + +function Step({ n, title, children }: { n: number; title: string; children: React.ReactNode }) { + return ( +
  • + {n} +

    {title}

    + {children} +
  • + ); +} + +function Transcript() { + const { broken, clean } = SITE.repair; + const { file, sha256, bytes } = SITE.receipt; + const prompt = (cmd: string) => ( + + stackmap · {cmd} .stackmap/commerce-api/diagram.json + + ); + const line = (l: string, i: number) => { + const tone = l.startsWith('error') || l.startsWith('✗') ? 'text-[#f0806e]' : l.startsWith('✓') ? 'text-[#a6d97a]' : ''; + return ( + + {l} + + ); + }; + return ( +
    +
    A repair round, as the agent sees it
    +
    +        {prompt('validate')}
    +        {broken.map(line)}
    +         
    +        {prompt('validate')}
    +        {clean.map(line)}
    +         
    +        {prompt('deliver')}
    +        
    +          delivered {file} · sha256 {sha256.slice(0, 12)}… · {bytes} bytes
    +        
    +      
    +
    + ); +} + +export default function DocsPage() { + return ( + +

    Docs › Get started

    +

    + Quick start. +

    +

    Install the skill, ask your coding agent for a diagram, and open the file it delivers.

    +
      + + +

      It installs into Claude Code, Cursor, Codex and the other agents that skills supports.

      +
      + + Make an architecture diagram of this repository, backed by evidence from the code. +

      + The agent writes .stackmap/<name>/diagram.json, validates it, repairs what the diagnostics name, and delivers{' '} + .stackmap/<name>/diagram.html. +

      +
      + +

      + Open diagram.html in any browser. There is no server, no account and nothing to install. To change the diagram, ask again: the agent edits + the JSON and delivers it again. The viewer itself is read-only. +

      +
      +
    + +

    + What the agent writes. +

    +

    + One small typed JSON file. The agent names the nodes, connections, groups and views; stackmap validates it and lays it out. There are no coordinates to write. +

    + +
    +
    + {[0, 1, 2].map((i) => ( +