flowfig 0.3.0 → 0.5.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,14 +1,16 @@
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, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, beatMs, STEP_HOLD_MS, } 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, 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)';
13
+ const NONE = []; // a stable default, so a memo keyed on it does not run every render
12
14
  const ACTIVE = 'flowfig-active'; // the box a packet has just reached
13
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.
14
16
  const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
@@ -46,7 +48,14 @@ const cardBody = (c) => {
46
48
  * The interactive player for one figure: tabs for the steps, play and pause, speed, hover and full screen.
47
49
  * Give it a `FlowProps` spec. Use `toSvg` from `flowfig/svg` for a static animated SVG instead.
48
50
  */
49
- 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);
50
59
  const root = useRef(null);
51
60
  const outer = useRef(null);
52
61
  const [fit, setFit] = useState({ scale: 1, height: 0 });
@@ -62,8 +71,17 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
62
71
  const chips = useRef([]);
63
72
  const bar = useRef(null); // the active tab's progress line
64
73
  const [beat, setBeat] = useState(0);
74
+ const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
65
75
  const paths = useRef({});
66
76
  const [routed, setRouted] = useState([]);
77
+ // Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
78
+ 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
+ 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 })
84
+ : null, [lanes, tl, layout, edges, steps, withRail]);
67
85
  const [active, setActive] = useState(steps.length ? 0 : null);
68
86
  const [playing, setPlaying] = useState(autoplay);
69
87
  // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
@@ -75,7 +93,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
75
93
  const clock = useRef({ beats: null, elapsed: 0 });
76
94
  const [hover, setHover] = useState(null);
77
95
  const ids = useMemo(() => edges.map(edgeId), [edges]);
78
- const tips = useMemo(() => new Set(decisions(layout)), [layout]);
96
+ // A milestone is a diamond too, so its edges meet the tips.
97
+ const tips = useMemo(() => new Set([
98
+ ...decisions(layout),
99
+ ...(tl
100
+ ? timelineLayout({ layout, edges, timeline, today }, TL_AXIS_W)
101
+ .items.filter((i) => i.milestone)
102
+ .map((i) => i.id)
103
+ : []),
104
+ ]), [layout, tl, edges, timeline, today]);
79
105
  const step = active == null ? null : steps[active];
80
106
  const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
81
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.
@@ -132,6 +158,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
132
158
  const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
133
159
  setFit({ scale, height: el.offsetHeight * scale });
134
160
  setMapW(el.offsetWidth);
161
+ if (area.current)
162
+ setAxisW(area.current.offsetWidth);
135
163
  const base = el.getBoundingClientRect();
136
164
  const k = base.width / el.offsetWidth; // the scale currently on screen; rects are measured unscaled
137
165
  const rects = {};
@@ -144,7 +172,67 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
144
172
  h: r.height / k,
145
173
  };
146
174
  });
147
- setRouted(route(edges.map((e, i) => ({ id: ids[i], from: e.from, to: e.to, around: e.around })), rects, tips));
175
+ const avoid = [];
176
+ el.querySelectorAll('[data-fig-outside]').forEach((n) => {
177
+ const r = n.getBoundingClientRect();
178
+ avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
179
+ });
180
+ // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
181
+ const stubs = lanePlan?.stubs ?? new Map();
182
+ if (stubs.size) {
183
+ for (const n of nodes(layout))
184
+ if (rects[n.id])
185
+ avoid.push(rects[n.id]);
186
+ el.querySelectorAll('[data-fig-gutter]').forEach((n) => {
187
+ const r = n.getBoundingClientRect();
188
+ avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
189
+ });
190
+ }
191
+ // A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
192
+ const bands = {};
193
+ el.querySelectorAll('[data-fig-copy]').forEach((n) => {
194
+ const r = n.getBoundingClientRect();
195
+ 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
+ });
197
+ // A stub pill stays in the lanes, right of the gutter, as in the SVG.
198
+ const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
199
+ const pillArea = stubs.size && blockRects.length
200
+ ? (() => {
201
+ const [x0, y0] = [Math.min(...blockRects.map((r) => r.left)), Math.min(...blockRects.map((r) => r.top))];
202
+ const [x1, y1] = [Math.max(...blockRects.map((r) => r.right)), Math.max(...blockRects.map((r) => r.bottom))];
203
+ const x = (x0 - base.left) / k + lanePlan.gutter;
204
+ return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
205
+ })()
206
+ : undefined;
207
+ // The band of an edge end, in the block of that end, as in the SVG.
208
+ const laneBands = {};
209
+ el.querySelectorAll('[data-fig-band]').forEach((n) => {
210
+ const r = n.getBoundingClientRect();
211
+ laneBands[n.dataset.figBand] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
212
+ });
213
+ const bandOf = (eid, id, j) => {
214
+ const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
215
+ return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
216
+ };
217
+ const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
218
+ const next = route(edges.map((e, i) => ({
219
+ id: ids[i],
220
+ from: end(ids[i], e.from, true),
221
+ to: end(ids[i], e.to, false),
222
+ around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
223
+ ...(tl && { sides: ['r', 'l'], elbow: true }),
224
+ ...(stubs.has(ids[i]) && {
225
+ stub: stubs.get(ids[i]).map(labelPillW),
226
+ bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
227
+ }),
228
+ ...(stubs.size && e.label != null && { labelW: labelPillW(str(e.label)) }),
229
+ })), rects, tips, avoid, pillArea);
230
+ setRouted(next);
231
+ if (lanePlan) {
232
+ const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
233
+ if (more.length)
234
+ setExtraTall(new Set([...extraTall, ...more]));
235
+ }
148
236
  };
