flowfig 0.5.0 → 0.7.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
@@ -5,14 +5,13 @@ import { 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, CARD_WIDTH, 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, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, } from './model.js';
8
+ import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, 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, beatMs, STEP_HOLD_MS, playheadItem, loopStartItem, dateLabelRaised, } from './model.js';
9
9
  export { LIGHT, DARK } from './model.js';
10
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
11
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
12
12
  const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
13
- const NONE = []; // a stable default, so a memo keyed on it does not run every render
14
- const ACTIVE = 'flowfig-active'; // the box a packet has just reached
15
- // The active box reads --ff-hop (the tone of the hop that arrived), then --ff-box (the tone of the box), then the accent.
13
+ const NONE = [];
14
+ const ACTIVE = 'flowfig-active';
16
15
  const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
17
16
  const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
18
17
  const cardBody = (c) => {
@@ -40,20 +39,17 @@ const cardBody = (c) => {
40
39
  fontFamily: r.mono ? MONO : undefined,
41
40
  fontSize: r.mono ? 10.5 : undefined,
42
41
  }, children: [r.text, r.meta != null && _jsxs("span", { style: { color: v('muted') }, children: [" \u00B7 ", r.meta] })] }));
43
- // A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
44
42
  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));
45
43
  }) }));
46
44
  };
47
45
  /**
48
- * The interactive player for one figure: tabs for the steps, play and pause, speed, hover and full screen.
49
- * Give it a `FlowProps` spec. Use `toSvg` from `flowfig/svg` for a static animated SVG instead.
46
+ * The interactive player for one figure. Give it a `FlowProps` spec.
47
+ * Use `toSvg` from `flowfig/svg` for a static animated SVG.
50
48
  */
51
49
  export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
52
50
  const tl = timeline && isLanesLayout(layout);
53
- // A timeline with no steps of its own walks its dated items in date order, with no tab row.
54
51
  const synthetic = tl && !stepsIn.length;
55
52
  const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
56
- // The width of the timeline area in px: the server draws TL_AXIS_W, and the browser measures the real width after mount.
57
53
  const [axisW, setAxisW] = useState(TL_AXIS_W);
58
54
  const area = useRef(null);
59
55
  const root = useRef(null);
@@ -69,22 +65,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
69
65
  }, [full]);
70
66
  const dots = useRef([]);
71
67
  const chips = useRef([]);
72
- const bar = useRef(null); // the active tab's progress line
68
+ const bar = useRef(null);
73
69
  const [beat, setBeat] = useState(0);
74
- const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
70
+ const [holding, setHolding] = useState(false);
71
+ const [returning, setReturning] = useState(false);
72
+ const firstLabel = useRef('');
75
73
  const paths = useRef({});
76
74
  const [routed, setRouted] = useState([]);
77
- // Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
78
75
  const [extraTall, setExtraTall] = useState(new Set());
79
- // Lanes: the shared wrap plan (blocks, gaps, stubs), the same as the SVG at the default 830 px width.
80
76
  const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
