@ashley-shrok/viewmodel-shell 1.9.0 → 1.11.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
@@ -62,6 +62,7 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
62
62
  | `nextPollIn` | Milliseconds. Schedule a `{"name": "poll"}` dispatch after this delay. |
63
63
  | `busy` | Boolean. While `true`, drop user-initiated dispatches. Polls bypass. The next response that omits or sets `false` clears the lock. |
64
64
  | `preventUnload` | Boolean. While `true`, treat the page as having unsaved work — warn before navigating away. |
65
+ | `rejected` | A SOFT (domain/validation) rejection — see below. The action was refused, but `vm`/`state` are still returned. |
65
66
 
66
67
  **Failure:**
67
68
 
@@ -71,6 +72,17 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
71
72
 
72
73
  `errors[].path` and `errors[].code` are optional. HTTP status is 4xx or 5xx on failure. Check `ok` once at the response edge; do not branch on HTTP status.
73
74
 
75
+ **Soft rejections (`rejected`) — important for wire-driving agents.** `ok:false` means the framework could not give you a view. A *domain/validation* rejection is different: the app refused the action (e.g. "targets must be non-negative", "can't remove the only person") but it is still a normal `ok:true` render that preserves the user's input. So `ok` alone does NOT tell you the action succeeded — **on an `ok:true` response, also check for `rejected`.** When present:
76
+
77
+ ```json
78
+ { "ok": true, "vm": { /* … */ }, "state": { /* … */ },
79
+ "rejected": { "violations": [ { "path": "targets.protein", "message": "must be non-negative" } ] } }
80
+ ```
81
+
82
+ - The write **did not take effect.** Do not treat it as success. Surface the violation(s); `vm`/`state` still hold the input the user typed, so you can correct and re-dispatch.
83
+ - Each violation reuses the `errors[]` entry shape (`{ path?, message, code? }`). **`path` is optional**: present → the violation is bound to that field; **absent → it's a form/action-level rejection** with no single field (like the "only person" case).
84
+ - `rejected` only appears on `ok:true`. It never coexists with `ok:false` (that channel carries no view).
85
+
74
86
  ## Side-effect verbs
75
87
 
76
88
  `sideEffects[]` entries each carry a `type` discriminator. Built-in verbs as of `viewmodel-shell/1.0`:
package/dist/browser.js CHANGED
@@ -265,6 +265,29 @@ export class BrowserAdapter {
265
265
  parent.appendChild(el);
266
266
  return;
267
267
  }
