From 506ec28a945c2aa73828b3b896a62d983a75c9da Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:11:16 +0800
Subject: [PATCH 1/6] fix(layout): keep edge labels clear of other labels,
cards and other edges' lines
---
packages/layout/src/index.ts | 26 ++------
packages/layout/src/labels.ts | 66 +++++++++++++++----
packages/layout/src/lanes.ts | 7 +-
packages/layout/test/labels.test.ts | 64 ++++++++++++++++++
.../viewer/src/samples/commerce-api.layout.ts | 3 +-
packages/viewer/src/samples/gallery.layout.ts | 38 +++++------
.../src/samples/grouped-platform.layout.ts | 10 +++
7 files changed, 154 insertions(+), 60 deletions(-)
create mode 100644 packages/layout/test/labels.test.ts
diff --git a/packages/layout/src/index.ts b/packages/layout/src/index.ts
index c7227a9..e6b291a 100644
--- a/packages/layout/src/index.ts
+++ b/packages/layout/src/index.ts
@@ -3,7 +3,7 @@ import ElkApi from 'elkjs/lib/elk-api.js';
import ElkBundled from 'elkjs/lib/elk.bundled.js';
import type { ElkExtendedEdge, ElkNode, ElkPort } from 'elkjs/lib/elk-api';
import { cardSize, isLaneKind, usesCompactCards, type DiagramDraft, type Direction, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
-import { findLabelSpot, labelWidth, placeLabel } from './labels';
+import { placeLabels } from './labels';
import { layoutLanes } from './lanes';
import { layoutSequence } from './sequence';
@@ -158,12 +158,12 @@ export async function layoutDiagram(draft: DiagramDraft): Promise placeLabels(draft.edges, l.edges, Object.values(l.nodes), !compact);
+ let spots = place(laid);
// Compact layouts start with tight layer gaps and widen them once if a label found no room to sit.
- let spots = compactLabels(draft, laid);
- if (spots.misfit > 0) {
+ if (compact && spots.misfit > 0) {
laid = await run(Math.min(COMPACT_GAP.max, Math.max(Number(root.layoutOptions!['elk.layered.spacing.nodeNodeBetweenLayers']), spots.misfit + 24)));
- spots = compactLabels(draft, laid);
+ spots = place(laid);
}
return { ...laid, labels: spots.labels };
} finally {
@@ -171,22 +171,6 @@ export async function layoutDiagram(draft: DiagramDraft): Promise; misfit: number } {
- const cards = Object.values(laid.nodes);
- const taken: Rect[] = [];
- const labels: Record = {};
- let misfit = 0;
- const pending = draft.edges.filter((e) => e.label && laid.edges[e.id]);
- for (const e of pending) {
- const spot = findLabelSpot(laid.edges[e.id]!, e.label!, cards, taken);
- if (spot) labels[e.id] = spot;
- else misfit = Math.max(misfit, labelWidth(e.label!));
- }
- for (const e of pending) if (!labels[e.id]) labels[e.id] = placeLabel(laid.edges[e.id]!, e.label!, cards, taken);
- return { labels, misfit };
-}
-
function collect(draft: DiagramDraft, result: ElkNode, direction: Direction, staged: boolean): LaidOutDiagram {
const nodes: Record = {};
const groups: Record = {};
diff --git a/packages/layout/src/labels.ts b/packages/layout/src/labels.ts
index 1d353df..71a3642 100644
--- a/packages/layout/src/labels.ts
+++ b/packages/layout/src/labels.ts
@@ -1,26 +1,65 @@
-import { measureText, type Point, type Rect } from '@stackmap/core';
+import { measureText, polylineMidpoint, type Point, type Rect } from '@stackmap/core';
-// Edge label placement shared by the lane and compact ELK layouts: the viewer's pill (11px Geist, 8px padding
-// each side, 20px tall) on a run of the route, clear of cards and of other labels.
+// Edge label placement shared by the lane and ELK layouts: the viewer's pill (11px Geist, 8px padding each side,
+// 20px tall) on a run of the route, clear of cards, other labels and other edges' lines.
const LABEL_H = 20;
export const labelWidth = (text: string) => Math.ceil(measureText(text, 'sans400', 11)) + 16;
const round = (n: number) => Math.round(n * 100) / 100;
const overlaps = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
+/** Whether any axis-aligned segment of a route runs through the rectangle. */
+const crossed = (r: Rect, lines: Point[][]) =>
+ lines.some((pts) =>
+ pts.slice(1).some((b, i) => {
+ const a = pts[i]!;
+ return Math.max(a.x, b.x) > r.x && Math.min(a.x, b.x) < r.x + r.width && Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.height;
+ }),
+ );
+const pillAt = (p: Point, w: number): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });
/**
- * The label goes on the longest run that holds its pill without touching a card or another label; along a run
- * it may slide off the midpoint to clear a neighbour's pill. Horizontal runs are preferred.
+ * Every label of a laid-out diagram, in draft order. A label goes on the longest run of its route that holds its
+ * pill clear of cards, other labels and other edges' lines; along a run it may slide off the midpoint to clear a
+ * neighbour. Horizontal runs are preferred. Where no spot clears the lines, one that clears cards and labels does,
+ * and failing that the middle of the longest run. With `keepMidpoints`, a label whose route midpoint is already
+ * clear stays there (where the viewer drew it before full layouts placed labels), so only the crowded ones move.
+ * `misfit` is the widest label that found no fully clear spot, 0 when all did.
*/
-export function placeLabel(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point {
- return findLabelSpot(points, text, cards, taken) ?? claimFallback(points, text, taken);
+export function placeLabels(
+ edges: { id: string; label?: string }[],
+ routes: Record,
+ cards: Rect[],
+ keepMidpoints = false,
+): { labels: Record; misfit: number } {
+ const labelled = edges.filter((e) => e.label && routes[e.id]);
+ const others = (id: string) => Object.entries(routes).flatMap(([k, pts]) => (k === id ? [] : [pts]));
+ const taken: Rect[] = [];
+ const labels: Record = {};
+ if (keepMidpoints) {
+ for (const e of labelled) {
+ const p = polylineMidpoint(routes[e.id]!);
+ const pill = pillAt(p, labelWidth(e.label!));
+ if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, others(e.id))) continue;
+ // Claimed now, so a later label whose midpoint lands here is the one that moves.
+ taken.push(pill);
+ labels[e.id] = { x: round(p.x), y: round(p.y) };
+ }
+ }
+ let misfit = 0;
+ for (const e of labelled) {
+ if (labels[e.id]) continue;
+ const spot = findLabelSpot(routes[e.id]!, e.label!, cards, taken, others(e.id));
+ if (spot) labels[e.id] = spot;
+ else misfit = Math.max(misfit, labelWidth(e.label!));
+ }
+ for (const e of labelled) if (!labels[e.id]) labels[e.id] = findLabelSpot(routes[e.id]!, e.label!, cards, taken) ?? claimFallback(routes[e.id]!, e.label!, taken);
+ return { labels, misfit };
}
/** A spot that fits, claimed in `taken`; null when none does. */
-export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[]): Point | null {
+export function findLabelSpot(points: Point[], text: string, cards: Rect[], taken: Rect[], lines: Point[][] = []): Point | null {
const w = labelWidth(text);
- const pillAt = (p: Point): Rect => ({ x: p.x - w / 2, y: p.y - LABEL_H / 2, width: w, height: LABEL_H });
let best: { p: Point; score: number } | null = null;
for (let i = 1; i < points.length; i++) {
const a = points[i - 1]!;
@@ -36,8 +75,8 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
search: for (const [dx, dy] of offsets) {
for (const t of [0.5, 0.35, 0.65, 0.2, 0.8]) {
const p = { x: round(a.x + (b.x - a.x) * t + dx), y: round(a.y + (b.y - a.y) * t + dy) };
- const pill = pillAt(p);
- if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r))) continue;
+ const pill = pillAt(p, w);
+ if (cards.some((r) => overlaps(pill, r)) || taken.some((r) => overlaps(pill, r)) || crossed(pill, lines)) continue;
// Longer and horizontal runs first; off-centre and off-line spots lose a little.
const score = (horizontal ? 2 : 1) * len - Math.abs(t - 0.5) * 40 - (dx || dy ? 30 : 0);
if (!best || score > best.score) best = { p, score };
@@ -46,7 +85,7 @@ export function findLabelSpot(points: Point[], text: string, cards: Rect[], take
}
}
if (!best) return null;
- taken.push(pillAt(best.p));
+ taken.push(pillAt(best.p, w));
return best.p;
}
@@ -59,8 +98,7 @@ function claimFallback(points: Point[], text: string, taken: Rect[]): Point {
const len = Math.abs(a.x - b.x) + Math.abs(a.y - b.y);
if (len > best.len) best = { p: { x: round((a.x + b.x) / 2), y: round((a.y + b.y) / 2) }, len };
}
- const w = labelWidth(text);
- taken.push({ x: best.p.x - w / 2, y: best.p.y - LABEL_H / 2, width: w, height: LABEL_H });
+ taken.push(pillAt(best.p, labelWidth(text)));
return best.p;
}
diff --git a/packages/layout/src/lanes.ts b/packages/layout/src/lanes.ts
index 6be6431..6b4544b 100644
--- a/packages/layout/src/lanes.ts
+++ b/packages/layout/src/lanes.ts
@@ -1,5 +1,5 @@
import { assignColumns, backEdges, cardSize, COMPACT, type DiagramDraft, type DiagramEdge, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
-import { labelWidth, placeLabel } from './labels';
+import { labelWidth, placeLabels } from './labels';
export { assignColumns, backEdges } from '@stackmap/core';
import { nudge, routeEdges, STUB, type PortChoice, type RouteRequest } from './route';
@@ -182,10 +182,7 @@ export function layoutLanes(draft: DiagramDraft): LaidOutDiagram {
if (routed[e.id]) edges[e.id] = routed[e.id]!.map((p) => ({ x: round(p.x), y: round(p.y) }));
}
- const cards = Object.values(rects);
- const taken: Rect[] = [];
- const labels: Record = {};
- for (const e of draft.edges) if (e.label && edges[e.id]) labels[e.id] = placeLabel(edges[e.id]!, e.label, cards, taken);
+ const { labels } = placeLabels(draft.edges, edges, Object.values(rects));
const height = y - LANE_GAP + PAD;
return {
diff --git a/packages/layout/test/labels.test.ts b/packages/layout/test/labels.test.ts
new file mode 100644
index 0000000..c54d4c3
--- /dev/null
+++ b/packages/layout/test/labels.test.ts
@@ -0,0 +1,64 @@
+import { readdirSync, readFileSync } from 'node:fs';
+import { describe, expect, it } from 'vitest';
+import { polylineMidpoint, type DiagramDraft, type LaidOutDiagram, type Point, type Rect } from '@stackmap/core';
+import { GALLERY } from '@stackmap/core/gallery';
+import { commerceApi, groupedPlatform } from '@stackmap/core/samples';
+import { labelWidth } from '../src/labels';
+import { layoutDiagram } from '../src/index';
+
+const overlap = (a: Rect, b: Rect) => a.x < b.x + b.width && b.x < a.x + a.width && a.y < b.y + b.height && b.y < a.y + a.height;
+/** Whether an axis-aligned segment runs through the rectangle's interior. */
+const crosses = (a: Point, b: Point, r: Rect) =>
+ Math.max(a.x, b.x) > r.x && Math.min(a.x, b.x) < r.x + r.width && Math.max(a.y, b.y) > r.y && Math.min(a.y, b.y) < r.y + r.height;
+
+const read = (dir: URL) =>
+ readdirSync(dir)
+ .filter((f) => f.endsWith('.json'))
+ .map((f) => [f, JSON.parse(readFileSync(new URL(f, dir), 'utf8')) as DiagramDraft] as const);
+
+// Everything stackmap ships a picture of: the samples, the archify gallery, the skill's examples and the site's gallery.
+const shipped: (readonly [string, DiagramDraft])[] = [
+ ['commerceApi', commerceApi] as const,
+ ['groupedPlatform', groupedPlatform] as const,
+ ...Object.entries(GALLERY).map(([name, d]) => [name, d] as const),
+ ...read(new URL('../../../skill/examples/', import.meta.url)),
+ ...read(new URL('../../../site/content/examples/', import.meta.url)),
+].filter(([, d]) => d.kind !== 'sequence');
+
+const laid = await Promise.all(shipped.map(async ([name, d]) => [name, d, await layoutDiagram(d)] as const));
+
+/** Each label's pill where the viewer draws it: the layout's spot, else the route's midpoint. */
+function pills(draft: DiagramDraft, out: LaidOutDiagram): { id: string; rect: Rect }[] {
+ return draft.edges
+ .filter((e) => e.label && out.edges[e.id])
+ .map((e) => {
+ const p = out.labels?.[e.id] ?? polylineMidpoint(out.edges[e.id]!);
+ const w = labelWidth(e.label!);
+ return { id: e.id, rect: { x: p.x - w / 2, y: p.y - 10, width: w, height: 20 } };
+ });
+}
+
+describe.each(laid)('edge labels in %s', (_name, draft, out) => {
+ const all = pills(draft, out);
+
+ it('places every label', () => {
+ for (const p of all) expect(out.labels?.[p.id], p.id).toBeDefined();
+ });
+
+ it('keeps labels off each other and off the cards', () => {
+ for (let i = 0; i < all.length; i++) {
+ for (let j = i + 1; j < all.length; j++) expect(overlap(all[i]!.rect, all[j]!.rect), `${all[i]!.id} × ${all[j]!.id}`).toBe(false);
+ for (const [n, r] of Object.entries(out.nodes)) expect(overlap(all[i]!.rect, r), `${all[i]!.id} on ${n}`).toBe(false);
+ }
+ });
+
+ it("keeps labels off other edges' lines", () => {
+ for (const p of all) {
+ for (const [id, pts] of Object.entries(out.edges)) {
+ if (id === p.id) continue;
+ const hit = pts.slice(1).some((b, i) => crosses(pts[i]!, b, p.rect));
+ expect(hit, `${p.id} over ${id}`).toBe(false);
+ }
+ }
+ });
+});
diff --git a/packages/viewer/src/samples/commerce-api.layout.ts b/packages/viewer/src/samples/commerce-api.layout.ts
index 500529d..2aac715 100644
--- a/packages/viewer/src/samples/commerce-api.layout.ts
+++ b/packages/viewer/src/samples/commerce-api.layout.ts
@@ -436,5 +436,6 @@ export const commerceApiLayout: LaidOutDiagram = {
"bounds": {
"width": 1160,
"height": 657
- }
+ },
+ "labels": {}
};
diff --git a/packages/viewer/src/samples/gallery.layout.ts b/packages/viewer/src/samples/gallery.layout.ts
index 04f9a44..c848448 100644
--- a/packages/viewer/src/samples/gallery.layout.ts
+++ b/packages/viewer/src/samples/gallery.layout.ts
@@ -1791,12 +1791,12 @@ export const galleryLayouts: Record = {
"y": 400
},
"e11": {
- "x": 1658,
+ "x": 1646.6,
"y": 674
},
"e13": {
"x": 540,
- "y": 674
+ "y": 661
}
},
"bounds": {
@@ -2237,7 +2237,7 @@ export const galleryLayouts: Record = {
"phases": {},
"labels": {
"needs-approval": {
- "x": 780,
+ "x": 813,
"y": 152
},
"approved": {
@@ -3328,14 +3328,14 @@ export const galleryLayouts: Record = {
},
"e7": {
"x": 1074,
- "y": 282
+ "y": 269
},
"e10": {
"x": 1625.5,
"y": 178
},
"e11": {
- "x": 1600.5,
+ "x": 1616.85,
"y": 282
},
"e12": {
@@ -3763,7 +3763,7 @@ export const galleryLayouts: Record = {
"y": 350
},
"e8": {
- "x": 1088.5,
+ "x": 1104.85,
"y": 258
}
}
@@ -4209,11 +4209,11 @@ export const galleryLayouts: Record = {
"labels": {
"e1": {
"x": 280,
- "y": 114
+ "y": 101
},
"e2": {
- "x": 240,
- "y": 164
+ "x": 292,
+ "y": 127
},
"e3": {
"x": 584,
@@ -4233,11 +4233,11 @@ export const galleryLayouts: Record = {
},
"e7": {
"x": 1192,
- "y": 214
+ "y": 227
},
"e8": {
"x": 1356,
- "y": 114
+ "y": 101
},
"e9": {
"x": 1496,
@@ -4245,7 +4245,7 @@ export const galleryLayouts: Record = {
},
"e10": {
"x": 1214.4,
- "y": 114
+ "y": 101
}
}
},
@@ -4789,11 +4789,11 @@ export const galleryLayouts: Record = {
},
"e5": {
"x": 903,
- "y": 214
+ "y": 201
},
"e6": {
- "x": 860,
- "y": 264
+ "x": 915,
+ "y": 227
},
"e7": {
"x": 1225,
@@ -4805,11 +4805,11 @@ export const galleryLayouts: Record = {
},
"e9": {
"x": 915,
- "y": 314
+ "y": 301
},
"e10": {
- "x": 862.8,
- "y": 314
+ "x": 903,
+ "y": 327
},
"e11": {
"x": 1225,
@@ -4817,7 +4817,7 @@ export const galleryLayouts: Record = {
},
"e12": {
"x": 1213,
- "y": 314
+ "y": 301
}
}
},
diff --git a/packages/viewer/src/samples/grouped-platform.layout.ts b/packages/viewer/src/samples/grouped-platform.layout.ts
index eb8ef96..bcda637 100644
--- a/packages/viewer/src/samples/grouped-platform.layout.ts
+++ b/packages/viewer/src/samples/grouped-platform.layout.ts
@@ -433,5 +433,15 @@ export const groupedPlatformLayout: LaidOutDiagram = {
"bounds": {
"width": 1328,
"height": 1081
+ },
+ "labels": {
+ "e1": {
+ "x": 344,
+ "y": 166.5
+ },
+ "e5": {
+ "x": 504,
+ "y": 695
+ }
}
};
From fc449ef849dc5fc35c730b2af2d86b86ab3aaac2 Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:16:36 +0800
Subject: [PATCH 2/6] =?UTF-8?q?feat(viewer):=20slow=20the=20flow=20to=20a?=
=?UTF-8?q?=20calmer=20pace=20and=20add=20a=200.5=C3=97,=201=C3=97,=202?=
=?UTF-8?q?=C3=97=20speed=20control?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
packages/viewer/e2e/flow.spec.ts | 23 ++++++++++++++-
packages/viewer/src/canvas/DiagramCanvas.tsx | 2 +-
packages/viewer/src/canvas/FlowLayer.test.tsx | 23 +++++++++++++--
packages/viewer/src/canvas/FlowLayer.tsx | 29 ++++++++++++-------
packages/viewer/src/canvas/SceneLayers.tsx | 5 +++-
packages/viewer/src/chrome/ExportMenu.tsx | 4 +--
packages/viewer/src/chrome/PlayButton.tsx | 13 +++++++++
packages/viewer/src/chrome/Presentation.tsx | 3 +-
packages/viewer/src/chrome/Toolbar.tsx | 3 +-
packages/viewer/src/chrome/export.test.ts | 10 ++++++-
packages/viewer/src/chrome/export.ts | 17 ++++++-----
packages/viewer/src/explore/state.test.ts | 13 ++++++++-
packages/viewer/src/explore/state.ts | 23 ++++++++++++++-
packages/viewer/src/motion/flow.test.ts | 5 ++++
packages/viewer/src/motion/flow.ts | 15 +++++-----
15 files changed, 151 insertions(+), 37 deletions(-)
diff --git a/packages/viewer/e2e/flow.spec.ts b/packages/viewer/e2e/flow.spec.ts
index d851691..b18e9db 100644
--- a/packages/viewer/e2e/flow.spec.ts
+++ b/packages/viewer/e2e/flow.spec.ts
@@ -24,6 +24,26 @@ test('P plays the whole flow as pulses along every connection, and stops it', as
await expect(page).not.toHaveURL(/play=/);
});
+test('while the flow plays, a speed button cycles 1×, 2× and 0.5×, kept in the link', async ({ page }) => {
+ await page.goto('/?page=release-delivery');
+ const speed = page.getByRole('button', { name: /^Flow speed/ });
+ await expect(speed).toHaveCount(0);
+ await page.keyboard.press('p');
+ await expect(speed).toHaveText('1×');
+ await speed.click();
+ await expect(speed).toHaveText('2×');
+ await expect(page).toHaveURL(/#play=1&speed=2$/);
+ await speed.click();
+ await expect(speed).toHaveText('0.5×');
+ await speed.click();
+ await expect(speed).toHaveText('1×');
+ await expect(page).toHaveURL(/#play=1$/);
+ await page.goto('/?page=release-delivery#play=1&speed=0.5');
+ await expect(speed).toHaveText('0.5×');
+ const before = await positions(page);
+ await expect.poll(() => positions(page)).not.toBe(before);
+});
+
test('a route plays only its own connections; a view plays only what it shows', async ({ page }) => {
await page.goto('/?page=release-delivery#route=commit~announce&play=1');
const lit = await page.locator('path[data-edge-id][data-tint]').evaluateAll((els) => els.map((el) => el.getAttribute('data-edge-id')).sort());
@@ -58,13 +78,14 @@ test('a sequence replays its messages one at a time, landing on activation bars'
expect(await page.locator('[data-flow] [data-part="glow"]').count()).toBeGreaterThan(0);
});
-test('presenting keeps the play button and the P key', async ({ page }) => {
+test('presenting keeps the play and speed buttons and the P key', async ({ page }) => {
await page.goto('/?page=release-delivery');
await expect(page.locator('.sm-card').first()).toBeVisible();
await page.keyboard.press('f');
const bar = page.getByRole('group', { name: 'Presentation' });
await bar.getByRole('button', { name: 'Play the flow (P)' }).click();
await expect(pulses(page).first()).toBeAttached();
+ await expect(bar.getByRole('button', { name: /^Flow speed/ })).toHaveText('1×');
await page.keyboard.press('p');
await expect(pulses(page)).toHaveCount(0);
});
diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx
index 084ca66..9e2d86e 100644
--- a/packages/viewer/src/canvas/DiagramCanvas.tsx
+++ b/packages/viewer/src/canvas/DiagramCanvas.tsx
@@ -201,7 +201,7 @@ export function DiagramCanvas({
className="sm-viewport absolute top-0 left-0 origin-top-left"
style={{ transform: `translate(${x}px, ${y}px) scale(${k})` }}
>
-
+
{/* Overlays are siblings of the stage, so wheel/drag on them never reaches d3-zoom. */}
diff --git a/packages/viewer/src/canvas/FlowLayer.test.tsx b/packages/viewer/src/canvas/FlowLayer.test.tsx
index efab184..3895645 100644
--- a/packages/viewer/src/canvas/FlowLayer.test.tsx
+++ b/packages/viewer/src/canvas/FlowLayer.test.tsx
@@ -1,7 +1,7 @@
import { render } from '@testing-library/react';
-import { describe, expect, it } from 'vitest';
+import { afterEach, describe, expect, it, vi } from 'vitest';
import type { Flow } from '../motion/flow';
-import { FlowLayer, type FlowClock } from './FlowLayer';
+import { FlowLayer, playingClock, type FlowClock } from './FlowLayer';
const flow: Flow = {
period: 2000,
@@ -41,3 +41,22 @@ describe('FlowLayer', () => {
expect(pulse.style.visibility).toBe('hidden');
});
});
+
+describe('playingClock', () => {
+ afterEach(() => vi.unstubAllGlobals());
+
+ it('runs at the current speed, and a change of speed carries on from where playback is', () => {
+ const frames: FrameRequestCallback[] = [];
+ vi.stubGlobal('requestAnimationFrame', (f: FrameRequestCallback) => frames.push(f));
+ vi.stubGlobal('cancelAnimationFrame', () => {});
+ const at = (now: number) => frames.shift()!(now);
+ let speed = 2;
+ const drawn: (number | null)[] = [];
+ playingClock(() => speed)((ms) => drawn.push(ms));
+ at(1000);
+ at(1100);
+ speed = 0.5;
+ at(1300);
+ expect(drawn).toEqual([0, 200, 300]);
+ });
+});
diff --git a/packages/viewer/src/canvas/FlowLayer.tsx b/packages/viewer/src/canvas/FlowLayer.tsx
index 24a409a..37a8fcc 100644
--- a/packages/viewer/src/canvas/FlowLayer.tsx
+++ b/packages/viewer/src/canvas/FlowLayer.tsx
@@ -9,17 +9,22 @@ const PACKETS = [0, 1, 2];
/** Drives the layer: calls `draw` with ms into playback, or null to hide every pulse; returns a stop. */
export type FlowClock = (draw: (ms: number | null) => void) => () => void;
-const playing: FlowClock = (draw) => {
- let began: number | null = null;
- let frame = 0;
- const tick = (now: number) => {
- began ??= now;
- draw(now - began);
+/** The viewer's own playback: real time at `speed()` (read every frame, so a change carries on without a jump). */
+export const playingClock =
+ (speed: () => number): FlowClock =>
+ (draw) => {
+ let last: number | null = null;
+ let ms = 0;
+ let frame = 0;
+ const tick = (now: number) => {
+ if (last !== null) ms += (now - last) * speed();
+ last = now;
+ draw(ms);
+ frame = requestAnimationFrame(tick);
+ };
frame = requestAnimationFrame(tick);
+ return () => cancelAnimationFrame(frame);
};
- frame = requestAnimationFrame(tick);
- return () => cancelAnimationFrame(frame);
-};
/**
* Flow playback on the canvas, drawing `pulseFrame` for every pulse: a landing glow behind the target, a
@@ -28,8 +33,10 @@ const playing: FlowClock = (draw) => {
* and a pulse slides out of one card into the next. One rAF loop writes attributes; React renders only when
* the flow changes; a `clock` (a scroll scrub) can drive it instead. Exports skip it (`data-flow`).
*/
-export function FlowLayer({ flow, width, height, clock }: { flow: Flow; width: number; height: number; clock?: FlowClock }) {
+export function FlowLayer({ flow, width, height, speed = 1, clock }: { flow: Flow; width: number; height: number; speed?: number; clock?: FlowClock }) {
const root = useRef(null);
+ const rate = useRef(speed);
+ rate.current = speed;
useEffect(() => {
const groups = [...(root.current?.children ?? [])] as SVGGElement[];
if (!groups.length) return;
@@ -90,7 +97,7 @@ export function FlowLayer({ flow, width, height, clock }: { flow: Flow; width: n
});
if (tracked) setInFlight(flying);
};
- const stop = (clock ?? playing)(draw);
+ const stop = (clock ?? playingClock(() => rate.current))(draw);
return () => {
stop();
if (tracked) setInFlight([]);
diff --git a/packages/viewer/src/canvas/SceneLayers.tsx b/packages/viewer/src/canvas/SceneLayers.tsx
index 6b375d2..6dba5d5 100644
--- a/packages/viewer/src/canvas/SceneLayers.tsx
+++ b/packages/viewer/src/canvas/SceneLayers.tsx
@@ -209,12 +209,15 @@ export const SceneLayers = memo(function SceneLayers({
emphasis,
selected,
flow = null,
+ speed = 1,
}: {
scene: Scene;
emphasis: Emphasis;
selected: string | null;
/** the flow playing, if any */
flow?: Flow | null;
+ /** its playback speed */
+ speed?: number;
}) {
const horizontal = scene.direction === 'RIGHT';
const typeOf = useMemo(() => new Map(scene.cards.map((c) => [c.node.id, c.node.type])), [scene]);
@@ -306,7 +309,7 @@ export const SceneLayers = memo(function SceneLayers({
);
})}
- {flow && }
+ {flow && }
{scene.cards.map((c) => (
css.getPropertyValue(`--sm-${tint}-accent`).trim() || css.getPropertyValue('--sm-edge').trim();
if (!flow.pulses.length) throw new Error('nothing to play: no connections are shown');
- download(await exportVideo(content, flow, colorOf, formats.video!), exportFileName(draft.title, videoExtension(formats.video!)));
+ download(await exportVideo(content, flow, colorOf, formats.video!, state.speed), exportFileName(draft.title, videoExtension(formats.video!)));
return 'Saved video';
}, 'Recording…'),
},
diff --git a/packages/viewer/src/chrome/PlayButton.tsx b/packages/viewer/src/chrome/PlayButton.tsx
index ed8ea19..999663a 100644
--- a/packages/viewer/src/chrome/PlayButton.tsx
+++ b/packages/viewer/src/chrome/PlayButton.tsx
@@ -1,5 +1,6 @@
import { Pause, Play } from 'lucide-react';
import { useExplore } from '../explore/ExploreContext';
+import { SPEEDS } from '../explore/state';
import { motionAllowed } from '../motion/motion';
import { IconButton } from './ui';
@@ -19,3 +20,15 @@ export function PlayButton() {
);
}
+
+/** While the flow plays: its speed, a click stepping 1× → 2× → 0.5×. */
+export function SpeedButton() {
+ const { state, dispatch } = useExplore();
+ if (!state.playing || !motionAllowed()) return null;
+ const next = SPEEDS[(SPEEDS.indexOf(state.speed) + 1) % SPEEDS.length];
+ return (
+ dispatch({ type: 'cycleSpeed' })}>
+ {state.speed}×
+
+ );
+}
diff --git a/packages/viewer/src/chrome/Presentation.tsx b/packages/viewer/src/chrome/Presentation.tsx
index 897d902..8810ac5 100644
--- a/packages/viewer/src/chrome/Presentation.tsx
+++ b/packages/viewer/src/chrome/Presentation.tsx
@@ -1,7 +1,7 @@
import { ChevronLeft, ChevronRight, X } from 'lucide-react';
import { useEffect } from 'react';
import { useExplore } from '../explore/ExploreContext';
-import { PlayButton } from './PlayButton';
+import { PlayButton, SpeedButton } from './PlayButton';
import { IconButton, PANEL_CLASS, PANEL_STYLE } from './ui';
/** Presentation steps: Overview, then each authored view. */
@@ -57,6 +57,7 @@ export function PresentBar({ onExit }: { onExit: () => void }) {
>
)}
+
diff --git a/packages/viewer/src/chrome/Toolbar.tsx b/packages/viewer/src/chrome/Toolbar.tsx
index 3c299c7..8b11feb 100644
--- a/packages/viewer/src/chrome/Toolbar.tsx
+++ b/packages/viewer/src/chrome/Toolbar.tsx
@@ -5,7 +5,7 @@ import { useInScope } from '../explore/scope';
import type { ThemeChoice } from '../theme/theme';
import { ExportMenu } from './ExportMenu';
import { LensPanel } from './LensPanel';
-import { PlayButton } from './PlayButton';
+import { PlayButton, SpeedButton } from './PlayButton';
import { SearchPanel } from './SearchPanel';
import { IconButton, PANEL_CLASS, PANEL_STYLE, ToolbarDivider } from './ui';
@@ -142,6 +142,7 @@ export function Toolbar({
+
{onPresent && (
diff --git a/packages/viewer/src/chrome/export.test.ts b/packages/viewer/src/chrome/export.test.ts
index bc60943..a3d30f7 100644
--- a/packages/viewer/src/chrome/export.test.ts
+++ b/packages/viewer/src/chrome/export.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { CANVAS_AREA_MAX, CANVAS_MAX, effectiveScale, exportFileName, videoExtension } from './export';
+import { CANVAS_AREA_MAX, CANVAS_MAX, effectiveScale, exportFileName, videoExtension, videoSpan } from './export';
describe('export', () => {
it('keeps the requested scale while the canvas fits', () => {
@@ -34,3 +34,11 @@ describe('video helpers', () => {
expect(videoExtension('video/mp4')).toBe('mp4');
});
});
+
+describe('videoSpan', () => {
+ it('records whole loops lasting at least VIDEO.duration of real time at the chosen speed', () => {
+ expect(videoSpan(4000, 1)).toBe(8000);
+ expect(videoSpan(4000, 2)).toBe(12000);
+ expect(videoSpan(4000, 0.5)).toBe(4000);
+ });
+});
diff --git a/packages/viewer/src/chrome/export.ts b/packages/viewer/src/chrome/export.ts
index 88f1638..d68ab0a 100644
--- a/packages/viewer/src/chrome/export.ts
+++ b/packages/viewer/src/chrome/export.ts
@@ -147,12 +147,15 @@ export const videoExtension = (type: string) => (type.startsWith('video/mp4') ?
export const VIDEO = { duration: 6000, fps: 30, maxWidth: 1920, maxHeight: 1080 } as const;
+/** How much of the flow (its own ms) a video records: whole loops, lasting at least VIDEO.duration at `speed`. */
+export const videoSpan = (period: number, speed: number) => Math.ceil((VIDEO.duration * speed) / period) * period;
+
/**
- * A short video of the diagram with a flow playing on it: the snapshot every export uses, and the live canvas's
- * pulses (`colorOf` resolving a tint to a colour), for whole loops of at least VIDEO.duration. Recorded from a
- * canvas with MediaRecorder.
+ * A short video of the diagram with a flow playing on it at `speed`: the snapshot every export uses, and the live
+ * canvas's pulses (`colorOf` resolving a tint to a colour), for whole loops of at least VIDEO.duration. Recorded
+ * from a canvas with MediaRecorder.
*/
-export async function exportVideo(content: SceneRect, flow: Flow, colorOf: (tint: NodeType) => string, type: string): Promise {
+export async function exportVideo(content: SceneRect, flow: Flow, colorOf: (tint: NodeType) => string, type: string, speed = 1): Promise {
const width = Math.ceil(content.width + 2 * EXPORT_PADDING);
const height = Math.ceil(content.height + 2 * EXPORT_PADDING);
// A frame the encoder can keep up with: the whole diagram inside 1920×1080.
@@ -168,7 +171,7 @@ export async function exportVideo(content: SceneRect, flow: Flow, colorOf: (tint
const at = (p: Point) => ({ x: (p.x - content.x + EXPORT_PADDING) * scale, y: (p.y - content.y + EXPORT_PADDING) * scale });
const stage = getComputedStyle(document.documentElement).getPropertyValue('--sm-stage').trim();
const pulses = flow.pulses.map((p) => ({ pulse: p, color: colorOf(p.tint), glowColor: p.glow ? colorOf(p.glow.tint) : '', path: p.path.map(at) }));
- const duration = Math.ceil(VIDEO.duration / flow.period) * flow.period;
+ const span = videoSpan(flow.period, speed);
const circle = (c: Point, r: number) => {
ctx.beginPath();
ctx.arc(c.x, c.y, r * scale, 0, Math.PI * 2);
@@ -271,9 +274,9 @@ export async function exportVideo(content: SceneRect, flow: Flow, colorOf: (tint
try {
await new Promise((resolve) => {
const tick = () => {
- const ms = performance.now() - began;
+ const ms = (performance.now() - began) * speed;
frame(ms);
- if (ms < duration && !failure) requestAnimationFrame(tick);
+ if (ms < span && !failure) requestAnimationFrame(tick);
else resolve();
};
requestAnimationFrame(tick);
diff --git a/packages/viewer/src/explore/state.test.ts b/packages/viewer/src/explore/state.test.ts
index 71831c8..a54829a 100644
--- a/packages/viewer/src/explore/state.test.ts
+++ b/packages/viewer/src/explore/state.test.ts
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
-import { explore, formatHash, INITIAL, parseHash } from './state';
+import { explore, formatHash, INITIAL, parseHash, type Speed } from './state';
const known = { nodes: new Set(['api', 'db']), views: new Set(['data']), types: new Set(['service', 'database'] as const) };
@@ -78,6 +78,17 @@ describe('route picking', () => {
expect(explore(s, { type: 'togglePlay' }).playing).toBe(false);
});
+ it('cycles the flow speed through 0.5×, 1× and 2×, and keeps it in the hash', () => {
+ expect(INITIAL.speed).toBe(1);
+ const cycle = (speed: Speed) => explore({ ...INITIAL, speed }, { type: 'cycleSpeed' }).speed;
+ expect([cycle(1), cycle(2), cycle(0.5)]).toEqual([2, 0.5, 1]);
+ expect(formatHash({ ...INITIAL, playing: true, speed: 0.5 })).toBe('#play=1&speed=0.5');
+ expect(formatHash({ ...INITIAL, speed: 1 })).toBe('');
+ expect(parseHash('#speed=2', known).speed).toBe(2);
+ expect(parseHash('#speed=3', known).speed).toBe(1);
+ expect(explore({ ...INITIAL, speed: 2 }, { type: 'clear' }).speed).toBe(2);
+ });
+
it('reads a route from the hash, dropping unknown ends', () => {
expect(parseHash('#route=api~db', known).route).toEqual({ from: 'api', to: 'db' });
expect(parseHash('#route=api~ghost', known).route).toBeNull();
diff --git a/packages/viewer/src/explore/state.ts b/packages/viewer/src/explore/state.ts
index 6745418..1c3a39e 100644
--- a/packages/viewer/src/explore/state.ts
+++ b/packages/viewer/src/explore/state.ts
@@ -16,9 +16,25 @@ export interface ExploreState {
routing: { next: 'from' } | { next: 'to'; start: string } | null;
/** flow playback (P): pulses run along whatever is shown */
playing: boolean;
+ /** how fast the flow plays, against its 1× timing */
+ speed: Speed;
}
-export const INITIAL: ExploreState = { selected: null, query: null, hiddenTypes: new Set(), trace: false, view: null, reveal: 0, route: null, routing: null, playing: false };
+export const SPEEDS = [0.5, 1, 2] as const;
+export type Speed = (typeof SPEEDS)[number];
+
+export const INITIAL: ExploreState = {
+ selected: null,
+ query: null,
+ hiddenTypes: new Set(),
+ trace: false,
+ view: null,
+ reveal: 0,
+ route: null,
+ routing: null,
+ playing: false,
+ speed: 1,
+};
export type ExploreAction =
| { type: 'select'; id: string | null; reveal?: boolean }
@@ -29,6 +45,7 @@ export type ExploreAction =
| { type: 'view'; id: string | null }
| { type: 'toggleRoute' }
| { type: 'togglePlay' }
+ | { type: 'cycleSpeed' }
| { type: 'replace'; state: ExploreState };
export function explore(s: ExploreState, a: ExploreAction): ExploreState {
@@ -62,6 +79,8 @@ export function explore(s: ExploreState, a: ExploreAction): ExploreState {
return { ...s, view: a.id };
case 'togglePlay':
return { ...s, playing: !s.playing };
+ case 'cycleSpeed':
+ return { ...s, speed: SPEEDS[(SPEEDS.indexOf(s.speed) + 1) % SPEEDS.length]! };
case 'replace':
return a.state;
}
@@ -92,6 +111,7 @@ export function parseHash(hash: string, known: Known): ExploreState {
hiddenTypes: new Set(lens),
route,
playing: params.get('play') === '1',
+ speed: SPEEDS.find((v) => String(v) === params.get('speed')) ?? 1,
};
}
@@ -102,5 +122,6 @@ export function formatHash(s: ExploreState): string {
if (s.hiddenTypes.size) parts.push(`lens=${[...s.hiddenTypes].sort().join(',')}`);
if (s.route) parts.push(`route=${encodeURIComponent(s.route.from)}~${encodeURIComponent(s.route.to)}`);
if (s.playing) parts.push('play=1');
+ if (s.speed !== 1) parts.push(`speed=${s.speed}`);
return parts.length ? `#${parts.join('&')}` : '';
}
diff --git a/packages/viewer/src/motion/flow.test.ts b/packages/viewer/src/motion/flow.test.ts
index 2aa955d..0e3fd1b 100644
--- a/packages/viewer/src/motion/flow.test.ts
+++ b/packages/viewer/src/motion/flow.test.ts
@@ -51,6 +51,11 @@ describe('flow timing', () => {
expect(travelFor(5000)).toBe(FLOW.travel.max);
});
+ it('paces a hop between 0.72 s and 2.1 s at 1×', () => {
+ expect(travelFor(0)).toBe(720);
+ expect(travelFor(5000)).toBe(2100);
+ });
+
it('sets a whole diagram off as one wave in reading order', () => {
const flow = waveFlow([
{ ...edge('b', 'n', 'm'), at: 200 },
diff --git a/packages/viewer/src/motion/flow.ts b/packages/viewer/src/motion/flow.ts
index 960cc37..6d98187 100644
--- a/packages/viewer/src/motion/flow.ts
+++ b/packages/viewer/src/motion/flow.ts
@@ -6,20 +6,21 @@ import type { Graph } from '../explore/graph';
* A pulse's travel time grows with its edge's length (clamped), so short hops don't crawl and long ones don't
* jump. A whole diagram staggers its pulses across `wave` ms in reading order; a focused flow chains its hops
* (a node fires on as its first pulse lands), squeezed to fit `span` (twice that for a sequence's replay).
+ * All of it is 1× playback: the viewer's speed control scales the clock, not these.
*/
export const FLOW = {
- wave: 1800,
- span: 6000,
- rest: 700,
- travel: { base: 380, perPx: 1.1, min: 480, max: 1400 },
+ wave: 2700,
+ span: 9000,
+ rest: 1050,
+ travel: { base: 570, perPx: 1.65, min: 720, max: 2100 },
/** trail length behind the head, diagram px */
tail: 72,
/** how long the trail takes to drain into the target once the head lands */
- drain: 220,
+ drain: 330,
/** the target's glow after a landing */
- glow: 560,
+ glow: 840,
/** the ring at the source port as a pulse sets off */
- flash: 360,
+ flash: 540,
} as const;
/** A pulse's look in diagram px, the same on the canvas and in the video. */
From f6ba7981563825b472b4182c9847767bea8071b6 Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:16:36 +0800
Subject: [PATCH 3/6] docs(site): document the flow speed control and the speed
link parameter
---
site/components/docs/ViewerTry.tsx | 2 +-
site/lib/reference.ts | 1 +
2 files changed, 2 insertions(+), 1 deletion(-)
diff --git a/site/components/docs/ViewerTry.tsx b/site/components/docs/ViewerTry.tsx
index e00a40f..2013010 100644
--- a/site/components/docs/ViewerTry.tsx
+++ b/site/components/docs/ViewerTry.tsx
@@ -66,7 +66,7 @@ const FEATURES: { id: Try; label: string; icon: ReactNode; title: string; desc:
label: 'Play',
icon: icon(Play),
title: 'Play the flow',
- desc: 'Pulses travel whatever you’re looking at: the whole diagram, a view, a trace or a route, hop by hop. With reduced motion there is nothing to play.',
+ desc: 'Pulses travel whatever you’re looking at: the whole diagram, a view, a trace or a route, hop by hop. The button beside Play sets the speed: 1×, 2× or 0.5×. With reduced motion there is nothing to play.',
keys: [['P', 'play or stop']],
},
];
diff --git a/site/lib/reference.ts b/site/lib/reference.ts
index 252ceda..c961485 100644
--- a/site/lib/reference.ts
+++ b/site/lib/reference.ts
@@ -45,4 +45,5 @@ export const SHARE_PARAMS = [
{ param: 'lens', keeps: 'The node types the lens hides, comma-separated', example: '#lens=cache,queue' },
{ param: 'route', keeps: 'A route between two nodes', example: '#route=storefront~orders' },
{ param: 'play', keeps: 'The flow is playing', example: '#play=1' },
+ { param: 'speed', keeps: 'The flow’s speed when it isn’t 1×: 0.5 or 2', example: '#play=1&speed=0.5' },
] as const;
From 635eafc5fba9dae1cd4a5b2a72b5f5d67a1ec76e Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:18:41 +0800
Subject: [PATCH 4/6] feat(cli): open the delivered viewer in the browser with
deliver --open, and have the skill use it
---
README.md | 8 ++++----
packages/cli/src/cli.ts | 9 +++++++--
packages/cli/src/commands.ts | 18 ++++++++++++++++--
packages/cli/src/open.ts | 28 ++++++++++++++++++++++++++++
packages/cli/test/commands.test.ts | 20 ++++++++++++++++++++
packages/cli/test/open.test.ts | 10 ++++++++++
skill/SKILL.md | 4 ++--
7 files changed, 87 insertions(+), 10 deletions(-)
create mode 100644 packages/cli/src/open.ts
create mode 100644 packages/cli/test/open.test.ts
diff --git a/README.md b/README.md
index cfdeb27..235e490 100644
--- a/README.md
+++ b/README.md
@@ -34,7 +34,7 @@ Then ask:
-The agent writes `.stackmap//diagram.json`, validates and repairs it, and delivers `.stackmap//diagram.html`. Open that file in any browser. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.
+The agent writes `.stackmap//diagram.json`, validates and repairs it, and delivers `.stackmap//diagram.html`, opening it in your browser. It opens in any browser later too. To change the diagram, ask again: the agent edits the JSON and delivers it again. The viewer itself is read-only.
## The viewer
@@ -90,9 +90,9 @@ Full reference: [skill/references/schema.md](skill/references/schema.md), or the
The skill runs the CLI for you, and it works on its own too (Node 22.12 or later):
```bash
-npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
-npx @hyamero/stackmap deliver diagram.json [-o out.html] # validate → layout → one offline HTML file
-npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
+npx @hyamero/stackmap validate diagram.json [--json] # diagnostics with fixes; exit 1 on errors
+npx @hyamero/stackmap deliver diagram.json [-o out.html] [--open] # validate → layout → one offline HTML file
+npx @hyamero/stackmap serve diagram.json [--port 4400] # live viewer that reloads on every save
```
- **validate** lists every problem with a code, its evidence and the allowed fixes, including card text that won't fit. It exits `1` when the diagram has errors and `2` on usage, IO or internal errors.
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index ce63068..155a2da 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -1,6 +1,7 @@
import { readFileSync } from 'node:fs';
import { parseArgs } from 'node:util';
import { deliverCommand, validateCommand, type CommandResult } from './commands';
+import { openInBrowser } from './open';
import { serve } from './serve';
import { styleFor } from './style';
@@ -12,12 +13,14 @@ const HELP = `stackmap ${VERSION}: validate agent-authored diagrams and deliver
Usage:
stackmap validate [--json] check the diagram; exit 1 on errors
- stackmap deliver [-o out.html] validate, lay out and write the viewer
+ stackmap deliver [-o out.html] [--open]
+ validate, lay out and write the viewer
stackmap serve [--port 4400] live viewer that reloads when the file changes
Options:
--json machine-readable diagnostics (validate)
-o, --out output path (deliver; default: next to the input, .html)
+ --open open the written viewer in the default browser (deliver)
--port port for serve (default 4400; the next free one is used if taken)
-h, --help show this help
-v, --version show the version
@@ -41,6 +44,7 @@ async function run(argv: string[]): Promise {
options: {
json: { type: 'boolean' },
out: { type: 'string', short: 'o' },
+ open: { type: 'boolean' },
port: { type: 'string' },
help: { type: 'boolean', short: 'h' },
version: { type: 'boolean', short: 'v' },
@@ -58,6 +62,7 @@ async function run(argv: string[]): Promise {
if (command !== 'validate' && command !== 'deliver' && command !== 'serve') return usage(`unknown command "${command}"`);
if (!file || extra.length) return usage(`${command} takes exactly one diagram file`);
if (values.port !== undefined && command !== 'serve') return usage('--port applies to serve');
+ if (values.open && command !== 'deliver') return usage('--open applies to deliver');
if (command === 'validate') {
if (values.out !== undefined) return usage('-o/--out applies to deliver, not validate');
return values.json ? validateCommand(file, { json: true }) : signed(command, await validateCommand(file, { json: false }));
@@ -70,7 +75,7 @@ async function run(argv: string[]): Promise {
} catch (e) {
return { code: 2, stdout: '', stderr: `stackmap: internal error: viewer template missing (${(e as Error).message})\n` };
}
- if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style }));
+ if (command === 'deliver') return signed(command, await deliverCommand(file, { template, out: values.out, style, open: values.open ? openInBrowser : undefined }));
const port = values.port === undefined ? 4400 : Number(values.port);
if (!Number.isInteger(port) || port < 0 || port > 65535) return usage(`--port must be 0-65535, got "${values.port}"`);
diff --git a/packages/cli/src/commands.ts b/packages/cli/src/commands.ts
index 60b5271..43238f4 100644
--- a/packages/cli/src/commands.ts
+++ b/packages/cli/src/commands.ts
@@ -71,7 +71,18 @@ export async function buildHtml(path: string, template: string): Promise Promise;
+ },
): Promise {
try {
const target = out ?? path.slice(0, path.length - extname(path).length) + '.html';
@@ -82,7 +93,10 @@ export async function deliverCommand(
writeAtomic(target, html);
const bytes = Buffer.byteLength(html);
const sha = createHash('sha256').update(html).digest('hex');
- return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) };
+ // The file is written either way, so a browser that won't open is a warning, not a failure.
+ const unopened = open ? await open(target) : null;
+ const warning = unopened ? `warning: couldn't open ${target} in a browser (${unopened})\n` : '';
+ return { code: 0, stdout: `delivered ${style.path(target)} · sha256 ${sha} · ${bytes} bytes\n`, stderr: report(diagnostics) + warning };
} catch (e) {
return failure(e);
}
diff --git a/packages/cli/src/open.ts b/packages/cli/src/open.ts
new file mode 100644
index 0000000..7d8fdf5
--- /dev/null
+++ b/packages/cli/src/open.ts
@@ -0,0 +1,28 @@
+import { spawn } from 'node:child_process';
+
+/** The command that opens a file in the default browser on `platform`. */
+export function browserCommand(file: string, platform: NodeJS.Platform = process.platform): { cmd: string; args: string[] } {
+ if (platform === 'darwin') return { cmd: 'open', args: [file] };
+ // `start` is a cmd builtin, and its first quoted argument is the window title.
+ if (platform === 'win32') return { cmd: 'cmd', args: ['/c', 'start', '""', file] };
+ return { cmd: 'xdg-open', args: [file] };
+}
+
+/**
+ * Opens `file` in the default browser; resolves null once the opener has handed it off, or why it couldn't (no
+ * opener installed, a headless machine). An opener that is still running after a moment is taken as success.
+ */
+export function openInBrowser(file: string): Promise {
+ const { cmd, args } = browserCommand(file);
+ return new Promise((resolve) => {
+ const child = spawn(cmd, args, { stdio: 'ignore', detached: true, windowsVerbatimArguments: cmd === 'cmd' });
+ const timer = setTimeout(() => resolve(null), 3000);
+ const done = (why: string | null) => {
+ clearTimeout(timer);
+ resolve(why);
+ };
+ child.once('error', (e) => done((e as NodeJS.ErrnoException).code === 'ENOENT' ? `${cmd} not found` : e.message));
+ child.once('exit', (code) => done(code ? `${cmd} exited with ${code}` : null));
+ child.unref();
+ });
+}
diff --git a/packages/cli/test/commands.test.ts b/packages/cli/test/commands.test.ts
index f170b7a..f4c8df0 100644
--- a/packages/cli/test/commands.test.ts
+++ b/packages/cli/test/commands.test.ts
@@ -64,6 +64,26 @@ describe('validate', () => {
});
describe('deliver', () => {
+ it('opens the written file when asked, and still delivers with a warning when it can\'t', async () => {
+ const input = file('platform.json', groupedPlatform);
+ const opened: string[] = [];
+ const ok = await deliverCommand(input, { template: TEMPLATE, open: async (f) => (opened.push(f), null) });
+ expect(ok.code).toBe(0);
+ expect(opened).toEqual([join(dir, 'platform.html')]);
+ expect(ok.stderr).toBe('');
+ const failed = await deliverCommand(input, { template: TEMPLATE, open: async () => 'xdg-open not found' });
+ expect(failed.code).toBe(0);
+ expect(failed.stdout).toMatch(/^delivered /);
+ expect(failed.stderr).toBe(`warning: couldn't open ${join(dir, 'platform.html')} in a browser (xdg-open not found)\n`);
+ });
+
+ it('opens nothing when validation fails', async () => {
+ let opened = false;
+ const r = await deliverCommand(file('broken.json', broken), { template: TEMPLATE, open: async () => ((opened = true), null) });
+ expect(r.code).toBe(1);
+ expect(opened).toBe(false);
+ });
+
it('writes the HTML next to the input and prints a sha256 receipt', async () => {
const input = file('platform.json', groupedPlatform);
const r = await deliverCommand(input, { template: TEMPLATE });
diff --git a/packages/cli/test/open.test.ts b/packages/cli/test/open.test.ts
new file mode 100644
index 0000000..a1ccec0
--- /dev/null
+++ b/packages/cli/test/open.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { browserCommand } from '../src/open';
+
+describe('browserCommand', () => {
+ it("uses each platform's own opener", () => {
+ expect(browserCommand('/tmp/a b.html', 'darwin')).toEqual({ cmd: 'open', args: ['/tmp/a b.html'] });
+ expect(browserCommand('C:\\d\\a.html', 'win32')).toEqual({ cmd: 'cmd', args: ['/c', 'start', '""', 'C:\\d\\a.html'] });
+ expect(browserCommand('/tmp/a.html', 'linux')).toEqual({ cmd: 'xdg-open', args: ['/tmp/a.html'] });
+ });
+});
diff --git a/skill/SKILL.md b/skill/SKILL.md
index 708024f..ae15598 100644
--- a/skill/SKILL.md
+++ b/skill/SKILL.md
@@ -18,7 +18,7 @@ Run it as one literal command (each shell call starts fresh, so don't rely on va
```bash
npx -y @hyamero/stackmap@0.1.0 validate --json
-npx -y @hyamero/stackmap@0.1.0 deliver
+npx -y @hyamero/stackmap@0.1.0 deliver --open
npx -y @hyamero/stackmap@0.1.0 serve # optional live preview; see below
```
@@ -45,7 +45,7 @@ Unless the user names a location, each diagram lives in `.stackmap//` unde
5. **Repair only what the diagnostics name.** Each has a `code`, a `subject` (JSON pointer into your file), a `message`, `evidence` and `allowedFixes`. Apply one of the `allowedFixes` at `subject`; don't restructure unrelated parts. `card-fit/overflow` means the text would be cut off on the card: shorten it to the `maxChars` in its evidence, keeping the meaning (move detail into a row, or into an evidence note on compact cards, which have no rows). Re-run step 4.
- **Stop rule:** if two consecutive rounds don't reduce the number of errors, stop, keep the last file, and report the remaining diagnostics verbatim instead of guessing.
- Warnings don't block delivery. Fix the cheap ones (a misspelt brand, a node you forgot to connect); mention the ones you leave.
-6. **Deliver** with `deliver `. Exit 1: back to step 5. Exit 2: an environment problem — report it; don't edit the diagram to "fix" it.
+6. **Deliver** with `deliver --open`: it writes `diagram.html` and opens it in the user's browser. A warning that no browser could be opened is not a failure (a headless or remote machine): the file is written, so give its path. Exit 1: back to step 5. Exit 2: an environment problem — report it; don't edit the diagram to "fix" it.
7. **Check evidence** (repository diagrams): every `evidence[].file` must exist, e.g. `git ls-files --error-unmatch ` from the repo root.
8. **Report** the absolute path of `diagram.html`, the receipt line, and any warnings you left. Offer `serve` for iterating. Don't claim you looked at the rendering unless you did.
From e18a2cc3d8598ec5fa20f024855f982660c80cc0 Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:18:41 +0800
Subject: [PATCH 5/6] docs(site): document deliver --open
---
site/app/docs/cli/page.tsx | 3 ++-
site/lib/reference.ts | 3 ++-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/site/app/docs/cli/page.tsx b/site/app/docs/cli/page.tsx
index 2045e36..1ff62da 100644
--- a/site/app/docs/cli/page.tsx
+++ b/site/app/docs/cli/page.tsx
@@ -73,7 +73,8 @@ export default function CliPage() {
Validates, lays out and writes one offline HTML file, next to the input unless you pass -o. It refuses to overwrite its input. Delivery is deterministic:
- the same JSON always gives the same file, byte for byte, so the receipt’s hash is worth keeping.
+ the same JSON always gives the same file, byte for byte, so the receipt’s hash is worth keeping. With --open it then opens the file in your default
+ browser; the skill passes it, so a diagram opens as soon as the agent delivers it.
diff --git a/site/lib/reference.ts b/site/lib/reference.ts
index c961485..a96f5cd 100644
--- a/site/lib/reference.ts
+++ b/site/lib/reference.ts
@@ -6,13 +6,14 @@ export type Family = (typeof FAMILIES)[number];
export const CLI_COMMANDS = [
{ name: 'validate', usage: '
[--json]', does: 'Check the diagram' },
- { name: 'deliver', usage: ' [-o out.html]', does: 'Write the offline HTML viewer' },
+ { name: 'deliver', usage: ' [-o out.html] [--open]', does: 'Write the offline HTML viewer' },
{ name: 'serve', usage: ' [--port 4400]', does: 'Live viewer that reloads on save' },
] as const;
export const CLI_OPTIONS = [
{ flag: '--json', for: 'validate', does: 'Machine-readable diagnostics' },
{ flag: '-o, --out', for: 'deliver', does: 'The output path; the default is next to the input, as .html' },
+ { flag: '--open', for: 'deliver', does: 'Open the written viewer in the default browser; when none opens, it warns and still exits 0' },
{ flag: '--port', for: 'serve', does: 'The port; 4400 by default, and the next free one if it is taken' },
{ flag: '-h, --help', for: 'any', does: 'Show the help' },
{ flag: '-v, --version', for: 'any', does: 'Show the version' },
From 8161a8dafb9f183db08e8b708e72c90d6ad8a2f9 Mon Sep 17 00:00:00 2001
From: Joseph Dale Banares
Date: Fri, 2 Oct 2026 15:26:42 +0800
Subject: [PATCH 6/6] test(viewer): update the lifecycle and stages baselines
for the moved edge labels
---
.../lifecycle-dark-linux.png | Bin 121958 -> 121932 bytes
.../lifecycle-light-linux.png | Bin 133107 -> 133095 bytes
.../stages-dark-linux.png | Bin 110746 -> 111554 bytes
.../stages-light-linux.png | Bin 121365 -> 122115 bytes
4 files changed, 0 insertions(+), 0 deletions(-)
diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png
index e00c938957e89486038ef1efc01d37d42a8cf526..2900ffe6638a4ee1f9c52eb6edb113e9190866b8 100644
GIT binary patch
delta 74748
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