@ashley-shrok/viewmodel-shell 4.2.0 → 5.0.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
@@ -156,15 +156,24 @@ File uploads use the multipart form above. One form entry per file input, keyed
156
156
  A `ChartNode` in the `vm` tree carries bounded, agent-legible declared data — it is read-only structured data like any other node, with no dispatch-bearing fields of its own:
157
157
 
158
158
  ```json
159
- { "type": "chart", "kind": "bar", "title": "Weekly visits",
160
- "points": [ { "label": "Mon", "value": 12 }, { "label": "Tue", "value": 19 } ],
161
- "tone": "info" }
159
+ { "type": "chart", "title": "Weekly visits",
160
+ "labels": [ "Mon", "Tue", "Wed" ],
161
+ "series": [
162
+ { "name": "Visits", "data": [ 12, 19, 7 ] },
163
+ { "name": "Errors", "data": [ 1, 3, 2 ], "tone": "danger" }
164
+ ],
165
+ "stacked": true }
162
166
  ```
163
167
 
164
- - `kind` optional. Omitted means `"bar"`, the only value in `viewmodel-shell/1.0`.
165
- - `points` — an array of self-contained `{label, value}` pairs. No parallel-array index alignment is needed (unlike some other frameworks' chart data shapes) — read each point directly.
168
+ (`kind` is omitted above, so this renders as the default `"bar"`, grouped→stacked via `stacked:true`.)
169
+
170
+ - `kind` — optional, a closed union: `bar | line | area | pie | donut`. Omitted means `"bar"`. Widened additively over time — key off the value you see, never assume a fixed set.
171
+ - `labels` — the shared category (x-)axis. `labels[i]` is the category every series' `data[i]` refers to.
172
+ - `series` — one or more `{ name, data, tone? }` entries over the shared `labels`. `data[i]` aligns by index to `labels[i]`. A single-series chart is just one entry in this array — there is no separate "simple" shape. `name` is the series label (rendered in a browser legend); a wire-driving agent reads it to identify which series is which.
173
+ - `series[].tone` — optional (`danger|warning|success|info`), set PER SERIES. When present, that series is drawn in the theme's semantic color instead of the next categorical palette slot (a loss/error series tagged `danger`, for example). Omitted → the browser client assigns the next palette slot; this only affects rendered COLOR. A wire-driving agent with no renderer can simply read `series[].data`/`name`/`labels`/`title` directly and ignore `tone`.
174
+ - `stacked` — optional boolean, applies to `bar`/`area` only (ignored for `line`/`pie`/`donut`). Omitted/`false` means series are grouped side-by-side; `true` stacks them.
166
175
  - `title` — optional chart title.
167
- - `tone`optional (`danger|warning|success|info`). This only affects rendered COLOR in a browser client. A wire-driving agent with no renderer can simply read `points`/`title` directly and ignore `tone`.
176
+ - **pie/donut note:** these kinds are single-series only `series[0]` is rendered (per-slice palette), any additional entries are ignored by the renderer. Not shown in the example above.
168
177
 
169
178
  There is nothing else to do to "drive" a chart over the wire.
170
179
 
package/dist/browser.d.ts CHANGED
@@ -72,30 +72,32 @@ export declare class BrowserAdapter implements Adapter {
72
72
  */
73
73
  private fits;
74
74
  /**
75
- * ChartNode (CHART-01/02/03/04) — a single-series BAR chart drawn by Chart.js,
76
- * loaded as a PRIVATE, LAZY, OPTIONAL adapter dependency: the dynamic
77
- * `import("chart.js")` in loadChart() is reached ONLY when a ChartNode renders,
78
- * and it registers ONLY the bar pieces so the bundle tree-shakes (an app that
79
- * renders no chart loads zero chart.js bytes; the core + .NET/bun backends gain
80
- * no dependency). tone→theme-token color (CHART-02) is read via getComputedStyle
81
- * NO raw CSS crosses the wire.
75
+ * ChartNode (CHARTBASE-02/03) — the multi-series base set (bar/line/area/
76
+ * pie/donut) drawn by Chart.js, loaded as a PRIVATE, LAZY, OPTIONAL adapter
77
+ * dependency: the dynamic `import("chart.js")` in loadChart() is reached
78
+ * ONLY when a ChartNode renders, and it registers the base-set pieces so an
79
+ * app that renders no chart loads zero chart.js bytes (the core + .NET/bun
80
+ * backends gain no dependency). Every color is read via getComputedStyle
81
+ * from the `--vms-chart-1..8` categorical palette (18-02) or, when a series
82
+ * carries a `tone`, from the theme's tone token — NO raw CSS crosses the wire.
82
83
  *
83
84
  * The canvas + Chart instance are keyed by a stable per-render ordinal and kept
84
85
  * in `chartInstances` ACROSS renders, so a re-render with changed data reuses
85
86
  * the SAME canvas (detached, not destroyed, by render()'s innerHTML wipe — its
86
- * 2D context + bitmap survive) and redraws IN PLACE via `.update()` (CHART-03)
87
- * rather than reconstructing. render() mark-sweeps + destroy()s any instance the
88
- * new tree dropped (leak prevention). getComputedStyle / canvas / Chart.js live
89
- * ONLY here in browser.ts — the core (index.ts) stays platform-agnostic.
87
+ * 2D context + bitmap survive) and redraws IN PLACE via `.update()` rather than
88
+ * reconstructing. render() mark-sweeps + destroy()s any instance the new tree
89
+ * dropped (leak prevention). getComputedStyle / canvas / Chart.js live ONLY
90
+ * here in browser.ts — the core (index.ts) stays platform-agnostic.
90
91
  */
91
92
  private chart;
92
93
  /**
93
94
  * Lazily import chart.js and construct the Chart for `key`. The dynamic import
94
- * is what keeps chart.js zero-bytes-when-absent; registering ONLY the bar
95
- * controller/elements/scales/tooltip is what keeps CHART-04 small (tree-shaken).
96
- * Fire-and-forget from chart() (`void this.loadChart(...)`), so a missing
97
- * dependency is surfaced through the fail-loud seam (chartFailLoud), NEVER a
98
- * floating unhandled rejection.
95
+ * is what keeps chart.js zero-bytes-when-absent; registering the base-set
96
+ * controllers/elements/scales/plugins (bar, line+fill, pie/doughnut, plus the
97
+ * shared category/linear scales, tooltip, and legend) covers every kind the
98
+ * widened chart() config can construct. Fire-and-forget from chart()
99
+ * (`void this.loadChart(...)`), so a missing dependency is surfaced through the
100
+ * fail-loud seam (chartFailLoud), NEVER a floating unhandled rejection.
99
101
  */
100
102
  private loadChart;
101
103
  /**
package/dist/browser.js CHANGED
@@ -481,21 +481,22 @@ export class BrowserAdapter {
481
481
  this.fitsObservers.push(ro);
482
482
  }
483
483
  /**
484
- * ChartNode (CHART-01/02/03/04) — a single-series BAR chart drawn by Chart.js,
485
- * loaded as a PRIVATE, LAZY, OPTIONAL adapter dependency: the dynamic
486
- * `import("chart.js")` in loadChart() is reached ONLY when a ChartNode renders,
487
- * and it registers ONLY the bar pieces so the bundle tree-shakes (an app that
488
- * renders no chart loads zero chart.js bytes; the core + .NET/bun backends gain
489
- * no dependency). tone→theme-token color (CHART-02) is read via getComputedStyle
490
- * NO raw CSS crosses the wire.
484
+ * ChartNode (CHARTBASE-02/03) — the multi-series base set (bar/line/area/
485
+ * pie/donut) drawn by Chart.js, loaded as a PRIVATE, LAZY, OPTIONAL adapter
486
+ * dependency: the dynamic `import("chart.js")` in loadChart() is reached
487
+ * ONLY when a ChartNode renders, and it registers the base-set pieces so an
488
+ * app that renders no chart loads zero chart.js bytes (the core + .NET/bun
489
+ * backends gain no dependency). Every color is read via getComputedStyle
490
+ * from the `--vms-chart-1..8` categorical palette (18-02) or, when a series
491
+ * carries a `tone`, from the theme's tone token — NO raw CSS crosses the wire.
491
492
  *
492
493
  * The canvas + Chart instance are keyed by a stable per-render ordinal and kept
493
494
  * in `chartInstances` ACROSS renders, so a re-render with changed data reuses
494
495
  * the SAME canvas (detached, not destroyed, by render()'s innerHTML wipe — its
495
- * 2D context + bitmap survive) and redraws IN PLACE via `.update()` (CHART-03)
496
- * rather than reconstructing. render() mark-sweeps + destroy()s any instance the
497
- * new tree dropped (leak prevention). getComputedStyle / canvas / Chart.js live
498
- * ONLY here in browser.ts — the core (index.ts) stays platform-agnostic.
496
+ * 2D context + bitmap survive) and redraws IN PLACE via `.update()` rather than
497
+ * reconstructing. render() mark-sweeps + destroy()s any instance the new tree
498
+ * dropped (leak prevention). getComputedStyle / canvas / Chart.js live ONLY
499
+ * here in browser.ts — the core (index.ts) stays platform-agnostic.
499
500
  */
500
501
  chart(n, parent) {
501
502
  // Stable key: title-derived base disambiguated by a per-render ordinal so
@@ -510,7 +511,8 @@ export class BrowserAdapter {
510
511
  wrapper.className = "vms-chart";
511
512
  parent.appendChild(wrapper);
512
513
  // tone → theme token, NOT `--vms-${tone}`: `danger` maps to `--vms-error`
513
- // (matching .vms-section--danger); omitted the neutral `--vms-accent`.
514
+ // (matching .vms-section--danger). Used ONLY when a series declares a
515
+ // `tone` — otherwise a series/slice gets the next categorical palette slot.
514
516
  const toneToken = {
515
517
  danger: "--vms-error",
516
518
  warning: "--vms-warning",
@@ -518,37 +520,90 @@ export class BrowserAdapter {
518
520
  info: "--vms-info",
519
521
  };
520
522
  const cs = getComputedStyle(this.container);
521
- const token = (n.tone && toneToken[n.tone]) || "--vms-accent";
522
- const color = cs.getPropertyValue(token).trim();
523
- // Grid/tick/axis colors track the theme so the chart reads consistently in
524
- // light AND dark. Chart.js's default grid is a FIXED faint-black
525
- // (rgba(0,0,0,0.1)) visible on a light background but ~invisible on a dark
526
- // one so wire the grid + axis border to `--vms-border` (subtle in every
527
- // theme) and the tick labels to `--vms-text-muted`.
523
+ // Categorical palette slot i (0-based) --vms-chart-1..8, cycling. Falls
524
+ // back to --vms-accent (the pre-reshape safety net) when a consumer's
525
+ // custom theme (built via the sanctioned --vms-* override seam) predates
526
+ // this phase and doesn't define the chart tokens every SHIPPED theme
527
+ // does, so this only matters for external reskins.
528
+ const paletteColor = (i) => cs.getPropertyValue(`--vms-chart-${(i % 8) + 1}`).trim() || cs.getPropertyValue("--vms-accent").trim();
529
+ // A series' resolved color: its tone token if set, else the next palette slot.
530
+ const seriesColor = (i, tone) => (tone && toneToken[tone]) ? cs.getPropertyValue(toneToken[tone]).trim() : paletteColor(i);
531
+ // Grid/tick/axis/text colors track the theme so the chart reads consistently
532
+ // in light AND dark. Chart.js's defaults are FIXED near-black — its grid is
533
+ // rgba(0,0,0,0.1) and its text (legend labels + title) is #666 — visible on a
534
+ // light background but low-contrast/~invisible on a dark one. So wire the grid
535
+ // + axis border to `--vms-border` (subtle in every theme), the tick labels
536
+ // (secondary) to `--vms-text-muted`, and the legend labels + title (the text
537
+ // that NAMES the series/chart — primary information) to the full-contrast
538
+ // `--vms-text` so they read prominently, not washed out.
528
539
  const gridColor = cs.getPropertyValue("--vms-border").trim();
529
540
  const tickColor = cs.getPropertyValue("--vms-text-muted").trim();
541
+ const textColor = cs.getPropertyValue("--vms-text").trim();
530
542
  const scaleOpts = {
531
543
  grid: { color: gridColor },
532
544
  border: { color: gridColor },
533
545
  ticks: { color: tickColor },
534
546
  };
547
+ const kind = n.kind ?? "bar";
548
+ const isPie = kind === "pie" || kind === "donut";
549
+ let type;
550
+ let datasets;
551
+ if (isPie) {
552
+ // pie/donut are single-series (LOCKED design): render series[0] only,
553
+ // colored PER SLICE from the palette (tone is a per-series concept and
554
+ // doesn't apply to a per-slice pie). Extra series are lenient — one dev
555
+ // warning, series[0] still renders — never a crash. Gated to the FIRST
556
+ // render of this chart key (chartInstances doesn't have it yet) so a
557
+ // mis-shaped pie/donut in a polling view warns once, not once per poll.
558
+ if (n.series.length > 1 && !this.chartInstances.has(key)) {
559
+ console.warn(`[ViewModelShell] ChartNode kind "${kind}" renders a single series; ` +
560
+ `${n.series.length - 1} extra series ignored.`);
561
+ }
562
+ const primary = n.series[0];
563
+ type = kind === "donut" ? "doughnut" : "pie";
564
+ datasets = [{
565
+ data: primary ? primary.data : [],
566
+ backgroundColor: n.labels.map((_, j) => paletteColor(j)),
567
+ }];
568
+ }
569
+ else {
570
+ // bar/line/area — one dataset per series, sharing the `labels` x-axis.
571
+ type = (kind === "line" || kind === "area") ? "line" : "bar";
572
+ datasets = n.series.map((s, i) => {
573
+ const color = seriesColor(i, s.tone);
574
+ const dataset = {
575
+ label: s.name,
576
+ data: s.data,
577
+ backgroundColor: color,
578
+ borderColor: color,
579
+ };
580
+ if (kind === "line" || kind === "area") {
581
+ // area = line + fill; the fill is token-derived (same resolved color
582
+ // as the stroke) — no raw color literal introduced for the fill.
583
+ dataset.fill = kind === "area";
584
+ }
585
+ return dataset;
586
+ });
587
+ }
588
+ // `stacked` applies to bar/area only (LOCKED design); ignored for line/pie/donut.
589
+ const stacked = (kind === "bar" || kind === "area") && !!n.stacked;
590
+ // Legend: multi-series always, OR pie/donut (always multi-slice).
591
+ const legendDisplay = n.series.length > 1 || isPie;
535
592
  const config = {
536
- type: "bar",
537
- data: {
538
- labels: n.points.map(p => p.label),
539
- datasets: [{
540
- data: n.points.map(p => p.value),
541
- backgroundColor: color,
542
- borderColor: color,
543
- }],
544
- },
593
+ type,
594
+ data: { labels: n.labels, datasets },
545
595
  options: {
546
596
  responsive: true,
547
597
  maintainAspectRatio: false,
548
- scales: { x: scaleOpts, y: scaleOpts },
598
+ ...(isPie ? {} : {
599
+ scales: {
600
+ x: { ...scaleOpts, stacked },
601
+ y: { ...scaleOpts, stacked },
602
+ },
603
+ }),
549
604
  plugins: {
550
- title: n.title ? { display: true, text: n.title } : { display: false },
551
- legend: { display: false }, // single series no legend
605
+ title: n.title ? { display: true, text: n.title, color: textColor } : { display: false },
606
+ legend: { display: legendDisplay, labels: { color: textColor } },
552
607
  },
553
608
  },
554
609
  };
@@ -558,7 +613,7 @@ export class BrowserAdapter {
558
613
  // destroyed) — its 2D context + drawn bitmap survive.
559
614
  wrapper.appendChild(existing.canvas);
560
615
  if (existing.chart) {
561
- // Redraw in place (CHART-03).
616
+ // Redraw in place (CHARTBASE-03).
562
617
  existing.chart.data = config.data;
563
618
  existing.chart.options = config.options;
564
619
  existing.chart.update();
@@ -578,11 +633,12 @@ export class BrowserAdapter {
578
633
  }
579
634
  /**
580
635
  * Lazily import chart.js and construct the Chart for `key`. The dynamic import
581
- * is what keeps chart.js zero-bytes-when-absent; registering ONLY the bar
582
- * controller/elements/scales/tooltip is what keeps CHART-04 small (tree-shaken).
583
- * Fire-and-forget from chart() (`void this.loadChart(...)`), so a missing
584
- * dependency is surfaced through the fail-loud seam (chartFailLoud), NEVER a
585
- * floating unhandled rejection.
636
+ * is what keeps chart.js zero-bytes-when-absent; registering the base-set
637
+ * controllers/elements/scales/plugins (bar, line+fill, pie/doughnut, plus the
638
+ * shared category/linear scales, tooltip, and legend) covers every kind the
639
+ * widened chart() config can construct. Fire-and-forget from chart()
640
+ * (`void this.loadChart(...)`), so a missing dependency is surfaced through the
641
+ * fail-loud seam (chartFailLoud), NEVER a floating unhandled rejection.
586
642
  */
587
643
  async loadChart(key, config) {
588
644
  let mod;
@@ -594,9 +650,10 @@ export class BrowserAdapter {
594
650
  "installed. Run: npm install chart.js");
595
651
  return;
596
652
  }
597
- const { Chart, BarController, BarElement, CategoryScale, LinearScale, Tooltip } = mod;
598
- // Tree-shaken registration — ONLY the bar pieces.
599
- Chart.register(BarController, BarElement, CategoryScale, LinearScale, Tooltip);
653
+ const { Chart, BarController, BarElement, LineController, LineElement, PointElement, Filler, PieController, DoughnutController, ArcElement, CategoryScale, LinearScale, Tooltip, Legend, } = mod;
654
+ // Base-set registration — bar, line/area (+ Filler for the area fill),
655
+ // pie/donut (+ Arc element), the shared scales, tooltip, and the legend.
656
+ Chart.register(BarController, BarElement, LineController, LineElement, PointElement, Filler, PieController, DoughnutController, ArcElement, CategoryScale, LinearScale, Tooltip, Legend);
600
657
  const entry = this.chartInstances.get(key);
601
658
  // A later render may have mark-swept this key before the import resolved.
602
659
  if (!entry)
@@ -1225,6 +1282,13 @@ export class BrowserAdapter {
1225
1282
  // attribute was cleared out-of-band.)
1226
1283
  if (n.disabled)
1227
1284
  return;
1285
+ // confirm: a destructive-action guard. Show the NATIVE browser confirm
1286
+ // BEFORE any pendingLabel swap or dispatch; Cancel suppresses everything
1287
+ // (no dispatch, no visual change). Native by design — zero app/framework
1288
+ // 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).
1290
+ if (n.confirm && !window.confirm(n.confirm))
1291
+ return;
1228
1292
  // pendingLabel: instant client-side feedback. Swap text + add
1229
1293
  // .vms-button--pending BEFORE handing off to the dispatcher. On
1230
1294
  // success the next render replaces the button entirely. On dispatch
@@ -1614,6 +1678,49 @@ export class BrowserAdapter {
1614
1678
  const prevDisabled = pg.page <= 1 || pg.prevAction == null;
1615
1679
  const nextDisabled = pg.page >= totalPages || pg.nextAction == null;
1616
1680
  footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.prevAction, prevDisabled));
1681
+ if (pg.jumpAction) {
1682
+ const jumpAction = pg.jumpAction;
1683
+ const jump = document.createElement("span");
1684
+ jump.className = "vms-table__pagination-jump";
1685
+ const label = document.createElement("span");
1686
+ label.className = "vms-table__pagination-jump-label";
1687
+ label.textContent = "Page";
1688
+ jump.appendChild(label);
1689
+ const input = document.createElement("input");
1690
+ input.type = "number";
1691
+ input.className = "vms-table__pagination-jump-input";
1692
+ input.min = "1";
1693
+ input.max = String(totalPages);
1694
+ input.inputMode = "numeric";
1695
+ input.setAttribute("aria-label", "Page number");
1696
+ input.value = String(pg.page);
1697
+ jump.appendChild(input);
1698
+ const ofLabel = document.createElement("span");
1699
+ ofLabel.className = "vms-table__pagination-jump-label";
1700
+ ofLabel.textContent = `of ${totalPages}`;
1701
+ jump.appendChild(ofLabel);
1702
+ const submitJump = () => {
1703
+ const parsed = Number.parseInt(input.value.trim(), 10);
1704
+ if (!Number.isFinite(parsed))
1705
+ return;
1706
+ const clamped = Math.min(Math.max(parsed, 1), totalPages);
1707
+ input.value = String(clamped);
1708
+ if (paginationBind != null)
1709
+ this.sa.write(paginationBind, clamped);
1710
+ on(jumpAction);
1711
+ };
1712
+ const goBtn = document.createElement("button");
1713
+ goBtn.type = "button";
1714
+ goBtn.className = "vms-button vms-button--secondary vms-table__pagination-btn";
1715
+ goBtn.textContent = "Go";
1716
+ goBtn.addEventListener("click", submitJump);
1717
+ input.addEventListener("keydown", (e) => {
1718
+ if (e.key === "Enter")
1719
+ submitJump();
1720
+ });
1721
+ jump.appendChild(goBtn);
1722
+ footer.appendChild(jump);
1723
+ }
1617
1724
  footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.nextAction, nextDisabled));
1618
1725
  wrapper.appendChild(footer);
1619
1726
  }
package/dist/index.d.ts CHANGED
@@ -384,6 +384,18 @@ export interface ButtonNode {
384
384
  * while in this state so the button visibly disables (cursor + opacity).
385
385
  * Omitted = no pending feedback (existing instant-click behavior). */
386
386
  pendingLabel?: string;
387
+ /** Optional confirmation question for a destructive/irreversible action
388
+ * (delete, reset, archive). When set, the BrowserAdapter shows a NATIVE
389
+ * browser confirm dialog with this message on click; the action dispatches
390
+ * only if the user accepts, and Cancel suppresses it entirely (no dispatch,
391
+ * no pendingLabel swap). Deliberately NATIVE, not a framework-drawn dialog:
392
+ * it adds ZERO app or framework state (no modal in the tree, nothing to
393
+ * round-trip or tear down) and its OS-native, deliberately-jarring look
394
+ * reinforces "this one is serious — stop." It is a CLIENT-ONLY human
395
+ * affordance: an agent dispatches the action directly over the wire and is
396
+ * never gated by it (the confirm exists only at browser render time). The
397
+ * TUI has no confirm() and dispatches as normal. Omitted = instant dispatch. */
398
+ confirm?: string;
387
399
  }
388
400
  export interface TextNode {
389
401
  type: "text";
@@ -504,6 +516,11 @@ export interface TablePagination {
504
516
  /** Dispatched on the next page-control click. Carries an action name only —
505
517
  * the renderer writes the target page to TableNode.paginationBind before dispatch. */
506
518
  nextAction?: ActionEvent;
519
+ /** Dispatched when the user submits a typed target page via the jump-to-page
520
+ * control's Go button or Enter key. The renderer clamps the typed value into
521
+ * [1, totalPages] before writing it to TableNode.paginationBind and dispatching —
522
+ * same mechanism as prevAction/nextAction. Omitted = no jump control renders. */
523
+ jumpAction?: ActionEvent;
507
524
  }
508
525
  export interface TableNode {
509
526
  type: "table";
@@ -649,30 +666,34 @@ export interface FitsNode {
649
666
  * guaranteed-fits fallback rendered when none fit. */
650
667
  children: ViewNode[];
651
668
  }
652
- export interface ChartPoint {
653
- /** Category label (x-axis tick). */
654
- label: string;
655
- /** Numeric magnitude (bar height). */
656
- value: number;
669
+ export interface ChartSeries {
670
+ /** Series name rendered in the legend and read by agents to identify the series. */
671
+ name: string;
672
+ /** Values aligned by index to the chart's `labels`: data[i] is the value at labels[i]. */
673
+ data: number[];
674
+ /** OPTIONAL semantic tone from the existing closed tone axis. When set, this series is
675
+ * drawn in the theme's tone token (danger→--vms-error, etc.) instead of the next
676
+ * categorical-palette slot. For MEANING (a loss series → danger), not decoration.
677
+ * Omitted → framework assigns the next --vms-chart-N slot. NO raw color crosses the wire. */
678
+ tone?: "danger" | "warning" | "success" | "info";
657
679
  }
658
680
  export interface ChartNode {
659
681
  type: "chart";
660
- /** Chart type. CLOSED union; OMITTED = "bar". "bar" is the only value in v4.1;
661
- * `line` is an ADDITIVE future value (CHART-LINE), NOT a new node so
662
- * consumers/agents key off `kind`, and a later milestone widens the union
663
- * without a wire break. The renderer treats an absent `kind` as "bar". */
664
- kind?: "bar";
665
- /** Ordered category→value data. Each point is a SELF-CONTAINED {label, value}
666
- * pair (mirroring StatItem) so an agent reads the series DIRECTLY with no
667
- * parallel-array index alignment. Bars render in array order. */
668
- points: ChartPoint[];
682
+ /** Chart type. CLOSED union; OMITTED = "bar". Widened additively later (e.g. scatter)
683
+ * consumers/agents key off `kind`, never assume a fixed set. The renderer treats an
684
+ * absent `kind` as "bar". */
685
+ kind?: "bar" | "line" | "area" | "pie" | "donut";
686
+ /** Shared category axis. labels[i] is the category for every series' data[i]. */
687
+ labels: string[];
688
+ /** One or more series over the shared `labels`. Single-series charts are just one entry.
689
+ * Multi-series charts share ONE x-axis (labels) this is the honest encoding of that
690
+ * shared axis, and the shape every charting library uses. */
691
+ series: ChartSeries[];
692
+ /** bar/area only: stack series instead of grouping side-by-side. Omitted/false = grouped
693
+ * (ignored for line/pie/donut). */
694
+ stacked?: boolean;
669
695
  /** Optional chart title rendered above the plot. */
670
696
  title?: string;
671
- /** Optional appearance tone from the existing tone axis, mapped to the theme's
672
- * --vms-* tone tokens (danger→--vms-error, warning→--vms-warning,
673
- * success→--vms-success, info→--vms-info; omitted → --vms-accent). NO raw
674
- * color/CSS crosses the wire (D3) — only the closed tone token. */
675
- tone?: "danger" | "warning" | "success" | "info";
676
697
  }
677
698
  export interface ShellOptions {
678
699
  endpoint: string;
package/dist/server.js CHANGED
@@ -153,6 +153,9 @@ function collectActions(node, enclosingForm, out) {
153
153
  if (table.pagination?.nextAction) {
154
154
  recordAction(table.pagination.nextAction, enclosingForm, out);
155
155
  }
156
+ if (table.pagination?.jumpAction) {
157
+ recordAction(table.pagination.jumpAction, enclosingForm, out);
158
+ }
156
159
  for (const row of table.rows) {
157
160
  if (row.actions) {
158
161
  // row.actions is ViewNode[] — it can include bind-only nodes (e.g. a
package/dist/tui.js CHANGED
@@ -1102,24 +1102,47 @@ function StatBarView({ node }) {
1102
1102
  return (_jsx("box", { flexDirection: "row", gap: 1, children: node.stats.map((s, i) => (_jsxs("box", { flexDirection: "row", gap: 1, children: [i > 0 ? _jsx("text", { fg: "#555555", children: "\u2502" }) : null, _jsx("text", { fg: "#888888", children: s.label }), _jsx("text", { children: String(s.value) })] }, i))) }));
1103
1103
  }
1104
1104
  // ── chart ─────────────────────────────────────────────────────────────────
1105
- // CHART-05 — DELIBERATE degradation. A terminal has no canvas, but a ChartNode
1106
- // is STRUCTURED data, so it prints as a legible label/value/ASCII-bar series:
1107
- // the title (if any) on its own line, then per point `<label padded> <value>
1108
- // <bar>` where <bar> is a run of "█" scaled to value/max × CHART_BAR_WIDTH.
1109
- // Empty points render just the title (or nothing); an all-zero / non-positive
1110
- // max renders labels+values with no bars (guarded, never throws / divides).
1111
- // The TUI is @experimental; the requirement is only that ChartNode does not
1112
- // break it and degrades legibly. ChartNode is a LEAF (no children) no
1105
+ // CHARTBASE-05 — DELIBERATE degradation for the reshaped multi-series
1106
+ // ChartNode { kind?; labels: string[]; series: ChartSeries[]; stacked?;
1107
+ // title? }. A terminal has no canvas, but a ChartNode is STRUCTURED data, so
1108
+ // it prints legibly: the title (if any) on its own line, then GROUPED BY
1109
+ // SERIES each series' name as a sub-header, followed by one row per label
1110
+ // `<label padded> <value> <bar>` where <bar> is a run of "█" scaled to
1111
+ // value / the GLOBAL max across ALL series (so multi-series bars are
1112
+ // comparable to each other, not just within their own series). pie/donut are
1113
+ // single-series by design (CHARTBASE-03) — they degrade to printing
1114
+ // series[0]'s label/value slices (no bars; a "share of whole" reads oddly as
1115
+ // a bar anyway). Empty series / empty labels / an all-zero or non-positive
1116
+ // max render names/labels/values with no bars — every division is guarded by
1117
+ // `maxValue > 0`, and the resulting length is additionally clamped to >= 0
1118
+ // before `.repeat()`, since an individual value can be negative even while
1119
+ // the GLOBAL max (across all series) is positive (e.g. a "Net" series with
1120
+ // mixed-sign entries) — never throws. ChartNode is a LEAF (no children) → no
1113
1121
  // container-walk arm is needed (mirrors StatBarView / ProgressView).
1114
1122
  const CHART_BAR_WIDTH = 20;
1115
1123
  function ChartView({ node }) {
1116
- const points = node.points ?? [];
1117
- const maxValue = points.length ? Math.max(0, ...points.map((p) => p.value)) : 0;
1118
- const labelWidth = points.reduce((w, p) => Math.max(w, p.label.length), 0);
1119
- return (_jsxs("box", { flexDirection: "column", children: [node.title ? _jsx("text", { attributes: 1 /* BOLD */, children: node.title }) : null, points.map((p, i) => {
1120
- const barLen = maxValue > 0 ? Math.round((p.value / maxValue) * CHART_BAR_WIDTH) : 0;
1121
- return (_jsxs("box", { flexDirection: "row", gap: 1, children: [_jsx("text", { fg: "#888888", children: p.label.padEnd(labelWidth) }), _jsx("text", { children: String(p.value).padStart(4) }), _jsx("text", { fg: "#4a9eff", children: "█".repeat(barLen) })] }, i));
1122
- })] }));
1124
+ const labels = node.labels ?? [];
1125
+ const series = node.series ?? [];
1126
+ const labelWidth = labels.reduce((w, l) => Math.max(w, l.length), 0);
1127
+ const isPie = node.kind === "pie" || node.kind === "donut";
1128
+ if (isPie) {
1129
+ // Single-series degrade: series[0]'s slices as label/value rows.
1130
+ const slice = series[0];
1131
+ const data = slice?.data ?? [];
1132
+ return (_jsxs("box", { flexDirection: "column", children: [node.title ? _jsx("text", { attributes: 1 /* BOLD */, children: node.title }) : null, slice ? _jsx("text", { attributes: 1 /* BOLD */, fg: "#888888", children: slice.name }) : null, labels.map((label, i) => (_jsxs("box", { flexDirection: "row", gap: 1, children: [_jsx("text", { fg: "#888888", children: label.padEnd(labelWidth) }), _jsx("text", { children: String(data[i] ?? 0) })] }, i)))] }));
1133
+ }
1134
+ // bar/line/area: scale every series' bars against the GLOBAL max so
1135
+ // multiple series are visually comparable to one another.
1136
+ const allValues = series.flatMap((s) => s.data ?? []);
1137
+ const maxValue = allValues.length ? Math.max(0, ...allValues) : 0;
1138
+ return (_jsxs("box", { flexDirection: "column", children: [node.title ? _jsx("text", { attributes: 1 /* BOLD */, children: node.title }) : null, series.map((s, si) => (_jsxs("box", { flexDirection: "column", children: [_jsx("text", { attributes: 1 /* BOLD */, children: s.name }), labels.map((label, li) => {
1139
+ const value = s.data?.[li] ?? 0;
1140
+ // Clamped to >= 0: `value` can be negative even while `maxValue`
1141
+ // (the global max across ALL series) is positive — an unclamped
1142
+ // negative length would throw on "█".repeat() below.
1143
+ const barLen = maxValue > 0 ? Math.max(0, Math.round((value / maxValue) * CHART_BAR_WIDTH)) : 0;
1144
+ return (_jsxs("box", { flexDirection: "row", gap: 1, children: [_jsx("text", { fg: "#888888", children: label.padEnd(labelWidth) }), _jsx("text", { children: String(value).padStart(4) }), _jsx("text", { fg: "#4a9eff", children: "█".repeat(barLen) })] }, li));
1145
+ })] }, si)))] }));
1123
1146
  }
1124
1147
  // ── modal ─────────────────────────────────────────────────────────────────
1125
1148
  // Modals are PORTALED to app-root by App (see findModal + ModalOverlay).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "4.2.0",
3
+ "version": "5.0.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 \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,6 +32,22 @@
32
32
  --vms-priority-high: #d76410;
33
33
  --vms-success: #2da359;
34
34
  --vms-info: #2277dd;
35
+ /* Chart palette (CHARTBASE-02) — 8-slot categorical palette the browser
36
+ adapter cycles per series (per slice for pie/donut). Framework-owned,
37
+ theme-token-driven: zero raw color crosses the wire. Every theme file
38
+ re-tunes these 8 for its own surface/bg (light themes get this set;
39
+ dark themes get a lighter/more-saturated set — see styles/themes/*.css).
40
+ Hand-checked (not CI-gated — check:aa-contrast covers a fixed pair-set
41
+ by design) for ~3:1 non-text contrast against --vms-surface/--vms-bg
42
+ and mutual distinguishability; see 18-02-SUMMARY.md for the ratio table. */
43
+ --vms-chart-1: #3366cc;
44
+ --vms-chart-2: #cc6b1c;
45
+ --vms-chart-3: #1f8a5f;
46
+ --vms-chart-4: #b3314d;
47
+ --vms-chart-5: #7a4fc9;
48
+ --vms-chart-6: #1f8fa3;
49
+ --vms-chart-7: #767a1f;
50
+ --vms-chart-8: #c23fa0;
35
51
  --vms-radius: 10px;
36
52
  --vms-radius-sm: 6px;
37
53
  --vms-image-small: 4rem;
@@ -963,9 +979,13 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
963
979
  hairline. Buttons reuse .vms-button styling; only sizing/disabled is added. */
964
980
  .vms-table__pagination {
965
981
  display: flex;
982
+ flex-wrap: wrap;
966
983
  align-items: center;
967
984
  gap: var(--vms-space-sm);
968
- padding-top: var(--vms-space-sm);
985
+ /* Inset to match the cell padding (space-md horizontal) so the footer content
986
+ aligns with the table cells above it, with breathing room below; the
987
+ border-top hairline still spans the full width. */
988
+ padding: var(--vms-space-sm) var(--vms-space-md);
969
989
  margin-top: var(--vms-space-xs);
970
990
  border-top: 1px solid var(--vms-border);
971
991
  }
@@ -973,6 +993,26 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
973
993
  .vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
974
994
  .vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
975
995
 
996
+ /* Jump-to-page control — "Page [ N ] of TOTAL Go", inserted between Prev/Next. */
997
+ .vms-table__pagination-jump { display: flex; align-items: center; gap: var(--vms-space-xs); }
998
+ .vms-table__pagination-jump-label { color: var(--vms-text-muted); font-size: var(--vms-text-sm); white-space: nowrap; }
999
+ .vms-table__pagination-jump-input {
1000
+ width: 3.5em;
1001
+ text-align: center;
1002
+ background: var(--vms-surface);
1003
+ border: 1px solid var(--vms-border);
1004
+ border-radius: var(--vms-radius);
1005
+ color: var(--vms-text);
1006
+ font-family: var(--vms-font-body);
1007
+ font-size: var(--vms-text-sm);
1008
+ padding: var(--vms-space-xs);
1009
+ color-scheme: var(--vms-color-scheme);
1010
+ }
1011
+ .vms-table__pagination-jump-input:focus {
1012
+ border-color: var(--vms-accent);
1013
+ outline: none;
1014
+ }
1015
+
976
1016
  /* 0.16.0 — `.vms-busy` is applied by BrowserAdapter when the shell is in a
977
1017
  busy state (a user-initiated dispatch is in flight OR the server returned
978
1018
  ShellResponse.busy: true). cursor: wait communicates the state; pointer-
@@ -23,5 +23,14 @@
23
23
  --vms-priority-high: #e07015;
24
24
  --vms-success: #4dd17a;
25
25
  --vms-info: #4a9eff;
26
+ /* Chart palette (CHARTBASE-02) — same dark-family palette as dark-purple.css. */
27
+ --vms-chart-1: #6fa8ff;
28
+ --vms-chart-2: #ffa552;
29
+ --vms-chart-3: #5ed19a;
30
+ --vms-chart-4: #ff7a90;
31
+ --vms-chart-5: #b79bfa;
32
+ --vms-chart-6: #5cd6e8;
33
+ --vms-chart-7: #d0d661;
34
+ --vms-chart-8: #ef8fd6;
26
35
  --vms-color-scheme: dark;
27
36
  }
@@ -23,5 +23,14 @@
23
23
  --vms-priority-high: #e07015;
24
24
  --vms-success: #4dd17a;
25
25
  --vms-info: #4a9eff;
26
+ /* Chart palette (CHARTBASE-02) — same dark-family palette as dark-purple.css. */
27
+ --vms-chart-1: #6fa8ff;
28
+ --vms-chart-2: #ffa552;
29
+ --vms-chart-3: #5ed19a;
30
+ --vms-chart-4: #ff7a90;
31
+ --vms-chart-5: #b79bfa;
32
+ --vms-chart-6: #5cd6e8;
33
+ --vms-chart-7: #d0d661;
34
+ --vms-chart-8: #ef8fd6;
26
35
  --vms-color-scheme: dark;
27
36
  }
@@ -23,5 +23,14 @@
23
23
  --vms-priority-high: #e07015;
24
24
  --vms-success: #4dd17a;
25
25
  --vms-info: #4a9eff;
26
+ /* Chart palette (CHARTBASE-02) — same dark-family palette as dark-purple.css. */
27
+ --vms-chart-1: #6fa8ff;
28
+ --vms-chart-2: #ffa552;
29
+ --vms-chart-3: #5ed19a;
30
+ --vms-chart-4: #ff7a90;
31
+ --vms-chart-5: #b79bfa;
32
+ --vms-chart-6: #5cd6e8;
33
+ --vms-chart-7: #d0d661;
34
+ --vms-chart-8: #ef8fd6;
26
35
  --vms-color-scheme: dark;
27
36
  }
@@ -20,5 +20,16 @@
20
20
  --vms-priority-high: #e07015;
21
21
  --vms-success: #4dd17a;
22
22
  --vms-info: #4a9eff;
23
+ /* Chart palette (CHARTBASE-02) — added on top of the byte-exact prior-default
24
+ capture (D-02); check-theme-byte-identity.mjs allows exactly this 8-token
25
+ addition alongside the frozen color block, see its comments. */
26
+ --vms-chart-1: #6fa8ff;
27
+ --vms-chart-2: #ffa552;
28
+ --vms-chart-3: #5ed19a;
29
+ --vms-chart-4: #ff7a90;
30
+ --vms-chart-5: #b79bfa;
31
+ --vms-chart-6: #5cd6e8;
32
+ --vms-chart-7: #d0d661;
33
+ --vms-chart-8: #ef8fd6;
23
34
  --vms-color-scheme: dark;
24
35
  }
@@ -23,5 +23,14 @@
23
23
  --vms-priority-high: #e07015;
24
24
  --vms-success: #4dd17a;
25
25
  --vms-info: #4a9eff;
26
+ /* Chart palette (CHARTBASE-02) — same dark-family palette as dark-purple.css. */
27
+ --vms-chart-1: #6fa8ff;
28
+ --vms-chart-2: #ffa552;
29
+ --vms-chart-3: #5ed19a;
30
+ --vms-chart-4: #ff7a90;
31
+ --vms-chart-5: #b79bfa;
32
+ --vms-chart-6: #5cd6e8;
33
+ --vms-chart-7: #d0d661;
34
+ --vms-chart-8: #ef8fd6;
26
35
  --vms-color-scheme: dark;
27
36
  }
@@ -23,5 +23,14 @@
23
23
  --vms-priority-high: #e07015;
24
24
  --vms-success: #4dd17a;
25
25
  --vms-info: #4a9eff;
26
+ /* Chart palette (CHARTBASE-02) — same dark-family palette as dark-purple.css. */
27
+ --vms-chart-1: #6fa8ff;
28
+ --vms-chart-2: #ffa552;
29
+ --vms-chart-3: #5ed19a;
30
+ --vms-chart-4: #ff7a90;
31
+ --vms-chart-5: #b79bfa;
32
+ --vms-chart-6: #5cd6e8;
33
+ --vms-chart-7: #d0d661;
34
+ --vms-chart-8: #ef8fd6;
26
35
  --vms-color-scheme: dark;
27
36
  }
@@ -17,5 +17,14 @@
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
20
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
21
+ --vms-chart-1: #3366cc;
22
+ --vms-chart-2: #cc6b1c;
23
+ --vms-chart-3: #1f8a5f;
24
+ --vms-chart-4: #b3314d;
25
+ --vms-chart-5: #7a4fc9;
26
+ --vms-chart-6: #1f8fa3;
27
+ --vms-chart-7: #767a1f;
28
+ --vms-chart-8: #c23fa0;
20
29
  --vms-color-scheme: light;
21
30
  }
@@ -17,5 +17,14 @@
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
20
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
21
+ --vms-chart-1: #3366cc;
22
+ --vms-chart-2: #cc6b1c;
23
+ --vms-chart-3: #1f8a5f;
24
+ --vms-chart-4: #b3314d;
25
+ --vms-chart-5: #7a4fc9;
26
+ --vms-chart-6: #1f8fa3;
27
+ --vms-chart-7: #767a1f;
28
+ --vms-chart-8: #c23fa0;
20
29
  --vms-color-scheme: light;
21
30
  }
@@ -17,5 +17,14 @@
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
20
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
21
+ --vms-chart-1: #3366cc;
22
+ --vms-chart-2: #cc6b1c;
23
+ --vms-chart-3: #1f8a5f;
24
+ --vms-chart-4: #b3314d;
25
+ --vms-chart-5: #7a4fc9;
26
+ --vms-chart-6: #1f8fa3;
27
+ --vms-chart-7: #767a1f;
28
+ --vms-chart-8: #c23fa0;
20
29
  --vms-color-scheme: light;
21
30
  }
@@ -20,5 +20,14 @@
20
20
  --vms-priority-high:#d76410;
21
21
  --vms-success: #2da359;
22
22
  --vms-info: #2277dd;
23
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
24
+ --vms-chart-1: #3366cc;
25
+ --vms-chart-2: #cc6b1c;
26
+ --vms-chart-3: #1f8a5f;
27
+ --vms-chart-4: #b3314d;
28
+ --vms-chart-5: #7a4fc9;
29
+ --vms-chart-6: #1f8fa3;
30
+ --vms-chart-7: #767a1f;
31
+ --vms-chart-8: #c23fa0;
23
32
  --vms-color-scheme: light;
24
33
  }
@@ -17,5 +17,14 @@
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
20
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
21
+ --vms-chart-1: #3366cc;
22
+ --vms-chart-2: #cc6b1c;
23
+ --vms-chart-3: #1f8a5f;
24
+ --vms-chart-4: #b3314d;
25
+ --vms-chart-5: #7a4fc9;
26
+ --vms-chart-6: #1f8fa3;
27
+ --vms-chart-7: #767a1f;
28
+ --vms-chart-8: #c23fa0;
20
29
  --vms-color-scheme: light;
21
30
  }
@@ -17,5 +17,14 @@
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
20
+ /* Chart palette (CHARTBASE-02) — same light-family palette as default.css. */
21
+ --vms-chart-1: #3366cc;
22
+ --vms-chart-2: #cc6b1c;
23
+ --vms-chart-3: #1f8a5f;
24
+ --vms-chart-4: #b3314d;
25
+ --vms-chart-5: #7a4fc9;
26
+ --vms-chart-6: #1f8fa3;
27
+ --vms-chart-7: #767a1f;
28
+ --vms-chart-8: #c23fa0;
20
29
  --vms-color-scheme: light;
21
30
  }