flowfig 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -65,7 +65,7 @@ jobs:
65
65
  - uses: actions/setup-node@v4
66
66
  with:
67
67
  node-version: 22
68
- - uses: iamalvisng/flowfig@v0.11.0
68
+ - uses: iamalvisng/flowfig@v0.12.0
69
69
  with:
70
70
  figures: 'docs/**/*.svg' # default **/*.svg
71
71
  # coverage: true # also fail the job if a figure is stale
@@ -120,7 +120,7 @@ the run.
120
120
 
121
121
  1. **The agent writes JSON, not pictures.** The agent reads the code and writes a spec of the parts, the calls and their
122
122
  order. flowfig does the layout.
123
- 2. **flowfig checks the spec.** `flowfig check` has 26 rules. It finds ids that point nowhere, text wider than its box,
123
+ 2. **flowfig checks the spec.** `flowfig check` has 29 rules. It finds ids that point nowhere, text wider than its box,
124
124
  edges through boxes, overlapping labels and low contrast. A render runs the same check and writes nothing on an error.
125
125
  The agent reads the faults and fixes the spec.
126
126
  3. **The output is one animated SVG with no script.** The SVG plays in a GitHub README, PR or issue. It follows the light
@@ -128,6 +128,8 @@ the run.
128
128
 
129
129
  ## Forms
130
130
 
