@ashley-shrok/viewmodel-shell 3.1.0 → 3.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,8 +1,12 @@
1
1
  # @ashley-shrok/viewmodel-shell
2
2
 
3
- 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.
3
+ A server-driven UI framework built around one promise: **an agent can build, test, and operate a complete app end-to-end with no human in the loop and no browser anywhere in sight.** That falls out of a single idea: the interface is structured data, not code.
4
4
 
5
- The server returns a JSON tree of typed nodes; a thin TypeScript adapter renders it to the DOM (or, with the same wire, a terminal). The browser never owns application state every interaction dispatches a semantic action to a single POST endpoint and the server returns the next state plus a fresh view.
5
+ The server is a pure transformer — current UI state + an action → next state + a fresh view tree (a JSON tree of typed nodes). A thin adapter renders it to the DOM (or, with the same wire, a terminal) with zero app-specific code; the browser never owns application state. Because the whole interface is data, three things come for free:
6
+
7
+ - **Build it blind** — there's nothing to *look at* to get it right; the view description is the truth.
8
+ - **Test it without a browser** — every interaction is a plain function from input → output, so the entire UI is exercised with ordinary in-process unit tests (no headless browser, no Playwright, no flake) on every CI run. The quiet giant: the full-coverage, test-everything-through-the-UI regime that's usually aspirational is the *default* here.
9
+ - **Use it like an API** — a finished app is already a clean, self-describing wire protocol, so a *different* agent can drive it (read the screen → act → read the next) as if it were an API, because it is one.
6
10
 
7
11
  The frontend is backend-agnostic: it speaks a small JSON contract over a single POST endpoint that takes `multipart/form-data` (with `_action` and `_state` fields). A .NET reference backend ships in the repo, but any language can produce the same contract.
8
12
 
package/dist/browser.js CHANGED
@@ -359,7 +359,7 @@ export class BrowserAdapter {
359
359
  this.sectionKeyCounter.set(baseKey, ordinal + 1);
360
360
  const finalKey = `${baseKey}:${ordinal}`;
361
361
  const el = document.createElement("details");
362
- 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}` : ""}`;
362
+ 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}` : ""}`;
363
363
  el.dataset.sectionKey = finalKey;
364
364
  // Initial render is always closed — the post-render restore loop in
365
365
  // render() re-applies `open=true` for keys the user had open before.
@@ -383,7 +383,7 @@ export class BrowserAdapter {
383
383
  // action — see validateSectionAction in server.ts.
384
384
  if (n.link) {
385
385
  const a = document.createElement("a");
386
- 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}` : ""}`;
386
+ 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}` : ""}`;
387
387
  a.href = n.link.url;
388
388
  // Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
389
389
  // byte-for-byte: target=_blank + rel=noopener noreferrer when external.
@@ -427,7 +427,7 @@ export class BrowserAdapter {
427
427
  return;
428
428
  }
429
429
  const el = document.createElement("section");
