@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 +29 -0
- package/dist/browser.d.ts +38 -0
- package/dist/browser.js +217 -17
- package/dist/index.d.ts +136 -4
- 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,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
|
|
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
|
|
@@ -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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
1519
|
+
tr.addEventListener("click", () => { on(rowAction); });
|
|
1320
1520
|
tr.addEventListener("keydown", (e) => {
|
|
1321
1521
|
if (e.key === "Enter") {
|
|
1322
|
-
on(
|
|
1522
|
+
on(rowAction);
|
|
1323
1523
|
}
|
|
1324
1524
|
else if (e.key === " " || e.key === "Spacebar") {
|
|
1325
1525
|
e.preventDefault(); // suppress page scroll
|
|
1326
|
-
on(
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
|
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": "
|
|
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
|
}
|
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
|