flowfig 0.6.0 → 0.8.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.
Files changed (56) hide show
  1. package/README.md +28 -8
  2. package/dist/browser.d.ts +1 -3
  3. package/dist/browser.js +67 -59
  4. package/dist/check.d.ts +5 -3
  5. package/dist/check.js +24 -17
  6. package/dist/cli.js +43 -49
  7. package/dist/code.d.ts +26 -0
  8. package/dist/code.js +568 -0
  9. package/dist/diff.js +0 -4
  10. package/dist/draw.d.ts +1 -3
  11. package/dist/draw.js +3 -7
  12. package/dist/edges.d.ts +7 -0
  13. package/dist/edges.js +510 -0
  14. package/dist/geometry.d.ts +6 -8
  15. package/dist/geometry.js +75 -82
  16. package/dist/gif.d.ts +1 -2
  17. package/dist/gif.js +115 -42
  18. package/dist/guide.d.ts +1 -4
  19. package/dist/guide.js +10 -11
  20. package/dist/imports.d.ts +11 -0
  21. package/dist/imports.js +235 -0
  22. package/dist/index.d.ts +2 -2
  23. package/dist/index.js +20 -78
  24. package/dist/init.d.ts +3 -13
  25. package/dist/init.js +13 -18
  26. package/dist/load.d.ts +1 -3
  27. package/dist/load.js +2 -5
  28. package/dist/mcp.d.ts +1 -2
  29. package/dist/mcp.js +15 -15
  30. package/dist/model.d.ts +57 -109
  31. package/dist/model.js +20 -89
  32. package/dist/open.d.ts +2 -5
  33. package/dist/open.js +6 -11
  34. package/dist/page.d.ts +0 -1
  35. package/dist/page.js +1 -3
  36. package/dist/picker.d.ts +2 -4
  37. package/dist/picker.js +11 -14
  38. package/dist/png.js +9 -10
  39. package/dist/rail.d.ts +1 -3
  40. package/dist/rail.js +2 -16
  41. package/dist/scene.d.ts +8 -8
  42. package/dist/source.d.ts +1 -2
  43. package/dist/source.js +5 -5
  44. package/dist/svg.d.ts +5 -8
  45. package/dist/svg.js +50 -112
  46. package/dist/text.d.ts +0 -2
  47. package/dist/text.js +1 -4
  48. package/dist/type-index.d.ts +55 -0
  49. package/dist/type-index.js +383 -0
  50. package/dist/types.d.ts +32 -0
  51. package/dist/types.js +733 -0
  52. package/dist/verify.d.ts +26 -4
  53. package/dist/verify.js +68 -26
  54. package/dist/version.d.ts +1 -2
  55. package/dist/version.js +2 -2
  56. package/package.json +4 -2
package/dist/index.js CHANGED
@@ -10,9 +10,8 @@ export { LIGHT, DARK } from './model.js';
10
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
12
12
  const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
13
- const NONE = []; // a stable default, so a memo keyed on it does not run every render
14
- const ACTIVE = 'flowfig-active'; // the box a packet has just reached
15
- // The active box reads --ff-hop (the tone of the hop that arrived), then --ff-box (the tone of the box), then the accent.
13
+ const NONE = [];
14
+ const ACTIVE = 'flowfig-active';
16
15
  const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
17
16
  const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
18
17
  const cardBody = (c) => {
@@ -40,20 +39,17 @@ const cardBody = (c) => {
40
39
  fontFamily: r.mono ? MONO : undefined,
41
40
  fontSize: r.mono ? 10.5 : undefined,
42
41
  }, children: [r.text, r.meta != null && _jsxs("span", { style: { color: v('muted') }, children: [" \u00B7 ", r.meta] })] }));
43
- // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
44
42
  return (r.tag?.length ?? 0) > 2 ? (_jsxs("div", { children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 5, marginBottom: 1 }, children: [tag, mark] }), text] }, i)) : (_jsxs("div", { style: { display: 'flex', gap: 5, alignItems: 'baseline' }, children: [tag, text, mark] }, i));
45
43
  }) }));
46
44
  };
