flowfig 0.8.4 → 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/geometry.js CHANGED
@@ -52,9 +52,18 @@ const spanOf = (a, b, around) => {
52
52
  return [Math.min(p, q), Math.max(p, q)];
53
53
  };
54
54
  const SIDES = { above: ['t', 't'], below: ['b', 'b'], left: ['l', 'l'], right: ['r', 'r'] };
55
+ export const samples = (curve) => {
56
+ const hull = curve.slice(1).reduce((s, q, k) => s + Math.hypot(q.x - curve[k].x, q.y - curve[k].y), 0);
57
+ const n = Math.max(32, Math.ceil(hull / 4));
58
+ return Array.from({ length: n + 1 }, (_, i) => bezier(curve, i / n));
59
+ };
55
60
  export const hit = (curve, boxes, m) => {
56
- const pts = Array.from({ length: 33 }, (_, i) => bezier(curve, i / 32));
57
- return boxes.filter((r) => pts.some((q) => q.x > r.x - m && q.x < r.x + r.w + m && q.y > r.y - m && q.y < r.y + r.h + m));
61
+ const xs = curve.map((q) => q.x), ys = curve.map((q) => q.y);
62
+ const near = boxes.filter((r) => r.x + r.w + m > Math.min(...xs) && r.x - m < Math.max(...xs) && r.y + r.h + m > Math.min(...ys) && r.y - m < Math.max(...ys));
63
+ if (!near.length)
64
+ return [];
65
+ const pts = samples(curve);
66
+ return near.filter((r) => pts.some((q) => q.x > r.x - m && q.x < r.x + r.w + m && q.y > r.y - m && q.y < r.y + r.h + m));
58
67
  };
59
68
  /** Routes edges between measured boxes as curved SVG paths. `elbow` draws right-angle paths; `stub` splits a cross-lane edge in two. */
60
69
  export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, pad = 24) {
@@ -68,7 +77,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
68
77
  const others = avoid.filter((r) => r.box && !same(r, a) && !same(r, b));
69
78
  const plain = stacked ? (a.y < b.y ? ['b', 't'] : ['t', 'b']) : a.x < b.x ? ['r', 'l'] : ['l', 'r'];
70
79
  const past = hit(bend(sideMid(a, plain[0]), sideMid(b, plain[1]), plain[0]), others, -2);
71
- const misses = (way, at) => [0.2, 0.5, 0.8].filter((f) => hit(arcLine(sideAt(a, SIDES[way][0], f), sideAt(b, SIDES[way][1], f), way, at), others, -2).length > 0)
80
+ const misses = (way, at) => [0.2, 0.5, 0.8].filter((f) => hit(arcLine(sideAt(a, SIDES[way][0], f), sideAt(b, SIDES[way][1], f), way, at), others, -6).length > 0)
72
81
  .length;
73
82
  const blocked = (way, at) => misses(way, at) === 3;
74
83
  let around = e.around;
@@ -81,7 +90,10 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
81
90
  if (blocked(around, arc))
82
91
  around = undefined;
83
92
  }
84
- if (!around && !e.sides && !e.stub && !e.elbow && past.length) {
93
+ const down = e.drop && !hit(bend(sideMid(a, 'b'), sideMid(b, 't'), 'b'), others, -2).length;
94
+ if (e.drop && !around)
95
+ elbow = !down;
96
+ else if (!around && !e.sides && !e.stub && !e.elbow && past.length) {
85
97
  const ways = stacked ? ['left', 'right'] : ['above', 'below'];
86
98
  const tries = [];
87
99
  for (const share of [false, true])
@@ -107,7 +119,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
107
119
  const [lo, hi] = spanOf(a, b, around);
108
120
  arcs.push({ way: around, lo, hi, at: arc });
109
121
  }
110
- const [sa, sb] = e.sides ?? (e.stub ? ['r', 'l'] : around ? SIDES[around] : plain);
122
+ const [sa, sb] = e.sides ?? (e.stub ? ['r', 'l'] : down ? ['b', 't'] : around ? SIDES[around] : plain);
111
123
  picks.push({ ...e, plain, a, b, sa, sb, around, arc, elbow });
112
124
  }
113
125
  let anchor = new Map();
@@ -152,10 +164,10 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
152
164
  if (!p.around || !s || !e)
153
165
  continue;
154
166
  const others = avoid.filter((r) => r.box && !same(r, p.a) && !same(r, p.b));
