@ashley-shrok/viewmodel-shell 1.11.0 → 2.0.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/dist/browser.d.ts CHANGED
@@ -5,6 +5,7 @@ export declare class BrowserAdapter implements Adapter {
5
5
  private sa;
6
6
  private detailsOpenSnapshot;
7
7
  private sectionKeyCounter;
8
+ private fitsObservers;
8
9
  constructor(container: HTMLElement);
9
10
  render(vm: ViewNode, onAction: (action: ActionEvent) => void, stateAccess?: StateAccess): void;
10
11
  navigate(url: string): void;
@@ -23,6 +24,31 @@ export declare class BrowserAdapter implements Adapter {
23
24
  private node;
24
25
  private kids;
25
26
  private page;
27
+ /**
28
+ * Phase 10 (FITS-01) — the SwiftUI `ViewThatFits` measure-and-pick renderer.
29
+ * Renders each candidate in order and keeps the FIRST that does not overflow
30
+ * the container on `axis` (1px tolerance to avoid sub-pixel false positives),
31
+ * leaving the LAST candidate rendered as the guaranteed-fits fallback if none
32
+ * fit. `pick()` runs SYNCHRONOUSLY inside one frame, so the browser paints
33
+ * only the final choice — no flash of intermediate candidates.
34
+ *
35
+ * No-layout fallback: when `container.clientWidth === 0` (jsdom / SSR /
36
+ * detached / display:none) measurement is unavailable, so it renders ONLY the
37
+ * LAST (safe-fallback) child.
38
+ *
39
+ * The `.vms-fits` container is a full-width block (CSS), so its observed width
40
+ * is PARENT-driven — it reflects the available space, not the chosen child.
41
+ * That keeps measurement correct AND prevents a measure→resize feedback loop,
42
+ * making observing the container stable. A `ResizeObserver` re-runs `pick()`
43
+ * on a window/parent resize and is tracked in `fitsObservers` for the next
44
+ * render's disconnect-and-clear.
45
+ *
46
+ * Known v1 limitation (document, don't solve): a resize-triggered candidate
47
+ * switch rebuilds the fits subtree, so focus/caret/draft state INSIDE a fits
48
+ * child may reset on a resize-switch. The framework's normal focus/scroll
49
+ * preservation covers server-driven re-renders, not this resize-switch path.
50
+ */
51
+ private fits;
26
52
  private section;
27
53
  private list;
28
54
  private listItem;
package/dist/browser.js CHANGED
@@ -51,6 +51,12 @@ export class BrowserAdapter {
51
51
  // collapsible:true so that multiple sections sharing the same base key
52
52
  // (anonymous, or duplicate heading) get distinct final keys.
53
53
  sectionKeyCounter = new Map();
54
+ // Phase 10 (FITS-01) — per-render registry of the ResizeObservers created by
55
+ // fits() containers. ALL are disconnected and the array cleared at the TOP of
56
+ // every render() (before the innerHTML wipe) so observers from a prior tree
57
+ // never leak when the tree is rebuilt — the same per-render reset idiom as
58
+ // detailsOpenSnapshot / sectionKeyCounter above.
59
+ fitsObservers = [];
54
60
  constructor(container) {
55
61
  this.container = container;
56
62
  }
@@ -84,6 +90,11 @@ export class BrowserAdapter {
84
90
  });
85
91
  this.detailsOpenSnapshot = openMap;
86
92
  this.sectionKeyCounter = new Map();
93
+ // Phase 10 (FITS-01) — disconnect every ResizeObserver registered by the
94
+ // prior render's fits() calls before the tree is rebuilt (leak prevention).
95
+ // Same per-render reset model as the focus/scroll/details snapshots above.
96
+ this.fitsObservers.forEach(o => o.disconnect());
97
+ this.fitsObservers = [];
87
98
  this.container.innerHTML = "";
88
99
  this.node(vm, this.container, onAction);
89
100
  if (focusId) {
@@ -222,6 +233,7 @@ export class BrowserAdapter {
222
233
  case "modal": return this.modal(n, parent, on);
223
234
  case "table": return this.table(n, parent, on);
224
235
  case "copy-button": return this.copyButton(n, parent);
236
+ case "fits": return this.fits(n, parent, on);
225
237
  }
226
238
  }
227
239
  kids(nodes, parent, on) {
@@ -229,7 +241,7 @@ export class BrowserAdapter {
229
241
  }
230
242
  page(n, parent, on) {
231
243
  const el = document.createElement("div");
232
- 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}` : ""}`;
244
+ 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}` : ""}`;
233
245
  if (n.title) {
234
246
  const h = document.createElement("h1");
235
247
  h.className = "vms-page__title";
@@ -239,6 +251,97 @@ export class BrowserAdapter {
239
251
  this.kids(n.children, el, on);
240
252
  parent.appendChild(el);
241
253
  }
254
+ /**
255
+ * Phase 10 (FITS-01) — the SwiftUI `ViewThatFits` measure-and-pick renderer.
256
+ * Renders each candidate in order and keeps the FIRST that does not overflow
257
+ * the container on `axis` (1px tolerance to avoid sub-pixel false positives),
258
+ * leaving the LAST candidate rendered as the guaranteed-fits fallback if none
259
+ * fit. `pick()` runs SYNCHRONOUSLY inside one frame, so the browser paints
260
+ * only the final choice — no flash of intermediate candidates.
261
+ *
262
+ * No-layout fallback: when `container.clientWidth === 0` (jsdom / SSR /
263
+ * detached / display:none) measurement is unavailable, so it renders ONLY the
264
+ * LAST (safe-fallback) child.
265
+ *
266
+ * The `.vms-fits` container is a full-width block (CSS), so its observed width
267
+ * is PARENT-driven — it reflects the available space, not the chosen child.
268
+ * That keeps measurement correct AND prevents a measure→resize feedback loop,
269
+ * making observing the container stable. A `ResizeObserver` re-runs `pick()`
270
+ * on a window/parent resize and is tracked in `fitsObservers` for the next
271
+ * render's disconnect-and-clear.
272
+ *
273
+ * Known v1 limitation (document, don't solve): a resize-triggered candidate
274
+ * switch rebuilds the fits subtree, so focus/caret/draft state INSIDE a fits
275
+ * child may reset on a resize-switch. The framework's normal focus/scroll
276
+ * preservation covers server-driven re-renders, not this resize-switch path.
277
+ */
278
+ fits(n, parent, on) {
279
+ const container = document.createElement("div");
280
+ container.className = "vms-fits";
281
+ parent.appendChild(container);
282
+ const axis = n.axis ?? "horizontal";
283
+ const candidates = n.children;
284
+ const pick = () => {
285
+ // Defensive: a fits with no children is a degenerate tree.
286
+ if (candidates.length === 0)
287
+ return;
288
+ const vertical = axis === "vertical";
289
+ // The available space is the container's REAL (constrained) box. The
290
+ // container is block / full-width so this is the slot the parent gave it,
291
+ // not the chosen child's size.
292
+ const available = vertical ? container.clientHeight : container.clientWidth;
293
+ // No-layout guard: measurement unavailable (jsdom / SSR / display:none /
294
+ // detached) → render the safe LAST child (guaranteed-fits fallback).
295
+ if (available === 0) {
296
+ container.innerHTML = "";
297
+ this.node(candidates[candidates.length - 1], container, on);
298
+ return;
299
+ }
300
+ // Measure each candidate's INTRINSIC size in an off-screen probe, NOT its
301
+ // constrained rendered size. This is the crux of a correct ViewThatFits:
302
+ // a candidate like a flex-wrap `row` SHRINKS / WRAPS to fit any width, so
303
+ // its in-container scrollWidth never exceeds clientWidth — measuring that
304
+ // would make every candidate "fit" and the selection would never change
305
+ // (the bug this replaces). Measuring the probe at `width: max-content`
306
+ // lets the candidate lay out at its IDEAL width (one line, no wrap), which
307
+ // is what ViewThatFits compares against the proposed size. The probe is
308
+ // appended to `container` for correct style/font inheritance but kept
309
+ // off-screen + hidden, and it does NOT change the container's observed
310
+ // border-box, so the ResizeObserver below cannot feed back into itself.
311
+ const probe = document.createElement("div");
312
+ probe.setAttribute("aria-hidden", "true");
313
+ probe.style.cssText =
314
+ "position:absolute;left:-99999px;top:0;visibility:hidden;pointer-events:none;";
315
+ if (vertical) {
316
+ // Vertical fit: constrain width to the real available width and measure
317
+ // the resulting intrinsic height against the available height.
318
+ probe.style.width = `${available}px`;
319
+ }
320
+ else {
321
+ probe.style.width = "max-content"; // intrinsic (ideal, unwrapped) width
322
+ }
323
+ container.appendChild(probe);
324
+ let chosen = candidates.length - 1; // fallback = last
325
+ for (let i = 0; i < candidates.length; i++) {
326
+ probe.innerHTML = "";
327
+ this.node(candidates[i], probe, on);
328
+ void probe.offsetWidth; // force a synchronous reflow before reading
329
+ const intrinsic = vertical ? probe.scrollHeight : probe.scrollWidth;
330
+ // First candidate whose intrinsic size fits the available space wins.
331
+ if (intrinsic <= available + 1) {
332
+ chosen = i;
333
+ break;
334
+ }
335
+ }
336
+ probe.remove();
337
+ container.innerHTML = "";
338
+ this.node(candidates[chosen], container, on);
339
+ };
340
+ pick();
341
+ const ro = new ResizeObserver(() => pick());
342
+ ro.observe(container);
343
+ this.fitsObservers.push(ro);
344
+ }
242
345
  section(n, parent, on) {
243
346
  // 1.2.0 — collapsible:true branch emits native <details>/<summary>; the
244
347
  // open/closed state is DOM-local and preserved across re-renders by the
@@ -250,7 +353,7 @@ export class BrowserAdapter {
250
353
  this.sectionKeyCounter.set(baseKey, ordinal + 1);
251
354
  const finalKey = `${baseKey}:${ordinal}`;
252
355
  const el = document.createElement("details");
253
- el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
356
+ el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${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}` : ""}`;
254
357
  el.dataset.sectionKey = finalKey;
255
358
  // Initial render is always closed — the post-render restore loop in
256
359
  // render() re-applies `open=true` for keys the user had open before.
@@ -265,29 +368,6 @@ export class BrowserAdapter {
265
368
  parent.appendChild(el);
266
369
  return;
267
370
  }
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
- }
291
371
  // 1.5.0 — SectionNode.link URL-wrapper variant (issue #21). When set,
292
372
  // emit a wrapping <a href> element instead of <section> so every native
293
373
  // browser link affordance works for free (middle-click / Ctrl/Cmd-click
@@ -297,7 +377,7 @@ export class BrowserAdapter {
297
377
  // action — see validateSectionAction in server.ts.
298
378
  if (n.link) {
299
379
  const a = document.createElement("a");
300
- a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
380
+ a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${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}` : ""}`;
301
381
  a.href = n.link.url;
302
382
  // Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
303
383
  // byte-for-byte: target=_blank + rel=noopener noreferrer when external.
@@ -341,7 +421,7 @@ export class BrowserAdapter {
341
421
  return;
342
422
  }
343
423
  const el = document.createElement("section");
344
- el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.action ? " vms-section--clickable" : ""}`;
424
+ el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${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" : ""}`;
345
425
  if (n.heading) {
346
426
  const h = document.createElement("h2");
347
427
  h.className = "vms-section__heading";
package/dist/index.d.ts CHANGED
@@ -63,16 +63,26 @@ export interface Adapter {
63
63
  * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
64
  setBusy?(active: boolean): void;
65
65
  }
66
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
66
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | FitsNode;
67
67
  export interface PageNode {
68
68
  type: "page";
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), "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";
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), "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
+ layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
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
+ /** 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}. */
77
+ arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
78
+ /** 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}. */
79
+ align?: "start" | "center" | "end" | "stretch" | "baseline";
80
+ /** For `layout:"switcher"`: the content-width FLIP point — a CLOSED size scale (NOT raw CSS, per P2) mapping sm→20rem, md→30rem, lg→40rem, xl→48rem. Emits .vms-switch--{token} which sets `--vms-switch-threshold`. Omitted = no class → the `var(--vms-switch-threshold, 30rem)` CSS default (30rem) holds = well-defined, byte-identical to today. (SWITCH-02) */
81
+ threshold?: "sm" | "md" | "lg" | "xl";
82
+ /** For `layout:"switcher"`: the OPTIONAL max-items-per-row count cap — once the child count exceeds `limit`, every child goes full-width regardless of container width. A bounded numeric union (2..8) per P2 (bounded scalar, not raw CSS). Emits .vms-switch-limit--{n}. Omitted = no class → no count cap, byte-identical to today. (SWITCH-02) */
83
+ limit?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
84
+ /** 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) */
85
+ minItem?: "xs" | "sm" | "md" | "lg" | "xl";
76
86
  children: ViewNode[];
77
87
  }
78
88
  export interface SectionNode {
@@ -80,34 +90,22 @@ export interface SectionNode {
80
90
  heading?: string;
81
91
  /** Section surface variant. Omitted = current behavior (no modifier class). "card" emits .vms-section--card. Closed union (D-03). */
82
92
  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), "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";
93
+ /** 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). */
94
+ layout?: "stack" | "split" | "cards" | "sidebar" | "row" | "switcher";
95
+ /** 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}. */
96
+ arrange?: "start" | "center" | "end" | "space-between" | "space-around" | "space-evenly";
97
+ /** 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}. */
98
+ align?: "start" | "center" | "end" | "stretch" | "baseline";
99
+ /** For `layout:"switcher"`: the content-width FLIP point — a CLOSED size scale (NOT raw CSS, per P2) mapping sm→20rem, md→30rem, lg→40rem, xl→48rem. Emits .vms-switch--{token} which sets `--vms-switch-threshold`. Omitted = no class → the `var(--vms-switch-threshold, 30rem)` CSS default (30rem) holds = well-defined, byte-identical to today. (SWITCH-02) */
100
+ threshold?: "sm" | "md" | "lg" | "xl";
101
+ /** For `layout:"switcher"`: the OPTIONAL max-items-per-row count cap — once the child count exceeds `limit`, every child goes full-width regardless of container width. A bounded numeric union (2..8) per P2 (bounded scalar, not raw CSS). Emits .vms-switch-limit--{n}. Omitted = no class → no count cap, byte-identical to today. (SWITCH-02) */
102
+ limit?: 2 | 3 | 4 | 5 | 6 | 7 | 8;
103
+ /** 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) */
104
+ minItem?: "xs" | "sm" | "md" | "lg" | "xl";
85
105
  /** 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
106
  id?: string;
87
107
  /** 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
108
  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;
111
109
  /** Click-anywhere section dispatch primitive — mirrors `TableRow.action` (1.1.0)
112
110
  * at the section level. When set, the renderer makes the entire section
113
111
  * clickable AND keyboard-activatable (Enter / Space — Space preventDefaults
@@ -413,6 +411,53 @@ export interface CopyButtonNode {
413
411
  * buttons. Closed union; omitted = current behavior (no modifier). */
414
412
  variant?: "primary" | "secondary" | "danger";
415
413
  }
414
+ /**
415
+ * The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
416
+ * intrinsic size FITS the available container (no axis overflow), else the next,
417
+ * else the LAST child as the guaranteed-fits fallback — container-relative
418
+ * responsive SELECTION decided CLIENT-SIDE at layout time via real measurement,
419
+ * with zero viewport breakpoints. Generalizes the `split`→`stack` collapse to
420
+ * arbitrary alternatives (e.g. a wide toolbar `row` first, a compact stacked
421
+ * `switcher` last).
422
+ *
423
+ * Children ordering convention (load-bearing): candidates are ordered
424
+ * preferred/widest FIRST → safe-fallback/narrowest LAST. Same direction as
425
+ * SwiftUI `ViewThatFits`.
426
+ *
427
+ * This is the ONE primitive that is NOT pure CSS — the selection requires real
428
+ * layout measurement and therefore lives ENTIRELY in `BrowserAdapter`
429
+ * (`browser.ts`), never in platform-agnostic core. The renderer measures each
430
+ * candidate's INTRINSIC (max-content / ideal, unwrapped) size in an off-screen
431
+ * probe and picks the first whose intrinsic size fits the container's available
432
+ * box — NOT its constrained rendered size, because a flex-wrap candidate shrinks
433
+ * to fit any width and would always appear to "fit". A `ResizeObserver` re-runs
434
+ * the selection on resize. In any no-layout context (TUI, SSR, jsdom,
435
+ * `clientWidth === 0`) it degrades to rendering the LAST (safe-fallback) child.
436
+ *
437
+ * ⚠️ SCOPE: `fits` is for selecting between layouts whose intrinsic width is
438
+ * BOUNDED and meaningful — a toolbar row vs. a stacked menu, icon-only vs.
439
+ * icon+label controls, a compact vs. full control cluster. It is NOT the tool
440
+ * for text-heavy multi-column page layouts: a paragraph's max-content width is
441
+ * "all text on one line" (effectively unbounded), so measuring it is not
442
+ * meaningful. For list/detail and similar text panes use `split` / `sidebar`,
443
+ * which collapse to a single column intrinsically on their own (zero @media).
444
+ */
445
+ export interface FitsNode {
446
+ type: "fits";
447
+ /** Axis on which the container's fit is tested. CLOSED union; OMITTED =
448
+ * `"horizontal"` (the dominant case: pick the widest layout that fits the
449
+ * available WIDTH). `"horizontal"` tests width overflow, `"vertical"` tests
450
+ * height overflow, `"both"` tests EITHER axis. The renderer treats an absent
451
+ * `axis` as `"horizontal"`. */
452
+ axis?: "horizontal" | "vertical" | "both";
453
+ /** Ordered candidate list. ORDERING CONVENTION (load-bearing — document
454
+ * prominently): candidates are ordered **preferred/widest FIRST →
455
+ * safe-fallback/narrowest LAST**, the same direction as SwiftUI
456
+ * `ViewThatFits`. The renderer picks the FIRST candidate whose intrinsic
457
+ * size fits the container on `axis` (no overflow); the LAST candidate is the
458
+ * guaranteed-fits fallback rendered when none fit. */
459
+ children: ViewNode[];
460
+ }
416
461
  export interface ShellOptions {
417
462
  endpoint: string;
418
463
  actionEndpoint: string;
package/dist/tui.js CHANGED
@@ -501,6 +501,14 @@ function countPanes(vm) {
501
501
  for (const c of node.children)
502
502
  visit(c, false);
503
503
  }
504
+ else if (node.type === "fits") {
505
+ // FITS-02 — the TUI renders only a fits node's LAST child, so treat it
506
+ // as a transparent wrapper around that child for pane counting (recurse
507
+ // into the last child only, isTopLevel=false like section).
508
+ const last = node.children[node.children.length - 1];
509
+ if (last)
510
+ visit(last, false);
511
+ }
504
512
  };
505
513
  // B4 focus trap: when a modal is in the tree, only count panes within the
506
514
  // modal's subtree. Outer panes still RENDER, but they're not part of the
@@ -565,6 +573,13 @@ function focusedPaneSummary(vm, index) {
565
573
  if (visit(c, false))
566
574
  return true;
567
575
  }
576
+ else if (node.type === "fits") {
577
+ // FITS-02 — only the LAST child renders in the TUI; mirror that here so
578
+ // focus targeting matches the rendered tree (recurse into last child only).
579
+ const last = node.children[node.children.length - 1];
580
+ if (last && visit(last, false))
581
+ return true;
582
+ }
568
583
  return false;
569
584
  };
570
585
  const modal = findModal(vm);
@@ -737,6 +752,16 @@ function renderNode(node, ctx, key) {
737
752
  case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
738
753
  case "form": return _jsx(FormView, { node: node, ctx: ctx }, key);
739
754
  case "field": return _jsx(FieldView, { node: node, ctx: ctx }, key);
755
+ case "fits": {
756
+ // FITS-02 — deliberate TUI degradation. A terminal has no pixel layout
757
+ // engine, so the `fits` node's measure-and-pick selection is meaningless
758
+ // here; we render its guaranteed-fits LAST candidate (the documented
759
+ // fallback — children are ordered preferred/widest FIRST → safe/narrowest
760
+ // LAST). The TUI is @experimental; the requirement is only that `fits`
761
+ // doesn't break it and degrades sensibly. Empty children → render nothing.
762
+ const last = node.children[node.children.length - 1];
763
+ return last ? renderNode(last, ctx, key) : null;
764
+ }
740
765
  default:
741
766
  return _jsx(UnsupportedView, { type: node.type }, key);
742
767
  }
@@ -783,8 +808,6 @@ function layoutProps(layout, _sidebarFraction) {
783
808
  return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
784
809
  case "row":
785
810
  // 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
811
  return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
789
812
  case "sidebar":
790
813
  return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.11.0",
3
+ "version": "2.0.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",
@@ -159,6 +159,17 @@ body {
159
159
  .vms-section--cards > .vms-section__heading {
160
160
  grid-column: 1 / -1;
161
161
  }
162
+ /* minItem tokens (GRID-01) — promote the CSS-only `--vms-card-min` (the D-05
163
+ seam) to declared server intent. Each sets the custom property the auto-fit
164
+ cards rule above reads unchanged (xs→10rem, sm→13rem, md→16rem [= the default],
165
+ lg→20rem, xl→24rem), so a smaller token yields more, narrower columns and a
166
+ larger token fewer, wider ones. An OMITTED minItem emits no class, so the
167
+ inherited 16rem default holds = byte-identical to today. */
168
+ .vms-cards-min--xs { --vms-card-min: 10rem; }
169
+ .vms-cards-min--sm { --vms-card-min: 13rem; }
170
+ .vms-cards-min--md { --vms-card-min: 16rem; }
171
+ .vms-cards-min--lg { --vms-card-min: 20rem; }
172
+ .vms-cards-min--xl { --vms-card-min: 24rem; }
162
173
 
163
174
  /* ── Layout preset: split (LAYOUT-02, D-06/D-07) ──
164
175
  Capped exactly-2-then-1, equal-width (shared 1fr max), zero media queries.
@@ -215,7 +226,7 @@ body {
215
226
 
216
227
  /* ── Layout preset: row (D-30 / 1.11.0) — left-aligned wrapping horizontal row;
217
228
  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.
229
+ The general horizontal-row primitive — a navbar composes from this.
219
230
  Title/heading takes its own full-width row, like the other presets. gap
220
231
  inherited from .vms-page/.vms-section — NOT redeclared. ── */
221
232
  .vms-page--row,
@@ -228,6 +239,82 @@ body {
228
239
  .vms-page--row > .vms-page__title,
229
240
  .vms-section--row > .vms-section__heading { flex: 0 0 100%; }
230
241
 
242
+ /* ── Fits container (FITS-01 / Phase 10). STRUCTURAL selector (not visual
243
+ layout): a full-width block so its observed width reflects the available
244
+ space (parent-driven), which both (a) makes measurement correct and (b)
245
+ prevents a measure→resize feedback loop. The candidate SELECTION is done in
246
+ the renderer (browser.ts), not in CSS. ── */
247
+ .vms-fits { display: block; }
248
+
249
+ /* ── Alignment: arrange (main axis → justify-content) / align (cross axis →
250
+ align-items) (ALIGN-01/02/03 / 1.12.0). Intended for layout:"row" (the cluster
251
+ primitive) but these are generic box-alignment, so they're harmless on any flex
252
+ container. Closed enum sets copied from Jetpack Compose Arrangement ∩ Flutter
253
+ MainAxisAlignment (arrange) and Flutter CrossAxisAlignment (align). An OMITTED
254
+ field emits no class, so the row defaults (justify-content:flex-start /
255
+ align-items:center, both inherited from the .vms-*--row block above) hold =
256
+ byte-identical to today. ── */
257
+ .vms-arrange--start { justify-content: flex-start; }
258
+ .vms-arrange--center { justify-content: center; }
259
+ .vms-arrange--end { justify-content: flex-end; }
260
+ .vms-arrange--space-between { justify-content: space-between; }
261
+ .vms-arrange--space-around { justify-content: space-around; }
262
+ .vms-arrange--space-evenly { justify-content: space-evenly; }
263
+ .vms-align--start { align-items: flex-start; }
264
+ .vms-align--center { align-items: center; }
265
+ .vms-align--end { align-items: flex-end; }
266
+ .vms-align--stretch { align-items: stretch; }
267
+ .vms-align--baseline { align-items: baseline; }
268
+
269
+ /* ── Layout preset: switcher (SWITCH-01/02 / 1.13.0) — the Every-Layout Switcher,
270
+ zero @media. N equal items flip all-row ↔ all-stack ATOMICALLY via a negative
271
+ `flex-basis`: above the threshold the basis `(threshold - 100%) * 999` goes
272
+ hugely negative → clamped to 0 → all children share one row; below it the
273
+ basis goes hugely positive → each child claims a full line → all stack. No
274
+ intermediate partial-wrap state — that's the distinction from `cards`
275
+ auto-fit (which passes through intermediate column counts). Title/heading
276
+ keeps its own full-width row like the other presets. gap inherited from
277
+ .vms-page/.vms-section — NOT redeclared. An OMITTED threshold/limit emits no
278
+ class, so the `var(--vms-switch-threshold, 30rem)` default (30rem) + no count
279
+ cap hold = byte-identical to having no token. ── */
280
+ .vms-page--switcher,
281
+ .vms-section--switcher {
282
+ display: flex;
283
+ flex-direction: row; /* override .vms-page/.vms-section base column — the
284
+ negative-flex-basis flip operates on the MAIN axis,
285
+ which MUST be horizontal or the children stack
286
+ vertically forever (the trick never fires). */
287
+ flex-wrap: wrap;
288
+ }
289
+ .vms-page--switcher > *,
290
+ .vms-section--switcher > * {
291
+ flex-grow: 1;
292
+ flex-basis: calc((var(--vms-switch-threshold, 30rem) - 100%) * 999);
293
+ }
294
+ .vms-page--switcher > .vms-page__title,
295
+ .vms-section--switcher > .vms-section__heading { flex: 0 0 100%; }
296
+
297
+ /* threshold tokens — each sets the custom property the basis calc reads
298
+ (sm→20rem, md→30rem, lg→40rem, xl→48rem; md == the 30rem default). */
299
+ .vms-switch--sm { --vms-switch-threshold: 20rem; }
300
+ .vms-switch--md { --vms-switch-threshold: 30rem; }
301
+ .vms-switch--lg { --vms-switch-threshold: 40rem; }
302
+ .vms-switch--xl { --vms-switch-threshold: 48rem; }
303
+
304
+ /* limit (max-per-row count cap) — a quantity query per allowed n (2..8). The
305
+ `:nth-last-child(n+{n+1})` selector matches every child ONLY when there are
306
+ MORE than n of them (a switcher with exactly `limit` children stays on one
307
+ row; the cap engages on overflow). The matched children get
308
+ `flex-basis:100%`, overriding the negative-basis calc above to force each
309
+ onto its own full-width line regardless of container width. */
310
+ .vms-switch-limit--2 > :nth-last-child(n+3), .vms-switch-limit--2 > :nth-last-child(n+3) ~ * { flex-basis: 100%; }
311
+ .vms-switch-limit--3 > :nth-last-child(n+4), .vms-switch-limit--3 > :nth-last-child(n+4) ~ * { flex-basis: 100%; }
312
+ .vms-switch-limit--4 > :nth-last-child(n+5), .vms-switch-limit--4 > :nth-last-child(n+5) ~ * { flex-basis: 100%; }
313
+ .vms-switch-limit--5 > :nth-last-child(n+6), .vms-switch-limit--5 > :nth-last-child(n+6) ~ * { flex-basis: 100%; }
314
+ .vms-switch-limit--6 > :nth-last-child(n+7), .vms-switch-limit--6 > :nth-last-child(n+7) ~ * { flex-basis: 100%; }
315
+ .vms-switch-limit--7 > :nth-last-child(n+8), .vms-switch-limit--7 > :nth-last-child(n+8) ~ * { flex-basis: 100%; }
316
+ .vms-switch-limit--8 > :nth-last-child(n+9), .vms-switch-limit--8 > :nth-last-child(n+9) ~ * { flex-basis: 100%; }
317
+
231
318
  /* ── Section variant: card (THEME-04) — grouped surface, existing seam vars ── */
232
319
  .vms-section--card {
233
320
  background: var(--vms-surface);
@@ -286,57 +373,6 @@ body {
286
373
  outline-offset: 2px;
287
374
  }
288
375
 
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
-
340
376
  /* ── Stat bar ── */
341
377
  .vms-stat-bar { display: flex; gap: var(--vms-space-lg); flex-wrap: wrap; }
342
378
  .vms-stat-bar__item { display: flex; align-items: baseline; gap: var(--vms-space-xs); }