@ashley-shrok/viewmodel-shell 0.16.0 → 1.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/dist/index.d.ts CHANGED
@@ -1,10 +1,20 @@
1
1
  export interface ActionEvent {
2
2
  name: string;
3
- context?: Record<string, unknown>;
4
3
  files?: Record<string, File>;
5
4
  }
5
+ export interface StateAccess {
6
+ read(path: string): unknown;
7
+ write(path: string, value: unknown): void;
8
+ }
6
9
  export interface Adapter {
7
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
10
+ /** Render the view tree. `stateAccess` is the shell-owned `{ read, write }`
11
+ * seam over the live state object: the adapter reads input values via
12
+ * `stateAccess.read(node.bind)` and writes user input back via
13
+ * `stateAccess.write(node.bind, value)`. The third arg is OPTIONAL so
14
+ * callers (tests, embedders) that have no live state can still mount
15
+ * the adapter for class-emission / static-tree checks — adapters supply
16
+ * a no-op fallback internally when omitted. */
17
+ render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
8
18
  /** Hand the platform off to a URL (the browser adapter sets the page location).
9
19
  * No safe no-op exists — if a redirect arrives and neither ShellOptions.onRedirect
10
20
  * nor this method is available, the shell fails loudly. */
@@ -98,12 +108,12 @@ export interface FormNode {
98
108
  /** Layout preset for the form's controls. Omitted or "stack" = fields stacked (current, no modifier class). "inline" = field row + submit on one line (add/search bar) — emits .vms-form--inline. Closed union (D-29). */
99
109
  layout?: "stack" | "inline";
100
110
  /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
101
- * `variant` + `pendingLabel` apply) that, on activation, HARVESTS this
102
- * form's current field values into its `action.context` and dispatches
103
- * the same harvest the default submit performs, but carrying a different
104
- * action. Mirrors HTML's multiple submit buttons / `formaction`. A plain
105
- * ButtonNode placed in `children` keeps its no-harvest behavior; only
106
- * buttons in THIS slot harvest. */
111
+ * `variant` + `pendingLabel` apply) that, on activation, dispatches its
112
+ * declared action by name. Field values live in state at each input's
113
+ * `bind` path and travel with the dispatch's `_state` payload. Mirrors
114
+ * HTML's multiple submit buttons / `formaction` different action per
115
+ * button, same underlying state. A plain ButtonNode placed in `children`
116
+ * has identical dispatch semantics; the buttons[] slot is a layout hint. */
107
117
  buttons?: ButtonNode[];
108
118
  children: ViewNode[];
109
119
  }
