@ashley-shrok/viewmodel-shell 3.2.0 → 3.3.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/README.md CHANGED
@@ -33,7 +33,7 @@ const shell = new ViewModelShell({
33
33
  shell.load();
34
34
  ```
35
35
 
36
- If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [GitHub repo](https://github.com/ashley-shrok/ViewModelShell) into your project and update the namespace that file is the full backend record set (`ViewNode`, `PageNode`, `FormNode`, `ShellResponse<TState>`, `ActionPayload<TState>`, etc.).
36
+ If your backend is .NET: install the [`AshleyShrok.ViewModelShell`](https://www.nuget.org/packages/AshleyShrok.ViewModelShell) NuGet package (`dotnet add package AshleyShrok.ViewModelShell`). It ships the full backend record set under the `ViewModelShell` namespace (`ViewNode`, `PageNode`, `FormNode`, `ShellResponse<TState>`, `ActionPayload<TState>`, etc.) — version-aligned with this npm package on major.minor.
37
37
 
38
38
  For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
39
39
 
package/dist/browser.js CHANGED
@@ -240,6 +240,18 @@ export class BrowserAdapter {
240
240
  case "copy-button": return this.copyButton(n, parent);
241
241
  case "divider": return this.divider(n, parent);
242
242
  case "fits": return this.fits(n, parent, on);
243
+ default: {
244
+ // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
245
+ // Runtime trees are server-controlled JSON, so an unknown/forward-version
246
+ // node type CAN reach here at runtime even though the union is
247
+ // exhaustive at compile time. We keep rendering the rest of the tree
248
+ // (forward-compatible, like unknown sideEffect types) but warn so the
249
+ // node doesn't just vanish without a trace.
250
+ const unknownType = n.type;
251
+ console.warn(`[viewmodel-shell] Unknown node type ${JSON.stringify(unknownType)} — ` +
252
+ `rendering nothing for it. The client may be older than the server's tree.`);
253
+ return;
254
+ }
243
255
  }
244
256
  }