155
- if (!hit(arcLine(s, e, p.around, p.arc), others, -2).length)
167
+ if (!hit(arcLine(s, e, p.around, p.arc), others, -6).length)
156
168
  continue;
157
169
  const [pa, pb] = p.plain;
158
- const straight = hit(bend(sideMid(p.a, pa), sideMid(p.b, pb), pa), others, -2).length > 0;
170
+ const straight = hit(bend(sideMid(p.a, pa), sideMid(p.b, pb), pa), others, -6).length > 0;
159
171
  [p.sa, p.sb, p.around, p.arc, p.elbow, moved] = [pa, pb, undefined, undefined, p.elbow || straight, true];
160
172
  }
161
173
  if (moved)
@@ -183,7 +195,15 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
183
195
  const outside = (way, { x, y }) => (way === 'above' ? y0 - y : way === 'below' ? y - y1 : way === 'left' ? x0 - x : x - x1);
184
196
  const roomOf = (way, at, curve, labelW) => Math.max(ARC - 6, lanes || !labelW ? outside(way, { x: at, y: at }) - 6 : outside(way, bezier(curve, 0.5)) + (upright(way) ? labelW / 2 : 9));
185
197
  const one = (p) => {
186
- const s = anchor.get(p.id + ':s'), e = anchor.get(p.id + ':e');
198
+ const turn = !p.stub && p.elbow && p.sa === 'l';
199
+ const [s, e] = [anchor.get(p.id + (turn ? ':e' : ':s')), anchor.get(p.id + (turn ? ':s' : ':e'))];
200
+ const flip = (r) => {
201
+ if (!turn)
202
+ return r;
203
+ const pts = [...r.elbow].reverse();
204
+ const d = pts.slice(1).reduce((d, q, k) => d + (q.y === pts[k].y ? ` H ${q.x}` : ` V ${q.y}`), `M ${pts[0].x} ${pts[0].y}`);
205
+ return { ...r, d, curve: [...r.curve].reverse(), elbow: pts };
206
+ };
187
207
  if (p.stub) {
188
208
  const [ow, iw, sw] = p.stub;
189
209
  const [sb, tb] = p.bands ?? [];
@@ -336,7 +356,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
336
356
  }
337
357
  if (p.elbow) {
338
358
  if (e.x - s.x < 16 && s.y === e.y) {
339
- return { id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e], elbow: [s, e] };
359
+ return flip({ id: p.id, d: `M ${s.x} ${s.y} H ${e.x}`, mid: { x: (s.x + e.x) / 2, y: s.y }, curve: [s, s, e, e], elbow: [s, e] });
340
360
  }
341
361
  const own = (r) => [p.a, p.b].some((q) => same(q, r));
342
362
  const all = avoid.filter((r) => !own(r));
@@ -347,14 +367,14 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
347
367
  const mx = s.x + 8, ex = e.x - 8, mid = (s.y + e.y) / 2;
348
368
  const ys = Array.from({ length: Math.floor((y1 - y0 + 24) / 4) + 1 }, (_, i) => y0 - 12 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
349
369
  const my = ys.find((y) => vClear(mx, s.y, y) && hClear(y, ex, mx, all) && vClear(ex, y, e.y)) ?? mid;
350
- return {
370
+ return flip({
351
371
  id: p.id,
352
372
  d: `M ${s.x} ${s.y} H ${mx} V ${my} H ${ex} V ${e.y} H ${e.x}`,
353
373
  mid: { x: (mx + ex) / 2, y: my },
354
374
  curve: [s, { x: mx, y: my }, { x: ex, y: my }, e],
355
375
  elbow: [s, { x: mx, y: s.y }, { x: mx, y: my }, { x: ex, y: my }, { x: ex, y: e.y }, e],
356
376
  bleed: { above: room(y0 - my), below: room(my - y1) },
357
- };
377
+ });
358
378
  }
359
379
  const mid = (s.x + e.x) / 2;
360
380
  const gap = Array.from({ length: Math.floor((e.x - s.x - 16) / 4) + 1 }, (_, i) => s.x + 8 + i * 4).sort((u, w) => Math.abs(u - mid) - Math.abs(w - mid));
@@ -383,7 +403,7 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
383
403
  }
