flowfig 0.9.0 → 0.10.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/model.js CHANGED
@@ -451,7 +451,7 @@ export const counts = (p) => {
451
451
  messages: st.reduce((n, s) => n + s.flow.reduce((m, b) => m + toBeat(b).hops.length, 0), 0),
452
452
  };
453
453
  };
454
- const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
454
+ export const idsIn = (c) => (isGroup(c) ? [...(c.id ? [c.id] : []), ...c.children.flatMap(idsIn)] : [c.id]);
455
455
  const LINE_CLEAR = 16;
456
456
  export function groupGap(g, edges) {
457
457
  const side = new Map();
@@ -503,3 +503,27 @@ export const altText = (fig) => {
503
503
  desc: [`The figure has these boxes: ${boxes.join(', ')}.`, ...lines].join('\n'),
504
504
  };
505
505
  };
506
+ const keyOf = (v, path) => {
507
+ if (typeof v === 'function' || typeof v === 'symbol')
508
+ return undefined;
509
+ if (typeof v === 'bigint')
510
+ return `${v}n`;
511
+ if (typeof v !== 'object' || v === null)
512
+ return v;
513
+ if (path.includes(v))
514
+ return '[cycle]';
515
+ const at = [...path, v];
516
+ if (Array.isArray(v))
517
+ return v.map((x) => keyOf(x, at));
518
+ const el = v;
519
+ const proto = Object.getPrototypeOf(v);
520
+ if (!el.$$typeof && proto !== Object.prototype && proto !== null)
521
+ return '[object]';
522
+ const src = el.$$typeof ? { type: typeof el.type === 'string' ? el.type : el.type?.name, key: el.key, props: el.props } : v;
523
+ return Object.fromEntries(Object.entries(src).flatMap(([k, x]) => (k === 'ref' || k.startsWith('_') ? [] : [[k, keyOf(x, at)]])));
524
+ };
525
+ export const specKey = (...parts) => JSON.stringify(keyOf(parts, []));
526
+ export const fitScale = (wide, box, map) => Math.max(wide ? 1 : 0.5, Math.min(1, box / map));
527
+ const PHONE = 480;
528
+ const folds = (box, map) => box < map / 2 || (box < PHONE && box < map);
529
+ export const nextWide = (wide, box, map) => (folds(box, wide || map) ? wide || map : 0);
package/dist/open.d.ts CHANGED
@@ -7,6 +7,7 @@ export declare function openerFor(platform: NodeJS.Platform, file: string): Open
7
7
  /** The temp page name: each character other than a letter, digit, - or _ becomes -. */
8
8
  export declare function pageName(svgPath: string): string;
9
9
  export declare const openedLine: (page: string) => string;
10
+ export declare function openFile(path: string): Promise<void>;
10
11
  /** Write the page to a new temp folder, then start the opener. Returns the written paths. */
11
12
  export declare function openSvg(svgPath: string, html?: string): Promise<string[]>;
12
13
  export declare function runOpen(argv: string[]): Promise<number>;
package/dist/open.js CHANGED
@@ -20,6 +20,16 @@ export function pageName(svgPath) {
20
20
  .replace(/[^\w-]/g, '-') + '.html');
21
21
  }
22
22
  export const openedLine = (page) => `${page} — opened in the default browser`;
23
+ export async function openFile(path) {
24
+ const o = process.env.FLOWFIG_OPENER
25
+ ? { cmd: process.env.FLOWFIG_OPENER, args: [path], verbatim: false }
26
+ : openerFor(process.platform, path);
27
+ await new Promise((resolve, reject) => {
28
+ const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
29
+ child.once('spawn', () => (child.unref(), resolve()));
30
+ child.once('error', (e) => reject(new Error(`${e.message}. Open ${path} in a browser.`)));
31
+ });
32
+ }
23
33
  /** Write the page to a new temp folder, then start the opener. Returns the written paths. */
