flowfig 0.5.0 → 0.6.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.js +6 -2
- package/dist/check.js +9 -6
- package/dist/cli.js +1 -1
- package/dist/geometry.d.ts +12 -2
- package/dist/geometry.js +112 -11
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +7 -0
- package/dist/index.js +55 -33
- package/dist/init.js +5 -2
- package/dist/model.d.ts +6 -0
- package/dist/model.js +2 -0
- package/dist/scene.d.ts +2 -0
- package/dist/svg.js +41 -19
- package/dist/verify.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
<h1><img src="docs/logo.svg" alt="flowfig" height="80"/></h1>
|
|
4
4
|
|
|
5
|
-
###
|
|
5
|
+
### Your coding agent draws the diagram from your code. flowfig checks it, and CI fails when the linked code is gone.
|
|
6
6
|
|
|
7
7
|
<p>
|
|
8
8
|
<a href="https://www.npmjs.com/package/flowfig"><img src="https://img.shields.io/npm/v/flowfig?style=for-the-badge&logo=npm&logoColor=white&label=npm" alt="npm version"/></a>
|
|
@@ -16,6 +16,58 @@
|
|
|
16
16
|
|
|
17
17
|
</div>
|
|
18
18
|
|
|
19
|
+
## The problem
|
|
20
|
+
|
|
21
|
+
A diagram in a repo drifts from the code. Someone renames a function, and the diagram still shows the old name. No
|
|
22
|
+
check fails, so no one sees the drift.
|
|
23
|
+
|
|
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.
|
|
26
|
+
|
|
27
|
+
## Fail CI when the diagram drifts
|
|
28
|
+
|
|
29
|
+
A box, an edge or a message can name the code that it draws:
|
|
30
|
+
|
|
31
|
+
```json
|
|
32
|
+
{ "id": "verify", "label": "verifyPassword", "source": "src/auth/login.ts#verifyPassword" }
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Rename `verifyPassword` to `checkPassword`. Then `verify` fails with exit code 1:
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ npx flowfig verify login.svg
|
|
39
|
+
error missing-symbol login.svg: box "verify" -> src/auth/login.ts#verifyPassword: symbol not found
|
|
40
|
+
1 error, 0 warnings
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
`verify` checks that each linked file exists and that the symbol name still occurs in it. A link to a Markdown file can
|
|
44
|
+
name a heading, so a process diagram can follow its SOP document.
|
|
45
|
+
|
|
46
|
+
The GitHub Action runs `verify` on every figure in a pull request. For each SVG that the PR changes, it comments the old
|
|
47
|
+
image, the new image and the spec changes. It also names each figure whose linked code the PR changes.
|
|
48
|
+
|
|
49
|
+
```yaml
|
|
50
|
+
# .github/workflows/figures.yml
|
|
51
|
+
name: figures
|
|
52
|
+
on: pull_request
|
|
53
|
+
jobs:
|
|
54
|
+
figures:
|
|
55
|
+
runs-on: ubuntu-latest
|
|
56
|
+
permissions:
|
|
57
|
+
contents: read
|
|
58
|
+
pull-requests: write
|
|
59
|
+
steps:
|
|
60
|
+
- uses: actions/checkout@v4
|
|
61
|
+
with:
|
|
62
|
+
fetch-depth: 0 # the Action reads the old figure from the base commit
|
|
63
|
+
- uses: actions/setup-node@v4
|
|
64
|
+
with:
|
|
65
|
+
node-version: 22
|
|
66
|
+
- uses: iamalvisng/flowfig@v0.6.0
|
|
67
|
+
with:
|
|
68
|
+
figures: 'docs/**/*.svg' # default **/*.svg
|
|
69
|
+
```
|
|
70
|
+
|
|
19
71
|
## Use it
|
|
20
72
|
|
|
21
73
|
Set up your repo:
|
|
@@ -41,49 +93,79 @@ draw the refund process with a lane for each team
|
|
|
41
93
|
The agent writes an SVG and ends its reply with `npx flowfig open <path>`. Run that command to see the animation in your
|
|
42
94
|
browser.
|
|
43
95
|
|
|
96
|
+
`init` sets up 7 agents: Claude Code, Cursor, GitHub Copilot, Codex and other `AGENTS.md` agents, Gemini CLI, Windsurf
|
|
97
|
+
and Kiro.
|
|
98
|
+
|
|
44
99
|
## One command
|
|
45
100
|
|
|
46
101
|
```bash
|
|
47
102
|
npx flowfig draw "how does login work"
|
|
48
103
|
```
|
|
49
104
|
|
|
50
|
-
`draw` runs Claude Code on the repo
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
`draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
|
|
55
|
-
and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
|
|
56
|
-
`--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
|
|
57
|
-
`--json` prints `{ out, reply, cost, session, findings }` for scripts. `--open` opens the figure in the default browser after the check.
|
|
105
|
+
`draw` runs Claude Code on the repo with the flowfig guide. It gives Claude Code the tools Read, Glob and Grep, and
|
|
106
|
+
`npx flowfig`. `draw` disallows the tools Edit, MultiEdit and NotebookEdit. Claude Code writes the figure. Then `draw`
|
|
107
|
+
checks the figure with `check --strict` and `verify`. `draw` prints the result, the reply of the agent and the cost of
|
|
108
|
+
the run.
|
|
58
109
|
|
|
59
|
-
|
|
110
|
+
`draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet. `--out` sets the path,
|
|
111
|
+
`--model` sets the model, `--max-turns` sets the turn cap (default 40), `--json` prints the result for scripts, and
|
|
112
|
+
`--open` opens the figure.
|
|
113
|
+
|
|
114
|
+
## How it works
|
|
115
|
+
|
|
116
|
+
1. **The agent writes JSON, not pictures.** The agent reads the code and writes a spec of the parts, the calls and their
|
|
117
|
+
order. flowfig does the layout.
|
|
118
|
+
2. **flowfig checks the spec.** `flowfig check` has 22 rules. It finds ids that point nowhere, text wider than its box,
|
|
119
|
+
edges through boxes, overlapping labels and low contrast. A render runs the same check and writes nothing on an error.
|
|
120
|
+
The agent reads the faults and fixes the spec.
|
|
121
|
+
3. **The output is one animated SVG with no script.** The SVG plays in a GitHub README, PR or issue. It follows the light
|
|
122
|
+
or dark mode of the reader. The SVG carries its spec, so `npx flowfig --spec figure.svg` prints it back.
|
|
123
|
+
|
|
124
|
+
## Forms
|
|
125
|
+
|
|
126
|
+
The map (the default) shows the parts and the messages between them:
|
|
127
|
+
|
|
128
|
+

|
|
60
129
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
130
|
+
The map and the rail (`rail: true`) adds one row for each message, with its payload:
|
|
131
|
+
|
|
132
|
+

|
|
133
|
+
|
|
134
|
+
The rail only (`rail: "only"`) shows the order of the messages without the map:
|
|
135
|
+
|
|
136
|
+

|
|
137
|
+
|
|
138
|
+
Swimlanes (`lanes: true`) give each role a lane and put the steps left to right in time order:
|
|
139
|
+
|
|
140
|
+

|
|
141
|
+
|
|
142
|
+
A long swimlane process wraps into blocks at the page width. An edge between two blocks becomes two labeled stubs:
|
|
143
|
+
|
|
144
|
+

|
|
145
|
+
|
|
146
|
+
The timeline (`timeline: true`) draws a roadmap with one track per team, dated bars, milestones and a today line:
|
|
147
|
+
|
|
148
|
+

