flowfig 0.4.0 → 0.6.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,7 +5,7 @@ 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, 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
+ 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]})`;
@@ -72,8 +72,18 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
72
72
  const bar = useRef(null); // the active tab's progress line
73
73
  const [beat, setBeat] = useState(0);
74
74
  const [holding, setHolding] = useState(false); // the step hold: a timeline's today line rests at today
75
+ const [returning, setReturning] = useState(false); // the end of the last hold: the playhead moves back to the loop start
76
+ const firstLabel = useRef(''); // the playhead date of the first mount, which shows at once; '' before the first render
75
77
  const paths = useRef({});
76
78
  const [routed, setRouted] = useState([]);
79
+ // Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
80
+ const [extraTall, setExtraTall] = useState(new Set());
81
+ // Lanes: the shared wrap plan (blocks, gaps, stubs), the same as the SVG at the default 830 px width.
82
+ const lanePlan = useMemo(() => lanes && !tl && isLanesLayout(layout)
83
+ ? planLanes({ layout, edges, steps, lanes },
84
+ // A rail sets a least width, as in the SVG.
85
+ { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
86
+ : null, [lanes, tl, layout, edges, steps, withRail]);
77
87
  const [active, setActive] = useState(steps.length ? 0 : null);
78
88
  const [playing, setPlaying] = useState(autoplay);
79
89
  // The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
@@ -169,13 +179,67 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
169
179
  const r = n.getBoundingClientRect();
170
180
  avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
171
181
  });
172
- setRouted(route(edges.map((e, i) => ({
182
+ // A timeline elbow keeps clear of the bars and milestones it does not connect, as in the SVG.
183
+ if (tl)
184
+ for (const n of nodes(layout))
185
+ if (rects[n.id])
186
+ avoid.push({ ...rects[n.id], box: true });
187
+ // Wrapped lanes: an edge between two blocks is two stubs with pills, clear of the boxes and the lane labels.
188
+ const stubs = lanePlan?.stubs ?? new Map();
189
+ if (stubs.size) {
190
+ for (const n of nodes(layout))
191
+ if (rects[n.id])
192
+ avoid.push(rects[n.id]);
193
+ el.querySelectorAll('[data-fig-gutter]').forEach((n) => {
194
+ const r = n.getBoundingClientRect();
195
+ avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
196
+ });
197
+ }
198
+ // A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
199
+ const bands = {};
200
+ el.querySelectorAll('[data-fig-copy]').forEach((n) => {
201
+ const r = n.getBoundingClientRect();
202
+ bands[n.dataset.figCopy] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
203
+ });
204
+ // A stub pill stays in the lanes, right of the gutter, as in the SVG.
205
+ const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
206
+ const pillArea = stubs.size && blockRects.length
207
+ ? (() => {
208
+ const [x0, y0] = [Math.min(...blockRects.map((r) => r.left)), Math.min(...blockRects.map((r) => r.top))];
209
+ const [x1, y1] = [Math.max(...blockRects.map((r) => r.right)), Math.max(...blockRects.map((r) => r.bottom))];
210
+ const x = (x0 - base.left) / k + lanePlan.gutter;
211
+ return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
212
+ })()
213
+ : undefined;
214
+ // The band of an edge end, in the block of that end, as in the SVG.
215
+ const laneBands = {};
216
+ el.querySelectorAll('[data-fig-band]').forEach((n) => {
217
+ const r = n.getBoundingClientRect();
218
+ laneBands[n.dataset.figBand] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
219
+ });
220
+ const bandOf = (eid, id, j) => {
221
+ const li = layout.children.findIndex((l) => isGroup(l) && (l.id === id || l.children.some((b) => b.id === id)));
222
+ return laneBands[`${li}@${lanePlan?.ends.get(eid)?.[j]}`];
223
+ };
224
+ const end = (eid, id, start) => lanePlan ? laneEnd(lanePlan, eid, id, start, (lane, b) => bands[`${lane}@${b}`], rects) : id;
225
+ const next = route(edges.map((e, i) => ({
173
226
  id: ids[i],
174
- from: e.from,
175
- to: e.to,
176
- around: e.around,
227
+ from: end(ids[i], e.from, true),
228
+ to: end(ids[i], e.to, false),
229
+ around: e.around ?? (lanePlan?.around.has(ids[i]) ? 'below' : undefined),
177
230
  ...(tl && { sides: ['r', 'l'], elbow: true }),
178
- })), rects, tips, avoid));
231
+ ...(stubs.has(ids[i]) && {
232
+ stub: stubs.get(ids[i]).map(labelPillW),
233
+ bands: [bandOf(ids[i], e.from, 0), bandOf(ids[i], e.to, 1)],
234
+ }),
235
+ ...((stubs.size || lanePlan) && e.label != null && { labelW: labelPillW(str(e.label)) }),
236
+ })), rects, tips, avoid, pillArea, lanePlan ? { bands: Object.values(laneBands), boxes: nodes(layout).flatMap((n) => (rects[n.id] ? [rects[n.id]] : [])) } : undefined);
237
+ setRouted(next);
238
+ if (lanePlan) {
239
+ const more = [...tightCopies(layout.children, lanePlan.ends, edges, next)].filter((k) => !lanePlan.tall.has(k) && !extraTall.has(k));
240
+ if (more.length)
241
+ setExtraTall(new Set([...extraTall, ...more]));
242
+ }
179
243
  };
180
244
  measure();
181
245
  const ro = new ResizeObserver(measure);
@@ -183,7 +247,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
183
247
  ro.observe(box);
184
248
  el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
185
249
  return () => ro.disconnect();
186
- }, [edges, ids, layout, tips, noMap, lanes, tl, axisW]);
250
+ }, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
187
251
  // The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
188
252
  const reported = useRef(new Set());
189
253
  useEffect(() => {
@@ -201,16 +265,30 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
201
265
  const boxIds = new Set(nodes(layout).map((n) => n.id));
202
266
  const boxes = [...(el ?? fig).querySelectorAll('[data-fig]')]
203
267
  .filter((n) => boxIds.has(n.dataset.fig))
204
- .map((n) => ({
205
- id: n.dataset.fig,
206
- rect: rel(n),
207
- texts: [...n.querySelectorAll('*')]
208
- .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
209
- .map((t) => {
210
- const fontSize = parseFloat(getComputedStyle(t).fontSize);
211
- return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
212
- }),
213
- }));
268
+ .map((n) => {
269
+ const rect = rel(n);
270
+ return {
271
+ id: n.dataset.fig,
272
+ rect,
273
+ texts: [...n.querySelectorAll('*')]
274
+ .filter((t) => !t.children.length && t.textContent?.trim() && t.clientWidth > 0 && !t.closest('[aria-hidden]'))
275
+ .map((t) => {
276
+ const fontSize = parseFloat(getComputedStyle(t).fontSize);
277
+ if (!n.dataset.diamond)
278
+ return { text: t.textContent, fontSize, room: t.clientWidth, need: t.scrollWidth };
279
+ // 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.
280
+ const [r, range] = [rel(t), document.createRange()];
281
+ range.selectNodeContents(t);
282
+ const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
283
+ return {
284
+ text: t.textContent,
285
+ fontSize,
286
+ room: diamondRoom(rect.w, rect.h, far),
287
+ need: range.getBoundingClientRect().width / k,
288
+ };
289
+ }),
290
+ };
291
+ });
214
292
  // Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
215
293
  // 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.
216
294
  const fonts = [...fig.querySelectorAll('*')]
@@ -220,13 +298,23 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
220
298
  !t.closest('[data-fig-tag],[aria-hidden]'))
221
299
  .map((t) => parseFloat(getComputedStyle(t).fontSize));
222
300
  const labels = Object.fromEntries([...fig.querySelectorAll('[data-fig-label]')].map((n) => [n.dataset.figLabel, rel(n)]));
301
+ const lanesIn = el ? [...el.querySelectorAll('[data-fig-band]')] : [];
223
302
  const scene = {
224
303
  width: Math.max(el?.offsetWidth ?? 0, rail?.width ?? 0),
225
304
  boxes,
305
+ ...(lanePlan?.stubs.size && {
306
+ lanes: lanesIn.map((n) => ({
307
+ id: str(layout.children[Number(n.dataset.figBand.split('@')[0])].label),
308
+ rect: rel(n),
309
+ })),
310
+ }),
226
311
  edges: [
227
- ...routed.map((r) => {
312
+ ...routed.flatMap((r) => {
228
313
  const e = edges[ids.indexOf(r.id)];
229
- return { id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) };
314
+ // A cross-block edge is two scene edges: each stub with its pill.
315
+ if (r.stub)
316
+ 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}`] }));
317
+ return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
230
318
  }),