47
45
  /**
48
- * The interactive player for one figure: tabs for the steps, play and pause, speed, hover and full screen.
49
- * Give it a `FlowProps` spec. Use `toSvg` from `flowfig/svg` for a static animated SVG instead.
46
+ * The interactive player for one figure. Give it a `FlowProps` spec.
47
+ * Use `toSvg` from `flowfig/svg` for a static animated SVG.
50
48
  */
51
49
  export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
52
50
  const tl = timeline && isLanesLayout(layout);
53
- // A timeline with no steps of its own walks its dated items in date order, with no tab row.
54
51
  const synthetic = tl && !stepsIn.length;
55
52
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
56
- // The width of the timeline area in px: the server draws TL_AXIS_W, and the browser measures the real width after mount.
57
53
  const [axisW, setAxisW] = useState(TL_AXIS_W);
58
54
  const area = useRef(null);
59
55
  const root = useRef(null);
@@ -69,24 +65,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
69
65
  }, [full]);
70
66
  const dots = useRef([]);
71
67
  const chips = useRef([]);
72
- const bar = useRef(null); // the active tab's progress line
68
+ const bar = useRef(null);
73
69
  const [beat, setBeat] = useState(0);
74
- const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
75
- const [returning, setReturning] = useState(false); // the end of the last hold: the playhead moves back to the loop start
76
- const firstLabel = useRef(''); // the playhead date of the first mount, which shows at once; '' before the first render
70
+ const [holding, setHolding] = useState(false);
71
+ const [returning, setReturning] = useState(false);
72
+ const firstLabel = useRef('');
77
73
  const paths = useRef({});
78
74
  const [routed, setRouted] = useState([]);
79
- // Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
80
75
  const [extraTall, setExtraTall] = useState(new Set());
