@ashley-shrok/viewmodel-shell 6.2.1 → 6.4.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
@@ -3024,6 +3025,45 @@ export class BrowserAdapter {
3024
3025
  if (tableHasCheckboxes) {
3025
3026
  const selTh = document.createElement("th");
3026
3027
  selTh.className = "vms-table__th vms-table__th--select";
3028
+ // Header "select all rendered rows" affordance. A pure client-side toggle
3029
+ // over the leading-column checkboxes: it writes the SAME per-row binds the
3030
+ // row checkboxes use, so the server learns the selection through the exact
3031
+ // path it already knows — no new wire field, and agents (which set binds
3032
+ // directly) never need it. Scope is deliberately "all RENDERED rows": under
3033
+ // filter-narrow that equals all matches; under pagination it is the current
3034
+ // page (selection accumulates via the round-tripped binds); over-cap /
3035
+ // zero-match render no rows, so the control below simply is not drawn and
3036
+ // can never claim to select rows that are not on screen. This re-adds ONLY
3037
+ // the DOM toggle, never the per-toggle dispatch that got the old
3038
+ // TableNode.selection seam removed in 0.15.0.
3039
+ const rowCheckboxes = n.rows.flatMap(r => (r.actions ?? []).filter((e) => e.type === "checkbox"));
3040
+ if (rowCheckboxes.length > 0) {
3041
+ const lbl = document.createElement("label");
3042
+ lbl.className = "vms-checkbox vms-table__select-all";
3043
+ const inp = document.createElement("input");
3044
+ inp.type = "checkbox";
3045
+ inp.className = "vms-checkbox__input";
3046
+ inp.setAttribute("aria-label", "Select all rows");
3047
+ const states = rowCheckboxes.map(cb => Boolean(this.sa.read(cb.bind)));
3048
+ const allChecked = states.every(Boolean);
3049
+ inp.checked = allChecked;
3050
+ inp.indeterminate = !allChecked && states.some(Boolean);
3051
+ const mark = document.createElement("span");
3052
+ mark.className = "vms-checkbox__mark";
3053
+ inp.addEventListener("change", () => {
3054
+ const target = inp.checked;
3055
+ for (const cb of rowCheckboxes) {
3056
+ this.sa.write(cb.bind, target);
3057
+ const rowInp = document.getElementById(`vms-checkbox-${cb.name}`);
3058
+ if (rowInp)
3059
+ rowInp.checked = target;
3060
+ }
3061
+ inp.indeterminate = false;
3062
+ });
3063
+ lbl.appendChild(inp);
3064
+ lbl.appendChild(mark);
3065
+ selTh.appendChild(lbl);
3066
+ }
3027
3067
  headerRow.appendChild(selTh);
3028
3068
  }
3029
3069
  const sortIntent = (n.sortBind != null ? this.sa.read(n.sortBind) : null);
@@ -3197,6 +3237,34 @@ export class BrowserAdapter {
3197
3237
  });
3198
3238
  table.appendChild(tbody);
3199
3239
  wrapper.appendChild(table);
3240
+ // Visible-scoped bulk-action toolbar (n.selection). Rendered ABOVE the table.
3241
+ // Each button, on click, harvests the currently-CHECKED, currently-RENDERED
3242
+ // rows (by walking tbody for a checked leading-column checkbox and reading the
3243
+ // row's data-id) and writes that id array to n.selection.harvestBind —
3244
+ // OVERWRITING — before dispatching. So the server only ever sees rows the user
3245
+ // can currently see; a row selected then filtered/paginated out of view is not
3246
+ // harvested. This revives the old selection.buttons[] harvest (removed with the
3247
+ // `context` wire in Phase 6), adapted to write a BIND instead of context. It
3248
+ // adds NONE of the per-toggle dispatch that got the 0.15.0 selection.action
3249
+ // seam removed — selection stays a pure client concern until a bulk click.
3250
+ if (n.selection && n.selection.buttons.length > 0) {
3251
+ const sel = n.selection;
3252
+ const toolbar = document.createElement("div");
3253
+ toolbar.className = "vms-table__bulk-actions";
3254
+ const harvest = (action) => {
3255
+ const ids = [];
3256
+ tbody.querySelectorAll("tr").forEach(tr => {
3257
+ const box = tr.querySelector(".vms-table__td--select input.vms-checkbox__input");
3258
+ if (box?.checked && tr.dataset.id)
3259
+ ids.push(tr.dataset.id);
3260
+ });
3261
+ this.sa.write(sel.harvestBind, ids);
3262
+ on({ name: action.name });
3263
+ };
3264
+ for (const btn of sel.buttons)
3265
+ this.button(btn, toolbar, harvest);
3266
+ wrapper.insertBefore(toolbar, table);
3267
+ }
3200
3268
  if (n.pagination) {
3201
3269
  const pg = n.pagination;
3202
3270
  const footer = document.createElement("div");
@@ -3491,4 +3559,53 @@ export class BrowserAdapter {
3491
3559
  });
