@caddis/cli 0.4.49 → 0.4.51

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 (48) hide show
  1. package/bundles/antigravity-plugin/plugin.json +1 -1
  2. package/bundles/antigravity-plugin/scripts/caddis_routing.py +4 -0
  3. package/bundles/antigravity-plugin/session_end_agy.py +36 -2
  4. package/bundles/antigravity-plugin/skills/flow-studio/SKILL.md +4 -2
  5. package/bundles/antigravity-plugin/skills/flow-studio/model.schema.json +4 -1
  6. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.test.tsx +18 -1
  7. package/bundles/antigravity-plugin/skills/flow-studio/react/FlowStudioView.tsx +6 -1
  8. package/bundles/antigravity-plugin/skills/flow-studio/react/README.md +1 -1
  9. package/bundles/antigravity-plugin/skills/flow-studio/references/model-schema.md +16 -0
  10. package/bundles/antigravity-plugin/skills/flow-studio/references/patterns.md +1 -1
  11. package/bundles/antigravity-plugin/skills/flow-studio/references/ux-rules.md +20 -2
  12. package/bundles/antigravity-plugin/skills/flow-studio/src/bars.test.ts +90 -0
  13. package/bundles/antigravity-plugin/skills/flow-studio/src/bars.ts +53 -0
  14. package/bundles/antigravity-plugin/skills/flow-studio/src/ctx.ts +3 -0
  15. package/bundles/antigravity-plugin/skills/flow-studio/src/engine.ts +8 -3
  16. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.test.ts +17 -0
  17. package/bundles/antigravity-plugin/skills/flow-studio/src/layout.ts +2 -1
  18. package/bundles/antigravity-plugin/skills/flow-studio/src/model.ts +11 -0
  19. package/bundles/antigravity-plugin/skills/flow-studio/src/render.ts +4 -2
  20. package/bundles/antigravity-plugin/skills/flow-studio/src/styles.css +5 -0
  21. package/bundles/antigravity-plugin/skills/flow-studio/templates/engine.html +61 -4
  22. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.css +5 -0
  23. package/bundles/antigravity-plugin/skills/flow-studio/templates/flow-studio.lib.mjs +56 -4
  24. package/bundles/antigravity-plugin-extras/plugin.json +1 -1
  25. package/bundles/antigravity-plugin-extras/skills/agent-orchestration/SKILL.md +38 -0
  26. package/bundles/codex/.codex/skills/frontend/flow-studio/SKILL.md +4 -2
  27. package/bundles/codex/.codex/skills/frontend/flow-studio/model.schema.json +4 -1
  28. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.test.tsx +18 -1
  29. package/bundles/codex/.codex/skills/frontend/flow-studio/react/FlowStudioView.tsx +6 -1
  30. package/bundles/codex/.codex/skills/frontend/flow-studio/react/README.md +1 -1
  31. package/bundles/codex/.codex/skills/frontend/flow-studio/references/model-schema.md +16 -0
  32. package/bundles/codex/.codex/skills/frontend/flow-studio/references/patterns.md +1 -1
  33. package/bundles/codex/.codex/skills/frontend/flow-studio/references/ux-rules.md +20 -2
  34. package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.test.ts +90 -0
  35. package/bundles/codex/.codex/skills/frontend/flow-studio/src/bars.ts +53 -0
  36. package/bundles/codex/.codex/skills/frontend/flow-studio/src/ctx.ts +3 -0
  37. package/bundles/codex/.codex/skills/frontend/flow-studio/src/engine.ts +8 -3
  38. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.test.ts +17 -0
  39. package/bundles/codex/.codex/skills/frontend/flow-studio/src/layout.ts +2 -1
  40. package/bundles/codex/.codex/skills/frontend/flow-studio/src/model.ts +11 -0
  41. package/bundles/codex/.codex/skills/frontend/flow-studio/src/render.ts +4 -2
  42. package/bundles/codex/.codex/skills/frontend/flow-studio/src/styles.css +5 -0
  43. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/engine.html +61 -4
  44. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.css +5 -0
  45. package/bundles/codex/.codex/skills/frontend/flow-studio/templates/flow-studio.lib.mjs +56 -4
  46. package/bundles/manifest.json +3 -3
  47. package/dist/cli.js +94 -1
  48. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "name": "caddis",
3
- "version": "1.3.135",
3
+ "version": "1.3.137",
4
4
  "description": "caddis — agent-agnostic dev harness as an Antigravity (agy) plugin: TDD skills + commands, single-sourced with the Claude Code plugin."
5
5
  }
@@ -14,6 +14,10 @@ from typing import Callable, Mapping
14
14
  import oss_model
15
15
 
16
16
 