81
- ? planLanes({ layout, edges, steps, lanes },
82
- // A rail sets a least width, as in the SVG.
83
- { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
77
+ ? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
84
78
  : null, [lanes, tl, layout, edges, steps, withRail]);
85
79
  const [active, setActive] = useState(steps.length ? 0 : null);
86
80
  const [playing, setPlaying] = useState(autoplay);
87
- // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
88
81
  const playingRef = useRef(playing);
89
82
  playingRef.current = playing;
90
83
  const [rate, setRate] = useState(1);
@@ -93,7 +86,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
93
86
  const clock = useRef({ beats: null, elapsed: 0 });
94
87
  const [hover, setHover] = useState(null);
95
88
  const ids = useMemo(() => edges.map(edgeId), [edges]);
96
- // A milestone is a diamond too, so its edges meet the tips.
97
89
  const tips = useMemo(() => new Set([
98
90
  ...decisions(layout),
99
91
  ...(tl
@@ -104,7 +96,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
104
96
  ]), [layout, tl, edges, timeline, today]);
105
97
  const step = active == null ? null : steps[active];
106
98
  const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
107
- // Every content each box's card will ever show. The card is sized to the largest, so text never gets cut and nothing jumps.
108
99
  const carded = useMemo(() => {
109
100
  const all = new Map();
110
101
  for (const b of steps.flatMap((s) => s.flow.map(toBeat)))
@@ -113,30 +104,23 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
113
104
  return all;
114
105
  }, [steps]);
115
106
  const cur = beats[beat];
116
- // The narration: the latest `say` so far (a beat without one keeps the line before), else the step caption.
117
107
  const saidAt = beats.slice(0, beat + 1).findLastIndex((b) => b.say != null);
118
108
  const said = saidAt === -1 ? step?.caption : beats[saidAt].say;
119
- // Content cards: everything the step has shown so far, latest wins.
120
109
  const shown = useMemo(() => Object.assign({}, ...beats.slice(0, beat + 1).map((b) => b.show)), [beats, beat]);
121
- // The beat that last changed each card, so only a card whose content just changed animates in.
122
110
  const shownAt = useMemo(() => {
123
111
  const at = {};
124
112
  beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
125
113
  return at;
126
114
  }, [beats, beat]);
127
- // The rail lays out in node terms, like the SVG, so both renderers draw the same numbers. Its width follows the measured map.
128
115
  const figure = useRef(null);
129
116
  const [mapW, setMapW] = useState(0);
130
117
  const railOnly = withRail === 'only';
131
118
  const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
132
- // 'only' drops the map, but only when the rail has a hop to draw; a figure never renders empty.
133
119
  const noMap = railOnly && rail != null;
134
- const jump = useRef(null); // a rail click that asks for a beat of another step
120
+ const jump = useRef(null);
135
121
  const [hoverEdge, setHoverEdge] = useState(null);
136
- // With reduced motion the player shows the last beat, so the rail marks every row as done.
137
122
  const [still, setStill] = useState(false);
138
123
  useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
139
- // Boxes with many edges on one side get taller so the edges and their labels have room.
140
124
  const minHeight = useMemo(() => {
141
125
  const out = {}, inn = {};
142
126
  for (const e of edges) {
@@ -148,20 +132,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
148
132
  return n > 2 ? n * 30 : undefined;
149
133
  };
150
134
  }, [edges]);
151
- // Measure every box and redraw the edges whenever anything changes size.
152
135
  useEffect(() => {
153
136
  const el = root.current, box = outer.current;
154
137
  if (!el || !box)
155
- return; // no map to measure with rail: 'only'
138
+ return;
156
139
  const measure = () => {
157
- // Too wide for its container? Shrink it so the whole figure stays in view. Below half size, scroll instead.
158
140
  const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
159
141
  setFit({ scale, height: el.offsetHeight * scale });
160
142
  setMapW(el.offsetWidth);
161
143
  if (area.current)
162
144
  setAxisW(area.current.offsetWidth);
163
145
  const base = el.getBoundingClientRect();
164
- const k = base.width / el.offsetWidth; // the scale currently on screen; rects are measured unscaled
146
+ // Rects are measured unscaled: k is the scale on screen.
147
+ const k = base.width / el.offsetWidth;
165
148
  const rects = {};
166
149
  el.querySelectorAll('[data-fig]').forEach((n) => {
167
150
  const r = n.getBoundingClientRect();
@@ -177,7 +160,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
177
160
  const r = n.getBoundingClientRect();
178
161
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
179
162
  });
180
- // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
163
+ if (tl)
164
+ for (const n of nodes(layout))
165
+ if (rects[n.id])
166
+ avoid.push({ ...rects[n.id], box: true });
181
167
  const stubs = lanePlan?.stubs ?? new Map();
182
168
  if (stubs.size) {
183
169
  for (const n of nodes(layout))
@@ -188,13 +174,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
188
174
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
189
175
  });
190
176
  }
191
- // A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
192
177
  const bands = {};
193
178
  el.querySelectorAll('[data-fig-copy]').forEach((n) => {
194
179
  const r = n.getBoundingClientRect();
195
180
  bands[n.dataset.figCopy] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
196
181
  });
197
- // A stub pill stays in the lanes, right of the gutter, as in the SVG.
198
182
  const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
199
183
  const pillArea = stubs.size && blockRects.length
200
184
  ? (() => {
@@ -204,7 +188,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
204
188
  return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
205
189
  })()
206
190
  : undefined;
207
- // The band of an edge end, in the block of that end, as in the SVG.
208
191
  const laneBands = {};
209
192
  el.querySelectorAll('[data-fig-band]').forEach((n) => {
210
193
  const r = n.getBoundingClientRect();
@@ -225,8 +208,8 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
225
208
  stub: stubs.get(ids[i]).map(labelPillW),
226
209
  bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
227
210
  }),
228
- ...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
229
- })), rects, tips, avoid, pillArea);
211
+ ...((stubs.size || lanePlan) && e.label != null && { labelW: labelPillW(str(e.label)) }),
212
+ })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
230
213
  setRouted(next);