3492
3560
  parent.appendChild(ol);
3493
3561
  }
3562
+ /** TrackerNode — a status/heat strip: a tight horizontal row of discrete
3563
+ * colored cells, one per time bucket. The framework owns ALL appearance and
3564
+ * a11y (never on the wire): the hairline gap, the intrinsic
3565
+ * shrink-to-a-min-then-scroll overflow, and the baked colorblind-safe palette
3566
+ * (success=blue / danger=red / warning=amber / muted=gray) via the
3567
+ * .vms-tracker__cell--{state} classes. A cell with a `label` carries it as both
3568
+ * the native tooltip AND aria-label (meaning as text, not color-only). A cell
3569
+ * with an `action` becomes a role="button" tabstop with Enter/Space activation
3570
+ * (Space suppresses page scroll), mirroring TableRow.action / SectionNode.action. */
3571
+ tracker(n, parent, on) {
3572
+ const strip = document.createElement("div");
3573
+ strip.className = "vms-tracker";
3574
+ if (n.id != null)
3575
+ strip.id = n.id;
3576
+ // The strip is a graphical status summary — expose it as an img group with a
3577
+ // label so a screen reader announces it as one thing; per-cell state is on
3578
+ // each cell's aria-label.
3579
+ strip.setAttribute("role", "img");
3580
+ strip.setAttribute("aria-label", "status tracker");
3581
+ for (const cell of n.cells) {
3582
+ const state = cell.state ?? "muted";
3583
+ const el = document.createElement("div");
3584
+ el.className = `vms-tracker__cell vms-tracker__cell--${state}`;
3585
+ // label → native tooltip + aria-label (non-color channel). When absent,
3586
+ // the state name is still the a11y fallback so a cell is never color-only.
3587
+ const aria = cell.label != null && cell.label !== "" ? cell.label : state;
3588
+ el.setAttribute("aria-label", aria);
3589
+ if (cell.label != null && cell.label !== "")
3590
+ el.title = cell.label;
3591
+ if (cell.action) {
3592
+ const action = cell.action;
3593
+ el.classList.add("vms-tracker__cell--clickable");
3594
+ el.tabIndex = 0;
3595
+ el.setAttribute("role", "button");
3596
+ el.addEventListener("click", () => { on(action); });
3597
+ el.addEventListener("keydown", (e) => {
3598
+ if (e.key === "Enter") {
3599
+ on(action);
3600
+ }
3601
+ else if (e.key === " " || e.key === "Spacebar") {
3602
+ e.preventDefault(); // suppress page scroll
3603
+ on(action);
3604
+ }
3605
+ });
3606
+ }
3607
+ strip.appendChild(el);
3608
+ }
3609
+ parent.appendChild(strip);
3610
+ }
3494
3611
  }
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;
@@ -868,6 +868,31 @@ export interface TableNode {
868
868
  * filter dispatches reset `page` to 1 on the server side, since
869
869
  * the row window changes underneath them. */
870
870
  pagination?: TablePagination;
871
+ /** Visible-scoped bulk-action toolbar (opt-in). When set, the adapter renders
872
+ * `selection.buttons[]` ABOVE the table; each button, on click, harvests the
873
+ * currently-CHECKED, currently-RENDERED row ids (from the leading-column
874
+ * per-row checkboxes) and writes them — a `string[]` of `TableRow.id` — to
875
+ * `selection.harvestBind`, OVERWRITING, before dispatching its own action.
876
+ * The server reads that path to act, so a bulk action can only ever affect
877
+ * rows the user can currently see: a row selected under one filter and then
878
+ * filtered/paginated out of view is NOT harvested. This is the safe default
879
+ * for the common case; an app that genuinely wants cross-page/persistent
880
+ * selection simply ignores this block and reads its own bound `selectedIds`
881
+ * map instead (the framework never introspects app state, so that escape
882
+ * hatch is always open). Selectable rows must carry `TableRow.id`. */
883
+ selection?: TableSelection;
884
+ }
885
+ /** Visible-scoped bulk-action toolbar for a `TableNode` — see `TableNode.selection`. */
886
+ export interface TableSelection {
887
+ /** Bulk-action buttons rendered above the table. On click, each harvests the
888
+ * visible-checked row ids into `harvestBind` (overwriting) and dispatches its
889
+ * own `action` — name-only, per the Phase-6 wire. Full ButtonNodes, so they
890
+ * carry their own emphasis/tone/confirm/disabled. */
891
+ buttons: ButtonNode[];
892
+ /** State path the harvest writes the visible-checked row-id array (`string[]`)
893
+ * to right before dispatch. The server reads THIS to act — not a per-row
894
+ * `selectedIds` map — which is what makes the action visible-scoped. */
895
+ harvestBind: string;
871
896
  }
