Skip to content
Merged
15 changes: 9 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@
<img alt="stackmap: architecture diagrams your coding agent writes, as one offline HTML file" src="assets/brand/stackmap-readme-header.svg" width="640">
</picture>

You ask your agent to diagram a codebase or a system. It writes a small typed JSON, stackmap validates it with repair hints the agent acts on, lays it out, and hands you a single self-contained viewer — pan and zoom, search, trace upstream and downstream, guided views, dark and light themes, PNG/SVG export. No server, no account, nothing to install to open it.
You ask your agent to diagram a codebase, a system, a process or a request. It writes a small typed JSON, stackmap validates it with repair hints the agent acts on, lays it out, and hands you a single self-contained viewer — pan and zoom, search, trace, routes between two nodes, guided views, presentation mode, dark and light themes, image and video export. No server, no account, nothing to install to open it.

Five kinds of diagram: **architecture** (components and what they call), **dataflow** (data moving through stages), **workflow** (steps across owner lanes), **lifecycle** (the states of one thing) and **sequence** (messages over time).

<p align="center">
<img src="assets/readme/viewer-light.png" alt="stackmap viewer in light mode: a Commerce API topology with the Orders database selected and its upstream traced; the inspector shows its details, connections and source evidence" width="49%" />
Expand Down Expand Up @@ -57,15 +59,16 @@ npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that re
}
```

Nine node types set the colour (`client`, `gateway`, `service`, `database`, `cache`, `queue`, `storage`, `external`, `security`); cards can carry rows, stats, a footer and a link; `brand` adds one of 146 [Simple Icons](https://simpleicons.org) logos. There are no coordinates — [ELK](https://eclipse.dev/elk/) lays everything out. Full reference: [skill/references/schema.md](skill/references/schema.md); modelling guidance: [authoring contract](skill/references/authoring-contract.md).
Nine node types set the colour (`client`, `gateway`, `service`, `database`, `cache`, `queue`, `storage`, `external`, `security`); lifecycles use seven state types (`start`, `active`, `waiting`, `decision`, `success`, `failure`, `neutral`). Cards can carry rows, stats, a footer and a link, or be compact (title, subtitle, tag); `brand` adds one of 146 [Simple Icons](https://simpleicons.org) logos. Edges can be `async` or a `return`, and a `tone` marks the main path, security crossings and failure paths. There are no coordinates: [ELK](https://eclipse.dev/elk/) lays out architecture and dataflow diagrams, and stackmap's own layout places swimlanes (workflow, lifecycle) and sequences, deriving columns, message rows and activation bars from the structure. Full reference: [skill/references/schema.md](skill/references/schema.md); modelling guidance: [authoring contract](skill/references/authoring-contract.md).

## The viewer

- **Explore:** click a card for its details, connections and source evidence (linked to your repo when `source.url` is set). **Trace** keeps a node's upstream and downstream and dims the rest.
- **Explore:** click a card for its details, connections and source evidence (linked to your repo when `source.url` is set). **Trace** keeps a node's upstream and downstream and dims the rest. **Route** (`R`) picks two nodes and lights every directed path between them, listing the shortest.
- **Find:** `/` searches titles, subtitles, ids and types; the **lens** dims node types you don't care about.
- **Guided views:** tabs the agent defines (“Checkout path”, “Data tier”) that dim everything else and fit the camera.
- **Share:** state lives in the URL hash (`#view=…&node=…&lens=…`); export PNG (1×/2×), copy PNG, or an SVG snapshot.
- **Keyboard:** Tab reaches the toolbar, then the canvas; arrows move between cards, Enter selects, Esc clears; with the canvas focused, arrows pan and `+`/`-`/`0` zoom. Honours reduced motion.
- **Guided views:** tabs the agent defines (“Checkout path”, “Data tier”) that dim everything else and fit the camera. **Present** (`F`) goes full screen and steps through them with the arrow keys.
- **Radar:** the minimap (`M`) mirrors what's lit and dimmed, and drags the camera.
- **Share:** state lives in the URL hash (`#view=…&node=…&lens=…&route=…`); export PNG (1×/2×), copy PNG, JPEG, WebP, an SVG snapshot, or a short video of the diagram with its flow animated (WebM, or MP4 where that's what the browser records).
- **Keyboard:** Tab reaches the toolbar, then the canvas; arrows move between cards, Enter selects (or picks a route end), Esc clears a selection or ends a route; `/`, `R`, `F` and `M` open search, route, presentation and the radar; with the canvas focused, arrows pan and `+`/`-`/`0` zoom. Honours reduced motion.

## Development

Expand Down
21 changes: 21 additions & 0 deletions packages/viewer/e2e/export.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,3 +158,24 @@ test('toned edges keep their colour in the export (main path in ink)', async ({
);
expect(darkest).toBeLessThan(80);
});

test('JPEG and WebP export the whole diagram at 2×', async ({ page }) => {
const jpeg = await exportAs(page, /^JPEG/);
expect(jpeg.name).toBe('commerce-api.jpg');
expect([...jpeg.bytes.subarray(0, 3)]).toEqual([0xff, 0xd8, 0xff]);
const webp = await exportAs(page, /^WebP/);
expect(webp.name).toBe('commerce-api.webp');
expect(webp.bytes.subarray(8, 12).toString('ascii')).toBe('WEBP');
// VP8/VP8L/VP8X headers all carry the canvas size; the extended one (VP8X) is 24-bit little-endian minus one.
const chunk = webp.bytes.subarray(12, 16).toString('ascii');
if (chunk === 'VP8X') expect(webp.bytes.readUIntLE(24, 3) + 1).toBe(Math.ceil(w + 64) * 2);
});

test('Video records the flow animation as a WebM', async ({ page }) => {
test.slow();
const video = await exportAs(page, /^Video/);
expect(video.name).toBe('commerce-api.webm');
// EBML magic, and several seconds' worth of frames.
expect([...video.bytes.subarray(0, 4)]).toEqual([0x1a, 0x45, 0xdf, 0xa3]);
expect(video.bytes.length).toBeGreaterThan(20_000);
});
16 changes: 14 additions & 2 deletions packages/viewer/e2e/motion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,18 +32,30 @@ test('after the intro, emphasis is still CSS: tracing dims to 0.22', async ({ pa
});

test('popovers opened by pointer grow in; search opened with "/" appears at once', async ({ page }) => {
// Count animate() calls per element instead of polling for a running animation: a 160ms pop-in can finish
// before the first poll on a busy machine.
await page.addInitScript(() => {
const seen: Element[] = [];
(window as unknown as { __animated: Element[] }).__animated = seen;
const animate = Element.prototype.animate;
Element.prototype.animate = function (this: Element, ...args: Parameters<Element['animate']>) {
seen.push(this);
return animate.apply(this, args);
};
});
const animated = (loc: import('@playwright/test').Locator) => loc.evaluate((el) => (window as unknown as { __animated: Element[] }).__animated.includes(el));
await page.goto('/?page=sample');
await settled(page);
await page.getByRole('button', { name: 'Filter by type' }).click();
const lens = page.getByRole('group', { name: 'Show node types' });
await expect.poll(() => lens.evaluate((el) => el.getAnimations().length)).toBeGreaterThan(0);
await expect.poll(() => animated(lens)).toBe(true);
await settled(page);
await page.keyboard.press('Escape');
await page.locator('body').click({ position: { x: 5, y: 5 } });
await page.keyboard.press('/');
const search = page.getByRole('combobox', { name: 'Search nodes' });
await expect(search).toBeFocused();
expect(await search.evaluate((el) => el.parentElement!.getAnimations().length)).toBe(0);
expect(await animated(search.locator('..'))).toBe(false);
});

test('reduced motion: nothing animates', async ({ browser }) => {
Expand Down
91 changes: 91 additions & 0 deletions packages/viewer/e2e/present.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import { expect, test } from '@playwright/test';

test('R picks a route between two cards, lights it, lists it and deep-links it', async ({ page }) => {
await page.goto('/?page=release-delivery');
await page.keyboard.press('r');
await expect(page.getByRole('status').filter({ hasText: 'Pick where the route starts' })).toBeVisible();
await page.locator('.sm-card[data-card-id="commit"]').click();
await expect(page.getByRole('status').filter({ hasText: 'Now pick where it ends' })).toBeVisible();
await page.locator('.sm-card[data-card-id="announce"]').click();
await expect(page).toHaveURL(/#route=commit~announce$/);
const steps = page.getByRole('list', { name: 'Route steps' });
await expect(steps.getByRole('listitem')).toHaveCount(8);
await expect(steps).toContainText('merge');
await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'dim');
await expect(page.locator('.sm-card[data-card-id="commit"]')).toHaveAttribute('data-emphasis', 'focus');
await expect(page.locator('path[data-edge-id="e2"]')).toHaveAttribute('data-tint', 'client');
await page.keyboard.press('Escape');
await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'normal');
await expect(page).not.toHaveURL(/route=/);
});

test('a route runs the other way when only that direction exists, and says when none does', async ({ page }) => {
await page.goto('/?page=release-delivery#route=announce~commit');
await expect(page.getByRole('complementary', { name: 'Inspector' })).toContainText('running the other way');
await page.goto('/?page=release-delivery#route=failed~announce');
await expect(page.getByRole('complementary', { name: 'Inspector' })).toContainText('No directed connections lead from one to the other');
await expect(page.locator('.sm-card[data-card-id="deploy"]')).toHaveAttribute('data-emphasis', 'dim');
});

test('M toggles the radar, which mirrors what the explorer dims', async ({ page }) => {
await page.goto('/?page=release-delivery');
await page.keyboard.press('m');
const radar = page.getByRole('img', { name: 'Minimap' });
await expect(radar).toBeVisible();
await expect(radar.locator('[data-minimap-edge]')).toHaveCount(10);
await page.getByRole('tab', { name: 'Stop conditions' }).click();
await expect(radar.locator('rect.sm-minimap-card[data-dim]')).toHaveCount(5);
await page.keyboard.press('m');
await expect(radar).toBeHidden();
});

test('F presents: chrome hidden, one step per view, arrows step, Esc ends', async ({ page }) => {
await page.goto('/?page=release-delivery');
await page.keyboard.press('f');
const bar = page.getByRole('group', { name: 'Presentation' });
await expect(bar).toContainText('Overview');
await expect(bar).toContainText('1 / 3');
await expect(page.getByRole('heading', { level: 1 })).toBeHidden();
await expect(page.getByRole('complementary', { name: 'Inspector' })).toBeHidden();
await page.keyboard.press('ArrowRight');
await expect(bar).toContainText('Happy path');
await expect(bar).toContainText('2 / 3');
await expect(page.locator('.sm-card[data-card-id="failed"]')).toHaveAttribute('data-emphasis', 'dim');
await page.keyboard.press('End');
await expect(bar).toContainText('Stop conditions');
await page.keyboard.press('Escape');
await expect(bar).toBeHidden();
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
});

test('Esc cancels route picking even with focus on the Route button', async ({ page }) => {
await page.goto('/?page=release-delivery');
await page.getByRole('button', { name: 'Route between two nodes (R)' }).click();
await expect(page.getByText('Pick where the route starts')).toBeVisible();
await page.keyboard.press('Escape');
await expect(page.getByText('Pick where the route starts')).toBeHidden();
await expect(page.getByRole('button', { name: 'Route between two nodes (R)' })).toHaveAttribute('aria-pressed', 'false');
});

test('a route in a sequence lights only the messages on its way, in time', async ({ page }) => {
await page.goto('/?page=cache-miss#route=user~db');
for (const id of ['open', 'get', 'query']) await expect(page.locator(`path[data-edge-id="${id}"]`)).toHaveAttribute('data-tint', /.+/);
for (const id of ['render', 'json', 'emit', 'set']) await expect(page.locator(`path[data-edge-id="${id}"]`)).toHaveAttribute('data-dim', 'true');
await expect(page.locator('.sm-card[data-card-id="trace"]')).toHaveAttribute('data-emphasis', 'dim');
});

test('in a presentation, Space presses the focused button and M leaves the radar alone', async ({ page }) => {
await page.goto('/?page=release-delivery');
await page.keyboard.press('f');
const bar = page.getByRole('group', { name: 'Presentation' });
await expect(page.getByRole('button', { name: 'Previous step' })).toBeDisabled();
await page.keyboard.press('ArrowRight');
await page.getByRole('button', { name: 'Previous step' }).focus();
await page.keyboard.press(' ');
await expect(bar).toContainText('1 / 3');
await page.keyboard.press('m');
await page.getByRole('button', { name: 'End presentation (Esc)' }).focus();
await page.keyboard.press(' ');
await expect(bar).toBeHidden();
await expect(page.getByRole('img', { name: 'Minimap' })).toBeHidden();
});
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
65 changes: 55 additions & 10 deletions packages/viewer/src/canvas/DiagramCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,16 @@ import type { LaidOutDiagram, Rect } from '@stackmap/core';
import { ZoomBar } from '../chrome/ZoomBar';
import { useSceneReveal } from '../motion/useSceneReveal';
import { useExplore } from '../explore/ExploreContext';
import { routeBetween } from '../explore/graph';
import { inMenu } from '../chrome/Toolbar';
import { CanvasPanel } from './CanvasPanel';
import { Minimap } from './Minimap';
import { toScene, type Scene } from './scene';
import { SceneLayers } from './SceneLayers';
import { useZoom } from './useZoom';
import type { Camera } from './useZoom';
import type { Transform } from './viewport';
import { CameraProvider, ContentProvider, ViewportProvider } from './ViewportContext';
import { CameraProvider, ContentProvider, SceneProvider, ViewportProvider } from './ViewportContext';

const PAN_STEP = 80;

Expand All @@ -24,7 +26,7 @@ function union(rects: Rect[]): Rect | null {
// Camera follows the explorer: a view fits its members (Overview fits everything, Q27); a revealing
// selection (search, deep link) centres the node. The initial Overview needs nothing: useZoom already fit it.
function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) {
const { draft, state } = useExplore();
const { draft, state, graph } = useExplore();
const rectOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.rect])), [scene]);
// Compare against the previous view, not a "first run" flag: StrictMode re-runs effects on mount.
const shownView = useRef<string | null | undefined>(restored ? state.view : null);
Expand All @@ -36,6 +38,16 @@ function useCameraEffects(scene: Scene, camera: Camera, restored: boolean) {
if (box) camera.fitRect(box);
else camera.fit();
}, [state.view, draft, rectOf, camera]);
// A new route frames everything on it.
const shownRoute = useRef(restored ? state.route : null);
useEffect(() => {
if (shownRoute.current === state.route) return;
shownRoute.current = state.route;
if (!state.route) return;
const on = routeBetween(graph, state.route.from, state.route.to)?.nodes ?? new Set([state.route.from, state.route.to]);
const box = union([...on].flatMap((id) => rectOf.get(id) ?? []));
if (box) camera.fitRect(box);
}, [state.route, graph, rectOf, camera]);
// The reveal a live reload restores from the hash is already on screen.
const restoredReveal = useRef(restored ? state.reveal : -1);
const latest = useRef({ state, draft });
Expand Down Expand Up @@ -77,7 +89,8 @@ function DotGrid({ x, y, k }: Transform) {
);
}

