@ashley-shrok/viewmodel-shell 6.2.1 → 6.3.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
@@ -240,4 +240,14 @@ export declare class BrowserAdapter implements Adapter {
240
240
  * conveyed by color alone. The stepper is NOT focusable and is NOT
241
241
  * `role="progressbar"` (that's a continuous %). All text via textContent. */
242
242
  private steps;
243
+ /** TrackerNode — a status/heat strip: a tight horizontal row of discrete
244
+ * colored cells, one per time bucket. The framework owns ALL appearance and
245
+ * a11y (never on the wire): the hairline gap, the intrinsic
246
+ * shrink-to-a-min-then-scroll overflow, and the baked colorblind-safe palette
247
+ * (success=blue / danger=red / warning=amber / muted=gray) via the
248
+ * .vms-tracker__cell--{state} classes. A cell with a `label` carries it as both
249
+ * the native tooltip AND aria-label (meaning as text, not color-only). A cell
250
+ * with an `action` becomes a role="button" tabstop with Enter/Space activation
251
+ * (Space suppresses page scroll), mirroring TableRow.action / SectionNode.action. */
252
+ private tracker;
243
253
  }
package/dist/browser.js CHANGED
@@ -508,6 +508,7 @@ export class BrowserAdapter {
508
508
  case "chart": return this.chart(n, parent);
509
509
  case "breadcrumb": return this.breadcrumb(n, parent, on);
510
510
  case "steps": return this.steps(n, parent);
511
+ case "tracker": return this.tracker(n, parent, on);
511
512
  default: {
512
513
  // Fail loud, not silent (AGENTS.md: "Nothing important fails quietly").
513
514
  // Runtime trees are server-controlled JSON, so an unknown/forward-version
@@ -3491,4 +3492,53 @@ export class BrowserAdapter {
3491
3492
  });
3492
3493
  parent.appendChild(ol);
3493
3494
  }
3495
+ /** TrackerNode — a status/heat strip: a tight horizontal row of discrete
3496
+ * colored cells, one per time bucket. The framework owns ALL appearance and
3497
+ * a11y (never on the wire): the hairline gap, the intrinsic
3498
+ * shrink-to-a-min-then-scroll overflow, and the baked colorblind-safe palette
3499
+ * (success=blue / danger=red / warning=amber / muted=gray) via the
3500
+ * .vms-tracker__cell--{state} classes. A cell with a `label` carries it as both
3501
+ * the native tooltip AND aria-label (meaning as text, not color-only). A cell
3502
+ * with an `action` becomes a role="button" tabstop with Enter/Space activation
3503
+ * (Space suppresses page scroll), mirroring TableRow.action / SectionNode.action. */
3504
+ tracker(n, parent, on) {
3505
+ const strip = document.createElement("div");
3506
+ strip.className = "vms-tracker";
3507
+ if (n.id != null)
3508
+ strip.id = n.id;
3509
+ // The strip is a graphical status summary — expose it as an img group with a
3510
+ // label so a screen reader announces it as one thing; per-cell state is on
3511
+ // each cell's aria-label.
3512
+ strip.setAttribute("role", "img");
3513
+ strip.setAttribute("aria-label", "status tracker");
3514
+ for (const cell of n.cells) {
3515
+ const state = cell.state ?? "muted";
3516
+ const el = document.createElement("div");
3517
+ el.className = `vms-tracker__cell vms-tracker__cell--${state}`;
3518
+ // label → native tooltip + aria-label (non-color channel). When absent,
3519
+ // the state name is still the a11y fallback so a cell is never color-only.
3520
+ const aria = cell.label != null && cell.label !== "" ? cell.label : state;
3521
+ el.setAttribute("aria-label", aria);
3522
+ if (cell.label != null && cell.label !== "")
3523
+ el.title = cell.label;
3524
+ if (cell.action) {
3525
+ const action = cell.action;
3526
+ el.classList.add("vms-tracker__cell--clickable");
3527
+ el.tabIndex = 0;
3528
+ el.setAttribute("role", "button");
3529
+ el.addEventListener("click", () => { on(action); });
3530
+ el.addEventListener("keydown", (e) => {
3531
+ if (e.key === "Enter") {
3532
+ on(action);
3533
+ }
3534
+ else if (e.key === " " || e.key === "Spacebar") {
3535
+ e.preventDefault(); // suppress page scroll
3536
+ on(action);
3537
+ }
3538
+ });
3539
+ }
3540
+ strip.appendChild(el);
3541
+ }
3542
+ parent.appendChild(strip);
3543
+ }
3494
3544
  }
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 | BreadcrumbNode | StepsNode;
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 | TrackerNode;
107
107
  export interface PageNode {
108
108
  type: "page";
109
109
  title?: string;
@@ -1087,6 +1087,49 @@ export interface StepsNode {
1087
1087
  * left, connector running down, descriptions beside each step). */
1088
1088
  orientation?: "horizontal" | "vertical";
1089
1089
  }