430
- 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.action ? " vms-section--clickable" : ""}`;
430
+ 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" : ""}`;
431
431
  if (n.heading) {
432
432
  const h = document.createElement("h2");
433
433
  h.className = "vms-section__heading";
package/dist/index.d.ts CHANGED
@@ -104,6 +104,10 @@ export interface SectionNode {
104
104
  limit?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
105
105
  /** For `layout:"cards"`: overrides the auto-fit minimum track width (today's fixed `--vms-card-min: 16rem`) — a CLOSED size scale (NOT raw CSS, per P2) mapping xs→10rem, sm→13rem, md→16rem (= today's default), lg→20rem, xl→24rem. Emits .vms-cards-min--{token} which sets `--vms-card-min` on that element (the existing `repeat(auto-fit, minmax(min(var(--vms-card-min),100%),1fr))` cards rule reads it). Omitted = no class → the inherited 16rem default holds = byte-identical to today. Intended for `cards`; harmless elsewhere (it only sets a variable the cards rule reads). (GRID-01) */
106
106
  minItem?: "xs" | "sm" | "md" | "lg" | "xl";
107
+ /** Per-child cross-axis self-alignment — maps to CSS `align-self`, the per-child counterpart to the parent-level `align`. In the default vertical `stack` (a flex column) the cross axis is horizontal, so `start | center | end` = left/center/right, overriding the parent's alignment for THIS section only. The motivating case is a chat bubble (a `variant:"card"` section aligned to one side); general-purpose otherwise (a centered narrow group). Omitted = no class → the child inherits the parent's cross-axis alignment = byte-identical to today. Emits .vms-self--{value}. Closed union (CHILD-01). */
108
+ alignSelf?: "start" | "center" | "end";
109
+ /** Bounded content-width cap — a CLOSED token set (NOT raw CSS, per P2), implemented with `max-inline-size` (writing-mode-safe). Fractional values are proportional to the container (half→50%, two-thirds→66.6667%, three-quarters→75%) — the chat-gutter case, scaling with width; `prose` caps at the readable measure (min(65ch,100%), the Tailwind `max-w-prose` / Every-Layout `--measure` cap). The section still shrinks to its content below the cap. Omitted = no class → no cap (today's full-width behavior) = byte-identical. Emits .vms-maxw--{value}. Closed union (CHILD-02). */
110
+ maxWidth?: "half" | "two-thirds" | "three-quarters" | "prose";
107
111
  /** 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. */
108
112
  id?: string;
109
113
  /** 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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.1.0",
3
+ "version": "3.2.1",
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",
@@ -278,6 +278,24 @@ body {
278
278
  .vms-align--stretch { align-items: stretch; }
279
279
  .vms-align--baseline { align-items: baseline; }
280
280
 
281
+ /* ── Child-side modifiers: alignSelf (per-child cross axis → align-self) + maxWidth
282
+ (bounded content cap → max-inline-size) on a SectionNode (CHILD-01/02 / 3.2.0).
283
+ alignSelf is the per-child counterpart to align: in the default flex column stack
284
+ the cross axis is horizontal, so start/center/end = left/center/right and it
285
+ overrides the parent's alignment for ONE section — the chat-bubble case (a card
286
+ pinned to one side). maxWidth is a CLOSED token set (P2), not raw CSS: fractional
287
+ values scale with the container (the chat gutter), prose caps at the readable
288
+ measure min(65ch,100%) (the established min(X,100%) intrinsic-safety idiom, same
289
+ as the cards rule). Both intrinsic, zero @media (P1). An OMITTED field emits no
290
+ class → inherit parent alignment / no cap = byte-identical to today. ── */
291
+ .vms-self--start { align-self: flex-start; }
292
+ .vms-self--center { align-self: center; }
293
+ .vms-self--end { align-self: flex-end; }
294
+ .vms-maxw--half { max-inline-size: 50%; }
295
+ .vms-maxw--two-thirds { max-inline-size: 66.6667%; }
296
+ .vms-maxw--three-quarters { max-inline-size: 75%; }
297
+ .vms-maxw--prose { max-inline-size: min(65ch, 100%); }
298
+
281
299
  /* ── Layout preset: switcher (SWITCH-01/02 / 1.13.0) — the Every-Layout Switcher,
282
300
  zero @media. N equal items flip all-row ↔ all-stack ATOMICALLY via a negative
283
301
  `flex-basis`: above the threshold the basis `(threshold - 100%) * 999` goes
@@ -421,6 +439,14 @@ body {
421
439
  flex-wrap: wrap;
422
440
  }
423
441
  .vms-form--inline > .vms-field { flex: 1 1 12rem; }
442
+ /* #23 — inline forms bottom-align their row (align-items: flex-end), but the
443
+ base align-self: flex-start on the implicit submit button and the buttons[]
444
+ row escapes that (an item's align-self beats the parent's align-items), so
445
+ the submit would top-anchor while the fields hang at the bottom. Drop those
446
+ direct children back to auto so the parent's flex-end wins — the same
447
+ override the modal footer already applies to .vms-button. */
448
+ .vms-form--inline > .vms-button,
449
+ .vms-form--inline > .vms-form__buttons { align-self: auto; }
424
450
  /* Multi-action submit buttons row (0.10.0 / #15) — groups the form's
425
451
  buttons[] entries on one line, like a dialog action bar. align-self
426
452
  flex-start keeps the row from stretching full-width in the stacked form. */