384
404
  if (best) {
385
405
  const { x, y, ex } = best;
386
- return {
406
+ return flip({
387
407
  id: p.id,
388
408
  d: `M ${s.x} ${s.y} H ${x} V ${y} H ${ex} V ${e.y} H ${e.x}`,
389
409
  mid: { x: (x + ex) / 2, y },
@@ -395,18 +415,18 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
395
415
  above: room(y0 - y),
396
416
  below: room(y - y1),
397
417
  },
398
- };
418
+ });
399
419
  }
400
420
  }
401
421
  const x = mx ?? mid;
402
422
  const c1 = { x, y: s.y }, c2 = { x, y: e.y };
403
- return {
423
+ return flip({
404
424
  id: p.id,
405
425
  d: `M ${s.x} ${s.y} H ${x} V ${e.y} H ${e.x}`,
406
426
  mid: { x, y: (s.y + e.y) / 2 },
407
427
  curve: [s, c1, c2, e],
408
428
  elbow: [s, c1, c2, e],
409
- };
429
+ });
410
430
  }
411
431
  if (p.around) {
412
432
  const curve = arcLine(s, e, p.around, p.arc);
@@ -456,15 +476,23 @@ export function route(edges, rects, tips = new Set(), avoid = [], area, lanes, p
456
476
  pills.push(...use.out);
457
477
  let k = 0;
458
478
  for (const [p, r] of done) {
459
- if (p.labelW)
479
+ if (p.labelW) {
460
480
  r.mid = use.mids[k++];
481
+ const [l, rt] = [x0 - r.mid.x + p.labelW / 2, r.mid.x + p.labelW / 2 - x1];
482
+ r.bleed = { ...r.bleed, left: Math.max(r.bleed?.left ?? 0, l, 0), right: Math.max(r.bleed?.right ?? 0, rt, 0) };
483
+ }
461
484
  track(Array.from({ length: 33 }, (_, i) => bezier(r.curve, i / 32)));
462
485
  }
463
486
  for (const p of picks)
464
487
  if (p.stub)
465
488
  done.set(p, one(p));
466
- return picks.map((p) => done.get(p));
489
+ return picks.map((p) => ({ ...done.get(p), ends: [`${p.from}:${p.sa}`, `${p.to}:${p.sb}`] }));
467
490
  }
491
+ export const markSide = (id, mark, routed) => {
492
+ const used = new Set(routed.flatMap((r) => r.ends ?? []));
493
+ const order = mark === 'start' ? ['l', 't', 'b', 'r'] : ['r', 'b', 't', 'l'];
494
+ return order.find((s) => !used.has(`${id}:${s}`)) ?? order[0];
495
+ };
468
496
  export const arcRoom = (routed, way) => Math.max(0, ...routed.map((r) => (r.around === way ? r.room : (r.bleed?.[way] ?? 0))));
469
497
  // The player pads its root by 4 px on a side with no detour.
470
498
  const PLAYER_PAD = 4;
package/dist/imports.d.ts CHANGED
@@ -8,4 +8,5 @@ export declare const OUTSIDE = "#outside";
8
8
  export declare function resolveTs(root: string, from: string, spec: string, read: Read): string | null;
9
9
  export declare function resolvePy(root: string, from: string, mod: string, read: Read, top?: string): string | null;
10
10
  export declare function importsOf(root: string, file: CodeFile, read: Read): Import[];
11
+ export declare function reexports(root: string, file: CodeFile, name: string, read: Read): [string | null, string][];
11
12
  export declare function leadsTo(root: string, fromPath: string, toPath: string, name: string, read?: Read, cache?: Map<string, CodeFile | null>, depth?: number): string | null;
package/dist/imports.js CHANGED
@@ -172,7 +172,7 @@ export function importsOf(root, file, read) {
172
172
  }
173
173
  return out;
174
174
  }
175
- function reexports(root, file, name, read) {
175
+ export function reexports(root, file, name, read) {
176
176
  const N = esc(name);
177
177
  const out = [];
178
178
  if (name === '*')
package/dist/index.d.ts CHANGED
@@ -5,4 +5,4 @@ export { LIGHT, DARK } from './model.ts';
5
5
  * The interactive player for one figure. Give it a `FlowProps` spec.
6
6
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
7
7
  */
8
- export declare function Flow({ layout, edges, steps: stepsIn, theme, speed, autoplay, check, rail: withRail, lanes, timeline, today, }: FlowProps): import("react").JSX.Element;
8
+ export declare function Flow(props: FlowProps): import("react").JSX.Element;
package/dist/index.js CHANGED
@@ -1,11 +1,12 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
4
- import { arcRoom, avoidOf, route } from './geometry.js';
3
+ import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { arcRoom, avoidOf, markSide, route } from './geometry.js';
5
5
  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
- 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, 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, } from './model.js';
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
10
  export { LIGHT, DARK } from './model.js';
10
11
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
12
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -42,12 +43,35 @@ const cardBody = (c) => {
42
43
  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));