1090
+ /** One bucket in a TrackerNode strip — a single time slot's status. Carries only
1091
+ * display data: the semantic state, an optional hover label, and an optional
1092
+ * click-through action. */
1093
+ export interface TrackerCell {
1094
+ /** Semantic status for this bucket. A CLOSED set specific to a status strip:
1095
+ * `success` (good), `danger` (bad), `warning` (soft flag), `muted` (no data /
1096
+ * no run this slot — the universal honest-uncertainty convention). Omitted =
1097
+ * `muted`. NOTE the color axis differs from the rest of the framework on
1098
+ * PURPOSE: a dense, color-ONLY strip must survive colorblindness by color
1099
+ * alone, so the framework bakes a colorblind-safe palette where `success`
1100
+ * renders BLUE (not the global green success tone), `danger` red, `warning`
1101
+ * amber, `muted` gray — no "colorblind mode" needed. Only the rendered color
1102
+ * diverges; the state NAME stays semantic on the wire (agent-legible). `info`
1103
+ * is intentionally NOT a member — it would collide with `success`=blue and has
1104
+ * no meaning in a pass/fail history. */
1105
+ state?: "success" | "danger" | "warning" | "muted";
1106
+ /** Optional hover label for this bucket (e.g. "2026-07-15 14:22 UTC · Success").
1107
+ * Rendered as the cell's native tooltip AND its aria-label, so the strip's
1108
+ * meaning is carried by TEXT, not color alone — the a11y + agent-legibility
1109
+ * channel a color-only glyph strip could never provide. */
1110
+ label?: string;
1111
+ /** Optional click-through: dispatches this action when the bucket is clicked or
1112
+ * keyboard-activated (Enter / Space — Space preventDefaults page scroll). Makes
1113
+ * the cell a `role="button"` tabstop. Per-bucket identity is encoded in the
1114
+ * action name (e.g. `open-run-4021`), consistent with `TableRow.action` — no
1115
+ * context field. Omitted = a non-interactive status swatch (no tabstop). */
1116
+ action?: ActionEvent;
1117
+ }
1118
+ /** A status tracker / heat strip — a tight horizontal row of discrete colored
1119
+ * cells, one per time bucket, where color encodes each bucket's semantic status.
1120
+ * This is the "uptime strip" / "sentinel history" primitive (industry precedent:
1121
+ * Tremor Tracker, Grafana Status History, Statuspage), NOT a numeric value
1122
+ * sparkline (a tiny line chart — a separate, chart-family concern, deliberately
1123
+ * not built here). The framework owns ALL appearance and a11y: the hairline gap,
1124
+ * the intrinsic shrink-to-a-min-then-scroll overflow (zero viewport
1125
+ * breakpoints), and the baked colorblind-safe palette (see TrackerCell.state).
1126
+ * Bucket count is simply `cells.length` (60 slots, 24 hourly, 7 daily…). */
1127
+ export interface TrackerNode {
1128
+ type: "tracker";
1129
+ id?: string;
1130
+ /** Ordered buckets, oldest → newest (rendered left → right). */
1131
+ cells: TrackerCell[];
1132
+ }
1090
1133
  export interface ShellOptions {
1091
1134
  endpoint: string;
1092
1135
  actionEndpoint: string;
package/dist/server.js CHANGED
@@ -224,6 +224,20 @@ function collectActions(node, enclosingForm, out) {
224
224
  }
225
225
  return;
226
226
  }
227
+ case "tracker": {
228
+ // A TrackerCell can carry an optional per-bucket click-through action
229
+ // (per-bucket identity in the name, like row.action). Each is a
230
+ // dispatch-bearing descendant, so the uniqueness collector MUST descend
231
+ // into the cells — otherwise tracker actions are silently exempt from the
232
+ // one-name-one-operation rule (the missed-walk failure class). Guard the
233
+ // optional action the way the empty-state / breadcrumb arms do.
234
+ const tracker = node;
235
+ for (const cell of tracker.cells) {
236
+ if (cell.action)
237
+ recordAction(cell.action, enclosingForm, out);
238
+ }
239
+ return;
240
+ }
227
241
  // Nodes with no dispatch-bearing actions of their own:
228
242
  // text, link, image, stat-bar, progress, copy-button, badge, chart, steps
229
243
  // (breadcrumb crumb actions ARE recorded above via the "breadcrumb" arm)
