flowfig 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -8
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +24 -17
- package/dist/cli.js +43 -49
- package/dist/code.d.ts +26 -0
- package/dist/code.js +568 -0
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/edges.d.ts +7 -0
- package/dist/edges.js +510 -0
- 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 +1 -4
- package/dist/guide.js +10 -11
- package/dist/imports.d.ts +11 -0
- package/dist/imports.js +235 -0
- 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 +15 -15
- package/dist/model.d.ts +57 -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 +1 -2
- package/dist/source.js +5 -5
- 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/type-index.d.ts +55 -0
- package/dist/type-index.js +383 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +733 -0
- package/dist/verify.d.ts +26 -4
- package/dist/verify.js +68 -26
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -22,7 +22,7 @@ A diagram in a repo drifts from the code. Someone renames a function, and the di
|
|
|
22
22
|
check fails, so no one sees the drift.
|
|
23
23
|
|
|
24
24
|
flowfig links each part of a diagram to the code that it draws. `flowfig verify` fails when a linked file or symbol is
|
|
25
|
-
gone. Run `verify` in CI, and a stale diagram fails the pull request.
|
|
25
|
+
gone, and warns when the code does not make an edge. Run `verify` in CI, and a stale diagram fails the pull request.
|
|
26
26
|
|
|
27
27
|
## Fail CI when the diagram drifts
|
|
28
28
|
|
|
@@ -36,12 +36,14 @@ Rename `verifyPassword` to `checkPassword`. Then `verify` fails with exit code 1
|
|
|
36
36
|
|
|
37
37
|
```console
|
|
38
38
|
$ npx flowfig verify login.svg
|
|
39
|
-
error missing-symbol login.svg: box "verify" -> src/auth/login.ts#verifyPassword: symbol not
|
|
39
|
+
error missing-symbol login.svg: box "verify" -> src/auth/login.ts#verifyPassword: symbol not defined
|
|
40
40
|
1 error, 0 warnings
|
|
41
|
+
login.svg: 0 of 1 boxes defined
|
|
41
42
|
```
|
|
42
43
|
|
|
43
|
-
`verify` checks that each linked file exists and that the symbol
|
|
44
|
-
name a heading, so a process diagram can follow its SOP
|
|
44
|
+
`verify` checks that each linked file exists and that it defines the symbol. It also checks each edge against the code
|
|
45
|
+
(see [Verify in CI](#verify-in-ci)). A link to a Markdown file can name a heading, so a process diagram can follow its SOP
|
|
46
|
+
document.
|
|
45
47
|
|
|
46
48
|
The GitHub Action runs `verify` on every figure in a pull request. For each SVG that the PR changes, it comments the old
|
|
47
49
|
image, the new image and the spec changes. It also names each figure whose linked code the PR changes.
|
|
@@ -63,7 +65,7 @@ jobs:
|
|
|
63
65
|
- uses: actions/setup-node@v4
|
|
64
66
|
with:
|
|
65
67
|
node-version: 22
|
|
66
|
-
- uses: iamalvisng/flowfig@v0.
|
|
68
|
+
- uses: iamalvisng/flowfig@v0.8.0
|
|
67
69
|
with:
|
|
68
70
|
figures: 'docs/**/*.svg' # default **/*.svg
|
|
69
71
|
```
|
|
@@ -252,7 +254,7 @@ Each SVG from the CLI carries its own spec. These commands made the rail-only fi
|
|
|
252
254
|
npx flowfig --spec docs/checkout.svg > checkout.json # print the spec in the SVG
|
|
253
255
|
# edit checkout.json: set "props.rail" to "only"
|
|
254
256
|
npx flowfig checkout.json docs/checkout-rail-only.svg
|
|
255
|
-
npx flowfig verify docs/checkout.svg # check
|
|
257
|
+
npx flowfig verify docs/checkout.svg # check each source and each edge against the code
|
|
256
258
|
npx flowfig diff old.svg docs/checkout.svg # list what changed in the spec
|
|
257
259
|
```
|
|
258
260
|
|
|
@@ -431,6 +433,23 @@ fails when the file or the symbol is gone. The action runs `verify` on every fig
|
|
|
431
433
|
the new image for each SVG the PR changes, with the spec changes as a list, and it names each figure whose linked code the PR
|
|
432
434
|
changes.
|
|
433
435
|
|
|
436
|
+
`verify` prints one count line for each figure, and gives each edge one result:
|
|
437
|
+
|
|
438
|
+
```console
|
|
439
|
+
docs/login.svg: 7 of 7 boxes defined; edges: 9 found, 0 not found, 1 unsure, 1 not checked
|
|
440
|
+
```
|
|
441
|
+
|
|
442
|
+
- found: the caller code calls or references the callee through an import, the same file or a typed receiver.
|
|
443
|
+
- not found: the caller code does not. `verify` warns, and `--strict` makes it an error.
|
|
444
|
+
- unsure: `verify` cannot decide, for example when a receiver has no declared type. `verify` lists it with the reason. It never fails CI.
|
|
445
|
+
- not checked: the edge has no `source`, or the language is not supported.
|
|
446
|
+
|
|
447
|
+
A method source is `path#Owner.name`. An edge `source` names the function that makes the call.
|
|
448
|
+
|
|
449
|
+
`via` on an edge names the route, queue, topic, table, file or key that both sides use. For a `via` edge, found means that the caller code and the callee file both use that token. It does not prove that a handler serves it.
|
|
450
|
+
|
|
451
|
+
Edge checks support TypeScript/JavaScript, Python, Go, Java, C# and Rust. Other files keep the name check for boxes.
|
|
452
|
+
|
|
434
453
|
A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-or-reject"`. The symbol is the heading as a GitHub anchor. If the heading is gone, `verify` fails.
|
|
435
454
|
|
|
436
455
|
```yaml
|
|
@@ -450,7 +469,7 @@ jobs:
|
|
|
450
469
|
- uses: actions/setup-node@v4
|
|
451
470
|
with:
|
|
452
471
|
node-version: 22
|
|
453
|
-
- uses: iamalvisng/flowfig@v0.
|
|
472
|
+
- uses: iamalvisng/flowfig@v0.8.0
|
|
454
473
|
with:
|
|
455
474
|
figures: 'docs/**/*.svg' # default **/*.svg
|
|
456
475
|
```
|
|
@@ -523,7 +542,8 @@ writeFileSync('first.svg', toSvg(spec));
|
|
|
523
542
|
- `check(spec, options)` returns the findings. It also takes `width` and `minText`.
|
|
524
543
|
- `render(spec, options)` returns `{ svg, scene }`. The scene is the layout that the check reads. Its shape can change.
|
|
525
544
|
|
|
526
|
-
`flowfig/verify` is for Node only. It exports `verify`, `links`, `owners` and `parseSource`, the same checks
|
|
545
|
+
`flowfig/verify` is for Node only. It exports `verify`, `verifyReport` (the findings and the counts), `links`, `owners` and `parseSource`, the same checks
|
|
546
|
+
as `flowfig verify`.
|
|
527
547
|
|
|
528
548
|
`toSvg` does not put the spec in the SVG. Only the CLI adds the spec, which `--spec` reads back.
|
|
529
549
|
|
package/dist/browser.d.ts
CHANGED
|
@@ -2,7 +2,7 @@ export type Found = {
|
|
|
2
2
|
path: string | null;
|
|
3
3
|
checked: string[];
|
|
4
4
|
};
|
|
5
|
-
/** The first capture browser that exists. `CHROME_PATH` wins.
|
|
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,10 +44,12 @@ export function checkSpec(fig) {
|
|
|
45
44
|
if (e.quiet && !used.has(edges[i]))
|
|
46
45
|
out.push(err('hidden-edge', [edges[i]], `edge "${edges[i]}" is quiet and no beat uses it, so the figure never shows it`));
|
|
47
46
|
});
|
|
48
|
-
|
|
49
|
-
for (const [who, source] of owners(fig))
|
|
47
|
+
for (const [who, source, via] of owners(fig)) {
|
|
50
48
|
if (source != null && !parseSource(source))
|
|
51
49
|
out.push(warn('bad-source', [], `${who}: source "${source}" is not path or path#symbol`));
|
|
50
|
+
if (via != null && (typeof via !== 'string' || !/^\S+$/.test(via)))
|
|
51
|
+
out.push(warn('bad-source', [], `${who}: via ${JSON.stringify(via)} is not a string of one word`));
|
|
52
|
+
}
|
|
52
53
|
const marks = nodes(fig.layout).map((n) => n.mark);
|
|
53
54
|
const starts = marks.filter((m) => m === 'start').length;
|
|
54
55
|
const ends = marks.filter((m) => m === 'end').length;
|
|
@@ -72,7 +73,6 @@ export function checkSpec(fig) {
|
|
|
72
73
|
if (f != null && t != null && t < f)
|
|
73
74
|
out.push(err('bad-date', [n.id], `box "${n.id}": to ${n.to} is before from ${n.from}`));
|
|
74
75
|
}
|
|
75
|
-
// A dependent item starts after its source ends. A milestone ends the day it starts.
|
|
76
76
|
const span = new Map(nodes(fig.layout).map((n) => [n.id, [n.from && dayOf(n.from), (n.to && dayOf(n.to)) ?? (n.from && dayOf(n.from))]]));
|
|
77
77
|
for (const e of fig.edges) {
|
|
78
78
|
const start = span.get(e.to)?.[0];
|
|
@@ -106,18 +106,16 @@ export function checkSpec(fig) {
|
|
|
106
106
|
out.push(err('bad-at', [n.id], `box "${n.id}": at ${n.at} is not an integer of 0 or more`));
|
|
107
107
|
return out;
|
|
108
108
|
}
|
|
109
|
-
/** A point on the cubic Bézier at `t`. */
|
|
110
109
|
const at = ([p0, p1, p2, p3], t) => {
|
|
111
110
|
const u = 1 - t;
|
|
112
111
|
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
|
113
112
|
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
|
114
113
|
};
|
|
115
114
|
const lerp = (p, q, t) => ({ x: p.x + (q.x - p.x) * t, y: p.y + (q.y - p.y) * t });
|
|
116
|
-
//
|
|
115
|
+
// Margins stop edges along a border and pills edge to edge from counting as touches.
|
|
117
116
|
const inside = (p, r, pad) => p.x > r.x + pad && p.x < r.x + r.w - pad && p.y > r.y + pad && p.y < r.y + r.h - pad;
|
|
118
117
|
const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x + a.w - pad && a.y + pad < b.y + b.h - pad && b.y + pad < a.y + a.h - pad;
|
|
119
118
|
const px = (n) => Math.round(n * 10) / 10;
|
|
120
|
-
/** Layout faults in what a renderer drew, and text that the target width shrinks below `minText`. */
|
|
121
119
|
export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
122
120
|
const out = [];
|
|
123
121
|
for (const b of scene.boxes)
|
|
@@ -137,7 +135,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
137
135
|
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|
|
138
136
|
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
|
|
139
137
|
}
|
|
140
|
-
// A stub pill (the label of an edge with `pts`) must not sit on another edge's path.
|
|
141
138
|
const pills = scene.edges.filter((e) => e.pts && e.label);
|
|
142
139
|
const under = new Set();
|
|
143
140
|
for (const e of scene.edges) {
|
|
@@ -152,7 +149,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
152
149
|
out.push(err('label-overlap', [e.id, f.id], `edge "${e.id}" passes under the pill of edge "${f.id}"`));
|
|
153
150
|
}
|
|
154
151
|
}
|
|
155
|
-
// A stub line (a segment of an edge with `pts`) must not cross the path of another edge.
|
|
156
152
|
const path = (e) => e.pts
|
|
157
153
|
? e.pts.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(e.pts[k], q, i / 16)))
|
|
158
154
|
: Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
@@ -168,7 +164,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
168
164
|
out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
|
|
169
165
|
}
|
|
170
166
|
}
|
|
171
|
-
// A stub pill or an edge label sits inside one band: a pill across a band border reads as part of two lanes.
|
|
172
167
|
for (const f of scene.edges.filter((e) => e.label))
|
|
173
168
|
for (const l of scene.lanes ?? []) {
|
|
174
169
|
const [p, r] = [f.label, l.rect];
|
|
@@ -192,7 +187,6 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
192
187
|
out.push(warn('small-text', [], `at ${width} px the smallest text is ${px(shown)} px (minimum ${minText} px)`));
|
|
193
188
|
return out;
|
|
194
189
|
}
|
|
195
|
-
/** Red, green and blue from a hex, rgb() or hsl() color; null for a CSS variable or a named color. */
|
|
196
190
|
function rgb(color) {
|
|
197
191
|
const s = color.trim().toLowerCase();
|
|
198
192
|
let m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
@@ -234,11 +228,10 @@ const PAIRS = [
|
|
|
234
228
|
['fg', 'tint'],
|
|
235
229
|
['muted', 'tint'],
|
|
236
230
|
];
|
|
237
|
-
/** A color mixed over a base at pct %, per channel like the CSS color-mix. The default is the active-box tint: 10 % accent over the surface. */
|
|
238
231
|
function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
|
|
239
232
|
const [a, s] = [rgb(color), rgb(base)];
|
|
240
233
|
if (!a || !s)
|
|
241
|
-
return base;
|
|
234
|
+
return base;
|
|
242
235
|
return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
|
|
243
236
|
}
|
|
244
237
|
/** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
|
|
@@ -267,7 +260,6 @@ export function checkTheme(theme = {}) {
|
|
|
267
260
|
else if (r < 4.5)
|
|
268
261
|
out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
|
|
269
262
|
}
|
|
270
|
-
// A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
|
|
271
263
|
for (const [tname, tone] of Object.entries(TONES)) {
|
|
272
264
|
for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
|
|
273
265
|
for (const fg of ['fg', 'muted']) {
|
|
@@ -282,3 +274,18 @@ export function checkTheme(theme = {}) {
|
|
|
282
274
|
out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
|
|
283
275
|
return out;
|
|
284
276
|
}
|
|
277
|
+
export function planFor(fig, opts) {
|
|
278
|
+
if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
|
|
279
|
+
return null;
|
|
280
|
+
const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
|
|
281
|
+
return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
|
|
282
|
+
}
|
|
283
|
+
export function checkRendered(fig, opts, scene) {
|
|
284
|
+
const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
|
|
285
|
+
rule: 'lane-end-block',
|
|
286
|
+
severity: 'warning',
|
|
287
|
+
ids: [id],
|
|
288
|
+
message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
|
|
289
|
+
}));
|
|
290
|
+
return [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
|
|
291
|
+
}
|