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/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 glow = `0 0 0 3px color-mix(in srgb, ${v('accent')} 18%, transparent)`;
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.ms ?? speed), 0) };
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.ms ?? speed)], []);
246
- const total = ends.at(-1) + speed * 1.5; // hold on the last beat before moving on
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) / ((beats[i].ms ?? speed) * 0.8)); // arrive a little early, rest at the end
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.ms ?? speed), 0);
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
- return (_jsxs("div", { "data-fig": item.id, onMouseEnter: () => setHover(item.id), onMouseLeave: () => setHover(null), style: {
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 ? '22px 34px' : `${store ? 24 : 10}px ${card ? 10 : 16}px 10px`,
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
- boxShadow: diamond ? undefined : lit ? glow : '0 1px 2px rgba(0,0,0,.06)',
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 .25s, box-shadow .25s, opacity .25s',
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 .25s' } }) })), store && (_jsx("div", { style: {
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}}' }), !noMap && (_jsx(_Fragment, { children: _jsx("div", { style: {
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: k === 'on' ? v('accent') : v('muted') }) }, k))) }), routed.map((r) => {
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: v('accent'), opacity: 0.2 }), _jsx("circle", { r: 4.5, fill: v('accent') })] }, j)))] }), Array.from({ length: Math.max(1, ...beats.map((b) => b.hops.length)) }, (_, j) => (_jsx("div", { ref: (c) => {
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 claude skill (use --agents claude)');
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
+ }