flowfig 0.6.0 → 0.7.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
@@ -63,7 +63,7 @@ jobs:
63
63
  - uses: actions/setup-node@v4
64
64
  with:
65
65
  node-version: 22
66
- - uses: iamalvisng/flowfig@v0.6.0
66
+ - uses: iamalvisng/flowfig@v0.7.0
67
67
  with:
68
68
  figures: 'docs/**/*.svg' # default **/*.svg
69
69
  ```
@@ -450,7 +450,7 @@ jobs:
450
450
  - uses: actions/setup-node@v4
451
451
  with:
452
452
  node-version: 22
453
- - uses: iamalvisng/flowfig@v0.6.0
453
+ - uses: iamalvisng/flowfig@v0.7.0
454
454
  with:
455
455
  figures: 'docs/**/*.svg' # default **/*.svg
456
456
  ```
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,7 +44,6 @@ 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
47
  for (const [who, source] 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`));
@@ -72,7 +70,6 @@ export function checkSpec(fig) {
72
70
  if (f != null && t != null && t < f)
73
71
  out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
74
72
  }
75
- // A dependent item starts after its source ends. A milestone ends the day it starts.
76
73
  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
74
  for (const e of fig.edges) {
78
75
  const start = span.get(e.to)?.[0];
@@ -106,18 +103,16 @@ export function checkSpec(fig) {
106
103
  out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
107
104
  return out;
108
105
  }
109
- /** A point on the cubic Bézier at `t`. */
110
106
  const at = ([p0, p1, p2, p3], t) => {
111
107
  const u = 1 - t;
112
108
  const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
113
109
  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
110
  };
115
111
  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.
112
+ // Margins stop edges along a border and pills edge to edge from counting as touches.
117
113
  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
114
  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
115
  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
116
  export function checkScene(scene, { width = 830, minText = 10 } = {}) {
122
117
  const out = [];
123
118
  for (const b of scene.boxes)
@@ -137,7 +132,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
137
132
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
138
133
  out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
139
134
  }
140
- // A stub pill (the label of an edge with `pts`) must not sit on another edge's path.
141
135
  const pills = scene.edges.filter((e) => e.pts && e.label);
142
136
  const under = new Set();
143
137
  for (const e of scene.edges) {
@@ -152,7 +146,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
152
146
  out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
153
147
  }
154
148
  }
155
- // A stub line (a segment of an edge with `pts`) must not cross the path of another edge.
156
149
  const path = (e) => e.pts
157
150
  ? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
158
151
  : Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
@@ -168,7 +161,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
168
161
  out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
169
162
  }
170
163
  }
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
164
  for (const f of scene.edges.filter((e) => e.label))
173
165
  for (const l of scene.lanes ?? []) {
174
166
  const [p, r] = [f.label, l.rect];
@@ -192,7 +184,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
192
184
  out.push(warn('small-text', [], `at ${width} px the smallest text is ${px(shown)} px (minimum ${minText} px)`));
193
185
  return out;
194
186
  }
195
- /** Red, green and blue from a hex, rgb() or hsl() color; null for a CSS variable or a named color. */
196
187
  function rgb(color) {
197
188
  const s = color.trim().toLowerCase();
198
189
  let m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
@@ -234,11 +225,10 @@ const PAIRS = [
234
225
  ['fg', 'tint'],
235
226
  ['muted', 'tint'],
236
227
  ];
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
228
  function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
239
229
  const [a, s] = [rgb(color), rgb(base)];
240
230
  if (!a || !s)
241
- return base; // an unreadable color is reported by its own pairs
231
+ return base;
242
232
  return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
243
233
  }
244
234
  /** 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 +257,6 @@ export function checkTheme(theme = {}) {
267
257
  else if (r < 4.5)
268
258
  out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
269
259
  }
270
- // A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
271
260
  for (const [tname, tone] of Object.entries(TONES)) {
272
261
  for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
273
262
  for (const fg of ['fg', 'muted']) {
@@ -282,3 +271,18 @@ export function checkTheme(theme = {}) {
282
271
  out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
283
272
  return out;
284
273
  }
274
+ export function planFor(fig, opts) {
275
+ if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
276
+ return null;
277
+ const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
278
+ return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
279
+ }
280
+ export function checkRendered(fig, opts, scene) {
281
+ const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
282
+ rule: 'lane-end-block',
283
+ severity: 'warning',
284
+ ids: [id],
285
+ message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
286
+ }));
287
+ return [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
288
+ }
package/dist/cli.js CHANGED
@@ -7,29 +7,6 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  }
8
8
  return path;
9
9
  };
10
- /**
11
- * Render a figure as one animated SVG — for a README, a PR, an issue, a blog post — and check it first.
12
- *
13
- * flowfig - out.svg < spec.json # a spec on stdin: nothing is left on disk
14
- * flowfig spec.json out.svg # a spec file
15
- * flowfig figure.ts out.svg # a module whose default export is a spec
16
- * flowfig --spec out.svg # print back the spec the SVG carries
17
- * flowfig check <input> [--json] # list the faults; the input can also be an SVG this wrote
18
- * flowfig verify <input>... [--root dir] # check that the code each figure links to still exists
19
- * flowfig diff <old> <new> [--json|--md] # list the spec changes between two figures
20
- * flowfig docs # print the full guide (Markdown)
21
- * flowfig mcp # serve check, render, verify, diff and docs over MCP (stdio)
22
- * flowfig init [dir] # write flowfig instructions for the coding agents of a repo
23
- * flowfig draw "<question>" [--out out.svg] # ask Claude Code for a figure, then check it
24
- * flowfig open figure.svg # show the figure in the default browser
25
- * flowfig gif figure.svg [out.gif] # write an animated GIF of the figure (needs Chrome, Edge, Chromium or Brave)
26
- *
27
- * A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
28
- * --min-text set the page width and the smallest text the reader should get.
29
- * --open opens the rendered SVG in the default browser (render and draw).
30
- *
31
- * Every rendered SVG carries its own spec in <metadata>, so a figure is editable later without anyone having to keep the JSON.
32
- */
33
10
  import { spawn, spawnSync } from 'node:child_process';
34
11
  import { existsSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
35
12
  import { tmpdir } from 'node:os';
@@ -46,7 +23,8 @@ import { pageHtml } from './page.js';
46
23
  import { decodePng } from './png.js';
47
24
  import { diff, formatDiff } from './diff.js';
48
25
  import { loadSpec, reportLines, sortFindings, specOf, svgWithSpec } from './load.js';
49
- import { check, toSvg } from './svg.js';
26
+ import { check, render, toSvg } from './svg.js';
27
+ import { checkRendered } from './check.js';
50
28
  import { links, verify } from './verify.js';
51
29
  const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
52
30
  flowfig check <-|spec.json|figure.ts|figure.svg> list the faults; the input can be an SVG this wrote
@@ -60,7 +38,6 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
60
38
  flowfig open <figure.svg> [--html <path>] show the figure in the default browser
61
39
  flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
62
40
  flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
63
- /** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
64
41
  function usage(message) {
65
42
  console.error(message);
66
43
  process.exit(2);
@@ -90,7 +67,7 @@ if (args[0] === 'docs') {
90
67
  }
91
68
  if (args[0] === 'mcp') {
92
69
  await serve(process.stdin, process.stdout);
93
- // A pipe write is asynchronous: wait until the queued response lines are out before the exit.
70
+ // A pipe write is asynchronous: wait for the queued lines before the exit.
94
71
  await new Promise((done) => process.stdout.write('', () => done(undefined)));
95
72
  process.exit(0);
96
73
  }
@@ -118,7 +95,6 @@ if (args[0] === '--spec') {
118
95
  }
119
96
  process.exit(0);
120
97
  }
121
- /** A spec from stdin, a JSON file, an SVG this wrote, or a module. Exit 2 with a message on any failure. */
122
98
  async function load(input) {
123
99
  try {
124
100
  if (input.endsWith('.json') || input.endsWith('.svg'))
@@ -195,7 +171,6 @@ if (args[0] === 'gif') {
195
171
  usage(`${path}: ${e.message}`);
196
172
  }
197
173
  if (step !== undefined) {
198
- // One step only: render the spec again with that step, so the loop holds that step alone.
199
174
  const props = (() => {
200
175
  try {
201
176
  return loadSpec(path);
@@ -222,24 +197,22 @@ if (args[0] === 'gif') {
222
197
  usage(['gif needs Chrome, Edge, Chromium or Brave. Checked:', ...found.checked, 'Set CHROME_PATH to the browser program.'].join('\n'));
223
198
  const line = (file, frames, bytes) => `${file} — ${frames} frame${frames === 1 ? '' : 's'}, ${(frames / fps).toFixed(1)} s, ${(bytes / 1048576).toFixed(1)} MB`;
224
199
  const temp = mkdtempSync(join(tmpdir(), 'flowfig-gif-'));
225
- // A Ctrl-C can come while the browser starts, so cleanup waits for the start before it stops the browser.
226
200
  let launching;
227
201
  let cleaning;
228
202
  const cleanup = () => (cleaning ??= (async () => {
229
203
  await (await launching?.catch(() => undefined))?.close();
230
204
  try {
231
- // The retries cover Windows, where the browser can hold a file lock for a short time after the exit.
205
+ // Windows can hold a file lock briefly after the exit, so retry.
232
206
  rmSync(temp, { recursive: true, force: true, maxRetries: 10 });
233
207
  }
234
208
  catch (e) {
235
209
  console.error(`warning: could not remove ${temp}: ${e.message}`);
236
210
  }
237
211
  })());
238
- // The output files of this run: a stop removes them, so no partial file stays.
239
212
  const written = [];
240
213
  let ffmpeg, ffmpegDone = Promise.resolve(), stopped = 0;
241
214
  const yieldLoop = () => new Promise((done) => setImmediate(done));
242
- // The handlers stay for the whole run: a second signal during the cleanup must not end the process before the cleanup ends.
215
+ // Keep the handlers: a second signal must not end the process mid-cleanup.
243
216
  const onSignal = (signal) => {
244
217
  if (stopped)
245
218
  return;
@@ -252,7 +225,7 @@ if (args[0] === 'gif') {
252
225
  };
253
226
  process.on('SIGINT', onSignal);
254
227
  process.on('SIGTERM', onSignal);
255
- // The encode and the ffmpeg run give the event loop a turn, so a signal handler can run. A write comes straight after this check.
228
+ // A write comes right after this check, so a signal must run its handler first.
256
229
  const proceed = async () => {
257
230
  await yieldLoop();
258
231
  if (stopped)
@@ -265,7 +238,7 @@ if (args[0] === 'gif') {
265
238
  const viewport = { width: Math.ceil(Number(size[1])), height: Math.ceil(Number(size[2])) };
266
239
  const { pngs } = await captureFrames(browser.cdp, pageHtml(svg, basename(path)), { ...viewport, scale, fps, dark });
267
240
  const wait = delays(pngs.length, fps);
268
- // The GIF keeps the PNG bytes, not the pixels: a decoded 2400 x 1600 frame is 15 MB.
241
+ // The GIF keeps the PNG bytes: a decoded 2400 x 1600 frame is 15 MB.
269
242
  const gif = await encodeGif(pngs.map((png, i) => ({ load: () => decodePng(png), delay: wait[i], same: i > 0 && png.equals(pngs[i - 1]) })));
270
243
  await proceed();
271
244
  written.push(out);
@@ -275,21 +248,37 @@ if (args[0] === 'gif') {
275
248
  console.error(`warning: ${out} is ${(gif.length / 1048576).toFixed(1)} MB, over 10 MB. Try --step <n>, a lower --fps or a lower --scale.`);
276
249
  if (mp4) {
277
250
  const mp4Path = out.replace(/(\.gif)?$/, '.mp4');
278
- // FLOWFIG_FFMPEG replaces the PATH lookup, so a test can hide ffmpeg and keep the full PATH.
279
251
  const ffmpegBin = process.env.FLOWFIG_FFMPEG ?? 'ffmpeg';
280
252
  if (spawnSync(ffmpegBin, ['-version']).error)
281
253
  console.error('gif: no ffmpeg on the PATH, so no MP4. The GIF is written.');
282
254
  else {
283
255
  await proceed();
284
256
  written.push(mp4Path);
285
- // yuv420p needs an even width and height, so the pad filter adds a pixel where needed.
257
+ // yuv420p needs an even width and height, so the pad filter adds a pixel.
286
258
  ffmpeg = spawn(ffmpegBin, [
287
- ...['-y', '-loglevel', 'error', '-f', 'image2pipe', '-framerate', String(fps), '-c:v', 'png', '-i', '-'],
288
- ...['-vf', 'pad=ceil(iw/2)*2:ceil(ih/2)*2', '-c:v', 'libx264', '-pix_fmt', 'yuv420p', mp4Path],
259
+ '-y',
260
+ '-loglevel',
261
+ 'error',
262
+ '-f',
263
+ 'image2pipe',
264
+ '-framerate',
265
+ String(fps),
266
+ '-c:v',
267
+ 'png',
268
+ '-i',
269
+ '-',
270
+ '-vf',
271
+ 'pad=ceil(iw/2)*2:ceil(ih/2)*2',
272
+ '-c:v',
273
+ 'libx264',
274
+ '-pix_fmt',
275
+ 'yuv420p',
276
+ mp4Path,
289
277
  ]);
290
278
  let stderr = '';
291
279
  ffmpeg.stderr.on('data', (d) => (stderr += d));
292
- ffmpeg.stdin.on('error', () => { }); // ffmpeg can exit before it reads all the input; the exit code reports that
280
+ // ffmpeg can exit early; the exit code reports that.
281
+ ffmpeg.stdin.on('error', () => { });
293
282
  ffmpeg.stdin.end(Buffer.concat(pngs));
294
283
  const status = await (ffmpegDone = new Promise((done) => {
295
284
  ffmpeg.once('error', () => done(-1));
@@ -312,7 +301,7 @@ if (args[0] === 'gif') {
312
301
  finally {
313
302
  await cleanup();
314
303
  }
315
- // A signal that came in the last turn runs its handler here, before the exit.
304
+ // A late signal runs its handler in this await, before the exit.
316
305
  await yieldLoop();
317
306
  if (stopped)
318
307
  written.forEach((f) => rmSync(f, { force: true }));
@@ -330,12 +319,10 @@ const [input, out] = args;
330
319
  if (!input)
331
320
  usage(USAGE);
332
321
  const props = await load(input);
333
- const findings = skip ? [] : sortFindings(check(props, opts), strict);
322
+ const rendered = skip ? undefined : render(props, opts);
323
+ const findings = rendered ? sortFindings(checkRendered(props, opts, rendered.scene), strict) : [];
334
324
  const errors = findings.filter((f) => f.severity === 'error').length;
335
- /** The findings for a person: one line each, colored only on a terminal, then the counts. A render prints them to stderr, next to
336
- * its own output. A clean render still prints `0 errors, 0 warnings`: the agent copies that line into its reply. */
337
325
  const report = (print, tty) => {
338
- // The severity field is 8 characters wide, so the painted field keeps the same width.
339
326
  const paint = (s) => tty && !process.env.NO_COLOR ? s.replace(/^(error {3}|warning )/, (w) => `\x1b[${w[0] === 'e' ? 31 : 33}m${w}\x1b[0m`) : s;
340
327
  for (const line of reportLines(props, findings, !skip))
341
328
  print(paint(line));
@@ -351,8 +338,7 @@ report(console.error, process.stderr.isTTY);
351
338
  if (errors)
352
339
  process.exit(1);
353
340
  const dest = out ?? (input === '-' ? 'figure.svg' : input.replace(/\.[^./\\]+$/, '') + '.svg');
354
- // The spec rides along in <metadata>: an SVG is then its own source, and no JSON has to be kept.
355
- const withSpec = svgWithSpec(props, opts);
341
+ const withSpec = svgWithSpec(props, opts, rendered?.svg);
356
342
  writeFileSync(dest, withSpec);
357
343
  console.log(`${dest} — ${(withSpec.length / 1024).toFixed(1)} kB`);
358
344
  if (open) {
package/dist/diff.js CHANGED
@@ -1,9 +1,6 @@
1
- // The spec diff: what a PR changed in a figure, by id. The SVG geometry follows from the spec, so the spec is the diff.
2
1
  import { edgeId, isGroup, str, toBeat } from './model.js';
3
2
  const show = (v) => (v == null ? '(none)' : `"${str(v)}"`);
4
- /** The fields that changed, as `name "old" -> "new"` parts; empty when equal. */
5
3
  const fields = (a, b, names) => names.filter((k) => str(a[k]) !== str(b[k])).map((k) => `${String(k)} ${show(a[k])} -> ${show(b[k])}`);
6
- /** Compare two maps by key. The order is stable: changed first, then removed, then added. */
7
4
  function compare(kind, before, after, fieldsOf) {
8
5
  const out = [];
9
6
  for (const [id, a] of before) {
@@ -28,7 +25,6 @@ const messages = (fig) => new Map((fig.steps ?? [])
28
25
  export function diff(before, after) {
29
26
  const boxes = (f) => {
30
27
  const m = new Map();
31
- // The group is its label, else its id; the top level has none.
32
28
  const walk = (g, parent) => g.children.forEach((c) => (isGroup(c) ? walk(c, c.label != null ? str(c.label) : c.id) : m.set(c.id, { ...c, group: parent })));
33
29
  walk(f.layout);
34
30
  return m;
package/dist/draw.d.ts CHANGED
@@ -1,6 +1,5 @@
1
- /** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
1
+ /** A file name from the question: at most 60 characters. */
2
2
  export declare function slug(question: string): string;
3
- /** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
4
3
  export declare function agentArgs(o: {
5
4
  question: string;
6
5
  out: string;
@@ -8,5 +7,4 @@ export declare function agentArgs(o: {
8
7
  maxTurns: number;
9
8
  model?: string;
10
9
  }): string[];
11
- /** Run `flowfig draw`. Prints, and returns the exit code. */
12
10
  export declare function runDraw(argv: string[]): Promise<number>;