@ashley-shrok/viewmodel-shell 3.11.0 → 4.1.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,10 +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
 
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.)
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
+
142
171
  ## Versioning
143
172
 
144
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
@@ -601,11 +771,26 @@ export class BrowserAdapter {
601
771
  const form = document.createElement("form");
602
772
  form.className = `vms-form${n.layout && n.layout !== "stack" ? ` vms-form--${n.layout}` : ""}`;
603
773
  form.noValidate = true;
604
- this.kids(n.children, form, on);
774
+ // File collection is by DECLARED intent, not button position: a file input
775
+ // rides an action iff that action's name is listed in the input's `uploadOn`
776
+ // (carried here via the data-vms-upload-on attribute set in field()). EVERY
777
+ // trigger inside the form — submit, buttons[], a ButtonNode or
778
+ // FieldNode.action nested in children — routes through this one path, so
779
+ // where a trigger sits is irrelevant; the file's own uploadOn decides. An
780
+ // input with no uploadOn rides nothing (there is no positional fallback).
605
781
  const dispatchWithFiles = (action) => {
606
782
  const files = {};
607
783
  form.querySelectorAll("input[type=file]").forEach(inp => {
608
- if (inp.name && inp.files?.[0])
784
+ if (!inp.name || !inp.files?.[0])
785
+ return;
786
+ let uploadOn = [];
787
+ try {
788
+ uploadOn = JSON.parse(inp.dataset.vmsUploadOn ?? "[]");
789
+ }
790
+ catch {
791
+ uploadOn = [];
792
+ }
793
+ if (uploadOn.includes(action.name))
609
794
  files[inp.name] = inp.files[0];
610
795
  });
611
796
  const ev = { name: action.name };
@@ -613,6 +798,10 @@ export class BrowserAdapter {
613
798
  ev.files = files;
614
799
  on(ev);
615
800
  };
801
+ // Children dispatch through the file-aware path too — so a ButtonNode (or a
802
+ // FieldNode.action Enter) nested anywhere in the form carries files per the
803
+ // uploadOn contract, identical to a footer buttons[] trigger.
804
+ this.kids(n.children, form, dispatchWithFiles);
616
805
  // #22 — submitButton takes precedence: the form renders the consumer's own
617
806
  // button (its label + emphasis/tone/size/width) as the submit and fires its
618
807
  // action; submitLabel/submitAction for the implicit button are then ignored.
@@ -799,7 +988,7 @@ export class BrowserAdapter {
799
988
  this.writeBind(n.bind, sel.value);
800
989
  }
801
990
  if (n.action)
802
- on({ name: n.action.name });
991
+ on(n.action);
803
992
  });
804
993
  wrapper.appendChild(sel);
805
994
  }
