@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 +3 -1
- package/dist/browser.d.ts +23 -0
- package/dist/browser.js +123 -1
- package/dist/index.d.ts +95 -2
- package/dist/index.js +10 -0
- package/dist/server.d.ts +8 -0
- package/dist/server.js +32 -3
- package/package.json +2 -2
- package/styles/default.css +143 -0
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
|
|
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
|
+
"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": "
|
|
29
|
+
"vms-tui": "dist/tui-cli.js"
|
|
30
30
|
},
|
|
31
31
|
"scripts": {
|
|
32
32
|
"build": "tsc -b tsconfig.tui.json",
|
package/styles/default.css
CHANGED
|
@@ -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
|
+
}
|