81
- // Lanes: the shared wrap plan (blocks, gaps, stubs), the same as the SVG at the default 830 px width.
82
76
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
83
- ? planLanes({ layout, edges, steps, lanes },
84
- // A rail sets a least width, as in the SVG.
85
- { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
77
+ ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
86
78
  : null, [lanes, tl, layout, edges, steps, withRail]);
87
79
  const [active, setActive] = useState(steps.length ? 0 : null);
88
80
  const [playing, setPlaying] = useState(autoplay);
89
- // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
90
81
  const playingRef = useRef(playing);
91
82
  playingRef.current = playing;
92
83
  const [rate, setRate] = useState(1);
@@ -95,7 +86,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
95
86
  const clock = useRef({ beats: null, elapsed: 0 });
96
87
  const [hover, setHover] = useState(null);
97
88
  const ids = useMemo(() => edges.map(edgeId), [edges]);
98
- // A milestone is a diamond too, so its edges meet the tips.
99
89
  const tips = useMemo(() => new Set([
100
90
  ...decisions(layout),
101
91
  ...(tl
@@ -106,7 +96,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
106
96
  ]), [layout, tl, edges, timeline, today]);
107
97
  const step = active == null ? null : steps[active];
108
98
  const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
109
- // Every content each box's card will ever show. The card is sized to the largest, so text never gets cut and nothing jumps.
110
99
  const carded = useMemo(() => {
111
100
  const all = new Map();
112
101
  for (const b of steps.flatMap((s) => s.flow.map(toBeat)))
@@ -115,30 +104,23 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
115
104
  return all;
116
105
  }, [steps]);
117
106
  const cur = beats[beat];
118
- // The narration: the latest `say` so far (a beat without one keeps the line before), else the step caption.
119
107
  const saidAt = beats.slice(0, beat + 1).findLastIndex((b) => b.say != null);
120
108
  const said = saidAt === -1 ? step?.caption : beats[saidAt].say;
121
- // Content cards: everything the step has shown so far, latest wins.
122
109
  const shown = useMemo(() => Object.assign({}, ...beats.slice(0, beat + 1).map((b) => b.show)), [beats, beat]);
123
- // The beat that last changed each card, so only a card whose content just changed animates in.
124
110
  const shownAt = useMemo(() => {
125
111
  const at = {};
126
112
  beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
127
113
  return at;
128
114
  }, [beats, beat]);
129
- // The rail lays out in node terms, like the SVG, so both renderers draw the same numbers. Its width follows the measured map.
130
115
  const figure = useRef(null);
131
116
  const [mapW, setMapW] = useState(0);
132
117
  const railOnly = withRail === 'only';
133
118
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
134
- // 'only' drops the map, but only when the rail has a hop to draw; a figure never renders empty.
135
119
  const noMap = railOnly && rail != null;
136
- const jump = useRef(null); // a rail click that asks for a beat of another step
120
+ const jump = useRef(null);
137
121
  const [hoverEdge, setHoverEdge] = useState(null);
138
- // With reduced motion the player shows the last beat, so the rail marks every row as done.
139
122
  const [still, setStill] = useState(false);
140
123
  useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
141
- // Boxes with many edges on one side get taller so the edges and their labels have room.
142
124
  const minHeight = useMemo(() => {
143
125
  const out = {}, inn = {};
144
126
  for (const e of edges) {
@@ -150,20 +132,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
150
132
  return n > 2 ? n * 30 : undefined;
151
133
  };
152
134
  }, [edges]);
153
- // Measure every box and redraw the edges whenever anything changes size.
154
135
  useEffect(() => {
155
136
  const el = root.current, box = outer.current;
156
137
  if (!el || !box)
157
- return; // no map to measure with rail: 'only'
138
+ return;
158
139
  const measure = () => {
159
- // Too wide for its container? Shrink it so the whole figure stays in view. Below half size, scroll instead.
160
140
  const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
161
141
  setFit({ scale, height: el.offsetHeight * scale });
162
142
  setMapW(el.offsetWidth);
163
143
  if (area.current)
164
144
  setAxisW(area.current.offsetWidth);
165
145
  const base = el.getBoundingClientRect();
166
- const k = base.width / el.offsetWidth; // the scale currently on screen; rects are measured unscaled
146
+ // Rects are measured unscaled: k is the scale on screen.
147
+ const k = base.width / el.offsetWidth;
167
148
  const rects = {};
168
149
  el.querySelectorAll('[data-fig]').forEach((n) => {
169
150
  const r = n.getBoundingClientRect();
@@ -179,12 +160,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
179
160
  const r = n.getBoundingClientRect();
180
161
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
181
162
  });
182
- // A timeline elbow keeps clear of the bars and milestones it does not connect, as in the SVG.
183
163
  if (tl)
184
164
  for (const n of nodes(layout))
185
165
  if (rects[n.id])
186
166
  avoid.push({ ...rects[n.id], box: true });
187
- // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
188
167
  const stubs = lanePlan?.stubs ?? new Map();
189
168
  if (stubs.size) {
190
169
  for (const n of nodes(layout))
@@ -195,13 +174,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
195
174
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
196
175
  });
197
176
  }
198
- // A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
199
177
  const bands = {};
200
178
  el.querySelectorAll('[data-fig-copy]').forEach((n) => {
201
179
  const r = n.getBoundingClientRect();
202
180
  bands[n.dataset.figCopy] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
203
181
  });
204
- // A stub pill stays in the lanes, right of the gutter, as in the SVG.
205
182
  const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
206
183
  const pillArea = stubs.size && blockRects.length
207
184
  ? (() => {
@@ -211,7 +188,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
211
188
  return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
212
189
  })()
213
190
  : undefined;
214
- // The band of an edge end, in the block of that end, as in the SVG.
215
191
  const laneBands = {};
216
192
  el.querySelectorAll('[data-fig-band]').forEach((n) => {
217
193
  const r = n.getBoundingClientRect();
@@ -248,12 +224,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
248
224
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
249
225
  return () => ro.disconnect();
250
226
  }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
251
- // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
252
227
  const reported = useRef(new Set());
253
228
  useEffect(() => {
254
229
  const el = root.current, box = outer.current;
255
230
  const fig = figure.current;
256
- // With rail: 'only' there is no map: the figure is the frame and the scene holds only the rail.
257
231
  if (!check || !fig || (!noMap && (!el || !box)))
258
232
  return;
259
233
  const base = (el ?? fig).getBoundingClientRect();
@@ -276,7 +250,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
276
250
  const fontSize = parseFloat(getComputedStyle(t).fontSize);
277
251
  if (!n.dataset.diamond)
278
252
  return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
279
- // A diamond gives a line the width of its outline at the far edge of the line, and the line is as wide as its text.
280
253
  const [r, range] = [rel(t), document.createRange()];
281
254
  range.selectNodeContents(t);
282
255
  const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
@@ -289,11 +262,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
289
262
  }),
290
263
  };
291
264
  });
292
- // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
293
- // The rail is a sibling of the map, so the rail queries start at the figure. An SVG <text> has no CSS box, so it skips clientWidth.
294
265
  const fonts = [...fig.querySelectorAll('*')]
295
266
  .filter((t) => !t.children.length &&
296
267
  t.textContent?.trim() &&
268
+ // An SVG <text> has no CSS box, so skip clientWidth.
297
269
  (t instanceof SVGElement || t.clientWidth > 0) &&
298
270
  !t.closest('[data-fig-tag],[aria-hidden]'))
299
271
  .map((t) => parseFloat(getComputedStyle(t).fontSize));
@@ -311,12 +283,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
311
283
  edges: [
312
284
  ...routed.flatMap((r) => {
313
285
  const e = edges[ids.indexOf(r.id)];
314
- // A cross-block edge is two scene edges: each stub with its pill.
315
286
  if (r.stub)
316
287
  return r.stub.pts.map((pts, j) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: labels[`${r.id}:${j}`] }));
317
288
  return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
318
289
  }),
