@caddis/cli 0.4.48 → 0.4.50

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.
Files changed (47) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +2 -2
  3. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +4 -1
  4. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +18 -1
  5. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +6 -1
  6. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +1 -1
  7. package/bundles/antigravity-plugin/skills/flow-studio/references/design-language.md +5 -5
  8. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +16 -0
  9. package/bundles/antigravity-plugin/skills/flow-studio/scripts/lib/styles.test.mjs +13 -4
  10. package/bundles/antigravity-plugin/skills/flow-studio/src/bars.test.ts +90 -0
  11. package/bundles/antigravity-plugin/skills/flow-studio/src/bars.ts +53 -0
  12. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +3 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +8 -3
  14. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +17 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +2 -1
  16. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +11 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/src/playbackui.ts +1 -1
  18. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +4 -2
  19. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +6 -1
  20. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +64 -7
  21. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +6 -1
  22. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +58 -6
  23. package/bundles/antigravity-plugin/skills/flow-studio/templates/shell-body.html +1 -1
  24. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +2 -2
  25. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +4 -1
  26. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +18 -1
  27. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +6 -1
  28. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +1 -1
  29. package/bundles/codex/.codex/skills/frontend/flow-studio/references/design-language.md +5 -5
  30. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +16 -0
  31. package/bundles/codex/.codex/skills/frontend/flow-studio/scripts/lib/styles.test.mjs +13 -4
  32. package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.test.ts +90 -0
  33. package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.ts +53 -0
  34. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +3 -0
  35. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +8 -3
  36. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +17 -0
  37. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +2 -1
  38. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +11 -0
  39. package/bundles/codex/.codex/skills/frontend/flow-studio/src/playbackui.ts +1 -1
  40. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +4 -2
  41. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +6 -1
  42. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +64 -7
  43. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +6 -1
  44. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +58 -6
  45. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/shell-body.html +1 -1
  46. package/bundles/manifest.json +2 -2
  47. package/package.json +1 -1
@@ -15,7 +15,7 @@
15
15
  <style>:root {
16
16
  --bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
17
17
  --accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
18
- --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:1.1px; --edge-w-hot:1.5px; --edge-w-play:1.6px; --trail-w:1.8px; --ripple-w:2px;
18
+ --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:0.8px; --edge-w-hot:1px; --edge-w-play:1.2px; --trail-w:1.3px; --ripple-w:1.5px;
19
19
  --warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
20
20
  --mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
21
21
  }
@@ -198,6 +198,11 @@
198
198
  .node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
199
199
  .node .s.has-spark { padding-right:60px; }
200
200
  .node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
201
+ .node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
202
+ .node svg.bars rect { fill:var(--muted); opacity:.6; }
203
+ .node svg.bars rect.last { fill:var(--accent); opacity:1; }
204
+ .node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
205
+ .node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
201
206
  .node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
202
207
  .node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
203
208
  .node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
@@ -229,7 +234,7 @@
229
234
  <div class="workspace" id="workspace">
230
235
  <section class="canvaswrap">
231
236
  <div id="viewport" tabindex="0" role="application" aria-label="Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.">
232
- <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
237
+ <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
233
238
  <div id="cols" aria-hidden="true"></div>
234
239
  <div id="tools" role="toolbar" aria-label="Canvas controls" aria-orientation="vertical"></div>
235
240
  <div id="zoomLabel" aria-hidden="true">100%</div>
@@ -326,6 +331,8 @@
326
331
  const metric = n.metrics;
327
332
  const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
328
333
  if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
334
+ const badBars = n.bars !== void 0 && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some((b) => !b || typeof b.key !== "string" || b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0)));
335
+ if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
329
336
  if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
330
337
  }
