flowfig 0.1.0 → 0.3.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 +87 -24
- package/dist/check.js +29 -3
- package/dist/cli.js +112 -49
- package/dist/diff.d.ts +9 -0
- package/dist/diff.js +59 -0
- package/dist/draw.d.ts +12 -0
- package/dist/draw.js +178 -0
- package/dist/guide.d.ts +2 -2
- package/dist/guide.js +34 -12
- package/dist/index.js +74 -24
- package/dist/init.d.ts +15 -0
- package/dist/init.js +92 -6
- package/dist/load.d.ts +12 -0
- package/dist/load.js +59 -0
- package/dist/mcp.d.ts +139 -0
- package/dist/mcp.js +216 -0
- package/dist/model.d.ts +25 -3
- package/dist/model.js +14 -0
- package/dist/rail.d.ts +2 -1
- package/dist/rail.js +15 -1
- package/dist/scene.d.ts +2 -0
- package/dist/source.d.ts +18 -0
- package/dist/source.js +29 -0
- package/dist/svg.d.ts +2 -0
- package/dist/svg.js +113 -27
- package/dist/verify.d.ts +7 -0
- package/dist/verify.js +43 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +2 -0
- package/package.json +5 -1
package/dist/index.js
CHANGED
|
@@ -4,12 +4,15 @@ import { useEffect, useMemo, useRef, useState } from 'react';
|
|
|
4
4
|
import { route } from './geometry.js';
|
|
5
5
|
import { foldedLabel, groupBox, layoutRail, railState, RAIL } from './rail.js';
|
|
6
6
|
import { checkScene, checkSpec, checkTheme } from './check.js';
|
|
7
|
-
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, } from './model.js';
|
|
7
|
+
import { ASYNC_TAG_W, BASE_RATE, CARD_LINE, CARD_PAD, CARD_WIDTH, EDGE_OFF, EDGE_ON, LIGHT, TONES, ON_ACCENT, toneFill, toneTint, decisions, edgeId, groupGap, isGroup, isRows, nodes, toBeat, beatMs, STEP_HOLD_MS, } from './model.js';
|
|
8
8
|
export { LIGHT, DARK } from './model.js';
|
|
9
9
|
const DEFAULTS = { ...LIGHT, font: 'inherit' };
|
|
10
10
|
const v = (k) => `var(--fig-${k}, ${DEFAULTS[k]})`;
|
|
11
11
|
const MONO = 'var(--ifm-font-family-monospace, ui-monospace, SFMono-Regular, Menlo, monospace)';
|
|
12
|
-
const
|
|
12
|
+
const ACTIVE = 'flowfig-active'; // the box a packet has just reached
|
|
13
|
+
// The active box reads --ff-hop (the tone of the hop that arrived), then --ff-box (the tone of the box), then the accent.
|
|
14
|
+
const hue = `var(--ff-hop, var(--ff-box, ${v('accent')}))`;
|
|
15
|
+
const glow = `0 0 0 3px color-mix(in srgb, ${hue} 18%, transparent)`;
|
|
13
16
|
const cardBody = (c) => {
|
|
14
17
|
if (c == null)
|
|
15
18
|
return '—';
|
|
@@ -203,6 +206,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
203
206
|
return [
|
|
204
207
|
{
|
|
205
208
|
id: `rail:${row.n}`,
|
|
209
|
+
step: row.step,
|
|
206
210
|
from: rail.columns[row.from].id,
|
|
207
211
|
to: rail.columns[row.to].id,
|
|
208
212
|
curve: [c, c, c, c],
|
|
@@ -227,12 +231,12 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
227
231
|
}, [check, routed, layout, edges, steps, ids, theme, rail, noMap]);
|
|
228
232
|
// Play the step's beats: each moves its packets (and their data cards) along its edges, then the next step starts.
|
|
229
233
|
useEffect(() => {
|
|
230
|
-
const gs = dots.current, cs = chips.current;
|
|
234
|
+
const gs = dots.current, cs = chips.current, edgeOf = Object.fromEntries(edges.map((e, i) => [ids[i], e]));
|
|
231
235
|
if (clock.current.beats !== beats) {
|
|
232
236
|
// A rail click can open another step at a later beat; start the clock at that beat, not at 0.
|
|
233
237
|
const j = jump.current ?? 0;
|
|
234
238
|
jump.current = null;
|
|
235
|
-
clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + (b
|
|
239
|
+
clock.current = { beats, elapsed: beats.slice(0, j).reduce((t, b) => t + beatMs(b, speed), 0) };
|
|
236
240
|
setBeat(j);
|
|
237
241
|
}
|
|
238
242
|
if (!beats.length || (!noMap && !routed.length))
|
|
@@ -242,8 +246,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
242
246
|
bar.current.style.transform = 'none';
|
|
243
247
|
return void setBeat(beats.length - 1);
|
|
244
248
|
}
|
|
245
|
-
const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + (b
|
|
246
|
-
const total = ends.at(-1) +
|
|
249
|
+
const ends = beats.reduce((acc, b) => [...acc, (acc.at(-1) ?? 0) + beatMs(b, speed)], []);
|
|
250
|
+
const total = ends.at(-1) + STEP_HOLD_MS; // The figure holds on the last beat before the next step.
|
|
247
251
|
let raf = 0, last = performance.now(), shownBeat = -1;
|
|
248
252
|
const tick = (now) => {
|
|
249
253
|
if (playingRef.current)
|
|
@@ -262,7 +266,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
262
266
|
if (i !== shownBeat)
|
|
263
267
|
setBeat((shownBeat = i));
|
|
264
268
|
const start = i ? ends[i - 1] : 0;
|
|
265
|
-
const f = Math.min(1, (t - start) /
|
|
269
|
+
const f = Math.min(1, (t - start) / speed); // the packet crosses in `speed`; the hold gives the rest
|
|
266
270
|
const eased = f < 0.5 ? 2 * f * f : 1 - (-2 * f + 2) ** 2 / 2;
|
|
267
271
|
const hops = t < ends.at(-1) ? beats[i].hops : [];
|
|
268
272
|
gs.forEach((g, j) => {
|
|
@@ -276,20 +280,51 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
276
280
|
return;
|
|
277
281
|
}
|
|
278
282
|
const pt = p.getPointAtLength((hops[j].back ? 1 - eased : eased) * p.getTotalLength());
|
|
283
|
+
const tone = hops[j].tone && TONES[hops[j].tone];
|
|
279
284
|
if (g) {
|
|
280
285
|
g.setAttribute('transform', `translate(${pt.x} ${pt.y})`);
|
|
281
286
|
g.style.opacity = '1';
|
|
287
|
+
g.style.color = tone ?? v('accent');
|
|
282
288
|
}
|
|
283
289
|
if (c) {
|
|
290
|
+
c.style.background = tone ? toneFill(tone) : v('accent');
|
|
284
291
|
c.style.transform = `translate(${pt.x}px, ${pt.y}px) translate(-50%, calc(-100% - 12px))`;
|
|
285
292
|
c.style.opacity = hops[j].data == null ? '0' : '1';
|
|
286
293
|
}
|
|
287
294
|
});
|
|
295
|
+
// A box is active from the packet arrival to the end of the step hold. The tick toggles the class and skips React.
|
|
296
|
+
const arrived = new Map();
|
|
297
|
+
if (f >= 1)
|
|
298
|
+
for (const h of beats[i].hops) {
|
|
299
|
+
const to = h.back ? edgeOf[h.edge]?.from : edgeOf[h.edge]?.to;
|
|
300
|
+
arrived.set(to, arrived.get(to) ?? (h.tone && TONES[h.tone]));
|
|
301
|
+
}
|
|
302
|
+
root.current?.querySelectorAll('[data-fig]').forEach((n) => {
|
|
303
|
+
const on = arrived.has(n.dataset.fig ?? '');
|
|
304
|
+
if (on) {
|
|
305
|
+
const tone = arrived.get(n.dataset.fig);
|
|
306
|
+
if (tone)
|
|
307
|
+
n.style.setProperty('--ff-hop', tone);
|
|
308
|
+
else
|
|
309
|
+
n.style.removeProperty('--ff-hop');
|
|
310
|
+
}
|
|
311
|
+
if (on === n.classList.contains(ACTIVE))
|
|
312
|
+
return;
|
|
313
|
+
if (!on)
|
|
314
|
+
return void n.classList.remove(ACTIVE); // the transition fades the box
|
|
315
|
+
// The box turns active at once: no transition while the class arrives.
|
|
316
|
+
const keep = n.style.transition;
|
|
317
|
+
n.style.transition = 'none';
|
|
318
|
+
n.classList.add(ACTIVE);
|
|
319
|
+
void n.offsetWidth;
|
|
320
|
+
n.style.transition = keep;
|
|
321
|
+
});
|
|
288
322
|
raf = requestAnimationFrame(tick);
|
|
289
323
|
};
|
|
290
324
|
raf = requestAnimationFrame(tick);
|
|
291
325
|
return () => {
|
|
292
326
|
cancelAnimationFrame(raf);
|
|
327
|
+
root.current?.querySelectorAll(`.${ACTIVE}`).forEach((n) => n.classList.remove(ACTIVE));
|
|
293
328
|
gs.forEach((g) => g && (g.style.opacity = '0'));
|
|
294
329
|
cs.forEach((c) => c && (c.style.opacity = '0'));
|
|
295
330
|
};
|
|
@@ -308,10 +343,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
308
343
|
if (litEdges.has(ids[i]))
|
|
309
344
|
litNodes.add(e.from).add(e.to);
|
|
310
345
|
});
|
|
346
|
+
// A toned hop colors its edge for its own beat only; the trail after it is the accent.
|
|
347
|
+
const hopTone = (edge) => {
|
|
348
|
+
const t = cur?.hops.find((h) => h.edge === edge && h.tone)?.tone;
|
|
349
|
+
return t && TONES[t];
|
|
350
|
+
};
|
|
311
351
|
const goTo = (step, b) => {
|
|
312
352
|
setPlaying(true);
|
|
313
353
|
if (step === active) {
|
|
314
|
-
clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + (x
|
|
354
|
+
clock.current.elapsed = beats.slice(0, b).reduce((t, x) => t + beatMs(x, speed), 0);
|
|
315
355
|
setBeat(b);
|
|
316
356
|
}
|
|
317
357
|
else {
|
|
@@ -359,7 +399,8 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
359
399
|
const diamond = item.shape === 'decision';
|
|
360
400
|
const store = item.shape === 'store';
|
|
361
401
|
const card = carded.has(item.id);
|
|
362
|
-
|
|
402
|
+
const bt = item.tone && TONES[item.tone];
|
|
403
|
+
return (_jsxs("div", { "data-fig": item.id, "data-diamond": diamond || undefined, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
|
|
363
404
|
position: 'relative',
|
|
364
405
|
isolation: 'isolate',
|
|
365
406
|
minWidth: 100,
|
|
@@ -370,17 +411,20 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
370
411
|
display: 'flex',
|
|
371
412
|
flexDirection: 'column',
|
|
372
413
|
justifyContent: 'center',
|
|
373
|
-
padding: diamond
|
|
414
|
+
padding: diamond
|
|
415
|
+
? '22px 34px'
|
|
416
|
+
: `calc(${store ? 24 : 10}px - var(--ff-b, 0px)) calc(${card ? 10 : 16}px - var(--ff-b, 0px)) calc(10px - var(--ff-b, 0px))`,
|
|
374
417
|
textAlign: 'center',
|
|
375
|
-
background: diamond ? undefined : v('bg'),
|
|
418
|
+
background: diamond ? undefined : bt ? toneTint(bt, v('bg')) : v('bg'),
|
|
376
419
|
color: v('fg'),
|
|
377
|
-
border: diamond ? undefined : `1px solid ${lit ? v('accent') : v('border')}`,
|
|
378
|
-
|
|
420
|
+
border: diamond ? undefined : `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
|
|
421
|
+
...(bt && { '--ff-box': bt }),
|
|
422
|
+
boxShadow: diamond ? undefined : '0 1px 2px rgba(0,0,0,.06)',
|
|
379
423
|
opacity: focus && !lit ? 0.7 : 1,
|
|
380
424
|
borderRadius: store ? '50% / 12px' : 10,
|
|
381
425
|
fontSize: 14,
|
|
382
426
|
fontWeight: 500,
|
|
383
|
-
transition: 'border-color .
|
|
427
|
+
transition: 'border-color .4s, background .4s, box-shadow .4s, border-width .4s, padding .4s, opacity .25s',
|
|
384
428
|
cursor: 'default',
|
|
385
429
|
}, children: [diamond && (_jsx("svg", { viewBox: "0 0 100 100", preserveAspectRatio: "none", style: {
|
|
386
430
|
position: 'absolute',
|
|
@@ -389,7 +433,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
389
433
|
height: '100%',
|
|
390
434
|
overflow: 'visible',
|
|
391
435
|
zIndex: -1,
|
|
392
|
-
}, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: v('bg'), stroke: lit ? v('accent') : v('border'), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .
|
|
436
|
+
}, children: _jsx("polygon", { points: "50,0 100,50 50,100 0,50", fill: bt ? toneTint(bt, v('bg')) : v('bg'), stroke: bt ?? (lit ? v('accent') : v('border')), strokeWidth: lit ? 1.6 : 1, vectorEffect: "non-scaling-stroke", style: { transition: 'stroke .4s, fill .4s, stroke-width .4s' } }) })), store && (_jsx("div", { style: {
|
|
393
437
|
position: 'absolute',
|
|
394
438
|
top: -1,
|
|
395
439
|
left: -1,
|
|
@@ -397,7 +441,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
397
441
|
height: 24,
|
|
398
442
|
boxSizing: 'border-box',
|
|
399
443
|
borderRadius: '50%',
|
|
400
|
-
border: `1px solid ${lit ? v('accent') : v('border')}`,
|
|
444
|
+
border: `1px solid ${bt ?? (lit ? v('accent') : v('border'))}`,
|
|
401
445
|
transition: 'border-color .25s',
|
|
402
446
|
} })), _jsx("div", { children: item.label }), item.sub != null && _jsx("div", { style: { fontSize: 12, fontWeight: 400, color: v('muted'), marginTop: 2 }, children: item.sub }), card && (_jsxs("div", { style: {
|
|
403
447
|
marginTop: 8,
|
|
@@ -451,7 +495,10 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
451
495
|
zIndex: 3,
|
|
452
496
|
width: 28,
|
|
453
497
|
height: 28,
|
|
454
|
-
}, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}'
|
|
498
|
+
}, children: _jsx(Icon, { d: full ? 'M4 4l8 8M12 4l-8 8' : 'M9 3h4v4M7 13H3V9M13 3L9 7M3 13l4-4' }) }), _jsx("style", { children: '@keyframes flowfig-in{from{opacity:0;transform:translateY(3px)}to{opacity:1;transform:none}}' +
|
|
499
|
+
// The active box has a 2 px border, a tint and a glow. The padding shrinks by 1 px. The box size stays the same, so the edges keep their route.
|
|
500
|
+
`.${ACTIVE}:not([data-diamond]){--ff-b:1px;border-width:2px!important;background:color-mix(in srgb, ${hue} 10%, ${v('surface')})!important;box-shadow:${glow}!important;border-color:${hue}!important}` +
|
|
501
|
+
`.${ACTIVE} polygon{fill:color-mix(in srgb, ${hue} 10%, ${v('surface')});stroke:${hue};stroke-width:2px}` }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
|
|
455
502
|
margin: full ? '-48px -24px 0' : '-20px -16px 0',
|
|
456
503
|
padding: full ? '48px 24px 24px' : '24px 16px',
|
|
457
504
|
borderRadius: full ? 0 : '16px 16px 0 0',
|
|
@@ -478,15 +525,16 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
478
525
|
height: '100%',
|
|
479
526
|
overflow: 'visible',
|
|
480
527
|
pointerEvents: 'none',
|
|
481
|
-
}, 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:
|
|
528
|
+
}, 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) => {
|
|
482
529
|
const on = litEdges.has(r.id);
|
|
530
|
+
const tone = hopTone(r.id);
|
|
483
531
|
const hidden = !on && edges[ids.indexOf(r.id)].quiet;
|
|
484
532
|
return (_jsx("path", { ref: (p) => {
|
|
485
533
|
paths.current[r.id] = p;
|
|
486
|
-
}, d: r.d, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
|
|
534
|
+
}, d: r.d, fill: "none", stroke: on ? (tone ?? v('accent')) : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeOpacity: hidden ? 0 : focus && !on ? 0.35 : 1, markerEnd: hidden ? undefined : `url(#fig-arrow-${on ? 'on' : 'off'})`, style: { transition: 'stroke .25s, stroke-opacity .25s' } }, r.id));
|
|
487
535
|
}), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsxs("g", { ref: (g) => {
|
|
488
536
|
dots.current[j] = g;
|
|
489
|
-
}, style: { opacity: 0 }, children: [_jsx("circle", { r: 10, fill:
|
|
537
|
+
}, 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) => {
|
|
490
538
|
chips.current[j] = c;
|
|
491
539
|
}, style: {
|
|
492
540
|
position: 'absolute',
|
|
@@ -510,6 +558,7 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
510
558
|
if (e.label == null)
|
|
511
559
|
return null;
|
|
512
560
|
const on = litEdges.has(r.id);
|
|
561
|
+
const tone = hopTone(r.id);
|
|
513
562
|
return (_jsx("div", { "data-fig-label": r.id, style: {
|
|
514
563
|
position: 'absolute',
|
|
515
564
|
left: r.mid.x,
|
|
@@ -521,9 +570,9 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
521
570
|
borderRadius: 999,
|
|
522
571
|
whiteSpace: 'nowrap',
|
|
523
572
|
pointerEvents: 'none',
|
|
524
|
-
background: on ? v('accent') : v('bg'),
|
|
573
|
+
background: on ? (tone ? toneFill(tone) : v('accent')) : v('bg'),
|
|
525
574
|
color: on ? ON_ACCENT : v('muted'),
|
|
526
|
-
border: `1px solid ${on ? v('accent') : v('border')}`,
|
|
575
|
+
border: `1px solid ${on ? (tone ?? v('accent')) : v('border')}`,
|
|
527
576
|
opacity: !on && e.quiet ? 0 : focus && !on ? 0.6 : 1,
|
|
528
577
|
fontFamily: MONO,
|
|
529
578
|
transition: 'background .25s, color .25s, opacity .25s',
|
|
@@ -564,14 +613,15 @@ export function Flow({ layout, edges, steps = [], theme, speed = 900, autoplay =
|
|
|
564
613
|
(hover != null && [rail.columns[row.from].id, rail.columns[row.to].id].includes(hover));
|
|
565
614
|
const [x1, x2] = [rail.columns[row.from].x, rail.columns[row.to].x];
|
|
566
615
|
const ly = y + RAIL.row / 2;
|
|
616
|
+
const tone = state === 'now' && row.tone ? TONES[row.tone] : undefined;
|
|
567
617
|
const g = row.group != null ? rail.groups[row.group] : null;
|
|
568
|
-
return (_jsxs("g", { "data-rail-row": row.n, "data-state": state, opacity: state === 'next' && !on ? 0.45 : 1, style: { cursor: 'pointer' }, onClick: () => goTo(row.step, row.beat), onMouseEnter: () => setHoverEdge(row.edge), onMouseLeave: () => setHoverEdge(null), children: [g && g.rows[0] === i && (_jsx("rect", { x: groupBox(rail, g).x, y: y + groupBox(rail, g).y, width: groupBox(rail, g).w, height: groupBox(rail, g).h, rx: 6, fill: `color-mix(in srgb, ${v('accent')} 8%, ${v('bg')})`, stroke: v('accent'), strokeDasharray: "3 3" })), _jsx("path", { d: `M ${x1} ${ly} H ${x2}`, fill: "none", stroke: on ? v('accent') : v('muted'), strokeWidth: on ? EDGE_ON : EDGE_OFF, strokeDasharray: row.async ? '4 3' : undefined, markerEnd: "url(#flowfig-rail-arrow)" }), row.async &&
|
|
618
|
+
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 &&
|
|
569
619
|
(() => {
|
|
570
620
|
const tagW = ASYNC_TAG_W;
|
|
571
621
|
// The tag sits on the tail side of the pill, so it never hides the arrowhead.
|
|
572
622
|
const tx = !row.pill ? (x1 + x2) / 2 - tagW / 2 : x2 > x1 ? row.pill.x - tagW - 4 : row.pill.x + row.pill.w + 4;
|
|
573
623
|
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" })] }));
|
|
574
|
-
})(), row.pill && (_jsxs("g", { "data-fig-label": `rail:${row.n}`, children: [_jsx("rect", { x: row.pill.x, y: ly - 9, width: row.pill.w, height: 18, rx: 9, fill: on ? v('accent') : v('bg'), stroke: on ? v('accent') : v('border') }), _jsx("text", { x: row.pill.x + row.pill.w / 2, y: ly + 4, fill: on ? ON_ACCENT : v('muted'), fontSize: 11, fontFamily: MONO, textAnchor: "middle", children: row.text })] }))] }, `m${i}`));
|
|
624
|
+
})(), 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}`));
|
|
575
625
|
});
|
|
576
626
|
})(), _jsx("defs", { children: _jsx("marker", { id: "flowfig-rail-arrow", viewBox: "0 0 10 10", refX: "9", refY: "5", markerWidth: "7", markerHeight: "7", orient: "auto-start-reverse", children: _jsx("path", { d: "M 0 1 L 9 5 L 0 9 z", fill: v('muted') }) }) })] })), steps.length > 0 && (_jsxs("figcaption", { style: { marginTop: 14, textAlign: 'center' }, children: [_jsxs("div", { style: { display: 'flex', gap: 10, justifyContent: 'center', alignItems: 'center' }, children: [_jsx("button", { type: "button", "aria-label": playing ? 'Pause' : 'Play', title: playing ? 'Pause' : 'Play', style: iconBtn, onClick: () => setPlaying((p) => !p), children: _jsx(Icon, { d: playing ? 'M5.5 4v8M10.5 4v8' : 'M5 3.5v9l7.5-4.5z', fill: !playing }) }), _jsx("div", { role: "tablist", style: {
|
|
577
627
|
display: 'inline-flex',
|
package/dist/init.d.ts
CHANGED
|
@@ -10,6 +10,19 @@ type Agent = {
|
|
|
10
10
|
/** Text before the marked block in a whole file. */
|
|
11
11
|
head?: string;
|
|
12
12
|
note?: string;
|
|
13
|
+
/** The MCP config file the agent reads, and the top-level key that holds the servers. `type` adds `"type": "stdio"`. */
|
|
14
|
+
mcp?: {
|
|
15
|
+
file: string;
|
|
16
|
+
key: 'mcpServers' | 'servers';
|
|
17
|
+
type?: true;
|
|
18
|
+
global?: string;
|
|
19
|
+
};
|
|
20
|
+
/** Shown in place of the MCP file when init cannot write the config. */
|
|
21
|
+
mcpNote?: string;
|
|
22
|
+
};
|
|
23
|
+
export declare const MCP_ENTRY: {
|
|
24
|
+
command: string;
|
|
25
|
+
args: string[];
|
|
13
26
|
};
|
|
14
27
|
export declare const AGENTS: Agent[];
|
|
15
28
|
/** The ids of the agents that the repo at `dir` already uses. */
|
|
@@ -20,6 +33,8 @@ export declare function toggle(input: string, selected: Set<string>, count: numb
|
|
|
20
33
|
export declare function renderList(selected: Set<string>, detected: string[], dir: string): string;
|
|
21
34
|
/** The new file text, or `undefined` if the file belongs to the user and init must leave it. `old` is `undefined` for a missing file. */
|
|
22
35
|
export declare function place(a: Agent, old: string | undefined): string | undefined;
|
|
36
|
+
/** The MCP file with the flowfig entry merged in, or `undefined` when the file is not a JSON object that can hold it. */
|
|
37
|
+
export declare function registerMcp(a: Agent, old: string | undefined): string | undefined;
|
|
23
38
|
/** Run `flowfig init`. Returns the exit code. */
|
|
24
39
|
export declare function runInit(argv: string[]): Promise<number>;
|
|
25
40
|
export {};
|
package/dist/init.js
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
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
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, realpathSync, renameSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { dirname, join, resolve } from 'node:path';
|
|
5
5
|
import { createInterface } from 'node:readline';
|
|
6
6
|
import { AGENT_TEXT, SKILL_DESCRIPTION } from './guide.js';
|
|
7
7
|
const START = '<!-- flowfig:start -->';
|
|
8
8
|
const END = '<!-- flowfig:end -->';
|
|
9
|
+
export const MCP_ENTRY = { command: 'npx', args: ['flowfig', 'mcp'] };
|
|
9
10
|
export const AGENTS = [
|
|
10
11
|
{
|
|
11
12
|
id: 'claude',
|
|
@@ -13,6 +14,7 @@ export const AGENTS = [
|
|
|
13
14
|
marks: ['.claude/', 'CLAUDE.md'],
|
|
14
15
|
file: '.claude/skills/figure/SKILL.md',
|
|
15
16
|
kind: 'whole',
|
|
17
|
+
mcp: { file: '.mcp.json', key: 'mcpServers', type: true, global: '.claude.json' },
|
|
16
18
|
head: `---\nname: figure\ndescription: ${JSON.stringify(SKILL_DESCRIPTION)}\nuser_invocable: true\n---\n\n`,
|
|
17
19
|
},
|
|
18
20
|
{ id: 'agents', name: 'AGENTS.md', marks: ['AGENTS.md'], file: 'AGENTS.md', kind: 'section', note: 'Codex, Amp, Jules, ...' },
|
|
@@ -22,6 +24,7 @@ export const AGENTS = [
|
|
|
22
24
|
marks: ['.cursor/', '.cursorrules'],
|
|
23
25
|
file: '.cursor/rules/flowfig.mdc',
|
|
24
26
|
kind: 'whole',
|
|
27
|
+
mcp: { file: '.cursor/mcp.json', key: 'mcpServers', type: true, global: '.cursor/mcp.json' },
|
|
25
28
|
head: '---\ndescription: Draw diagrams and flow figures with flowfig\nalwaysApply: false\n---\n\n',
|
|
26
29
|
},
|
|
27
30
|
{
|
|
@@ -30,10 +33,32 @@ export const AGENTS = [
|
|
|
30
33
|
marks: ['.github/copilot-instructions.md'],
|
|
31
34
|
file: '.github/copilot-instructions.md',
|
|
32
35
|
kind: 'section',
|
|
36
|
+
mcp: { file: '.vscode/mcp.json', key: 'servers', type: true },
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
id: 'gemini',
|
|
40
|
+
name: 'Gemini CLI',
|
|
41
|
+
marks: ['GEMINI.md', '.gemini/'],
|
|
42
|
+
file: 'GEMINI.md',
|
|
43
|
+
kind: 'section',
|
|
44
|
+
mcp: { file: '.gemini/settings.json', key: 'mcpServers', global: '.gemini/settings.json' },
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: 'windsurf',
|
|
48
|
+
name: 'Windsurf',
|
|
49
|
+
marks: ['.windsurf/', '.windsurfrules'],
|
|
50
|
+
file: '.windsurf/rules/flowfig.md',
|
|
51
|
+
kind: 'whole',
|
|
52
|
+
mcpNote: 'add {"command":"npx","args":["flowfig","mcp"]} to the MCP config by hand',
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
id: 'kiro',
|
|
56
|
+
name: 'Kiro',
|
|
57
|
+
marks: ['.kiro/'],
|
|
58
|
+
file: '.kiro/steering/flowfig.md',
|
|
59
|
+
kind: 'whole',
|
|
60
|
+
mcp: { file: '.kiro/settings/mcp.json', key: 'mcpServers', global: '.kiro/settings/mcp.json' },
|
|
33
61
|
},
|
|
34
|
-
{ id: 'gemini', name: 'Gemini CLI', marks: ['GEMINI.md', '.gemini/'], file: 'GEMINI.md', kind: 'section' },
|
|
35
|
-
{ id: 'windsurf', name: 'Windsurf', marks: ['.windsurf/', '.windsurfrules'], file: '.windsurf/rules/flowfig.md', kind: 'whole' },
|
|
36
|
-
{ id: 'kiro', name: 'Kiro', marks: ['.kiro/'], file: '.kiro/steering/flowfig.md', kind: 'whole' },
|
|
37
62
|
];
|
|
38
63
|
/** The ids of the agents that the repo at `dir` already uses. */
|
|
39
64
|
export const detect = (dir) => AGENTS.filter((a) => a.marks.some((m) => existsSync(join(dir, m)))).map((a) => a.id);
|
|
@@ -77,12 +102,34 @@ export function place(a, old) {
|
|
|
77
102
|
return old.slice(0, s) + mine.trimEnd() + old.slice(e + END.length);
|
|
78
103
|
return old + (old.endsWith('\n\n') ? '' : old.endsWith('\n') ? '\n' : '\n\n') + mine;
|
|
79
104
|
}
|
|
105
|
+
/** The MCP file with the flowfig entry merged in, or `undefined` when the file is not a JSON object that can hold it. */
|
|
106
|
+
export function registerMcp(a, old) {
|
|
107
|
+
if (!a.mcp)
|
|
108
|
+
return undefined;
|
|
109
|
+
let json;
|
|
110
|
+
try {
|
|
111
|
+
json = old === undefined ? {} : JSON.parse(old);
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
return undefined;
|
|
115
|
+
}
|
|
116
|
+
if (typeof json !== 'object' || json === null || Array.isArray(json))
|
|
117
|
+
return undefined;
|
|
118
|
+
const servers = json[a.mcp.key] ?? {};
|
|
119
|
+
if (typeof servers !== 'object' || servers === null || Array.isArray(servers))
|
|
120
|
+
return undefined;
|
|
121
|
+
if ('flowfig' in servers)
|
|
122
|
+
return old; // the user may have pinned a version or set env: keep the entry
|
|
123
|
+
const entry = a.mcp.type ? { ...MCP_ENTRY, type: 'stdio' } : MCP_ENTRY;
|
|
124
|
+
json[a.mcp.key] = { ...servers, flowfig: entry };
|
|
125
|
+
return JSON.stringify(json, null, 2) + '\n';
|
|
126
|
+
}
|
|
80
127
|
const HINT = 'run with -y or --agents <ids>';
|
|
81
128
|
/** Run `flowfig init`. Returns the exit code. */
|
|
82
129
|
export async function runInit(argv) {
|
|
83
130
|
const args = [...argv];
|
|
84
131
|
const flag = (n, ...alias) => [n, ...alias].some((x) => args.includes(x) && args.splice(args.indexOf(x), 1).length > 0);
|
|
85
|
-
const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run');
|
|
132
|
+
const listAgents = flag('--list-agents'), all = flag('--all-agents'), yes = flag('-y', '--yes'), global = flag('--global'), dry = flag('--dry-run'), noMcp = flag('--no-mcp');
|
|
86
133
|
let ids;
|
|
87
134
|
const at = args.indexOf('--agents');
|
|
88
135
|
if (at !== -1)
|
|
@@ -100,7 +147,7 @@ export async function runInit(argv) {
|
|
|
100
147
|
if (unknown)
|
|
101
148
|
return fail(`init: unknown agent "${unknown}" (ids: ${AGENTS.map((a) => a.id).join(', ')})`);
|
|
102
149
|
if (global && ids?.some((i) => i !== 'claude'))
|
|
103
|
-
return fail('init: --global writes only the
|
|
150
|
+
return fail('init: --global writes only the Claude skill and the MCP entry (use --agents claude)');
|
|
104
151
|
const detected = detect(dir);
|
|
105
152
|
const auto = new Set(detected.length ? detected : ['agents']);
|
|
106
153
|
let chosen;
|
|
@@ -142,6 +189,45 @@ export async function runInit(argv) {
|
|
|
142
189
|
}
|
|
143
190
|
}
|
|
144
191
|
console.log(`${status.padEnd(9)} ${path}`);
|
|
192
|
+
if (noMcp)
|
|
193
|
+
continue;
|
|
194
|
+
if (a.mcpNote) {
|
|
195
|
+
console.log(`note ${a.id}: ${a.mcpNote}`);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (!a.mcp || (global && !a.mcp.global))
|
|
199
|
+
continue;
|
|
200
|
+
const mcpPath = global ? join(homedir(), a.mcp.global) : join(dir, a.mcp.file);
|
|
201
|
+
const oldMcp = existsSync(mcpPath) ? readFileSync(mcpPath, 'utf8') : undefined;
|
|
202
|
+
const nextMcp = registerMcp(a, oldMcp);
|
|
203
|
+
let mcpStatus;
|
|
204
|
+
if (nextMcp === undefined)
|
|
205
|
+
mcpStatus = `skipped (not a JSON object with ${a.mcp.key})`;
|
|
206
|
+
else if (nextMcp === oldMcp)
|
|
207
|
+
mcpStatus = 'unchanged';
|
|
208
|
+
else {
|
|
209
|
+
mcpStatus = oldMcp === undefined ? 'created' : 'updated';
|
|
210
|
+
if (dry)
|
|
211
|
+
mcpStatus = `would ${mcpStatus === 'created' ? 'create' : 'update'}`;
|
|
212
|
+
else {
|
|
213
|
+
mkdirSync(dirname(mcpPath), { recursive: true });
|
|
214
|
+
// Same-folder temp file, then rename: a crash or a parallel write cannot leave half a file (~/.claude.json holds user settings).
|
|
215
|
+
// Resolve a symlink first, so the rename replaces the target and not the link. Copy the old mode to the new file.
|
|
216
|
+
const real = existsSync(mcpPath) ? realpathSync(mcpPath) : mcpPath;
|
|
217
|
+
const tmp = `${real}.${process.pid}.tmp`;
|
|
218
|
+
try {
|
|
219
|
+
writeFileSync(tmp, nextMcp);
|
|
220
|
+
if (existsSync(real))
|
|
221
|
+
chmodSync(tmp, statSync(real).mode & 0o777);
|
|
222
|
+
renameSync(tmp, real);
|
|
223
|
+
}
|
|
224
|
+
catch (e) {
|
|
225
|
+
rmSync(tmp, { force: true });
|
|
226
|
+
throw e;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
console.log(`${mcpStatus.padEnd(9)} ${mcpPath}`);
|
|
145
231
|
}
|
|
146
232
|
return 0;
|
|
147
233
|
}
|
package/dist/load.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { type FlowProps } from './model.ts';
|
|
2
|
+
import { type Finding, type SvgOptions } from './svg.ts';
|
|
3
|
+
/** The spec JSON inside an SVG this package wrote. */
|
|
4
|
+
export declare function specOf(svg: string, name: string): string;
|
|
5
|
+
/** A spec from a `.json` or SVG path, or an object (`{ props }` or the props). Every error message starts with the name. */
|
|
6
|
+
export declare function loadSpec(input: string | object, name?: string): FlowProps;
|
|
7
|
+
/** 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. */
|
|
10
|
+
export declare function sortFindings(findings: Finding[], strict: boolean): Finding[];
|
|
11
|
+
/** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
|
|
12
|
+
export declare function reportLines(props: FlowProps, findings: Finding[], withCounts?: boolean): string[];
|
package/dist/load.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
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
|
+
import { readFileSync } from 'node:fs';
|
|
3
|
+
import { counts } from './model.js';
|
|
4
|
+
import { toSvg } from './svg.js';
|
|
5
|
+
const SPEC_OPEN = '<metadata id="figure-spec"><![CDATA[';
|
|
6
|
+
const SPEC_CLOSE = ']]></metadata>';
|
|
7
|
+
/** The spec JSON inside an SVG this package wrote. */
|
|
8
|
+
export function specOf(svg, name) {
|
|
9
|
+
const at = svg.indexOf(SPEC_OPEN);
|
|
10
|
+
if (at === -1)
|
|
11
|
+
throw new Error(`${name}: no figure spec inside this SVG`);
|
|
12
|
+
return svg.slice(at + SPEC_OPEN.length, svg.indexOf(SPEC_CLOSE, at));
|
|
13
|
+
}
|
|
14
|
+
/** A spec from a `.json` or SVG path, or an object (`{ props }` or the props). Every error message starts with the name. */
|
|
15
|
+
export function loadSpec(input, name = typeof input === 'string' ? input : 'spec') {
|
|
16
|
+
let loaded = input;
|
|
17
|
+
if (typeof input === 'string') {
|
|
18
|
+
if (!input.endsWith('.json') && !input.endsWith('.svg'))
|
|
19
|
+
throw new Error(`${input}: expected a .json or .svg path`);
|
|
20
|
+
try {
|
|
21
|
+
const text = readFileSync(input, 'utf8');
|
|
22
|
+
loaded = JSON.parse(input.endsWith('.svg') ? specOf(text, input) : text);
|
|
23
|
+
}
|
|
24
|
+
catch (e) {
|
|
25
|
+
const m = e.message;
|
|
26
|
+
throw new Error(m.startsWith(`${input}: `) ? m : `${input}: ${m}`);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
const props = (loaded?.props ?? loaded);
|
|
30
|
+
if (!props?.layout)
|
|
31
|
+
throw new Error(`${name}: no figure props (expected { props: { layout, edges, steps } })`);
|
|
32
|
+
if (!Array.isArray(props.edges))
|
|
33
|
+
throw new Error(`${name}: edges must be an array (use [] for none)`);
|
|
34
|
+
if (props.steps?.some((s) => !Array.isArray(s?.flow)))
|
|
35
|
+
throw new Error(`${name}: each step needs a flow array`);
|
|
36
|
+
return props;
|
|
37
|
+
}
|
|
38
|
+
/** 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 = {}) {
|
|
40
|
+
const spec = JSON.stringify({ props }).replaceAll(']]>', ']]\\u003e');
|
|
41
|
+
return toSvg(props, opts).replace(/(<svg[^>]*>\n?)/, (tag) => `${tag}${SPEC_OPEN}${spec}${SPEC_CLOSE}\n`);
|
|
42
|
+
}
|
|
43
|
+
/** Errors first, then by rule name. `strict` makes every warning an error. */
|
|
44
|
+
export function sortFindings(findings, strict) {
|
|
45
|
+
return findings
|
|
46
|
+
.map((f) => (strict ? { ...f, severity: 'error' } : f))
|
|
47
|
+
.sort((a, b) => (a.severity === b.severity ? a.rule.localeCompare(b.rule) : a.severity === 'error' ? -1 : 1));
|
|
48
|
+
}
|
|
49
|
+
const n = (k, word) => `${k} ${word}${k === 1 ? '' : 's'}`;
|
|
50
|
+
/** One line per finding, the counts line (unless `withCounts` is false), and the `figure:` line. No color: the CLI adds it. */
|
|
51
|
+
export function reportLines(props, findings, withCounts = true) {
|
|
52
|
+
const out = findings.map((f) => `${f.severity.padEnd(8)} ${f.rule.padEnd(18)} ${f.message}`);
|
|
53
|
+
const errors = findings.filter((f) => f.severity === 'error').length;
|
|
54
|
+
if (withCounts)
|
|
55
|
+
out.push(`${n(errors, 'error')}, ${n(findings.length - errors, 'warning')}`);
|
|
56
|
+
const c = counts(props);
|
|
57
|
+
out.push(`figure: ${n(c.boxes, 'box').replace('boxs', 'boxes')}, ${n(c.groups, 'group')}, ${n(c.edges, 'edge')}, ${n(c.steps, 'step')}, ${n(c.messages, 'message')}`);
|
|
58
|
+
return out;
|
|
59
|
+
}
|