@ashley-shrok/viewmodel-shell 3.3.0 → 3.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/agent-skill.md CHANGED
@@ -91,7 +91,8 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
91
91
  { "sideEffects": [
92
92
  { "type": "set-local-storage", "key": "auth_jwt", "value": "eyJ..." },
93
93
  { "type": "set-session-storage", "key": "draft_id", "value": "42" },
94
- { "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" }
94
+ { "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" },
95
+ { "type": "toast", "message": "Ticket #4012 closed.", "tone": "success" }
95
96
  ] }
96
97
  ```
97
98
 
@@ -100,6 +101,7 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
100
101
  | `set-local-storage` | Write `key`/`value` to platform localStorage (or your agent's equivalent persistent store). |
101
102
  | `set-session-storage` | Same as above for session-scoped storage. |
102
103
  | `download` | Fetch `url` (re-presenting your auth headers — see *Auth*) and save the bytes. Filename precedence: `Content-Disposition` > side-effect `filename` > URL basename > `"download"`. |
104
+ | `toast` | Show a transient confirmation: `message` (required) + optional `tone` (`danger`\|`warning`\|`success`\|`info`) + `durationMs`. A UX nicety — **fail-quiet**: an agent/adapter with no toast surface simply ignores it (nothing to persist or act on), so it carries no state and needs no acknowledgement. |
103
105
 
104
106
  **Forward-compat rule — silently ignore unknown verbs.** A future minor release may add new verbs. If you see a `type` you do not recognize, skip it; do not error. Honor or ignore per your policy.
105
107
 
package/dist/browser.d.ts CHANGED
@@ -12,6 +12,17 @@ export declare class BrowserAdapter implements Adapter {
12
12
  storage(scope: "local" | "session", key: string, value: string): void;
13
13
  saveFile(data: Blob, filename: string, _contentType: string): void;
14
14
  setBusy(active: boolean): void;
15
+ /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
16
+ * host region (.vms-toast-region) appended to <body> so toasts stack and
17
+ * survive the container's innerHTML wipe on each render(); appends a
18
+ * .vms-toast element (+ tone modifier) and auto-removes it after
19
+ * durationMs (default 4000), with a brief fade-out. This is the ONLY place
20
+ * toast DOM lives — core stays platform-agnostic (it just calls this verb).
21
+ * Fail-quiet by absence is the core's concern (it optional-chains the call). */
22
+ toast(message: string, opts?: {
23
+ tone?: string;
24
+ durationMs?: number;
25
+ }): void;
15
26
  private unloadHandler;
16
27
  setPreventUnload(active: boolean): void;
17
28
  transport(input: string, init: {
@@ -60,6 +71,11 @@ export declare class BrowserAdapter implements Adapter {
60
71
  * When `action` is set, it fires on Enter (text-like) or change (select) —
61
72
  * the new value is already in state by that point. */
62
73
  private field;
74
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
75
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
76
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
77
+ * return before this). */
78
+ private decorateField;
63
79
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
64
80
  * write to state then dispatch the action name (if any). */
65
81
  private checkbox;
@@ -91,4 +107,11 @@ export declare class BrowserAdapter implements Adapter {
91
107
  * concept. */
92
108
  private table;
93
109
  private copyButton;
110
+ /** EmptyStateNode — a centered "nothing here" block: a heading, an optional
111
+ * message, then the optional CTA ButtonNode (rendered via the shared button
112
+ * renderer so it dispatches like any other button). */
113
+ private emptyState;
114
+ /** BadgeNode — a compact inline status pill / count. Leaf node: label text +
115
+ * tone/emphasis modifier classes. */
116
+ private badge;
94
117
  }
package/dist/browser.js CHANGED
@@ -162,6 +162,38 @@ export class BrowserAdapter {
162
162
  setBusy(active) {
163
163
  this.container.classList.toggle("vms-busy", active);
164
164
  }
165
+ /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
166
+ * host region (.vms-toast-region) appended to <body> so toasts stack and
167
+ * survive the container's innerHTML wipe on each render(); appends a
168
+ * .vms-toast element (+ tone modifier) and auto-removes it after
169
+ * durationMs (default 4000), with a brief fade-out. This is the ONLY place
170
+ * toast DOM lives — core stays platform-agnostic (it just calls this verb).
171
+ * Fail-quiet by absence is the core's concern (it optional-chains the call). */
172
+ toast(message, opts) {
173
+ let region = document.querySelector(".vms-toast-region");
174
+ if (!region) {
175
+ region = document.createElement("div");
176
+ region.className = "vms-toast-region";
177
+ document.body.appendChild(region);
178
+ }
179
+ const el = document.createElement("div");
180
+ el.className = `vms-toast${opts?.tone ? ` vms-toast--${opts.tone}` : ""}`;
181
+ el.setAttribute("role", "status");
182
+ el.setAttribute("aria-live", "polite");
183
+ el.textContent = message;
184
+ region.appendChild(el);
185
+ const duration = opts?.durationMs ?? 4000;
186
+ setTimeout(() => {
187
+ el.classList.add("vms-toast--leaving");
188
+ // Remove after the fade-out transition; a short fixed delay keeps it
189
+ // simple (no transitionend bookkeeping). Clean up the region if it empties.
190
+ setTimeout(() => {
191
+ el.remove();
192
+ if (region && region.childElementCount === 0)
193
+ region.remove();
194
+ }, 200);
195
+ }, duration);
196
+ }
165
197
  unloadHandler = null;
166
198
  setPreventUnload(active) {
167
199
  if (active && this.unloadHandler == null) {
@@ -240,6 +272,8 @@ export class BrowserAdapter {
240
272
  case "copy-button": return this.copyButton(n, parent);
241
273
  case "divider": return this.divider(n, parent);
242
274
  case "fits": return this.fits(n, parent, on);
275
+ case "empty-state": return this.emptyState(n, parent, on);
276
+ case "badge": return this.badge(n, parent);
243
277
  default: {
244
278
  // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
245
279
  // Runtime trees are server-controlled JSON, so an unknown/forward-version
@@ -772,8 +806,61 @@ export class BrowserAdapter {
772
806
  }
773
807
  wrapper.appendChild(inp);
774
808
  }
809
+ this.decorateField(wrapper, n);
775
810
  parent.appendChild(wrapper);
776
811
  }
812
+ /** Forms-completeness (3.4.0) — apply disabled/readonly to the control and
813
+ * render help + error text below it, wiring aria-describedby / aria-invalid.
814
+ * Runs on the main field path (the hidden + checkbox-FieldNode variants
815
+ * return before this). */
816
+ decorateField(wrapper, n) {
817
+ const control = wrapper.querySelector(".vms-field__input");
818
+ if (n.disabled) {
819
+ if (control)
820
+ control.disabled = true;
821
+ wrapper.classList.add("vms-field--disabled");
822
+ }
823
+ if (n.readonly && control && "readOnly" in control) {
824
+ control.readOnly = true;
825
+ }
826
+ // Native input constraints — min/max/step on <input>, maxLength on
827
+ // <input>/<textarea>. Strings pass straight to the attribute.
828
+ if (control instanceof HTMLInputElement) {
829
+ if (n.min != null)
830
+ control.min = n.min;
831
+ if (n.max != null)
832
+ control.max = n.max;
833
+ if (n.step != null)
834
+ control.step = n.step;
835
+ }
836
+ if (n.maxLength != null &&
837
+ (control instanceof HTMLInputElement || control instanceof HTMLTextAreaElement)) {
838
+ control.maxLength = n.maxLength;
839
+ }
840
+ const describedBy = [];
841
+ if (n.help != null && n.help !== "") {
842
+ const helpEl = document.createElement("div");
843
+ helpEl.className = "vms-field__help";
844
+ helpEl.id = `vms-${n.name}-help`;
845
+ helpEl.textContent = n.help;
846
+ wrapper.appendChild(helpEl);
847
+ describedBy.push(helpEl.id);
848
+ }
849
+ if (n.error != null && n.error !== "") {
850
+ wrapper.classList.add("vms-field--error");
851
+ const errEl = document.createElement("div");
852
+ errEl.className = "vms-field__error";
853
+ errEl.id = `vms-${n.name}-error`;
854
+ errEl.setAttribute("role", "alert");
855
+ errEl.textContent = n.error;
856
+ wrapper.appendChild(errEl);
857
+ describedBy.push(errEl.id);
858
+ control?.setAttribute("aria-invalid", "true");
859
+ }
860
+ if (control && describedBy.length > 0) {
861
+ control.setAttribute("aria-describedby", describedBy.join(" "));
862
+ }
863
+ }
777
864
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
778
865
  * write to state then dispatch the action name (if any). */
779
866
  checkbox(n, parent, on) {
@@ -809,9 +896,16 @@ export class BrowserAdapter {
809
896
  button(n, parent, on) {
810
897
  const btn = document.createElement("button");
811
898
  btn.type = "button";
812
- 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" : ""}`;
899
+ 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" : ""}`;
813
900
  btn.textContent = n.label;