@@ -394,8 +408,9 @@ function walkForSectionAction(node, outerInteractive) {
394
408
  return;
395
409
  }
396
410
  // Leaf-like nodes (field, checkbox, button, text, link, image, stat-bar,
397
- // tabs, progress, table, copy-button, badge, chart, breadcrumb, steps) carry
398
- // no SectionNode descendants — TableNode rows hold strings + per-row controls,
411
+ // tabs, progress, table, copy-button, badge, chart, breadcrumb, steps,
412
+ // tracker) carry no SectionNode descendants — TrackerNode cells hold plain
413
+ // { state, label, action } records; TableNode rows hold strings + per-row controls,
399
414
  // not sections; ChartNode (CHART-05) is a childless/action-free data leaf;
400
415
  // BreadcrumbNode/StepsNode (NAV-01..03) hold plain { label, ... } records,
401
416
  // not ViewNode children, so no recursion is needed here (deliberate, not a
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "6.2.1",
3
+ "version": "6.3.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",
@@ -1724,3 +1724,54 @@ button.vms-breadcrumb__link {
1724
1724
  transform: translateX(-1px);
1725
1725
  }
1726
1726
  }
1727
+
1728
+ /* ── Status tracker / heat strip (TrackerNode) ──
1729
+ A tight horizontal row of discrete colored cells, one per time bucket (uptime
1730
+ strip / sentinel history). The palette is a FIXED, colorblind-safe scheme —
1731
+ blue=good / red=bad / amber=warn / gray=no-data — deliberately NOT the theme's
1732
+ success(green)/danger(red) tones: a dense, color-ONLY strip has to survive
1733
+ colorblindness by color alone (verified separable under deuteranopia /
1734
+ protanopia / tritanopia), so "blue means good" is baked in and needs no mode.
1735
+ It is intentionally the SAME across all themes for a11y consistency (only the
1736
+ no-data gray adapts to the surface). Overflow is intrinsic: cells flex-shrink
1737
+ to a min, then the strip scrolls (zero viewport breakpoints). Overridable via
1738
+ the --vms-tracker-* seam like any other token. */
1739
+ :root {
1740
+ /* Fixed palette, verified separable (deltaE >= 28 worst-pair) under normal +
1741
+ deuteranopia + protanopia + tritanopia. fail is a deep red and warn a bright
1742
+ amber ON PURPOSE — under red-green colorblindness red and amber converge in
1743
+ hue, so they're separated by LIGHTNESS (deep vs bright), which the achromatic
1744
+ channel preserves. pass(blue)/fail is the huge, always-obvious pair. */
1745
+ --vms-tracker-pass: #4a9eff; /* good — blue anchor */
1746
+ --vms-tracker-fail: #cc2936; /* bad — deep red */
1747
+ --vms-tracker-warn: #f2c94c; /* warn — bright amber */
1748
+ /* no-data recedes into the surface, adapting per theme (light→light gray, dark→dark gray) */
1749
+ --vms-tracker-nodata: color-mix(in srgb, var(--vms-text-muted) 42%, var(--vms-surface));
1750
+ }
1751
+ .vms-tracker {
1752
+ display: flex;
1753
+ gap: 1px; /* hairline separation — cells read as discrete, not one smeared bar */
1754
+ align-items: stretch;
1755
+ width: 100%;
1756
+ overflow-x: auto; /* shrink-to-a-min-then-scroll (see cell min-width) */
1757
+ border-radius: var(--vms-radius);
1758
+ }
1759
+ .vms-tracker__cell {
1760
+ flex: 1 1 0;
1761
+ min-width: 3px; /* legibility floor; once cells hit it the strip scrolls instead of vanishing */
1762
+ height: 2rem;
1763
+ background: var(--vms-tracker-nodata);
1764
+ }
1765
+ .vms-tracker__cell--success { background: var(--vms-tracker-pass); }
1766
+ .vms-tracker__cell--danger { background: var(--vms-tracker-fail); }
1767
+ .vms-tracker__cell--warning { background: var(--vms-tracker-warn); }
1768
+ .vms-tracker__cell--muted { background: var(--vms-tracker-nodata); }
1769
+ /* clickable cell: gentle brighten on hover (NO border), keyboard focus ring for a11y */
1770
+ .vms-tracker__cell--clickable { cursor: pointer; }
1771
+ .vms-tracker__cell--clickable:hover { filter: brightness(1.32); }
1772
+ .vms-tracker__cell--clickable:focus-visible {
1773
+ outline: 2px solid var(--vms-accent);
1774
+ outline-offset: 1px;
1775
+ position: relative; /* lift the ring above neighbors */
1776
+ z-index: 1;
1777
+ }