flowfig 0.11.0 → 0.12.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,12 +1,13 @@
1
1
  'use client';
2
+ import { createElement as _createElement } from "react";
2
3
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { Fragment, useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
4
+ import { Fragment, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from 'react';
4
5
  import { arcRoom, avoidOf, markSide, route } from './geometry.js';
5
6
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
7
  import { textWidth } from './text.js';
7
8
  import { checkScene, checkSpec, checkTheme } from './check.js';
8
9
  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, shownFont, textFloor, specKey, nextWide, } from './model.js';
10
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, lanePlan as planLanes, laneEnd, laneBlock, STUB_ROOM, labelPillW, str, altText, LANE_BLOCK_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, diamondLines, diamondRoom, tightCopies, nodeWidth, labelLines, fitCap, LABEL_LINE, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, edgeTip, fitScale, shownFont, textFloor, specKey, nextWide, scenes, insideStep, flowOrder, readMs, portLabel, OPEN_MS, } from './model.js';
10
11
  export { LIGHT, DARK } from './model.js';
11
12
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
12
13
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
@@ -56,15 +57,64 @@ const HIDDEN = {
56
57
  * Use `toSvg` from `flowfig/svg` for a static animated SVG.
57
58
  */
58
59
  export function Flow(props) {
59
- const { layout, edges, steps, lanes, timeline } = props;
60
+ const all = useMemo(() => scenes(props), [props.layout, props.edges, props.steps, props.lanes, props.timeline, props.rail, props.theme, props.speed]);
61
+ const [path, setPath] = useState([]);
62
+ const [full, setFull] = useState(false);
63
+ const [start, setStart] = useState();
64
+ const memo = useRef({});
65
+ const at = path.join('>');
66
+ const s = all.find((x) => x.path.join('>') === at) ?? all[0];
67
+ const { layout, edges, steps, lanes, timeline } = s.props;
60
68
  const key = useMemo(() => specKey(layout, edges, steps), [layout, edges, steps]);
61
69
  const [narrow, setNarrow] = useState({ wide: 0, key: '' });
62
70
  const fold = !lanes && !timeline && props.rail !== 'only';
63
71
  const wide = fold && narrow.key === key ? narrow.wide : 0;
64
72
  const setWide = useCallback((w) => setNarrow({ wide: w, key }), [key]);
65
73
  const placed = useMemo(() => autoLayout({ layout: wide > 0 ? { ...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 }));
74
+ const level = all.length > 1
75
+ ? {
76
+ scene: s,
77
+ owners: new Map(all.filter((x) => x.path.slice(0, -1).join('>') === at && x.path.length > s.path.length).map((x) => [x.path.at(-1), x.parts])),
78
+ path: s.path.map((id, i) => ({ id, name: all.find((x) => x.path.join('>') === s.path.slice(0, i + 1).join('>')).name })),
79
+ onOpen: (id, from) => {
80
+ memo.current[at] = from;
81
+ const child = all.find((x) => x.path.join('>') === [...s.path, id].join('>'));
82
+ setStart({
83
+ step: child.props.steps?.length ? insideStep(child, from.port) : null,
84
+ beat: 0,
85
+ playing: from.playing,
86
+ rect: from.rect,
87
+ });
88
+ setPath([...s.path, id]);
89
+ },
90
+ onUp: (depth) => {
91
+ const to = s.path.slice(0, depth);
92
+ const { step, beat, playing } = memo.current[to.join('>')];
93
+ const beats = step == null ? 1 : (all.find((x) => x.path.join('>') === to.join('>')).props.steps?.[step].flow.length ?? 1);
94
+ setStart({ step, beat: Math.min(beat + 1, beats - 1), playing, box: s.path[depth] });
95
+ setPath(to);
96
+ },
97
+ }
98
+ : undefined;
99
+ const figure = useRef(null);
100
+ const descId = useId();
101
+ const alt = useMemo(() => altText({ layout: placed.layout, edges: placed.edges, steps }), [placed, steps]);
102
+ return (_jsxs("figure", { ref: figure, className: "flowfig", "data-inside": level ? '' : undefined, "aria-label": alt.title, "aria-describedby": descId, style: {
103
+ ...Object.fromEntries(Object.entries(props.theme ?? {}).map(([k, val]) => [`--fig-${k}`, val])),
104
+ position: full ? 'fixed' : 'relative',
105
+ inset: full ? 0 : undefined,
106
+ zIndex: full ? 1000 : undefined,
107
+ overflow: full ? 'auto' : undefined,
108
+ margin: full ? 0 : '24px 0',
109
+ padding: full ? '48px 24px 24px' : '20px 16px 16px',
110
+ border: full ? 'none' : `1px solid ${v('border')}`,
111
+ borderRadius: full ? 0 : 16,
112
+ background: v('bg'),
113
+ fontFamily: v('font'),
114
+ color: v('fg'),
115
+ }, children: [_jsx("div", { id: descId, style: HIDDEN, children: alt.desc }), _createElement(FlowBody, { ...props, ...s.props, key: at, layout: placed.layout, edges: placed.edges, written: s.props, wide: wide, setWide: fold ? setWide : undefined, full: full, setFull: setFull, level: level, start: start, figure: figure })] }));
67
116
  }
117
+ const reduced = () => window.matchMedia('(prefers-reduced-motion: reduce)').matches;
68
118
  // The ASYNC tag is 9 px.
69
119
  const RAIL_TEXT = 9;
70
120
  // offsetWidth is rounded, so a rect ratio reads scale 1 as 0.999.
@@ -74,10 +124,8 @@ const shownScale = (el) => {
74
124
  k *= new DOMMatrix(getComputedStyle(n).transform).a;
75
125
  return k;
76
126
  };
77
- function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, }) {
127
+ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, written, wide, setWide, full, setFull, level, start, figure, }) {
78
128
  const tl = timeline && isLanesLayout(layout);
79
- const alt = useMemo(() => altText({ layout, edges, steps: stepsIn }), [layout, edges, stepsIn]);
80
- const descId = useId();
81
129
  const tabsId = useId();
82
130
  const synthetic = tl && !stepsIn.length;
83
131
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
@@ -86,18 +134,63 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
86
134
  const root = useRef(null);
87
135
  const outer = useRef(null);
88
136
  const [fit, setFit] = useState({ scale: 1, height: 0, scroll: false });
89
- const [full, setFull] = useState(false);
137
+ const backBtn = useRef(null);
138
+ const ghost = useRef(null);
139
+ const place = (n) => {
140
+ const f = figure.current, a = f.getBoundingClientRect(), r = n.getBoundingClientRect();
141
+ return { x: r.left - a.left - f.clientLeft + f.scrollLeft, y: r.top - a.top - f.clientTop + f.scrollTop, w: r.width, h: r.height };
142
+ };
143
+ const showGhost = (r) => {
144
+ Object.assign(ghost.current.style, { display: 'block', left: `${r.x}px`, top: `${r.y}px`, width: `${r.w}px`, height: `${r.h}px` });
145
+ return ghost.current;
146
+ };
147
+ const toward = (from, to) => `translate(${from.x - to.x}px, ${from.y - to.y}px) scale(${from.w / to.w}, ${from.h / to.h})`;
148
+ const moving = useRef(false);
149
+ const goUp = (depth) => {
150
+ const map = root.current, r = start?.rect;
151
+ if (moving.current)
152
+ return;
153
+ if (reduced() || !map || !r)
154
+ return level.onUp(depth);
155
+ moving.current = true;
156
+ map.animate([{ opacity: 1 }, { opacity: 0 }], { duration: OPEN_MS.hide, fill: 'forwards' });
157
+ const to = place(map);
158
+ showGhost(to).animate([{ transform: 'none' }, { transform: toward(r, to) }], {
159
+ duration: OPEN_MS.shrink,
160
+ delay: OPEN_MS.hide,
161
+ easing: 'cubic-bezier(0.4, 0, 1, 1)',
162
+ fill: 'forwards',
163
+ }).onfinish = () => level.onUp(depth);
164
+ };
165
+ const up = level?.path.length ? () => goUp(level.path.length - 1) : undefined;
90
166
  useEffect(() => {
91
- if (!full)
167
+ if (!full && !up)
92
168
  return;
93
- const onKey = (e) => e.key === 'Escape' && setFull(false);
169
+ const onKey = (e) => {
170
+ const at = document.activeElement;
171
+ const inside = !!at && !!figure.current?.contains(at);
172
+ if (e.key === 'Escape' && up && (inside || full))
173
+ up();
174
+ else if (e.key === 'Escape')
175
+ setFull(false);
176
+ else if (e.key === 'Backspace' && up && inside && !at.closest('input,textarea,[contenteditable]')) {
177
+ e.preventDefault();
178
+ up();
179
+ }
180
+ };
94
181
  addEventListener('keydown', onKey);
95
182
  return () => removeEventListener('keydown', onKey);
96
- }, [full]);
183
+ }, [full, up]);
184
+ useEffect(() => {
185
+ if (start?.box)
186
+ figure.current?.querySelector(`[data-fig="${CSS.escape(start.box)}"]`)?.focus();
187
+ else if (level?.path.length)
188
+ backBtn.current?.focus();
189
+ }, []);
97
190
  const dots = useRef([]);
