flowfig 0.1.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/LICENSE +22 -0
- package/README.md +428 -0
- package/dist/check.d.ts +14 -0
- package/dist/check.js +155 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +133 -0
- package/dist/geometry.d.ts +24 -0
- package/dist/geometry.js +80 -0
- package/dist/guide.d.ts +6 -0
- package/dist/guide.js +280 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +638 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +171 -0
- package/dist/model.d.ts +191 -0
- package/dist/model.js +71 -0
- package/dist/rail.d.ts +82 -0
- package/dist/rail.js +162 -0
- package/dist/scene.d.ts +35 -0
- package/dist/scene.js +1 -0
- package/dist/svg.d.ts +28 -0
- package/dist/svg.js +563 -0
- package/dist/text.d.ts +2 -0
- package/dist/text.js +13 -0
- package/package.json +80 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Vectorize AI, Inc.
|
|
4
|
+
Copyright (c) 2026 Alvis Ng
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
<h1><img src="docs/logo.svg" alt="flowfig" height="80"/></h1>
|
|
4
|
+
|
|
5
|
+
### Claude Code, Cursor, Copilot, Codex, Gemini CLI, Windsurf and Kiro draw the diagram from your code. flowfig checks it before it goes in your README.
|
|
6
|
+
|
|
7
|
+
<p>
|
|
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>
|
|
9
|
+
<a href="https://github.com/iamalvisng/flowfig/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/iamalvisng/flowfig/ci.yml?branch=main&style=for-the-badge&logo=github&label=CI" alt="CI status"/></a>
|
|
10
|
+
<a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-20C997?style=for-the-badge" alt="License: MIT"/></a>
|
|
11
|
+
<img src="https://img.shields.io/badge/node-%3E%3D18-5FA04E?style=for-the-badge&logo=nodedotjs&logoColor=white" alt="Node 18 or later"/>
|
|
12
|
+
<img src="https://img.shields.io/badge/dependencies-0-0074D9?style=for-the-badge" alt="Zero runtime dependencies"/>
|
|
13
|
+
</p>
|
|
14
|
+
|
|
15
|
+
<img src="docs/hero.svg" alt="You ask your coding agent for a diagram. The agent reads the code and writes a spec. flowfig check sends a fault back, the agent fixes the spec, and the SVG goes in the README." width="100%"/>
|
|
16
|
+
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
An agent with the flowfig instructions made faithful, checked diagrams more often than the same agent without them:
|
|
20
|
+
|
|
21
|
+
| Criterion | Agent without flowfig instructions | Agent with flowfig instructions |
|
|
22
|
+
| --------------------------------------------------- | ---------------------------------- | ---------------------------------------------- |
|
|
23
|
+
| Diagram faithful to the code (no invented part) | 7 of 8 | 8 of 8 |
|
|
24
|
+
| Right diagram form for the task | 5 of 7 | 7 of 7 |
|
|
25
|
+
| Passes `flowfig check --strict` | 5 of 7 | 6 of 6 |
|
|
26
|
+
| Right action (draws, declines or splits) | 6 of 8 | 7 of 8 |
|
|
27
|
+
| Held-out tasks (not used to write the instructions) | – | 2 of 4 pass all 6 criteria; 2 of 4 pass 4 of 6 |
|
|
28
|
+
|
|
29
|
+
[How we measured](#how-we-measured).
|
|
30
|
+
|
|
31
|
+
## Quick start
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx flowfig init
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Then ask your agent: "draw a diagram of how login works in this repo".
|
|
38
|
+
|
|
39
|
+
`init` writes the flowfig instructions for the agents that your repo uses. The agent reads the code, writes a spec, runs
|
|
40
|
+
`flowfig check`, fixes the faults, and renders the SVG.
|
|
41
|
+
|
|
42
|
+
## The problem
|
|
43
|
+
|
|
44
|
+
- A diagram in a repo drifts from the code. No one sees the drift.
|
|
45
|
+
- An agent that you ask for a diagram invents parts that the code does not have.
|
|
46
|
+
- A static picture cannot show the order of the calls or their payloads.
|
|
47
|
+
|
|
48
|
+
## What flowfig does
|
|
49
|
+
|
|
50
|
+
- **One command sets up 7 agents.** `npx flowfig init` writes instructions for Claude Code, Cursor, GitHub Copilot, Codex and
|
|
51
|
+
others through `AGENTS.md`, Gemini CLI, Windsurf and Kiro.
|
|
52
|
+
- **The agent writes JSON, not pictures.** The agent reads the code and writes a JSON spec of the parts, the calls and their
|
|
53
|
+
order. flowfig does the layout.
|
|
54
|
+
- **`flowfig check` finds the faults a machine can read.** It finds ids that point nowhere, text wider than its box, edges
|
|
55
|
+
through boxes, overlapping labels, hidden edges, text too small at the README width, and low contrast. The agent reads the
|
|
56
|
+
faults and fixes the spec.
|
|
57
|
+
- **The output is one animated SVG with no script.** The SVG plays in a GitHub README, PR or issue. It follows the light or dark
|
|
58
|
+
mode of the reader.
|
|
59
|
+
- **Every SVG carries its own spec.** `npx flowfig --spec figure.svg` prints the spec. Any agent can read a diagram back, change
|
|
60
|
+
it, and render it again.
|
|
61
|
+
- **Three forms and a React player.** The forms are the map, the map with a lifeline rail for sequences, and the rail alone. The
|
|
62
|
+
React player adds tabs, pause and hover. The package has zero runtime dependencies. Only the React player needs React.
|
|
63
|
+
|
|
64
|
+
## flowfig, Mermaid and a hand-drawn image
|
|
65
|
+
|
|
66
|
+
| | flowfig | Mermaid | Hand-drawn image |
|
|
67
|
+
| ------------------------------ | --------------------------------- | ------------------------------------ | ---------------- |
|
|
68
|
+
| Made by an agent from the code | Yes, with the `init` instructions | Yes, as Mermaid text | No |
|
|
69
|
+
| Checked for faults | Yes, 11 rules in `flowfig check` | Syntax errors only | No |
|
|
70
|
+
| Animated | Yes, one packet per message | No | No |
|
|
71
|
+
| Shows payloads and order | Yes, on the map and the rail | Order and text in a sequence diagram | No |
|
|
72
|
+
| Plays in a GitHub README | Yes, as an SVG | Yes, GitHub renders it | Yes, as an image |
|
|
73
|
+
| Readable back as source | Yes, with `--spec` | Yes, the source is text | No |
|
|
74
|
+
| Class and ER diagrams | No | Yes. Mermaid wins this row. | Yes |
|
|
75
|
+
|
|
76
|
+
## Contents
|
|
77
|
+
|
|
78
|
+
- [Install](#install)
|
|
79
|
+
- [Your first figure](#your-first-figure)
|
|
80
|
+
- [Three forms](#three-forms)
|
|
81
|
+
- [The spec](#the-spec)
|
|
82
|
+
- [Check a figure](#check-a-figure)
|
|
83
|
+
- [Use in React](#use-in-react)
|
|
84
|
+
- [Use from Node](#use-from-node)
|
|
85
|
+
- [Use with your coding agent](#use-with-your-coding-agent)
|
|
86
|
+
- [What flowfig does not draw](#what-flowfig-does-not-draw)
|
|
87
|
+
- [How we measured](#how-we-measured)
|
|
88
|
+
- [License](#license)
|
|
89
|
+
- [Development](#development)
|
|
90
|
+
|
|
91
|
+
## Install
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
npm i flowfig
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
React (>=18) is a peer dependency. Only the React player needs React. The CLI and `flowfig/svg` do not need React. flowfig needs
|
|
98
|
+
Node 18 or later.
|
|
99
|
+
|
|
100
|
+
## Your first figure
|
|
101
|
+
|
|
102
|
+
Save this spec as `first.json`. The spec has 3 boxes, 2 edges and 1 step.
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"layout": {
|
|
107
|
+
"children": [
|
|
108
|
+
{ "id": "browser", "label": "Browser" },
|
|
109
|
+
{ "id": "api", "label": "API" },
|
|
110
|
+
{ "id": "db", "label": "Database", "shape": "store" }
|
|
111
|
+
]
|
|
112
|
+
},
|
|
113
|
+
"edges": [
|
|
114
|
+
{ "from": "browser", "to": "api", "label": "GET /user" },
|
|
115
|
+
{ "from": "api", "to": "db", "label": "SELECT" }
|
|
116
|
+
],
|
|
117
|
+
"steps": [
|
|
118
|
+
{
|
|
119
|
+
"label": "Load a user",
|
|
120
|
+
"flow": [
|
|
121
|
+
{ "edges": "browser->api", "say": "The browser asks the API for the user." },
|
|
122
|
+
{ "edges": "api->db", "say": "The API reads the row." },
|
|
123
|
+
{ "edges": { "edge": "api->db", "back": true }, "say": "The row comes back." },
|
|
124
|
+
{ "edges": { "edge": "browser->api", "back": true }, "say": "The API sends the user as JSON." }
|
|
125
|
+
]
|
|
126
|
+
}
|
|
127
|
+
]
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Render the spec:
|
|
132
|
+
|
|
133
|
+
```console
|
|
134
|
+
$ npx flowfig first.json
|
|
135
|
+
figure: 3 boxes, 0 groups, 2 edges, 1 step, 4 messages
|
|
136
|
+
first.svg — 8.8 kB
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The command writes `first.svg`:
|
|
140
|
+
|
|
141
|
+

|
|
142
|
+
|
|
143
|
+
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
|
|
144
|
+
color scheme of the reader.
|
|
145
|
+
|
|
146
|
+
## Three forms
|
|
147
|
+
|
|
148
|
+
| Form | Spec | Use it when |
|
|
149
|
+
| ------------- | -------------- | ----------------------------------------------------------------------- |
|
|
150
|
+
| The map | (default) | The reader must see where each part runs and which parts talk. |
|
|
151
|
+
| Map and rail | `rail: true` | The reader must see the parts, and also the order and payload of calls. |
|
|
152
|
+
| The rail only | `rail: "only"` | The order of the messages is the point, and the map adds nothing to it. |
|
|
153
|
+
|
|
154
|
+
The map:
|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+
The map with the rail:
|
|
159
|
+
|
|
160
|
+

|
|
161
|
+
|
|
162
|
+
The rail draws one row for each message, with its payload.
|
|
163
|
+
Hops in one beat share a parallel band. An `async` hop has a dashed arrow and an `async` tag.
|
|
164
|
+
|
|
165
|
+
The rail only, from the same checkout spec:
|
|
166
|
+
|
|
167
|
+

|
|
168
|
+
|
|
169
|
+
Each SVG from the CLI carries its own spec. These commands made the rail-only figure from `docs/checkout.svg`:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
npx flowfig --spec docs/checkout.svg > checkout.json # print the spec in the SVG
|
|
173
|
+
# edit checkout.json: set "props.rail" to "only"
|
|
174
|
+
npx flowfig checkout.json docs/checkout-rail-only.svg
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
## The spec
|
|
178
|
+
|
|
179
|
+
A spec has a `layout`, `edges`, and optional `steps`. The full types have doc comments, so your editor shows each field. For the
|
|
180
|
+
full reference, run `npx flowfig docs`.
|
|
181
|
+
|
|
182
|
+
### Boxes
|
|
183
|
+
|
|
184
|
+
| Field | Meaning | Default |
|
|
185
|
+
| ------- | ----------------------------------------------------------------- | ----------- |
|
|
186
|
+
| `id` | The name that edges and steps use. It must be unique. | required |
|
|
187
|
+
| `label` | The title in the box. | required |
|
|
188
|
+
| `sub` | A smaller line under the label. | none |
|
|
189
|
+
| `shape` | `"box"`, `"decision"` (a diamond) or `"store"` (a data cylinder). | `"box"` |
|
|
190
|
+
| `lines` | The least number of text lines that a content card keeps. | none |
|
|
191
|
+
| `width` | The width in px. This value replaces the width that layout picks. | from layout |
|
|
192
|
+
|
|
193
|
+
### Groups
|
|
194
|
+
|
|
195
|
+
`layout` is a group. A group holds boxes and other groups.
|
|
196
|
+
|
|
197
|
+
| Field | Meaning | Default |
|
|
198
|
+
| ----------- | --------------------------------------------------------------- | --------------------------------------------------- |
|
|
199
|
+
| `children` | The boxes and groups in the group, in order. | required |
|
|
200
|
+
| `id` | The name that an edge can use to reach the whole group. | none |
|
|
201
|
+
| `label` | The title of the frame. Only a group with a label has a frame. | none |
|
|
202
|
+
| `direction` | `"row"` puts the children side by side. `"column"` stacks them. | `"row"` |
|
|
203
|
+
| `gap` | The space between the children in px. | column: 28; row: fits the widest label, at least 56 |
|
|
204
|
+
| `align` | `"start"`, `"center"` or `"end"`, across the direction. | `"center"` (a column stretches) |
|
|
205
|
+
|
|
206
|
+
### Edges
|
|
207
|
+
|
|
208
|
+
| Field | Meaning | Default |
|
|
209
|
+
| -------- | ----------------------------------------------------------------------- | ---------- |
|
|
210
|
+
| `from` | The id of the box or group where the edge starts. | required |
|
|
211
|
+
| `to` | The id of the box or group where the edge ends. | required |
|
|
212
|
+
| `id` | The name that beats use. | `from->to` |
|
|
213
|
+
| `label` | The text on the edge. | none |
|
|
214
|
+
| `around` | `"above"` or `"below"` routes the edge over or under the boxes between. | none |
|
|
215
|
+
| `quiet` | `true` draws the edge only while a step uses it. | `false` |
|
|
216
|
+
|
|
217
|
+
### Steps
|
|
218
|
+
|
|
219
|
+
Each step is one story. The player shows one tab for each step. With no steps, the figure is a still map.
|
|
220
|
+
|
|
221
|
+
| Field | Meaning | Default |
|
|
222
|
+
| --------- | ----------------------------------------------------- | -------- |
|
|
223
|
+
| `label` | The tab title. | required |
|
|
224
|
+
| `flow` | The beats, in play order. | required |
|
|
225
|
+
| `caption` | The line under the figure while no beat has a `say`. | none |
|
|
226
|
+
| `nodes` | The ids of the boxes to highlight for the whole step. | none |
|
|
227
|
+
|
|
228
|
+
### Beats
|
|
229
|
+
|
|
230
|
+
A beat is an edge id, an array of edge ids that run at the same time, or an object:
|
|
231
|
+
|
|
232
|
+
| Field | Meaning | Default |
|
|
233
|
+
| ------- | ---------------------------------------------------------------------------------- | -------------- |
|
|
234
|
+
| `edges` | One hop or an array of hops. A hop is an edge id or `{ edge, back, data, async }`. | none (a pause) |
|
|
235
|
+
| `say` | The line of narration for the beat. | none |
|
|
236
|
+
| `show` | `{ boxId: content }` fills the content card of a box until the step ends. | none |
|
|
237
|
+
| `light` | The ids of the boxes to highlight for this beat only. | none |
|
|
238
|
+
| `ms` | The length of the beat in ms. | `speed` (900) |
|
|
239
|
+
|
|
240
|
+
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
|
|
241
|
+
that does not wait for an answer.
|
|
242
|
+
|
|
243
|
+
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
|
|
244
|
+
required. The tones are `blue` (the default), `purple`, `green`, `orange` and `gray`.
|
|
245
|
+
|
|
246
|
+
### Figure options
|
|
247
|
+
|
|
248
|
+
| Field | Meaning | Default |
|
|
249
|
+
| ---------- | ------------------------------------------------------------------------- | -------- |
|
|
250
|
+
| `rail` | `true` draws the rail under the map. `"only"` draws the rail alone. | `false` |
|
|
251
|
+
| `speed` | The time in ms for a packet to cross one edge. | 900 |
|
|
252
|
+
| `theme` | `{ accent, fg, muted, bg, surface, border, font }`. Each key is optional. | built-in |
|
|
253
|
+
| `autoplay` | Start to play when the figure mounts. Only the React player reads it. | `true` |
|
|
254
|
+
| `check` | Run the check rules in the browser. Only the React player reads it. | `false` |
|
|
255
|
+
|
|
256
|
+
## Check a figure
|
|
257
|
+
|
|
258
|
+
`flowfig check` reads a spec and lists the faults. The input is `-` (stdin), a `.json` file, a `.ts` module or an SVG from the
|
|
259
|
+
CLI. A `.ts` module needs a Node version that strips types, such as Node 22.18 or later.
|
|
260
|
+
|
|
261
|
+
```console
|
|
262
|
+
$ npx flowfig check docs/checkout.svg
|
|
263
|
+
0 errors, 0 warnings
|
|
264
|
+
figure: 5 boxes, 3 groups, 4 edges, 2 steps, 6 messages
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
The last line gives the counts of the parts of the figure. Compare the counts with the parts that you planned.
|
|
268
|
+
|
|
269
|
+
| Rule | Severity | What it finds |
|
|
270
|
+
| ------------------- | -------- | ---------------------------------------------------------------------------- |
|
|
271
|
+
| `unknown-id` | error | An edge, step or beat names a box or edge that does not exist. |
|
|
272
|
+
| `duplicate-id` | error | Two boxes, groups or edges have the same id. |
|
|
273
|
+
| `hidden-edge` | error | A `quiet` edge that no beat uses, so the figure never shows it. |
|
|
274
|
+
| `text-overflow` | error | Text that needs more width than its box has. |
|
|
275
|
+
| `edge-crosses-box` | error | An edge that goes through a box that is not one of its ends. |
|
|
276
|
+
| `label-overlap` | error | Two edge labels overlap, or an edge label covers a box. |
|
|
277
|
+
| `low-contrast` | error | A text and background pair below 4.5:1, in the light, dark or custom theme. |
|
|
278
|
+
| `empty-step` | warning | A step with no beats. |
|
|
279
|
+
| `small-text` | warning | At the page width, the smallest text is below the minimum size. |
|
|
280
|
+
| `font-estimated` | warning | `theme.font` is set. The SVG check estimates text width for the system font. |
|
|
281
|
+
| `color-not-checked` | warning | A color that the check cannot read, so its contrast is not checked. |
|
|
282
|
+
|
|
283
|
+
| Option | Effect |
|
|
284
|
+
| ----------------- | ---------------------------------------------------------------------------------------------------------- |
|
|
285
|
+
| `--strict` | Every warning becomes an error. |
|
|
286
|
+
| `--json` | Print the findings as a JSON array, for scripts. Each finding has `rule`, `severity`, `ids` and `message`. |
|
|
287
|
+
| `--width <px>` | The page width for `small-text`. Default: 830. |
|
|
288
|
+
| `--min-text <px>` | The smallest text size the reader must get. Default: 10. |
|
|
289
|
+
|
|
290
|
+
The exit code is 0 with no errors, 1 with one or more errors, and 2 for bad use, such as a missing input, an unknown flag, or a `--width` value that is not a number.
|
|
291
|
+
|
|
292
|
+
A render runs the same check first. If the check finds an error, the render writes nothing. `--no-check` skips the check.
|
|
293
|
+
|
|
294
|
+
In React, `<Flow check />` runs the same rules on the layout that the browser drew. The player prints each fault with
|
|
295
|
+
`console.warn`.
|
|
296
|
+
|
|
297
|
+
## Use in React
|
|
298
|
+
|
|
299
|
+
```tsx
|
|
300
|
+
import { Flow, type FlowProps } from 'flowfig';
|
|
301
|
+
import spec from './first.json';
|
|
302
|
+
|
|
303
|
+
export const LoadUser = () => <Flow {...(spec as FlowProps)} />;
|
|
304
|
+
```
|
|
305
|
+
|
|
306
|
+
The player has these controls:
|
|
307
|
+
|
|
308
|
+
- One tab for each step, with a progress line. A click on a tab starts that step.
|
|
309
|
+
- A pause button, and a speed button that changes between 1× and 2×.
|
|
310
|
+
- A full screen button. Esc closes full screen.
|
|
311
|
+
- A hover on a box highlights its edges. With the rail, a click on a row starts that message, and a hover highlights its edge.
|
|
312
|
+
|
|
313
|
+
The player props are the spec fields. `theme` sets the colors, `speed` sets the packet time, and `autoplay={false}` stops the
|
|
314
|
+
auto start. `check` runs the check rules. If a figure is wider than its container, the player shrinks the figure to half size at
|
|
315
|
+
most, then scrolls. If the reader asks for reduced motion, the player shows the last beat and does not move packets.
|
|
316
|
+
|
|
317
|
+
The player has no dark theme of its own. For dark mode, set the `--fig-*` CSS variables on `.flowfig`:
|
|
318
|
+
|
|
319
|
+
```css
|
|
320
|
+
@media (prefers-color-scheme: dark) {
|
|
321
|
+
.flowfig {
|
|
322
|
+
--fig-accent: #1f78c8;
|
|
323
|
+
--fig-fg: #e3e3e3;
|
|
324
|
+
--fig-muted: #9aa0a6;
|
|
325
|
+
--fig-bg: #1b1b1d;
|
|
326
|
+
--fig-surface: #242526;
|
|
327
|
+
--fig-border: #3a3b3c;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
These are the built-in dark colors. `flowfig` exports them as `DARK`, and the light colors as `LIGHT`.
|
|
333
|
+
|
|
334
|
+
## Use from Node
|
|
335
|
+
|
|
336
|
+
`flowfig/svg` needs no React and no browser.
|
|
337
|
+
|
|
338
|
+
```ts
|
|
339
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
340
|
+
import { check, toSvg } from 'flowfig/svg';
|
|
341
|
+
|
|
342
|
+
const spec = JSON.parse(readFileSync('first.json', 'utf8'));
|
|
343
|
+
for (const f of check(spec)) console.log(f.severity, f.rule, f.message);
|
|
344
|
+
writeFileSync('first.svg', toSvg(spec));
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
- `toSvg(spec, options)` returns the SVG string. The options are `speed`, `padding` (default 24) and `theme`.
|
|
348
|
+
- `check(spec, options)` returns the findings. It also takes `width` and `minText`.
|
|
349
|
+
- `render(spec, options)` returns `{ svg, scene }`. The scene is the layout that the check reads. Its shape can change.
|
|
350
|
+
|
|
351
|
+
`toSvg` does not put the spec in the SVG. Only the CLI adds the spec, which `--spec` reads back.
|
|
352
|
+
|
|
353
|
+
## Use with your coding agent
|
|
354
|
+
|
|
355
|
+
`npx flowfig init` writes flowfig instructions for the coding agents of a repo. The instructions tell the agent how to write a
|
|
356
|
+
spec, check the spec, and render the SVG.
|
|
357
|
+
|
|
358
|
+
```bash
|
|
359
|
+
npx flowfig init # the current directory
|
|
360
|
+
npx flowfig init path/to/repo
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
On a terminal, `init` shows a picker. The picker selects the agents that the repo already uses. If it finds none, it selects
|
|
364
|
+
`AGENTS.md`. Type the numbers to toggle agents, press Enter to write, or type `q` to quit.
|
|
365
|
+
|
|
366
|
+
| Id | Agent | File | How |
|
|
367
|
+
| ---------- | -------------- | --------------------------------- | ------------------------------------ |
|
|
368
|
+
| `claude` | Claude Code | `.claude/skills/figure/SKILL.md` | whole file (a skill) |
|
|
369
|
+
| `agents` | AGENTS.md | `AGENTS.md` | section (for Codex, Amp, Jules, ...) |
|
|
370
|
+
| `cursor` | Cursor | `.cursor/rules/flowfig.mdc` | whole file |
|
|
371
|
+
| `copilot` | GitHub Copilot | `.github/copilot-instructions.md` | section |
|
|
372
|
+
| `gemini` | Gemini CLI | `GEMINI.md` | section |
|
|
373
|
+
| `windsurf` | Windsurf | `.windsurf/rules/flowfig.md` | whole file |
|
|
374
|
+
| `kiro` | Kiro | `.kiro/steering/flowfig.md` | whole file |
|
|
375
|
+
|
|
376
|
+
| Flag | Effect |
|
|
377
|
+
| ---------------- | ------------------------------------------------------------- |
|
|
378
|
+
| `--agents <ids>` | Write for these agents, for example `--agents claude,cursor`. |
|
|
379
|
+
| `--all-agents` | Write for all 7 agents. |
|
|
380
|
+
| `-y`, `--yes` | Write for the agents that the repo uses, with no picker. |
|
|
381
|
+
| `--global` | Write only the Claude Code skill, to your home directory. |
|
|
382
|
+
| `--dry-run` | Print what `init` would write, and write nothing. |
|
|
383
|
+
| `--list-agents` | Print the agent ids. |
|
|
384
|
+
|
|
385
|
+
With no terminal and no `-y`, `--agents` or `--all-agents`, `init` prints the agent ids and exits with code 2.
|
|
386
|
+
|
|
387
|
+
A section sits between `<!-- flowfig:start -->` and `<!-- flowfig:end -->`. A second run replaces that section and keeps the rest
|
|
388
|
+
of the file. A second run also replaces a whole file that `init` wrote. If a whole file exists and did not come from `init`,
|
|
389
|
+
`init` skips the file. `init` also skips a section file that has a start marker and no end marker.
|
|
390
|
+
|
|
391
|
+
`npx flowfig docs` prints the full guide as Markdown.
|
|
392
|
+
|
|
393
|
+
## What flowfig does not draw
|
|
394
|
+
|
|
395
|
+
flowfig draws parts, the messages between the parts, and their order. flowfig does not draw class diagrams, ER diagrams, Gantt
|
|
396
|
+
charts, timelines with dates, charts of numbers or mind maps. Use Mermaid or a chart library for those.
|
|
397
|
+
|
|
398
|
+
## How we measured
|
|
399
|
+
|
|
400
|
+
We ran an evaluation before this release: 47 coding-agent runs, with Claude Sonnet as the agent. The runs covered 12 tasks in 5
|
|
401
|
+
test repos: a TypeScript login API, a Python ETL pipeline, an order state machine, a 20-service platform and a mixed CLI repo.
|
|
402
|
+
The agent ran without the flowfig instructions and with them. An Opus grader scored each run against an answer key.
|
|
403
|
+
|
|
404
|
+
4 tasks were held out: they shaped no instruction. On the held-out tasks, 2 of 4 runs passed all 6 criteria. The other 2 passed
|
|
405
|
+
4 of 6: one drew an edge from the wrong box, and one reply stated a runtime effect wrongly.
|
|
406
|
+
|
|
407
|
+
A first draft of the instructions scored worse than no instructions: 5 of 8 faithful. We rewrote the instructions from the
|
|
408
|
+
failures and tested them again. The "with" column comes from the version that we tested on all 8 tasks. The shipped instructions
|
|
409
|
+
add two refinements: the scope of a repo with many flows, and each edge taken from the caller in the code. 9 more runs tested
|
|
410
|
+
the two refinements on the tasks that still failed.
|
|
411
|
+
|
|
412
|
+
The evaluation also found 3 faults in flowfig itself. We fixed all 3 before this release.
|
|
413
|
+
|
|
414
|
+
## License
|
|
415
|
+
|
|
416
|
+
MIT. See [LICENSE](LICENSE).
|
|
417
|
+
|
|
418
|
+
## Development
|
|
419
|
+
|
|
420
|
+
```bash
|
|
421
|
+
npm install
|
|
422
|
+
npm run dev # the gallery: each file in figures/ is one figure
|
|
423
|
+
npm test # build, then run the unit tests
|
|
424
|
+
npm run typecheck
|
|
425
|
+
npm run format:check
|
|
426
|
+
```
|
|
427
|
+
|
|
428
|
+
A change to the layout, the routing or the SVG output needs a unit test in `src/*.test.ts`.
|
package/dist/check.d.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { type FigTheme, type FlowProps } from './model.ts';
|
|
2
|
+
import type { Finding, Scene } from './scene.ts';
|
|
3
|
+
/** Ids that point nowhere, ids used twice, steps with nothing in them. The renderers skip these without a word. */
|
|
4
|
+
export declare function checkSpec(fig: FlowProps): Finding[];
|
|
5
|
+
export type CheckOptions = {
|
|
6
|
+
width?: number;
|
|
7
|
+
minText?: number;
|
|
8
|
+
};
|
|
9
|
+
/** Layout faults in what a renderer drew, and text that the target width shrinks below `minText`. */
|
|
10
|
+
export declare function checkScene(scene: Scene, { width, minText }?: CheckOptions): Finding[];
|
|
11
|
+
/** The WCAG contrast ratio of two colors, rounded to 2 places; null if either cannot be read. */
|
|
12
|
+
export declare function contrast(a: string, b: string): number | null;
|
|
13
|
+
/** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
|
|
14
|
+
export declare function checkTheme(theme?: FigTheme): Finding[];
|
package/dist/check.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
// The rules behind `flowfig check`. They never draw and never measure: a renderer gives a Scene, and these read it. So one rule set
|
|
2
|
+
// covers the SVG (estimated text) and the React player (measured text), and any later view that gives a scene.
|
|
3
|
+
import { DARK, LIGHT, ON_ACCENT, edgeId, isGroup, nodes, str, toBeat } from './model.js';
|
|
4
|
+
import { textWidth } from './text.js';
|
|
5
|
+
const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
|
|
6
|
+
const warn = (rule, ids, message) => ({ rule, severity: 'warning', ids, message });
|
|
7
|
+
const groupIds = (g) => [...(g.id ? [g.id] : []), ...g.children.flatMap((c) => (isGroup(c) ? groupIds(c) : []))];
|
|
8
|
+
/** Ids that point nowhere, ids used twice, steps with nothing in them. The renderers skip these without a word. */
|
|
9
|
+
export function checkSpec(fig) {
|
|
10
|
+
const out = [];
|
|
11
|
+
const boxIds = nodes(fig.layout).map((n) => n.id);
|
|
12
|
+
const groups = groupIds(fig.layout);
|
|
13
|
+
const edges = fig.edges.map(edgeId);
|
|
14
|
+
const twice = (kind, list) => new Set(list.filter((id, i) => list.indexOf(id) !== i)).forEach((id) => out.push(err('duplicate-id', [id], `two ${kind} have the id "${id}"`)));
|
|
15
|
+
twice('boxes', boxIds);
|
|
16
|
+
twice('groups', groups);
|
|
17
|
+
twice('edges', edges);
|
|
18
|
+
const known = new Set([...boxIds, ...groups]);
|
|
19
|
+
const knownEdges = new Set(edges);
|
|
20
|
+
fig.edges.forEach((e, i) => {
|
|
21
|
+
for (const end of [e.from, e.to])
|
|
22
|
+
if (!known.has(end))
|
|
23
|
+
out.push(err('unknown-id', [edges[i], end], `edge "${edges[i]}" names box "${end}", which does not exist`));
|
|
24
|
+
});
|
|
25
|
+
(fig.steps ?? []).forEach((s, si) => {
|
|
26
|
+
const where = `step ${si + 1} ("${str(s.label)}")`;
|
|
27
|
+
if (!s.flow.length)
|
|
28
|
+
out.push(warn('empty-step', [], `${where} has no beats`));
|
|
29
|
+
for (const id of s.nodes ?? [])
|
|
30
|
+
if (!known.has(id))
|
|
31
|
+
out.push(err('unknown-id', [id], `${where}: nodes names box "${id}", which does not exist`));
|
|
32
|
+
for (const b of s.flow.map(toBeat)) {
|
|
33
|
+
for (const h of b.hops)
|
|
34
|
+
if (!knownEdges.has(h.edge))
|
|
35
|
+
out.push(err('unknown-id', [h.edge], `${where} moves along edge "${h.edge}", which does not exist`));
|
|
36
|
+
for (const id of [...Object.keys(b.show ?? {}), ...(b.light ?? [])])
|
|
37
|
+
if (!known.has(id))
|
|
38
|
+
out.push(err('unknown-id', [id], `${where}: show or light names box "${id}", which does not exist`));
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
const used = new Set((fig.steps ?? []).flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => h.edge))));
|
|
42
|
+
fig.edges.forEach((e, i) => {
|
|
43
|
+
if (e.quiet && !used.has(edges[i]))
|
|
44
|
+
out.push(err('hidden-edge', [edges[i]], `edge "${edges[i]}" is quiet and no beat uses it, so the figure never shows it`));
|
|
45
|
+
});
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
/** A point on the cubic Bézier at `t`. */
|
|
49
|
+
const at = ([p0, p1, p2, p3], t) => {
|
|
50
|
+
const u = 1 - t;
|
|
51
|
+
const [a, b, c, d] = [u * u * u, 3 * u * u * t, 3 * u * t * t, t * t * t];
|
|
52
|
+
return { x: a * p0.x + b * p1.x + c * p2.x + d * p3.x, y: a * p0.y + b * p1.y + c * p2.y + d * p3.y };
|
|
53
|
+
};
|
|
54
|
+
// The margins keep a touch from counting: an edge leaves along a border, and pills sit edge to edge.
|
|
55
|
+
const inside = (p, r, pad) => p.x > r.x + pad && p.x < r.x + r.w - pad && p.y > r.y + pad && p.y < r.y + r.h - pad;
|
|
56
|
+
const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x + a.w - pad && a.y + pad < b.y + b.h - pad && b.y + pad < a.y + a.h - pad;
|
|
57
|
+
const px = (n) => Math.round(n * 10) / 10;
|
|
58
|
+
/** Layout faults in what a renderer drew, and text that the target width shrinks below `minText`. */
|
|
59
|
+
export function checkScene(scene, { width = 830, minText = 10 } = {}) {
|
|
60
|
+
const out = [];
|
|
61
|
+
for (const b of scene.boxes)
|
|
62
|
+
for (const t of b.texts) {
|
|
63
|
+
const need = t.need ?? textWidth(t.text, t.fontSize, t.mono);
|
|
64
|
+
if (need > t.room + 0.5)
|
|
65
|
+
out.push(err('text-overflow', [b.id], `box "${b.id}": "${t.text}" needs ${Math.ceil(need)} px, has ${Math.floor(t.room)} px`));
|
|
66
|
+
}
|
|
67
|
+
for (const e of scene.edges) {
|
|
68
|
+
const pts = Array.from({ length: 33 }, (_, i) => at(e.curve, i / 32));
|
|
69
|
+
for (const b of scene.boxes)
|
|
70
|
+
if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, 2)))
|
|
71
|
+
out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}"`));
|
|
72
|
+
}
|
|
73
|
+
const labeled = scene.edges.filter((e) => e.label);
|
|
74
|
+
labeled.forEach((e, i) => {
|
|
75
|
+
for (const f of labeled.slice(i + 1))
|
|
76
|
+
if (overlap(e.label, f.label, 1))
|
|
77
|
+
out.push(err('label-overlap', [e.id, f.id], `the labels of edges "${e.id}" and "${f.id}" overlap`));
|
|
78
|
+
for (const b of scene.boxes)
|
|
79
|
+
if (overlap(e.label, b.rect, 1))
|
|
80
|
+
out.push(err('label-overlap', [e.id, b.id], `the label of edge "${e.id}" covers box "${b.id}"`));
|
|
81
|
+
});
|
|
82
|
+
const shown = scene.minFont * Math.min(1, width / scene.width);
|
|
83
|
+
if (shown < minText)
|
|
84
|
+
out.push(warn('small-text', [], `at ${width} px the smallest text is ${px(shown)} px (minimum ${minText} px)`));
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
/** Red, green and blue from a hex, rgb() or hsl() color; null for a CSS variable or a named color. */
|
|
88
|
+
function rgb(color) {
|
|
89
|
+
const s = color.trim().toLowerCase();
|
|
90
|
+
let m = s.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/);
|
|
91
|
+
if (m) {
|
|
92
|
+
const h = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1];
|
|
93
|
+
return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
94
|
+
}
|
|
95
|
+
m = s.match(/^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/);
|
|
96
|
+
if (m)
|
|
97
|
+
return [+m[1], +m[2], +m[3]];
|
|
98
|
+
m = s.match(/^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%/);
|
|
99
|
+
if (m) {
|
|
100
|
+
const [h, sat, l] = [+m[1] / 360, +m[2] / 100, +m[3] / 100];
|
|
101
|
+
const f = (n) => {
|
|
102
|
+
const k = (n + h * 12) % 12;
|
|
103
|
+
return 255 * (l - sat * Math.min(l, 1 - l) * Math.max(-1, Math.min(k - 3, 9 - k, 1)));
|
|
104
|
+
};
|
|
105
|
+
return [f(0), f(8), f(4)];
|
|
106
|
+
}
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
const lum = (c) => c
|
|
110
|
+
.map((v) => v / 255)
|
|
111
|
+
.map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
112
|
+
.reduce((s, v, i) => s + v * [0.2126, 0.7152, 0.0722][i], 0);
|
|
113
|
+
/** The WCAG contrast ratio of two colors, rounded to 2 places; null if either cannot be read. */
|
|
114
|
+
export function contrast(a, b) {
|
|
115
|
+
const [x, y] = [rgb(a), rgb(b)];
|
|
116
|
+
if (!x || !y)
|
|
117
|
+
return null;
|
|
118
|
+
const [hi, lo] = [lum(x), lum(y)].sort((p, q) => q - p);
|
|
119
|
+
return Math.round(((hi + 0.05) / (lo + 0.05)) * 100) / 100;
|
|
120
|
+
}
|
|
121
|
+
const PAIRS = [
|
|
122
|
+
['fg', 'bg'],
|
|
123
|
+
['muted', 'bg'],
|
|
124
|
+
['fg', 'surface'],
|
|
125
|
+
['text', 'accent'],
|
|
126
|
+
];
|
|
127
|
+
/** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
|
|
128
|
+
export function checkTheme(theme = {}) {
|
|
129
|
+
const out = [];
|
|
130
|
+
if (theme.font && theme.font !== 'inherit')
|
|
131
|
+
out.push(warn('font-estimated', [], `theme.font is "${theme.font}"; the SVG check estimates text width for the system font`));
|
|
132
|
+
const custom = Object.keys(theme).some((k) => k !== 'font');
|
|
133
|
+
const sets = [
|
|
134
|
+
['light', LIGHT],
|
|
135
|
+
['dark', DARK],
|
|
136
|
+
];
|
|
137
|
+
if (custom)
|
|
138
|
+
sets.push(['custom', { ...LIGHT, ...Object.fromEntries(Object.entries(theme).filter(([, v]) => v != null)) }]);
|
|
139
|
+
const unread = new Set();
|
|
140
|
+
for (const [name, t] of sets)
|
|
141
|
+
for (const [fg, bg] of PAIRS) {
|
|
142
|
+
const a = fg === 'text' ? ON_ACCENT : t[fg];
|
|
143
|
+
const r = contrast(a, t[bg]);
|
|
144
|
+
if (r == null) {
|
|
145
|
+
for (const c of [a, t[bg]])
|
|
146
|
+
if (!rgb(c))
|
|
147
|
+
unread.add(c);
|
|
148
|
+
}
|
|
149
|
+
else if (r < 4.5)
|
|
150
|
+
out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
|
|
151
|
+
}
|
|
152
|
+
for (const c of unread)
|
|
153
|
+
out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
|
|
154
|
+
return out;
|
|
155
|
+
}
|
package/dist/cli.d.ts
ADDED