flowfig 0.5.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 +185 -114
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +72 -60
- package/dist/check.d.ts +5 -3
- package/dist/check.js +28 -21
- package/dist/cli.js +33 -47
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/geometry.d.ts +13 -5
- package/dist/geometry.js +164 -70
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +1 -4
- package/dist/guide.js +7 -4
- package/dist/index.d.ts +2 -2
- package/dist/index.js +69 -105
- package/dist/init.d.ts +3 -13
- package/dist/init.js +17 -19
- 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 +57 -108
- package/dist/model.js +21 -88
- 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 +9 -7
- package/dist/source.d.ts +0 -1
- package/dist/source.js +2 -2
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +81 -121
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +3 -6
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
import
|
|
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,26 +71,28 @@ 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
|
-
|
|
77
|
+
// Windows has no process group: taskkill /T stops the helpers.
|
|
78
|
+
if (process.platform === 'win32') {
|
|
79
|
+
if (child.pid)
|
|
80
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
79
81
|
child.kill('SIGKILL');
|
|
82
|
+
}
|
|
80
83
|
else if (child.pid)
|
|
81
84
|
process.kill(-child.pid, 'SIGKILL');
|
|
82
85
|
}
|
|
83
|
-
catch {
|
|
84
|
-
// the group is already gone
|
|
85
|
-
}
|
|
86
|
+
catch { }
|
|
86
87
|
};
|
|
87
88
|
const closed = new Promise((done) => {
|
|
88
89
|
const end = (e) => (stop(e), done());
|
|
89
90
|
const stopped = (code) => new Error(`the browser stopped (exit ${code}): ${tail.trim()}`);
|
|
90
91
|
child.once('error', end);
|
|
91
92
|
child.once('exit', (code) => {
|
|
92
|
-
out.destroy(); //
|
|
93
|
+
out.destroy(); // The write end of fd 3 does not close by itself.
|
|
93
94
|
killGroup();
|
|
94
|
-
setTimeout(() => end(stopped(code)), 1000).unref(); //
|
|
95
|
+
setTimeout(() => end(stopped(code)), 1000).unref(); // A pipe can stay open after the group kill.
|
|
95
96
|
});
|
|
96
97
|
child.once('close', (code) => end(stopped(code)));
|
|
97
98
|
});
|
|
@@ -119,7 +120,6 @@ export function launch(path, profile, timeoutMs = 30_000) {
|
|
|
119
120
|
if (start < chunk.length)
|
|
120
121
|
parts.push(chunk.subarray(start));
|
|
121
122
|
});
|
|
122
|
-
/** A promise that rejects after the timeout; `add` registers the waiter, `drop` removes it. */
|
|
123
123
|
const timed = (label, add, drop, ms = timeoutMs) => new Promise((resolve, reject) => {
|
|
124
124
|
if (dead)
|
|
125
125
|
return reject(dead);
|
|
@@ -142,7 +142,7 @@ export function launch(path, profile, timeoutMs = 30_000) {
|
|
|
142
142
|
return timed(event, (w) => events.push((me = { ...w, event, sessionId })), () => events.splice(events.indexOf(me), 1), ms);
|
|
143
143
|
},
|
|
144
144
|
};
|
|
145
|
-
//
|
|
145
|
+
// Chrome takes about 10 s to exit after Browser.close.
|
|
146
146
|
const close = async () => {
|
|
147
147
|
killGroup();
|
|
148
148
|
await closed;
|
|
@@ -152,50 +152,62 @@ export function launch(path, profile, timeoutMs = 30_000) {
|
|
|
152
152
|
throw e;
|
|
153
153
|
});
|
|
154
154
|
}
|
|
155
|
-
|
|
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;
|
|
156
158
|
export async function captureFrames(cdp, html, o) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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 };
|
|
165
189
|
};
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
await send('Emulation.setEmulatedMedia', { features: [{ name: 'prefers-color-scheme', value: o.dark ? 'dark' : 'light' }] });
|
|
169
|
-
// The page goes in as content, not as a file: a snap browser has a private /tmp and cannot read the temp folder.
|
|
170
|
-
const { frameTree } = await send('Page.getFrameTree');
|
|
171
|
-
await send('Page.setDocumentContent', { frameId: frameTree.frame.id, html });
|
|
172
|
-
// CSS animations are in getAnimations(). The packet moves with SMIL, which only the <svg> element controls.
|
|
173
|
-
const info = await run(`(async () => {
|
|
174
|
-
await document.fonts.ready;
|
|
175
|
-
const svg = document.querySelector('svg');
|
|
176
|
-
const css = document.getAnimations();
|
|
177
|
-
for (const a of css) a.pause();
|
|
178
|
-
svg.pauseAnimations();
|
|
179
|
-
let loop = 0;
|
|
180
|
-
for (const a of css) loop = Math.max(loop, Number(a.effect.getTiming().duration) || 0);
|
|
181
|
-
for (const m of svg.querySelectorAll('animate, animateMotion, animateTransform, set'))
|
|
182
|
-
try { loop = Math.max(loop, m.getSimpleDuration() * 1000); } catch {}
|
|
183
|
-
const r = svg.getBoundingClientRect();
|
|
184
|
-
return { loop, x: r.x, y: r.y, width: r.width, height: r.height };
|
|
185
|
-
})()`);
|
|
190
|
+
const first = await open();
|
|
191
|
+
const { info } = first;
|
|
186
192
|
const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
|
|
187
193
|
const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
+
}));
|
|
200
212
|
return { pngs, width: Math.round(info.width * o.scale), height: Math.round(info.height * o.scale), loopMs: info.loop };
|
|
201
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)
|
|
@@ -127,16 +122,16 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
127
122
|
out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
|
|
128
123
|
}
|
|
129
124
|
for (const e of scene.edges) {
|
|
130
|
-
if (e.behind)
|
|
125
|
+
if (e.behind && !e.elbow)
|
|
131
126
|
continue;
|
|
132
|
-
const
|
|
133
|
-
|
|
127
|
+
const line = e.elbow ?? e.pts;
|
|
128
|
+
const pts = line
|
|
129
|
+
? line.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(line[k], q, i / 16)))
|
|
134
130
|
: Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
135
131
|
for (const b of scene.boxes)
|
|
136
132
|
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|
|
137
133
|
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
|
|
138
134
|
}
|
|
139
|
-
// A stub pill (the label of an edge with `pts`) must not sit on another edge's path.
|
|
140
135
|
const pills = scene.edges.filter((e) => e.pts && e.label);
|
|
141
136
|
const under = new Set();
|
|
142
137
|
for (const e of scene.edges) {
|
|
@@ -151,7 +146,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
151
146
|
out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
|
|
152
147
|
}
|
|
153
148
|
}
|
|
154
|
-
// A stub line (a segment of an edge with `pts`) must not cross the path of another edge.
|
|
155
149
|
const path = (e) => e.pts
|
|
156
150
|
? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
|
|
157
151
|
: Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
@@ -167,13 +161,14 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
167
161
|
out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
|
|
168
162
|
}
|
|
169
163
|
}
|
|
170
|
-
|
|
171
|
-
for (const f of pills)
|
|
164
|
+
for (const f of scene.edges.filter((e) => e.label))
|
|
172
165
|
for (const l of scene.lanes ?? []) {
|
|
173
166
|
const [p, r] = [f.label, l.rect];
|
|
174
167
|
const inside = p.x >= r.x && p.x + p.w <= r.x + r.w && p.y >= r.y && p.y + p.h <= r.y + r.h;
|
|
175
168
|
if (!inside && overlap(p, r, 0))
|
|
176
|
-
out.push(err('label-overlap', [f.id],
|
|
169
|
+
out.push(err('label-overlap', [f.id], f.pts
|
|
170
|
+
? `the pill of edge "${f.id}" crosses the border of lane "${l.id}"`
|
|
171
|
+
: `the label of edge "${f.id}" crosses the border of lane "${l.id}"`));
|
|
177
172
|
}
|
|
178
173
|
const labeled = scene.edges.filter((e) => e.label);
|
|
179
174
|
labeled.forEach((e, i) => {
|
|
@@ -189,7 +184,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
189
184
|
out.push(warn('small-text', [], `at ${width} px the smallest text is ${px(shown)} px (minimum ${minText} px)`));
|
|
190
185
|
return out;
|
|
191
186
|
}
|
|
192
|
-
/** Red, green and blue from a hex, rgb() or hsl() color; null for a CSS variable or a named color. */
|
|
193
187
|
function rgb(color) {
|
|
194
188
|
const s = color.trim().toLowerCase();
|
|
195
189
|
let m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
@@ -231,11 +225,10 @@ const PAIRS = [
|
|
|
231
225
|
['fg', 'tint'],
|
|
232
226
|
['muted', 'tint'],
|
|
233
227
|
];
|
|
234
|
-
/** 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. */
|
|
235
228
|
function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
|
|
236
229
|
const [a, s] = [rgb(color), rgb(base)];
|
|
237
230
|
if (!a || !s)
|
|
238
|
-
return base;
|
|
231
|
+
return base;
|
|
239
232
|
return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
|
|
240
233
|
}
|
|
241
234
|
/** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
|
|
@@ -264,7 +257,6 @@ export function checkTheme(theme = {}) {
|
|
|
264
257
|
else if (r < 4.5)
|
|
265
258
|
out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
|
|
266
259
|
}
|
|
267
|
-
// A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
|
|
268
260
|
for (const [tname, tone] of Object.entries(TONES)) {
|
|
269
261
|
for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
|
|
270
262
|
for (const fg of ['fg', 'muted']) {
|
|
@@ -279,3 +271,18 @@ export function checkTheme(theme = {}) {
|
|
|
279
271
|
out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
|
|
280
272
|
return out;
|
|
281
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
|
-
//
|
|
232
|
-
rmSync(temp, { recursive: true, force: true, maxRetries:
|
|
205
|
+
// Windows can hold a file lock briefly after the exit, so retry.
|
|
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>;
|