43
44
  }) }));
44
45
  };
46
+ const HIDDEN = {
47
+ position: 'absolute',
48
+ width: 1,
49
+ height: 1,
50
+ overflow: 'hidden',
51
+ clip: 'rect(0 0 0 0)',
52
+ whiteSpace: 'pre-line',
53
+ };
45
54
  /**
46
55
  * The interactive player for one figure. Give it a `FlowProps` spec.
47
56
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
48
57
  */
49
- export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
58
+ export function Flow(props) {
59
+ const { layout, edges, steps, lanes, timeline } = props;
60
+ const key = useMemo(() => specKey(layout, edges, steps), [layout, edges, steps]);
61
+ const [narrow, setNarrow] = useState({ wide: 0, key: '' });
62
+ const fold = !lanes && !timeline && props.rail !== 'only';
63
+ const wide = fold && narrow.key === key ? narrow.wide : 0;
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]);
66
+ return (_jsx(FlowBody, { ...props, layout: placed.layout, edges: placed.edges, written: props, wide: wide, setWide: fold ? setWide : undefined }));
67
+ }
68
+ // The ASYNC tag is 9 px; phone text needs 11 px.
69
+ const RAIL_ZOOM = 11 / 9;
70
+ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, }) {
50
71
  const tl = timeline && isLanesLayout(layout);
72
+ const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
73
+ const descId = useId();
74
+ const tabsId = useId();
51
75
  const synthetic = tl && !stepsIn.length;
52
76
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
53
77
  const [axisW, setAxisW] = useState(TL_AXIS_W);
@@ -72,6 +96,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
72
96
  const firstLabel = useRef('');
73
97
  const paths = useRef({});
74
98
  const [routed, setRouted] = useState([]);
99
+ const markSides = new Set(nodes(layout).flatMap((n) => (n.mark ? [markSide(n.id, n.mark, routed)] : [])));
75
100
  const [extraTall, setExtraTall] = useState(new Set());
76
101
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
77
102
  ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
@@ -119,6 +144,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
119
144
  const railOnly = withRail === 'only';
120
145
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
121
146
  const noMap = railOnly && rail != null;
147
+ const railPane = useRef(null);
148
+ const follow = useRef(true);
149
+ const user = useRef(false);
150
+ const left = useRef(0);
151
+ const busy = useRef(0);
122
152
  const jump = useRef(null);
123
153
  const [hoverEdge, setHoverEdge] = useState(null);
124
154
  const [still, setStill] = useState(false);
@@ -138,8 +168,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
138
168
  const el = root.current, box = outer.current;
139
169
  if (!el || !box)
140
170
  return;
171
+ let last = '';
141
172
  const measure = () => {
142
- const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
173
+ if (!el.offsetWidth)
174
+ return;
175
+ const goal = nextWide(wide, box.clientWidth, el.offsetWidth);
176
+ if (setWide && goal !== wide)
177
+ return setWide(goal);
178
+ const scale = fitScale(wide, box.clientWidth, el.offsetWidth);
143
179
  setFit({ scale, height: el.offsetHeight * scale });
144
180
  setMapW(el.offsetWidth);
145
181
  if (area.current)
@@ -192,6 +228,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
192
228
  const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
193
229
  return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
194
230
  };
231
+ const key = JSON.stringify([rects, extra, bands, laneBands, pillArea], (_k, v) => (typeof v === 'number' ? Math.round(v) : v));
232
+ if (key === last)
233
+ return;
234
+ last = key;
195
235
  const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
196
236
  const next = route(edges.map((e, i) => ({
197
237
  id: ids[i],
@@ -199,6 +239,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
199
239
  to: end(ids[i], e.to, false),
200
240
  around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
201
241
  ...(tl && { sides: ['r', 'l'], elbow: true }),
242
+ ...(rowBreaks.has(e) && { drop: true }),
202
243
  ...(stubs.has(ids[i]) && {
203
244
  stub: stubs.get(ids[i]).map(labelPillW),
204
245
  bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
@@ -214,16 +255,38 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
214
255
  };
215
256
  measure();
216
257
  const ro = new ResizeObserver(measure);
217
- ro.observe(el);
258
+ ro.observe(el, { box: 'border-box' });
218
259
  ro.observe(box);
219
260
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
220
261
  return () => ro.disconnect();
221
- }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
262
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall, wide, setWide]);
263
+ useEffect(() => {
264
+ const pane = railPane.current;
265
+ 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;
267
+ if (!pane || !now)
268
+ return;
269
+ const [p, r] = [pane.getBoundingClientRect(), now.getBoundingClientRect()];
270
+ user.current = false;
271
+ // Some browsers fire no scrollend event.
272
+ busy.current = performance.now() + 1000;
273
+ 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]);
275
+ useEffect(() => {
276
+ const pane = railPane.current;
277
+ if (!pane)
278
+ return;
279
+ const end = () => (busy.current = 0);
280
+ pane.addEventListener('scrollend', end);
281
+ return () => pane.removeEventListener('scrollend', end);
282
+ }, [rail]);
222
283
  const reported = useRef(new Set());
223
284
  useEffect(() => {
224
285
  const el = root.current, box = outer.current;
225
286
  const fig = figure.current;
226
- if (!check || !fig || (!noMap && (!el || !box)))
287
+ if (!check || !fig || (!noMap && (!el || !box || !el.offsetWidth)))
288
+ return;
289
+ if (setWide && el && box && nextWide(wide, box.clientWidth, el.offsetWidth) !== wide)
227
290
  return;
228
291
  const base = (el ?? fig).getBoundingClientRect();
229
292
  const k = el ? base.width / el.offsetWidth : 1;
@@ -263,11 +326,15 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
263
326
  // An SVG <text> has no CSS box, so skip clientWidth.
264
327
  (t instanceof SVGElement || t.clientWidth > 0) &&
265
328
  !t.closest('[data-fig-tag],[aria-hidden]'))
266
- .map((t) => parseFloat(getComputedStyle(t).fontSize));
329
+ .map((t) => {
330
+ 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;
333
+ });
267
334
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
268
335
  const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
269
336
  const scene = {
270
- width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
337
+ width: (box ?? fig).clientWidth,
271
338
  boxes,
272
339
  ...(lanePlan?.stubs.size && {
273
340
  lanes: lanesIn.map((n) => ({
@@ -311,11 +378,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
311
378
  ],
312
379
  minFont: Math.min(...fonts),
313
380
  };
314
- for (const f of [
315
- ...checkSpec({ layout, edges, steps }),
316
- ...checkScene(scene, { width: (box ?? fig).clientWidth }),
317
- ...checkTheme(theme),
318
- ]) {
381
+ for (const f of [...checkSpec(written), ...checkScene(scene, { width: (box ?? fig).clientWidth }), ...checkTheme(theme)]) {
319
382
  const key = f.rule + ':' + f.ids.join() + ':' + f.message;
320
383
  if (reported.current.has(key))
321
384
  continue;
@@ -323,7 +386,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
323
386
  // oxlint-disable-next-line no-console
324
387
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
325
388
  }
326
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
389
+ }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, lanePlan, wide, setWide]);
327
390
  useEffect(() => {
328
391
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
329
392
  if (clock.current.beats !== beats) {
@@ -443,6 +506,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
443
506
  return t && TONES[t];
444
507
  };
445
508
  const goTo = (step, b) => {
509
+ follow.current = true;
446
510
  setPlaying(true);
447
511
  if (step === active) {
448
512
  clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
@@ -638,15 +702,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
638
702
  display: 'flex',
639
703
  flexDirection: item.direction ?? 'row',
640
704
  gap: groupGap(item, edges),
641
- alignItems: item.align
642
- ? item.align === 'center'
643
- ? 'center'
644
- : 'flex-' + item.align
645
- : item.direction === 'column'
646
- ? 'stretch'
647
- : 'center',
705
+ alignItems: !item.align || item.align === 'center' ? 'center' : 'flex-' + item.align,
648
706
  justifyContent: 'center',
649
- }, children: item.children.map((c) => renderItem(c, depth + 1)) })] }, item.id ?? depth + String(item.label)));
707
+ }, children: item.children.map((c) => renderItem(c, depth + 1)) })] }, item.id ?? `group:${nodes(item)[0]?.id}`));
650
708
  }