231
214
  if (lanePlan) {
232
215
  const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
@@ -241,12 +224,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
241
224
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
242
225
  return () => ro.disconnect();
243
226
  }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
244
- // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
245
227
  const reported = useRef(new Set());
246
228
  useEffect(() => {
247
229
  const el = root.current, box = outer.current;
248
230
  const fig = figure.current;
249
- // With rail: 'only' there is no map: the figure is the frame and the scene holds only the rail.
250
231
  if (!check || !fig || (!noMap && (!el || !box)))
251
232
  return;
252
233
  const base = (el ?? fig).getBoundingClientRect();
@@ -269,7 +250,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
269
250
  const fontSize = parseFloat(getComputedStyle(t).fontSize);
270
251
  if (!n.dataset.diamond)
271
252
  return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
272
- // A diamond gives a line the width of its outline at the far edge of the line, and the line is as wide as its text.
273
253
  const [r, range] = [rel(t), document.createRange()];
274
254
  range.selectNodeContents(t);
275
255
  const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
@@ -282,11 +262,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
282
262
  }),
283
263
  };
284
264
  });
285
- // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
286
- // The rail is a sibling of the map, so the rail queries start at the figure. An SVG <text> has no CSS box, so it skips clientWidth.
287
265
  const fonts = [...fig.querySelectorAll('*')]
288
266
  .filter((t) => !t.children.length &&
289
267
  t.textContent?.trim() &&
268
+ // An SVG <text> has no CSS box, so skip clientWidth.
290
269
  (t instanceof SVGElement || t.clientWidth > 0) &&
291
270
  !t.closest('[data-fig-tag],[aria-hidden]'))
292
271
  .map((t) => parseFloat(getComputedStyle(t).fontSize));
@@ -304,12 +283,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
304
283
  edges: [
305
284
  ...routed.flatMap((r) => {
306
285
  const e = edges[ids.indexOf(r.id)];
307
- // A cross-block edge is two scene edges: each stub with its pill.
308
286
  if (r.stub)
309
287
  return r.stub.pts.map((pts, j) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: labels[`${r.id}:${j}`] }));
310
288
  return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
311
289
  }),
312
- // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
313
290
  ...(rail?.rows ?? []).flatMap((row) => {
314
291
  const r = row.kind === 'message' ? labels[`rail:${row.n}`] : undefined;
315
292
  if (row.kind !== 'message' || !r)
@@ -338,14 +315,13 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
338
315
  if (reported.current.has(key))
339
316
  continue;
340
317
  reported.current.add(key);
318
+ // oxlint-disable-next-line no-console
341
319
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
342
320
  }
343
321
  }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
344
- // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
345
322
  useEffect(() => {
346
323
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
347
324
  if (clock.current.beats !== beats) {
348
- // A rail click can open another step at a later beat; start the clock at that beat, not at 0.
349
325
  const j = jump.current ?? 0;
350
326
  jump.current = null;
351
327
  clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
@@ -359,7 +335,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
359
335
  return void setBeat(beats.length - 1);
360
336
  }
361
337
  const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
362
- const total = ends.at(-1) + STEP_HOLD_MS; // The figure holds on the last beat before the next step.
338
+ const total = ends.at(-1) + STEP_HOLD_MS;
363
339
  let raf = 0, last = performance.now(), shownBeat = -1;
364
340
  const tick = (now) => {
365
341
  if (playingRef.current)
@@ -378,8 +354,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
378
354
  if (i !== shownBeat)
379
355
  setBeat((shownBeat = i));
380
356
  setHolding(t >= ends.at(-1));
357
+ setReturning(active === steps.length - 1 && t >= total - 400 * BASE_RATE);
381
358
  const start = i ? ends[i - 1] : 0;
382
- const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
359
+ const f = Math.min(1, (t - start) / speed);
383
360
  const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
384
361
  const hops = t < ends.at(-1) ? beats[i].hops : [];
385
362
  gs.forEach((g, j) => {
@@ -405,14 +382,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
405
382
  c.style.opacity = hops[j].data == null ? '0' : '1';
406
383
  }
407
384
  });
408
- // A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
409
385
  const arrived = new Map();
410
386
  if (f >= 1)
411
387
  for (const h of beats[i].hops) {
412
388
  const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
413
389
  arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
414
390
  }
415
- // A focused box turns active at the beat start; in a timeline, when the today line arrives.
416
391
  if (t - start >= (tl ? 400 * BASE_RATE : 0))
417
392
  for (const id of beats[i].focus ?? [])
418
393
  if (!arrived.has(id))
@@ -429,8 +404,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
429
404
  if (on === n.classList.contains(ACTIVE))
430
405
  return;
431
406
  if (!on)
432
- return void n.classList.remove(ACTIVE); // the transition fades the box
433
- // The box turns active at once: no transition while the class arrives.
407
+ return void n.classList.remove(ACTIVE);
434
408
  const keep = n.style.transition;
435
409
  n.style.transition = 'none';
436
410
  n.classList.add(ACTIVE);
@@ -447,8 +421,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
447
421
  cs.forEach((c) => c && (c.style.opacity = '0'));
448
422
  };
449
423
  }, [beats, routed, noMap, speed, steps.length]);
