@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
@@ -0,0 +1,90 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { barsHtml, markKey } from "./bars.ts";
4
+ import { validateModel, type Model, type NodeBar } from "./model.ts";
5
+
6
+ const bars: NodeBar[] = [
7
+ { key: "2026-09-01", value: 10 },
8
+ { key: "2026-09-02", value: null },
9
+ { key: "2026-09-03", value: 4, kind: "low" },
10
+ { key: "2026-09-04", value: 20 },
11
+ ];
12
+
13
+ test("no bars, no chart", () => {
14
+ assert.equal(barsHtml(undefined), "");
15
+ assert.equal(barsHtml([]), "");
16
+ });
17
+
18
+ test("one rect per bar, each carrying its key; a missing day is a short red tick, never a zero bar", () => {
19
+ const html = barsHtml(bars);
20
+ assert.equal((html.match(/<rect /g) ?? []).length, 4);
21
+ for (const b of bars) assert.match(html, new RegExp(`data-key="${b.key}"`));
22
+ assert.match(html, /<rect class="miss" data-key="2026-09-02"/);
23
+ assert.match(html, /<rect class="low" data-key="2026-09-03"/);
24
+ // The last bar with data is the accent one.
25
+ assert.match(html, /<rect class="last" data-key="2026-09-04"/);
26
+ });
27
+
28
+ test("bar height follows the value against the largest, and the largest fills the chart", () => {
29
+ const html = barsHtml([{ key: "a", value: 5 }, { key: "b", value: 10 }], 20);
30
+ const heights = [...html.matchAll(/height="([0-9.]+)"/g)].map(m => Number(m[1])).filter(h => h < 20);
31
+ assert.equal(heights.length, 2);
32
+ assert.ok(Math.abs(heights[1]! - 15) < 0.01, "the largest bar is height - 5");
33
+ assert.ok(Math.abs(heights[0]! - 7.5) < 0.01, "half the value is half the height");
34
+ });
35
+
36
+ test("keys and labels are escaped, so a hostile key cannot become markup", () => {
37
+ const html = barsHtml([{ key: '"><script>x</script>', value: 1, label: "<b>" }]);
38
+ assert.doesNotMatch(html, /<script>/);
39
+ assert.doesNotMatch(html, /<b>/);
40
+ assert.match(html, /&lt;b&gt;/);
41
+ });
42
+
43
+ test("the chart is described for a screen reader", () => {
44
+ assert.match(barsHtml(bars), /role="img" aria-label="[^"]+"/);
45
+ });
46
+
47
+ interface FakeRect { key: string; attrs: Map<string, string>; getAttribute(n: string): string | null; setAttribute(n: string, v: string): void; removeAttribute(n: string): void }
48
+ const fakeRect = (key: string): FakeRect => {
49
+ const attrs = new Map<string, string>([["data-key", key]]);
50
+ return { key, attrs, getAttribute: n => attrs.get(n) ?? null, setAttribute: (n, v) => void attrs.set(n, v), removeAttribute: n => void attrs.delete(n) };
51
+ };
52
+
53
+ test("markKey marks every bar with that key, on every chart, and clears the others", () => {
54
+ const rects = [fakeRect("a"), fakeRect("b"), fakeRect("a")];
55
+ const root = { querySelectorAll: () => rects } as unknown as ParentNode;
56
+ markKey(root, "a");
57
+ assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), ["true", undefined, "true"]);
58
+ markKey(root, "b");
59
+ assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), [undefined, "true", undefined]);
60
+ markKey(root, null);
61
+ assert.deepEqual(rects.map(r => r.attrs.get("data-hl")), [undefined, undefined, undefined]);
62
+ });
63
+
64
+ const base = (): Model => ({
65
+ version: 1,
66
+ meta: { title: "T", help: "h", asOf: "2026-01-01", source: "s" },
67
+ layout: "columns-lanes",
68
+ columns: [{ id: "c0", title: "A", width: 150 }],
69
+ lanes: [{ id: "l1", title: "Lane 1" }],
70
+ states: { ok: { word: "OK", tone: "ok", border: "solid", edge: "flow", motion: "flow", bucket: "good" } },
71
+ nodes: [{ id: "a", kind: "card", lane: "l1", col: 0, row: 0, state: "ok", title: "A", allowIsolated: true, bars }],
72
+ links: [],
73
+ });
74
+ const codes = (m: unknown) => validateModel(m).filter(i => i.level === "error").map(i => i.code);
75
+
76
+ test("a node may carry bars", () => assert.deepEqual(codes(base()), []));
77
+ test("bars must be a list", () => {
78
+ const m = base(); (m.nodes[0] as unknown as { bars: unknown }).bars = "nope";
79
+ assert.ok(codes(m).includes("bars-invalid"));
80
+ });
81
+ test("each bar needs a string key and a finite, non-negative value or null", () => {
82
+ for (const bad of [{ key: 1, value: 1 }, { key: "k", value: -1 }, { key: "k", value: Number.NaN }, { key: "k", value: "3" }, null]) {
83
+ const m = base(); (m.nodes[0] as unknown as { bars: unknown[] }).bars = [bad];
84
+ assert.ok(codes(m).includes("bars-invalid"), JSON.stringify(bad));
85
+ }
86
+ });
87
+ test("at most 120 bars", () => {
88
+ const m = base(); m.nodes[0]!.bars = Array.from({ length: 121 }, (_, i) => ({ key: String(i), value: 1 }));
89
+ assert.ok(codes(m).includes("bars-invalid"));
90
+ });
@@ -0,0 +1,53 @@
1
+ // Small bar charts on a node, one bar per key (a day, a step). Hovering a bar marks the same key on
2
+ // every chart on the canvas, so a reader can ask "was this day bad everywhere?". The key is opaque:
3
+ // the model decides what it means. Pure string rendering plus one tiny DOM helper, no layout maths.
4
+
5
+ import type { Ctx } from "./ctx.ts";
6
+ import { on } from "./ctx.ts";
7
+ import { esc, type NodeBar } from "./model.ts";
8
+
9
+ const BAR_UNIT = 4; // viewBox units per bar; the chart is stretched to the node's width by CSS
10
+
11
+ /** The chart as an SVG string, or "" when the node has no bars. A null value is a missing key: a red tick, never a zero bar. */
12
+ export function barsHtml(bars: NodeBar[] | undefined, height = 22): string {
13
+ if (!bars || bars.length === 0) return "";
14
+ const width = bars.length * BAR_UNIT;
15
+ const max = Math.max(1, ...bars.map(b => b.value ?? 0));
16
+ const lastWithData = bars.map(b => b.value !== null).lastIndexOf(true);
17
+ const rects = bars.map((b, i) => {
18
+ const x = i * BAR_UNIT;
19
+ const w = BAR_UNIT - 1;
20
+ const label = esc(b.label ?? (b.value === null ? `${b.key}: no data` : `${b.key}: ${b.value}`));
21
+ if (b.value === null) {
22
+ return `<rect class="miss" data-key="${esc(b.key)}" x="${x}" y="${height - 4}" width="${w}" height="4"><title>${label}</title></rect>`;
23
+ }
24
+ const h = Math.max(1, (b.value / max) * (height - 5));
25
+ const cls = b.kind === "low" ? "low" : i === lastWithData ? "last" : "";
26
+ return `<rect${cls ? ` class="${cls}"` : ""} data-key="${esc(b.key)}" x="${x}" y="${height - h}" width="${w}" height="${h}"><title>${label}</title></rect>`;
27
+ });
28
+ const first = esc(bars[0]!.key), last = esc(bars[bars.length - 1]!.key);
29
+ return `<svg class="bars" viewBox="0 0 ${width} ${height}" preserveAspectRatio="none" role="img" aria-label="Bars, ${first} to ${last}">${rects.join("")}</svg>`;
30
+ }
31
+
32
+ /** Mark every bar with this key, on every chart under root, and clear the rest. null clears all. */
33
+ export function markKey(root: ParentNode, key: string | null): void {
34
+ for (const r of Array.from(root.querySelectorAll("rect[data-key]"))) {
35
+ if (key !== null && r.getAttribute("data-key") === key) r.setAttribute("data-hl", "true");
36
+ else r.removeAttribute("data-hl");
37
+ }
38
+ }
39
+
40
+ /** Hovering a bar marks its key everywhere and tells the host page; leaving the canvas clears it. */
41
+ export function installBars(ctx: Ctx): void {
42
+ const set = (key: string | null): void => {
43
+ if (ctx.hoverKey === key) return;
44
+ ctx.hoverKey = key;
45
+ markKey(ctx.els.world, key);
46
+ ctx.onHoverKey?.(key);
47
+ };
48
+ on(ctx, ctx.els.world, "mouseover", (e: MouseEvent) => {
49
+ const bar = (e.target as Element | null)?.closest?.("rect[data-key]");
50
+ set(bar ? bar.getAttribute("data-key") : null);
51
+ });
52
+ on(ctx, ctx.els.world, "mouseleave", () => set(null));
53
+ }
@@ -92,6 +92,9 @@ export interface Ctx {
92
92
  collapsed: Set<string>;
93
93
  selected: string | null;
94
94
  hl: string | null;
95
+ /** The bar key under the pointer (see bars.ts), or null. */
96
+ hoverKey: string | null;
97
+ onHoverKey?: (key: string | null) => void;
95
98
  query: string;
96
99
  hops: number;
97
100
  filter: Filter;
@@ -17,6 +17,7 @@ import { install as installNotes } from "./notes.ts";
17
17
  import { install as installPlayback, type PlaybackController } from "./playbackui.ts";
18
18
  import { diffModels } from "./diff.ts";
19
19
  import { notifySelection } from "./selection.ts";
20
+ import { installBars, markKey } from "./bars.ts";
20
21
  import type { Issue } from "./model.ts";
21
22
 
22
23
  export interface FlowStudio {
@@ -26,6 +27,8 @@ export interface FlowStudio {
26
27
  setView(v: View, animate?: boolean): void;
27
28
  fit(animate?: boolean): void;
28
29
  select(id: string | null): void;
30
+ /** Mark one bar key on every chart, as if the pointer were over it. Does not call onHoverKey. */
31
+ highlightKey(key: string | null): void;
29
32
  selected(): string | null;
30
33
  mode(): string;
31
34
  setMode(id: string): void;
@@ -65,7 +68,7 @@ function findEls(root: ParentNode): Els {
65
68
  }
66
69
 
67
70
  interface SavedState { view: View; mode: string; selected: string | null; inspectorOpen: boolean; query: string; hops: number; filter: Ctx["filter"]; collapsed: Set<string>; knownGroups: Set<string>; playback: ReturnType<PlaybackController["playback"]> }
68
- export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; restore?: SavedState } = {}): FlowStudio {
71
+ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[]; onSelect?: (id: string | null) => void; onHoverKey?: (key: string | null) => void; restore?: SavedState } = {}): FlowStudio {
69
72
  if (!model || !Array.isArray(model.columns) || !Array.isArray(model.lanes) || !Array.isArray(model.nodes) || !Array.isArray(model.links) || !model.states) {
70
73
  throw new Error("flow-studio: the model needs columns, lanes, states, nodes and links");
71
74
  }
@@ -78,7 +81,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
78
81
 
79
82
  const rm = matchMedia("(prefers-reduced-motion: reduce)");
80
83
  const ctx: Ctx = {
81
- model, root, els, onSelect: opts.onSelect,
84
+ model, root, els, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, hoverKey: null,
82
85
  notes: opts.notes && opts.notes.length ? opts.notes : model.notes ?? [],
83
86
  nodes: new Map(model.nodes.map(n => [n.id, n])),
84
87
  links: model.links,
@@ -103,6 +106,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
103
106
 
104
107
  installInteract(ctx);
105
108
  installRender(ctx);
109
+ installBars(ctx);
106
110
  installControls(ctx);
107
111
  installInspector(ctx);
108
112
  installChrome(ctx);
@@ -145,6 +149,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
145
149
  setView: (v, animate) => ctx.fn.setView(v, animate),
146
150
  fit: animate => { ctx.userMoved = false; ctx.fn.fit(animate); },
147
151
  select: id => ctx.fn.select(id),
152
+ highlightKey: key => { ctx.hoverKey = key; markKey(ctx.els.world, key); },
148
153
  selected: () => ctx.selected,
149
154
  mode: () => ctx.mode,
150
155
  setMode: id => ctx.fn.setMode(id),
@@ -167,7 +172,7 @@ export function mount(root: ParentNode, model: Model, opts: { notes?: NoteDef[];
167
172
  const changes = diffModels(ctx.model, prepared);
168
173
  const saved: SavedState = { 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() };
169
174
  api.destroy();
170
- const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, restore: saved });
175
+ const replacement = mount(root, prepared, { notes: opts.notes, onSelect: opts.onSelect, onHoverKey: opts.onHoverKey, restore: saved });
171
176
  notifySelection(saved.selected, replacement.selected(), opts.onSelect);
172
177
  // Refresh this api in place: callers keep their reference and window.FlowStudio stays this
173
178
  // instance, so an update can never overwrite a different mounted engine.
@@ -142,3 +142,20 @@ test("free layout preserves finite negative node coordinates", () => {
142
142
  assert.equal(g.nodes.a!.y, -40);
143
143
  assert.ok([g.bounds.x, g.bounds.y, g.bounds.w, g.bounds.h, g.world.w, g.world.h].every(Number.isFinite));
144
144
  });
145
+
146
+ test("a node with bars is taller by default, so the chart does not sit on its text; an explicit height wins", () => {
147
+ const model = m();
148
+ model.nodes[0]!.bars = [{ key: "a", value: 1 }];
149
+ const g = computeGeometry(model);
150
+ assert.equal(g.nodes.a!.h, 54 + 26);
151
+ assert.equal(g.nodes.b!.h, 54);
152
+ const free = m();
153
+ free.layout = "free";
154
+ free.nodes = [
155
+ { id: "x", kind: "card", lane: "l1", x: 0, y: 0, state: "ok", title: "X", bars: [{ key: "a", value: 1 }] },
156
+ { id: "y", kind: "card", lane: "l1", x: 0, y: 200, h: 100, state: "ok", title: "Y", bars: [{ key: "a", value: 1 }] },
157
+ ];
158
+ const gf = computeGeometry(free);
159
+ assert.equal(gf.nodes.x!.h, 54 + 26);
160
+ assert.equal(gf.nodes.y!.h, 100);
161
+ });
@@ -17,6 +17,7 @@ export interface LayoutOptions { pitch: number; top: number; gap: number; padX:
17
17
 
18
18
  export const DEFAULTS: LayoutOptions = { pitch: 62, top: 64, gap: 34, padX: 24, visible: () => true };
19
19
 
20
+ const BARS_EXTRA = 26; // room for the bar chart under a node's text
20
21
  const LANE_PAD_TOP = 38; // room for the lane title
21
22
  const LANE_PAD_BOTTOM = 14;
22
23
 
@@ -29,7 +30,7 @@ export function computeGeometry(m: Model, opt: Partial<LayoutOptions> = {}): Geo
29
30
 
30
31
  const nodes: Record<string, Placed> = {};
31
32
  for (const n of m.nodes) {
32
- const h = NODE_HEIGHT[n.kind] ?? 54;
33
+ const h = (NODE_HEIGHT[n.kind] ?? 54) + (n.bars?.length ? BARS_EXTRA : 0);
33
34
  if (m.layout === "free") {
34
35
  nodes[n.id] = { id: n.id, x: n.x ?? 0, y: n.y ?? 0, w: n.w ?? 190, h: n.h ?? h };
35
36
  continue;
@@ -43,6 +43,7 @@ export interface NodeDef {
43
43
  collapsed?: boolean;
44
44
  sla?: number;
45
45
  metrics?: NodeMetric;
46
+ bars?: NodeBar[];
46
47
  }
47
48
  export interface NodeMetric {
48
49
  value: number;
@@ -52,6 +53,14 @@ export interface NodeMetric {
52
53
  label?: string;
53
54
  series?: number[];
54
55
  }
56
+ /** One bar of a node's chart. `key` is opaque (a date, a step) and links the same key across charts;
57
+ * a null `value` means no data for that key and is drawn as a short red tick, never as zero. */
58
+ export interface NodeBar {
59
+ key: string;
60
+ value: number | null;
61
+ kind?: "low";
62
+ label?: string;
63
+ }
55
64
  export interface LinkDef {
56
65
  id: string;
57
66
  from: string;
@@ -153,6 +162,8 @@ export function validateModel(input: unknown): Issue[] {
153
162
  const metric = n.metrics;
154
163
  const invalidSeries = metric?.series !== undefined && (!Array.isArray(metric.series) || metric.series.length > 60 || metric.series.some(v => !Number.isFinite(v)));
155
164
  if (metric !== undefined && (!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.");
165
+ const badBars = n.bars !== undefined && (!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))));
166
+ 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.");
156
167
  if (n.sla !== undefined && (!Number.isFinite(n.sla) || n.sla <= 0)) add("error", "sla-invalid", w, "SLA must be a positive finite number of seconds.");
157
168
  }
158
169
  for (const n of nodes) {
@@ -23,7 +23,7 @@ export function install(ctx: Ctx): PlaybackController {
23
23
  // Only the elements marked in the last draw are cleared again, so a frame does not walk the whole graph.
24
24
  let markedEdges: SVGPathElement[] = [];
25
25
  let markedNodes: HTMLElement[] = [];
26
- const token = sv("circle", { id: "token", class: "token", r: "6" }) as SVGCircleElement;
26
+ const token = sv("circle", { id: "token", class: "token", r: "4" }) as SVGCircleElement;
27
27
  e.layer.appendChild(token);
28
28
  const pathOf = (link: string): SVGPathElement | undefined => ctx.edgeEls[ctx.links.findIndex(l => l.id === link)];
29
29
  const nodeOf = (id: string) => ctx.nodeEls.get(id);
@@ -9,6 +9,7 @@ import { elbow, loopBack } from "./routing.ts";
9
9
  import { cssId } from "./stateCss.ts";
10
10
  import { baselineView, bucketOf, canonOf, effectiveLinks, isImproved, isNew, linkStateId, nodeStateId, nodeVisible } from "./rules.ts";
11
11
  import { metricBadge, sparkline } from "./metrics.ts";
12
+ import { barsHtml, markKey } from "./bars.ts";
12
13
 
13
14
  function loopRoute(ctx: Ctx, fromId: string, toId: string, from: Placed, to: Placed, channel: number): string {
14
15
  const detourFor = (end: Placed, other: Placed) => {
@@ -45,7 +46,7 @@ function nodeHtml(ctx: Ctx, n: NodeDef): string {
45
46
  const lead = showWord ? `<b class="ev st-${cssId(sid)}">${esc(word)}</b> · ` : "";
46
47
  const fold = n.kind === "group" && n.contains?.length ? `<span class="fold">▸ ${n.contains.length}</span>` : "";
47
48
  return `<div class="t"><span class="newdot" title="Added in this view"></span><span class="name">${arrow}${esc(n.title)}</span>${fold}${badge}</div>`
48
- + `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${bar}${improved}${upgrade}`;
49
+ + `<div class="s${spark ? " has-spark" : ""}" title="${esc(sub)}">${lead}${esc(sub)}</div>${spark}${barsHtml(n.bars)}${bar}${improved}${upgrade}`;
49
50
  }
50
51
 
51
52
  function build(ctx: Ctx): void {
@@ -75,7 +76,7 @@ function build(ctx: Ctx): void {
75
76
  for (const n of model.nodes) {
76
77
  const p = ctx.geom.nodes[n.id];
77
78
  if (!p) continue;
78
- const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : ""));
79
+ const d = el("div", "node" + (n.kind === "chip" ? " ref" : "") + ((n.colSpan ?? 1) > 1 ? " wide" : "") + (n.bars?.length ? " has-bars" : ""));
79
80
  d.id = "n_" + n.id;
80
81
  d.tabIndex = 0;
81
82
  d.setAttribute("role", "button");
@@ -190,6 +191,7 @@ function paint(ctx: Ctx, fromSwitch = false): void {
190
191
  if (r) { d.style.left = r.x + "px"; d.style.top = r.y + "px"; d.style.width = r.w + "px"; d.style.height = r.h + "px"; }
191
192
  }
192
193
 
194
+ markKey(ctx.els.world, ctx.hoverKey);
193
195
  ctx.fn.buildChrome();
194
196
  ctx.fn.trace(ctx.selected);
195
197
  ctx.fn.renderInspector();
@@ -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; }
@@ -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; }