flowfig 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/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, useId, 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, 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, } 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]})`;
@@ -54,10 +55,23 @@ const HIDDEN = {
54
55
  * The interactive player for one figure. Give it a `FlowProps` spec.
55
56
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
56
57
  */
57
- 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, }) {
58
71
  const tl = timeline && isLanesLayout(layout);
59
72
  const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
60
73
  const descId = useId();
74
+ const tabsId = useId();
61
75
  const synthetic = tl && !stepsIn.length;
62
76
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
63
77
  const [axisW, setAxisW] = useState(TL_AXIS_W);
@@ -82,6 +96,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
82
96
  const firstLabel = useRef('');
83
97
  const paths = useRef({});
84
98
  const [routed, setRouted] = useState([]);
99
+ const markSides = new Set(nodes(layout).flatMap((n) => (n.mark ? [markSide(n.id, n.mark, routed)] : [])));
85
100
  const [extraTall, setExtraTall] = useState(new Set());
86
101
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
87
102
  ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
@@ -129,6 +144,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
129
144
  const railOnly = withRail === 'only';
130
145
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
131
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);
132
152
  const jump = useRef(null);
133
153
  const [hoverEdge, setHoverEdge] = useState(null);
134
154
  const [still, setStill] = useState(false);
@@ -148,8 +168,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
148
168
  const el = root.current, box = outer.current;
149
169
  if (!el || !box)
150
170
  return;
171
+ let last = '';
151
172
  const measure = () => {
152
- 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);
153
179
  setFit({ scale, height: el.offsetHeight * scale });
154
180
  setMapW(el.offsetWidth);
155
181
  if (area.current)
@@ -202,6 +228,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
202
228
  const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
203
229
  return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
204
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;
205
235
  const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
206
236
  const next = route(edges.map((e, i) => ({
207
237
  id: ids[i],
@@ -209,6 +239,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
209
239
  to: end(ids[i], e.to, false),
210
240
  around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
211
241
  ...(tl && { sides: ['r', 'l'], elbow: true }),
242
+ ...(rowBreaks.has(e) && { drop: true }),
212
243
  ...(stubs.has(ids[i]) && {
213
244
  stub: stubs.get(ids[i]).map(labelPillW),
214
245
  bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
@@ -224,16 +255,38 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
224
255
  };
225
256
  measure();
226
257
  const ro = new ResizeObserver(measure);
227
- ro.observe(el);
258
+ ro.observe(el, { box: 'border-box' });
228
259
  ro.observe(box);
229
260
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
230
261
  return () => ro.disconnect();
231
- }, [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]);
232
283
  const reported = useRef(new Set());
233
284
  useEffect(() => {
234
285
  const el = root.current, box = outer.current;
235
286
  const fig = figure.current;
236
- 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)
237
290
  return;
238
291
  const base = (el ?? fig).getBoundingClientRect();
239
292
  const k = el ? base.width / el.offsetWidth : 1;
@@ -273,11 +326,15 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
273
326
  // An SVG <text> has no CSS box, so skip clientWidth.
274
327
  (t instanceof SVGElement || t.clientWidth > 0) &&
275
328
  !t.closest('[data-fig-tag],[aria-hidden]'))
276
- .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
+ });
277
334
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
278
335
  const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
279
336
  const scene = {
280
- width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
337
+ width: (box ?? fig).clientWidth,
281
338
  boxes,
282
339
  ...(lanePlan?.stubs.size && {
283
340
  lanes: lanesIn.map((n) => ({
@@ -321,11 +378,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
321
378
  ],
322
379
  minFont: Math.min(...fonts),
323
380
  };
324
- for (const f of [
325
- ...checkSpec({ layout, edges, steps }),
326
- ...checkScene(scene, { width: (box ?? fig).clientWidth }),
327
- ...checkTheme(theme),
328
- ]) {
381
+ for (const f of [...checkSpec(written), ...checkScene(scene, { width: (box ?? fig).clientWidth }), ...checkTheme(theme)]) {
329
382
  const key = f.rule + ':' + f.ids.join() + ':' + f.message;
330
383
  if (reported.current.has(key))
331
384
  continue;
@@ -333,7 +386,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
333
386
  // oxlint-disable-next-line no-console
334
387
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
335
388
  }
336
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
389
+ }, [check, routed, written, layout, edges, steps, ids, theme, rail, noMap, lanePlan, wide, setWide]);
337
390
  useEffect(() => {
338
391
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
339
392
  if (clock.current.beats !== beats) {
@@ -453,6 +506,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
453
506
  return t && TONES[t];
454
507
  };
455
508
  const goTo = (step, b) => {
509
+ follow.current = true;
456
510
  setPlaying(true);
457
511
  if (step === active) {
458
512
  clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
@@ -648,15 +702,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
648
702
  display: 'flex',
649
703
  flexDirection: item.direction ?? 'row',
650
704
  gap: groupGap(item, edges),
651
- alignItems: item.align
652
- ? item.align === 'center'
653
- ? 'center'
654
- : 'flex-' + item.align
655
- : item.direction === 'column'
656
- ? 'stretch'
657
- : 'center',
705
+ alignItems: !item.align || item.align === 'center' ? 'center' : 'flex-' + item.align,
658
706
  justifyContent: 'center',
659
- }, 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}`));
660
708
  }
661
709
  const lit = litNodes.has(item.id);
662
710
  const diamond = item.shape === 'decision';
@@ -711,14 +759,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
711
759
  transition: 'border-color .25s',
