@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 +27 -0
- package/dist/browser.d.ts +38 -0
- package/dist/browser.js +185 -15
- package/dist/index.d.ts +123 -3
- package/dist/index.js +195 -32
- package/dist/server.js +7 -3
- package/dist/tui.js +21 -0
- package/package.json +6 -1
- package/styles/default.css +6 -0
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
|
|
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(
|
|
731
|
+
el.addEventListener("click", () => { on(action); });
|
|
562
732
|
el.addEventListener("keydown", (e) => {
|
|
563
733
|
if (e.key === "Enter") {
|
|
564
|
-
on(
|
|
734
|
+
on(action);
|
|
565
735
|
}
|
|
566
736
|
else if (e.key === " " || e.key === "Spacebar") {
|
|
567
737
|
e.preventDefault(); // suppress page scroll
|
|
568
|
-
on(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
1519
|
+
tr.addEventListener("click", () => { on(rowAction); });
|
|
1350
1520
|
tr.addEventListener("keydown", (e) => {
|
|
1351
1521
|
if (e.key === "Enter") {
|
|
1352
|
-
on(
|
|
1522
|
+
on(rowAction);
|
|
1353
1523
|
}
|
|
1354
1524
|
else if (e.key === " " || e.key === "Spacebar") {
|
|
1355
1525
|
e.preventDefault(); // suppress page scroll
|
|
1356
|
-
on(
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
-
|
|
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
|
-
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
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.
|
|
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
|
}
|
package/styles/default.css
CHANGED
|
@@ -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
|