flowfig 0.2.0 → 0.4.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,15 +1,20 @@
1
1
  'use client';
2
2
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useMemo, useRef, useState } from 'react';
3
+ import { Fragment, useEffect, useMemo, useRef, useState } from 'react';
4
4
  import { route } from './geometry.js';
5
5
  import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
6
+ import { textWidth } from './text.js';
6
7
  import { checkScene, checkSpec, checkTheme } from './check.js';
7
- import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, } from './model.js';
8
+ 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, laneColumns, LANE_GAP, LANE_PAD, LANE_ROW_GAP, isLanesLayout, nodes, timelineBeats, timelineLayout, TL_AXIS_H, TL_AXIS_W, TL_BAR_H, TL_ROW_GAP, toBeat, beatMs, STEP_HOLD_MS, playheadItem, dateLabelRaised, } from './model.js';
8
9
  export { LIGHT, DARK } from './model.js';
9
10
  const DEFAULTS = { ...LIGHT, font: 'inherit' };
10
11
  const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
11
12
  const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
12
- const glow = `0 0 0 3px color-mix(in srgb, ${v('accent')} 18%, transparent)`;
13
+ const 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.
16
+ const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
17
+ const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
13
18
  const cardBody = (c) => {
14
19
  if (c == null)
15
20
  return '—';
@@ -43,7 +48,14 @@ const cardBody = (c) => {
43
48
  * The interactive player for one figure: tabs for the steps, play and pause, speed, hover and full screen.
44
49
  * Give it a `FlowProps` spec. Use `toSvg` from `flowfig/svg` for a static animated SVG instead.
45
50
  */
46
- export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay = true, check = false, rail: withRail = false }) {
51
+ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
52
+ 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
+ const synthetic = tl && !stepsIn.length;
55
+ 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
+ const [axisW, setAxisW] = useState(TL_AXIS_W);
58
+ const area = useRef(null);
47
59
  const root = useRef(null);
48
60
  const outer = useRef(null);
49
61
  const [fit, setFit] = useState({ scale: 1, height: 0 });
@@ -59,6 +71,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
59
71
  const chips = useRef([]);
60
72
  const bar = useRef(null); // the active tab's progress line
61
73
  const [beat, setBeat] = useState(0);
74
+ const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
62
75
  const paths = useRef({});
63
76
  const [routed, setRouted] = useState([]);
64
77
  const [active, setActive] = useState(steps.length ? 0 : null);
@@ -72,7 +85,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
72
85
  const clock = useRef({ beats: null, elapsed: 0 });
73
86
  const [hover, setHover] = useState(null);
74
87
  const ids = useMemo(() => edges.map(edgeId), [edges]);
75
- const tips = useMemo(() => new Set(decisions(layout)), [layout]);
88
+ // A milestone is a diamond too, so its edges meet the tips.
89
+ const tips = useMemo(() => new Set([
90
+ ...decisions(layout),
91
+ ...(tl
92
+ ? timelineLayout({ layout, edges, timeline, today }, TL_AXIS_W)
93
+ .items.filter((i) => i.milestone)
94
+ .map((i) => i.id)
95
+ : []),
96
+ ]), [layout, tl, edges, timeline, today]);
76
97
  const step = active == null ? null : steps[active];
77
98
  const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
78
99
  // Every content each box's card will ever show. The card is sized to the largest, so text never gets cut and nothing jumps.
@@ -129,6 +150,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
129
150
  const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
130
151
  setFit({ scale, height: el.offsetHeight * scale });
131
152
  setMapW(el.offsetWidth);
153
+ if (area.current)
154
+ setAxisW(area.current.offsetWidth);
132
155
  const base = el.getBoundingClientRect();
133
156
  const k = base.width / el.offsetWidth; // the scale currently on screen; rects are measured unscaled
134
157
  const rects = {};
@@ -141,7 +164,18 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
141
164
  h: r.height / k,
142
165
  };
143
166
  });
144
- setRouted(route(edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips));
167
+ const avoid = [];
168
+ el.querySelectorAll('[data-fig-outside]').forEach((n) => {
169
+ const r = n.getBoundingClientRect();
170
+ avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
171
+ });
172
+ setRouted(route(edges.map((e, i) => ({
173
+ id: ids[i],
174
+ from: e.from,
175
+ to: e.to,
176
+ around: e.around,
177
+ ...(tl && { sides: ['r', 'l'], elbow: true }),
178
+ })), rects, tips, avoid));
145
179
  };
