flowfig 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,16 +28,45 @@ An agent with the flowfig instructions made faithful, checked diagrams more ofte
28
28
 
29
29
  [How we measured](#how-we-measured).
30
30
 
31
- ## Quick start
31
+ ## Use it
32
+
33
+ Set up your repo:
32
34
 
33
35
  ```bash
34
36
  npx flowfig init
35
37
  ```
36
38
 
37
- Then ask your agent: "draw a diagram of how login works in this repo".
39
+ Then start a new agent session. In Claude Code, run:
40
+
41
+ ```
42
+ /figure how does login work
43
+ ```
44
+
45
+ With any agent, ask in plain words:
46
+
47
+ ```
48
+ draw a diagram of how login works in this repo
49
+ use flowfig to draw the checkout flow as a sequence
50
+ draw the refund process with a lane for each team
51
+ ```
52
+
53
+ The agent writes an SVG and ends its reply with `npx flowfig open <path>`. Run that command to see the animation in your
54
+ browser.
55
+
56
+ ## One command
57
+
58
+ ```bash
59
+ npx flowfig draw "how does login work"
60
+ ```
38
61
 
39
- `init` writes the flowfig instructions for the agents that your repo uses. The agent reads the code, writes a spec, runs
40
- `flowfig check`, fixes the faults, and renders the SVG.
62
+ `draw` runs Claude Code on the repo. It gives Claude Code the flowfig guide, `npx flowfig` and the tools Read, Glob and Grep.
63
+ The permission mode is `acceptEdits`, so Claude Code can write files. `draw` disallows the tools Edit, MultiEdit and
64
+ NotebookEdit. Claude Code writes the spec `how-does-login-work.json`, then renders `how-does-login-work.svg`. Then `draw`
65
+ checks the figure itself with `check --strict` and `verify`, and removes the spec file. If the agent writes no new figure,
66
+ `draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
67
+ and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
68
+ `--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
69
+ `--json` prints `{ out, reply, cost, session, findings }` for scripts. `--open` opens the figure in the default browser after the check.
41
70
 
42
71
  ## The problem
43
72
 
@@ -80,7 +109,9 @@ Then ask your agent: "draw a diagram of how login works in this repo".
80
109
  - [Three forms](#three-forms)
81
110
  - [The spec](#the-spec)
82
111
  - [Check a figure](#check-a-figure)
112
+ - [Open and share a figure](#open-and-share-a-figure)
83
113
  - [Verify in CI](#verify-in-ci)
114
+ - [For teams](#for-teams)
84
115
  - [MCP server](#mcp-server)
85
116
  - [Use in React](#use-in-react)
86
117
  - [Use from Node](#use-from-node)
@@ -300,6 +331,31 @@ A render runs the same check first. If the check finds an error, the render writ
300
331
  In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
301
332
  `console.warn`.
302
333
 
334
+ ## Open and share a figure
335
+
336
+ `flowfig open` shows a figure in your default browser. On macOS, an `.svg` file often opens in a text editor or in Xcode. Thus
337
+ `open` writes an HTML page that holds the SVG, and opens that page.
338
+
339
+ ```bash
340
+ npx flowfig open docs/checkout.svg
341
+ npx flowfig open docs/checkout.svg --html checkout.html # also write the page to checkout.html
342
+ npx flowfig docs/checkout.json docs/checkout.svg --open # render, then open
343
+ ```
344
+
345
+ `flowfig gif` writes an animated GIF of the whole loop. A GIF plays in Slack, Notion, X and slide decks, where an SVG
346
+ animation does not play.
347
+
348
+ ```bash
349
+ npx flowfig gif docs/checkout.svg # writes docs/checkout.gif
350
+ npx flowfig gif docs/checkout.svg --step 2 # only step 2: writes docs/checkout-step2.gif
351
+ npx flowfig gif docs/checkout.svg --dark --mp4 # the dark theme, and also docs/checkout.mp4
352
+ ```
353
+
354
+ `gif` needs Chrome, Edge, Chromium or Brave in the standard install path. Set `CHROME_PATH` to use another browser program.
355
+ `--fps` sets the frames per second (1 to 50, default 20). `--scale` sets the pixel scale (default 2). `--mp4` needs `ffmpeg`
356
+ on the PATH. If a GIF is over 10 MB, `gif` prints a warning. Use `--step`, a lower `--fps` or a lower `--scale` to make the
357
+ file smaller.
358
+
303
359
  ## Verify in CI
304
360
 
305
361
  A box, an edge or a hop can name the code it draws: `"source": "src/auth/login.ts#verifyPassword"`. `npx flowfig verify docs/login.svg`
@@ -307,6 +363,8 @@ fails when the file or the symbol is gone. The action runs `verify` on every fig
307
363
  the new image for each SVG the PR changes, with the spec changes as a list, and it names each figure whose linked code the PR
308
364
  changes.
309
365
 
366
+ A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-the-refund"`. The symbol is the heading as a GitHub anchor. If the heading is gone, `verify` fails.
367
+
310
368
  ```yaml
311
369
  permissions:
312
370
  contents: read
@@ -323,6 +381,21 @@ steps:
323
381
  figures: 'docs/**/*.svg' # default **/*.svg
324
382
  ```
325
383
 
384
+ A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`.
385
+
386
+ ![An order status lifecycle with a start dot and two end rings](docs/order-status.svg)
387
+
388
+ ## For teams
389
+
390
+ A process is a diagram too. `lanes: true` draws one lane per role and the steps left to right in time order. Each step
391
+ links to a heading in the SOP, so `verify` fails when the SOP changes under the diagram.
392
+
393
+ ![The refund process across Customer, Support and Finance](docs/refund-process.svg)
394
+
395
+ A plan is a figure too. `timeline: true` draws one track per team, with each item as a bar from its `from` date to its `to` date. A milestone has only `from`, and `today` draws the line for now.
396
+
397
+ ![A Q4 roadmap with three tracks, milestones and a today line](docs/roadmap.svg)
398
+
326
399
  ## MCP server
327
400
 
328
401
  `npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
@@ -438,8 +511,9 @@ of the file. A second run also replaces a whole file that `init` wrote. If a who
438
511
 
439
512
  ## What flowfig does not draw
440
513
 
441
- flowfig draws parts, the messages between the parts, and their order. flowfig does not draw class diagrams, ER diagrams, Gantt
442
- charts, timelines with dates, charts of numbers or mind maps. Use Mermaid or a chart library for those.
514
+ flowfig draws parts, the messages between the parts, and their order. flowfig draws a roadmap or a timeline with dates (`timeline: true`),
515
+ and converts a Mermaid `gantt` to it. flowfig does not draw class diagrams, ER diagrams, charts of numbers or mind maps. Use Mermaid
516
+ or a chart library for those.
443
517
 
444
518
  ## How we measured
445
519
 
@@ -0,0 +1,33 @@
1
+ export type Found = {
2
+ path: string | null;
3
+ checked: string[];
4
+ };
5
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
6
+ export declare function findBrowser(o: {
7
+ platform: NodeJS.Platform;
8
+ env: Record<string, string | undefined>;
9
+ exists: (p: string) => boolean;
10
+ }): Found;
11
+ export type Cdp = {
12
+ send(method: string, params?: object, sessionId?: string, timeoutMs?: number): Promise<any>;
13
+ once(event: string, sessionId?: string, timeoutMs?: number): Promise<any>;
14
+ };
15
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
16
+ export declare function launch(path: string, profile: string, timeoutMs?: number): Promise<{
17
+ cdp: Cdp;
18
+ close: () => Promise<void>;
19
+ }>;
20
+ export type Capture = {
21
+ pngs: Buffer[];
22
+ width: number;
23
+ height: number;
24
+ loopMs: number;
25
+ };
26
+ /** Set the page, pause the animations and take one PNG per frame time. */
27
+ export declare function captureFrames(cdp: Cdp, html: string, o: {
28
+ width: number;
29
+ height: number;
30
+ scale: number;
31
+ fps: number;
32
+ dark: boolean;
33
+ }): Promise<Capture>;
@@ -0,0 +1,201 @@
1
+ // The capture browser of `flowfig gif`: find it, start it in headless mode, and talk CDP to it over a pipe. Node only.
2
+ import { spawn } from 'node:child_process';
3
+ import { win32 } from 'node:path';
4
+ const MAC = ['Google Chrome', 'Microsoft Edge', 'Chromium', 'Brave Browser'].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
5
+ const LINUX = [
6
+ '/usr/bin/google-chrome',
7
+ '/usr/bin/google-chrome-stable',
8
+ '/usr/bin/chromium',
9
+ '/usr/bin/chromium-browser',
10
+ '/snap/bin/chromium',
11
+ '/usr/bin/microsoft-edge',
12
+ '/usr/bin/brave-browser',
13
+ '/opt/google/chrome/chrome',
14
+ ];
15
+ const WINDOWS = [
16
+ 'Google\\Chrome\\Application\\chrome.exe',
17
+ 'Microsoft\\Edge\\Application\\msedge.exe',
18
+ 'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
19
+ 'Chromium\\Application\\chrome.exe',
20
+ ];
21
+ /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
22
+ export function findBrowser(o) {
23
+ const { env } = o;
24
+ const paths = env.CHROME_PATH
25
+ ? [env.CHROME_PATH]
26
+ : o.platform === 'darwin'
27
+ ? MAC
28
+ : o.platform === 'win32'
29
+ ? [env.PROGRAMFILES, env['PROGRAMFILES(X86)'], env.LOCALAPPDATA].flatMap((dir) => dir ? WINDOWS.map((p) => win32.join(dir, p)) : [])
30
+ : LINUX;
31
+ const checked = [];
32
+ for (const p of paths) {
33
+ checked.push(p);
34
+ if (o.exists(p))
35
+ return { path: p, checked };
36
+ }
37
+ return { path: null, checked };
38
+ }
39
+ /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
40
+ export function launch(path, profile, timeoutMs = 30_000) {
41
+ const args = [
42
+ '--headless=new',
43
+ '--remote-debugging-pipe',
44
+ `--user-data-dir=${profile}`,
45
+ '--no-first-run',
46
+ '--no-default-browser-check',
47
+ '--hide-scrollbars',
48
+ '--mute-audio',
49
+ // The GPU raster gave a different pixel run to run on a rounded corner, and a bigger GIF.
50
+ '--disable-gpu',
51
+ // A transform animation (the timeline playhead) runs on the compositor thread, so a seek can draw a stale x under load.
52
+ '--disable-threaded-animation',
53
+ ];
54
+ // Chrome refuses to start as root without it, for example in a Docker container.
55
+ if (process.getuid?.() === 0)
56
+ args.push('--no-sandbox');
57
+ const child = spawn(path, [...args, 'about:blank'], {
58
+ stdio: ['ignore', 'ignore', 'pipe', 'pipe', 'pipe'],
59
+ // Its own process group, so close can stop the helpers too. Not on Windows: there it opens a console window.
60
+ detached: process.platform !== 'win32',
61
+ });
62
+ // The browser reads commands on fd 3 and writes replies on fd 4. A NUL byte ends each message.
63
+ const out = child.stdio[3], input = child.stdio[4];
64
+ out.on('error', () => { }); // a write after the exit fails; the exit handler below rejects the commands
65
+ let tail = '', dead = null, nextId = 1;
66
+ child.stderr.on('data', (d) => (tail = (tail + d.toString()).slice(-2048)));
67
+ const pending = new Map();
68
+ const events = [];
69
+ const stop = (e) => {
70
+ dead ??= e;
71
+ for (const w of [...pending.values(), ...events.splice(0)])
72
+ w.reject(dead);
73
+ pending.clear();
74
+ };
75
+ // The browser starts helper processes in its process group. A helper can outlive the browser and keep a pipe open.
76
+ const killGroup = () => {
77
+ try {
78
+ if (process.platform === 'win32')
79
+ child.kill('SIGKILL');
80
+ else if (child.pid)
81
+ process.kill(-child.pid, 'SIGKILL');
82
+ }
83
+ catch {
84
+ // the group is already gone
85
+ }
86
+ };
87
+ const closed = new Promise((done) => {
88
+ const end = (e) => (stop(e), done());
89
+ const stopped = (code) => new Error(`the browser stopped (exit ${code}): ${tail.trim()}`);
90
+ child.once('error', end);
91
+ child.once('exit', (code) => {
92
+ out.destroy(); // the write end of fd 3 does not close by itself, and 'close' waits for it
93
+ killGroup();
94
+ setTimeout(() => end(stopped(code)), 1000).unref(); // in case a pipe stays open after the group kill
95
+ });
96
+ child.once('close', (code) => end(stopped(code)));
97
+ });
98
+ let parts = [];
99
+ input.on('data', (chunk) => {
100
+ let start = 0;
101
+ for (let end = chunk.indexOf(0); end !== -1; start = end + 1, end = chunk.indexOf(0, start)) {
102
+ parts.push(chunk.subarray(start, end));
103
+ const msg = JSON.parse(Buffer.concat(parts).toString('utf8'));
104
+ parts = [];
105
+ if (msg.id !== undefined) {
106
+ const w = pending.get(msg.id);
107
+ pending.delete(msg.id);
108
+ if (msg.error)
109
+ w?.reject(new Error(`${w.method}: ${msg.error.message}`));
110
+ else
111
+ w?.resolve(msg.result);
112
+ }
113
+ else {
114
+ const i = events.findIndex((w) => w.event === msg.method && w.sessionId === msg.sessionId);
115
+ if (i !== -1)
116
+ events.splice(i, 1)[0].resolve(msg.params);
117
+ }
118
+ }
119
+ if (start < chunk.length)
120
+ parts.push(chunk.subarray(start));
121
+ });
122
+ /** A promise that rejects after the timeout; `add` registers the waiter, `drop` removes it. */
123
+ const timed = (label, add, drop, ms = timeoutMs) => new Promise((resolve, reject) => {
124
+ if (dead)
125
+ return reject(dead);
126
+ const timer = setTimeout(() => {
127
+ drop();
128
+ reject(new Error(`${label}: no reply in ${ms / 1000} s`));
129
+ }, ms);
130
+ add({ resolve: (v) => (clearTimeout(timer), resolve(v)), reject: (e) => (clearTimeout(timer), reject(e)) });
131
+ });
132
+ const cdp = {
133
+ send: (method, params = {}, sessionId, ms) => {
134
+ const id = nextId++;
135
+ return timed(method, (w) => {
136
+ pending.set(id, { ...w, method });
137
+ out.write(JSON.stringify({ id, method, params, sessionId }) + '\0');
138
+ }, () => pending.delete(id), ms);
139
+ },
140
+ once: (event, sessionId, ms) => {
141
+ let me;
142
+ return timed(event, (w) => events.push((me = { ...w, event, sessionId })), () => events.splice(events.indexOf(me), 1), ms);
143
+ },
144
+ };
145
+ // No Browser.close: the profile is a temp folder with nothing to keep, and Chrome takes about 10 s to exit after it.
146
+ const close = async () => {
147
+ killGroup();
148
+ await closed;
149
+ };
150
+ return cdp.send('Browser.getVersion').then(() => ({ cdp, close }), async (e) => {
151
+ await close();
152
+ throw e;
153
+ });
154
+ }
155
+ /** Set the page, pause the animations and take one PNG per frame time. */
156
+ export async function captureFrames(cdp, html, o) {
157
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
158
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
159
+ const send = (method, params) => cdp.send(method, params, sessionId);
160
+ const run = async (expression) => {
161
+ const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
162
+ if (r.exceptionDetails)
163
+ throw new Error(`Runtime.evaluate: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
164
+ return r.result.value;
165
+ };
166
+ await send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: o.scale, mobile: false });
167
+ // The figure picks its theme with a media query, so the run sets the query and leaves the SVG as it is.
168
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
169
+ // The page goes in as content, not as a file: a snap browser has a private /tmp and cannot read the temp folder.
170
+ const { frameTree } = await send('Page.getFrameTree');
171
+ await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
172
+ // CSS animations are in getAnimations(). The packet moves with SMIL, which only the <svg> element controls.
173
+ const info = await run(`(async () => {
174
+ await document.fonts.ready;
175
+ const svg = document.querySelector('svg');
176
+ const css = document.getAnimations();
177
+ for (const a of css) a.pause();
178
+ svg.pauseAnimations();
179
+ let loop = 0;
180
+ for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
181
+ for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
182
+ try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
183
+ const r = svg.getBoundingClientRect();
184
+ return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
185
+ })()`);
186
+ const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
187
+ const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
188
+ const pngs = [];
189
+ for (let i = 0; i < n; i++) {
190
+ const t = (i * 1000) / o.fps;
191
+ await run(`(() => {
192
+ for (const a of document.getAnimations()) a.currentTime = ${t};
193
+ document.querySelector('svg').setCurrentTime(${t / 1000});
194
+ // two frames: one frame can fire before the browser draws the new time under load
195
+ return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
196
+ })()`);
197
+ const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
198
+ pngs.push(Buffer.from(data, 'base64'));
199
+ }
200
+ return { pngs, width: Math.round(info.width * o.scale), height: Math.round(info.height * o.scale), loopMs: info.loop };
201
+ }
package/dist/check.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The rules behind `flowfig check`. They never draw and never measure: a renderer gives a Scene, and these read it. So one rule set
2
2
  // covers the SVG (estimated text) and the React player (measured text), and any later view that gives a scene.
3
- import { DARK, LIGHT, ON_ACCENT, edgeId, isGroup, nodes, str, toBeat } from './model.js';
3
+ import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
4
4
  import { textWidth } from './text.js';
5
5
  import { owners, parseSource } from './source.js';
6
6
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
@@ -34,9 +34,9 @@ export function checkSpec(fig) {
34
34
  for (const h of b.hops)
35
35
  if (!knownEdges.has(h.edge))
36
36
  out.push(err('unknown-id', [h.edge], `${where} moves along edge "${h.edge}", which does not exist`));
37
- for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? [])])
37
+ for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? []), ...(b.focus ?? [])])
38
38
  if (!known.has(id))
39
- out.push(err('unknown-id', [id], `${where}: show or light names box "${id}", which does not exist`));
39
+ out.push(err('unknown-id', [id], `${where}: show, light or focus names box "${id}", which does not exist`));
40
40
  }
41
41
  });
42
42
  const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
@@ -48,6 +48,61 @@ export function checkSpec(fig) {
48
48
  for (const [who, source] of owners(fig))
49
49
  if (source != null && !parseSource(source))
50
50
  out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
51
+ const marks = nodes(fig.layout).map((n) => n.mark);
52
+ const starts = marks.filter((m) => m === 'start').length;
53
+ const ends = marks.filter((m) => m === 'end').length;
54
+ if (starts > 1 || (starts && !ends))
55
+ out.push(warn('mark-count', [], `a lifecycle has one start and at least one end: ${starts} start, ${ends} end`));
56
+ if (fig.timeline && fig.lanes)
57
+ out.push(warn('timeline-and-lanes', [], 'the figure sets timeline and lanes; the renderers draw the timeline and ignore lanes'));
58
+ if (fig.timeline) {
59
+ if (!isLanesLayout(fig.layout))
60
+ out.push(err('lanes-need-column', [], 'a timeline uses the lanes layout: a column group of labeled groups, one per track, with boxes only inside'));
61
+ for (const n of nodes(fig.layout)) {
62
+ if (n.from == null)
63
+ out.push(err('timeline-need-from', [n.id], `box "${n.id}" has no from date, and a timeline needs one`));
64
+ const [f, t] = [n.from == null ? null : dayOf(n.from), n.to == null ? null : dayOf(n.to)];
65
+ for (const [name, v, d] of [
66
+ ['from', n.from, f],
67
+ ['to', n.to, t],
68
+ ])
69
+ if (v != null && d == null)
70
+ out.push(err('bad-date', [n.id], `box "${n.id}": ${name} "${v}" is not a real YYYY-MM-DD date`));
71
+ if (f != null && t != null && t < f)
72
+ out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
73
+ }
74
+ // A dependent item starts after its source ends. A milestone ends the day it starts.
75
+ const span = new Map(nodes(fig.layout).map((n) => [n.id, [n.from && dayOf(n.from), (n.to && dayOf(n.to)) ?? (n.from && dayOf(n.from))]]));
76
+ for (const e of fig.edges) {
77
+ const start = span.get(e.to)?.[0];
78
+ const end = span.get(e.from)?.[1];
79
+ if (start != null && end != null && start <= end)
80
+ out.push(warn('timeline-dependency-order', [e.from, e.to], `"${e.to}" does not start after "${e.from}" ends`));
81
+ }
82
+ if (fig.today != null && dayOf(fig.today) == null)
83
+ out.push(err('bad-date', [], `today "${fig.today}" is not a real YYYY-MM-DD date`));
84
+ }
85
+ else if (fig.lanes) {
86
+ const top = fig.layout;
87
+ if (!isLanesLayout(top))
88
+ out.push(err('lanes-need-column', [], 'lanes need a column group of labeled groups, one per lane, with boxes only inside'));
89
+ else {
90
+ const cols = laneColumns(fig);
91
+ for (const lane of top.children) {
92
+ const at = new Map();
93
+ for (const n of lane.children) {
94
+ const c = cols.get(n.id);
95
+ const other = at.get(c);
96
+ if (other != null)
97
+ out.push(warn('lane-column-taken', [other, n.id], `boxes "${other}" and "${n.id}" share column ${c} in lane "${str(lane.label)}"`));
98
+ at.set(c, n.id);
99
+ }
100
+ }
101
+ }
102
+ }
103
+ for (const n of nodes(fig.layout))
104
+ if (n.at != null && !validAt(n.at))
105
+ out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
51
106
  return out;
52
107
  }
53
108
  /** A point on the cubic Bézier at `t`. */
@@ -70,6 +125,8 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
70
125
  out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
71
126
  }
72
127
  for (const e of scene.edges) {
128
+ if (e.behind)
129
+ continue;
73
130
  const pts = Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
74
131
  for (const b of scene.boxes)
75
132
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
@@ -128,7 +185,16 @@ const PAIRS = [
128
185
  ['muted', 'bg'],
129
186
  ['fg', 'surface'],
130
187
  ['text', 'accent'],
188
+ ['fg', 'tint'],
189
+ ['muted', 'tint'],
131
190
  ];
191
+ /** A color mixed over a base at pct %, per channel like the CSS color-mix. The default is the active-box tint: 10 % accent over the surface. */
192
+ function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
193
+ const [a, s] = [rgb(color), rgb(base)];
194
+ if (!a || !s)
195
+ return base; // an unreadable color is reported by its own pairs
196
+ return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
197
+ }
132
198
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
133
199
  export function checkTheme(theme = {}) {
134
200
  const out = [];
@@ -142,7 +208,8 @@ export function checkTheme(theme = {}) {
142
208
  if (custom)
143
209
  sets.push(['custom', { ...LIGHT, ...Object.fromEntries(Object.entries(theme).filter(([, v]) => v != null)) }]);
144
210
  const unread = new Set();
145
- for (const [name, t] of sets)
211
+ for (const [name, base] of sets) {
212
+ const t = { ...base, tint: tintOf(base) };
146
213
  for (const [fg, bg] of PAIRS) {
147
214
  const a = fg === 'text' ? ON_ACCENT : t[fg];
148
215
  const r = contrast(a, t[bg]);
@@ -154,6 +221,17 @@ export function checkTheme(theme = {}) {
154
221
  else if (r < 4.5)
155
222
  out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
156
223
  }
224
+ // A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
225
+ for (const [tname, tone] of Object.entries(TONES)) {
226
+ for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
227
+ for (const fg of ['fg', 'muted']) {
228
+ const r = contrast(t[fg], bg);
229
+ if (r != null && r < 4.5)
230
+ out.push(err('low-contrast', [], `${name} theme: ${fg} on the ${tname} box tint has contrast ${r}:1 (minimum 4.5:1)`));
231
+ }
232
+ }
233
+ }
234
+ }
157
235
  for (const c of unread)
158
236
  out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
159
237
  return out;