24
34
  export async function openSvg(svgPath, html) {
25
35
  const page = pageHtml(readFileSync(svgPath, 'utf8'), basename(svgPath));
@@ -30,14 +40,7 @@ export async function openSvg(svgPath, html) {
30
40
  writeFileSync(temp, page);
31
41
  if (html)
32
42
  writeFileSync(html, page);
33
- const o = process.env.FLOWFIG_OPENER
34
- ? { cmd: process.env.FLOWFIG_OPENER, args: [temp], verbatim: false }
35
- : openerFor(process.platform, temp);
36
- await new Promise((resolve, reject) => {
37
- const child = spawn(o.cmd, o.args, { detached: true, windowsHide: true, stdio: 'ignore', windowsVerbatimArguments: o.verbatim });
38
- child.once('spawn', () => (child.unref(), resolve()));
39
- child.once('error', (e) => reject(new Error(`${e.message}. Open ${temp} in a browser.`)));
40
- });
43
+ await openFile(temp);
41
44
  return html ? [temp, html] : [temp];
42
45
  }
43
46
  export async function runOpen(argv) {
package/dist/page.d.ts CHANGED
@@ -1 +1,2 @@
1
- export declare function pageHtml(svg: string, title: string): string;
1
+ export declare const esc: (s: string) => string;
2
+ export declare function pageHtml(svg: string, title: string, before?: string, after?: string, head?: string): string;
package/dist/page.js CHANGED
@@ -1,14 +1,15 @@
1
- const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2
- export function pageHtml(svg, title) {
1
+ export const esc = (s) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
2
+ export function pageHtml(svg, title, before = '', after = '', head = '') {
3
3
  // Use element selectors only: figure styles use class names.
4
4
  return `<!doctype html>
5
5
  <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
7
  <title>${esc(title)}</title>
7
- <style>
8
+ ${head}<style>
8
9
  :root { color-scheme: light dark }
9
10
  body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: Canvas }
10
11
  svg { display: block; max-width: 100%; height: auto }
11
12
  </style>
12
- ${svg}
13
+ ${before}${svg}${after}
13
14
  `;
14
15
  }
package/dist/svg.d.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  import { type CheckOptions } from './check.ts';
2
2
  import type { Finding, Scene } from './scene.ts';
3
+ import type { DiffMarks } from './diff.ts';
3
4
  export type { CheckOptions } from './check.ts';
4
5
  export type { Finding, Scene } from './scene.ts';
5
6
  export type * from './model.ts';
6
7
  export { parseSource, links, type Link } from './source.ts';
7
- export { diff, formatDiff, type Change } from './diff.ts';
8
+ export { diff, formatDiff, mergeFigures, type Change, type DiffMark, type DiffMarks } from './diff.ts';
8
9
  import { type FigTheme, type FlowProps } from './model.ts';
9
10
  export { LIGHT, DARK } from './model.ts';
10
11
  /** Options for `toSvg`, `render` and `check`. */
@@ -18,9 +19,13 @@ export type SvgOptions = {
18
19
  /** Lanes: page width and smallest text size that set the wrap. Default: 830 and 10. */
19
20
  width?: number;
20
21
  minText?: number;
22
+ /** Draws a still diff with a legend. `mergeFigures` makes the marks. */
23
+ marks?: DiffMarks;
24
+ /** Links a box to an href, by box id. `flowfig atlas` sets it. */
25
+ links?: Record<string, string>;
21
26
  };
22
27
  /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
23
- export declare function render(fig: FlowProps, opts?: SvgOptions): {
28
+ export declare function render(spec: FlowProps, opts?: SvgOptions): {
24
29
  svg: string;
25
30
  scene: Scene;
26
31
  };
package/dist/svg.js CHANGED
@@ -1,14 +1,16 @@
1
1
  // Wrapping is approximate: text is measured by character class, not by a browser.
2
- import { arcRoom, avoidOf, route } from './geometry.js';
2
+ import { arcRoom, avoidOf, markSide, route } from './geometry.js';
3
3
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
4
4
  import { textWidth, wrap } from './text.js';
5
5
  import { checkRendered, planFor } from './check.js';
6
+ import { autoLayout, rowBreaks } from './auto.js';
6
7
  export { parseSource, links } from './source.js';
7
- export { diff, formatDiff } from './diff.js';
8
+ export { diff, formatDiff, mergeFigures } from './diff.js';
8
9
  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, LABEL_LINE, itemWidth, labelLines, fitCap, diamondLines, diamondRoom, isLanesLayout, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, altText, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, labelSpan, outsideLabelRect, edgeTip, } from './model.js';
9
10
  export { LIGHT, DARK } from './model.js';
10
11
  const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
11
12
  const SUB_LINE = 15;
13
+ const MIN_W = 320;
12
14
  const FRAME_TOP = 37, FRAME_BOTTOM = 18;
13
15
  // The card-on fills approximate the player's 8 % accent tint.
14
16
  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));`;
@@ -159,13 +161,39 @@ function place(item, x, y, s, out) {
159
161
  }
160
162
  }
161
163
  }
164
+ const MARK_COLOR = { added: TONES.green, removed: TONES.red, changed: TONES.orange };
165
+ const marked = (mark, inner) => mark === 'removed' ? `<g opacity="0.5" stroke-dasharray="5 4">${inner}</g>` : mark ? `<g stroke-width="2">${inner}</g>` : inner;
166
+ function legendOf(m) {
167
+ const count = (kind) => Object.values(m.boxes).filter((k) => k === kind).length + Object.values(m.edges).filter((k) => k === kind).length;
168
+ const items = ['added', 'removed', 'changed'].filter(count).map((k) => ({ color: MARK_COLOR[k], text: `${k} ${count(k)}` }));
169
+ if (m.steps)
170
+ items.push({ color: 'none', text: 'steps: see the text diff' });
171
+ return items.length ? items : [{ color: 'none', text: 'no change in boxes and edges' }];
172
+ }
173
+ const legendWidths = (items) => items.map((i) => textWidth(i.text, 12) + (i.color === 'none' ? 0 : 16));
174
+ const legendWidth = (items) => legendWidths(items).reduce((a, b) => a + b + 20, -20);
175
+ function legendSvg(items, W, H) {
176
+ if (!items.length)
177
+ return '';
178
+ const widths = legendWidths(items);
179
+ let x = (W - legendWidth(items)) / 2;
180
+ return items
181
+ .map((i, k) => {
182
+ const sq = i.color === 'none' ? '' : `<rect x="${n2(x)}" y="${n2(H - 24)}" width="10" height="10" rx="2" fill="${i.color}"/>`;
183
+ const out = `${sq}<text x="${n2(x + (i.color === 'none' ? 0 : 16))}" y="${n2(H - 15)}" font-size="12" fill="var(--muted)">${esc(i.text)}</text>\n`;
184
+ x += widths[k] + 20;
185
+ return out;
186
+ })
187
+ .join('');
188
+ }
162
189
  const SYSTEM_FONT = "-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif";
163
190
  /** The SVG string and the scene it drew. The scene shape may change; use `toSvg` for the SVG only. */
164
- export function render(fig, opts = {}) {
191
+ export function render(spec, opts = {}) {
192
+ const fig = autoLayout(spec);
165
193
  const speed = (opts.speed ?? fig.speed ?? 900) / 1000 / BASE_RATE;
166
194
  const pad = opts.padding ?? 24;
167
195
  const tl = fig.timeline && isLanesLayout(fig.layout) ? timelineLayout(fig, TL_AXIS_W) : null;
168
- const synthetic = tl != null && !fig.steps?.length;
196
+ const synthetic = tl != null && !fig.steps?.length && !opts.marks;
169
197
  const steps = synthetic ? timelineBeats(fig) : (fig.steps ?? []);
170
198
  const beats = steps.map((s) => s.flow.map(toBeat));
171
199
  const cards = new Map();
@@ -233,6 +261,7 @@ export function render(fig, opts = {}) {
233
261
  to: end(ids[i], e.to, false),
234
262
  around: e.around ?? (plan?.around.has(ids[i]) ? 'below' : undefined),
235
263
  ...(tl && { sides: ['r', 'l'], elbow: true }),
264
+ ...(rowBreaks.has(e) && { drop: true }),
236
265
  ...(stubs.has(ids[i]) && { stub: stubs.get(ids[i]).map(labelPillW), bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)] }),
237
266
  ...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
238
267
  })), rects, tips, avoidOf(placed.filter((p) => !isGroup(p.item)), stubs.size
@@ -451,8 +480,13 @@ export function render(fig, opts = {}) {
451
480
  cls(p.lane && 'lane', anim(on, 'stroke: var(--accent)', 'stroke: var(--border)', 'n')) +
452
481
  `/><text x="${n2(p.x + FRAME_SIDE)}" y="${n2(p.lane ? p.y + p.h / 2 + 4 : p.y + 20)}" class="frame">${esc(str(item.label).toUpperCase())}</text>`);
453
482
  }
454
- const tip = (inner) => (item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner);
455
- const bt = item.tone && TONES[item.tone];
483
+ const diffMark = opts.marks?.boxes[item.id];
484
+ const href = opts.links?.[item.id];
485
+ const tip = (inner) => {
486
+ const body = marked(diffMark, item.source ? `<g><title>${esc(item.source)}</title>${inner}</g>` : inner);
487
+ return href == null ? body : `<a href="${esc(href)}" tabindex="-1">${body}</a>`;
488
+ };
489
+ const bt = diffMark ? MARK_COLOR[diffMark] : item.tone && TONES[item.tone];
456
490
  const stroke = cls(boxAnim(item.id, true, bt));
457
491
  if (p.tl) {
458
492
  const fill0 = bt ? toneTint(bt, 'var(--bg)') : 'var(--bg)';
@@ -490,11 +524,13 @@ export function render(fig, opts = {}) {
490
524
  ? `<text x="${n2(cx)}" y="${n2(firstY + extra + SUB_LINE)}" class="sub">${esc(str(item.sub))}</text>`
491
525
  : '';
492
526
  const dot = bt ?? 'var(--accent)';
493
- const my = n2(p.y + p.h / 2);
527
+ const side = item.mark && markSide(item.id, item.mark, routed);
528
+ const mx = n2(side === 'l' ? p.x - 12 : side === 'r' ? p.x + p.w + 12 : cx);
529
+ const my = n2(side === 't' ? p.y - 12 : side === 'b' ? p.y + p.h + 12 : p.y + p.h / 2);
494
530
  const mark = item.mark === 'start'
495
- ? `<circle cx="${n2(p.x - 12)}" cy="${my}" r="5" fill="${dot}"/>`
531
+ ? `<circle cx="${mx}" cy="${my}" r="5" fill="${dot}"/>`
496
532
  : item.mark === 'end'
497
- ? `<circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${n2(p.x + p.w + 12)}" cy="${my}" r="4" fill="${dot}"/>`
533
+ ? `<circle cx="${mx}" cy="${my}" r="6.25" fill="none" stroke="${dot}" stroke-width="1.5"/><circle cx="${mx}" cy="${my}" r="4" fill="${dot}"/>`
498
534
  : '';
499
535
  return tip(shape + label + sub + mark + (contents ? card(p, cardTop, contents) : ''));
500
536
  });
