flowfig 0.10.0 → 0.11.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/README.md CHANGED
@@ -65,7 +65,7 @@ jobs:
65
65
  - uses: actions/setup-node@v4
66
66
  with:
67
67
  node-version: 22
68
- - uses: iamalvisng/flowfig@v0.10.0
68
+ - uses: iamalvisng/flowfig@v0.11.0
69
69
  with:
70
70
  figures: 'docs/**/*.svg' # default **/*.svg
71
71
  # coverage: true # also fail the job if a figure is stale
@@ -530,7 +530,7 @@ jobs:
530
530
  - uses: actions/setup-node@v4
531
531
  with:
532
532
  node-version: 22
533
- - uses: iamalvisng/flowfig@v0.10.0
533
+ - uses: iamalvisng/flowfig@v0.11.0
534
534
  with:
535
535
  figures: 'docs/**/*.svg' # default **/*.svg
536
536
  ```
@@ -850,8 +850,10 @@ The player has these controls:
850
850
  - A hover on a box, an edge or a rail row with a `source` shows the `source`. The SVG shows it as a tooltip too.
851
851
 
852
852
  The player props are the spec fields. `theme` sets the colors, `speed` sets the packet time, and `autoplay={false}` stops the
853
- auto start. `check` runs the check rules. If a figure is wider than its container, the player shrinks the figure to half size at
854
- most, then scrolls. If the reader asks for reduced motion, the player shows the last beat and does not move packets.
853
+ auto start. `check` runs the check rules. If a figure is wider than its container, the player changes the figure to fit.
854
+ In a box under 600 px, the player keeps reader text at 11 px or more. If the map does not fit, the player draws the map as a column.
855
+ In a box of 600 px or more, the player folds the map only when text would fall under 9 px.
856
+ A lanes map or a timeline map stops at that size and scrolls sideways in its pane. If the reader asks for reduced motion, the player shows the last beat and does not move packets.
855
857
 
856
858
  The player has no dark theme of its own. For dark mode, set the `--fig-*` CSS variables on `.flowfig`:
857
859
 
package/dist/auto.js CHANGED
@@ -3,6 +3,71 @@ const PAGE = 830, PAD = 24, SWEEPS = 8, TALL = 2.5,
3
3
  // A 20-box chain measures 105 px for each rank.
4
4
  RANK_H = 105, MIN_TEXT = 10;
5
5
  const flat = (it) => (isGroup(it) && it.label == null && it.id == null ? it.children.flatMap(flat) : [it]);
6
+ export const rowBreaks = new WeakSet();
7
+ const less = (p, q) => {
8
+ const k = p.findIndex((v, i) => v !== q[i]);
9
+ return k >= 0 && p[k] < q[k];
10
+ };
11
+ function arcFold(run, long, pos, carded, edges) {
12
+ const m = run.length;
13
+ const spans = long.map((e) => [pos.get(e.from), pos.get(e.to)].sort((p, q) => p - q));
14
+ const wide = new Map();
15
+ const width = (i, j) => wide.get(i * m + j) ?? wide.set(i * m + j, itemWidth({ direction: 'row', children: run.slice(i, j) }, carded, edges)).get(i * m + j);
16
+ let pick;
17
+ for (let code = 0; long.length <= 6 && code < 3 ** long.length; code++) {
18
+ const sides = spans.map((_, k) => Math.floor(code / 3 ** k) % 3);
19
+ const need = new Set(), full = new Set(), ban = new Set();
20
+ spans.forEach(([a, b], k) => {
21
+ if (!sides[k])
22
+ for (let c = a + 1; c <= b; c++)
23
+ ban.add(c);
24
+ else
25
+ for (const c of sides[k] === 1 ? [a + 1, b + 1] : [a, b]) {
26
+ need.add(c);
27
+ if (sides[k] === 1)
28
+ full.add(c);
29
+ }
30
+ });
31
+ const plans = [{ cost: [0, 0], from: -1 }];
32
+ for (let j = 1; j <= m; j++)
33
+ for (let i = j - 1; i >= Math.max(0, j - 4); i--) {
34
+ if ((i < j - 1 && need.has(i + 1)) || width(i, j) > PAGE - 2 * PAD)
35
+ break;
36
+ const prev = plans[i];
37
+ if (!prev || (i && ban.has(i)) || (full.has(j) && j - i < 4))
38
+ continue;
39
+ const cost = [prev.cost[0] + 1, Math.max(prev.cost[1], width(i, j))];
40
+ if (!plans[j] || less(cost, plans[j].cost))
41
+ plans[j] = { cost, from: i };
42
+ }
43
+ if (!plans[m] || (pick && !less(plans[m].cost, pick.cost)))
44
+ continue;
45
+ const cuts = [];
46
+ for (let j = m; j > 0; j = plans[j].from)
47
+ cuts.unshift(plans[j].from);
48
+ const line = (k) => cuts.findLastIndex((c) => c <= k);
49
+ const rowW = (r) => width(cuts[r], cuts[r + 1] ?? m);
50
+ const clash = spans.some(([a, b], k) => {
51
+ const [ra, rb] = [line(a), line(b)];
52
+ const inner = Array.from({ length: rb - ra + 1 }, (_, r) => rowW(ra + r));
53
+ return ((!sides[k] && ra > 0 && ra < cuts.length - 1) ||
54
+ (sides[k] === 1 && Math.min(rowW(ra), rowW(rb)) < Math.max(...inner)) ||
55
+ spans.some(([p, q], l) => l > k && sides[k] && sides[k] === sides[l] && a < q && p < b));
56
+ });
57
+ if (!clash)
58
+ pick = { cost: plans[m].cost, cuts, sides };
59
+ }
60
+ if (!pick)
61
+ return undefined;
62
+ const top = pick.cuts[1] ?? m;
63
+ long.forEach((e, k) => (e.around = [pos.get(e.from) < top ? 'above' : 'below', 'right', 'left'][pick.sides[k]]));
64
+ const rows = pick.cuts.map((c, k) => ({ direction: 'row', children: run.slice(c, pick.cuts[k + 1] ?? m) }));
65
+ const line = (id) => rows.findIndex((l) => idsIn(l).includes(id));
66
+ for (const e of edges)
67
+ if (!long.includes(e) && line(e.from) >= 0 && line(e.from) < line(e.to))
68
+ rowBreaks.add(e);
69
+ return { direction: 'column', align: 'start', children: rows };
70
+ }
6
71
  function seenOrder(fig) {
7
72
  const at = new Map();
8
73
  const byId = new Map(fig.edges.map((e) => [edgeId(e), e]));
@@ -206,18 +271,46 @@ function level(items, dir, ctx) {
206
271
  return rows;
207
272
  const out = [];
208
273
  let run = [];
209
- const lines = (per) => {
210
- const k = Math.ceil(run.length / per), q = Math.ceil(run.length / k);
211
- return Array.from({ length: k }, (_, i) => ({ direction: 'row', children: run.slice(i * q, (i + 1) * q) }));
274
+ let runAt = [];
275
+ const owner = new Map(via.flatMap((vs, i) => vs.map((v) => [v, pairs[i][2]])));
276
+ const lines = (part, per) => {
277
+ const k = Math.ceil(part.length / per), q = Math.ceil(part.length / k);
278
+ return Array.from({ length: k }, (_, i) => ({ direction: 'row', children: part.slice(i * q, (i + 1) * q) }));
279
+ };
280
+ const plain = (part) => {
281
+ const folded = part.length < 4
282
+ ? undefined
283
+ : [4, 3].map((per) => lines(part, per)).find((ls) => ls.every((l) => itemWidth(l, ctx.carded, ctx.edges) <= PAGE - 2 * PAD));
284
+ out.push(...(folded ?? part));
212
285
  };
213
286
  const flush = () => {
214
- const folded = run.length < 4 ? undefined : [4, 3].map(lines).find((ls) => ls.every((l) => itemWidth(l, ctx.carded, ctx.edges) <= PAGE - 2 * PAD));
215
- out.push(...(folded ?? run));
287
+ const pos = new Map(run.map((r, k) => [r.id, k]));
288
+ const long = ctx.edges.filter((e) => pos.has(e.from) && pos.has(e.to) && Math.abs(pos.get(e.from) - pos.get(e.to)) > 1);
289
+ const local = runAt.every((i) => kept[i].every((x) => x < n || long.includes(owner.get(x))));
290
+ const folded = run.length >= 4 && long.length && local ? arcFold(run, long, pos, ctx.carded, ctx.edges) : undefined;
291
+ if (folded)
292
+ out.push(folded);
293
+ else {
294
+ let part = [];
295
+ run.forEach((r, k) => {
296
+ if (kept[runAt[k]].length === 1)
297
+ part.push(r);
298
+ else {
299
+ plain(part);
300
+ part = [];
301
+ out.push(r);
302
+ }
303
+ });
304
+ plain(part);
305
+ }
216
306
  run = [];
307
+ runAt = [];
217
308
  };
218
309
  rows.forEach((r, i) => {
219
- if (kept[i].length === 1 && !isGroup(r))
310
+ if (kept[i].filter((x) => x < n).length === 1 && !isGroup(r)) {
220
311
  run.push(r);
312
+ runAt.push(i);
313
+ }
221
314
  else {
222
315
  flush();
223
316
  out.push(r);
@@ -243,7 +336,6 @@ function smallFont(f, carded) {
243
336
  return 12;
244
337
  return f.steps?.length ? 13.5 : 14;
245
338
  }
246
- export const rowBreaks = new WeakSet();
247
339
  function fold(f, carded) {
248
340
  const room = (PAGE * smallFont(f, carded)) / MIN_TEXT - 2 * PAD;
249
341
  if (f.rail || itemWidth(f.layout, carded, f.edges, fitCap(f, PAGE)) <= room)
@@ -268,8 +360,11 @@ function fold(f, carded) {
268
360
  if (!plans[j] || better(cost, plans[j].cost) < 0)
269
361
  plans[j] = { cost, from: i };
270
362
  }
271
- if (!plans[n] || plans[n].cost[0] < 2)
272
- return f;
363
+ if (!plans[n] || plans[n].cost[0] < 2) {
364
+ const long = f.edges.filter((e) => unit.has(e.from) && unit.has(e.to) && (e.around || Math.abs(unit.get(e.from) - unit.get(e.to)) > 1));
365
+ const layout = n >= 4 && long.some((e) => e.around) && !ranks.some(isGroup) ? arcFold(ranks, long, unit, carded, f.edges) : undefined;
366
+ return layout ? { ...f, layout } : f;
367
+ }
273
368
  const cuts = [];
274
369
  for (let j = n; j > 0; j = plans[j].from)
275
370
  cuts.unshift(plans[j].from);
package/dist/check.d.ts CHANGED
@@ -6,8 +6,9 @@ export declare function checkSpec(fig: FlowProps): Finding[];
6
6
  export type CheckOptions = {
7
7
  width?: number;
8
8
  minText?: number;
9
+ auto?: boolean;
9
10
  };
10
- export declare function checkScene(scene: Scene, { width, minText }?: CheckOptions): Finding[];
11
+ export declare function checkScene(scene: Scene, { width, minText, auto }?: CheckOptions): Finding[];
11
12
  /** The WCAG contrast ratio of two colors, rounded to 2 places; null if either cannot be read. */
12
13
  export declare function contrast(a: string, b: string): number | null;
13
14
  /** Text the reader cannot read: each text and background pair, in light, dark and any custom theme, needs 4.5:1 (WCAG AA). */
package/dist/check.js CHANGED
@@ -209,7 +209,7 @@ const overlap = (a, b, pad) => a.x + pad < b.x + b.w - pad && b.x + pad < a.x +
209
209
  const px = (n) => Math.round(n * 10) / 10;
210
210
  // Measured corner touches go 3.5 px deep; real crossings go 24 px or more.
211
211
  const TOUCH = 6;
212
- export function checkScene(scene, { width = 830, minText = 10 } = {}) {
212
+ export function checkScene(scene, { width = 830, minText = 10, auto } = {}) {
213
213
  const out = [];
214
214
  for (const b of scene.boxes)
215
215
  for (const t of b.texts) {
@@ -223,7 +223,7 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
223
223
  const pts = path(e);
224
224
  for (const b of scene.boxes)
225
225
  if (b.id !== e.from && b.id !== e.to && pts.some((p) => inside(p, b.rect, TOUCH)))
226
- out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}": move a box, or set "auto": true on the root layout`));
226
+ out.push(err('edge-crosses-box', [e.id, b.id], `edge "${e.id}" passes through box "${b.id}": move a box${auto ? '' : ', or set "auto": true on the root layout'}`));
227
227
  }