131
+ To explain a flow one moment at a time in a web page, see [Explain a flow in a web page](#explain-a-flow-in-a-web-page).
132
+
131
133
  The map (the default) shows the parts and the messages between them:
132
134
 
133
135
  ![A request that misses the cache, then hits the cache](docs/cached-request.svg)
@@ -156,12 +158,16 @@ A state lifecycle marks the first state with `mark: "start"` and each final stat
156
158
 
157
159
  ![An order status lifecycle with a start dot and two end rings](docs/order-status.svg)
158
160
 
161
+ A box with inside parts opens to show them, down to 3 levels. See [Inside a box](#inside-a-box).
162
+
159
163
  ## Open and share
160
164
 
161
165
  `npx flowfig open figure.svg` shows the figure in your default browser.
162
166
 
163
167
  `npx flowfig gif figure.svg` writes an animated GIF for Slack, Notion, X and slides, where SVG animation does not play.
164
168
 
169
+ `npx flowfig frames figure.svg` writes one PNG sheet of the key frames, so a coding agent can read the motion in one image.
170
+
165
171
  The options are in [Open and share a figure](#open-and-share-a-figure).
166
172
 
167
173
  ## flowfig, Mermaid and a hand-drawn image
@@ -170,7 +176,7 @@ The options are in [Open and share a figure](#open-and-share-a-figure).
170
176
  | ------------------------------ | --------------------------------- | ------------------------------------ | ---------------- |
171
177
  | Made by an agent from the code | Yes, with the `init` instructions | Yes, as Mermaid text | No |
172
178
  | Linked to the code | Yes, `verify` fails in CI | No | No |
173
- | Checked for faults | Yes, 26 rules in `flowfig check` | Syntax errors only | No |
179
+ | Checked for faults | Yes, 29 rules in `flowfig check` | Syntax errors only | No |
174
180
  | Animated | Yes, one packet per message | No | No |
175
181
  | Shows payloads and order | Yes, on the map and the rail | Order and text in a sequence diagram | No |
176
182
  | Plays in a GitHub README | Yes, as an SVG | Yes, GitHub renders it | Yes, as an image |
@@ -193,6 +199,7 @@ The options are in [Open and share a figure](#open-and-share-a-figure).
193
199
  - [MCP server](#mcp-server)
194
200
  - [Use in React](#use-in-react)
195
201
  - [Use from Node](#use-from-node)
202
+ - [Explain a flow in a web page](#explain-a-flow-in-a-web-page)
196
203
  - [Use with your coding agent](#use-with-your-coding-agent)
197
204
  - [What flowfig does not draw](#what-flowfig-does-not-draw)
198
205
  - [License](#license)
@@ -291,6 +298,8 @@ full reference, run `npx flowfig docs`.
291
298
  | `label` | The title in the box. | required |
292
299
  | `sub` | A smaller line under the label. | none |
293
300
  | `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder). | `"box"` |
301
+ | `check` | The question on the gate in the guide world. The labels of the edges out of the box are the answers. | the label, for a `decision` box |
302
+ | `done` | The state after the box, such as "Oil in stock". The ending tags of the guide world show it. | the label |
294
303
  | `source` | The code this draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
295
304
  | `detail` | A more detailed figure: the SVG path from the repo root. `flowfig atlas` links the box to it. | none |
296
305
  | `lines` | The least number of text lines that a content card keeps. | none |
@@ -299,6 +308,7 @@ full reference, run `npx flowfig docs`.
299
308
  | `from` | In a `timeline` figure: the start date of the item, as YYYY-MM-DD. A box with only `from` is a milestone. | none |
300
309
  | `to` | In a `timeline` figure: the last day of the item, as YYYY-MM-DD. | none |
301
310
  | `mark` | `"start"` draws a dot before the box. `"end"` draws a ring after the box. Use it on a state lifecycle. | none |
311
+ | `look` | One of the 12 pictures that the guide world draws for this box. | none |
302
312
 
303
313
  ### Groups
304
314
 
@@ -313,6 +323,7 @@ full reference, run `npx flowfig docs`.
313
323
  | `gap` | The smallest space between the children in px. It grows to fit the labels of edges that cross the group. | column: 28; row: fits the widest label, at least 56 |
314
324
  | `align` | `"start"`, `"center"` or `"end"`, across the direction. | `"center"` (a column stretches) |
315
325
  | `auto` | On the root layout: `true` makes flowfig place the boxes. See [Automatic layout](#automatic-layout). | off |
326
+ | `look` | One of the 12 pictures that the guide world draws for this group. | none |
316
327
 
317
328
  ### Automatic layout
318
329
 
@@ -344,28 +355,64 @@ A group with a `label` or an `id` stays together as one frame. flowfig removes a
344
355
  and `align`. flowfig ignores `around` on the edges. A `direction` on the root forces `"row"` or `"column"`. `lanes` and
345
356
  `timeline` figures ignore `auto`.
346
357
 
358
+ ### Inside a box
359
+
360
+ A box can hold its inside parts, down to 3 levels. Put the parts in `inside`, with the same fields as a figure:
361
+ `layout`, `edges` and `steps`. An inside edge can start or end at an outer neighbor of the box. The open box shows that
362
+ neighbor as a port.
363
+
364
+ ```json
365
+ {
366
+ "id": "orders",
367
+ "label": "Orders",
368
+ "source": "src/orders.ts#createOrder",
369
+ "inside": {
370
+ "layout": {
371
+ "auto": true,
372
+ "children": [
373
+ { "id": "validate", "label": "Check the cart", "source": "src/orders.ts#validateCart" },
374
+ { "id": "save", "label": "Save the order", "source": "src/orders.ts#insertOrder" }
375
+ ]
376
+ },
377
+ "edges": [
378
+ { "from": "gateway", "to": "validate" },
379
+ { "from": "validate", "to": "save" },
380
+ { "from": "save", "to": "payments" }
381
+ ]
382
+ }
383
+ }
384
+ ```
385
+
386
+ The SVG plays a tour: it opens each box in turn and plays one step inside it. A README image shows that tour. Add
387
+ `--script` to open a box with a click when the SVG is in a browser tab. `--scene orders` renders one level as a plain
388
+ figure. The React player opens a box on a click. `check` and `verify` run on each level.
389
+
390
+ ![Flow figure: Browser, Gateway, Cart, Orders, Payments, Mail. Steps: Place an order, Add to cart.](docs/inside-shop.svg)
391
+
347
392
  ### Edges
348
393
 
349
- | Field | Meaning | Default |
350
- | -------- | --------------------------------------------------------------------------------------------------- | ---------- |
351
- | `from` | The id of the box or group where the edge starts. | required |
352
- | `to` | The id of the box or group where the edge ends. | required |
353
- | `id` | The name that beats use. | `from->to` |
354
- | `label` | The text on the edge. | none |
355
- | `around` | `"above"`, `"below"`, `"left"` or `"right"` routes the edge around the boxes between, on that side. | none |
356
- | `quiet` | `true` draws the edge only while a step uses it. | `false` |
357
- | `source` | The code this edge draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
394
+ | Field | Meaning | Default |
395
+ | --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------- |
396
+ | `from` | The id of the box or group where the edge starts. | required |
397
+ | `to` | The id of the box or group where the edge ends. | required |
398
+ | `id` | The name that beats use. | `from->to` |
399
+ | `label` | The text on the edge. | none |
400
+ | `around` | `"above"`, `"below"`, `"left"` or `"right"` routes the edge around the boxes between, on that side. | none |
401
+ | `quiet` | `true` draws the edge only while a step uses it. | `false` |
402
+ | `carries` | What moves on the edge in the guide world: `"data"`, or `{ thing, form, count, from }` for a physical thing. `form` is `box`, `barrel` or `notes`. `count` is 1 to 4. `from` is the id of the box or group where the thing starts. | `"data"` |
403
+ | `source` | The code this edge draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
358
404
 
359
405
  ### Steps
360
406
 
361
407
  Each step is one story. The player shows one tab for each step. With no steps, the figure is a still map.
362
408
 
363
- | Field | Meaning | Default |
364
- | --------- | ----------------------------------------------------- | -------- |
365
- | `label` | The tab title. | required |
366
- | `flow` | The beats, in play order. | required |
367
- | `caption` | The line under the figure while no beat has a `say`. | none |
368
- | `nodes` | The ids of the boxes to highlight for the whole step. | none |
409
+ | Field | Meaning | Default |
410
+ | --------- | ------------------------------------------------------------------------------------------------------ | ------------------------------- |
411
+ | `label` | The tab title. | required |
412
+ | `flow` | The beats, in play order. | required |
413
+ | `caption` | The line under the figure while no beat has a `say`. | none |
414
+ | `outcome` | One sentence, 15 words or fewer, about how the story ends. The guide shows it at the end of the story. | "Ends at: <the last box label>" |
415
+ | `nodes` | The ids of the boxes to highlight for the whole step. | none |
369
416
 
370
417
  ### Beats
371
418
 
@@ -377,6 +424,7 @@ A beat is an edge id, an array of edge ids that run at the same time, or an obje
377
424
  | `say` | The line of narration for the beat. | none |
378
425
  | `show` | `{ boxId: content }` fills the content card of a box until the step ends. | none |
379
426
  | `light` | The ids of the boxes to highlight for this beat only. | none |
427
+ | `note` | The reason for the beat, in one line. The guide form shows it under the `say` line. | none |
380
428
  | `ms` | The length of the beat in ms. | `speed` (900) |
381
429
 
382
430
  In a hop, `back: true` runs the packet from `to` to `from`. `data` is a small card on the packet. `async: true` marks a message
@@ -415,11 +463,11 @@ figure: 5 boxes, 3 groups, 4 edges, 2 steps, 6 messages
415
463
 
416
464
  The last line gives the counts of the parts of the figure. Compare the counts with the parts that you planned.
417
465
 
418
- `flowfig check` has 26 rules: 11 errors and 15 warnings.
466
+ `flowfig check` has 29 rules: 11 errors and 18 warnings.
419
467
 
420
468
  | Rule | Severity | What it finds |
421
469
  | --------------------------- | -------- | -------------------------------------------------------------------------------------------------------------------------- |
422
- | `unknown-id` | error | An edge, step or beat names a box, group or edge that does not exist. |
470
+ | `unknown-id` | error | An edge, step or beat names a box, group or edge that does not exist, or `carries.from` names no box and no group. |
423
471
  | `duplicate-id` | error | Two boxes, two groups or two edges have the same id. |
424
472
  | `hidden-edge` | error | A `quiet` edge that no beat uses, so the figure never shows it. |
425
473
  | `text-overflow` | error | Text that needs more width than its box has. |
@@ -430,6 +478,8 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
430
478
  | `bad-at` | error | An `at` value that is not an integer of 0 or more. |
431
479
  | `timeline-need-from` | error | A box in a timeline has no `from` date. |
432
480
  | `bad-date` | error | A `from`, `to` or `today` value is not a real YYYY-MM-DD date, or a `to` is before its `from`. |
481
+ | `bad-carries` | warning | An edge `carries` has an empty `thing`, a `form` that is not `box`, `barrel` or `notes`, or a `count` that is not 1 to 4. |
482
+ | `thing-no-road` | warning | A thing edge starts and ends at the same actor, or one end has no actor. |
433
483
  | `empty-step` | warning | A step with no beats. |
434
484
  | `small-text` | warning | At the page width, the smallest text is below the minimum size. |
435
485
  | `bad-source` | warning | A `source` that is not `path` or `path#symbol`. |
@@ -444,6 +494,7 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
444
494
  | `font-estimated` | warning | `theme.font` is set. The SVG check estimates text width for the system font. |
445
495
  | `color-not-checked` | warning | A color that the check cannot read, so its contrast is not checked. |
446
496
  | `plain-text` | warning | Text with a code name, a filler word, or a `say` or caption over 20 words. |
497
+ | `unknown-look` | warning | A box or a group has a `look` that is not one of the 12 pictures. |
447
498
  | `auto-ignored` | warning | `auto` with `lanes` or `timeline`, `auto` on a nested group, or `around` on an edge with `auto`. |
448
499
 
449
500
  | Option | Effect |
@@ -452,6 +503,8 @@ The last line gives the counts of the parts of the figure. Compare the counts wi
452
503
  | `--json` | Print the findings as a JSON array, for scripts. Each finding has `rule`, `severity`, `ids` and `message`. |
453
504
  | `--width <px>` | The page width for `small-text` and for the lanes wrap. Default: 830. |
454
505
  | `--min-text <px>` | The smallest text size the reader must get. Default: 10. |
506
+ | `--scene <id>` | A render only: draw the level inside the box with this id, as a plain figure. |
507
+ | `--script` | A render only: add the script that opens a box with a click. |
455
508
 
456
509
  The exit code is 0 with no errors, 1 with one or more errors, and 2 for bad use, such as a missing input, an unknown flag, or a `--width` value that is not a number.
457
510
 
@@ -485,6 +538,16 @@ npx flowfig gif docs/checkout.svg --dark --mp4 # the dark theme, and also doc
485
538
  on the PATH. If a GIF is over 10 MB, `gif` prints a warning. Use `--step`, a lower `--fps` or a lower `--scale` to make the
486
539
  file smaller.
487
540
 
541
+ `flowfig frames` writes one PNG contact sheet: the key frames of the loop, each with the frame number, the time, the step,
542
+ the beat and the boxes. It also prints one line per tile. The frame numbers match `flowfig gif` with the same `--fps`
543
+ and `--step`.
544
+
545
+ ```bash
546
+ npx flowfig frames docs/checkout.svg # writes docs/checkout-frames.png
547
+ npx flowfig frames docs/checkout.svg --step 2 # only step 2: writes docs/checkout-step2-frames.png
548
+ npx flowfig frames docs/checkout.svg --every 500 # one frame each 500 ms, in place of the key frames
549
+ ```
550
+
488
551
  ## Verify in CI
489
552
 
490
553
  A box, an edge or a hop can name the code it draws: `"source": "src/auth/login.ts#verifyPassword"`. `npx flowfig verify docs/login.svg`
@@ -530,7 +593,7 @@ jobs:
530
593
  - uses: actions/setup-node@v4
531
594
  with:
532
595
  node-version: 22
533
- - uses: iamalvisng/flowfig@v0.11.0
596
+ - uses: iamalvisng/flowfig@v0.12.0
534
597
  with:
535
598
  figures: 'docs/**/*.svg' # default **/*.svg
536
599
  ```
@@ -894,6 +957,70 @@ as `flowfig verify`.
894
957
 
895
958
  `toSvg` does not put the spec in the SVG. Only the CLI adds the spec, which `--spec` reads back.
896
959
 
960
+ ## Explain a flow in a web page
961
+
962
+ `flowfig/element` is a web component. It shows one flow to a reader who does not know the code. Load the script and add the tag:
963
+
964
+ ```html
965
+ <script type="module" src="https://cdn.jsdelivr.net/npm/flowfig/dist/element.js"></script>
966
+ <flowfig-explain src="oil.json" form="guide"></flowfig-explain>
967
+ <flowfig-explain src="after.json" before="before.json" form="change">
968
+ <p slot="verdict">A failed ledger write now runs again by itself.</p>
969
+ </flowfig-explain>
970
+ ```
971
+
972
+ ![The guide form as a small world: the last moment of a refused pickup, with the check gate at the server](docs/explain-guide.png)
973
+
974
+ The element has three forms:
975
+
976
+ - `diagram` shows the figure that `toSvg` draws, with the step tabs of the player.
977
+ - `guide` shows one moment at a time. The actors are the cast. The cast comes from the lanes. Else it comes from the labeled top-level groups.
978
+ Else it comes from the boxes that the steps visit. A store box adds the actor "Records" at the end. When the cast comes from the boxes, the cast holds at most 6 actors, and "Records" takes one of the 6 places. In a figure
979
+ without lanes, a decision box is not an actor.
980
+ - `change` shows the older and the newer spec side by side, with the differences marked. Below the diagrams, an outcome row
981
+ compares Before and After: red for an end box with the tone `red` or `gray`, green for the tone `green`. A step that only one
982
+ side has plays on that side only.
983
+
984
+ For a good guide, give each role a lane or a labeled group. Give each beat a `say`. Use `note` for the reason and `look` for the picture.
985
+
986
+ The guide form shows a figure as a small isometric world when an edge `carries` a thing, or when an actor has the look
987
+ `person`, `shop` or `truck`. Each actor stands in a building along a road. A thing moves by road or by hand. Data flies as an arc
988
+ through the air. A check is a gate at the door of its owner. Each story ends on an outcome frame, with the `outcome` sentence and
989
+ one tag for each actor. On a phone, the road runs down the screen as a stacked street. Other figures show the diagram in the guide.
990
+ The world loads from `element-world.js` next to `element.js`, with a shared chunk (`element-looks.js`). A CDN script tag loads
991
+ them from the same folder. The world art stays light in a dark theme.
992
+
993
+ | Attribute | Values | Default |
994
+ | --------- | ------------------------------------------------------------------ | --------------------------------------------- |
995
+ | `src` | The URL of a spec JSON file, or of an SVG that flowfig wrote. | required, unless the `spec` property is set |
996
+ | `form` | `diagram`, `guide` or `change`. | `guide` if the spec has steps, else `diagram` |
997
+ | `step` | A step label to start on. | the first step |
998
+ | `before` | The URL of the older spec. | required for `change` |
999
+ | `theme` | `auto`, `light` or `dark`. | `auto` |
1000
+ | `code` | Present: each moment shows its `source` and `via` values. | absent |
1001
+ | `forms` | The forms that the reader can switch between, separated by spaces. | all forms that the input allows |
1002
+
1003
+ The `theme` key of the spec colors the SVG in the diagram and change forms. It applies only when the `theme` attribute is `auto`. The element keeps a color only if it is plain color syntax. Else it uses the default color.
1004
+
1005
+ - Properties: `spec` and `beforeSpec` (objects, instead of the URLs), and `moment` (the index; read and write).
1006
+ - Methods: `next()`, `back()`, `play()`, `pause()` and `show(stepLabel)`.
1007
+ - Events: `flowfig-moment` (`detail: { step, moment, total }`) and `flowfig-form` (`detail: { form }`).
1008
+ - Slots: `verdict` (the change form; it shows above the diagrams), `title` and `caption`. Without a `verdict`, the change form shows a plain list of what changed. The raw diff is behind "Show all changes".
1009
+ - Keys: the element takes focus. The arrow keys move between moments (or between steps in the diagram form). Space plays and pauses. The keys 1 to 9 pick a step.
1010
+
1011
+ The element has a shadow root. The CSS of the host page does not reach inside it. To change the look, set the `--flowfig-*` tokens
1012
+ on the element: `--flowfig-bg`, `--flowfig-surface`, `--flowfig-border`, `--flowfig-ink`, `--flowfig-muted`, `--flowfig-accent`,
1013
+ `--flowfig-on-accent`, `--flowfig-good`, `--flowfig-bad`, `--flowfig-play`, `--flowfig-font`. Only these tokens change the look.
1014
+
1015
+ ```css
1016
+ flowfig-explain {
1017
+ --flowfig-accent: #7c3aed;
1018
+ --flowfig-font: Georgia, serif;
1019
+ }
1020
+ ```
1021
+
1022
+ The element is `element.js` and the files `element-world.js` and `element-looks.js` next to it. The core loads under 45 kB gzip, and the world loads on demand. There is no React. In a bundler, write `import 'flowfig/element'`.
1023
+
897
1024
  ## Use with your coding agent
898
1025
 
899
1026
  `npx flowfig init` writes flowfig instructions for the coding agents of a repo. The instructions tell the agent how to write a
package/dist/atlas.js CHANGED
@@ -2,7 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2
2
  import { dirname, join, posix, resolve } from 'node:path';
3
3
  import { coverageLines, coverageReport } from './coverage.js';
4
4
  import { loadSpec, specOf } from './load.js';
5
- import { altText, nodes, str } from './model.js';
5
+ import { altText, nodes, scenes, str } from './model.js';
6
6
  import { openFile } from './open.js';
7
7
  import { esc, pageHtml } from './page.js';
8
8
  import { toSvg } from './svg.js';
@@ -47,7 +47,11 @@ export function atlasFiles({ root = process.cwd(), figures } = {}) {
47
47
  return to && specs.has(to) ? [{ n, to }] : [];
48
48
  });
49
49
  const hrefs = new Map(targets.map(({ n, to }) => [n.id, hrefTo(page, pageOf(to))]));
50
- const svg = toSvg(spec, { links: Object.fromEntries(hrefs) });
50
+ const owners = new Set(scenes(spec).flatMap((s) => (s.path.length === 1 ? s.path : [])));
51
+ const svg = toSvg(spec, {
52
+ links: Object.fromEntries([...hrefs].filter(([id]) => !owners.has(id))),
53
+ ...(owners.size && { script: true }),
54
+ });
51
55
  const details = nodes(spec.layout)
52
56
  .filter((n) => typeof n.detail === 'string')
53
57
  .map((n) => {
package/dist/browser.d.ts CHANGED
@@ -28,4 +28,5 @@ export declare function captureFrames(cdp: Cdp, html: string, o: {
28
28
  scale: number;
29
29
  fps: number;
30
30
  dark: boolean;
31
+ frames?: number[];
31
32
  }): Promise<Capture>;
package/dist/browser.js CHANGED
@@ -51,6 +51,8 @@ export function launch(path, profile, timeoutMs = 30_000) {
51
51
  '--disable-gpu',
52
52
  // A seek can draw a stale transform x under load on the compositor thread.
53
53
  '--disable-threaded-animation',
54
+ // A partial raster of a reused tile depends on the frames before it.
55
+ '--disable-partial-raster',
54
56
  ];
55
57
  // Chrome refuses to start as root without it, for example in Docker.
56
58
  if (process.getuid?.() === 0)
@@ -176,6 +178,8 @@ export async function captureFrames(cdp, html, o) {
176
178
  // The packet moves with SMIL, which only the <svg> element controls.
177
179
  const info = await run(`(async () => {
178
180
  await document.fonts.ready;
181
+ // A data URI image decodes after the page parse, so wait for each one.
182
+ await Promise.all([...document.querySelectorAll('image')].map((i) => i.decode().catch(() => {})));
179
183
  const svg = document.querySelector('svg');
180
184
  const css = document.getAnimations();
181
185
  for (const a of css) a.pause();
@@ -192,15 +196,16 @@ export async function captureFrames(cdp, html, o) {
192
196
  const first = await open();
193
197
  const { info } = first;
194
198
  const n = Math.max(1, Math.round((info.loop * o.fps) / 1000));
199
+ const want = o.frames ?? Array.from({ length: n }, (_, i) => i);
195
200
  const clip = { x: info.x, y: info.y, width: info.width, height: info.height, scale: 1 };
196
201
  // availableParallelism arrived in Node 18.14; the package supports Node 18.0.
197
202
  const cores = os.availableParallelism?.() ?? os.cpus().length;
198
- const count = Math.max(1, Math.min(MAX_TABS, cores, Math.floor(n / MIN_FRAMES_PER_TAB)));
203
+ const count = Math.max(1, Math.min(MAX_TABS, cores, Math.floor(want.length / MIN_FRAMES_PER_TAB)));
199
204
  const tabs = [first, ...(await Promise.all(Array.from({ length: count - 1 }, open)))];
200
- const pngs = Array.from({ length: n });
205
+ const pngs = Array.from({ length: want.length });
201
206
  await Promise.all(tabs.map(async ({ send, run }, k) => {
202
- for (let i = k; i < n; i += count) {
203
- const t = (i * 1000) / o.fps;
207
+ for (let i = k; i < want.length; i += count) {
208
+ const t = (want[i] * 1000) / o.fps;
204
209
  await run(`(() => {
205
210
  for (const a of document.getAnimations()) a.currentTime = ${t};
206
211
  document.querySelector('svg').setCurrentTime(${t / 1000});
package/dist/check.d.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { type FigTheme, type FlowProps, type LanePlan } from './model.ts';
2
2
  import type { Finding, Scene } from './scene.ts';
3
- import type { SvgOptions } from './svg.ts';
3
+ import type { Level, SvgOptions } from './svg.ts';
4
4
  /** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
5
5
  export declare function checkSpec(fig: FlowProps): Finding[];
6
6
  export type CheckOptions = {
@@ -14,4 +14,4 @@ export declare function contrast(a: string, b: string): number | null;
14
14
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
15
15
  export declare function checkTheme(theme?: FigTheme): Finding[];
16
16
  export declare function planFor(fig: FlowProps, opts: SvgOptions): LanePlan | null;
17
- export declare function checkRendered(fig: FlowProps, opts: SvgOptions & CheckOptions, scene: Scene): Finding[];
17
+ export declare function checkRendered(fig: FlowProps, opts: SvgOptions & CheckOptions, scene: Scene, levels?: Level[]): Finding[];
package/dist/check.js CHANGED
@@ -1,7 +1,8 @@
1
- import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, str, toBeat, } from './model.js';
1
+ import { DARK, LIGHT, LOOKS, ON_ACCENT, TONES, edgeId, isGroup, dayOf, isLanesLayout, lanePlan, validAt, laneColumns, nodes, scenes, str, toBeat, MAX_LEVEL, } from './model.js';
2
2
  import { layoutRail } from './rail.js';
3
3
  import { crosses, samples } from './geometry.js';
4
4
  import { textWidth } from './text.js';
5
+ import { cast } from './explain.js';
5
6
  import { owners, parseSource } from './source.js';
6
7
  const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
7
8
  const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
@@ -42,6 +43,8 @@ function plainText(fig) {
42
43
  for (const n of nodes(fig.layout)) {
43
44
  check(`box "${n.id}"`, 'label', n.label, { source: n.source, command: true });
44
45
  check(`box "${n.id}"`, 'sub', n.sub);
46
+ check(`box "${n.id}"`, 'check', n.check);
47
+ check(`box "${n.id}"`, 'done', n.done);
45
48
  }
46
49
  const groupLabels = (g) => {
47
50
  if (g.label != null)
@@ -51,11 +54,16 @@ function plainText(fig) {
51
54
  groupLabels(c);
52
55
  };
53
56
  groupLabels(fig.layout);
54
- fig.edges.forEach((e) => check(`edge "${edgeId(e)}"`, 'label', e.label, { source: e.source, command: true }));
57
+ fig.edges.forEach((e) => {
58
+ check(`edge "${edgeId(e)}"`, 'label', e.label, { source: e.source, command: true });
59
+ if (e.carries && typeof e.carries === 'object')
60
+ check(`edge "${edgeId(e)}"`, 'carries.thing', e.carries.thing);
61
+ });
55
62
  for (const s of fig.steps ?? []) {
56
63
  const name = str(s.label);
57
64
  check(`step "${name}"`, 'label', s.label);
58
65
  check(`step "${name}"`, 'caption', s.caption, { words: true });
66
+ check(`step "${name}"`, 'outcome', s.outcome, { words: true });
59
67
  s.flow.map(toBeat).forEach((b, i) => {
60
68
  check(`step "${name}"`, `say ${i + 1}`, b.say, { words: true });
61
69
  b.hops.forEach((h) => check(`step "${name}"`, `data ${i + 1}`, h.data, { code: false }));
@@ -65,6 +73,94 @@ function plainText(fig) {
65
73
  }
66
74
  /** Faults in the spec: dangling ids, duplicate ids, empty steps. The renderers skip them silently. */
67
75
  export function checkSpec(fig) {
76
+ const own = specRules(fig);
77
+ if (!nodes(fig.layout).some((n) => n.inside))
78
+ return own;
79
+ return [...own, ...insideRules(fig)];
80
+ }
81
+ function insideRules(fig) {
82
+ const out = [];
83
+ const form = fig.timeline
84
+ ? 'timeline; a timeline does not'
85
+ : fig.lanes
86
+ ? 'lanes; lanes do not'
87
+ : fig.rail === 'only'
88
+ ? 'rail: "only"; a rail-only figure does not'
89
+ : null;
90
+ if (form) {
91
+ for (const n of nodes(fig.layout))
92
+ if (n.inside)
93
+ out.push(warn('parts-ignored', [n.id], `the figure sets ${form} open boxes, so the renderers ignore inside on "${n.id}"`));
94
+ return out;
95
+ }
96
+ let total = 0;
97
+ const limits = (g, level) => {
98
+ for (const n of nodes(g)) {
99
+ if (!n.inside)
100
+ continue;
101
+ if (level === MAX_LEVEL) {
102
+ out.push(err('part-limit', [n.id], `box "${n.id}" is at level ${MAX_LEVEL}, so its parts are at level ${MAX_LEVEL + 1}; the limit is ${MAX_LEVEL} levels`));
103
+ continue;
104
+ }
105
+ const count = nodes(n.inside.layout).length;
106
+ total += count;
107
+ if (!count)
108
+ out.push(err('part-limit', [n.id], `box "${n.id}": inside has no parts`));
109
+ else if (count > 16)
110
+ out.push(err('part-limit', [n.id], `box "${n.id}" has ${count} parts; the limit is 16`));
111
+ limits(n.inside.layout, level + 1);
112
+ }
113
+ };
114
+ limits(fig.layout, 1);
115
+ if (total > 60)
116
+ out.push(err('part-limit', [], `the figure has ${total} parts; the limit is 60`));
117
+ const all = scenes(fig);
118
+ const seen = { boxes: new Map(), groups: new Map(), edges: new Map() };
119
+ const partsOf = (s) => {
120
+ const ports = new Set(s.ports.map((p) => p.id));
121
+ return nodes(s.props.layout).filter((n) => !ports.has(n.id));
122
+ };
123
+ for (const s of all) {
124
+ const add = (m, ids) => ids.forEach((id) => m.set(id, (m.get(id) ?? new Map()).set(s.path.join('>'), s.line || 'the root')));
125
+ add(seen.boxes, partsOf(s).map((n) => n.id));
126
+ add(seen.groups, groupIds(s.props.layout));
127
+ add(seen.edges, s.props.edges.map(edgeId));
128
+ }
129
+ for (const [kind, m] of Object.entries(seen))
130
+ for (const [id, lines] of m)
131
+ if (lines.size > 1)
132
+ out.push(err('duplicate-id', [id], `two ${kind} have the id "${id}": ${[...lines.values()].join(' and ')}`));
133
+ const byKey = new Map(all.map((s) => [s.path.join('>'), s]));
134
+ for (const s of all.slice(1)) {
135
+ const parent = byKey.get(s.path.slice(0, -1).join('>'));
136
+ const owner = s.path.at(-1);
137
+ const found = specRules(s.props).filter((f) => f.rule !== 'bad-source');
138
+ const parts = new Set(partsOf(s).map((n) => n.id));
139
+ for (const n of partsOf(s))
140
+ if (n.source == null)
141
+ found.push(warn('part-no-source', [n.id], `part "${n.id}" has no source`));
142
+ const outerIn = parent.props.edges.filter((e) => e.to === owner);
143
+ const outerOut = parent.props.edges.filter((e) => e.from === owner);
144
+ for (const e of s.props.edges) {
145
+ const id = edgeId(e);
146
+ if (!parts.has(e.from) && !parts.has(e.to))
147
+ found.push(err('part-edge-mismatch', [id], `edge "${id}" has no part at either end`));
148
+ else if (!parts.has(e.from) && !outerIn.some((o) => o.from === e.from))
149
+ found.push(err('part-edge-mismatch', [id, e.from], `edge "${id}" comes in from "${e.from}", but no edge goes from "${e.from}" to "${owner}"`));
150
+ else if (!parts.has(e.to) && !outerOut.some((o) => o.to === e.to))
151
+ found.push(err('part-edge-mismatch', [id, e.to], `edge "${id}" goes out to "${e.to}", but no edge goes from "${owner}" to "${e.to}"`));
152
+ }
153
+ for (const o of outerIn)
154
+ if (!s.props.edges.some((e) => e.from === o.from))
155
+ found.push(warn('part-edge-mismatch', [edgeId(o)], `edge "${edgeId(o)}" comes in, but no edge inside "${owner}" starts at "${o.from}"; the open view cannot show where the flow comes in`));
156
+ for (const o of outerOut)
157
+ if (!s.props.edges.some((e) => e.to === o.to))
158
+ found.push(warn('part-edge-mismatch', [edgeId(o)], `edge "${edgeId(o)}" goes out, but no edge inside "${owner}" ends at "${o.to}"; the open view cannot show where the flow goes out`));
159
+ out.push(...found.map((f) => ({ ...f, scene: s.line })));
160
+ }
161
+ return out;
162
+ }
163
+ function specRules(fig) {
68
164
  const out = [];
69
165
  const boxIds = nodes(fig.layout).map((n) => n.id);
70
166
  const groups = groupIds(fig.layout);
@@ -76,13 +172,21 @@ export function checkSpec(fig) {
76
172
  const known = new Set([...boxIds, ...groups]);
77
173
  const knownEdges = new Set(edges);
78
174
  const empty = new Set();
175
+ const firstBox = new Map();
79
176
  const walk = (g) => {
80
- for (const c of g.children)
177
+ for (const c of g.children) {
178
+ if (c.look != null && !LOOKS.includes(c.look)) {
179
+ const who = isGroup(c) ? `group "${c.id ?? str(c.label)}"` : `box "${c.id}"`;
180
+ out.push(warn('unknown-look', isGroup(c) ? (c.id == null ? [] : [c.id]) : [c.id], `${who} has look "${c.look}"; use one of: ${LOOKS.join(', ')}`));
181
+ }
81
182
  if (isGroup(c)) {
82
183
  if (c.id != null && !nodes(c).length)
83
184
  empty.add(c.id);
185
+ if (c.id != null && nodes(c).length)
186
+ firstBox.set(c.id, nodes(c)[0].id);
84
187
  walk(c);
85
188
  }
189
+ }
86
190
  };
87
191
  walk(fig.layout);
88
192
  fig.edges.forEach((e, i) => {
@@ -92,6 +196,30 @@ export function checkSpec(fig) {
92
196
  else if (empty.has(end))
93
197
  out.push(err('unknown-id', [edges[i], end], `edge "${edges[i]}" ends at group "${end}", which has no boxes`));
94
198
  });
199
+ const actorOf = new Map(cast(fig).flatMap((a) => a.boxes.map((b) => [b, a.name])));
200
+ fig.edges.forEach((e, i) => {
201
+ const c = e.carries;
202
+ if (!c || typeof c !== 'object')
203
+ return;
204
+ const id = edges[i];
205
+ const thing = str(c.thing).trim();
206
+ if (c.count != null && !(Number.isInteger(c.count) && c.count >= 1 && c.count <= 4))
207
+ out.push(warn('bad-carries', [id], `edge "${id}" has carries.count ${c.count}; use 1 to 4`));
208
+ if (!thing)
209
+ out.push(warn('bad-carries', [id], `edge "${id}" has an empty carries.thing; name the thing in plain words`));
210
+ if (c.form != null && !['box', 'barrel', 'notes'].includes(c.form))
211
+ out.push(warn('bad-carries', [id], `edge "${id}" has carries.form "${c.form}"; use box, barrel or notes`));
212
+ if (c.from != null && !known.has(c.from)) {
213
+ out.push(err('unknown-id', [id, c.from], `edge "${id}" has carries.from "${c.from}", which names no box and no group`));
214
+ return;
215
+ }
216
+ const start = c.from == null ? e.from : (firstBox.get(c.from) ?? c.from);
217
+ const [a, b] = [actorOf.get(start), actorOf.get(e.to)];
218
+ if (a == null || b == null)
219
+ out.push(warn('thing-no-road', [id], `edge "${id}" carries "${thing}", but box "${a == null ? start : e.to}" has no actor; put it in a lane or a labeled group`));
220
+ else if (a === b)
221
+ out.push(warn('thing-no-road', [id], `edge "${id}" carries "${thing}", but both ends are at actor "${a}"; set carries.from to the box where the thing starts`));
222
+ });
95
223
  (fig.steps ?? []).forEach((s, si) => {
96
224
  const where = `step ${si + 1} ("${str(s.label)}")`;
97
225
  if (!s.flow.length)
@@ -376,7 +504,7 @@ export function planFor(fig, opts) {
376
504
  const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
377
505
  return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
378
506
  }
379
- export function checkRendered(fig, opts, scene) {
507
+ export function checkRendered(fig, opts, scene, levels) {
380
508
  const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
381
509
  rule: 'lane-end-block',
382
510
  severity: 'warning',
@@ -385,7 +513,9 @@ export function checkRendered(fig, opts, scene) {
385
513
  }));
386
514
  return [
387
515
  ...checkSpec(fig),
388
- ...checkScene(scene, { ...opts, auto: opts.auto ?? fig.layout.auto }),
516
+ ...(levels
517
+ ? levels.flatMap((l, i) => checkScene({ ...l.scene, width: Math.max(...levels.map((x) => x.scene.width)) }, { ...opts, auto: opts.auto ?? l.props.layout.auto }).map((f) => (i ? { ...f, scene: l.line } : f)))
518
+ : checkScene(scene, { ...opts, auto: opts.auto ?? fig.layout.auto })),
389
519
  ...lost,
390
520
  ...checkTheme({ ...fig.theme, ...opts.theme }),
391
521
  ];