flowfig 0.5.0 → 0.7.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/dist/init.js CHANGED
@@ -1,7 +1,6 @@
1
- // `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
2
1
  import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
2
  import { homedir } from 'node:os';
4
- import { dirname, join, relative, resolve } from 'node:path';
3
+ import { dirname, join, relative, resolve, sep } from 'node:path';
5
4
  import { createInterface } from 'node:readline';
6
5
  import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
7
6
  import { runPicker } from './picker.js';
@@ -65,20 +64,20 @@ export const AGENTS = [
65
64
  mcp: { file: '.kiro/settings/mcp.json', key: 'mcpServers', global: '.kiro/settings/mcp.json' },
66
65
  },
67
66
  ];
68
- /** The ids of the agents that the repo at `dir` already uses. */
69
67
  export const detect = (dir) => AGENTS.filter((a) => a.marks.some((m) => existsSync(join(dir, m)))).map((a) => a.id);
70
- /** Toggle the agents named by numbers ("1 3" or "1,3"). A number out of range and other text are ignored. */
68
+ /** Toggle the agents named by numbers ("1 3" or "1,3"). Ignore bad input. */
71
69
  export function toggle(input, selected, count) {
72
70
  const next = new Set(selected);
73
71
  for (const t of input.split(/[\s,]+/)) {
74
72
  const n = /^\d+$/.test(t) ? Number(t) : 0;
75
73
  const id = n >= 1 && n <= count ? AGENTS[n - 1].id : undefined;
76
- if (id)
77
- next.has(id) ? next.delete(id) : next.add(id);
74
+ if (id && next.has(id))
75
+ next.delete(id);
76
+ else if (id)
77
+ next.add(id);
78
78
  }
79
79
  return next;
80
80
  }