98
191
  const chips = useRef([]);
99
192
  const bar = useRef(null);
100
- const [beat, setBeat] = useState(0);
193
+ const [beat, setBeat] = useState(start?.beat ?? 0);
101
194
  const [holding, setHolding] = useState(false);
102
195
  const [returning, setReturning] = useState(false);
103
196
  const firstLabel = useRef('');
@@ -109,8 +202,8 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
109
202
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
110
203
  ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
111
204
  : null, [lanes, tl, layout, edges, steps, withRail]);
112
- const [active, setActive] = useState(steps.length ? 0 : null);
113
- const [playing, setPlaying] = useState(autoplay);
205
+ const [active, setActive] = useState(start ? start.step : steps.length ? 0 : null);
206
+ const [playing, setPlaying] = useState(start ? start.playing : autoplay);
114
207
  const playingRef = useRef(playing);
115
208
  playingRef.current = playing;
116
209
  const [rate, setRate] = useState(1);
@@ -147,7 +240,6 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
147
240
  beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
148
241
  return at;
149
242
  }, [beats, beat]);
150
- const figure = useRef(null);
151
243
  const [mapW, setMapW] = useState(0);
152
244
  const railOnly = withRail === 'only';
153
245
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
@@ -159,7 +251,34 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
159
251
  const user = useRef(false);