@@ -809,6 +998,10 @@ export class BrowserAdapter {
809
998
  inp.className = "vms-field__input";
810
999
  inp.id = `vms-${n.name}`;
811
1000
  inp.name = n.name;
1001
+ // Carry the declared upload routing to dispatch time — form()'s
1002
+ // dispatchWithFiles reads this and attaches the file only to an action
1003
+ // named here. Absent/empty => the file rides no action.
1004
+ inp.dataset.vmsUploadOn = JSON.stringify(n.uploadOn ?? []);
812
1005
  // File-input persistence: re-apply any registered file to the new node.
813
1006
  const existingFile = this.fileRegistry.get(n.name);
814
1007
  if (existingFile) {
@@ -823,6 +1016,13 @@ export class BrowserAdapter {
823
1016
  const file = inp.files?.[0];
824
1017
  if (file) {
825
1018
  this.fileRegistry.set(n.name, file);
1019
+ // [vms:orphan-file] — a picked file that declares no uploadOn action
1020
+ // will never be sent (the binary rides an action, and this input
1021
+ // names none). Silent under-attach is the dangerous failure, so warn.
1022
+ if (!n.uploadOn || n.uploadOn.length === 0) {
1023
+ this.warnOnce("orphan-file:" + n.name, "[vms:orphan-file] file field '" + n.name + "' has a picked file but no uploadOn action — " +
1024
+ "its binary will not be sent; add uploadOn:[\"<action>\"] naming the action that should carry it.");
1025
+ }
826
1026
  // [vms:type-mismatch] — OBSERVABLE-SUBSET diagnostic. The client is
827
1027
  // untyped JS: it CANNOT know a state slot's *declared* server type, so
828
1028
  // it only catches the observable case where a file object overwrites a
@@ -919,7 +1119,7 @@ export class BrowserAdapter {
919
1119
  // dispatching, in case the browser hasn't fired `input` yet
920
1120
  // (e.g. an autofill that lands then submits).
921
1121
  this.writeBind(n.bind, inp.value);
922
- on({ name: action.name });
1122
+ on(action);
923
1123
  }
924
1124
  });
925
1125
  }
@@ -1008,7 +1208,7 @@ export class BrowserAdapter {
1008
1208
  inp.addEventListener("change", () => {
1009
1209
  this.sa.write(n.bind, inp.checked);
1010
1210
  if (n.action)
1011
- on({ name: n.action.name });
1211
+ on(n.action);
1012
1212
  });
1013
1213
  parent.appendChild(lbl);
1014
1214
  }
@@ -1105,7 +1305,7 @@ export class BrowserAdapter {
1105
1305
  btn.setAttribute("aria-selected", String(tab.value === n.selected));
1106
1306
  btn.addEventListener("click", () => {
1107
1307
  this.sa.write(n.bind, tab.value);
1108
- on({ name: tab.action.name });
1308
+ on(tab.action);
1109
1309
  });
1110
1310
  nav.appendChild(btn);
1111
1311
  });
@@ -1239,7 +1439,7 @@ export class BrowserAdapter {
1239
1439
  const cur = this.sa.read(sortBind);
1240
1440
  const nextDir = cur?.column === col.key && cur?.direction === "asc" ? "desc" : "asc";
1241
1441
  this.sa.write(sortBind, { column: col.key, direction: nextDir });
1242
- on({ name: sortAction.name });
1442
+ on(sortAction);
1243
1443
  });
1244
1444
  }
1245
1445
  headerRow.appendChild(th);
@@ -1279,7 +1479,7 @@ export class BrowserAdapter {
1279
1479
  if (e.key === "Enter") {
1280
1480
  if (bindPath != null)
1281
1481
  this.sa.write(bindPath, inp.value);
1282
- on({ name: filterAction.name });
1482
+ on(filterAction);
1283
1483
  }
1284
1484
  });
1285
1485
  th.appendChild(inp);