@@ -562,18 +598,21 @@ export function render(fig, opts = {}) {
562
598
  const lit = cls(toned
563
599
  ? frames(on.map((x, i) => (x ? `stroke: ${col[i]}; stroke-width: ${EDGE_ON}` : off)), 'e', off)
564
600
  : anim(on, `stroke: var(--accent); stroke-width: ${EDGE_ON}`, off, 'e'));
601
+ const em = opts.marks?.edges[r.id];
602
+ const line = em && (em !== 'changed' || e.label == null) ? MARK_COLOR[em] : 'var(--muted)';
565
603
  const tip = edgeTip(r.id, e.source, beats.flat());
566
604
  const path0 = r.stub
567
605
  ? r.stub.parts
568
- .map((d) => `<path d="${d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
606
+ .map((d) => `<path d="${d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`)
569
607
  .join('') + `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="none"/>`
570
- : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="var(--muted)" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
608
+ : `<path id="p-${esc(r.id)}" d="${r.d}" fill="none" stroke="${line}" stroke-width="${EDGE_OFF}" marker-end="url(#arrow)"${lit}/>`;
571
609
  const path = tip ? `<g><title>${esc(tip)}</title>${path0}</g>` : path0;
572
- 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)"` +
610
+ const pillColor = em ? MARK_COLOR[em] : undefined;
611
+ const pill = (x, y, lw, text) => `<rect x="${n2(x - lw / 2)}" y="${n2(y - 9)}" width="${n2(lw)}" height="18" rx="9" fill="${pillColor ? toneTint(pillColor, 'var(--bg)', 14) : 'var(--bg)'}" stroke="${pillColor ?? 'var(--border)'}"` +
573
612
  cls(toned
574
613
  ? 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)')
575
614
  : anim(on, 'fill: var(--accent); stroke: var(--accent)', 'fill: var(--bg); stroke: var(--border)', 'l')) +
