flowfig 0.6.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/svg.js CHANGED
@@ -1,34 +1,24 @@
1
- // The same figure, as one self-contained animated SVG: no scripts, no fonts to fetch, so it plays
2
- // inside a markdown image on GitHub, GitLab and Notion, where the React player cannot go. It gives
3
- // up what needs a reader: no hover, no tabs, no pause — every step runs in one loop.
4
- //
5
- // It reads the same spec and the same `route()` as the React renderer, so the two cannot drift.
6
- //
7
- // Limit: text is measured by character class (src/text.ts) rather than by a browser, so this runs anywhere with plain node.
8
- // Wrapping is therefore approximate; `flowfig check` uses the same measure, and the player check measures real text.
1
+ // Wrapping is approximate: text is measured by character class, not by a browser.
9
2
  import { route } from './geometry.js';
10
3
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
11
4
  import { textWidth, wrap } from './text.js';
12
- import { checkScene, checkSpec, checkTheme } from './check.js';
5
+ import { checkRendered, planFor } from './check.js';
13
6
  export { parseSource, links } from './source.js';
14
7
  export { diff, formatDiff } from './diff.js';
15
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, lanePlan, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, DARK, EDGE_OFF, EDGE_ON, LIGHT, TONES, toneFill, toneTint, labelPillW, ON_ACCENT, str, edgeId, groupGap, isGroup, isRows, LANE_PAD, LANE_ROW_GAP, LANE_BLOCK_GAP, FRAME_SIDE, laneGutter as gutterOf, STUB_ROOM, laneBlock, laneEnd, tightCopies, nodeWidth, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, } from './model.js';
16
9
  export { LIGHT, DARK } from './model.js';
17
10
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
18
11
  const LABEL_LINE = 18, SUB_LINE = 15;
19
12
  const FRAME_TOP = 37, FRAME_BOTTOM = 18;
