@ashley-shrok/viewmodel-shell 3.2.1 → 3.4.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.d.ts CHANGED
@@ -60,6 +60,11 @@ export declare class BrowserAdapter implements Adapter {
60
60
  * When `action` is set, it fires on Enter (text-like) or change (select) —
61
61
  * the new value is already in state by that point. */
62
62
  private field;
63
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
64
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
65
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
66
+ * return before this). */
67
+ private decorateField;
63
68
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
64
69
  * write to state then dispatch the action name (if any). */
65
70
  private checkbox;
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) {
@@ -760,8 +772,61 @@ export class BrowserAdapter {
760
772
  }
761
773
  wrapper.appendChild(inp);
762
774
  }
775
+ this.decorateField(wrapper, n);
763
776
  parent.appendChild(wrapper);
764
777
  }
778
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
779
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
780
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
781
+ * return before this). */
782
+ decorateField(wrapper, n) {
783
+ const control = wrapper.querySelector(".vms-field__input");
784
+ if (n.disabled) {
785
+ if (control)
786
+ control.disabled = true;
787
+ wrapper.classList.add("vms-field--disabled");
788
+ }
789
+ if (n.readonly && control && "readOnly" in control) {
790
+ control.readOnly = true;
791
+ }
792
+ // Native input constraints — min/max/step on <input>, maxLength on
793
+ // <input>/<textarea>. Strings pass straight to the attribute.
794
+ if (control instanceof HTMLInputElement) {
795
+ if (n.min != null)
796
+ control.min = n.min;
797
+ if (n.max != null)
798
+ control.max = n.max;
799
+ if (n.step != null)
800
+ control.step = n.step;
801
+ }
802
+ if (n.maxLength != null &&
803
+ (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
804
+ control.maxLength = n.maxLength;
805
+ }
806
+ const describedBy = [];
807
+ if (n.help != null && n.help !== "") {
808
+ const helpEl = document.createElement("div");
809
+ helpEl.className = "vms-field__help";
810
+ helpEl.id = `vms-${n.name}-help`;
811
+ helpEl.textContent = n.help;
812
+ wrapper.appendChild(helpEl);
813
+ describedBy.push(helpEl.id);
814
+ }
815
+ if (n.error != null && n.error !== "") {
816
+ wrapper.classList.add("vms-field--error");
817
+ const errEl = document.createElement("div");
818
+ errEl.className = "vms-field__error";
819
+ errEl.id = `vms-${n.name}-error`;
820
+ errEl.setAttribute("role", "alert");
821
+ errEl.textContent = n.error;
822
+ wrapper.appendChild(errEl);
823
+ describedBy.push(errEl.id);
824
+ control?.setAttribute("aria-invalid", "true");
825
+ }
826
+ if (control && describedBy.length > 0) {
827
+ control.setAttribute("aria-describedby", describedBy.join(" "));
828
+ }
829
+ }
765
830
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
766
831
  * write to state then dispatch the action name (if any). */
767
832
  checkbox(n, parent, on) {
@@ -771,6 +836,11 @@ export class BrowserAdapter {
771
836
  inp.type = "checkbox";
772
837
  inp.className = "vms-checkbox__input";
773
838
  inp.name = n.name;
839
+ // Stable id so keyboard focus survives a re-render (poll or action). The
840
+ // wrapping <label> gives the click/label association; the id is purely for
841
+ // focus restore. Namespaced distinctly from field() ids (vms-${name}) to
842
+ // avoid an id collision when a field and a standalone checkbox share a name.
843
+ inp.id = `vms-checkbox-${n.name}`;
774
844
  inp.checked = Boolean(this.sa.read(n.bind));
775
845
  const mark = document.createElement("span");
776
846
  mark.className = "vms-checkbox__mark";
@@ -792,9 +862,16 @@ export class BrowserAdapter {
792
862
  button(n, parent, on) {
793
863
  const btn = document.createElement("button");
794
864
  btn.type = "button";
795
- btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
865
+ btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}${n.disabled ? " vms-button--disabled" : ""}`;
796
866
  btn.textContent = n.label;
867
+ if (n.disabled)
868
+ btn.disabled = true;
797
869
  btn.addEventListener("click", () => {
870
+ // Forms-completeness (3.4.0): a disabled button never dispatches. (Native
871
+ // `disabled` already suppresses the click, but guard anyway in case the
872
+ // attribute was cleared out-of-band.)
873
+ if (n.disabled)
874
+ return;
798
875
  // pendingLabel: instant client-side feedback. Swap text + add
799
876
  // .vms-button--pending BEFORE handing off to the dispatcher. On
800
877
  // success the next render replaces the button entirely. On dispatch
@@ -868,6 +945,9 @@ export class BrowserAdapter {
868
945
  const btn = document.createElement("button");
869
946
  btn.className = `vms-tabs__tab${tab.value === n.selected ? " vms-tabs__tab--active" : ""}`;
870
947
  btn.textContent = tab.label;
948
+ // Stable id so focus survives the re-render a tab click triggers (and any
949
+ // poll re-render) — render()'s restore keys off id.
950
+ btn.id = `vms-tab-${n.bind}-${tab.value}`;
871
951
  btn.setAttribute("role", "tab");
872
952
  btn.setAttribute("aria-selected", String(tab.value === n.selected));
873
953
  btn.addEventListener("click", () => {
@@ -887,16 +967,25 @@ export class BrowserAdapter {
887
967
  cls += ` vms-image--${n.shape}`;
888
968
  img.className = cls;
889
969
  img.src = n.src;
890
- if (n.alt != null)
891
- img.alt = n.alt;
970
+ // Always set alt: a present alt for meaningful images, an explicit empty
971
+ // string for decorative ones (alt="" tells assistive tech to skip it,
972
+ // whereas a missing alt may make it announce the src/URL).
973
+ img.alt = n.alt ?? "";
892
974
  parent.appendChild(img);
893
975
  }
894
976
  progress(n, parent) {
895
977
  const track = document.createElement("div");
896
978
  track.className = "vms-progress";
979
+ // Clamp to 0–100 (the documented range): an out-of-range value would
980
+ // otherwise overflow the track or render a negative-width bar.
981
+ const value = Math.max(0, Math.min(100, n.value));
982
+ track.setAttribute("role", "progressbar");
983
+ track.setAttribute("aria-valuemin", "0");
984
+ track.setAttribute("aria-valuemax", "100");
985
+ track.setAttribute("aria-valuenow", String(value));
897
986
  const bar = document.createElement("div");
898
987
  bar.className = "vms-progress__bar";
899
- bar.style.width = `${n.value}%`;
988
+ bar.style.width = `${value}%`;
900
989
  track.appendChild(bar);
901
990
  parent.appendChild(track);
902
991
  }
@@ -1018,6 +1107,12 @@ export class BrowserAdapter {
1018
1107
  inp.type = "text";
1019
1108
  inp.className = "vms-table__filter-input";
1020
1109
  inp.dataset.col = col.key;
1110
+ // Stable id so render()'s focus+caret restore can re-find this input
1111
+ // after a re-render — critical because a silent poll can fire mid-
1112
+ // keystroke while the user is typing a filter (the canonical
1113
+ // workflow-table pattern). Without an id the value survives (it's
1114
+ // bound state) but focus/caret are lost on every poll tick.
1115
+ inp.id = `vms-tablefilter-${col.key}`;
1021
1116
  const bindPath = n.filterBinds?.[col.key];
1022
1117
  const bound = bindPath != null ? this.sa.read(bindPath) : undefined;
1023
1118
  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";
@@ -241,6 +241,35 @@ export interface FieldNode {
241
241
  label?: string;
242
242
  placeholder?: string;
243
243
  required?: boolean;
244
+ /** Disables the control (greys it out, blocks input, excludes it from form
245
+ * submission). Server-owned. Emits `.vms-field--disabled` on the wrapper +
246
+ * the native `disabled` attribute. Omitted = enabled. */
247
+ disabled?: boolean;
248
+ /** Read-only: the value is shown and submitted but the user can't edit it
249
+ * (distinct from `disabled`, which also greys out + excludes from submit).
250
+ * Text-like inputs + textarea only. Emits the native `readonly` attribute. */
251
+ readonly?: boolean;
252
+ /** Per-field validation error message, rendered inline below the control as
253
+ * `.vms-field__error` (role="alert"); also sets the wrapper's
254
+ * `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
255
+ * message into `aria-describedby`. The view-side complement to the
256
+ * response-level `rejected` channel — set it on the offending field when you
257
+ * build the tree. Omitted = no error shown. */
258
+ error?: string;
259
+ /** Hint/help text rendered below the control as `.vms-field__help` and wired
260
+ * into the control's `aria-describedby`. Omitted = no hint. */
261
+ help?: string;
262
+ /** `min`/`max`/`step` passed through to the native input attribute for
263
+ * `number`/`range`/date-time inputs. STRINGS (HTML-attribute semantics): a
264
+ * numeric bound (`"0"`), a date bound (`"2020-01-01"`), and `step` also
265
+ * accepts `"any"`. Typed as strings (not numbers) so the wire stays
266
+ * byte-identical across backends. Omitted = no constraint. */
267
+ min?: string;
268
+ max?: string;
269
+ step?: string;
270
+ /** Maximum input length (characters) for text-like inputs + textarea →
271
+ * native `maxlength`. Integer. Omitted = no cap. */
272
+ maxLength?: number;
244
273
  options?: Array<{
245
274
  value: string;
246
275
  label: string;
@@ -279,6 +308,10 @@ export interface ButtonNode {
279
308
  * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
280
309
  * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
281
310
  width?: "auto" | "full";
311
+ /** Disables the button — greys it out (`.vms-button--disabled` + native
312
+ * `disabled`) and the renderer will NOT dispatch its action on click.
313
+ * Server-owned (e.g. a submit gated on a precondition). Omitted = enabled. */
314
+ disabled?: boolean;
282
315
  /** Transient label shown from click until the dispatch resolves (response
283
316
  * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
284
317
  * lifecycle pattern at a different beat: shown DURING the round-trip
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.1",
3
+ "version": "3.4.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
@@ -494,6 +494,27 @@ body {
494
494
  textarea.vms-field__input { min-height: 80px; }
495
495
  select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl); }
496
496
 
497
+ /* Forms-completeness (3.4.0) — help/error text + disabled/readonly/error states.
498
+ help is the muted hint; error is the danger message (role="alert"). */
499
+ .vms-field__help { font-size: var(--vms-text-sm); color: var(--vms-text-muted); }
500
+ .vms-field__error { font-size: var(--vms-text-sm); color: var(--vms-error); }
501
+ .vms-field--error .vms-field__input { border-color: var(--vms-error); }
502
+ .vms-field--error .vms-field__input:focus {
503
+ border-color: var(--vms-error);
504
+ box-shadow: 0 0 0 3px var(--vms-error-glow);
505
+ }
506
+ .vms-field__input:disabled,
507
+ .vms-field--disabled .vms-field__input {
508
+ opacity: 0.55;
509
+ cursor: not-allowed;
510
+ }
511
+ .vms-field--disabled .vms-field__label { opacity: 0.7; }
512
+ /* read-only: editable-looking but visibly inert background (not greyed like disabled). */
513
+ .vms-field__input:read-only:not([type="file"]) {
514
+ background: color-mix(in srgb, var(--vms-surface) 70%, var(--vms-border));
515
+ cursor: default;
516
+ }
517
+
497
518
  /* Code editor field — monospaced, tab-aware, no spell-check chrome.
498
519
  Apps add syntax highlighting via .vms-field--code-{language} hooks. */
499
520
  .vms-field__input--code {
@@ -617,6 +638,14 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
617
638
  pointer-events: none;
618
639
  }
619
640
 
641
+ /* Forms-completeness (3.4.0) — server-declared disabled button. */
642
+ .vms-button:disabled,
643
+ .vms-button--disabled {
644
+ opacity: 0.5;
645
+ cursor: not-allowed;
646
+ pointer-events: none;
647
+ }
648
+
620
649
  /* Inside a list item, a danger button is treated as the row's "X" affordance:
621
650
  muted and hidden until the row is hovered, then visible and red on hover. */
622
651
  .vms-list-item .vms-button--danger {