@ashley-shrok/viewmodel-shell 4.0.0 → 4.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agent-skill.md CHANGED
@@ -135,12 +135,39 @@ The wire does not mandate an auth shape. If the app needs credentials, the app p
135
135
 
136
136
  If a response carries `nextPollIn: N`, schedule a POST `{ "name": "poll", "state": <last state> }` against the same `actionEndpoint` after `N` milliseconds. The server may continue returning `nextPollIn` until the workflow reaches a terminal state, at which point the field will be absent. Polls run silently — they are not user-initiated.
137
137
 
138
+ ## Non-blocking actions (`blocking:false`)
139
+
140
+ Some node action descriptors in the `vm` tree (a `CheckboxNode.action`, `ButtonNode.action`, a `TableRow.action`, etc.) may carry `"blocking": false` alongside the action's `name`. This is a CLIENT-SIDE scheduling hint for the browser `ViewModelShell` instance: it selects a non-blocking dispatch lane that coexists with an in-flight blocking action instead of queuing behind it, and coalesces rapid repeated triggers of the same action to "latest wins."
141
+
142
+ It never appears in the `_action` POST payload you send. The request body shape stays `{"name": "<action-name>"}` (JSON form) or `{"name":"<action-name>"}` (multipart `_action` field) regardless of whether the descriptor you read it from said `blocking:true` (the default, typically omitted) or `blocking:false`.
143
+
144
+ **For you (a wire-driving agent with no client-side dispatch loop): `blocking` is INFORMATIONAL ONLY. Dispatch the action exactly the same way regardless of its value** — POST `_action`/`_state` (or the JSON form) as normal and read the response per the existing rules in this manual (`ok`, `rejected`, `errors[]`). You do not need to implement coalescing, an epoch, or any dispatch-lane concept to drive the wire correctly.
145
+
146
+ This connects to the polling section above: the `{"name": "poll"}` dispatch this manual already documents is itself an instance of a non-blocking action — a poll always rides the non-blocking lane client-side — so nothing about how you send a poll dispatch changes either.
147
+
138
148
  ## Files
139
149
 
140
150
  File uploads use the multipart form above. One form entry per file input, keyed by the input's `name` attribute (from the corresponding node's `name` field in the tree). The file's binary content is the entry's value. JSON-body dispatch cannot carry files; use multipart.
141
151
 
142
152
  **A file rides only the action(s) its input declares.** Each file `FieldNode` carries an `uploadOn` array of action names. Send a file's binary entry **only** when the action you are dispatching (`_action.name`) is listed in that file input's `uploadOn`; if you dispatch any other action, do **not** include the file. A file input with no `uploadOn` (absent or empty) rides **nothing** — its binary is never sent. This mirrors the browser, where the same declaration decides which click sends the file: an agent should not attach a file to an action a human's click could not have sent it with. (There is no positional/implicit rule — the file's own `uploadOn` is the whole contract.)
143
153
 