160
252
  const left = useRef(0);
161
253
  const busy = useRef(0);
162
- const jump = useRef(null);
254
+ const jump = useRef(start?.beat ?? null);
255
+ const wait = useRef(start?.rect && start.playing
256
+ ? OPEN_MS.grow +
257
+ OPEN_MS.stagger * (nodes(layout).length - 1) +
258
+ readMs([level.scene.line, ...level.scene.ports.map(portLabel)].join(' '))
259
+ : 0);
260
+ useLayoutEffect(() => {
261
+ const map = root.current;
262
+ if (!map || reduced())
263
+ return;
264
+ if (start?.box)
265
+ return void map.animate([{ opacity: 0 }, { opacity: 1 }], { duration: OPEN_MS.fade });
266
+ if (!start?.rect)
267
+ return;
268
+ const to = place(map);
269
+ showGhost(to).animate([{ transform: toward(start.rect, to) }, { transform: 'none' }], {
270
+ duration: OPEN_MS.grow,
271
+ easing: 'cubic-bezier(0.2, 0, 0, 1)',
272
+ });
273
+ for (const e of map.querySelectorAll(':scope > svg'))
274
+ e.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 1, delay: OPEN_MS.grow, fill: 'backwards' });
275
+ const shows = flowOrder(level.scene).map((id, k) => map.querySelector(`[data-fig="${CSS.escape(id)}"]`)?.animate([{ opacity: 0 }, { opacity: 1 }], {
276
+ duration: OPEN_MS.fade,
277
+ delay: OPEN_MS.grow + OPEN_MS.stagger * k,
278
+ fill: 'backwards',
279
+ }));
280
+ shows.at(-1)?.finished.then(() => ghost.current && (ghost.current.style.display = 'none'));
281
+ }, []);
163
282
  const [hoverEdge, setHoverEdge] = useState(null);
164
283
  const [still, setStill] = useState(false);
165
284
  useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