450
- // What to light up: the step's edges and their ends, or whatever touches the hovered box.
451
- // A step lights the trail so far: every edge its beats have crossed, and every box it has filled.
452
424
  const trail = beats.slice(0, beat + 1).flatMap((b) => b.hops.map((h) => h.edge));
453
425
  const hoverEdgeIdx = hoverEdge == null ? -1 : ids.indexOf(hoverEdge);
454
426
  const litEdges = new Set(hoverEdge ? [hoverEdge] : hover ? ids.filter((id, i) => edges[i].from === hover || edges[i].to === hover) : trail);
@@ -461,7 +433,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
461
433
  if (litEdges.has(ids[i]))
462
434
  litNodes.add(e.from).add(e.to);
463
435
  });
464
- // A toned hop colors its edge for its own beat only; the trail after it is the accent.
465
436
  const hopTone = (edge) => {
466
437
  const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
467
438
  return t && TONES[t];
@@ -480,7 +451,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
480
451
  const focus = hover != null || hoverEdge != null || step != null;
481
452
  const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
482
453
  const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
483
- // A bar or a milestone of a timeline: placed by the shared layout, with the look of a box.
484
454
  const renderBar = (n, it) => {
485
455
  const lit = litNodes.has(n.id);
486
456
  const bt = n.tone && TONES[n.tone];
@@ -514,20 +484,56 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
514
484
  const renderItem = (item, depth) => {
515
485
  if (timelineFig && item === layout) {
516
486
  const lanesList = layout.children;
517
- // The playhead follows the latest dated focus in the step, and rests at the last date before one and in the hold.
518
- const focused = holding || still ? undefined : playheadItem(timelineFig.items, beats, beat);
487
+ const focused = still
488
+ ? undefined
489
+ : returning
490
+ ? loopStartItem(timelineFig.items, steps)
491
+ : holding
492
+ ? undefined
493
+ : playheadItem(timelineFig.items, beats, beat);
519
494
  const home = timelineFig.last ?? 0;
520
495
  const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
521
496
  const dateText = focused ? focused.date : timelineFig.lastDate;
522
497
  const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
498
+ if (firstLabel.current === '')
499
+ firstLabel.current = dateText;
500
+ else if (firstLabel.current !== dateText)
501
+ firstLabel.current = null;
502
+ const marks = (front) => timelineFig.last != null && (_jsxs("div", { style: { gridColumn: '2 / 4', gridRow: '1 / -1', position: 'relative', zIndex: front ? 2 : 1, pointerEvents: 'none' }, children: [timelineFig.today != null && (_jsx("div", { "data-fig-today": front ? undefined : '', style: { position: 'absolute', left: timelineFig.today, top: 12, bottom: 0, width: 0 }, children: front ? (_jsx("span", { style: {
503
+ position: 'absolute',
504
+ right: 3,
505
+ top: -1,
506
+ fontSize: 11,
507
+ lineHeight: '11px',
508
+ fontWeight: 600,
509
+ color: v('accent'),
510
+ }, children: "today" })) : (_jsx("div", { style: { position: 'absolute', top: 0, bottom: 0, borderLeft: `1px dashed ${v('accent')}`, opacity: 0.6 } })) })), _jsx("div", { "data-fig-playhead": front ? undefined : '', style: {
511
+ position: 'absolute',
512
+ left: 0,
513
+ top: 12,
514
+ bottom: 0,
515
+ width: front ? 0 : 1.5,
516
+ background: front ? undefined : v('accent'),
517
+ transform: `translateX(${at}px)`,
518
+ transition: still ? 'none' : 'transform .4s linear',
519
+ }, children: front && (_jsx("span", { style: {
520
+ position: 'absolute',
521
+ left: 3,
522
+ top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
523
+ fontSize: 11,
524
+ lineHeight: '11px',
525
+ fontWeight: 600,
526
+ whiteSpace: 'nowrap',
527
+ color: v('accent'),
528
+ animation: still || firstLabel.current === dateText ? undefined : 'flowfig-label .4s step-end',
529
+ }, children: dateText }, dateText)) })] }));
523
530
  return (_jsxs("div", { style: {
524
531
  display: 'grid',
525
532
  gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
526
- // The axis strip sits over the gap above the first band, as in the SVG.
527
533
  gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
528
534
  rowGap: LANE_ROW_GAP,
529
535
  position: 'relative',
530
- }, children: [_jsxs("div", { "data-fig-axis": "", style: { gridColumn: '2 / 4', gridRow: 1, position: 'relative', height: TL_AXIS_H, alignSelf: 'start' }, children: [_jsx("div", { style: { position: 'absolute', left: 0, right: 0, top: TL_AXIS_H - 1, height: 1, background: v('border') } }), timelineFig.ticks.map((k) => (_jsxs(Fragment, { children: [_jsx("div", { style: { position: 'absolute', left: k.x, top: TL_AXIS_H - 5, width: 1, height: 4, background: v('border') } }), _jsx("div", { style: { position: 'absolute', left: k.x + 3, top: 0, fontSize: 11, lineHeight: '13px', color: v('muted') }, children: k.label })] }, k.x)))] }), lanesList.map((lane, li) => {
536
+ }, children: [marks(false), _jsxs("div", { "data-fig-axis": "", style: { gridColumn: '2 / 4', gridRow: 1, position: 'relative', height: TL_AXIS_H, alignSelf: 'start' }, children: [_jsx("div", { style: { position: 'absolute', left: 0, right: 0, top: TL_AXIS_H - 1, height: 1, background: v('border') } }), timelineFig.ticks.map((k) => (_jsxs(Fragment, { children: [_jsx("div", { style: { position: 'absolute', left: k.x, top: TL_AXIS_H - 5, width: 1, height: 4, background: v('border') } }), _jsx("div", { style: { position: 'absolute', left: k.x + 3, top: 0, fontSize: 11, lineHeight: '13px', color: v('muted') }, children: k.label })] }, k.x)))] }), lanesList.map((lane, li) => {
531
537
  const lit = lane.id != null && litNodes.has(lane.id);
532
538
  return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
533
539
  gridColumn: '1 / -1',
@@ -552,50 +558,18 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
552
558
  }, children: lane.label }), _jsx("div", { ref: li === 0 ? area : undefined, style: { gridColumn: 2, gridRow: li + 2, position: 'relative', zIndex: 1 }, children: timelineFig.items
553
559
  .filter((it) => it.track === li)
554
560
  .map((it) => renderBar(lane.children.find((k) => k.id === it.id), it)) })] }, lane.id ?? String(lane.label)));
555
- }), timelineFig.last != null && (_jsxs("div", { style: { gridColumn: '2 / 4', gridRow: '1 / -1', position: 'relative', zIndex: 2, pointerEvents: 'none' }, children: [timelineFig.today != null && (_jsxs("div", { "data-fig-today": "", style: { position: 'absolute', left: timelineFig.today, top: 12, bottom: 0, width: 0 }, children: [_jsx("div", { style: { position: 'absolute', top: 0, bottom: 0, borderLeft: `1px dashed ${v('accent')}`, opacity: 0.6 } }), _jsx("span", { style: {
556
- position: 'absolute',
557
- right: 3,
558
- top: -1,
559
- fontSize: 11,
560
- lineHeight: '11px',
561
- fontWeight: 600,
562
- color: v('accent'),
563
- }, children: "today" })] })), _jsx("div", { "data-fig-playhead": "", style: {
564
- position: 'absolute',
565
- left: 0,
566
- top: 12,
567
- bottom: 0,
568
- width: 1.5,
569
- background: v('accent'),
570
- transform: `translateX(${at}px)`,
571
- transition: still ? 'none' : 'transform .4s linear',
572
- }, children: _jsx("span", { style: {
573
- position: 'absolute',
574
- left: 3,
575
- top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
576
- fontSize: 11,
577
- lineHeight: '11px',
578
- fontWeight: 600,
579
- whiteSpace: 'nowrap',
580
- color: v('accent'),
581
- animation: still ? undefined : 'flowfig-label .4s step-end',
582
- }, children: dateText }, dateText) })] }))] }));
561
+ }), marks(true)] }));
583
562
  }
