@ashley-shrok/viewmodel-shell 1.10.0 → 1.12.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 +26 -0
- package/dist/browser.js +130 -4
- package/dist/index.d.ts +94 -5
- package/dist/tui.js +30 -0
- package/package.json +1 -1
- package/styles/default.css +153 -0
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,6 +368,29 @@ export class BrowserAdapter {
|
|
|
265
368
|
parent.appendChild(el);
|
|
266
369
|
return;
|
|
267
370
|
}
|
|
371
|
+
// 1.11.0 — flyout:true overlay disclosure. The hover/focus sibling of
|
|
372
|
+
// collapsible's inline <details>: heading => focusable <button> trigger,
|
|
373
|
+
// children => an absolutely-positioned panel revealed on :hover /
|
|
374
|
+
// :focus-within (pure CSS — see default.css; no JS, no listeners, no
|
|
375
|
+
// round-tripped open state). Precedence: collapsible (checked above) wins,
|
|
376
|
+
// so reaching here means collapsible is not set; flyout in turn wins over
|
|
377
|
+
// link/action below. Omitted/false renders byte-identical to <section>.
|
|
378
|
+
if (n.flyout === true) {
|
|
379
|
+
const el = document.createElement("div");
|
|
380
|
+
el.className = `vms-section vms-section--flyout${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}` : ""}`;
|
|
381
|
+
const trigger = document.createElement("button");
|
|
382
|
+
trigger.type = "button";
|
|
383
|
+
trigger.className = "vms-section__trigger";
|
|
384
|
+
// Headingless fallback label — mirrors collapsible's "Show details".
|
|
385
|
+
trigger.textContent = n.heading ?? "Menu";
|
|
386
|
+
el.appendChild(trigger);
|
|
387
|
+
const panel = document.createElement("div");
|
|
388
|
+
panel.className = "vms-section__panel";
|
|
389
|
+
this.kids(n.children, panel, on);
|
|
390
|
+
el.appendChild(panel);
|
|
391
|
+
parent.appendChild(el);
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
268
394
|
// 1.5.0 — SectionNode.link URL-wrapper variant (issue #21). When set,
|
|
269
395
|
// emit a wrapping <a href> element instead of <section> so every native
|
|
270
396
|
// browser link affordance works for free (middle-click / Ctrl/Cmd-click
|
|
@@ -274,7 +400,7 @@ export class BrowserAdapter {
|
|
|
274
400
|
// action — see validateSectionAction in server.ts.
|
|
275
401
|
if (n.link) {
|
|
276
402
|
const a = document.createElement("a");
|
|
277
|
-
a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
|
|
403
|
+
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}` : ""}`;
|
|
278
404
|
a.href = n.link.url;
|
|
279
405
|
// Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
|
|
280
406
|
// byte-for-byte: target=_blank + rel=noopener noreferrer when external.
|
|
@@ -318,7 +444,7 @@ export class BrowserAdapter {
|
|
|
318
444
|
return;
|
|
319
445
|
}
|
|
320
446
|
const el = document.createElement("section");
|
|
321
|
-
el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.action ? " vms-section--clickable" : ""}`;
|
|
447
|
+
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" : ""}`;
|
|
322
448
|
if (n.heading) {
|
|
323
449
|
const h = document.createElement("h2");
|
|
324
450
|
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) 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), "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,12 +90,44 @@ 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) emit .vms-section--{value}. Closed union (D-01/D-02; sidebar D-28). */
|
|
84
|
-
layout?: "stack" | "split" | "cards" | "sidebar";
|
|
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;
|
|
109
|
+
/** When true, the section renders as an OVERLAY disclosure ("flyout") — the
|
|
110
|
+
* hover/focus sibling of `collapsible`'s inline `<details>` reveal. The
|
|
111
|
+
* `heading` becomes a focusable `<button class="vms-section__trigger">`
|
|
112
|
+
* trigger; the `children` are wrapped in a `<div class="vms-section__panel">`
|
|
113
|
+
* that is absolutely positioned and revealed on `:hover` / `:focus-within`
|
|
114
|
+
* (pure CSS — no JavaScript, no state machine). Hover (desktop), tap-to-focus
|
|
115
|
+
* (touch), and keyboard Tab-to-trigger (a11y) all reveal it for free; it hides
|
|
116
|
+
* on blur / pointer-leave. Unlike `collapsible`, the open state is EPHEMERAL
|
|
117
|
+
* (driven by `:hover`/`:focus-within`), so it is NOT round-tripped and NOT
|
|
118
|
+
* snapshotted across re-renders — there is nothing to preserve.
|
|
119
|
+
*
|
|
120
|
+
* Use a flyout (overlay) rather than `collapsible` (inline) when the revealed
|
|
121
|
+
* content should float over siblings instead of pushing them — e.g. a menu
|
|
122
|
+
* inside a `layout:"row"` bar, where an inline disclosure would shove the bar
|
|
123
|
+
* open. A headingless flyout uses the fallback trigger label `"Menu"`.
|
|
124
|
+
*
|
|
125
|
+
* Mutually exclusive with the other section modes; the renderer resolves a
|
|
126
|
+
* fixed precedence and never combines them: `collapsible` > `flyout` > `link`
|
|
127
|
+
* > `action`. So `collapsible: true` wins if both are set, and a flyout
|
|
128
|
+
* section ignores `link`/`action`. Omitted/false = today's `<section>`
|
|
129
|
+
* rendering, byte-identical (no class drift, no extra elements). */
|
|
130
|
+
flyout?: boolean;
|
|
89
131
|
/** Click-anywhere section dispatch primitive — mirrors `TableRow.action` (1.1.0)
|
|
90
132
|
* at the section level. When set, the renderer makes the entire section
|
|
91
133
|
* clickable AND keyboard-activatable (Enter / Space — Space preventDefaults
|
|
@@ -391,6 +433,53 @@ export interface CopyButtonNode {
|
|
|
391
433
|
* buttons. Closed union; omitted = current behavior (no modifier). */
|
|
392
434
|
variant?: "primary" | "secondary" | "danger";
|
|
393
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
|
|
438
|
+
* intrinsic size FITS the available container (no axis overflow), else the next,
|
|
439
|
+
* else the LAST child as the guaranteed-fits fallback — container-relative
|
|
440
|
+
* responsive SELECTION decided CLIENT-SIDE at layout time via real measurement,
|
|
441
|
+
* with zero viewport breakpoints. Generalizes the `split`→`stack` collapse to
|
|
442
|
+
* arbitrary alternatives (e.g. a wide toolbar `row` first, a compact stacked
|
|
443
|
+
* `switcher` last).
|
|
444
|
+
*
|
|
445
|
+
* Children ordering convention (load-bearing): candidates are ordered
|
|
446
|
+
* preferred/widest FIRST → safe-fallback/narrowest LAST. Same direction as
|
|
447
|
+
* SwiftUI `ViewThatFits`.
|
|
448
|
+
*
|
|
449
|
+
* This is the ONE primitive that is NOT pure CSS — the selection requires real
|
|
450
|
+
* layout measurement and therefore lives ENTIRELY in `BrowserAdapter`
|
|
451
|
+
* (`browser.ts`), never in platform-agnostic core. The renderer measures each
|
|
452
|
+
* candidate's INTRINSIC (max-content / ideal, unwrapped) size in an off-screen
|
|
453
|
+
* probe and picks the first whose intrinsic size fits the container's available
|
|
454
|
+
* box — NOT its constrained rendered size, because a flex-wrap candidate shrinks
|
|
455
|
+
* to fit any width and would always appear to "fit". A `ResizeObserver` re-runs
|
|
456
|
+
* the selection on resize. In any no-layout context (TUI, SSR, jsdom,
|
|
457
|
+
* `clientWidth === 0`) it degrades to rendering the LAST (safe-fallback) child.
|
|
458
|
+
*
|
|
459
|
+
* ⚠️ SCOPE: `fits` is for selecting between layouts whose intrinsic width is
|
|
460
|
+
* BOUNDED and meaningful — a toolbar row vs. a stacked menu, icon-only vs.
|
|
461
|
+
* icon+label controls, a compact vs. full control cluster. It is NOT the tool
|
|
462
|
+
* for text-heavy multi-column page layouts: a paragraph's max-content width is
|
|
463
|
+
* "all text on one line" (effectively unbounded), so measuring it is not
|
|
464
|
+
* meaningful. For list/detail and similar text panes use `split` / `sidebar`,
|
|
465
|
+
* which collapse to a single column intrinsically on their own (zero @media).
|
|
466
|
+
*/
|
|
467
|
+
export interface FitsNode {
|
|
468
|
+
type: "fits";
|
|
469
|
+
/** Axis on which the container's fit is tested. CLOSED union; OMITTED =
|
|
470
|
+
* `"horizontal"` (the dominant case: pick the widest layout that fits the
|
|
471
|
+
* available WIDTH). `"horizontal"` tests width overflow, `"vertical"` tests
|
|
472
|
+
* height overflow, `"both"` tests EITHER axis. The renderer treats an absent
|
|
473
|
+
* `axis` as `"horizontal"`. */
|
|
474
|
+
axis?: "horizontal" | "vertical" | "both";
|
|
475
|
+
/** Ordered candidate list. ORDERING CONVENTION (load-bearing — document
|
|
476
|
+
* prominently): candidates are ordered **preferred/widest FIRST →
|
|
477
|
+
* safe-fallback/narrowest LAST**, the same direction as SwiftUI
|
|
478
|
+
* `ViewThatFits`. The renderer picks the FIRST candidate whose intrinsic
|
|
479
|
+
* size fits the container on `axis` (no overflow); the LAST candidate is the
|
|
480
|
+
* guaranteed-fits fallback rendered when none fit. */
|
|
481
|
+
children: ViewNode[];
|
|
482
|
+
}
|
|
394
483
|
export interface ShellOptions {
|
|
395
484
|
endpoint: string;
|
|
396
485
|
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
|
}
|
|
@@ -781,6 +806,11 @@ function layoutProps(layout, _sidebarFraction) {
|
|
|
781
806
|
return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
|
|
782
807
|
case "cards":
|
|
783
808
|
return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
|
|
809
|
+
case "row":
|
|
810
|
+
// 1.11.0 — left-aligned wrapping horizontal row; items hug content.
|
|
811
|
+
// (Section flyout has no TUI overlay; it degrades to a plain labeled
|
|
812
|
+
// section — SectionView ignores the flag and renders children inline.)
|
|
813
|
+
return { flexDirection: "row", flexWrap: "wrap", gap: 1 };
|
|
784
814
|
case "sidebar":
|
|
785
815
|
return { flexDirection: "row", gap: 1, flexGrow: 1, flexShrink: 1 };
|
|
786
816
|
case "stack":
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.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",
|
package/styles/default.css
CHANGED
|
@@ -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.
|
|
@@ -213,6 +224,97 @@ body {
|
|
|
213
224
|
.vms-page--sidebar > .vms-page__title,
|
|
214
225
|
.vms-section--sidebar > .vms-section__heading { flex: 0 0 100%; }
|
|
215
226
|
|
|
227
|
+
/* ── Layout preset: row (D-30 / 1.11.0) — left-aligned wrapping horizontal row;
|
|
228
|
+
children hug their content (no forced grow), wrap to the next line on overflow.
|
|
229
|
+
The general horizontal-row primitive — a navbar composes from this + flyout.
|
|
230
|
+
Title/heading takes its own full-width row, like the other presets. gap
|
|
231
|
+
inherited from .vms-page/.vms-section — NOT redeclared. ── */
|
|
232
|
+
.vms-page--row,
|
|
233
|
+
.vms-section--row {
|
|
234
|
+
display: flex;
|
|
235
|
+
flex-direction: row;
|
|
236
|
+
flex-wrap: wrap;
|
|
237
|
+
align-items: center;
|
|
238
|
+
}
|
|
239
|
+
.vms-page--row > .vms-page__title,
|
|
240
|
+
.vms-section--row > .vms-section__heading { flex: 0 0 100%; }
|
|
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
|
+
|
|
216
318
|
/* ── Section variant: card (THEME-04) — grouped surface, existing seam vars ── */
|
|
217
319
|
.vms-section--card {
|
|
218
320
|
background: var(--vms-surface);
|
|
@@ -271,6 +373,57 @@ body {
|
|
|
271
373
|
outline-offset: 2px;
|
|
272
374
|
}
|
|
273
375
|
|
|
376
|
+
/* ── Section: flyout (1.11.0 — overlay disclosure, the hover/focus sibling of
|
|
377
|
+
collapsible) ── The heading renders as a focusable .vms-section__trigger button;
|
|
378
|
+
the children are wrapped in an absolutely-positioned .vms-section__panel revealed
|
|
379
|
+
on :hover / :focus-within. Pure CSS — hover (desktop), tap-to-focus (touch), and
|
|
380
|
+
Tab-to-trigger (keyboard a11y) all reveal it; it hides on blur / pointer-leave.
|
|
381
|
+
No JS, no round-tripped open state. The panel overlays (out of flow) so a flyout
|
|
382
|
+
inside a .vms-section--row bar does not reflow its siblings. */
|
|
383
|
+
.vms-section--flyout {
|
|
384
|
+
position: relative;
|
|
385
|
+
flex-direction: column; /* trigger stacks above the (absolute) panel; wrapper hugs the trigger */
|
|
386
|
+
align-items: flex-start;
|
|
387
|
+
}
|
|
388
|
+
.vms-section__trigger {
|
|
389
|
+
font-size: var(--vms-text-xs);
|
|
390
|
+
letter-spacing: 0.08em;
|
|
391
|
+
text-transform: uppercase;
|
|
392
|
+
color: var(--vms-text-muted);
|
|
393
|
+
cursor: pointer;
|
|
394
|
+
background: none;
|
|
395
|
+
border: none;
|
|
396
|
+
padding: 0;
|
|
397
|
+
font-family: inherit;
|
|
398
|
+
}
|
|
399
|
+
.vms-section__trigger:focus-visible {
|
|
400
|
+
outline: 2px solid var(--vms-accent);
|
|
401
|
+
outline-offset: 2px;
|
|
402
|
+
}
|
|
403
|
+
.vms-section__panel {
|
|
404
|
+
position: absolute;
|
|
405
|
+
top: 100%;
|
|
406
|
+
left: 0;
|
|
407
|
+
z-index: 50;
|
|
408
|
+
min-width: max-content;
|
|
409
|
+
display: flex;
|
|
410
|
+
flex-direction: column;
|
|
411
|
+
gap: var(--vms-space-sm);
|
|
412
|
+
margin-top: var(--vms-space-xs);
|
|
413
|
+
padding: var(--vms-space-sm);
|
|
414
|
+
background: var(--vms-surface);
|
|
415
|
+
border: 1px solid var(--vms-border);
|
|
416
|
+
border-radius: var(--vms-radius);
|
|
417
|
+
visibility: hidden;
|
|
418
|
+
opacity: 0;
|
|
419
|
+
transition: opacity var(--vms-t);
|
|
420
|
+
}
|
|
421
|
+
.vms-section--flyout:hover > .vms-section__panel,
|
|
422
|
+
.vms-section--flyout:focus-within > .vms-section__panel {
|
|
423
|
+
visibility: visible;
|
|
424
|
+
opacity: 1;
|
|
425
|
+
}
|
|
426
|
+
|
|
274
427
|
/* ── Stat bar ── */
|
|
275
428
|
.vms-stat-bar { display: flex; gap: var(--vms-space-lg); flex-wrap: wrap; }
|
|
276
429
|
.vms-stat-bar__item { display: flex; align-items: baseline; gap: var(--vms-space-xs); }
|