712
760
  } })), item.mark && (_jsx("span", { "aria-hidden": true, style: {
713
761
  position: 'absolute',
714
- top: '50%',
715
- transform: 'translateY(-50%)',
762
+ ...markAt(markSide(item.id, item.mark, routed), item.mark === 'start' ? -18 : -20),
716
763
  boxSizing: 'border-box',
717
764
  borderRadius: '50%',
718
765
  ...(item.mark === 'start'
719
- ? { left: -18, width: 10, height: 10, background: bt ?? v('accent') }
766
+ ? { width: 10, height: 10, background: bt ?? v('accent') }
720
767
  : {
721
- right: -20,
722
768
  width: 14,
723
769
  height: 14,
724
770
  border: `1.5px solid ${bt ?? v('accent')}`,
@@ -788,8 +834,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
788
834
  borderBottom: `1px solid ${v('border')}`,
789
835
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
790
836
  backgroundSize: '14px 14px',
791
- }, children: _jsx("div", { ref: outer, style: {
792
- 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',
793
839
  height: fit.scale < 1 ? fit.height : undefined,
794
840
  }, children: _jsxs("div", { ref: root, style: {
795
841
  position: 'relative',
@@ -797,8 +843,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
797
843
  margin: '0 auto',
798
844
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
799
845
  transformOrigin: 'top left',
800
- paddingTop: arcRoom(routed, 'above') || 4,
801
- 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),
802
848
  paddingLeft: arcRoom(routed, 'left') || 4,
803
849
  paddingRight: arcRoom(routed, 'right') || 4,
804
850
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
@@ -879,52 +925,72 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
879
925
  fontFamily: MONO,
880
926
  transition: 'background .25s, color .25s, opacity .25s',
881
927
  }, children: p.text }, p.key)));
882
- })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
883
- display: 'block',
884
- width: '100%',
885
- maxWidth: rail.width,
886
- height: 'auto',
887
- margin: `${noMap ? 0 : RAIL.gap}px auto 0`,
888
- overflow: 'visible',
889
- fontFamily: v('font'),
890
- }, 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))), (() => {
891
- const st = railState(rail, active ?? 0);
892
- 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}`) : []);
893
- return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
894
- })(), (() => {
895
- const st = railState(rail, active ?? 0);
896
- return rail.rows.map((row, i) => {
897
- if (!st[i].shown)
898
- return null;
899
- const y = st[i].y;
900
- if (row.kind === 'phase') {
901
- const open = !rail.folds || row.step === active;
902
- const playing = row.step === active ? rail.rows.find((r) => r.kind === 'message' && r.step === active && r.beat === beat) : undefined;
903
- 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}`));
904
- }
905
- const state = still
906
- ? 'done'
907
- : active == null || row.step > active || (row.step === active && row.beat > beat)
908
- ? 'next'
909
- : row.step === active && row.beat === beat
910
- ? 'now'
911
- : 'done';
912
- const on = state === 'now' ||
913
- hoverEdge === row.edge ||
914
- (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
915
- const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
916
- const ly = y + RAIL.row / 2;
917
- const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
918
- const g = row.group != null ? rail.groups[row.group] : null;
919
- const tip = edgeTip(row.edge, edges[ids.indexOf(row.edge)].source, allBeats);
920
- 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 &&
921
- (() => {
922
- const tagW = ASYNC_TAG_W;
923
- const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
924
- 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" })] }));
925
- })(), 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}`));
926
- });
927
- })(), _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: {
928
994
  display: 'inline-flex',
929
995
  gap: 2,
930
996
  padding: 3,
@@ -933,8 +999,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
933
999
  border: `1px solid ${v('border')}`,
934
1000
  }, children: steps.map((s, i) => {
935
1001
  const on = active === i;
936
- 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: () => {
937
1003
  clock.current.elapsed = 0;
1004
+ follow.current = true;
938
1005
  setActive(i);
939
1006
  setPlaying(true);
940
1007
  setBeat(0);
@@ -983,6 +1050,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
983
1050
  animation: 'flowfig-in .35s ease-out',
984
1051
  }, children: said }, `${active}-${saidAt}`))] }))] }));
985
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%)' };
986
1056
  function Icon({ d, fill = false }) {
987
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 }) }));
988
1058
  }
package/dist/load.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { altText, counts, toBeat } from './model.js';
3
- import { coverageLine, owners, unsureLines, verifyReport } from './verify.js';
3
+ import { coverageLine, detailFindings, owners, unsureLines, verifyReport } from './verify.js';
4
4
  import { toSvg } from './svg.js';
5
5
  const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
6
6
  const SPEC_CLOSE = ']]></metadata>';
@@ -68,8 +68,9 @@ export function summaryLines(props) {
68
68
  return [...edges, ...steps, `alt: ${altText(props).title}`];
69
69
  }
70
70
  export function verifyLines(props, figure, root = process.cwd()) {
71
- if (!owners(props).some(([, source, via]) => source != null || via != null))
72
- return [];
71
+ if (!owners(props).some(([, source, via]) => source != null || via != null)) {
72
+ return detailFindings(props, root).map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`);
73
+ }
73
74
  const { findings, coverage } = verifyReport(props, { root });
74
75
  return [
75
76
  ...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`),
@@ -0,0 +1,19 @@
1
+ import { type FlowProps } from './model.ts';
2
+ export type MermaidFault = {
3
+ line: number;
4
+ reason: string;
5
+ };
6
+ type Result = {
7
+ spec: FlowProps;
8
+ faults: [];
9
+ } | {
10
+ spec?: undefined;
11
+ faults: MermaidFault[];
12
+ };
13
+ export declare function fromMermaid(text: string, first?: number): Result;
14
+ export declare function markdownBlocks(text: string): {
15
+ text: string;
16
+ line: number;
17
+ }[];
18
+ export declare function runFromMermaid(argv: string[]): number;
19
+ export {};