flowfig 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +428 -0
- package/dist/check.d.ts +14 -0
- package/dist/check.js +155 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +133 -0
- package/dist/geometry.d.ts +24 -0
- package/dist/geometry.js +80 -0
- package/dist/guide.d.ts +6 -0
- package/dist/guide.js +280 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +638 -0
- package/dist/init.d.ts +25 -0
- package/dist/init.js +171 -0
- package/dist/model.d.ts +191 -0
- package/dist/model.js +71 -0
- package/dist/rail.d.ts +82 -0
- package/dist/rail.js +162 -0
- package/dist/scene.d.ts +35 -0
- package/dist/scene.js +1 -0
- package/dist/svg.d.ts +28 -0
- package/dist/svg.js +563 -0
- package/dist/text.d.ts +2 -0
- package/dist/text.js +13 -0
- package/package.json +80 -0
package/dist/init.d.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
type Agent = {
|
|
2
|
+
id: string;
|
|
3
|
+
name: string;
|
|
4
|
+
/** Paths whose presence means the repo uses the agent. A trailing `/` means a directory. */
|
|
5
|
+
marks: string[];
|
|
6
|
+
/** The file init writes, relative to the repo. */
|
|
7
|
+
file: string;
|
|
8
|
+
/** `section` edits a file the user owns; `whole` owns the file. */
|
|
9
|
+
kind: 'section' | 'whole';
|
|
10
|
+
/** Text before the marked block in a whole file. */
|
|
11
|
+
head?: string;
|
|
12
|
+
note?: string;
|
|
13
|
+
};
|
|
14
|
+
export declare const AGENTS: Agent[];
|
|
15
|
+
/** The ids of the agents that the repo at `dir` already uses. */
|
|
16
|
+
export declare const detect: (dir: string) => string[];
|
|
17
|
+
/** Toggle the agents named by numbers ("1 3" or "1,3"). A number out of range and other text are ignored. */
|
|
18
|
+
export declare function toggle(input: string, selected: Set<string>, count: number): Set<string>;
|
|
19
|
+
/** The picker screen. */
|
|
20
|
+
export declare function renderList(selected: Set<string>, detected: string[], dir: string): string;
|
|
21
|
+
/** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
|
|
22
|
+
export declare function place(a: Agent, old: string | undefined): string | undefined;
|
|
23
|
+
/** Run `flowfig init`. Returns the exit code. */
|
|
24
|
+
export declare function runInit(argv: string[]): Promise<number>;
|
|
25
|
+
export {};
|
package/dist/init.js
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
|
|
2
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
4
|
+
import { dirname, join, resolve } from 'node:path';
|
|
5
|
+
import { createInterface } from 'node:readline';
|
|
6
|
+
import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
|
|
7
|
+
const START = '<!-- flowfig:start -->';
|
|
8
|
+
const END = '<!-- flowfig:end -->';
|
|
9
|
+
export const AGENTS = [
|
|
10
|
+
{
|
|
11
|
+
id: 'claude',
|
|
12
|
+
name: 'Claude Code',
|
|
13
|
+
marks: ['.claude/', 'CLAUDE.md'],
|
|
14
|
+
file: '.claude/skills/figure/SKILL.md',
|
|
15
|
+
kind: 'whole',
|
|
16
|
+
head: `---\nname: figure\ndescription: ${JSON.stringify(SKILL_DESCRIPTION)}\nuser_invocable: true\n---\n\n`,
|
|
17
|
+
},
|
|
18
|
+
{ id: 'agents', name: 'AGENTS.md', marks: ['AGENTS.md'], file: 'AGENTS.md', kind: 'section', note: 'Codex, Amp, Jules, ...' },
|
|
19
|
+
{
|
|
20
|
+
id: 'cursor',
|
|
21
|
+
name: 'Cursor',
|
|
22
|
+
marks: ['.cursor/', '.cursorrules'],
|
|
23
|
+
file: '.cursor/rules/flowfig.mdc',
|
|
24
|
+
kind: 'whole',
|
|
25
|
+
head: '---\ndescription: Draw diagrams and flow figures with flowfig\nalwaysApply: false\n---\n\n',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: 'copilot',
|
|
29
|
+
name: 'GitHub Copilot',
|
|
30
|
+
marks: ['.github/copilot-instructions.md'],
|
|
31
|
+
file: '.github/copilot-instructions.md',
|
|
32
|
+
kind: 'section',
|
|
33
|
+
},
|
|
34
|
+
{ id: 'gemini', name: 'Gemini CLI', marks: ['GEMINI.md', '.gemini/'], file: 'GEMINI.md', kind: 'section' },
|
|
35
|
+
{ id: 'windsurf', name: 'Windsurf', marks: ['.windsurf/', '.windsurfrules'], file: '.windsurf/rules/flowfig.md', kind: 'whole' },
|
|
36
|
+
{ id: 'kiro', name: 'Kiro', marks: ['.kiro/'], file: '.kiro/steering/flowfig.md', kind: 'whole' },
|
|
37
|
+
];
|
|
38
|
+
/** The ids of the agents that the repo at `dir` already uses. */
|
|
39
|
+
export const detect = (dir) => AGENTS.filter((a) => a.marks.some((m) => existsSync(join(dir, m)))).map((a) => a.id);
|
|
40
|
+
/** Toggle the agents named by numbers ("1 3" or "1,3"). A number out of range and other text are ignored. */
|
|
41
|
+
export function toggle(input, selected, count) {
|
|
42
|
+
const next = new Set(selected);
|
|
43
|
+
for (const t of input.split(/[\s,]+/)) {
|
|
44
|
+
const n = /^\d+$/.test(t) ? Number(t) : 0;
|
|
45
|
+
const id = n >= 1 && n <= count ? AGENTS[n - 1].id : undefined;
|
|
46
|
+
if (id)
|
|
47
|
+
next.has(id) ? next.delete(id) : next.add(id);
|
|
48
|
+
}
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
/** The picker screen. */
|
|
52
|
+
export function renderList(selected, detected, dir) {
|
|
53
|
+
const rows = AGENTS.map((a, i) => {
|
|
54
|
+
const found = detected.includes(a.id) ? `(${a.marks.find((m) => existsSync(join(dir, m)))} found)` : '';
|
|
55
|
+
const dest = a.kind === 'whole' ? a.file : `section in ${a.file}${a.note ? ` (${a.note})` : ''}`;
|
|
56
|
+
return ` [${selected.has(a.id) ? 'x' : ' '}] ${i + 1} ${a.name.padEnd(15)} ${found.padEnd(20)} → ${dest}`;
|
|
57
|
+
});
|
|
58
|
+
return [
|
|
59
|
+
'flowfig: pick the coding agents that should draw diagrams in this repo',
|
|
60
|
+
'',
|
|
61
|
+
...rows,
|
|
62
|
+
'Type numbers to toggle (for example "1 3"), Enter to write, q to quit.',
|
|
63
|
+
].join('\n');
|
|
64
|
+
}
|
|
65
|
+
const block = () => `${START}\n${AGENT_TEXT.trimEnd()}\n${END}\n`;
|
|
66
|
+
/** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
|
|
67
|
+
export function place(a, old) {
|
|
68
|
+
const mine = block();
|
|
69
|
+
if (a.kind === 'whole')
|
|
70
|
+
return old === undefined || old.includes(START) ? `${a.head ?? ''}${mine}` : undefined;
|
|
71
|
+
if (old === undefined)
|
|
72
|
+
return mine;
|
|
73
|
+
const s = old.indexOf(START), e = old.indexOf(END, s);
|
|
74
|
+
if (s !== -1 && e === -1)
|
|
75
|
+
return undefined; // an open block: the next run would eat the text after START
|
|
76
|
+
if (s !== -1)
|
|
77
|
+
return old.slice(0, s) + mine.trimEnd() + old.slice(e + END.length);
|
|
78
|
+
return old + (old.endsWith('\n\n') ? '' : old.endsWith('\n') ? '\n' : '\n\n') + mine;
|
|
79
|
+
}
|
|
80
|
+
const HINT = 'run with -y or --agents <ids>';
|
|
81
|
+
/** Run `flowfig init`. Returns the exit code. */
|
|
82
|
+
export async function runInit(argv) {
|
|
83
|
+
const args = [...argv];
|
|
84
|
+
const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
|
|
85
|
+
const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run');
|
|
86
|
+
let ids;
|
|
87
|
+
const at = args.indexOf('--agents');
|
|
88
|
+
if (at !== -1)
|
|
89
|
+
ids = (args.splice(at, 2)[1] ?? '').split(',').filter(Boolean);
|
|
90
|
+
const bad = args.find((a) => a.startsWith('-'));
|
|
91
|
+
if (bad)
|
|
92
|
+
return fail(`init: unknown flag ${bad}`);
|
|
93
|
+
const dir = resolve(args[0] ?? '.');
|
|
94
|
+
if (listAgents) {
|
|
95
|
+
for (const a of AGENTS)
|
|
96
|
+
console.log(`${a.id.padEnd(9)}${a.name}`);
|
|
97
|
+
return 0;
|
|
98
|
+
}
|
|
99
|
+
const unknown = ids?.find((i) => !AGENTS.some((a) => a.id === i));
|
|
100
|
+
if (unknown)
|
|
101
|
+
return fail(`init: unknown agent "${unknown}" (ids: ${AGENTS.map((a) => a.id).join(', ')})`);
|
|
102
|
+
if (global && ids?.some((i) => i !== 'claude'))
|
|
103
|
+
return fail('init: --global writes only the claude skill (use --agents claude)');
|
|
104
|
+
const detected = detect(dir);
|
|
105
|
+
const auto = new Set(detected.length ? detected : ['agents']);
|
|
106
|
+
let chosen;
|
|
107
|
+
if (global)
|
|
108
|
+
chosen = new Set(['claude']);
|
|
109
|
+
else if (all)
|
|
110
|
+
chosen = new Set(AGENTS.map((a) => a.id));
|
|
111
|
+
else if (ids)
|
|
112
|
+
chosen = new Set(ids);
|
|
113
|
+
else if (yes)
|
|
114
|
+
chosen = auto;
|
|
115
|
+
else if (!process.stdin.isTTY) {
|
|
116
|
+
for (const a of AGENTS)
|
|
117
|
+
console.log(`${a.id.padEnd(9)}${a.name}`);
|
|
118
|
+
return fail(HINT);
|
|
119
|
+
}
|
|
120
|
+
else {
|
|
121
|
+
const picked = await pick(auto, detected, dir);
|
|
122
|
+
if (!picked)
|
|
123
|
+
return 0;
|
|
124
|
+
chosen = picked;
|
|
125
|
+
}
|
|
126
|
+
for (const a of AGENTS.filter((x) => chosen.has(x.id))) {
|
|
127
|
+
const path = global ? join(homedir(), a.file) : join(dir, a.file);
|
|
128
|
+
const old = existsSync(path) ? readFileSync(path, 'utf8') : undefined;
|
|
129
|
+
const next = place(a, old);
|
|
130
|
+
let status;
|
|
131
|
+
if (next === undefined)
|
|
132
|
+
status = old?.includes(START) ? 'skipped (flowfig:start without flowfig:end)' : 'skipped (the file exists and is not from flowfig)';
|
|
133
|
+
else if (next === old)
|
|
134
|
+
status = 'unchanged';
|
|
135
|
+
else {
|
|
136
|
+
status = old === undefined ? 'created' : 'updated';
|
|
137
|
+
if (dry)
|
|
138
|
+
status = `would ${status === 'created' ? 'create' : 'update'}`;
|
|
139
|
+
else {
|
|
140
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
141
|
+
writeFileSync(path, next);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
console.log(`${status.padEnd(9)} ${path}`);
|
|
145
|
+
}
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
function fail(message) {
|
|
149
|
+
console.error(message);
|
|
150
|
+
return 2;
|
|
151
|
+
}
|
|
152
|
+
/** Ask on the terminal. Returns the chosen ids, or `undefined` if the user quits. */
|
|
153
|
+
async function pick(start, detected, dir) {
|
|
154
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
155
|
+
const ask = (q) => new Promise((res) => (rl.once('close', () => res(undefined)), rl.question(q, res)));
|
|
156
|
+
let selected = start;
|
|
157
|
+
try {
|
|
158
|
+
for (;;) {
|
|
159
|
+
console.log(`\n${renderList(selected, detected, dir)}`);
|
|
160
|
+
const line = (await ask('> '))?.trim();
|
|
161
|
+
if (line === undefined || line.toLowerCase() === 'q')
|
|
162
|
+
return undefined;
|
|
163
|
+
if (line === '')
|
|
164
|
+
return selected;
|
|
165
|
+
selected = toggle(line, selected, AGENTS.length);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
rl.close();
|
|
170
|
+
}
|
|
171
|
+
}
|
package/dist/model.d.ts
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
/** A box in the figure. A box with a `show` in some step becomes a content card: it grows to fit the most any step puts in it. */
|
|
3
|
+
export type FigNode = {
|
|
4
|
+
/** Name that edges, steps and beats use to point at this box. Must be unique in the figure. */
|
|
5
|
+
id: string;
|
|
6
|
+
/** The title in the box. */
|
|
7
|
+
label: ReactNode;
|
|
8
|
+
/** A smaller line under the label. */
|
|
9
|
+
sub?: ReactNode;
|
|
10
|
+
/** `box` is a rounded box, `decision` a diamond, `store` a database cylinder for data at rest. Default: `box`. */
|
|
11
|
+
shape?: 'box' | 'decision' | 'store';
|
|
12
|
+
/** The least number of text lines a content card keeps. The card still grows to fit its content. */
|
|
13
|
+
lines?: number;
|
|
14
|
+
/** Width in px. Overrides the width the layout picks. */
|
|
15
|
+
width?: number;
|
|
16
|
+
};
|
|
17
|
+
/** A frame that lays out its children in a row or a column. Groups can hold groups. */
|
|
18
|
+
export type FigGroup = {
|
|
19
|
+
/** Name that edges can point at, to reach the group as a whole. */
|
|
20
|
+
id?: string;
|
|
21
|
+
/** The title on the frame. A group with a label is drawn as a frame around its children. */
|
|
22
|
+
label?: ReactNode;
|
|
23
|
+
/** `row` puts the children side by side, `column` stacks them. Default: `row`. */
|
|
24
|
+
direction?: 'row' | 'column';
|
|
25
|
+
/** Space between children in px. Default: 28 for a column. For a row, wide enough for the widest edge label between two children (at least 56). */
|
|
26
|
+
gap?: number;
|
|
27
|
+
/** Where children line up across the group's direction. Default: center (columns stretch). */
|
|
28
|
+
align?: 'start' | 'center' | 'end';
|
|
29
|
+
/** The boxes and groups inside, in order. */
|
|
30
|
+
children: (FigNode | FigGroup)[];
|
|
31
|
+
};
|
|
32
|
+
/** An arrow from one box to another. */
|
|
33
|
+
export type FigEdge = {
|
|
34
|
+
/** Name that steps and beats use to send a packet along this edge. Default: `from->to`. */
|
|
35
|
+
id?: string;
|
|
36
|
+
/** The `id` of the box where the edge starts. */
|
|
37
|
+
from: string;
|
|
38
|
+
/** The `id` of the box where the edge ends. */
|
|
39
|
+
to: string;
|
|
40
|
+
/** Text on the edge, drawn as a small pill. */
|
|
41
|
+
label?: ReactNode;
|
|
42
|
+
/** Routes the edge over or under the boxes in between (loops, skip-ahead edges). */
|
|
43
|
+
around?: 'above' | 'below';
|
|
44
|
+
/** Draws the edge only while a step uses it. Use it for long edges that would cut across everything. */
|
|
45
|
+
quiet?: boolean;
|
|
46
|
+
};
|
|
47
|
+
/** One packet on one edge. A string is the edge `id`. */
|
|
48
|
+
export type FigHop = string | {
|
|
49
|
+
/** The `id` of the edge the packet crosses. */
|
|
50
|
+
edge: string;
|
|
51
|
+
/** Runs the packet from the edge's `to` box to its `from` box. Default: `false`. */
|
|
52
|
+
back?: boolean;
|
|
53
|
+
/** A small card that rides along with the packet. */
|
|
54
|
+
data?: ReactNode;
|
|
55
|
+
/** Marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag. Default: `false`. */
|
|
56
|
+
async?: boolean;
|
|
57
|
+
};
|
|
58
|
+
/** One moment of a step. No edges means a pause. */
|
|
59
|
+
export type FigBeat = {
|
|
60
|
+
/** The packets that cross in this beat. An array runs them at the same time. */
|
|
61
|
+
edges?: FigHop | FigHop[];
|
|
62
|
+
/** The line of narration for this beat. */
|
|
63
|
+
say?: ReactNode;
|
|
64
|
+
/** Fills the content card of each named box. The card keeps it until the step ends. */
|
|
65
|
+
show?: Record<string, FigContent>;
|
|
66
|
+
/** Ids of boxes to highlight for this beat, without a packet or a content card. */
|
|
67
|
+
light?: string[];
|
|
68
|
+
/** How long the beat lasts in ms. Default: the time one packet needs to cross an edge (`speed`). */
|
|
69
|
+
ms?: number;
|
|
70
|
+
};
|
|
71
|
+
/** One line of a content card. */
|
|
72
|
+
export type FigRow = {
|
|
73
|
+
/** A short colored label at the start of the line. */
|
|
74
|
+
tag?: string;
|
|
75
|
+
/** The color of the tag. Default: `blue`. */
|
|
76
|
+
tone?: FigTone;
|
|
77
|
+
/** The main text of the line. */
|
|
78
|
+
text: ReactNode;
|
|
79
|
+
/** Muted text after the main text. */
|
|
80
|
+
meta?: ReactNode;
|
|
81
|
+
/** A mark at the right end of the line, such as a check or "new". */
|
|
82
|
+
mark?: ReactNode;
|
|
83
|
+
/** Draws the text in a monospace font. Default: `false`. */
|
|
84
|
+
mono?: boolean;
|
|
85
|
+
};
|
|
86
|
+
/** The color of a row tag. Each tone is a fixed color. */
|
|
87
|
+
export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'gray';
|
|
88
|
+
/** What a content card shows: rows, or anything React can render. */
|
|
89
|
+
export type FigContent = FigRow[] | ReactNode;
|
|
90
|
+
/** One story the figure can tell. The player shows one tab for each step. */
|
|
91
|
+
export type FigStep = {
|
|
92
|
+
/** The tab title. */
|
|
93
|
+
label: ReactNode;
|
|
94
|
+
/** The line shown under the figure while no beat of the step has a `say`. */
|
|
95
|
+
caption?: ReactNode;
|
|
96
|
+
/** The beats in play order. A plain hop or hop array is a beat with just edges. */
|
|
97
|
+
flow: (FigHop | FigHop[] | FigBeat)[];
|
|
98
|
+
/** Ids of boxes to highlight for the whole step. */
|
|
99
|
+
nodes?: string[];
|
|
100
|
+
};
|
|
101
|
+
/** Colors for the figure. Each key sets one color, and a color you leave out keeps the built-in value. */
|
|
102
|
+
export type FigTheme = Partial<Record<'accent' | 'fg' | 'muted' | 'bg' | 'surface' | 'border' | 'font', string>>;
|
|
103
|
+
/** The spec of one figure. Give it to `Flow` (React) or to `toSvg` (SVG string). */
|
|
104
|
+
export type FlowProps = {
|
|
105
|
+
/** The boxes and how they line up. */
|
|
106
|
+
layout: FigGroup;
|
|
107
|
+
/** The arrows between the boxes. */
|
|
108
|
+
edges: FigEdge[];
|
|
109
|
+
/** The stories the figure tells. With no steps, the figure is a still map. */
|
|
110
|
+
steps?: FigStep[];
|
|
111
|
+
/** Colors. Can also be set from CSS with --fig-accent, --fig-bg, ... on `.flowfig`. `font` defaults to `inherit`. */
|
|
112
|
+
theme?: FigTheme;
|
|
113
|
+
/** Milliseconds a packet takes to cross one edge. Default: 900. */
|
|
114
|
+
speed?: number;
|
|
115
|
+
/** Starts playing when the figure mounts. Default: `true`. Only the React player reads it. */
|
|
116
|
+
autoplay?: boolean;
|
|
117
|
+
/** Development aid: run the `flowfig check` rules on what the browser drew, and print the faults with console.warn. Default: `false`. */
|
|
118
|
+
check?: boolean;
|
|
119
|
+
/** Draw the steps as a lifeline rail under the map: one row for each message, with its payload. `'only'` draws the rail without the map. Default: `false`. */
|
|
120
|
+
rail?: boolean | 'only';
|
|
121
|
+
};
|
|
122
|
+
/** A figure file's default export: a title, a source note and the props. */
|
|
123
|
+
export type Figure = {
|
|
124
|
+
title: string;
|
|
125
|
+
source?: string;
|
|
126
|
+
props: FlowProps;
|
|
127
|
+
};
|
|
128
|
+
/** A beat with its hops spelled out: every hop has an edge id and a direction. */
|
|
129
|
+
export type Beat = Omit<FigBeat, 'edges'> & {
|
|
130
|
+
hops: {
|
|
131
|
+
edge: string;
|
|
132
|
+
back: boolean;
|
|
133
|
+
data?: ReactNode;
|
|
134
|
+
async?: boolean;
|
|
135
|
+
}[];
|
|
136
|
+
};
|
|
137
|
+
export declare const toBeat: (b: FigHop | FigHop[] | FigBeat) => Beat;
|
|
138
|
+
/** A React node can be an object too, so a row is told apart by its `text` key. */
|
|
139
|
+
export declare const isRows: (c: FigContent) => c is FigRow[];
|
|
140
|
+
export declare const edgeId: (e: FigEdge) => string;
|
|
141
|
+
export declare const isGroup: (x: FigNode | FigGroup) => x is FigGroup;
|
|
142
|
+
export declare const decisions: (g: FigGroup) => string[];
|
|
143
|
+
/** The width of a content card in px. Both renderers use it, so a figure lays out the same way in each. */
|
|
144
|
+
export declare const CARD_WIDTH = 166;
|
|
145
|
+
/** The player runs 1.25 times faster than the timings in a figure: figure timings are written to be read slowly. The SVG matches it. */
|
|
146
|
+
export declare const BASE_RATE = 1.25;
|
|
147
|
+
/** The height of one text line in a content card, and the padding above and below the lines. */
|
|
148
|
+
export declare const CARD_LINE = 15, CARD_PAD = 6;
|
|
149
|
+
/** The fixed color of each row tone. */
|
|
150
|
+
export declare const TONES: Record<FigTone, string>;
|
|
151
|
+
/** 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. */
|
|
152
|
+
export declare const labelPillW: (label: string) => number;
|
|
153
|
+
/** The width of the `async` tag in px. */
|
|
154
|
+
export declare const ASYNC_TAG_W: number;
|
|
155
|
+
/** The stroke width in px of an edge that a packet crosses now, and of an idle edge. */
|
|
156
|
+
export declare const EDGE_ON = 2, EDGE_OFF = 1.25;
|
|
157
|
+
/** The built-in colors. Both renderers and `flowfig check` read these, so they cannot drift apart. */
|
|
158
|
+
export declare const LIGHT: {
|
|
159
|
+
accent: string;
|
|
160
|
+
fg: string;
|
|
161
|
+
muted: string;
|
|
162
|
+
bg: string;
|
|
163
|
+
surface: string;
|
|
164
|
+
border: string;
|
|
165
|
+
};
|
|
166
|
+
export declare const DARK: {
|
|
167
|
+
accent: string;
|
|
168
|
+
fg: string;
|
|
169
|
+
muted: string;
|
|
170
|
+
bg: string;
|
|
171
|
+
surface: string;
|
|
172
|
+
border: string;
|
|
173
|
+
};
|
|
174
|
+
/** Text on an accent fill: the data chips and a lit edge label. */
|
|
175
|
+
export declare const ON_ACCENT = "#ffffff";
|
|
176
|
+
/** Only text survives outside React: a React element has no string form. */
|
|
177
|
+
export declare const str: (n: unknown) => string;
|
|
178
|
+
export declare const nodes: (g: FigGroup) => FigNode[];
|
|
179
|
+
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
180
|
+
export declare const counts: (p: FlowProps) => {
|
|
181
|
+
boxes: number;
|
|
182
|
+
groups: number;
|
|
183
|
+
edges: number;
|
|
184
|
+
steps: number;
|
|
185
|
+
messages: number;
|
|
186
|
+
};
|
|
187
|
+
/**
|
|
188
|
+
* A group's gap. A row's default gap grows to hold the widest label of an edge between two of its children, with 8px each side:
|
|
189
|
+
* the label sits at the middle of that gap. A gap the author sets stays as set; `flowfig check` reports it if it is too narrow.
|
|
190
|
+
*/
|
|
191
|
+
export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;
|
package/dist/model.js
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { textWidth } from './text.js';
|
|
2
|
+
export const toBeat = (b) => {
|
|
3
|
+
const isBeat = typeof b === 'object' && !Array.isArray(b) && !('edge' in b);
|
|
4
|
+
const { edges, ...rest } = isBeat ? b : { edges: b };
|
|
5
|
+
const hops = edges == null ? [] : Array.isArray(edges) ? edges : [edges];
|
|
6
|
+
return {
|
|
7
|
+
...rest,
|
|
8
|
+
hops: hops.map((h) => (typeof h === 'string' ? { edge: h, back: false } : { back: false, ...h })),
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** A React node can be an object too, so a row is told apart by its `text` key. */
|
|
12
|
+
export const isRows = (c) => Array.isArray(c) && c.every((r) => r != null && typeof r === 'object' && 'text' in r);
|
|
13
|
+
export const edgeId = (e) => e.id ?? `${e.from}->${e.to}`;
|
|
14
|
+
export const isGroup = (x) => 'children' in x;
|
|
15
|
+
export const decisions = (g) => g.children.flatMap((c) => (isGroup(c) ? decisions(c) : c.shape === 'decision' ? [c.id] : []));
|
|
16
|
+
/** The width of a content card in px. Both renderers use it, so a figure lays out the same way in each. */
|
|
17
|
+
export const CARD_WIDTH = 166;
|
|
18
|
+
/** The player runs 1.25 times faster than the timings in a figure: figure timings are written to be read slowly. The SVG matches it. */
|
|
19
|
+
export const BASE_RATE = 1.25;
|
|
20
|
+
/** The height of one text line in a content card, and the padding above and below the lines. */
|
|
21
|
+
export const CARD_LINE = 15, CARD_PAD = 6;
|
|
22
|
+
/** The fixed color of each row tone. */
|
|
23
|
+
export const TONES = {
|
|
24
|
+
blue: '#3b82f6',
|
|
25
|
+
purple: '#8b5cf6',
|
|
26
|
+
green: '#10b981',
|
|
27
|
+
orange: '#f59e0b',
|
|
28
|
+
gray: '#8b949e',
|
|
29
|
+
};
|
|
30
|
+
/** 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
|
+
export const labelPillW = (label) => textWidth(label, 11, true) + 14;
|
|
32
|
+
/** The width of the `async` tag in px. */
|
|
33
|
+
export const ASYNC_TAG_W = textWidth('ASYNC', 9) + 8;
|
|
34
|
+
/** The stroke width in px of an edge that a packet crosses now, and of an idle edge. */
|
|
35
|
+
export const EDGE_ON = 2, EDGE_OFF = 1.25;
|
|
36
|
+
/** The built-in colors. Both renderers and `flowfig check` read these, so they cannot drift apart. */
|
|
37
|
+
export const LIGHT = { accent: '#0074d9', fg: '#1c1e21', muted: '#606770', bg: '#ffffff', surface: '#f5f7fa', border: '#d0d7de' };
|
|
38
|
+
// The dark accent is darker than a typical dark-mode blue: white text sits on it and needs 4.5:1.
|
|
39
|
+
export const DARK = { accent: '#1f78c8', fg: '#e3e3e3', muted: '#9aa0a6', bg: '#1b1b1d', surface: '#242526', border: '#3a3b3c' };
|
|
40
|
+
/** Text on an accent fill: the data chips and a lit edge label. */
|
|
41
|
+
export const ON_ACCENT = '#ffffff';
|
|
42
|
+
/** Only text survives outside React: a React element has no string form. */
|
|
43
|
+
export const str = (n) => (typeof n === 'string' || typeof n === 'number' ? String(n) : '');
|
|
44
|
+
export const nodes = (g) => g.children.flatMap((c) => (isGroup(c) ? nodes(c) : [c]));
|
|
45
|
+
const labeledGroups = (g) => (g.label ? 1 : 0) + g.children.reduce((n, c) => n + (isGroup(c) ? labeledGroups(c) : 0), 0);
|
|
46
|
+
/** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
|
|
47
|
+
export const counts = (p) => ({
|
|
48
|
+
boxes: nodes(p.layout).length,
|
|
49
|
+
groups: labeledGroups(p.layout),
|
|
50
|
+
edges: p.edges.length,
|
|
51
|
+
steps: (p.steps ?? []).length,
|
|
52
|
+
messages: (p.steps ?? []).reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
|
|
53
|
+
});
|
|
54
|
+
/** Every id inside a child: its own id, and the ids of all boxes and groups below it. */
|
|
55
|
+
const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
|
|
56
|
+
/**
|
|
57
|
+
* A group's gap. A row's default gap grows to hold the widest label of an edge between two of its children, with 8px each side:
|
|
58
|
+
* the label sits at the middle of that gap. A gap the author sets stays as set; `flowfig check` reports it if it is too narrow.
|
|
59
|
+
*/
|
|
60
|
+
export function groupGap(g, edges) {
|
|
61
|
+
if (g.gap != null)
|
|
62
|
+
return g.gap;
|
|
63
|
+
if (g.direction === 'column')
|
|
64
|
+
return 28;
|
|
65
|
+
const side = new Map();
|
|
66
|
+
g.children.forEach((c, i) => idsIn(c).forEach((id) => side.set(id, i)));
|
|
67
|
+
const pills = edges
|
|
68
|
+
.filter((e) => e.label != null && side.has(e.from) && side.has(e.to) && side.get(e.from) !== side.get(e.to))
|
|
69
|
+
.map((e) => labelPillW(str(e.label)) + 16);
|
|
70
|
+
return Math.max(56, ...pills);
|
|
71
|
+
}
|
package/dist/rail.d.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import type { Rect } from './geometry.ts';
|
|
2
|
+
import { type FlowProps } from './model.ts';
|
|
3
|
+
export declare const RAIL: {
|
|
4
|
+
readonly gap: 24;
|
|
5
|
+
readonly band: 20;
|
|
6
|
+
readonly cols: 28;
|
|
7
|
+
readonly phase: 30;
|
|
8
|
+
readonly row: 34;
|
|
9
|
+
readonly minCol: 120;
|
|
10
|
+
readonly pad: 24;
|
|
11
|
+
readonly bandInset: 8;
|
|
12
|
+
readonly clear: 12;
|
|
13
|
+
readonly inset: 6;
|
|
14
|
+
readonly maxOpen: 10;
|
|
15
|
+
};
|
|
16
|
+
export type RailColumn = {
|
|
17
|
+
id: string;
|
|
18
|
+
label: string;
|
|
19
|
+
x: number;
|
|
20
|
+
w: number;
|
|
21
|
+
};
|
|
22
|
+
export type RailBand = {
|
|
23
|
+
id: string;
|
|
24
|
+
label: string;
|
|
25
|
+
rect: Rect;
|
|
26
|
+
};
|
|
27
|
+
export type RailRow = {
|
|
28
|
+
kind: 'phase';
|
|
29
|
+
step: number;
|
|
30
|
+
label: string;
|
|
31
|
+
messages: number;
|
|
32
|
+
/** The divider: it starts after the open label or after the folded label, and ends before the counter. */
|
|
33
|
+
line: {
|
|
34
|
+
open: number;
|
|
35
|
+
folded: number;
|
|
36
|
+
end: number;
|
|
37
|
+
};
|
|
38
|
+
} | {
|
|
39
|
+
kind: 'message';
|
|
40
|
+
step: number;
|
|
41
|
+
beat: number;
|
|
42
|
+
n: number;
|
|
43
|
+
edge: string;
|
|
44
|
+
from: number;
|
|
45
|
+
to: number;
|
|
46
|
+
text: string;
|
|
47
|
+
async: boolean;
|
|
48
|
+
pill?: {
|
|
49
|
+
x: number;
|
|
50
|
+
w: number;
|
|
51
|
+
};
|
|
52
|
+
group?: number;
|
|
53
|
+
};
|
|
54
|
+
export type RailState = {
|
|
55
|
+
y: number;
|
|
56
|
+
shown: boolean;
|
|
57
|
+
};
|
|
58
|
+
export type Rail = {
|
|
59
|
+
width: number;
|
|
60
|
+
/** The y where the first row starts: below the band-label line (if a band exists) and the column labels. */
|
|
61
|
+
head: number;
|
|
62
|
+
height: number;
|
|
63
|
+
columns: RailColumn[];
|
|
64
|
+
bands: RailBand[];
|
|
65
|
+
rows: RailRow[];
|
|
66
|
+
groups: {
|
|
67
|
+
step: number;
|
|
68
|
+
rows: number[];
|
|
69
|
+
}[];
|
|
70
|
+
total: number;
|
|
71
|
+
folds: boolean;
|
|
72
|
+
states: RailState[][];
|
|
73
|
+
};
|
|
74
|
+
/** The folded phase text. Both renderers and the layout use it, so the measured width matches the drawn width. */
|
|
75
|
+
export declare const foldedLabel: (row: {
|
|
76
|
+
label: string;
|
|
77
|
+
messages: number;
|
|
78
|
+
}) => string;
|
|
79
|
+
export declare function railState(rail: Rail, step: number): RailState[];
|
|
80
|
+
export declare function layoutRail(fig: FlowProps, mapWidth: number): Rail | null;
|
|
81
|
+
/** A parallel band around its rows, relative to the y of its first row: RAIL.inset around every arrow and pill it holds. */
|
|
82
|
+
export declare function groupBox(rail: Rail, g: Rail['groups'][number]): Rect;
|