81
- /** The picker screen. */
82
81
  export function renderList(selected, detected, dir) {
83
82
  const rows = AGENTS.map((a, i) => {
84
83
  const found = detected.includes(a.id) ? `(${a.marks.find((m) => existsSync(join(dir, m)))} found)` : '';
@@ -93,25 +92,25 @@ export function renderList(selected, detected, dir) {
93
92
  ].join('\n');
94
93
  }
95
94
  const block = (text = AGENT_TEXT) => `${START}\n${text.trimEnd()}\n${END}\n`;
96
- /** 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. */
95
+ /** The new file text, or `undefined` if the user owns the file. */
97
96
  export function place(a, old) {
98
97
  const mine = block();
99
98
  if (a.kind === 'whole')
100
99
  return old === undefined || old.includes(START) ? `${a.head ?? ''}${mine}` : undefined;
101
100
  return section(old, mine);
102
101
  }
103
- /** `old` with the marked block `mine` added or replaced. `undefined` if the block is open. */
104
102
  function section(old, mine) {
105
103
  if (old === undefined)
106
104
  return mine;
107
105
  const s = old.indexOf(START), e = old.indexOf(END, s);
106
+ // An open block: the next run would eat the text after START.
108
107
  if (s !== -1 && e === -1)
109
- return undefined; // an open block: the next run would eat the text after START
108
+ return undefined;
110
109
  if (s !== -1)
111
110
  return old.slice(0, s) + mine.trimEnd() + old.slice(e + END.length);
112
111
  return old + (old.endsWith('\n\n') ? '' : old.endsWith('\n') ? '\n' : '\n\n') + mine;
113
112
  }
114
- /** The MCP file with the flowfig entry merged in, or `undefined` when the file is not a JSON object that can hold it. */
113
+ /** The MCP file with the flowfig entry, or `undefined` if the file cannot hold it. */
115
114
  export function registerMcp(a, old) {
116
115
  if (!a.mcp)
117
116
  return undefined;
@@ -127,18 +126,19 @@ export function registerMcp(a, old) {
127
126
  const servers = json[a.mcp.key] ?? {};
128
127
  if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
129
128
  return undefined;
129
+ // Keep the entry: the user may have pinned a version.
130
130
  if ('flowfig' in servers)
131
- return old; // the user may have pinned a version or set env: keep the entry
131
+ return old;
132
132
  const entry = a.mcp.type ? { ...MCP_ENTRY, type: 'stdio' } : MCP_ENTRY;
133
133
  json[a.mcp.key] = { ...servers, flowfig: entry };
134
134
  return JSON.stringify(json, null, 2) + '\n';
135
135
  }
136
- /** `✓ ` before a finished write. */
137
136
  const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
138
- /** The path to show: relative to the repo folder, or `~` for the home folder. */
139
- const shown = (path, base, global) => (global ? `~/${relative(base, path)}` : relative(base, path));
137
+ const shown = (path, base, global) => {
138
+ const rel = relative(base, path).split(sep).join('/');
139
+ return global ? `~/${rel}` : rel;
140
+ };
140
141
  const HINT = 'run with -y or --agents <ids>';
141
- /** Run `flowfig init`. Returns the exit code. */
142
142
  export async function runInit(argv) {
143
143
  const args = [...argv];
144
144
  const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
@@ -245,8 +245,7 @@ export async function runInit(argv) {
245
245
  mcpStatus = `would ${mcpStatus === 'created' ? 'create' : 'update'}`;
246
246
  else {
247
247
  mkdirSync(dirname(mcpPath), { recursive: true });
248
- // Same-folder temp file, then rename: a crash or a parallel write cannot leave half a file (~/.claude.json holds user settings).
249
- // Resolve a symlink first, so the rename replaces the target and not the link. Copy the old mode to the new file.
248
+ // Rename a temp file over the symlink target, so a crash leaves no half file.
250
249
  const real = existsSync(mcpPath) ? realpathSync(mcpPath) : mcpPath;
251
250
  const tmp = `${real}.${process.pid}.tmp`;
252
251
  try {
@@ -271,7 +270,6 @@ function fail(message) {
271
270
  console.error(message);
272
271
  return 2;
273
272
  }
274
- /** Ask on the terminal. Returns the chosen ids, or `undefined` if the user quits. */
275
273
  async function pick(start, detected, dir) {
276
274
  const rl = createInterface({ input: process.stdin, output: process.stdout });
277
275
  const ask = (q) => new Promise((res) => (rl.once('close', () => res(undefined)), rl.question(q, res)));
package/dist/load.d.ts CHANGED
@@ -1,12 +1,10 @@
1
1
  import { type FlowProps } from './model.ts';
2
2
  import { type Finding, type SvgOptions } from './svg.ts';
3
- /** The spec JSON inside an SVG this package wrote. */
4
3
  export declare function specOf(svg: string, name: string): string;
5
4
  /** A spec from a `.json` or SVG path, or an object (`{ props }` or the props). Every error message starts with the name. */
6
5
  export declare function loadSpec(input: string | object, name?: string): FlowProps;
7
6
  /** The SVG with its spec in `<metadata>`: an SVG is then its own source. `]]>` would close the CDATA early, so it is escaped. */
8
- export declare function svgWithSpec(props: FlowProps, opts?: SvgOptions): string;
9
- /** Errors first, then by rule name. `strict` makes every warning an error. */
7
+ export declare function svgWithSpec(props: FlowProps, opts?: SvgOptions, svg?: string): string;
10
8
  export declare function sortFindings(findings: Finding[], strict: boolean): Finding[];
11
9
  /** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
12
10
  export declare function reportLines(props: FlowProps, findings: Finding[], withCounts?: boolean): string[];
package/dist/load.js CHANGED
@@ -1,10 +1,8 @@
1
- // What the CLI and the MCP server share: read a spec, write an SVG that carries its spec, and print the check lines.
2
1
  import { readFileSync } from 'node:fs';
3
2
  import { counts } from './model.js';
4
3
  import { toSvg } from './svg.js';
5
4
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
6
5
  const SPEC_CLOSE = ']]></metadata>';
7
- /** The spec JSON inside an SVG this package wrote. */
8
6
  export function specOf(svg, name) {
9
7
  const at = svg.indexOf(SPEC_OPEN);
10
8
  if (at === -1)
@@ -36,11 +34,10 @@ export function loadSpec(input, name = typeof input === 'string' ? input : 'spec
36
34
  return props;
37
35
  }
38
36
  /** The SVG with its spec in `<metadata>`: an SVG is then its own source. `]]>` would close the CDATA early, so it is escaped. */
39
- export function svgWithSpec(props, opts = {}) {
37
+ export function svgWithSpec(props, opts = {}, svg = toSvg(props, opts)) {
40
38
  const spec = JSON.stringify({ props }).replaceAll(']]>', ']]\\u003e');
41
- return toSvg(props, opts).replace(/(<svg[^>]*>\n?)/, (tag) => `${tag}${SPEC_OPEN}${spec}${SPEC_CLOSE}\n`);
39
+ return svg.replace(/(<svg[^>]*>\n?)/, (tag) => `${tag}${SPEC_OPEN}${spec}${SPEC_CLOSE}\n`);
42
40
  }
43
- /** Errors first, then by rule name. `strict` makes every warning an error. */
44
41
  export function sortFindings(findings, strict) {
45
42
  return findings
46
43
  .map((f) => (strict ? { ...f, severity: 'error' } : f))
package/dist/mcp.d.ts CHANGED
@@ -133,7 +133,6 @@ export declare const TOOLS: ({
133
133
  required: string[];
134
134
  };
135
135
  })[];
136
- /** One JSON-RPC message in, one response out; `undefined` for a notification. Pure, so the tests call it directly. */
136
+ /** Returns `undefined` for a notification. */
137
137
  export declare function handle(message: unknown): Response | undefined;
138
- /** Read one message per line from `input`, write one response per line to `output`. Returns when `input` ends. */
139
138
  export declare function serve(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise<void>;
package/dist/mcp.js CHANGED
@@ -1,12 +1,11 @@
1
- // The MCP server: the check, render, verify, diff and docs tools over stdio JSON-RPC, for an agent with no shell.
2
- // No SDK: the subset the clients need is small, and the package keeps its zero dependencies.
3
1
  import { mkdirSync, writeFileSync } from 'node:fs';
4
2
  import { dirname } from 'node:path';
5
3
  import { createInterface } from 'node:readline';
6
4
  import { diff, formatDiff } from './diff.js';
7
5
  import { GUIDE } from './guide.js';
8
6
  import { loadSpec, reportLines, sortFindings, svgWithSpec } from './load.js';
9
- import { check } from './svg.js';
7
+ import { check, render } from './svg.js';
8
+ import { checkRendered } from './check.js';
10
9
  import { verify } from './verify.js';
11
10
  import { VERSION } from './version.js';
12
11
  const specSchema = {
@@ -55,9 +54,9 @@ export const TOOLS = [
55
54
  },
56
55
  ];
57
56
  class Bad extends Error {
58
- } // bad params: a JSON-RPC error, not a tool result
57
+ }
59
58
  class Fault extends Error {
60
- } // a path that cannot be read or written: a tool result with isError
59
+ }
61
60
  const ok = (text, isError = false) => isError ? { content: [{ type: 'text', text }], isError: true } : { content: [{ type: 'text', text }] };
62
61
  const spec = (v, name) => {
63
62
  if (typeof v !== 'string' && (typeof v !== 'object' || v === null))
@@ -66,7 +65,6 @@ const spec = (v, name) => {
66
65
  return loadSpec(v);
67
66
  }
68
67
  catch (e) {
69
- // The message of a path failure already starts with the path.
70
68
  throw typeof v === 'string' ? new Fault(e.message) : new Bad(`${name}: ${e.message}`);
71
69
  }
72
70
  };
@@ -75,7 +73,6 @@ const need = (args, tool, keys) => {
75
73
  if (args[k] == null)
76
74
  throw new Bad(`${tool}: ${k} is required`);
77
75
  };
78
- /** A present argument must have its type; a number must be positive. */
79
76
  const types = (args, tool, want) => {
80
77
  for (const [k, t] of Object.entries(want)) {
81
78
  const v = args[k];
@@ -94,13 +91,14 @@ function run(name, args) {
94
91
  types(args, name, { width: 'number', minText: 'number', out: 'string', strict: 'boolean' });
95
92
  const props = spec(args.spec, name);
96
93
  const opts = { width: args.width, minText: args.minText };
97
- const findings = sortFindings(check(props, opts), args.strict === true);
94
+ const rendered = render(props, opts);
95
+ const findings = sortFindings(checkRendered(props, opts, rendered.scene), args.strict === true);
98
96
  const errors = findings.some((f) => f.severity === 'error');
99
97
  const lines = reportLines(props, findings);
100
98
  if (name === 'check' || errors)
101
99
  return ok(lines.join('\n'), errors);
102
100
  const out = String(args.out);
103
- const svg = svgWithSpec(props, opts);
101
+ const svg = svgWithSpec(props, opts, rendered.svg);
104
102
  try {
105
103
  mkdirSync(dirname(out), { recursive: true });
106
104
  writeFileSync(out, svg);
@@ -147,7 +145,7 @@ function run(name, args) {
147
145
  }
148
146
  const reply = (id, result) => ({ jsonrpc: '2.0', id, result });
149
147
  const fail = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } });
150
- /** One JSON-RPC message in, one response out; `undefined` for a notification. Pure, so the tests call it directly. */
148
+ /** Returns `undefined` for a notification. */
151
149
  export function handle(message) {
152
150
  if (typeof message !== 'object' || message === null)
153
151
  return fail(null, -32600, 'invalid request');
@@ -156,7 +154,7 @@ export function handle(message) {
156
154
  if (typeof method !== 'string')
157
155
  return notification ? undefined : fail(id, -32600, 'invalid request');
158
156
  if (notification)
159
- return undefined; // a notification gets no reply, and a call sent as one does not run
157
+ return undefined;
160
158
  const info = { name: 'flowfig', version: VERSION };
161
159
  try {
162
160
  switch (method) {
@@ -195,7 +193,6 @@ export function handle(message) {
195
193
  return fail(id, -32603, e.message);
196
194
  }
197
195
  }
198
- /** Read one message per line from `input`, write one response per line to `output`. Returns when `input` ends. */
199
196
  export async function serve(input, output) {
200
197
  const rl = createInterface({ input, crlfDelay: Infinity });
201
198
  for await (const line of rl) {
package/dist/model.d.ts CHANGED
@@ -1,41 +1,41 @@
1
1
  import type { ReactNode } from 'react';
2
2
  import type { Rect } from './geometry.ts';
3
- /** 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
+ /** A box. A box with `show` in a step becomes a content card. */
4
4
  export type FigNode = {
5
- /** Name that edges, steps and beats use to point at this box. Must be unique in the figure. */
5
+ /** Name that edges, steps and beats use for this box. Unique in the figure. */
6
6
  id: string;
7
7
  /** The title in the box. */
8
8
  label: ReactNode;
9
9
  /** A smaller line under the label. */
10
10
  sub?: ReactNode;
11
- /** `box` is a rounded box, `decision` a diamond, `store` a database cylinder for data at rest. Default: `box`. */
11
+ /** `decision` is a diamond, `store` a cylinder for data at rest. Default: `box`. */
12
12
  shape?: 'box' | 'decision' | 'store';
13
- /** The least number of text lines a content card keeps. The card still grows to fit its content. */
13
+ /** The least text lines a content card keeps. It still grows to fit. */
14
14
  lines?: number;
15
15
  /** Width in px. Overrides the width the layout picks. */
16
16
  width?: number;
17
- /** In a `lanes` figure: the time column of the box, 0-based. Default: the box's first appearance in the steps. */
17
+ /** In `lanes`: the 0-based time column. Default: the first appearance in the steps. */
18
18
  at?: number;
19
- /** In a `timeline` figure: the start of the item, or the date of a milestone, as YYYY-MM-DD. */
19
+ /** In `timeline`: the start date, or the milestone date, as YYYY-MM-DD. */
20
20
  from?: string;
21
- /** In a `timeline` figure: the last day of the item, as YYYY-MM-DD. The day counts. Without it the box is a milestone. */
21
+ /** In `timeline`: the last day, as YYYY-MM-DD. No `to` makes a milestone. */
22
22
  to?: string;
23
- /** 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. */
23
+ /** A permanent state color. An arriving hop with no tone uses it too. */
24
24
  tone?: FigTone;
25
- /** A lifecycle mark: `start` draws a filled dot before the box, `end` a ringed dot after it. No layout change. */
25
+ /** `start` draws a filled dot before the box, `end` a ringed dot after it. */
26
26
  mark?: 'start' | 'end';
27
- /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
27
+ /** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
28
28
  source?: string;
29
29
  };
30
- /** A frame that lays out its children in a row or a column. Groups can hold groups. */
30
+ /** A frame that lays out its children in a row or a column. */
31
31
  export type FigGroup = {
32
32
  /** Name that edges can point at, to reach the group as a whole. */
33
33
  id?: string;
34
- /** The title on the frame. A group with a label is drawn as a frame around its children. */
34
+ /** The title on the frame. A labeled group is drawn as a frame. */
35
35
  label?: ReactNode;
36
36
  /** `row` puts the children side by side, `column` stacks them. Default: `row`. */
37
37
  direction?: 'row' | 'column';
38
- /** 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). */
38
+ /** Space between children in px. Default: 28 for a column, fitted for a row. */
39
39
  gap?: number;
40
40
  /** Where children line up across the group's direction. Default: center (columns stretch). */
41
41
  align?: 'start' | 'center' | 'end';
@@ -54,9 +54,9 @@ export type FigEdge = {
54
54
  label?: ReactNode;
55
55
  /** Routes the edge over or under the boxes in between (loops, skip-ahead edges). */
56
56
  around?: 'above' | 'below';
57
- /** Draws the edge only while a step uses it. Use it for long edges that would cut across everything. */
57
+ /** Draws the edge only while a step uses it. */
58
58
  quiet?: boolean;
59
- /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
59
+ /** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
60
60
  source?: string;
61
61
  };
62
62
  /** One packet on one edge. A string is the edge `id`. */
@@ -67,11 +67,11 @@ export type FigHop = string | {
67
67
  back?: boolean;
68
68
  /** A small card that rides along with the packet. */
69
69
  data?: ReactNode;
70
- /** 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. */
70
+ /** Colors the packet, data card, edge and arrival box for this beat. Default: the accent. */
71
71
  tone?: FigTone;
72
- /** Marks a message that does not wait for an answer. The rail draws it dashed, with an `async` tag. Default: `false`. */
72
+ /** Marks a message that needs no reply. The rail draws it dashed. Default: `false`. */
73
73
  async?: boolean;
74
- /** The code this draws, `path` or `path#symbol`, relative to the repo root. `flowfig verify` checks it. */
74
+ /** Code this draws: `path` or `path#symbol` from the repo root. Checked by `flowfig verify`. */
75
75
  source?: string;
76
76
  };
77
77
  /** One moment of a step. No edges means a pause. */
@@ -80,13 +80,13 @@ export type FigBeat = {
80
80
  edges?: FigHop | FigHop[];
81
81
  /** The line of narration for this beat. */
82
82
  say?: ReactNode;
83
- /** Fills the content card of each named box. The card keeps it until the step ends. */
83
+ /** Fills the content card of each named box until the step ends. */
84
84
  show?: Record<string, FigContent>;
85
85
  /** Ids of boxes to highlight for this beat, without a packet or a content card. */
86
86
  light?: string[];
87
- /** Ids of boxes that take the active look for this beat: a 2 px border, a tint and a glow. The boxes in `light` keep the trail look. */
87
+ /** Ids of boxes that take the active look in this beat. */
88
88
  focus?: string[];
89
- /** 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`. */
89
+ /** Beat length in ms. Default: `speed` plus the time to read `say`. */
90
90
  ms?: number;
91
91
  };
92
92
  /** One line of a content card. */
@@ -104,7 +104,7 @@ export type FigRow = {
104
104
  /** Draws the text in a monospace font. Default: `false`. */
105
105
  mono?: boolean;
106
106
  };
107
- /** The color of a row tag, a hop or a box. Each tone is a fixed color. */
107
+ /** The color of a row tag, a hop or a box. */
108
108
  export type FigTone = 'blue' | 'purple' | 'green' | 'orange' | 'red' | 'gray';
109
109
  /** What a content card shows: rows, or anything React can render. */
110
110
  export type FigContent = FigRow[] | ReactNode;
@@ -114,12 +114,12 @@ export type FigStep = {
114
114
  label: ReactNode;
115
115
  /** The line shown under the figure while no beat of the step has a `say`. */
116
116
  caption?: ReactNode;
117
- /** The beats in play order. A plain hop or hop array is a beat with just edges. */
117
+ /** The beats in play order. A bare hop or hop array is one beat. */
118
118
  flow: (FigHop | FigHop[] | FigBeat)[];
119
119
  /** Ids of boxes to highlight for the whole step. */
120
120
  nodes?: string[];
121
121
  };
122
- /** Colors for the figure. Each key sets one color, and a color you leave out keeps the built-in value. */
122
+ /** Colors for the figure. A key you leave out keeps the built-in color. */
123
123
  export type FigTheme = Partial<Record<'accent' | 'fg' | 'muted' | 'bg' | 'surface' | 'border' | 'font', string>>;
124
124
  /** The spec of one figure. Give it to `Flow` (React) or to `toSvg` (SVG string). */
125
125
  export type FlowProps = {
@@ -129,21 +129,21 @@ export type FlowProps = {
129
129
  edges: FigEdge[];
130
130
  /** The stories the figure tells. With no steps, the figure is a still map. */
131
131
  steps?: FigStep[];
132
- /** Colors. Can also be set from CSS with --fig-accent, --fig-bg, ... on `.flowfig`. `font` defaults to `inherit`. */
132
+ /** Colors. CSS `--fig-accent`, `--fig-bg` on `.flowfig` also set them. `font` defaults to `inherit`. */
133
133
  theme?: FigTheme;
134
134
  /** Milliseconds a packet takes to cross one edge. Default: 900. */
135
135
  speed?: number;
136
136
  /** Starts playing when the figure mounts. Default: `true`. Only the React player reads it. */
137
137
  autoplay?: boolean;
138
- /** Development aid: run the `flowfig check` rules on what the browser drew, and print the faults with console.warn. Default: `false`. */
138
+ /** Dev aid: run the `flowfig check` rules on the drawn figure, then console.warn faults. */
139
139
  check?: boolean;
140
- /** 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`. */
140
+ /** Draws the steps as a lifeline rail under the map. `'only'` draws no map. */
141
141
  rail?: boolean | 'only';
142
- /** Draw the layout as swimlanes: a `column` group of labeled groups, one per role, with the boxes in time order left to right. Default: off. */
142
+ /** Draws the layout as swimlanes: a `column` group of labeled groups, one per role. */
143
143
  lanes?: true;
144
- /** Draw the layout as a timeline: one labeled group per track, with the boxes placed by their `from` and `to` dates. Default: off. */
144
+ /** Draws a timeline: one labeled group per track, boxes placed by `from` and `to`. */
145
145
  timeline?: true;
146
- /** In a `timeline` figure: the date of the fixed today marker, as YYYY-MM-DD. With no `today`, the figure has no marker. */
146
+ /** In `timeline`: the date of the today marker, as YYYY-MM-DD. */
147
147
  today?: string;
148
148
  };
149
149
  /** A figure file's default export: a title, a source note and the props. */
@@ -152,7 +152,7 @@ export type Figure = {
152
152
  source?: string;
153
153
  props: FlowProps;
154
154
  };
155
- /** A beat with its hops spelled out: every hop has an edge id and a direction. */
155
+ /** A beat with every hop spelled out: an edge id and a direction. */
156
156
  export type Beat = Omit<FigBeat, 'edges'> & {
157
157
  hops: {
158
158
  edge: string;
@@ -164,30 +164,19 @@ export type Beat = Omit<FigBeat, 'edges'> & {
164
164
  }[];
165
165
  };
166
166
  export declare const toBeat: (b: FigHop | FigHop[] | FigBeat) => Beat;
167
- /** A React node can be an object too, so a row is told apart by its `text` key. */
168
167
  export declare const isRows: (c: FigContent) => c is FigRow[];
169
168
  export declare const edgeId: (e: FigEdge) => string;
170
169
  export declare const isGroup: (x: FigNode | FigGroup) => x is FigGroup;
171
170
  export declare const decisions: (g: FigGroup) => string[];
172
- /** The width of a content card in px. Both renderers use it, so a figure lays out the same way in each. */
173
171
  export declare const CARD_WIDTH = 166;
174
- /** 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. */
175
172
  export declare const BASE_RATE = 1.25;
176
- /** The height of one text line in a content card, and the padding above and below the lines. */
177
173
  export declare const CARD_LINE = 15, CARD_PAD = 6;
178
- /** The fixed color of each row tone. */
179
174
  export declare const TONES: Record<FigTone, string>;
180
- /** A tone as a fill under white text: darker, so the text keeps its contrast. */
181
175
  export declare const toneFill: (c: string) => string;
182
- /** The light tint of a tone over a background: the fill of a toned box. */
183
176
  export declare const toneTint: (c: string, bg: string, pct?: number) => string;
184
- /** 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. */
185
177
  export declare const labelPillW: (label: string) => number;
186
- /** The width of the `async` tag in px. */
187
178
  export declare const ASYNC_TAG_W: number;
188
- /** The stroke width in px of an edge that a packet crosses now, and of an idle edge. */
189
179
  export declare const EDGE_ON = 2, EDGE_OFF = 1.25;
190
- /** The built-in colors. Both renderers and `flowfig check` read these, so they cannot drift apart. */
191
180
  export declare const LIGHT: {
192
181
  accent: string;
193
182
  fg: string;
@@ -204,21 +193,20 @@ export declare const DARK: {
204
193
  surface: string;
205
194
  border: string;
206
195
  };
207
- /** Text on an accent fill: the data chips and a lit edge label. */
208
196
  export declare const ON_ACCENT = "#ffffff";
209
- /** Only text survives outside React: a React element has no string form. */
210
197
  export declare const str: (n: unknown) => string;
211
- /** The time a reader needs for a caption line: 400 ms plus 240 ms per word. The result is 0 for no text. */
212
198
  export declare const readMs: (say: unknown) => number;
213
- /** How long a beat lasts. An explicit `ms` wins as written. Else the beat lasts `speed` plus the time to read `say`. */
214
199
  export declare const beatMs: (b: Beat, speed: number) => number;
215
- /** The item the playhead rests on at beat `i`: the first dated id in the latest focus up to `i`, or undefined for home. */
216
200
  export declare const playheadItem: <T extends {
217
201
  id: string;
218
202
  }>(items: T[], beats: {
219
203
  focus?: string[];
220
204
  }[], i: number) => T | undefined;
221
- /** The box of a timeline label that sits beside its bar, in the bar's coordinates. */
205
+ export declare const loopStartItem: <T extends {
206
+ id: string;
207
+ }>(items: T[], steps: {
208
+ flow: FigStep['flow'];
209
+ }[]) => T | undefined;
222
210
  export declare const outsideLabelRect: (bar: {
223
211
  x: number;
224
212
  y: number;
@@ -230,38 +218,22 @@ export declare const outsideLabelRect: (bar: {
230
218
  w: number;
231
219
  h: number;
232
220
  };
233
- /**
234
- * The time span in which the playhead label shows its date. The label waits for the line to arrive: it stays hidden for
235
- * the ramp (the move), so it never shows a date at a place that is not that date, and it never meets "today" on the way.
236
- */
237
221
  export declare const labelSpan: (t0: number, end: number, ramp: number) => [number, number];
238
- /** True if a playhead date label at x would overlap the "today" label, so it moves to the top row. Widths are in px. */
239
222
  export declare const dateLabelRaised: (x: number, today: number | null | undefined, w: number, todayW: number) => boolean;
240
- /** The hold at the end of each step, before the next step starts. */
241
223
  export declare const STEP_HOLD_MS = 2000;
242
224
  export declare const nodes: (g: FigGroup) => FigNode[];
243
- /** Lane layout constants, shared by both renderers: the gap between time columns, the padding inside a lane, and the gap between lanes. */
244
225
  export declare const LANE_GAP = 56, LANE_PAD = 24, LANE_ROW_GAP = 20;
245
- /** True if the layout can draw as lanes: a `column` group whose every child is a labeled group with boxes only. */
246
226
  export declare const isLanesLayout: (g: FigGroup) => boolean;
247
- /** True if `at` is a usable time column: an integer of 0 or more. */
248
227
  export declare const validAt: (at: unknown) => at is number;
249
- /** The time column of each box in a lanes figure: its first appearance in the steps (from, then to; a back hop to, then from),
250
- * then the boxes no step touches in layout order. `at` overrides. Two boxes may share a column. */
228
+ /** The time column of each box: first use in the steps, then layout order. `at` wins. */
251
229
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
252
- /** The side of an SVG frame, and the width limits of a box with no card. The lane split measures with them. */
253
230
  export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
254
- /** The gap between two blocks of wrapped lanes. */
255
231
  export declare const LANE_BLOCK_GAP = 40;
256
- /** The length of a stub line between a box and its pill, the clear space past a pill, and the room a lane grows by for a pill. */
257
232
  export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
258
233
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
259
234
  export declare function nodeWidth(item: FigNode, carded: boolean): number;
260
- /** The room a diamond of size w by h gives a text line whose far edge sits `far` px from the center: the width of the outline there, less 8 px. */
261
235
  export declare const diamondRoom: (w: number, h: number, far: number) => number;
262
- /** The lines of a diamond with no card. The sub wraps to the room the outline gives its last line; the diamond grows taller by 15 px
263
- * for each line until the lines fit. `dy` is the baseline of each line below the center, as the SVG draws it: the label sits 2 px
264
- * above the center over a one-line sub, and 5 px below it with no sub. `far` is the distance from the center to the far edge of the text. */
236
+ /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
265
237
  export declare function diamondLines(item: FigNode, w: number): {
266
238
  h: number;
267
239
  label: {
@@ -274,58 +246,46 @@ export declare function diamondLines(item: FigNode, w: number): {
274
246
  far: number;
275
247
  }[];
276
248
  };
277
- /** The lane copies (`laneIndex@block`) that hold a stub end with no place clear of every edge path. A renderer grows each by
278
- * STUB_ROOM and routes again: the pill then fits under or over its box, inside the band. Both renderers call it with their routes. */
249
+ /** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
279
250
  export declare function tightCopies(lanes: FigGroup[], ends: Map<string, [number, number]>, edges: FlowProps['edges'], routed: {
280
251
  id: string;
281
252
  stub?: {
282
253
  tight: [boolean, boolean];
283
254
  };
284
255
  }[]): Set<string>;
285
- /** The width of the label gutter of lanes and of a timeline: the widest track label plus the frame sides. */
286
256
  export declare const laneGutter: (lanes: FigGroup[]) => number;
287
- /** How a lanes figure wraps. Both renderers lay out with it, so the player shows the blocks and stubs of the SVG. */
257
+ /** How a lanes figure wraps. Both renderers lay out with it. */
288
258
  export type LanePlan = {
289
259
  /** The time column of each box. */
290
260
  cols: Map<string, number>;
291
- /** The first column of each block, then the column count: block k holds columns starts[k] to starts[k + 1] - 1. */
261
+ /** The first column of each block, then the column count. */
292
262
  starts: number[];
293
263
  /** The most columns in one block. */
294
264
  per: number;
295
- /** The blocks that hold a box, in order. A block with no box is not drawn. A figure with no box keeps block 0. */
265
+ /** The blocks that hold a box, in order. An empty figure keeps block 0. */
296
266
  blocks: number[];
297
267
  /** The width of the label gutter, the same in every block. */
298
268
  gutter: number;
299
- /** The space after each column: the gap to the next column of its block, or the right margin of the band for the last one. */
269
+ /** The space after each column: the gap to the next, or the band margin. */
300
270
  gaps: number[];
301
271
  /** The room before the first column of each block, after the gutter. */
302
272
  lead: number[];
303
- /** The block of the source and of the target of each edge, by edge id. A lane end names the copy of the lane in that block. */
273
+ /** The block of each edge source and target, by edge id. */
304
274
  ends: Map<string, [number, number]>;
305
- /** The edges whose ends sit in two blocks, by edge id: the pill texts at the source and at the target, then a shorter source
306
- * text without the edge label, if the edge has one. */
275
+ /** The pill texts of an edge across two blocks: source, target, then a short source. */
307
276
  stubs: Map<string, string[]>;
308
- /** Edges between two boxes of one lane and one block, with a box of that lane between them and no `around` of their own.
309
- * A straight route would cross that box, so the renderers route them around below. */
277
+ /** Same-lane, same-block edges with a box between them and no `around`. They route below. */
310
278
  around: Set<string>;
311
- /** The lane copies (`laneIndex@block`) that grow by STUB_ROOM at the bottom: a stub pill there has no room beside its box. */
279
+ /** The lane copies (`laneIndex@block`) that grow by STUB_ROOM: a stub pill has no room. */
312
280
  tall: Set<string>;
313
- /** Edges with a lane end that has no block on the side of the other end. They use the nearest block; `check` reports them. */
281
+ /** Edges with a lane end in no block on the other side. `check` reports them. */
314
282
  lost: string[];
315
283
  };
316
- /** The block of a time column, from the block starts of a plan. */
317
284
  export declare const laneBlock: (starts: number[], c: number) => number;
318
- /** The pill texts of a cross-block edge: "label → Target" at the source, "from Source" at the target, and "→ Target", a
319
- * shorter source text for a pill that has no place for the long one. */
320
285
  export declare const stubTexts: (label: unknown, from: unknown, to: unknown) => string[];
321
286
  /**
322
- * The wrap of a lanes figure. If all the time columns do not fit `width` with text at `minText` or more, the lanes wrap like a
323
- * line of text: block 1 holds columns 0..n-1, block 2 holds n..2n-1. n is the largest count that fits; it is 1 at least.
324
- * The text test is the one `check` runs: the smallest text (11 px, or 10.5 px with a mono card row) times width / figure width.
325
- * If the wrap cannot make the text readable, the lanes do not wrap: the smallest font is under `minText`, or `floor` (the least
326
- * width of the rest of the figure, such as the rail) is already too wide.
327
- * The blocks share the columns as evenly as they can, and a block holds 2 columns at least: if 2 do not fit, the lanes keep
328
- * one block. An edge between two blocks becomes two stubs with pills. A pill takes no room of its own, so n depends on the boxes.
287
+ * The wrap of a lanes figure: it wraps like text when the time columns do not fit `width`.
288
+ * The block count depends on the boxes only, not on pills.
329
289
  */
330
290
  export declare function lanePlan(fig: FlowProps, { width, minText, padding, floor }?: {
331
291
  floor?: number | undefined;
@@ -333,16 +293,9 @@ export declare function lanePlan(fig: FlowProps, { width, minText, padding, floo
333
293
  padding?: number | undefined;
334
294
  width?: number | undefined;
335
295
  }): LanePlan;
336
- /**
337
- * The rect id that an edge end routes to in wrapped lanes. A box keeps its id. A lane has one band per block, so a lane end names
338
- * the band in the block from the plan (`band` finds it). A stub end at a lane is a point inside that band: the source at the band
339
- * end, past the room for its pill, and the target at the first column of the block. The function adds the rect it names to `rects`.
340
- */
296
+ /** The rect id that an edge end routes to in wrapped lanes. A stub end is a point in the band. */
341
297
  export declare function laneEnd(plan: LanePlan, edge: string, id: string, start: boolean, band: (lane: string, block: number) => Rect | undefined, rects: Record<string, Rect>): string;
342
- /** Timeline constants, shared by both renderers: the bar height, the gap between rows, the least bar width, the milestone width,
343
- * the axis strip height and the axis width of the SVG. */
344
298
  export declare const TL_BAR_H = 28, TL_ROW_GAP = 8, TL_MIN_BAR = 24, TL_DIAMOND = 14, TL_AXIS_H = 28, TL_AXIS_W = 640;
345
- /** The days since 1970-01-01 (UTC) of a YYYY-MM-DD date. It is null for any other text and for a date that does not exist. */
346
299
  export declare const dayOf: (iso: string) => number | null;
347
300
  export type TimelineItem = {
348
301
  id: string;
@@ -356,7 +309,7 @@ export type TimelineItem = {
356
309
  date: string;
357
310
  };
358
311
  export type TimelineLayout = {
359
- /** The range in days, rounded out to whole weeks: the Monday of the first week and the Sunday of the last week. */
312
+ /** The range in days, rounded out to whole Monday-to-Sunday weeks. */
360
313
  start: number;
361
314
  end: number;
362
315
  /** The week ticks (`W41`) or the month ticks (`Oct`). */
@@ -364,21 +317,20 @@ export type TimelineLayout = {
364
317
  x: number;
365
318
  label: string;
366
319
  }[];
367
- /** Bars and milestones. `x` and `w` are px inside the axis; `row` is the stack row inside the track. */
320
+ /** Bars and milestones. `x` and `w` are px in the axis. */
368
321
  items: TimelineItem[];
369
322
  /** The row count of each track. */
370
323
  rows: number[];
371
- /** The x of the today marker. It is null when the figure has no valid `today`. */
324
+ /** The x of the today marker, or null with no valid `today`. */
372
325
  today: number | null;
373
- /** The x and the d MMM date of the last day: the playhead rests there in the step hold. The x is null when the figure has no dated item. */
326
+ /** The x and date of the last day. Null x: no dated item. */
374
327
  last: number | null;
375
328
  lastDate: string;
376
329
  };
377
330
  /** Where each dated item, tick and the today line sit on the axis, `axisWidth` px wide. Both renderers call it. */
378
331
  export declare function timelineLayout(fig: FlowProps, axisWidth: number): TimelineLayout;
379
- /** One step, "timeline", with one beat for each dated item in date order. The beat lights the item and says "label, d MMM to d MMM" (a milestone: "label, d MMM"), then " · sub". */
332
+ /** One step with one beat for each dated item, in date order. */
380
333
  export declare function timelineBeats(fig: FlowProps): FigStep[];
381
- /** The part counts of a spec, for the line that `flowfig check` prints. An agent copies the line into its reply. */
382
334
  export declare const counts: (p: FlowProps) => {
383
335
  boxes: number;
384
336
  groups: number;
@@ -386,8 +338,5 @@ export declare const counts: (p: FlowProps) => {
386
338
  steps: number;
387
339
  messages: number;
388
340
  };
389
- /**
390
- * 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:
391
- * 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.
392
- */
341
+ /** A row's default gap grows to hold its widest edge label, plus 8 px each side. */
393
342
  export declare function groupGap(g: FigGroup, edges: FigEdge[]): number;