flowfig 0.6.0 → 0.8.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 +28 -8
- package/dist/browser.d.ts +1 -3
- package/dist/browser.js +67 -59
- package/dist/check.d.ts +5 -3
- package/dist/check.js +24 -17
- package/dist/cli.js +43 -49
- package/dist/code.d.ts +26 -0
- package/dist/code.js +568 -0
- package/dist/diff.js +0 -4
- package/dist/draw.d.ts +1 -3
- package/dist/draw.js +3 -7
- package/dist/edges.d.ts +7 -0
- package/dist/edges.js +510 -0
- package/dist/geometry.d.ts +6 -8
- package/dist/geometry.js +75 -82
- package/dist/gif.d.ts +1 -2
- package/dist/gif.js +115 -42
- package/dist/guide.d.ts +1 -4
- package/dist/guide.js +10 -11
- package/dist/imports.d.ts +11 -0
- package/dist/imports.js +235 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.js +20 -78
- package/dist/init.d.ts +3 -13
- package/dist/init.js +13 -18
- package/dist/load.d.ts +1 -3
- package/dist/load.js +2 -5
- package/dist/mcp.d.ts +1 -2
- package/dist/mcp.js +15 -15
- package/dist/model.d.ts +57 -109
- package/dist/model.js +20 -89
- package/dist/open.d.ts +2 -5
- package/dist/open.js +6 -11
- package/dist/page.d.ts +0 -1
- package/dist/page.js +1 -3
- package/dist/picker.d.ts +2 -4
- package/dist/picker.js +11 -14
- package/dist/png.js +9 -10
- package/dist/rail.d.ts +1 -3
- package/dist/rail.js +2 -16
- package/dist/scene.d.ts +8 -8
- package/dist/source.d.ts +1 -2
- package/dist/source.js +5 -5
- package/dist/svg.d.ts +5 -8
- package/dist/svg.js +50 -112
- package/dist/text.d.ts +0 -2
- package/dist/text.js +1 -4
- package/dist/type-index.d.ts +55 -0
- package/dist/type-index.js +383 -0
- package/dist/types.d.ts +32 -0
- package/dist/types.js +733 -0
- package/dist/verify.d.ts +26 -4
- package/dist/verify.js +68 -26
- package/dist/version.d.ts +1 -2
- package/dist/version.js +2 -2
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -10,9 +10,8 @@ export { LIGHT, DARK } from './model.js';
|
|
|
10
10
|
const DEFAULTS = { ...LIGHT, font: 'inherit' };
|
|
11
11
|
const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
|
|
12
12
|
const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
|
|
13
|
-
const NONE = [];
|
|
14
|
-
const ACTIVE = 'flowfig-active';
|
|
15
|
-
// The active box reads --ff-hop (the tone of the hop that arrived), then --ff-box (the tone of the box), then the accent.
|
|
13
|
+
const NONE = [];
|
|
14
|
+
const ACTIVE = 'flowfig-active';
|
|
16
15
|
const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
|
|
17
16
|
const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
|
|
18
17
|
const cardBody = (c) => {
|
|
@@ -40,20 +39,17 @@ const cardBody = (c) => {
|
|
|
40
39
|
fontFamily: r.mono ? MONO : undefined,
|
|
41
40
|
fontSize: r.mono ? 10.5 : undefined,
|
|
42
41
|
}, children: [r.text, r.meta != null && _jsxs("span", { style: { color: v('muted') }, children: [" \u00B7 ", r.meta] })] }));
|
|
43
|
-
// A word-sized tag heads its row so the text keeps the full width; a number or no tag sits inline.
|
|
44
42
|
return (r.tag?.length ?? 0) > 2 ? (_jsxs("div", { children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', gap: 5, marginBottom: 1 }, children: [tag, mark] }), text] }, i)) : (_jsxs("div", { style: { display: 'flex', gap: 5, alignItems: 'baseline' }, children: [tag, text, mark] }, i));
|
|
45
43
|
}) }));
|
|
46
44
|
};
|
|
47
45
|
/**
|
|
48
|
-
* The interactive player for one figure
|
|
49
|
-
*
|
|
46
|
+
* The interactive player for one figure. Give it a `FlowProps` spec.
|
|
47
|
+
* Use `toSvg` from `flowfig/svg` for a static animated SVG.
|
|
50
48
|
*/
|
|
51
49
|
export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900, autoplay = true, check = false, rail: withRail = false, lanes, timeline, today, }) {
|
|
52
50
|
const tl = timeline && isLanesLayout(layout);
|
|
53
|
-
// A timeline with no steps of its own walks its dated items in date order, with no tab row.
|
|
54
51
|
const synthetic = tl && !stepsIn.length;
|
|
55
52
|
const steps = useMemo(() => (synthetic ? timelineBeats({ layout, edges, timeline, today }) : stepsIn), [synthetic, layout, edges, timeline, today, stepsIn]);
|
|
56
|
-
// The width of the timeline area in px: the server draws TL_AXIS_W, and the browser measures the real width after mount.
|
|
57
53
|
const [axisW, setAxisW] = useState(TL_AXIS_W);
|
|
58
54
|
const area = useRef(null);
|
|
59
55
|
const root = useRef(null);
|
|
@@ -69,24 +65,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
69
65
|
}, [full]);
|
|
70
66
|
const dots = useRef([]);
|
|
71
67
|
const chips = useRef([]);
|
|
72
|
-
const bar = useRef(null);
|
|
68
|
+
const bar = useRef(null);
|
|
73
69
|
const [beat, setBeat] = useState(0);
|
|
74
|
-
const [holding, setHolding] = useState(false);
|
|
75
|
-
const [returning, setReturning] = useState(false);
|
|
76
|
-
const firstLabel = useRef('');
|
|
70
|
+
const [holding, setHolding] = useState(false);
|
|
71
|
+
const [returning, setReturning] = useState(false);
|
|
72
|
+
const firstLabel = useRef('');
|
|
77
73
|
const paths = useRef({});
|
|
78
74
|
const [routed, setRouted] = useState([]);
|
|
79
|
-
// Lane copies that grow because a stub there had no place clear of every edge path (see `tightCopies`).
|
|
80
75
|
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
76
|
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 })
|
|
77
|
+
? planLanes({ layout, edges, steps, lanes }, { floor: withRail ? (layoutRail({ layout, edges, steps, rail: true }, 560)?.width ?? 0) : 0 })
|
|
86
78
|
: null, [lanes, tl, layout, edges, steps, withRail]);
|
|
87
79
|
const [active, setActive] = useState(steps.length ? 0 : null);
|
|
88
80
|
const [playing, setPlaying] = useState(autoplay);
|
|
89
|
-
// The step's clock lives outside React: pausing freezes it, resizing keeps it, only a new step resets it.
|
|
90
81
|
const playingRef = useRef(playing);
|
|
91
82
|
playingRef.current = playing;
|
|
92
83
|
const [rate, setRate] = useState(1);
|
|
@@ -95,7 +86,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
95
86
|
const clock = useRef({ beats: null, elapsed: 0 });
|
|
96
87
|
const [hover, setHover] = useState(null);
|
|
97
88
|
const ids = useMemo(() => edges.map(edgeId), [edges]);
|
|
98
|
-
// A milestone is a diamond too, so its edges meet the tips.
|
|
99
89
|
const tips = useMemo(() => new Set([
|
|
100
90
|
...decisions(layout),
|
|
101
91
|
...(tl
|
|
@@ -106,7 +96,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
106
96
|
]), [layout, tl, edges, timeline, today]);
|
|
107
97
|
const step = active == null ? null : steps[active];
|
|
108
98
|
const beats = useMemo(() => (step?.flow ?? []).map(toBeat), [step]);
|
|
109
|
-
// Every content each box's card will ever show. The card is sized to the largest, so text never gets cut and nothing jumps.
|
|
110
99
|
const carded = useMemo(() => {
|
|
111
100
|
const all = new Map();
|
|
112
101
|
for (const b of steps.flatMap((s) => s.flow.map(toBeat)))
|
|
@@ -115,30 +104,23 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
115
104
|
return all;
|
|
116
105
|
}, [steps]);
|
|
117
106
|
const cur = beats[beat];
|
|
118
|
-
// The narration: the latest `say` so far (a beat without one keeps the line before), else the step caption.
|
|
119
107
|
const saidAt = beats.slice(0, beat + 1).findLastIndex((b) => b.say != null);
|
|
120
108
|
const said = saidAt === -1 ? step?.caption : beats[saidAt].say;
|
|
121
|
-
// Content cards: everything the step has shown so far, latest wins.
|
|
122
109
|
const shown = useMemo(() => Object.assign({}, ...beats.slice(0, beat + 1).map((b) => b.show)), [beats, beat]);
|
|
123
|
-
// The beat that last changed each card, so only a card whose content just changed animates in.
|
|
124
110
|
const shownAt = useMemo(() => {
|
|
125
111
|
const at = {};
|
|
126
112
|
beats.slice(0, beat + 1).forEach((b, i) => Object.keys(b.show ?? {}).forEach((k) => (at[k] = i)));
|
|
127
113
|
return at;
|
|
128
114
|
}, [beats, beat]);
|
|
129
|
-
// The rail lays out in node terms, like the SVG, so both renderers draw the same numbers. Its width follows the measured map.
|
|
130
115
|
const figure = useRef(null);
|
|
131
116
|
const [mapW, setMapW] = useState(0);
|
|
132
117
|
const railOnly = withRail === 'only';
|
|
133
118
|
const rail = useMemo(() => (withRail ? layoutRail({ layout, edges, steps, rail: true }, railOnly ? 560 : mapW) : null), [withRail, railOnly, layout, edges, steps, mapW]);
|
|
134
|
-
// 'only' drops the map, but only when the rail has a hop to draw; a figure never renders empty.
|
|
135
119
|
const noMap = railOnly && rail != null;
|
|
136
|
-
const jump = useRef(null);
|
|
120
|
+
const jump = useRef(null);
|
|
137
121
|
const [hoverEdge, setHoverEdge] = useState(null);
|
|
138
|
-
// With reduced motion the player shows the last beat, so the rail marks every row as done.
|
|
139
122
|
const [still, setStill] = useState(false);
|
|
140
123
|
useEffect(() => setStill(window.matchMedia('(prefers-reduced-motion: reduce)').matches), []);
|
|
141
|
-
// Boxes with many edges on one side get taller so the edges and their labels have room.
|
|
142
124
|
const minHeight = useMemo(() => {
|
|
143
125
|
const out = {}, inn = {};
|
|
144
126
|
for (const e of edges) {
|
|
@@ -150,20 +132,19 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
150
132
|
return n > 2 ? n * 30 : undefined;
|
|
151
133
|
};
|
|
152
134
|
}, [edges]);
|
|
153
|
-
// Measure every box and redraw the edges whenever anything changes size.
|
|
154
135
|
useEffect(() => {
|
|
155
136
|
const el = root.current, box = outer.current;
|
|
156
137
|
if (!el || !box)
|
|
157
|
-
return;
|
|
138
|
+
return;
|
|
158
139
|
const measure = () => {
|
|
159
|
-
// Too wide for its container? Shrink it so the whole figure stays in view. Below half size, scroll instead.
|
|
160
140
|
const scale = Math.max(0.5, Math.min(1, box.clientWidth / el.offsetWidth));
|
|
161
141
|
setFit({ scale, height: el.offsetHeight * scale });
|
|
162
142
|
setMapW(el.offsetWidth);
|
|
163
143
|
if (area.current)
|
|
164
144
|
setAxisW(area.current.offsetWidth);
|
|
165
145
|
const base = el.getBoundingClientRect();
|
|
166
|
-
|
|
146
|
+
// Rects are measured unscaled: k is the scale on screen.
|
|
147
|
+
const k = base.width / el.offsetWidth;
|
|
167
148
|
const rects = {};
|
|
168
149
|
el.querySelectorAll('[data-fig]').forEach((n) => {
|
|
169
150
|
const r = n.getBoundingClientRect();
|
|
@@ -179,12 +160,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
179
160
|
const r = n.getBoundingClientRect();
|
|
180
161
|
avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
|
|
181
162
|
});
|
|
182
|
-
// A timeline elbow keeps clear of the bars and milestones it does not connect, as in the SVG.
|
|
183
163
|
if (tl)
|
|
184
164
|
for (const n of nodes(layout))
|
|
185
165
|
if (rects[n.id])
|
|
186
166
|
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
167
|
const stubs = lanePlan?.stubs ?? new Map();
|
|
189
168
|
if (stubs.size) {
|
|
190
169
|
for (const n of nodes(layout))
|
|
@@ -195,13 +174,11 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
195
174
|
avoid.push({ x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k });
|
|
196
175
|
});
|
|
197
176
|
}
|
|
198
|
-
// A wrapped lane end routes to the band of its block (`data-fig-copy`), as in the SVG.
|
|
199
177
|
const bands = {};
|
|
200
178
|
el.querySelectorAll('[data-fig-copy]').forEach((n) => {
|
|
201
179
|
const r = n.getBoundingClientRect();
|
|
202
180
|
bands[n.dataset.figCopy] = { x: (r.left - base.left) / k, y: (r.top - base.top) / k, w: r.width / k, h: r.height / k };
|
|
203
181
|
});
|
|
204
|
-
// A stub pill stays in the lanes, right of the gutter, as in the SVG.
|
|
205
182
|
const blockRects = [...el.querySelectorAll('[data-fig-block]')].map((n) => n.getBoundingClientRect());
|
|
206
183
|
const pillArea = stubs.size && blockRects.length
|
|
207
184
|
? (() => {
|
|
@@ -211,7 +188,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
211
188
|
return { x, y: (y0 - base.top) / k, w: (x1 - base.left) / k - x, h: (y1 - y0) / k };
|
|
212
189
|
})()
|
|
213
190
|
: undefined;
|
|
214
|
-
// The band of an edge end, in the block of that end, as in the SVG.
|
|
215
191
|
const laneBands = {};
|
|
216
192
|
el.querySelectorAll('[data-fig-band]').forEach((n) => {
|
|
217
193
|
const r = n.getBoundingClientRect();
|
|
@@ -248,12 +224,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
248
224
|
el.querySelectorAll('[data-fig]').forEach((n) => ro.observe(n));
|
|
249
225
|
return () => ro.disconnect();
|
|
250
226
|
}, [edges, ids, layout, tips, noMap, lanes, tl, axisW, lanePlan, extraTall]);
|
|
251
|
-
// The same rules as `flowfig check`, on what the browser actually drew: real fonts, real wrapping. Each fault prints once.
|
|
252
227
|
const reported = useRef(new Set());
|
|
253
228
|
useEffect(() => {
|
|
254
229
|
const el = root.current, box = outer.current;
|
|
255
230
|
const fig = figure.current;
|
|
256
|
-
// With rail: 'only' there is no map: the figure is the frame and the scene holds only the rail.
|
|
257
231
|
if (!check || !fig || (!noMap && (!el || !box)))
|
|
258
232
|
return;
|
|
259
233
|
const base = (el ?? fig).getBoundingClientRect();
|
|
@@ -276,7 +250,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
276
250
|
const fontSize = parseFloat(getComputedStyle(t).fontSize);
|
|
277
251
|
if (!n.dataset.diamond)
|
|
278
252
|
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
253
|
const [r, range] = [rel(t), document.createRange()];
|
|
281
254
|
range.selectNodeContents(t);
|
|
282
255
|
const far = Math.max(Math.abs(r.y - rect.y - rect.h / 2), Math.abs(r.y + r.h - rect.y - rect.h / 2));
|
|
@@ -289,11 +262,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
289
262
|
}),
|
|
290
263
|
};
|
|
291
264
|
});
|
|
292
|
-
// Every visible leaf text counts, as in the SVG scene; the 9 px tag pills do not.
|
|
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.
|
|
294
265
|
const fonts = [...fig.querySelectorAll('*')]
|
|
295
266
|
.filter((t) => !t.children.length &&
|
|
296
267
|
t.textContent?.trim() &&
|
|
268
|
+
// An SVG <text> has no CSS box, so skip clientWidth.
|
|
297
269
|
(t instanceof SVGElement || t.clientWidth > 0) &&
|
|
298
270
|
!t.closest('[data-fig-tag],[aria-hidden]'))
|
|
299
271
|
.map((t) => parseFloat(getComputedStyle(t).fontSize));
|
|
@@ -311,12 +283,10 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
311
283
|
edges: [
|
|
312
284
|
...routed.flatMap((r) => {
|
|
313
285
|
const e = edges[ids.indexOf(r.id)];
|
|
314
|
-
// A cross-block edge is two scene edges: each stub with its pill.
|
|
315
286
|
if (r.stub)
|
|
316
287
|
return r.stub.pts.map((pts, j) => ({ id: r.id, from: e.from, to: e.to, curve: r.curve, pts, label: labels[`${r.id}:${j}`] }));
|
|
317
288
|
return [{ id: r.id, from: e.from, to: e.to, curve: r.curve, label: labels[r.id], ...(tl && { behind: true }) }];
|
|
318
289
|
}),
|
|
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.
|
|
320
290
|
...(rail?.rows ?? []).flatMap((row) => {
|
|
321
291
|
const r = row.kind === 'message' ? labels[`rail:${row.n}`] : undefined;
|
|
322
292
|
if (row.kind !== 'message' || !r)
|
|
@@ -345,14 +315,13 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
345
315
|
if (reported.current.has(key))
|
|
346
316
|
continue;
|
|
347
317
|
reported.current.add(key);
|
|
318
|
+
// oxlint-disable-next-line no-console
|
|
348
319
|
console.warn(`flowfig check: ${f.severity} ${f.rule}: ${f.message}`);
|
|
349
320
|
}
|
|
350
321
|
}, [check, routed, layout, edges, steps, ids, theme, rail, noMap, lanePlan]);
|
|
351
|
-
// Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
|
|
352
322
|
useEffect(() => {
|
|
353
323
|
const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
|
|
354
324
|
if (clock.current.beats !== beats) {
|
|
355
|
-
// A rail click can open another step at a later beat; start the clock at that beat, not at 0.
|
|
356
325
|
const j = jump.current ?? 0;
|
|
357
326
|
jump.current = null;
|
|
358
327
|
clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
|
|
@@ -366,7 +335,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
366
335
|
return void setBeat(beats.length - 1);
|
|
367
336
|
}
|
|
368
337
|
const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
|
|
369
|
-
const total = ends.at(-1) + STEP_HOLD_MS;
|
|
338
|
+
const total = ends.at(-1) + STEP_HOLD_MS;
|
|
370
339
|
let raf = 0, last = performance.now(), shownBeat = -1;
|
|
371
340
|
const tick = (now) => {
|
|
372
341
|
if (playingRef.current)
|
|
@@ -387,7 +356,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
387
356
|
setHolding(t >= ends.at(-1));
|
|
388
357
|
setReturning(active === steps.length - 1 && t >= total - 400 * BASE_RATE);
|
|
389
358
|
const start = i ? ends[i - 1] : 0;
|
|
390
|
-
const f = Math.min(1, (t - start) / speed);
|
|
359
|
+
const f = Math.min(1, (t - start) / speed);
|
|
391
360
|
const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
|
|
392
361
|
const hops = t < ends.at(-1) ? beats[i].hops : [];
|
|
393
362
|
gs.forEach((g, j) => {
|
|
@@ -413,14 +382,12 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
413
382
|
c.style.opacity = hops[j].data == null ? '0' : '1';
|
|
414
383
|
}
|
|
415
384
|
});
|
|
416
|
-
// A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
|
|
417
385
|
const arrived = new Map();
|
|
418
386
|
if (f >= 1)
|
|
419
387
|
for (const h of beats[i].hops) {
|
|
420
388
|
const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
|
|
421
389
|
arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
|
|
422
390
|
}
|
|
423
|
-
// A focused box turns active at the beat start; in a timeline, when the today line arrives.
|
|
424
391
|
if (t - start >= (tl ? 400 * BASE_RATE : 0))
|
|
425
392
|
for (const id of beats[i].focus ?? [])
|
|
426
393
|
if (!arrived.has(id))
|
|
@@ -437,8 +404,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
437
404
|
if (on === n.classList.contains(ACTIVE))
|
|
438
405
|
return;
|
|
439
406
|
if (!on)
|
|
440
|
-
return void n.classList.remove(ACTIVE);
|
|
441
|
-
// The box turns active at once: no transition while the class arrives.
|
|
407
|
+
return void n.classList.remove(ACTIVE);
|
|
442
408
|
const keep = n.style.transition;
|
|
443
409
|
n.style.transition = 'none';
|
|
444
410
|
n.classList.add(ACTIVE);
|
|
@@ -455,8 +421,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
455
421
|
cs.forEach((c) => c && (c.style.opacity = '0'));
|
|
456
422
|
};
|
|
457
423
|
}, [beats, routed, noMap, speed, steps.length]);
|
|
458
|
-
// What to light up: the step's edges and their ends, or whatever touches the hovered box.
|
|
459
|
-
// A step lights the trail so far: every edge its beats have crossed, and every box it has filled.
|
|
460
424
|
const trail = beats.slice(0, beat + 1).flatMap((b) => b.hops.map((h) => h.edge));
|
|
461
425
|
const hoverEdgeIdx = hoverEdge == null ? -1 : ids.indexOf(hoverEdge);
|
|
462
426
|
const litEdges = new Set(hoverEdge ? [hoverEdge] : hover ? ids.filter((id, i) => edges[i].from === hover || edges[i].to === hover) : trail);
|
|
@@ -469,7 +433,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
469
433
|
if (litEdges.has(ids[i]))
|
|
470
434
|
litNodes.add(e.from).add(e.to);
|
|
471
435
|
});
|
|
472
|
-
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
473
436
|
const hopTone = (edge) => {
|
|
474
437
|
const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
|
|
475
438
|
return t && TONES[t];
|
|
@@ -488,7 +451,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
488
451
|
const focus = hover != null || hoverEdge != null || step != null;
|
|
489
452
|
const vars = Object.fromEntries(Object.entries(theme ?? {}).map(([k, val]) => [`--fig-${k}`, val]));
|
|
490
453
|
const timelineFig = useMemo(() => (tl ? timelineLayout({ layout, edges, timeline, today }, axisW) : null), [tl, layout, edges, timeline, today, axisW]);
|
|
491
|
-
// A bar or a milestone of a timeline: placed by the shared layout, with the look of a box.
|
|
492
454
|
const renderBar = (n, it) => {
|
|
493
455
|
const lit = litNodes.has(n.id);
|
|
494
456
|
const bt = n.tone && TONES[n.tone];
|
|
@@ -522,8 +484,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
522
484
|
const renderItem = (item, depth) => {
|
|
523
485
|
if (timelineFig && item === layout) {
|
|
524
486
|
const lanesList = layout.children;
|
|
525
|
-
// The playhead follows the latest dated focus in the step, and rests at the last date before one and in the hold.
|
|
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
487
|
const focused = still
|
|
528
488
|
? undefined
|
|
529
489
|
: returning
|
|
@@ -535,8 +495,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
535
495
|
const at = focused ? focused.x + (focused.milestone ? focused.w / 2 : 0) : home;
|
|
536
496
|
const dateText = focused ? focused.date : timelineFig.lastDate;
|
|
537
497
|
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
498
|
if (firstLabel.current === '')
|
|
541
499
|
firstLabel.current = dateText;
|
|
542
500
|
else if (firstLabel.current !== dateText)
|
|
@@ -572,7 +530,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
572
530
|
return (_jsxs("div", { style: {
|
|
573
531
|
display: 'grid',
|
|
574
532
|
gridTemplateColumns: `max-content minmax(${TL_AXIS_W}px, 1fr) 18px`,
|
|
575
|
-
// The axis strip sits over the gap above the first band, as in the SVG.
|
|
576
533
|
gridTemplateRows: `${TL_AXIS_H - LANE_ROW_GAP}px ${rowsPx.map((h) => h + 'px').join(' ')}`,
|
|
577
534
|
rowGap: LANE_ROW_GAP,
|
|
578
535
|
position: 'relative',
|
|
@@ -605,19 +562,14 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
605
562
|
}
|
|
606
563
|
if (lanes && item === layout && isLanesLayout(layout)) {
|
|
607
564
|
const { cols, starts, gaps, lead, blocks, gutter, tall } = lanePlan;
|
|
608
|
-
const n = Math.max(0, ...cols.values()) + 1;
|
|
609
565
|
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
566
|
const grid = (bk) => {
|
|
612
567
|
const m = starts[bk + 1] - starts[bk];
|
|
613
568
|
const inBlock = (b) => laneBlock(starts, cols.get(b.id)) === bk;
|
|
614
569
|
const shown = layout.children.filter((lane) => count < 2 || lane.children.some(inBlock));
|
|
615
570
|
return (_jsx("div", { "data-fig-block": bk, style: {
|
|
616
571
|
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
572
|
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
573
|
gridAutoRows: `minmax(${38 + LANE_PAD * 2}px, auto)`,
|
|
622
574
|
columnGap: 0,
|
|
623
575
|
rowGap: LANE_ROW_GAP,
|
|
@@ -650,7 +602,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
650
602
|
gridRow: li + 1,
|
|
651
603
|
alignSelf: 'center',
|
|
652
604
|
zIndex: 1,
|
|
653
|
-
// The space after each column and before a block's first column comes from the plan, as in the SVG.
|
|
654
605
|
padding: `${LANE_PAD}px ${gaps[cols.get(b.id)]}px ${LANE_PAD + (tall.has(`${layout.children.indexOf(lane)}@${bk}`) || extraTall.has(`${layout.children.indexOf(lane)}@${bk}`) ? STUB_ROOM : 0)}px ${cols.get(b.id) === starts[bk] ? lead[bk] : 0}px`,
|
|
655
606
|
}, children: renderItem(b, 1) }, b.id)))] }, lane.id ?? String(lane.label)));
|
|
656
607
|
}) }, bk));
|
|
@@ -696,7 +647,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
696
647
|
const diamond = item.shape === 'decision';
|
|
697
648
|
const store = item.shape === 'store';
|
|
698
649
|
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
650
|
const dl = diamond && !card ? diamondLines(item, nodeWidth(item, false) + 70) : null;
|
|
701
651
|
const bt = item.tone && TONES[item.tone];
|
|
702
652
|
return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
|
|
@@ -812,7 +762,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
812
762
|
width: 28,
|
|
813
763
|
height: 28,
|
|
814
764
|
}, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-label{from{opacity:0}to{opacity:0}}@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
|
|
815
|
-
// The
|
|
765
|
+
// The 2 px active border takes 1 px of padding, so the box size stays.
|
|
816
766
|
`.${ACTIVE}:not([data-diamond]){--ff-b:1px;border-width:2px!important;background:color-mix(in srgb, ${hue} 10%, ${v('surface')})!important;box-shadow:${glow}!important;border-color:${hue}!important}` +
|
|
817
767
|
`.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
|
|
818
768
|
margin: full ? '-48px -24px 0' : '-20px -16px 0',
|
|
@@ -831,8 +781,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
831
781
|
transform: fit.scale < 1 ? `scale(${fit.scale})` : undefined,
|
|
832
782
|
transformOrigin: 'top left',
|
|
833
783
|
padding: 4,
|
|
834
|
-
// room for edges that arc over or under the boxes
|
|
835
|
-
// A stub does not arc.
|
|
836
784
|
paddingTop: edges.some((e, i) => e.around === 'above' && !lanePlan?.stubs.has(ids[i])) ? 44 : 4,
|
|
837
785
|
paddingBottom: edges.some((e, i) => (e.around === 'below' || (!e.around && lanePlan?.around.has(ids[i]))) && !lanePlan?.stubs.has(ids[i]))
|
|
838
786
|
? 44
|
|
@@ -844,7 +792,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
844
792
|
height: '100%',
|
|
845
793
|
overflow: 'visible',
|
|
846
794
|
pointerEvents: 'none',
|
|
847
|
-
// A timeline dependency runs under the bars (z-index 1) and over the bands (0).
|
|
848
795
|
zIndex: tl ? 0 : undefined,
|
|
849
796
|
}, children: [_jsx("defs", { children: ['off', 'on'].map((k) => (_jsx("marker", { id: `fig-arrow-${k}`, viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: "context-stroke" }) }, k))) }), routed.map((r) => {
|
|
850
797
|
const on = litEdges.has(r.id);
|
|
@@ -858,7 +805,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
858
805
|
const guide = (_jsx("path", { ref: (p) => {
|
|
859
806
|
paths.current[r.id] = p;
|
|
860
807
|
}, 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
808
|
if (r.stub)
|
|
863
809
|
return [...r.stub.parts.map((d, j) => look(d, `${r.id}:${j}`)), guide];
|
|
864
810
|
return look(r.d, r.id, true);
|
|
@@ -890,7 +836,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
890
836
|
return [];
|
|
891
837
|
const on = litEdges.has(r.id);
|
|
892
838
|
const tone = hopTone(r.id);
|
|
893
|
-
// A cross-block edge has two pills, one per stub, in place of its label.
|
|
894
839
|
const pills = texts
|
|
895
840
|
? r.stub.pills.map((p, j) => ({
|
|
896
841
|
key: `${r.id}:${j}`,
|
|
@@ -927,7 +872,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
927
872
|
fontFamily: v('font'),
|
|
928
873
|
}, children: [rail.bands.map((b) => (_jsxs("g", { children: [_jsx("rect", { x: b.rect.x, y: b.rect.y, width: b.rect.w, height: b.rect.h, rx: 10, fill: v('surface'), stroke: v('border') }), _jsx("text", { x: b.rect.x + 10, y: b.rect.y + 14, fill: v('muted'), fontSize: 11, fontWeight: 600, letterSpacing: ".04em", children: b.label.toUpperCase() })] }, b.id))), rail.columns.map((c) => (_jsx("text", { x: c.x, y: rail.head - RAIL.cols / 2 + 4, fill: v('fg'), fontSize: 12, fontWeight: 500, textAnchor: "middle", children: c.label }, c.id))), (() => {
|
|
929
874
|
const st = railState(rail, active ?? 0);
|
|
930
|
-
// The lifelines run through the message rows only, so a phase header reads as one clear divider.
|
|
931
875
|
const lines = rail.rows.flatMap((row, i) => row.kind === 'message' && st[i].shown ? rail.columns.map((c) => `M ${c.x} ${st[i].y} V ${st[i].y + RAIL.row}`) : []);
|
|
932
876
|
return _jsx("path", { d: lines.join(' '), stroke: v('border'), strokeDasharray: "2 3" });
|
|
933
877
|
})(), (() => {
|
|
@@ -958,7 +902,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
958
902
|
return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
|
|
959
903
|
(() => {
|
|
960
904
|
const tagW = ASYNC_TAG_W;
|
|
961
|
-
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
962
905
|
const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
963
906
|
return (_jsxs(_Fragment, { children: [_jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: v('bg') }), _jsx("rect", { x: tx, y: ly - 6, width: tagW, height: 12, rx: 4, fill: TONES.gray, fillOpacity: 0.15 }), _jsx("text", { x: tx + tagW / 2, y: ly + 3, fill: TONES.gray, fontSize: 9, fontWeight: 600, letterSpacing: ".03em", textAnchor: "middle", "data-fig-tag": true, children: "ASYNC" })] }));
|
|
964
907
|
})(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'), stroke: on ? (tone ?? v('accent')) : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
|
|
@@ -973,7 +916,7 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
973
916
|
}, children: steps.map((s, i) => {
|
|
974
917
|
const on = active === i;
|
|
975
918
|
return (_jsxs("button", { type: "button", role: "tab", "aria-selected": on, onClick: () => {
|
|
976
|
-
clock.current.elapsed = 0;
|
|
919
|
+
clock.current.elapsed = 0;
|
|
977
920
|
setActive(i);
|
|
978
921
|
setPlaying(true);
|
|
979
922
|
setBeat(0);
|
|
@@ -1022,7 +965,6 @@ export function Flow({ layout, edges, steps: stepsIn = NONE, theme, speed = 900,
|
|
|
1022
965
|
animation: 'flowfig-in .35s ease-out',
|
|
1023
966
|
}, children: said }, `${active}-${saidAt}`))] }))] }));
|
|
1024
967
|
}
|
|
1025
|
-
/** A 16px stroke icon drawn from one SVG path. */
|
|
1026
968
|
function Icon({ d, fill = false }) {
|
|
1027
969
|
return (_jsx("svg", { width: 14, height: 14, viewBox: "0 0 16 16", fill: fill ? 'currentColor' : 'none', stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round", strokeLinejoin: "round", "aria-hidden": true, children: _jsx("path", { d: d }) }));
|
|
1028
970
|
}
|
package/dist/init.d.ts
CHANGED
|
@@ -1,25 +1,18 @@
|
|
|
1
1
|
type Agent = {
|
|
2
2
|
id: string;
|
|
3
3
|
name: string;
|
|
4
|
-
/** Paths whose presence means the repo uses the agent. A trailing `/` means a directory. */
|
|
5
4
|
marks: string[];
|
|
6
|
-
/** The file init writes, relative to the repo. */
|
|
7
5
|
file: string;
|
|
8
|
-
/** `section` edits a file the user owns; `whole` owns the file. */
|
|
9
6
|
kind: 'section' | 'whole';
|
|
10
|
-
/** Text before the marked block in a whole file. */
|
|
11
7
|
head?: string;
|
|
12
8
|
note?: string;
|
|
13
|
-
/** The MCP config file the agent reads, and the top-level key that holds the servers. `type` adds `"type": "stdio"`. */
|
|
14
9
|
mcp?: {
|
|
15
10
|
file: string;
|
|
16
11
|
key: 'mcpServers' | 'servers';
|
|
17
12
|
type?: true;
|
|
18
13
|
global?: string;
|
|
19
14
|
};
|
|
20
|
-
/** Shown in place of the MCP file when init cannot write the config. */
|
|
21
15
|
mcpNote?: string;
|
|
22
|
-
/** A second file, a marked section in a file the user owns. Written only in a repo run, never with `--global`. */
|
|
23
16
|
also?: {
|
|
24
17
|
file: string;
|
|
25
18
|
text: string;
|
|
@@ -30,16 +23,13 @@ export declare const MCP_ENTRY: {
|
|
|
30
23
|
args: string[];
|
|
31
24
|
};
|
|
32
25
|
export declare const AGENTS: Agent[];
|
|
33
|
-
/** The ids of the agents that the repo at `dir` already uses. */
|
|
34
26
|
export declare const detect: (dir: string) => string[];
|
|
35
|
-
/** Toggle the agents named by numbers ("1 3" or "1,3").
|
|
27
|
+
/** Toggle the agents named by numbers ("1 3" or "1,3"). Ignore bad input. */
|
|
36
28
|
export declare function toggle(input: string, selected: Set<string>, count: number): Set<string>;
|
|
37
|
-
/** The picker screen. */
|
|
38
29
|
export declare function renderList(selected: Set<string>, detected: string[], dir: string): string;
|
|
39
|
-
/** The new file text, or `undefined` if the
|
|
30
|
+
/** The new file text, or `undefined` if the user owns the file. */
|
|
40
31
|
export declare function place(a: Agent, old: string | undefined): string | undefined;
|
|
41
|
-
/** The MCP file with the flowfig entry
|
|
32
|
+
/** The MCP file with the flowfig entry, or `undefined` if the file cannot hold it. */
|
|
42
33
|
export declare function registerMcp(a: Agent, old: string | undefined): string | undefined;
|
|
43
|
-
/** Run `flowfig init`. Returns the exit code. */
|
|
44
34
|
export declare function runInit(argv: string[]): Promise<number>;
|
|
45
35
|
export {};
|
package/dist/init.js
CHANGED
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
// `flowfig init`: write flowfig instructions for the coding agents of a repo. Pure functions where possible, so the tests need no TTY.
|
|
2
1
|
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
2
|
import { homedir } from 'node:os';
|
|
4
3
|
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
@@ -65,20 +64,20 @@ export const AGENTS = [
|
|
|
65
64
|
mcp: { file: '.kiro/settings/mcp.json', key: 'mcpServers', global: '.kiro/settings/mcp.json' },
|
|
66
65
|
},
|
|
67
66
|
];
|
|
68
|
-
/** The ids of the agents that the repo at `dir` already uses. */
|
|
69
67
|
export const detect = (dir) => AGENTS.filter((a) => a.marks.some((m) => existsSync(join(dir, m)))).map((a) => a.id);
|
|
70
|
-
/** Toggle the agents named by numbers ("1 3" or "1,3").
|
|
68
|
+
/** Toggle the agents named by numbers ("1 3" or "1,3"). Ignore bad input. */
|
|
71
69
|
export function toggle(input, selected, count) {
|
|
72
70
|
const next = new Set(selected);
|
|
73
71
|
for (const t of input.split(/[\s,]+/)) {
|
|
74
72
|
const n = /^\d+$/.test(t) ? Number(t) : 0;
|
|
75
73
|
const id = n >= 1 && n <= count ? AGENTS[n - 1].id : undefined;
|
|
76
|
-
if (id)
|
|
77
|
-
next.
|
|
74
|
+
if (id && next.has(id))
|
|
75
|
+
next.delete(id);
|
|
76
|
+
else if (id)
|
|
77
|
+
next.add(id);
|
|
78
78
|
}
|
|
79
79
|
return next;
|
|
80
80
|
}
|
|
81
|
-
/** The picker screen. */
|
|
82
81
|
export function renderList(selected, detected, dir) {
|
|
83
82
|
const rows = AGENTS.map((a, i) => {
|
|
84
83
|
const found = detected.includes(a.id) ? `(${a.marks.find((m) => existsSync(join(dir, m)))} found)` : '';
|
|
@@ -93,25 +92,25 @@ export function renderList(selected, detected, dir) {
|
|
|
93
92
|
].join('\n');
|
|
94
93
|
}
|
|
95
94
|
const block = (text = AGENT_TEXT) => `${START}\n${text.trimEnd()}\n${END}\n`;
|
|
96
|
-
/** The new file text, or `undefined` if the
|
|
95
|
+
/** The new file text, or `undefined` if the user owns the file. */
|
|
97
96
|
export function place(a, old) {
|
|
98
97
|
const mine = block();
|
|
99
98
|
if (a.kind === 'whole')
|
|
100
99
|
return old === undefined || old.includes(START) ? `${a.head ?? ''}${mine}` : undefined;
|
|
101
100
|
return section(old, mine);
|
|
102
101
|
}
|
|
103
|
-
/** `old` with the marked block `mine` added or replaced. `undefined` if the block is open. */
|
|
104
102
|
function section(old, mine) {
|
|
105
103
|
if (old === undefined)
|
|
106
104
|
return mine;
|
|
107
105
|
const s = old.indexOf(START), e = old.indexOf(END, s);
|
|
106
|
+
// An open block: the next run would eat the text after START.
|
|
108
107
|
if (s !== -1 && e === -1)
|
|
109
|
-
return undefined;
|
|
108
|
+
return undefined;
|
|
110
109
|
if (s !== -1)
|
|
111
110
|
return old.slice(0, s) + mine.trimEnd() + old.slice(e + END.length);
|
|
112
111
|
return old + (old.endsWith('\n\n') ? '' : old.endsWith('\n') ? '\n' : '\n\n') + mine;
|
|
113
112
|
}
|
|
114
|
-
/** The MCP file with the flowfig entry
|
|
113
|
+
/** The MCP file with the flowfig entry, or `undefined` if the file cannot hold it. */
|
|
115
114
|
export function registerMcp(a, old) {
|
|
116
115
|
if (!a.mcp)
|
|
117
116
|
return undefined;
|
|
@@ -127,21 +126,19 @@ export function registerMcp(a, old) {
|
|
|
127
126
|
const servers = json[a.mcp.key] ?? {};
|
|
128
127
|
if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
|
|
129
128
|
return undefined;
|
|
129
|
+
// Keep the entry: the user may have pinned a version.
|
|
130
130
|
if ('flowfig' in servers)
|
|
131
|
-
return old;
|
|
131
|
+
return old;
|
|
132
132
|
const entry = a.mcp.type ? { ...MCP_ENTRY, type: 'stdio' } : MCP_ENTRY;
|
|
133
133
|
json[a.mcp.key] = { ...servers, flowfig: entry };
|
|
134
134
|
return JSON.stringify(json, null, 2) + '\n';
|
|
135
135
|
}
|
|
136
|
-
/** `✓ ` before a finished write. */
|
|
137
136
|
const mark = (status) => (/^(created|updated|unchanged)$/.test(status) ? '✓ ' : '');
|
|
138
|
-
/** The path to show: relative to the repo folder, or `~` for the home folder. */
|
|
139
137
|
const shown = (path, base, global) => {
|
|
140
|
-
const rel = relative(base, path).split(sep).join('/');
|
|
138
|
+
const rel = relative(base, path).split(sep).join('/');
|
|
141
139
|
return global ? `~/${rel}` : rel;
|
|
142
140
|
};
|
|
143
141
|
const HINT = 'run with -y or --agents <ids>';
|
|
144
|
-
/** Run `flowfig init`. Returns the exit code. */
|
|
145
142
|
export async function runInit(argv) {
|
|
146
143
|
const args = [...argv];
|
|
147
144
|
const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
|
|
@@ -248,8 +245,7 @@ export async function runInit(argv) {
|
|
|
248
245
|
mcpStatus = `would ${mcpStatus === 'created' ? 'create' : 'update'}`;
|
|
249
246
|
else {
|
|
250
247
|
mkdirSync(dirname(mcpPath), { recursive: true });
|
|
251
|
-
//
|
|
252
|
-
// Resolve a symlink first, so the rename replaces the target and not the link. Copy the old mode to the new file.
|
|
248
|
+
// Rename a temp file over the symlink target, so a crash leaves no half file.
|
|
253
249
|
const real = existsSync(mcpPath) ? realpathSync(mcpPath) : mcpPath;
|
|
254
250
|
const tmp = `${real}.${process.pid}.tmp`;
|
|
255
251
|
try {
|
|
@@ -274,7 +270,6 @@ function fail(message) {
|
|
|
274
270
|
console.error(message);
|
|
275
271
|
return 2;
|
|
276
272
|
}
|
|
277
|
-
/** Ask on the terminal. Returns the chosen ids, or `undefined` if the user quits. */
|
|
278
273
|
async function pick(start, detected, dir) {
|
|
279
274
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
280
275
|
const ask = (q) => new Promise((res) => (rl.once('close', () => res(undefined)), rl.question(q, res)));
|
package/dist/load.d.ts
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
import { type FlowProps } from './model.ts';
|
|
2
2
|
import { type Finding, type SvgOptions } from './svg.ts';
|
|
3
|
-
/** The spec JSON inside an SVG this package wrote. */
|
|
4
3
|
export declare function specOf(svg: string, name: string): string;
|
|
5
4
|
/** A spec from a `.json` or SVG path, or an object (`{ props }` or the props). Every error message starts with the name. */
|
|
6
5
|
export declare function loadSpec(input: string | object, name?: string): FlowProps;
|
|
7
6
|
/** The SVG with its spec in `<metadata>`: an SVG is then its own source. `]]>` would close the CDATA early, so it is escaped. */
|
|
8
|
-
export declare function svgWithSpec(props: FlowProps, opts?: SvgOptions): string;
|
|
9
|
-
/** Errors first, then by rule name. `strict` makes every warning an error. */
|
|
7
|
+
export declare function svgWithSpec(props: FlowProps, opts?: SvgOptions, svg?: string): string;
|
|
10
8
|
export declare function sortFindings(findings: Finding[], strict: boolean): Finding[];
|
|
11
9
|
/** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
|
|
12
10
|
export declare function reportLines(props: FlowProps, findings: Finding[], withCounts?: boolean): string[];
|
package/dist/load.js
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
|
-
// What the CLI and the MCP server share: read a spec, write an SVG that carries its spec, and print the check lines.
|
|
2
1
|
import { readFileSync } from 'node:fs';
|
|
3
2
|
import { counts } from './model.js';
|
|
4
3
|
import { toSvg } from './svg.js';
|
|
5
4
|
const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
|
|
6
5
|
const SPEC_CLOSE = ']]></metadata>';
|
|
7
|
-
/** The spec JSON inside an SVG this package wrote. */
|
|
8
6
|
export function specOf(svg, name) {
|
|
9
7
|
const at = svg.indexOf(SPEC_OPEN);
|
|
10
8
|
if (at === -1)
|
|
@@ -36,11 +34,10 @@ export function loadSpec(input, name = typeof input === 'string' ? input : 'spec
|
|
|
36
34
|
return props;
|
|
37
35
|
}
|
|
38
36
|
/** The SVG with its spec in `<metadata>`: an SVG is then its own source. `]]>` would close the CDATA early, so it is escaped. */
|
|
39
|
-
export function svgWithSpec(props, opts = {}) {
|
|
37
|
+
export function svgWithSpec(props, opts = {}, svg = toSvg(props, opts)) {
|
|
40
38
|
const spec = JSON.stringify({ props }).replaceAll(']]>', ']]\\u003e');
|
|
41
|
-
return
|
|
39
|
+
return svg.replace(/(<svg[^>]*>\n?)/, (tag) => `${tag}${SPEC_OPEN}${spec}${SPEC_CLOSE}\n`);
|
|
42
40
|
}
|
|
43
|
-
/** Errors first, then by rule name. `strict` makes every warning an error. */
|
|
44
41
|
export function sortFindings(findings, strict) {
|
|
45
42
|
return findings
|
|
46
43
|
.map((f) => (strict ? { ...f, severity: 'error' } : f))
|
package/dist/mcp.d.ts
CHANGED
|
@@ -133,7 +133,6 @@ export declare const TOOLS: ({
|
|
|
133
133
|
required: string[];
|
|
134
134
|
};
|
|
135
135
|
})[];
|
|
136
|
-
/**
|
|
136
|
+
/** Returns `undefined` for a notification. */
|
|
137
137
|
export declare function handle(message: unknown): Response | undefined;
|
|
138
|
-
/** Read one message per line from `input`, write one response per line to `output`. Returns when `input` ends. */
|
|
139
138
|
export declare function serve(input: NodeJS.ReadableStream, output: NodeJS.WritableStream): Promise<void>;
|