331
338
  for (const n of nodes) {
@@ -975,6 +982,7 @@
975
982
 
976
983
  // src/layout.ts
977
984
  var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
985
+ var BARS_EXTRA = 26;
978
986
  var LANE_PAD_TOP = 38;
979
987
  var LANE_PAD_BOTTOM = 14;
980
988
  function computeGeometry(m, opt = {}) {
@@ -988,7 +996,7 @@
988
996
  const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
989
997
  const nodes = {};
990
998
  for (const n of m.nodes) {
991
- const h = NODE_HEIGHT[n.kind] ?? 54;
999
+ const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
992
1000
  if (m.layout === "free") {
993
1001
  nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
994
1002
  continue;
@@ -1105,6 +1113,47 @@
1105
1113
  return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
1106
1114
  }
1107
1115
 
1116
+ // src/bars.ts
1117
+ var BAR_UNIT = 4;
1118
+ function barsHtml(bars, height = 22) {
1119
+ if (!bars || bars.length === 0) return "";
1120
+ const width = bars.length * BAR_UNIT;
1121
+ const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
1122
+ const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
1123
+ const rects = bars.map((b, i) => {
1124
+ const x = i * BAR_UNIT;
1125
+ const w = BAR_UNIT - 1;
1126
+ const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
1127
+ if (b.value === null) {
1128
+ return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
1129
+ }
1130
+ const h = Math.max(1, b.value / max * (height - 5));
1131
+ const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
1132
+ return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
1133
+ });
1134
+ const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
1135
+ return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
1136
+ }
1137
+ function markKey(root, key) {
1138
+ for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
1139
+ if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
1140
+ else r.removeAttribute("data-hl");
1141
+ }
1142
+ }
1143
+ function installBars(ctx) {
1144
+ const set = (key) => {
1145
+ if (ctx.hoverKey === key) return;
1146
+ ctx.hoverKey = key;
1147
+ markKey(ctx.els.world, key);
1148
+ ctx.onHoverKey?.(key);
1149
+ };
1150
+ on(ctx, ctx.els.world, "mouseover", (e) => {
1151
+ const bar = e.target?.closest?.("rect[data-key]");
1152
+ set(bar ? bar.getAttribute("data-key") : null);
1153
+ });
1154
+ on(ctx, ctx.els.world, "mouseleave", () => set(null));
1155
+ }
1156
+
1108
1157
  // src/render.ts
1109
1158
  function loopRoute(ctx, fromId, toId, from, to, channel) {
1110
1159
  const detourFor = (end, other) => {
@@ -1141,7 +1190,7 @@
1141
1190
  const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
1142
1191
  const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
1143
1192
  const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
1144
- return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
1193
+ return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
1145
1194
  }
1146
1195
  function build(ctx) {
1147
1196
  const { model, els } = ctx;
@@ -1176,7 +1225,7 @@
1176
1225
  for (const n of model.nodes) {
1177
1226
  const p = ctx.geom.nodes[n.id];
1178
1227
  if (!p) continue;
1179
- const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
1228
+ const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
1180
1229
  d.id = "n_" + n.id;
1181
1230
  d.tabIndex = 0;
1182
1231
  d.setAttribute("role", "button");
@@ -1311,6 +1360,7 @@
1311
1360
  d.style.height = r.h + "px";
1312
1361
  }
1313
1362
  }
1363
+ markKey(ctx.els.world, ctx.hoverKey);
1314
1364
  ctx.fn.buildChrome();
1315
1365
  ctx.fn.trace(ctx.selected);
1316
1366
  ctx.fn.renderInspector();
@@ -2183,7 +2233,7 @@
2183
2233
  let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
2184
2234
  let markedEdges = [];
2185
2235
  let markedNodes = [];
2186
- const token = sv("circle", { id: "token", class: "token", r: "6" });
2236
+ const token = sv("circle", { id: "token", class: "token", r: "4" });
2187
2237
  e.layer.appendChild(token);
2188
2238
  const pathOf = (link) => ctx.edgeEls[ctx.links.findIndex((l) => l.id === link)];
2189
2239
  const nodeOf = (id) => ctx.nodeEls.get(id);
@@ -2506,6 +2556,8 @@
2506
2556
  root,
2507
2557
  els,
2508
2558
  onSelect: opts.onSelect,
2559
+ onHoverKey: opts.onHoverKey,
2560
+ hoverKey: null,
2509
2561
  notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
2510
2562
  nodes: new Map(model.nodes.map((n) => [n.id, n])),
2511
2563
  links: model.links,
@@ -2548,6 +2600,7 @@
2548
2600
  style.textContent = stateCss(model.states);
2549
2601
  install(ctx);
2550
2602
  install2(ctx);
2603
+ installBars(ctx);
2551
2604
  install3(ctx);
2552
2605
  install4(ctx);
2553
2606
  install5(ctx);
@@ -2594,6 +2647,10 @@
2594
2647
  ctx.fn.fit(animate);
2595
2648
  },
2596
2649
  select: (id) => ctx.fn.select(id),
2650
+ highlightKey: (key) => {
2651
+ ctx.hoverKey = key;
2652
+ markKey(ctx.els.world, key);
2653
+ },
2597
2654
  selected: () => ctx.selected,
2598
2655
  mode: () => ctx.mode,
2599
2656
  setMode: (id) => ctx.fn.setMode(id),
@@ -2619,7 +2676,7 @@
2619
2676
  const changes = diffModels(ctx.model, prepared);
2620
2677
  const saved = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter((n) => n.kind === "group").map((n) => n.id)), playback: playback.playback() };
2621
2678
  api.destroy();
2622
- const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
2679
+ const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
2623
2680
  notifySelection(saved.selected, replacement.selected(), opts.onSelect);
2624
2681
  Object.assign(api, replacement);
2625
2682
  window.FlowStudio = api;
@@ -1,7 +1,7 @@
1
1
  :root {
2
2
  --bg:#f6f8fa; --panel:#ffffff; --panel2:#f6f8fa; --edge:#d0d7de; --rule:#d8dee4; --fg:#1f2328; --muted:#59636e;
3
3
  --accent:#0969da; --accent-solid:#0969da; --ok:#1a7f37; --warn:#9a6700; --crit:#cf222e; --glow:rgba(9,105,218,.22);
4
- --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:1.1px; --edge-w-hot:1.5px; --edge-w-play:1.6px; --trail-w:1.8px; --ripple-w:2px;
4
+ --up:#0a7ea4; --down:#8250df; --shadow:0 1px 0 rgba(31,35,40,.04), 0 6px 18px rgba(31,35,40,.08); --grid:rgba(31,35,40,.035); --dot:rgba(31,35,40,.18); --dot-size:1.2px; --dot-gap:24px; --edge-w:0.8px; --edge-w-hot:1px; --edge-w-play:1.2px; --trail-w:1.3px; --ripple-w:1.5px;
5
5
  --warn-bg:rgba(154,103,0,.10); --ok-bg:rgba(26,127,55,.10); --crit-bg:rgba(207,34,46,.08); --info-bg:rgba(9,105,218,.10);
6
6
  --mono:"IBM Plex Mono", ui-monospace, "Cascadia Mono", Consolas, Menlo, monospace;
7
7
  }
@@ -184,6 +184,11 @@
184
184
  .node .metric-badge i { display:block; height:2px; position:absolute; bottom:0; left:0; background:var(--accent); }
185
185
  .node .s.has-spark { padding-right:60px; }
186
186
  .node .spark { position:absolute; right:6px; bottom:4px; overflow:visible; }
