@ashley-shrok/viewmodel-shell 3.4.0 → 3.6.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 +18 -0
- package/dist/browser.js +66 -4
- package/dist/index.d.ts +66 -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 +125 -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: {
|
|
@@ -96,4 +107,11 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
96
107
|
* concept. */
|
|
97
108
|
private table;
|
|
98
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;
|
|
99
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
|
|
@@ -259,7 +293,7 @@ export class BrowserAdapter {
|
|
|
259
293
|
}
|
|
260
294
|
page(n, parent, on) {
|
|
261
295
|
const el = document.createElement("div");
|
|
262
|
-
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${n.width ? ` vms-page--${n.width}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}`;
|
|
296
|
+
el.className = `vms-page${n.density === "compact" ? " vms-page--compact" : ""}${n.layout && n.layout !== "stack" ? ` vms-page--${n.layout}` : ""}${n.fill === true ? " vms-page--fill" : ""}${n.width ? ` vms-page--${n.width}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}`;
|
|
263
297
|
if (n.title) {
|
|
264
298
|
const h = document.createElement("h1");
|
|
265
299
|
h.className = "vms-page__title";
|
|
@@ -371,7 +405,7 @@ export class BrowserAdapter {
|
|
|
371
405
|
this.sectionKeyCounter.set(baseKey, ordinal + 1);
|
|
372
406
|
const finalKey = `${baseKey}:${ordinal}`;
|
|
373
407
|
const el = document.createElement("details");
|
|
374
|
-
el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
408
|
+
el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
375
409
|
el.dataset.sectionKey = finalKey;
|
|
376
410
|
// Initial render is always closed — the post-render restore loop in
|
|
377
411
|
// render() re-applies `open=true` for keys the user had open before.
|
|
@@ -395,7 +429,7 @@ export class BrowserAdapter {
|
|
|
395
429
|
// action — see validateSectionAction in server.ts.
|
|
396
430
|
if (n.link) {
|
|
397
431
|
const a = document.createElement("a");
|
|
398
|
-
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
432
|
+
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
|
|
399
433
|
a.href = n.link.url;
|
|
400
434
|
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
401
435
|
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
@@ -439,7 +473,7 @@ export class BrowserAdapter {
|
|
|
439
473
|
return;
|
|
440
474
|
}
|
|
441
475
|
const el = document.createElement("section");
|
|
442
|
-
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
476
|
+
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
443
477
|
if (n.heading) {
|
|
444
478
|
const h = document.createElement("h2");
|
|
445
479
|
h.className = "vms-section__heading";
|
|
@@ -1293,4 +1327,32 @@ export class BrowserAdapter {
|
|
|
1293
1327
|
});
|
|
1294
1328
|
parent.appendChild(btn);
|
|
1295
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
|
+
}
|
|
1296
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;
|
|
@@ -71,6 +83,8 @@ export interface PageNode {
|
|
|
71
83
|
density?: "comfortable" | "compact";
|
|
72
84
|
/** Layout preset arranging direct children. Omitted or "stack" = current vertical flow (no modifier class). "split" (equal 2-up), "cards" (uniform grid), "sidebar" (thin + wide app shell), "row" (left-aligned wrapping horizontal row; items hug content), "switcher" (N equal items flipping all-row ↔ all-stack atomically at a content-width `threshold` — the negative-flex-basis primitive a grid cannot express; distinct from `cards` auto-fit which passes through intermediate column counts) emit .vms-page--{value}. Closed union (D-01/D-02; sidebar D-28; row D-30; switcher SWITCH-01). */
|
|
73
85
|
layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
|
|
86
|
+
/** When true the page fills the viewport height (`height:100dvh`) so a `fill` section inside it can take the leftover space and scroll internally — the full-height app-shell axis (a pinned footer/header with an internally-scrolling body, the Flutter Column+Expanded mechanism). Meant to pair with a `SectionNode.fill` child, which becomes the `flex:1 1 auto; min-height:0; overflow-y:auto` region. Absent/false = normal document flow (byte-identical to today). Orthogonal to `layout`. */
|
|
87
|
+
fill?: boolean;
|
|
74
88
|
/** Page-shell max-width override. Omitted = framework default cap (`--vms-page-max`, 1080px). "wide" = `--vms-page-max-wide` (1440px default), for data-heavy pages with wide tables. "full" = uncapped (max-width: none), for full-bleed dashboards. TUI ignores this (terminals fill naturally). Closed union (D-13 / issue #13). */
|
|
75
89
|
width?: "wide" | "full";
|
|
76
90
|
/** Main-axis arrangement for `layout:"row"` (the cluster primitive) — maps to `justify-content`. Omitted = no class → the row default (`flex-start`, left-pack) holds = byte-identical to today. Closed union copied verbatim from Jetpack Compose `Arrangement` ∩ Flutter `MainAxisAlignment` (ALIGN-01). Emits .vms-arrange--{value}. */
|
|
@@ -94,6 +108,8 @@ export interface SectionNode {
|
|
|
94
108
|
tone?: "danger" | "warning" | "success" | "info";
|
|
95
109
|
/** Layout preset arranging direct children. Omitted or "stack" = current vertical flow (no modifier class). "split" (equal 2-up), "cards" (uniform grid), "sidebar" (thin + wide app shell), "row" (left-aligned wrapping horizontal row; items hug content), "switcher" (N equal items flipping all-row ↔ all-stack atomically at a content-width `threshold` — the negative-flex-basis primitive a grid cannot express; distinct from `cards` auto-fit which passes through intermediate column counts) emit .vms-section--{value}. Closed union (D-01/D-02; sidebar D-28; row D-30; switcher SWITCH-01). */
|
|
96
110
|
layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
|
|
111
|
+
/** When true (and inside a `fill` page) this section takes the remaining column height and scrolls internally (`flex:1 1 auto; min-height:0; overflow-y:auto`) — the body region of a full-height app shell (e.g. a chat transcript above a pinned composer). Orthogonal to `layout` — a fill section still arranges its own children via `layout`. Outside a `fill` page it's a harmless no-op (the modifier class is inert without a `100dvh` parent). Absent/false = byte-identical to today. */
|
|
112
|
+
fill?: boolean;
|
|
97
113
|
/** Main-axis arrangement for `layout:"row"` (the cluster primitive) — maps to `justify-content`. Omitted = no class → the row default (`flex-start`, left-pack) holds = byte-identical to today. Closed union copied verbatim from Jetpack Compose `Arrangement` ∩ Flutter `MainAxisAlignment` (ALIGN-01). Emits .vms-arrange--{value}. */
|
|
98
114
|
arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
|
|
99
115
|
/** Cross-axis alignment for `layout:"row"` (the cluster primitive) — maps to `align-items`. Omitted = no class → the row default (`center`) holds = byte-identical to today. Closed union copied verbatim from Flutter `CrossAxisAlignment` (ALIGN-02). Emits .vms-align--{value}. */
|
|
@@ -488,6 +504,47 @@ export interface CopyButtonNode {
|
|
|
488
504
|
* = content-width (the default hug). Orthogonal to emphasis/tone/size. */
|
|
489
505
|
width?: "auto" | "full";
|
|
490
506
|
}
|
|
507
|
+
/**
|
|
508
|
+
* A first-class "nothing here" presentation — the empty-state primitive
|
|
509
|
+
* (MUI/Ant `<Empty>`, the standard zero-data placeholder). A centered block
|
|
510
|
+
* with a required heading, an optional supporting message, and an optional
|
|
511
|
+
* call-to-action ButtonNode (e.g. "Create your first ticket"). No icon field —
|
|
512
|
+
* the framework ships no icon set.
|
|
513
|
+
*
|
|
514
|
+
* The `action` ButtonNode carries a real action name and is a dispatch-bearing
|
|
515
|
+
* descendant, so EVERY tree-walk (action-name uniqueness collector, section
|
|
516
|
+
* shape validator) descends into it — exactly like `modal.footer` / `fits`
|
|
517
|
+
* candidates / `section.action`. A missed walk would silently exempt the CTA
|
|
518
|
+
* from the one-name-one-operation rule.
|
|
519
|
+
*/
|
|
520
|
+
export interface EmptyStateNode {
|
|
521
|
+
type: "empty-state";
|
|
522
|
+
/** The primary line — what's missing / what to do. Required. */
|
|
523
|
+
heading: string;
|
|
524
|
+
/** Optional supporting line below the heading. */
|
|
525
|
+
message?: string;
|
|
526
|
+
/** Optional call-to-action button (e.g. "Add the first item"). */
|
|
527
|
+
action?: ButtonNode;
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* A compact status pill / count — the badge primitive (MUI/Ant `<Badge>`,
|
|
531
|
+
* `<Tag>`). A leaf inline node (no children, no action) for a short label or
|
|
532
|
+
* count that appears inside text/section/list/table contexts. Appearance is
|
|
533
|
+
* the universal `tone` (semantic color) + `emphasis` (filled vs outline) axes,
|
|
534
|
+
* matching the rest of the framework's appearance vocabulary.
|
|
535
|
+
*/
|
|
536
|
+
export interface BadgeNode {
|
|
537
|
+
type: "badge";
|
|
538
|
+
/** The pill text — a short label or count (e.g. "New", "3", "Beta"). */
|
|
539
|
+
label: string;
|
|
540
|
+
/** Semantic intent/severity — the universal status tone axis. Emits
|
|
541
|
+
* .vms-badge--{tone}. Omitted = neutral. Closed union. */
|
|
542
|
+
tone?: "danger" | "warning" | "success" | "info";
|
|
543
|
+
/** Visual weight — `primary` = filled solid tone, `secondary` = outline.
|
|
544
|
+
* Mirrors ButtonNode's emphasis semantics. Emits .vms-badge--{emphasis}.
|
|
545
|
+
* Omitted = the default filled tint. Closed union. */
|
|
546
|
+
emphasis?: "primary" | "secondary";
|
|
547
|
+
}
|
|
491
548
|
/**
|
|
492
549
|
* The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
|
|
493
550
|
* intrinsic size FITS the available container (no axis overflow), else the next,
|
|
@@ -563,7 +620,7 @@ export interface ShellOptions {
|
|
|
563
620
|
pollInterval?: number;
|
|
564
621
|
}
|
|
565
622
|
export interface ShellSideEffect {
|
|
566
|
-
/** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
|
|
623
|
+
/** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
|
|
567
624
|
type: string;
|
|
568
625
|
/** For "set-local-storage" / "set-session-storage": the storage key. */
|
|
569
626
|
key?: string;
|
|
@@ -574,6 +631,13 @@ export interface ShellSideEffect {
|
|
|
574
631
|
/** For "download": optional filename hint. Response Content-Disposition wins
|
|
575
632
|
* when present; this is the fallback before the URL basename. */
|
|
576
633
|
filename?: string;
|
|
634
|
+
/** For "toast": the text shown in the transient confirmation. Required for a
|
|
635
|
+
* toast effect (the shell guards `message != null` before routing). */
|
|
636
|
+
message?: string;
|
|
637
|
+
/** For "toast": optional semantic tone — "danger" | "warning" | "success" | "info". */
|
|
638
|
+
tone?: string;
|
|
639
|
+
/** For "toast": optional auto-dismiss delay in ms (adapter default ~4000). */
|
|
640
|
+
durationMs?: number;
|
|
577
641
|
}
|
|
578
642
|
/** One entry in the structured error payload returned on `ok: false`. */
|
|
579
643
|
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.6.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
|
@@ -144,6 +144,17 @@ body {
|
|
|
144
144
|
.vms-page--wide { max-width: var(--vms-page-max-wide); }
|
|
145
145
|
.vms-page--full { max-width: none; }
|
|
146
146
|
|
|
147
|
+
/* ── Fill / full-height app-shell axis ──
|
|
148
|
+
`.vms-page--fill` makes the page fill the viewport height so a `.vms-section--fill`
|
|
149
|
+
child can claim the leftover column height and scroll internally (the
|
|
150
|
+
pinned-footer/header + internally-scrolling body shell — Flutter Column+Expanded).
|
|
151
|
+
`.vms-section--fill` is the Expanded region: flex:1 → take remaining height,
|
|
152
|
+
min-height:0 → allow the flex child to shrink below content so overflow scrolls
|
|
153
|
+
instead of pushing siblings off-screen, overflow-y:auto → scroll internally.
|
|
154
|
+
Orthogonal to layout; outside a fill page the section rule is an inert no-op. */
|
|
155
|
+
.vms-page--fill { height: 100dvh; }
|
|
156
|
+
.vms-section--fill { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
|
|
157
|
+
|
|
147
158
|
/* ── Section ── */
|
|
148
159
|
.vms-section { display: flex; flex-direction: column; gap: var(--vms-space-sm); }
|
|
149
160
|
.vms-section__heading {
|
|
@@ -977,3 +988,117 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
977
988
|
cursor: pointer;
|
|
978
989
|
font-size: var(--vms-text-lg);
|
|
979
990
|
}
|
|
991
|
+
|
|
992
|
+
/* ── Toast (side-effect-driven transient confirmation) ──
|
|
993
|
+
The BrowserAdapter lazily creates ONE .vms-toast-region pinned to the
|
|
994
|
+
bottom-right corner and stacks .vms-toast elements into it; each auto-removes
|
|
995
|
+
after its durationMs with a brief fade. The region is click-through
|
|
996
|
+
(pointer-events:none) so it never blocks the UI under it; individual toasts
|
|
997
|
+
re-enable pointer events for their own content. Framework-owned styling —
|
|
998
|
+
this is the framework owning its presentation, not app CSS. */
|
|
999
|
+
.vms-toast-region {
|
|
1000
|
+
position: fixed;
|
|
1001
|
+
bottom: var(--vms-space-md);
|
|
1002
|
+
right: var(--vms-space-md);
|
|
1003
|
+
z-index: 1100; /* above .vms-modal-backdrop (1000) */
|
|
1004
|
+
display: flex;
|
|
1005
|
+
flex-direction: column;
|
|
1006
|
+
gap: var(--vms-space-xs);
|
|
1007
|
+
align-items: flex-end;
|
|
1008
|
+
pointer-events: none;
|
|
1009
|
+
max-width: min(24rem, calc(100vw - 2 * var(--vms-space-md)));
|
|
1010
|
+
}
|
|
1011
|
+
.vms-toast {
|
|
1012
|
+
pointer-events: auto;
|
|
1013
|
+
/* Neutral toast = high-contrast inverted chip (the snackbar pattern): the
|
|
1014
|
+
theme's text color as the surface, the theme's surface as the text. This
|
|
1015
|
+
stands clear of the page background it sits on in EITHER theme — a
|
|
1016
|
+
surface-colored toast was nearly invisible against the page surface. */
|
|
1017
|
+
background: var(--vms-text);
|
|
1018
|
+
color: var(--vms-surface);
|
|
1019
|
+
border: 1px solid transparent;
|
|
1020
|
+
border-radius: var(--vms-radius);
|
|
1021
|
+
font-size: var(--vms-text-base);
|
|
1022
|
+
font-weight: 500;
|
|
1023
|
+
padding: var(--vms-space-sm) var(--vms-space-md);
|
|
1024
|
+
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.30);
|
|
1025
|
+
opacity: 1;
|
|
1026
|
+
transition: opacity var(--vms-t), transform var(--vms-t);
|
|
1027
|
+
}
|
|
1028
|
+
/* tone — a FULLY filled chip in the status color (not a thin accent rail), so
|
|
1029
|
+
an important toast (especially danger) is impossible to miss. error/warning
|
|
1030
|
+
are dark enough for white body text to clear WCAG-AA on their pure tokens;
|
|
1031
|
+
success/info are deepened (color-mix w/ black) so white at 14px also clears
|
|
1032
|
+
4.5:1 (their pure tokens sit at 3.2 / 4.4:1). */
|
|
1033
|
+
.vms-toast--danger { background: var(--vms-error); color: #fff; }
|
|
1034
|
+
.vms-toast--warning { background: var(--vms-warning); color: #fff; }
|
|
1035
|
+
.vms-toast--success { background: color-mix(in srgb, var(--vms-success) 80%, #000); color: #fff; }
|
|
1036
|
+
.vms-toast--info { background: color-mix(in srgb, var(--vms-info) 88%, #000); color: #fff; }
|
|
1037
|
+
/* fade-out applied just before removal. */
|
|
1038
|
+
.vms-toast--leaving { opacity: 0; transform: translateY(0.25rem); }
|
|
1039
|
+
|
|
1040
|
+
/* ── Empty state (first-class "nothing here" presentation) ──
|
|
1041
|
+
A centered block: heading, optional message, optional CTA button. */
|
|
1042
|
+
.vms-empty-state {
|
|
1043
|
+
display: flex;
|
|
1044
|
+
flex-direction: column;
|
|
1045
|
+
align-items: center;
|
|
1046
|
+
justify-content: center;
|
|
1047
|
+
gap: var(--vms-space-sm);
|
|
1048
|
+
text-align: center;
|
|
1049
|
+
padding: var(--vms-space-xl) var(--vms-space-md);
|
|
1050
|
+
color: var(--vms-text-muted);
|
|
1051
|
+
}
|
|
1052
|
+
.vms-empty-state__heading {
|
|
1053
|
+
font-size: var(--vms-text-lg);
|
|
1054
|
+
font-weight: 600;
|
|
1055
|
+
color: var(--vms-text);
|
|
1056
|
+
}
|
|
1057
|
+
.vms-empty-state__message {
|
|
1058
|
+
font-size: var(--vms-text-base);
|
|
1059
|
+
color: var(--vms-text-muted);
|
|
1060
|
+
max-width: min(40ch, 100%);
|
|
1061
|
+
}
|
|
1062
|
+
/* The CTA button inside an empty state centers (override the button's default
|
|
1063
|
+
align-self:flex-start) so it sits under the centered heading/message. */
|
|
1064
|
+
.vms-empty-state .vms-button { align-self: center; }
|
|
1065
|
+
|
|
1066
|
+
/* ── Badge (compact inline status pill / count) ──
|
|
1067
|
+
Leaf inline element. Default = neutral filled tint; tone recolors it; emphasis
|
|
1068
|
+
switches between a solid fill (primary) and an outline (secondary). */
|
|
1069
|
+
.vms-badge {
|
|
1070
|
+
--_badge-tone: var(--vms-text-muted);
|
|
1071
|
+
display: inline-flex;
|
|
1072
|
+
align-items: center;
|
|
1073
|
+
/* Center the pill on the surrounding text instead of baselining it (an
|
|
1074
|
+
inline-flex pill otherwise hangs below the baseline of adjacent text). */
|
|
1075
|
+
vertical-align: middle;
|
|
1076
|
+
gap: var(--vms-space-2xs);
|
|
1077
|
+
font-size: var(--vms-text-xs);
|
|
1078
|
+
font-weight: 600;
|
|
1079
|
+
line-height: 1;
|
|
1080
|
+
padding: 0.2em 0.6em;
|
|
1081
|
+
border-radius: 999px;
|
|
1082
|
+
white-space: nowrap;
|
|
1083
|
+
/* default (no tone, no emphasis): subtle neutral tint pill. */
|
|
1084
|
+
background: color-mix(in srgb, var(--_badge-tone) 16%, var(--vms-surface));
|
|
1085
|
+
color: var(--_badge-tone);
|
|
1086
|
+
border: 1px solid transparent;
|
|
1087
|
+
}
|
|
1088
|
+
/* tone — the universal status color, sets the pill's working color. */
|
|
1089
|
+
.vms-badge--danger { --_badge-tone: var(--vms-error); }
|
|
1090
|
+
.vms-badge--warning { --_badge-tone: var(--vms-warning); }
|
|
1091
|
+
.vms-badge--success { --_badge-tone: var(--vms-success); }
|
|
1092
|
+
.vms-badge--info { --_badge-tone: var(--vms-info); }
|
|
1093
|
+
/* emphasis — primary = solid filled tone (white text); secondary = outline.
|
|
1094
|
+
Declared after tone so the chosen --_badge-tone drives both. */
|
|
1095
|
+
.vms-badge--primary {
|
|
1096
|
+
background: var(--_badge-tone);
|
|
1097
|
+
border-color: var(--_badge-tone);
|
|
1098
|
+
color: #fff;
|
|
1099
|
+
}
|
|
1100
|
+
.vms-badge--secondary {
|
|
1101
|
+
background: transparent;
|
|
1102
|
+
border-color: var(--_badge-tone);
|
|
1103
|
+
color: var(--_badge-tone);
|
|
1104
|
+
}
|