319
- // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
320
290
  ...(rail?.rows ?? []).flatMap((row) => {
321
291
  const r = row.kind === 'message' ? labels[`rail:${row.n}`] : undefined;
322
292
  if (row.kind !== 'message' || !r)
@@ -345,14 +315,13 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
345
315
  if (reported.current.has(key))
346
316
  continue;
347
317
  reported.current.add(key);
318
+ // oxlint-disable-next-line no-console
348
319
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
349
320
  }
350
321
  }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
351
- // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
352
322
  useEffect(() => {
353
323
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
354
324
  if (clock.current.beats !== beats) {
355
- // A rail click can open another step at a later beat; start the clock at that beat, not at 0.
356
325
  const j = jump.current ?? 0;
357
326
  jump.current = null;
358
327
  clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
@@ -366,7 +335,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
366
335
  return void setBeat(beats.length - 1);
367
336
  }
368
337
  const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
369
- const total = ends.at(-1) + STEP_HOLD_MS; // The figure holds on the last beat before the next step.
338
+ const total = ends.at(-1) + STEP_HOLD_MS;
370
339
  let raf = 0, last = performance.now(), shownBeat = -1;
371
340
  const tick = (now) => {
372
341
  if (playingRef.current)
@@ -387,7 +356,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
387
356
  setHolding(t >= ends.at(-1));
388
357
  setReturning(active === steps.length - 1 && t >= total - 400 * BASE_RATE);
389
358
  const start = i ? ends[i - 1] : 0;
390
- const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
359
+ const f = Math.min(1, (t - start) / speed);
391
360
  const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
392
361
  const hops = t < ends.at(-1) ? beats[i].hops : [];
393
362
  gs.forEach((g, j) => {
@@ -413,14 +382,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
413
382
  c.style.opacity = hops[j].data == null ? '0' : '1';
414
383
  }
415
384
  });
416
- // A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
417
385
  const arrived = new Map();
418
386
  if (f >= 1)
419
387
  for (const h of beats[i].hops) {
420
388
  const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
421
389
  arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
422
390
  }
423
- // A focused box turns active at the beat start; in a timeline, when the today line arrives.
424
391
  if (t - start >= (tl ? 400 * BASE_RATE : 0))
425
392
  for (const id of beats[i].focus ?? [])
426
393
  if (!arrived.has(id))
@@ -437,8 +404,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
437
404
  if (on === n.classList.contains(ACTIVE))
438
405
  return;
439
406
  if (!on)
440
- return void n.classList.remove(ACTIVE); // the transition fades the box
441
- // The box turns active at once: no transition while the class arrives.
407
+ return void n.classList.remove(ACTIVE);
442
408
  const keep = n.style.transition;
443
409
  n.style.transition = 'none';
444
410
  n.classList.add(ACTIVE);
@@ -455,8 +421,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
455
421
  cs.forEach((c) => c && (c.style.opacity = '0'));
456
422
  };
457
423
  }, [beats, routed, noMap, speed, steps.length]);
458
- // What to light up: the step's edges and their ends, or whatever touches the hovered box.
459
- // A step lights the trail so far: every edge its beats have crossed, and every box it has filled.
460
424
  const trail = beats.slice(0, beat + 1).flatMap((b) => b.hops.map((h) => h.edge));