export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram; children?: ReactNode }) {
/** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */
export function DiagramCanvas({ diagram, children, chrome = true }: { diagram: LaidOutDiagram; children?: ReactNode; chrome?: boolean }) {
const scene = useMemo(() => toScene(diagram), [diagram]);
const stageRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
Expand All @@ -88,10 +101,38 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
return union([scene.content, ...points.map((p) => ({ x: p.x, y: p.y, width: 0, height: 0 }))])!;
}, [scene]);
const { camera } = viewport;
const { emphasis, dispatch, state } = useExplore();
const explore = useExplore();
const { emphasis, dispatch, state } = explore;
const [minimap, setMinimap] = useState(false);
const { x, y, k } = viewport.transform;
useCameraEffects(scene, camera, viewport.restored);
// The stage changes size when the chrome comes or goes: frame the current view again once it has.
const framed = useRef(chrome);
useEffect(() => {
if (framed.current === chrome) return;
framed.current = chrome;
const id = requestAnimationFrame(() => {
const members = state.view ? (explore.draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : [];
const box = union(members.flatMap((m) => scene.cards.find((c) => c.node.id === m)?.rect ?? []));
if (box) camera.fitRect(box);
else camera.fit();
});
return () => cancelAnimationFrame(id);
}, [chrome, state.view, explore.draft, scene, camera]);
// M toggles the radar from anywhere but a text field or menu; not while presenting (it's hidden then).
const chromeRef = useRef(chrome);
chromeRef.current = chrome;
useEffect(() => {
const onKey = (e: globalThis.KeyboardEvent) => {
if ((e.key !== 'm' && e.key !== 'M') || !chromeRef.current) return;
const t = e.target as HTMLElement | null;
if (e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return;
e.preventDefault();
setMinimap((v) => !v);
};
addEventListener('keydown', onKey);
return () => removeEventListener('keydown', onKey);
}, []);
useSceneReveal(sceneRef, scene, viewport.restored);

// Keys when the stage itself has focus (cards handle their own and stop propagation).
Expand All @@ -115,9 +156,10 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
<ViewportProvider value={viewport}>
<CameraProvider value={camera}>
<ContentProvider value={exportBox}>
<SceneProvider value={scene}>
<div className="relative size-full">
{/* Top-left chrome first in the DOM: Tab reaches search/lens/trace before the cards. */}
{children}
{chrome && children}
<div
ref={stageRef}
role="region"
Expand All @@ -139,15 +181,18 @@ export function DiagramCanvas({ diagram, children }: { diagram: LaidOutDiagram;
</div>
</div>
{/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */}
<CanvasPanel position="bottom-left">
<ZoomBar minimapOn={minimap} onToggleMinimap={() => setMinimap((v) => !v)} />
</CanvasPanel>
{minimap && (
{chrome && (
<CanvasPanel position="bottom-left">
<ZoomBar minimapOn={minimap} onToggleMinimap={() => setMinimap((v) => !v)} />
</CanvasPanel>
)}
{chrome && minimap && (
<CanvasPanel position="bottom-right">
<Minimap scene={scene} />
<Minimap scene={scene} emphasis={emphasis} />
</CanvasPanel>
)}
</div>
</SceneProvider>
</ContentProvider>
</CameraProvider>
</ViewportProvider>
Expand Down
Loading
Loading