187
+ .node svg.bars { position:absolute; left:10px; right:10px; bottom:6px; width:calc(100% - 20px); height:22px; overflow:visible; }
188
+ .node svg.bars rect { fill:var(--muted); opacity:.6; }
189
+ .node svg.bars rect.last { fill:var(--accent); opacity:1; }
190
+ .node svg.bars rect.low, .node svg.bars rect.miss { fill:var(--crit); opacity:1; }
191
+ .node svg.bars rect[data-hl="true"] { stroke:var(--fg); stroke-width:1.5px; vector-effect:non-scaling-stroke; opacity:1; }
187
192
  .node.gateway .metric-badge { position:absolute; right:9px; top:18px; }
188
193
  .node.gateway:has(.metric-badge) .gw-title, .node.gateway.has-metrics .gw-title { width:45%; margin-right:42px; }
189
194
  .node .spark polyline, .metrics polyline { fill:none; stroke:var(--accent); stroke-width:1.5; }
@@ -69,6 +69,8 @@ function validateModel(input) {
69
69
  const metric = n.metrics;
70
70
  const invalidSeries = metric?.series !== void 0 && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some((v) => !Number.isFinite(v)));
71
71
  if (metric !== void 0 && (!metric || !Number.isFinite(metric.value) || metric.max != null && !Number.isFinite(metric.max) || invalidSeries)) add("error", "metric-invalid", w, "Metrics need finite values and at most 60 finite series points.");
72
+ const badBars = n.bars !== void 0 && (!Array.isArray(n.bars) || n.bars.length > 120 || n.bars.some((b) => !b || typeof b.key !== "string" || b.value !== null && (typeof b.value !== "number" || !Number.isFinite(b.value) || b.value < 0)));
73
+ if (badBars) add("error", "bars-invalid", w, "Bars need a list of at most 120 items, each with a string key and a finite, non-negative value or null.");
72
74
  if (n.sla !== void 0 && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
73
75
  }
