@ashley-shrok/viewmodel-shell 0.15.0 → 1.0.1

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. */
@@ -42,6 +52,16 @@ export interface Adapter {
42
52
  * targets (TUI) have no terminal equivalent. Modern browsers show a
43
53
  * generic "Leave site?" dialog; the message is not customizable. */
44
54
  setPreventUnload?(active: boolean): void;
55
+ /** 0.16.0 — visually lock the UI during periods where user dispatches will
56
+ * be dropped. Called by the shell on every transition with `active = true`
57
+ * when EITHER a user-initiated dispatch is in flight OR the server returned
58
+ * `ShellResponse.busy: true` on its most recent response. Polls (silent
59
+ * dispatches) bypass — they're the only way out of a server-busy state.
60
+ * `BrowserAdapter` toggles `.vms-busy` on its container; default CSS makes
61
+ * every interactive descendant non-clickable (cursor: wait + pointer-events:
62
+ * none), so a rapid checkbox click during an in-flight round-trip never
63
+ * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
+ setBusy?(active: boolean): void;
45
65
  }
46
66
  export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
47
67
  export interface PageNode {
@@ -88,12 +108,12 @@ export interface FormNode {
88
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). */
89
109
  layout?: "stack" | "inline";
90
110
  /** Multi-action submit buttons (#15). Each is a full ButtonNode (so
91
- * `variant` + `pendingLabel` apply) that, on activation, HARVESTS this
92
- * form's current field values into its `action.context` and dispatches
93
- * the same harvest the default submit performs, but carrying a different
94
- * action. Mirrors HTML's multiple submit buttons / `formaction`. A plain
95
- * ButtonNode placed in `children` keeps its no-harvest behavior; only
96
- * 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. */
97
117
  buttons?: ButtonNode[];
98
118
  children: ViewNode[];
99
119
  }
