flowfig 0.6.0 → 0.8.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.
Files changed (56) hide show
  1. package/README.md +28 -8
  2. package/dist/browser.d.ts +1 -3
  3. package/dist/browser.js +67 -59
  4. package/dist/check.d.ts +5 -3
  5. package/dist/check.js +24 -17
  6. package/dist/cli.js +43 -49
  7. package/dist/code.d.ts +26 -0
  8. package/dist/code.js +568 -0
  9. package/dist/diff.js +0 -4
  10. package/dist/draw.d.ts +1 -3
  11. package/dist/draw.js +3 -7
  12. package/dist/edges.d.ts +7 -0
  13. package/dist/edges.js +510 -0
  14. package/dist/geometry.d.ts +6 -8
  15. package/dist/geometry.js +75 -82
  16. package/dist/gif.d.ts +1 -2
  17. package/dist/gif.js +115 -42
  18. package/dist/guide.d.ts +1 -4
  19. package/dist/guide.js +10 -11
  20. package/dist/imports.d.ts +11 -0
  21. package/dist/imports.js +235 -0
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +20 -78
  24. package/dist/init.d.ts +3 -13
  25. package/dist/init.js +13 -18
  26. package/dist/load.d.ts +1 -3
  27. package/dist/load.js +2 -5
  28. package/dist/mcp.d.ts +1 -2
  29. package/dist/mcp.js +15 -15
  30. package/dist/model.d.ts +57 -109
  31. package/dist/model.js +20 -89
  32. package/dist/open.d.ts +2 -5
  33. package/dist/open.js +6 -11
  34. package/dist/page.d.ts +0 -1
  35. package/dist/page.js +1 -3
  36. package/dist/picker.d.ts +2 -4
  37. package/dist/picker.js +11 -14
  38. package/dist/png.js +9 -10
  39. package/dist/rail.d.ts +1 -3
  40. package/dist/rail.js +2 -16
  41. package/dist/scene.d.ts +8 -8
  42. package/dist/source.d.ts +1 -2
  43. package/dist/source.js +5 -5
  44. package/dist/svg.d.ts +5 -8
  45. package/dist/svg.js +50 -112
  46. package/dist/text.d.ts +0 -2
  47. package/dist/text.js +1 -4
  48. package/dist/type-index.d.ts +55 -0
  49. package/dist/type-index.js +383 -0
  50. package/dist/types.d.ts +32 -0
  51. package/dist/types.js +733 -0
  52. package/dist/verify.d.ts +26 -4
  53. package/dist/verify.js +68 -26
  54. package/dist/version.d.ts +1 -2
  55. package/dist/version.js +2 -2
  56. package/package.json +4 -2
package/README.md CHANGED
@@ -22,7 +22,7 @@ A diagram in a repo drifts from the code. Someone renames a function, and the di
22
22
  check fails, so no one sees the drift.
23
23
 
24
24
  flowfig links each part of a diagram to the code that it draws. `flowfig verify` fails when a linked file or symbol is
25
- gone. Run `verify` in CI, and a stale diagram fails the pull request.
25
+ gone, and warns when the code does not make an edge. Run `verify` in CI, and a stale diagram fails the pull request.
26
26
 
27
27
  ## Fail CI when the diagram drifts
28
28
 
@@ -36,12 +36,14 @@ Rename `verifyPassword` to `checkPassword`. Then `verify` fails with exit code 1
36
36
 
37
37
  ```console
38
38
  $ npx flowfig verify login.svg
39
- error missing-symbol login.svg: box "verify" -> src/auth/login.ts#verifyPassword: symbol not found
39
+ error missing-symbol login.svg: box "verify" -> src/auth/login.ts#verifyPassword: symbol not defined
40
40
  1 error, 0 warnings
41
+ login.svg: 0 of 1 boxes defined
41
42
  ```
42
43
 