245
257
  kids(nodes, parent, on) {
@@ -771,6 +783,11 @@ export class BrowserAdapter {
771
783
  inp.type = "checkbox";
772
784
  inp.className = "vms-checkbox__input";
773
785
  inp.name = n.name;
786
+ // Stable id so keyboard focus survives a re-render (poll or action). The
787
+ // wrapping <label> gives the click/label association; the id is purely for
788
+ // focus restore. Namespaced distinctly from field() ids (vms-${name}) to
789
+ // avoid an id collision when a field and a standalone checkbox share a name.
790
+ inp.id = `vms-checkbox-${n.name}`;
774
791
  inp.checked = Boolean(this.sa.read(n.bind));
775
792
  const mark = document.createElement("span");
776
793
  mark.className = "vms-checkbox__mark";
@@ -868,6 +885,9 @@ export class BrowserAdapter {
868
885
  const btn = document.createElement("button");
869
886
  btn.className = `vms-tabs__tab${tab.value === n.selected ? " vms-tabs__tab--active" : ""}`;
870
887
  btn.textContent = tab.label;
888
+ // Stable id so focus survives the re-render a tab click triggers (and any
889
+ // poll re-render) — render()'s restore keys off id.
890
+ btn.id = `vms-tab-${n.bind}-${tab.value}`;
871
891
  btn.setAttribute("role", "tab");
872
892
  btn.setAttribute("aria-selected", String(tab.value === n.selected));
873
893
  btn.addEventListener("click", () => {
@@ -887,16 +907,25 @@ export class BrowserAdapter {
887
907
  cls += ` vms-image--${n.shape}`;
888
908
  img.className = cls;
889
909
  img.src = n.src;
890
- if (n.alt != null)
891
- img.alt = n.alt;
910
+ // Always set alt: a present alt for meaningful images, an explicit empty
911
+ // string for decorative ones (alt="" tells assistive tech to skip it,
912
+ // whereas a missing alt may make it announce the src/URL).
913
+ img.alt = n.alt ?? "";
892
914
  parent.appendChild(img);
893
915
  }
894
916
  progress(n, parent) {
895
917
  const track = document.createElement("div");
896
918
  track.className = "vms-progress";
919
+ // Clamp to 0–100 (the documented range): an out-of-range value would
920
+ // otherwise overflow the track or render a negative-width bar.
921
+ const value = Math.max(0, Math.min(100, n.value));
922
+ track.setAttribute("role", "progressbar");
923
+ track.setAttribute("aria-valuemin", "0");
924
+ track.setAttribute("aria-valuemax", "100");
925
+ track.setAttribute("aria-valuenow", String(value));
897
926
  const bar = document.createElement("div");
898
927
  bar.className = "vms-progress__bar";
899
- bar.style.width = `${n.value}%`;
928
+ bar.style.width = `${value}%`;
900
929
  track.appendChild(bar);
901
930
  parent.appendChild(track);
902
931
  }
@@ -1018,6 +1047,12 @@ export class BrowserAdapter {
1018
1047
  inp.type = "text";
1019
1048
  inp.className = "vms-table__filter-input";
1020
1049
  inp.dataset.col = col.key;
1050
+ // Stable id so render()'s focus+caret restore can re-find this input
1051
+ // after a re-render — critical because a silent poll can fire mid-
1052
+ // keystroke while the user is typing a filter (the canonical
1053
+ // workflow-table pattern). Without an id the value survives (it's
1054
+ // bound state) but focus/caret are lost on every poll tick.
1055
+ inp.id = `vms-tablefilter-${col.key}`;
1021
1056
  const bindPath = n.filterBinds?.[col.key];
1022
1057
  const bound = bindPath != null ? this.sa.read(bindPath) : undefined;
1023
1058
  inp.value = bound != null
package/dist/index.d.ts CHANGED
@@ -192,7 +192,7 @@ export interface ListNode {
192
192
  export interface ListItemNode {
193
193
  type: "list-item";
194
194
  id?: string;
195
- /** Row lifecycle/selection STATE (NOT severity — that's `tone`). A freeform, app-extensible token; the framework ships styling for `active` (selected), `done`, `disabled`, `high` (priority), `running`, `moving`. Appended as a BEM modifier: vms-list-item--{state}. Orthogonal to `tone` (a row can be `state:"active"` AND `tone:"danger"`). */
195
+ /** Row lifecycle/selection STATE (NOT severity — that's `tone`). A freeform, app-extensible token; the framework ships list-item styling for `active` (selected), `done`, `disabled`, and `high` (priority). Appended as a BEM modifier: vms-list-item--{state}. An unrecognized state renders an unstyled class (it still round-trips; just no shipped rule). Orthogonal to `tone` (a row can be `state:"active"` AND `tone:"danger"`). (TableRow additionally ships a `running` style; ListItem does not yet.) */
196
196
  state?: string;
197
197
  /** Semantic intent/severity — the universal status tone axis (closed). Emits .vms-list-item--{tone} (colored accent border, reusing the shared tokens). Omitted = neutral. */
198
198
  tone?: "danger" | "warning" | "success" | "info";
package/dist/index.js CHANGED
@@ -311,9 +311,21 @@ export class ViewModelShell {
311
311
  }
312
312
  return;
313
313
  }
314
- this.currentVm = body.vm;
315
- this.currentState = body.state;
316
- this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
314
+ // C2 (3.3.0) a non-redirect response MAY legitimately omit `vm` (e.g. a
315
+ // side-effects-only or poll-keepalive response: "persist to storage and
316
+ // keep polling, but don't rebuild the view"). Do NOT blank the screen by
317
+ // rendering `undefined` — keep the current view, update state only if the
318
+ // server sent fresh state, and still schedule the next poll. Render only
319
+ // when a fresh tree actually arrived.
320
+ if (body.vm != null) {
321
+ this.currentVm = body.vm;
322
+ if (body.state !== undefined)
323
+ this.currentState = body.state;
324
+ this.options.adapter.render(body.vm, (a) => this.dispatch(a), this.stateAccessForAdapter());
325
+ }
326
+ else if (body.state !== undefined) {
327
+ this.currentState = body.state;
328
+ }
317
329
  this.schedulePoll(body.nextPollIn);
318
330
  }
319
331
  schedulePoll(nextPollIn) {
package/dist/server.js CHANGED
@@ -180,6 +180,16 @@ function collectActions(node, enclosingForm, out) {
180
180
  }
181
181
  return;
182
182
  }
183
+ case "fits": {
184
+ // FitsNode.children are full ViewNode[] (can hold forms, buttons,
185
+ // sections with action/link) — the renderer picks ONE at runtime but
186
+ // every candidate ships on the wire, so all must be validated. Without
187
+ // this arm the entire fits subtree skipped action-name uniqueness checks.
188
+ const fits = node;
189
+ for (const child of fits.children)
190
+ collectActions(child, enclosingForm, out);
191
+ return;
192
+ }
183
193
  // Nodes with no dispatch-bearing actions of their own:
184
194
  // text, link, image, stat-bar, progress, copy-button
185
195
  default:
@@ -328,6 +338,14 @@ function walkForSectionAction(node, outerInteractive) {
328
338
  }
329
339
  return;
330
340
  }
341
+ case "fits": {
342
+ // A fits candidate can itself be a section with action/link (or contain
343
+ // one), so the nested-section-interaction rules must descend here too.
344
+ const fits = node;
345
+ for (const child of fits.children)
346
+ walkForSectionAction(child, outerInteractive);
347
+ return;
348
+ }
331
349
  // Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
332
350
  // tabs, progress, table, copy-button) carry no SectionNode descendants —
333
351
  // TableNode rows hold strings + per-row controls, not sections.
@@ -369,6 +387,18 @@ export function parseJsonAction(body) {
369
387
  if (typeof parsed.name !== "string" || parsed.name === "") {
370
388
  throw new Error("Missing required 'name' field in action payload");
371
389
  }
390
+ // C4 (3.3.0) — require `state`. The shell always sends it, but a hand-rolled
391
+ // curl/agent caller that posts `{name}` only would otherwise run the handler
392
+ // with `undefined` state and crash on the first property access → a 500
393
+ // uncaught_exception, the wrong error class. A missing/null state is a
394
+ // malformed request the caller can fix, so surface it as a 400 parse_error
395
+ // (this throw is caught by createAction's parse arm). An EMPTY object `{}` is
396
+ // a valid state and is left alone.
397
+ if (parsed.state == null) {
398
+ throw new Error("Missing required 'state' field in action payload. The action wire is " +
399
+ "{name, state} — echo back the state from the GET response (or the prior " +
400
+ "action response); send {} only if the app's state really is empty.");
401
+ }
372
402
  return {
373
403
  name: parsed.name,
374
404
  state: parsed.state,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.2.0",
3
+ "version": "3.3.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 — a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -296,7 +296,7 @@ body {
296
296
  .vms-maxw--three-quarters { max-inline-size: 75%; }
297
297
  .vms-maxw--prose { max-inline-size: min(65ch, 100%); }
298
298
 
299
- /* ── Layout preset: switcher (SWITCH-01/02 / 1.13.0) — the Every-Layout Switcher,
299
+ /* ── Layout preset: switcher (SWITCH-01/02 / npm 1.12.0) — the Every-Layout Switcher,
300
300
  zero @media. N equal items flip all-row ↔ all-stack ATOMICALLY via a negative
301
301
  `flex-basis`: above the threshold the basis `(threshold - 100%) * 999` goes
302
302
  hugely negative → clamped to 0 → all children share one row; below it the
@@ -439,6 +439,14 @@ body {
439
439
  flex-wrap: wrap;
440
440
  }
441
441
  .vms-form--inline > .vms-field { flex: 1 1 12rem; }
442
+ /* #23 — inline forms bottom-align their row (align-items: flex-end), but the
443
+ base align-self: flex-start on the implicit submit button and the buttons[]
444
+ row escapes that (an item's align-self beats the parent's align-items), so
445
+ the submit would top-anchor while the fields hang at the bottom. Drop those
446
+ direct children back to auto so the parent's flex-end wins — the same
447
+ override the modal footer already applies to .vms-button. */
448
+ .vms-form--inline > .vms-button,
449
+ .vms-form--inline > .vms-form__buttons { align-self: auto; }
442
450
  /* Multi-action submit buttons row (0.10.0 / #15) — groups the form's
443
451
  buttons[] entries on one line, like a dialog action bar. align-self
444
452
  flex-start keeps the row from stretching full-width in the stacked form. */