146
180
  measure();
147
181
  const ro = new ResizeObserver(measure);
@@ -149,7 +183,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
149
183
  ro.observe(box);
150
184
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
151
185
  return () => ro.disconnect();
152
- }, [edges, ids, layout, tips, noMap]);
186
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW]);
153
187
  // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
154
188
  const reported = useRef(new Set());
155
189
  useEffect(() => {
@@ -192,7 +226,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
192
226
  edges: [
193
227
  ...routed.map((r) => {
194
228
  const e = edges[ids.indexOf(r.id)];
195
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id] };
229
+ return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) };
196
230
  }),
197
231
  // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
198
232
  ...(rail?.rows ?? []).flatMap((row) => {
@@ -228,23 +262,23 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
228
262
  }, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
229
263
  // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
230
264
  useEffect(() => {
231
- const gs = dots.current, cs = chips.current;
265
+ const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
232
266
  if (clock.current.beats !== beats) {
233
267
  // A rail click can open another step at a later beat; start the clock at that beat, not at 0.
234
268
  const j = jump.current ?? 0;
235
269
  jump.current = null;
236
- clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + (b.ms ?? speed), 0) };
270
+ clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
237
271
  setBeat(j);
238
272
  }
239
- if (!beats.length || (!noMap && !routed.length))
273
+ if (!beats.length || (!noMap && !routed.length && !(tl && !edges.length)))
240
274
  return;
241
275
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
242
276
  if (bar.current)
243
277
  bar.current.style.transform = 'none';
244
278
  return void setBeat(beats.length - 1);
245
279
  }
246
- const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + (b.ms ?? speed)], []);
247
- const total = ends.at(-1) + speed * 1.5; // hold on the last beat before moving on
280
+ const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
281
+ const total = ends.at(-1) + STEP_HOLD_MS; // The figure holds on the last beat before the next step.
248
282
  let raf = 0, last = performance.now(), shownBeat = -1;
249
283
  const tick = (now) => {
250
284
  if (playingRef.current)
@@ -262,8 +296,9 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
262
296
  const i = next === -1 ? beats.length - 1 : next;
263
297
  if (i !== shownBeat)
264
298
  setBeat((shownBeat = i));
299
+ setHolding(t >= ends.at(-1));
265
300
  const start = i ? ends[i - 1] : 0;
266
- const f = Math.min(1, (t - start) / ((beats[i].ms ?? speed) * 0.8)); // arrive a little early, rest at the end
301
+ const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
267
302
  const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
268
303
  const hops = t < ends.at(-1) ? beats[i].hops : [];
269
304
  gs.forEach((g, j) => {
@@ -277,20 +312,56 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
277
312
  return;
278
313
  }
279
314
  const pt = p.getPointAtLength((hops[j].back ? 1 - eased : eased) * p.getTotalLength());
315
+ const tone = hops[j].tone && TONES[hops[j].tone];
280
316
  if (g) {
281
317
  g.setAttribute('transform', `translate(${pt.x} ${pt.y})`);
282
318
  g.style.opacity = '1';
319
+ g.style.color = tone ?? v('accent');
283
320
  }
284
321
  if (c) {
322
+ c.style.background = tone ? toneFill(tone) : v('accent');
285
323
  c.style.transform = `translate(${pt.x}px, ${pt.y}px) translate(-50%, calc(-100% - 12px))`;
286
324
  c.style.opacity = hops[j].data == null ? '0' : '1';
287
325
  }
288
326
  });
327
+ // A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
328
+ const arrived = new Map();
329
+ if (f >= 1)
330
+ for (const h of beats[i].hops) {
331
+ const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
332
+ arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
333
+ }
334
+ // A focused box turns active at the beat start; in a timeline, when the today line arrives.
335
+ if (t - start >= (tl ? 400 * BASE_RATE : 0))
336
+ for (const id of beats[i].focus ?? [])
337
+ if (!arrived.has(id))
338
+ arrived.set(id, undefined);
339
+ root.current?.querySelectorAll('[data-fig]').forEach((n) => {
340
+ const on = arrived.has(n.dataset.fig ?? '');
341
+ if (on) {
342
+ const tone = arrived.get(n.dataset.fig);
343
+ if (tone)
344
+ n.style.setProperty('--ff-hop', tone);
345
+ else
346
+ n.style.removeProperty('--ff-hop');
347
+ }
348
+ if (on === n.classList.contains(ACTIVE))
349
+ return;
350
+ if (!on)
351
+ return void n.classList.remove(ACTIVE); // the transition fades the box
352
+ // The box turns active at once: no transition while the class arrives.
353
+ const keep = n.style.transition;
354
+ n.style.transition = 'none';
355
+ n.classList.add(ACTIVE);
356
+ void n.offsetWidth;
357
+ n.style.transition = keep;
358
+ });
289
359
  raf = requestAnimationFrame(tick);