901
+ if (n.disabled)
902
+ btn.disabled = true;
814
903
  btn.addEventListener("click", () => {
904
+ // Forms-completeness (3.4.0): a disabled button never dispatches. (Native
905
+ // `disabled` already suppresses the click, but guard anyway in case the
906
+ // attribute was cleared out-of-band.)
907
+ if (n.disabled)
908
+ return;
815
909
  // pendingLabel: instant client-side feedback. Swap text + add
816
910
  // .vms-button--pending BEFORE handing off to the dispatcher. On
817
911
  // success the next render replaces the button entirely. On dispatch
@@ -1233,4 +1327,32 @@ export class BrowserAdapter {
1233
1327
  });
1234
1328
  parent.appendChild(btn);
1235
1329
  }
1330
+ /** EmptyStateNode — a centered "nothing here" block: a heading, an optional
1331
+ * message, then the optional CTA ButtonNode (rendered via the shared button
1332
+ * renderer so it dispatches like any other button). */
1333
+ emptyState(n, parent, on) {
1334
+ const el = document.createElement("div");
1335
+ el.className = "vms-empty-state";
1336
+ const heading = document.createElement("div");
1337
+ heading.className = "vms-empty-state__heading";
1338
+ heading.textContent = n.heading;
1339
+ el.appendChild(heading);
1340
+ if (n.message != null && n.message !== "") {
1341
+ const msg = document.createElement("div");
1342
+ msg.className = "vms-empty-state__message";
1343
+ msg.textContent = n.message;
1344
+ el.appendChild(msg);
1345
+ }
1346
+ if (n.action)
1347
+ this.button(n.action, el, on);
1348
+ parent.appendChild(el);
1349
+ }
1350
+ /** BadgeNode — a compact inline status pill / count. Leaf node: label text +
1351
+ * tone/emphasis modifier classes. */
1352
+ badge(n, parent) {
1353
+ const span = document.createElement("span");
1354
+ span.className = `vms-badge${n.tone ? ` vms-badge--${n.tone}` : ""}${n.emphasis ? ` vms-badge--${n.emphasis}` : ""}`;
1355
+ span.textContent = n.label;
1356
+ parent.appendChild(span);
1357
+ }
1236
1358
  }
