@ashley-shrok/viewmodel-shell 5.0.1 → 5.1.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 +18 -0
- package/dist/browser.js +119 -0
- package/dist/index.d.ts +67 -1
- package/dist/server.js +26 -7
- package/dist/tui.js +20 -0
- package/package.json +2 -2
- package/styles/default.css +184 -0
package/dist/browser.d.ts
CHANGED
|
@@ -180,4 +180,22 @@ export declare class BrowserAdapter implements Adapter {
|
|
|
180
180
|
/** BadgeNode — a compact inline status pill / count. Leaf node: label text +
|
|
181
181
|
* tone/emphasis modifier classes. */
|
|
182
182
|
private badge;
|
|
183
|
+
/** BreadcrumbNode — a `<nav aria-label="breadcrumb">` landmark wrapping an
|
|
184
|
+
* `<ol>`. Every crumb but the last navigates (href → `<a>`, action →
|
|
185
|
+
* dispatching `<button>`); the LAST crumb is the current page, rendered as
|
|
186
|
+
* plain text with `aria-current="page"` on its `<li>` (position is the
|
|
187
|
+
* signal — no per-item flag). A framework-drawn, `aria-hidden` separator
|
|
188
|
+
* sits between items (its glyph is CSS-owned — see default.css). All text is
|
|
189
|
+
* set via textContent (never innerHTML). */
|
|
190
|
+
private breadcrumb;
|
|
191
|
+
/** StepsNode — a discrete stepper. Per-step status DERIVES from `current`
|
|
192
|
+
* (index < current = done, === current = current, > current = upcoming);
|
|
193
|
+
* there is NO per-step status field. The framework draws numbered markers
|
|
194
|
+
* (a check glyph for done), the connector lines, and the intrinsic
|
|
195
|
+
* horizontal→vertical collapse (CSS). a11y: the group carries an accessible
|
|
196
|
+
* name; the current step's `<li>` gets `aria-current="step"`; each marker's
|
|
197
|
+
* state (complete/current/upcoming) rides an `aria-label` so it's never
|
|
198
|
+
* conveyed by color alone. The stepper is NOT focusable and is NOT
|
|
199
|
+
* `role="progressbar"` (that's a continuous %). All text via textContent. */
|
|
200
|
+
private steps;
|
|
183
201
|
}
|
package/dist/browser.js
CHANGED
|
@@ -360,6 +360,8 @@ export class BrowserAdapter {
|
|
|
360
360
|
case "empty-state": return this.emptyState(n, parent, on);
|
|
361
361
|
case "badge": return this.badge(n, parent);
|
|
362
362
|
case "chart": return this.chart(n, parent);
|
|
363
|
+
case "breadcrumb": return this.breadcrumb(n, parent, on);
|
|
364
|
+
case "steps": return this.steps(n, parent);
|
|
363
365
|
default: {
|
|
364
366
|
// Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
|
|
365
367
|
// Runtime trees are server-controlled JSON, so an unknown/forward-version
|
|
@@ -1795,4 +1797,121 @@ export class BrowserAdapter {
|
|
|
1795
1797
|
span.textContent = n.label;
|
|
1796
1798
|
parent.appendChild(span);
|
|
1797
1799
|
}
|
|
1800
|
+
/** BreadcrumbNode — a `<nav aria-label="breadcrumb">` landmark wrapping an
|
|
1801
|
+
* `<ol>`. Every crumb but the last navigates (href → `<a>`, action →
|
|
1802
|
+
* dispatching `<button>`); the LAST crumb is the current page, rendered as
|
|
1803
|
+
* plain text with `aria-current="page"` on its `<li>` (position is the
|
|
1804
|
+
* signal — no per-item flag). A framework-drawn, `aria-hidden` separator
|
|
1805
|
+
* sits between items (its glyph is CSS-owned — see default.css). All text is
|
|
1806
|
+
* set via textContent (never innerHTML). */
|
|
1807
|
+
breadcrumb(n, parent, on) {
|
|
1808
|
+
const nav = document.createElement("nav");
|
|
1809
|
+
nav.setAttribute("aria-label", "breadcrumb");
|
|
1810
|
+
const ol = document.createElement("ol");
|
|
1811
|
+
ol.className = "vms-breadcrumb";
|
|
1812
|
+
n.items.forEach((item, i) => {
|
|
1813
|
+
const isLast = i === n.items.length - 1;
|
|
1814
|
+
const li = document.createElement("li");
|
|
1815
|
+
li.className = "vms-breadcrumb__item";
|
|
1816
|
+
if (isLast) {
|
|
1817
|
+
// Current page: plain, non-clickable, aria-current on the <li>.
|
|
1818
|
+
li.setAttribute("aria-current", "page");
|
|
1819
|
+
const span = document.createElement("span");
|
|
1820
|
+
span.className = "vms-breadcrumb__current";
|
|
1821
|
+
span.textContent = item.label;
|
|
1822
|
+
li.appendChild(span);
|
|
1823
|
+
}
|
|
1824
|
+
else if (item.href != null) {
|
|
1825
|
+
// URL navigation — reuse LinkNode's external target/rel handling.
|
|
1826
|
+
const a = document.createElement("a");
|
|
1827
|
+
a.className = "vms-breadcrumb__link";
|
|
1828
|
+
a.href = item.href;
|
|
1829
|
+
a.textContent = item.label;
|
|
1830
|
+
if (item.external) {
|
|
1831
|
+
a.target = "_blank";
|
|
1832
|
+
a.rel = "noopener noreferrer";
|
|
1833
|
+
}
|
|
1834
|
+
li.appendChild(a);
|
|
1835
|
+
}
|
|
1836
|
+
else if (item.action) {
|
|
1837
|
+
// Server dispatch — a button that fires the action name only.
|
|
1838
|
+
const action = item.action;
|
|
1839
|
+
const btn = document.createElement("button");
|
|
1840
|
+
btn.type = "button";
|
|
1841
|
+
btn.className = "vms-breadcrumb__link vms-breadcrumb__link--action";
|
|
1842
|
+
btn.textContent = item.label;
|
|
1843
|
+
btn.addEventListener("click", () => { on({ name: action.name }); });
|
|
1844
|
+
li.appendChild(btn);
|
|
1845
|
+
}
|
|
1846
|
+
else {
|
|
1847
|
+
// Non-last crumb with neither href nor action — inert label.
|
|
1848
|
+
const span = document.createElement("span");
|
|
1849
|
+
span.className = "vms-breadcrumb__link";
|
|
1850
|
+
span.textContent = item.label;
|
|
1851
|
+
li.appendChild(span);
|
|
1852
|
+
}
|
|
1853
|
+
// Framework-drawn separator after every non-last crumb (glyph via CSS).
|
|
1854
|
+
if (!isLast) {
|
|
1855
|
+
const sep = document.createElement("span");
|
|
1856
|
+
sep.className = "vms-breadcrumb__separator";
|
|
1857
|
+
sep.setAttribute("aria-hidden", "true");
|
|
1858
|
+
li.appendChild(sep);
|
|
1859
|
+
}
|
|
1860
|
+
ol.appendChild(li);
|
|
1861
|
+
});
|
|
1862
|
+
nav.appendChild(ol);
|
|
1863
|
+
parent.appendChild(nav);
|
|
1864
|
+
}
|
|
1865
|
+
/** StepsNode — a discrete stepper. Per-step status DERIVES from `current`
|
|
1866
|
+
* (index < current = done, === current = current, > current = upcoming);
|
|
1867
|
+
* there is NO per-step status field. The framework draws numbered markers
|
|
1868
|
+
* (a check glyph for done), the connector lines, and the intrinsic
|
|
1869
|
+
* horizontal→vertical collapse (CSS). a11y: the group carries an accessible
|
|
1870
|
+
* name; the current step's `<li>` gets `aria-current="step"`; each marker's
|
|
1871
|
+
* state (complete/current/upcoming) rides an `aria-label` so it's never
|
|
1872
|
+
* conveyed by color alone. The stepper is NOT focusable and is NOT
|
|
1873
|
+
* `role="progressbar"` (that's a continuous %). All text via textContent. */
|
|
1874
|
+
steps(n, parent) {
|
|
1875
|
+
const ol = document.createElement("ol");
|
|
1876
|
+
ol.className = n.orientation === "vertical"
|
|
1877
|
+
? "vms-steps vms-steps--vertical"
|
|
1878
|
+
: "vms-steps";
|
|
1879
|
+
ol.setAttribute("aria-label", "progress");
|
|
1880
|
+
n.steps.forEach((step, i) => {
|
|
1881
|
+
const state = i < n.current ? "done" : i === n.current ? "current" : "upcoming";
|
|
1882
|
+
const li = document.createElement("li");
|
|
1883
|
+
li.className = `vms-steps__step vms-steps__step--${state}`;
|
|
1884
|
+
if (state === "current")
|
|
1885
|
+
li.setAttribute("aria-current", "step");
|
|
1886
|
+
// Connector — CSS-drawn line marker-center to marker-center, behind the
|
|
1887
|
+
// opaque marker (hidden on the first step via CSS).
|
|
1888
|
+
const connector = document.createElement("span");
|
|
1889
|
+
connector.className = "vms-steps__connector";
|
|
1890
|
+
connector.setAttribute("aria-hidden", "true");
|
|
1891
|
+
li.appendChild(connector);
|
|
1892
|
+
// Marker — number, or a check glyph for done. State rides aria-label so
|
|
1893
|
+
// it's not color-only (the aria-label overrides the visual glyph name).
|
|
1894
|
+
const marker = document.createElement("span");
|
|
1895
|
+
marker.className = "vms-steps__marker";
|
|
1896
|
+
marker.setAttribute("aria-label", state === "done" ? "complete" : state === "current" ? "current" : "upcoming");
|
|
1897
|
+
marker.textContent = state === "done" ? "✓" : String(i + 1);
|
|
1898
|
+
li.appendChild(marker);
|
|
1899
|
+
// Body — label + optional one-line description.
|
|
1900
|
+
const body = document.createElement("span");
|
|
1901
|
+
body.className = "vms-steps__body";
|
|
1902
|
+
const label = document.createElement("span");
|
|
1903
|
+
label.className = "vms-steps__label";
|
|
1904
|
+
label.textContent = step.label;
|
|
1905
|
+
body.appendChild(label);
|
|
1906
|
+
if (step.description != null && step.description !== "") {
|
|
1907
|
+
const desc = document.createElement("span");
|
|
1908
|
+
desc.className = "vms-steps__description";
|
|
1909
|
+
desc.textContent = step.description;
|
|
1910
|
+
body.appendChild(desc);
|
|
1911
|
+
}
|
|
1912
|
+
li.appendChild(body);
|
|
1913
|
+
ol.appendChild(li);
|
|
1914
|
+
});
|
|
1915
|
+
parent.appendChild(ol);
|
|
1916
|
+
}
|
|
1798
1917
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -103,7 +103,7 @@ export interface Adapter {
|
|
|
103
103
|
* concept). */
|
|
104
104
|
reload?(): void;
|
|
105
105
|
}
|
|
106
|
-
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode | ChartNode;
|
|
106
|
+
export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode | ChartNode | BreadcrumbNode | StepsNode;
|
|
107
107
|
export interface PageNode {
|
|
108
108
|
type: "page";
|
|
109
109
|
title?: string;
|
|
@@ -417,6 +417,39 @@ export interface LinkNode {
|
|
|
417
417
|
* like every other view decision — there is no client-side route matching. */
|
|
418
418
|
active?: boolean;
|
|
419
419
|
}
|
|
420
|
+
/** One crumb in a BreadcrumbNode trail. Mirrors LinkNode's nav model:
|
|
421
|
+
* `href` = browser navigation (`external` ⇒ new tab + noopener, exactly like
|
|
422
|
+
* LinkNode); `action` = a server dispatch instead of a URL (the VMS-native
|
|
423
|
+
* navigate-by-state path). There is NO per-item "current" flag — position is
|
|
424
|
+
* the signal: the LAST item in `items` is auto-rendered as the current page
|
|
425
|
+
* (non-clickable, `aria-current="page"`), so it needs neither `href` nor
|
|
426
|
+
* `action`. A crumb that carries `action` is a dispatch-bearing descendant, so
|
|
427
|
+
* the action-name uniqueness walk descends into it (see collectActions). */
|
|
428
|
+
export interface BreadcrumbItem {
|
|
429
|
+
/** Visible crumb text (required). */
|
|
430
|
+
label: string;
|
|
431
|
+
/** Browser navigation target. Omit on the last (current) crumb. */
|
|
432
|
+
href?: string;
|
|
433
|
+
/** true = open outside the current app context (browser: new tab + noopener),
|
|
434
|
+
* exactly like LinkNode.external. Only meaningful alongside `href`. */
|
|
435
|
+
external?: boolean;
|
|
436
|
+
/** Server dispatch instead of a URL — the VMS navigate-by-state alternative to
|
|
437
|
+
* `href`. Its name is uniqueness-checked by the tree validator. */
|
|
438
|
+
action?: ActionEvent;
|
|
439
|
+
}
|
|
440
|
+
/** A breadcrumb trail — an ordered list of labelled positions from the site/app
|
|
441
|
+
* root to the current page. The framework owns ALL appearance and a11y (never
|
|
442
|
+
* on the wire): it draws the `<nav aria-label="breadcrumb">` landmark, the
|
|
443
|
+
* `<ol>`, `aria-current="page"` on the last item, and a FIXED separator between
|
|
444
|
+
* items (the one appearance knob other frameworks expose stays framework-drawn
|
|
445
|
+
* here — cf. DividerNode's framework-drawn separator). The wire carries only the
|
|
446
|
+
* ordered labels + their nav targets. */
|
|
447
|
+
export interface BreadcrumbNode {
|
|
448
|
+
type: "breadcrumb";
|
|
449
|
+
/** Ordered root→current list. The LAST entry is the current page (auto
|
|
450
|
+
* non-clickable); earlier entries navigate via `href` or `action`. */
|
|
451
|
+
items: BreadcrumbItem[];
|
|
452
|
+
}
|
|
420
453
|
export interface ImageNode {
|
|
421
454
|
type: "image";
|
|
422
455
|
/** Image source URL (required). */
|
|
@@ -695,6 +728,39 @@ export interface ChartNode {
|
|
|
695
728
|
/** Optional chart title rendered above the plot. */
|
|
696
729
|
title?: string;
|
|
697
730
|
}
|
|
731
|
+
/** One stage in a StepsNode progression. Carries only display data — status
|
|
732
|
+
* (done/current/upcoming) is NEVER on the item; it DERIVES from the node's
|
|
733
|
+
* `current` index. */
|
|
734
|
+
export interface StepItem {
|
|
735
|
+
/** Stage name (required). */
|
|
736
|
+
label: string;
|
|
737
|
+
/** Optional one-line supporting text shown beside/under the label. */
|
|
738
|
+
description?: string;
|
|
739
|
+
}
|
|
740
|
+
/** A discrete step / stepper / wizard progress indicator — an ordered list of
|
|
741
|
+
* stages with a single 0-based `current` index. Per-step status DERIVES from
|
|
742
|
+
* `current` (index < current = done, index === current = current, index >
|
|
743
|
+
* current = upcoming); there is NO per-step status field. The framework owns
|
|
744
|
+
* ALL appearance and a11y (never on the wire): numbered markers (check glyph
|
|
745
|
+
* for done), connector lines drawn marker-center to marker-center, the
|
|
746
|
+
* intrinsic horizontal→vertical reflow, and the discrete `aria-current="step"`
|
|
747
|
+
* a11y pattern (it is NOT `role="progressbar"`). The wire carries only the
|
|
748
|
+
* ordered labels + which one is current. */
|
|
749
|
+
export interface StepsNode {
|
|
750
|
+
type: "steps";
|
|
751
|
+
/** Ordered stages. */
|
|
752
|
+
steps: StepItem[];
|
|
753
|
+
/** 0-based index of the active step. Required — `0` is a meaningful value
|
|
754
|
+
* (the first step is current), so it always crosses the wire. */
|
|
755
|
+
current: number;
|
|
756
|
+
/** Layout INTENT (a closed enum, not a raw directive — the framework owns the
|
|
757
|
+
* actual layout + reflow). OMITTED = `"horizontal"`: the renderer treats an
|
|
758
|
+
* absent `orientation` as horizontal — a responsive strip that auto-stacks to
|
|
759
|
+
* vertical INTRINSICALLY when the container is narrow (zero viewport
|
|
760
|
+
* breakpoints). `"vertical"` = a deliberate vertical wizard (markers down the
|
|
761
|
+
* left, connector running down, descriptions beside each step). */
|
|
762
|
+
orientation?: "horizontal" | "vertical";
|
|
763
|
+
}
|
|
698
764
|
export interface ShellOptions {
|
|
699
765
|
endpoint: string;
|
|
700
766
|
actionEndpoint: string;
|
package/dist/server.js
CHANGED
|
@@ -203,11 +203,27 @@ function collectActions(node, enclosingForm, out) {
|
|
|
203
203
|
collectActions(es.action, enclosingForm, out);
|
|
204
204
|
return;
|
|
205
205
|
}
|
|
206
|
+
case "breadcrumb": {
|
|
207
|
+
// A breadcrumb crumb can navigate by DISPATCHING AN ACTION instead of an
|
|
208
|
+
// href (the VMS navigate-by-state model). Each such action is a
|
|
209
|
+
// dispatch-bearing descendant, so the uniqueness collector MUST descend
|
|
210
|
+
// into it — modeled on the `tabs` arm, with the optional guard the
|
|
211
|
+
// empty-state arm uses (not every crumb carries an action; the last/current
|
|
212
|
+
// crumb and href-only crumbs carry none). Skipping this would silently
|
|
213
|
+
// exempt crumb actions from the one-name-one-operation rule.
|
|
214
|
+
const bc = node;
|
|
215
|
+
for (const item of bc.items) {
|
|
216
|
+
if (item.action)
|
|
217
|
+
recordAction(item.action, enclosingForm, out);
|
|
218
|
+
}
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
206
221
|
// Nodes with no dispatch-bearing actions of their own:
|
|
207
|
-
// text, link, image, stat-bar, progress, copy-button, badge, chart
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
222
|
+
// text, link, image, stat-bar, progress, copy-button, badge, chart, steps
|
|
223
|
+
// (breadcrumb crumb actions ARE recorded above via the "breadcrumb" arm)
|
|
224
|
+
// ChartNode (CHART-05) and StepsNode (NAV-02) are DELIBERATE
|
|
225
|
+
// childless/action-free leaves — they carry only data, so they fall through
|
|
226
|
+
// here with no recursion (no fits-style blind spot).
|
|
211
227
|
default:
|
|
212
228
|
return;
|
|
213
229
|
}
|
|
@@ -372,9 +388,12 @@ function walkForSectionAction(node, outerInteractive) {
|
|
|
372
388
|
return;
|
|
373
389
|
}
|
|
374
390
|
// Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
|
|
375
|
-
// tabs, progress, table, copy-button, badge, chart) carry
|
|
376
|
-
// descendants — TableNode rows hold strings + per-row controls,
|
|
377
|
-
// ChartNode (CHART-05) is a childless/action-free data leaf
|
|
391
|
+
// tabs, progress, table, copy-button, badge, chart, breadcrumb, steps) carry
|
|
392
|
+
// no SectionNode descendants — TableNode rows hold strings + per-row controls,
|
|
393
|
+
// not sections; ChartNode (CHART-05) is a childless/action-free data leaf;
|
|
394
|
+
// BreadcrumbNode/StepsNode (NAV-01..03) hold plain { label, ... } records,
|
|
395
|
+
// not ViewNode children, so no recursion is needed here (deliberate, not a
|
|
396
|
+
// missed walk).
|
|
378
397
|
default:
|
|
379
398
|
return;
|
|
380
399
|
}
|
package/dist/tui.js
CHANGED
|
@@ -752,6 +752,26 @@ function renderNode(node, ctx, key) {
|
|
|
752
752
|
case "modal": return _jsx(ModalView, { node: node, ctx: ctx }, key);
|
|
753
753
|
case "copy-button": return _jsx(CopyButtonView, { node: node, ctx: ctx }, key);
|
|
754
754
|
case "divider": return _jsx("text", { fg: "#555555", children: node.orientation === "vertical" ? "│" : "─".repeat(40) }, key);
|
|
755
|
+
case "breadcrumb":
|
|
756
|
+
// NAV-03 — @experimental TUI degrade. A terminal has no breadcrumb chrome,
|
|
757
|
+
// so we render the trail inline as labels joined by a separator glyph (the
|
|
758
|
+
// LAST item is the current page — rendered plainly, no interactivity). The
|
|
759
|
+
// framework-owned separator becomes a text " › ". Bar is "doesn't break +
|
|
760
|
+
// degrades sensibly" (see the `fits` case). No DOM.
|
|
761
|
+
return _jsx("text", { fg: "#888888", children: node.items.map((i) => i.label).join(" › ") }, key);
|
|
762
|
+
case "steps": {
|
|
763
|
+
// NAV-03 — @experimental TUI degrade. No stepper chrome in a terminal, so
|
|
764
|
+
// each step renders on its own line with a state marker DERIVED from
|
|
765
|
+
// `current` (index < current = done ✓, === current = ▸, > current = ·),
|
|
766
|
+
// mirroring the browser renderer's derive-from-current rule. `description`
|
|
767
|
+
// is appended when present. No DOM.
|
|
768
|
+
return (_jsx("box", { flexDirection: "column", children: node.steps.map((step, i) => {
|
|
769
|
+
const marker = i < node.current ? "✓" : i === node.current ? "▸" : "·";
|
|
770
|
+
const fg = i === node.current ? "#88aaff" : i < node.current ? "#aaaaaa" : "#666666";
|
|
771
|
+
const line = step.description ? `${marker} ${step.label} — ${step.description}` : `${marker} ${step.label}`;
|
|
772
|
+
return _jsx("text", { fg: fg, children: line }, i);
|
|
773
|
+
}) }, key));
|
|
774
|
+
}
|
|
755
775
|
case "form": return _jsx(FormView, { node: node, ctx: ctx }, key);
|
|
756
776
|
case "field": return _jsx(FieldView, { node: node, ctx: ctx }, key);
|
|
757
777
|
case "fits": {
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "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
|
|
3
|
+
"version": "5.1.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.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"author": "ashley-shrok",
|
package/styles/default.css
CHANGED
|
@@ -1162,3 +1162,187 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
1162
1162
|
border-color: var(--_badge-tone);
|
|
1163
1163
|
color: var(--_badge-tone);
|
|
1164
1164
|
}
|
|
1165
|
+
|
|
1166
|
+
/* ── Breadcrumb ──
|
|
1167
|
+
<nav aria-label="breadcrumb"> > <ol.vms-breadcrumb> trail. Earlier crumbs are
|
|
1168
|
+
accent links (or dispatching buttons reset to read as links); the current
|
|
1169
|
+
(last) crumb is bold --vms-text; a fixed, muted, framework-drawn separator
|
|
1170
|
+
(glyph via ::before — off the wire) sits after each non-last crumb. All
|
|
1171
|
+
spacing/colour is token-driven. */
|
|
1172
|
+
.vms-breadcrumb {
|
|
1173
|
+
display: flex;
|
|
1174
|
+
flex-wrap: wrap;
|
|
1175
|
+
align-items: center;
|
|
1176
|
+
gap: var(--vms-space-xs);
|
|
1177
|
+
list-style: none;
|
|
1178
|
+
margin: 0;
|
|
1179
|
+
padding: 0;
|
|
1180
|
+
font-size: var(--vms-text-sm);
|
|
1181
|
+
}
|
|
1182
|
+
.vms-breadcrumb__item {
|
|
1183
|
+
display: inline-flex;
|
|
1184
|
+
align-items: center;
|
|
1185
|
+
gap: var(--vms-space-xs);
|
|
1186
|
+
}
|
|
1187
|
+
.vms-breadcrumb__link {
|
|
1188
|
+
color: var(--vms-accent);
|
|
1189
|
+
text-decoration: none;
|
|
1190
|
+
border-bottom: 1px solid transparent;
|
|
1191
|
+
transition: border-color var(--vms-t);
|
|
1192
|
+
}
|
|
1193
|
+
.vms-breadcrumb__link:hover { border-bottom-color: var(--vms-accent); }
|
|
1194
|
+
/* Action crumbs render as <button> — strip native chrome so they read as links. */
|
|
1195
|
+
button.vms-breadcrumb__link {
|
|
1196
|
+
background: none;
|
|
1197
|
+
padding: 0;
|
|
1198
|
+
border-top: none;
|
|
1199
|
+
border-right: none;
|
|
1200
|
+
border-left: none;
|
|
1201
|
+
font: inherit;
|
|
1202
|
+
line-height: inherit;
|
|
1203
|
+
cursor: pointer;
|
|
1204
|
+
}
|
|
1205
|
+
.vms-breadcrumb__current {
|
|
1206
|
+
color: var(--vms-text);
|
|
1207
|
+
font-weight: 600;
|
|
1208
|
+
}
|
|
1209
|
+
/* One fixed framework-drawn separator (the glyph is CSS-owned, never on the wire). */
|
|
1210
|
+
.vms-breadcrumb__separator {
|
|
1211
|
+
color: var(--vms-text-muted);
|
|
1212
|
+
user-select: none;
|
|
1213
|
+
}
|
|
1214
|
+
.vms-breadcrumb__separator::before { content: "/"; }
|
|
1215
|
+
|
|
1216
|
+
/* ── Steps ──
|
|
1217
|
+
A discrete stepper. Numbered markers (a check glyph for done) joined by
|
|
1218
|
+
connector lines the framework draws marker-CENTER to marker-CENTER behind the
|
|
1219
|
+
opaque markers (so the line reads bubble-edge to bubble-edge). Per-step state
|
|
1220
|
+
(done/current/upcoming) derives from the node's `current` index; done/current
|
|
1221
|
+
markers fill --vms-accent, current gets an --vms-accent-glow ring, upcoming is
|
|
1222
|
+
an outlined --vms-text-muted marker. Horizontal by default; it auto-collapses
|
|
1223
|
+
to the stacked/vertical form INTRINSICALLY via a container query (ZERO
|
|
1224
|
+
viewport breakpoints — P1); the deliberate .vms-steps--vertical wizard always
|
|
1225
|
+
uses that stacked form. Marker/line widths use small px like .vms-link /
|
|
1226
|
+
.vms-badge (no width token exists); every colour + gap is a --vms-* token. */
|
|
1227
|
+
.vms-steps {
|
|
1228
|
+
--_step-marker: var(--vms-space-xl); /* marker diameter (2.25rem) */
|
|
1229
|
+
container-type: inline-size;
|
|
1230
|
+
display: flex;
|
|
1231
|
+
flex-wrap: wrap;
|
|
1232
|
+
list-style: none;
|
|
1233
|
+
margin: 0;
|
|
1234
|
+
padding: 0;
|
|
1235
|
+
font-size: var(--vms-text-sm);
|
|
1236
|
+
}
|
|
1237
|
+
/* Horizontal (default): equal columns, centered marker over centered label. */
|
|
1238
|
+
.vms-steps__step {
|
|
1239
|
+
flex: 1 1 0;
|
|
1240
|
+
min-width: 0;
|
|
1241
|
+
position: relative;
|
|
1242
|
+
display: flex;
|
|
1243
|
+
flex-direction: column;
|
|
1244
|
+
align-items: center;
|
|
1245
|
+
text-align: center;
|
|
1246
|
+
gap: var(--vms-space-xs);
|
|
1247
|
+
}
|
|
1248
|
+
.vms-steps__marker {
|
|
1249
|
+
position: relative;
|
|
1250
|
+
z-index: 1; /* opaque marker sits ON the connector */
|
|
1251
|
+
flex-shrink: 0;
|
|
1252
|
+
box-sizing: border-box;
|
|
1253
|
+
width: var(--_step-marker);
|
|
1254
|
+
height: var(--_step-marker);
|
|
1255
|
+
border-radius: 50%;
|
|
1256
|
+
display: inline-flex;
|
|
1257
|
+
align-items: center;
|
|
1258
|
+
justify-content: center;
|
|
1259
|
+
font-size: var(--vms-text-xs);
|
|
1260
|
+
font-weight: 600;
|
|
1261
|
+
line-height: 1;
|
|
1262
|
+
/* upcoming (default): outlined muted marker on the surface. */
|
|
1263
|
+
background: var(--vms-surface);
|
|
1264
|
+
border: 2px solid var(--vms-text-muted);
|
|
1265
|
+
color: var(--vms-text-muted);
|
|
1266
|
+
}
|
|
1267
|
+
/* done + current: filled accent marker. The glyph uses --vms-surface so it
|
|
1268
|
+
auto-adapts polarity (light theme → light glyph, dark → dark) and clears the
|
|
1269
|
+
WCAG-AA graphical-object bar (>=3:1) on every shipped theme — the marker is a
|
|
1270
|
+
graphical state indicator, its semantic state also rides an aria-label. */
|
|
1271
|
+
.vms-steps__step--done .vms-steps__marker,
|
|
1272
|
+
.vms-steps__step--current .vms-steps__marker {
|
|
1273
|
+
background: var(--vms-accent);
|
|
1274
|
+
border-color: var(--vms-accent);
|
|
1275
|
+
color: var(--vms-surface);
|
|
1276
|
+
}
|
|
1277
|
+
.vms-steps__step--current .vms-steps__marker {
|
|
1278
|
+
box-shadow: 0 0 0 4px var(--vms-accent-glow);
|
|
1279
|
+
}
|
|
1280
|
+
.vms-steps__body {
|
|
1281
|
+
display: flex;
|
|
1282
|
+
flex-direction: column;
|
|
1283
|
+
gap: var(--vms-space-2xs);
|
|
1284
|
+
}
|
|
1285
|
+
.vms-steps__label { color: var(--vms-text); font-weight: 600; }
|
|
1286
|
+
.vms-steps__description { color: var(--vms-text-muted); font-size: var(--vms-text-xs); }
|
|
1287
|
+
|
|
1288
|
+
/* Connector — the OUTGOING segment from this marker toward the next (hidden on
|
|
1289
|
+
the last step). Colored by the step's own state: accent once the step is done,
|
|
1290
|
+
muted otherwise. z-index 0 so the opaque markers cover it edge-to-edge. */
|
|
1291
|
+
.vms-steps__connector {
|
|
1292
|
+
position: absolute;
|
|
1293
|
+
z-index: 0;
|
|
1294
|
+
top: calc(var(--_step-marker) / 2);
|
|
1295
|
+
left: 50%;
|
|
1296
|
+
width: 100%;
|
|
1297
|
+
height: 2px;
|
|
1298
|
+
transform: translateY(-1px);
|
|
1299
|
+
}
|
|
1300
|
+
.vms-steps__step:last-child .vms-steps__connector { display: none; }
|
|
1301
|
+
.vms-steps__step--done .vms-steps__connector { background: var(--vms-accent); }
|
|
1302
|
+
.vms-steps__step--current .vms-steps__connector,
|
|
1303
|
+
.vms-steps__step--upcoming .vms-steps__connector { background: var(--vms-text-muted); }
|
|
1304
|
+
|
|
1305
|
+
/* ── Stacked form — shared by the deliberate vertical wizard AND the intrinsic
|
|
1306
|
+
narrow-container collapse: marker on the left, label + description beside it,
|
|
1307
|
+
the connector becomes a vertical line down the marker's center. ── */
|
|
1308
|
+
.vms-steps--vertical .vms-steps__step {
|
|
1309
|
+
flex: 1 1 100%;
|
|
1310
|
+
flex-direction: row;
|
|
1311
|
+
align-items: flex-start;
|
|
1312
|
+
text-align: left;
|
|
1313
|
+
gap: var(--vms-space-sm);
|
|
1314
|
+
padding-bottom: var(--vms-space-md);
|
|
1315
|
+
}
|
|
1316
|
+
.vms-steps--vertical .vms-steps__body {
|
|
1317
|
+
padding-top: calc((var(--_step-marker) - var(--vms-text-sm)) / 2);
|
|
1318
|
+
}
|
|
1319
|
+
.vms-steps--vertical .vms-steps__connector {
|
|
1320
|
+
top: calc(var(--_step-marker) / 2);
|
|
1321
|
+
left: calc(var(--_step-marker) / 2);
|
|
1322
|
+
bottom: 0;
|
|
1323
|
+
width: 2px;
|
|
1324
|
+
height: auto;
|
|
1325
|
+
transform: translateX(-1px);
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
@container (max-width: 30rem) {
|
|
1329
|
+
.vms-steps:not(.vms-steps--vertical) .vms-steps__step {
|
|
1330
|
+
flex: 1 1 100%;
|
|
1331
|
+
flex-direction: row;
|
|
1332
|
+
align-items: flex-start;
|
|
1333
|
+
text-align: left;
|
|
1334
|
+
gap: var(--vms-space-sm);
|
|
1335
|
+
padding-bottom: var(--vms-space-md);
|
|
1336
|
+
}
|
|
1337
|
+
.vms-steps:not(.vms-steps--vertical) .vms-steps__body {
|
|
1338
|
+
padding-top: calc((var(--_step-marker) - var(--vms-text-sm)) / 2);
|
|
1339
|
+
}
|
|
1340
|
+
.vms-steps:not(.vms-steps--vertical) .vms-steps__connector {
|
|
1341
|
+
top: calc(var(--_step-marker) / 2);
|
|
1342
|
+
left: calc(var(--_step-marker) / 2);
|
|
1343
|
+
bottom: 0;
|
|
1344
|
+
width: 2px;
|
|
1345
|
+
height: auto;
|
|
1346
|
+
transform: translateX(-1px);
|
|
1347
|
+
}
|
|
1348
|
+
}
|