290
360
  };
291
361
  raf = requestAnimationFrame(tick);
292
362
  return () => {
293
363
  cancelAnimationFrame(raf);
364
+ root.current?.querySelectorAll(`.${ACTIVE}`).forEach((n) => n.classList.remove(ACTIVE));
294
365
  gs.forEach((g) => g && (g.style.opacity = '0'));
295
366
  cs.forEach((c) => c && (c.style.opacity = '0'));
296
367
  };
@@ -304,15 +375,20 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
304
375
  ? [edges[hoverEdgeIdx].from, edges[hoverEdgeIdx].to]
305
376
  : hover
306
377
  ? [hover]
307
- : [...(step?.nodes ?? []), ...Object.keys(shown), ...(cur?.light ?? [])]);
378
+ : [...(step?.nodes ?? []), ...Object.keys(shown), ...(cur?.light ?? []), ...(cur?.focus ?? [])]);
308
379
  edges.forEach((e, i) => {
309
380
  if (litEdges.has(ids[i]))
310
381
  litNodes.add(e.from).add(e.to);
311
382
  });
383
+ // A toned hop colors its edge for its own beat only; the trail after it is the accent.
384
+ const hopTone = (edge) => {
385
+ const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
386
+ return t && TONES[t];
387
+ };
312
388
  const goTo = (step, b) => {
313
389
  setPlaying(true);
314
390
  if (step === active) {
315
- clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + (x.ms ?? speed), 0);
391
+ clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
316
392
  setBeat(b);
317
393
  }
318
394
  else {
@@ -322,7 +398,149 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
322
398
  };
323
399
  const focus = hover != null || hoverEdge != null || step != null;
324
400
  const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