|
|
149
|
+
|
|
150
|
+
A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`:
|
|
151
|
+
|
|
152
|
+

|
|
153
|
+
|
|
154
|
+
## Open and share
|
|
155
|
+
|
|
156
|
+
`npx flowfig open figure.svg` shows the figure in your default browser.
|
|
157
|
+
|
|
158
|
+
`npx flowfig gif figure.svg` writes an animated GIF for Slack, Notion, X and slides, where SVG animation does not play.
|
|
159
|
+
|
|
160
|
+
The options are in [Open and share a figure](#open-and-share-a-figure).
|
|
80
161
|
|
|
81
162
|
## flowfig, Mermaid and a hand-drawn image
|
|
82
163
|
|
|
83
164
|
| | flowfig | Mermaid | Hand-drawn image |
|
|
84
165
|
| ------------------------------ | --------------------------------- | ------------------------------------ | ---------------- |
|
|
85
166
|
| Made by an agent from the code | Yes, with the `init` instructions | Yes, as Mermaid text | No |
|
|
86
|
-
|
|
|
167
|
+
| Linked to the code | Yes, `verify` fails in CI | No | No |
|
|
168
|
+
| Checked for faults | Yes, 22 rules in `flowfig check` | Syntax errors only | No |
|
|
87
169
|
| Animated | Yes, one packet per message | No | No |
|
|
88
170
|
| Shows payloads and order | Yes, on the map and the rail | Order and text in a sequence diagram | No |
|
|
89
171
|
| Plays in a GitHub README | Yes, as an SVG | Yes, GitHub renders it | Yes, as an image |
|
|
@@ -94,12 +176,11 @@ and the cost of the run. `draw` needs Claude Code on the machine and a login. `d
|
|
|
94
176
|
|
|
95
177
|
- [Install](#install)
|
|
96
178
|
- [Your first figure](#your-first-figure)
|
|
97
|
-
- [
|
|
179
|
+
- [Read and change a figure](#read-and-change-a-figure)
|
|
98
180
|
- [The spec](#the-spec)
|
|
99
181
|
- [Check a figure](#check-a-figure)
|
|
100
182
|
- [Open and share a figure](#open-and-share-a-figure)
|
|
101
183
|
- [Verify in CI](#verify-in-ci)
|
|
102
|
-
- [For teams](#for-teams)
|
|
103
184
|
- [MCP server](#mcp-server)
|
|
104
185
|
- [Use in React](#use-in-react)
|
|
105
186
|
- [Use from Node](#use-from-node)
|
|
@@ -163,28 +244,7 @@ The command writes `first.svg`:
|
|
|
163
244
|
The SVG has no script and fetches no font. GitHub shows the SVG in a README, a PR or an issue. The SVG follows the light or dark
|
|
164
245
|
color scheme of the reader.
|
|
165
246
|
|
|
166
|
-
##
|
|
167
|
-
|
|
168
|
-
| Form | Spec | Use it when |
|
|
169
|
-
| ------------- | -------------- | ----------------------------------------------------------------------- |
|
|
170
|
-
| The map | (default) | The reader must see where each part runs and which parts talk. |
|
|
171
|
-
| Map and rail | `rail: true` | The reader must see the parts, and also the order and payload of calls. |
|
|
172
|
-
| The rail only | `rail: "only"` | The order of the messages is the point, and the map adds nothing to it. |
|
|
173
|
-
|
|
174
|
-
The map:
|
|
175
|
-
|
|
176
|
-

|
|
177
|
-
|
|
178
|
-
The map with the rail:
|
|
179
|
-
|
|
180
|
-

|
|
181
|
-
|
|
182
|
-
The rail draws one row for each message, with its payload.
|
|
183
|
-
Hops in one beat share a parallel band. An `async` hop has a dashed arrow and an `async` tag.
|
|
184
|
-
|
|
185
|
-
The rail only, from the same checkout spec:
|
|
186
|
-
|
|
187
|
-

|
|
247
|
+
## Read and change a figure
|
|
188
248
|
|
|
189
249
|
Each SVG from the CLI carries its own spec. These commands made the rail-only figure from `docs/checkout.svg`:
|
|
190
250
|
|
|
@@ -203,15 +263,19 @@ full reference, run `npx flowfig docs`.
|
|
|
203
263
|
|
|
204
264
|
### Boxes
|
|
205
265
|
|
|
206
|
-
| Field | Meaning
|
|
207
|
-
| -------- |
|
|
208
|
-
| `id` | The name that edges and steps use. It must be unique.
|
|
209
|
-
| `label` | The title in the box.
|
|
210
|
-
| `sub` | A smaller line under the label.
|
|
211
|
-
| `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder).
|
|
212
|
-
| `source` | The code this draws: `path` or `path#symbol`. `flowfig verify` checks it.
|
|
213
|
-
| `lines` | The least number of text lines that a content card keeps.
|
|
214
|
-
| `width` | The width in px. This value replaces the width that layout picks.
|
|
266
|
+
| Field | Meaning | Default |
|
|
267
|
+
| -------- | --------------------------------------------------------------------------------------------------------- | ----------------------------------- |
|
|
268
|
+
| `id` | The name that edges and steps use. It must be unique. | required |
|
|
269
|
+
| `label` | The title in the box. | required |
|
|
270
|
+
| `sub` | A smaller line under the label. | none |
|
|
271
|
+
| `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder). | `"box"` |
|
|
272
|
+
| `source` | The code this draws: `path` or `path#symbol`. `flowfig verify` checks it. | none |
|
|
273
|
+
| `lines` | The least number of text lines that a content card keeps. | none |
|
|
274
|
+
| `width` | The width in px. This value replaces the width that layout picks. | from layout |
|
|
275
|
+
| `at` | In a `lanes` figure: the time column of the box, from 0. | the order of first use in the steps |
|
|
276
|
+
| `from` | In a `timeline` figure: the start date of the item, as YYYY-MM-DD. A box with only `from` is a milestone. | none |
|
|
277
|
+
| `to` | In a `timeline` figure: the last day of the item, as YYYY-MM-DD. | none |
|
|
278
|
+
| `mark` | `"start"` draws a dot before the box. `"end"` draws a ring after the box. Use it on a state lifecycle. | none |
|
|
215
279
|
|
|
216
280
|
### Groups
|
|
217
281
|
|
|
@@ -262,20 +326,24 @@ A beat is an edge id, an array of edge ids that run at the same time, or an obje
|
|
|
262
326
|
| `ms` | The length of the beat in ms. | `speed` (900) |
|
|
263
327
|
|
|
264
328
|
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
|
|
265
|
-
that does not wait for an answer.
|
|
329
|
+
that does not wait for an answer. On the rail, an `async` hop has a dashed arrow and an `async` tag. The hops of one beat
|
|
330
|
+
share a parallel band on the rail.
|
|
266
331
|
|
|
267
332
|
Content is an array of rows, or a React node in the player. A row is `{ text, tag, tone, meta, mark, mono }`. Only `text` is
|
|
268
333
|
required. The tones are `blue` (the default), `purple`, `green`, `orange` and `gray`.
|
|
269
334
|
|
|
270
335
|
### Figure options
|
|
271
336
|
|
|
272
|
-
| Field | Meaning
|
|
273
|
-
| ---------- |
|
|
274
|
-
| `rail` | `true` draws the rail under the map. `"only"` draws the rail alone.
|
|
275
|
-
| `
|
|
276
|
-
| `
|
|
277
|
-
| `
|
|
278
|
-
| `
|
|
337
|
+
| Field | Meaning | Default |
|
|
338
|
+
| ---------- | ------------------------------------------------------------------------------------ | -------- |
|
|
339
|
+
| `rail` | `true` draws the rail under the map. `"only"` draws the rail alone. | `false` |
|
|
340
|
+
| `lanes` | `true` draws swimlanes: a `column` group of labeled groups, one lane per role. | off |
|
|
341
|
+
| `timeline` | `true` draws a timeline: one labeled group per track, with the boxes at their dates. | off |
|
|
342
|
+
| `today` | In a `timeline` figure: the date of the today line, as YYYY-MM-DD. | none |
|
|
343
|
+
| `speed` | The time in ms for a packet to cross one edge. | 900 |
|
|
344
|
+
| `theme` | `{ accent, fg, muted, bg, surface, border, font }`. Each key is optional. | built-in |
|
|
345
|
+
| `autoplay` | Start to play when the figure mounts. Only the React player reads it. | `true` |
|
|
346
|
+
| `check` | Run the check rules in the browser. Only the React player reads it. | `false` |
|
|
279
347
|
|
|
280
348
|
## Check a figure
|
|
281
349
|
|
|
@@ -290,20 +358,32 @@ figure: 5 boxes, 3 groups, 4 edges, 2 steps, 6 messages
|
|
|
290
358
|
|
|
291
359
|
The last line gives the counts of the parts of the figure. Compare the counts with the parts that you planned.
|
|
292
360
|
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
|
296
|
-
|
|
|
297
|
-
| `
|
|
298
|
-
| `
|
|
299
|
-
| `edge
|
|
300
|
-
| `
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
361
|
+
`flowfig check` has 22 rules: 11 errors and 11 warnings.
|
|
362
|
+
|
|
363
|
+
| Rule | Severity | What it finds |
|
|
364
|
+
| --------------------------- | -------- | --------------------------------------------------------------------------------------------------- |
|
|
365
|
+
| `unknown-id` | error | An edge, step or beat names a box, group or edge that does not exist. |
|
|
366
|
+
| `duplicate-id` | error | Two boxes, two groups or two edges have the same id. |
|
|
367
|
+
| `hidden-edge` | error | A `quiet` edge that no beat uses, so the figure never shows it. |
|
|
368
|
+
| `text-overflow` | error | Text that needs more width than its box has. |
|
|
369
|
+
| `edge-crosses-box` | error | An edge that goes through a box that is not one of its ends. |
|
|
370
|
+
| `label-overlap` | error | Two edge labels overlap, or a label covers a box or an edge, or a stub label crosses a lane border. |
|
|
371
|
+
| `low-contrast` | error | A text and background pair below 4.5:1, in the light, dark or custom theme, or on a tone tint. |
|
|
372
|
+
| `lanes-need-column` | error | The layout of a `lanes` or `timeline` figure is not a column group of labeled groups. |
|
|
373
|
+
| `bad-at` | error | An `at` value that is not an integer of 0 or more. |
|
|
374
|
+
| `timeline-need-from` | error | A box in a timeline has no `from` date. |
|
|
375
|
+
| `bad-date` | error | A `from`, `to` or `today` value is not a real YYYY-MM-DD date, or a `to` is before its `from`. |
|
|
376
|
+
| `empty-step` | warning | A step with no beats. |
|
|
377
|
+
| `small-text` | warning | At the page width, the smallest text is below the minimum size. |
|
|
378
|
+
| `bad-source` | warning | A `source` that is not `path` or `path#symbol`. |
|
|
379
|
+
| `mark-count` | warning | A lifecycle has more than one `start` mark, or a `start` mark and no `end` mark. |
|
|
380
|
+
| `lane-column-taken` | warning | Two boxes in one lane share a time column. |
|
|
381
|
+
| `lane-end-block` | warning | In wrapped lanes, an edge ends at a lane that no block on its side shows. |
|
|
382
|
+
| `stub-crosses-edge` | warning | In wrapped lanes, a stub line crosses another edge. |
|
|
383
|
+
| `timeline-dependency-order` | warning | In a timeline, an item does not start after the item that it depends on ends. |
|
|
384
|
+
| `timeline-and-lanes` | warning | The figure sets `timeline` and `lanes`. The renderers draw the timeline and ignore `lanes`. |
|
|
385
|
+
| `font-estimated` | warning | `theme.font` is set. The SVG check estimates text width for the system font. |
|
|
386
|
+
| `color-not-checked` | warning | A color that the check cannot read, so its contrast is not checked. |
|
|
307
387
|
|
|
308
388
|
| Option | Effect |
|
|
309
389
|
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
@@ -351,39 +431,30 @@ fails when the file or the symbol is gone. The action runs `verify` on every fig
|
|
|
351
431
|
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
|
|
352
432
|
changes.
|
|
353
433
|
|
|
354
|
-
A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-
|
|
434
|
+
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.
|
|
355
435
|
|
|
356
436
|
```yaml
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
437
|
+
# .github/workflows/figures.yml
|
|
438
|
+
name: figures
|
|
439
|
+
on: pull_request
|
|
440
|
+
jobs:
|
|
441
|
+
figures:
|
|
442
|
+
runs-on: ubuntu-latest
|
|
443
|
+
permissions:
|
|
444
|
+
contents: read
|
|
445
|
+
pull-requests: write
|
|
446
|
+
steps:
|
|
447
|
+
- uses: actions/checkout@v4
|
|
448
|
+
with:
|
|
449
|
+
fetch-depth: 0 # the Action reads the old figure from the base commit
|
|
450
|
+
- uses: actions/setup-node@v4
|
|
451
|
+
with:
|
|
452
|
+
node-version: 22
|
|
453
|
+
- uses: iamalvisng/flowfig@v0.6.0
|
|
454
|
+
with:
|
|
455
|
+
figures: 'docs/**/*.svg' # default **/*.svg
|
|
370
456
|
```
|
|
371
457
|
|
|
372
|
-
A state lifecycle marks the first state with `mark: "start"` and each final state with `mark: "end"`.
|
|
373
|
-
|
|
374
|
-

|
|
375
|
-
|
|
376
|
-
## For teams
|
|
377
|
-
|
|
378
|
-
A process is a diagram too. `lanes: true` draws one lane per role and the steps left to right in time order. Each step
|
|
379
|
-
links to a heading in the SOP, so `verify` fails when the SOP changes under the diagram.
|
|
380
|
-
|
|
381
|
-

|
|
382
|
-
|
|
383
|
-
A plan is a figure too. `timeline: true` draws one track per team, with each item as a bar from its `from` date to its `to` date. A milestone has only `from`, and `today` draws the line for now.
|
|
384
|
-
|
|
385
|
-

|
|
386
|
-
|
|
387
458
|
## MCP server
|
|
388
459
|
|
|
389
460
|
`npx flowfig mcp` serves the tools `docs`, `check`, `render`, `verify` and `diff` over stdio, for an agent with no shell.
|
package/dist/browser.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
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
|
-
import { spawn } from 'node:child_process';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
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 = [
|
|
@@ -75,8 +75,12 @@ export function launch(path, profile, timeoutMs = 30_000) {
|
|
|
75
75
|
// The browser starts helper processes in its process group. A helper can outlive the browser and keep a pipe open.
|
|
76
76
|
const killGroup = () => {
|
|
77
77
|
try {
|
|
78
|
-
|
|
78
|
+
// Windows has no process group: taskkill /T stops the helpers of the browser too.
|
|
79
|
+
if (process.platform === 'win32') {
|
|
80
|
+
if (child.pid)
|
|
81
|
+
spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
|
|
79
82
|
child.kill('SIGKILL');
|
|
83
|
+
}
|
|
80
84
|
else if (child.pid)
|
|
81
85
|
process.kill(-child.pid, 'SIGKILL');
|
|
82
86
|
}
|
package/dist/check.js
CHANGED
|
@@ -127,10 +127,11 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
127
127
|
out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
|
|
128
128
|
}
|
|
129
129
|
for (const e of scene.edges) {
|
|
130
|
-
if (e.behind)
|
|
130
|
+
if (e.behind && !e.elbow)
|
|
131
131
|
continue;
|
|
132
|
-
const
|
|
133
|
-
|
|
132
|
+
const line = e.elbow ?? e.pts;
|
|
133
|
+
const pts = line
|
|
134
|
+
? line.slice(1).flatMap((q, k) => Array.from({ length: 17 }, (_, i) => lerp(line[k], q, i / 16)))
|
|
134
135
|
: Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
135
136
|
for (const b of scene.boxes)
|
|
136
137
|
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|
|
@@ -167,13 +168,15 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
|
167
168
|
out.push(warn('stub-crosses-edge', [f.id, e.id], `the stub of edge "${f.id}" crosses edge "${e.id}"`));
|
|
168
169
|
}
|
|
169
170
|
}
|
|
170
|
-
// A stub pill sits inside
|
|
171
|
-
for (const f of
|
|
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
|
+
for (const f of scene.edges.filter((e) => e.label))
|
|
172
173
|
for (const l of scene.lanes ?? []) {
|
|
173
174
|
const [p, r] = [f.label, l.rect];
|
|
174
175
|
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
176
|
if (!inside && overlap(p, r, 0))
|
|
176
|
-
out.push(err('label-overlap', [f.id],
|
|
177
|
+
out.push(err('label-overlap', [f.id], f.pts
|
|
178
|
+
? `the pill of edge "${f.id}" crosses the border of lane "${l.id}"`
|
|
179
|
+
: `the label of edge "${f.id}" crosses the border of lane "${l.id}"`));
|
|
177
180
|
}
|
|
178
181
|
const labeled = scene.edges.filter((e) => e.label);
|
|
179
182
|
labeled.forEach((e, i) => {
|
package/dist/cli.js
CHANGED
|
@@ -229,7 +229,7 @@ if (args[0] === 'gif') {
|
|
|
229
229
|
await (await launching?.catch(() => undefined))?.close();
|
|
230
230
|
try {
|
|
231
231
|
// The retries cover Windows, where the browser can hold a file lock for a short time after the exit.
|
|
232
|
-
rmSync(temp, { recursive: true, force: true, maxRetries:
|
|
232
|
+
rmSync(temp, { recursive: true, force: true, maxRetries: 10 });
|
|
233
233
|
}
|
|
234
234
|
catch (e) {
|
|
235
235
|
console.error(`warning: could not remove ${temp}: ${e.message}`);
|
package/dist/geometry.d.ts
CHANGED
|
@@ -18,12 +18,18 @@ export type Stub = {
|
|
|
18
18
|
short?: true;
|
|
19
19
|
tight: [boolean, boolean];
|
|
20
20
|
};
|
|
21
|
+
/** `elbow` holds the corners of an elbow path, so `check` can test its straight runs. */
|
|
21
22
|
export type Routed = {
|
|
22
23
|
id: string;
|
|
23
24
|
d: string;
|
|
24
25
|
mid: Pt;
|
|
25
26
|
curve: [Pt, Pt, Pt, Pt];
|
|
26
27
|
stub?: Stub;
|
|
28
|
+
elbow?: Pt[];
|
|
29
|
+
};
|
|
30
|
+
/** A rect that a route keeps clear of. `box` marks a box: an elbow's horizontal runs keep clear of a box, not of a label. */
|
|
31
|
+
export type Avoid = Rect & {
|
|
32
|
+
box?: boolean;
|
|
27
33
|
};
|
|
28
34
|
type Around = 'above' | 'below';
|
|
29
35
|
export declare function route(edges: {
|
|
@@ -36,8 +42,12 @@ export declare function route(edges: {
|
|
|
36
42
|
stub?: number[];
|
|
37
43
|
bands?: [Rect | undefined, Rect | undefined];
|
|
38
44
|
labelW?: number;
|
|
39
|
-
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?:
|
|
40
|
-
area?: Rect
|
|
45
|
+
}[], rects: Record<string, Rect>, tips?: Set<string>, avoid?: Avoid[], // what an elbow's vertical run and a stub pill must not cross (the timeline's outside labels; the boxes and gutters of lanes)
|
|
46
|
+
area?: Rect, // where a stub pill may sit: the lanes right of the gutter
|
|
47
|
+
lanes?: {
|
|
48
|
+
bands: Rect[];
|
|
49
|
+
boxes: Rect[];
|
|
50
|
+
}): Routed[];
|
|
41
51
|
/** True if the segment a-b crosses the segment c-d. The ends of a-b are trimmed by 1.5 px, so a touch at a box side does not count. */
|
|
42
52
|
export declare function crosses(a: Pt, b: Pt, c: Pt, d: Pt): boolean;
|
|
43
53
|
export {};
|
package/dist/geometry.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { STUB } from './model.js';
|
|
2
|
+
/** The least gap between a label pill and a lane border. */
|
|
3
|
+
const LABEL_CLEAR = 4;
|
|
2
4
|
const cx = (r) => r.x + r.w / 2;
|
|
3
5
|
const cy = (r) => r.y + r.h / 2;
|
|
4
6
|
/** A point on the cubic Bézier at `t`, as `check` samples an edge. */
|
|
@@ -18,7 +20,8 @@ const bezier = ([p0, p1, p2, p3], t) => {
|
|
|
18
20
|
// `labelW` is the width of an edge's label pill: a stub pill keeps clear of it.
|
|
19
21
|
// `stub` (wrapped lanes: the source pill width, the target pill width, and the width of a shorter source pill) draws a short stub from the source to a pill, and from a second pill into the target.
|
|
20
22
|
export function route(edges, rects, tips = new Set(), avoid = [], // what an elbow's vertical run and a stub pill must not cross (the timeline's outside labels; the boxes and gutters of lanes)
|
|
21
|
-
area
|
|
23
|
+
area, // where a stub pill may sit: the lanes right of the gutter
|
|
24
|
+
lanes) {
|
|
22
25
|
const picks = [];
|
|
23
26
|
for (const e of edges) {
|
|
24
27
|
const a = rects[e.from], b = rects[e.to];
|
|
@@ -143,6 +146,22 @@ area) {
|
|
|
143
146
|
}
|
|
144
147
|
return out.sort((m, q) => m[3] - q[3]).map(([r, a, b]) => [r, a, b]);
|
|
145
148
|
};
|
|
149
|
+
// A diamond with no room beside its point: the stub leaves the side point straight down or up, to a pill that spans its x.
|
|
150
|
+
const along = (w, box, from, dir, band) => {
|
|
151
|
+
if (!tips.has(dir > 0 ? p.from : p.to))
|
|
152
|
+
return [];
|
|
153
|
+
const out = [];
|
|
154
|
+
for (const y of rows(box, from.y + 12, band))
|
|
155
|
+
for (let x = from.x - w + 8; x <= from.x - 8; x += 8) {
|
|
156
|
+
const down = y >= from.y + 12;
|
|
157
|
+
if (!down && y + 18 > from.y - 12)
|
|
158
|
+
continue;
|
|
159
|
+
const r = { x, y, w, h: 18 };
|
|
160
|
+
const [a, b] = [from, { x: from.x, y: down ? y : y + 18 }];
|
|
161
|
+
out.push([r, ...(dir > 0 ? [a, b] : [b, a]), Math.abs(y + 9 - from.y) + Math.abs(x + w / 2 - from.x)]);
|
|
162
|
+
}
|
|
163
|
+
return out.sort((m, q) => m[3] - q[3]).map(([r, a, b]) => [r, a, b]);
|
|
164
|
+
};
|
|
146
165
|
const outs = (w) => [
|
|
147
166
|
...beside(w, p.a, s, 1, sb),
|
|
148
167
|
[
|
|
@@ -155,6 +174,7 @@ area) {
|
|
|
155
174
|
{ x: cx(p.a), y: p.a.y },
|
|
156
175
|
{ x: cx(p.a), y: p.a.y - STUB },
|
|
157
176
|
],
|
|
177
|
+
...along(w, p.a, s, 1, sb),
|
|
158
178
|
];
|
|
159
179
|
const ins = [
|
|
160
180
|
...beside(iw, p.b, e, -1, tb),
|
|
@@ -168,6 +188,7 @@ area) {
|
|
|
168
188
|
{ x: cx(p.b), y: p.b.y + p.b.h + STUB },
|
|
169
189
|
{ x: cx(p.b), y: p.b.y + p.b.h },
|
|
170
190
|
],
|
|
191
|
+
...along(iw, p.b, e, -1, tb),
|
|
171
192
|
];
|
|
172
193
|
const seg = (a, b) => Array.from({ length: 9 }, (_, i) => ({ x: a.x + ((b.x - a.x) * i) / 8, y: a.y + ((b.y - a.y) * i) / 8 }));
|
|
173
194
|
// A stub must not run through a box other than its own, nor through a pill or a label, nor cross an edge path.
|
|
@@ -206,27 +227,79 @@ area) {
|
|
|
206
227
|
};
|
|
207
228
|
}
|
|
208
229
|
if (p.elbow) {
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
// Over 16 px, it tries the midpoint, then 8 px before the end, then 8 px after the start, and takes the first that clears `avoid`.
|
|
212
|
-
const y0 = Math.min(s.y, e.y), y1 = Math.max(s.y, e.y);
|
|
213
|
-
const clear = (x) => !avoid.some((r) => x > r.x - 2 && x < r.x + r.w + 2 && y1 > r.y && y0 < r.y + r.h);
|
|
214
|
-
const mx = e.x - s.x >= 16 ? ([(s.x + e.x) / 2, e.x - 8, s.x + 8].find(clear) ?? (s.x + e.x) / 2) : s.x + 8;
|
|
215
|
-
const c1 = { x: mx, y: s.y }, c2 = { x: mx, y: e.y };
|
|
230
|
+
// Under 16 px of gap, the elbow detours: out 8 px past the start, back to 8 px before the end, then forward into the target.
|
|
231
|
+
// A gap of 0 px or less takes the same detour.
|
|
216
232
|
if (e.x - s.x < 16 && s.y === e.y) {
|
|
217
233
|
// One row: a straight line, since a detour would only double back.
|
|
218
|
-
return { id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e] };
|
|
234
|
+
return { id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e], elbow: [s, e] };
|
|
219
235
|
}
|
|
220
236
|
if (e.x - s.x < 16) {
|
|
221
|
-
const ex = e.x - 8, my = (s.y + e.y) / 2;
|
|
237
|
+
const mx = s.x + 8, ex = e.x - 8, my = (s.y + e.y) / 2;
|
|
222
238
|
return {
|
|
223
239
|
id: p.id,
|
|
224
240
|
d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
|
|
225
241
|
mid: { x: (mx + ex) / 2, y: my },
|
|
226
242
|
curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
|
|
243
|
+
elbow: [s, { x: mx, y: s.y }, { x: mx, y: my }, { x: ex, y: my }, { x: ex, y: e.y }, e],
|
|
227
244
|
};
|
|
228
245
|
}
|
|
229
|
-
|
|
246
|
+
// Over 16 px, the vertical run tries the midpoint, 8 px before the end, 8 px after the start, then any x in the gap.
|
|
247
|
+
// A vertical run keeps clear of every label and box; a horizontal run keeps clear of every box but the two ends.
|
|
248
|
+
const own = (r) => [p.a, p.b].some((q) => q.x === r.x && q.y === r.y && q.w === r.w && q.h === r.h);
|
|
249
|
+
const all = avoid.filter((r) => !own(r));
|
|
250
|
+
const boxes = all.filter((r) => r.box);
|
|
251
|
+
// `m` is the margin: 2 px in the gap, 8 px on a detour, so a detour does not run along a box side.
|
|
252
|
+
const vClear = (x, ya, yb, m = 2) => !all.some((r) => x > r.x - m && x < r.x + r.w + m && Math.max(ya, yb) > r.y && Math.min(ya, yb) < r.y + r.h);
|
|
253
|
+
const hClear = (y, xa, xb, list = boxes, m = 2) => !list.some((r) => y > r.y - m && y < r.y + r.h + m && Math.max(xa, xb) > r.x && Math.min(xa, xb) < r.x + r.w);
|
|
254
|
+
const mid = (s.x + e.x) / 2;
|
|
255
|
+
const gap = Array.from({ length: Math.floor((e.x - s.x - 16) / 4) + 1 }, (_, i) => s.x + 8 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
|
|
256
|
+
const mx = [mid, e.x - 8, s.x + 8, ...gap].find((x) => vClear(x, s.y, e.y) && hClear(s.y, s.x, x) && hClear(e.y, x, e.x));
|
|
257
|
+
if (mx == null && boxes.length) {
|
|
258
|
+
// No x in the gap is clear: the elbow goes around. It runs right past the boxes in its way, along a clear row, back to
|
|
259
|
+
// a clear x at or before 8 px before the target, then down or up and forward into the target. The shortest clear
|
|
260
|
+
// detour wins; ties take the row nearest the middle.
|
|
261
|
+
const right = Math.max(...boxes.map((r) => r.x + r.w)) + 12;
|
|
262
|
+
const left = Math.min(...boxes.map((r) => r.x));
|
|
263
|
+
const [top, bottom] = [Math.min(...boxes.map((r) => r.y)) - 12, Math.max(...boxes.map((r) => r.y + r.h)) + 12];
|
|
264
|
+
let best;
|
|
265
|
+
// ponytail: a grid search in 4 px steps, about 10^5 tries for a figure the size of the roadmap.
|
|
266
|
+
for (let x = s.x + 8; x <= right; x += 4) {
|
|
267
|
+
if (!hClear(s.y, s.x, x))
|
|
268
|
+
continue;
|
|
269
|
+
for (let ex = e.x - 8; ex >= left; ex -= 4) {
|
|
270
|
+
if (!hClear(e.y, ex, e.x))
|
|
271
|
+
continue;
|
|
272
|
+
for (let y = top; y <= bottom; y += 4) {
|
|
273
|
+
const len = x - s.x + Math.abs(y - s.y) + Math.abs(x - ex) + Math.abs(e.y - y) + e.x - ex;
|
|
274
|
+
const off = Math.abs(y - (s.y + e.y) / 2);
|
|
275
|
+
if (best && (len > best.len || (len === best.len && off >= best.off)))
|
|
276
|
+
continue;
|
|
277
|
+
if (vClear(x, s.y, y, 8) && hClear(y, x, ex, all, 8) && vClear(ex, y, e.y))
|
|
278
|
+
best = { len, off, x, y, ex };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (best) {
|
|
283
|
+
const { x, y, ex } = best;
|
|
284
|
+
return {
|
|
285
|
+
id: p.id,
|
|
286
|
+
d: `M ${s.x} ${s.y} H ${x} V ${y} H ${ex} V ${e.y} H ${e.x}`,
|
|
287
|
+
mid: { x: (x + ex) / 2, y },
|
|
288
|
+
curve: [s, { x, y }, { x: ex, y }, e],
|
|
289
|
+
elbow: [s, { x, y: s.y }, { x, y }, { x: ex, y }, { x: ex, y: e.y }, e],
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
// If no path is clear, the midpoint stays and `check` reports the crossing.
|
|
294
|
+
const x = mx ?? mid;
|
|
295
|
+
const c1 = { x, y: s.y }, c2 = { x, y: e.y };
|
|
296
|
+
return {
|
|
297
|
+
id: p.id,
|
|
298
|
+
d: `M ${s.x} ${s.y} H ${x} V ${e.y} H ${e.x}`,
|
|
299
|
+
mid: { x, y: (s.y + e.y) / 2 },
|
|
300
|
+
curve: [s, c1, c2, e],
|
|
301
|
+
elbow: [s, c1, c2, e],
|
|
302
|
+
};
|
|
230
303
|
}
|
|
231
304
|
if (p.around) {
|
|
232
305
|
// ponytail: arcs 50px past the two ends' boxes; a taller box in between can still be crossed.
|
|
@@ -246,6 +319,8 @@ area) {
|
|
|
246
319
|
continue;
|
|
247
320
|
const r = one(p);
|
|
248
321
|
done.set(p, r);
|
|
322
|
+
if (p.labelW && lanes)
|
|
323
|
+
r.mid = labelInBand(r, p.labelW, lanes, pills);
|
|
249
324
|
if (p.labelW)
|
|
250
325
|
pills.push({ x: r.mid.x - p.labelW / 2, y: r.mid.y - 9, w: p.labelW, h: 18 });
|
|
251
326
|
track(Array.from({ length: 33 }, (_, i) => bezier(r.curve, i / 32)));
|
|
@@ -255,6 +330,32 @@ area) {
|
|
|
255
330
|
done.set(p, one(p));
|
|
256
331
|
return picks.map((p) => done.get(p));
|
|
257
332
|
}
|
|
333
|
+
/** The point of the path nearest its middle where a label pill of width `w` sits inside one band, LABEL_CLEAR from the border, clear of the boxes
|
|
334
|
+
* and the earlier pills. If a short run leaves no such point, the pill keeps the x of a path point and moves up or down into the nearest
|
|
335
|
+
* band, still on the path. If that fails too, the middle. Both renderers route through this, so they place the label alike. */
|
|
336
|
+
function labelInBand(r, w, { bands, boxes }, pills) {
|
|
337
|
+
const hit = (a, b, m) => a.x < b.x + b.w + m && b.x < a.x + a.w + m && a.y < b.y + b.h + m && b.y < a.y + a.h + m;
|
|
338
|
+
const ts = Array.from({ length: 65 }, (_, i) => i / 64).sort((a, b) => Math.abs(a - 0.5) - Math.abs(b - 0.5));
|
|
339
|
+
const path = ts.map((t) => bezier(r.curve, t));
|
|
340
|
+
const clear = (pill) => !boxes.some((b) => hit(pill, b, 1)) && !pills.some((b) => hit(pill, b, 1));
|
|
341
|
+
const room = (x, b) => x - w / 2 >= b.x + LABEL_CLEAR && x + w / 2 <= b.x + b.w - LABEL_CLEAR;
|
|
342
|
+
for (const q of path) {
|
|
343
|
+
const pill = { x: q.x - w / 2, y: q.y - 9, w, h: 18 };
|
|
344
|
+
const fits = bands.some((b) => room(q.x, b) && pill.y >= b.y + LABEL_CLEAR && pill.y + 18 <= b.y + b.h - LABEL_CLEAR);
|
|
345
|
+
if (fits && clear(pill))
|
|
346
|
+
return q;
|
|
347
|
+
}
|
|
348
|
+
for (const q of path)
|
|
349
|
+
for (const b of bands) {
|
|
350
|
+
if (!room(q.x, b) || b.h < 18 + LABEL_CLEAR * 2)
|
|
351
|
+
continue;
|
|
352
|
+
const y = Math.min(Math.max(q.y, b.y + LABEL_CLEAR + 9), b.y + b.h - LABEL_CLEAR - 9);
|
|
353
|
+
const pill = { x: q.x - w / 2, y: y - 9, w, h: 18 };
|
|
354
|
+
if (clear(pill) && path.some((o) => o.x >= pill.x && o.x <= pill.x + w && o.y >= pill.y && o.y <= pill.y + 18))
|
|
355
|
+
return { x: q.x, y };
|
|
356
|
+
}
|
|
357
|
+
return r.mid;
|
|
358
|
+
}
|
|
258
359
|
/** True if the segment a-b crosses the segment c-d. The ends of a-b are trimmed by 1.5 px, so a touch at a box side does not count. */
|
|
259
360
|
export function crosses(a, b, c, d) {
|
|
260
361
|
const len = Math.hypot(b.x - a.x, b.y - a.y);
|
package/dist/guide.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** The full guide, as Markdown. `flowfig docs` prints it. */
|
|
2
|
-
export declare const GUIDE = "# flowfig guide\n\nflowfig draws animated flow figures. A figure has boxes, the edges between the boxes, and steps. In each step, a packet moves along\nthe edges. The output is one animated SVG with no script. The SVG plays in a GitHub README, in PR and issue comments, and in blog\nposts. A figure is about 5 to 50 kB.\n\nYou write a JSON spec. You never write SVG by hand. The command `npx flowfig` checks the spec and renders it.\n\n## What flowfig draws\n\nA spec has three forms.\n\n- The map (the default). The map shows the boxes and the edges. A packet moves along an edge in each beat.\n- The map with a rail (`\"rail\": true`). The rail is a lifeline diagram under the map. It has one row for each message.\n- The rail alone (`\"rail\": \"only\"`). Use it when the reader needs only the order of the messages, for example when the map is already in the same document.\n\nUse the rail for a sequence of messages: a request lifecycle, an API call chain, or a Mermaid `sequenceDiagram`. Put each message on\nan edge. Put each phase in a step. Put messages that run at the same time in one beat. Mark a message that does not wait for an\nanswer with `\"async\": true`: every send to a queue or a topic, every emitted event, and every call the caller does not await.\n\n## Scope: what flowfig draws and what it does not\n\nflowfig draws how something works: parts, the messages between them, and the order of the messages.\n\n| The user asks for | Draw |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------ |\n| An architecture, a data flow, a pipeline | The map. Group parts by service, network or trust boundary. `store` for data at rest. |\n| A request lifecycle, a call chain | The map with `\"rail\": true`. One message per hop, with its real payload in `data`. |\n| A sequence diagram only | `\"rail\": \"only\"`. |\n| A state lifecycle, a state machine | One box per state, one edge per transition, labeled with its event. One step per path. Mark the first state with `mark: \"start\"` and each final state with `mark: \"end\"`. |\n| A flowchart with branches | `shape: \"decision\"` for each branch. One step per path. |\n| A roadmap or a timeline | `timeline: true`. One labeled group per track, in a `column` group. Items with `from` and `to` (dates), a milestone with `from` only, an edge for a dependency, `today` for the line. A dependent item starts after its source ends. |\n| A process across roles (a ticket, an order, a refund) | `lanes: true`. One labeled group per role, in a `column` group. Put each step in the lane of the role that does it. Link each step with `source` to the SOP heading. If the steps do not fit the width, flowfig wraps the time columns into blocks and links the blocks with labeled stubs, so keep the whole process in one figure. Give the outcomes of one decision the same `at`. |\n\nflowfig draws a roadmap or a timeline with dates (`timeline: true`), and converts a Mermaid `gantt` to it. flowfig does not\ndraw class or ER diagrams (fields, types, cardinality), charts of numbers or mind maps. If the user asks for one of these, say that\nflowfig does not draw it, suggest Mermaid (`classDiagram`, `erDiagram`), and draw nothing with flowfig.\n\n## Workflow\n\n### 1. Pick the scope\n\nIf the code has more than one flow, name each flow in the reply. Then draw one flow, or draw one structure view and call it a\nstructure view. If the user asks for the whole system, draw one top view with groups, or a set of figures that together cover\nevery part. Keep each figure to 12 boxes or fewer.\n\n### 2. Write the fact list\n\nBefore the spec, write a list of the facts that the figure must show. Take each fact from the code and from its wiring: config\nfiles, compose files, route tables and queue bindings (or from the pasted Mermaid). Give each fact its `file:line`:\n\n- every part (service, function, store, queue, external system);\n- every call, in order, written as `caller \u2192 callee: payload (file:line of the call)`. The edge in the spec goes from that caller\n to that callee;\n- every call that does not wait for an answer (fire and forget, a queue, a webhook, a callback), and every consumer of each queue\n or topic;\n- every branch and every error path (a rejected input, a retry, a dead-letter path);\n- every end state;\n- for a process document (an SOP): every deadline, every message to a person (an email, a notice), every choice that a person\n makes, and every wait for a reply. Put each one in a box `sub`, a step `say` or a `data` card. A small fact still counts.\n\nIf the code does not show a fact, leave the fact out. Do not guess a part, a name or an order.\n\n### 3. Write the spec\n\nEach fact becomes a box, an edge, a beat, a `data` card or a `show` row. Use the real names from the code as labels, and\nreal example data from the code or its tests. Keep each fact that you leave out, with the reason, for the reply.\nGive each box and edge that draws code a `source`, from the fact list: `\"src/auth/login.ts#verifyPassword\"`. A store or an outside part may have none.\nA box that draws a step from a document links to that document: `\"docs/sop/refunds.md#step-3-approve-the-refund\"`, the heading as a GitHub anchor. `verify` checks it the same way.\n\nMermaid mapping:\n\n| Mermaid | flowfig |\n| ---------------------------------------- | ------------------------------------------------------------------ |\n| `participant` | a box |\n| `A->>B: text` | an edge from A to B, and a hop with `data: \"text\"` |\n| `B-->>A: text` (a reply) | a hop on the same edge with `back: true` |\n| `A-)B: text` (async) | a hop with `async: true` |\n| `par ... and ... end` | the hops of all branches in one beat |\n| `alt` / `opt` | one step per path |\n| `stateDiagram` states and transitions | boxes, and edges labeled with the event |\n\n### 4. Render, then fix\n\n```bash\nnpx flowfig - out.svg <<'SPEC'\n{ \"props\": { \"layout\": ..., \"edges\": [...], \"steps\": [...] } }\nSPEC\n```\n\nThe quoted `<<'SPEC'` heredoc passes the JSON with no change. The command checks the spec first. If the check finds an error, the\ncommand writes no SVG and lists the faults. Fix each fault and render again.\n\n- Fix a layout fault by changing the layout first: put parts in rows or columns, route an edge `around`, or change a gap.\n Never remove a fact from the fact list to pass the check. If you must remove one, name it in the reply.\n- If `small-text` appears, the figure is too wide. Put the parts in two rows, move a detail from `label` to `sub`, or split\n the figure. Use more steps in one figure before you make a second figure. Keep the real name from the code in the label:\n `ReportsController` stays `ReportsController`, not `Controller`. Do not change `--width` to pass the check: the\n reader sees the figure at the real page width.\n- Fix every other warning, or tell the user why it stays.\n- Do not use `--no-check` to hide a fault.\n\n### 5. Read the figure back\n\nRun `npx flowfig --spec out.svg`. Compare the spec with the fact list. Check that each edge goes from the caller to the callee\nthat its fact names. Add each fact that is missing, fix each wrong edge, and render again.\n\nRun `npx flowfig verify out.svg` from the repo root. Fix each `missing-file` or `missing-symbol`.\n\n### 6. Look at the SVG\n\nThe check estimates the text width, so it cannot see everything. If you can open a browser, open the SVG and take a\nscreenshot. Wait a few seconds and take a second screenshot to see a later beat. If you cannot open a browser, write \"not\nlooked at\" in the reply. Never report a look that you did not do. A browser tool can block `file://` URLs. If it does, serve the folder\nwith `python3 -m http.server`.\n\n### 7. Write the reply\n\nThe reply has these parts, in this order:\n\n1. the path of each SVG;\n2. what each figure shows: the parts and the steps. Copy the counts from the `figure:` line of the check output. Copy every name\n from the read-back spec. Never count from memory;\n3. the scope: the flow you drew, why, and the other flows that exist;\n4. what the figure leaves out, and why;\n5. the two check lines that the render printed, `0 errors, 0 warnings` and `figure: ...`, copied as printed, and whether\n you looked at the SVG;\n6. one line `npx flowfig open <path>` for each SVG, at the end of the reply. Do not run that command yourself.\n\nTo change an SVG later, print its spec with `--spec`, change the spec, and render again.\n\n## Spec reference\n\n`props` has these fields.\n\n| Field | Type | Meaning |\n| ---------- | ----------------------- | ---------------------------------------------------------------------------------------------- |\n| `layout` | group | The boxes and how they line up. Required. |\n| `edges` | edge[] | The arrows between the boxes. Required. |\n| `steps` | step[] | The stories the figure tells. With no steps, the figure is a still map. |\n| `lanes` | `true` | Draw the layout as swimlanes: a `column` group of labeled groups, one per role. The boxes run left to right in step order. |\n| `timeline` | `true` | Draw the layout as a timeline: one labeled group per track, with the boxes placed by their `from` and `to` dates. |\n| `today` | `YYYY-MM-DD` | In a `timeline` figure: the date where the today line stops. Default: the last date of the items. |\n| `rail` | `true` or `\"only\"` | Draw the steps as a lifeline rail under the map. `\"only\"` draws the rail without the map. |\n| `speed` | number | Milliseconds a packet takes to cross one edge. Default: 900. |\n| `theme` | object | Colors: `accent`, `fg`, `muted`, `bg`, `surface`, `border`, `font`. Each color you omit keeps its built-in value. |\n\n### layout\n\nThe layout is a tree. A group has `children` and these optional fields.\n\n- `id`: a name that an edge can use to reach the group as a whole.\n- `label`: the title on the frame. Only a group with a `label` gets a frame.\n- `direction`: `\"row\"` (the default) or `\"column\"`.\n- `gap`: the space between children in px. Default: 28 for a column. For a row, the gap fits the widest edge label between two children (at least 56).\n- `align`: `\"start\"`, `\"center\"` or `\"end\"`. This sets where the children line up across the direction.\n\nEvery other item is a box.\n\n| Box field | Meaning |\n| --------- | --------------------------------------------------------------------------------------------------------- |\n| `id` | Required. The name that edges and steps use. It must be unique in the figure. |\n| `label` | Required. The title in the box. |\n| `sub` | A smaller line under the label. |\n| `shape` | `\"box\"` (default), `\"decision\"` (a diamond) or `\"store\"` (a database cylinder for data at rest). |\n| `at` | In a `lanes` figure: the time column of the box, 0 first. Default: the order in which the steps first reach the box. |\n| `from` | In a `timeline` figure: the start of the item, or the date of a milestone, as YYYY-MM-DD. |\n| `to` | In a `timeline` figure: the last day of the item, as YYYY-MM-DD. Without it the box is a milestone. |\n| `width` | Width in px. This overrides the width that the layout picks. |\n| `source` | The code this draws: `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. |\n| `lines` | The least number of text lines that a content card keeps. The card still grows to fit its content. |\n| `tone` | The state color of the box: a 1 px border and a light tint. Use `blue`, `purple`, `green`, `orange`, `red` or `gray`. |\n| `mark` | `\"start\"` or `\"end\"` | A lifecycle mark: a filled dot before a start state, a ringed dot after an end state. |\n\n### edges\n\nAn edge has these fields.\n\n- `id`: the name that steps use. Default: `from->to`.\n- `from` and `to`: the `id` of a box or a group.\n- `label`: text on the edge, drawn as a small pill.\n- `around`: `\"above\"` or `\"below\"`. This routes the edge over or under the boxes in between (loops, skip-ahead edges).\n- `quiet`: if `true`, the SVG draws the edge only while a step uses it.\n- `source`: the code this edge draws, as `path` or `path#symbol`. `flowfig verify` checks it.\n\n### steps\n\nA step has `label` and `flow`. It can also have `caption` (the line under the figure while no beat has a `say`) and `nodes` (ids of\nboxes to highlight for the whole step).\n\n`flow` is a list of beats. A beat is one of these:\n\n- an edge id (a string);\n- an array of edge ids, which run at the same time;\n- an object `{ edges?, say?, show?, light?, ms? }`. A beat with no `edges` is a pause.\n\nThe beat fields:\n\n- `edges`: an edge id, a hop object, or an array of these. A hop object is `{ edge, back?, data?, tone?, async?, source? }`.\n - `back: true` runs the packet from the `to` box to the `from` box.\n - `data` is a small card that moves with the packet.\n - `async: true` marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag.\n - `tone`: the color of this hop for its beat. The packet, the data card, the edge, the label pill and the arrival box take the color. The trail after the beat stays the accent.\n - `green`: a success or a cache hit. `orange`: a warning, a miss or a retry. `red`: an error or a rejected input.\n - `gray`: idle or skipped. `purple`: async or background. `blue`: the default row color.\n - If the hop has no `tone`, the arrival box uses its box `tone`, then the accent. A hop `tone` wins over a box `tone`.\n- `say`: the caption for the beat.\n- `show`: `{ boxId: [row, ...] }`. This fills the content card of each named box. The card keeps the rows until the step ends.\n- `light`: ids of boxes to highlight for this beat.\n- `ms`: how long the beat lasts. Default: the time one packet needs to cross an edge.\n\nA row of a content card is `{ tag?, tone?, text, meta?, mark?, mono? }`.\n\n- `tag`: a short colored label at the start of the line.\n- `tone`: the color of the tag: `blue` (default), `purple`, `green`, `orange`, `red` or `gray`.\n- `text`: the main text.\n- `meta`: muted text after the main text.\n- `mark`: a mark at the right end of the line, such as a check or \"new\".\n- `mono`: if `true`, the text uses a monospace font.\n\n## Content rules\n\n- Give every box a stable `id`.\n- Use `shape: \"store\"` for data at rest (a database, a cache, a file, a queue). Use a plain box for a part that does work. Use `shape: \"decision\"` for a branch.\n- Keep one clear main path, left to right. Put side parts in a `column` group.\n- Use `quiet: true` on a long edge that crosses the picture. A quiet edge needs a beat that uses it, or the check reports\n `hidden-edge`.\n- In a rail, draw an error reply as a hop back on the same edge, with the error in `data` (`{ \"edge\": \"login\", \"back\": true,\n \"data\": \"401 CREDENTIALS_INVALID\" }`). A call that stays inside one part (a hash check, a guard) is not a message: put it in\n `say` or in a `show` row of that part.\n- Keep an SVG to one or two steps. The SVG plays every step in a loop, with no controls.\n- Use the React player, `<Flow {...props} />` from `flowfig`, for a page that needs tabs, pause or hover.\n\n## Example\n\n```json\n{\n \"props\": {\n \"layout\": {\n \"children\": [\n { \"id\": \"client\", \"label\": \"Browser\" },\n { \"id\": \"api\", \"label\": \"API Server\" },\n { \"id\": \"db\", \"label\": \"Database\", \"shape\": \"store\" }\n ]\n },\n \"edges\": [\n { \"id\": \"req\", \"from\": \"client\", \"to\": \"api\", \"label\": \"GET /users/42\" },\n { \"id\": \"q\", \"from\": \"api\", \"to\": \"db\", \"label\": \"SELECT\" }\n ],\n \"steps\": [\n {\n \"label\": \"read\",\n \"flow\": [\n { \"edges\": { \"edge\": \"req\", \"data\": \"GET /users/42\" }, \"say\": \"The browser asks for user 42.\" },\n { \"edges\": \"q\", \"show\": { \"db\": [{ \"tag\": \"row\", \"tone\": \"gray\", \"text\": \"Ada Lovelace\" }] }, \"say\": \"The API server reads the row.\" },\n { \"edges\": { \"edge\": \"req\", \"back\": true, \"data\": \"200 OK\" }, \"say\": \"The API server sends the user.\" }\n ]\n }\n ]\n }\n}\n```\n\n## Commands\n\n```bash\nnpx flowfig - out.svg < spec.json # render a spec from stdin\nnpx flowfig spec.json out.svg # render a spec file\nnpx flowfig figure.ts out.svg # render a module whose default export is a spec\nnpx flowfig check <input> [--json] # list the faults. The input can also be an SVG that flowfig wrote.\nnpx flowfig verify <input>... # check that the code each source names still exists\nnpx flowfig diff <old> <new> # list what changed in the spec between two figures\nnpx flowfig --spec out.svg # print the spec that an SVG carries\nnpx flowfig open out.svg # show the figure in the default browser\nnpx flowfig gif out.svg [out.gif] # write an animated GIF (needs Chrome, Edge, Chromium or Brave)\nnpx flowfig docs # print this guide\nnpx flowfig init # write these instructions for the coding agents of a repo\n```\n\nA render checks first and writes nothing on an error. `--strict` makes warnings errors. `--width` sets the page width.\n`--min-text` sets the smallest text that the reader should get. `--no-check` skips the check.\n";
|
|
2
|
+
export declare const GUIDE = "# flowfig guide\n\nflowfig draws animated flow figures. A figure has boxes, the edges between the boxes, and steps. In each step, a packet moves along\nthe edges. The output is one animated SVG with no script. The SVG plays in a GitHub README, in PR and issue comments, and in blog\nposts. A figure is about 5 to 50 kB.\n\nYou write a JSON spec. You never write SVG by hand. The command `npx flowfig` checks the spec and renders it.\n\n## What flowfig draws\n\nA spec has three forms.\n\n- The map (the default). The map shows the boxes and the edges. A packet moves along an edge in each beat.\n- The map with a rail (`\"rail\": true`). The rail is a lifeline diagram under the map. It has one row for each message.\n- The rail alone (`\"rail\": \"only\"`). Use it when the reader needs only the order of the messages, for example when the map is already in the same document.\n\nUse the rail for a sequence of messages: a request lifecycle, an API call chain, or a Mermaid `sequenceDiagram`. Put each message on\nan edge. Put each phase in a step. Put messages that run at the same time in one beat. Mark a message that does not wait for an\nanswer with `\"async\": true`: every send to a queue or a topic, every emitted event, and every call the caller does not await.\n\n## Scope: what flowfig draws and what it does not\n\nflowfig draws how something works: parts, the messages between them, and the order of the messages.\n\n| The user asks for | Draw |\n| ------------------------------------------ | ------------------------------------------------------------------------------------------ |\n| An architecture, a data flow, a pipeline | The map. Group parts by service, network or trust boundary. `store` for data at rest. |\n| A request lifecycle, a call chain | The map with `\"rail\": true`. One message per hop, with its real payload in `data`. |\n| A sequence diagram only | `\"rail\": \"only\"`. |\n| A state lifecycle, a state machine | One box per state, one edge per transition, labeled with its event. One step per path. Mark the first state with `mark: \"start\"` and each final state with `mark: \"end\"`. |\n| A flowchart with branches | `shape: \"decision\"` for each branch. One step per path. |\n| A roadmap or a timeline | `timeline: true`. One labeled group per track, in a `column` group. Items with `from` and `to` (dates), a milestone with `from` only, an edge for a dependency, `today` for the line. A dependent item starts after its source ends. |\n| A process across roles (a ticket, an order, a refund) | `lanes: true`. One labeled group per role, in a `column` group. Put each step in the lane of the role that does it. Link each step with `source` to the SOP heading. If the steps do not fit the width, flowfig wraps the time columns into blocks and links the blocks with labeled stubs, so keep the whole process in one figure. Give the outcomes of one decision the same `at`. |\n\nflowfig draws a roadmap or a timeline with dates (`timeline: true`), and converts a Mermaid `gantt` to it. flowfig does not\ndraw class or ER diagrams (fields, types, cardinality), charts of numbers or mind maps. If the user asks for one of these, say that\nflowfig does not draw it, suggest Mermaid (`classDiagram`, `erDiagram`), and draw nothing with flowfig.\n\n## Workflow\n\n### 1. Pick the scope\n\nIf the code has more than one flow, name each flow in the reply. Then draw one flow, or draw one structure view and call it a\nstructure view. If the user asks for the whole system, draw one top view with groups, or a set of figures that together cover\nevery part. Keep each figure to 12 boxes or fewer.\n\n### 2. Write the fact list\n\nBefore the spec, write a list of the facts that the figure must show. Take each fact from the code and from its wiring: config\nfiles, compose files, route tables and queue bindings (or from the pasted Mermaid). Give each fact its `file:line`:\n\n- every part (service, function, store, queue, external system);\n- every call, in order, written as `caller \u2192 callee: payload (file:line of the call)`. The edge in the spec goes from that caller\n to that callee;\n- every call that does not wait for an answer (fire and forget, a queue, a webhook, a callback), and every consumer of each queue\n or topic;\n- every branch and every error path (a rejected input, a retry, a dead-letter path);\n- every end state;\n- for a process document (an SOP): every deadline, every message to a person (an email, a notice), every choice that a person\n makes, and every wait for a reply. Put each one in a box `sub`, a step `say` or a `data` card. A small fact still counts.\n Draw each path to its end: after a rejection, also draw the next step that the document gives. Give each path its own step.\n\nIf the code does not show a fact, leave the fact out. Do not guess a part, a name or an order.\n\n### 3. Write the spec\n\nEach fact becomes a box, an edge, a beat, a `data` card or a `show` row. Use the real names from the code as labels, and\nreal example data from the code or its tests. Keep each fact that you leave out, with the reason, for the reply.\nGive each box and edge that draws code a `source`, from the fact list: `\"src/auth/login.ts#verifyPassword\"`. A store or an outside part may have none.\nA box that draws a step from a document links to that document: `\"docs/sop/refunds.md#step-3-approve-the-refund\"`, the heading as a GitHub anchor. `verify` checks it the same way.\n\nMermaid mapping:\n\n| Mermaid | flowfig |\n| ---------------------------------------- | ------------------------------------------------------------------ |\n| `participant` | a box |\n| `A->>B: text` | an edge from A to B, and a hop with `data: \"text\"` |\n| `B-->>A: text` (a reply) | a hop on the same edge with `back: true` |\n| `A-)B: text` (async) | a hop with `async: true` |\n| `par ... and ... end` | the hops of all branches in one beat |\n| `alt` / `opt` | one step per path |\n| `stateDiagram` states and transitions | boxes, and edges labeled with the event |\n\nA pasted design wins over the async rule: keep each arrow as the Mermaid draws it. `A->>B` stays a plain hop, also for a\nsend to a queue. Mark a hop async only for `-)`. If you think the design is wrong, say so in the reply.\n\n### 4. Render, then fix\n\n```bash\nnpx flowfig - out.svg <<'SPEC'\n{ \"props\": { \"layout\": ..., \"edges\": [...], \"steps\": [...] } }\nSPEC\n```\n\nThe quoted `<<'SPEC'` heredoc passes the JSON with no change. The command checks the spec first. If the check finds an error, the\ncommand writes no SVG and lists the faults. Fix each fault and render again.\n\n- Fix a layout fault by changing the layout first: put parts in rows or columns, route an edge `around`, or change a gap.\n Never remove a fact from the fact list to pass the check. If you must remove one, name it in the reply.\n- If `small-text` appears, the figure is too wide. Put the parts in two rows, move a detail from `label` to `sub`, or split\n the figure. Use more steps in one figure before you make a second figure. Keep the real name from the code in the label:\n `ReportsController` stays `ReportsController`, not `Controller`. Do not change `--width` to pass the check: the\n reader sees the figure at the real page width.\n- If `text-overflow` appears, a text is wider than its box. Give the box a larger `width`, move a detail from `label` to\n `sub`, or put the detail in the step `say`. Never cut a fact, a number or a unit to make a text fit: \"within 30 days of\n delivery\" does not become \"within 30 days\". If you must shorten a text, keep every fact and name the change in the reply.\n- Fix every other warning, or tell the user why it stays.\n- Do not use `--no-check` to hide a fault.\n\n### 5. Read the figure back\n\nRun `npx flowfig --spec out.svg`. Compare the spec with the fact list. Check that each edge goes from the caller to the callee\nthat its fact names. Add each fact that is missing, fix each wrong edge, and render again.\n\nRun `npx flowfig verify out.svg` from the repo root. Fix each `missing-file` or `missing-symbol`.\n\n### 6. Look at the SVG\n\nThe check estimates the text width, so it cannot see everything. If you can open a browser, open the SVG and take a\nscreenshot. Wait a few seconds and take a second screenshot to see a later beat. If you cannot open a browser, write \"not\nlooked at\" in the reply. Never report a look that you did not do. A browser tool can block `file://` URLs. If it does, serve the folder\nwith `python3 -m http.server`.\n\n### 7. Write the reply\n\nThe reply has these parts, in this order:\n\n1. the path of each SVG;\n2. what each figure shows: the parts and the steps. Copy the counts from the `figure:` line of the check output. Copy every name\n from the read-back spec. Never count from memory;\n3. the scope: the flow you drew, why, and the other flows that exist;\n4. what the figure leaves out, and why;\n5. the two check lines that the render printed, `0 errors, 0 warnings` and `figure: ...`, copied as printed, and whether\n you looked at the SVG;\n6. one line `npx flowfig open <path>` for each SVG, at the end of the reply. Do not run that command yourself.\n\nTo change an SVG later, print its spec with `--spec`, change the spec, and render again.\n\n## Spec reference\n\n`props` has these fields.\n\n| Field | Type | Meaning |\n| ---------- | ----------------------- | ---------------------------------------------------------------------------------------------- |\n| `layout` | group | The boxes and how they line up. Required. |\n| `edges` | edge[] | The arrows between the boxes. Required. |\n| `steps` | step[] | The stories the figure tells. With no steps, the figure is a still map. |\n| `lanes` | `true` | Draw the layout as swimlanes: a `column` group of labeled groups, one per role. The boxes run left to right in step order. |\n| `timeline` | `true` | Draw the layout as a timeline: one labeled group per track, with the boxes placed by their `from` and `to` dates. |\n| `today` | `YYYY-MM-DD` | In a `timeline` figure: the date where the today line stops. Default: the last date of the items. |\n| `rail` | `true` or `\"only\"` | Draw the steps as a lifeline rail under the map. `\"only\"` draws the rail without the map. |\n| `speed` | number | Milliseconds a packet takes to cross one edge. Default: 900. |\n| `theme` | object | Colors: `accent`, `fg`, `muted`, `bg`, `surface`, `border`, `font`. Each color you omit keeps its built-in value. |\n\n### layout\n\nThe layout is a tree. A group has `children` and these optional fields.\n\n- `id`: a name that an edge can use to reach the group as a whole.\n- `label`: the title on the frame. Only a group with a `label` gets a frame.\n- `direction`: `\"row\"` (the default) or `\"column\"`.\n- `gap`: the space between children in px. Default: 28 for a column. For a row, the gap fits the widest edge label between two children (at least 56).\n- `align`: `\"start\"`, `\"center\"` or `\"end\"`. This sets where the children line up across the direction.\n\nEvery other item is a box.\n\n| Box field | Meaning |\n| --------- | --------------------------------------------------------------------------------------------------------- |\n| `id` | Required. The name that edges and steps use. It must be unique in the figure. |\n| `label` | Required. The title in the box. |\n| `sub` | A smaller line under the label. |\n| `shape` | `\"box\"` (default), `\"decision\"` (a diamond) or `\"store\"` (a database cylinder for data at rest). |\n| `at` | In a `lanes` figure: the time column of the box, 0 first. Default: the order in which the steps first reach the box. |\n| `from` | In a `timeline` figure: the start of the item, or the date of a milestone, as YYYY-MM-DD. |\n| `to` | In a `timeline` figure: the last day of the item, as YYYY-MM-DD. Without it the box is a milestone. |\n| `width` | Width in px. This overrides the width that the layout picks. |\n| `source` | The code this draws: `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. |\n| `lines` | The least number of text lines that a content card keeps. The card still grows to fit its content. |\n| `tone` | The state color of the box: a 1 px border and a light tint. Use `blue`, `purple`, `green`, `orange`, `red` or `gray`. |\n| `mark` | `\"start\"` or `\"end\"` | A lifecycle mark: a filled dot before a start state, a ringed dot after an end state. |\n\n### edges\n\nAn edge has these fields.\n\n- `id`: the name that steps use. Default: `from->to`.\n- `from` and `to`: the `id` of a box or a group.\n- `label`: text on the edge, drawn as a small pill.\n- `around`: `\"above\"` or `\"below\"`. This routes the edge over or under the boxes in between (loops, skip-ahead edges).\n- `quiet`: if `true`, the SVG draws the edge only while a step uses it.\n- `source`: the code this edge draws, as `path` or `path#symbol`. `flowfig verify` checks it.\n\n### steps\n\nA step has `label` and `flow`. It can also have `caption` (the line under the figure while no beat has a `say`) and `nodes` (ids of\nboxes to highlight for the whole step).\n\n`flow` is a list of beats. A beat is one of these:\n\n- an edge id (a string);\n- an array of edge ids, which run at the same time;\n- an object `{ edges?, say?, show?, light?, ms? }`. A beat with no `edges` is a pause.\n\nThe beat fields:\n\n- `edges`: an edge id, a hop object, or an array of these. A hop object is `{ edge, back?, data?, tone?, async?, source? }`.\n - `back: true` runs the packet from the `to` box to the `from` box.\n - `data` is a small card that moves with the packet.\n - `async: true` marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag.\n - `tone`: the color of this hop for its beat. The packet, the data card, the edge, the label pill and the arrival box take the color. The trail after the beat stays the accent.\n - `green`: a success or a cache hit. `orange`: a warning, a miss or a retry. `red`: an error or a rejected input.\n - `gray`: idle or skipped. `purple`: async or background. `blue`: the default row color.\n - If the hop has no `tone`, the arrival box uses its box `tone`, then the accent. A hop `tone` wins over a box `tone`.\n- `say`: the caption for the beat.\n- `show`: `{ boxId: [row, ...] }`. This fills the content card of each named box. The card keeps the rows until the step ends.\n- `light`: ids of boxes to highlight for this beat.\n- `ms`: how long the beat lasts. Default: the time one packet needs to cross an edge.\n\nA row of a content card is `{ tag?, tone?, text, meta?, mark?, mono? }`.\n\n- `tag`: a short colored label at the start of the line.\n- `tone`: the color of the tag: `blue` (default), `purple`, `green`, `orange`, `red` or `gray`.\n- `text`: the main text.\n- `meta`: muted text after the main text.\n- `mark`: a mark at the right end of the line, such as a check or \"new\".\n- `mono`: if `true`, the text uses a monospace font.\n\n## Content rules\n\n- Give every box a stable `id`.\n- Use `shape: \"store\"` for data at rest (a database, a cache, a file, a queue). Use a plain box for a part that does work. Use `shape: \"decision\"` for a branch.\n- Keep one clear main path, left to right. Put side parts in a `column` group.\n- Use `quiet: true` on a long edge that crosses the picture. A quiet edge needs a beat that uses it, or the check reports\n `hidden-edge`.\n- In a rail, draw an error reply as a hop back on the same edge, with the error in `data` (`{ \"edge\": \"login\", \"back\": true,\n \"data\": \"401 CREDENTIALS_INVALID\" }`). A call that stays inside one part (a hash check, a guard) is not a message: put it in\n `say` or in a `show` row of that part.\n- Keep an SVG to one or two steps. The SVG plays every step in a loop, with no controls.\n- Use the React player, `<Flow {...props} />` from `flowfig`, for a page that needs tabs, pause or hover.\n\n## Example\n\n```json\n{\n \"props\": {\n \"layout\": {\n \"children\": [\n { \"id\": \"client\", \"label\": \"Browser\" },\n { \"id\": \"api\", \"label\": \"API Server\" },\n { \"id\": \"db\", \"label\": \"Database\", \"shape\": \"store\" }\n ]\n },\n \"edges\": [\n { \"id\": \"req\", \"from\": \"client\", \"to\": \"api\", \"label\": \"GET /users/42\" },\n { \"id\": \"q\", \"from\": \"api\", \"to\": \"db\", \"label\": \"SELECT\" }\n ],\n \"steps\": [\n {\n \"label\": \"read\",\n \"flow\": [\n { \"edges\": { \"edge\": \"req\", \"data\": \"GET /users/42\" }, \"say\": \"The browser asks for user 42.\" },\n { \"edges\": \"q\", \"show\": { \"db\": [{ \"tag\": \"row\", \"tone\": \"gray\", \"text\": \"Ada Lovelace\" }] }, \"say\": \"The API server reads the row.\" },\n { \"edges\": { \"edge\": \"req\", \"back\": true, \"data\": \"200 OK\" }, \"say\": \"The API server sends the user.\" }\n ]\n }\n ]\n }\n}\n```\n\n## Commands\n\n```bash\nnpx flowfig - out.svg < spec.json # render a spec from stdin\nnpx flowfig spec.json out.svg # render a spec file\nnpx flowfig figure.ts out.svg # render a module whose default export is a spec\nnpx flowfig check <input> [--json] # list the faults. The input can also be an SVG that flowfig wrote.\nnpx flowfig verify <input>... # check that the code each source names still exists\nnpx flowfig diff <old> <new> # list what changed in the spec between two figures\nnpx flowfig --spec out.svg # print the spec that an SVG carries\nnpx flowfig open out.svg # show the figure in the default browser\nnpx flowfig gif out.svg [out.gif] # write an animated GIF (needs Chrome, Edge, Chromium or Brave)\nnpx flowfig docs # print this guide\nnpx flowfig init # write these instructions for the coding agents of a repo\n```\n\nA render checks first and writes nothing on an error. `--strict` makes warnings errors. `--width` sets the page width.\n`--min-text` sets the smallest text that the reader should get. `--no-check` skips the check.\n";
|
|
3
3
|
/** The short instruction text that `flowfig init` writes for each coding agent. */
|
|
4
4
|
export declare const AGENT_TEXT = "# Diagrams with flowfig\n\nIf the user names no other tool, use flowfig for every diagram, figure, flowchart or picture of how something works. Do not answer with a Mermaid block or ASCII art. Use it for an architecture, a data flow or pipeline, a request lifecycle,\na call sequence, a state lifecycle, or a flowchart. flowfig also converts a pasted Mermaid flowchart, `stateDiagram` or\n`sequenceDiagram`.\n\nflowfig draws a roadmap or a timeline with dates (`timeline: true`), and converts a Mermaid `gantt` to it. flowfig does not\ndraw class or ER diagrams or charts of numbers. For those, tell the user so, suggest Mermaid (`classDiagram`, `erDiagram`),\nand draw nothing with flowfig.\n\n1. Run `npx flowfig docs` and read it. If the command fails, stop: tell the user that flowfig is not available, give them\n `npm install --save-dev flowfig`, and draw nothing with another tool.\n2. Pick the scope. If the code has more than one flow, name each flow in the reply, then draw one flow, or draw one structure\n view and call it a structure view. If the user asks for the whole system, draw one top view with groups, or a set of figures\n that together cover every part. Keep each figure to 12 boxes or fewer. For a process that several roles do, use\n `lanes: true`, one labeled group per role. For a plan or a roadmap, read the dates from the document and use `timeline: true`.\n3. Write a fact list from the code and from its wiring (config, compose, route and queue files). Write each call as\n `caller \u2192 callee: payload (file:line of the call)`, in order. Include each async call and every consumer of a queue, each\n branch and error path, and each end state. Leave out what the code does not show.\n4. Write the spec from the fact list, with the real names and data. Render it with `npx flowfig - out.svg <<'SPEC'` and the\n JSON in the heredoc. Fix every fault by changing the layout first (rows, `around`, the gap). Never remove a fact to pass\n the check; if you must remove one, name it in the reply. If `small-text` appears, use two rows or split the figure; keep\n the real names; do not change `--width`. Give each box and edge that draws code a `source` (`file#symbol`) from the fact list.\n Mark every send to a queue or a topic, every emitted event, and every call the caller does not await with `async: true`.\n5. Run `npx flowfig --spec out.svg` and compare it with the fact list: each edge must go from the caller to the callee that the\n fact names. Fix each gap. Then run `npx flowfig verify out.svg` from the repo root and fix each fault.\n6. If you can open a browser, look at the SVG at two moments of the loop. If you cannot, write \"not looked at\" in the reply.\n Never report a look that you did not do.\n7. Reply with: the SVG path; what it shows, with the counts copied from the `figure:` line and the names copied from the read-back spec; what it leaves out and why;\n the two lines the render printed, `0 errors, 0 warnings` and `figure: ...`, copied as printed.\n End the reply with one line `npx flowfig open <path>` for each SVG. Do not run that command yourself.\n";
|
|
5
5
|
/** The description of the Claude skill: the words that make the agent load it. */
|
package/dist/guide.js
CHANGED
|
@@ -60,6 +60,7 @@ files, compose files, route tables and queue bindings (or from the pasted Mermai
|
|
|
60
60
|
- every end state;
|
|
61
61
|
- for a process document (an SOP): every deadline, every message to a person (an email, a notice), every choice that a person
|
|
62
62
|
makes, and every wait for a reply. Put each one in a box \`sub\`, a step \`say\` or a \`data\` card. A small fact still counts.
|
|
63
|
+
Draw each path to its end: after a rejection, also draw the next step that the document gives. Give each path its own step.
|
|
63
64
|
|
|
64
65
|
If the code does not show a fact, leave the fact out. Do not guess a part, a name or an order.
|
|
65
66
|
|
|
@@ -82,6 +83,9 @@ Mermaid mapping:
|
|
|
82
83
|
| \`alt\` / \`opt\` | one step per path |
|
|
83
84
|
| \`stateDiagram\` states and transitions | boxes, and edges labeled with the event |
|
|
84
85
|
|
|
86
|
+
A pasted design wins over the async rule: keep each arrow as the Mermaid draws it. \`A->>B\` stays a plain hop, also for a
|
|
87
|
+
send to a queue. Mark a hop async only for \`-)\`. If you think the design is wrong, say so in the reply.
|
|
88
|
+
|
|
85
89
|
### 4. Render, then fix
|
|
86
90
|
|
|
87
91
|
\`\`\`bash
|
|
@@ -99,6 +103,9 @@ command writes no SVG and lists the faults. Fix each fault and render again.
|
|
|
99
103
|
the figure. Use more steps in one figure before you make a second figure. Keep the real name from the code in the label:
|
|
100
104
|
\`ReportsController\` stays \`ReportsController\`, not \`Controller\`. Do not change \`--width\` to pass the check: the
|
|
101
105
|
reader sees the figure at the real page width.
|
|
106
|
+
- If \`text-overflow\` appears, a text is wider than its box. Give the box a larger \`width\`, move a detail from \`label\` to
|
|
107
|
+
\`sub\`, or put the detail in the step \`say\`. Never cut a fact, a number or a unit to make a text fit: "within 30 days of
|
|
108
|
+
delivery" does not become "within 30 days". If you must shorten a text, keep every fact and name the change in the reply.
|
|
102
109
|
- Fix every other warning, or tell the user why it stays.
|
|
103
110
|
- Do not use \`--no-check\` to hide a fault.
|
|
104
111
|
|
package/dist/index.js
CHANGED
|
@@ -5,7 +5,7 @@ import { route } from './geometry.js';
|
|
|
5
5
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
6
6
|
import { textWidth } from './text.js';
|
|
7
7
|
import { checkScene, checkSpec, checkTheme } from './check.js';
|
|
8
|
-
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, } from './model.js';
|
|
8
|
+
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, } from './model.js';
|
|
9
9
|
export { LIGHT, DARK } from './model.js';
|
|
10
10
|
const DEFAULTS = { ...LIGHT, font: 'inherit' };
|
|
11
11
|
const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
|
|
@@ -72,6 +72,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
72
72
|
const bar = useRef(null); // the active tab's progress line
|
|
73
73
|
const [beat, setBeat] = useState(0);
|
|
74
74
|
const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
|
|
75
|
+
const [returning, setReturning] = useState(false); // the end of the last hold: the playhead moves back to the loop start
|
|
76
|
+
const firstLabel = useRef(''); // the playhead date of the first mount, which shows at once; '' before the first render
|
|
75
77
|
const paths = useRef({});
|
|
76
78
|
const [routed, setRouted] = useState([]);
|
|
77
79
|
// Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
|
|
@@ -177,6 +179,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
177
179
|
const r = n.getBoundingClientRect();
|
|
178
180
|
avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
|
|
179
181
|
});
|
|
182
|
+
// A timeline elbow keeps clear of the bars and milestones it does not connect, as in the SVG.
|
|
183
|
+
if (tl)
|
|
184
|
+
for (const n of nodes(layout))
|
|
185
|
+
if (rects[n.id])
|
|
186
|
+
avoid.push({ ...rects[n.id], box: true });
|
|
180
187
|
// Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
|
|
181
188
|
const stubs = lanePlan?.stubs ?? new Map();
|
|
182
189
|
if (stubs.size) {
|
|
@@ -225,8 +232,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
225
232
|
stub: stubs.get(ids[i]).map(labelPillW),
|
|
226
233
|
bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
|
|
227
234
|
}),
|
|
228
|
-
...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
229
|
-
})), rects, tips, avoid, pillArea);
|
|
235
|
+
...((stubs.size || lanePlan) && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
236
|
+
})), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
|
|
230
237
|
setRouted(next);
|
|
231
238
|
if (lanePlan) {
|
|
232
239
|
const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
|
|
@@ -378,6 +385,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
378
385
|
if (i !== shownBeat)
|
|
379
386
|
setBeat((shownBeat = i));
|
|
380
387
|
setHolding(t >= ends.at(-1));
|
|
388
|
+
setReturning(active === steps.length - 1 && t >= total - 400 * BASE_RATE);
|
|
381
389
|
const start = i ? ends[i - 1] : 0;
|
|
382
390
|
const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
|
|
383
391
|
const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
|
|
@@ -515,11 +523,52 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
515
523
|
if (timelineFig && item === layout) {
|
|
516
524
|
const lanesList = layout.children;
|
|
517
525
|
// The playhead follows the latest dated focus in the step, and rests at the last date before one and in the hold.
|
|
518
|
-
|
|
526
|
+
// In the last 400 ms of the loop it moves back to the loop start, so the next loop starts in place, as in the SVG.
|
|
527
|
+
const focused = still
|
|
528
|
+
? undefined
|
|
529
|
+
: returning
|
|
530
|
+
? loopStartItem(timelineFig.items, steps)
|
|
531
|
+
: holding
|
|
532
|
+
? undefined
|
|
533
|
+
: playheadItem(timelineFig.items, beats, beat);
|
|
519
534
|
const home = timelineFig.last ?? 0;
|
|
520
535
|
const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
|
|
521
536
|
const dateText = focused ? focused.date : timelineFig.lastDate;
|
|
522
537
|
const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
|
|
538
|
+
// The today line and the playhead line sit behind the bars (before them, at the bars' z-index); their labels sit on top.
|
|
539
|
+
// The playhead label waits for the line, except on the first mount, where the line starts in place.
|
|
540
|
+
if (firstLabel.current === '')
|
|
541
|
+
firstLabel.current = dateText;
|
|
542
|
+
else if (firstLabel.current !== dateText)
|
|
543
|
+
firstLabel.current = null;
|
|
544
|
+
const marks = (front) => timelineFig.last != null && (_jsxs("div", { style: { gridColumn: '2 / 4', gridRow: '1 / -1', position: 'relative', zIndex: front ? 2 : 1, pointerEvents: 'none' }, children: [timelineFig.today != null && (_jsx("div", { "data-fig-today": front ? undefined : '', style: { position: 'absolute', left: timelineFig.today, top: 12, bottom: 0, width: 0 }, children: front ? (_jsx("span", { style: {
|
|
545
|
+
position: 'absolute',
|
|
546
|
+
right: 3,
|
|
547
|
+
top: -1,
|
|
548
|
+
fontSize: 11,
|
|
549
|
+
lineHeight: '11px',
|
|
550
|
+
fontWeight: 600,
|
|
551
|
+
color: v('accent'),
|
|
552
|
+
}, children: "today" })) : (_jsx("div", { style: { position: 'absolute', top: 0, bottom: 0, borderLeft: `1px dashed ${v('accent')}`, opacity: 0.6 } })) })), _jsx("div", { "data-fig-playhead": front ? undefined : '', style: {
|
|
553
|
+
position: 'absolute',
|
|
554
|
+
left: 0,
|
|
555
|
+
top: 12,
|
|
556
|
+
bottom: 0,
|
|
557
|
+
width: front ? 0 : 1.5,
|
|
558
|
+
background: front ? undefined : v('accent'),
|
|
559
|
+
transform: `translateX(${at}px)`,
|
|
560
|
+
transition: still ? 'none' : 'transform .4s linear',
|
|
561
|
+
}, children: front && (_jsx("span", { style: {
|
|
562
|
+
position: 'absolute',
|
|
563
|
+
left: 3,
|
|
564
|
+
top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
|
|
565
|
+
fontSize: 11,
|
|
566
|
+
lineHeight: '11px',
|
|
567
|
+
fontWeight: 600,
|
|
568
|
+
whiteSpace: 'nowrap',
|
|
569
|
+
color: v('accent'),
|
|
570
|
+
animation: still || firstLabel.current === dateText ? undefined : 'flowfig-label .4s step-end',
|
|
571
|
+
}, children: dateText }, dateText)) })] }));
|
|
523
572
|
return (_jsxs("div", { style: {
|
|
524
573
|
display: 'grid',
|
|
525
574
|
gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
|
|
@@ -527,7 +576,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
527
576
|
gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
|
|
528
577
|
rowGap: LANE_ROW_GAP,
|
|
529
578
|
position: 'relative',
|
|
530
|
-
}, children: [_jsxs("div", { "data-fig-axis": "", style: { gridColumn: '2 / 4', gridRow: 1, position: 'relative', height: TL_AXIS_H, alignSelf: 'start' }, children: [_jsx("div", { style: { position: 'absolute', left: 0, right: 0, top: TL_AXIS_H - 1, height: 1, background: v('border') } }), timelineFig.ticks.map((k) => (_jsxs(Fragment, { children: [_jsx("div", { style: { position: 'absolute', left: k.x, top: TL_AXIS_H - 5, width: 1, height: 4, background: v('border') } }), _jsx("div", { style: { position: 'absolute', left: k.x + 3, top: 0, fontSize: 11, lineHeight: '13px', color: v('muted') }, children: k.label })] }, k.x)))] }), lanesList.map((lane, li) => {
|
|
579
|
+
}, children: [marks(false), _jsxs("div", { "data-fig-axis": "", style: { gridColumn: '2 / 4', gridRow: 1, position: 'relative', height: TL_AXIS_H, alignSelf: 'start' }, children: [_jsx("div", { style: { position: 'absolute', left: 0, right: 0, top: TL_AXIS_H - 1, height: 1, background: v('border') } }), timelineFig.ticks.map((k) => (_jsxs(Fragment, { children: [_jsx("div", { style: { position: 'absolute', left: k.x, top: TL_AXIS_H - 5, width: 1, height: 4, background: v('border') } }), _jsx("div", { style: { position: 'absolute', left: k.x + 3, top: 0, fontSize: 11, lineHeight: '13px', color: v('muted') }, children: k.label })] }, k.x)))] }), lanesList.map((lane, li) => {
|
|
531
580
|
const lit = lane.id != null && litNodes.has(lane.id);
|
|
532
581
|
return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
|
|
533
582
|
gridColumn: '1 / -1',
|
|
@@ -552,34 +601,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
552
601
|
}, children: lane.label }), _jsx("div", { ref: li === 0 ? area : undefined, style: { gridColumn: 2, gridRow: li + 2, position: 'relative', zIndex: 1 }, children: timelineFig.items
|
|
553
602
|
.filter((it) => it.track === li)
|
|
554
603
|
.map((it) => renderBar(lane.children.find((k) => k.id === it.id), it)) })] }, lane.id ?? String(lane.label)));
|
|
555
|
-
}),
|
|
556
|
-
position: 'absolute',
|
|
557
|
-
right: 3,
|
|
558
|
-
top: -1,
|
|
559
|
-
fontSize: 11,
|
|
560
|
-
lineHeight: '11px',
|
|
561
|
-
fontWeight: 600,
|
|
562
|
-
color: v('accent'),
|
|
563
|
-
}, children: "today" })] })), _jsx("div", { "data-fig-playhead": "", style: {
|
|
564
|
-
position: 'absolute',
|
|
565
|
-
left: 0,
|
|
566
|
-
top: 12,
|
|
567
|
-
bottom: 0,
|
|
568
|
-
width: 1.5,
|
|
569
|
-
background: v('accent'),
|
|
570
|
-
transform: `translateX(${at}px)`,
|
|
571
|
-
transition: still ? 'none' : 'transform .4s linear',
|
|
572
|
-
}, children: _jsx("span", { style: {
|
|
573
|
-
position: 'absolute',
|
|
574
|
-
left: 3,
|
|
575
|
-
top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
|
|
576
|
-
fontSize: 11,
|
|
577
|
-
lineHeight: '11px',
|
|
578
|
-
fontWeight: 600,
|
|
579
|
-
whiteSpace: 'nowrap',
|
|
580
|
-
color: v('accent'),
|
|
581
|
-
animation: still ? undefined : 'flowfig-label .4s step-end',
|
|
582
|
-
}, children: dateText }, dateText) })] }))] }));
|
|
604
|
+
}), marks(true)] }));
|
|
583
605
|
}
|
|
584
606
|
if (lanes && item === layout && isLanesLayout(layout)) {
|
|
585
607
|
const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
|
package/dist/init.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
|
|
2
2
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
|
-
import { dirname, join, relative, resolve } from 'node:path';
|
|
4
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
|
|
7
7
|
import { runPicker } from './picker.js';
|
|
@@ -136,7 +136,10 @@ export function registerMcp(a, old) {
|
|
|
136
136
|
/** `✓ ` before a finished write. */
|
|
137
137
|
const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
|
|
138
138
|
/** The path to show: relative to the repo folder, or `~` for the home folder. */
|
|
139
|
-
const shown = (path, base, global) =>
|
|
139
|
+
const shown = (path, base, global) => {
|
|
140
|
+
const rel = relative(base, path).split(sep).join('/'); // the output uses / on every system
|
|
141
|
+
return global ? `~/${rel}` : rel;
|
|
142
|
+
};
|
|
140
143
|
const HINT = 'run with -y or --agents <ids>';
|
|
141
144
|
/** Run `flowfig init`. Returns the exit code. */
|
|
142
145
|
export async function runInit(argv) {
|
package/dist/model.d.ts
CHANGED
|
@@ -218,6 +218,12 @@ export declare const playheadItem: <T extends {
|
|
|
218
218
|
}>(items: T[], beats: {
|
|
219
219
|
focus?: string[];
|
|
220
220
|
}[], i: number) => T | undefined;
|
|
221
|
+
/** The item the playhead rests on when the loop starts: the playhead item of the first beat of the first step. */
|
|
222
|
+
export declare const loopStartItem: <T extends {
|
|
223
|
+
id: string;
|
|
224
|
+
}>(items: T[], steps: {
|
|
225
|
+
flow: FigStep['flow'];
|
|
226
|
+
}[]) => T | undefined;
|
|
221
227
|
/** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
|
|
222
228
|
export declare const outsideLabelRect: (bar: {
|
|
223
229
|
x: number;
|
package/dist/model.js
CHANGED
|
@@ -62,6 +62,8 @@ export const playheadItem = (items, beats, i) => {
|
|
|
62
62
|
}
|
|
63
63
|
return undefined;
|
|
64
64
|
};
|
|
65
|
+
/** The item the playhead rests on when the loop starts: the playhead item of the first beat of the first step. */
|
|
66
|
+
export const loopStartItem = (items, steps) => playheadItem(items, (steps[0]?.flow ?? []).slice(0, 1).map(toBeat), 0);
|
|
65
67
|
/** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
|
|
66
68
|
export const outsideLabelRect = (bar, label) => ({
|
|
67
69
|
x: bar.x + bar.w + 6,
|
package/dist/scene.d.ts
CHANGED
|
@@ -24,6 +24,8 @@ export type SceneEdge = {
|
|
|
24
24
|
label?: Rect;
|
|
25
25
|
step?: number;
|
|
26
26
|
behind?: true;
|
|
27
|
+
/** The corners of a timeline elbow. `check` tests a `behind` edge against the boxes only when it has them. */
|
|
28
|
+
elbow?: Pt[];
|
|
27
29
|
};
|
|
28
30
|
/** `minFont` is the smallest reading text in px, before any scale. Group frames are not boxes. */
|
|
29
31
|
/** `lanes` are the lane bands of a swimlanes figure, one per lane and block, named by the lane label. A stub pill stays inside one. */
|
package/dist/svg.js
CHANGED
|
@@ -12,7 +12,7 @@ import { textWidth, wrap } from './text.js';
|
|
|
12
12
|
import { checkScene, checkSpec, checkTheme } from './check.js';
|
|
13
13
|
export { parseSource, links } from './source.js';
|
|
14
14
|
export { diff, formatDiff } from './diff.js';
|
|
15
|
-
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
|
|
15
|
+
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
|
|
16
16
|
export { LIGHT, DARK } from './model.js';
|
|
17
17
|
const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
|
|
18
18
|
const LABEL_LINE = 18, SUB_LINE = 15;
|
|
@@ -253,12 +253,16 @@ export function render(fig, opts = {}) {
|
|
|
253
253
|
around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
|
|
254
254
|
...(tl && { sides: ['r', 'l'], elbow: true }),
|
|
255
255
|
...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
|
|
256
|
-
...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
256
|
+
...((stubs.size || lanes) && e.label != null && { labelW: labelPillW(str(e.label)) }),
|
|
257
257
|
})), rects, tips, stubs.size
|
|
258
258
|
? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
|
|
259
|
-
:
|
|
259
|
+
: [
|
|
260
|
+
...placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
|
|
261
|
+
// A timeline elbow keeps clear of the bars and milestones it does not connect.
|
|
262
|
+
...placed.filter((p) => p.tl).map((p) => ({ x: p.x, y: p.y, w: p.w, h: p.h, box: true })),
|
|
263
|
+
],
|
|
260
264
|
// A stub pill stays in the lanes, right of the gutter.
|
|
261
|
-
stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined);
|
|
265
|
+
stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
|
|
262
266
|
};
|
|
263
267
|
let routed = go();
|
|
264
268
|
for (let pass = 0; plan && pass < 3; pass++) {
|
|
@@ -618,8 +622,8 @@ export function render(fig, opts = {}) {
|
|
|
618
622
|
`<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
|
|
619
623
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
620
624
|
});
|
|
621
|
-
// The timeline axis
|
|
622
|
-
let axisSvg = '', todaySvg = '';
|
|
625
|
+
// The timeline axis, the fixed today marker and the moving playhead. Their lines sit behind the bars, their labels on top.
|
|
626
|
+
let axisSvg = '', lineSvg = '', todaySvg = '';
|
|
623
627
|
if (tl) {
|
|
624
628
|
const top = placed[0];
|
|
625
629
|
const x0 = top.x + gutterOf(fig.layout.children);
|
|
@@ -635,19 +639,21 @@ export function render(fig, opts = {}) {
|
|
|
635
639
|
// The today marker is fixed at the figure's `today`.
|
|
636
640
|
if (tl.today != null) {
|
|
637
641
|
const tx = x0 + tl.today;
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
`<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
642
|
+
lineSvg = `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>`;
|
|
643
|
+
todaySvg = `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
|
|
641
644
|
}
|
|
642
|
-
// The playhead moves to the start of each beat's item, then to the last date in the step hold.
|
|
645
|
+
// The playhead moves to the start of each beat's item, then to the last date in the step hold. The loop starts with
|
|
646
|
+
// the playhead at the first beat's item, so the first frame shows the first beat; the last hold moves it back there.
|
|
643
647
|
if (tl.last != null && segs.length) {
|
|
644
648
|
const home = tl.last;
|
|
645
649
|
const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
|
|
646
650
|
const dateOf = new Map(tl.items.map((i) => [i.id, i.date]));
|
|
647
651
|
const hold = STEP_HOLD_MS / 1000 / BASE_RATE;
|
|
648
|
-
const
|
|
652
|
+
const first = loopStartItem(tl.items, steps);
|
|
653
|
+
const start = first ? startOf.get(first.id) : home;
|
|
654
|
+
const pts = [[0, start]];
|
|
649
655
|
const dates = []; // the label date in each time span
|
|
650
|
-
let at =
|
|
656
|
+
let at = start;
|
|
651
657
|
segs.forEach((s, i) => {
|
|
652
658
|
const item = playheadItem(tl.items, beats[s.si], s.bi);
|
|
653
659
|
const to = item ? startOf.get(item.id) : home;
|
|
@@ -655,15 +661,20 @@ export function render(fig, opts = {}) {
|
|
|
655
661
|
const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
|
|
656
662
|
const end = last ? s.t1 - hold : s.t1; // the step hold starts here
|
|
657
663
|
pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
|
|
658
|
-
|
|
664
|
+
// The label waits for the line, except at the loop start, where the line is already in place.
|
|
665
|
+
const [la, lb] = i === 0 && to === start ? [s.t0, end] : labelSpan(s.t0, end, RAMP);
|
|
659
666
|
if (la < lb)
|
|
660
667
|
dates.push({ a: la, b: lb, d: date, x: to });
|
|
661
668
|
at = to;
|
|
662
669
|
if (last) {
|
|
663
|
-
|
|
664
|
-
const
|
|
670
|
+
// The last hold of the loop ends with the move back to the loop start.
|
|
671
|
+
const back = i === segs.length - 1 ? Math.max(end + RAMP, s.t1 - RAMP) : s.t1;
|
|
672
|
+
pts.push([Math.min(end + RAMP, s.t1), home], [back, home]);
|
|
673
|
+
const [ha, hb] = labelSpan(end, back, RAMP);
|
|
665
674
|
if (ha < hb)
|
|
666
675
|
dates.push({ a: ha, b: hb, d: tl.lastDate, x: home });
|
|
676
|
+
if (back < s.t1)
|
|
677
|
+
pts.push([s.t1, start]);
|
|
667
678
|
at = home;
|
|
668
679
|
}
|
|
669
680
|
});
|
|
@@ -688,7 +699,8 @@ export function render(fig, opts = {}) {
|
|
|
688
699
|
const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
|
|
689
700
|
return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
|
|
690
701
|
});
|
|
691
|
-
|
|
702
|
+
lineSvg += `<g${cls(name)}><path d="M ${n2(lx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-width="1.5"/></g>`;
|
|
703
|
+
todaySvg += `<g${cls(name)}>${labels.join('')}</g>`;
|
|
692
704
|
}
|
|
693
705
|
}
|
|
694
706
|
const bounds = placed[0];
|
|
@@ -831,7 +843,7 @@ ${only
|
|
|
831
843
|
? ''
|
|
832
844
|
: `<g transform="translate(${n2(shift)} ${arcs ? 44 : 0})">
|
|
833
845
|
${axisSvg}
|
|
834
|
-
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}
|
|
846
|
+
${tl ? boxes.filter((b, i) => b && isGroup(placed[i].item)).join('\n') : ''}${lineSvg && `\n${lineSvg}`}
|
|
835
847
|
${tl ? edgeSvg.join('\n') : ''}
|
|
836
848
|
${boxes.filter((b, i) => b && !(tl && isGroup(placed[i].item))).join('\n')}
|
|
837
849
|
${tl ? '' : edgeSvg.join('\n')}
|
|
@@ -854,7 +866,17 @@ ${said.join('\n')}
|
|
|
854
866
|
// A cross-block edge is two scene edges: each stub with its pill.
|
|
855
867
|
if (r.stub)
|
|
856
868
|
return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
|
|
857
|
-
|
|
869
|
+
// A timeline edge is drawn behind, but `check` still tests its elbow against the boxes it does not connect.
|
|
870
|
+
return [
|
|
871
|
+
{
|
|
872
|
+
id: r.id,
|
|
873
|
+
from: e.from,
|
|
874
|
+
to: e.to,
|
|
875
|
+
curve: r.curve,
|
|
876
|
+
label: labelRects[r.id],
|
|
877
|
+
...(tl && { behind: true, elbow: r.elbow }),
|
|
878
|
+
},
|
|
879
|
+
];
|
|
858
880
|
}),
|
|
859
881
|
// Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
|
|
860
882
|
...(rail?.rows ?? []).flatMap((row, i) => {
|
|
@@ -878,7 +900,7 @@ ${said.join('\n')}
|
|
|
878
900
|
}),
|
|
879
901
|
],
|
|
880
902
|
minFont: Math.min(...fonts),
|
|
881
|
-
...(
|
|
903
|
+
...(lanes && {
|
|
882
904
|
lanes: placed.filter((p) => p.lane).map((p) => ({ id: str(p.item.label), rect: { x: p.x, y: p.y, w: p.w, h: p.h } })),
|
|
883
905
|
}),
|
|
884
906
|
};
|
package/dist/verify.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// `flowfig verify`: checks that the code a link names is still there. The link parsing lives in source.ts, which has no Node import.
|
|
2
2
|
import { readFileSync, statSync } from 'node:fs';
|
|
3
|
-
import { isAbsolute, relative, resolve } from 'node:path';
|
|
3
|
+
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
|
4
4
|
import { headingSlug, links } from './source.js';
|
|
5
5
|
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
6
6
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
@@ -28,7 +28,7 @@ export function verify(fig, { root = process.cwd() } = {}) {
|
|
|
28
28
|
for (const l of all) {
|
|
29
29
|
const full = resolve(root, l.path);
|
|
30
30
|
const rel = relative(root, full);
|
|
31
|
-
const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith(
|
|
31
|
+
const outside = isAbsolute(l.path) || rel === '..' || rel.startsWith(`..${sep}`) || isAbsolute(rel);
|
|
32
32
|
if (!files.has(full))
|
|
33
33
|
files.set(full, outside ? null : read(full));
|
|
34
34
|
const text = files.get(full);
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
|
|
2
|
-
export declare const VERSION = "0.
|
|
2
|
+
export declare const VERSION = "0.6.0";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
|
|
2
|
-
export const VERSION = '0.
|
|
2
|
+
export const VERSION = '0.6.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|