@@ -1305,7 +1505,7 @@ export class BrowserAdapter {
1305
1505
  // row.action — click-anywhere + keyboard + ARIA. Per-row controls and
1306
1506
  // cell linkLabel anchors stopPropagation below so they don't double-fire.
1307
1507
  if (row.action) {
1308
- const rowActionName = row.action.name;
1508
+ const rowAction = row.action;
1309
1509
  tr.tabIndex = 0;
1310
1510
  tr.setAttribute("role", "button");
1311
1511
  const labelParts = Object.values(row.cells)
@@ -1316,14 +1516,14 @@ export class BrowserAdapter {
1316
1516
  : (row.id ? `Row ${row.id}` : "");
1317
1517
  if (ariaLabel)
1318
1518
  tr.setAttribute("aria-label", ariaLabel);
1319
- tr.addEventListener("click", () => { on({ name: rowActionName }); });
1519
+ tr.addEventListener("click", () => { on(rowAction); });
1320
1520
  tr.addEventListener("keydown", (e) => {
1321
1521
  if (e.key === "Enter") {
1322
- on({ name: rowActionName });
1522
+ on(rowAction);
1323
1523
  }
1324
1524
  else if (e.key === " " || e.key === "Spacebar") {
1325
1525
  e.preventDefault(); // suppress page scroll
1326
- on({ name: rowActionName });
1526
+ on(rowAction);
1327
1527
  }
1328
1528
  });
1329
1529
  }
@@ -1406,7 +1606,7 @@ export class BrowserAdapter {
1406
1606
  b.addEventListener("click", () => {
1407
1607
  if (paginationBind != null)
1408
1608
  this.sa.write(paginationBind, targetPage);
1409
- on({ name: action.name });
1609
+ on(action);
1410
1610
  });
1411
1611
  }
1412
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;
@@ -255,7 +270,10 @@ export interface FormNode {
255
270
  * `bind` path and travel with the dispatch's `_state` payload. Mirrors
256
271
  * HTML's multiple submit buttons / `formaction` — different action per
257
272
  * button, same underlying state. A plain ButtonNode placed in `children`
258
- * has identical dispatch semantics; the buttons[] slot is a layout hint. */
273
+ * has identical dispatch semantics: both flow through the same file-aware
274
+ * dispatch, so file collection is governed by each file input's `uploadOn`
275
+ * (below), NOT by whether the trigger sits in `buttons[]` or `children`. The
276
+ * buttons[] slot is purely a layout hint. */
259
277
  buttons?: ButtonNode[];
260
278
  /** Opt-in: bare Enter inside a descendant <textarea> dispatches submitAction
261
279
  * (chat-composer "Enter sends, Shift/Ctrl/Meta/Alt+Enter = newline"). No-op
@@ -320,6 +338,15 @@ export interface FieldNode {
320
338
  /** Dispatched when Enter is pressed (text-like inputs only). Carries an
321
339
  * action name only — the current value is already in state at the bind path. */
322
340
  action?: ActionEvent;
341
+ /** FILE INPUTS ONLY. The action name(s) whose dispatch carries this file's
342
+ * binary over the multipart wire. A file rides an action iff that action's
343
+ * name is listed here — declared on the *file*, so which trigger sends it no
344
+ * longer depends on where a button sits (the trigger can live anywhere in the
345
+ * form; footer `buttons[]`, `children`, submit, and Enter all honor this
346
+ * equally). An absent or empty `uploadOn` means the file rides **nothing**
347
+ * (there is no positional fallback); the browser warns `[vms:orphan-file]`
348
+ * when a file is picked with no `uploadOn`. Ignored on non-file inputs. */
349
+ uploadOn?: string[];
323
350
  }
324
351
  export interface CheckboxNode {
325
352
  type: "checkbox";
@@ -622,6 +649,31 @@ export interface FitsNode {
622
649
  * guaranteed-fits fallback rendered when none fit. */
623
650
  children: ViewNode[];
624
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
+ }
625
677
  export interface ShellOptions {
626
678
  endpoint: string;
627
679
  actionEndpoint: string;
@@ -638,7 +690,10 @@ export interface ShellOptions {
638
690
  onUploadProgress?: (sent: number, total: number) => void;
639
691
  /** When set, the shell dispatches a "poll" action at this interval (ms) after every load/dispatch.
640
692
  * The server can override the next interval via ShellResponse.nextPollIn, or stop polling by
641
- * 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`. */
642
697
  pollInterval?: number;
643
698
  /** 3.8.0 — the id of the client bundle this shell instance is running (the app
644
699
  * injects it at build time, e.g. from a Vite `define`/env — VMS never derives
@@ -755,13 +810,78 @@ export declare class ViewModelShell {
755
810
  private options;
756
811
  private currentVm;
757
812
  private currentState;
758
- 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;
759
863
  private pollTimer;
760
864
  private serverBusy;
761
865
  private userDispatching;
762
866
  constructor(options: ShellOptions);
763
867
  private syncBusy;
764
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;
765
885
  dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
766
886
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
767
887
  push(response: ShellResponse): void;
@@ -809,6 +929,18 @@ export declare class ViewModelShell {
809
929
  * the two ids match.
810
930
  */
811
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
+ */
812
944
  private schedulePoll;
813
945
  /**
814
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": "3.11.0",
3
+ "version": "4.1.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