@@ -416,7 +535,8 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
416
535
  if (clock.current.beats !== beats) {
417
536
  const j = jump.current ?? 0;
418
537
  jump.current = null;
419
- clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
538
+ clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) - wait.current * BASE_RATE };
539
+ wait.current = 0;
420
540
  setBeat(j);
421
541
  }
422
542
  if (!beats.length || (!noMap && !routed.length && !(tl && !edges.length)))
@@ -438,7 +558,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
438
558
  return setActive((a) => ((a ?? 0) + 1) % steps.length);
439
559
  clock.current.elapsed = 0;
440
560
  }
441
- const t = clock.current.elapsed;
561
+ const t = Math.max(0, clock.current.elapsed);
442
562
  if (bar.current)
443
563
  bar.current.style.transform = `scaleX(${Math.min(1, t / total)})`;
444
564
  const next = ends.findIndex((e) => t < e);
@@ -542,7 +662,6 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
542
662
  }
543
663
  };
544
664
  const focus = hover != null || hoverEdge != null || step != null;
545
- const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
546
665
  const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
547
666
  const renderBar = (n, it) => {
548
667
  const lit = litNodes.has(n.id);
@@ -736,9 +855,36 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
736
855
  const card = carded.has(item.id);
737
856
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
738
857
  const w = diamond || card || item.width != null || str(item.label) ? nodeWidth(item, card, cap) + (diamond ? 70 : 0) : undefined;
739
- const lines = w != null && str(item.label) ? labelLines(item, w) : null;
740
858
  const bt = item.tone && TONES[item.tone];
741
- return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
859
+ const parts = level?.owners.get(item.id);
860
+ const port = level?.scene.ports.some((p) => p.id === item.id);
861
+ const lines = w != null && str(item.label) ? labelLines(item, w) : null;
862
+ const open = () => {
863
+ const hop = cur?.hops.find((h) => (h.back ? edges[ids.indexOf(h.edge)]?.from : edges[ids.indexOf(h.edge)]?.to) === item.id);
864
+ const e = hop && edges[ids.indexOf(hop.edge)];
865
+ const at = { step: active, beat, playing, port: e ? (hop.back ? e.to : e.from) : undefined };
866
+ const map = root.current, n = map?.querySelector(`[data-fig="${CSS.escape(item.id)}"]`);
867
+ if (moving.current)
868
+ return;
869
+ if (reduced() || !map || !n)
870
+ return level.onOpen(item.id, at);
871
+ moving.current = true;
872
+ const rect = place(n);
873
+ showGhost(rect);
874
+ map.animate([{ opacity: 1 }, { opacity: 0 }], { duration: OPEN_MS.fade, fill: 'forwards' }).onfinish = () => level.onOpen(item.id, { ...at, rect });
875
+ };
876
+ return (_jsxs("div", { "data-fig": item.id, title: item.source, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), ...(parts != null && {
877
+ role: 'button',
878
+ tabIndex: 0,
879
+ 'aria-label': `Open ${str(item.label) || item.id}, ${parts} parts`,
880
+ onClick: open,
881
+ onKeyDown: (e) => {
882
+ if (e.key !== 'Enter' && e.key !== ' ')
883
+ return;
884
+ e.preventDefault();
885
+ open();
886
+ },
887
+ }), style: {
742
888
  position: 'relative',
743
889
  isolation: 'isolate',
744
890
  minWidth: 100,
@@ -751,19 +897,23 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
751
897
  justifyContent: 'center',
752
898
  padding: diamond
753
899
  ? '22px 34px'
754
- : `calc(${store ? 24 : 10}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(10px - var(--ff-b, 0px))`,
900
+ : `calc(${store ? 23 : 9}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(9px - var(--ff-b, 0px))`,
755
901
  textAlign: 'center',
756
902
  background: diamond ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
757
- color: v('fg'),
758
- border: diamond ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
903
+ color: port ? v('muted') : v('fg'),
904
+ border: diamond ? undefined : `1px ${port ? 'dashed' : 'solid'} ${bt ?? (lit ? v('accent') : v('border'))}`,
759
905
  ...(bt && { '--ff-box': bt }),
760
- boxShadow: diamond ? undefined : '0 1px 2px rgba(0,0,0,.06)',
906
+ boxShadow: diamond
907
+ ? undefined
908
+ : parts != null
909
+ ? `3px 3px 0 -1px ${v('bg')}, 3px 3px 0 0 ${v('border')}`
910
+ : '0 1px 2px rgba(0,0,0,.06)',
761
911
  opacity: focus && !lit ? 0.7 : 1,
762
912
  borderRadius: store ? '50% / 12px' : 10,
763
913
  fontSize: 14,
764
914
  fontWeight: 500,
765
915
  transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
766
- cursor: 'default',
916
+ cursor: parts != null ? 'pointer' : 'default',
767
917
  }, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
768
918
  position: 'absolute',
769
919
  inset: 0,
@@ -794,7 +944,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
794
944
  border: `1.5px solid ${bt ?? v('accent')}`,
795
945
  background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
796
946
  }),
797
- } })), lines ? (lines.map((l, i) => (_jsx("div", { style: { whiteSpace: 'nowrap', lineHeight: `${LABEL_LINE}px` }, children: l }, i)))) : (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px` }, children: item.label })), dl
947
+ } })), lines && port ? (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px`, width: 'min-content', margin: '0 auto' }, children: lines.map((l, i) => (_jsxs(Fragment, { children: [i > 0 && ' ', _jsx("span", { style: { whiteSpace: 'nowrap' }, children: l })] }, i))) })) : lines ? (lines.map((l, i) => (_jsx("div", { style: { whiteSpace: 'nowrap', lineHeight: `${LABEL_LINE}px` }, children: l }, i)))) : (_jsx("div", { style: { lineHeight: `${LABEL_LINE}px` }, children: item.label })), dl
798
948
  ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