231
319
  // A rail pill is a label for label-overlap; its curve is a point at the pill center, so it can cross no box.
232
320
  ...(rail?.rows ?? []).flatMap((row) => {
@@ -259,7 +347,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
259
347
  reported.current.add(key);
260
348
  console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
261
349
  }
262
- }, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
350
+ }, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
263
351
  // Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
264
352
  useEffect(() => {
265
353
  const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
@@ -297,6 +385,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
297
385
  if (i !== shownBeat)
298
386
  setBeat((shownBeat = i));
299
387
  setHolding(t >= ends.at(-1));
388
+ setReturning(active === steps.length - 1 && t >= total - 400 * BASE_RATE);
300
389
  const start = i ? ends[i - 1] : 0;
301
390
  const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
302
391
  const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
@@ -434,11 +523,52 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
434
523
  if (timelineFig && item === layout) {
435
524
  const lanesList = layout.children;
436
525
  // 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);
526
+ // In the last 400 ms of the loop it moves back to the loop start, so the next loop starts in place, as in the SVG.
527
+ const focused = still
528
+ ? undefined
529
+ : returning
530
+ ? loopStartItem(timelineFig.items, steps)
531
+ : holding
532
+ ? undefined
533
+ : playheadItem(timelineFig.items, beats, beat);
438
534
  const home = timelineFig.last ?? 0;
439
535
  const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
440
536
  const dateText = focused ? focused.date : timelineFig.lastDate;
441
537
  const rowsPx = timelineFig.rows.map((r) => r * TL_BAR_H + (r - 1) * TL_ROW_GAP + LANE_PAD * 2);
538
+ // The today line and the playhead line sit behind the bars (before them, at the bars' z-index); their labels sit on top.
539
+ // The playhead label waits for the line, except on the first mount, where the line starts in place.
540
+ if (firstLabel.current === '')
541
+ firstLabel.current = dateText;
542
+ else if (firstLabel.current !== dateText)
543
+ firstLabel.current = null;
544
+ 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: {
545
+ position: 'absolute',
546
+ right: 3,
547
+ top: -1,
548
+ fontSize: 11,
549
+ lineHeight: '11px',
550
+ fontWeight: 600,
551
+ color: v('accent'),
552
+ }, 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: {
553
+ position: 'absolute',
554
+ left: 0,
555
+ top: 12,
556
+ bottom: 0,
557
+ width: front ? 0 : 1.5,
558
+ background: front ? undefined : v('accent'),
559
+ transform: `translateX(${at}px)`,
560
+ transition: still ? 'none' : 'transform .4s linear',
561
+ }, children: front && (_jsx("span", { style: {
562
+ position: 'absolute',
563
+ left: 3,
564
+ top: dateLabelRaised(at, timelineFig.today, textWidth(dateText, 11), textWidth('today', 11)) ? -13 : -1,
565
+ fontSize: 11,
566
+ lineHeight: '11px',
567
+ fontWeight: 600,
568
+ whiteSpace: 'nowrap',
569
+ color: v('accent'),
570
+ animation: still || firstLabel.current === dateText ? undefined : 'flowfig-label .4s step-end',
571
+ }, children: dateText }, dateText)) })] }));
442
572
  return (_jsxs("div", { style: {
443
573
  display: 'grid',
444
574
  gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
@@ -446,7 +576,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
446
576
  gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
447
577
  rowGap: LANE_ROW_GAP,
448
578
  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) => {
579
+ }, 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) => {
450
580
  const lit = lane.id != null && litNodes.has(lane.id);
451
581
  return (_jsxs(Fragment, { children: [_jsx("div", { "data-fig": lane.id, "data-fig-lane": "", style: {
452
582
  gridColumn: '1 / -1',
@@ -471,75 +601,63 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
471
601
  }, children: lane.label }), _jsx("div", { ref: li === 0 ? area : undefined, style: { gridColumn: 2, gridRow: li + 2, position: 'relative', zIndex: 1 }, children: timelineFig.items
472
602
  .filter((it) => it.track === li)
473
603
  .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) })] }))] }));