576
- `/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}>${esc(text)}</text>`;
615
+ `/><text x="${n2(x)}" y="${n2(y + 4)}"${cls('edgelabel', anim(on, `fill: ${ON_ACCENT}`, 'fill: var(--muted)', 'x'))}${pillColor ? ' style="fill: var(--fg)"' : ''}>${esc(text)}</text>`;
577
616
  let label = '';
578
617
  if (r.stub) {
579
618
  fonts.push(11);
@@ -586,7 +625,7 @@ export function render(fig, opts = {}) {
586
625
  fonts.push(11);
587
626
  label = pill(r.mid.x, r.mid.y, lw, str(e.label));
588
627
  }
589
- return hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label;
628
+ return marked(em === 'removed' ? em : undefined, hidden ? `<g opacity="0"${shown}>${path}${label}</g>` : path + label);
590
629
  });
591
630
  const packets = hops.map((h, i) => {
592
631
  const t0 = h.t0 / total, t1 = h.t1 / total, tEnd = h.tEnd / total;
@@ -683,16 +722,21 @@ export function render(fig, opts = {}) {
683
722
  }
684
723
  const bounds = placed[0];
685
724
  const [arcT, arcB, arcL, arcR] = ['above', 'below', 'left', 'right'].map((w) => arcRoom(routed, w));
686
- const capLines = [...new Set(captions)].flatMap((c) => wrap(c, Math.max(560, bounds.w), 13.5).length);
687
- const mapW = Math.max(bounds.w + pad * 2 + arcL + arcR, 560);
725
+ const legend = opts.marks ? legendOf(opts.marks) : [];
726
+ const stepLabels = fig.rail || synthetic ? [] : steps.map((s) => textWidth(str(s.label), 13, true));
727
+ const floor = Math.max(MIN_W, ...[...stepLabels, legend.length ? legendWidth(legend) : 0].map((w) => w + 16));
728
+ const mapW = Math.max(bounds.w + pad * 2 + arcL + arcR, floor);
688
729
  const rail = fig.rail ? layoutRail(fig, fig.rail === 'only' ? 560 : mapW) : null;
689
730
  const only = fig.rail === 'only' && rail != null;
731
+ const wrapW = Math.max(only ? 560 : MIN_W, bounds.w);
732
+ const capLines = [...new Set(captions)].flatMap((c) => wrap(c, wrapW, 13.5).length);
690
733
  const capTop = rail ? 20 : 26;
691
734
  const capH = steps.length ? capTop + 4 + Math.max(0, ...capLines) * 20 : 0;
692
- const W = only ? Math.max(560, rail.width) : Math.max(mapW, rail?.width ?? 0);
735
+ const W = only ? Math.max(560, floor, rail.width) : Math.max(mapW, rail?.width ?? 0);
693
736
  const mapH = only ? 0 : bounds.h + pad * 2 + arcT + arcB;
694
737
  const top = only ? pad : mapH + RAIL.gap;
695
- const H = only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH;
738
+ const legendH = opts.marks ? 34 : 0;
739
+ const H = (only ? pad + rail.height + capH : mapH + (rail ? RAIL.gap + rail.height : 0) + capH) + legendH;
696
740
  const shift = (W - (bounds.w + pad * 2 + arcL + arcR)) / 2 + arcL;
697
741
  const railX = rail ? (W - rail.width) / 2 : 0;
698
742
  const labels = rail || synthetic
@@ -703,7 +747,7 @@ export function render(fig, opts = {}) {
703
747
  });
704
748
  const said = [...new Set(captions)].map((text) => {
705
749
  const on = captions.map((c) => c === text);
706
- const lines = wrap(text, Math.max(560, bounds.w), 13.5);
750
+ const lines = wrap(text, wrapW, 13.5);
707
751
  return (`<g opacity="0"${cls(anim(on, 'opacity: 1', 'opacity: 0', 'y'))}>` +
708
752
  lines.map((l, li) => `<text x="${n2(W / 2)}" y="${n2(H - capH + capTop + 16 + li * 20)}" class="caption">${esc(l)}</text>`).join('') +
709
753
  `</g>`);
@@ -828,10 +872,12 @@ ${todaySvg}
828
872
  `}${railSvg}
829
873
  ${labels.join('\n')}
830
874
  ${said.join('\n')}
831
- </svg>
875
+ ${legendSvg(legend, W, H)}</svg>
832
876
  `;
833
877
  if (rail)
834
878
  fonts.push(12, 13);
879
+ if (legend.length)
880
+ fonts.push(12);
835
881
  const scene = {
836
882
  width: n2(W),
837
883
  ...(!only && { area: { x: -shift, y: -arcT, w: W, h: mapH } }),
package/dist/trace.js CHANGED
@@ -258,11 +258,18 @@ export function trace(start, { root = process.cwd(), depth = 2, max = 40, read =
258
258
  }
259
259
  const count = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
260
260
  export function traceLines(r, depth, max) {
261
- const pairs = r.edges.map((e) => `${e.from} -> ${e.to}`);
262
- const width = Math.max(0, ...pairs.map((p) => p.length));
261
+ const file = (id) => id.slice(0, id.indexOf('#'));
262
+ const lineNo = (e) => (e.at.startsWith(`${file(e.from)}:`) ? e.at.slice(file(e.from).length + 1) : e.at);
263
+ const width = Math.max(0, ...r.edges.map((e) => lineNo(e).length));
263
264
  const symbols = new Set([r.start, ...r.edges.map((e) => e.to)]).size;
265
+ let last = '';
264
266
  return [
265
- ...r.edges.map((e, i) => `${pairs[i].padEnd(width)} ${e.at}`),
267
+ ...r.edges.flatMap((e) => {
268
+ const head = e.from === last ? [] : [e.from];
269
+ last = e.from;
270
+ const to = file(e.to) === file(e.from) ? e.to.slice(e.to.indexOf('#') + 1) : e.to;
271
+ return [...head, ` ${lineNo(e).padEnd(width)} ${to}`];
272
+ }),
266
273
  ...r.unsure.map((u) => `unsure ${u.at} ${u.reason}`),
267
274
  ...r.open.map((o) => `open ${o.at} ${o.reason}`),
268
275
  ...r.stops.map((s) => `stop ${s.reason} ${s.reason === 'depth' ? depth : max}: ${count(s.count, 'symbol')} not followed`),
package/dist/verify.d.ts CHANGED
@@ -16,6 +16,8 @@ export type Coverage = {
16
16
  };
17
17
  export declare const coverageLine: (figure: string, c: Coverage) => string;
18
18
  export declare const unsureLines: (c: Coverage) => string[];
19
+ export declare const SPEC_MARK = "<metadata id=\"figure-spec\">";
20
+ export declare function detailFindings(fig: FlowProps, root: string, read?: Read): Finding[];
19
21
  /** The links against the files under `root`. A path outside `root` counts as a missing file. */
20
22
  export declare function verifyReport(fig: FlowProps, { root, read, cache, }?: {
21
23
  root?: string;
package/dist/verify.js CHANGED
@@ -3,8 +3,25 @@ import { edgeId, nodes, toBeat } from './model.js';
3
3
  import { headingSlug, links } from './source.js';
4
4
  import { codeFile, isDefined, outside, readFile } from './code.js';
5
5
  import { edgeResult } from './edges.js';
6
+ import { reexports } from './imports.js';
6
7
  export { parseSource, headingSlug, owners, links } from './source.js';
7
8
  const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
9
+ const defines = (root, file, symbol, read, cache, depth = 0) => {
10
+ if (!file || depth > 5)
11
+ return null;
12
+ if (isDefined(file, symbol))
13
+ return true;
14
+ let doubt = false;
15
+ for (const [path, inner] of reexports(root, file, symbol, read)) {
16
+ const target = path == null || inner === '?' ? null : codeFile(root, path, read, cache);
17
+ const r = inner === '*' ? (target ? true : null) : defines(root, target, inner, read, cache, depth + 1);
18
+ if (r)
19
+ return true;
20
+ if (r == null)
21
+ doubt = true;
22
+ }
23
+ return doubt ? null : false;
24
+ };
8
25
  const hasHeading = (text, slug) => text.split('\n').some((line) => {
9
26
  const m = /^#{1,6}\s+(.*?)\s*#*\s*$/.exec(line);
10
27
  return m != null && headingSlug(m[1]) === slug;
@@ -15,14 +32,27 @@ export const coverageLine = (figure, c) => `${figure}: ${c.boxesDefined} of ${c.
15
32
  ? `; edges: ${c.found} found, ${c.notFound} not found, ${c.unsure} unsure, ${c.notChecked} not checked`
16
33
  : '');
17
34
  export const unsureLines = (c) => c.unsureEdges.map((e) => `unsure edge "${e.id}": ${e.reason}`);
35
+ export const SPEC_MARK = '<metadata id="figure-spec">';
36
+ export function detailFindings(fig, root, read = readFile) {
37
+ return nodes(fig.layout).flatMap((n) => {
38
+ if (typeof n.detail !== 'string')
39
+ return [];
40
+ const text = outside(root, n.detail) ? null : read(resolve(root, n.detail));
41
+ const why = text == null ? 'file not found' : text.includes(SPEC_MARK) ? '' : 'no figure spec in the file';
42
+ return why
43
+ ? [{ rule: 'missing-detail', severity: 'warning', ids: [n.id], message: `box "${n.id}" -> ${n.detail}: ${why}` }]
44
+ : [];
45
+ });
46
+ }
18
47
  /** The links against the files under `root`. A path outside `root` counts as a missing file. */
19
48
  export function verifyReport(fig, { root = process.cwd(), read = readFile, cache = new Map(), } = {}) {
20
49
  const findings = [];
21
50
  const coverage = emptyCoverage();
51
+ findings.push(...detailFindings(fig, root, read));
22
52
  const all = links(fig);
23
53
  if (!all.length)
24
54
  return {
25
- findings: [{ rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
55
+ findings: [...findings, { rule: 'no-source', severity: 'warning', ids: [], message: 'no box, edge or hop has a source' }],
26
56
  coverage,
27
57
  };
28
58
  const files = new Map();
@@ -32,14 +62,17 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache
32
62
  files.set(full, outside(root, l.path) ? null : read(full));
33
63
  const text = files.get(full);
34
64
  const before = findings.length;
65
+ let doubt = false;
35
66
  const fail = (rule, what) => findings.push({ rule, severity: 'error', ids: [], message: `${l.owner} -> ${l.source}: ${what}` });
36
67
  if (text == null)
37
68
  fail('missing-file', 'file not found');
38
69
  else if (l.symbol) {
39
70
  const code = codeFile(root, l.path, read, cache);
40
71
  if (code) {
41
- if (!isDefined(code, l.symbol))
72
+ const r = defines(root, code, l.symbol, read, cache);
73
+ if (r === false)
42
74
  fail('missing-symbol', 'symbol not defined');
75
+ doubt = r == null;
43
76
  }
44
77
  else if (!new RegExp(`(^|[^\\w$])${escape(l.symbol)}(?![\\w$])`).test(text) &&
45
78
  !(/\.(md|markdown)$/i.test(l.path) && hasHeading(text, l.symbol)))
@@ -47,7 +80,7 @@ export function verifyReport(fig, { root = process.cwd(), read = readFile, cache
47
80
  }
48
81
  if (l.owner.startsWith('box "')) {
49
82
  coverage.boxes++;
50
- if (findings.length === before)
83
+ if (findings.length === before && !doubt)
51
84
  coverage.boxesDefined++;
52
85
  }
53
86
  }
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.9.0";
1
+ export declare const VERSION = "0.10.0";
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
1
  // The release list bumps this with package.json.
2
- export const VERSION = '0.9.0';
2
+ export const VERSION = '0.10.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Your coding agent draws animated diagrams from your code, and flowfig checks each box and arrow against that code. CI fails when a diagram drifts from the code. The output is one animated SVG with no script, for a README, PR or issue. Works with Claude Code, Cursor, GitHub Copilot, Codex, Gemini CLI, Windsurf and Kiro.",
5
5
  "license": "MIT",
6
6
  "repository": {