799
949
  : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
800
950
  marginTop: 8,
@@ -827,20 +977,7 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
827
977
  background: v('bg'),
828
978
  color: v('muted'),
829
979
  };
830
- return (_jsxs("figure", { ref: figure, className: "flowfig", "aria-label": alt.title, "aria-describedby": descId, style: {
831
- ...vars,
832
- position: full ? 'fixed' : 'relative',
833
- inset: full ? 0 : undefined,
834
- zIndex: full ? 1000 : undefined,
835
- overflow: full ? 'auto' : undefined,
836
- margin: full ? 0 : '24px 0',
837
- padding: full ? '48px 24px 24px' : '20px 16px 16px',
838
- border: full ? 'none' : `1px solid ${v('border')}`,
839
- borderRadius: full ? 0 : 16,
840
- background: v('bg'),
841
- fontFamily: v('font'),
842
- color: v('fg'),
843
- }, 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: {
980
+ return (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "aria-label": full ? 'Close full screen' : 'Full screen', title: full ? 'Close (Esc)' : 'Full screen', onClick: () => setFull(!full), style: {
844
981
  ...iconBtn,
845
982
  position: 'absolute',
846
983
  top: 10,
@@ -851,105 +988,120 @@ function FlowBody({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, au
851
988
  }, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-label{from{opacity:0}to{opacity:0}}@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
852
989
  // The 2 px active border takes 1 px of padding, so the box size stays.
853
990
  `.${ACTIVE}:not([data-diamond]){--ff-b:1px;border-width:2px!important;background:color-mix(in srgb, ${hue} 10%, ${v('surface')})!important;box-shadow:${glow}!important;border-color:${hue}!important}` +
854
- `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
991
+ `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` +
992
+ (level
993
+ ? '@media (prefers-reduced-motion: reduce){.flowfig[data-inside] *{transition:none!important;animation:none!important}}'
994
+ : '') }), level && (_jsx("div", { ref: ghost, style: {
995
+ display: 'none',
996
+ position: 'absolute',
997
+ zIndex: 4,
998
+ boxSizing: 'border-box',
999
+ border: `1.5px solid ${v('accent')}`,
1000
+ borderRadius: 10,
1001
+ transformOrigin: '0 0',
1002
+ pointerEvents: 'none',
1003
+ } })), !noMap && (_jsx(_Fragment, { children: _jsxs("div", { style: {
855
1004
  margin: full ? '-48px -24px 0' : '-20px -16px 0',
856
1005
  padding: full ? '48px 24px 24px' : '24px 16px',
857
1006
  borderRadius: full ? 0 : '16px 16px 0 0',
858
1007
  borderBottom: `1px solid ${v('border')}`,
859
1008
  backgroundImage: `radial-gradient(color-mix(in srgb, ${v('fg')} 16%, transparent) 1px, transparent 1.2px)`,
860
1009
  backgroundSize: '14px 14px',
861
- }, children: _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
862
- overflow: wide || fit.scroll ? 'auto' : 'hidden',
863
- height: fit.scale < 1 ? fit.height : undefined,
864
- }, children: _jsxs("div", { ref: root, style: {
865
- position: 'relative',
866
- width: 'max-content',
867
- margin: '0 auto',
868
- transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
869
- transformOrigin: 'top left',
870
- paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
871
- paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
872
- paddingLeft: arcRoom(routed, 'left') || 4,
873
- paddingRight: arcRoom(routed, 'right') || 4,
874
- }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
875
- position: 'absolute',
876
- inset: 0,
877
- width: '100%',
878
- height: '100%',
879
- overflow: 'visible',
880
- pointerEvents: 'none',
881
- zIndex: tl ? 0 : undefined,
882
- }, 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: "context-stroke" }) }, k))) }), routed.map((r) => {
883
- const on = litEdges.has(r.id);
884
- const tone = hopTone(r.id);
885
- const hidden = !on && edges[ids.indexOf(r.id)].quiet;
886
- const tip = edgeTip(r.id, edges[ids.indexOf(r.id)].source, allBeats);
887
- const look = (d, key, guides = false) => (_jsx("path", { ref: guides
888
- ? (p) => {
1010
+ }, children: [up && (_jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center', marginBottom: 12, fontSize: 13, color: v('muted') }, children: [_jsx("button", { ref: backBtn, type: "button", onClick: up, style: { ...iconBtn, width: 'auto', padding: '0 12px', font: 'inherit' }, children: "Back" }), level.path.map((p, i) => i < level.path.length - 1 ? (_jsxs(Fragment, { children: [_jsx("button", { type: "button", onClick: () => goUp(i + 1), style: { border: 0, padding: 0, background: 'none', font: 'inherit', color: v('accent'), cursor: 'pointer' }, children: p.name }), ' > '] }, p.id)) : (_jsx("span", { style: { color: v('fg') }, children: p.name }, p.id))), _jsx("div", { "aria-live": "polite", style: HIDDEN, children: `${level.scene.line}: ${level.scene.parts} parts.` +
1011
+ level.scene.ports
1012
+ .map((p) => p.way === 'in' ? ` In from ${p.name}.` : p.way === 'out' ? ` Out to ${p.name}.` : ` In and out: ${p.name}.`)
1013
+ .join('') })] })), _jsx("div", { ref: outer, role: "tabpanel", id: `${tabsId}-panel`, "aria-labelledby": `${tabsId}-tab-${active ?? 0}`, style: {
1014
+ overflow: wide || fit.scroll ? 'auto' : 'hidden',
1015
+ height: fit.scale < 1 ? fit.height : undefined,
1016
+ }, children: _jsxs("div", { ref: root, style: {
1017
+ position: 'relative',
1018
+ width: 'max-content',
1019
+ margin: '0 auto',
1020
+ transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
1021
+ transformOrigin: 'top left',
1022
+ paddingTop: arcRoom(routed, 'above') || (markSides.has('t') ? 20 : 4),
1023
+ paddingBottom: arcRoom(routed, 'below') || (markSides.has('b') ? 20 : 4),
1024
+ paddingLeft: arcRoom(routed, 'left') || 4,
1025
+ paddingRight: arcRoom(routed, 'right') || 4,
1026
+ }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
1027
+ position: 'absolute',
1028
+ inset: 0,
1029
+ width: '100%',
1030
+ height: '100%',
1031
+ overflow: 'visible',
1032
+ pointerEvents: 'none',
1033
+ zIndex: tl ? 0 : undefined,
1034
+ }, 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: "context-stroke" }) }, k))) }), routed.map((r) => {
1035
+ const on = litEdges.has(r.id);
1036
+ const tone = hopTone(r.id);
1037
+ const hidden = !on && edges[ids.indexOf(r.id)].quiet;
1038
+ const tip = edgeTip(r.id, edges[ids.indexOf(r.id)].source, allBeats);
1039
+ const look = (d, key, guides = false) => (_jsx("path", { ref: guides
1040
+ ? (p) => {
1041
+ paths.current[r.id] = p;
1042
+ }
1043
+ : undefined, d: d, fill: "none", stroke: on ? (tone ?? 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', pointerEvents: tip ? 'stroke' : undefined }, children: tip && _jsx("title", { children: tip }) }, key));
1044
+ const guide = (_jsx("path", { ref: (p) => {
889
1045
  paths.current[r.id] = p;
890
- }
891
- : undefined, d: d, fill: "none", stroke: on ? (tone ?? 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', pointerEvents: tip ? 'stroke' : undefined }, children: tip && _jsx("title", { children: tip }) }, key));
892
- const guide = (_jsx("path", { ref: (p) => {
893
- paths.current[r.id] = p;
894
- }, d: r.d, fill: "none", stroke: "none" }, r.id));
895
- if (r.stub)
896
- return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
897
- return look(r.d, r.id, true);
898
- }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
899
- dots.current[j] = g;
900
- }, style: { opacity: 0, color: v('accent') }, children: [_jsx("circle", { r: 10, fill: "currentColor", opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: "currentColor" })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
901
- chips.current[j] = c;
902
- }, style: {
903
- position: 'absolute',
904
- left: 0,
905
- top: 0,
906
- zIndex: 2,
907
- opacity: 0,
908
- maxWidth: 220,
909
- width: 'max-content',
910
- padding: '4px 9px',
911
- borderRadius: 8,
912
- fontSize: 11.5,
913
- lineHeight: '15px',
914
- pointerEvents: 'none',
915
- background: v('accent'),
916
- color: ON_ACCENT,
917
- boxShadow: '0 4px 14px rgba(0,0,0,.18)',
918
- transition: 'opacity .2s',
919
- }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
920
- const e = edges[ids.indexOf(r.id)];
921
- const texts = r.stub && lanePlan?.stubs.get(r.id);
922
- if (!texts && (e.label == null || tl))
923
- return [];
924
- const on = litEdges.has(r.id);
925
- const tone = hopTone(r.id);
926
- const pills = texts
927
- ? r.stub.pills.map((p, j) => ({
928
- key: `${r.id}:${j}`,
929
- x: p.x + p.w / 2,
930
- y: p.y + p.h / 2,
931
- text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
932
- }))
933
- : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
934
- return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
1046
+ }, d: r.d, fill: "none", stroke: "none" }, r.id));
1047
+ if (r.stub)
1048
+ return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
1049
+ return look(r.d, r.id, true);
1050
+ }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
1051
+ dots.current[j] = g;
1052
+ }, style: { opacity: 0, color: v('accent') }, children: [_jsx("circle", { r: 10, fill: "currentColor", opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: "currentColor" })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
1053
+ chips.current[j] = c;
1054
+ }, style: {
935
1055
  position: 'absolute',
936
- left: p.x,
937
- top: p.y,
938
- transform: 'translate(-50%, -50%)',
939
- fontSize: 11,
940
- lineHeight: '16px',
941
- padding: '0 7px',
942
- borderRadius: 999,
943
- whiteSpace: 'nowrap',
1056
+ left: 0,
1057
+ top: 0,
1058
+ zIndex: 2,
1059
+ opacity: 0,
1060
+ maxWidth: 220,
1061
+ width: 'max-content',
1062
+ padding: '4px 9px',
1063
+ borderRadius: 8,
1064
+ fontSize: 11.5,
1065
+ lineHeight: '15px',
944
1066
  pointerEvents: 'none',
945
- background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
946
- color: on ? ON_ACCENT : v('muted'),
947
- border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
948
- opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
949
- fontFamily: MONO,
950
- transition: 'background .25s, color .25s, opacity .25s',
951
- }, children: p.text }, p.key)));
952
- })] }) }) }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
1067
+ background: v('accent'),
1068
+ color: ON_ACCENT,
1069
+ boxShadow: '0 4px 14px rgba(0,0,0,.18)',
1070
+ transition: 'opacity .2s',
1071
+ }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
1072
+ const e = edges[ids.indexOf(r.id)];
1073
+ const texts = r.stub && lanePlan?.stubs.get(r.id);
1074
+ if (!texts && (e.label == null || tl))
1075
+ return [];
1076
+ const on = litEdges.has(r.id);
1077
+ const tone = hopTone(r.id);
1078
+ const pills = texts
1079
+ ? r.stub.pills.map((p, j) => ({
1080
+ key: `${r.id}:${j}`,
1081
+ x: p.x + p.w / 2,
1082
+ y: p.y + p.h / 2,
1083
+ text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
1084
+ }))
1085
+ : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
1086
+ return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
1087
+ position: 'absolute',
1088
+ left: p.x,
1089
+ top: p.y,
1090
+ transform: 'translate(-50%, -50%)',
1091
+ fontSize: 11,
1092
+ lineHeight: '16px',
1093
+ padding: '0 7px',
1094
+ borderRadius: 999,
1095
+ whiteSpace: 'nowrap',
1096
+ pointerEvents: 'none',
1097
+ background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
1098
+ color: on ? ON_ACCENT : v('muted'),
1099
+ border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
1100
+ opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
1101
+ fontFamily: MONO,
1102
+ transition: 'background .25s, color .25s, opacity .25s',
1103
+ }, children: p.text }, p.key)));
1104
+ })] }) })] }) })), rail && (_jsx("div", { ref: railPane, onWheel: () => (user.current = true), onPointerDown: () => (user.current = true), onTouchStart: () => (user.current = true), onScroll: (e) => {
953
1105
  const x = e.currentTarget.scrollLeft;
954
1106
  if (user.current && x !== left.current && performance.now() > busy.current)
955
1107
  follow.current = false;
package/dist/load.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from 'node:fs';
2
- import { altText, counts, toBeat } from './model.js';
3
- import { coverageLine, detailFindings, owners, unsureLines, verifyReport } from './verify.js';
2
+ import { altText, counts, scenes, toBeat, tourPlan } from './model.js';
3
+ import { coverageLine, detailFindings, levelLines, 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>';
@@ -47,12 +47,16 @@ export function sortFindings(findings, strict) {
47
47
  const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
48
48
  /** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
49
49
  export function reportLines(props, findings, withCounts = true) {
50
- const out = findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
50
+ const out = findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.scene ? `[${f.scene}] ` : ''}${f.message}`);
51
51
  const errors = findings.filter((f) => f.severity === 'error').length;
52
52
  if (withCounts)
53
53
  out.push(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
54
54
  const c = counts(props);
55
- out.push(`figure: ${n(c.boxes, 'box').replace('boxs', 'boxes')}, ${n(c.groups, 'group')}, ${n(c.edges, 'edge')}, ${n(c.steps, 'step')}, ${n(c.messages, 'message')}`);
55
+ const all = scenes(props);
56
+ const levels = all.length > 1
57
+ ? `; ${1 + Math.max(...all.map((s) => s.path.length))} levels, ${all.length} scenes, ${all.reduce((k, s) => k + s.parts, 0)} parts; tour ${Math.round(tourPlan(props).ms / 1000)} s`
58
+ : '';
59
+ out.push(`figure: ${n(c.boxes, 'box').replace('boxs', 'boxes')}, ${n(c.groups, 'group')}, ${n(c.edges, 'edge')}, ${n(c.steps, 'step')}, ${n(c.messages, 'message')}${levels}`);
56
60
  return out;
57
61
  }
58
62
  const text = (x) => (typeof x === 'string' ? x : '');
@@ -75,6 +79,7 @@ export function verifyLines(props, figure, root = process.cwd()) {
75
79
  return [
76
80
  ...findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${figure}: ${f.message}`),
77
81
  coverageLine(figure, coverage),
82
+ ...levelLines(coverage),
78
83
  ...unsureLines(coverage),
79
84
  ];
80
85
  }
@@ -0,0 +1,15 @@
1
+ export declare const TOKENS: {
2
+ light: Record<string, string>;
3
+ dark: Record<string, string>;
4
+ };
5
+ export declare const HUD: {
6
+ wide: number;
7
+ roomy: number;
8
+ panel: number;
9
+ slim: number;
10
+ pad: number;
11
+ chips: number;
12
+ sheet: number;
13
+ short: number;
14
+ tall: number;
15
+ };