flowfig 0.1.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/index.js ADDED
@@ -0,0 +1,638 @@
1
+ 'use client';
2
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
+ import { route } from './geometry.js';
5
+ import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
+ import { checkScene, checkSpec, checkTheme } from './check.js';
7
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, } from './model.js';
8
+ export { LIGHT, DARK } from './model.js';
9
+ const DEFAULTS = { ...LIGHT, font: 'inherit' };
10
+ const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
11
+ const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
12
+ const glow = `0 0 0 3px color-mix(in srgb, ${v('accent')} 18%, transparent)`;
13
+ const cardBody = (c) => {
14
+ if (c == null)
15
+ return '—';
16
+ if (!isRows(c))
17
+ return c;
18
+ return (_jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: 4, whiteSpace: 'normal' }, children: c.map((r, i) => {
19
+ const tone = TONES[r.tone ?? 'blue'];
20
+ const tag = r.tag != null && (_jsx("span", { "data-fig-tag": true, style: {
21
+ flexShrink: 0,
22
+ fontSize: 9,
23
+ fontWeight: 600,
24
+ letterSpacing: '.03em',
25
+ textTransform: 'uppercase',
26
+ padding: '0 4px',
27
+ borderRadius: 4,
28
+ lineHeight: '14px',
29
+ color: tone,
30
+ background: `color-mix(in srgb, ${tone} 15%, transparent)`,
31
+ }, children: r.tag }));
32
+ const mark = r.mark != null && (_jsx("span", { style: { flexShrink: 0, marginLeft: 'auto', color: v('accent'), fontWeight: 600 }, children: r.mark }));
33
+ const text = (_jsxs("span", { style: {
34
+ minWidth: 0,
35
+ fontFamily: r.mono ? MONO : undefined,
36
+ fontSize: r.mono ? 10.5 : undefined,
37
+ }, children: [r.text, r.meta != null && _jsxs("span", { style: { color: v('muted') }, children: [" \u00B7 ", r.meta] })] }));
38
+ // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
39
+ return (r.tag?.length ?? 0) > 2 ? (_jsxs("div", { children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 5, marginBottom: 1 }, children: [tag, mark] }), text] }, i)) : (_jsxs("div", { style: { display: 'flex', gap: 5, alignItems: 'baseline' }, children: [tag, text, mark] }, i));
40
+ }) }));
41
+ };
42
+ /**
43
+ * The interactive player for one figure: tabs for the steps, play and pause, speed, hover and full screen.
44
+ * Give it a `FlowProps` spec. Use `toSvg` from `flowfig/svg` for a static animated SVG instead.
45
+ */
46
+ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay = true, check = false, rail: withRail = false }) {
47
+ const root = useRef(null);
48
+ const outer = useRef(null);
49
+ const [fit, setFit] = useState({ scale: 1, height: 0 });
50
+ const [full, setFull] = useState(false);
51
+ useEffect(() => {
52
+ if (!full)
53
+ return;
54
+ const onKey = (e) => e.key === 'Escape' && setFull(false);
55
+ addEventListener('keydown', onKey);
56
+ return () => removeEventListener('keydown', onKey);
57
+ }, [full]);
58
+ const dots = useRef([]);
59
+ const chips = useRef([]);
60
+ const bar = useRef(null); // the active tab's progress line
61
+ const [beat, setBeat] = useState(0);
62
+ const paths = useRef({});
63
+ const [routed, setRouted] = useState([]);
64
+ const [active, setActive] = useState(steps.length ? 0 : null);
65
+ const [playing, setPlaying] = useState(autoplay);
66
+ // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
67
+ const playingRef = useRef(playing);
68
+ playingRef.current = playing;
69
+ const [rate, setRate] = useState(1);
70
+ const rateRef = useRef(rate);
71
+ rateRef.current = rate;
72
+ const clock = useRef({ beats: null, elapsed: 0 });
73
+ const [hover, setHover] = useState(null);
74
+ const ids = useMemo(() => edges.map(edgeId), [edges]);
75
+ const tips = useMemo(() => new Set(decisions(layout)), [layout]);
76
+ const step = active == null ? null : steps[active];
77
+ const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
78
+ // Every content each box's card will ever show. The card is sized to the largest, so text never gets cut and nothing jumps.
79
+ const carded = useMemo(() => {
80
+ const all = new Map();
81
+ for (const b of steps.flatMap((s) => s.flow.map(toBeat)))
82
+ for (const [id, c] of Object.entries(b.show ?? {}))
83
+ all.set(id, [...(all.get(id) ?? []), c]);
84
+ return all;
85
+ }, [steps]);
86
+ const cur = beats[beat];
87
+ // The narration: the latest `say` so far (a beat without one keeps the line before), else the step caption.
88
+ const saidAt = beats.slice(0, beat + 1).findLastIndex((b) => b.say != null);
89
+ const said = saidAt === -1 ? step?.caption : beats[saidAt].say;
90
+ // Content cards: everything the step has shown so far, latest wins.
91
+ const shown = useMemo(() => Object.assign({}, ...beats.slice(0, beat + 1).map((b) => b.show)), [beats, beat]);
92
+ // The beat that last changed each card, so only a card whose content just changed animates in.
93
+ const shownAt = useMemo(() => {
94
+ const at = {};
95
+ beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
96
+ return at;
97
+ }, [beats, beat]);
98
+ // The rail lays out in node terms, like the SVG, so both renderers draw the same numbers. Its width follows the measured map.
99
+ const figure = useRef(null);
100
+ const [mapW, setMapW] = useState(0);
101
+ const railOnly = withRail === 'only';
102
+ const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
103
+ // 'only' drops the map, but only when the rail has a hop to draw; a figure never renders empty.
104
+ const noMap = railOnly && rail != null;
105
+ const jump = useRef(null); // a rail click that asks for a beat of another step
106
+ const [hoverEdge, setHoverEdge] = useState(null);
107
+ // With reduced motion the player shows the last beat, so the rail marks every row as done.
108
+ const [still, setStill] = useState(false);
109
+ useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
110
+ // Boxes with many edges on one side get taller so the edges and their labels have room.
111
+ const minHeight = useMemo(() => {
112
+ const out = {}, inn = {};
113
+ for (const e of edges) {
114
+ out[e.from] = (out[e.from] ?? 0) + 1;
115
+ inn[e.to] = (inn[e.to] ?? 0) + 1;
116
+ }
117
+ return (id) => {
118
+ const n = Math.max(out[id] ?? 0, inn[id] ?? 0);
119
+ return n > 2 ? n * 30 : undefined;
120
+ };
121
+ }, [edges]);
122
+ // Measure every box and redraw the edges whenever anything changes size.
123
+ useEffect(() => {
124
+ const el = root.current, box = outer.current;
125
+ if (!el || !box)
126
+ return; // no map to measure with rail: 'only'
127
+ const measure = () => {
128
+ // Too wide for its container? Shrink it so the whole figure stays in view. Below half size, scroll instead.
129
+ const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
130
+ setFit({ scale, height: el.offsetHeight * scale });
131
+ setMapW(el.offsetWidth);
132
+ const base = el.getBoundingClientRect();
133
+ const k = base.width / el.offsetWidth; // the scale currently on screen; rects are measured unscaled
134
+ const rects = {};
135
+ el.querySelectorAll('[data-fig]').forEach((n) => {
136
+ const r = n.getBoundingClientRect();
137
+ rects[n.dataset.fig] = {
138
+ x: (r.left - base.left) / k,
139
+ y: (r.top - base.top) / k,
140
+ w: r.width / k,
141
+ h: r.height / k,
142
+ };
143
+ });
144
+ setRouted(route(edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips));
145
+ };
146
+ measure();
147
+ const ro = new ResizeObserver(measure);
148
+ ro.observe(el);
149
+ ro.observe(box);
150
+ el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
151
+ return () => ro.disconnect();
152
+ }, [edges, ids, layout, tips, noMap]);
153
+ // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
154
+ const reported = useRef(new Set());
155
+ useEffect(() => {
156
+ const el = root.current, box = outer.current;
157
+ const fig = figure.current;
158
+ // With rail: 'only' there is no map: the figure is the frame and the scene holds only the rail.
159
+ if (!check || !fig || (!noMap && (!el || !box)))
160
+ return;
161
+ const base = (el ?? fig).getBoundingClientRect();
162
+ const k = el ? base.width / el.offsetWidth : 1;
163
+ const rel = (n) => {
164
+ const r = n.getBoundingClientRect();
165
+ return { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
166
+ };
167
+ const boxIds = new Set(nodes(layout).map((n) => n.id));
168
+ const boxes = [...(el ?? fig).querySelectorAll('[data-fig]')]
169
+ .filter((n) => boxIds.has(n.dataset.fig))
170
+ .map((n) => ({
171
+ id: n.dataset.fig,
172
+ rect: rel(n),
173
+ texts: [...n.querySelectorAll('*')]
174
+ .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
175
+ .map((t) => {
176
+ const fontSize = parseFloat(getComputedStyle(t).fontSize);
177
+ return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
178
+ }),
179
+ }));
180
+ // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
181
+ // The rail is a sibling of the map, so the rail queries start at the figure. An SVG <text> has no CSS box, so it skips clientWidth.
182
+ const fonts = [...fig.querySelectorAll('*')]
183
+ .filter((t) => !t.children.length &&
184
+ t.textContent?.trim() &&
185
+ (t instanceof SVGElement || t.clientWidth > 0) &&
186
+ !t.closest('[data-fig-tag],[aria-hidden]'))
187
+ .map((t) => parseFloat(getComputedStyle(t).fontSize));
188
+ const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
189
+ const scene = {
190
+ width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
191
+ boxes,
192
+ edges: [
193
+ ...routed.map((r) => {
194
+ const e = edges[ids.indexOf(r.id)];
195
+ return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id] };
196
+ }),
197
+ // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
198
+ ...(rail?.rows ?? []).flatMap((row) => {
199
+ const r = row.kind === 'message' ? labels[`rail:${row.n}`] : undefined;
200
+ if (row.kind !== 'message' || !r)
201
+ return [];
202
+ const c = { x: r.x + r.w / 2, y: r.y + r.h / 2 };
203
+ return [
204
+ {
205
+ id: `rail:${row.n}`,
206
+ from: rail.columns[row.from].id,
207
+ to: rail.columns[row.to].id,
208
+ curve: [c, c, c, c],
209
+ label: r,
210
+ },
211
+ ];
212
+ }),
213
+ ],
214
+ minFont: Math.min(...fonts),
215
+ };
216
+ for (const f of [
217
+ ...checkSpec({ layout, edges, steps }),
218
+ ...checkScene(scene, { width: (box ?? fig).clientWidth }),
219
+ ...checkTheme(theme),
220
+ ]) {
221
+ const key = f.rule + ':' + f.ids.join() + ':' + f.message;
222
+ if (reported.current.has(key))
223
+ continue;
224
+ reported.current.add(key);
225
+ console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
226
+ }
227
+ }, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
228
+ // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
229
+ useEffect(() => {
230
+ const gs = dots.current, cs = chips.current;
231
+ if (clock.current.beats !== beats) {
232
+ // A rail click can open another step at a later beat; start the clock at that beat, not at 0.
233
+ const j = jump.current ?? 0;
234
+ jump.current = null;
235
+ clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + (b.ms ?? speed), 0) };
236
+ setBeat(j);
237
+ }
238
+ if (!beats.length || (!noMap && !routed.length))
239
+ return;
240
+ if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
241
+ if (bar.current)
242
+ bar.current.style.transform = 'none';
243
+ return void setBeat(beats.length - 1);
244
+ }
245
+ const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + (b.ms ?? speed)], []);
246
+ const total = ends.at(-1) + speed * 1.5; // hold on the last beat before moving on
247
+ let raf = 0, last = performance.now(), shownBeat = -1;
248
+ const tick = (now) => {
249
+ if (playingRef.current)
250
+ clock.current.elapsed += (now - last) * rateRef.current * BASE_RATE;
251
+ last = now;
252
+ if (clock.current.elapsed >= total) {
253
+ if (steps.length > 1)
254
+ return setActive((a) => ((a ?? 0) + 1) % steps.length);
255
+ clock.current.elapsed = 0;
256
+ }
257
+ const t = clock.current.elapsed;
258
+ if (bar.current)
259
+ bar.current.style.transform = `scaleX(${Math.min(1, t / total)})`;
260
+ const next = ends.findIndex((e) => t < e);
261
+ const i = next === -1 ? beats.length - 1 : next;
262
+ if (i !== shownBeat)
263
+ setBeat((shownBeat = i));
264
+ const start = i ? ends[i - 1] : 0;
265
+ const f = Math.min(1, (t - start) / ((beats[i].ms ?? speed) * 0.8)); // arrive a little early, rest at the end
266
+ const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
267
+ const hops = t < ends.at(-1) ? beats[i].hops : [];
268
+ gs.forEach((g, j) => {
269
+ const p = hops[j] && paths.current[hops[j].edge];
270
+ const c = cs[j];
271
+ if (!p) {
272
+ if (g)
273
+ g.style.opacity = '0';
274
+ if (c)
275
+ c.style.opacity = '0';
276
+ return;
277
+ }
278
+ const pt = p.getPointAtLength((hops[j].back ? 1 - eased : eased) * p.getTotalLength());
279
+ if (g) {
280
+ g.setAttribute('transform', `translate(${pt.x} ${pt.y})`);
281
+ g.style.opacity = '1';
282
+ }
283
+ if (c) {
284
+ c.style.transform = `translate(${pt.x}px, ${pt.y}px) translate(-50%, calc(-100% - 12px))`;
285
+ c.style.opacity = hops[j].data == null ? '0' : '1';
286
+ }
287
+ });
288
+ raf = requestAnimationFrame(tick);
289
+ };
290
+ raf = requestAnimationFrame(tick);
291
+ return () => {
292
+ cancelAnimationFrame(raf);
293
+ gs.forEach((g) => g && (g.style.opacity = '0'));
294
+ cs.forEach((c) => c && (c.style.opacity = '0'));
295
+ };
296
+ }, [beats, routed, noMap, speed, steps.length]);
297
+ // What to light up: the step's edges and their ends, or whatever touches the hovered box.
298
+ // A step lights the trail so far: every edge its beats have crossed, and every box it has filled.
299
+ const trail = beats.slice(0, beat + 1).flatMap((b) => b.hops.map((h) => h.edge));
300
+ const hoverEdgeIdx = hoverEdge == null ? -1 : ids.indexOf(hoverEdge);
301
+ const litEdges = new Set(hoverEdge ? [hoverEdge] : hover ? ids.filter((id, i) => edges[i].from === hover || edges[i].to === hover) : trail);
302
+ const litNodes = new Set(hoverEdgeIdx !== -1
303
+ ? [edges[hoverEdgeIdx].from, edges[hoverEdgeIdx].to]
304
+ : hover
305
+ ? [hover]
306
+ : [...(step?.nodes ?? []), ...Object.keys(shown), ...(cur?.light ?? [])]);
307
+ edges.forEach((e, i) => {
308
+ if (litEdges.has(ids[i]))
309
+ litNodes.add(e.from).add(e.to);
310
+ });
311
+ const goTo = (step, b) => {
312
+ setPlaying(true);
313
+ if (step === active) {
314
+ clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + (x.ms ?? speed), 0);
315
+ setBeat(b);
316
+ }
317
+ else {
318
+ jump.current = b;
319
+ setActive(step);
320
+ }
321
+ };
322
+ const focus = hover != null || hoverEdge != null || step != null;
323
+ const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
324
+ const renderItem = (item, depth) => {
325
+ if (isGroup(item)) {
326
+ const lit = item.id != null && litNodes.has(item.id);
327
+ const framed = item.label != null;
328
+ return (_jsxs("div", { "data-fig": item.id, style: {
329
+ ...(framed && {
330
+ background: v('surface'),
331
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
332
+ boxShadow: lit ? glow : undefined,
333
+ borderRadius: 14,
334
+ padding: '10px 18px 18px',
335
+ }),
336
+ transition: 'border-color .25s, box-shadow .25s',
337
+ }, children: [item.label != null && (_jsx("div", { style: {
338
+ fontSize: 12,
339
+ fontWeight: 600,
340
+ letterSpacing: '.04em',
341
+ textTransform: 'uppercase',
342
+ color: v('muted'),
343
+ marginBottom: 12,
344
+ }, children: item.label })), _jsx("div", { style: {
345
+ display: 'flex',
346
+ flexDirection: item.direction ?? 'row',
347
+ gap: groupGap(item, edges),
348
+ alignItems: item.align
349
+ ? item.align === 'center'
350
+ ? 'center'
351
+ : 'flex-' + item.align
352
+ : item.direction === 'column'
353
+ ? 'stretch'
354
+ : 'center',
355
+ justifyContent: 'center',
356
+ }, children: item.children.map((c) => renderItem(c, depth + 1)) })] }, item.id ?? depth + String(item.label)));
357
+ }
358
+ const lit = litNodes.has(item.id);
359
+ const diamond = item.shape === 'decision';
360
+ const store = item.shape === 'store';
361
+ const card = carded.has(item.id);
362
+ return (_jsxs("div", { "data-fig": item.id, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
363
+ position: 'relative',
364
+ isolation: 'isolate',
365
+ minWidth: 100,
366
+ maxWidth: card ? undefined : 190,
367
+ width: item.width ?? (card ? CARD_WIDTH : undefined),
368
+ minHeight: minHeight(item.id),
369
+ boxSizing: 'border-box',
370
+ display: 'flex',
371
+ flexDirection: 'column',
372
+ justifyContent: 'center',
373
+ padding: diamond ? '22px 34px' : `${store ? 24 : 10}px ${card ? 10 : 16}px 10px`,
374
+ textAlign: 'center',
375
+ background: diamond ? undefined : v('bg'),
376
+ color: v('fg'),
377
+ border: diamond ? undefined : `1px solid ${lit ? v('accent') : v('border')}`,
378
+ boxShadow: diamond ? undefined : lit ? glow : '0 1px 2px rgba(0,0,0,.06)',
379
+ opacity: focus && !lit ? 0.7 : 1,
380
+ borderRadius: store ? '50% / 12px' : 10,
381
+ fontSize: 14,
382
+ fontWeight: 500,
383
+ transition: 'border-color .25s, box-shadow .25s, opacity .25s',
384
+ cursor: 'default',
385
+ }, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
386
+ position: 'absolute',
387
+ inset: 0,
388
+ width: '100%',
389
+ height: '100%',
390
+ overflow: 'visible',
391
+ zIndex: -1,
392
+ }, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: v('bg'), stroke: lit ? v('accent') : v('border'), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .25s' } }) })), store && (_jsx("div", { style: {
393
+ position: 'absolute',
394
+ top: -1,
395
+ left: -1,
396
+ right: -1,
397
+ height: 24,
398
+ boxSizing: 'border-box',
399
+ borderRadius: '50%',
400
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
401
+ transition: 'border-color .25s',
402
+ } })), _jsx("div", { children: item.label }), item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
403
+ marginTop: 8,
404
+ padding: '6px 8px',
405
+ minHeight: item.lines != null ? item.lines * CARD_LINE + CARD_PAD * 2 + 1 : undefined,
406
+ boxSizing: 'border-box',
407
+ display: 'grid',
408
+ borderRadius: 6,
409
+ textAlign: 'left',
410
+ fontSize: 11,
411
+ lineHeight: '15px',
412
+ fontWeight: 400,
413
+ whiteSpace: 'pre-wrap',
414
+ overflowWrap: 'anywhere',
415
+ color: shown[item.id] != null ? v('fg') : v('muted'),
416
+ background: shown[item.id] != null ? `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})` : v('surface'),
417
+ border: `1px dashed ${shown[item.id] != null ? v('accent') : v('border')}`,
418
+ animation: shown[item.id] != null ? 'flowfig-in .35s ease-out' : undefined,
419
+ }, children: [carded.get(item.id).map((c, i) => (_jsx("div", { "aria-hidden": true, style: { gridArea: '1 / 1', visibility: 'hidden' }, children: cardBody(c) }, i))), _jsx("div", { style: { gridArea: '1 / 1' }, children: cardBody(shown[item.id]) })] }, `${active}-${shownAt[item.id] ?? 'empty'}`))] }, item.id));
420
+ };
421
+ const iconBtn = {
422
+ display: 'grid',
423
+ placeItems: 'center',
424
+ width: 30,
425
+ height: 30,
426
+ padding: 0,
427
+ borderRadius: 999,
428
+ cursor: 'pointer',
429
+ border: `1px solid ${v('border')}`,
430
+ background: v('bg'),
431
+ color: v('muted'),
432
+ };
433
+ return (_jsxs("figure", { ref: figure, className: "flowfig", style: {
434
+ ...vars,
435
+ position: full ? 'fixed' : 'relative',
436
+ inset: full ? 0 : undefined,
437
+ zIndex: full ? 1000 : undefined,
438
+ overflow: full ? 'auto' : undefined,
439
+ margin: full ? 0 : '24px 0',
440
+ padding: full ? '48px 24px 24px' : '20px 16px 16px',
441
+ border: full ? 'none' : `1px solid ${v('border')}`,
442
+ borderRadius: full ? 0 : 16,
443
+ background: v('bg'),
444
+ fontFamily: v('font'),
445
+ color: v('fg'),
446
+ }, children: [_jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
447
+ ...iconBtn,
448
+ position: 'absolute',
449
+ top: 10,
450
+ right: 10,
451
+ zIndex: 3,
452
+ width: 28,
453
+ height: 28,
454
+ }, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
455
+ margin: full ? '-48px -24px 0' : '-20px -16px 0',
456
+ padding: full ? '48px 24px 24px' : '24px 16px',
457
+ borderRadius: full ? 0 : '16px 16px 0 0',
458
+ borderBottom: `1px solid ${v('border')}`,
459
+ backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
460
+ backgroundSize: '14px 14px',
461
+ }, children: _jsx("div", { ref: outer, style: {
462
+ overflow: fit.scale > 0.5 ? 'hidden' : 'auto',
463
+ height: fit.scale < 1 ? fit.height : undefined,
464
+ }, children: _jsxs("div", { ref: root, style: {
465
+ position: 'relative',
466
+ width: 'max-content',
467
+ margin: '0 auto',
468
+ transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
469
+ transformOrigin: 'top left',
470
+ padding: 4,
471
+ // room for edges that arc over or under the boxes
472
+ paddingTop: edges.some((e) => e.around === 'above') ? 44 : 4,
473
+ paddingBottom: edges.some((e) => e.around === 'below') ? 44 : 4,
474
+ }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
475
+ position: 'absolute',
476
+ inset: 0,
477
+ width: '100%',
478
+ height: '100%',
479
+ overflow: 'visible',
480
+ pointerEvents: 'none',
481
+ }, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: k === 'on' ? v('accent') : v('muted') }) }, k))) }), routed.map((r) => {
482
+ const on = litEdges.has(r.id);
483
+ const hidden = !on && edges[ids.indexOf(r.id)].quiet;
484
+ return (_jsx("path", { ref: (p) => {
485
+ paths.current[r.id] = p;
486
+ }, d: r.d, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
487
+ }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
488
+ dots.current[j] = g;
489
+ }, style: { opacity: 0 }, children: [_jsx("circle", { r: 10, fill: v('accent'), opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: v('accent') })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
490
+ chips.current[j] = c;
491
+ }, style: {
492
+ position: 'absolute',
493
+ left: 0,
494
+ top: 0,
495
+ zIndex: 2,
496
+ opacity: 0,
497
+ maxWidth: 220,
498
+ width: 'max-content',
499
+ padding: '4px 9px',
500
+ borderRadius: 8,
501
+ fontSize: 11.5,
502
+ lineHeight: '15px',
503
+ pointerEvents: 'none',
504
+ background: v('accent'),
505
+ color: ON_ACCENT,
506
+ boxShadow: '0 4px 14px rgba(0,0,0,.18)',
507
+ transition: 'opacity .2s',
508
+ }, children: cur?.hops[j]?.data }, j))), routed.map((r) => {
509
+ const e = edges[ids.indexOf(r.id)];
510
+ if (e.label == null)
511
+ return null;
512
+ const on = litEdges.has(r.id);
513
+ return (_jsx("div", { "data-fig-label": r.id, style: {
514
+ position: 'absolute',
515
+ left: r.mid.x,
516
+ top: r.mid.y,
517
+ transform: 'translate(-50%, -50%)',
518
+ fontSize: 11,
519
+ lineHeight: '16px',
520
+ padding: '0 7px',
521
+ borderRadius: 999,
522
+ whiteSpace: 'nowrap',
523
+ pointerEvents: 'none',
524
+ background: on ? v('accent') : v('bg'),
525
+ color: on ? ON_ACCENT : v('muted'),
526
+ border: `1px solid ${on ? v('accent') : v('border')}`,
527
+ opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
528
+ fontFamily: MONO,
529
+ transition: 'background .25s, color .25s, opacity .25s',
530
+ }, children: e.label }, r.id));
531
+ })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
532
+ display: 'block',
533
+ width: '100%',
534
+ maxWidth: rail.width,
535
+ height: 'auto',
536
+ margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
537
+ overflow: 'visible',
538
+ fontFamily: v('font'),
539
+ }, children: [rail.bands.map((b) => (_jsxs("g", { children: [_jsx("rect", { x: b.rect.x, y: b.rect.y, width: b.rect.w, height: b.rect.h, rx: 10, fill: v('surface'), stroke: v('border') }), _jsx("text", { x: b.rect.x + 10, y: b.rect.y + 14, fill: v('muted'), fontSize: 11, fontWeight: 600, letterSpacing: ".04em", children: b.label.toUpperCase() })] }, b.id))), rail.columns.map((c) => (_jsx("text", { x: c.x, y: rail.head - RAIL.cols / 2 + 4, fill: v('fg'), fontSize: 12, fontWeight: 500, textAnchor: "middle", children: c.label }, c.id))), (() => {
540
+ const st = railState(rail, active ?? 0);
541
+ // The lifelines run through the message rows only, so a phase header reads as one clear divider.
542
+ const lines = rail.rows.flatMap((row, i) => row.kind === 'message' && st[i].shown ? rail.columns.map((c) => `M ${c.x} ${st[i].y} V ${st[i].y + RAIL.row}`) : []);
543
+ return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
544
+ })(), (() => {
545
+ const st = railState(rail, active ?? 0);
546
+ return rail.rows.map((row, i) => {
547
+ if (!st[i].shown)
548
+ return null;
549
+ const y = st[i].y;
550
+ if (row.kind === 'phase') {
551
+ const open = !rail.folds || row.step === active;
552
+ const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
553
+ return (_jsxs("g", { style: { cursor: open ? undefined : 'pointer' }, onClick: open ? undefined : () => goTo(row.step, 0), children: [_jsx("text", { x: RAIL.pad, y: y + 19, fill: open ? v('fg') : v('muted'), fontSize: 13, fontWeight: 600, fontFamily: MONO, children: open ? row.label : foldedLabel(row) }), _jsx("path", { d: `M ${open ? row.line.open : row.line.folded} ${y + 15} H ${row.line.end}`, stroke: v('border') }), playing?.kind === 'message' && (_jsxs("text", { x: rail.width - RAIL.pad, y: y + 19, fill: v('muted'), fontSize: 11, fontWeight: 600, textAnchor: "end", children: [still ? rail.total : playing.n, " of ", rail.total] }))] }, `p${i}`));
554
+ }
555
+ const state = still
556
+ ? 'done'
557
+ : active == null || row.step > active || (row.step === active && row.beat > beat)
558
+ ? 'next'
559
+ : row.step === active && row.beat === beat
560
+ ? 'now'
561
+ : 'done';
562
+ const on = state === 'now' ||
563
+ hoverEdge === row.edge ||
564
+ (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
565
+ const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
566
+ const ly = y + RAIL.row / 2;
567
+ const g = row.group != null ? rail.groups[row.group] : null;
568
+ return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
569
+ (() => {
570
+ const tagW = ASYNC_TAG_W;
571
+ // The tag sits on the tail side of the pill, so it never hides the arrowhead.
572
+ const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
573
+ return (_jsxs(_Fragment, { children: [_jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: v('bg') }), _jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: TONES.gray, fillOpacity: 0.15 }), _jsx("text", { x: tx + tagW / 2, y: ly + 3, fill: TONES.gray, fontSize: 9, fontWeight: 600, letterSpacing: ".03em", textAnchor: "middle", "data-fig-tag": true, children: "ASYNC" })] }));
574
+ })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? v('accent') : v('bg'), stroke: on ? v('accent') : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
575
+ });
576
+ })(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => setPlaying((p) => !p), children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), _jsx("div", { role: "tablist", style: {
577
+ display: 'inline-flex',
578
+ gap: 2,
579
+ padding: 3,
580
+ borderRadius: 10,
581
+ background: v('surface'),
582
+ border: `1px solid ${v('border')}`,
583
+ }, children: steps.map((s, i) => {
584
+ const on = active === i;
585
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
586
+ clock.current.elapsed = 0; // replay from the start, even when it is already the active step
587
+ setActive(i);
588
+ setPlaying(true);
589
+ setBeat(0);
590
+ }, style: {
591
+ position: 'relative',
592
+ overflow: 'hidden',
593
+ font: 'inherit',
594
+ fontFamily: MONO,
595
+ fontSize: 12.5,
596
+ padding: '5px 14px',
597
+ borderRadius: 7,
598
+ border: 'none',
599
+ cursor: 'pointer',
600
+ background: on ? v('bg') : 'transparent',
601
+ color: on ? v('fg') : v('muted'),
602
+ boxShadow: on ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
603
+ transition: 'background .2s, color .2s',
604
+ }, children: [s.label, on && (_jsx("div", { ref: bar, style: {
605
+ position: 'absolute',
606
+ left: 0,
607
+ right: 0,
608
+ bottom: 0,
609
+ height: 2,
610
+ background: v('accent'),
611
+ transformOrigin: 'left',
612
+ transform: 'scaleX(0)',
613
+ opacity: playing ? 1 : 0.35,
614
+ } }))] }, i));
615
+ }) }), _jsxs("button", { type: "button", "aria-label": `Speed ${rate}×, switch to ${rate === 1 ? 2 : 1}×`, title: "Playback speed", style: {
616
+ ...iconBtn,
617
+ width: 'auto',
618
+ padding: '0 10px',
619
+ font: 'inherit',
620
+ fontFamily: MONO,
621
+ fontSize: 12,
622
+ color: rate === 2 ? v('accent') : v('muted'),
623
+ borderColor: rate === 2 ? v('accent') : v('border'),
624
+ }, onClick: () => setRate((r) => (r === 1 ? 2 : 1)), children: [rate, "\u00D7"] })] }), said != null && (_jsx("div", { style: {
625
+ marginTop: 12,
626
+ fontSize: 13.5,
627
+ lineHeight: 1.5,
628
+ color: v('muted'),
629
+ minHeight: '3em',
630
+ maxWidth: 640,
631
+ marginInline: 'auto',
632
+ animation: 'flowfig-in .35s ease-out',
633
+ }, children: said }, `${active}-${saidAt}`))] }))] }));
634
+ }
635
+ /** A 16px stroke icon drawn from one SVG path. */
636
+ function Icon({ d, fill = false }) {
637
+ return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 16 16", fill: fill ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: d }) }));
638
+ }