20
- // The card-on fills are fixed colors that approximate the player's 8% accent tint. The active tint is the player's color-mix.
13
+ // The card-on fills approximate the player's 8 % accent tint.
21
14
  const vars = (t, cardOn) => `--accent:${t.accent}; --fg:${t.fg}; --muted:${t.muted}; --bg:${t.bg}; --surface:${t.surface}; --border:${t.border}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
22
- /** What a card actually holds: rows, or its text. Only text survives outside React. */
23
15
  const content = (c) => (isRows(c) ? c : str(c));
24
16
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
25
17
  const n2 = (v) => Math.round(v * 10) / 10;
26
- // Keyframe times are fractions of a loop that can last a minute: one decimal would round a 0.7 s hop away, and the packet would jump.
18
+ // Four decimals: one decimal rounds a 0.7 s hop away and the packet jumps.
27
19
  const n4 = (v) => Math.round(v * 10000) / 10000;
28
20
  const pct = (v) => Math.round(v * 10000) / 100 + '%';
29
- /** A tag pill. The tag is drawn in bold capitals with letter spacing, so the width counts both. */
30
21
  const pillW = (tag) => textWidth(tag.toUpperCase(), 9) + tag.length * 0.27 + 8;
31
- /** A card's rows, wrapped for `width`, with the height they need. */
32
22
  function layoutCard(c, width) {
33
23
  const inner = width - CARD_SIDE * 2;
34
24
  if (c == null)
@@ -39,7 +29,6 @@ function layoutCard(c, width) {
39
29
  return { rows: [{ row: { text: body }, heads: false, lines, room: inner }], height: lines.length * LINE + CARD_PAD * 2 };
40
30
  }
41
31
  const rows = body.map((row) => {
42
- // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
43
32
  const heads = (row.tag?.length ?? 0) > 2;
44
33
  const tagW = row.tag && !heads ? pillW(row.tag) + 5 : 0;
45
34
  const markW = row.mark && !heads ? textWidth(str(row.mark), 11) + 6 : 0;
@@ -68,8 +57,6 @@ function size(item, s) {
68
57
  const inner = item.direction === 'column' ? { w: across, h: along } : { w: along, h: across };
69
58
  return item.label != null ? { w: inner.w + FRAME_SIDE * 2, h: inner.h + FRAME_TOP + FRAME_BOTTOM } : inner;
70
59
  }
71
- /** Swimlanes: the bands span the width, the label sits in a left gutter, and a box sits at its time column. Columns past
72
- * the plan wrap into blocks under the first. A block holds only the lanes with a box in it; all bands share one width. */
73
60
  function placeLanes(fig, x, y, s, out) {
74
61
  const lanes = fig.layout.children;
75
62
  const { cols, starts, gaps, lead, blocks } = s.plan;
@@ -87,7 +74,6 @@ function placeLanes(fig, x, y, s, out) {
87
74
  .slice(0, c - starts[k])
88
75
  .reduce((a, w, i) => a + w + gaps[starts[k] + i], 0));
89
76
  });
90
- // A figure with no box has no column: its band keeps the frame side on the right.
91
77
  const width = Math.max(...blocks.map((k) => {
92
78
  const ws = colsOf(k);
93
79
  return (gutter +
@@ -104,7 +90,6 @@ function placeLanes(fig, x, y, s, out) {
104
90
  ly += LANE_BLOCK_GAP - LANE_ROW_GAP;
105
91
  for (const lane of lanes) {
106
92
  const kids = lane.children.filter((b) => laneBlock(starts, cols.get(b.id)) === bk).map((b) => ({ b, ...size(b, s) }));
107
- // A wrapped block draws only its own lanes; one block draws every lane, an empty one too.
108
93
  if (blocks.length > 1 && !kids.length)
109
94
  continue;
110
95
  const inner = Math.max(LABEL_LINE + 20, ...kids.map((k) => k.h));
@@ -117,7 +102,6 @@ function placeLanes(fig, x, y, s, out) {
117
102
  });
118
103
  out[topAt].h = ly - LANE_ROW_GAP - y;
119
104
  }
120
- /** A timeline: the axis strip on top, one band per track, and the bars and milestones at the shared layout x. */
121
105
  function placeTimeline(fig, x, y, out) {
122
106
  const lanes = fig.layout.children;
123
107
  const gutter = gutterOf(lanes);
@@ -178,24 +162,18 @@ function place(item, x, y, s, out) {
178
162
  }
179
163
  }
180
164
  const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
181
- /**
182
- * The SVG string, and the scene it drew. The scene lets `flowfig check` test the same layout the SVG uses.
183
- * The scene is for tools, and its shape may change. Use `toSvg` if you only need the SVG.
184
- */
165
+ /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
185
166
  export function render(fig, opts = {}) {
186
167
  const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
187
168
  const pad = opts.padding ?? 24;
188
169
  const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
189
- // A timeline with no steps of its own walks its dated items in date order.
190
170
  const synthetic = tl != null && !fig.steps?.length;
191
171
  const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
192
172
  const beats = steps.map((s) => s.flow.map(toBeat));
193
- // Every content a box will ever show, so its card can be sized to the biggest one up front.
194
173
  const cards = new Map();
195
174
  for (const b of beats.flat())
196
175
  for (const [id, c] of Object.entries(b.show ?? {}))
197
176
  cards.set(id, [...(cards.get(id) ?? []), c]);
198
- // Boxes with many edges on one side get taller so the edges and their labels have room.
199
177
  const out = {}, inn = {};
200
178
  for (const e of fig.edges) {
201
179
  out[e.from] = (out[e.from] ?? 0) + 1;
@@ -210,14 +188,13 @@ export function render(fig, opts = {}) {
210
188
  const plan = lanes ? planFor(fig, opts) : null;
211
189
  const sizes = { cards, cardH, minH, gap: (g) => groupGap(g, fig.edges), fig, plan, tall: new Set(plan?.tall) };
212
190
  for (const [id, contents] of cards) {
213
- const width = CARD_WIDTH; // refined below once the node's own width is known
191
+ const width = CARD_WIDTH;
214
192
  const widths = [width];
215
193
  cardH.set(id, Math.max(LINE + CARD_PAD * 2, ...contents.map((c) => layoutCard(c, Math.min(...widths) - 20).height)));
216
194
  }
217
195
  const placed = [];
218
196
  place(fig.layout, pad, pad, sizes, placed);
219
197
  let nodes = placed.filter((p) => !isGroup(p.item));
220
- // A node's own width can differ from CARD_WIDTH (`width` in the spec), so re-measure once placed.
221
198
  for (const p of nodes) {
222
199
  if (!cards.has(p.item.id))
223
200
  continue;
@@ -227,11 +204,9 @@ export function render(fig, opts = {}) {
227
204
  const rects = {};
228
205
  const tips = new Set(placed.filter((p) => !isGroup(p.item) && (p.item.shape === 'decision' || p.tl?.milestone)).map((p) => p.item.id));
229
206
  const ids = fig.edges.map(edgeId);
230
- // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane gutters.
231
207
  const stubs = plan?.stubs ?? new Map();
232
208
  const gutter = plan ? gutterOf(fig.layout.children) : 0;
233
209
  const band = (id, k) => placed.find((p) => p.lane && p.item.id === id && p.block === k);
234
- // The band of an edge end, in the block of that end: the band of the box's lane, or of the lane itself.
235
210
  const laneGroups = fig.layout.children;
236
211
  const bandOf = (eid, id, k) => {
237
212
  const block = plan?.ends.get(eid)?.[k];
@@ -239,7 +214,6 @@ export function render(fig, opts = {}) {
239
214
  return placed.find((p) => p.lane && p.item === lane && p.block === block);
240
215
  };
241
216
  const end = (eid, id, start) => (plan ? laneEnd(plan, eid, id, start, band, rects) : id);
242
- // Place and route. A stub end with no place clear of every edge path makes its lane copy taller, then both run again.
243
217
  const go = () => {
244
218
  placed.length = 0;
245
219
  place(fig.layout, pad, pad, sizes, placed);
@@ -258,11 +232,8 @@ export function render(fig, opts = {}) {
258
232
  ? placed.flatMap((p) => (!isGroup(p.item) ? [p] : p.lane ? [{ x: p.x, y: p.y, w: gutter, h: p.h }] : []))
259
233
  : [
260
234
  ...placed.filter((p) => p.tl && !p.tl.labelInside).map((p) => outsideLabelRect(p, str(p.item.label))),
261
- // A timeline elbow keeps clear of the bars and milestones it does not connect.
262
235
  ...placed.filter((p) => p.tl).map((p) => ({ x: p.x, y: p.y, w: p.w, h: p.h, box: true })),
263
- ],
264
- // A stub pill stays in the lanes, right of the gutter.
265
- stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
236
+ ], stubs.size ? { x: placed[0].x + gutter, y: placed[0].y, w: placed[0].w - gutter, h: placed[0].h } : undefined, lanes ? { bands: placed.filter((p) => p.lane), boxes: placed.filter((p) => !isGroup(p.item)) } : undefined);
266
237
  };
267
238
  let routed = go();
268
239
  for (let pass = 0; plan && pass < 3; pass++) {
@@ -274,11 +245,9 @@ export function render(fig, opts = {}) {
274
245
  }
275
246
  nodes = placed.filter((p) => !isGroup(p.item));
276
247
  const byId = Object.fromEntries(routed.map((r) => [r.id, r]));
277
- // What this layout drew, in scene form. Tags are left out of minFont: short bold capitals, not reading text.
278
248
  const fonts = [14, ...(steps.length ? [13.5] : [])];
279
249
  const sceneBoxes = nodes.map((p) => {
280
250
  if (p.tl) {
281
- // A label inside the bar has the bar as room; a label beside the bar has the space to the next item of its row, or to the band edge.
282
251
  const label = str(p.item.label);
283
252
  const mine = tl.items.find((i) => i.id === p.item.id);
284
253
  const next = tl.items.filter((i) => i.track === mine.track && i.row === mine.row && i.x > mine.x).map((i) => p.x - mine.x + i.x - 8);
@@ -310,7 +279,6 @@ export function render(fig, opts = {}) {
310
279
  });
311
280
  if (placed.some((p) => isGroup(p.item) && p.item.label != null))
312
281
  fonts.push(11);
313
- // The timeline: every beat of every step, in order, with the player's hold at the end of each step.
314
282
  const segs = [];
315
283
  const hops = [];
316
284
  const edgeOf = Object.fromEntries(fig.edges.map((e, i) => [ids[i], e]));
@@ -341,7 +309,6 @@ export function render(fig, opts = {}) {
341
309
  });
342
310
  });
343
311
  const total = t || 1;
344
- // What the figure shows during each segment, matching the React player exactly.
345
312
  const shownAt = segs.map(({ si, bi }) => Object.assign({}, ...beats[si].slice(0, bi + 1).map((b) => b.show)));
346
313
  const litEdges = segs.map(({ si, bi }) => new Set(beats[si].slice(0, bi + 1).flatMap((b) => b.hops.map((h) => h.edge))));
347
314
  const litNodes = segs.map((seg, i) => {
@@ -361,10 +328,19 @@ export function render(fig, opts = {}) {
361
328
  const said = beats[si].slice(0, bi + 1).findLastIndex((b) => b.say != null);
362
329
  return str(said === -1 ? steps[si].caption : beats[si][said].say);
363
330
  });
364
- // One class per on/off pattern over the segments, so 30 boxes share a handful of keyframes.
365
331
  const css = [];
366
332
  const seen = new Map();
367
- /** The class that switches this element between `onCss` and `offCss` as the loop plays. */
333
+ const runs = (values) => {
334
+ const out = [];
335
+ for (let i = 0; i < segs.length;) {
336
+ let j = i;
337
+ while (j + 1 < segs.length && values[j + 1] === values[i])
338
+ j++;
339
+ out.push(`${pct(segs[i].t0 / total)},${pct(segs[j].t1 / total - 0.0001)} { ${values[i]} }`);
340
+ i = j + 1;
341
+ }
342
+ return out.join(' ');
343
+ };
368
344
  const anim = (on, onCss, offCss, prefix) => {
369
345
  if (!segs.length || on.every((x) => !x))
370
346
  return '';
@@ -372,17 +348,16 @@ export function render(fig, opts = {}) {
372
348
  if (!seen.has(key)) {
373
349
  const name = `a${seen.size}`;
374
350
  seen.set(key, name);
375
- const frames = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${on[i] ? onCss : offCss} }`).join(' ');
351
+ const frames = runs(on.map((x) => (x ? onCss : offCss)));
376
352
  css.push(`@keyframes ${name} { ${frames} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
377
353
  }
378
354
  return seen.get(key);
379
355
  };
380
- /** One `class` attribute from the static classes and the animated one — two would be invalid XML. */
356
+ // One class attribute only: two would be invalid XML.
381
357
  const cls = (...names) => {
382
358
  const list = names.filter(Boolean).join(' ');
383
359
  return list ? ` class="${list}"` : '';
384
360
  };
385
- /** Like `anim`, for a property that takes a different value in each segment (a row that moves as phases fold). */
386
361
  const frames = (values, prefix, base = values[0]) => {
387
362
  if (!segs.length || values.every((v) => v === base))
388
363
  return '';
@@ -390,20 +365,14 @@ export function render(fig, opts = {}) {
390
365
  if (!seen.has(key)) {
391
366
  const name = `a${seen.size}`;
392
367
  seen.set(key, name);
393
- const kf = segs.map((s, i) => `${pct(s.t0 / total)},${pct(s.t1 / total - 0.0001)} { ${values[i]} }`).join(' ');
368
+ const kf = runs(values);
394
369
  css.push(`@keyframes ${name} { ${kf} }\n.${name} { animation: ${name} ${n2(total)}s infinite step-end; }`);
395
370
  }
396
371
  return seen.get(key);
397
372
  };
398
- /**
399
- * The look of one box over the loop: off, trail (visited) or active (the packet arrived, until the segment ends).
400
- * A segment ends at the beat end, or after the step hold for the last beat.
401
- * Each frame holds one look. The gap before the frame that follows an active frame gives the FADE.
402
- * FADE is in timeline seconds, which are wall seconds.
403
- */
373
+ // FADE and RAMP are wall seconds; the player matches them in ms.
404
374
  const FADE = 0.4;
405
- const RAMP = 0.4; // the today line moves in 400 ms, in wall seconds like FADE
406
- // A box tone is a permanent state: a 1 px border and a light tint in the off and trail looks. The active look uses the hop tone, else the box tone.
375
+ const RAMP = 0.4;
407
376
  const looks = (look, boxTone, hopTone) => {
408
377
  const tint = boxTone ? toneTint(boxTone, 'var(--bg)') : 'var(--bg)';
409
378
  if (look === 'off')
@@ -415,13 +384,21 @@ export function render(fig, opts = {}) {
415
384
  ? [toneTint(c, 'var(--surface)', 10), c, 2, `drop-shadow(0 0 4px ${c})`]
416
385
  : ['var(--tint)', 'var(--accent)', 2, 'drop-shadow(0 0 4px var(--accent))'];
417
386
  };
387
+ const hopsAt = new Map();
388
+ for (const h of hops) {
389
+ const k = `${h.si}:${h.bi}:${h.dest}`;
390
+ const list = hopsAt.get(k);
391
+ if (list)
392
+ list.push(h);
393
+ else
394
+ hopsAt.set(k, [h]);
395
+ }
418
396
  const boxAnim = (id, shape, boxTone) => {
419
397
  const pieces = [];
420
398
  segs.forEach((s, i) => {
421
399
  if (!litNodes[i].has(id))
422
400
  return void pieces.push({ a: s.t0, b: s.t1, look: 'off' });
423
- const here = hops.filter((h) => h.dest === id && h.si === s.si && h.bi === s.bi);
424
- // A focused box turns active at the beat start; in a timeline, when the today line arrives.
401
+ const here = hopsAt.get(`${s.si}:${s.bi}:${id}`) ?? [];
425
402
  const focused = beats[s.si][s.bi].focus?.includes(id);
426
403
  const arrive = Math.min(...here.map((h) => h.t1), focused ? Math.min(s.t0 + (tl ? RAMP : 0), s.t1) : s.t1, s.t1);
427
404
  if (arrive > s.t0)
@@ -431,18 +408,23 @@ export function render(fig, opts = {}) {
431
408
  });
432
409
  if (!pieces.length || pieces.every((q) => q.look === 'off'))
433
410
  return '';
434
- // The last frame is active: the fade ends at 100 %, so the loop wraps with no jump.
435
411
  const end = pieces.at(-1);
436
412
  if (end.look === 'active') {
437
413
  end.b -= FADE;
438
414
  pieces.push({ a: end.b, b: end.b + FADE, look: pieces[0].look, hop: pieces[0].hop, ramp: FADE - 0.0002 * total });
439
415
  }
440
- const kf = pieces.map((q, k) => {
416
+ const merged = [];
417
+ pieces.forEach((q, k) => {
441
418
  const ramp = q.ramp ?? (q.look !== 'active' && pieces[k - 1]?.look === 'active' ? Math.min(FADE, (q.b - q.a) / 2) : 0);
442
419
  const [fill, stroke, width, filter] = looks(q.look, boxTone, q.hop);
443
420
  const css = `${shape ? `fill: ${fill}; ` : ''}stroke: ${stroke}; stroke-width: ${width}${shape ? `; filter: ${filter}` : ''}`;
444
- return `${pct((q.a + ramp) / total)},${pct(q.b / total - 0.0001)} { ${css} }`;
421
+ const last = merged.at(-1);
422
+ if (last?.css === css)
423
+ last.b = q.b;
424
+ else
425
+ merged.push({ a: q.a + ramp, b: q.b, css });
445
426
  });
427
+ const kf = merged.map((f) => `${pct(f.a / total)},${pct(f.b / total - 0.0001)} { ${f.css} }`);
446
428
  const key = (shape ? 'B' : 'R') + kf.join(' ');
447
429
  if (!seen.has(key)) {
448
430
  const name = `a${seen.size}`;
@@ -495,7 +477,6 @@ export function render(fig, opts = {}) {
495
477
  : item.sub
496
478
  ? `<text x="${n2(cx)}" y="${n2(labelY + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
497
479
  : '';
498
- // The mark sits in the gap beside the box, so it changes no size and adds nothing to the scene.
499
480
  const dot = bt ?? 'var(--accent)';
500
481
  const my = n2(p.y + p.h / 2);
501
482
  const mark = item.mark === 'start'
@@ -505,7 +486,6 @@ export function render(fig, opts = {}) {
505
486
  : '';
506
487
  return shape + label + sub + mark + (contents ? card(p, cardTop, contents) : '');
507
488
  });