584
563
  if (lanes && item === layout && isLanesLayout(layout)) {
585
564
  const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
586
- const n = Math.max(0, ...cols.values()) + 1;
587
565
  const count = blocks.length;
588
- // One grid per block of `per` time columns, as in the SVG. A wrapped block shows only the lanes with a box in it.
589
566
  const grid = (bk) => {
590
567
  const m = starts[bk + 1] - starts[bk];
591
568
  const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
592
569
  const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
593
570
  return (_jsx("div", { "data-fig-block": bk, style: {
594
571
  display: 'grid',
595
- // A wrapped block stretches to the widest block, so all bands share one width.
596
- // Every block has the gutter of the plan, so the labels and the columns line up as in the SVG.
597
572
  gridTemplateColumns: `${gutter}px repeat(${m}, max-content)${count > 1 ? ' 1fr' : ''}`,
598
- // An empty lane keeps the SVG's least band height: a 38 px label line plus the lane padding.
599
573
  gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
600
574
  columnGap: 0,
601
575
  rowGap: LANE_ROW_GAP,
@@ -628,7 +602,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
628
602
  gridRow: li + 1,
629
603
  alignSelf: 'center',
630
604
  zIndex: 1,
631
- // The space after each column and before a block's first column comes from the plan, as in the SVG.
632
605
  padding: `${LANE_PAD}px ${gaps[cols.get(b.id)]}px ${LANE_PAD + (tall.has(`${layout.children.indexOf(lane)}@${bk}`) || extraTall.has(`${layout.children.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0)}px ${cols.get(b.id) === starts[bk] ? lead[bk] : 0}px`,
633
606
  }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
634
607
  }) }, bk));
@@ -674,7 +647,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
674
647
  const diamond = item.shape === 'decision';
675
648
  const store = item.shape === 'store';
676
649
  const card = carded.has(item.id);
677
- // The sub of a diamond wraps as in the SVG, one div per line, so a line stays inside the outline.
678
650
  const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
679
651
  const bt = item.tone && TONES[item.tone];
680
652
  return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
@@ -790,7 +762,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
790
762
  width: 28,
791
763
  height: 28,
792
764
  }, 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}}' +
793
- // The active box has a 2 px border, a tint and a glow. The padding shrinks by 1 px. The box size stays the same, so the edges keep their route.
765
+ // The 2 px active border takes 1 px of padding, so the box size stays.
794
766
  `.${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}` +
795
767
  `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
796
768
  margin: full ? '-48px -24px 0' : '-20px -16px 0',
@@ -809,8 +781,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
809
781
  transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
810
782
  transformOrigin: 'top left',
811
783
  padding: 4,
812
- // room for edges that arc over or under the boxes
813
- // A stub does not arc.
814
784
  paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
815
785
  paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
816
786
  ? 44
@@ -822,7 +792,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
822
792
  height: '100%',
823
793
  overflow: 'visible',
824
794
  pointerEvents: 'none',
825
- // A timeline dependency runs under the bars (z-index 1) and over the bands (0).
826
795
  zIndex: tl ? 0 : undefined,
827
796
  }, 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) => {
828
797
  const on = litEdges.has(r.id);
@@ -836,7 +805,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
836
805
  const guide = (_jsx("path", { ref: (p) => {
837
806
  paths.current[r.id] = p;
838
807
  }, d: r.d, fill: "none", stroke: "none" }, r.id));
839
- // A cross-block edge draws its two stubs; its full path only guides the packet, which jumps between the blocks.
840
808
  if (r.stub)
841
809
  return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
842
810
  return look(r.d, r.id, true);
@@ -868,7 +836,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
868
836
  return [];
869
837
  const on = litEdges.has(r.id);
870
838
  const tone = hopTone(r.id);
871
- // A cross-block edge has two pills, one per stub, in place of its label.
872
839
  const pills = texts
873
840
  ? r.stub.pills.map((p, j) => ({
874
841
  key: `${r.id}:${j}`,
@@ -905,7 +872,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
905
872
  fontFamily: v('font'),
906
873
  }, 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))), (() => {
907
874
  const st = railState(rail, active ?? 0);
908
- // The lifelines run through the message rows only, so a phase header reads as one clear divider.
909
875
  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}`) : []);
910
876
  return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
911
877
  })(), (() => {
@@ -936,7 +902,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
936
902
  return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
937
903
  (() => {
938
904
  const tagW = ASYNC_TAG_W;
939
- // The tag sits on the tail side of the pill, so it never hides the arrowhead.
940
905
  const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
941
906
  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" })] }));
942
907
  })(), 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}`));
@@ -951,7 +916,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
951
916
  }, children: steps.map((s, i) => {
952
917
  const on = active === i;
953
918
  return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
954
- clock.current.elapsed = 0; // replay from the start, even when it is already the active step
919
+ clock.current.elapsed = 0;
955
920
  setActive(i);
956
921
  setPlaying(true);
957
922
  setBeat(0);
@@ -1000,7 +965,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
1000
965
  animation: 'flowfig-in .35s ease-out',
1001
966
  }, children: said }, `${active}-${saidAt}`))] }))] }));