401
+ const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
402
+ // A bar or a milestone of a timeline: placed by the shared layout, with the look of a box.
403
+ const renderBar = (n, it) => {
404
+ const lit = litNodes.has(n.id);
405
+ const bt = n.tone && TONES[n.tone];
406
+ const label = String(n.label);
407
+ const inside = it.labelInside;
408
+ const outside = (_jsx("span", { "data-fig-outside": "", style: { position: 'absolute', left: '100%', marginLeft: 6, whiteSpace: 'nowrap', fontSize: 13, fontWeight: 500, color: v('fg') }, children: label }));
409
+ const top = LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
410
+ return (_jsxs("div", { "data-fig": n.id, "data-diamond": it.milestone || undefined, onMouseEnter: () => setHover(n.id), onMouseLeave: () => setHover(null), style: {
411
+ position: 'absolute',
412
+ left: it.x,
413
+ top: it.milestone ? top + (TL_BAR_H - it.w) / 2 : top,
414
+ width: it.w,
415
+ height: it.milestone ? it.w : TL_BAR_H,
416
+ boxSizing: 'border-box',
417
+ display: 'flex',
418
+ alignItems: 'center',
419
+ padding: it.milestone ? 0 : '0 calc(8px - var(--ff-b, 0px))',
420
+ color: v('fg'),
421
+ background: it.milestone ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
422
+ border: it.milestone ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
423
+ ...(bt && { '--ff-box': bt }),
424
+ borderRadius: 6,
425
+ fontSize: 13,
426
+ fontWeight: 500,
427
+ whiteSpace: 'nowrap',
428
+ opacity: focus && !lit ? 0.7 : 1,
429
+ transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
430
+ cursor: 'default',
431
+ }, children: [it.milestone && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: { position: 'absolute', inset: 0, width: '100%', height: '100%', overflow: 'visible' }, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: bt ? toneTint(bt, v('bg')) : v('bg'), stroke: bt ?? (lit ? v('accent') : v('border')), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .4s, fill .4s, stroke-width .4s' } }) })), inside ? _jsx("span", { style: { position: 'relative' }, children: label }) : outside] }, n.id));
432
+ };
325
433
  const renderItem = (item, depth) => {
434
+ if (timelineFig && item === layout) {
435
+ const lanesList = layout.children;
436
+ // The playhead follows the latest dated focus in the step, and rests at the last date before one and in the hold.
437
+ const focused = holding || still ? undefined : playheadItem(timelineFig.items, beats, beat);
438
+ const home = timelineFig.last ?? 0;
439
+ const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
440
+ const dateText = focused ? focused.date : timelineFig.lastDate;
441
+ const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
442
+ return (_jsxs("div", { style: {
443
+ display: 'grid',
444
+ gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
445
+ // The axis strip sits over the gap above the first band, as in the SVG.
446
+ gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
447
+ rowGap: LANE_ROW_GAP,
448
+ position: 'relative',
449
+ }, 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) => {
450
+ const lit = lane.id != null && litNodes.has(lane.id);
451
+ return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
452
+ gridColumn: '1 / -1',
453
+ gridRow: li + 2,
454
+ background: v('surface'),
455
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
456
+ boxShadow: lit ? glow : undefined,
457
+ transition: 'border-color .25s, box-shadow .25s',
458
+ borderRadius: 14,
459
+ zIndex: 0,
460
+ } }), _jsx("div", { style: {
461
+ gridColumn: 1,
462
+ gridRow: li + 2,
463
+ alignSelf: 'center',
464
+ zIndex: 1,
465
+ padding: '0 18px',
466
+ fontSize: 12,
467
+ fontWeight: 600,
468
+ letterSpacing: '.04em',
469
+ textTransform: 'uppercase',
470
+ color: v('muted'),
471
+ }, children: lane.label }), _jsx("div", { ref: li === 0 ? area : undefined, style: { gridColumn: 2, gridRow: li + 2, position: 'relative', zIndex: 1 }, children: timelineFig.items
472
+ .filter((it) => it.track === li)
473
+ .map((it) => renderBar(lane.children.find((k) => k.id === it.id), it)) })] }, lane.id ?? String(lane.label)));
474
+ }), 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: {
475
+ position: 'absolute',
476
+ right: 3,
477
+ top: -1,
478
+ fontSize: 11,
479
+ lineHeight: '11px',
480
+ fontWeight: 600,
481
+ color: v('accent'),
482
+ }, children: "today" })] })), _jsx("div", { "data-fig-playhead": "", style: {
483
+ position: 'absolute',
484
+ left: 0,
485
+ top: 12,
486
+ bottom: 0,
487
+ width: 1.5,
488
+ background: v('accent'),
489
+ transform: `translateX(${at}px)`,
490
+ transition: still ? 'none' : 'transform .4s linear',
491
+ }, children: _jsx("span", { style: {
492
+ position: 'absolute',
493
+ left: 3,
494
+ top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
495
+ fontSize: 11,
496
+ lineHeight: '11px',
497
+ fontWeight: 600,
498
+ whiteSpace: 'nowrap',
499
+ color: v('accent'),
500
+ animation: still ? undefined : 'flowfig-label .4s step-end',
501
+ }, children: dateText }, dateText) })] }))] }));
502
+ }
503
+ if (lanes && item === layout && isLanesLayout(layout)) {
504
+ const cols = laneColumns({ layout, edges, steps, lanes });
505
+ const n = Math.max(0, ...cols.values()) + 1;
506
+ return (_jsx("div", { style: {
507
+ display: 'grid',
508
+ gridTemplateColumns: `max-content repeat(${n}, max-content)`,
509
+ columnGap: 0,
510
+ rowGap: LANE_ROW_GAP,
511
+ position: 'relative',
512
+ }, children: layout.children.map((lane, li) => {
513
+ const lit = lane.id != null && litNodes.has(lane.id);
514
+ return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
515
+ gridColumn: '1 / -1',
516
+ gridRow: li + 1,
517
+ background: v('surface'),
518
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
519
+ boxShadow: lit ? glow : undefined,
520
+ transition: 'border-color .25s, box-shadow .25s',
521
+ borderRadius: 14,
522
+ zIndex: 0,
523
+ } }), _jsx("div", { style: {
524
+ gridColumn: 1,
525
+ gridRow: li + 1,
526
+ alignSelf: 'center',
527
+ zIndex: 1,
528
+ padding: '0 18px',
529
+ fontSize: 12,
530
+ fontWeight: 600,
531
+ letterSpacing: '.04em',
532
+ textTransform: 'uppercase',
533
+ color: v('muted'),
534
+ }, children: lane.label }), lane.children.map((b) => (_jsx("div", { style: {
535
+ gridColumn: cols.get(b.id) + 2,
536
+ gridRow: li + 1,
537
+ alignSelf: 'center',
538
+ zIndex: 1,
539
+ // The last time column keeps the frame side, as in the SVG.
540
+ padding: `${LANE_PAD}px ${cols.get(b.id) === n - 1 ? 18 : LANE_GAP}px ${LANE_PAD}px 0`,
541
+ }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
542
+ }) }));
543
+ }
326
544
  if (isGroup(item)) {
327
545
  const lit = item.id != null && litNodes.has(item.id);
328
546
  const framed = item.label != null;
@@ -360,7 +578,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
360
578
  const diamond = item.shape === 'decision';
361
579
  const store = item.shape === 'store';
362
580
  const card = carded.has(item.id);
363
- return (_jsxs("div", { "data-fig": item.id, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
581
+ const bt = item.tone && TONES[item.tone];
582
+ return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
364
583
  position: 'relative',
365
584
  isolation: 'isolate',
366
585
  minWidth: 100,
@@ -371,17 +590,20 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
371
590
  display: 'flex',
372
591
  flexDirection: 'column',
373
592
  justifyContent: 'center',
374
- padding: diamond ? '22px 34px' : `${store ? 24 : 10}px ${card ? 10 : 16}px 10px`,
593
+ padding: diamond
594
+ ? '22px 34px'
595
+ : `calc(${store ? 24 : 10}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(10px - var(--ff-b, 0px))`,
375
596
  textAlign: 'center',
376
- background: diamond ? undefined : v('bg'),
597
+ background: diamond ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
377
598
  color: v('fg'),
378
- border: diamond ? undefined : `1px solid ${lit ? v('accent') : v('border')}`,
379
- boxShadow: diamond ? undefined : lit ? glow : '0 1px 2px rgba(0,0,0,.06)',
599
+ border: diamond ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
600
+ ...(bt && { '--ff-box': bt }),
601
+ boxShadow: diamond ? undefined : '0 1px 2px rgba(0,0,0,.06)',
380
602
  opacity: focus && !lit ? 0.7 : 1,
381
603
  borderRadius: store ? '50% / 12px' : 10,
382
604
  fontSize: 14,
383
605
  fontWeight: 500,
384
- transition: 'border-color .25s, box-shadow .25s, opacity .25s',
606
+ transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
385
607
  cursor: 'default',
386
608
  }, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
387
609
  position: 'absolute',
@@ -390,7 +612,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
390
612
  height: '100%',
391
613
  overflow: 'visible',
392
614
  zIndex: -1,
393
- }, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: v('bg'), stroke: lit ? v('accent') : v('border'), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .25s' } }) })), store && (_jsx("div", { style: {
615
+ }, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: bt ? toneTint(bt, v('bg')) : v('bg'), stroke: bt ?? (lit ? v('accent') : v('border')), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .4s, fill .4s, stroke-width .4s' } }) })), store && (_jsx("div", { style: {
394
616
  position: 'absolute',
395
617
  top: -1,
396
618
  left: -1,
@@ -398,8 +620,23 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
398
620
  height: 24,
399
621
  boxSizing: 'border-box',
400
622
  borderRadius: '50%',
401
- border: `1px solid ${lit ? v('accent') : v('border')}`,
623
+ border: `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
402
624
  transition: 'border-color .25s',
625
+ } })), item.mark && (_jsx("span", { "aria-hidden": true, style: {
626
+ position: 'absolute',
627
+ top: '50%',
628
+ transform: 'translateY(-50%)',
629
+ boxSizing: 'border-box',
630
+ borderRadius: '50%',
631
+ ...(item.mark === 'start'
632
+ ? { left: -18, width: 10, height: 10, background: bt ?? v('accent') }
633
+ : {
634
+ right: -20,
635
+ width: 14,
636
+ height: 14,
637
+ border: `1.5px solid ${bt ?? v('accent')}`,
638
+ background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
639
+ }),
403
640
  } })), _jsx("div", { children: item.label }), item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
404
641
  marginTop: 8,
405
642
  padding: '6px 8px',
@@ -452,7 +689,10 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
452
689
  zIndex: 3,
453
690
  width: 28,
454
691
  height: 28,
455
- }, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
692
+ }, 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}}' +
693
+ // 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.
694
+ `.${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}` +
695
+ `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
456
696
  margin: full ? '-48px -24px 0' : '-20px -16px 0',
457
697
  padding: full ? '48px 24px 24px' : '24px 16px',
458
698
  borderRadius: full ? 0 : '16px 16px 0 0',
@@ -479,15 +719,18 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
479
719
  height: '100%',
480
720
  overflow: 'visible',
481
721
  pointerEvents: 'none',
482
- }, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: k === 'on' ? v('accent') : v('muted') }) }, k))) }), routed.map((r) => {
722
+ // A timeline dependency runs under the bars (z-index 1) and over the bands (0).
723
+ zIndex: tl ? 0 : undefined,
724
+ }, 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) => {
483
725
  const on = litEdges.has(r.id);
726
+ const tone = hopTone(r.id);
484
727
  const hidden = !on && edges[ids.indexOf(r.id)].quiet;
485
728
  return (_jsx("path", { ref: (p) => {
486
729
  paths.current[r.id] = p;
487
- }, d: r.d, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
730
+ }, d: r.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' } }, r.id));
488
731
  }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
489
732
  dots.current[j] = g;
490
- }, style: { opacity: 0 }, children: [_jsx("circle", { r: 10, fill: v('accent'), opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: v('accent') })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
733
+ }, 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) => {
491
734
  chips.current[j] = c;
492
735
  }, style: {
493
736
  position: 'absolute',
@@ -508,9 +751,10 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
508
751
  transition: 'opacity .2s',
509
752
  }, children: cur?.hops[j]?.data }, j))), routed.map((r) => {
510
753
  const e = edges[ids.indexOf(r.id)];
511
- if (e.label == null)
754
+ if (e.label == null || tl)
512
755
  return null;
513
756
  const on = litEdges.has(r.id);
757
+ const tone = hopTone(r.id);
514
758
  return (_jsx("div", { "data-fig-label": r.id, style: {
515
759
  position: 'absolute',
516
760
  left: r.mid.x,
@@ -522,9 +766,9 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
522
766
  borderRadius: 999,
523
767
  whiteSpace: 'nowrap',
524
768
  pointerEvents: 'none',
525
- background: on ? v('accent') : v('bg'),
769
+ background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
526
770
  color: on ? ON_ACCENT : v('muted'),
527
- border: `1px solid ${on ? v('accent') : v('border')}`,
771
+ border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
528
772
  opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
529
773
  fontFamily: MONO,
530
774
  transition: 'background .25s, color .25s, opacity .25s',
@@ -565,16 +809,17 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
565
809
  (hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
566
810
  const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
567
811
  const ly = y + RAIL.row / 2;
812
+ const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
568
813
  const g = row.group != null ? rail.groups[row.group] : null;
569
- return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
814
+ 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 &&
570
815
  (() => {
571
816
  const tagW = ASYNC_TAG_W;
572
817
  // The tag sits on the tail side of the pill, so it never hides the arrowhead.
573
818
  const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
574
819
  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" })] }));
575
- })(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? v('accent') : v('bg'), stroke: on ? v('accent') : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
820
+ })(), 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}`));
576
821
  });
577
- })(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => setPlaying((p) => !p), children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), _jsx("div", { role: "tablist", style: {
822
+ })(), _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: {
578
823
  display: 'inline-flex',
579
824
  gap: 2,
580
825
  padding: 3,
@@ -613,7 +858,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
613
858
  transform: 'scaleX(0)',
614
859
  opacity: playing ? 1 : 0.35,
615
860
  } }))] }, i));
616
- }) }), _jsxs("button", { type: "button", "aria-label": `Speed ${rate}×, switch to ${rate === 1 ? 2 : 1}×`, title: "Playback speed", style: {
861
+ }) })), _jsxs("button", { type: "button", "aria-label": `Speed ${rate}×, switch to ${rate === 1 ? 2 : 1}×`, title: "Playback speed", style: {
617
862
  ...iconBtn,
618
863
  width: 'auto',
619
864
  padding: '0 10px',
package/dist/init.d.ts CHANGED
@@ -19,6 +19,11 @@ type Agent = {
19
19
  };
20
20
  /** Shown in place of the MCP file when init cannot write the config. */
21
21
  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
+ also?: {
24
+ file: string;
25
+ text: string;
26
+ };
22
27
  };
23
28
  export declare const MCP_ENTRY: {
24
29
  command: string;