@ashley-shrok/viewmodel-shell 5.0.0 → 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 CHANGED
@@ -136,6 +136,15 @@ export declare class BrowserAdapter implements Adapter {
136
136
  /** CheckboxNode (standalone, immediate-dispatch) — bound boolean; on toggle,
137
137
  * write to state then dispatch the action name (if any). */
138
138
  private checkbox;
139
+ /** Shared button appearance + activation behavior, applied to a <button> element
140
+ * (a standalone ButtonNode's button, OR a form's submitButton). Sets the full
141
+ * className (emphasis/tone/size/width/disabled), label, and the `disabled` attr,
142
+ * and returns a guarded `activate()` that runs disabled -> confirm -> pendingLabel
143
+ * swap -> dispatch. Both the standalone button() renderer and the FormNode
144
+ * submitButton branch use this so the two can NEVER diverge — the divergence WAS
145
+ * the bug (a form-level submit button silently dropped pendingLabel/disabled/
146
+ * confirm because it re-implemented rendering without the click behavior). */
147
+ private applyButtonBehavior;
139
148
  private button;
140
149
  private text;
141
150
  private link;
@@ -171,4 +180,22 @@ export declare class BrowserAdapter implements Adapter {
171
180
  /** BadgeNode — a compact inline status pill / count. Leaf node: label text +
172
181
  * tone/emphasis modifier classes. */
173
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;
174
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
@@ -865,15 +867,17 @@ export class BrowserAdapter {
865
867
  const sb = n.submitButton;
866
868
  const effectiveSubmit = sb ? sb.action : n.submitAction;
867
869
  if (sb) {
868
- const submitAction = sb.action;
869
870
  const submit = document.createElement("button");
870
871
  submit.type = "submit";
871
- submit.className = `vms-button${sb.emphasis ? ` vms-button--${sb.emphasis}` : ""}${sb.tone ? ` vms-button--${sb.tone}` : ""}${sb.size ? ` vms-button--${sb.size}` : ""}${sb.width === "full" ? " vms-button--full" : ""}`;
872
- submit.textContent = sb.label;
872
+ // Same appearance + activation as a standalone button disabled/confirm/
873
+ // pendingLabel included. The form's submit event is the single dispatch
874
+ // point (keeps native Enter-to-submit for text fields working); activate()
875
+ // carries the disabled guard, confirm guard, and pendingLabel swap.
876
+ const activate = this.applyButtonBehavior(submit, sb, dispatchWithFiles);
873
877
  form.appendChild(submit);
874
878
  form.addEventListener("submit", (e) => {
875
879
  e.preventDefault();
876
- dispatchWithFiles(submitAction);
880
+ activate();
877
881
  });
878
882
  }
879
883
  else if (n.submitAction) {
@@ -1269,24 +1273,30 @@ export class BrowserAdapter {
1269
1273
  });
1270
1274
  parent.appendChild(lbl);
1271
1275
  }
1272
- button(n, parent, on) {
1273
- const btn = document.createElement("button");
1274
- btn.type = "button";
1276
+ /** Shared button appearance + activation behavior, applied to a <button> element
1277
+ * (a standalone ButtonNode's button, OR a form's submitButton). Sets the full
1278
+ * className (emphasis/tone/size/width/disabled), label, and the `disabled` attr,
1279
+ * and returns a guarded `activate()` that runs disabled -> confirm -> pendingLabel
1280
+ * swap -> dispatch. Both the standalone button() renderer and the FormNode
1281
+ * submitButton branch use this so the two can NEVER diverge — the divergence WAS
1282
+ * the bug (a form-level submit button silently dropped pendingLabel/disabled/
1283
+ * confirm because it re-implemented rendering without the click behavior). */
1284
+ applyButtonBehavior(btn, n, dispatch) {
1275
1285
  btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}${n.disabled ? " vms-button--disabled" : ""}`;
1276
1286
  btn.textContent = n.label;
1277
1287
  if (n.disabled)
1278
1288
  btn.disabled = true;
1279
- btn.addEventListener("click", () => {
1289
+ return () => {
1280
1290
  // Forms-completeness (3.4.0): a disabled button never dispatches. (Native
1281
- // `disabled` already suppresses the click, but guard anyway in case the
1282
- // attribute was cleared out-of-band.)
1291
+ // `disabled` already suppresses a click, but guard anyway a form submit
1292
+ // isn't a native button click, and the attribute could be cleared out-of-band.)
1283
1293
  if (n.disabled)
1284
1294
  return;
1285
1295
  // confirm: a destructive-action guard. Show the NATIVE browser confirm
1286
1296
  // BEFORE any pendingLabel swap or dispatch; Cancel suppresses everything
1287
1297
  // (no dispatch, no visual change). Native by design — zero app/framework
1288
1298
  // state, and it's a client-only human affordance (an agent never reaches
1289
- // this click handler; it dispatches the action directly over the wire).
1299
+ // this handler; it dispatches the action directly over the wire).
1290
1300
  if (n.confirm && !window.confirm(n.confirm))
1291
1301
  return;
1292
1302
  // pendingLabel: instant client-side feedback. Swap text + add
@@ -1297,8 +1307,14 @@ export class BrowserAdapter {
1297
1307
  btn.textContent = n.pendingLabel;
1298
1308
  btn.classList.add("vms-button--pending");
1299
1309
  }
1300
- on(n.action);
1301
- });
1310
+ dispatch(n.action);
1311
+ };
1312
+ }
1313
+ button(n, parent, on) {
1314
+ const btn = document.createElement("button");
1315
+ btn.type = "button";
1316
+ const activate = this.applyButtonBehavior(btn, n, on);
1317
+ btn.addEventListener("click", activate);
1302
1318
  parent.appendChild(btn);
1303
1319
  }
1304
1320
  text(n, parent) {
@@ -1781,4 +1797,121 @@ export class BrowserAdapter {
1781
1797
  span.textContent = n.label;
1782
1798
  parent.appendChild(span);
1783
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
+ }
1784
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
- // ChartNode (CHART-05) is a DELIBERATE childless/action-free leaf it
209
- // carries only data points, so it falls through here with no recursion (no
210
- // fits-style blind spot).
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 no SectionNode
376
- // descendants — TableNode rows hold strings + per-row controls, not sections;
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.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 \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
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",
@@ -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
+ }