651
709
  const lit = litNodes.has(item.id);
652
710
  const diamond = item.shape === 'decision';
@@ -701,14 +759,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
701
759
  transition: 'border-color .25s',
702
760
  } })), item.mark && (_jsx("span", { "aria-hidden": true, style: {
703
761
  position: 'absolute',
704
- top: '50%',
705
- transform: 'translateY(-50%)',
762
+ ...markAt(markSide(item.id, item.mark, routed), item.mark === 'start' ? -18 : -20),
706
763
  boxSizing: 'border-box',
707
764
  borderRadius: '50%',
708
765
  ...(item.mark === 'start'
709
- ? { left: -18, width: 10, height: 10, background: bt ?? v('accent') }
766
+ ? { width: 10, height: 10, background: bt ?? v('accent') }
710
767
  : {
711
- right: -20,
712
768
  width: 14,
713
769
  height: 14,
714
770
  border: `1.5px solid ${bt ?? v('accent')}`,
@@ -747,7 +803,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
747
803
  background: v('bg'),
748
804
  color: v('muted'),
749
805
  };
750
- return (_jsxs("figure", { ref: figure, className: "flowfig", style: {
806
+ return (_jsxs("figure", { ref: figure, className: "flowfig", "aria-label": alt.title, "aria-describedby": descId, style: {
751
807
  ...vars,
752
808
  position: full ? 'fixed' : 'relative',
753
809
  inset: full ? 0 : undefined,
@@ -760,7 +816,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
760
816
  background: v('bg'),
761
817
  fontFamily: v('font'),
762
818
  color: v('fg'),
763
- }, children: [_jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
819
+ }, children: [_jsx("div", { id: descId, style: HIDDEN, children: alt.desc }), _jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull((f) => !f), style: {
764
820
  ...iconBtn,
765
821
  position: 'absolute',
766
822
  top: 10,
@@ -778,8 +834,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
778
834
  borderBottom: `1px solid ${v('border')}`,
779
835
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
780
836
  backgroundSize: '14px 14px',
781
- }, children: _jsx("div", { ref: outer, style: {
782
- overflow: fit.scale > 0.5 ? 'hidden' : 'auto',
837
+ }, 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',
783
839
  height: fit.scale < 1 ? fit.height : undefined,
784
840
  }, children: _jsxs("div", { ref: root, style: {
785
841
  position: 'relative',
@@ -787,8 +843,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
787
843
  margin: '0 auto',
788
844
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
789
845
  transformOrigin: 'top left',
790
- paddingTop: arcRoom(routed, 'above') || 4,
791
- paddingBottom: arcRoom(routed, 'below') || 4,
846
+ paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
847
+ paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
792
848
  paddingLeft: arcRoom(routed, 'left') || 4,
793
849
  paddingRight: arcRoom(routed, 'right') || 4,
794
850
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
@@ -869,52 +925,72 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
869
925
  fontFamily: MONO,
870
926
  transition: 'background .25s, color .25s, opacity .25s',
871
927
  }, children: p.text }, p.key)));
872
- })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
873
- display: 'block',
874
- width: '100%',
875
- maxWidth: rail.width,
876
- height: 'auto',
877
- margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
878
- overflow: 'visible',
879
- fontFamily: v('font'),
880
- }, 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))), (() => {
881
- const st = railState(rail, active ?? 0);
882
- 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}`) : []);
883
- return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
884
- })(), (() => {
885
- const st = railState(rail, active ?? 0);
886
- return rail.rows.map((row, i) => {
887
- if (!st[i].shown)
888
- return null;
889
- const y = st[i].y;
890
- if (row.kind === 'phase') {
891
- const open = !rail.folds || row.step === active;
892
- const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
893
- 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}`));
894
- }
895
- const state = still
896
- ? 'done'
897
- : active == null || row.step > active || (row.step === active && row.beat > beat)
898
- ? 'next'
899
- : row.step === active && row.beat === beat
900
- ? 'now'
901
- : 'done';
902
- const on = state === 'now' ||
903
- hoverEdge === row.edge ||
904
- (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
905
- const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
906
- const ly = y + RAIL.row / 2;
907
- const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
908
- const g = row.group != null ? rail.groups[row.group] : null;
909
- const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
910
- 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: [tip && _jsx("title", { children: tip }), g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
911
- (() => {
912
- const tagW = ASYNC_TAG_W;
913
- const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
914
- 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" })] }));
915
- })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
916
- });
917
- })(), _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 }) }), !synthetic && (_jsx("div", { role: "tablist", style: {
928
+ })] }) }) }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
929
+ const x = e.currentTarget.scrollLeft;
930
+ if (user.current && x !== left.current && performance.now() > busy.current)
931
+ follow.current = false;
932
+ left.current = x;
933
+ }, style: { overflowX: wide ? 'auto' : undefined }, children: _jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
934
+ display: 'block',
935
+ width: wide ? rail.width * RAIL_ZOOM : '100%',
936
+ maxWidth: wide ? undefined : rail.width,
937
+ height: 'auto',
938
+ margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
939
+ overflow: 'visible',
940
+ fontFamily: v('font'),
941
+ }, 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))), (() => {
942
+ const st = railState(rail, active ?? 0);
943
+ 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}`) : []);
944
+ return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
945
+ })(), (() => {
946
+ const st = railState(rail, active ?? 0);
947
+ return rail.rows.map((row, i) => {
948
+ if (!st[i].shown)
949
+ return null;
950
+ const y = st[i].y;
951
+ if (row.kind === 'phase') {
952
+ const open = !rail.folds || row.step === active;
953
+ const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
954
+ 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}`));
955
+ }
956
+ const state = still
957
+ ? 'done'
958
+ : active == null || row.step > active || (row.step === active && row.beat > beat)
959
+ ? 'next'
960
+ : row.step === active && row.beat === beat
961
+ ? 'now'
962
+ : 'done';
963
+ const on = state === 'now' ||
964
+ hoverEdge === row.edge ||
965
+ (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
966
+ const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
967
+ const ly = y + RAIL.row / 2;
968
+ const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
969
+ const g = row.group != null ? rail.groups[row.group] : null;
970
+ const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
971
+ 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: [tip && _jsx("title", { children: tip }), g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
972
+ (() => {
973
+ const tagW = ASYNC_TAG_W;
974
+ const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
975
+ 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" })] }));
976
+ })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
977
+ });
978
+ })(), _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: () => {
979
+ follow.current ||= !playing;
980
+ setPlaying((p) => !p);
981
+ }, children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), !synthetic && (_jsx("div", { role: "tablist", onKeyDown: (e) => {
982
+ const to = { ArrowRight: (active ?? 0) + 1, ArrowLeft: (active ?? 0) - 1, Home: 0, End: steps.length - 1 }[e.key];
983
+ if (to === undefined)
984
+ return;
985
+ e.preventDefault();
986
+ const n = (to + steps.length) % steps.length;
987
+ clock.current.elapsed = 0;
988
+ follow.current = true;
989
+ setActive(n);
990
+ setPlaying(true);
991
+ setBeat(0);
992
+ document.getElementById(`${tabsId}-tab-${n}`)?.focus();
993
+ }, style: {
918
994
  display: 'inline-flex',
919
995
  gap: 2,
920
996
  padding: 3,
@@ -923,8 +999,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
923
999
  border: `1px solid ${v('border')}`,
924
1000
  }, children: steps.map((s, i) => {
925
1001
  const on = active === i;
926
- return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
1002
+ return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, id: `${tabsId}-tab-${i}`, "aria-controls": noMap ? undefined : `${tabsId}-panel`, tabIndex: on ? 0 : -1, onClick: () => {
927
1003
  clock.current.elapsed = 0;
1004
+ follow.current = true;
928
1005
  setActive(i);
929
1006
  setPlaying(true);
930
1007
  setBeat(0);
@@ -973,6 +1050,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
973
1050
  animation: 'flowfig-in .35s ease-out',
974
1051
  }, children: said }, `${active}-${saidAt}`))] }))] }));
975
1052
  }
1053
+ const markAt = (side, off) => side === 'l' || side === 'r'
1054
+ ? { [side === 'l' ? 'left' : 'right']: off, top: '50%', transform: 'translateY(-50%)' }
1055
+ : { [side === 't' ? 'top' : 'bottom']: off, left: '50%', transform: 'translateX(-50%)' };
976
1056
  function Icon({ d, fill = false }) {
977
1057
  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 }) }));
978
1058
  }