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/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/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/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png index e00c938..2900ffe 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png index 6e76467..b077ad8 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/lifecycle-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png index 2865a41..6f08369 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png index 4083f85..6624e11 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/stages-light-linux.png differ 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. */ 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 + } } }; 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/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..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' }, @@ -45,4 +46,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; 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.