1002
967
  }
1003
- /** A 16px stroke icon drawn from one SVG path. */
1004
968
  function Icon({ d, fill = false }) {
1005
969
  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 }) }));
1006
970
  }
package/dist/init.d.ts CHANGED
@@ -1,25 +1,18 @@
1
1
  type Agent = {
2
2
  id: string;
3
3
  name: string;
4
- /** Paths whose presence means the repo uses the agent. A trailing `/` means a directory. */
5
4
  marks: string[];
6
- /** The file init writes, relative to the repo. */
7
5
  file: string;
8
- /** `section` edits a file the user owns; `whole` owns the file. */
9
6
  kind: 'section' | 'whole';
10
- /** Text before the marked block in a whole file. */
11
7
  head?: string;
12
8
  note?: string;
13
- /** The MCP config file the agent reads, and the top-level key that holds the servers. `type` adds `"type": "stdio"`. */
14
9
  mcp?: {
15
10
  file: string;
16
11
  key: 'mcpServers' | 'servers';
17
12
  type?: true;
18
13
  global?: string;
19
14
  };
20
- /** Shown in place of the MCP file when init cannot write the config. */
21
15
  mcpNote?: string;
22
- /** A second file, a marked section in a file the user owns. Written only in a repo run, never with `--global`. */
23
16
  also?: {
24
17
  file: string;
25
18
  text: string;
@@ -30,16 +23,13 @@ export declare const MCP_ENTRY: {
30
23
  args: string[];
31
24
  };
32
25
  export declare const AGENTS: Agent[];
33
- /** The ids of the agents that the repo at `dir` already uses. */
34
26
  export declare const detect: (dir: string) => string[];
35
- /** Toggle the agents named by numbers ("1 3" or "1,3"). A number out of range and other text are ignored. */
27
+ /** Toggle the agents named by numbers ("1 3" or "1,3"). Ignore bad input. */
36
28
  export declare function toggle(input: string, selected: Set<string>, count: number): Set<string>;
37
- /** The picker screen. */
38
29
  export declare function renderList(selected: Set<string>, detected: string[], dir: string): string;
39
- /** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
30
+ /** The new file text, or `undefined` if the user owns the file. */
40
31
  export declare function place(a: Agent, old: string | undefined): string | undefined;
41
- /** The MCP file with the flowfig entry merged in, or `undefined` when the file is not a JSON object that can hold it. */
32
+ /** The MCP file with the flowfig entry, or `undefined` if the file cannot hold it. */
42
33
  export declare function registerMcp(a: Agent, old: string | undefined): string | undefined;
43
- /** Run `flowfig init`. Returns the exit code. */
44
34
  export declare function runInit(argv: string[]): Promise<number>;
45
35
  export {};