268
+ // 1.11.0 — flyout:true overlay disclosure. The hover/focus sibling of
269
+ // collapsible's inline <details>: heading => focusable <button> trigger,
270
+ // children => an absolutely-positioned panel revealed on :hover /
271
+ // :focus-within (pure CSS — see default.css; no JS, no listeners, no
272
+ // round-tripped open state). Precedence: collapsible (checked above) wins,
273
+ // so reaching here means collapsible is not set; flyout in turn wins over
274
+ // link/action below. Omitted/false renders byte-identical to <section>.
275
+ if (n.flyout === true) {
276
+ const el = document.createElement("div");
277
+ el.className = `vms-section vms-section--flyout${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
278
+ const trigger = document.createElement("button");
279
+ trigger.type = "button";
280
+ trigger.className = "vms-section__trigger";
281
+ // Headingless fallback label — mirrors collapsible's "Show details".
282
+ trigger.textContent = n.heading ?? "Menu";
283
+ el.appendChild(trigger);
284
+ const panel = document.createElement("div");
285
+ panel.className = "vms-section__panel";
286
+ this.kids(n.children, panel, on);
287
+ el.appendChild(panel);
288
+ parent.appendChild(el);
289
+ return;
290
+ }
268
291
  // 1.5.0 — SectionNode.link URL-wrapper variant (issue #21). When set,
269
292
  // emit a wrapping <a href> element instead of <section> so every native
270
293
  // browser link affordance works for free (middle-click / Ctrl/Cmd-click
package/dist/index.d.ts CHANGED
@@ -69,8 +69,8 @@ export interface PageNode {
69
69
  title?: string;
70
70
  /** Density of global spacing. Omitted or "comfortable" = current behavior (no modifier class). "compact" emits .vms-page--compact. Closed union (D-03). */
71
71
  density?: "comfortable" | "compact";
72
- /** 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) emit .vms-page--{value}. Closed union (D-01/D-02; sidebar D-28). */
73
- layout?: "stack" | "split" | "cards" | "sidebar";
72
+ /** 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) emit .vms-page--{value}. Closed union (D-01/D-02; sidebar D-28; row D-30). */
73
+ layout?: "stack" | "split" | "cards" | "sidebar" | "row";
74
74
  /** 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
75
  width?: "wide" | "full";
76
76
  children: ViewNode[];
@@ -80,12 +80,34 @@ export interface SectionNode {
80
80
  heading?: string;
81
81
  /** Section surface variant. Omitted = current behavior (no modifier class). "card" emits .vms-section--card. Closed union (D-03). */
82
82
  variant?: "card";
83
- /** 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) emit .vms-section--{value}. Closed union (D-01/D-02; sidebar D-28). */
84
- layout?: "stack" | "split" | "cards" | "sidebar";
83
+ /** 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) emit .vms-section--{value}. Closed union (D-01/D-02; sidebar D-28; row D-30). */
84
+ layout?: "stack" | "split" | "cards" | "sidebar" | "row";
85
85
  /** Optional stable preservation key for the renderer's collapsible-section open-state snapshot. Used only when `collapsible: true`. Provide when `heading` isn't unique within a page or is absent — otherwise the renderer falls back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal. Omitted = use the heading fallback. */
86
86
  id?: string;
87
87
  /** When true, the section renders as a native `<details>`/`<summary>` disclosure widget (closed by default). Aesthetic, client-side primitive — the open/closed state is DOM-local and the server does NOT round-trip it (same conceptual model as draft text values in unsubmitted form inputs). The browser adapter snapshots `<details>.open` before each re-render and restores it after, keyed by `id ?? heading ?? "vms-section-anon"` (disambiguated by per-render ordinal); a re-key drops the preserved state (the documented escape hatch for rare server-driven expansion). The summary label is the section's `heading`; a headingless collapsible section uses the fallback string `"Show details"`. If a section needs to start open, do not mark it collapsible. Omitted/false = today's `<section>` rendering, byte-identical. */
88
88
  collapsible?: boolean;
89
+ /** When true, the section renders as an OVERLAY disclosure ("flyout") — the
90
+ * hover/focus sibling of `collapsible`'s inline `<details>` reveal. The
91
+ * `heading` becomes a focusable `<button class="vms-section__trigger">`
92
+ * trigger; the `children` are wrapped in a `<div class="vms-section__panel">`
93
+ * that is absolutely positioned and revealed on `:hover` / `:focus-within`
94
+ * (pure CSS — no JavaScript, no state machine). Hover (desktop), tap-to-focus
95
+ * (touch), and keyboard Tab-to-trigger (a11y) all reveal it for free; it hides
96
+ * on blur / pointer-leave. Unlike `collapsible`, the open state is EPHEMERAL
97
+ * (driven by `:hover`/`:focus-within`), so it is NOT round-tripped and NOT
98
+ * snapshotted across re-renders — there is nothing to preserve.
99
+ *
100
+ * Use a flyout (overlay) rather than `collapsible` (inline) when the revealed
101
+ * content should float over siblings instead of pushing them — e.g. a menu
102
+ * inside a `layout:"row"` bar, where an inline disclosure would shove the bar
103
+ * open. A headingless flyout uses the fallback trigger label `"Menu"`.
104
+ *
105
+ * Mutually exclusive with the other section modes; the renderer resolves a
106
+ * fixed precedence and never combines them: `collapsible` > `flyout` > `link`
107
+ * > `action`. So `collapsible: true` wins if both are set, and a flyout
108
+ * section ignores `link`/`action`. Omitted/false = today's `<section>`
109
+ * rendering, byte-identical (no class drift, no extra elements). */
110
+ flyout?: boolean;
89
111
  /** Click-anywhere section dispatch primitive — mirrors `TableRow.action` (1.1.0)
90
112
  * at the section level. When set, the renderer makes the entire section
91
113
  * clickable AND keyboard-activatable (Enter / Space — Space preventDefaults
package/dist/server.d.ts CHANGED
@@ -53,9 +53,29 @@ export interface ShellResponseBody<TState> {
53
53
  * clear the state. Naturally paired with `preventUnload` for long-running
54
54
  * server actions. */
55
55
  busy?: boolean;
56
+ /** A SOFT (domain/validation) rejection that rides on an ok:true render —
57
+ * the action was refused but vm/state are still returned so the form keeps
58
+ * the user's input. Distinct from the ok:false + errors[] channel (which
59
+ * carries NO view): ok:false = "no view for you"; ok:true + rejected =
60
+ * "here's your view back, but the action did not take". An agent driving the
61
+ * wire checks `rejected` IN ADDITION to `ok`. Each violation reuses the
62
+ * ErrorEntry {path?, message, code?} shape; `path` is OPTIONAL — a violation
63
+ * with no path is a form/action-level rejection (vs field-bound when set). */
64
+ rejected?: ShellRejection;
65
+ }
66
+ /** Wrapper for a soft-validation rejection on an ok:true response. */
67
+ export interface ShellRejection {
68
+ violations: ErrorEntry[];
56
69
  }
57
70
  /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
58
71
  export declare function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState>;
72
+ /** Attach a soft-validation rejection to a normal re-render. Spread alongside
73
+ * vm/state — unlike a redirect, a rejection KEEPS the view:
74
+ * return { vm, state, ...shellRejection([{ path: "x", message: "…" }]) };
75
+ * Mirrors the C# `ShellResponse<T>.WithRejection(...)` fluent helper. */
76
+ export declare function shellRejection(violations: ErrorEntry[]): {
77
+ rejected: ShellRejection;
78
+ };
59
79
  /** Side-effect factories matching the C# ShellSideEffect static methods. */
60
80
  export declare const shellSideEffect: {
61
81
  setLocalStorage: (key: string, value: string) => ShellSideEffect;
package/dist/server.js CHANGED
@@ -379,6 +379,13 @@ export function parseJsonAction(body) {
379
379
  export function shellRedirect(url) {
380
380
  return { redirect: url };
381
381
  }
382
+ /** Attach a soft-validation rejection to a normal re-render. Spread alongside
383
+ * vm/state — unlike a redirect, a rejection KEEPS the view:
384
+ * return { vm, state, ...shellRejection([{ path: "x", message: "…" }]) };
385
+ * Mirrors the C# `ShellResponse<T>.WithRejection(...)` fluent helper. */
386
+ export function shellRejection(violations) {
387
+ return { rejected: { violations } };
388
+ }
382
389
  /** Side-effect factories matching the C# ShellSideEffect static methods. */
383
390
  export const shellSideEffect = {
384
391
  setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
package/dist/tui.js CHANGED
@@ -781,6 +781,11 @@ function layoutProps(layout, _sidebarFraction) {
781
781
  return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
782
782
  case "cards":
783
783
  return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
784
+ case "row":
785
+ // 1.11.0 — left-aligned wrapping horizontal row; items hug content.
786
+ // (Section flyout has no TUI overlay; it degrades to a plain labeled
787
+ // section — SectionView ignores the flag and renders children inline.)
788
+ return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
784
789
  case "sidebar":
785
790
  return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
786
791
  case "stack":
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.9.0",
3
+ "version": "1.11.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",
@@ -213,6 +213,21 @@ body {
213
213
  .vms-page--sidebar > .vms-page__title,
214
214
  .vms-section--sidebar > .vms-section__heading { flex: 0 0 100%; }
215
215
 
216
+ /* ── Layout preset: row (D-30 / 1.11.0) — left-aligned wrapping horizontal row;
217
+ children hug their content (no forced grow), wrap to the next line on overflow.
218
+ The general horizontal-row primitive — a navbar composes from this + flyout.
219
+ Title/heading takes its own full-width row, like the other presets. gap
220
+ inherited from .vms-page/.vms-section — NOT redeclared. ── */
221
+ .vms-page--row,
222
+ .vms-section--row {
223
+ display: flex;
224
+ flex-direction: row;
225
+ flex-wrap: wrap;
226
+ align-items: center;
227
+ }
228
+ .vms-page--row > .vms-page__title,
229
+ .vms-section--row > .vms-section__heading { flex: 0 0 100%; }
230
+
216
231
  /* ── Section variant: card (THEME-04) — grouped surface, existing seam vars ── */
217
232
  .vms-section--card {
218
233
  background: var(--vms-surface);
@@ -271,6 +286,57 @@ body {
271
286
  outline-offset: 2px;
272
287
  }
273
288
 
289
+ /* ── Section: flyout (1.11.0 — overlay disclosure, the hover/focus sibling of
290
+ collapsible) ── The heading renders as a focusable .vms-section__trigger button;
291
+ the children are wrapped in an absolutely-positioned .vms-section__panel revealed
292
+ on :hover / :focus-within. Pure CSS — hover (desktop), tap-to-focus (touch), and
293
+ Tab-to-trigger (keyboard a11y) all reveal it; it hides on blur / pointer-leave.
294
+ No JS, no round-tripped open state. The panel overlays (out of flow) so a flyout
295
+ inside a .vms-section--row bar does not reflow its siblings. */
296
+ .vms-section--flyout {
297
+ position: relative;
298
+ flex-direction: column; /* trigger stacks above the (absolute) panel; wrapper hugs the trigger */
299
+ align-items: flex-start;
300
+ }
301
+ .vms-section__trigger {
302
+ font-size: var(--vms-text-xs);
303
+ letter-spacing: 0.08em;
304
+ text-transform: uppercase;
305
+ color: var(--vms-text-muted);
306
+ cursor: pointer;
307
+ background: none;
308
+ border: none;
309
+ padding: 0;
310
+ font-family: inherit;
311
+ }
312
+ .vms-section__trigger:focus-visible {
313
+ outline: 2px solid var(--vms-accent);
314
+ outline-offset: 2px;
315
+ }
316
+ .vms-section__panel {
317
+ position: absolute;
318
+ top: 100%;
319
+ left: 0;
320
+ z-index: 50;
321
+ min-width: max-content;
322
+ display: flex;
323
+ flex-direction: column;
324
+ gap: var(--vms-space-sm);
325
+ margin-top: var(--vms-space-xs);
326
+ padding: var(--vms-space-sm);
327
+ background: var(--vms-surface);
328
+ border: 1px solid var(--vms-border);
329
+ border-radius: var(--vms-radius);
330
+ visibility: hidden;
331
+ opacity: 0;
332
+ transition: opacity var(--vms-t);
333
+ }
334
+ .vms-section--flyout:hover > .vms-section__panel,
335
+ .vms-section--flyout:focus-within > .vms-section__panel {
336
+ visibility: visible;
337
+ opacity: 1;
338
+ }
339
+
274
340
  /* ── Stat bar ── */
275
341
  .vms-stat-bar { display: flex; gap: var(--vms-space-lg); flex-wrap: wrap; }
276
342
  .vms-stat-bar__item { display: flex; align-items: baseline; gap: var(--vms-space-xs); }