872
897
  export interface CopyButtonNode {
873
898
  type: "copy-button";
@@ -1087,6 +1112,49 @@ export interface StepsNode {
1087
1112
  * left, connector running down, descriptions beside each step). */
1088
1113
  orientation?: "horizontal" | "vertical";
1089
1114
  }
1115
+ /** One bucket in a TrackerNode strip — a single time slot's status. Carries only
1116
+ * display data: the semantic state, an optional hover label, and an optional
1117
+ * click-through action. */
1118
+ export interface TrackerCell {
1119
+ /** Semantic status for this bucket. A CLOSED set specific to a status strip:
1120
+ * `success` (good), `danger` (bad), `warning` (soft flag), `muted` (no data /
1121
+ * no run this slot — the universal honest-uncertainty convention). Omitted =
1122
+ * `muted`. NOTE the color axis differs from the rest of the framework on
1123
+ * PURPOSE: a dense, color-ONLY strip must survive colorblindness by color
1124
+ * alone, so the framework bakes a colorblind-safe palette where `success`
1125
+ * renders BLUE (not the global green success tone), `danger` red, `warning`
1126
+ * amber, `muted` gray — no "colorblind mode" needed. Only the rendered color
1127
+ * diverges; the state NAME stays semantic on the wire (agent-legible). `info`
1128
+ * is intentionally NOT a member — it would collide with `success`=blue and has
1129
+ * no meaning in a pass/fail history. */
1130
+ state?: "success" | "danger" | "warning" | "muted";
1131
+ /** Optional hover label for this bucket (e.g. "2026-07-15 14:22 UTC · Success").
1132
+ * Rendered as the cell's native tooltip AND its aria-label, so the strip's
1133
+ * meaning is carried by TEXT, not color alone — the a11y + agent-legibility
1134
+ * channel a color-only glyph strip could never provide. */
1135
+ label?: string;
1136
+ /** Optional click-through: dispatches this action when the bucket is clicked or
1137
+ * keyboard-activated (Enter / Space — Space preventDefaults page scroll). Makes
1138
+ * the cell a `role="button"` tabstop. Per-bucket identity is encoded in the
1139
+ * action name (e.g. `open-run-4021`), consistent with `TableRow.action` — no
1140
+ * context field. Omitted = a non-interactive status swatch (no tabstop). */
1141
+ action?: ActionEvent;
1142
+ }
1143
+ /** A status tracker / heat strip — a tight horizontal row of discrete colored
1144
+ * cells, one per time bucket, where color encodes each bucket's semantic status.
1145
+ * This is the "uptime strip" / "sentinel history" primitive (industry precedent:
1146
+ * Tremor Tracker, Grafana Status History, Statuspage), NOT a numeric value
1147
+ * sparkline (a tiny line chart — a separate, chart-family concern, deliberately
1148
+ * not built here). The framework owns ALL appearance and a11y: the hairline gap,
1149
+ * the intrinsic shrink-to-a-min-then-scroll overflow (zero viewport
1150
+ * breakpoints), and the baked colorblind-safe palette (see TrackerCell.state).
1151
+ * Bucket count is simply `cells.length` (60 slots, 24 hourly, 7 daily…). */
1152
+ export interface TrackerNode {
1153
+ type: "tracker";
1154
+ id?: string;
1155
+ /** Ordered buckets, oldest → newest (rendered left → right). */
1156
+ cells: TrackerCell[];
1157
+ }
1090
1158
  export interface ShellOptions {
1091
1159
  endpoint: string;
1092
1160
  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.4.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",
@@ -884,6 +884,14 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
884
884
  border-left: none;
885
885
  transform: rotate(40deg);
886
886
  }