package/dist/index.d.ts CHANGED
@@ -62,8 +62,20 @@ export interface Adapter {
62
62
  * none), so a rapid checkbox click during an in-flight round-trip never
63
63
  * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
64
  setBusy?(active: boolean): void;
65
+ /** Show a transient confirmation toast, driven by a `{ type: "toast" }`
66
+ * ShellSideEffect. `BrowserAdapter` stacks toasts in a single fixed-corner
67
+ * host region and auto-dismisses each after `opts.durationMs` (default
68
+ * ~4000ms). FAIL-QUIET BY ABSENCE — modeled on setPreventUnload/setBusy,
69
+ * NOT on navigate/storage/saveFile: a dropped toast is a missed UX nicety,
70
+ * never a correctness/security bug, so the core MUST NOT call failCapability
71
+ * when this verb is absent (non-browser targets like the TUI simply have no
72
+ * toast surface and the effect is a no-op). */
73
+ toast?(message: string, opts?: {
74
+ tone?: string;
75
+ durationMs?: number;
76
+ }): void;
65
77
  }
66
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode;
78
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode;
67
79
  export interface PageNode {
68
80
  type: "page";
69
81
  title?: string;
@@ -241,6 +253,35 @@ export interface FieldNode {
241
253
  label?: string;
242
254
  placeholder?: string;
243
255
  required?: boolean;
256
+ /** Disables the control (greys it out, blocks input, excludes it from form
257
+ * submission). Server-owned. Emits `.vms-field--disabled` on the wrapper +
258
+ * the native `disabled` attribute. Omitted = enabled. */
259
+ disabled?: boolean;
260
+ /** Read-only: the value is shown and submitted but the user can't edit it
261
+ * (distinct from `disabled`, which also greys out + excludes from submit).
262
+ * Text-like inputs + textarea only. Emits the native `readonly` attribute. */
263
+ readonly?: boolean;
264
+ /** Per-field validation error message, rendered inline below the control as
265
+ * `.vms-field__error` (role="alert"); also sets the wrapper's
266
+ * `.vms-field--error`, the control's `aria-invalid="true"`, and wires the
267
+ * message into `aria-describedby`. The view-side complement to the
268
+ * response-level `rejected` channel — set it on the offending field when you
269
+ * build the tree. Omitted = no error shown. */
270
+ error?: string;
271
+ /** Hint/help text rendered below the control as `.vms-field__help` and wired
272
+ * into the control's `aria-describedby`. Omitted = no hint. */
273
+ help?: string;
274
+ /** `min`/`max`/`step` passed through to the native input attribute for
275
+ * `number`/`range`/date-time inputs. STRINGS (HTML-attribute semantics): a
276
+ * numeric bound (`"0"`), a date bound (`"2020-01-01"`), and `step` also
277
+ * accepts `"any"`. Typed as strings (not numbers) so the wire stays
278
+ * byte-identical across backends. Omitted = no constraint. */
279
+ min?: string;
280
+ max?: string;
281
+ step?: string;
282
+ /** Maximum input length (characters) for text-like inputs + textarea →
283
+ * native `maxlength`. Integer. Omitted = no cap. */
284
+ maxLength?: number;
244
285
  options?: Array<{
245
286
  value: string;
246
287
  label: string;
@@ -279,6 +320,10 @@ export interface ButtonNode {
279
320
  * `block`, Chakra `width="full"`). Emits `.vms-button--full`. Omitted/`"auto"`
280
321
  * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
281
322
  width?: "auto" | "full";
323
+ /** Disables the button — greys it out (`.vms-button--disabled` + native
324
+ * `disabled`) and the renderer will NOT dispatch its action on click.
325
+ * Server-owned (e.g. a submit gated on a precondition). Omitted = enabled. */
326
+ disabled?: boolean;
282
327
  /** Transient label shown from click until the dispatch resolves (response
283
328
  * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
284
329
  * lifecycle pattern at a different beat: shown DURING the round-trip
@@ -455,6 +500,47 @@ export interface CopyButtonNode {
455
500
  * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
456
501
  width?: "auto" | "full";
457
502
  }
503
+ /**
504
+ * A first-class "nothing here" presentation — the empty-state primitive
505
+ * (MUI/Ant `<Empty>`, the standard zero-data placeholder). A centered block
506
+ * with a required heading, an optional supporting message, and an optional
507
+ * call-to-action ButtonNode (e.g. "Create your first ticket"). No icon field —
508
+ * the framework ships no icon set.
509
+ *
510
+ * The `action` ButtonNode carries a real action name and is a dispatch-bearing
511
+ * descendant, so EVERY tree-walk (action-name uniqueness collector, section
512
+ * shape validator) descends into it — exactly like `modal.footer` / `fits`
513
+ * candidates / `section.action`. A missed walk would silently exempt the CTA
514
+ * from the one-name-one-operation rule.
515
+ */
516
+ export interface EmptyStateNode {
517
+ type: "empty-state";
518
+ /** The primary line — what's missing / what to do. Required. */
519
+ heading: string;
520
+ /** Optional supporting line below the heading. */
521
+ message?: string;
522
+ /** Optional call-to-action button (e.g. "Add the first item"). */
523
+ action?: ButtonNode;
524
+ }
525
+ /**
526
+ * A compact status pill / count — the badge primitive (MUI/Ant `<Badge>`,
527
+ * `<Tag>`). A leaf inline node (no children, no action) for a short label or
528
+ * count that appears inside text/section/list/table contexts. Appearance is
529
+ * the universal `tone` (semantic color) + `emphasis` (filled vs outline) axes,
530
+ * matching the rest of the framework's appearance vocabulary.
531
+ */
532
+ export interface BadgeNode {
533
+ type: "badge";
534
+ /** The pill text — a short label or count (e.g. "New", "3", "Beta"). */
535
+ label: string;
536
+ /** Semantic intent/severity — the universal status tone axis. Emits
537
+ * .vms-badge--{tone}. Omitted = neutral. Closed union. */
538
+ tone?: "danger" | "warning" | "success" | "info";
539
+ /** Visual weight — `primary` = filled solid tone, `secondary` = outline.
540
+ * Mirrors ButtonNode's emphasis semantics. Emits .vms-badge--{emphasis}.
541
+ * Omitted = the default filled tint. Closed union. */
542
+ emphasis?: "primary" | "secondary";
543
+ }
458
544
  /**
459
545
  * The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
460
546
  * intrinsic size FITS the available container (no axis overflow), else the next,
@@ -530,7 +616,7 @@ export interface ShellOptions {
530
616
  pollInterval?: number;
531
617
  }
532
618
  export interface ShellSideEffect {
533
- /** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
619
+ /** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
534
620
  type: string;
535
621
  /** For "set-local-storage" / "set-session-storage": the storage key. */
536
622
  key?: string;
@@ -541,6 +627,13 @@ export interface ShellSideEffect {
541
627
  /** For "download": optional filename hint. Response Content-Disposition wins
542
628
  * when present; this is the fallback before the URL basename. */
543
629
  filename?: string;
630
+ /** For "toast": the text shown in the transient confirmation. Required for a
631
+ * toast effect (the shell guards `message != null` before routing). */
632
+ message?: string;
633
+ /** For "toast": optional semantic tone — "danger" | "warning" | "success" | "info". */
634
+ tone?: string;
635
+ /** For "toast": optional auto-dismiss delay in ms (adapter default ~4000). */
636
+ durationMs?: number;
544
637
  }
545
638
  /** One entry in the structured error payload returned on `ok: false`. */
546
639
  export interface ErrorEntry {
package/dist/index.js CHANGED
@@ -290,6 +290,16 @@ export class ViewModelShell {
290
290
  // storage, and a slow download MUST NOT delay the user-visible update.
291
291
  void this.download(effect.url, effect.filename);
292
292
  }
293
+ else if (effect.type === "toast" && effect.message != null) {
294
+ // FAIL-QUIET (cf. setPreventUnload/setBusy): a toast is a UX nicety,
295
+ // not a correctness/security guarantee, so an adapter without the
296
+ // capability simply drops it — NO failCapability, no onError. The
297
+ // optional-chaining call is the entire contract.
298
+ adapter.toast?.(effect.message, {
299
+ tone: effect.tone,
300
+ durationMs: effect.durationMs,
301
+ });
302
+ }
293
303
  }
294
304
  // 0.14.0 — apply the unload guard before the redirect/render branch so it's
295
305
  // in place (or cleared) consistently across both branches. A server that
package/dist/server.d.ts CHANGED
@@ -87,6 +87,14 @@ export declare const shellSideEffect: {
87
87
  * The conditional spread keeps `filename` ABSENT (not undefined) on the
88
88
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
89
89
  download: (url: string, filename?: string) => ShellSideEffect;
90
+ /** Transient confirmation toast (a UX nicety, fail-quiet by absence — see
91
+ * Adapter.toast). `message` is required; `tone`/`durationMs` are optional and
92
+ * kept ABSENT (not undefined) from the JSON wire via conditional spread,
93
+ * matching the .NET WhenWritingNull null-omission contract. */
94
+ toast: (message: string, opts?: {
95
+ tone?: string;
96
+ durationMs?: number;
97
+ }) => ShellSideEffect;
90
98
  };
91
99
  /**
92
100
  * Mount the canonical VMS agent skill markdown as an HTTP handler.
package/dist/server.js CHANGED
@@ -190,8 +190,18 @@ function collectActions(node, enclosingForm, out) {
190
190
  collectActions(child, enclosingForm, out);
191
191
  return;
192
192
  }
193
+ case "empty-state": {
194
+ // EmptyStateNode.action is an optional ButtonNode carrying a real action
195
+ // name. It is a dispatch-bearing descendant, so the uniqueness collector
196
+ // MUST descend into it — otherwise the CTA is silently exempt from the
197
+ // one-name-one-operation rule (the 3.3.0 missed-walk failure class).
198
+ const es = node;
199
+ if (es.action)
200
+ collectActions(es.action, enclosingForm, out);
201
+ return;
202
+ }
193
203
  // Nodes with no dispatch-bearing actions of their own:
194
- // text, link, image, stat-bar, progress, copy-button
204
+ // text, link, image, stat-bar, progress, copy-button, badge
195
205
  default:
196
206
  return;
197
207
  }
@@ -346,9 +356,18 @@ function walkForSectionAction(node, outerInteractive) {
346
356
  walkForSectionAction(child, outerInteractive);
347
357
  return;
348
358
  }
359
+ case "empty-state": {
360
+ // EmptyStateNode.action is a ButtonNode (no SectionNode descendants), but
361
+ // descend for consistency with every other walk so a future shape can't
362
+ // slip an interactive section past this validator.
363
+ const es = node;
364
+ if (es.action)
365
+ walkForSectionAction(es.action, outerInteractive);
366
+ return;
367
+ }
349
368
  // Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
350
- // tabs, progress, table, copy-button) carry no SectionNode descendants —
351
- // TableNode rows hold strings + per-row controls, not sections.
369
+ // tabs, progress, table, copy-button, badge) carry no SectionNode
370
+ // descendants — TableNode rows hold strings + per-row controls, not sections.
352
371
  default:
353
372
  return;
354
373
  }
@@ -427,6 +446,16 @@ export const shellSideEffect = {
427
446
  * The conditional spread keeps `filename` ABSENT (not undefined) on the
428
447
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
429
448
  download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
449
+ /** Transient confirmation toast (a UX nicety, fail-quiet by absence — see
450
+ * Adapter.toast). `message` is required; `tone`/`durationMs` are optional and
451
+ * kept ABSENT (not undefined) from the JSON wire via conditional spread,
452
+ * matching the .NET WhenWritingNull null-omission contract. */
453
+ toast: (message, opts) => ({
454
+ type: "toast",
455
+ message,
456
+ ...(opts?.tone != null ? { tone: opts.tone } : {}),
457
+ ...(opts?.durationMs != null ? { durationMs: opts.durationMs } : {}),
458
+ }),
430
459
  };
431
460
  // ─── Canonical agent skill mount helper (1.6.0 / 1.5.0) ──────────────────────
432
461
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.3.0",
3
+ "version": "3.5.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",
@@ -26,7 +26,7 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "bin": {
29
- "vms-tui": "./dist/tui-cli.js"
29
+ "vms-tui": "dist/tui-cli.js"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -b tsconfig.tui.json",
@@ -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 {
@@ -948,3 +977,117 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
948
977
  cursor: pointer;
949
978
  font-size: var(--vms-text-lg);
950
979
  }
980
+
981
+ /* ── Toast (side-effect-driven transient confirmation) ──
982
+ The BrowserAdapter lazily creates ONE .vms-toast-region pinned to the
983
+ bottom-right corner and stacks .vms-toast elements into it; each auto-removes
984
+ after its durationMs with a brief fade. The region is click-through
985
+ (pointer-events:none) so it never blocks the UI under it; individual toasts
986
+ re-enable pointer events for their own content. Framework-owned styling —
987
+ this is the framework owning its presentation, not app CSS. */
988
+ .vms-toast-region {
989
+ position: fixed;
990
+ bottom: var(--vms-space-md);
991
+ right: var(--vms-space-md);
992
+ z-index: 1100; /* above .vms-modal-backdrop (1000) */
993
+ display: flex;
994
+ flex-direction: column;
995
+ gap: var(--vms-space-xs);
996
+ align-items: flex-end;
997
+ pointer-events: none;
998
+ max-width: min(24rem, calc(100vw - 2 * var(--vms-space-md)));
999
+ }
1000
+ .vms-toast {
1001
+ pointer-events: auto;
1002
+ /* Neutral toast = high-contrast inverted chip (the snackbar pattern): the
1003
+ theme's text color as the surface, the theme's surface as the text. This
1004
+ stands clear of the page background it sits on in EITHER theme — a
1005
+ surface-colored toast was nearly invisible against the page surface. */
1006
+ background: var(--vms-text);
1007
+ color: var(--vms-surface);
1008
+ border: 1px solid transparent;
1009
+ border-radius: var(--vms-radius);
1010
+ font-size: var(--vms-text-base);
1011
+ font-weight: 500;
1012
+ padding: var(--vms-space-sm) var(--vms-space-md);
1013
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.30);
1014
+ opacity: 1;
1015
+ transition: opacity var(--vms-t), transform var(--vms-t);
1016
+ }
1017
+ /* tone — a FULLY filled chip in the status color (not a thin accent rail), so
1018
+ an important toast (especially danger) is impossible to miss. error/warning
1019
+ are dark enough for white body text to clear WCAG-AA on their pure tokens;
1020
+ success/info are deepened (color-mix w/ black) so white at 14px also clears
1021
+ 4.5:1 (their pure tokens sit at 3.2 / 4.4:1). */
1022
+ .vms-toast--danger { background: var(--vms-error); color: #fff; }
1023
+ .vms-toast--warning { background: var(--vms-warning); color: #fff; }
1024
+ .vms-toast--success { background: color-mix(in srgb, var(--vms-success) 80%, #000); color: #fff; }
1025
+ .vms-toast--info { background: color-mix(in srgb, var(--vms-info) 88%, #000); color: #fff; }
1026
+ /* fade-out applied just before removal. */
1027
+ .vms-toast--leaving { opacity: 0; transform: translateY(0.25rem); }
1028
+
1029
+ /* ── Empty state (first-class "nothing here" presentation) ──
1030
+ A centered block: heading, optional message, optional CTA button. */
1031
+ .vms-empty-state {
1032
+ display: flex;
1033
+ flex-direction: column;
1034
+ align-items: center;
1035
+ justify-content: center;
1036
+ gap: var(--vms-space-sm);
1037
+ text-align: center;
1038
+ padding: var(--vms-space-xl) var(--vms-space-md);
1039
+ color: var(--vms-text-muted);
1040
+ }
1041
+ .vms-empty-state__heading {
1042
+ font-size: var(--vms-text-lg);
1043
+ font-weight: 600;
1044
+ color: var(--vms-text);
1045
+ }
1046
+ .vms-empty-state__message {
1047
+ font-size: var(--vms-text-base);
1048
+ color: var(--vms-text-muted);
1049
+ max-width: min(40ch, 100%);
1050
+ }
1051
+ /* The CTA button inside an empty state centers (override the button's default
1052
+ align-self:flex-start) so it sits under the centered heading/message. */
1053
+ .vms-empty-state .vms-button { align-self: center; }
1054
+
1055
+ /* ── Badge (compact inline status pill / count) ──
1056
+ Leaf inline element. Default = neutral filled tint; tone recolors it; emphasis
1057
+ switches between a solid fill (primary) and an outline (secondary). */
1058
+ .vms-badge {
1059
+ --_badge-tone: var(--vms-text-muted);
1060
+ display: inline-flex;
1061
+ align-items: center;
1062
+ /* Center the pill on the surrounding text instead of baselining it (an
1063
+ inline-flex pill otherwise hangs below the baseline of adjacent text). */
1064
+ vertical-align: middle;
1065
+ gap: var(--vms-space-2xs);
1066
+ font-size: var(--vms-text-xs);
1067
+ font-weight: 600;
1068
+ line-height: 1;
1069
+ padding: 0.2em 0.6em;
1070
+ border-radius: 999px;
1071
+ white-space: nowrap;
1072
+ /* default (no tone, no emphasis): subtle neutral tint pill. */
1073
+ background: color-mix(in srgb, var(--_badge-tone) 16%, var(--vms-surface));
1074
+ color: var(--_badge-tone);
1075
+ border: 1px solid transparent;
1076
+ }
1077
+ /* tone — the universal status color, sets the pill's working color. */
1078
+ .vms-badge--danger { --_badge-tone: var(--vms-error); }
1079
+ .vms-badge--warning { --_badge-tone: var(--vms-warning); }
1080
+ .vms-badge--success { --_badge-tone: var(--vms-success); }
1081
+ .vms-badge--info { --_badge-tone: var(--vms-info); }
1082
+ /* emphasis — primary = solid filled tone (white text); secondary = outline.
1083
+ Declared after tone so the chosen --_badge-tone drives both. */
1084
+ .vms-badge--primary {
1085
+ background: var(--_badge-tone);
1086
+ border-color: var(--_badge-tone);
1087
+ color: #fff;
1088
+ }
1089
+ .vms-badge--secondary {
1090
+ background: transparent;
1091
+ border-color: var(--_badge-tone);
1092
+ color: var(--_badge-tone);
1093
+ }