228
228
  for (const e of scene.edges) {
229
229
  const pts = path(e);
@@ -231,7 +231,7 @@ export function checkScene(scene, { width = 830, minText = 10 } = {}) {
231
231
  const ratio = drawn / Math.max(1, Math.hypot(pts.at(-1).x - pts[0].x, pts.at(-1).y - pts[0].y));
232
232
  // The README figures have a good edge at ratio 1.77 and 425 px.
233
233
  if (ratio >= 1.6 && drawn >= 600)
234
- out.push(warn('long-edge', [e.id], `edge "${e.id}" is ${px(ratio)} times the straight distance between its ends: put its ends in one row or column, route it with around, or set "auto": true on the root layout`));
234
+ out.push(warn('long-edge', [e.id], `edge "${e.id}" is ${px(ratio)} times the straight distance between its ends: put its ends in one row or column${auto ? ', or route it with around' : ', route it with around, or set "auto": true on the root layout'}`));
235
235
  }
236
236
  const pills = scene.edges.filter((e) => e.pts && e.label);
237
237
  const under = new Set();
@@ -383,5 +383,10 @@ export function checkRendered(fig, opts, scene) {
383
383
  ids: [id],
384
384
  message: `edge "${id}" ends at a lane that no block on its side shows; the edge uses the nearest block`,
385
385
  }));