43
- `verify` checks that each linked file exists and that the symbol name still occurs in it. A link to a Markdown file can
44
- name a heading, so a process diagram can follow its SOP document.
44
+ `verify` checks that each linked file exists and that it defines the symbol. It also checks each edge against the code
45
+ (see [Verify in CI](#verify-in-ci)). A link to a Markdown file can name a heading, so a process diagram can follow its SOP
46
+ document.
45
47
 
46
48
  The GitHub Action runs `verify` on every figure in a pull request. For each SVG that the PR changes, it comments the old
47
49
  image, the new image and the spec changes. It also names each figure whose linked code the PR changes.
@@ -63,7 +65,7 @@ jobs:
63
65
  - uses: actions/setup-node@v4
64
66
  with:
65
67
  node-version: 22
66
- - uses: iamalvisng/flowfig@v0.6.0
68
+ - uses: iamalvisng/flowfig@v0.8.0
67
69
  with:
68
70
  figures: 'docs/**/*.svg' # default **/*.svg
69
71
  ```
@@ -252,7 +254,7 @@ Each SVG from the CLI carries its own spec. These commands made the rail-only fi
252
254
  npx flowfig --spec docs/checkout.svg > checkout.json # print the spec in the SVG
253
255
  # edit checkout.json: set "props.rail" to "only"
254
256
  npx flowfig checkout.json docs/checkout-rail-only.svg
255
- npx flowfig verify docs/checkout.svg # check that each source still exists
257
+ npx flowfig verify docs/checkout.svg # check each source and each edge against the code
256
258
  npx flowfig diff old.svg docs/checkout.svg # list what changed in the spec
257
259
  ```
258
260
 
@@ -431,6 +433,23 @@ fails when the file or the symbol is gone. The action runs `verify` on every fig
431
433
  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
432
434
  changes.
433
435
 
436
+ `verify` prints one count line for each figure, and gives each edge one result:
437
+
438
+ ```console
439
+ docs/login.svg: 7 of 7 boxes defined; edges: 9 found, 0 not found, 1 unsure, 1 not checked
440
+ ```
441
+
442
+ - found: the caller code calls or references the callee through an import, the same file or a typed receiver.
443
+ - not found: the caller code does not. `verify` warns, and `--strict` makes it an error.
444
+ - unsure: `verify` cannot decide, for example when a receiver has no declared type. `verify` lists it with the reason. It never fails CI.
445
+ - not checked: the edge has no `source`, or the language is not supported.
446
+
447
+ A method source is `path#Owner.name`. An edge `source` names the function that makes the call.
448
+
449
+ `via` on an edge names the route, queue, topic, table, file or key that both sides use. For a `via` edge, found means that the caller code and the callee file both use that token. It does not prove that a handler serves it.
450
+
451
+ Edge checks support TypeScript/JavaScript, Python, Go, Java, C# and Rust. Other files keep the name check for boxes.
452
+
434
453
  A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-or-reject"`. The symbol is the heading as a GitHub anchor. If the heading is gone, `verify` fails.
435
454
 
436
455
  ```yaml
@@ -450,7 +469,7 @@ jobs:
450
469
  - uses: actions/setup-node@v4
451
470
  with:
452
471
  node-version: 22
453
- - uses: iamalvisng/flowfig@v0.6.0
472
+ - uses: iamalvisng/flowfig@v0.8.0
454
473
  with:
455
474
  figures: 'docs/**/*.svg' # default **/*.svg
456
475
  ```
@@ -523,7 +542,8 @@ writeFileSync('first.svg', toSvg(spec));
523
542
  - `check(spec, options)` returns the findings. It also takes `width` and `minText`.
524
543
  - `render(spec, options)` returns `{ svg, scene }`. The scene is the layout that the check reads. Its shape can change.
525
544
 
526
- `flowfig/verify` is for Node only. It exports `verify`, `links`, `owners` and `parseSource`, the same checks as `flowfig verify`.
545
+ `flowfig/verify` is for Node only. It exports `verify`, `verifyReport` (the findings and the counts), `links`, `owners` and `parseSource`, the same checks
546
+ as `flowfig verify`.
527
547
 
528
548
  `toSvg` does not put the spec in the SVG. Only the CLI adds the spec, which `--spec` reads back.
529
549
 
package/dist/browser.d.ts CHANGED
@@ -2,7 +2,7 @@ export type Found = {
2
2
  path: string | null;
3
3
  checked: string[];
4
4
  };
5
- /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
5
+ /** The first capture browser that exists. `CHROME_PATH` wins. */
6
6
  export declare function findBrowser(o: {
7
7
  platform: NodeJS.Platform;
8
8
  env: Record<string, string | undefined>;
@@ -12,7 +12,6 @@ export type Cdp = {
12
12
  send(method: string, params?: object, sessionId?: string, timeoutMs?: number): Promise<any>;
13
13
  once(event: string, sessionId?: string, timeoutMs?: number): Promise<any>;
14
14
  };
15
- /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
16
15
  export declare function launch(path: string, profile: string, timeoutMs?: number): Promise<{
17
16
  cdp: Cdp;
18
17
  close: () => Promise<void>;
@@ -23,7 +22,6 @@ export type Capture = {
23
22
  height: number;
24
23
  loopMs: number;
25
24
  };
26
- /** Set the page, pause the animations and take one PNG per frame time. */
27
25
  export declare function captureFrames(cdp: Cdp, html: string, o: {
28
26
  width: number;
29
27
  height: number;
package/dist/browser.js CHANGED
@@ -1,5 +1,5 @@
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
1
  import { spawn, spawnSync } from 'node:child_process';
2
+ import * as os from 'node:os';
3
3
  import { win32 } from 'node:path';
4
4
  const MAC = ['Google Chrome', 'Microsoft Edge', 'Chromium', 'Brave Browser'].map((n) => `/Applications/${n}.app/Contents/MacOS/${n}`);
5
5
  const LINUX = [
@@ -18,7 +18,7 @@ const WINDOWS = [
18
18
  'BraveSoftware\\Brave-Browser\\Application\\brave.exe',
19
19
  'Chromium\\Application\\chrome.exe',
20
20
  ];
21
- /** The first capture browser that exists. `CHROME_PATH` wins. Pure apart from `exists`. */
21
+ /** The first capture browser that exists. `CHROME_PATH` wins. */
22
22
  export function findBrowser(o) {
23
23
  const { env } = o;
24
24
  const paths = env.CHROME_PATH
@@ -36,7 +36,6 @@ export function findBrowser(o) {
36
36
  }
37
37
  return { path: null, checked };
38
38
  }
39
- /** Start the capture browser in headless mode with a CDP pipe. `close` stops it and never throws. */
40
39
  export function launch(path, profile, timeoutMs = 30_000) {
41
40
  const args = [
42
41
  '--headless=new',
@@ -46,22 +45,22 @@ export function launch(path, profile, timeoutMs = 30_000) {
46
45
  '--no-default-browser-check',
47
46
  '--hide-scrollbars',
48
47
  '--mute-audio',
49
- // The GPU raster gave a different pixel run to run on a rounded corner, and a bigger GIF.
48
+ // GPU raster gave different pixels run to run on rounded corners.
50
49
  '--disable-gpu',
51
- // A transform animation (the timeline playhead) runs on the compositor thread, so a seek can draw a stale x under load.
50
+ // A seek can draw a stale transform x under load on the compositor thread.
52
51
  '--disable-threaded-animation',
53
52
  ];
54
- // Chrome refuses to start as root without it, for example in a Docker container.
53
+ // Chrome refuses to start as root without it, for example in Docker.
55
54
  if (process.getuid?.() === 0)
56
55
  args.push('--no-sandbox');
57
56
  const child = spawn(path, [...args, 'about:blank'], {
58
57
  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.
58
+ // Not on Windows: a process group there opens a console window.
60
59
  detached: process.platform !== 'win32',
61
60
  });
62
- // The browser reads commands on fd 3 and writes replies on fd 4. A NUL byte ends each message.
61
+ // The browser reads fd 3 and writes fd 4. A NUL byte ends each message.
63
62
  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
63
+ out.on('error', () => { });
65
64
  let tail = '', dead = null, nextId = 1;
66
65
  child.stderr.on('data', (d) => (tail = (tail + d.toString()).slice(-2048)));
67
66
  const pending = new Map();
@@ -72,10 +71,10 @@ export function launch(path, profile, timeoutMs = 30_000) {
72
71
  w.reject(dead);
73
72
  pending.clear();
74
73
  };
75
- // The browser starts helper processes in its process group. A helper can outlive the browser and keep a pipe open.
74
+ // A helper can outlive the browser and keep a pipe open.
76
75
  const killGroup = () => {
77
76
  try {
78
- // Windows has no process group: taskkill /T stops the helpers of the browser too.
77
+ // Windows has no process group: taskkill /T stops the helpers.
79
78
  if (process.platform === 'win32') {
80
79
  if (child.pid)
81
80
  spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
@@ -84,18 +83,16 @@ export function launch(path, profile, timeoutMs = 30_000) {
84
83
  else if (child.pid)
85
84
  process.kill(-child.pid, 'SIGKILL');
86
85
  }
87
- catch {
88
- // the group is already gone
89
- }
86
+ catch { }
90
87
  };
91
88
  const closed = new Promise((done) => {
92
89
  const end = (e) => (stop(e), done());
93
90
  const stopped = (code) => new Error(`the browser stopped (exit ${code}): ${tail.trim()}`);
94
91
  child.once('error', end);
95
92
  child.once('exit', (code) => {
96
- out.destroy(); // the write end of fd 3 does not close by itself, and 'close' waits for it
93
+ out.destroy(); // The write end of fd 3 does not close by itself.
97
94
  killGroup();
98
- setTimeout(() => end(stopped(code)), 1000).unref(); // in case a pipe stays open after the group kill
95
+ setTimeout(() => end(stopped(code)), 1000).unref(); // A pipe can stay open after the group kill.
99
96
  });
100
97
  child.once('close', (code) => end(stopped(code)));
101
98
  });
@@ -123,7 +120,6 @@ export function launch(path, profile, timeoutMs = 30_000) {
123
120
  if (start < chunk.length)
124
121
  parts.push(chunk.subarray(start));
125
122
  });
126
- /** A promise that rejects after the timeout; `add` registers the waiter, `drop` removes it. */
127
123
  const timed = (label, add, drop, ms = timeoutMs) => new Promise((resolve, reject) => {
128
124
  if (dead)
129
125
  return reject(dead);
@@ -146,7 +142,7 @@ export function launch(path, profile, timeoutMs = 30_000) {
146
142
  return timed(event, (w) => events.push((me = { ...w, event, sessionId })), () => events.splice(events.indexOf(me), 1), ms);
147
143
  },
148
144
  };
149
- // No Browser.close: the profile is a temp folder with nothing to keep, and Chrome takes about 10 s to exit after it.
145
+ // Chrome takes about 10 s to exit after Browser.close.
150
146
  const close = async () => {
151
147
  killGroup();
152
148
  await closed;
@@ -156,50 +152,62 @@ export function launch(path, profile, timeoutMs = 30_000) {
156
152
  throw e;
157
153
  });
158
154
  }
159
- /** Set the page, pause the animations and take one PNG per frame time. */
155
+ // Measured: 4 tabs halve the capture time; 8 tabs gain 15% more.
156
+ const MAX_TABS = 4;
157
+ const MIN_FRAMES_PER_TAB = 8;
160
158
  export async function captureFrames(cdp, html, o) {
161
- const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank' });
162
- const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
163
- const send = (method, params) => cdp.send(method, params, sessionId);
164
- const run = async (expression) => {
165
- const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
166
- if (r.exceptionDetails)
167
- throw new Error(`Runtime.evaluate: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
168
- return r.result.value;
159
+ const open = async () => {
160
+ const { targetId } = await cdp.send('Target.createTarget', { url: 'about:blank', newWindow: true });
161
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true });
162
+ const send = (method, params) => cdp.send(method, params, sessionId);
163
+ const run = async (expression) => {
164
+ const r = await send('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true });
165
+ if (r.exceptionDetails)
166
+ throw new Error(`Runtime.evaluate: ${r.exceptionDetails.exception?.description ?? r.exceptionDetails.text}`);
167
+ return r.result.value;
168
+ };
169
+ await send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: o.scale, mobile: false });
170
+ await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
171
+ // A snap browser has a private /tmp and cannot read the temp folder.
172
+ const { frameTree } = await send('Page.getFrameTree');
173
+ await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
174
+ // The packet moves with SMIL, which only the <svg> element controls.
175
+ const info = await run(`(async () => {
176
+ await document.fonts.ready;
177
+ const svg = document.querySelector('svg');
178
+ const css = document.getAnimations();
179
+ for (const a of css) a.pause();
180
+ svg.pauseAnimations();
181
+ let loop = 0;
182
+ for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
183
+ for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
184
+ try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
185
+ const r = svg.getBoundingClientRect();
186
+ return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
187
+ })()`);
188
+ return { send, run, info };
169
189
  };
170
- await send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: o.scale, mobile: false });
171
- // The figure picks its theme with a media query, so the run sets the query and leaves the SVG as it is.
172
- await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
173
- // The page goes in as content, not as a file: a snap browser has a private /tmp and cannot read the temp folder.
174
- const { frameTree } = await send('Page.getFrameTree');
175
- await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
176
- // CSS animations are in getAnimations(). The packet moves with SMIL, which only the <svg> element controls.
177
- const info = await run(`(async () => {
178
- await document.fonts.ready;
179
- const svg = document.querySelector('svg');
180
- const css = document.getAnimations();
181
- for (const a of css) a.pause();
182
- svg.pauseAnimations();
183
- let loop = 0;
184
- for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
185
- for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
186
- try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
187
- const r = svg.getBoundingClientRect();
188
- return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
189
- })()`);
190
+ const first = await open();
191
+ const { info } = first;
190
192
  const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
191
193
  const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
192
- const pngs = [];
193
- for (let i = 0; i < n; i++) {
194
- const t = (i * 1000) / o.fps;
195
- await run(`(() => {
196
- for (const a of document.getAnimations()) a.currentTime = ${t};
197
- document.querySelector('svg').setCurrentTime(${t / 1000});
198
- // two frames: one frame can fire before the browser draws the new time under load
199
- return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
200
- })()`);
201
- const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
202
- pngs.push(Buffer.from(data, 'base64'));
203
- }
194
+ // availableParallelism arrived in Node 18.14; the package supports Node 18.0.
195
+ const cores = os.availableParallelism?.() ?? os.cpus().length;
196
+ const count = Math.max(1, Math.min(MAX_TABS, cores, Math.floor(n / MIN_FRAMES_PER_TAB)));
197
+ const tabs = [first, ...(await Promise.all(Array.from({ length: count - 1 }, open)))];
198
+ const pngs = Array.from({ length: n });
199
+ await Promise.all(tabs.map(async ({ send, run }, k) => {
200
+ for (let i = k; i < n; i += count) {
201
+ const t = (i * 1000) / o.fps;
202
+ await run(`(() => {
203
+ for (const a of document.getAnimations()) a.currentTime = ${t};
204
+ document.querySelector('svg').setCurrentTime(${t / 1000});
205
+ // One frame can fire before the browser draws the new time under load.
206
+ return new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r)));
207
+ })()`);
208
+ const { data } = await send('Page.captureScreenshot', { format: 'png', clip });
209
+ pngs[i] = Buffer.from(data, 'base64');
210
+ }
211
+ }));
204
212
  return { pngs, width: Math.round(info.width * o.scale), height: Math.round(info.height * o.scale), loopMs: info.loop };
205
213
  }
package/dist/check.d.ts CHANGED
@@ -1,14 +1,16 @@
1
- import { type FigTheme, type FlowProps } from './model.ts';
1
+ import { type FigTheme, type FlowProps, type LanePlan } from './model.ts';
2
2
  import type { Finding, Scene } from './scene.ts';
3
- /** Ids that point nowhere, ids used twice, steps with nothing in them. The renderers skip these without a word. */
3
+ import type { SvgOptions } from './svg.ts';
4
+ /** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
4
5
  export declare function checkSpec(fig: FlowProps): Finding[];
5
6
  export type CheckOptions = {
6
7
  width?: number;
7
8
  minText?: number;
8
9
  };
9
- /** Layout faults in what a renderer drew, and text that the target width shrinks below `minText`. */
10
10
  export declare function checkScene(scene: Scene, { width, minText }?: CheckOptions): Finding[];
11
11
  /** The WCAG contrast ratio of two colors, rounded to 2 places; null if either cannot be read. */
12
12
  export declare function contrast(a: string, b: string): number | null;
13
13
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
14
14
  export declare function checkTheme(theme?: FigTheme): Finding[];
15
+ export declare function planFor(fig: FlowProps, opts: SvgOptions): LanePlan | null;
16
+ export declare function checkRendered(fig: FlowProps, opts: SvgOptions & CheckOptions, scene: Scene): Finding[];
package/dist/check.js CHANGED
@@ -1,13 +1,12 @@
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
- // 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, TONES, edgeId, isGroup, dayOf, isLanesLayout, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
1
+ import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
2
+ import { layoutRail } from './rail.js';
4
3
  import { crosses } from './geometry.js';
5
4
  import { textWidth } from './text.js';
6
5
  import { owners, parseSource } from './source.js';
7
6
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
8
7
  const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
9
8
  const groupIds = (g) => [...(g.id ? [g.id] : []), ...g.children.flatMap((c) => (isGroup(c) ? groupIds(c) : []))];
10
- /** Ids that point nowhere, ids used twice, steps with nothing in them. The renderers skip these without a word. */
9
+ /** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
11
10
  export function checkSpec(fig) {
12
11
  const out = [];
13
12
  const boxIds = nodes(fig.layout).map((n) => n.id);
@@ -45,10 +44,12 @@ export function checkSpec(fig) {
45
44
  if (e.quiet && !used.has(edges[i]))
46
45
  out.push(err('hidden-edge', [edges[i]], `edge "${edges[i]}" is quiet and no beat uses it, so the figure never shows it`));
47
46
  });
48
- // A link with a bad form never verifies, so say so here, where the spec is checked.
49
- for (const [who, source] of owners(fig))
47
+ for (const [who, source, via] of owners(fig)) {
50
48
  if (source != null && !parseSource(source))
51
49
  out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
50
+ if (via != null && (typeof via !== 'string' || !/^\S+$/.test(via)))
51
+ out.push(warn('bad-source', [], `${who}: via ${JSON.stringify(via)} is not a string of one word`));
52
+ }
52
53
  const marks = nodes(fig.layout).map((n) => n.mark);
53
54
  const starts = marks.filter((m) => m === 'start').length;
54
55
  const ends = marks.filter((m) => m === 'end').length;
@@ -72,7 +73,6 @@ export function checkSpec(fig) {
72
73
  if (f != null && t != null && t < f)
73
74
  out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
74
75
  }
75
- // A dependent item starts after its source ends. A milestone ends the day it starts.
76
76
  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))]]));
77
77
  for (const e of fig.edges) {
78
78
  const start = span.get(e.to)?.[0];
@@ -106,18 +106,16 @@ export function checkSpec(fig) {
106
106
  out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
107
107
  return out;
108
108
  }
109
- /** A point on the cubic Bézier at `t`. */
110
109
  const at = ([p0, p1, p2, p3], t) => {
111
110
  const u = 1 - t;
112
111
  const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
113
112
  return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
114
113
  };
115
114
  const lerp = (p, q, t) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
116
- // The margins keep a touch from counting: an edge leaves along a border, and pills sit edge to edge.
115
+ // Margins stop edges along a border and pills edge to edge from counting as touches.
117
116
  const inside = (p, r, pad) => p.x > r.x + pad && p.x < r.x + r.w - pad && p.y > r.y + pad && p.y < r.y + r.h - pad;
118
117
  const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x + a.w - pad && a.y + pad < b.y + b.h - pad && b.y + pad < a.y + a.h - pad;
119
118
  const px = (n) => Math.round(n * 10) / 10;
120
- /** Layout faults in what a renderer drew, and text that the target width shrinks below `minText`. */
121
119
  export function checkScene(scene, { width = 830, minText = 10 } = {}) {
122
120
  const out = [];
123
121
  for (const b of scene.boxes)
@@ -137,7 +135,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
137
135
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
138
136
  out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
139
137
  }
140
- // A stub pill (the label of an edge with `pts`) must not sit on another edge's path.
141
138
  const pills = scene.edges.filter((e) => e.pts && e.label);
142
139
  const under = new Set();
143
140
  for (const e of scene.edges) {
@@ -152,7 +149,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
152
149
  out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
153
150
  }
154
151
  }
155
- // A stub line (a segment of an edge with `pts`) must not cross the path of another edge.
156
152
  const path = (e) => e.pts
157
153
  ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
158
154
  : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
@@ -168,7 +164,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
168
164
  out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
169
165
  }
170
166
  }
171
- // A stub pill or an edge label sits inside one band: a pill across a band border reads as part of two lanes.
172
167
  for (const f of scene.edges.filter((e) => e.label))
173
168
  for (const l of scene.lanes ?? []) {
174
169
  const [p, r] = [f.label, l.rect];
@@ -192,7 +187,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
192
187
  out.push(warn('small-text', [], `at ${width} px the smallest text is ${px(shown)} px (minimum ${minText} px)`));
193
188
  return out;
194
189
  }
195
- /** Red, green and blue from a hex, rgb() or hsl() color; null for a CSS variable or a named color. */
196
190
  function rgb(color) {
197
191
  const s = color.trim().toLowerCase();
198
192
  let m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
@@ -234,11 +228,10 @@ const PAIRS = [
234
228
  ['fg', 'tint'],
235
229
  ['muted', 'tint'],
236
230
  ];
237
- /** 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. */
238
231
  function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
239
232
  const [a, s] = [rgb(color), rgb(base)];
240
233
  if (!a || !s)
241
- return base; // an unreadable color is reported by its own pairs
234
+ return base;
242
235
  return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
243
236
  }
244
237
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
@@ -267,7 +260,6 @@ export function checkTheme(theme = {}) {
267
260
  else if (r < 4.5)
268
261
  out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
269
262
  }
270
- // A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
271
263
  for (const [tname, tone] of Object.entries(TONES)) {
272
264
  for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
273
265
  for (const fg of ['fg', 'muted']) {
@@ -282,3 +274,18 @@ export function checkTheme(theme = {}) {
282
274
  out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
283
275
  return out;
284
276
  }
277
+ export function planFor(fig, opts) {
278
+ if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
279
+ return null;
280
+ const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
281
+ return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
282
+ }
283
+ export function checkRendered(fig, opts, scene) {
284
+ const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
285
+ rule: 'lane-end-block',
286
+ severity: 'warning',
287
+ ids: [id],
288
+ message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
289
+ }));
290
+ return [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
291
+ }