17
+ # agy is Flash-only on some machines and is good only at small, exact, mechanical cards (112 of 114 passed
18
+ # in the 2026-10-08 UI-upgrade run) and CI watching. Give design-fidelity, cross-file refactor and judgment
19
+ # work to codex or claude. See .caddis/kb/agy-headless-implementer-limits.md. A separate `design` role is an
20
+ # owner decision: it changes the routing line every session prints.
17
21
  DEFAULT_ROUTING: dict[str, list[str]] = {
18
22
  "critical": ["opus"],
19
23
  "light": ["sonnet"],
@@ -19,6 +19,7 @@ agy Stop stdin (camelCase protojson):
19
19
  """
20
20
  import json
21
21
  import os
22
+ import subprocess
22
23
  import sys
23
24
  from datetime import datetime, timezone
24
25
 
@@ -27,6 +28,30 @@ def _artifact_root(root: str) -> str:
27
28
  return os.path.join(str(root), ".caddis")
28
29
 
29
30
 
31
+ def _repo_root(path: str):
32
+ """The git top-level folder that holds `path`, or None when `path` is not inside a repository.
33
+
34
+ agy can run with its working folder in a SUB-folder of the repo (a monorepo app). The artifact
35
+ folder belongs at the repo root, where the project's ignore rule (`/.caddis/`) covers it. Creating
36
+ it in the sub-folder left untracked files that every lane had to delete before staging
37
+ (UI-upgrade run, 2026-10-08).
38
+ """
39
+ try:
40
+ done = subprocess.run(
41
+ ["git", "-C", str(path), "rev-parse", "--show-toplevel"],
42
+ capture_output=True, text=True, timeout=5,
43
+ )
44
+ top = done.stdout.strip()
45
+ return os.path.normpath(top) if done.returncode == 0 and top else None
46
+ except Exception:
47
+ return None
48
+
49
+
50
+ def _user_artifact_root() -> str:
51
+ """Where usage goes when the workspace is in no repository: the user's own folder, never the cwd."""
52
+ return _artifact_root(os.path.expanduser("~"))
53
+
54
+
30
55
 
31
56
  def _hook_note(feature, exc, root=None):
32
57
  """Record a swallowed failure in the caddis hook ledger. Never raises, never prints.
@@ -82,13 +107,22 @@ def main() -> None:
82
107
  return
83
108
  try:
84
109
  roots = _workspace_roots(data) or [os.getcwd()]
110
+ # A workspace folder may sit inside a repo: the artifact dir is the REPO ROOT's, not the sub-folder's.
111
+ resolved = [(_repo_root(r) or str(r)) for r in roots]
85
112
  # Write the record to every root that ALREADY has a .caddis dir, rather than to
86
113
  # whichever root happened to be first. A multi-root session's usage belongs to each
87
114
  # repo that participates in it, and creating .caddis in a repo that never opted in
88
115
  # would be caddis littering someone else's tree.
89
- targets = [_artifact_root(r) for r in roots if os.path.isdir(_artifact_root(r))]
116
+ targets = []
117
+ for root in resolved:
118
+ art = _artifact_root(root)
119
+ if os.path.isdir(art) and art not in targets:
120
+ targets.append(art)
90
121
  if not targets:
91
- targets = [_artifact_root(roots[0])] # single-root default: unchanged behaviour
122
+ # Nothing has opted in. In a repo, the first root's repo root is the default. Outside any
123
+ # repo there is no project to attach usage to, so it goes to the user's folder, not the cwd.
124
+ first_repo = _repo_root(roots[0])
125
+ targets = [_artifact_root(first_repo) if first_repo else _user_artifact_root()]
92
126
  record = {
93
127
  "ts": datetime.now(timezone.utc).isoformat(),
94
128
  "event": "session_end",
@@ -70,7 +70,9 @@ 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 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`.
73
+ 11. **The lane is the owner**: a lane is the app, team or system that owns its row. Never add a column that repeats the owner (an "Apps" or "Owner" column); it adds a block between the real stages and the pages and says nothing new.
74
+ 12. **Draw a shared node once**: when several rows use the same database, job or table, draw one node, centre it on the rows it serves, and link it to each node downstream. A chip (`kind: "chip"` with `ref`) is only for a node that lives in another lane, never a copy inside the same lane.
75
+ 13. **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
76
 
75
77
  ## Model summary
76
78
 
@@ -80,7 +82,7 @@ columns [{id, title, short?, width, phase?, continues?}] stages, le
80
82
  lanes [{id, title, badge?, badgeTone?, note?, gap?}] rows; gap true = dashed amber lane
81
83
  states {id: {word, tone, border, edge, motion, bucket}} tone: ok|warn|crit|info|muted|accent; bucket: good|neutral|gap
82
84
  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?}]
85
+ ref?, upgrade?, contains?, collapsed?, stateIn?, visibleIn?, sla?, metrics?, bars?}]
84
86
  links [{id, from, to, state, kind?, label?, stateIn?, visibleIn?}] kind: forward | loop-back
85
87
  views [{id, label, default?}] first view serves as baseline
86
88
  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
 
@@ -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.
@@ -21,7 +21,7 @@ You can start from these base states and adjust words to match your domain:
21
21
 
22
22
  - **Example file:** `examples/lineage.json`
23
23
  - **Scenario:** Trace data assets from ingestion sources through storage, pipelines, transformations, to consuming dashboards and applications.
24
- - **Typical columns:** Source, Host, Database, Pipeline, Tables, Refresher, Views, App, Dashboard.
24
+ - **Typical columns:** Source, Host, Database, Pipeline, Tables, Refresher, Views, Dashboard. Do not add an App or Owner column: the lane is the app (ux-rules 11).
25
25
  - **Typical lanes:** Customer Portal, Order Service, Data Warehouse, Reporting Platform. A lane without documentation uses `gap: true`.
26
26
  - **Node count guideline:** 15 to 30 nodes.
27
27
  - **Recommended inspector relations:**
@@ -1,8 +1,8 @@
1
1
  # UX and design rules
2
2
 
3
- The 10 rules ensure flow-studio pages remain readable, informative, accessible, and fast.
3
+ The 12 rules ensure flow-studio pages remain readable, informative, accessible, and fast.
4
4
 
5
- ## The 10 rules
5
+ ## The 12 rules
6
6
 
7
7
  ### 1. One screen
8
8
  - **Rule statement:** The full flow fits on one screen without requiring panning in the initial default view.
@@ -66,6 +66,20 @@ The 10 rules ensure flow-studio pages remain readable, informative, accessible,
66
66
 
67
67
  ---
68
68
 
69
+ ### 11. The lane is the owner
70
+ - **Rule statement:** A lane names the app, team or system that owns its row. No column repeats the owner.
71
+ - **Rationale:** An "Apps" column between the real stages and the pages adds a block that says nothing new. In the estate lineage it sat between the reporting tables and the pages, and the owner asked why it was there (2026-10-09).
72
+ - **Pre-flight checklist question:** Does any column hold one node per lane that only repeats the lane title?
73
+ - **Common mistake & fix:** Adding an "App" column because the pattern list showed one. *Fix:* Remove the column. Link the stage that feeds a page straight to the page; a page that reads no table stands alone.
74
+
75
+ ### 12. Draw a shared node once
76
+ - **Rule statement:** A database, job or table that several rows share is one node, centred on the rows it serves, with a link to each node downstream. A chip is only for a node that lives in another lane.
77
+ - **Rationale:** Copying a shared node into every row hides that it is shared and makes the reader compare copies. In the app lineage page, a database repeated in every source row looked like several databases (2026-10-09).
78
+ - **Pre-flight checklist question:** Does any node id appear as a chip inside the lane that already holds the real node?
79
+ - **Common mistake & fix:** One row per source, each with its own copy of the host, database and job. *Fix:* Draw each distinct node once, place it at the mean position of its rows, move it down if it would touch its neighbour, and de-duplicate links by their two ends.
80
+
81
+ ---
82
+
69
83
  ## Pre-flight checklist for agents
70
84
 
71
85
  Before presenting a generated flow page, verify:
@@ -77,6 +91,8 @@ Before presenting a generated flow page, verify:
77
91
  6. **Accessible names:** Does every button, input, tab, and control have an `aria-label` or visible text?
78
92
  7. **Keyboard navigation:** Can a user Tab through controls and press Escape to close the inspector?
79
93
  8. **Responsive breakdown:** Does the layout degrade cleanly down to 820x900?
94
+ 9. **No owner column:** Does any column only repeat the lane's owner? Remove it (rule 11).
95
+ 10. **Shared nodes once:** Is every shared database, job or table drawn once, not copied per row (rule 12)?
80
96
 
81
97
  ---
82
98
 
@@ -88,4 +104,6 @@ Before presenting a generated flow page, verify:
88
104
  - **Text spill:** Node labels exceed card width. *Fix:* Use short titles (under 24 characters) and set `subtitle` for auxiliary metadata.
89
105
  - **Permanent side panel:** The inspector remains open on load. *Fix:* The inspector must start closed and open only on selection.
90
106
  - **Flat visual hierarchy:** Every card looks identical. *Fix:* Vary card kinds (`card`, `group`, `gateway`, `chip`) and state tones (`ok`, `warn`, `crit`, `accent`).
107
+ - **Owner column:** A column of one node per lane that repeats the lane title. *Fix:* Remove it; the lane is the owner.
108
+ - **Copies of a shared node:** The same database or job drawn in every row. *Fix:* Draw it once and link it to each node downstream.
91
109
  - **Unlabelled controls:** Icon buttons without text lack screen reader descriptions. *Fix:* Provide descriptive `aria-label` attributes on all buttons.
@@ -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) {
@@ -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();
@@ -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; }