diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76e27fe..c8a49cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -141,7 +141,7 @@ jobs: contents: write # tags and the GitHub release issues: write # release comments on issues pull-requests: write # and on pull requests - id-token: write # npm provenance + id-token: write # npm trusted publishing (OIDC) and provenance steps: # The deploy key pushes the release commit and the back-merge past branch protection. - uses: actions/checkout@v4 @@ -155,13 +155,14 @@ jobs: with: node-version: 24 registry-url: https://registry.npmjs.org + # Trusted publishing needs npm 11.5.1+, newer than some Node 24 images bundle. + - run: npm install -g npm@^11.5.1 - run: bun install --frozen-lockfile # The CLI's prepack bundles the viewer template, which this fresh checkout hasn't built yet. - run: bun run build - run: npx --no-install semantic-release env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} GIT_AUTHOR_NAME: github-actions[bot] GIT_AUTHOR_EMAIL: 41898282+github-actions[bot]@users.noreply.github.com GIT_COMMITTER_NAME: github-actions[bot] 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/assets/brand/README.md b/assets/brand/README.md index 80f09fd..428f881 100644 --- a/assets/brand/README.md +++ b/assets/brand/README.md @@ -21,7 +21,8 @@ Every file carries explicit colours, so pick the light file or its `-dark` twin | `stackmap-app-icon.svg`, `-512.png`, `stackmap-apple-touch-icon-180.png` | The full mark on the near-black icon tile | | `stackmap-favicon.svg`, `-32.png`, `-16.png` | The compact mark on the icon tile (inlined in the viewer) | | `stackmap-readme-header.png`, `-dark` | The 1280×400 README header at 2×, in a `` at the full column width | -| `stackmap-social-card.png`, `.svg`, `-light.svg` | The 1200×630 link preview (`og:image`, the GitHub social preview) | +| `stackmap-social-card.png`, `-light.png` | The 1200×630 link preview (`og:image`, `twitter:image`); the site serves the dark one | +| `stackmap-github-preview.png` | The 1280×640 GitHub social preview (Settings › General › Social preview), with everything that matters inside GitHub's 80px crop border | ## Rules diff --git a/assets/brand/stackmap-github-preview.png b/assets/brand/stackmap-github-preview.png new file mode 100644 index 0000000..d0b914b Binary files /dev/null and b/assets/brand/stackmap-github-preview.png differ diff --git a/assets/brand/stackmap-social-card-light.png b/assets/brand/stackmap-social-card-light.png new file mode 100644 index 0000000..aeee97a Binary files /dev/null and b/assets/brand/stackmap-social-card-light.png differ diff --git a/assets/brand/stackmap-social-card-light.svg b/assets/brand/stackmap-social-card-light.svg deleted file mode 100644 index 7c5549f..0000000 --- a/assets/brand/stackmap-social-card-light.svg +++ /dev/null @@ -1 +0,0 @@ -stackmap: every layer of your stack, on one map \ No newline at end of file diff --git a/assets/brand/stackmap-social-card.png b/assets/brand/stackmap-social-card.png index 2165ddc..ac20e3f 100644 Binary files a/assets/brand/stackmap-social-card.png and b/assets/brand/stackmap-social-card.png differ diff --git a/assets/brand/stackmap-social-card.svg b/assets/brand/stackmap-social-card.svg deleted file mode 100644 index 8fd55a0..0000000 --- a/assets/brand/stackmap-social-card.svg +++ /dev/null @@ -1 +0,0 @@ -stackmap: every layer of your stack, on one map \ No newline at end of file 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/focus.spec.ts b/packages/viewer/e2e/focus.spec.ts new file mode 100644 index 0000000..1a24b3f --- /dev/null +++ b/packages/viewer/e2e/focus.spec.ts @@ -0,0 +1,69 @@ +import { expect, test, type Page } from '@playwright/test'; + +const inspector = (page: Page) => page.getByRole('complementary', { name: 'Inspector' }); +const header = (page: Page) => page.getByRole('banner'); +const tool = (page: Page, name: string | RegExp) => page.getByRole('button', { name }); +const zoom = async (page: Page) => Number((await page.locator('span.tabular-nums', { hasText: /^\d+%$/ }).textContent())!.replace('%', '')); + +test('Z leaves only the canvas, which still works, and Z again brings everything back', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('.sm-card').first()).toBeVisible(); + const before = (await page.locator('#sm-diagram').boundingBox())!; + const fitted = await zoom(page); + await page.keyboard.press('z'); + // The bigger stage frames the diagram again. + await expect.poll(() => zoom(page)).toBeGreaterThan(fitted); + await expect(header(page)).toHaveCount(0); + await expect(inspector(page)).toHaveCount(0); + await expect(tool(page, 'Leave focus (Z)')).toHaveAttribute('aria-pressed', 'true'); + const after = (await page.locator('#sm-diagram').boundingBox())!; + expect(after.width).toBeGreaterThan(before.width); + expect(after.height).toBeGreaterThan(before.height); + // Selecting still works; its details wait until focus ends. + await page.locator('.sm-card[data-card-id="deploy"]').click(); + await expect(page).toHaveURL(/#node=deploy$/); + await expect(inspector(page)).toHaveCount(0); + await page.keyboard.press('z'); + await expect(header(page)).toBeVisible(); + await expect(inspector(page)).toContainText('Deploy'); +}); + +test('the toolbar button enters focus, and the URL never keeps it', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await tool(page, 'Focus on the canvas (Z)').click(); + await expect(header(page)).toHaveCount(0); + await expect(page).not.toHaveURL(/focus/); + await tool(page, 'Leave focus (Z)').click(); + await expect(header(page)).toBeVisible(); +}); + +test('in focus, H hides the toolbar and shows the way back; leaving focus restores it', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('.sm-card').first()).toBeVisible(); + // Outside focus H does nothing. + await page.keyboard.press('h'); + await expect(tool(page, 'Focus on the canvas (Z)')).toBeVisible(); + await page.keyboard.press('z'); + await page.keyboard.press('h'); + await expect(tool(page, 'Leave focus (Z)')).toHaveCount(0); + await expect(tool(page, /^Play the flow/)).toHaveCount(0); + const back = tool(page, 'Show the toolbar (H)'); + await expect(back).toBeVisible(); + await back.click(); + await expect(tool(page, /^Play the flow/)).toBeVisible(); + await tool(page, 'Hide the toolbar (H)').click(); + await expect(tool(page, /^Play the flow/)).toHaveCount(0); + await page.keyboard.press('z'); + await expect(header(page)).toBeVisible(); + await expect(tool(page, /^Play the flow/)).toBeVisible(); + await expect(tool(page, 'Hide the toolbar (H)')).toHaveCount(0); +}); + +test('Z and H are ignored while typing in search', async ({ page }) => { + await page.goto('/?page=release-delivery'); + await expect(page.locator('.sm-card').first()).toBeVisible(); + await page.keyboard.press('/'); + await page.keyboard.type('zh'); + await expect(header(page)).toBeVisible(); + await expect(page.getByRole('combobox', { name: 'Search nodes' })).toHaveValue('zh'); +}); diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png index 4b82e2f..fe9fe98 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png index a523005..bc9b0ee 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/grouped-light-linux.png differ 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..8d048eb 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..5a2359a 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/sample-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png index f2c5132..d3326f1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png index 9249b95..7727860 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sample-light-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png index ed0da49..673a8dd 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png index 342c2c6..62d6756 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/sequence-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..3faee80 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..78fffb8 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/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png index e43e7ef..e36eff1 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-dark-linux.png differ diff --git a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png index 0b7a466..a06aaf5 100644 Binary files a/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png and b/packages/viewer/e2e/visual.spec.ts-snapshots/workflow-light-linux.png differ diff --git a/packages/viewer/src/canvas/DiagramCanvas.tsx b/packages/viewer/src/canvas/DiagramCanvas.tsx index 084ca66..5384f9a 100644 --- a/packages/viewer/src/canvas/DiagramCanvas.tsx +++ b/packages/viewer/src/canvas/DiagramCanvas.tsx @@ -92,17 +92,22 @@ function DotGrid({ x, y, k }: Transform) { ); } -/** `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. */ +/** + * `chrome` false (presentation): no toolbar, zoom bar or minimap, and the camera refits the bigger stage. A change + * of `stage` (focus mode) says the stage changed size for another reason, and refits too. + */ export function DiagramCanvas({ diagram, children, chrome = true, + stage, minimap: withMinimap = true, wheel = 'zoom', }: { diagram: LaidOutDiagram; children?: ReactNode; chrome?: boolean; + stage?: string; /** false: no minimap and no M key */ minimap?: boolean; wheel?: WheelMode; @@ -124,10 +129,11 @@ export function DiagramCanvas({ const { x, y, k } = viewport.transform; useCameraEffects(scene, camera, viewport.restored); // The stage changes size when the chrome comes or goes: frame the current view again once it has. - const framed = useRef(chrome); + const layout = `${chrome}|${stage ?? ''}`; + const framed = useRef(layout); useEffect(() => { - if (framed.current === chrome) return; - framed.current = chrome; + if (framed.current === layout) return; + framed.current = layout; const id = requestAnimationFrame(() => { const members = state.view ? (explore.draft.views?.find((v) => v.id === state.view)?.nodes ?? []) : []; const box = union(members.flatMap((m) => scene.cards.find((c) => c.node.id === m)?.rect ?? [])); @@ -135,7 +141,7 @@ export function DiagramCanvas({ else camera.fit(); }); return () => cancelAnimationFrame(id); - }, [chrome, state.view, explore.draft, scene, camera]); + }, [layout, state.view, explore.draft, scene, camera]); // M toggles the radar from anywhere but a text field or menu; not while presenting (it's hidden then). const chromeRef = useRef(chrome); chromeRef.current = chrome; @@ -201,7 +207,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..a617c59 100644 --- a/packages/viewer/src/chrome/Toolbar.tsx +++ b/packages/viewer/src/chrome/Toolbar.tsx @@ -1,11 +1,11 @@ -import { Filter, Moon, Presentation, Route, Search, Sun, Waypoints } from 'lucide-react'; +import { EyeOff, Filter, Focus, Moon, Presentation, Route, Search, Sun, Waypoints } from 'lucide-react'; import { useEffect, useRef, useState } from 'react'; import { useExplore } from '../explore/ExploreContext'; 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'; @@ -16,12 +16,19 @@ export function Toolbar({ theme, onToggleTheme, onPresent, + focused = false, + onFocus, + onHide, exports = true, trace = true, }: { theme: ThemeChoice; onToggleTheme: () => void; onPresent?: () => void; + /** focus mode (Z): the canvas alone, and in it a button that hides the toolbar (H) */ + focused?: boolean; + onFocus?: () => void; + onHide?: () => void; /** the export menu; a page that embeds the viewer has no file of its own to export */ exports?: boolean; /** the trace button; an embed that offers trace elsewhere can leave it out */ @@ -142,12 +149,26 @@ export function Toolbar({ + {onPresent && ( )} + {/* A phone's toolbar has no room for it; Z still works there with a keyboard. */} + {onFocus && ( + + + + + + )} + {focused && onHide && ( + + + + )} {theme === 'dark' ? : } diff --git a/packages/viewer/src/chrome/ViewerShell.tsx b/packages/viewer/src/chrome/ViewerShell.tsx index e9f6e97..19dbd5a 100644 --- a/packages/viewer/src/chrome/ViewerShell.tsx +++ b/packages/viewer/src/chrome/ViewerShell.tsx @@ -1,3 +1,4 @@ +import { Eye } from 'lucide-react'; import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; import { KIND_LABELS, type LaidOutDiagram } from '@stackmap/core'; import { CanvasPanel } from '../canvas/CanvasPanel'; @@ -11,6 +12,7 @@ import { Inspector } from './Inspector'; import { PresentBar } from './Presentation'; import { inMenu } from './Toolbar'; import { Toolbar } from './Toolbar'; +import { IconButton, PANEL_CLASS, PANEL_STYLE } from './ui'; import { ViewTabs } from './ViewTabs'; /** Below this width the inspector starts collapsed (Q27). */ @@ -64,12 +66,25 @@ export function ViewerShell({ if (on) void shell.current?.requestFullscreen?.().catch(() => {}); else if (document.fullscreenElement) void document.exitFullscreen().catch(() => {}); }, []); + // Focus (Z): the canvas alone in the window, everything on it still working; H hides the toolbar too. + const [focused, setFocused] = useState(false); + const [toolbarHidden, setToolbarHidden] = useState(false); + const focus = useCallback((on: boolean) => { + setFocused(on); + // Leaving focus always brings the toolbar back. + if (!on) setToolbarHidden(false); + }, []); useEffect(() => { const onKey = (e: globalThis.KeyboardEvent) => { const t = e.target as HTMLElement | null; - if ((e.key !== 'f' && e.key !== 'F') || e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return; + const key = e.key.toLowerCase(); + if ((key !== 'f' && key !== 'z' && key !== 'h') || e.metaKey || e.ctrlKey || e.altKey || e.repeat || inMenu(t) || (t && (t.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName)))) return; + if (key !== 'f' && presenting) return; + if (key === 'h' && !focused) return; e.preventDefault(); - present(!presenting); + if (key === 'f') present(!presenting); + else if (key === 'z') focus(!focused); + else setToolbarHidden((v) => !v); }; // Leaving full screen (Esc in the browser's own handling) ends the presentation too. const onFullscreen = () => { @@ -83,7 +98,7 @@ export function ViewerShell({ removeEventListener('keydown', onKey); document.removeEventListener('fullscreenchange', onFullscreen); }; - }, [presenting, present]); + }, [presenting, present, focused, focus]); // Read during render: the canvas (a child) records what it shows in its own layout effect, which runs first. const [reloaded] = useState(() => readLiveConfig(document) !== null && readShown() !== null); useLayoutEffect(() => { @@ -95,8 +110,8 @@ export function ViewerShell({ }, [reloaded]); return ( -
- {!presenting && ( +
+ {!presenting && !focused && (
{draft.title} @@ -113,8 +128,8 @@ export function ViewerShell({
)} - {!presenting && } -
+ {!presenting && !focused && } +
- + {/* Never wider than the stage: the identity card gives way (truncating, then hidden) before the toolbar. */} - {/* Narrow, the header already names the diagram: the toolbar gets the room. */} -
- setInspectorCollapsed(false)} /> -
- present(true)} /> + {toolbarHidden ? ( +
+ setToolbarHidden(false)}> + + +
+ ) : ( + <> + {/* Narrow, the header already names the diagram: the toolbar gets the room. Focus shows the canvas alone. */} + {!focused && ( +
+ setInspectorCollapsed(false)} /> +
+ )} + present(true)} + focused={focused} + onFocus={() => focus(!focused)} + onHide={() => setToolbarHidden(true)} + /> + + )}
{presenting && present(false)} />}
- {!presenting && setInspectorCollapsed((v) => !v)} />} + {!presenting && !focused && setInspectorCollapsed((v) => !v)} />}
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/app/docs/viewer/page.tsx b/site/app/docs/viewer/page.tsx index b4fb4bb..b317a79 100644 --- a/site/app/docs/viewer/page.tsx +++ b/site/app/docs/viewer/page.tsx @@ -41,11 +41,15 @@ export default function ViewerPage() { Parameters join with &: diagram.html#view=checkout&node=api&play=1.

-

Present

+

Present and focus

Press F, or Present in the toolbar, for the stage alone, full screen where the browser allows it. It steps through Overview and then each view:{' '} → or Space moves on, ← goes back, Home and End jump to the ends, and Esc leaves.

+

+ For the canvas alone without presenting, press Z or Focus in the toolbar. The header, view tabs and inspector step aside and everything on the canvas keeps + working; H then hides the toolbar too, and Z brings it all back. +

Export

The Export menu in the toolbar saves what the stage shows:

diff --git a/site/app/opengraph-image.alt.txt b/site/app/opengraph-image.alt.txt index dc0c1d7..f01727e 100644 --- a/site/app/opengraph-image.alt.txt +++ b/site/app/opengraph-image.alt.txt @@ -1 +1 @@ -stackmap: every layer of your stack, on one map \ No newline at end of file +stackmap: every layer of your stack, on one map. The viewer, open on a Commerce API diagram with a route lit \ No newline at end of file diff --git a/site/app/opengraph-image.png b/site/app/opengraph-image.png index 2165ddc..ac20e3f 100644 Binary files a/site/app/opengraph-image.png and b/site/app/opengraph-image.png differ diff --git a/site/app/twitter-image.alt.txt b/site/app/twitter-image.alt.txt index dc0c1d7..f01727e 100644 --- a/site/app/twitter-image.alt.txt +++ b/site/app/twitter-image.alt.txt @@ -1 +1 @@ -stackmap: every layer of your stack, on one map \ No newline at end of file +stackmap: every layer of your stack, on one map. The viewer, open on a Commerce API diagram with a route lit \ No newline at end of file diff --git a/site/app/twitter-image.png b/site/app/twitter-image.png index 2165ddc..ac20e3f 100644 Binary files a/site/app/twitter-image.png and b/site/app/twitter-image.png differ 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/components/site/Nav.tsx b/site/components/site/Nav.tsx index 95390f4..6481dda 100644 --- a/site/components/site/Nav.tsx +++ b/site/components/site/Nav.tsx @@ -9,13 +9,12 @@ import { LINKS } from './links'; import { Lockup } from './Lockup'; import { ThemeButton } from './theme'; -// The landing's sections the nav follows; `install` has no link, so over it the marker hides. +// The landing's sections the nav follows; `kinds` and `install` have no link, so over them the marker hides. const SPY = ['how', 'kinds', 'install'] as const; type Spy = (typeof SPY)[number]; const ITEMS = [ { ...LINKS.how, spy: 'how' as Spy }, - { ...LINKS.kinds, label: 'Kinds', spy: 'kinds' as Spy }, { ...LINKS.examples, page: (p: string) => p.startsWith('/examples') }, { ...LINKS.docs, page: (p: string) => p.startsWith('/docs') }, ]; diff --git a/site/lib/docs-nav.ts b/site/lib/docs-nav.ts index 7614370..a0b2308 100644 --- a/site/lib/docs-nav.ts +++ b/site/lib/docs-nav.ts @@ -42,7 +42,7 @@ export const DOC_PAGES: DocPage[] = [ sections: [ s('try', 'Try it'), s('share', 'Share a view'), - s('present', 'Present'), + s('present', 'Present and focus'), s('export', 'Export'), s('keys', 'Keyboard'), s('themes', 'Themes and motion'), diff --git a/site/lib/reference.ts b/site/lib/reference.ts index 252ceda..5cac21e 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' }, @@ -30,6 +31,8 @@ export const VIEWER_KEYS: { keys: string[]; does: string }[] = [ { keys: ['R'], does: 'Route: pick where it starts, then where it ends' }, { keys: ['P'], does: 'Play or stop the flow' }, { keys: ['F'], does: 'Present: the stage alone, stepping through the views' }, + { keys: ['Z'], does: 'Focus: the canvas alone, without the header, tabs or inspector' }, + { keys: ['H'], does: 'Hide or show the toolbar, in focus' }, { keys: ['M'], does: 'Show or hide the minimap' }, { keys: ['Esc'], does: 'Clear the selection, the search or the route' }, { keys: ['←', '↑', '→', '↓'], does: 'Pan, while the stage has focus' }, @@ -45,4 +48,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/site/lib/search-index.ts b/site/lib/search-index.ts index b7da448..c1dfe10 100644 --- a/site/lib/search-index.ts +++ b/site/lib/search-index.ts @@ -22,7 +22,7 @@ const WORDS: Record = { '/docs/viewer': 'viewer html file inspector select trace route search lens views present play share export keyboard', '/docs/viewer#try': 'demo interactive select trace route find views play', '/docs/viewer#share': 'url hash link parameters deep link', - '/docs/viewer#present': 'full screen slides presentation', + '/docs/viewer#present': 'full screen slides presentation focus canvas only hide toolbar zen', '/docs/viewer#export': 'png jpeg webp svg video download image', '/docs/viewer#keys': 'shortcuts keyboard hotkeys', '/docs/viewer#themes': 'dark light theme reduced motion animation', diff --git a/site/lib/seo.ts b/site/lib/seo.ts index 74abcae..cc7f7c5 100644 --- a/site/lib/seo.ts +++ b/site/lib/seo.ts @@ -15,7 +15,7 @@ export const OPEN_GRAPH = { type: 'website' as const, siteName: SITE_NAME, locale: 'en_US', - images: [{ url: '/opengraph-image.png', width: 1200, height: 630, alt: 'stackmap: every layer of your stack, on one map' }], + images: [{ url: '/opengraph-image.png', width: 1200, height: 630, alt: 'stackmap: every layer of your stack, on one map. The viewer, open on a Commerce API diagram with a route lit' }], }; /** Every static page; the sitemap test fails when a page.tsx is added without its route here. */ 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.