@ashley-shrok/viewmodel-shell 3.4.0 → 3.5.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/agent-skill.md CHANGED
@@ -91,7 +91,8 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
91
91
  { "sideEffects": [
92
92
  { "type": "set-local-storage", "key": "auth_jwt", "value": "eyJ..." },
93
93
  { "type": "set-session-storage", "key": "draft_id", "value": "42" },
94
- { "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" }
94
+ { "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" },
95
+ { "type": "toast", "message": "Ticket #4012 closed.", "tone": "success" }
95
96
  ] }
96
97
  ```
97
98
 
@@ -100,6 +101,7 @@ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
100
101
  | `set-local-storage` | Write `key`/`value` to platform localStorage (or your agent's equivalent persistent store). |
101
102
  | `set-session-storage` | Same as above for session-scoped storage. |
102
103
  | `download` | Fetch `url` (re-presenting your auth headers — see *Auth*) and save the bytes. Filename precedence: `Content-Disposition` > side-effect `filename` > URL basename > `"download"`. |
104
+ | `toast` | Show a transient confirmation: `message` (required) + optional `tone` (`danger`\|`warning`\|`success`\|`info`) + `durationMs`. A UX nicety — **fail-quiet**: an agent/adapter with no toast surface simply ignores it (nothing to persist or act on), so it carries no state and needs no acknowledgement. |
103
105
 
104
106
  **Forward-compat rule — silently ignore unknown verbs.** A future minor release may add new verbs. If you see a `type` you do not recognize, skip it; do not error. Honor or ignore per your policy.
105
107
 
package/dist/browser.d.ts CHANGED
@@ -12,6 +12,17 @@ export declare class BrowserAdapter implements Adapter {
12
12
  storage(scope: "local" | "session", key: string, value: string): void;
13
13
  saveFile(data: Blob, filename: string, _contentType: string): void;
14
14
  setBusy(active: boolean): void;
15
+ /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
16
+ * host region (.vms-toast-region) appended to <body> so toasts stack and
17
+ * survive the container's innerHTML wipe on each render(); appends a
18
+ * .vms-toast element (+ tone modifier) and auto-removes it after
19
+ * durationMs (default 4000), with a brief fade-out. This is the ONLY place
20
+ * toast DOM lives — core stays platform-agnostic (it just calls this verb).
21
+ * Fail-quiet by absence is the core's concern (it optional-chains the call). */
22
+ toast(message: string, opts?: {
23
+ tone?: string;
24
+ durationMs?: number;
25
+ }): void;
15
26
  private unloadHandler;
16
27
  setPreventUnload(active: boolean): void;
17
28
  transport(input: string, init: {
@@ -96,4 +107,11 @@ export declare class BrowserAdapter implements Adapter {
96
107
  * concept. */
97
108
  private table;
98
109
  private copyButton;
110
+ /** EmptyStateNode — a centered "nothing here" block: a heading, an optional
111
+ * message, then the optional CTA ButtonNode (rendered via the shared button
112
+ * renderer so it dispatches like any other button). */
113
+ private emptyState;
114
+ /** BadgeNode — a compact inline status pill / count. Leaf node: label text +
115
+ * tone/emphasis modifier classes. */
116
+ private badge;
99
117
  }
package/dist/browser.js CHANGED
@@ -162,6 +162,38 @@ export class BrowserAdapter {
162
162
  setBusy(active) {
163
163
  this.container.classList.toggle("vms-busy", active);
164
164
  }
165
+ /** Transient confirmation toast. Lazily creates/reuses a single fixed-corner
166
+ * host region (.vms-toast-region) appended to <body> so toasts stack and
167
+ * survive the container's innerHTML wipe on each render(); appends a
168
+ * .vms-toast element (+ tone modifier) and auto-removes it after
169
+ * durationMs (default 4000), with a brief fade-out. This is the ONLY place
170
+ * toast DOM lives — core stays platform-agnostic (it just calls this verb).
171
+ * Fail-quiet by absence is the core's concern (it optional-chains the call). */
172
+ toast(message, opts) {
173
+ let region = document.querySelector(".vms-toast-region");
174
+ if (!region) {
175
+ region = document.createElement("div");
176
+ region.className = "vms-toast-region";
177
+ document.body.appendChild(region);
178
+ }
179
+ const el = document.createElement("div");
180
+ el.className = `vms-toast${opts?.tone ? ` vms-toast--${opts.tone}` : ""}`;
181
+ el.setAttribute("role", "status");
182
+ el.setAttribute("aria-live", "polite");
183
+ el.textContent = message;
184
+ region.appendChild(el);
185
+ const duration = opts?.durationMs ?? 4000;
186
+ setTimeout(() => {
187
+ el.classList.add("vms-toast--leaving");
188
+ // Remove after the fade-out transition; a short fixed delay keeps it
189
+ // simple (no transitionend bookkeeping). Clean up the region if it empties.
190
+ setTimeout(() => {
191
+ el.remove();
192
+ if (region && region.childElementCount === 0)
193
+ region.remove();
194
+ }, 200);
195
+ }, duration);
196
+ }
165
197
  unloadHandler = null;
166
198
  setPreventUnload(active) {
167
199
  if (active && this.unloadHandler == null) {
@@ -240,6 +272,8 @@ export class BrowserAdapter {
240
272
  case "copy-button": return this.copyButton(n, parent);
241
273
  case "divider": return this.divider(n, parent);
242
274
  case "fits": return this.fits(n, parent, on);
275
+ case "empty-state": return this.emptyState(n, parent, on);
276
+ case "badge": return this.badge(n, parent);
243
277
  default: {
244
278
  // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
245
279
  // Runtime trees are server-controlled JSON, so an unknown/forward-version
@@ -1293,4 +1327,32 @@ export class BrowserAdapter {
1293
1327
  });
1294
1328
  parent.appendChild(btn);
1295
1329
  }
1330
+ /** EmptyStateNode — a centered "nothing here" block: a heading, an optional
1331
+ * message, then the optional CTA ButtonNode (rendered via the shared button
1332
+ * renderer so it dispatches like any other button). */
1333
+ emptyState(n, parent, on) {
1334
+ const el = document.createElement("div");
1335
+ el.className = "vms-empty-state";
1336
+ const heading = document.createElement("div");
1337
+ heading.className = "vms-empty-state__heading";
1338
+ heading.textContent = n.heading;
1339
+ el.appendChild(heading);
1340
+ if (n.message != null && n.message !== "") {
1341
+ const msg = document.createElement("div");
1342
+ msg.className = "vms-empty-state__message";
1343
+ msg.textContent = n.message;
1344
+ el.appendChild(msg);
1345
+ }
1346
+ if (n.action)
1347
+ this.button(n.action, el, on);
1348
+ parent.appendChild(el);
1349
+ }
1350
+ /** BadgeNode — a compact inline status pill / count. Leaf node: label text +
1351
+ * tone/emphasis modifier classes. */
1352
+ badge(n, parent) {
1353
+ const span = document.createElement("span");
1354
+ span.className = `vms-badge${n.tone ? ` vms-badge--${n.tone}` : ""}${n.emphasis ? ` vms-badge--${n.emphasis}` : ""}`;
1355
+ span.textContent = n.label;
1356
+ parent.appendChild(span);
1357
+ }
1296
1358
  }
package/dist/index.d.ts CHANGED
@@ -62,8 +62,20 @@ export interface Adapter {
62
62
  * none), so a rapid checkbox click during an in-flight round-trip never
63
63
  * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
64
64
  setBusy?(active: boolean): void;
65
+ /** Show a transient confirmation toast, driven by a `{ type: "toast" }`
66
+ * ShellSideEffect. `BrowserAdapter` stacks toasts in a single fixed-corner
67
+ * host region and auto-dismisses each after `opts.durationMs` (default
68
+ * ~4000ms). FAIL-QUIET BY ABSENCE — modeled on setPreventUnload/setBusy,
69
+ * NOT on navigate/storage/saveFile: a dropped toast is a missed UX nicety,
70
+ * never a correctness/security bug, so the core MUST NOT call failCapability
71
+ * when this verb is absent (non-browser targets like the TUI simply have no
72
+ * toast surface and the effect is a no-op). */
73
+ toast?(message: string, opts?: {
74
+ tone?: string;
75
+ durationMs?: number;
76
+ }): void;
65
77
  }
66
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode;
78
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode | DividerNode | FitsNode | EmptyStateNode | BadgeNode;
67
79
  export interface PageNode {
68
80
  type: "page";
69
81
  title?: string;
@@ -488,6 +500,47 @@ export interface CopyButtonNode {
488
500
  * = content-width (the default hug). Orthogonal to emphasis/tone/size. */
489
501
  width?: "auto" | "full";
490
502
  }
503
+ /**
504
+ * A first-class "nothing here" presentation — the empty-state primitive
505
+ * (MUI/Ant `<Empty>`, the standard zero-data placeholder). A centered block
506
+ * with a required heading, an optional supporting message, and an optional
507
+ * call-to-action ButtonNode (e.g. "Create your first ticket"). No icon field —
508
+ * the framework ships no icon set.
509
+ *
510
+ * The `action` ButtonNode carries a real action name and is a dispatch-bearing
511
+ * descendant, so EVERY tree-walk (action-name uniqueness collector, section
512
+ * shape validator) descends into it — exactly like `modal.footer` / `fits`
513
+ * candidates / `section.action`. A missed walk would silently exempt the CTA
514
+ * from the one-name-one-operation rule.
515
+ */
516
+ export interface EmptyStateNode {
517
+ type: "empty-state";
518
+ /** The primary line — what's missing / what to do. Required. */
519
+ heading: string;
520
+ /** Optional supporting line below the heading. */
521
+ message?: string;
522
+ /** Optional call-to-action button (e.g. "Add the first item"). */
523
+ action?: ButtonNode;
524
+ }
525
+ /**
526
+ * A compact status pill / count — the badge primitive (MUI/Ant `<Badge>`,
527
+ * `<Tag>`). A leaf inline node (no children, no action) for a short label or
528
+ * count that appears inside text/section/list/table contexts. Appearance is
529
+ * the universal `tone` (semantic color) + `emphasis` (filled vs outline) axes,
530
+ * matching the rest of the framework's appearance vocabulary.
531
+ */
532
+ export interface BadgeNode {
533
+ type: "badge";
534
+ /** The pill text — a short label or count (e.g. "New", "3", "Beta"). */
535
+ label: string;
536
+ /** Semantic intent/severity — the universal status tone axis. Emits
537
+ * .vms-badge--{tone}. Omitted = neutral. Closed union. */
538
+ tone?: "danger" | "warning" | "success" | "info";
539
+ /** Visual weight — `primary` = filled solid tone, `secondary` = outline.
540
+ * Mirrors ButtonNode's emphasis semantics. Emits .vms-badge--{emphasis}.
541
+ * Omitted = the default filled tint. Closed union. */
542
+ emphasis?: "primary" | "secondary";
543
+ }
491
544
  /**
492
545
  * The SwiftUI `ViewThatFits` port. The renderer picks the FIRST child whose
493
546
  * intrinsic size FITS the available container (no axis overflow), else the next,
@@ -563,7 +616,7 @@ export interface ShellOptions {
563
616
  pollInterval?: number;
564
617
  }
565
618
  export interface ShellSideEffect {
566
- /** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
619
+ /** "set-local-storage" | "set-session-storage" | "download" | "toast" — unknown types are silently ignored. */
567
620
  type: string;
568
621
  /** For "set-local-storage" / "set-session-storage": the storage key. */
569
622
  key?: string;
@@ -574,6 +627,13 @@ export interface ShellSideEffect {
574
627
  /** For "download": optional filename hint. Response Content-Disposition wins
575
628
  * when present; this is the fallback before the URL basename. */
576
629
  filename?: string;
630
+ /** For "toast": the text shown in the transient confirmation. Required for a
631
+ * toast effect (the shell guards `message != null` before routing). */
632
+ message?: string;
633
+ /** For "toast": optional semantic tone — "danger" | "warning" | "success" | "info". */
634
+ tone?: string;
635
+ /** For "toast": optional auto-dismiss delay in ms (adapter default ~4000). */
636
+ durationMs?: number;
577
637
  }
578
638
  /** One entry in the structured error payload returned on `ok: false`. */
579
639
  export interface ErrorEntry {
package/dist/index.js CHANGED
@@ -290,6 +290,16 @@ export class ViewModelShell {
290
290
  // storage, and a slow download MUST NOT delay the user-visible update.
291
291
  void this.download(effect.url, effect.filename);
292
292
  }
293
+ else if (effect.type === "toast" && effect.message != null) {
294
+ // FAIL-QUIET (cf. setPreventUnload/setBusy): a toast is a UX nicety,
295
+ // not a correctness/security guarantee, so an adapter without the
296
+ // capability simply drops it — NO failCapability, no onError. The
297
+ // optional-chaining call is the entire contract.
298
+ adapter.toast?.(effect.message, {
299
+ tone: effect.tone,
300
+ durationMs: effect.durationMs,
301
+ });
302
+ }
293
303
  }
294
304
  // 0.14.0 — apply the unload guard before the redirect/render branch so it's
295
305
  // in place (or cleared) consistently across both branches. A server that
package/dist/server.d.ts CHANGED
@@ -87,6 +87,14 @@ export declare const shellSideEffect: {
87
87
  * The conditional spread keeps `filename` ABSENT (not undefined) on the
88
88
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
89
89
  download: (url: string, filename?: string) => ShellSideEffect;
90
+ /** Transient confirmation toast (a UX nicety, fail-quiet by absence — see
91
+ * Adapter.toast). `message` is required; `tone`/`durationMs` are optional and
92
+ * kept ABSENT (not undefined) from the JSON wire via conditional spread,
93
+ * matching the .NET WhenWritingNull null-omission contract. */
94
+ toast: (message: string, opts?: {
95
+ tone?: string;
96
+ durationMs?: number;
97
+ }) => ShellSideEffect;
90
98
  };
91
99
  /**
92
100
  * Mount the canonical VMS agent skill markdown as an HTTP handler.
package/dist/server.js CHANGED
@@ -190,8 +190,18 @@ function collectActions(node, enclosingForm, out) {
190
190
  collectActions(child, enclosingForm, out);
191
191
  return;
192
192
  }
193
+ case "empty-state": {
194
+ // EmptyStateNode.action is an optional ButtonNode carrying a real action
195
+ // name. It is a dispatch-bearing descendant, so the uniqueness collector
196
+ // MUST descend into it — otherwise the CTA is silently exempt from the
197
+ // one-name-one-operation rule (the 3.3.0 missed-walk failure class).
198
+ const es = node;
199
+ if (es.action)
200
+ collectActions(es.action, enclosingForm, out);
201
+ return;
202
+ }
193
203
  // Nodes with no dispatch-bearing actions of their own:
194
- // text, link, image, stat-bar, progress, copy-button
204
+ // text, link, image, stat-bar, progress, copy-button, badge
195
205
  default:
196
206
  return;
197
207
  }
@@ -346,9 +356,18 @@ function walkForSectionAction(node, outerInteractive) {
346
356
  walkForSectionAction(child, outerInteractive);
347
357
  return;
348
358
  }
359
+ case "empty-state": {
360
+ // EmptyStateNode.action is a ButtonNode (no SectionNode descendants), but
361
+ // descend for consistency with every other walk so a future shape can't
362
+ // slip an interactive section past this validator.
363
+ const es = node;
364
+ if (es.action)
365
+ walkForSectionAction(es.action, outerInteractive);
366
+ return;
367
+ }
349
368
  // Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
350
- // tabs, progress, table, copy-button) carry no SectionNode descendants —
351
- // TableNode rows hold strings + per-row controls, not sections.
369
+ // tabs, progress, table, copy-button, badge) carry no SectionNode
370
+ // descendants — TableNode rows hold strings + per-row controls, not sections.
352
371
  default:
353
372
  return;
354
373
  }
@@ -427,6 +446,16 @@ export const shellSideEffect = {
427
446
  * The conditional spread keeps `filename` ABSENT (not undefined) on the
428
447
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
429
448
  download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
449
+ /** Transient confirmation toast (a UX nicety, fail-quiet by absence — see
450
+ * Adapter.toast). `message` is required; `tone`/`durationMs` are optional and
451
+ * kept ABSENT (not undefined) from the JSON wire via conditional spread,
452
+ * matching the .NET WhenWritingNull null-omission contract. */
453
+ toast: (message, opts) => ({
454
+ type: "toast",
455
+ message,
456
+ ...(opts?.tone != null ? { tone: opts.tone } : {}),
457
+ ...(opts?.durationMs != null ? { durationMs: opts.durationMs } : {}),
458
+ }),
430
459
  };
431
460
  // ─── Canonical agent skill mount helper (1.6.0 / 1.5.0) ──────────────────────
432
461
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.4.0",
3
+ "version": "3.5.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",
@@ -26,7 +26,7 @@
26
26
  "LICENSE"
27
27
  ],
28
28
  "bin": {
29
- "vms-tui": "./dist/tui-cli.js"
29
+ "vms-tui": "dist/tui-cli.js"
30
30
  },
31
31
  "scripts": {
32
32
  "build": "tsc -b tsconfig.tui.json",
@@ -977,3 +977,117 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
977
977
  cursor: pointer;
978
978
  font-size: var(--vms-text-lg);
979
979
  }
980
+
981
+ /* ── Toast (side-effect-driven transient confirmation) ──
982
+ The BrowserAdapter lazily creates ONE .vms-toast-region pinned to the
983
+ bottom-right corner and stacks .vms-toast elements into it; each auto-removes
984
+ after its durationMs with a brief fade. The region is click-through
985
+ (pointer-events:none) so it never blocks the UI under it; individual toasts
986
+ re-enable pointer events for their own content. Framework-owned styling —
987
+ this is the framework owning its presentation, not app CSS. */
988
+ .vms-toast-region {
989
+ position: fixed;
990
+ bottom: var(--vms-space-md);
991
+ right: var(--vms-space-md);
992
+ z-index: 1100; /* above .vms-modal-backdrop (1000) */
993
+ display: flex;
994
+ flex-direction: column;
995
+ gap: var(--vms-space-xs);
996
+ align-items: flex-end;
997
+ pointer-events: none;
998
+ max-width: min(24rem, calc(100vw - 2 * var(--vms-space-md)));
999
+ }
1000
+ .vms-toast {
1001
+ pointer-events: auto;
1002
+ /* Neutral toast = high-contrast inverted chip (the snackbar pattern): the
1003
+ theme's text color as the surface, the theme's surface as the text. This
1004
+ stands clear of the page background it sits on in EITHER theme — a
1005
+ surface-colored toast was nearly invisible against the page surface. */
1006
+ background: var(--vms-text);
1007
+ color: var(--vms-surface);
1008
+ border: 1px solid transparent;
1009
+ border-radius: var(--vms-radius);
1010
+ font-size: var(--vms-text-base);
1011
+ font-weight: 500;
1012
+ padding: var(--vms-space-sm) var(--vms-space-md);
1013
+ box-shadow: 0 6px 22px rgba(0, 0, 0, 0.30);
1014
+ opacity: 1;
1015
+ transition: opacity var(--vms-t), transform var(--vms-t);
1016
+ }
1017
+ /* tone — a FULLY filled chip in the status color (not a thin accent rail), so
1018
+ an important toast (especially danger) is impossible to miss. error/warning
1019
+ are dark enough for white body text to clear WCAG-AA on their pure tokens;
1020
+ success/info are deepened (color-mix w/ black) so white at 14px also clears
1021
+ 4.5:1 (their pure tokens sit at 3.2 / 4.4:1). */
1022
+ .vms-toast--danger { background: var(--vms-error); color: #fff; }
1023
+ .vms-toast--warning { background: var(--vms-warning); color: #fff; }
1024
+ .vms-toast--success { background: color-mix(in srgb, var(--vms-success) 80%, #000); color: #fff; }
1025
+ .vms-toast--info { background: color-mix(in srgb, var(--vms-info) 88%, #000); color: #fff; }
1026
+ /* fade-out applied just before removal. */
1027
+ .vms-toast--leaving { opacity: 0; transform: translateY(0.25rem); }
1028
+
1029
+ /* ── Empty state (first-class "nothing here" presentation) ──
1030
+ A centered block: heading, optional message, optional CTA button. */
1031
+ .vms-empty-state {
1032
+ display: flex;
1033
+ flex-direction: column;
1034
+ align-items: center;
1035
+ justify-content: center;
1036
+ gap: var(--vms-space-sm);
1037
+ text-align: center;
1038
+ padding: var(--vms-space-xl) var(--vms-space-md);
1039
+ color: var(--vms-text-muted);
1040
+ }
1041
+ .vms-empty-state__heading {
1042
+ font-size: var(--vms-text-lg);
1043
+ font-weight: 600;
1044
+ color: var(--vms-text);
1045
+ }
1046
+ .vms-empty-state__message {
1047
+ font-size: var(--vms-text-base);
1048
+ color: var(--vms-text-muted);
1049
+ max-width: min(40ch, 100%);
1050
+ }
1051
+ /* The CTA button inside an empty state centers (override the button's default
1052
+ align-self:flex-start) so it sits under the centered heading/message. */
1053
+ .vms-empty-state .vms-button { align-self: center; }
1054
+
1055
+ /* ── Badge (compact inline status pill / count) ──
1056
+ Leaf inline element. Default = neutral filled tint; tone recolors it; emphasis
1057
+ switches between a solid fill (primary) and an outline (secondary). */
1058
+ .vms-badge {
1059
+ --_badge-tone: var(--vms-text-muted);
1060
+ display: inline-flex;
1061
+ align-items: center;
1062
+ /* Center the pill on the surrounding text instead of baselining it (an
1063
+ inline-flex pill otherwise hangs below the baseline of adjacent text). */
1064
+ vertical-align: middle;
1065
+ gap: var(--vms-space-2xs);
1066
+ font-size: var(--vms-text-xs);
1067
+ font-weight: 600;
1068
+ line-height: 1;
1069
+ padding: 0.2em 0.6em;
1070
+ border-radius: 999px;
1071
+ white-space: nowrap;
1072
+ /* default (no tone, no emphasis): subtle neutral tint pill. */
1073
+ background: color-mix(in srgb, var(--_badge-tone) 16%, var(--vms-surface));
1074
+ color: var(--_badge-tone);
1075
+ border: 1px solid transparent;
1076
+ }
1077
+ /* tone — the universal status color, sets the pill's working color. */
1078
+ .vms-badge--danger { --_badge-tone: var(--vms-error); }
1079
+ .vms-badge--warning { --_badge-tone: var(--vms-warning); }
1080
+ .vms-badge--success { --_badge-tone: var(--vms-success); }
1081
+ .vms-badge--info { --_badge-tone: var(--vms-info); }
1082
+ /* emphasis — primary = solid filled tone (white text); secondary = outline.
1083
+ Declared after tone so the chosen --_badge-tone drives both. */
1084
+ .vms-badge--primary {
1085
+ background: var(--_badge-tone);
1086
+ border-color: var(--_badge-tone);
1087
+ color: #fff;
1088
+ }
1089
+ .vms-badge--secondary {
1090
+ background: transparent;
1091
+ border-color: var(--_badge-tone);
1092
+ color: var(--_badge-tone);
1093
+ }