flowfig 0.2.0 → 0.3.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 +17 -0
- package/dist/check.js +23 -2
- package/dist/cli.js +5 -0
- package/dist/diff.js +2 -2
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +178 -0
- package/dist/guide.d.ts +2 -2
- package/dist/guide.js +10 -3
- package/dist/index.js +73 -24
- package/dist/model.d.ts +18 -3
- package/dist/model.js +14 -0
- package/dist/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/source.d.ts +2 -0
- package/dist/source.js +5 -0
- package/dist/svg.js +110 -27
- package/dist/verify.d.ts +1 -1
- package/dist/verify.js +10 -3
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -28,6 +28,21 @@ An agent with the flowfig instructions made faithful, checked diagrams more ofte
|
|
|
28
28
|
|
|
29
29
|
[How we measured](#how-we-measured).
|
|
30
30
|
|
|
31
|
+
## One command
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
npx flowfig draw "how does login work"
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`draw` runs Claude Code on the repo. It gives Claude Code the flowfig guide, `npx flowfig` and the tools Read, Glob and Grep.
|
|
38
|
+
The permission mode is `acceptEdits`, so Claude Code can write files. `draw` disallows the tools Edit, MultiEdit and
|
|
39
|
+
NotebookEdit. Claude Code writes the spec `how-does-login-work.json`, then renders `how-does-login-work.svg`. Then `draw`
|
|
40
|
+
checks the figure itself with `check --strict` and `verify`, and removes the spec file. If the agent writes no new figure,
|
|
41
|
+
`draw` exits 1 and shows the tool calls that the permission rules denied. `draw` prints the result, the reply of the agent
|
|
42
|
+
and the cost of the run. `draw` needs Claude Code on the machine and a login. `draw` does not run on Windows yet.
|
|
43
|
+
`--out` sets the path (it must end in `.svg`), `--model` sets the model, `--max-turns` sets the turn cap (default 40) and
|
|
44
|
+
`--json` prints `{ out, reply, cost, session, findings }` for scripts.
|
|
45
|
+
|
|
31
46
|
## Quick start
|
|
32
47
|
|
|
33
48
|
```bash
|
|
@@ -307,6 +322,8 @@ fails when the file or the symbol is gone. The action runs `verify` on every fig
|
|
|
307
322
|
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
|
|
308
323
|
changes.
|
|
309
324
|
|
|
325
|
+
A process figure for a team links its boxes to the SOP document, not to code: `"source": "docs/sop/refunds.md#step-3-approve-the-refund"`. The symbol is the heading as a GitHub anchor. If the heading is gone, `verify` fails.
|
|
326
|
+
|
|
310
327
|
```yaml
|
|
311
328
|
permissions:
|
|
312
329
|
contents: read
|
package/dist/check.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
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
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';
|
|
3
|
+
import { DARK, LIGHT, ON_ACCENT, TONES, edgeId, isGroup, nodes, str, toBeat, } from './model.js';
|
|
4
4
|
import { textWidth } from './text.js';
|
|
5
5
|
import { owners, parseSource } from './source.js';
|
|
6
6
|
const err = (rule, ids, message) => ({ rule, severity: 'error', ids, message });
|
|
@@ -128,7 +128,16 @@ const PAIRS = [
|
|
|
128
128
|
['muted', 'bg'],
|
|
129
129
|
['fg', 'surface'],
|
|
130
130
|
['text', 'accent'],
|
|
131
|
+
['fg', 'tint'],
|
|
132
|
+
['muted', 'tint'],
|
|
131
133
|
];
|
|
134
|
+
/** A color mixed over a base at pct %, per channel like the CSS color-mix. The default is the active-box tint: 10 % accent over the surface. */
|
|
135
|
+
function tintOf(t, color = t.accent, base = t.surface, pct = 10) {
|
|
136
|
+
const [a, s] = [rgb(color), rgb(base)];
|
|
137
|
+
if (!a || !s)
|
|
138
|
+
return base; // an unreadable color is reported by its own pairs
|
|
139
|
+
return `rgb(${a.map((v, i) => (pct / 100) * v + (1 - pct / 100) * s[i]).join(',')})`;
|
|
140
|
+
}
|
|
132
141
|
/** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
|
|
133
142
|
export function checkTheme(theme = {}) {
|
|
134
143
|
const out = [];
|
|
@@ -142,7 +151,8 @@ export function checkTheme(theme = {}) {
|
|
|
142
151
|
if (custom)
|
|
143
152
|
sets.push(['custom', { ...LIGHT, ...Object.fromEntries(Object.entries(theme).filter(([, v]) => v != null)) }]);
|
|
144
153
|
const unread = new Set();
|
|
145
|
-
for (const [name,
|
|
154
|
+
for (const [name, base] of sets) {
|
|
155
|
+
const t = { ...base, tint: tintOf(base) };
|
|
146
156
|
for (const [fg, bg] of PAIRS) {
|
|
147
157
|
const a = fg === 'text' ? ON_ACCENT : t[fg];
|
|
148
158
|
const r = contrast(a, t[bg]);
|
|
@@ -154,6 +164,17 @@ export function checkTheme(theme = {}) {
|
|
|
154
164
|
else if (r < 4.5)
|
|
155
165
|
out.push(err('low-contrast', [], `${name} theme: ${fg} on ${bg} has contrast ${r}:1 (minimum 4.5:1)`));
|
|
156
166
|
}
|
|
167
|
+
// A toned box shows fg and muted text on its tone tint: 8 % over bg (off, trail) and 10 % over surface (active).
|
|
168
|
+
for (const [tname, tone] of Object.entries(TONES)) {
|
|
169
|
+
for (const bg of [tintOf(t, tone, t.bg, 8), tintOf(t, tone, t.surface, 10)]) {
|
|
170
|
+
for (const fg of ['fg', 'muted']) {
|
|
171
|
+
const r = contrast(t[fg], bg);
|
|
172
|
+
if (r != null && r < 4.5)
|
|
173
|
+
out.push(err('low-contrast', [], `${name} theme: ${fg} on the ${tname} box tint has contrast ${r}:1 (minimum 4.5:1)`));
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
157
178
|
for (const c of unread)
|
|
158
179
|
out.push(warn('color-not-checked', [], `cannot read the color "${c}", so its contrast is not checked`));
|
|
159
180
|
return out;
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
|
|
|
20
20
|
* flowfig docs # print the full guide (Markdown)
|
|
21
21
|
* flowfig mcp # serve check, render, verify, diff and docs over MCP (stdio)
|
|
22
22
|
* flowfig init [dir] # write flowfig instructions for the coding agents of a repo
|
|
23
|
+
* flowfig draw "<question>" [--out out.svg] # ask Claude Code for a figure, then check it
|
|
23
24
|
*
|
|
24
25
|
* A render checks first and writes nothing on an error (--no-check skips that). --strict makes warnings errors; --width and
|
|
25
26
|
* --min-text set the page width and the smallest text the reader should get.
|
|
@@ -30,6 +31,7 @@ import { readFileSync, writeFileSync } from 'node:fs';
|
|
|
30
31
|
import { resolve } from 'node:path';
|
|
31
32
|
import { pathToFileURL } from 'node:url';
|
|
32
33
|
import { GUIDE } from './guide.js';
|
|
34
|
+
import { runDraw } from './draw.js';
|
|
33
35
|
import { runInit } from './init.js';
|
|
34
36
|
import { serve } from './mcp.js';
|
|
35
37
|
import { diff, formatDiff } from './diff.js';
|
|
@@ -44,6 +46,7 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] render a figur
|
|
|
44
46
|
flowfig docs print the guide (Markdown)
|
|
45
47
|
flowfig mcp serve check, render, verify, diff and docs over MCP (stdio)
|
|
46
48
|
flowfig init [dir] [--agents <ids>] [-y] [--global] [--dry-run] [--no-mcp] write flowfig instructions for the coding agents of a repo
|
|
49
|
+
flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json] ask Claude Code for a figure, then check it
|
|
47
50
|
flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only)`;
|
|
48
51
|
/** Bad use, not a bad figure: exit 2 with a message, not a stack trace. A declaration, so TypeScript narrows after a call. */
|
|
49
52
|
function usage(message) {
|
|
@@ -81,6 +84,8 @@ if (args[0] === 'mcp') {
|
|
|
81
84
|
}
|
|
82
85
|
if (args[0] === 'init')
|
|
83
86
|
process.exit(await runInit(args.slice(1)));
|
|
87
|
+
if (args[0] === 'draw')
|
|
88
|
+
process.exit(await runDraw(args.slice(1)));
|
|
84
89
|
if (args[0] === '--spec') {
|
|
85
90
|
if (!args[1])
|
|
86
91
|
usage('--spec needs the path of an SVG');
|
package/dist/diff.js
CHANGED
|
@@ -23,7 +23,7 @@ function compare(kind, before, after, fieldsOf) {
|
|
|
23
23
|
return out;
|
|
24
24
|
}
|
|
25
25
|
const messages = (fig) => new Map((fig.steps ?? [])
|
|
26
|
-
.flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}`)))
|
|
26
|
+
.flatMap((s) => s.flow.flatMap((b) => toBeat(b).hops.map((h) => `${str(s.label)}: ${h.edge}${h.back ? ' back' : ''}${h.data != null ? ` "${str(h.data)}"` : ''}${h.tone ? ` [${h.tone}]` : ''}`)))
|
|
27
27
|
.map((id) => [id, id]));
|
|
28
28
|
export function diff(before, after) {
|
|
29
29
|
const boxes = (f) => {
|
|
@@ -36,7 +36,7 @@ export function diff(before, after) {
|
|
|
36
36
|
const edges = (f) => new Map(f.edges.map((e) => [edgeId(e), e]));
|
|
37
37
|
const steps = (f) => new Map((f.steps ?? []).map((s) => [str(s.label), s]));
|
|
38
38
|
const out = [
|
|
39
|
-
...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'source', 'group'])),
|
|
39
|
+
...compare('box', boxes(before), boxes(after), (a, b) => fields(a, b, ['label', 'sub', 'shape', 'tone', 'source', 'group'])),
|
|
40
40
|
...compare('edge', edges(before), edges(after), (a, b) => fields(a, b, ['from', 'to', 'label', 'source'])),
|
|
41
41
|
...compare('step', steps(before), steps(after), () => []),
|
|
42
42
|
...compare('message', messages(before), messages(after), () => []),
|
package/dist/draw.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
|
|
2
|
+
export declare function slug(question: string): string;
|
|
3
|
+
/** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
|
|
4
|
+
export declare function agentArgs(o: {
|
|
5
|
+
question: string;
|
|
6
|
+
out: string;
|
|
7
|
+
cwd: string;
|
|
8
|
+
maxTurns: number;
|
|
9
|
+
model?: string;
|
|
10
|
+
}): string[];
|
|
11
|
+
/** Run `flowfig draw`. Prints, and returns the exit code. */
|
|
12
|
+
export declare function runDraw(argv: string[]): Promise<number>;
|
package/dist/draw.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// `flowfig draw`: one command to the first figure. Claude Code reads the repo and renders; draw checks the result itself.
|
|
2
|
+
import { spawnSync } from 'node:child_process';
|
|
3
|
+
import { existsSync, mkdirSync, rmSync, statSync } from 'node:fs';
|
|
4
|
+
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { AGENT_TEXT } from './guide.js';
|
|
6
|
+
import { loadSpec, reportLines, sortFindings } from './load.js';
|
|
7
|
+
import { check } from './svg.js';
|
|
8
|
+
import { verify } from './verify.js';
|
|
9
|
+
const ALLOWED = 'Read,Glob,Grep,Bash(npx flowfig *)';
|
|
10
|
+
const DISALLOWED = 'Edit,MultiEdit,NotebookEdit';
|
|
11
|
+
const USAGE = 'usage: flowfig draw "<question>" [--out <path>] [--model <alias>] [--max-turns <n>] [--json]';
|
|
12
|
+
/** A file name from the question: lower case, letters and digits, joined by -, at most 60 characters. */
|
|
13
|
+
export function slug(question) {
|
|
14
|
+
const s = question
|
|
15
|
+
.toLowerCase()
|
|
16
|
+
.match(/[a-z0-9]+/g)
|
|
17
|
+
?.join('-')
|
|
18
|
+
.slice(0, 60)
|
|
19
|
+
.replace(/-$/, '');
|
|
20
|
+
return s || 'figure';
|
|
21
|
+
}
|
|
22
|
+
const specPath = (out) => out.replace(/\.svg$/, '.json');
|
|
23
|
+
/** The claude argv after the binary. The system prompt carries the agent text, the output path and the folder rule. */
|
|
24
|
+
export function agentArgs(o) {
|
|
25
|
+
const spec = specPath(o.out);
|
|
26
|
+
const system = `${AGENT_TEXT.trimEnd()}\n\nWrite the spec to ${spec} with the Write tool. Then run exactly \`npx flowfig ${spec} ${o.out}\` as one command, alone on its line: no heredoc, no \`;\`, no \`&&\`, no pipe (the permission rule matches one plain command only). Then run \`npx flowfig verify ${o.out}\` the same way. Run every command from this folder, ${o.cwd}.`;
|
|
27
|
+
const args = [
|
|
28
|
+
'-p',
|
|
29
|
+
o.question,
|
|
30
|
+
'--output-format',
|
|
31
|
+
'json',
|
|
32
|
+
'--max-turns',
|
|
33
|
+
String(o.maxTurns),
|
|
34
|
+
'--permission-mode',
|
|
35
|
+
'acceptEdits',
|
|
36
|
+
'--allowedTools',
|
|
37
|
+
ALLOWED,
|
|
38
|
+
'--disallowedTools',
|
|
39
|
+
DISALLOWED,
|
|
40
|
+
'--append-system-prompt',
|
|
41
|
+
system,
|
|
42
|
+
];
|
|
43
|
+
if (o.model)
|
|
44
|
+
args.push('--model', o.model);
|
|
45
|
+
return args;
|
|
46
|
+
}
|
|
47
|
+
class Bad extends Error {
|
|
48
|
+
}
|
|
49
|
+
const fail = (message, code) => {
|
|
50
|
+
console.error(message);
|
|
51
|
+
return code;
|
|
52
|
+
};
|
|
53
|
+
/** Run `flowfig draw`. Prints, and returns the exit code. */
|
|
54
|
+
export async function runDraw(argv) {
|
|
55
|
+
const args = [...argv];
|
|
56
|
+
const flag = (n) => args.includes(n) && args.splice(args.indexOf(n), 1).length > 0;
|
|
57
|
+
const value = (n) => {
|
|
58
|
+
const i = args.indexOf(n);
|
|
59
|
+
if (i === -1)
|
|
60
|
+
return undefined;
|
|
61
|
+
const v = args.splice(i, 2)[1];
|
|
62
|
+
if (v === undefined)
|
|
63
|
+
throw new Bad(`${n} needs a value`);
|
|
64
|
+
return v;
|
|
65
|
+
};
|
|
66
|
+
let json, outArg, model, turns;
|
|
67
|
+
try {
|
|
68
|
+
json = flag('--json');
|
|
69
|
+
outArg = value('--out');
|
|
70
|
+
model = value('--model');
|
|
71
|
+
turns = value('--max-turns');
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e instanceof Bad)
|
|
75
|
+
return fail(`draw: ${e.message}\n${USAGE}`, 2);
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
const maxTurns = turns === undefined ? 40 : Number(turns);
|
|
79
|
+
if (!Number.isInteger(maxTurns) || maxTurns < 1)
|
|
80
|
+
return fail(`draw: --max-turns needs a positive whole number\n${USAGE}`, 2);
|
|
81
|
+
const bad = args.find((a) => a.startsWith('-'));
|
|
82
|
+
if (bad)
|
|
83
|
+
return fail(`draw: unknown flag ${bad}\n${USAGE}`, 2);
|
|
84
|
+
const question = args.join(' ').trim();
|
|
85
|
+
if (!question)
|
|
86
|
+
return fail(`draw needs a question\n${USAGE}`, 2);
|
|
87
|
+
if (process.platform === 'win32')
|
|
88
|
+
return fail('draw does not run on Windows yet', 2);
|
|
89
|
+
const bin = process.env.FLOWFIG_CLAUDE_BIN || 'claude';
|
|
90
|
+
const version = spawnSync(bin, ['--version'], { encoding: 'utf8' });
|
|
91
|
+
if (version.error || version.status !== 0)
|
|
92
|
+
return fail('draw needs Claude Code: npm i -g @anthropic-ai/claude-code', 2);
|
|
93
|
+
const cwd = process.cwd();
|
|
94
|
+
const out = outArg ?? `${slug(question)}.svg`;
|
|
95
|
+
if (!out.endsWith('.svg'))
|
|
96
|
+
return fail(`draw: --out needs a .svg path\n${USAGE}`, 2);
|
|
97
|
+
const full = resolve(cwd, out);
|
|
98
|
+
try {
|
|
99
|
+
mkdirSync(dirname(full), { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
catch (e) {
|
|
102
|
+
return fail(`draw: ${e.message}`, 2);
|
|
103
|
+
}
|
|
104
|
+
// A figure from an earlier run must not pass as this run's work: compare the mtime.
|
|
105
|
+
const mtime = () => (existsSync(full) ? statSync(full).mtimeMs : undefined);
|
|
106
|
+
const before = mtime();
|
|
107
|
+
const run = spawnSync(bin, agentArgs({ question, out, cwd, maxTurns, model }), {
|
|
108
|
+
cwd,
|
|
109
|
+
encoding: 'utf8',
|
|
110
|
+
stdio: ['ignore', 'pipe', 'inherit'],
|
|
111
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
112
|
+
});
|
|
113
|
+
// The JSON is the last line that starts with {: a warning line may come before it.
|
|
114
|
+
const line = (run.stdout ?? '')
|
|
115
|
+
.split('\n')
|
|
116
|
+
.reverse()
|
|
117
|
+
.find((l) => l.startsWith('{'));
|
|
118
|
+
let reply = '', cost = null, session = null, stopped, denials = [];
|
|
119
|
+
try {
|
|
120
|
+
const parsed = JSON.parse(line ?? '');
|
|
121
|
+
reply = String(parsed.result ?? '');
|
|
122
|
+
cost = typeof parsed.total_cost_usd === 'number' ? parsed.total_cost_usd : null;
|
|
123
|
+
session = typeof parsed.session_id === 'string' ? parsed.session_id : null;
|
|
124
|
+
if (parsed.is_error === true)
|
|
125
|
+
stopped = String(parsed.subtype ?? '?');
|
|
126
|
+
if (Array.isArray(parsed.permission_denials))
|
|
127
|
+
denials = parsed.permission_denials;
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
process.stderr.write(run.stdout ?? '');
|
|
131
|
+
const message = `claude failed (exit ${run.status ?? 'null'})`;
|
|
132
|
+
if (json)
|
|
133
|
+
console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
|
|
134
|
+
return fail(`draw: ${message}`, 1);
|
|
135
|
+
}
|
|
136
|
+
const stop = () => {
|
|
137
|
+
if (stopped !== undefined)
|
|
138
|
+
console.error(`draw: claude stopped (${stopped})`);
|
|
139
|
+
};
|
|
140
|
+
const after = mtime();
|
|
141
|
+
if (after === undefined || after === before) {
|
|
142
|
+
const message = `the agent wrote no figure at ${out}`;
|
|
143
|
+
if (json)
|
|
144
|
+
console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
|
|
145
|
+
else
|
|
146
|
+
console.log(reply);
|
|
147
|
+
for (const d of denials)
|
|
148
|
+
console.error(`denied: ${d.tool_name ?? '?'} ${d.tool_input?.command ?? d.tool_input?.file_path ?? ''}`.trimEnd());
|
|
149
|
+
stop();
|
|
150
|
+
return fail(`draw: ${message}`, 1);
|
|
151
|
+
}
|
|
152
|
+
let props;
|
|
153
|
+
try {
|
|
154
|
+
props = loadSpec(full);
|
|
155
|
+
}
|
|
156
|
+
catch (e) {
|
|
157
|
+
stop();
|
|
158
|
+
const message = e.message;
|
|
159
|
+
if (json)
|
|
160
|
+
console.log(JSON.stringify({ out, reply, cost, session, findings: [], error: message }, null, 2));
|
|
161
|
+
return fail(`draw: ${message}`, 1);
|
|
162
|
+
}
|
|
163
|
+
const findings = sortFindings([...check(props), ...verify(props, { root: cwd })], true);
|
|
164
|
+
const errors = findings.length > 0;
|
|
165
|
+
if (!errors && stopped === undefined)
|
|
166
|
+
rmSync(specPath(full), { force: true });
|
|
167
|
+
if (json)
|
|
168
|
+
console.log(JSON.stringify({ out, reply, cost, session, findings }, null, 2));
|
|
169
|
+
else {
|
|
170
|
+
for (const l of reportLines(props, findings))
|
|
171
|
+
console.log(l);
|
|
172
|
+
console.log('');
|
|
173
|
+
console.log(reply);
|
|
174
|
+
console.log(`agent: claude, $${cost === null ? '?' : cost.toFixed(4)}, session ${session ?? '?'}`);
|
|
175
|
+
}
|
|
176
|
+
stop();
|
|
177
|
+
return errors || stopped !== undefined ? 1 : 0;
|
|
178
|
+
}
|
package/dist/guide.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
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`.\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 each end state, for example with `sub: \"end state\"`. |\n| A flowchart with branches | `shape: \"decision\"` for each branch. One step per path. |\n\nflowfig does not draw class or ER diagrams (fields, types, cardinality), Gantt charts, timelines with dates, charts of numbers,\nor mind maps. If the user asks for one of these, say that flowfig does not draw it, suggest Mermaid (`classDiagram`,\n`erDiagram`, `gantt`), 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\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.\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.\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| `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| `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\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?, 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- `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` 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 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 each end state, for example with `sub: \"end state\"`. |\n| A flowchart with branches | `shape: \"decision\"` for each branch. One step per path. |\n\nflowfig does not draw class or ER diagrams (fields, types, cardinality), Gantt charts, timelines with dates, charts of numbers,\nor mind maps. If the user asks for one of these, say that flowfig does not draw it, suggest Mermaid (`classDiagram`,\n`erDiagram`, `gantt`), 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\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.\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| `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| `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\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 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
|
-
export declare const AGENT_TEXT = "# Diagrams with flowfig\n\nUse flowfig when the user wants a diagram of how something works: 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 does not draw class or ER diagrams, Gantt charts or charts of numbers. For those, tell the user so, suggest Mermaid\n(`classDiagram`, `erDiagram`, `gantt`), and 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.\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.\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";
|
|
4
|
+
export declare const AGENT_TEXT = "# Diagrams with flowfig\n\nUse flowfig when the user wants a diagram of how something works: 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 does not draw class or ER diagrams, Gantt charts or charts of numbers. For those, tell the user so, suggest Mermaid\n(`classDiagram`, `erDiagram`, `gantt`), and 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.\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";
|
|
5
5
|
/** The description of the Claude skill: the words that make the agent load it. */
|
|
6
6
|
export declare const SKILL_DESCRIPTION = "Use when the user asks for a diagram, figure or animation of how code or a system works: an architecture, a data flow, a pipeline, a request lifecycle, a call sequence, a state lifecycle or a flowchart; when the user pastes a Mermaid flowchart, stateDiagram or sequenceDiagram to convert; or when the user asks for a class, ER or Gantt diagram.";
|
package/dist/guide.js
CHANGED
|
@@ -18,7 +18,7 @@ A spec has three forms.
|
|
|
18
18
|
|
|
19
19
|
Use the rail for a sequence of messages: a request lifecycle, an API call chain, or a Mermaid \`sequenceDiagram\`. Put each message on
|
|
20
20
|
an 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
|
|
21
|
-
answer with \`"async": true
|
|
21
|
+
answer with \`"async": true\`: every send to a queue or a topic, every emitted event, and every call the caller does not await.
|
|
22
22
|
|
|
23
23
|
## Scope: what flowfig draws and what it does not
|
|
24
24
|
|
|
@@ -64,6 +64,7 @@ If the code does not show a fact, leave the fact out. Do not guess a part, a nam
|
|
|
64
64
|
Each 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
|
|
65
65
|
real example data from the code or its tests. Keep each fact that you leave out, with the reason, for the reply.
|
|
66
66
|
Give 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.
|
|
67
|
+
A 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.
|
|
67
68
|
|
|
68
69
|
Mermaid mapping:
|
|
69
70
|
|
|
@@ -159,6 +160,7 @@ Every other item is a box.
|
|
|
159
160
|
| \`width\` | Width in px. This overrides the width that the layout picks. |
|
|
160
161
|
| \`source\` | The code this draws: \`path\` or \`path#symbol\`, relative to the repo root. \`flowfig verify\` checks it. |
|
|
161
162
|
| \`lines\` | The least number of text lines that a content card keeps. The card still grows to fit its content. |
|
|
163
|
+
| \`tone\` | The state color of the box: a 1 px border and a light tint. Use \`blue\`, \`purple\`, \`green\`, \`orange\`, \`red\` or \`gray\`. |
|
|
162
164
|
|
|
163
165
|
### edges
|
|
164
166
|
|
|
@@ -184,10 +186,14 @@ boxes to highlight for the whole step).
|
|
|
184
186
|
|
|
185
187
|
The beat fields:
|
|
186
188
|
|
|
187
|
-
- \`edges\`: an edge id, a hop object, or an array of these. A hop object is \`{ edge, back?, data?, async?, source? }\`.
|
|
189
|
+
- \`edges\`: an edge id, a hop object, or an array of these. A hop object is \`{ edge, back?, data?, tone?, async?, source? }\`.
|
|
188
190
|
- \`back: true\` runs the packet from the \`to\` box to the \`from\` box.
|
|
189
191
|
- \`data\` is a small card that moves with the packet.
|
|
190
192
|
- \`async: true\` marks a message that does not wait for an answer. The rail draws it dashed, with an \`async\` tag.
|
|
193
|
+
- \`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.
|
|
194
|
+
- \`green\`: a success or a cache hit. \`orange\`: a warning, a miss or a retry. \`red\`: an error or a rejected input.
|
|
195
|
+
- \`gray\`: idle or skipped. \`purple\`: async or background. \`blue\`: the default row color.
|
|
196
|
+
- If the hop has no \`tone\`, the arrival box uses its box \`tone\`, then the accent. A hop \`tone\` wins over a box \`tone\`.
|
|
191
197
|
- \`say\`: the caption for the beat.
|
|
192
198
|
- \`show\`: \`{ boxId: [row, ...] }\`. This fills the content card of each named box. The card keeps the rows until the step ends.
|
|
193
199
|
- \`light\`: ids of boxes to highlight for this beat.
|
|
@@ -196,7 +202,7 @@ The beat fields:
|
|
|
196
202
|
A row of a content card is \`{ tag?, tone?, text, meta?, mark?, mono? }\`.
|
|
197
203
|
|
|
198
204
|
- \`tag\`: a short colored label at the start of the line.
|
|
199
|
-
- \`tone\`: the color of the tag: \`blue\` (default), \`purple\`, \`green\`, \`orange\` or \`gray\`.
|
|
205
|
+
- \`tone\`: the color of the tag: \`blue\` (default), \`purple\`, \`green\`, \`orange\`, \`red\` or \`gray\`.
|
|
200
206
|
- \`text\`: the main text.
|
|
201
207
|
- \`meta\`: muted text after the main text.
|
|
202
208
|
- \`mark\`: a mark at the right end of the line, such as a check or "new".
|
|
@@ -284,6 +290,7 @@ flowfig does not draw class or ER diagrams, Gantt charts or charts of numbers. F
|
|
|
284
290
|
JSON in the heredoc. Fix every fault by changing the layout first (rows, \`around\`, the gap). Never remove a fact to pass
|
|
285
291
|
the check; if you must remove one, name it in the reply. If \`small-text\` appears, use two rows or split the figure; keep
|
|
286
292
|
the real names; do not change \`--width\`. Give each box and edge that draws code a \`source\` (\`file#symbol\`) from the fact list.
|
|
293
|
+
Mark every send to a queue or a topic, every emitted event, and every call the caller does not await with \`async: true\`.
|
|
287
294
|
5. 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
|
|
288
295
|
fact names. Fix each gap. Then run \`npx flowfig verify out.svg\` from the repo root and fix each fault.
|
|
289
296
|
6. 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.
|
package/dist/index.js
CHANGED
|
@@ -4,12 +4,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
4
4
|
import { route } from './geometry.js';
|
|
5
5
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
6
6
|
import { checkScene, checkSpec, checkTheme } from './check.js';
|
|
7
|
-
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, } from './model.js';
|
|
7
|
+
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, nodes, toBeat, beatMs, STEP_HOLD_MS, } from './model.js';
|
|
8
8
|
export { LIGHT, DARK } from './model.js';
|
|
9
9
|
const DEFAULTS = { ...LIGHT, font: 'inherit' };
|
|
10
10
|
const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
|
|
11
11
|
const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
|
|
12
|
-
const
|
|
12
|
+
const ACTIVE = 'flowfig-active'; // the box a packet has just reached
|
|
13
|
+
// The active box reads --ff-hop (the tone of the hop that arrived), then --ff-box (the tone of the box), then the accent.
|
|
14
|
+
const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
|
|
15
|
+
const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
|
|
13
16
|
const cardBody = (c) => {
|
|
14
17
|
if (c == null)
|
|
15
18
|
return '—';
|
|
@@ -228,12 +231,12 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
228
231
|
}, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
|
|
229
232
|
// Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
|
|
230
233
|
useEffect(() => {
|
|
231
|
-
const gs = dots.current, cs = chips.current;
|
|
234
|
+
const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
|
|
232
235
|
if (clock.current.beats !== beats) {
|
|
233
236
|
// A rail click can open another step at a later beat; start the clock at that beat, not at 0.
|
|
234
237
|
const j = jump.current ?? 0;
|
|
235
238
|
jump.current = null;
|
|
236
|
-
clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + (b
|
|
239
|
+
clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
|
|
237
240
|
setBeat(j);
|
|
238
241
|
}
|
|
239
242
|
if (!beats.length || (!noMap && !routed.length))
|
|
@@ -243,8 +246,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
243
246
|
bar.current.style.transform = 'none';
|
|
244
247
|
return void setBeat(beats.length - 1);
|
|
245
248
|
}
|
|
246
|
-
const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + (b
|
|
247
|
-
const total = ends.at(-1) +
|
|
249
|
+
const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
|
|
250
|
+
const total = ends.at(-1) + STEP_HOLD_MS; // The figure holds on the last beat before the next step.
|
|
248
251
|
let raf = 0, last = performance.now(), shownBeat = -1;
|
|
249
252
|
const tick = (now) => {
|
|
250
253
|
if (playingRef.current)
|
|
@@ -263,7 +266,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
263
266
|
if (i !== shownBeat)
|
|
264
267
|
setBeat((shownBeat = i));
|
|
265
268
|
const start = i ? ends[i - 1] : 0;
|
|
266
|
-
const f = Math.min(1, (t - start) /
|
|
269
|
+
const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
|
|
267
270
|
const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
|
|
268
271
|
const hops = t < ends.at(-1) ? beats[i].hops : [];
|
|
269
272
|
gs.forEach((g, j) => {
|
|
@@ -277,20 +280,51 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
277
280
|
return;
|
|
278
281
|
}
|
|
279
282
|
const pt = p.getPointAtLength((hops[j].back ? 1 - eased : eased) * p.getTotalLength());
|
|
283
|
+
const tone = hops[j].tone && TONES[hops[j].tone];
|
|
280
284
|
if (g) {
|
|
281
285
|
g.setAttribute('transform', `translate(${pt.x} ${pt.y})`);
|
|
282
286
|
g.style.opacity = '1';
|
|
287
|
+
g.style.color = tone ?? v('accent');
|
|
283
288
|
}
|
|
284
289
|
if (c) {
|
|
290
|
+
c.style.background = tone ? toneFill(tone) : v('accent');
|
|
285
291
|
c.style.transform = `translate(${pt.x}px, ${pt.y}px) translate(-50%, calc(-100% - 12px))`;
|
|
286
292
|
c.style.opacity = hops[j].data == null ? '0' : '1';
|
|
287
293
|
}
|
|
288
294
|
});
|
|
295
|
+
// A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
|
|
296
|
+
const arrived = new Map();
|
|
297
|
+
if (f >= 1)
|
|
298
|
+
for (const h of beats[i].hops) {
|
|
299
|
+
const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
|
|
300
|
+
arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
|
|
301
|
+
}
|
|
302
|
+
root.current?.querySelectorAll('[data-fig]').forEach((n) => {
|
|
303
|
+
const on = arrived.has(n.dataset.fig ?? '');
|
|
304
|
+
if (on) {
|
|
305
|
+
const tone = arrived.get(n.dataset.fig);
|
|
306
|
+
if (tone)
|
|
307
|
+
n.style.setProperty('--ff-hop', tone);
|
|
308
|
+
else
|
|
309
|
+
n.style.removeProperty('--ff-hop');
|
|
310
|
+
}
|
|
311
|
+
if (on === n.classList.contains(ACTIVE))
|
|
312
|
+
return;
|
|
313
|
+
if (!on)
|
|
314
|
+
return void n.classList.remove(ACTIVE); // the transition fades the box
|
|
315
|
+
// The box turns active at once: no transition while the class arrives.
|
|
316
|
+
const keep = n.style.transition;
|
|
317
|
+
n.style.transition = 'none';
|
|
318
|
+
n.classList.add(ACTIVE);
|
|
319
|
+
void n.offsetWidth;
|
|
320
|
+
n.style.transition = keep;
|
|
321
|
+
});
|
|
289
322
|
raf = requestAnimationFrame(tick);
|
|
290
323
|
};
|
|
291
324
|
raf = requestAnimationFrame(tick);
|
|
292
325
|
return () => {
|
|
293
326
|
cancelAnimationFrame(raf);
|
|
327
|
+
root.current?.querySelectorAll(`.${ACTIVE}`).forEach((n) => n.classList.remove(ACTIVE));
|
|
294
328
|
gs.forEach((g) => g && (g.style.opacity = '0'));
|
|
295
329
|
cs.forEach((c) => c && (c.style.opacity = '0'));
|
|
296
330
|
};
|
|
@@ -309,10 +343,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
309
343
|
if (litEdges.has(ids[i]))
|
|
310
344
|
litNodes.add(e.from).add(e.to);
|
|
311
345
|
});
|
|
346
|
+
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
347
|
+
const hopTone = (edge) => {
|
|
348
|
+
const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
|
|
349
|
+
return t && TONES[t];
|
|
350
|
+
};
|
|
312
351
|
const goTo = (step, b) => {
|
|
313
352
|
setPlaying(true);
|
|
314
353
|
if (step === active) {
|
|
315
|
-
clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + (x
|
|
354
|
+
clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
|
|
316
355
|
setBeat(b);
|
|
317
356
|
}
|
|
318
357
|
else {
|
|
@@ -360,7 +399,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
360
399
|
const diamond = item.shape === 'decision';
|
|
361
400
|
const store = item.shape === 'store';
|
|
362
401
|
const card = carded.has(item.id);
|
|
363
|
-
|
|
402
|
+
const bt = item.tone && TONES[item.tone];
|
|
403
|
+
return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
|
|
364
404
|
position: 'relative',
|
|
365
405
|
isolation: 'isolate',
|
|
366
406
|
minWidth: 100,
|
|
@@ -371,17 +411,20 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
371
411
|
display: 'flex',
|
|
372
412
|
flexDirection: 'column',
|
|
373
413
|
justifyContent: 'center',
|
|
374
|
-
padding: diamond
|
|
414
|
+
padding: diamond
|
|
415
|
+
? '22px 34px'
|
|
416
|
+
: `calc(${store ? 24 : 10}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(10px - var(--ff-b, 0px))`,
|
|
375
417
|
textAlign: 'center',
|
|
376
|
-
background: diamond ? undefined : v('bg'),
|
|
418
|
+
background: diamond ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
|
|
377
419
|
color: v('fg'),
|
|
378
|
-
border: diamond ? undefined : `1px solid ${lit ? v('accent') : v('border')}`,
|
|
379
|
-
|
|
420
|
+
border: diamond ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
|
|
421
|
+
...(bt && { '--ff-box': bt }),
|
|
422
|
+
boxShadow: diamond ? undefined : '0 1px 2px rgba(0,0,0,.06)',
|
|
380
423
|
opacity: focus && !lit ? 0.7 : 1,
|
|
381
424
|
borderRadius: store ? '50% / 12px' : 10,
|
|
382
425
|
fontSize: 14,
|
|
383
426
|
fontWeight: 500,
|
|
384
|
-
transition: 'border-color .
|
|
427
|
+
transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
|
|
385
428
|
cursor: 'default',
|
|
386
429
|
}, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
|
|
387
430
|
position: 'absolute',
|
|
@@ -390,7 +433,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
390
433
|
height: '100%',
|
|
391
434
|
overflow: 'visible',
|
|
392
435
|
zIndex: -1,
|
|
393
|
-
}, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: v('bg'), stroke: lit ? v('accent') : v('border'), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .
|
|
436
|
+
}, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: bt ? toneTint(bt, v('bg')) : v('bg'), stroke: bt ?? (lit ? v('accent') : v('border')), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .4s, fill .4s, stroke-width .4s' } }) })), store && (_jsx("div", { style: {
|
|
394
437
|
position: 'absolute',
|
|
395
438
|
top: -1,
|
|
396
439
|
left: -1,
|
|
@@ -398,7 +441,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
398
441
|
height: 24,
|
|
399
442
|
boxSizing: 'border-box',
|
|
400
443
|
borderRadius: '50%',
|
|
401
|
-
border: `1px solid ${lit ? v('accent') : v('border')}`,
|
|
444
|
+
border: `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
|
|
402
445
|
transition: 'border-color .25s',
|
|
403
446
|
} })), _jsx("div", { children: item.label }), item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
|
|
404
447
|
marginTop: 8,
|
|
@@ -452,7 +495,10 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
452
495
|
zIndex: 3,
|
|
453
496
|
width: 28,
|
|
454
497
|
height: 28,
|
|
455
|
-
}, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}'
|
|
498
|
+
}, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
|
|
499
|
+
// The active box has a 2 px border, a tint and a glow. The padding shrinks by 1 px. The box size stays the same, so the edges keep their route.
|
|
500
|
+
`.${ACTIVE}:not([data-diamond]){--ff-b:1px;border-width:2px!important;background:color-mix(in srgb, ${hue} 10%, ${v('surface')})!important;box-shadow:${glow}!important;border-color:${hue}!important}` +
|
|
501
|
+
`.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
|
|
456
502
|
margin: full ? '-48px -24px 0' : '-20px -16px 0',
|
|
457
503
|
padding: full ? '48px 24px 24px' : '24px 16px',
|
|
458
504
|
borderRadius: full ? 0 : '16px 16px 0 0',
|
|
@@ -479,15 +525,16 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
479
525
|
height: '100%',
|
|
480
526
|
overflow: 'visible',
|
|
481
527
|
pointerEvents: 'none',
|
|
482
|
-
}, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill:
|
|
528
|
+
}, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: "context-stroke" }) }, k))) }), routed.map((r) => {
|
|
483
529
|
const on = litEdges.has(r.id);
|
|
530
|
+
const tone = hopTone(r.id);
|
|
484
531
|
const hidden = !on && edges[ids.indexOf(r.id)].quiet;
|
|
485
532
|
return (_jsx("path", { ref: (p) => {
|
|
486
533
|
paths.current[r.id] = p;
|
|
487
|
-
}, d: r.d, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
|
|
534
|
+
}, d: r.d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
|
|
488
535
|
}), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
|
|
489
536
|
dots.current[j] = g;
|
|
490
|
-
}, style: { opacity: 0 }, children: [_jsx("circle", { r: 10, fill:
|
|
537
|
+
}, style: { opacity: 0, color: v('accent') }, children: [_jsx("circle", { r: 10, fill: "currentColor", opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: "currentColor" })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
|
|
491
538
|
chips.current[j] = c;
|
|
492
539
|
}, style: {
|
|
493
540
|
position: 'absolute',
|
|
@@ -511,6 +558,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
511
558
|
if (e.label == null)
|
|
512
559
|
return null;
|
|
513
560
|
const on = litEdges.has(r.id);
|
|
561
|
+
const tone = hopTone(r.id);
|
|
514
562
|
return (_jsx("div", { "data-fig-label": r.id, style: {
|
|
515
563
|
position: 'absolute',
|
|
516
564
|
left: r.mid.x,
|
|
@@ -522,9 +570,9 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
522
570
|
borderRadius: 999,
|
|
523
571
|
whiteSpace: 'nowrap',
|
|
524
572
|
pointerEvents: 'none',
|
|
525
|
-
background: on ? v('accent') : v('bg'),
|
|
573
|
+
background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
|
|
526
574
|
color: on ? ON_ACCENT : v('muted'),
|
|
527
|
-
border: `1px solid ${on ? v('accent') : v('border')}`,
|
|
575
|
+
border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
|
|
528
576
|
opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
|
|
529
577
|
fontFamily: MONO,
|
|
530
578
|
transition: 'background .25s, color .25s, opacity .25s',
|
|
@@ -565,14 +613,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
565
613
|
(hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
|
|
566
614
|
const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
|
|
567
615
|
const ly = y + RAIL.row / 2;
|
|
616
|
+
const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
|
|
568
617
|
const g = row.group != null ? rail.groups[row.group] : null;
|
|
569
|
-
return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
|
|
618
|
+
return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
|
|
570
619
|
(() => {
|
|
571
620
|
const tagW = ASYNC_TAG_W;
|
|
572
621
|
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
573
622
|
const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
574
623
|
return (_jsxs(_Fragment, { children: [_jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: v('bg') }), _jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: TONES.gray, fillOpacity: 0.15 }), _jsx("text", { x: tx + tagW / 2, y: ly + 3, fill: TONES.gray, fontSize: 9, fontWeight: 600, letterSpacing: ".03em", textAnchor: "middle", "data-fig-tag": true, children: "ASYNC" })] }));
|
|
575
|
-
})(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? v('accent') : v('bg'), stroke: on ? v('accent') : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
|
|
624
|
+
})(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
|
|
576
625
|
});
|
|
577
626
|
})(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => setPlaying((p) => !p), children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), _jsx("div", { role: "tablist", style: {
|
|
578
627
|
display: 'inline-flex',
|
package/dist/model.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ export type FigNode = {
|
|
|
13
13
|
lines?: number;
|
|
14
14
|
/** Width in px. Overrides the width the layout picks. */
|
|
15
15
|
width?: number;
|
|
16
|
+
/** A permanent state color for the box, such as a failing part. It gives a 1 px border and a light tint. An arrival with no hop tone uses it too. */
|
|
17
|
+
tone?: FigTone;
|
|
16
18
|
/** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
|
|
17
19
|
source?: string;
|
|
18
20
|
};
|
|
@@ -56,6 +58,8 @@ export type FigHop = string | {
|
|
|
56
58
|
back?: boolean;
|
|
57
59
|
/** A small card that rides along with the packet. */
|
|
58
60
|
data?: ReactNode;
|
|
61
|
+
/** Colors the packet, its data card, the edge and the box it arrives at, for this beat. The trail after the beat stays the accent. Default: the accent. */
|
|
62
|
+
tone?: FigTone;
|
|
59
63
|
/** Marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag. Default: `false`. */
|
|
60
64
|
async?: boolean;
|
|
61
65
|
/** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
|
|
@@ -71,7 +75,7 @@ export type FigBeat = {
|
|
|
71
75
|
show?: Record<string, FigContent>;
|
|
72
76
|
/** Ids of boxes to highlight for this beat, without a packet or a content card. */
|
|
73
77
|
light?: string[];
|
|
74
|
-
/** How long the beat lasts in ms.
|
|
78
|
+
/** How long the beat lasts in ms. The packet crosses in `speed`; the rest is a hold. An explicit `ms` wins as written. Default: `speed` plus the time to read `say`. */
|
|
75
79
|
ms?: number;
|
|
76
80
|
};
|
|
77
81
|
/** One line of a content card. */
|
|
@@ -89,8 +93,8 @@ export type FigRow = {
|
|
|
89
93
|
/** Draws the text in a monospace font. Default: `false`. */
|
|
90
94
|
mono?: boolean;
|
|
91
95
|
};
|
|
92
|
-
/** The color of a row tag. Each tone is a fixed color. */
|
|
93
|
-
export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'gray';
|
|
96
|
+
/** The color of a row tag, a hop or a box. Each tone is a fixed color. */
|
|
97
|
+
export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray';
|
|
94
98
|
/** What a content card shows: rows, or anything React can render. */
|
|
95
99
|
export type FigContent = FigRow[] | ReactNode;
|
|
96
100
|
/** One story the figure can tell. The player shows one tab for each step. */
|
|
@@ -137,6 +141,7 @@ export type Beat = Omit<FigBeat, 'edges'> & {
|
|
|
137
141
|
edge: string;
|
|
138
142
|
back: boolean;
|
|
139
143
|
data?: ReactNode;
|
|
144
|
+
tone?: FigTone;
|
|
140
145
|
async?: boolean;
|
|
141
146
|
source?: string;
|
|
142
147
|
}[];
|
|
@@ -155,6 +160,10 @@ export declare const BASE_RATE = 1.25;
|
|
|
155
160
|
export declare const CARD_LINE = 15, CARD_PAD = 6;
|
|
156
161
|
/** The fixed color of each row tone. */
|
|
157
162
|
export declare const TONES: Record<FigTone, string>;
|
|
163
|
+
/** A tone as a fill under white text: darker, so the text keeps its contrast. */
|
|
164
|
+
export declare const toneFill: (c: string) => string;
|
|
165
|
+
/** The light tint of a tone over a background: the fill of a toned box. */
|
|
166
|
+
export declare const toneTint: (c: string, bg: string, pct?: number) => string;
|
|
158
167
|
/** The width of an edge-label pill in px: the label text plus its padding. The rail uses it for a payload, so both look the same. */
|
|
159
168
|
export declare const labelPillW: (label: string) => number;
|
|
160
169
|
/** The width of the `async` tag in px. */
|
|
@@ -182,6 +191,12 @@ export declare const DARK: {
|
|
|
182
191
|
export declare const ON_ACCENT = "#ffffff";
|
|
183
192
|
/** Only text survives outside React: a React element has no string form. */
|
|
184
193
|
export declare const str: (n: unknown) => string;
|
|
194
|
+
/** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
|
|
195
|
+
export declare const readMs: (say: unknown) => number;
|
|
196
|
+
/** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
|
|
197
|
+
export declare const beatMs: (b: Beat, speed: number) => number;
|
|
198
|
+
/** The hold at the end of each step, before the next step starts. */
|
|
199
|
+
export declare const STEP_HOLD_MS = 2000;
|
|
185
200
|
export declare const nodes: (g: FigGroup) => FigNode[];
|
|
186
201
|
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
187
202
|
export declare const counts: (p: FlowProps) => {
|
package/dist/model.js
CHANGED
|
@@ -25,8 +25,13 @@ export const TONES = {
|
|
|
25
25
|
purple: '#8b5cf6',
|
|
26
26
|
green: '#10b981',
|
|
27
27
|
orange: '#f59e0b',
|
|
28
|
+
red: '#ef4444',
|
|
28
29
|
gray: '#8b949e',
|
|
29
30
|
};
|
|
31
|
+
/** A tone as a fill under white text: darker, so the text keeps its contrast. */
|
|
32
|
+
export const toneFill = (c) => `color-mix(in srgb, ${c} 64%, #000)`;
|
|
33
|
+
/** The light tint of a tone over a background: the fill of a toned box. */
|
|
34
|
+
export const toneTint = (c, bg, pct = 8) => `color-mix(in srgb, ${c} ${pct}%, ${bg})`;
|
|
30
35
|
/** The width of an edge-label pill in px: the label text plus its padding. The rail uses it for a payload, so both look the same. */
|
|
31
36
|
export const labelPillW = (label) => textWidth(label, 11, true) + 14;
|
|
32
37
|
/** The width of the `async` tag in px. */
|
|
@@ -41,6 +46,15 @@ export const DARK = { accent: '#1f78c8', fg: '#e3e3e3', muted: '#9aa0a6', bg: '#
|
|
|
41
46
|
export const ON_ACCENT = '#ffffff';
|
|
42
47
|
/** Only text survives outside React: a React element has no string form. */
|
|
43
48
|
export const str = (n) => (typeof n === 'string' || typeof n === 'number' ? String(n) : '');
|
|
49
|
+
/** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
|
|
50
|
+
export const readMs = (say) => {
|
|
51
|
+
const w = str(say).split(/\s+/).filter(Boolean).length;
|
|
52
|
+
return w ? 400 + 240 * w : 0;
|
|
53
|
+
};
|
|
54
|
+
/** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
|
|
55
|
+
export const beatMs = (b, speed) => b.ms ?? speed + readMs(b.say);
|
|
56
|
+
/** The hold at the end of each step, before the next step starts. */
|
|
57
|
+
export const STEP_HOLD_MS = 2000;
|
|
44
58
|
export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
|
|
45
59
|
const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
|
|
46
60
|
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
package/dist/rail.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Rect } from './geometry.ts';
|
|
2
|
-
import { type FlowProps } from './model.ts';
|
|
2
|
+
import { type FigTone, type FlowProps } from './model.ts';
|
|
3
3
|
export declare const RAIL: {
|
|
4
4
|
readonly gap: 24;
|
|
5
5
|
readonly band: 20;
|
|
@@ -45,6 +45,7 @@ export type RailRow = {
|
|
|
45
45
|
to: number;
|
|
46
46
|
text: string;
|
|
47
47
|
async: boolean;
|
|
48
|
+
tone?: FigTone;
|
|
48
49
|
pill?: {
|
|
49
50
|
x: number;
|
|
50
51
|
w: number;
|
package/dist/rail.js
CHANGED
|
@@ -43,6 +43,7 @@ export function layoutRail(fig, mapWidth) {
|
|
|
43
43
|
back: h.back,
|
|
44
44
|
text: str(h.data) || str(edges.get(h.edge).label),
|
|
45
45
|
async: h.async === true,
|
|
46
|
+
tone: h.tone,
|
|
46
47
|
}))));
|
|
47
48
|
const msgs = perStep.flat(2);
|
|
48
49
|
if (!msgs.length)
|
|
@@ -127,7 +128,20 @@ export function layoutRail(fig, mapWidth) {
|
|
|
127
128
|
const pill = m.text ? { x: mid - labelPillW(m.text) / 2, w: labelPillW(m.text) } : undefined;
|
|
128
129
|
if (group != null)
|
|
129
130
|
groups[group].rows.push(rows.length);
|
|
130
|
-
rows.push({
|
|
131
|
+
rows.push({
|
|
132
|
+
kind: 'message',
|
|
133
|
+
step: si,
|
|
134
|
+
beat: m.beat,
|
|
135
|
+
n: ++n,
|
|
136
|
+
edge: m.edge,
|
|
137
|
+
from,
|
|
138
|
+
to,
|
|
139
|
+
text: m.text,
|
|
140
|
+
async: m.async,
|
|
141
|
+
tone: m.tone,
|
|
142
|
+
pill,
|
|
143
|
+
group,
|
|
144
|
+
});
|
|
131
145
|
}
|
|
132
146
|
}
|
|
133
147
|
});
|
package/dist/source.d.ts
CHANGED
|
@@ -4,6 +4,8 @@ export declare function parseSource(s: string): {
|
|
|
4
4
|
path: string;
|
|
5
5
|
symbol?: string;
|
|
6
6
|
} | null;
|
|
7
|
+
/** The GitHub anchor of a heading: lower case, only letters, digits, spaces and hyphens, spaces to hyphens. */
|
|
8
|
+
export declare const headingSlug: (text: string) => string;
|
|
7
9
|
export type Link = {
|
|
8
10
|
owner: string;
|
|
9
11
|
source: string;
|
package/dist/source.js
CHANGED
|
@@ -7,6 +7,11 @@ export function parseSource(s) {
|
|
|
7
7
|
return null;
|
|
8
8
|
return m[2] ? { path: m[1], symbol: m[2] } : { path: m[1] };
|
|
9
9
|
}
|
|
10
|
+
/** The GitHub anchor of a heading: lower case, only letters, digits, spaces and hyphens, spaces to hyphens. */
|
|
11
|
+
export const headingSlug = (text) => text
|
|
12
|
+
.toLowerCase()
|
|
13
|
+
.replace(/[^\p{L}\p{N} -]/gu, '')
|
|
14
|
+
.replace(/ /g, '-');
|
|
10
15
|
/** Every box, edge and hop with its raw `source`, well-formed or not. The one owner list that `links` and `checkSpec` share. */
|
|
11
16
|
export function owners(fig) {
|
|
12
17
|
return [
|
package/dist/svg.js
CHANGED
|
@@ -12,18 +12,20 @@ import { textWidth } 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, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, toBeat, } 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, toBeat, beatMs, STEP_HOLD_MS, } 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, NODE_MIN_W = 100, NODE_MAX_W = 190;
|
|
19
19
|
const FRAME_TOP = 37, FRAME_SIDE = 18, FRAME_BOTTOM = 18;
|
|
20
|
-
// The card-on fills are fixed colors that approximate the player's 8% accent tint
|
|
21
|
-
const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn};`;
|
|
20
|
+
// The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
|
|
21
|
+
const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
|
|
22
22
|
/** What a card actually holds: rows, or its text. Only text survives outside React. */
|
|
23
23
|
const content = (c) => (isRows(c) ? c : str(c));
|
|
24
24
|
const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '<', '>': '>', '&': '&', '"': '"' })[c]);
|
|
25
25
|
const n2 = (v) => Math.round(v * 10) / 10;
|
|
26
|
-
|
|
26
|
+
// Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
|
|
27
|
+
const n4 = (v) => Math.round(v * 10000) / 10000;
|
|
28
|
+
const pct = (v) => Math.round(v * 10000) / 100 + '%';
|
|
27
29
|
/** Break a string into lines that fit `width`, keeping the newlines it already has. */
|
|
28
30
|
function wrap(s, width, fontSize, mono = false) {
|
|
29
31
|
const out = [];
|
|
@@ -191,17 +193,31 @@ export function render(fig, opts = {}) {
|
|
|
191
193
|
// The timeline: every beat of every step, in order, with the player's hold at the end of each step.
|
|
192
194
|
const segs = [];
|
|
193
195
|
const hops = [];
|
|
196
|
+
const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
|
|
194
197
|
let t = 0;
|
|
195
198
|
beats.forEach((stepBeats, si) => {
|
|
196
199
|
stepBeats.forEach((b, bi) => {
|
|
197
|
-
const dur = (b.ms ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
198
200
|
const last = bi === stepBeats.length - 1;
|
|
201
|
+
const dur = beatMs(b, opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
|
|
202
|
+
const hold = last ? STEP_HOLD_MS / 1000 / BASE_RATE : 0;
|
|
199
203
|
b.hops.forEach((h, lane) => {
|
|
200
204
|
if (byId[h.edge])
|
|
201
|
-
hops.push({
|
|
205
|
+
hops.push({
|
|
206
|
+
si,
|
|
207
|
+
bi,
|
|
208
|
+
lane,
|
|
209
|
+
edge: h.edge,
|
|
210
|
+
dest: h.back ? edgeOf[h.edge].from : edgeOf[h.edge].to,
|
|
211
|
+
back: h.back,
|
|
212
|
+
data: h.data,
|
|
213
|
+
tone: h.tone && TONES[h.tone],
|
|
214
|
+
t0: t,
|
|
215
|
+
t1: t + speed,
|
|
216
|
+
tEnd: t + dur,
|
|
217
|
+
});
|
|
202
218
|
});
|
|
203
|
-
segs.push({ t0: t, t1: t + dur +
|
|
204
|
-
t += dur +
|
|
219
|
+
segs.push({ t0: t, t1: t + dur + hold, si, bi });
|
|
220
|
+
t += dur + hold;
|
|
205
221
|
});
|
|
206
222
|
});
|
|
207
223
|
const total = t || 1;
|
|
@@ -242,8 +258,8 @@ export function render(fig, opts = {}) {
|
|
|
242
258
|
return list ? ` class="${list}"` : '';
|
|
243
259
|
};
|
|
244
260
|
/** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
|
|
245
|
-
const frames = (values, prefix) => {
|
|
246
|
-
if (!segs.length || values.every((v) => v ===
|
|
261
|
+
const frames = (values, prefix, base = values[0]) => {
|
|
262
|
+
if (!segs.length || values.every((v) => v === base))
|
|
247
263
|
return '';
|
|
248
264
|
const key = prefix + values.join('|');
|
|
249
265
|
if (!seen.has(key)) {
|
|
@@ -254,6 +270,59 @@ export function render(fig, opts = {}) {
|
|
|
254
270
|
}
|
|
255
271
|
return seen.get(key);
|
|
256
272
|
};
|
|
273
|
+
/**
|
|
274
|
+
* The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
|
|
275
|
+
* A segment ends at the beat end, or after the step hold for the last beat.
|
|
276
|
+
* Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
|
|
277
|
+
* FADE is in timeline seconds, which are wall seconds.
|
|
278
|
+
*/
|
|
279
|
+
const FADE = 0.4;
|
|
280
|
+
// A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
|
|
281
|
+
const looks = (look, boxTone, hopTone) => {
|
|
282
|
+
const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
|
|
283
|
+
if (look === 'off')
|
|
284
|
+
return [tint, boxTone ?? 'var(--border)', 1, 'drop-shadow(0 0 0 transparent)'];
|
|
285
|
+
if (look === 'trail')
|
|
286
|
+
return [tint, boxTone ?? 'var(--accent)', 1, 'drop-shadow(0 0 0 transparent)'];
|
|
287
|
+
const c = hopTone ?? boxTone;
|
|
288
|
+
return c
|
|
289
|
+
? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
|
|
290
|
+
: ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
|
|
291
|
+
};
|
|
292
|
+
const boxAnim = (id, shape, boxTone) => {
|
|
293
|
+
const pieces = [];
|
|
294
|
+
segs.forEach((s, i) => {
|
|
295
|
+
if (!litNodes[i].has(id))
|
|
296
|
+
return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
|
|
297
|
+
const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
|
|
298
|
+
const arrive = Math.min(...here.map((h) => h.t1), s.t1);
|
|
299
|
+
if (arrive > s.t0)
|
|
300
|
+
pieces.push({ a: s.t0, b: arrive, look: 'trail' });
|
|
301
|
+
if (arrive < s.t1)
|
|
302
|
+
pieces.push({ a: arrive, b: s.t1, look: 'active', hop: here.find((h) => h.tone)?.tone });
|
|
303
|
+
});
|
|
304
|
+
if (!pieces.length || pieces.every((q) => q.look === 'off'))
|
|
305
|
+
return '';
|
|
306
|
+
// The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
|
|
307
|
+
const end = pieces.at(-1);
|
|
308
|
+
if (end.look === 'active') {
|
|
309
|
+
end.b -= FADE;
|
|
310
|
+
pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
|
|
311
|
+
}
|
|
312
|
+
const kf = pieces.map((q, k) => {
|
|
313
|
+
const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
|
|
314
|
+
const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
|
|
315
|
+
const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
|
|
316
|
+
return `${pct((q.a + ramp) / total)},${pct(q.b / total - 0.0001)} { ${css} }`;
|
|
317
|
+
});
|
|
318
|
+
const key = (shape ? 'B' : 'R') + kf.join(' ');
|
|
319
|
+
if (!seen.has(key)) {
|
|
320
|
+
const name = `a${seen.size}`;
|
|
321
|
+
seen.set(key, name);
|
|
322
|
+
css.push(`@keyframes ${name} { ${kf.join(' ')} }\n.${name} { animation: ${name} ${n2(total)}s infinite linear; }`);
|
|
323
|
+
}
|
|
324
|
+
return seen.get(key);
|
|
325
|
+
};
|
|
257
326
|
const boxes = placed.map((p) => {
|
|
258
327
|
const { item } = p;
|
|
259
328
|
if (isGroup(item)) {
|
|
@@ -264,18 +333,21 @@ export function render(fig, opts = {}) {
|
|
|
264
333
|
cls(anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
|
|
265
334
|
`/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
|
|
266
335
|
}
|
|
267
|
-
const
|
|
268
|
-
const stroke = cls(
|
|
336
|
+
const bt = item.tone && TONES[item.tone];
|
|
337
|
+
const stroke = cls(boxAnim(item.id, true, bt));
|
|
338
|
+
const rim = item.shape === 'store' ? cls(boxAnim(item.id, false, bt)) : '';
|
|
339
|
+
const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
|
|
340
|
+
const stroke0 = bt ?? 'var(--border)';
|
|
269
341
|
const cx = p.x + p.w / 2;
|
|
270
342
|
const contents = cards.get(item.id);
|
|
271
343
|
const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
|
|
272
344
|
const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (item.sub ? -2 : 5);
|
|
273
345
|
const shape = item.shape === 'decision'
|
|
274
|
-
? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="
|
|
346
|
+
? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
|
|
275
347
|
: item.shape === 'store'
|
|
276
|
-
? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="
|
|
277
|
-
`<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="
|
|
278
|
-
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="
|
|
348
|
+
? `<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(p.w)} 0 v ${n2(p.h - 24)} a ${n2(p.w / 2)} 12 0 0 1 ${n2(-p.w)} 0 z" fill="${fill0}" stroke="${stroke0}"${stroke}/>` +
|
|
349
|
+
`<path d="M${n2(p.x)} ${n2(p.y + 12)} a ${n2(p.w / 2)} 12 0 0 0 ${n2(p.w)} 0" fill="none" stroke="${stroke0}"${rim}/>`
|
|
350
|
+
: `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
|
|
279
351
|
const label = `<text x="${n2(cx)}" y="${n2(labelY)}" class="label">${esc(str(item.label))}</text>`;
|
|
280
352
|
const sub = item.sub ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>` : '';
|
|
281
353
|
return shape + label + sub + (contents ? card(p, cardTop, contents) : '');
|
|
@@ -340,7 +412,15 @@ export function render(fig, opts = {}) {
|
|
|
340
412
|
const on = segs.map((_, i) => litEdges[i].has(r.id));
|
|
341
413
|
const hidden = e.quiet && !on.every(Boolean);
|
|
342
414
|
const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
|
|
343
|
-
|
|
415
|
+
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
416
|
+
const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
|
|
417
|
+
const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
|
|
418
|
+
const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
|
|
419
|
+
// With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
|
|
420
|
+
const toned = tone.some(Boolean);
|
|
421
|
+
const lit = cls(toned
|
|
422
|
+
? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
|
|
423
|
+
: anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
|
|
344
424
|
const path = `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
|
|
345
425
|
// A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
|
|
346
426
|
const label = e.label == null
|
|
@@ -350,17 +430,19 @@ export function render(fig, opts = {}) {
|
|
|
350
430
|
labelRects[r.id] = { x: r.mid.x - lw / 2, y: r.mid.y - 9, w: lw, h: 18 };
|
|
351
431
|
fonts.push(11);
|
|
352
432
|
return (`<rect x="${n2(r.mid.x - lw / 2)}" y="${n2(r.mid.y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
|
|
353
|
-
cls(
|
|
433
|
+
cls(toned
|
|
434
|
+
? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
|
|
435
|
+
: anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
|
|
354
436
|
`/><text x="${n2(r.mid.x)}" y="${n2(r.mid.y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(str(e.label))}</text>`);
|
|
355
437
|
})();
|
|
356
438
|
return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
|
|
357
439
|
});
|
|
358
|
-
// A packet per hop: it waits offstage, crosses its edge
|
|
440
|
+
// A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
|
|
359
441
|
// rides above it in a chip, which is how the figure says what is moving.
|
|
360
442
|
const packets = hops.map((h, i) => {
|
|
361
|
-
const t0 = h.t0 / total, t1 = h.t1 / total;
|
|
443
|
+
const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
|
|
362
444
|
const name = `p${i}`;
|
|
363
|
-
css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(
|
|
445
|
+
css.push(`@keyframes ${name} { 0%,${pct(t0)} { opacity: 0 } ${pct(t0 + 0.0001)},${pct(tEnd - 0.0001)} { opacity: 1 } ${pct(tEnd)},100% { opacity: 0 } }\n` +
|
|
364
446
|
`.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
|
|
365
447
|
const chip = (() => {
|
|
366
448
|
const label = str(h.data);
|
|
@@ -370,11 +452,11 @@ export function render(fig, opts = {}) {
|
|
|
370
452
|
const lines = wrap(label, 210, 11.5);
|
|
371
453
|
const w = Math.max(...lines.map((l) => textWidth(l, 11.5))) + 18;
|
|
372
454
|
const boxH = lines.length * 15 + 8;
|
|
373
|
-
return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="var(--accent)"/>` +
|
|
455
|
+
return (`<rect x="${n2(-w / 2)}" y="${n2(-boxH - 12)}" width="${n2(w)}" height="${n2(boxH)}" rx="8" fill="${h.tone ? toneFill(h.tone) : 'var(--accent)'}"/>` +
|
|
374
456
|
lines.map((l, li) => `<text x="0" y="${n2(-boxH - 12 + 15 * li + 15)}" class="chip">${esc(l)}</text>`).join(''));
|
|
375
457
|
})();
|
|
376
|
-
return (`<g${cls(name)} opacity="0"><circle r="10" fill="var(--accent)" opacity="0.2"/><circle r="4.5" fill="var(--accent)"/>${chip}` +
|
|
377
|
-
`<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${
|
|
458
|
+
return (`<g${cls(name)} opacity="0"><circle r="10" fill="${h.tone ?? 'var(--accent)'}" opacity="0.2"/><circle r="4.5" fill="${h.tone ?? 'var(--accent)'}"/>${chip}` +
|
|
459
|
+
`<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">` +
|
|
378
460
|
`<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
|
|
379
461
|
});
|
|
380
462
|
const bounds = placed[0];
|
|
@@ -463,7 +545,8 @@ export function render(fig, opts = {}) {
|
|
|
463
545
|
const next = segs.map((s) => s.si < row.step || (s.si === row.step && s.bi < row.beat));
|
|
464
546
|
const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
|
|
465
547
|
const ly = y + RAIL.row / 2;
|
|
466
|
-
const
|
|
548
|
+
const col = row.tone ? TONES[row.tone] : 'var(--accent)';
|
|
549
|
+
const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
|
|
467
550
|
const tagW = ASYNC_TAG_W;
|
|
468
551
|
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
469
552
|
const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
@@ -473,7 +556,7 @@ export function render(fig, opts = {}) {
|
|
|
473
556
|
`<text x="${n2(tagX + tagW / 2)}" y="${n2(ly + 3)}" class="tag" fill="${TONES.gray}">ASYNC</text>`
|
|
474
557
|
: '';
|
|
475
558
|
const pill = row.pill
|
|
476
|
-
? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now,
|
|
559
|
+
? `<rect x="${n2(row.pill.x)}" y="${n2(ly - 9)}" width="${n2(row.pill.w)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"${cls(anim(now, `fill: ${row.tone ? toneFill(col) : col}; stroke: ${col}`, 'fill: var(--bg); stroke: var(--border)', row.tone ? 'l' + col : 'l'))}/>` +
|
|
477
560
|
`<text x="${n2(row.pill.x + row.pill.w / 2)}" y="${n2(ly + 4)}"${cls('edgelabel', anim(now, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(row.text)}</text>`
|
|
478
561
|
: '';
|
|
479
562
|
parts.push(`<g${cls('railrow', moveRow(i))}><g${cls(anim(next, 'opacity: .45', 'opacity: 1', 'r'))}>` +
|
|
@@ -507,7 +590,7 @@ svg { ${vars(t0, '#eef5fd')} }
|
|
|
507
590
|
.end { text-anchor: end; }
|
|
508
591
|
${css.join('\n')}
|
|
509
592
|
</style>
|
|
510
|
-
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="
|
|
593
|
+
<defs><marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse"><path d="M 0 1 L 9 5 L 0 9 z" fill="context-stroke"/></marker></defs>
|
|
511
594
|
<rect width="100%" height="100%" fill="var(--bg)"/>
|
|
512
595
|
${only
|
|
513
596
|
? ''
|
package/dist/verify.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { FlowProps } from './model.ts';
|
|
2
2
|
import type { Finding } from './scene.ts';
|
|
3
|
-
export { parseSource, owners, links, type Link } from './source.ts';
|
|
3
|
+
export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
|
|
4
4
|
/** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
|
|
5
5
|
export declare function verify(fig: FlowProps, { root }?: {
|
|
6
6
|
root?: string;
|
package/dist/verify.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
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
3
|
import { isAbsolute, relative, resolve } from 'node:path';
|
|
4
|
-
import { links } from './source.js';
|
|
5
|
-
export { parseSource, owners, links } from './source.js';
|
|
4
|
+
import { headingSlug, links } from './source.js';
|
|
5
|
+
export { parseSource, headingSlug, owners, links } from './source.js';
|
|
6
6
|
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
7
7
|
/** The file text, or null for a missing path, a folder or an unreadable file. */
|
|
8
8
|
function read(full) {
|
|
@@ -13,6 +13,11 @@ function read(full) {
|
|
|
13
13
|
return null;
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
|
+
/** True if a heading line of the Markdown text has this slug. */
|
|
17
|
+
const hasHeading = (text, slug) => text.split('\n').some((line) => {
|
|
18
|
+
const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
|
|
19
|
+
return m != null && headingSlug(m[1]) === slug;
|
|
20
|
+
});
|
|
16
21
|
/** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
|
|
17
22
|
export function verify(fig, { root = process.cwd() } = {}) {
|
|
18
23
|
const out = [];
|
|
@@ -29,7 +34,9 @@ export function verify(fig, { root = process.cwd() } = {}) {
|
|
|
29
34
|
const text = files.get(full);
|
|
30
35
|
if (text == null)
|
|
31
36
|
out.push({ rule: 'missing-file', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: file not found` });
|
|
32
|
-
else if (l.symbol &&
|
|
37
|
+
else if (l.symbol &&
|
|
38
|
+
!new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
|
|
39
|
+
!(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
|
|
33
40
|
out.push({ rule: 'missing-symbol', severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: symbol not found` });
|
|
34
41
|
}
|
|
35
42
|
return out;
|
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.3.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.3.0';
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "flowfig",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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": {
|