604
+ }), marks(true)] }));
502
605
  }
503
606
  if (lanes && item === layout && isLanesLayout(layout)) {
504
- const cols = laneColumns({ layout, edges, steps, lanes });
607
+ const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
505
608
  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
- }) }));
609
+ const count = blocks.length;
610
+ // One grid per block of `per` time columns, as in the SVG. A wrapped block shows only the lanes with a box in it.
611
+ const grid = (bk) => {
612
+ const m = starts[bk + 1] - starts[bk];
613
+ const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
614
+ const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
615
+ return (_jsx("div", { "data-fig-block": bk, style: {
616
+ display: 'grid',
617
+ // A wrapped block stretches to the widest block, so all bands share one width.
618
+ // Every block has the gutter of the plan, so the labels and the columns line up as in the SVG.
619
+ gridTemplateColumns: `${gutter}px repeat(${m}, max-content)${count > 1 ? ' 1fr' : ''}`,
620
+ // An empty lane keeps the SVG's least band height: a 38 px label line plus the lane padding.
621
+ gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
622
+ columnGap: 0,
623
+ rowGap: LANE_ROW_GAP,
624
+ position: 'relative',
625
+ }, children: shown.map((lane, li) => {
626
+ const lit = lane.id != null && litNodes.has(lane.id);
627
+ 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: {
628
+ gridColumn: '1 / -1',
629
+ gridRow: li + 1,
630
+ background: v('surface'),
631
+ border: `1px solid ${lit ? v('accent') : v('border')}`,
632
+ boxShadow: lit ? glow : undefined,
633
+ transition: 'border-color .25s, box-shadow .25s',
634
+ borderRadius: 14,
635
+ zIndex: 0,
636
+ } }), _jsx("div", { "data-fig-gutter": "", style: {
637
+ gridColumn: 1,
638
+ gridRow: li + 1,
639
+ alignSelf: 'center',
640
+ zIndex: 1,
641
+ padding: '0 18px',
642
+ whiteSpace: 'nowrap',
643
+ fontSize: 12,
644
+ fontWeight: 600,
645
+ letterSpacing: '.04em',
646
+ textTransform: 'uppercase',
647
+ color: v('muted'),
648
+ }, children: lane.label }), lane.children.filter(inBlock).map((b) => (_jsx("div", { style: {
649
+ gridColumn: cols.get(b.id) - starts[bk] + 2,
650
+ gridRow: li + 1,
651
+ alignSelf: 'center',
652
+ zIndex: 1,
653
+ // The space after each column and before a block's first column comes from the plan, as in the SVG.
654
+ 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`,
655
+ }, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
656
+ }) }, bk));
657
+ };
658
+ if (count < 2)
659
+ return grid(blocks[0]);
660
+ return _jsx("div", { style: { display: 'flex', flexDirection: 'column', gap: LANE_BLOCK_GAP }, children: blocks.map(grid) });
543
661
  }
544
662
  if (isGroup(item)) {
545
663
  const lit = item.id != null && litNodes.has(item.id);
@@ -578,14 +696,16 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
578
696
  const diamond = item.shape === 'decision';
579
697
  const store = item.shape === 'store';
580
698
  const card = carded.has(item.id);
699
+ // The sub of a diamond wraps as in the SVG, one div per line, so a line stays inside the outline.
700
+ const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
581
701
  const bt = item.tone && TONES[item.tone];
582
702
  return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
583
703
  position: 'relative',
584
704
  isolation: 'isolate',
585
705
  minWidth: 100,
586
- maxWidth: card ? undefined : 190,
587
- width: item.width ?? (card ? CARD_WIDTH : undefined),
588
- minHeight: minHeight(item.id),
706
+ maxWidth: card || diamond ? undefined : 190,
707
+ width: diamond ? nodeWidth(item, card) + 70 : (item.width ?? (card ? CARD_WIDTH : undefined)),
708
+ minHeight: dl ? Math.max(dl.h, (minHeight(item.id) ?? 0) + 24) : minHeight(item.id),
589
709
  boxSizing: 'border-box',
590
710
  display: 'flex',
591
711
  flexDirection: 'column',
@@ -637,7 +757,9 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
637
757
  border: `1.5px solid ${bt ?? v('accent')}`,
638
758
  background: `radial-gradient(circle, ${bt ?? v('accent')} 0 4px, transparent 4.5px)`,
639
759
  }),
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: {
760
+ } })), _jsx("div", { children: item.label }), dl
761
+ ? dl.subs.map((l, i) => (_jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), lineHeight: '15px', whiteSpace: 'nowrap' }, children: l.text }, i)))
762
+ : item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
641
763
  marginTop: 8,
642
764
  padding: '6px 8px',
643
765
  minHeight: item.lines != null ? item.lines * CARD_LINE + CARD_PAD * 2 + 1 : undefined,
@@ -710,8 +832,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
710
832
  transformOrigin: 'top left',
711
833
  padding: 4,
712
834
  // room for edges that arc over or under the boxes
713
- paddingTop: edges.some((e) => e.around === 'above') ? 44 : 4,
714
- paddingBottom: edges.some((e) => e.around === 'below') ? 44 : 4,
835
+ // A stub does not arc.
836
+ paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
837
+ paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
838
+ ? 44
839
+ : 4,
715
840
  }, children: [renderItem(layout, 0), _jsxs("svg", { style: {
716
841
  position: 'absolute',
717
842
  inset: 0,
@@ -725,9 +850,18 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
725
850
  const on = litEdges.has(r.id);
726
851
  const tone = hopTone(r.id);
727
852
  const hidden = !on && edges[ids.indexOf(r.id)].quiet;
728
- return (_jsx("path", { ref: (p) => {
853
+ const look = (d, key, guides = false) => (_jsx("path", { ref: guides
854
+ ? (p) => {
855
+ paths.current[r.id] = p;
856
+ }
857
+ : 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));
858
+ const guide = (_jsx("path", { ref: (p) => {
729
859
  paths.current[r.id] = p;
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));
860
+ }, d: r.d, fill: "none", stroke: "none" }, r.id));
861
+ // A cross-block edge draws its two stubs; its full path only guides the packet, which jumps between the blocks.
862
+ if (r.stub)
863
+ return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
864
+ return look(r.d, r.id, true);
731
865
  }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
732
866
  dots.current[j] = g;
733
867
  }, 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) => {
@@ -749,16 +883,26 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
749
883
  color: ON_ACCENT,
750
884
  boxShadow: '0 4px 14px rgba(0,0,0,.18)',
751
885
  transition: 'opacity .2s',
752
- }, children: cur?.hops[j]?.data }, j))), routed.map((r) => {
886
+ }, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
753
887
  const e = edges[ids.indexOf(r.id)];
754
- if (e.label == null || tl)
755
- return null;
888
+ const texts = r.stub && lanePlan?.stubs.get(r.id);
889
+ if (!texts && (e.label == null || tl))
890
+ return [];
756
891
  const on = litEdges.has(r.id);
757
892
  const tone = hopTone(r.id);
758
- return (_jsx("div", { "data-fig-label": r.id, style: {
893
+ // A cross-block edge has two pills, one per stub, in place of its label.
894
+ const pills = texts
895
+ ? r.stub.pills.map((p, j) => ({
896
+ key: `${r.id}:${j}`,
897
+ x: p.x + p.w / 2,
898
+ y: p.y + p.h / 2,
899
+ text: (j === 0 && r.stub.short ? texts[2] : texts[j]),
900
+ }))
901
+ : [{ key: r.id, x: r.mid.x, y: r.mid.y, text: e.label }];
902
+ return pills.map((p) => (_jsx("div", { "data-fig-label": p.key, style: {
759
903
  position: 'absolute',
760
- left: r.mid.x,
761
- top: r.mid.y,
904
+ left: p.x,
905
+ top: p.y,
762
906
  transform: 'translate(-50%, -50%)',
763
907
  fontSize: 11,
764
908
  lineHeight: '16px',
@@ -772,7 +916,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
772
916
  opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
773
917
  fontFamily: MONO,
774
918
  transition: 'background .25s, color .25s, opacity .25s',
775
- }, children: e.label }, r.id));
919
+ }, children: p.text }, p.key)));
776
920
  })] }) }) }) })), rail && (_jsxs("svg", { viewBox: `0 0 ${rail.width} ${rail.height}`, style: {
777
921
  display: 'block',
778
922
  width: '100%',
package/dist/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
2
2
  import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
3
3
  import { homedir } from 'node:os';
4
- import { dirname, join, relative, resolve } from 'node:path';
4
+ import { dirname, join, relative, resolve, sep } from 'node:path';
5
5
  import { createInterface } from 'node:readline';
6
6
  import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
7
7
  import { runPicker } from './picker.js';
@@ -136,7 +136,10 @@ export function registerMcp(a, old) {
136
136
  /** `✓ ` before a finished write. */
137
137
  const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
138
138
  /** The path to show: relative to the repo folder, or `~` for the home folder. */
139
- const shown = (path, base, global) => (global ? `~/${relative(base, path)}` : relative(base, path));
139
+ const shown = (path, base, global) => {
140
+ const rel = relative(base, path).split(sep).join('/'); // the output uses / on every system
141
+ return global ? `~/${rel}` : rel;
142
+ };
140
143
  const HINT = 'run with -y or --agents <ids>';
141
144
  /** Run `flowfig init`. Returns the exit code. */
142
145
  export async function runInit(argv) {
package/dist/mcp.js CHANGED
@@ -100,7 +100,7 @@ function run(name, args) {
100
100
  if (name === 'check' || errors)
101
101
  return ok(lines.join('\n'), errors);
102
102
  const out = String(args.out);
103
- const svg = svgWithSpec(props);
103
+ const svg = svgWithSpec(props, opts);
104
104
  try {
105
105
  mkdirSync(dirname(out), { recursive: true });
106
106
  writeFileSync(out, svg);