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 +2 -2
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +20 -16
- package/dist/cli.js +32 -46
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +0 -3
- package/dist/guide.js +0 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +9 -12
- package/dist/model.d.ts +52 -109
- package/dist/model.js +20 -89
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +8 -8
- package/dist/source.d.ts +0 -1
- package/dist/source.js +2 -2
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +1 -4
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
48
|
+
// GPU raster gave different pixels run to run on rounded corners.
|
|
50
49
|
'--disable-gpu',
|
|
51
|
-
// A
|
|
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
|
|
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
|
-
//
|
|
58
|
+
// Not on Windows: a process group there opens a console window.
|
|
60
59
|
detached: process.platform !== 'win32',
|
|
61
60
|
});
|
|
62
|
-
// The browser reads
|
|
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', () => { });
|
|
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
|
-
//
|
|
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
|
|
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(); //
|
|
93
|
+
out.destroy(); // The write end of fd 3 does not close by itself.
|
|
97
94
|
killGroup();
|
|
98
|
-
setTimeout(() => end(stopped(code)), 1000).unref(); //
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
171
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2
|
-
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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;
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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
|
|
257
|
+
// yuv420p needs an even width and height, so the pad filter adds a pixel.
|
|
286
258
|
ffmpeg = spawn(ffmpegBin, [
|
|
287
|
-
|
|
288
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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>;
|