@ashley-shrok/viewmodel-shell 3.5.0 → 3.6.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/dist/browser.js CHANGED
@@ -293,7 +293,7 @@ export class BrowserAdapter {
293
293
  }
294
294
  page(n, parent, on) {
295
295
  const el = document.createElement("div");
296
- 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}` : ""}`;
297
297
  if (n.title) {
298
298
  const h = document.createElement("h1");
299
299
  h.className = "vms-page__title";
@@ -405,7 +405,7 @@ export class BrowserAdapter {
405
405
  this.sectionKeyCounter.set(baseKey, ordinal + 1);
406
406
  const finalKey = `${baseKey}:${ordinal}`;
407
407
  const el = document.createElement("details");
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.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}` : ""}`;
409
409
  el.dataset.sectionKey = finalKey;
410
410
  // Initial render is always closed — the post-render restore loop in
411
411
  // render() re-applies `open=true` for keys the user had open before.
@@ -429,7 +429,7 @@ export class BrowserAdapter {
429
429
  // action — see validateSectionAction in server.ts.
430
430
  if (n.link) {
431
431
  const a = document.createElement("a");
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.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}` : ""}`;
433
433
  a.href = n.link.url;
434
434
  // Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
435
435
  // byte-for-byte: target=_blank + rel=noopener noreferrer when external.
@@ -473,7 +473,7 @@ export class BrowserAdapter {
473
473
  return;
474
474
  }
475
475
  const el = document.createElement("section");
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.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" : ""}`;
477
477
  if (n.heading) {
478
478
  const h = document.createElement("h2");
479
479
  h.className = "vms-section__heading";
package/dist/index.d.ts CHANGED
@@ -83,6 +83,8 @@ export interface PageNode {
83
83
  density?: "comfortable" | "compact";
84
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). */
85
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;
86
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). */
87
89
  width?: "wide" | "full";
88
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}. */
@@ -106,6 +108,8 @@ export interface SectionNode {
106
108
  tone?: "danger" | "warning" | "success" | "info";
107
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). */
108
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;
109
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}. */
110
114
  arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
111
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}. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.5.0",
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.",
3
+ "version": "3.6.1",
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 \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "ashley-shrok",
@@ -144,6 +144,31 @@ 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
+ /* Fill makes the host's <body> margin load-bearing: the base `body` rule above
158
+ deliberately leaves margin to the host (one-line `body{margin:0}` convention),
159
+ but a 100dvh page plus the UA's default 8px body margin overflows the viewport
160
+ (100dvh + 16px → ~16px of scroll, a clipped pinned footer). So when — and ONLY
161
+ when — a fill page is present, the framework owns the body reset itself. Scoped
162
+ via :has() so non-fill pages keep the existing host-owns-margin convention. */
163
+ body:has(.vms-page--fill) { margin: 0; }
164
+ /* Fill + sidebar composition: `.vms-page--sidebar` is `flex-wrap: wrap` (for the
165
+ breakpoint-free stacked collapse), so the flex LINE's cross-size is the children's
166
+ natural max, not the container's 100dvh. With `align: stretch` the children then
167
+ stretch to that natural max and burst the fill height (e.g. a long sidebar list
168
+ pushes the page past the viewport). Cap each child at the page height and let it
169
+ scroll internally instead. Scoped to fill+sidebar so no other layout is touched. */
170
+ .vms-page--fill.vms-page--sidebar > * { max-height: 100%; overflow-y: auto; }
171
+
147
172
  /* ── Section ── */
148
173
  .vms-section { display: flex; flex-direction: column; gap: var(--vms-space-sm); }
149
174
  .vms-section__heading {