74
76
  for (const n of nodes) {
@@ -749,6 +751,7 @@ function on(ctx, target, type, fn, opts) {
749
751
 
750
752
  // src/layout.ts
751
753
  var DEFAULTS = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
754
+ var BARS_EXTRA = 26;
752
755
  var LANE_PAD_TOP = 38;
753
756
  var LANE_PAD_BOTTOM = 14;
754
757
  function computeGeometry(m, opt = {}) {
@@ -762,7 +765,7 @@ function computeGeometry(m, opt = {}) {
762
765
  const world = { w: Math.max(1, x - o.gap + o.padX), h: 1 };
763
766
  const nodes = {};
764
767
  for (const n of m.nodes) {
765
- const h = NODE_HEIGHT[n.kind] ?? 54;
768
+ const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
766
769
  if (m.layout === "free") {
767
770
  nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
768
771
  continue;
@@ -884,6 +887,47 @@ function sparkline(metric, width = 56, height = 12) {
884
887
  return `<svg class="spark" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" aria-hidden="true"><polyline points="${points}"/></svg>`;
885
888
  }
886
889
 
890
+ // src/bars.ts
891
+ var BAR_UNIT = 4;
892
+ function barsHtml(bars, height = 22) {
893
+ if (!bars || bars.length === 0) return "";
894
+ const width = bars.length * BAR_UNIT;
895
+ const max = Math.max(1, ...bars.map((b) => b.value ?? 0));
896
+ const lastWithData = bars.map((b) => b.value !== null).lastIndexOf(true);
897
+ const rects = bars.map((b, i) => {
898
+ const x = i * BAR_UNIT;
899
+ const w = BAR_UNIT - 1;
900
+ const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
901
+ if (b.value === null) {
902
+ return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
903
+ }
904
+ const h = Math.max(1, b.value / max * (height - 5));
905
+ const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
906
+ return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
907
+ });
908
+ const first = esc(bars[0].key), last = esc(bars[bars.length - 1].key);
909
+ return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
910
+ }
911
+ function markKey(root, key) {
912
+ for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
913
+ if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
914
+ else r.removeAttribute("data-hl");
915
+ }
916
+ }
917
+ function installBars(ctx) {
918
+ const set = (key) => {
919
+ if (ctx.hoverKey === key) return;
920
+ ctx.hoverKey = key;
921
+ markKey(ctx.els.world, key);
922
+ ctx.onHoverKey?.(key);
923
+ };
924
+ on(ctx, ctx.els.world, "mouseover", (e) => {
925
+ const bar = e.target?.closest?.("rect[data-key]");
926
+ set(bar ? bar.getAttribute("data-key") : null);
927
+ });
928
+ on(ctx, ctx.els.world, "mouseleave", () => set(null));
929
+ }
930
+
887
931
  // src/render.ts
888
932
  function loopRoute(ctx, fromId, toId, from, to, channel) {
889
933
  const detourFor = (end, other) => {
@@ -920,7 +964,7 @@ function nodeHtml(ctx, n) {
920
964
  const arrow = n.kind === "chip" ? '<span aria-hidden="true">\u2191 </span>' : "";
921
965
  const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> \xB7 ` : "";
922
966
  const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">\u25B8 ${n.contains.length}</span>` : "";
923
- return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
967
+ return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div><div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
924
968
  }
925
969
  function build(ctx) {
926
970
  const { model, els } = ctx;
@@ -955,7 +999,7 @@ function build(ctx) {
955
999
  for (const n of model.nodes) {
956
1000
  const p = ctx.geom.nodes[n.id];
957
1001
  if (!p) continue;
958
- const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
1002
+ const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
959
1003
  d.id = "n_" + n.id;
960
1004
  d.tabIndex = 0;
961
1005
  d.setAttribute("role", "button");
@@ -1090,6 +1134,7 @@ function paint(ctx, fromSwitch = false) {
1090
1134
  d.style.height = r.h + "px";
1091
1135
  }
1092
1136
  }
1137
+ markKey(ctx.els.world, ctx.hoverKey);
1093
1138
  ctx.fn.buildChrome();
1094
1139
  ctx.fn.trace(ctx.selected);
1095
1140
  ctx.fn.renderInspector();
@@ -1983,7 +2028,7 @@ function install7(ctx) {
1983
2028
  let t = 0, idx = -1, playing = false, frame = 0, timer = 0, stamp = 0, choicesKey = "";
1984
2029
  let markedEdges = [];
1985
2030
  let markedNodes = [];
1986
- const token = sv("circle", { id: "token", class: "token", r: "6" });
2031
+ const token = sv("circle", { id: "token", class: "token", r: "4" });
1987
2032
  e.layer.appendChild(token);
1988
2033
  const pathOf = (link) => ctx.edgeEls[ctx.links.findIndex((l) => l.id === link)];
1989
2034
  const nodeOf = (id) => ctx.nodeEls.get(id);
@@ -2310,6 +2355,8 @@ function mount(root, model, opts = {}) {
2310
2355
  root,
2311
2356
  els,
2312
2357
  onSelect: opts.onSelect,
2358
+ onHoverKey: opts.onHoverKey,
2359
+ hoverKey: null,
2313
2360
  notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
2314
2361
  nodes: new Map(model.nodes.map((n) => [n.id, n])),
2315
2362
  links: model.links,
@@ -2352,6 +2399,7 @@ function mount(root, model, opts = {}) {
2352
2399
  style.textContent = stateCss(model.states);
2353
2400
  install(ctx);
2354
2401
  install2(ctx);
2402
+ installBars(ctx);
2355
2403
  install3(ctx);
2356
2404
  install4(ctx);
2357
2405
  install5(ctx);
@@ -2398,6 +2446,10 @@ function mount(root, model, opts = {}) {
2398
2446
  ctx.fn.fit(animate);
2399
2447
  },
2400
2448
  select: (id) => ctx.fn.select(id),
2449
+ highlightKey: (key) => {
2450
+ ctx.hoverKey = key;
2451
+ markKey(ctx.els.world, key);
2452
+ },
2401
2453
  selected: () => ctx.selected,
2402
2454
  mode: () => ctx.mode,
2403
2455
  setMode: (id) => ctx.fn.setMode(id),
@@ -2423,7 +2475,7 @@ function mount(root, model, opts = {}) {
2423
2475
  const changes = diffModels(ctx.model, prepared);
2424
2476
  const saved = { view: { ...ctx.view }, mode: ctx.mode, selected: ctx.selected, inspectorOpen: els.inspector.classList.contains("open"), query: ctx.query, hops: ctx.hops, filter: ctx.filter, collapsed: new Set(ctx.collapsed), knownGroups: new Set(ctx.model.nodes.filter((n) => n.kind === "group").map((n) => n.id)), playback: playback.playback() };
2425
2477
  api.destroy();
2426
- const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
2478
+ const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
2427
2479
  notifySelection(saved.selected, replacement.selected(), opts.onSelect);
2428
2480
  Object.assign(api, replacement);
2429
2481
  window.FlowStudio = api;
@@ -2448,7 +2500,7 @@ function mount(root, model, opts = {}) {
2448
2500
  }
2449
2501
 
2450
2502
  // src/lib.ts
2451
- var SHELL_BODY_HTML = "<header class=\"top\">\r\n <span class=\"brand\"><i></i>flow-studio</span>\r\n <span class=\"spacer\"></span>\r\n <button id=\"notesBtn\" type=\"button\" aria-pressed=\"false\">Implementation notes (N)</button>\r\n <button id=\"theme\" type=\"button\" aria-label=\"Toggle light and dark\">☀ / ☾</button>\r\n</header>\r\n<main>\r\n <div class=\"titlerow\"><h1 id=\"title\"></h1><span class=\"readline\" id=\"readat\"></span></div>\r\n <p class=\"help\" id=\"help\"></p>\r\n <div id=\"banner\" role=\"alert\" hidden></div>\r\n\r\n <section class=\"summary\"><div class=\"layerstrip\" id=\"layerstrip\" aria-label=\"Completeness by column\"></div><div class=\"tiles\" id=\"kpis\" aria-label=\"Summary\"></div></section>\r\n\r\n <div class=\"toolbar\">\r\n <div class=\"seggrp\" role=\"tablist\" aria-label=\"View\" id=\"modeSwitch\"></div>\r\n <input id=\"q\" type=\"search\" placeholder=\"Find a group or member… ( / )\" aria-label=\"Find a group or member\">\r\n <select id=\"hops\" aria-label=\"Search radius\"><option value=\"0\">This group</option><option value=\"1\">+1 hop</option><option value=\"2\">+2 hops</option><option value=\"3\">+3 hops</option></select>\r\n <span id=\"matchinfo\" class=\"note\" style=\"margin:0\"></span>\r\n </div>\r\n\r\n <div class=\"workspace\" id=\"workspace\">\r\n <section class=\"canvaswrap\">\r\n <div id=\"viewport\" tabindex=\"0\" role=\"application\" aria-label=\"Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.\">\r\n <div id=\"world\"><svg id=\"edges\" aria-hidden=\"true\"><defs><marker id=\"arr\" viewBox=\"0 0 8 8\" refX=\"7\" refY=\"4\" markerWidth=\"9\" markerHeight=\"9\" markerUnits=\"userSpaceOnUse\" orient=\"auto\"><path d=\"M0 0 L8 4 L0 8 z\" fill=\"context-stroke\"/></marker></defs><g id=\"edgeLayer\"></g></svg></div>\r\n <div id=\"cols\" aria-hidden=\"true\"></div>\r\n <div id=\"tools\" role=\"toolbar\" aria-label=\"Canvas controls\" aria-orientation=\"vertical\"></div>\r\n <div id=\"zoomLabel\" aria-hidden=\"true\">100%</div>\r\n </div>\r\n <div id=\"playbar\" hidden aria-label=\"Scenario playback\">\r\n <select id=\"pbScenario\" aria-label=\"Scenario\"></select>\r\n <button id=\"pbPlay\" type=\"button\" aria-label=\"Play\">▶</button>\r\n <button id=\"pbStep\" type=\"button\" aria-label=\"Step\">Step</button>\r\n <button id=\"pbReset\" type=\"button\" aria-label=\"Reset\">Reset</button>\r\n <select id=\"pbSpeed\" aria-label=\"Speed\"><option value=\"0.5\">0.5x</option><option value=\"1\" selected>1x</option><option value=\"2\">2x</option></select>\r\n <input id=\"pbScrub\" type=\"range\" min=\"0\" max=\"1000\" value=\"0\" aria-label=\"Playback position\" aria-valuetext=\"Step 1 of 1\">\r\n <span id=\"pbCaption\"></span><span id=\"pbCounter\"></span><span id=\"pbChoices\"></span>\r\n </div>\r\n <div class=\"keyline\" id=\"legend\" aria-label=\"Legend\"></div>\r\n <aside class=\"insp\" id=\"inspector\" aria-label=\"Inspector\" aria-hidden=\"true\"></aside>\r\n </section>\r\n </div>\r\n <div id=\"live\" class=\"sr\" role=\"status\" aria-live=\"polite\"></div>\r\n\r\n <div class=\"ledger\" id=\"ledger\"></div>\r\n <details class=\"tableview\"><summary>Table view</summary><table class=\"sources\" id=\"tbl\"></table></details>\r\n <details class=\"spec\" id=\"spec\"><summary>Model reference (for the person or agent who builds this)</summary><div id=\"specbody\"></div></details>\r\n</main>\r\n<footer id=\"footer\"></footer>\r\n<aside id=\"drawer\" aria-label=\"Implementation notes\"><h2>Implementation notes</h2><p class=\"note\">Numbered markers on the page match these cards. Hover a card to find its place; click a marker to jump to its card.</p><div id=\"notelist\"></div></aside>\r\n\r\n";
2503
+ var SHELL_BODY_HTML = "<header class=\"top\">\r\n <span class=\"brand\"><i></i>flow-studio</span>\r\n <span class=\"spacer\"></span>\r\n <button id=\"notesBtn\" type=\"button\" aria-pressed=\"false\">Implementation notes (N)</button>\r\n <button id=\"theme\" type=\"button\" aria-label=\"Toggle light and dark\">☀ / ☾</button>\r\n</header>\r\n<main>\r\n <div class=\"titlerow\"><h1 id=\"title\"></h1><span class=\"readline\" id=\"readat\"></span></div>\r\n <p class=\"help\" id=\"help\"></p>\r\n <div id=\"banner\" role=\"alert\" hidden></div>\r\n\r\n <section class=\"summary\"><div class=\"layerstrip\" id=\"layerstrip\" aria-label=\"Completeness by column\"></div><div class=\"tiles\" id=\"kpis\" aria-label=\"Summary\"></div></section>\r\n\r\n <div class=\"toolbar\">\r\n <div class=\"seggrp\" role=\"tablist\" aria-label=\"View\" id=\"modeSwitch\"></div>\r\n <input id=\"q\" type=\"search\" placeholder=\"Find a group or member… ( / )\" aria-label=\"Find a group or member\">\r\n <select id=\"hops\" aria-label=\"Search radius\"><option value=\"0\">This group</option><option value=\"1\">+1 hop</option><option value=\"2\">+2 hops</option><option value=\"3\">+3 hops</option></select>\r\n <span id=\"matchinfo\" class=\"note\" style=\"margin:0\"></span>\r\n </div>\r\n\r\n <div class=\"workspace\" id=\"workspace\">\r\n <section class=\"canvaswrap\">\r\n <div id=\"viewport\" tabindex=\"0\" role=\"application\" aria-label=\"Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.\">\r\n <div id=\"world\"><svg id=\"edges\" aria-hidden=\"true\"><defs><marker id=\"arr\" viewBox=\"0 0 8 8\" refX=\"7\" refY=\"4\" markerWidth=\"6\" markerHeight=\"6\" markerUnits=\"userSpaceOnUse\" orient=\"auto\"><path d=\"M0 0 L8 4 L0 8 z\" fill=\"context-stroke\"/></marker></defs><g id=\"edgeLayer\"></g></svg></div>\r\n <div id=\"cols\" aria-hidden=\"true\"></div>\r\n <div id=\"tools\" role=\"toolbar\" aria-label=\"Canvas controls\" aria-orientation=\"vertical\"></div>\r\n <div id=\"zoomLabel\" aria-hidden=\"true\">100%</div>\r\n </div>\r\n <div id=\"playbar\" hidden aria-label=\"Scenario playback\">\r\n <select id=\"pbScenario\" aria-label=\"Scenario\"></select>\r\n <button id=\"pbPlay\" type=\"button\" aria-label=\"Play\">▶</button>\r\n <button id=\"pbStep\" type=\"button\" aria-label=\"Step\">Step</button>\r\n <button id=\"pbReset\" type=\"button\" aria-label=\"Reset\">Reset</button>\r\n <select id=\"pbSpeed\" aria-label=\"Speed\"><option value=\"0.5\">0.5x</option><option value=\"1\" selected>1x</option><option value=\"2\">2x</option></select>\r\n <input id=\"pbScrub\" type=\"range\" min=\"0\" max=\"1000\" value=\"0\" aria-label=\"Playback position\" aria-valuetext=\"Step 1 of 1\">\r\n <span id=\"pbCaption\"></span><span id=\"pbCounter\"></span><span id=\"pbChoices\"></span>\r\n </div>\r\n <div class=\"keyline\" id=\"legend\" aria-label=\"Legend\"></div>\r\n <aside class=\"insp\" id=\"inspector\" aria-label=\"Inspector\" aria-hidden=\"true\"></aside>\r\n </section>\r\n </div>\r\n <div id=\"live\" class=\"sr\" role=\"status\" aria-live=\"polite\"></div>\r\n\r\n <div class=\"ledger\" id=\"ledger\"></div>\r\n <details class=\"tableview\"><summary>Table view</summary><table class=\"sources\" id=\"tbl\"></table></details>\r\n <details class=\"spec\" id=\"spec\"><summary>Model reference (for the person or agent who builds this)</summary><div id=\"specbody\"></div></details>\r\n</main>\r\n<footer id=\"footer\"></footer>\r\n<aside id=\"drawer\" aria-label=\"Implementation notes\"><h2>Implementation notes</h2><p class=\"note\">Numbered markers on the page match these cards. Hover a card to find its place; click a marker to jump to its card.</p><div id=\"notelist\"></div></aside>\r\n\r\n";
2452
2504
  export {
2453
2505
  K_MAX,
2454
2506
  K_MIN,
@@ -21,7 +21,7 @@
21
21
  <div class="workspace" id="workspace">
22
22
  <section class="canvaswrap">
23
23
  <div id="viewport" tabindex="0" role="application" aria-label="Flow canvas. Scroll to zoom, drag to pan. Keys: V select, H hand, plus and minus zoom, 0 fit, 1 actual size, F zoom to selection, arrows pan.">
24
- <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="9" markerHeight="9" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
24
+ <div id="world"><svg id="edges" aria-hidden="true"><defs><marker id="arr" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="6" markerHeight="6" markerUnits="userSpaceOnUse" orient="auto"><path d="M0 0 L8 4 L0 8 z" fill="context-stroke"/></marker></defs><g id="edgeLayer"></g></svg></div>
25
25
  <div id="cols" aria-hidden="true"></div>
26
26
  <div id="tools" role="toolbar" aria-label="Canvas controls" aria-orientation="vertical"></div>
27
27
  <div id="zoomLabel" aria-hidden="true">100%</div>
@@ -70,7 +70,7 @@ In either route, derive node and link IDs from immutable business keys. Preserve
70
70
  8. **Short titles**: titles fit one line at column width. Long descriptions belong in subtitles or member lists.
71
71
  9. **Fixed node dimensions**: node kinds use fixed box sizes so font rendering does not shift layout.
72
72
  10. **Reduced motion**: removes all animations while preserving static borders and status glows.
73
- 11. **Thin links, dotted canvas**: links are hairlines (about 1.1 px) and the canvas background is faint dots, not a grid. Both come from tokens (`--edge-w`, `--dot`, ...), so an app can change them with one CSS line; see `references/design-language.md`.
73
+ 11. **Thin links, dotted canvas**: links are hairlines (about 0.8 px, with a 6 px arrowhead) and the canvas background is faint dots, not a grid. Both come from tokens (`--edge-w`, `--dot`, ...), so an app can change them with one CSS line; see `references/design-language.md`.
74
74
 
75
75
  ## Model summary
76
76
 
@@ -80,7 +80,7 @@ columns [{id, title, short?, width, phase?, continues?}] stages, le
80
80
  lanes [{id, title, badge?, badgeTone?, note?, gap?}] rows; gap true = dashed amber lane
81
81
  states {id: {word, tone, border, edge, motion, bucket}} tone: ok|warn|crit|info|muted|accent; bucket: good|neutral|gap
82
82
  nodes [{id, kind, lane?, col?, colSpan?, row?, x?, y?, w?, h?, state, title, subtitle?, members?, weight?,
83
- ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?}]
83
+ ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?, bars?}]
84
84
  links [{id, from, to, state, kind?, label?, stateIn?, visibleIn?}] kind: forward | loop-back
85
85
  views [{id, label, default?}] first view serves as baseline
86
86
  inspector {relations [up to 3], actions [{id, label, dir, staleText}]}
@@ -75,7 +75,10 @@
75
75
  "sla": { "type": "number", "exclusiveMinimum": 0, "description": "Target service level agreement in seconds" },
76
76
  "metrics": { "type": "object", "required": ["value"], "additionalProperties": false,
77
77
  "properties": { "value": { "type": "number" }, "max": { "type": ["number", "null"], "description": "Upper bound for the badge fill bar; null means there is no maximum" }, "unit": { "type": "string" }, "label": { "type": "string" },
78
- "series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } }
78
+ "series": { "type": "array", "maxItems": 60, "items": { "type": "number" } } } },
79
+ "bars": { "type": "array", "maxItems": 120, "description": "A small bar chart on the node, one bar per key. A null value is a missing key (a red tick, never zero). Hovering a bar marks the same key on every chart.",
80
+ "items": { "type": "object", "required": ["key", "value"], "additionalProperties": false,
81
+ "properties": { "key": { "type": "string" }, "value": { "type": ["number", "null"], "minimum": 0 }, "kind": { "enum": ["low"] }, "label": { "type": "string" } } } }
79
82
  }
80
83
  }
81
84
  },
@@ -7,15 +7,17 @@ const lifecycle = vi.hoisted(() => ({
7
7
  alive: 0, updates: [] as Model[], mounts: 0, listenerCount: 0,
8
8
  notes: [] as (NoteDef[] | undefined)[],
9
9
  onSelect: undefined as ((id: string | null) => void) | undefined,
10
+ onHoverKey: undefined as ((key: string | null) => void) | undefined,
10
11
  }));
11
12
  vi.mock('../templates/flow-studio.lib.mjs', () => ({
12
13
  SHELL_BODY_HTML: '<div id="viewport"></div>',
13
- mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void }) => {
14
+ mount: (_root: ParentNode, _model: Model, options: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; onHoverKey?: (key: string | null) => void }) => {
14
15
  lifecycle.mounts++;
15
16
  lifecycle.alive++;
16
17
  lifecycle.listenerCount++;
17
18
  lifecycle.notes.push(options.notes);
18
19
  lifecycle.onSelect = options.onSelect;
20
+ lifecycle.onHoverKey = options.onHoverKey;
19
21
  return {
20
22
  update: (model: Model) => { lifecycle.updates.push(model); return { ok: true, issues: [] }; },
21
23
  destroy: () => { lifecycle.alive--; lifecycle.listenerCount--; },
@@ -56,6 +58,21 @@ describe('FlowStudioView', () => {
56
58
  expect(globals.flowStudioMount).toBe(previousMount);
57
59
  });
58
60
 
61
+ it('forwards the hovered bar key to the latest onHoverKey, without remounting', () => {
62
+ lifecycle.mounts = 0;
63
+ const first = vi.fn();
64
+ const second = vi.fn();
65
+ const rendered = render(<FlowStudioView model={model} onHoverKey={first} />);
66
+ lifecycle.onHoverKey?.('2026-09-02');
67
+ expect(first).toHaveBeenCalledWith('2026-09-02');
68
+ rendered.rerender(<FlowStudioView model={model} onHoverKey={second} />);
69
+ expect(lifecycle.mounts).toBe(1);
70
+ lifecycle.onHoverKey?.(null);
71
+ expect(second).toHaveBeenCalledWith(null);
72
+ expect(first).toHaveBeenCalledTimes(1);
73
+ rendered.unmount();
74
+ });
75
+
59
76
  it('passes notes at mount and ignores later notes-only changes', () => {
60
77
  lifecycle.updates = [];
61
78
  lifecycle.notes = [];
@@ -7,6 +7,8 @@ export interface FlowStudioViewProps {
7
7
  /** Read once at mount; later changes to this prop are ignored. For notes that change with the data, put them on `model.notes` — `update` forwards them. */
8
8
  notes?: NoteDef[];
9
9
  onSelect?: (id: string | null) => void;
10
+ /** The bar key under the pointer (a node's `bars`), or null when it leaves. The latest function is always called. */
11
+ onHoverKey?: (key: string | null) => void;
10
12
  className?: string;
11
13
  }
12
14
 
@@ -17,16 +19,18 @@ let mountedViews = 0;
17
19
  let previousGlobalMount: typeof mount | undefined;
18
20
 
19
21
  /** Mounts the finished engine in React. The shell stays outside React's child tree. */
20
- export function FlowStudioView({ model, notes, onSelect, className }: FlowStudioViewProps) {
22
+ export function FlowStudioView({ model, notes, onSelect, onHoverKey, className }: FlowStudioViewProps) {
21
23
  const root = useRef<HTMLDivElement>(null);
22
24
  const api = useRef<FlowStudio | null>(null);
23
25
  const currentModel = useRef(model);
24
26
  const currentNotes = useRef(notes);
25
27
  const currentSelect = useRef(onSelect);
28
+ const currentHoverKey = useRef(onHoverKey);
26
29
  const lastModel = useRef<string | null>(null);
27
30
  currentModel.current = model;
28
31
  currentNotes.current = notes;
29
32
  currentSelect.current = onSelect;
33
+ currentHoverKey.current = onHoverKey;
30
34
  const modelJson = JSON.stringify(model);
31
35
 
32
36
  useEffect(() => {
@@ -36,6 +40,7 @@ export function FlowStudioView({ model, notes, onSelect, className }: FlowStudio
36
40
  const instance = mount(container, currentModel.current, {
37
41
  notes: currentNotes.current,
38
42
  onSelect: id => currentSelect.current?.(id),
43
+ onHoverKey: key => currentHoverKey.current?.(key),
39
44
  });
40
45
  if (mountedViews === 0) {
41
46
  previousGlobalMount = window.flowStudioMount;
@@ -22,7 +22,7 @@ function FlowPage({ state, onRetry, onSelect }: { state: State; onRetry: () => v
22
22
  }
23
23
  ```
24
24
 
25
- `FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
25
+ `FlowStudioView` takes a required `model` and optional `notes`, `onSelect`, `onHoverKey`, and `className`. `notes` is read once at mount; later changes to the prop are ignored. For notes that change with the data, put them on the model (`model.notes`) — `update` forwards them. `onSelect` receives a stable node id or `null` when selection clears. `onHoverKey` receives the key of the bar under the pointer (see `bars` on a node) or `null`; call `window.FlowStudio?.highlightKey(key)` to mark a key from outside. The component mounts the engine once, sends changed models to `update`, and destroys its listeners on unmount. React StrictMode's development mount cycle is supported. Several views may be mounted at once; the engine's global mount helper stays available until the last one unmounts.
26
26
 
27
27
  ## Live data contract
28
28
 
@@ -28,11 +28,11 @@ Tokens are defined on `:root` and adapted for dark mode via `@media (prefers-col
28
28
  | `--dot` | `rgba(31,35,40,.18)` | `rgba(255,255,255,.16)` | Colour of the canvas background dots |
29
29
  | `--dot-size` | `1.2px` | Same | Radius of a background dot |
30
30
  | `--dot-gap` | `24px` | Same | Spacing of the background dots |
31
- | `--edge-w` | `1.1px` | Same | Width of a normal link (thin, like a hairline; motion carries the state) |
32
- | `--edge-w-hot` | `1.5px` | Same | Width of a traced or selected link |
33
- | `--edge-w-play` | `1.6px` | Same | Width of a link while playback runs along it |
34
- | `--trail-w` | `1.8px` | Same | Width of the draw-in trail on a selected path (divided by the zoom) |
35
- | `--ripple-w` | `2px` | Same | Width of the what-if ripple (divided by the zoom) |
31
+ | `--edge-w` | `0.8px` | Same | Width of a normal link (thin, like a hairline; motion carries the state) |
32
+ | `--edge-w-hot` | `1px` | Same | Width of a traced or selected link |
33
+ | `--edge-w-play` | `1.2px` | Same | Width of a link while playback runs along it |
34
+ | `--trail-w` | `1.3px` | Same | Width of the draw-in trail on a selected path (divided by the zoom) |
35
+ | `--ripple-w` | `1.5px` | Same | Width of the what-if ripple (divided by the zoom) |
36
36
  | `--ok-bg` | `rgba(26,127,55,.10)` | `rgba(63,185,80,.12)` | Subtle tinted background for healthy states |
37
37
  | `--warn-bg` | `rgba(154,103,0,.10)` | `rgba(210,153,34,.14)` | Subtle tinted background for warning states |
38
38
  | `--crit-bg` | `rgba(207,34,46,.08)` | `rgba(248,81,73,.12)` | Subtle tinted background for critical states |
@@ -112,6 +112,7 @@ Nodes represent processing units, cards, gateways, groups, and milestones.
112
112
  | `collapsed` | `boolean` | Optional | `true` | When true, group starts collapsed hiding child nodes. |
113
113
  | `sla` | `number` | Optional | - | Target service level agreement threshold in seconds. |
114
114
  | `metrics` | `object` | Optional | - | Telemetry payload (see metrics table). |
115
+ | `bars` | `array` | Optional | - | A small bar chart on the node, one bar per `key` (see bars table). |
115
116
 
116
117
  ---
117
118
 
@@ -129,6 +130,21 @@ Provides numerical telemetry, health bars, and sparklines.
129
130
 
130
131
  ---
131
132
 
133
+ ### `bars` items (under `nodes`)
134
+
135
+ A bar chart drawn inside the node, for example rows per day. Hovering a bar marks the bars with the same `key` on
136
+ every node, so a reader can ask "was this day bad everywhere?". The host page can follow the hover with the
137
+ `onHoverKey` mount option and drive it with `highlightKey(key)`. At most 120 bars.
138
+
139
+ | Property | Type | Required? | Default | Description |
140
+ |---|---|---|---|---|
141
+ | `key` | `string` | Required | - | Opaque id of the bar (a date or a step). Equal keys are linked across charts. |
142
+ | `value` | `number` or `null` | Required | - | Bar size (finite, not negative). `null` means no data for that key: a short red tick, never a zero bar. |
143
+ | `kind` | `string` | Optional | - | `"low"` draws the bar in the critical colour. The last bar with data is drawn in the accent colour, unless it is `low`. |
144
+ | `label` | `string` | Optional | - | Tooltip text; defaults to `key: value`. |
145
+
146
+ ---
147
+
132
148
  ## 8. `links` array items
133
149
 
134
150
  Links represent directional data transit, dependencies, or workflow transitions.
@@ -22,14 +22,23 @@ test("dots are set by tokens: size, spacing and a colour for light and dark", ()
22
22
  });
23
23
 
24
24
  test("links are thin by default and every width comes from a token", () => {
25
- assert.match(css, /--edge-w:\s*1\.1px/);
25
+ assert.match(css, /--edge-w:\s*0\.8px/);
26
26
  assert.match(css, /path\.edge \{ fill:none; stroke-width:var\(--edge-w\)/);
27
27
  assert.match(css, /path\.edge\.hot \{[^}]*stroke-width:var\(--edge-w-hot\)/);
28
28
  assert.match(css, /path\.trail \{[^}]*stroke-width:calc\(var\(--trail-w\)/);
29
29
  assert.match(css, /path\.ripple \{[^}]*stroke-width:calc\(var\(--ripple-w\)/);
30
- // Thin means at most 1.5 px for a plain link and 2 px for the highlighted one.
31
- const w = (name) => Number(css.match(new RegExp(`${name}:\s*([0-9.]+)px`))[1]);
32
- assert.ok(w("--edge-w") <= 1.5 && w("--edge-w-hot") <= 2 && w("--ripple-w") <= 2.5 && w("--trail-w") <= 2.5);
30
+ // Thin means a hairline: at most 1 px for a plain link, 1.2 px for a highlighted or playing one.
31
+ const w = (name) => Number(css.match(new RegExp(`${name}: *([0-9.]+)px`))[1]);
32
+ assert.ok(w("--edge-w") <= 1 && w("--edge-w-hot") <= 1.2 && w("--edge-w-play") <= 1.2 && w("--ripple-w") <= 1.5 && w("--trail-w") <= 1.5);
33
+ });
34
+
35
+ test("the arrowhead is small: at most 7 px, and the playback dot at most 5 px in radius", () => {
36
+ const shell = readFileSync(join(root, "templates/shell-body.html"), "utf8");
37
+ const marker = shell.match(/<marker id="arr"[^>]*markerWidth="(\d+)" markerHeight="(\d+)"/);
38
+ assert.ok(marker, "the arrow marker exists");
39
+ assert.ok(Number(marker[1]) <= 7 && Number(marker[2]) <= 7, `arrowhead is ${marker[1]} x ${marker[2]}`);
40
+ const playback = readFileSync(join(root, "src/playbackui.ts"), "utf8");
41
+ assert.ok(Number(playback.match(/class: "token", r: "(\d+(?:\.\d+)?)"/)[1]) <= 5);
33
42
  });
34
43
 
35
44
  test("forced-colours mode drops the dot pattern", () => {