149
237
  measure();
150
238
  const ro = new ResizeObserver(measure);
@@ -152,7 +240,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
152
240
  ro.observe(box);
153
241
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
154
242
  return () => ro.disconnect();
155
- }, [edges, ids, layout, tips, noMap]);
243
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
156
244
  // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
157
245
  const reported = useRef(new Set());
158
246
  useEffect(() => {
@@ -170,16 +258,30 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
170
258
  const boxIds = new Set(nodes(layout).map((n) => n.id));
171
259
  const boxes = [...(el ?? fig).querySelectorAll('[data-fig]')]
172
260
  .filter((n) => boxIds.has(n.dataset.fig))
173
- .map((n) => ({
174
- id: n.dataset.fig,
175
- rect: rel(n),
176
- texts: [...n.querySelectorAll('*')]
177
- .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
178
- .map((t) => {
179
- const fontSize = parseFloat(getComputedStyle(t).fontSize);
180
- return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
181
- }),
182
- }));
261
+ .map((n) => {
262
+ const rect = rel(n);
263
+ return {
264
+ id: n.dataset.fig,
265
+ rect,
266
+ texts: [...n.querySelectorAll('*')]
267
+ .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
268
+ .map((t) => {
269
+ const fontSize = parseFloat(getComputedStyle(t).fontSize);
270
+ if (!n.dataset.diamond)
271
+ 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
+ const [r, range] = [rel(t), document.createRange()];
274
+ range.selectNodeContents(t);
275
+ const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
276
+ return {
277
+ text: t.textContent,
278
+ fontSize,
279
+ room: diamondRoom(rect.w, rect.h, far),
280
+ need: range.getBoundingClientRect().width / k,
281
+ };
282
+ }),
283
+ };
284
+ });
183
285
  // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
184
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.
185
287
  const fonts = [...fig.querySelectorAll('*')]
@@ -189,13 +291,23 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
189
291
  !t.closest('[data-fig-tag],[aria-hidden]'))
190
292
  .map((t) => parseFloat(getComputedStyle(t).fontSize));
191
293
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
294
+ const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
192
295
  const scene = {
193
296
  width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
194
297
  boxes,
298
+ ...(lanePlan?.stubs.size && {
299
+ lanes: lanesIn.map((n) => ({
300
+ id: str(layout.children[Number(n.dataset.figBand.split('@')[0])].label),
301
+ rect: rel(n),
302
+ })),
303
+ }),
195
304
  edges: [
196
- ...routed.map((r) => {
305
+ ...routed.flatMap((r) => {
197
306
  const e = edges[ids.indexOf(r.id)];
198
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id] };
307
+ // A cross-block edge is two scene edges: each stub with its pill.
308
+ if (r.stub)
309
+ 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
+ return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
199
311
  }),
200
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.
201
313
  ...(rail?.rows ?? []).flatMap((row) => {
@@ -228,7 +340,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
228
340
  reported.current.add(key);
229
341
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
230
342
  }
231
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
343
+ }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
232
344
  // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
233
345
  useEffect(() => {
234
346
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
@@ -239,7 +351,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
239
351
  clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
240
352
  setBeat(j);
241
353
  }
242
- if (!beats.length || (!noMap && !routed.length))
354
+ if (!beats.length || (!noMap && !routed.length && !(tl && !edges.length)))
243
355
  return;
244
356
  if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
245
357
  if (bar.current)
@@ -265,6 +377,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
265
377
  const i = next === -1 ? beats.length - 1 : next;
266
378
  if (i !== shownBeat)
267
379
  setBeat((shownBeat = i));
380
+ setHolding(t >= ends.at(-1));
268
381
  const start = i ? ends[i - 1] : 0;
269
382
  const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
270
383
  const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
@@ -299,6 +412,11 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
299
412
  const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
300
413
  arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
301
414
  }
415
+ // A focused box turns active at the beat start; in a timeline, when the today line arrives.
416
+ if (t - start >= (tl ? 400 * BASE_RATE : 0))
417
+ for (const id of beats[i].focus ?? [])
418
+ if (!arrived.has(id))
419
+ arrived.set(id, undefined);
302
420
  root.current?.querySelectorAll('[data-fig]').forEach((n) => {
303
421
  const on = arrived.has(n.dataset.fig ?? '');
304
422
  if (on) {
@@ -338,7 +456,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
338
456
  ? [edges[hoverEdgeIdx].from, edges[hoverEdgeIdx].to]
339
457
  : hover
340
458
  ? [hover]
341
- : [...(step?.nodes ?? []), ...Object.keys(shown), ...(cur?.light ?? [])]);
459
+ : [...(step?.nodes ?? []), ...Object.keys(shown), ...(cur?.light ?? []), ...(cur?.focus ?? [])]);
342
460
  edges.forEach((e, i) => {
343
461
  if (litEdges.has(ids[i]))
344
462
  litNodes.add(e.from).add(e.to);
@@ -361,7 +479,164 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
361
479
  };
362
480
  const focus = hover != null || hoverEdge != null || step != null;
363
481
  const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
482
+ 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
+ const renderBar = (n, it) => {
485
+ const lit = litNodes.has(n.id);
486
+ const bt = n.tone && TONES[n.tone];
487
+ const label = String(n.label);
488
+ const inside = it.labelInside;
489
+ 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 }));
490
+ const top = LANE_PAD + it.row * (TL_BAR_H + TL_ROW_GAP);
491
+ return (_jsxs("div", { "data-fig": n.id, "data-diamond": it.milestone || undefined, onMouseEnter: () => setHover(n.id), onMouseLeave: () => setHover(null), style: {
492
+ position: 'absolute',
493
+ left: it.x,
494
+ top: it.milestone ? top + (TL_BAR_H - it.w) / 2 : top,
495
+ width: it.w,
496
+ height: it.milestone ? it.w : TL_BAR_H,
497
+ boxSizing: 'border-box',
498
+ display: 'flex',
499
+ alignItems: 'center',
500
+ padding: it.milestone ? 0 : '0 calc(8px - var(--ff-b, 0px))',
501
+ color: v('fg'),
502
+ background: it.milestone ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
503
+ border: it.milestone ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
504
+ ...(bt && { '--ff-box': bt }),
505
+ borderRadius: 6,
506
+ fontSize: 13,
507
+ fontWeight: 500,
508
+ whiteSpace: 'nowrap',
509
+ opacity: focus && !lit ? 0.7 : 1,
510
+ transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
511
+ cursor: 'default',
512
+ }, 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));
513
+ };
364
514
  const renderItem = (item, depth) => {
515
+ if (timelineFig && item === layout) {
516
+ 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);
519
+ const home = timelineFig.last ?? 0;
520
+ const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
521
+ const dateText = focused ? focused.date : timelineFig.lastDate;
522
+ const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
523
+ return (_jsxs("div", { style: {
524
+ display: 'grid',
525
+ 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
+ gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
528
+ rowGap: LANE_ROW_GAP,
529
+ 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) => {
531
+ const lit = lane.id != null && litNodes.has(lane.id);
532
+ return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
533
+ gridColumn: '1 / -1',
534
+ gridRow: li + 2,
535
+ background: v('surface'),
536
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
537
+ boxShadow: lit ? glow : undefined,
538
+ transition: 'border-color .25s, box-shadow .25s',
539
+ borderRadius: 14,
540
+ zIndex: 0,
541
+ } }), _jsx("div", { style: {
542
+ gridColumn: 1,
543
+ gridRow: li + 2,
544
+ alignSelf: 'center',
545
+ zIndex: 1,
546
+ padding: '0 18px',
547
+ fontSize: 12,
548
+ fontWeight: 600,
549
+ letterSpacing: '.04em',
550
+ textTransform: 'uppercase',
551
+ color: v('muted'),
552
+ }, children: lane.label }), _jsx("div", { ref: li === 0 ? area : undefined, style: { gridColumn: 2, gridRow: li + 2, position: 'relative', zIndex: 1 }, children: timelineFig.items
553
+ .filter((it) => it.track === li)
554
+ .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) })] }))] }));
583
+ }
584
+ if (lanes && item === layout && isLanesLayout(layout)) {
585
+ const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
586
+ const n = Math.max(0, ...cols.values()) + 1;
587
+ 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
+ const grid = (bk) => {
590
+ const m = starts[bk + 1] - starts[bk];
591
+ const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
592
+ const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
593
+ return (_jsx("div", { "data-fig-block": bk, style: {
594
+ 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
+ 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
+ gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
600
+ columnGap: 0,
601
+ rowGap: LANE_ROW_GAP,
602
+ position: 'relative',
603
+ }, children: shown.map((lane, li) => {
604
+ const lit = lane.id != null && litNodes.has(lane.id);
605
+ return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-copy": lane.id != null ? `${lane.id}@${bk}` : undefined, "data-fig-band": `${layout.children.indexOf(lane)}@${bk}`, "data-fig-lane": "", style: {
606
+ gridColumn: '1 / -1',
607
+ gridRow: li + 1,
608
+ background: v('surface'),
609
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
610
+ boxShadow: lit ? glow : undefined,
611
+ transition: 'border-color .25s, box-shadow .25s',
612
+ borderRadius: 14,
613
+ zIndex: 0,
614
+ } }), _jsx("div", { "data-fig-gutter": "", style: {
615
+ gridColumn: 1,
616
+ gridRow: li + 1,
617
+ alignSelf: 'center',
618
+ zIndex: 1,
619
+ padding: '0 18px',
620
+ whiteSpace: 'nowrap',
621
+ fontSize: 12,
622
+ fontWeight: 600,
623
+ letterSpacing: '.04em',
624
+ textTransform: 'uppercase',
625
+ color: v('muted'),
626
+ }, children: lane.label }), lane.children.filter(inBlock).map((b) => (_jsx("div", { style: {
627
+ gridColumn: cols.get(b.id) - starts[bk] + 2,
628
+ gridRow: li + 1,
629
+ alignSelf: 'center',
630
+ zIndex: 1,
631
+ // The space after each column and before a block's first column comes from the plan, as in the SVG.
632
+ 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
+ }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
634
+ }) }, bk));
635
+ };
636
+ if (count < 2)
637
+ return grid(blocks[0]);
638
+ return _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: LANE_BLOCK_GAP }, children: blocks.map(grid) });
639
+ }
365
640
  if (isGroup(item)) {
366
641
  const lit = item.id != null && litNodes.has(item.id);
367
642
  const framed = item.label != null;
@@ -399,14 +674,16 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
399
674
  const diamond = item.shape === 'decision';
400
675
  const store = item.shape === 'store';
401
676
  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
+ const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
402
679
  const bt = item.tone && TONES[item.tone];
403
680
  return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
404
681
  position: 'relative',
405
682
  isolation: 'isolate',
406
683
  minWidth: 100,
407
- maxWidth: card ? undefined : 190,
408
- width: item.width ?? (card ? CARD_WIDTH : undefined),
409
- minHeight: minHeight(item.id),
684
+ maxWidth: card || diamond ? undefined : 190,
685
+ width: diamond ? nodeWidth(item, card) + 70 : (item.width ?? (card ? CARD_WIDTH : undefined)),
686
+ minHeight: dl ? Math.max(dl.h, (minHeight(item.id) ?? 0) + 24) : minHeight(item.id),
410
687
  boxSizing: 'border-box',
411
688
  display: 'flex',
412
689
  flexDirection: 'column',
@@ -443,7 +720,24 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
443
720
  borderRadius: '50%',
444
721
  border: `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
445
722
  transition: 'border-color .25s',
446
- } })), _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: {
723
+ } })), item.mark && (_jsx("span", { "aria-hidden": true, style: {
724
+ position: 'absolute',
725
+ top: '50%',
726
+ transform: 'translateY(-50%)',
727
+ boxSizing: 'border-box',
728
+ borderRadius: '50%',
729
+ ...(item.mark === 'start'
730
+ ? { left: -18, width: 10, height: 10, background: bt ?? v('accent') }
731
+ : {
732
+ right: -20,
733
+ width: 14,
734
+ height: 14,
735
+ border: `1.5px solid ${bt ?? v('accent')}`,
736
+ background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
737
+ }),
738
+ } })), _jsx("div", { children: item.label }), dl
739
+ ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
740
+ : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
447
741
  marginTop: 8,
448
742
  padding: '6px 8px',
449
743
  minHeight: item.lines != null ? item.lines * CARD_LINE + CARD_PAD * 2 + 1 : undefined,
@@ -495,7 +789,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
495
789
  zIndex: 3,
496
790
  width: 28,
497
791
  height: 28,
498
- }, 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}}' +
792
+ }, 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}}' +
499
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.
500
794
  `.${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}` +
501
795
  `.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
@@ -516,8 +810,11 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
516
810
  transformOrigin: 'top left',
517
811
  padding: 4,
518
812
  // room for edges that arc over or under the boxes
519
- paddingTop: edges.some((e) => e.around === 'above') ? 44 : 4,
520
- paddingBottom: edges.some((e) => e.around === 'below') ? 44 : 4,
813
+ // A stub does not arc.
814
+ paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
815
+ paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
816
+ ? 44
817
+ : 4,
521
818
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
522
819
  position: 'absolute',
523
820
  inset: 0,
@@ -525,13 +822,24 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
525
822
  height: '100%',
526
823
  overflow: 'visible',
527
824
  pointerEvents: 'none',
825
+ // A timeline dependency runs under the bars (z-index 1) and over the bands (0).
826
+ zIndex: tl ? 0 : undefined,
528
827
  }, 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) => {
529
828
  const on = litEdges.has(r.id);
530
829
  const tone = hopTone(r.id);
531
830
  const hidden = !on && edges[ids.indexOf(r.id)].quiet;
532
- return (_jsx("path", { ref: (p) => {
831
+ const look = (d, key, guides = false) => (_jsx("path", { ref: guides
832
+ ? (p) => {
833
+ paths.current[r.id] = p;
834
+ }
835
+ : 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' } }, key));
836
+ const guide = (_jsx("path", { ref: (p) => {
533
837
  paths.current[r.id] = p;
534
- }, 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));
838
+ }, 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
+ if (r.stub)
841
+ return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
842
+ return look(r.d, r.id, true);
535
843
  }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
536
844
  dots.current[j] = g;
537
845
  }, 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) => {
@@ -553,16 +861,26 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
553
861
  color: ON_ACCENT,
554
862
  boxShadow: '0 4px 14px rgba(0,0,0,.18)',
555
863
  transition: 'opacity .2s',
556
- }, children: cur?.hops[j]?.data }, j))), routed.map((r) => {
864
+ }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
557
865
  const e = edges[ids.indexOf(r.id)];
558
- if (e.label == null)
559
- return null;
866
+ const texts = r.stub && lanePlan?.stubs.get(r.id);
867
+ if (!texts && (e.label == null || tl))
868
+ return [];
560
869
  const on = litEdges.has(r.id);
561
870
  const tone = hopTone(r.id);
562
- return (_jsx("div", { "data-fig-label": r.id, style: {
871
+ // A cross-block edge has two pills, one per stub, in place of its label.
872
+ const pills = texts
873
+ ? r.stub.pills.map((p, j) => ({
874
+ key: `${r.id}:${j}`,
875
+ x: p.x + p.w / 2,
876
+ y: p.y + p.h / 2,
877
+ text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
878
+ }))
879
+ : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
880
+ return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
563
881
  position: 'absolute',
564
- left: r.mid.x,
565
- top: r.mid.y,
882
+ left: p.x,
883
+ top: p.y,
566
884
  transform: 'translate(-50%, -50%)',
567
885
  fontSize: 11,
568
886
  lineHeight: '16px',
@@ -576,7 +894,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
576
894
  opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
577
895
  fontFamily: MONO,
578
896
  transition: 'background .25s, color .25s, opacity .25s',
579
- }, children: e.label }, r.id));
897
+ }, children: p.text }, p.key)));
580
898
  })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
581
899
  display: 'block',
582
900
  width: '100%',
@@ -623,7 +941,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
623
941
  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" })] }));
624
942
  })(), 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}`));
625
943
  });
626
- })(), _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: {
944
+ })(), _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: {
627
945
  display: 'inline-flex',
628
946
  gap: 2,
629
947
  padding: 3,
@@ -662,7 +980,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
662
980
  transform: 'scaleX(0)',
663
981
  opacity: playing ? 1 : 0.35,
664
982
  } }))] }, i));
665
- }) }), _jsxs("button", { type: "button", "aria-label": `Speed ${rate}×, switch to ${rate === 1 ? 2 : 1}×`, title: "Playback speed", style: {
983
+ }) })), _jsxs("button", { type: "button", "aria-label": `Speed ${rate}×, switch to ${rate === 1 ? 2 : 1}×`, title: "Playback speed", style: {
666
984
  ...iconBtn,
667
985
  width: 'auto',
668
986
  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;