887
+ .vms-checkbox__input:indeterminate + .vms-checkbox__mark { background: var(--vms-accent); border-color: var(--vms-accent); }
888
+ .vms-checkbox__input:indeterminate + .vms-checkbox__mark::after {
889
+ content: '';
890
+ position: absolute;
891
+ left: 3px; top: 7px;
892
+ width: 10px; height: 0;
893
+ border-top: 2px solid #fff;
894
+ }
887
895
  .vms-checkbox__label { margin-left: var(--vms-space-xs); font-size: var(--vms-text-base); }
888
896
 
889
897
  /* ── Buttons — three orthogonal axes: emphasis (fill) × tone (color) × size
@@ -1724,3 +1732,54 @@ button.vms-breadcrumb__link {
1724
1732
  transform: translateX(-1px);
1725
1733
  }
1726
1734
  }
1735
+
1736
+ /* ── Status tracker / heat strip (TrackerNode) ──
1737
+ A tight horizontal row of discrete colored cells, one per time bucket (uptime
1738
+ strip / sentinel history). The palette is a FIXED, colorblind-safe scheme —
1739
+ blue=good / red=bad / amber=warn / gray=no-data — deliberately NOT the theme's
1740
+ success(green)/danger(red) tones: a dense, color-ONLY strip has to survive
1741
+ colorblindness by color alone (verified separable under deuteranopia /
1742
+ protanopia / tritanopia), so "blue means good" is baked in and needs no mode.
1743
+ It is intentionally the SAME across all themes for a11y consistency (only the
1744
+ no-data gray adapts to the surface). Overflow is intrinsic: cells flex-shrink
1745
+ to a min, then the strip scrolls (zero viewport breakpoints). Overridable via
1746
+ the --vms-tracker-* seam like any other token. */
1747
+ :root {
1748
+ /* Fixed palette, verified separable (deltaE >= 28 worst-pair) under normal +
1749
+ deuteranopia + protanopia + tritanopia. fail is a deep red and warn a bright
1750
+ amber ON PURPOSE — under red-green colorblindness red and amber converge in
1751
+ hue, so they're separated by LIGHTNESS (deep vs bright), which the achromatic
1752
+ channel preserves. pass(blue)/fail is the huge, always-obvious pair. */
1753
+ --vms-tracker-pass: #4a9eff; /* good — blue anchor */
1754
+ --vms-tracker-fail: #cc2936; /* bad — deep red */
1755
+ --vms-tracker-warn: #f2c94c; /* warn — bright amber */
1756
+ /* no-data recedes into the surface, adapting per theme (light→light gray, dark→dark gray) */
1757
+ --vms-tracker-nodata: color-mix(in srgb, var(--vms-text-muted) 42%, var(--vms-surface));
1758
+ }
1759
+ .vms-tracker {
1760
+ display: flex;
1761
+ gap: 1px; /* hairline separation — cells read as discrete, not one smeared bar */
1762
+ align-items: stretch;
1763
+ width: 100%;
1764
+ overflow-x: auto; /* shrink-to-a-min-then-scroll (see cell min-width) */
1765
+ border-radius: var(--vms-radius);
1766
+ }
1767
+ .vms-tracker__cell {
1768
+ flex: 1 1 0;
1769
+ min-width: 3px; /* legibility floor; once cells hit it the strip scrolls instead of vanishing */
1770
+ height: 2rem;
1771
+ background: var(--vms-tracker-nodata);
1772
+ }
1773
+ .vms-tracker__cell--success { background: var(--vms-tracker-pass); }
1774
+ .vms-tracker__cell--danger { background: var(--vms-tracker-fail); }
1775
+ .vms-tracker__cell--warning { background: var(--vms-tracker-warn); }
1776
+ .vms-tracker__cell--muted { background: var(--vms-tracker-nodata); }
1777
+ /* clickable cell: gentle brighten on hover (NO border), keyboard focus ring for a11y */
1778
+ .vms-tracker__cell--clickable { cursor: pointer; }
1779
+ .vms-tracker__cell--clickable:hover { filter: brightness(1.32); }
1780
+ .vms-tracker__cell--clickable:focus-visible {
1781
+ outline: 2px solid var(--vms-accent);
1782
+ outline-offset: 1px;
1783
+ position: relative; /* lift the ring above neighbors */
1784
+ z-index: 1;
1785
+ }