508
- /** The dashed content card: one group per content it will ever hold, each visible on its own beats. */
509
489
  function card(p, top, contents) {
510
490
  const id = p.item.id;
511
491
  const x = p.x + 10, w = p.w - 20, h = cardH.get(id);
@@ -513,7 +493,6 @@ export function render(fig, opts = {}) {
513
493
  const box = `<rect x="${n2(x)}" y="${n2(top)}" width="${n2(w)}" height="${n2(h)}" rx="6" fill="var(--surface)" stroke="var(--border)" stroke-dasharray="3 3"` +
514
494
  cls(anim(filled, 'stroke: var(--accent); fill: var(--card-on)', 'stroke: var(--border); fill: var(--surface)', 'c')) +
515
495
  `/>`;
516
- // Every distinct content gets a layer; the segments decide which one is showing.
517
496
  const layers = contents
518
497
  .map((c, ci) => {
519
498
  const on = segs.map((_, i) => contents.indexOf(shownAt[i][id]) === ci);
@@ -528,7 +507,6 @@ export function render(fig, opts = {}) {
528
507
  : '';
529
508
  return box + layers + dash;
530
509
  }
531
- /** One card's rows: a colored tag pill, the text, its muted meta, and the mark on the right. */
532
510
  function rows(c, x, top, w) {
533
511
  const { rows } = layoutCard(c, w);
534
512
  let y = top + CARD_PAD;
@@ -565,22 +543,18 @@ export function render(fig, opts = {}) {
565
543
  const on = segs.map((_, i) => litEdges[i].has(r.id));
566
544
  const hidden = e.quiet && !on.every(Boolean);
567
545
  const shown = hidden ? cls(anim(on, 'opacity: 1', 'opacity: 0', 'q')) : '';
568
- // A toned hop colors its edge for its own beat only; the trail after it is the accent.
569
546
  const tone = segs.map(({ si, bi }) => (beats[si][bi].hops.find((h) => h.edge === r.id && h.tone)?.tone ?? undefined));
570
547
  const col = tone.map((x) => (x ? TONES[x] : 'var(--accent)'));
571
548
  const off = `stroke: var(--muted); stroke-width: ${EDGE_OFF}`;
572
- // With no tone the map and the rail share one keyframe (same key); a toned edge needs its own values.
573
549
  const toned = tone.some(Boolean);
574
550
  const lit = cls(toned
575
551
  ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
576
552
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
577
- // A cross-block edge draws its two stubs; its full path only guides the packet, so the packet jumps between the blocks.
578
553
  const path = r.stub
579
554
  ? r.stub.parts
580
555
  .map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
581
556
  .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
582
557
  : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
583
- // A quiet edge is only drawn while a step uses it, so wrap the whole thing rather than the stroke.
584
558
  const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="var(--bg)" stroke="var(--border)"` +
585
559
  cls(toned
586
560
  ? frames(on.map((x, i) => x ? `fill: ${tone[i] ? toneFill(col[i]) : col[i]}; stroke: ${col[i]}` : 'fill: var(--bg); stroke: var(--border)'), 'l', 'fill: var(--bg); stroke: var(--border)')
@@ -600,8 +574,6 @@ export function render(fig, opts = {}) {
600
574
  }
601
575
  return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
602
576
  });
603
- // A packet per hop: it waits offstage, crosses its edge in `speed`, rests at the end of the edge to the end of its beat, then leaves. Any `data`
604
- // rides above it in a chip, which is how the figure says what is moving.
605
577
  const packets = hops.map((h, i) => {
606
578
  const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
607
579
  const name = `p${i}`;
@@ -622,7 +594,6 @@ export function render(fig, opts = {}) {
622
594
  `<animateMotion dur="${n2(total)}s" repeatCount="indefinite" keyTimes="0;${n4(t0)};${n4(t1)};1" keyPoints="${h.back ? '1;1;0;0' : '0;0;1;1'}" calcMode="linear">` +
623
595
  `<mpath href="#p-${esc(h.edge)}" xlink:href="#p-${esc(h.edge)}"/></animateMotion></g>`);
624
596
  });
625
- // The timeline axis, the fixed today marker and the moving playhead. Their lines sit behind the bars, their labels on top.
626
597
  let axisSvg = '', lineSvg = '', todaySvg = '';
627
598
  if (tl) {
628
599
  const top = placed[0];
@@ -636,14 +607,11 @@ export function render(fig, opts = {}) {
636
607
  `<text x="${n2(x0 + k.x + 3)}" y="${n2(top.y + 10)}" class="tick">${esc(k.label)}</text>`)
637
608
  .join('');
638
609
  const bottom = n2(top.y + top.h);
639
- // The today marker is fixed at the figure's `today`.
640
610
  if (tl.today != null) {
641
611
  const tx = x0 + tl.today;
642
612
  lineSvg = `<path d="M ${n2(tx)} ${n2(top.y + 12)} V ${bottom}" stroke="var(--accent)" stroke-opacity="0.6" stroke-width="1" stroke-dasharray="3 3"/>`;
643
613
  todaySvg = `<text x="${n2(tx - 3)}" y="${n2(top.y + 22)}" text-anchor="end" class="today">today</text>`;
644
614
  }
645
- // The playhead moves to the start of each beat's item, then to the last date in the step hold. The loop starts with
646
- // the playhead at the first beat's item, so the first frame shows the first beat; the last hold moves it back there.
647
615
  if (tl.last != null && segs.length) {
648
616
  const home = tl.last;
649
617
  const startOf = new Map(tl.items.map((i) => [i.id, i.x + (i.milestone ? i.w / 2 : 0)]));
@@ -652,22 +620,20 @@ export function render(fig, opts = {}) {
652
620
  const first = loopStartItem(tl.items, steps);
653
621
  const start = first ? startOf.get(first.id) : home;
654
622
  const pts = [[0, start]];
655
- const dates = []; // the label date in each time span
623
+ const dates = [];
656
624
  let at = start;
657
625
  segs.forEach((s, i) => {
658
626
  const item = playheadItem(tl.items, beats[s.si], s.bi);
659
627
  const to = item ? startOf.get(item.id) : home;
660
628
  const date = item ? dateOf.get(item.id) : tl.lastDate;
661
629
  const last = i === segs.length - 1 || segs[i + 1].si !== s.si;
662
- const end = last ? s.t1 - hold : s.t1; // the step hold starts here
630
+ const end = last ? s.t1 - hold : s.t1;
663
631
  pts.push([s.t0, at], [Math.min(s.t0 + RAMP, end), to], [end, to]);
664
- // The label waits for the line, except at the loop start, where the line is already in place.
665
632
  const [la, lb] = i === 0 && to === start ? [s.t0, end] : labelSpan(s.t0, end, RAMP);
666
633
  if (la < lb)
667
634
  dates.push({ a: la, b: lb, d: date, x: to });
668
635
  at = to;
669
636
  if (last) {
670
- // The last hold of the loop ends with the move back to the loop start.
671
637
  const back = i === segs.length - 1 ? Math.max(end + RAMP, s.t1 - RAMP) : s.t1;
672
638
  pts.push([Math.min(end + RAMP, s.t1), home], [back, home]);
673
639
  const [ha, hb] = labelSpan(end, back, RAMP);
@@ -689,13 +655,11 @@ export function render(fig, opts = {}) {
689
655
  if (!seen.has(key)) {
690
656
  const nm = `a${seen.size}`;
691
657
  seen.set(key, nm);
692
- // Each span ends with a hidden frame: the label stays off while the line moves.
693
658
  const fr = dates
694
659
  .map((x) => `${pct(x.a / total)},${pct(x.b / total - 0.0001)} { opacity: ${x.d === d ? 1 : 0} } ${pct(x.b / total)} { opacity: 0 }`)
695
660
  .join(' ');
696
661
  css.push(`@keyframes ${nm} { ${fr} }\n.${nm} { animation: ${nm} ${n2(total)}s infinite step-end; }`);
697
662
  }
698
- // A label that would meet "today" moves to the tick row.
699
663
  const raised = dateLabelRaised(dates.find((x) => x.d === d).x, tl.today, textWidth(d, 11), textWidth('today', 11));
700
664
  return `<text x="${n2(lx + 3)}" y="${n2(top.y + (raised ? 10 : 22))}" opacity="0"${cls('today', seen.get(key))}>${esc(d)}</text>`;
701
665
  });
@@ -704,22 +668,19 @@ export function render(fig, opts = {}) {
704
668
  }
705
669
  }
706
670
  const bounds = placed[0];
707
- const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i])); // a stub does not arc
671
+ const arcs = fig.edges.some((e, i) => (e.around || plan?.around.has(ids[i])) && !stubs.has(ids[i]));
708
672
  const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
709
673
  const mapW = Math.max(bounds.w + pad * 2, 560);
710
- // `rail: 'only'` drops the map, but only when the rail has a hop to draw; a figure never renders empty.
711
674
  const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
712
675
  const only = fig.rail === 'only' && rail != null;
713
- // With the rail, the rail shows the step label, so the caption holds the narration only.
714
676
  const capTop = rail ? 20 : 26;
715
677
  const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
716
678
  const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
717
679
  const mapH = only ? 0 : bounds.h + pad * 2 + (arcs ? 44 : 0);
718
- const top = only ? pad : mapH + RAIL.gap; // the rail's top
680
+ const top = only ? pad : mapH + RAIL.gap;
719
681
  const H = only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH;
720
682
  const shift = (W - (bounds.w + pad * 2)) / 2;
721
683
  const railX = rail ? (W - rail.width) / 2 : 0;
722
- // The step label and its narration, both switching with the beats.
723
684
  const labels = rail || synthetic
724
685
  ? []
725
686
  : steps.map((_, si) => {
@@ -733,23 +694,19 @@ export function render(fig, opts = {}) {
733
694
  lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
734
695
  `</g>`);
735
696
  });
736
- // The rail: lifelines under the map. Every part reuses a map part: the frame for a band, the edge-label pill for a payload, the card
737
- // tag for `async`, the filled card for a parallel group. So the rail adds no color and no font size.
738
697
  const railSvg = !rail
739
698
  ? ''
740
699
  : (() => {
741
700
  const parts = [];
742
- const cols = rail.head - RAIL.cols / 2 + 4; // the column-label baseline
701
+ const cols = rail.head - RAIL.cols / 2 + 4;
743
702
  for (const b of rail.bands)
744
703
  parts.push(`<rect x="${n2(b.rect.x)}" y="${n2(top + b.rect.y)}" width="${n2(b.rect.w)}" height="${n2(b.rect.h)}" rx="10" fill="var(--surface)" stroke="var(--border)"/>` +
745
704
  `<text x="${n2(b.rect.x + 10)}" y="${n2(top + b.rect.y + 14)}" class="frame">${esc(b.label.toUpperCase())}</text>`);
746
705
  for (const c of rail.columns)
747
706
  parts.push(`<text x="${n2(c.x)}" y="${n2(top + cols)}" class="railcol">${esc(c.label)}</text>`);
748
- // Where each row sits in each segment: it moves when another phase opens, and hides while its phase is folded.
749
707
  const at = (i) => segs.map((s) => railState(rail, s.si)[i]);
750
708
  const base = (i) => railState(rail, 0)[i].y;
751
709
  const moveRow = (i) => frames(at(i).map((st) => `transform: translateY(${n2(st.y - base(i))}px); opacity: ${st.shown ? 1 : 0}`), 'm');
752
- // The lifelines run through the message rows only, so a phase header reads as one clear divider.
753
710
  rail.rows.forEach((row, i) => {
754
711
  if (row.kind !== 'message')
755
712
  return;
@@ -767,8 +724,7 @@ export function render(fig, opts = {}) {
767
724
  if (row.kind === 'phase') {
768
725
  const folded = segs.map((s) => rail.folds && s.si !== row.step);
769
726
  const rule = (x1) => `<path d="M ${n2(x1)} ${n2(y + 15)} H ${n2(row.line.end)}" stroke="var(--border)"/>`;
770
- // One counter per beat of this phase, on this row, so it moves with the row. A parallel beat counts its first message.
771
- const first = new Map(); // beat -> the n of its first message
727
+ const first = new Map();
772
728
  for (const r of rail.rows)
773
729
  if (r.kind === 'message' && r.step === row.step && !first.has(r.beat))
774
730
  first.set(r.beat, r.n);
@@ -792,7 +748,6 @@ export function render(fig, opts = {}) {
792
748
  const col = row.tone ? TONES[row.tone] : 'var(--accent)';
793
749
  const lit = anim(now, `stroke: ${col}; stroke-width: ${EDGE_ON}`, `stroke: var(--muted); stroke-width: ${EDGE_OFF}`, row.tone ? 'e' + col : 'e');
794
750
  const tagW = ASYNC_TAG_W;
795
- // The tag sits on the tail side of the pill, so it never hides the arrowhead.
796
751
  const tagX = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
797
752
  const tag = row.async
798
753
  ? `<rect x="${n2(tagX)}" y="${n2(ly - 6)}" width="${n2(tagW)}" height="12" rx="4" fill="var(--bg)"/>` +
@@ -863,10 +818,8 @@ ${said.join('\n')}
863
818
  edges: [
864
819
  ...(only ? [] : routed).flatMap((r) => {
865
820
  const e = fig.edges[ids.indexOf(r.id)];
866
- // A cross-block edge is two scene edges: each stub with its pill.
867
821
  if (r.stub)
868
822
  return r.stub.pts.map((pts, k) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: r.stub.pills[k] }));
869
- // A timeline edge is drawn behind, but `check` still tests its elbow against the boxes it does not connect.
870
823
  return [
871
824
  {
872
825
  id: r.id,
@@ -878,11 +831,9 @@ ${said.join('\n')}
878
831
  },
879
832
  ];
880
833
  }),
881
- // Each rail payload is a label too, in its own open-phase position, so label-overlap covers the rail.
882
834
  ...(rail?.rows ?? []).flatMap((row, i) => {
883
835
  if (row.kind !== 'message')
884
836
  return [];
885
- // Scene coordinates are the map's layout coordinates: the page minus the map's translate(shift, arcs).
886
837
  const dx = only ? railX : railX - shift, dy = only ? top : top - (arcs ? 44 : 0);
887
838
  const y = dy + railState(rail, row.step)[i].y + RAIL.row / 2;
888
839
  const [x1, x2] = [dx + rail.columns[row.from].x, dx + rail.columns[row.to].x];
@@ -906,24 +857,11 @@ ${said.join('\n')}
906
857
  };
907
858
  return { svg, scene };
908
859
  }
909
- /** The lanes wrap plan at the width and text size of `opts`, or null if the figure does not draw as lanes. A rail sets a least width. */
910
- function planFor(fig, opts) {
911
- if (fig.timeline || !fig.lanes || !isLanesLayout(fig.layout))
912
- return null;
913
- const floor = fig.rail ? (layoutRail(fig, 560)?.width ?? 0) : 0;
914
- return lanePlan(fig, { width: opts.width, minText: opts.minText, padding: opts.padding ?? 24, floor });
915
- }
916
860
  /** One self-contained animated SVG string for the figure. Needs no React and no browser. */
917
861
  export function toSvg(fig, opts = {}) {
918
862
  return render(fig, opts).svg;
919
863
  }
920
- /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. A `Finding` has a `rule` name, a `severity`, the ids it names and a message. */
864
+ /** Every fault `flowfig check` knows about, for this figure as the SVG lays it out. */
921
865
  export function check(fig, opts = {}) {
922
- const lost = (planFor(fig, opts)?.lost ?? []).map((id) => ({
923
- rule: 'lane-end-block',
924
- severity: 'warning',
925
- ids: [id],
926
- message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
927
- }));
928
- return [...checkSpec(fig), ...checkScene(render(fig, opts).scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
866
+ return checkRendered(fig, opts, render(fig, opts).scene);
929
867
  }
package/dist/text.d.ts CHANGED
@@ -1,4 +1,2 @@
1
- /** The width of `s` in px at `fontSize`. */
2
1
  export declare function textWidth(s: string, fontSize: number, mono?: boolean): number;
3
- /** Break a string into lines that fit `width`, keeping the newlines it already has. */
4
2
  export declare function wrap(s: string, width: number, fontSize: number, mono?: boolean): string[];
package/dist/text.js CHANGED
@@ -1,17 +1,14 @@
1
- // Text width without a browser: the SVG layout and `flowfig check` both run in plain node. Each character counts by its class in
2
- // the system sans-serif stack. Monospace is exact at 0.6em. The React player measures real text instead.
1
+ // Estimated widths: no browser here. The React player measures real text.
3
2
  const FULL = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|\p{Extended_Pictographic}/u;
4
3
  const NARROW = /[ilj.,:;'|!()[\]ftrI ]/;
5
4
  const WIDE = /[WMmw@%]/;
6
5
  const TALL = /[A-Z0-9#&?]/;
7
- /** The width of `s` in px at `fontSize`. */
8
6
  export function textWidth(s, fontSize, mono = false) {
9
7
  let em = 0;
10
8
  for (const ch of s)
11
9
  em += FULL.test(ch) ? 1 : mono ? 0.6 : WIDE.test(ch) ? 0.85 : NARROW.test(ch) ? 0.3 : TALL.test(ch) ? 0.64 : 0.53;
12
10
  return em * fontSize;
13
11
  }
14
- /** Break a string into lines that fit `width`, keeping the newlines it already has. */
15
12
  export function wrap(s, width, fontSize, mono = false) {
16
13
  const out = [];
17
14
  for (const para of s.split('\n')) {
package/dist/verify.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { FlowProps } from './model.ts';
2
2
  import type { Finding } from './scene.ts';
3
3
  export { parseSource, headingSlug, owners, links, type Link } from './source.ts';
4
- /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
4
+ /** The links against the files under `root`. A path outside `root` counts as a missing file. */
5
5
  export declare function verify(fig: FlowProps, { root }?: {
6
6
  root?: string;
7
7
  }): Finding[];
package/dist/verify.js CHANGED
@@ -1,10 +1,8 @@
1
- // `flowfig verify`: checks that the code a link names is still there. The link parsing lives in source.ts, which has no Node import.
2
1
  import { readFileSync, statSync } from 'node:fs';
3
2
  import { isAbsolute, relative, resolve, sep } from 'node:path';
4
3
  import { headingSlug, links } from './source.js';
5
4
  export { parseSource, headingSlug, owners, links } from './source.js';
6
5
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
7
- /** The file text, or null for a missing path, a folder or an unreadable file. */
8
6
  function read(full) {
9
7
  try {
10
8
  return statSync(full).isFile() ? readFileSync(full, 'utf8') : null;
@@ -13,12 +11,11 @@ function read(full) {
13
11
  return null;
14
12
  }
15
13
  }
16
- /** True if a heading line of the Markdown text has this slug. */
17
14
  const hasHeading = (text, slug) => text.split('\n').some((line) => {
18
15
  const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
19
16
  return m != null && headingSlug(m[1]) === slug;
20
17
  });
21
- /** The links against the files under `root`. A path outside the root is a missing file: the figure names the repo, not the disk. */
18
+ /** The links against the files under `root`. A path outside `root` counts as a missing file. */
22
19
  export function verify(fig, { root = process.cwd() } = {}) {
23
20
  const out = [];
24
21
  const all = links(fig);
package/dist/version.d.ts CHANGED
@@ -1,2 +1 @@
1
- /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export declare const VERSION = "0.6.0";
1
+ export declare const VERSION = "0.7.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- /** The package version, for the MCP `serverInfo`. The release list bumps it with package.json. */
2
- export const VERSION = '0.6.0';
1
+ // The release list bumps this with package.json.
2
+ export const VERSION = '0.7.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.6.0",
3
+ "version": "0.7.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks them. One animated SVG for a GitHub README, PR or issue, and a React player. Set up Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro with npx flowfig init.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -58,7 +58,8 @@
58
58
  "format:check": "prettier --check .",
59
59
  "svg": "node scripts/figure-svg.mjs",
60
60
  "test": "npm run build && node --test src/*.test.ts",
61
- "typecheck": "tsc --noEmit"
61
+ "typecheck": "tsc --noEmit",
62
+ "lint": "oxlint src scripts"
62
63
  },
63
64
  "peerDependencies": {
64
65
  "react": ">=18"
@@ -71,6 +72,7 @@
71
72
  "@types/react": "^19.0.0",
72
73
  "@types/react-dom": "^19.0.0",
73
74
  "@vitejs/plugin-react": "^6.1.1",
75
+ "oxlint": "1.86.0",
74
76
  "playwright": "^1.50.0",
75
77
  "prettier": "^3.7.4",
76
78
  "react": "^19.0.0",