386
- return [...checkSpec(fig), ...checkScene(scene, opts), ...lost, ...checkTheme({ ...fig.theme, ...opts.theme })];
386
+ return [
387
+ ...checkSpec(fig),
388
+ ...checkScene(scene, { ...opts, auto: opts.auto ?? fig.layout.auto }),
389
+ ...lost,
390
+ ...checkTheme({ ...fig.theme, ...opts.theme }),
391
+ ];
387
392
  }
package/dist/cli.js CHANGED
@@ -28,6 +28,7 @@ import { loadSpec, reportLines, sortFindings, specOf, summaryLines, svgWithSpec,
28
28
  import { check, render, toSvg } from './svg.js';
29
29
  import { checkRendered } from './check.js';
30
30
  import { coverageLine, links, parseSource, unsureLines, verifyReport } from './verify.js';
31
+ import { VERSION } from './version.js';
31
32
  import { trace, traceLines } from './trace.js';
32
33
  import { coverageLines, coverageReport } from './coverage.js';
33
34
  const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] render a figure; a spec on stdin with -
@@ -45,7 +46,8 @@ const USAGE = `usage: flowfig <-|spec.json|figure.ts> [out.svg] [--open] rende
45
46
  flowfig open <figure.svg> [--html <path>] show the figure in the default browser
46
47
  flowfig from-mermaid <file.mmd|file.md|-> [--out <dir>] convert Mermaid diagrams to specs
47
48
  flowfig gif <figure.svg> [out.gif] [--step <n>] [--dark] [--fps <n>] [--scale <n>] [--mp4] write an animated GIF
48
- flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)`;
49
+ flags for render and check: --strict (warnings are errors), --json, --width <px>, --min-text <px>, --no-check (render only), --no-verify (render only)
50
+ flowfig --version, -v print the version`;
49
51
  function usage(message) {
50
52
  console.error(message);
51
53
  process.exit(2);
@@ -73,6 +75,10 @@ if (!args[0] || ['help', '--help', '-h'].includes(args[0])) {
73
75
  usage(USAGE);
74
76
  process.exit(0);
75
77
  }
78
+ if (['--version', '-v'].includes(args[0])) {
79
+ console.log(VERSION);
80
+ process.exit(0);
81
+ }
76
82
  if (args[0] === 'docs') {
77
83
  const topic = args[1];
78
84
  if (topic && !Object.hasOwn(TOPICS, topic))
package/dist/index.js CHANGED
@@ -6,7 +6,7 @@ import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
6
  import { textWidth } from './text.js';
7
7
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
8
  import { autoLayout, rowBreaks } from './auto.js';
9
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, fitScale, specKey, nextWide, } from './model.js';
9
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, fitScale, shownFont, textFloor, specKey, nextWide, } from './model.js';
10
10
  export { LIGHT, DARK } from './model.js';
11
11
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
12
12
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -62,11 +62,18 @@ export function Flow(props) {
62
62
  const fold = !lanes && !timeline && props.rail !== 'only';
63
63
  const wide = fold && narrow.key === key ? narrow.wide : 0;
64
64
  const setWide = useCallback((w) => setNarrow({ wide: w, key }), [key]);
65
- const placed = useMemo(() => autoLayout({ layout: wide ? { ...layout, auto: true, direction: 'column' } : layout, edges, steps, lanes, timeline }), [wide, layout, edges, steps, lanes, timeline]);
65
+ const placed = useMemo(() => autoLayout({ layout: wide > 0 ? { ...layout, auto: true, direction: 'column' } : layout, edges, steps, lanes, timeline }), [wide, layout, edges, steps, lanes, timeline]);
66
66
  return (_jsx(FlowBody, { ...props, layout: placed.layout, edges: placed.edges, written: props, wide: wide, setWide: fold ? setWide : undefined }));
67
67
  }
68
- // The ASYNC tag is 9 px; phone text needs 11 px.
69
- const RAIL_ZOOM = 11 / 9;
68
+ // The ASYNC tag is 9 px.
69
+ const RAIL_TEXT = 9;
70
+ // offsetWidth is rounded, so a rect ratio reads scale 1 as 0.999.
71
+ const shownScale = (el) => {
72
+ let k = 1;
73
+ for (let n = el; n; n = n.parentElement)
74
+ k *= new DOMMatrix(getComputedStyle(n).transform).a;
75
+ return k;
76
+ };
70
77
  function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, }) {
71
78
  const tl = timeline && isLanesLayout(layout);
72
79
  const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
@@ -78,7 +85,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
78
85
  const area = useRef(null);
79
86
  const root = useRef(null);
80
87
  const outer = useRef(null);
81
- const [fit, setFit] = useState({ scale: 1, height: 0 });
88
+ const [fit, setFit] = useState({ scale: 1, height: 0, scroll: false });
82
89
  const [full, setFull] = useState(false);
83
90
  useEffect(() => {
84
91
  if (!full)
@@ -96,6 +103,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
96
103
  const firstLabel = useRef('');
97
104
  const paths = useRef({});
98
105
  const [routed, setRouted] = useState([]);
106
+ const routedOn = useRef(new WeakMap());
99
107
  const markSides = new Set(nodes(layout).flatMap((n) => (n.mark ? [markSide(n.id, n.mark, routed)] : [])));
100
108
  const [extraTall, setExtraTall] = useState(new Set());
101
109
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
@@ -145,6 +153,8 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
145
153
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
146
154
  const noMap = railOnly && rail != null;
147
155
  const railPane = useRef(null);
156
+ const [railBox, setRailBox] = useState(0);
157
+ const zoom = wide ? 11 / RAIL_TEXT : noMap && railBox ? textFloor(railBox) / RAIL_TEXT : 0;
148
158
  const follow = useRef(true);
149
159
  const user = useRef(false);
150
160
  const left = useRef(0);
@@ -176,13 +186,12 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
176
186
  if (setWide && goal !== wide)
177
187
  return setWide(goal);
178
188
  const scale = fitScale(wide, box.clientWidth, el.offsetWidth);
179
- setFit({ scale, height: el.offsetHeight * scale });
189
+ setFit({ scale, height: el.offsetHeight * scale, scroll: scale > box.clientWidth / el.offsetWidth });
180
190
  setMapW(el.offsetWidth);
181
191
  if (area.current)
182
192
  setAxisW(area.current.offsetWidth);
183
193
  const base = el.getBoundingClientRect();
184
- // Rects are measured unscaled: k is the scale on screen.
185
- const k = base.width / el.offsetWidth;
194
+ const k = shownScale(el);
186
195
  const rects = {};
187
196
  el.querySelectorAll('[data-fig]').forEach((n) => {
188
197
  const r = n.getBoundingClientRect();
@@ -246,6 +255,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
246
255
  }),
247
256
  ...(!tl && e.label != null && { labelW: labelPillW(str(e.label)) }),
248
257
  })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
258
+ routedOn.current.set(next, rects);
249
259
  setRouted(next);
250
260
  if (lanePlan) {
251
261
  const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
@@ -263,7 +273,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
263
273
  useEffect(() => {
264
274
  const pane = railPane.current;
265
275
  const n = rail?.rows.flatMap((r) => (r.kind === 'message' && r.step === active && r.beat === beat ? [r.n] : []))[0];
266
- const now = wide && follow.current && n != null ? pane?.querySelector(`[data-rail-row="${n}"] path`) : null;
276
+ const now = zoom && follow.current && n != null ? pane?.querySelector(`[data-rail-row="${n}"] path`) : null;
267
277
  if (!pane || !now)
268
278
  return;
269
279
  const [p, r] = [pane.getBoundingClientRect(), now.getBoundingClientRect()];
@@ -271,25 +281,28 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
271
281
  // Some browsers fire no scrollend event.
272
282
  busy.current = performance.now() + 1000;
273
283
  pane.scrollTo({ left: pane.scrollLeft + (r.left + r.right) / 2 - p.left - pane.clientWidth / 2, behavior: still ? 'auto' : 'smooth' });
274
- }, [wide, active, beat, still, rail]);
284
+ }, [zoom, active, beat, still, rail]);
275
285
  useEffect(() => {
276
286
  const pane = railPane.current;
277
287
  if (!pane)
278
288
  return;
279
289
  const end = () => (busy.current = 0);
280
290
  pane.addEventListener('scrollend', end);
281
- return () => pane.removeEventListener('scrollend', end);
282
- }, [rail]);
291
+ const ro = new ResizeObserver(() => setRailBox(pane.clientWidth));
292
+ if (noMap)
293
+ ro.observe(pane);
294
+ return () => (pane.removeEventListener('scrollend', end), ro.disconnect());
295
+ }, [rail, noMap]);
283
296
  const reported = useRef(new Set());
284
297
  useEffect(() => {
285
298
  const el = root.current, box = outer.current;
286
299
  const fig = figure.current;
287
- if (!check || !fig || (!noMap && (!el || !box || !el.offsetWidth)))
300
+ if (!check || !fig || (noMap ? !railBox : !el || !box || !el.offsetWidth))
288
301
  return;
289
302
  if (setWide && el && box && nextWide(wide, box.clientWidth, el.offsetWidth) !== wide)
290
303
  return;
291
304
  const base = (el ?? fig).getBoundingClientRect();
292
- const k = el ? base.width / el.offsetWidth : 1;
305
+ const k = el ? shownScale(el) : 1;
293
306
  const rel = (n) => {
294
307
  const r = n.getBoundingClientRect();
295
308
  return { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
@@ -306,7 +319,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
306
319
  .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
307
320
  .map((t) => {
308
321
  const fontSize = parseFloat(getComputedStyle(t).fontSize);
309
- if (!n.dataset.diamond)
322
+ if (!n.dataset.diamond || t.closest('[data-fig-outside]'))
310
323
  return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
311
324
  const [r, range] = [rel(t), document.createRange()];
312
325
  range.selectNodeContents(t);
@@ -320,6 +333,12 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
320
333
  }),
321
334
  };
322
335
  });
336
+ const moved = (b) => {
337
+ const r = routedOn.current.get(routed)?.[b.id];
338
+ return !r || Math.abs(r.x - b.rect.x) > 1 || Math.abs(r.y - b.rect.y) > 1;
339
+ };
340
+ if (el && boxes.some(moved))
341
+ return;
323
342
  const fonts = [...fig.querySelectorAll('*')]
324
343
  .filter((t) => !t.children.length &&
325
344
  t.textContent?.trim() &&
@@ -328,8 +347,9 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
328
347
  !t.closest('[data-fig-tag],[aria-hidden]'))
329
348
  .map((t) => {
330
349
  const svg = railPane.current?.firstElementChild;
331
- const at = el?.contains(t) ? k : svg?.contains(t) && rail ? svg.getBoundingClientRect().width / rail.width : 1;
332
- return parseFloat(getComputedStyle(t).fontSize) * at;
350
+ const place = el?.contains(t) ? 'map' : svg?.contains(t) && rail ? 'rail' : 'plain';
351
+ const railScale = svg && rail ? svg.getBoundingClientRect().width / rail.width : 1;
352
+ return shownFont(parseFloat(getComputedStyle(t).fontSize), place, { map: k, rail: railScale });
333
353
  });
334
354
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
335
355
  const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
@@ -378,7 +398,11 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
378
398
  ],
379
399
  minFont: Math.min(...fonts),
380
400
  };
381
- for (const f of [...checkSpec(written), ...checkScene(scene, { width: (box ?? fig).clientWidth }), ...checkTheme(theme)]) {
401
+ for (const f of [
402
+ ...checkSpec(written),
403
+ ...checkScene(scene, { width: (box ?? fig).clientWidth, minText: textFloor((box ?? fig).clientWidth), auto: written.layout.auto }),
404
+ ...checkTheme(theme),
405
+ ]) {
382
406
  const key = f.rule + ':' + f.ids.join() + ':' + f.message;
383
407
  if (reported.current.has(key))
384
408
  continue;
@@ -386,7 +410,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
386
410
  // oxlint-disable-next-line no-console
387
411
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
388
412
  }
389
- }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, lanePlan, wide, setWide]);
413
+ }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, railBox, lanePlan, wide, setWide]);
390
414
  useEffect(() => {
391
415
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
392
416
  if (clock.current.beats !== beats) {
@@ -712,7 +736,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
712
736
  const card = carded.has(item.id);
713
737
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
714
738
  const w = diamond || card || item.width != null || str(item.label) ? nodeWidth(item, card, cap) + (diamond ? 70 : 0) : undefined;
715
- const lines = w != null && !diamond && str(item.label) ? labelLines(item, w) : null;
739
+ const lines = w != null && str(item.label) ? labelLines(item, w) : null;
716
740
  const bt = item.tone && TONES[item.tone];
717
741
  return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
718
742
  position: 'relative',
@@ -835,7 +859,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
835
859
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
836
860
  backgroundSize: '14px 14px',
837
861
  }, children: _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
838
- overflow: wide || fit.scale <= 0.5 ? 'auto' : 'hidden',
862
+ overflow: wide || fit.scroll ? 'auto' : 'hidden',
839
863
  height: fit.scale < 1 ? fit.height : undefined,
840
864
  }, children: _jsxs("div", { ref: root, style: {
841
865
  position: 'relative',
@@ -930,10 +954,10 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
930
954
  if (user.current && x !== left.current && performance.now() > busy.current)
931
955
  follow.current = false;
932
956
  left.current = x;
933
- }, style: { overflowX: wide ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
957
+ }, style: { overflowX: zoom ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
934
958
  display: 'block',
935
- width: wide ? rail.width * RAIL_ZOOM : '100%',
936
- maxWidth: wide ? undefined : rail.width,
959
+ width: zoom ? rail.width * zoom : '100%',
960
+ maxWidth: zoom ? undefined : rail.width,
937
961
  height: 'auto',
938
962
  margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
939
963
  overflow: 'visible',
package/dist/model.d.ts CHANGED
@@ -238,7 +238,7 @@ export declare const validAt: (at: unknown) => at is number;
238
238
  /** The time column of each box: first use in the steps, then layout order. `at` wins. */
239
239
  export declare function laneColumns(fig: FlowProps): Map<string, number>;
240
240
  export declare const LABEL_LINE = 18;
241
- export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
241
+ export declare const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190, DIAMOND_MAX_W = 170;
242
242
  export declare const LANE_BLOCK_GAP = 40;
243
243
  export declare const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
244
244
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
@@ -249,13 +249,13 @@ export declare function itemWidth(item: FigNode | FigGroup, carded: {
249
249
  }, edges: FigEdge[], cap?: number): number;
250
250
  export declare function fitCap(fig: FlowProps, width: number, padding?: number): number;
251
251
  export declare const diamondRoom: (w: number, h: number, far: number) => number;
252
- /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
253
252
  export declare function diamondLines(item: FigNode, w: number): {
254
253
  h: number;
255
254
  label: {
255
+ text: string;
256
256
  dy: number;
257
257
  far: number;
258
- };
258
+ }[];
259
259
  subs: {
260
260
  text: string;
261
261
  dy: number;
@@ -361,5 +361,10 @@ export declare const altText: (fig: FlowProps) => {
361
361
  desc: string;
362
362
  };
363
363
  export declare const specKey: (...parts: unknown[]) => string;
364
+ export declare const textFloor: (box: number) => 9 | 11;
364
365
  export declare const fitScale: (wide: number, box: number, map: number) => number;
366
+ export declare const shownFont: (size: number, place: 'map' | 'rail' | 'plain', scale: {
367
+ map: number;
368
+ rail: number;
369
+ }) => number;
365
370
  export declare const nextWide: (wide: number, box: number, map: number) => number;
package/dist/model.js CHANGED
@@ -95,7 +95,7 @@ export function laneColumns(fig) {
95
95
  return cols;
96
96
  }
97
97
  export const LABEL_LINE = 18;
98
- export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190;
98
+ export const FRAME_SIDE = 18, NODE_MIN_W = 100, NODE_MAX_W = 190, DIAMOND_MAX_W = 170;
99
99
  export const LANE_BLOCK_GAP = 40;
100
100
  export const STUB = 12, STUB_CLEAR = 8, STUB_ROOM = 30;
101
101
  /** The width of a box as the SVG draws it, before a diamond adds its 70 px. */
@@ -106,14 +106,16 @@ export function nodeWidth(item, carded, cap = NODE_MAX_W) {
106
106
  return CARD_WIDTH;
107
107
  const label = textWidth(str(item.label), 14) + 32;
108
108
  const sub = textWidth(str(item.sub), 12) + 32;
109
- return Math.min(item.shape === 'decision' ? NODE_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
109
+ return Math.min(item.shape === 'decision' ? DIAMOND_MAX_W : cap, Math.max(NODE_MIN_W, label, sub));
110
110
  }
111
111
  export function labelLines(item, w) {
112
112
  const label = str(item.label);
113
- if (item.shape === 'decision' || textWidth(label, 14) <= w - 32 + 0.5)
113
+ if (item.shape === 'decision')
114
+ return wrap(label, w - 102 + 0.5, 14);
115
+ if (textWidth(label, 14) <= w - 32 + 0.5)
114
116
  return [label];
115
117
  const lines = wrap(label, w - 32, 14);
116
- return lines.length === 2 && lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
118
+ return lines.every((l) => textWidth(l, 14) <= w - 32) ? lines : [label];
117
119
  }
118
120
  export function itemWidth(item, carded, edges, cap = NODE_MAX_W) {
119
121
  if (!isGroup(item))
@@ -137,21 +139,28 @@ export function fitCap(fig, width, padding = 24) {
137
139
  return cap;
138
140
  }
139
141
  export const diamondRoom = (w, h, far) => w * Math.max(0, 1 - far / (h / 2)) - 8;
140
- /** The lines of a diamond with no card. The diamond grows 15 px for each sub line that wraps. */
141
142
  export function diamondLines(item, w) {
142
143
  const sub = item.sub ? str(item.sub) : '';
143
- for (let n = sub ? 1 : 0;; n++) {
144
- const h = 62 + 15 * n;
145
- const dy0 = n ? -2 - 7.5 * (n - 1) : 5;
146
- const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
147
- const last = n ? at(dy0 + 15 * n, 12) : 0;
144
+ const label = labelLines(item, w);
145
+ const k = label.length - 1;
146
+ const at = (dy, size) => Math.max(Math.abs(dy - 0.75 * size), dy + 0.25 * size);
147
+ for (let n = sub ? 1 : 0, h = 0;;) {
148
+ h = Math.max(h, 62 + 15 * n + LABEL_LINE * k);
149
+ const dy0 = (n ? 5.5 : 5) - 7.5 * n - (LABEL_LINE / 2) * k;
150
+ const last = n ? at(dy0 + LABEL_LINE * k + 15 * n, 12) : 0;
148
151
  const lines = n ? wrap(sub, diamondRoom(w, h, last), 12) : [];
149
- if (lines.length <= n || n >= 6)
150
- return {
151
- h,
152
- label: { dy: dy0, far: at(dy0, 14) },
153
- subs: lines.map((text, k) => ({ text, dy: dy0 + 15 * (k + 1), far: at(dy0 + 15 * (k + 1), 12) })),
154
- };
152
+ const labels = label.map((text, i) => ({ text, dy: dy0 + LABEL_LINE * i, far: at(dy0 + LABEL_LINE * i, 14) }));
153
+ const subs = lines.map((text, j) => ({
154
+ text,
155
+ dy: dy0 + LABEL_LINE * k + 15 * (j + 1),
156
+ far: at(dy0 + LABEL_LINE * k + 15 * (j + 1), 12),
157
+ }));
158
+ if (lines.length > n && n < 6)
159
+ n++;
160
+ else if (h < w && labels.some((l) => textWidth(l.text, 14) > diamondRoom(w, h, l.far)))
161
+ h += 4;
162
+ else
163
+ return { h, label: labels, subs };
155
164
  }
156
165
  }
157
166
  /** The lane copies whose stub end has no clear place. The renderer grows each by STUB_ROOM. */
@@ -523,7 +532,17 @@ const keyOf = (v, path) => {
523
532
  return Object.fromEntries(Object.entries(src).flatMap(([k, x]) => (k === 'ref' || k.startsWith('_') ? [] : [[k, keyOf(x, at)]])));
524
533
  };
525
534
  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);
535
+ // Edge labels are 11 px, the smallest reader text at scale 1.
536
+ const READER_PX = 11;
537
+ const DESKTOP_PX = 9;
538
+ const DESKTOP_BOX = 600;
539
+ export const textFloor = (box) => (box < DESKTOP_BOX ? READER_PX : DESKTOP_PX);
540
+ export const fitScale = (wide, box, map) => Math.max(wide ? 1 : textFloor(box) / READER_PX, Math.min(1, box / map));
541
+ export const shownFont = (size, place, scale) => size * (place === 'map' ? scale.map : place === 'rail' ? scale.rail : 1);
542
+ const folds = (box, row) => box * READER_PX < row * textFloor(box);
543
+ export const nextWide = (wide, box, map) => {
544
+ const row = Math.abs(wide) || map;
545
+ if (!folds(box, row))
546
+ return 0;
547
+ return wide > 0 && map >= wide ? -wide : wide || map;
548
+ };
package/dist/svg.js CHANGED
@@ -12,8 +12,12 @@ const LINE = CARD_LINE, CARD_SIDE = 8, ROW_GAP = 4;
12
12
  const SUB_LINE = 15;
13
13
  const MIN_W = 320;
14
14
  const FRAME_TOP = 37, FRAME_BOTTOM = 18;
15
- // The card-on fills approximate the player's 8 % accent tint.
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));`;
15
+ const color = (t, k) => {
16
+ const v = String(t[k]);
17
+ const depth = [...v].reduce((d, c) => (d < 0 ? d : d + (c === '(' ? 1 : c === ')' ? -1 : 0)), 0);
18
+ return /^[#a-zA-Z0-9 ,.%()/+-]+$/.test(v) && !/url\(/i.test(v) && depth === 0 ? v : LIGHT[k];
19
+ };
20
+ const vars = (t, cardOn) => `--accent:${color(t, 'accent')}; --fg:${color(t, 'fg')}; --muted:${color(t, 'muted')}; --bg:${color(t, 'bg')}; --surface:${color(t, 'surface')}; --border:${color(t, 'border')}; --card-on:${cardOn}; --tint:color-mix(in srgb, var(--accent) 10%, var(--surface));`;
17
21
  const content = (c) => (isRows(c) ? c : str(c));
18
22
  const esc = (s) => s.replace(/[<>&"]/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;' })[c]);
19
23
  const n2 = (v) => Math.round(v * 10) / 10;
@@ -293,10 +297,10 @@ export function render(spec, opts = {}) {
293
297
  const dl = dia ? diamondLines(p.item, p.w) : null;
294
298
  const room = (far) => diamondRoom(p.w, p.h, far);
295
299
  const flat = (p.item.shape === 'decision' ? p.w - 70 : p.w) - 16;
296
- const texts = labelLines(p.item, p.w).map((text) => ({
300
+ const texts = labelLines(p.item, p.w).map((text, i) => ({
297
301
  text,
298
302
  fontSize: 14,
299
- room: dl ? room(dl.label.far) : flat,
303
+ room: dl ? room(dl.label[i].far) : flat,
300
304
  }));
301
305
  if (dl)
302
306
  for (const l of dl.subs)
@@ -507,7 +511,7 @@ export function render(spec, opts = {}) {
507
511
  const contents = cards.get(item.id);
508
512
  const cardTop = p.y + p.h - 10 - (contents ? cardH.get(item.id) : 0);
509
513
  const dl = item.shape === 'decision' && !contents ? diamondLines(item, p.w) : null;
510
- const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label.dy : item.sub ? -2 : 5);
514
+ const labelY = item.shape === 'store' ? p.y + 24 + 13 : contents ? p.y + 10 + 13 : p.y + p.h / 2 + (dl ? dl.label[0].dy : item.sub ? -2 : 5);
511
515
  const shape = item.shape === 'decision'
512
516
  ? `<polygon points="${n2(cx)},${n2(p.y)} ${n2(p.x + p.w)},${n2(p.y + p.h / 2)} ${n2(cx)},${n2(p.y + p.h)} ${n2(p.x)},${n2(p.y + p.h / 2)}" fill="${fill0}" stroke="${stroke0}"${stroke}/>`
513
517
  : item.shape === 'store'
@@ -516,7 +520,7 @@ export function render(spec, opts = {}) {
516
520
  : `<rect x="${n2(p.x)}" y="${n2(p.y)}" width="${n2(p.w)}" height="${n2(p.h)}" rx="10" fill="${fill0}" stroke="${stroke0}"${stroke}/>`;
517
521
  const lines = labelLines(item, p.w);
518
522
  const extra = (lines.length - 1) * LABEL_LINE;
519
- const firstY = item.shape === 'store' || contents ? labelY : labelY - extra / 2;
523
+ const firstY = item.shape === 'store' || contents || dl ? labelY : labelY - extra / 2;
520
524
  const label = lines.map((l, i) => `<text x="${n2(cx)}" y="${n2(firstY + i * LABEL_LINE)}" class="label">${esc(l)}</text>`).join('');
521
525
  const sub = dl
522
526
  ? dl.subs.map((l) => `<text x="${n2(cx)}" y="${n2(p.y + p.h / 2 + l.dy)}" class="sub">${esc(l.text)}</text>`).join('')
package/dist/version.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const VERSION = "0.10.0";
1
+ export declare const VERSION = "0.11.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.10.0';
2
+ export const VERSION = '0.11.0';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowfig",
3
- "version": "0.10.0",
3
+ "version": "0.11.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": {