@@ -111,9 +121,10 @@ export interface FieldNode {
111
121
  type: "field";
112
122
  name: string;
113
123
  inputType: "text" | "email" | "password" | "number" | "date" | "time" | "datetime-local" | "textarea" | "hidden" | "file" | "select" | "select-multiple" | "checkbox" | "code";
124
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
125
+ bind: string;
114
126
  label?: string;
115
127
  placeholder?: string;
116
- value?: string;
117
128
  required?: boolean;
118
129
  options?: Array<{
119
130
  value: string;
@@ -124,15 +135,18 @@ export interface FieldNode {
124
135
  * library (CodeMirror, Monaco, etc.) — the framework only ships
125
136
  * monospaced editable text, no coloring. */
126
137
  language?: string;
127
- /** Dispatched when Enter is pressed (text-like inputs only). Adapter merges { [name]: value } into context. */
138
+ /** Dispatched when Enter is pressed (text-like inputs only). Carries an
139
+ * action name only — the current value is already in state at the bind path. */
128
140
  action?: ActionEvent;
129
141
  }
130
142
  export interface CheckboxNode {
131
143
  type: "checkbox";
132
144
  name: string;
133
- checked: boolean;
145
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
146
+ bind: string;
134
147
  label?: string;
135
- /** Dispatched immediately on change. Adapter merges { checked: boolean } into context. */
148
+ /** Dispatched immediately on change. Carries an action name only the new
149
+ * checked value is already in state at the bind path. */
136
150
  action?: ActionEvent;
137
151
  }
138
152
  export interface ButtonNode {
@@ -182,11 +196,15 @@ export interface StatBarNode {
182
196
  export interface TabsNode {
183
197
  type: "tabs";
184
198
  selected: string;
185
- /** Base action. Adapter merges { value: tab.value } into context on click. */
186
- action: ActionEvent;
199
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
200
+ bind: string;
201
+ /** Each tab declares its own action — the framework requires a unique action
202
+ * name per tab (e.g. `select-tab-pending`, `select-tab-active`). The renderer
203
+ * writes `value` to the bound state path before dispatching the action. */
187
204
  tabs: Array<{
188
205
  value: string;
189
206
  label: string;
207
+ action: ActionEvent;
190
208
  }>;
191
209
  }
192
210
  export interface ProgressNode {
@@ -219,25 +237,24 @@ export interface TableColumn {
219
237
  export interface TableRow {
220
238
  id?: string;
221
239
  cells: Record<string, string>;
240
+ /** Click-anywhere row dispatch primitive. When set, the renderer makes the
241
+ * entire row clickable AND keyboard-activatable (Enter / Space — Space
242
+ * preventDefaults page scroll) AND exposes accessibility (role="button",
243
+ * tabindex=0, aria-label derived from cell text). Per-row identity is
244
+ * encoded in the action name (e.g. `select-ticket-42`) — no context field,
245
+ * consistent with the Phase 6 wire. Coexists with `actions[]`: clicking a
246
+ * per-row button, checkbox, or cell linkLabel anchor does NOT also fire
247
+ * `row.action` (the renderer stops propagation on those targets). */
222
248
  action?: ActionEvent;
249
+ /** Per-row interactive controls rendered in a trailing actions cell. Each
250
+ * entry is either a ButtonNode (its own unique action name encodes per-row
251
+ * identity, e.g. `delete-row-42`) or a CheckboxNode (its own `bind` path
252
+ * per row). The renderer dispatches by `entry.type` so both types render
253
+ * correctly — a previous version typed this as `ButtonNode[]` and called
254
+ * the button renderer blindly, silently dropping non-button entries. */
255
+ actions?: (ButtonNode | CheckboxNode)[];
223
256
  variant?: string;
224
257
  }
225
- export interface TableSelection {
226
- /** Row ids that should render PRE-SELECTED on initial render — the server's
227
- * initial pre-selection. Subsequent toggles are purely client-side DOM state;
228
- * the server doesn't see them until a `buttons[]` click harvests them. */
229
- selectedIds: string[];
230
- /** When present, the adapter renders these as a bulk-action toolbar ABOVE
231
- * the table. On click, each button harvests the currently-checked rows from
232
- * the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
233
- * into its `context`. This is the only way to act on the selection — there
234
- * is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
235
- * because rapid clicks were dropped by the dispatch guard and the in-flight
236
- * response wiped the DOM). If a future release needs cross-page persistence
237
- * or live "N selected" indicators, the way back is a redesigned wire shape
238
- * (dispatch queueing + optimistic preservation), not the old `action` mode. */
239
- buttons?: ButtonNode[];
240
- }
241
258
  export interface TablePagination {
242
259
  /** 1-based current page. */
243
260
  page: number;
@@ -246,30 +263,38 @@ export interface TablePagination {
246
263
  /** Total rows across all pages — server-truth. The adapter renders the range
247
264
  * label and enables/disables prev/next from this; it does NOT slice. */
248
265
  totalRows: number;
249
- /** Dispatched on a page-control click. The adapter merges `{ page }` the
250
- * target 1-based page. */
251
- action: ActionEvent;
266
+ /** Dispatched on the prev page-control click. Carries an action name only
267
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
268
+ prevAction?: ActionEvent;
269
+ /** Dispatched on the next page-control click. Carries an action name only —
270
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
271
+ nextAction?: ActionEvent;
252
272
  }
253
273
  export interface TableNode {
254
274
  type: "table";
255
275
  columns: TableColumn[];
256
276
  rows: TableRow[];
257
- sortColumn?: string;
258
- sortDirection?: "asc" | "desc";
259
- /** Base action. Adapter merges { column, direction } into context on header click. */
260
- sortAction?: ActionEvent;
261
- /** Base action. Adapter merges { column, value, filters } into context on Enter. */
277
+ /** Path into state where the current sort intent (`{column, direction}`) is read/written. */
278
+ sortBind?: string;
279
+ /** Per-column filter input bind paths. The renderer reads/writes filter
280
+ * values at these paths; the values then travel with the next dispatch. */
281
+ filterBinds?: Record<string, string>;
282
+ /** Path into state where the renderer writes the target page number before
283
+ * firing `pagination.prevAction` / `pagination.nextAction`. */
284
+ paginationBind?: string;
285
+ /** Per-column sort header click actions, keyed by column key. Each carries a
286
+ * unique action name — the renderer writes the new sort intent to `sortBind`
287
+ * before dispatching. */
288
+ sortActions?: Record<string, ActionEvent>;
289
+ /** One filter-dispatch action per table. The renderer fires this when the
290
+ * user submits the filter form; per-column filter values are already in
291
+ * state at the `filterBinds` paths. */
262
292
  filterAction?: ActionEvent;
263
- /** Per-row multi-select. When set, the adapter renders a leading checkbox
264
- * column + a header select-all checkbox and tints selected rows. `TableRow.id`
265
- * is REQUIRED on every row when selection is set — it's the address the
266
- * toggle action reports back. */
267
- selection?: TableSelection;
268
293
  /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
269
294
  * range + prev/next controls below the table. **The server slices `rows` to
270
295
  * the current page** — the adapter never paginates client-side (that would
271
296
  * break for DB-backed tables, which are most of them). By convention
272
- * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
297
+ * filter dispatches reset `page` to 1 on the server side, since
273
298
  * the row window changes underneath them. */
274
299
  pagination?: TablePagination;
275
300
  }
@@ -319,6 +344,36 @@ export interface ShellSideEffect {
319
344
  * when present; this is the fallback before the URL basename. */
320
345
  filename?: string;
321
346
  }
347
+ /** One entry in the structured error payload returned on `ok: false`. */
348
+ export interface ErrorEntry {
349
+ /** Absent when not tied to a specific input slot (parse errors, uncaught
350
+ * exceptions, etc.). Present when bound to a specific bind-path on an input. */
351
+ path?: string;
352
+ /** Human-readable description of the error. Always present. */
353
+ message: string;
354
+ /** Framework-set discriminator for the failure class. Initial vocabulary:
355
+ * "parse_error" | "unknown_action" | "invalid_tree" | "uncaught_exception".
356
+ * Absent when not applicable (e.g. BadRequest / D-08 path). */
357
+ code?: string;
358
+ }
359
+ /**
360
+ * Structured error surfaced via `onError` when the server returns `ok: false`.
361
+ * Extends `Error` so existing `onError` consumers that don't know about the
362
+ * envelope continue receiving a normal Error with a useful `.message`.
363
+ *
364
+ * Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
365
+ *
366
+ * `status` is the HTTP status code (0 for push-originated errors with no
367
+ * HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
368
+ * branching on single-error responses.
369
+ */
370
+ export declare class VmsActionError extends Error {
371
+ readonly errors: ErrorEntry[];
372
+ readonly status: number;
373
+ constructor(errors: ErrorEntry[], status: number);
374
+ /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
375
+ get code(): string | undefined;
376
+ }
322
377
  export interface ShellResponse {
323
378
  vm: ViewNode;
324
379
  state: unknown;
@@ -338,6 +393,14 @@ export interface ShellResponse {
338
393
  * dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
339
394
  * bypass so the server can clear the state. See `Adapter.setBusy`. */
340
395
  busy?: boolean;
396
+ /** 1.0.0 — framework-set envelope flag. Present on every framework-rendered
397
+ * response; absent on hand-constructed legacy push bodies (treated as ok:true
398
+ * for backwards compatibility). On ok:false responses, only `errors[]` is
399
+ * consumed — any vm/state present in the body is IGNORED at runtime (D-15
400
+ * hardening: the shell throws before the currentVm/currentState writes run). */
401
+ ok?: boolean;
402
+ /** 1.0.0 — structured error entries. Present when `ok: false`. */
403
+ errors?: ErrorEntry[];
341
404
  }
342
405
  export declare class ViewModelShell {
343
406
  private options;
@@ -356,6 +419,35 @@ export declare class ViewModelShell {
356
419
  stopPolling(): void;
357
420
  getCurrentVm(): ViewNode | null;
358
421
  getCurrentState(): unknown;
422
+ /**
423
+ * Read the bound state value at a dotted path (e.g. "fields.title",
424
+ * "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
425
+ * render an input's current value. Returns `undefined` when any segment
426
+ * along the path is missing — adapters treat that as "no value set" and
427
+ * render the appropriate empty form (empty string, unchecked, etc.).
428
+ *
429
+ * This is half of the bind-path seam introduced in Phase 6: the shell holds
430
+ * state mutably so that input events can update it (via `stateWrite`)
431
+ * without round-tripping to the server until a real dispatch fires.
432
+ */
433
+ stateRead(path: string): unknown;
434
+ /**
435
+ * Write a value into state at a dotted path. The shell mutates the held
436
+ * state object in place at the bind path; the next dispatch sends the
437
+ * updated state to the server. Intermediate objects/arrays are created on
438
+ * demand (numeric next segment → array, else object).
439
+ *
440
+ * This is the other half of the bind-path seam — the BrowserAdapter (or any
441
+ * adapter that supports user input) calls this on every keystroke / change
442
+ * event so drafts ARE state.
443
+ */
444
+ stateWrite(path: string, value: unknown): void;
445
+ /**
446
+ * Build the read/write seam the Adapter receives as its third render arg.
447
+ * Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
448
+ * reference to the shell.
449
+ */
450
+ private stateAccessForAdapter;
359
451
  private failCapability;
360
452
  private processResponse;
361
453
  private schedulePoll;
package/dist/index.js CHANGED
@@ -1,4 +1,41 @@
1
1
  // ─── Action ──────────────────────────────────────────────────────────────────
2
+ /**
3
+ * Private helper: compose a human-readable `.message` from an errors array.
4
+ * Single entry → the entry's message verbatim.
5
+ * Multiple entries → "<first> (and N more)" so console.error is still useful.
6
+ */
7
+ function summarizeErrors(errors) {
8
+ if (errors.length === 0)
9
+ return "Server returned ok:false with no error details";
10
+ if (errors.length === 1)
11
+ return errors[0].message;
12
+ return `${errors[0].message} (and ${errors.length - 1} more)`;
13
+ }
14
+ /**
15
+ * Structured error surfaced via `onError` when the server returns `ok: false`.
16
+ * Extends `Error` so existing `onError` consumers that don't know about the
17
+ * envelope continue receiving a normal Error with a useful `.message`.
18
+ *
19
+ * Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
20
+ *
21
+ * `status` is the HTTP status code (0 for push-originated errors with no
22
+ * HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
23
+ * branching on single-error responses.
24
+ */
25
+ export class VmsActionError extends Error {
26
+ errors;
27
+ status;
28
+ constructor(errors, status) {
29
+ super(summarizeErrors(errors));
30
+ this.errors = errors;
31
+ this.status = status;
32
+ this.name = "VmsActionError";
33
+ }
34
+ /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
35
+ get code() {
36
+ return this.errors[0]?.code;
37
+ }
38
+ }
2
39
  export class ViewModelShell {
3
40
  options;
4
41
  currentVm = null;
@@ -25,9 +62,25 @@ export class ViewModelShell {
25
62
  const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
26
63
  const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
27
64
  const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
28
- if (!res.ok)
65
+ // 1.0.0 — parse-then-branch: always parse the body even on 4xx/5xx so the
66
+ // structured envelope is available. The ok:false check throws BEFORE the
67
+ // currentVm/currentState writes below — D-15 runtime hardening (throw-before-
68
+ // write ordering means the shell never mutates state from a failure body).
69
+ let body;
70
+ try {
71
+ body = (await res.json());
72
+ }
73
+ catch (_parseErr) {
74
+ // Non-JSON body on a 4xx/5xx (proxy error page, 502, etc.).
75
+ // Fall back to a plain Error — the body was never a VMS envelope.
29
76
  throw new Error(`${res.status} ${res.statusText}`);
30
- const body = (await res.json());
77
+ }
78
+ if (body.ok === false) {
79
+ // D-15 runtime hardening: throw BEFORE currentVm/currentState are written.
80
+ // Even if the server (incorrectly) sent vm/state on an ok:false response,
81
+ // neither field is consumed — type-erosion-safe.
82
+ throw new VmsActionError(body.errors ?? [{ message: `${res.status} ${res.statusText}` }], res.status);
83
+ }
31
84
  this.currentVm = body.vm;
32
85
  this.currentState = body.state;
33
86
  // 0.14.0 — apply the unload guard from the initial-load response too. The
@@ -37,7 +90,7 @@ export class ViewModelShell {
37
90
  // 0.16.0 — same for the busy lockout.
38
91
  this.serverBusy = body.busy ?? false;
39
92
  this.syncBusy();
40
- adapter.render(body.vm, (action) => this.dispatch(action));
93
+ adapter.render(body.vm, (action) => this.dispatch(action), this.stateAccessForAdapter());
41
94
  this.schedulePoll(body.nextPollIn);
42
95
  }
43
96
  catch (err) {
@@ -75,7 +128,10 @@ export class ViewModelShell {
75
128
  onLoading?.(true);
76
129
  }
77
130
  const form = new FormData();
78
- form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
131
+ // Phase 6 wire-shape break: `_action` carries the action name only.
132
+ // The state at the input's bind path holds whatever value the previous
133
+ // `context` payload used to carry; the server reads it from there.
134
+ form.append("_action", JSON.stringify({ name: action.name }));
79
135
  form.append("_state", JSON.stringify(this.currentState));
80
136
  if (action.files) {
81
137
  for (const [name, file] of Object.entries(action.files)) {
@@ -98,9 +154,27 @@ export class ViewModelShell {
98
154
  else {
99
155
  res = await fetch(actionEndpoint, init);
100
156
  }
101
- if (!res.ok)
102
- throw new Error(`Action '${action.name}' failed: ${res.status}`);
103
- this.processResponse((await res.json()));
157
+ // 1.0.0 — parse-then-branch: always parse the body even on 4xx/5xx so the
158
+ // structured envelope is available to construct VmsActionError. The existing
159
+ // catch arm below re-renders currentVm on error (D-15 behavior) — VmsActionError
160
+ // flows through that same path since it IS an Error instance.
161
+ let body;
162
+ try {
163
+ body = (await res.json());
164
+ }
165
+ catch (_parseErr) {
166
+ // Non-JSON body on a 4xx/5xx (proxy error page, 502, etc.).
167
+ // Fall back to a plain Error — the body was never a VMS envelope.
168
+ throw new Error(`Action '${action.name}' failed: ${res.status} ${res.statusText}`);
169
+ }
170
+ if (body.ok === false) {
171
+ // D-15 runtime hardening: throw BEFORE this.processResponse(body) runs.
172
+ // The throw skips processResponse entirely, so currentVm/currentState are
173
+ // NOT updated from the failure body — even if the server (incorrectly) sent
174
+ // vm/state on an ok:false response. Type-erosion-safe.
175
+ throw new VmsActionError(body.errors ?? [{ message: `Action '${action.name}' failed: ${res.status}` }], res.status);
176
+ }
177
+ this.processResponse(body);
104
178
  }
105
179
  catch (err) {
106
180
  const error = err instanceof Error ? err : new Error(String(err));
@@ -112,7 +186,7 @@ export class ViewModelShell {
112
186
  // Skipped when no VM has loaded yet (pre-initial-load dispatch is
113
187
  // already an error case handled above; currentVm stays null there).
114
188
  if (this.currentVm !== null) {
115
- this.options.adapter.render(this.currentVm, (a) => this.dispatch(a));
189
+ this.options.adapter.render(this.currentVm, (a) => this.dispatch(a), this.stateAccessForAdapter());
116
190
  }
117
191
  }
118
192
  finally {
@@ -128,6 +202,16 @@ export class ViewModelShell {
128
202
  push(response) {
129
203
  if (this.dispatching)
130
204
  return;
205
+ // 1.0.0 — parse-then-branch for push. External push consumers (SSE, WebSocket)
206
+ // may feed ok:false responses (e.g. a server-pushed error notification). Route
207
+ // them to onError WITHOUT calling processResponse — currentVm/currentState
208
+ // are NOT updated (D-15 runtime hardening for the push path).
209
+ // status: 0 because there was no HTTP transaction in an external push.
210
+ if (response.ok === false) {
211
+ const err = new VmsActionError(response.errors ?? [{ message: "Server pushed ok:false envelope" }], 0);
212
+ this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
213
+ return;
214
+ }
131
215
  this.processResponse(response);
132
216
  }
133
217
  stopPolling() {
@@ -138,6 +222,44 @@ export class ViewModelShell {
138
222
  }
139
223
  getCurrentVm() { return this.currentVm; }
140
224
  getCurrentState() { return this.currentState; }
225
+ /**
226
+ * Read the bound state value at a dotted path (e.g. "fields.title",
227
+ * "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
228
+ * render an input's current value. Returns `undefined` when any segment
229
+ * along the path is missing — adapters treat that as "no value set" and
230
+ * render the appropriate empty form (empty string, unchecked, etc.).
231
+ *
232
+ * This is half of the bind-path seam introduced in Phase 6: the shell holds
233
+ * state mutably so that input events can update it (via `stateWrite`)
234
+ * without round-tripping to the server until a real dispatch fires.
235
+ */
236
+ stateRead(path) {
237
+ return readPath(this.currentState, path);
238
+ }
239
+ /**
240
+ * Write a value into state at a dotted path. The shell mutates the held
241
+ * state object in place at the bind path; the next dispatch sends the
242
+ * updated state to the server. Intermediate objects/arrays are created on
243
+ * demand (numeric next segment → array, else object).
244
+ *
245
+ * This is the other half of the bind-path seam — the BrowserAdapter (or any
246
+ * adapter that supports user input) calls this on every keystroke / change
247
+ * event so drafts ARE state.
248
+ */
249
+ stateWrite(path, value) {
250
+ this.currentState = writePath(this.currentState, path, value);
251
+ }
252
+ /**
253
+ * Build the read/write seam the Adapter receives as its third render arg.
254
+ * Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
255
+ * reference to the shell.
256
+ */
257
+ stateAccessForAdapter() {
258
+ return {
259
+ read: (path) => this.stateRead(path),
260
+ write: (path, value) => this.stateWrite(path, value),
261
+ };
262
+ }
141
263
  failCapability(capability, detail) {
142
264
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
143
265
  `server response requires it (${detail}). This is a hard failure, not a no-op: ` +
@@ -191,7 +313,7 @@ export class ViewModelShell {
191
313
  }
192
314
  this.currentVm = body.vm;
193
315
  this.currentState = body.state;
194
- this.options.adapter.render(body.vm, (a) => this.dispatch(a));
316
+ this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
195
317
  this.schedulePoll(body.nextPollIn);
196
318
  }
197
319
  schedulePoll(nextPollIn) {
@@ -246,6 +368,105 @@ export class ViewModelShell {
246
368
  }
247
369
  }
248
370
  }
371
+ // ─── Bind-path walkers (file-private; no platform globals) ───────────────────
372
+ //
373
+ // Tiny JSON walk + write helpers consumed by stateRead/stateWrite. Numeric
374
+ // segments index into arrays when the current value is an array; otherwise
375
+ // they're object keys. writePath creates intermediate objects/arrays on
376
+ // demand when the next segment shape implies one. Bind paths arrive trimmed
377
+ // of leading/trailing dots; an empty path writes to the root.
378
+ // Reject segments that would mutate Object.prototype on write or expose it on
379
+ // read. The bind string is server-controlled today, but stateWrite() is a
380
+ // public method and demos build bind paths dynamically; the guard is defense
381
+ // in depth so consumer code can never turn a path string into prototype pollution.
382
+ function isUnsafeSegment(seg) {
383
+ return seg === "__proto__" || seg === "constructor" || seg === "prototype";
384
+ }
385
+ function readPath(obj, path) {
386
+ // Defense: a bind-less input that somehow reached the renderer would call
387
+ // here with path=undefined. The wire contract guarantees `bind` on every
388
+ // input, but the runtime should not crash if a malformed tree leaks
389
+ // through — return undefined and let the field render as empty.
390
+ if (path == null || path === "")
391
+ return path == null ? undefined : obj;
392
+ const segs = path.split(".");
393
+ let cur = obj;
394
+ for (const seg of segs) {
395
+ if (isUnsafeSegment(seg))
396
+ return undefined;
397
+ if (cur == null)
398
+ return undefined;
399
+ if (Array.isArray(cur)) {
400
+ const idx = Number(seg);
401
+ if (!Number.isInteger(idx) || idx < 0)
402
+ return undefined;
403
+ cur = cur[idx];
404
+ }
405
+ else if (typeof cur === "object") {
406
+ cur = cur[seg];
407
+ }
408
+ else {
409
+ return undefined;
410
+ }
411
+ }
412
+ return cur;
413
+ }
414
+ function writePath(obj, path, value) {
415
+ // Defense: drop writes from bind-less inputs (see readPath).
416
+ if (path == null)
417
+ return obj;
418
+ if (path === "")
419
+ return value;
420
+ const segs = path.split(".");
421
+ for (const seg of segs) {
422
+ if (isUnsafeSegment(seg))
423
+ return obj;
424
+ }
425
+ // Bootstrap a root if the current state is null/undefined; choose the shape
426
+ // implied by the first segment (numeric ⇒ array, else object).
427
+ let root = obj;
428
+ if (root == null || typeof root !== "object") {
429
+ root = isArrayIndexSegment(segs[0]) ? [] : {};
430
+ }
431
+ let cur = root;
432
+ for (let i = 0; i < segs.length - 1; i++) {
433
+ const seg = segs[i];
434
+ if (Array.isArray(cur)) {
435
+ const idx = Number(seg);
436
+ let nxt = cur[idx];
437
+ if (nxt == null || typeof nxt !== "object") {
438
+ // Intermediate slot creation: default to object. The next segment's
439
+ // shape can't be inferred safely (numeric keys appear in both arrays
440
+ // and maps keyed by id), so the round-trip-safe default is {}. The
441
+ // root bootstrap above remains the only place the array heuristic
442
+ // fires — there we genuinely have no parent shape to honor.
443
+ nxt = {};
444
+ cur[idx] = nxt;
445
+ }
446
+ cur = nxt;
447
+ }
448
+ else {
449
+ const o = cur;
450
+ let nxt = o[seg];
451
+ if (nxt == null || typeof nxt !== "object") {
452
+ nxt = {};
453
+ o[seg] = nxt;
454
+ }
455
+ cur = nxt;
456
+ }
457
+ }
458
+ const last = segs[segs.length - 1];
459
+ if (Array.isArray(cur)) {
460
+ cur[Number(last)] = value;
461
+ }
462
+ else {
463
+ cur[last] = value;
464
+ }
465
+ return root;
466
+ }
467
+ function isArrayIndexSegment(seg) {
468
+ return /^[0-9]+$/.test(seg);
469
+ }
249
470
  // ─── Download helpers (file-private; no platform globals) ────────────────────
250
471
  // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
251
472
  // off the check:core-globals denylist. `URL.createObjectURL` is browser-only