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/README.md +183 -140
- package/dist/browser.js +6 -2
- package/dist/check.js +48 -2
- package/dist/cli.js +2 -2
- package/dist/geometry.d.ts +27 -1
- package/dist/geometry.js +288 -24
- package/dist/guide.d.ts +1 -1
- package/dist/guide.js +11 -2
- package/dist/index.js +247 -103
- package/dist/init.js +5 -2
- package/dist/mcp.js +1 -1
- package/dist/model.d.ts +97 -0
- package/dist/model.js +251 -1
- package/dist/scene.d.ts +9 -0
- package/dist/svg.d.ts +3 -0
- package/dist/svg.js +190 -100
- package/dist/text.d.ts +2 -0
- package/dist/text.js +19 -0
- package/dist/verify.js +2 -2
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
.
|
|
210
|
-
|
|
211
|
-
|
|
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.
|
|
312
|
+
...routed.flatMap((r) => {
|
|
228
313
|
const e = edges[ids.indexOf(r.id)];
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
}),
|
|
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
|
|
607
|
+
const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
|
|
505
608
|
const n = Math.max(0, ...cols.values()) + 1;
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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 }),
|
|
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
|
-
|
|
714
|
-
|
|
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
|
-
|
|
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:
|
|
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.
|
|
886
|
+
}, children: cur?.hops[j]?.data }, j))), routed.flatMap((r) => {
|
|
753
887
|
const e = edges[ids.indexOf(r.id)];
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
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:
|
|
761
|
-
top:
|
|
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:
|
|
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) =>
|
|
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);
|