461
425
  const hoverEdgeIdx = hoverEdge == null ? -1 : ids.indexOf(hoverEdge);
462
426
  const litEdges = new Set(hoverEdge ? [hoverEdge] : hover ? ids.filter((id, i) => edges[i].from === hover || edges[i].to === hover) : trail);
@@ -469,7 +433,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
469
433
  if (litEdges.has(ids[i]))
470
434
  litNodes.add(e.from).add(e.to);
471
435
  });
472
- // A toned hop colors its edge for its own beat only; the trail after it is the accent.
473
436
  const hopTone = (edge) => {
474
437
  const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
475
438
  return t && TONES[t];
@@ -488,7 +451,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
488
451
  const focus = hover != null || hoverEdge != null || step != null;
489
452
  const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
490
453
  const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
491
- // A bar or a milestone of a timeline: placed by the shared layout, with the look of a box.
492
454
  const renderBar = (n, it) => {
493
455
  const lit = litNodes.has(n.id);
494
456
  const bt = n.tone && TONES[n.tone];
@@ -522,8 +484,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
522
484
  const renderItem = (item, depth) => {
523
485
  if (timelineFig && item === layout) {
524
486
  const lanesList = layout.children;
525
- // The playhead follows the latest dated focus in the step, and rests at the last date before one and in the hold.
526
- // In the last 400 ms of the loop it moves back to the loop start, so the next loop starts in place, as in the SVG.
527
487
  const focused = still
528
488
  ? undefined
529
489
  : returning
@@ -535,8 +495,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
535
495
  const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
536
496
  const dateText = focused ? focused.date : timelineFig.lastDate;
537
497
  const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
538
- // The today line and the playhead line sit behind the bars (before them, at the bars' z-index); their labels sit on top.
539
- // The playhead label waits for the line, except on the first mount, where the line starts in place.
540
498
  if (firstLabel.current === '')
541
499
  firstLabel.current = dateText;
542
500
  else if (firstLabel.current !== dateText)
@@ -572,7 +530,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
572
530
  return (_jsxs("div", { style: {
573
531
  display: 'grid',
574
532
  gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
575
- // The axis strip sits over the gap above the first band, as in the SVG.
576
533
  gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
577
534
  rowGap: LANE_ROW_GAP,
578
535
  position: 'relative',
@@ -605,19 +562,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
605
562
  }
606
563
  if (lanes && item === layout && isLanesLayout(layout)) {
607
564
  const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
608
- const n = Math.max(0, ...cols.values()) + 1;
609
565
  const count = blocks.length;
610
- // One grid per block of `per` time columns, as in the SVG. A wrapped block shows only the lanes with a box in it.
611
566
  const grid = (bk) => {
612
567
  const m = starts[bk + 1] - starts[bk];
613
568
  const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
614
569
  const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
615
570
  return (_jsx("div", { "data-fig-block": bk, style: {
616
571
  display: 'grid',
617
- // A wrapped block stretches to the widest block, so all bands share one width.
618
- // Every block has the gutter of the plan, so the labels and the columns line up as in the SVG.
619
572
  gridTemplateColumns: `${gutter}px repeat(${m}, max-content)${count > 1 ? ' 1fr' : ''}`,
620
- // An empty lane keeps the SVG's least band height: a 38 px label line plus the lane padding.
621
573
  gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
622
574
  columnGap: 0,
623
575
  rowGap: LANE_ROW_GAP,
@@ -650,7 +602,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
650
602
  gridRow: li + 1,
651
603
  alignSelf: 'center',
652
604
  zIndex: 1,
653
- // The space after each column and before a block's first column comes from the plan, as in the SVG.
654
605
  padding: `${LANE_PAD}px ${gaps[cols.get(b.id)]}px ${LANE_PAD + (tall.has(`${layout.children.indexOf(lane)}@${bk}`) || extraTall.has(`${layout.children.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0)}px ${cols.get(b.id) === starts[bk] ? lead[bk] : 0}px`,
655
606
  }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
656
607
  }) }, bk));
@@ -696,7 +647,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
696
647
  const diamond = item.shape === 'decision';
697
648
  const store = item.shape === 'store';
698
649
  const card = carded.has(item.id);
699
- // The sub of a diamond wraps as in the SVG, one div per line, so a line stays inside the outline.
700
650
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
701
651
  const bt = item.tone && TONES[item.tone];
702
652
  return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
@@ -812,7 +762,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
812
762
  width: 28,
813
763
  height: 28,
814
764
  }, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-label{from{opacity:0}to{opacity:0}}@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
815
- // 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.
765
+ // The 2 px active border takes 1 px of padding, so the box size stays.
816
766
  `.${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}` +
817
767
  `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
818
768
  margin: full ? '-48px -24px 0' : '-20px -16px 0',
@@ -831,8 +781,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
831
781
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
832
782
  transformOrigin: 'top left',
833
783
  padding: 4,
834
- // room for edges that arc over or under the boxes
835
- // A stub does not arc.
836
784
  paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
837
785
  paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
838
786
  ? 44
@@ -844,7 +792,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
844
792
  height: '100%',
845
793
  overflow: 'visible',
846
794
  pointerEvents: 'none',
847
- // A timeline dependency runs under the bars (z-index 1) and over the bands (0).
848
795
  zIndex: tl ? 0 : undefined,
849
796
  }, 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) => {
850
797
  const on = litEdges.has(r.id);
@@ -858,7 +805,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
858
805
  const guide = (_jsx("path", { ref: (p) => {
859
806
  paths.current[r.id] = p;
860
807
  }, d: r.d, fill: "none", stroke: "none" }, r.id));
861
- // A cross-block edge draws its two stubs; its full path only guides the packet, which jumps between the blocks.
862
808
  if (r.stub)
863
809
  return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
864
810
  return look(r.d, r.id, true);
@@ -890,7 +836,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
890
836
  return [];
891
837
  const on = litEdges.has(r.id);
892
838
  const tone = hopTone(r.id);
893
- // A cross-block edge has two pills, one per stub, in place of its label.
894
839
  const pills = texts
895
840
  ? r.stub.pills.map((p, j) => ({
896
841
  key: `${r.id}:${j}`,
@@ -927,7 +872,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
927
872
  fontFamily: v('font'),
928
873
  }, children: [rail.bands.map((b) => (_jsxs("g", { children: [_jsx("rect", { x: b.rect.x, y: b.rect.y, width: b.rect.w, height: b.rect.h, rx: 10, fill: v('surface'), stroke: v('border') }), _jsx("text", { x: b.rect.x + 10, y: b.rect.y + 14, fill: v('muted'), fontSize: 11, fontWeight: 600, letterSpacing: ".04em", children: b.label.toUpperCase() })] }, b.id))), rail.columns.map((c) => (_jsx("text", { x: c.x, y: rail.head - RAIL.cols / 2 + 4, fill: v('fg'), fontSize: 12, fontWeight: 500, textAnchor: "middle", children: c.label }, c.id))), (() => {
929
874
  const st = railState(rail, active ?? 0);
930
- // The lifelines run through the message rows only, so a phase header reads as one clear divider.
931
875
  const lines = rail.rows.flatMap((row, i) => row.kind === 'message' && st[i].shown ? rail.columns.map((c) => `M ${c.x} ${st[i].y} V ${st[i].y + RAIL.row}`) : []);
932
876
  return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
933
877
  })(), (() => {
@@ -958,7 +902,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
958
902
  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 &&
959
903
  (() => {
960
904
  const tagW = ASYNC_TAG_W;
961
- // The tag sits on the tail side of the pill, so it never hides the arrowhead.
962
905
  const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
963
906
  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" })] }));
964
907
  })(), 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}`));
@@ -973,7 +916,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
973
916
  }, children: steps.map((s, i) => {
974
917
  const on = active === i;
975
918
  return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
976
- clock.current.elapsed = 0; // replay from the start, even when it is already the active step
919
+ clock.current.elapsed = 0;
977
920
  setActive(i);
978
921
  setPlaying(true);
979
922
  setBeat(0);
@@ -1022,7 +965,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
1022
965
  animation: 'flowfig-in .35s ease-out',
1023
966
  }, children: said }, `${active}-${saidAt}`))] }))] }));
1024
967
  }
1025
- /** A 16px stroke icon drawn from one SVG path. */
1026
968
  function Icon({ d, fill = false }) {
1027
969
  return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 16 16", fill: fill ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: d }) }));
1028
970
  }
package/dist/init.d.ts CHANGED
@@ -1,25 +1,18 @@
1
1
  type Agent = {
2
2
  id: string;
3
3
  name: string;
4
- /** Paths whose presence means the repo uses the agent. A trailing `/` means a directory. */
5
4
  marks: string[];
6
- /** The file init writes, relative to the repo. */
7
5
  file: string;
8
- /** `section` edits a file the user owns; `whole` owns the file. */
9
6
  kind: 'section' | 'whole';
10
- /** Text before the marked block in a whole file. */
11
7
  head?: string;
12
8
  note?: string;
13
- /** The MCP config file the agent reads, and the top-level key that holds the servers. `type` adds `"type": "stdio"`. */
14
9
  mcp?: {
15
10
  file: string;
16
11
  key: 'mcpServers' | 'servers';
17
12
  type?: true;
18
13
  global?: string;
19
14
  };
20
- /** Shown in place of the MCP file when init cannot write the config. */
21
15
  mcpNote?: string;
22
- /** A second file, a marked section in a file the user owns. Written only in a repo run, never with `--global`. */
23
16
  also?: {
24
17
  file: string;
25
18
  text: string;
@@ -30,16 +23,13 @@ export declare const MCP_ENTRY: {
30
23
  args: string[];
31
24
  };
32
25
  export declare const AGENTS: Agent[];
33
- /** The ids of the agents that the repo at `dir` already uses. */
34
26
  export declare const detect: (dir: string) => string[];
35
- /** Toggle the agents named by numbers ("1 3" or "1,3"). A number out of range and other text are ignored. */
27
+ /** Toggle the agents named by numbers ("1 3" or "1,3"). Ignore bad input. */
36
28
  export declare function toggle(input: string, selected: Set<string>, count: number): Set<string>;
37
- /** The picker screen. */
38
29
  export declare function renderList(selected: Set<string>, detected: string[], dir: string): string;
39
- /** 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. */
30
+ /** The new file text, or `undefined` if the user owns the file. */
40
31
  export declare function place(a: Agent, old: string | undefined): string | undefined;
41
- /** The MCP file with the flowfig entry merged in, or `undefined` when the file is not a JSON object that can hold it. */
32
+ /** The MCP file with the flowfig entry, or `undefined` if the file cannot hold it. */
42
33
  export declare function registerMcp(a: Agent, old: string | undefined): string | undefined;
43
- /** Run `flowfig init`. Returns the exit code. */
44
34
  export declare function runInit(argv: string[]): Promise<number>;
45
35
  export {};
package/dist/init.js CHANGED
@@ -1,4 +1,3 @@
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
3
  import { dirname, join, relative, resolve, sep } from 'node:path';
@@ -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,21 +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
137
  const shown = (path, base, global) => {
140
- const rel = relative(base, path).split(sep).join('/'); // the output uses / on every system
138
+ const rel = relative(base, path).split(sep).join('/');
141
139
  return global ? `~/${rel}` : rel;
142
140
  };
143
141
  const HINT = 'run with -y or --agents <ids>';
144
- /** Run `flowfig init`. Returns the exit code. */
145
142
  export async function runInit(argv) {
146
143
  const args = [...argv];
147
144
  const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
@@ -248,8 +245,7 @@ export async function runInit(argv) {
248
245
  mcpStatus = `would ${mcpStatus === 'created' ? 'create' : 'update'}`;
249
246
  else {
250
247
  mkdirSync(dirname(mcpPath), { recursive: true });
251
- // Same-folder temp file, then rename: a crash or a parallel write cannot leave half a file (~/.claude.json holds user settings).
252
- // 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.
253
249
  const real = existsSync(mcpPath) ? realpathSync(mcpPath) : mcpPath;
254
250
  const tmp = `${real}.${process.pid}.tmp`;
255
251
  try {
@@ -274,7 +270,6 @@ function fail(message) {
274
270
  console.error(message);
275
271
  return 2;
276
272
  }
277
- /** Ask on the terminal. Returns the chosen ids, or `undefined` if the user quits. */
278
273
  async function pick(start, detected, dir) {
279
274
  const rl = createInterface({ input: process.stdin, output: process.stdout });
280
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>;