154
+ ## Chart data (`type:"chart"`)
155
+
156
+ A `ChartNode` in the `vm` tree carries bounded, agent-legible declared data — it is read-only structured data like any other node, with no dispatch-bearing fields of its own:
157
+
158
+ ```json
159
+ { "type": "chart", "kind": "bar", "title": "Weekly visits",
160
+ "points": [ { "label": "Mon", "value": 12 }, { "label": "Tue", "value": 19 } ],
161
+ "tone": "info" }
162
+ ```
163
+
164
+ - `kind` — optional. Omitted means `"bar"`, the only value in `viewmodel-shell/1.0`.
165
+ - `points` — an array of self-contained `{label, value}` pairs. No parallel-array index alignment is needed (unlike some other frameworks' chart data shapes) — read each point directly.
166
+ - `title` — optional chart title.
167
+ - `tone` — optional (`danger|warning|success|info`). This only affects rendered COLOR in a browser client. A wire-driving agent with no renderer can simply read `points`/`title` directly and ignore `tone`.
168
+
169
+ There is nothing else to do to "drive" a chart over the wire.
170
+
144
171
  ## Versioning
145
172
 
146
173
  This manual applies to protocol token `viewmodel-shell/1.0` — the value of the `protocol` field on the discoverability meta tag. The protocol token tracks the wire shape, NOT the package version: a 1.5.x or 1.6.x package release may still carry protocol `viewmodel-shell/1.0` because the wire has not undergone a breaking change. A future major-version bump (`viewmodel-shell/2.0`) signals a breaking change and invalidates this manual; expect a new skill at the same `/.well-known/vms-skill.md` URL.
package/dist/browser.d.ts CHANGED
@@ -7,6 +7,9 @@ export declare class BrowserAdapter implements Adapter {
7
7
  private detailsOpenSnapshot;
8
8
  private sectionKeyCounter;
9
9
  private fitsObservers;
10
+ private chartInstances;
11
+ private chartKeyCounter;
12
+ private chartKeysSeen;
10
13
  constructor(container: HTMLElement);
11
14
  render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
12
15
  navigate(url: string): void;
@@ -68,6 +71,41 @@ export declare class BrowserAdapter implements Adapter {
68
71
  * preservation covers server-driven re-renders, not this resize-switch path.
69
72
  */
70
73
  private fits;
74
+ /**
75
+ * ChartNode (CHART-01/02/03/04) — a single-series BAR chart drawn by Chart.js,
76
+ * loaded as a PRIVATE, LAZY, OPTIONAL adapter dependency: the dynamic
77
+ * `import("chart.js")` in loadChart() is reached ONLY when a ChartNode renders,
78
+ * and it registers ONLY the bar pieces so the bundle tree-shakes (an app that
79
+ * renders no chart loads zero chart.js bytes; the core + .NET/bun backends gain
80
+ * no dependency). tone→theme-token color (CHART-02) is read via getComputedStyle
81
+ * — NO raw CSS crosses the wire.
82
+ *
83
+ * The canvas + Chart instance are keyed by a stable per-render ordinal and kept
84
+ * in `chartInstances` ACROSS renders, so a re-render with changed data reuses
85
+ * the SAME canvas (detached, not destroyed, by render()'s innerHTML wipe — its
86
+ * 2D context + bitmap survive) and redraws IN PLACE via `.update()` (CHART-03)
87
+ * rather than reconstructing. render() mark-sweeps + destroy()s any instance the
88
+ * new tree dropped (leak prevention). getComputedStyle / canvas / Chart.js live
89
+ * ONLY here in browser.ts — the core (index.ts) stays platform-agnostic.
90
+ */
91
+ private chart;
92
+ /**
93
+ * Lazily import chart.js and construct the Chart for `key`. The dynamic import
94
+ * is what keeps chart.js zero-bytes-when-absent; registering ONLY the bar
95
+ * controller/elements/scales/tooltip is what keeps CHART-04 small (tree-shaken).
96
+ * Fire-and-forget from chart() (`void this.loadChart(...)`), so a missing
97
+ * dependency is surfaced through the fail-loud seam (chartFailLoud), NEVER a
98
+ * floating unhandled rejection.
99
+ */
100
+ private loadChart;
101
+ /**
102
+ * Fail-loud for a missing chart.js — routed through the SAME sanctioned seam as
103
+ * the other no-safe-default capabilities (AGENTS.md fail-loud rule). The
104
+ * BrowserAdapter holds no ShellOptions.onError reference, so it uses the
105
+ * AGENTS.md-sanctioned fallback (console.error). NEVER a silent no-op, NEVER a
106
+ * floating unhandled rejection — deterministic + spy-able in tests.
107
+ */
108
+ private chartFailLoud;
71
109
  private section;
72
110
  private list;
73
111
  private listItem;
package/dist/browser.js CHANGED
@@ -68,6 +68,24 @@ export class BrowserAdapter {
68
68
  // never leak when the tree is rebuilt — the same per-render reset idiom as
69
69
  // detailsOpenSnapshot / sectionKeyCounter above.
70
70
  fitsObservers = [];
71
+ // Phase 12 (CHART-01/03) — live Chart.js instances keyed by a stable per-render
72
+ // ordinal chart key. DELIBERATELY PERSISTENT across renders (NOT reset like the
73
+ // per-render fields below): the canvas + Chart instance must SURVIVE render()'s
74
+ // innerHTML wipe so a re-render with changed data redraws IN PLACE via
75
+ // .update() instead of re-constructing. `chart` is `any` (the module is
76
+ // dynamically imported, so there's no compile-time Chart type dependency);
77
+ // `latest` stashes the newest config while an async load is in flight so a fast
78
+ // second render still applies once the import resolves. Instances are
79
+ // mark-swept (destroy()'d + deleted) in render() when the new tree drops them.
80
+ chartInstances = new Map();
81
+ // Per-render disambiguator for chart keys (title-derived or anonymous). Reset
82
+ // at the TOP of every render() (like sectionKeyCounter) so snapshot keys and
83
+ // rebuild keys compute identically across a render pass.
84
+ chartKeyCounter = new Map();
85
+ // Per-render set of every chart key rendered this pass. Reset at the TOP of
86
+ // every render(); render() mark-sweeps any chartInstances key NOT in this set
87
+ // (a ChartNode removed from the new tree → its Chart instance is destroyed).
88
+ chartKeysSeen = new Set();
71
89
  constructor(container) {
72
90
  this.container = container;
73
91
  }
@@ -123,8 +141,25 @@ export class BrowserAdapter {
123
141
  // Same per-render reset model as the focus/scroll/details snapshots above.
124
142
  this.fitsObservers.forEach(o => o.disconnect());
125
143
  this.fitsObservers = [];
144
+ // Phase 12 (CHART-01/03) — reset the per-render chart bookkeeping (NOT
145
+ // chartInstances, which is deliberately persistent). Same per-render reset
146
+ // model as sectionKeyCounter: keys must compute identically across the
147
+ // rebuild + the post-rebuild mark-sweep below.
148
+ this.chartKeyCounter = new Map();
149
+ this.chartKeysSeen = new Set();
126
150
  this.container.innerHTML = "";
127
151
  this.node(vm, this.container, onAction);
152
+ // Phase 12 (CHART-03) — mark-sweep: destroy + drop any Chart instance whose
153
+ // key was NOT rendered this pass (a ChartNode removed from the new tree), so
154
+ // instances never leak across a long session. Swept POST-rebuild (unlike the
155
+ // fits pre-wipe disconnect) because a persisting chart's canvas must survive
156
+ // the innerHTML wipe to be reused for an in-place .update().
157
+ for (const [key, entry] of this.chartInstances) {
158
+ if (!this.chartKeysSeen.has(key)) {
159
+ entry.chart?.destroy();
160
+ this.chartInstances.delete(key);
161
+ }
162
+ }
128
163
  if (focusId) {
129
164
  const el = this.container.querySelector(`#${CSS.escape(focusId)}`);
130
165
  if (el) {
@@ -324,6 +359,7 @@ export class BrowserAdapter {
324
359
  case "fits": return this.fits(n, parent, on);
325
360
  case "empty-state": return this.emptyState(n, parent, on);
326
361
  case "badge": return this.badge(n, parent);
362
+ case "chart": return this.chart(n, parent);
327
363
  default: {
328
364
  // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
329
365
  // Runtime trees are server-controlled JSON, so an unknown/forward-version
@@ -444,6 +480,140 @@ export class BrowserAdapter {
444
480
  ro.observe(container);
445
481
  this.fitsObservers.push(ro);
446
482
  }
483
+ /**
484
+ * ChartNode (CHART-01/02/03/04) — a single-series BAR chart drawn by Chart.js,
485
+ * loaded as a PRIVATE, LAZY, OPTIONAL adapter dependency: the dynamic
486
+ * `import("chart.js")` in loadChart() is reached ONLY when a ChartNode renders,
487
+ * and it registers ONLY the bar pieces so the bundle tree-shakes (an app that
488
+ * renders no chart loads zero chart.js bytes; the core + .NET/bun backends gain
489
+ * no dependency). tone→theme-token color (CHART-02) is read via getComputedStyle
490
+ * — NO raw CSS crosses the wire.
491
+ *
492
+ * The canvas + Chart instance are keyed by a stable per-render ordinal and kept
493
+ * in `chartInstances` ACROSS renders, so a re-render with changed data reuses
494
+ * the SAME canvas (detached, not destroyed, by render()'s innerHTML wipe — its
495
+ * 2D context + bitmap survive) and redraws IN PLACE via `.update()` (CHART-03)
496
+ * rather than reconstructing. render() mark-sweeps + destroy()s any instance the
497
+ * new tree dropped (leak prevention). getComputedStyle / canvas / Chart.js live
498
+ * ONLY here in browser.ts — the core (index.ts) stays platform-agnostic.
499
+ */
500
+ chart(n, parent) {
501
+ // Stable key: title-derived base disambiguated by a per-render ordinal so
502
+ // multiple/anonymous charts get distinct keys that compute identically across
503
+ // renders (mirrors the collapsible-section key disambiguation).
504
+ const baseKey = n.title ?? "vms-chart-anon";
505
+ const ordinal = this.chartKeyCounter.get(baseKey) ?? 0;
506
+ this.chartKeyCounter.set(baseKey, ordinal + 1);
507
+ const key = `${baseKey}#${ordinal}`;
508
+ this.chartKeysSeen.add(key);
509
+ const wrapper = document.createElement("div");
510
+ wrapper.className = "vms-chart";
511
+ parent.appendChild(wrapper);
512
+ // tone → theme token, NOT `--vms-${tone}`: `danger` maps to `--vms-error`
513
+ // (matching .vms-section--danger); omitted → the neutral `--vms-accent`.
514
+ const toneToken = {
515
+ danger: "--vms-error",
516
+ warning: "--vms-warning",
517
+ success: "--vms-success",
518
+ info: "--vms-info",
519
+ };
520
+ const cs = getComputedStyle(this.container);
521
+ const token = (n.tone && toneToken[n.tone]) || "--vms-accent";
522
+ const color = cs.getPropertyValue(token).trim();
523
+ // Grid/tick/axis colors track the theme so the chart reads consistently in
524
+ // light AND dark. Chart.js's default grid is a FIXED faint-black
525
+ // (rgba(0,0,0,0.1)) — visible on a light background but ~invisible on a dark
526
+ // one — so wire the grid + axis border to `--vms-border` (subtle in every
527
+ // theme) and the tick labels to `--vms-text-muted`.
528
+ const gridColor = cs.getPropertyValue("--vms-border").trim();
529
+ const tickColor = cs.getPropertyValue("--vms-text-muted").trim();
530
+ const scaleOpts = {
531
+ grid: { color: gridColor },
532
+ border: { color: gridColor },
533
+ ticks: { color: tickColor },
534
+ };
535
+ const config = {
536
+ type: "bar",
537
+ data: {
538
+ labels: n.points.map(p => p.label),
539
+ datasets: [{
540
+ data: n.points.map(p => p.value),
541
+ backgroundColor: color,
542
+ borderColor: color,
543
+ }],
544
+ },
545
+ options: {
546
+ responsive: true,
547
+ maintainAspectRatio: false,
548
+ scales: { x: scaleOpts, y: scaleOpts },
549
+ plugins: {
550
+ title: n.title ? { display: true, text: n.title } : { display: false },
551
+ legend: { display: false }, // single series — no legend
552
+ },
553
+ },
554
+ };
555
+ const existing = this.chartInstances.get(key);
556
+ if (existing) {
557
+ // Reuse the SAME canvas element (detached by the innerHTML wipe, not
558
+ // destroyed) — its 2D context + drawn bitmap survive.
559
+ wrapper.appendChild(existing.canvas);
560
+ if (existing.chart) {
561
+ // Redraw in place (CHART-03).
562
+ existing.chart.data = config.data;
563
+ existing.chart.options = config.options;
564
+ existing.chart.update();
565
+ }
566
+ else {
567
+ // Still loading — stash the newest config to apply when the import resolves.
568
+ existing.latest = config;
569
+ }
570
+ return;
571
+ }
572
+ // First render of this key: create a fresh canvas + kick the lazy loader
573
+ // (do NOT await inside the synchronous render()).
574
+ const canvas = document.createElement("canvas");
575
+ wrapper.appendChild(canvas);
576
+ this.chartInstances.set(key, { canvas, chart: null, latest: config });
577
+ void this.loadChart(key, config);
578
+ }
579
+ /**
580
+ * Lazily import chart.js and construct the Chart for `key`. The dynamic import
581
+ * is what keeps chart.js zero-bytes-when-absent; registering ONLY the bar
582
+ * controller/elements/scales/tooltip is what keeps CHART-04 small (tree-shaken).
583
+ * Fire-and-forget from chart() (`void this.loadChart(...)`), so a missing
584
+ * dependency is surfaced through the fail-loud seam (chartFailLoud), NEVER a
585
+ * floating unhandled rejection.
586
+ */
587
+ async loadChart(key, config) {
588
+ let mod;
589
+ try {
590
+ mod = await import("chart.js");
591
+ }
592
+ catch {
593
+ this.chartFailLoud("ChartNode present but the optional peer dependency 'chart.js' is not " +
594
+ "installed. Run: npm install chart.js");
595
+ return;
596
+ }
597
+ const { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip } = mod;
598
+ // Tree-shaken registration — ONLY the bar pieces.
599
+ Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);
600
+ const entry = this.chartInstances.get(key);
601
+ // A later render may have mark-swept this key before the import resolved.
602
+ if (!entry)
603
+ return;
604
+ entry.chart = new Chart(entry.canvas, entry.latest ?? config);
605
+ entry.latest = null;
606
+ }
607
+ /**
608
+ * Fail-loud for a missing chart.js — routed through the SAME sanctioned seam as
609
+ * the other no-safe-default capabilities (AGENTS.md fail-loud rule). The
610
+ * BrowserAdapter holds no ShellOptions.onError reference, so it uses the
611
+ * AGENTS.md-sanctioned fallback (console.error). NEVER a silent no-op, NEVER a
612
+ * floating unhandled rejection — deterministic + spy-able in tests.
613
+ */
614
+ chartFailLoud(msg) {
615
+ console.error("[ViewModelShell]", new Error(msg));
616
+ }
447
617
  section(n, parent, on) {
448
618
  // 1.2.0 — collapsible:true branch emits native <details>/<summary>; the
449
619
  // open/closed state is DOM-local and preserved across re-renders by the
@@ -541,7 +711,7 @@ export class BrowserAdapter {
541
711
  // TableRow.action (1.1.0). Containment via stopPropagation on nested
542
712
  // interactive controls AFTER kids() has rendered them.
543
713
  if (n.action) {
544
- const actionName = n.action.name;
714
+ const action = n.action;
545
715
  el.tabIndex = 0;
546
716
  el.setAttribute("role", "button");
547
717
  // aria-label derivation: heading > flattened descendant text (capped) > "Card".
@@ -558,14 +728,14 @@ export class BrowserAdapter {
558
728
  ariaLabel = text.length > 0 ? text.slice(0, 200) : "Card";
559
729
  }
560
730
  el.setAttribute("aria-label", ariaLabel);
561
- el.addEventListener("click", () => { on({ name: actionName }); });
731
+ el.addEventListener("click", () => { on(action); });
562
732
  el.addEventListener("keydown", (e) => {
563
733
  if (e.key === "Enter") {
564
- on({ name: actionName });
734
+ on(action);
565
735
  }
566
736
  else if (e.key === " " || e.key === "Spacebar") {
567
737
  e.preventDefault(); // suppress page scroll
568
- on({ name: actionName });
738
+ on(action);
569
739
  }
570
740
  });
571
741
  // Containment: clicks on nested interactive controls must NOT bubble to
@@ -818,7 +988,7 @@ export class BrowserAdapter {
818
988
  this.writeBind(n.bind, sel.value);
819
989
  }
820
990
  if (n.action)
821
- on({ name: n.action.name });
991
+ on(n.action);
822
992
  });
823
993
  wrapper.appendChild(sel);
824
994
  }
@@ -949,7 +1119,7 @@ export class BrowserAdapter {
949
1119
  // dispatching, in case the browser hasn't fired `input` yet
950
1120
  // (e.g. an autofill that lands then submits).
951
1121
  this.writeBind(n.bind, inp.value);
952
- on({ name: action.name });
1122
+ on(action);
953
1123
  }
954
1124
  });
955
1125
  }
@@ -1038,7 +1208,7 @@ export class BrowserAdapter {
1038
1208
  inp.addEventListener("change", () => {
1039
1209
  this.sa.write(n.bind, inp.checked);
1040
1210
  if (n.action)
1041
- on({ name: n.action.name });
1211
+ on(n.action);
1042
1212
  });
1043
1213
  parent.appendChild(lbl);
1044
1214
  }
@@ -1135,7 +1305,7 @@ export class BrowserAdapter {
1135
1305
  btn.setAttribute("aria-selected", String(tab.value === n.selected));
1136
1306
  btn.addEventListener("click", () => {
1137
1307
  this.sa.write(n.bind, tab.value);
1138
- on({ name: tab.action.name });
1308
+ on(tab.action);
1139
1309
  });
1140
1310
  nav.appendChild(btn);
1141
1311
  });
@@ -1269,7 +1439,7 @@ export class BrowserAdapter {
1269
1439
  const cur = this.sa.read(sortBind);
1270
1440
  const nextDir = cur?.column === col.key && cur?.direction === "asc" ? "desc" : "asc";
1271
1441
  this.sa.write(sortBind, { column: col.key, direction: nextDir });
1272
- on({ name: sortAction.name });
1442
+ on(sortAction);
1273
1443
  });
1274
1444
  }
1275
1445
  headerRow.appendChild(th);
@@ -1309,7 +1479,7 @@ export class BrowserAdapter {
1309
1479
  if (e.key === "Enter") {
1310
1480
  if (bindPath != null)
1311
1481
  this.sa.write(bindPath, inp.value);
1312
- on({ name: filterAction.name });
1482
+ on(filterAction);
1313
1483
  }
1314
1484
  });
1315
1485
  th.appendChild(inp);
@@ -1335,7 +1505,7 @@ export class BrowserAdapter {
1335
1505
  // row.action — click-anywhere + keyboard + ARIA. Per-row controls and
1336
1506
  // cell linkLabel anchors stopPropagation below so they don't double-fire.
1337
1507
  if (row.action) {
1338
- const rowActionName = row.action.name;
1508
+ const rowAction = row.action;
1339
1509
  tr.tabIndex = 0;
1340
1510
  tr.setAttribute("role", "button");
1341
1511
  const labelParts = Object.values(row.cells)
@@ -1346,14 +1516,14 @@ export class BrowserAdapter {
1346
1516
  : (row.id ? `Row ${row.id}` : "");
1347
1517
  if (ariaLabel)
1348
1518
  tr.setAttribute("aria-label", ariaLabel);
1349
- tr.addEventListener("click", () => { on({ name: rowActionName }); });
1519
+ tr.addEventListener("click", () => { on(rowAction); });
1350
1520
  tr.addEventListener("keydown", (e) => {
1351
1521
  if (e.key === "Enter") {
1352
- on({ name: rowActionName });
1522
+ on(rowAction);
1353
1523
  }
1354
1524
  else if (e.key === " " || e.key === "Spacebar") {
1355
1525
  e.preventDefault(); // suppress page scroll
1356
- on({ name: rowActionName });
1526
+ on(rowAction);
1357
1527
  }
1358
1528
  });
1359
1529
  }
@@ -1436,7 +1606,7 @@ export class BrowserAdapter {
1436
1606
  b.addEventListener("click", () => {
1437
1607
  if (paginationBind != null)
1438
1608
  this.sa.write(paginationBind, targetPage);
1439
- on({ name: action.name });
1609
+ on(action);
1440
1610
  });
1441
1611
  }
1442
1612
  return b;
package/dist/index.d.ts CHANGED
@@ -1,6 +1,21 @@
1
1
  export interface ActionEvent {
2
2
  name: string;
3
3
  files?: Record<string, File>;
4
+ /**
5
+ * Phase 14 (NBA-01..04) — optional dispatch-scheduling hint, read PURELY
6
+ * client-side to pick a dispatch lane. Omitted = `true`, the framework's
7
+ * pre-Phase-14 behavior: byte-identical for every existing app. `false` =
8
+ * a non-blocking round trip that coexists with a blocking dispatch instead
9
+ * of contending for the client's single dispatch mutex (see
10
+ * `.planning/design/non-blocking-actions.md`).
11
+ *
12
+ * This field never rides inside the `_action` POST payload — the wire shape
13
+ * stays `{name}` only (the Phase 6 shape). `blocking` travels on the SAME
14
+ * ActionEvent object already embedded on a triggering node's
15
+ * `action`/`dismissAction`/`sortActions[...]`/`filterAction`/`prevAction`/
16
+ * `nextAction`/tab `action` field; the server never needs to see it.
17
+ */
18
+ blocking?: boolean;
4
19
  }
5
20
  export interface StateAccess {
6
21
  read(path: string): unknown;
@@ -88,7 +103,7 @@ export interface Adapter {
88
103
  * concept). */
89
104
  reload?(): void;
90
105
  }
91
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode;
106
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode | ChartNode;
92
107
  export interface PageNode {
93
108
  type: "page";
94
109
  title?: string;
@@ -634,6 +649,31 @@ export interface FitsNode {
634
649
  * guaranteed-fits fallback rendered when none fit. */
635
650
  children: ViewNode[];
636
651
  }
652
+ export interface ChartPoint {
653
+ /** Category label (x-axis tick). */
654
+ label: string;
655
+ /** Numeric magnitude (bar height). */
656
+ value: number;
657
+ }
658
+ export interface ChartNode {
659
+ type: "chart";
660
+ /** Chart type. CLOSED union; OMITTED = "bar". "bar" is the only value in v4.1;
661
+ * `line` is an ADDITIVE future value (CHART-LINE), NOT a new node — so
662
+ * consumers/agents key off `kind`, and a later milestone widens the union
663
+ * without a wire break. The renderer treats an absent `kind` as "bar". */
664
+ kind?: "bar";
665
+ /** Ordered category→value data. Each point is a SELF-CONTAINED {label, value}
666
+ * pair (mirroring StatItem) so an agent reads the series DIRECTLY with no
667
+ * parallel-array index alignment. Bars render in array order. */
668
+ points: ChartPoint[];
669
+ /** Optional chart title rendered above the plot. */
670
+ title?: string;
671
+ /** Optional appearance tone from the existing tone axis, mapped to the theme's
672
+ * --vms-* tone tokens (danger→--vms-error, warning→--vms-warning,
673
+ * success→--vms-success, info→--vms-info; omitted → --vms-accent). NO raw
674
+ * color/CSS crosses the wire (D3) — only the closed tone token. */
675
+ tone?: "danger" | "warning" | "success" | "info";
676
+ }
637
677
  export interface ShellOptions {
638
678
  endpoint: string;
639
679
  actionEndpoint: string;
@@ -650,7 +690,10 @@ export interface ShellOptions {
650
690
  onUploadProgress?: (sent: number, total: number) => void;
651
691
  /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
652
692
  * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
653
- * omitting nextPollIn when no pollInterval is configured. */
693
+ * omitting nextPollIn when no pollInterval is configured. NBA-05 (Phase 15): every poll dispatch
694
+ * rides the non-blocking lane (see `schedulePoll`'s doc comment) — a blocking user action fired
695
+ * while a poll round trip is in flight is never dropped or delayed by it. See
696
+ * `.planning/design/non-blocking-actions.md`. */
654
697
  pollInterval?: number;
655
698
  /** 3.8.0 — the id of the client bundle this shell instance is running (the app
656
699
  * injects it at build time, e.g. from a Vite `define`/env — VMS never derives
@@ -767,13 +810,78 @@ export declare class ViewModelShell {
767
810
  private options;
768
811
  private currentVm;
769
812
  private currentState;
770
- private dispatching;
813
+ /** Guards ONLY the blocking lane — renamed 1:1 from `dispatching`. Today's
814
+ * rapid-click-during-a-round-trip protection, byte-identical. */
815
+ private blockingInFlight;
816
+ /** Guards the non-blocking lane so at most one non-blocking round trip is
817
+ * ever in flight at once. */
818
+ private nonBlockingInFlight;
819
+ /** NBA-02 coalescing slot. Holds the LATEST non-blocking dispatch requested
820
+ * while one is already in flight; each subsequent trigger OVERWRITES it
821
+ * (never appended/queued — "latest wins"), so at most one extra round
822
+ * trip fires once the in-flight one resolves. Stores the pending trigger's
823
+ * OWN `silent` classification alongside its action (CR-01 fix, Phase 14
824
+ * gap closure) — the refire must replay with the classification the
825
+ * coalesced trigger was ORIGINALLY dispatched with, never with whichever
826
+ * invocation happens to resolve first and run the refire. Without this, a
827
+ * bare `poll` action (silent=true, no `blocking` field of its own)
828
+ * coalescing behind an in-flight `blocking:false` action would refire
829
+ * through the resolving action's `silent=false`, misrouting the poll into
830
+ * the blocking lane. See `.planning/design/non-blocking-actions.md`. */
831
+ private pendingNonBlockingRefire;
832
+ /** Monotonic counter incremented once per ACTUAL network dispatch attempt,
833
+ * shared across both lanes, assigned at the moment the request is fired
834
+ * (not at trigger/coalesce time) so it reflects real fire order. */
835
+ private dispatchSeq;
836
+ /** The highest dispatchSeq whose response has been applied (rendered) so
837
+ * far. NBA-03: a NON-BLOCKING response is applied only when its seq >=
838
+ * appliedSeq; a lower seq means a strictly newer dispatch already applied
839
+ * and this response is stale — discard it rather than clobber the newer
840
+ * render. A BLOCKING response is authoritative and always applies
841
+ * unconditionally (CR-02 fix, Phase 14 gap closure): `blockingInFlight`
842
+ * guarantees at most one blocking dispatch is ever in flight, so a
843
+ * blocking response can never be superseded by another blocking one —
844
+ * gating it against a faster-resolving, later-fired NON-blocking response
845
+ * would silently discard the user's own action with no signal. Always
846
+ * advanced via `Math.max` (never lowered) regardless of which lane
847
+ * applied. See `.planning/design/non-blocking-actions.md` — "Epoch".
848
+ *
849
+ * NBA-06 (Phase 15): the non-blocking apply gate ALSO discards a response
850
+ * whenever `pendingNonBlockingRefire !== null` at apply time, even if its
851
+ * own seq is not stale by the `seq >= appliedSeq` test above. This closes
852
+ * the rapid-double-toggle gap: toggle A fires, toggle B (the user's very
853
+ * next click on the same control) coalesces into `pendingNonBlockingRefire`
854
+ * while A is still in flight; A's response is the ONLY one outstanding, so
855
+ * it is never stale by seq alone — but it necessarily echoes state as of
856
+ * A's own send time, predating B's local write. Applying it would revert
857
+ * B's not-yet-sent value AND poison the refire (which reads `currentState`
858
+ * fresh at its own fire time). Discarding A here is safe because the
859
+ * queued refire (B) is guaranteed to fire immediately next, in the same
860
+ * `finally` block, and will itself advance `appliedSeq` when it applies.
861
+ * See `.planning/design/non-blocking-actions.md` — "Coalescing". */
862
+ private appliedSeq;
771
863
  private pollTimer;
772
864
  private serverBusy;
773
865
  private userDispatching;
774
866
  constructor(options: ShellOptions);
775
867
  private syncBusy;
776
868
  load(params?: Record<string, string>): Promise<void>;
869
+ /**
870
+ * The actual network round trip for a single dispatch: builds the
871
+ * multipart body, fires the request, parses/validates the response, and
872
+ * applies it per the lane-aware epoch rule (Phase 14 / NBA-03, refined by
873
+ * the CR-02 gap closure — see the `appliedSeq` field doc). Never throws to
874
+ * its caller — every error path is swallowed here exactly as it was in
875
+ * pre-Phase-14 `dispatch()`, so lane call sites need only a bare
876
+ * `try { await this.performRoundTrip(action, nonBlocking); } finally { ... }`
877
+ * with no `catch` of their own.
878
+ *
879
+ * @param nonBlocking whether THIS dispatch is on the non-blocking lane
880
+ * (silent=true or action.blocking===false). Determines whether the
881
+ * response is subject to the staleness-discard (non-blocking) or always
882
+ * applies unconditionally (blocking — see the `appliedSeq` field doc).
883
+ */
884
+ private performRoundTrip;
777
885
  dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
778
886
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
779
887
  push(response: ShellResponse): void;
@@ -821,6 +929,18 @@ export declare class ViewModelShell {
821
929
  * the two ids match.
822
930
  */
823
931
  private checkVersionSkew;
932
+ /**
933
+ * NBA-05 (Phase 15): the timer-driven poll dispatch below always calls
934
+ * `this.dispatch({ name: "poll" }, true)` — passing `silent = true` — so
935
+ * `nonBlocking = silent || action.blocking === false` in `dispatch()` is
936
+ * ALWAYS `true` for a poll, regardless of any `blocking` field on the
937
+ * action itself. This means poll ALWAYS rides the non-blocking lane and
938
+ * never contends with `blockingInFlight`: `ShellOptions.pollInterval` is
939
+ * sugar over the same non-blocking dispatch path a `blocking: false`
940
+ * action uses, not a separate mechanism. See
941
+ * `.planning/design/non-blocking-actions.md` — "Wire / API surface" (the
942
+ * "`pollInterval` becomes sugar over the same non-blocking path" line).
943
+ */
824
944
  private schedulePoll;
825
945
  /**
826
946
  * Authenticated download: fetch the URL with getRequestHeaders() merged
package/dist/index.js CHANGED
@@ -63,7 +63,60 @@ export class ViewModelShell {
63
63
  options;
64
64
  currentVm = null;
65
65
  currentState = null;
66
- dispatching = false;
66
+ // Phase 14 (NBA-01..03) — the single `dispatching` mutex is replaced by two
67
+ // independent in-flight lanes so a non-blocking (silent/blocking:false)
68
+ // round trip coexists with a blocking one instead of contending for one
69
+ // shared slot. See `.planning/design/non-blocking-actions.md`.
70
+ /** Guards ONLY the blocking lane — renamed 1:1 from `dispatching`. Today's
71
+ * rapid-click-during-a-round-trip protection, byte-identical. */
72
+ blockingInFlight = false;
73
+ /** Guards the non-blocking lane so at most one non-blocking round trip is
74
+ * ever in flight at once. */
75
+ nonBlockingInFlight = false;
76
+ /** NBA-02 coalescing slot. Holds the LATEST non-blocking dispatch requested
77
+ * while one is already in flight; each subsequent trigger OVERWRITES it
78
+ * (never appended/queued — "latest wins"), so at most one extra round
79
+ * trip fires once the in-flight one resolves. Stores the pending trigger's
80
+ * OWN `silent` classification alongside its action (CR-01 fix, Phase 14
81
+ * gap closure) — the refire must replay with the classification the
82
+ * coalesced trigger was ORIGINALLY dispatched with, never with whichever
83
+ * invocation happens to resolve first and run the refire. Without this, a
84
+ * bare `poll` action (silent=true, no `blocking` field of its own)
85
+ * coalescing behind an in-flight `blocking:false` action would refire
86
+ * through the resolving action's `silent=false`, misrouting the poll into
87
+ * the blocking lane. See `.planning/design/non-blocking-actions.md`. */
88
+ pendingNonBlockingRefire = null;
89
+ /** Monotonic counter incremented once per ACTUAL network dispatch attempt,
90
+ * shared across both lanes, assigned at the moment the request is fired
91
+ * (not at trigger/coalesce time) so it reflects real fire order. */
92
+ dispatchSeq = 0;
93
+ /** The highest dispatchSeq whose response has been applied (rendered) so
94
+ * far. NBA-03: a NON-BLOCKING response is applied only when its seq >=
95
+ * appliedSeq; a lower seq means a strictly newer dispatch already applied
96
+ * and this response is stale — discard it rather than clobber the newer
97
+ * render. A BLOCKING response is authoritative and always applies
98
+ * unconditionally (CR-02 fix, Phase 14 gap closure): `blockingInFlight`
99
+ * guarantees at most one blocking dispatch is ever in flight, so a
100
+ * blocking response can never be superseded by another blocking one —
101
+ * gating it against a faster-resolving, later-fired NON-blocking response
102
+ * would silently discard the user's own action with no signal. Always
103
+ * advanced via `Math.max` (never lowered) regardless of which lane
104
+ * applied. See `.planning/design/non-blocking-actions.md` — "Epoch".
105
+ *
106
+ * NBA-06 (Phase 15): the non-blocking apply gate ALSO discards a response
107
+ * whenever `pendingNonBlockingRefire !== null` at apply time, even if its
108
+ * own seq is not stale by the `seq >= appliedSeq` test above. This closes
109
+ * the rapid-double-toggle gap: toggle A fires, toggle B (the user's very
110
+ * next click on the same control) coalesces into `pendingNonBlockingRefire`
111
+ * while A is still in flight; A's response is the ONLY one outstanding, so
112
+ * it is never stale by seq alone — but it necessarily echoes state as of
113
+ * A's own send time, predating B's local write. Applying it would revert
114
+ * B's not-yet-sent value AND poison the refire (which reads `currentState`
115
+ * fresh at its own fire time). Discarding A here is safe because the
116
+ * queued refire (B) is guaranteed to fire immediately next, in the same
117
+ * `finally` block, and will itself advance `appliedSeq` when it applies.
118
+ * See `.planning/design/non-blocking-actions.md` — "Coalescing". */
119
+ appliedSeq = 0;
67
120
  pollTimer = null;
68
121
  // 0.16.0 — busy = serverBusy OR a user-initiated dispatch is in flight.
69
122
  // Polls (silent=true dispatches) don't flip userDispatching so they never
@@ -128,32 +181,27 @@ export class ViewModelShell {
128
181
  onLoading?.(false);
129
182
  }
130
183
  }
131
- async dispatch(action, silent = false) {
132
- // 0.16.0 drop user-initiated dispatches while server-busy. Polls (silent)
133
- // bypass so the server can clear the busy state.
134
- if (!silent && this.serverBusy)
135
- return;
136
- if (this.dispatching)
137
- return;
138
- const { actionEndpoint, onError, onLoading } = this.options;
139
- if (this.currentState === null) {
140
- const err = new Error(`Cannot dispatch '${action.name}' before initial load completes. ` +
141
- `Call shell.load() and wait for it before allowing user interaction.`);
142
- onError ? onError(err) : console.error("[ViewModelShell]", err);
143
- return;
144
- }
184
+ /**
185
+ * The actual network round trip for a single dispatch: builds the
186
+ * multipart body, fires the request, parses/validates the response, and
187
+ * applies it per the lane-aware epoch rule (Phase 14 / NBA-03, refined by
188
+ * the CR-02 gap closure — see the `appliedSeq` field doc). Never throws to
189
+ * its caller — every error path is swallowed here exactly as it was in
190
+ * pre-Phase-14 `dispatch()`, so lane call sites need only a bare
191
+ * `try { await this.performRoundTrip(action, nonBlocking); } finally { ... }`
192
+ * with no `catch` of their own.
193
+ *
194
+ * @param nonBlocking whether THIS dispatch is on the non-blocking lane
195
+ * (silent=true or action.blocking===false). Determines whether the
196
+ * response is subject to the staleness-discard (non-blocking) or always
197
+ * applies unconditionally (blocking — see the `appliedSeq` field doc).
198
+ */
199
+ async performRoundTrip(action, nonBlocking) {
200
+ // Phase 14 (NBA-03) — assigned at the moment the request actually fires
201
+ // (not at trigger/coalesce time) so it reflects real fire order.
202
+ const seq = ++this.dispatchSeq;
203
+ const { actionEndpoint, onError } = this.options;
145
204
  try {
146
- this.dispatching = true;
147
- if (!silent) {
148
- // 0.16.0 — flag a user dispatch as in-flight + apply .vms-busy. This
149
- // is what kills the "rapid clicks during a round-trip silently flip the
150
- // checkbox" UX bug: by the time the user's second click arrives, the
151
- // container has pointer-events: none and the click never reaches the
152
- // input.
153
- this.userDispatching = true;
154
- this.syncBusy();
155
- onLoading?.(true);
156
- }
157
205
  const form = new FormData();
158
206
  // Phase 6 — wire-shape break: `_action` carries the action name only.
159
207
  // The state at the input's bind path holds whatever value the previous
@@ -207,7 +255,37 @@ export class ViewModelShell {
207
255
  // vm/state on an ok:false response. Type-erosion-safe.
208
256
  throw new VmsActionError(body.errors ?? [{ message: `Action '${action.name}' failed: ${res.status}` }], res.status);
209
257
  }
210
- this.processResponse(body);
258
+ // Phase 14 (NBA-03, refined by the CR-02 gap closure) — lane-aware
259
+ // epoch gate. Purely client-side; no wire field (see
260
+ // .planning/design/non-blocking-actions.md — "Epoch").
261
+ if (nonBlocking) {
262
+ // Non-blocking (background) response: apply only if no strictly-newer
263
+ // dispatch (of either lane) has already been applied — this is the
264
+ // staleness-discard NBA-03 exists for. Phase 15 (NBA-06) ALSO
265
+ // discards when a coalesced re-fire is already queued
266
+ // (`pendingNonBlockingRefire !== null`) at the moment this response
267
+ // is ready to apply: a strictly newer round trip — carrying the
268
+ // user's latest local writes — is guaranteed to fire immediately
269
+ // after (in this same dispatch's `finally` block) and supersede it,
270
+ // so applying THIS response first would only clobber those
271
+ // not-yet-sent writes with a stale echo. See the `pendingNonBlockingRefire`
272
+ // field doc and .planning/design/non-blocking-actions.md — "Coalescing".
273
+ if (seq >= this.appliedSeq && this.pendingNonBlockingRefire === null) {
274
+ this.appliedSeq = Math.max(this.appliedSeq, seq);
275
+ this.processResponse(body);
276
+ }
277
+ }
278
+ else {
279
+ // Blocking (user) response: authoritative — ALWAYS applies. At most
280
+ // one blocking dispatch is ever in flight (blockingInFlight guards
281
+ // it), so it can never be superseded by another blocking response;
282
+ // gating it against a faster non-blocking response would silently
283
+ // discard the user's own action. appliedSeq still advances (via max,
284
+ // never lowered) so a later-arriving stale non-blocking response is
285
+ // correctly discarded against this newer high-water mark.
286
+ this.appliedSeq = Math.max(this.appliedSeq, seq);
287
+ this.processResponse(body);
288
+ }
211
289
  }
212
290
  catch (err) {
213
291
  const error = err instanceof Error ? err : new Error(String(err));
@@ -233,18 +311,91 @@ export class ViewModelShell {
233
311
  this.options.adapter.render(this.currentVm, (a) => this.dispatch(a), this.stateAccessForAdapter());
234
312
  }
235
313
  }
236
- finally {
237
- this.dispatching = false;
238
- if (!silent) {
314
+ }
315
+ async dispatch(action, silent = false) {
316
+ // Phase 14 (NBA-01) — unifies the existing poll-only `silent` flag with
317
+ // the new `blocking:false` field under one "non-blocking lane" concept
318
+ // (design doc: "Poll = a non-blocking action on a timer").
319
+ const nonBlocking = silent || action.blocking === false;
320
+ if (!nonBlocking) {
321
+ // ─── Blocking lane — byte-identical guard order/behavior to the
322
+ // pre-Phase-14 single `dispatching` mutex, renamed to `blockingInFlight`. ───
323
+ // 0.16.0 — drop user-initiated dispatches while server-busy.
324
+ if (this.serverBusy)
325
+ return;
326
+ if (this.blockingInFlight)
327
+ return;
328
+ if (this.currentState === null) {
329
+ const err = new Error(`Cannot dispatch '${action.name}' before initial load completes. ` +
330
+ `Call shell.load() and wait for it before allowing user interaction.`);
331
+ const { onError } = this.options;
332
+ onError ? onError(err) : console.error("[ViewModelShell]", err);
333
+ return;
334
+ }
335
+ // 0.16.0 — flag a user dispatch as in-flight + apply .vms-busy. This
336
+ // is what kills the "rapid clicks during a round-trip silently flip the
337
+ // checkbox" UX bug: by the time the user's second click arrives, the
338
+ // container has pointer-events: none and the click never reaches the
339
+ // input.
340
+ this.blockingInFlight = true;
341
+ this.userDispatching = true;
342
+ this.syncBusy();
343
+ this.options.onLoading?.(true);
344
+ try {
345
+ await this.performRoundTrip(action, false);
346
+ }
347
+ finally {
348
+ this.blockingInFlight = false;
239
349
  this.userDispatching = false;
240
350
  this.syncBusy();
241
- onLoading?.(false);
351
+ this.options.onLoading?.(false);
242
352
  }
353
+ return;
354
+ }
355
+ // ─── Non-blocking lane — covers BOTH silent===true (poll) and
356
+ // action.blocking===false (NBA-01). Deliberately does NOT set
357
+ // userDispatching / call onLoading / toggle .vms-busy — that is what
358
+ // "does not trip the busy-lock" means; only the blocking lane does.
359
+ if (this.nonBlockingInFlight) {
360
+ // NBA-02 — coalesce; do NOT fire a second concurrent request. Overwrite
361
+ // (never append/queue) so at most one extra round trip fires once the
362
+ // in-flight one resolves, carrying the LATEST trigger. CR-01 fix: store
363
+ // this trigger's OWN `silent` alongside its action — see the field doc
364
+ // on `pendingNonBlockingRefire`.
365
+ this.pendingNonBlockingRefire = { action, silent };
366
+ return;
367
+ }
368
+ // Mirrors the blocking lane's pre-load guard, identical error message.
369
+ if (this.currentState === null) {
370
+ const err = new Error(`Cannot dispatch '${action.name}' before initial load completes. ` +
371
+ `Call shell.load() and wait for it before allowing user interaction.`);
372
+ const { onError } = this.options;
373
+ onError ? onError(err) : console.error("[ViewModelShell]", err);
374
+ return;
375
+ }
376
+ this.nonBlockingInFlight = true;
377
+ try {
378
+ await this.performRoundTrip(action, true);
379
+ }
380
+ finally {
381
+ this.nonBlockingInFlight = false;
382
+ const refire = this.pendingNonBlockingRefire;
383
+ this.pendingNonBlockingRefire = null;
384
+ // CR-01 fix — the coalesced re-fire recurses into dispatch() with the
385
+ // PENDING TRIGGER'S OWN `silent` classification (stored alongside its
386
+ // action in the slot), never with the value THIS (resolving)
387
+ // invocation happened to be entered with. A poll's coalesced refire
388
+ // always stays silent regardless of what resolved first; a
389
+ // blocking:false action's coalesced refire always re-enters the
390
+ // non-blocking branch via its own action.blocking===false. See the
391
+ // field doc on `pendingNonBlockingRefire`.
392
+ if (refire)
393
+ void this.dispatch(refire.action, refire.silent);
243
394
  }
244
395
  }
245
396
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
246
397
  push(response) {
247
- if (this.dispatching)
398
+ if (this.blockingInFlight || this.nonBlockingInFlight)
248
399
  return;
249
400
  // 1.0.0 — parse-then-branch for push. External push consumers (SSE, WebSocket)
250
401
  // may feed ok:false responses (e.g. a server-pushed error notification). Route
@@ -403,6 +554,18 @@ export class ViewModelShell {
403
554
  this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
404
555
  }
405
556
  }
557
+ /**
558
+ * NBA-05 (Phase 15): the timer-driven poll dispatch below always calls
559
+ * `this.dispatch({ name: "poll" }, true)` — passing `silent = true` — so
560
+ * `nonBlocking = silent || action.blocking === false` in `dispatch()` is
561
+ * ALWAYS `true` for a poll, regardless of any `blocking` field on the
562
+ * action itself. This means poll ALWAYS rides the non-blocking lane and
563
+ * never contends with `blockingInFlight`: `ShellOptions.pollInterval` is
564
+ * sugar over the same non-blocking dispatch path a `blocking: false`
565
+ * action uses, not a separate mechanism. See
566
+ * `.planning/design/non-blocking-actions.md` — "Wire / API surface" (the
567
+ * "`pollInterval` becomes sugar over the same non-blocking path" line).
568
+ */
406
569
  schedulePoll(nextPollIn) {
407
570
  const delay = nextPollIn ?? this.options.pollInterval;
408
571
  if (delay == null)
package/dist/server.js CHANGED
@@ -201,7 +201,10 @@ function collectActions(node, enclosingForm, out) {
201
201
  return;
202
202
  }
203
203
  // Nodes with no dispatch-bearing actions of their own:
204
- // text, link, image, stat-bar, progress, copy-button, badge
204
+ // text, link, image, stat-bar, progress, copy-button, badge, chart
205
+ // ChartNode (CHART-05) is a DELIBERATE childless/action-free leaf — it
206
+ // carries only data points, so it falls through here with no recursion (no
207
+ // fits-style blind spot).
205
208
  default:
206
209
  return;
207
210
  }
@@ -366,8 +369,9 @@ function walkForSectionAction(node, outerInteractive) {
366
369
  return;
367
370
  }
368
371
  // Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
369
- // tabs, progress, table, copy-button, badge) carry no SectionNode
370
- // descendants — TableNode rows hold strings + per-row controls, not sections.
372
+ // tabs, progress, table, copy-button, badge, chart) carry no SectionNode
373
+ // descendants — TableNode rows hold strings + per-row controls, not sections;
374
+ // ChartNode (CHART-05) is a childless/action-free data leaf.
371
375
  default:
372
376
  return;
373
377
  }
package/dist/tui.js CHANGED
@@ -748,6 +748,7 @@ function renderNode(node, ctx, key) {
748
748
  case "tabs": return _jsx(TabsView, { node: node, ctx: ctx }, key);
749
749
  case "progress": return _jsx(ProgressView, { node: node }, key);
750
750
  case "stat-bar": return _jsx(StatBarView, { node: node }, key);
751
+ case "chart": return _jsx(ChartView, { node: node }, key);
751
752
  case "modal": return _jsx(ModalView, { node: node, ctx: ctx }, key);
752
753
  case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
753
754
  case "divider": return _jsx("text", { fg: "#555555", children: node.orientation === "vertical" ? "│" : "─".repeat(40) }, key);
@@ -1100,6 +1101,26 @@ function ProgressView({ node }) {
1100
1101
  function StatBarView({ node }) {
1101
1102
  return (_jsx("box", { flexDirection: "row", gap: 1, children: node.stats.map((s, i) => (_jsxs("box", { flexDirection: "row", gap: 1, children: [i > 0 ? _jsx("text", { fg: "#555555", children: "\u2502" }) : null, _jsx("text", { fg: "#888888", children: s.label }), _jsx("text", { children: String(s.value) })] }, i))) }));
1102
1103
  }
1104
+ // ── chart ─────────────────────────────────────────────────────────────────
1105
+ // CHART-05 — DELIBERATE degradation. A terminal has no canvas, but a ChartNode
1106
+ // is STRUCTURED data, so it prints as a legible label/value/ASCII-bar series:
1107
+ // the title (if any) on its own line, then per point `<label padded> <value>
1108
+ // <bar>` where <bar> is a run of "█" scaled to value/max × CHART_BAR_WIDTH.
1109
+ // Empty points render just the title (or nothing); an all-zero / non-positive
1110
+ // max renders labels+values with no bars (guarded, never throws / divides).
1111
+ // The TUI is @experimental; the requirement is only that ChartNode does not
1112
+ // break it and degrades legibly. ChartNode is a LEAF (no children) → no
1113
+ // container-walk arm is needed (mirrors StatBarView / ProgressView).
1114
+ const CHART_BAR_WIDTH = 20;
1115
+ function ChartView({ node }) {
1116
+ const points = node.points ?? [];
1117
+ const maxValue = points.length ? Math.max(0, ...points.map((p) => p.value)) : 0;
1118
+ const labelWidth = points.reduce((w, p) => Math.max(w, p.label.length), 0);
1119
+ return (_jsxs("box", { flexDirection: "column", children: [node.title ? _jsx("text", { attributes: 1 /* BOLD */, children: node.title }) : null, points.map((p, i) => {
1120
+ const barLen = maxValue > 0 ? Math.round((p.value / maxValue) * CHART_BAR_WIDTH) : 0;
1121
+ return (_jsxs("box", { flexDirection: "row", gap: 1, children: [_jsx("text", { fg: "#888888", children: p.label.padEnd(labelWidth) }), _jsx("text", { children: String(p.value).padStart(4) }), _jsx("text", { fg: "#4a9eff", children: "█".repeat(barLen) })] }, i));
1122
+ })] }));
1123
+ }
1103
1124
  // ── modal ─────────────────────────────────────────────────────────────────
1104
1125
  // Modals are PORTALED to app-root by App (see findModal + ModalOverlay).
1105
1126
  // The inline ModalView (invoked when renderNode hits a "modal" in the tree)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "4.0.0",
3
+ "version": "4.2.0",
4
4
  "description": "A server-driven UI framework where the wire format is structured enough that agents can build full-stack apps without ever opening a browser and all UI tests are pure unit tests with no browser runtime. Server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to DOM. Backend-agnostic \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -44,6 +44,7 @@
44
44
  "@types/bun": "^1.1.0",
45
45
  "@types/node": "^22.0.0",
46
46
  "@types/react": "^19.0.0",
47
+ "chart.js": "^4",
47
48
  "jsdom": "^25.0.1",
48
49
  "react": "^19.2.0",
49
50
  "typescript": "^5.4.0",
@@ -56,9 +57,13 @@
56
57
  "react": "^19.2.0"
57
58
  },
58
59
  "peerDependencies": {
60
+ "chart.js": "^4",
59
61
  "vite": ">=5"
60
62
  },
61
63
  "peerDependenciesMeta": {
64
+ "chart.js": {
65
+ "optional": true
66
+ },
62
67
  "vite": {
63
68
  "optional": true
64
69
  }
@@ -283,6 +283,12 @@ body:has(.vms-page--fill) { margin: 0; }
283
283
  the renderer (browser.ts), not in CSS. ── */
284
284
  .vms-fits { display: block; }
285
285
 
286
+ /* ── Chart container (CHART-01 / Phase 12). Framework CSS (NOT app CSS): Chart.js
287
+ responsive sizing requires a BOUNDED, positioned container — the canvas fills
288
+ this wrapper. The zero-app-CSS policy is unaffected (this is shipped framework
289
+ CSS, and no raw style crosses the wire — tone maps to a --vms-* token). ── */
290
+ .vms-chart { display: block; position: relative; width: 100%; height: 20rem; }
291
+
286
292
  /* ── Alignment: arrange (main axis → justify-content) / align (cross axis →
287
293
  align-items) (ALIGN-01/02/03 / 1.12.0). Intended for layout:"row" (the cluster
288
294
  primitive) but these are generic box-alignment, so they're harmless on any flex