@@ -101,9 +121,10 @@ export interface FieldNode {
101
121
  type: "field";
102
122
  name: string;
103
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;
104
126
  label?: string;
105
127
  placeholder?: string;
106
- value?: string;
107
128
  required?: boolean;
108
129
  options?: Array<{
109
130
  value: string;
@@ -114,15 +135,18 @@ export interface FieldNode {
114
135
  * library (CodeMirror, Monaco, etc.) — the framework only ships
115
136
  * monospaced editable text, no coloring. */
116
137
  language?: string;
117
- /** 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. */
118
140
  action?: ActionEvent;
119
141
  }
120
142
  export interface CheckboxNode {
121
143
  type: "checkbox";
122
144
  name: string;
123
- checked: boolean;
145
+ /** Path into state where this input reads its current value and writes user changes (e.g. `fields.title`). */
146
+ bind: string;
124
147
  label?: string;
125
- /** 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. */
126
150
  action?: ActionEvent;
127
151
  }
128
152
  export interface ButtonNode {
@@ -172,11 +196,15 @@ export interface StatBarNode {
172
196
  export interface TabsNode {
173
197
  type: "tabs";
174
198
  selected: string;
175
- /** Base action. Adapter merges { value: tab.value } into context on click. */
176
- 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. */
177
204
  tabs: Array<{
178
205
  value: string;
179
206
  label: string;
207
+ action: ActionEvent;
180
208
  }>;
181
209
  }
182
210
  export interface ProgressNode {
@@ -209,25 +237,12 @@ export interface TableColumn {
209
237
  export interface TableRow {
210
238
  id?: string;
211
239
  cells: Record<string, string>;
212
- action?: ActionEvent;
240
+ /** Per-row action buttons. Each is a full ButtonNode with its own unique
241
+ * action name (e.g. `delete-row-42`, `close-ticket-42`) — per-row identity
242
+ * is encoded in the name, not as a separate context payload. */
243
+ actions?: ButtonNode[];
213
244
  variant?: string;
214
245
  }
215
- export interface TableSelection {
216
- /** Row ids that should render PRE-SELECTED on initial render — the server's
217
- * initial pre-selection. Subsequent toggles are purely client-side DOM state;
218
- * the server doesn't see them until a `buttons[]` click harvests them. */
219
- selectedIds: string[];
220
- /** When present, the adapter renders these as a bulk-action toolbar ABOVE
221
- * the table. On click, each button harvests the currently-checked rows from
222
- * the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
223
- * into its `context`. This is the only way to act on the selection — there
224
- * is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
225
- * because rapid clicks were dropped by the dispatch guard and the in-flight
226
- * response wiped the DOM). If a future release needs cross-page persistence
227
- * or live "N selected" indicators, the way back is a redesigned wire shape
228
- * (dispatch queueing + optimistic preservation), not the old `action` mode. */
229
- buttons?: ButtonNode[];
230
- }
231
246
  export interface TablePagination {
232
247
  /** 1-based current page. */
233
248
  page: number;
@@ -236,30 +251,38 @@ export interface TablePagination {
236
251
  /** Total rows across all pages — server-truth. The adapter renders the range
237
252
  * label and enables/disables prev/next from this; it does NOT slice. */
238
253
  totalRows: number;
239
- /** Dispatched on a page-control click. The adapter merges `{ page }` the
240
- * target 1-based page. */
241
- action: ActionEvent;
254
+ /** Dispatched on the prev page-control click. Carries an action name only
255
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
256
+ prevAction?: ActionEvent;
257
+ /** Dispatched on the next page-control click. Carries an action name only —
258
+ * the renderer writes the target page to TableNode.paginationBind before dispatch. */
259
+ nextAction?: ActionEvent;
242
260
  }
243
261
  export interface TableNode {
244
262
  type: "table";
245
263
  columns: TableColumn[];
246
264
  rows: TableRow[];
247
- sortColumn?: string;
248
- sortDirection?: "asc" | "desc";
249
- /** Base action. Adapter merges { column, direction } into context on header click. */
250
- sortAction?: ActionEvent;
251
- /** Base action. Adapter merges { column, value, filters } into context on Enter. */
265
+ /** Path into state where the current sort intent (`{column, direction}`) is read/written. */
266
+ sortBind?: string;
267
+ /** Per-column filter input bind paths. The renderer reads/writes filter
268
+ * values at these paths; the values then travel with the next dispatch. */
269
+ filterBinds?: Record<string, string>;
270
+ /** Path into state where the renderer writes the target page number before
271
+ * firing `pagination.prevAction` / `pagination.nextAction`. */
272
+ paginationBind?: string;
273
+ /** Per-column sort header click actions, keyed by column key. Each carries a
274
+ * unique action name — the renderer writes the new sort intent to `sortBind`
275
+ * before dispatching. */
276
+ sortActions?: Record<string, ActionEvent>;
277
+ /** One filter-dispatch action per table. The renderer fires this when the
278
+ * user submits the filter form; per-column filter values are already in
279
+ * state at the `filterBinds` paths. */
252
280
  filterAction?: ActionEvent;
253
- /** Per-row multi-select. When set, the adapter renders a leading checkbox
254
- * column + a header select-all checkbox and tints selected rows. `TableRow.id`
255
- * is REQUIRED on every row when selection is set — it's the address the
256
- * toggle action reports back. */
257
- selection?: TableSelection;
258
281
  /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
259
282
  * range + prev/next controls below the table. **The server slices `rows` to
260
283
  * the current page** — the adapter never paginates client-side (that would
261
284
  * break for DB-backed tables, which are most of them). By convention
262
- * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
285
+ * filter dispatches reset `page` to 1 on the server side, since
263
286
  * the row window changes underneath them. */
264
287
  pagination?: TablePagination;
265
288
  }
@@ -309,6 +332,36 @@ export interface ShellSideEffect {
309
332
  * when present; this is the fallback before the URL basename. */
310
333
  filename?: string;
311
334
  }
335
+ /** One entry in the structured error payload returned on `ok: false`. */
336
+ export interface ErrorEntry {
337
+ /** Absent when not tied to a specific input slot (parse errors, uncaught
338
+ * exceptions, etc.). Present when bound to a specific bind-path on an input. */
339
+ path?: string;
340
+ /** Human-readable description of the error. Always present. */
341
+ message: string;
342
+ /** Framework-set discriminator for the failure class. Initial vocabulary:
343
+ * "parse_error" | "unknown_action" | "invalid_tree" | "uncaught_exception".
344
+ * Absent when not applicable (e.g. BadRequest / D-08 path). */
345
+ code?: string;
346
+ }
347
+ /**
348
+ * Structured error surfaced via `onError` when the server returns `ok: false`.
349
+ * Extends `Error` so existing `onError` consumers that don't know about the
350
+ * envelope continue receiving a normal Error with a useful `.message`.
351
+ *
352
+ * Consumers that want the structured payload: `if (err instanceof VmsActionError) { ... err.errors ... }`.
353
+ *
354
+ * `status` is the HTTP status code (0 for push-originated errors with no
355
+ * HTTP transaction). `code` is a shortcut to `errors[0].code` for ergonomic
356
+ * branching on single-error responses.
357
+ */
358
+ export declare class VmsActionError extends Error {
359
+ readonly errors: ErrorEntry[];
360
+ readonly status: number;
361
+ constructor(errors: ErrorEntry[], status: number);
362
+ /** Shortcut to `errors[0]?.code`. Undefined when the first entry has no code. */
363
+ get code(): string | undefined;
364
+ }
312
365
  export interface ShellResponse {
313
366
  vm: ViewNode;
314
367
  state: unknown;
@@ -324,6 +377,18 @@ export interface ShellResponse {
324
377
  * `preventUnload: true` from each response; clear it when the work
325
378
  * completes (typically via polling). See `Adapter.setPreventUnload`. */
326
379
  preventUnload?: boolean;
380
+ /** 0.16.0 — when true, the shell locks the UI (drops user-initiated
381
+ * dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
382
+ * bypass so the server can clear the state. See `Adapter.setBusy`. */
383
+ busy?: boolean;
384
+ /** 1.0.0 — framework-set envelope flag. Present on every framework-rendered
385
+ * response; absent on hand-constructed legacy push bodies (treated as ok:true
386
+ * for backwards compatibility). On ok:false responses, only `errors[]` is
387
+ * consumed — any vm/state present in the body is IGNORED at runtime (D-15
388
+ * hardening: the shell throws before the currentVm/currentState writes run). */
389
+ ok?: boolean;
390
+ /** 1.0.0 — structured error entries. Present when `ok: false`. */
391
+ errors?: ErrorEntry[];
327
392
  }
328
393
  export declare class ViewModelShell {
329
394
  private options;
@@ -331,7 +396,10 @@ export declare class ViewModelShell {
331
396
  private currentState;
332
397
  private dispatching;
333
398
  private pollTimer;
399
+ private serverBusy;
400
+ private userDispatching;
334
401
  constructor(options: ShellOptions);
402
+ private syncBusy;
335
403
  load(params?: Record<string, string>): Promise<void>;
336
404
  dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
337
405
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
@@ -339,6 +407,35 @@ export declare class ViewModelShell {
339
407
  stopPolling(): void;
340
408
  getCurrentVm(): ViewNode | null;
341
409
  getCurrentState(): unknown;
410
+ /**
411
+ * Read the bound state value at a dotted path (e.g. "fields.title",
412
+ * "rows.42.selected"). Used by adapters (BrowserAdapter, TuiAdapter, …) to
413
+ * render an input's current value. Returns `undefined` when any segment
414
+ * along the path is missing — adapters treat that as "no value set" and
415
+ * render the appropriate empty form (empty string, unchecked, etc.).
416
+ *
417
+ * This is half of the bind-path seam introduced in Phase 6: the shell holds
418
+ * state mutably so that input events can update it (via `stateWrite`)
419
+ * without round-tripping to the server until a real dispatch fires.
420
+ */
421
+ stateRead(path: string): unknown;
422
+ /**
423
+ * Write a value into state at a dotted path. The shell mutates the held
424
+ * state object in place at the bind path; the next dispatch sends the
425
+ * updated state to the server. Intermediate objects/arrays are created on
426
+ * demand (numeric next segment → array, else object).
427
+ *
428
+ * This is the other half of the bind-path seam — the BrowserAdapter (or any
429
+ * adapter that supports user input) calls this on every keystroke / change
430
+ * event so drafts ARE state.
431
+ */
432
+ stateWrite(path: string, value: unknown): void;
433
+ /**
434
+ * Build the read/write seam the Adapter receives as its third render arg.
435
+ * Backed by `stateRead` / `stateWrite` so the adapter never needs a direct
436
+ * reference to the shell.
437
+ */
438
+ private stateAccessForAdapter;
342
439
  private failCapability;
343
440
  private processResponse;
344
441
  private schedulePoll;
package/dist/index.js CHANGED
@@ -1,13 +1,59 @@
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;
5
42
  currentState = null;
6
43
  dispatching = false;
7
44
  pollTimer = null;
45
+ // 0.16.0 — busy = serverBusy OR a user-initiated dispatch is in flight.
46
+ // Polls (silent=true dispatches) don't flip userDispatching so they never
47
+ // toggle the busy class — that's how a server-busy state stays continuously
48
+ // locked across many ticks without flicker.
49
+ serverBusy = false;
50
+ userDispatching = false;
8
51
  constructor(options) {
9
52
  this.options = options;
10
53
  }
54
+ syncBusy() {
55
+ this.options.adapter.setBusy?.(this.serverBusy || this.userDispatching);
56
+ }
11
57
  async load(params) {
12
58
  const { endpoint, adapter, onError, onLoading } = this.options;
13
59
  this.stopPolling();
@@ -16,16 +62,35 @@ export class ViewModelShell {
16
62
  const url = params ? `${endpoint}?${new URLSearchParams(params)}` : endpoint;
17
63
  const extraHeaders = this.options.getRequestHeaders ? await this.options.getRequestHeaders() : {};
18
64
  const res = await fetch(url, { headers: { Accept: "application/json", ...extraHeaders } });
19
- 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.
20
76
  throw new Error(`${res.status} ${res.statusText}`);
21
- 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
+ }
22
84
  this.currentVm = body.vm;
23
85
  this.currentState = body.state;
24
86
  // 0.14.0 — apply the unload guard from the initial-load response too. The
25
87
  // server may legitimately want it on at first paint (e.g. the page was
26
88
  // refreshed mid-work and the long action is still pending server-side).
27
89
  adapter.setPreventUnload?.(body.preventUnload ?? false);
28
- adapter.render(body.vm, (action) => this.dispatch(action));
90
+ // 0.16.0 same for the busy lockout.
91
+ this.serverBusy = body.busy ?? false;
92
+ this.syncBusy();
93
+ adapter.render(body.vm, (action) => this.dispatch(action), this.stateAccessForAdapter());
29
94
  this.schedulePoll(body.nextPollIn);
30
95
  }
31
96
  catch (err) {
@@ -37,6 +102,10 @@ export class ViewModelShell {
37
102
  }
38
103
  }
39
104
  async dispatch(action, silent = false) {
105
+ // 0.16.0 — drop user-initiated dispatches while server-busy. Polls (silent)
106
+ // bypass so the server can clear the busy state.
107
+ if (!silent && this.serverBusy)
108
+ return;
40
109
  if (this.dispatching)
41
110
  return;
42
111
  const { actionEndpoint, onError, onLoading } = this.options;
@@ -48,10 +117,21 @@ export class ViewModelShell {
48
117
  }
49
118
  try {
50
119
  this.dispatching = true;
51
- if (!silent)
120
+ if (!silent) {
121
+ // 0.16.0 — flag a user dispatch as in-flight + apply .vms-busy. This
122
+ // is what kills the "rapid clicks during a round-trip silently flip the
123
+ // checkbox" UX bug: by the time the user's second click arrives, the
124
+ // container has pointer-events: none and the click never reaches the
125
+ // input.
126
+ this.userDispatching = true;
127
+ this.syncBusy();
52
128
  onLoading?.(true);
129
+ }
53
130
  const form = new FormData();
54
- 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 }));
55
135
  form.append("_state", JSON.stringify(this.currentState));
56
136
  if (action.files) {
57
137
  for (const [name, file] of Object.entries(action.files)) {
@@ -74,9 +154,27 @@ export class ViewModelShell {
74
154
  else {
75
155
  res = await fetch(actionEndpoint, init);
76
156
  }
77
- if (!res.ok)
78
- throw new Error(`Action '${action.name}' failed: ${res.status}`);
79
- 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);
80
178
  }
81
179
  catch (err) {
82
180
  const error = err instanceof Error ? err : new Error(String(err));
@@ -88,19 +186,32 @@ export class ViewModelShell {
88
186
  // Skipped when no VM has loaded yet (pre-initial-load dispatch is
89
187
  // already an error case handled above; currentVm stays null there).
90
188
  if (this.currentVm !== null) {
91
- this.options.adapter.render(this.currentVm, (a) => this.dispatch(a));
189
+ this.options.adapter.render(this.currentVm, (a) => this.dispatch(a), this.stateAccessForAdapter());
92
190
  }
93
191
  }
94
192
  finally {
95
193
  this.dispatching = false;
96
- if (!silent)
194
+ if (!silent) {
195
+ this.userDispatching = false;
196
+ this.syncBusy();
97
197
  onLoading?.(false);
198
+ }
98
199
  }
99
200
  }
100
201
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
101
202
  push(response) {
102
203
  if (this.dispatching)
103
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
+ }
104
215
  this.processResponse(response);
105
216
  }
106
217
  stopPolling() {
@@ -111,6 +222,44 @@ export class ViewModelShell {
111
222
  }
112
223
  getCurrentVm() { return this.currentVm; }
113
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
+ }
114
263
  failCapability(capability, detail) {
115
264
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
116
265
  `server response requires it (${detail}). This is a hard failure, not a no-op: ` +
@@ -147,6 +296,9 @@ export class ViewModelShell {
147
296
  // wants a redirect to NOT be blocked by its own guard simply omits
148
297
  // preventUnload (or sets it false) on that response — standard pattern.
149
298
  adapter.setPreventUnload?.(body.preventUnload ?? false);
299
+ // 0.16.0 — likewise for the busy lockout.
300
+ this.serverBusy = body.busy ?? false;
301
+ this.syncBusy();
150
302
  if (body.redirect) {
151
303
  if (this.options.onRedirect) {
152
304
  this.options.onRedirect(body.redirect);
@@ -161,7 +313,7 @@ export class ViewModelShell {
161
313
  }
162
314
  this.currentVm = body.vm;
163
315
  this.currentState = body.state;
164
- this.options.adapter.render(body.vm, (a) => this.dispatch(a));
316
+ this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
165
317
  this.schedulePoll(body.nextPollIn);
166
318
  }
167
319
  schedulePoll(nextPollIn) {
@@ -216,6 +368,105 @@ export class ViewModelShell {
216
368
  }
217
369
  }
218
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
+ }
219
470
  // ─── Download helpers (file-private; no platform globals) ────────────────────
220
471
  // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
221
472
  // off the check:core-globals denylist. `URL.createObjectURL` is browser-only