@ashley-shrok/viewmodel-shell 0.10.0 → 0.12.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/README.md CHANGED
@@ -51,6 +51,8 @@ The download endpoint stays auth-gated and the server authorizes in the action h
51
51
 
52
52
  ## Terminal (TUI)
53
53
 
54
+ > ⚠️ **Experimental.** The terminal adapter (`@ashley-shrok/viewmodel-shell/tui` + `vms-tui`) is incomplete and under active design — scrolling, keyboard/focus ergonomics, and layout coverage all need more work. Its API and behavior may change or be removed **without a major-version bump**; don't build production workflows on it yet. The browser/server/core packages are stable and unaffected. Constructing a `TuiAdapter` prints a one-time notice — silence it with `VMS_TUI_SILENCE_EXPERIMENTAL=1`.
55
+
54
56
  The same backend renders in a terminal — same wire, no backend change, with a real lazygit-style UX: mouse clicks, wheel scroll, per-pane focus cycle, and a context-aware status bar. Point the CLI at any ViewModel Shell endpoint:
55
57
 
56
58
  ```bash
package/dist/browser.d.ts CHANGED
@@ -31,6 +31,7 @@ export declare class BrowserAdapter implements Adapter {
31
31
  private link;
32
32
  private statBar;
33
33
  private tabs;
34
+ private image;
34
35
  private progress;
35
36
  private modal;
36
37
  private table;
package/dist/browser.js CHANGED
@@ -190,6 +190,7 @@ export class BrowserAdapter {
190
190
  case "button": return this.button(n, parent, on);
191
191
  case "text": return this.text(n, parent);
192
192
  case "link": return this.link(n, parent);
193
+ case "image": return this.image(n, parent);
193
194
  case "stat-bar": return this.statBar(n, parent);
194
195
  case "tabs": return this.tabs(n, parent, on);
195
196
  case "progress": return this.progress(n, parent);
@@ -569,6 +570,19 @@ export class BrowserAdapter {
569
570
  });
570
571
  parent.appendChild(nav);
571
572
  }
573
+ image(n, parent) {
574
+ const img = document.createElement("img");
575
+ let cls = "vms-image";
576
+ if (n.size)
577
+ cls += ` vms-image--${n.size}`;
578
+ if (n.shape)
579
+ cls += ` vms-image--${n.shape}`;
580
+ img.className = cls;
581
+ img.src = n.src;
582
+ if (n.alt != null)
583
+ img.alt = n.alt;
584
+ parent.appendChild(img);
585
+ }
572
586
  progress(n, parent) {
573
587
  const track = document.createElement("div");
574
588
  track.className = "vms-progress";
@@ -623,6 +637,25 @@ export class BrowserAdapter {
623
637
  table.className = "vms-table";
624
638
  const thead = document.createElement("thead");
625
639
  const headerRow = document.createElement("tr");
640
+ // Selection: a leading checkbox column. The header box is a select-all over
641
+ // the rows CURRENTLY rendered (the current page) — never unloaded rows.
642
+ const sel = n.selection;
643
+ const selectedSet = sel ? new Set(sel.selectedIds) : null;
644
+ if (sel) {
645
+ const th = document.createElement("th");
646
+ th.className = "vms-table__th vms-table__th--select";
647
+ const allOnPage = n.rows.length > 0 && n.rows.every(r => r.id != null && selectedSet.has(r.id));
648
+ const someOnPage = n.rows.some(r => r.id != null && selectedSet.has(r.id));
649
+ const box = document.createElement("input");
650
+ box.type = "checkbox";
651
+ box.className = "vms-table__select vms-table__select--all";
652
+ box.checked = allOnPage;
653
+ box.indeterminate = someOnPage && !allOnPage;
654
+ const selAction = sel.action;
655
+ box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } }));
656
+ th.appendChild(box);
657
+ headerRow.appendChild(th);
658
+ }
626
659
  n.columns.forEach(col => {
627
660
  const th = document.createElement("th");
628
661
  const isSorted = col.key === n.sortColumn;
@@ -651,6 +684,8 @@ export class BrowserAdapter {
651
684
  const filterAction = n.filterAction;
652
685
  const filterRow = document.createElement("tr");
653
686
  filterRow.className = "vms-table__filter-row";
687
+ if (sel)
688
+ filterRow.appendChild(document.createElement("th")); // align under the select column
654
689
  n.columns.forEach(col => {
655
690
  const th = document.createElement("th");
656
691
  if (col.filterable) {
@@ -684,6 +719,9 @@ export class BrowserAdapter {
684
719
  rowClass += ` vms-table__row--${row.variant}`;
685
720
  if (row.action)
686
721
  rowClass += " vms-table__row--clickable";
722
+ const isSelected = sel != null && row.id != null && selectedSet.has(row.id);
723
+ if (isSelected)
724
+ rowClass += " vms-table__row--selected";
687
725
  tr.className = rowClass;
688
726
  if (row.id)
689
727
  tr.dataset.id = row.id;
@@ -691,6 +729,26 @@ export class BrowserAdapter {
691
729
  const rowAction = row.action;
692
730
  tr.addEventListener("click", () => on(rowAction));
693
731
  }
732
+ if (sel) {
733
+ const td = document.createElement("td");
734
+ td.className = "vms-table__td vms-table__td--select";
735
+ // A click in the checkbox cell must not also fire the row's click action.
736
+ td.addEventListener("click", (e) => e.stopPropagation());
737
+ const box = document.createElement("input");
738
+ box.type = "checkbox";
739
+ box.className = "vms-table__select";
740
+ box.checked = isSelected;
741
+ if (row.id != null) {
742
+ const rowId = row.id;
743
+ const selAction = sel.action;
744
+ box.addEventListener("change", () => on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } }));
745
+ }
746
+ else {
747
+ box.disabled = true; // selection addresses rows by id; a row without one can't be selected
748
+ }
749
+ td.appendChild(box);
750
+ tr.appendChild(td);
751
+ }
694
752
  n.columns.forEach(col => {
695
753
  const td = document.createElement("td");
696
754
  td.className = "vms-table__td";
@@ -715,6 +773,32 @@ export class BrowserAdapter {
715
773
  });
716
774
  table.appendChild(tbody);
717
775
  wrapper.appendChild(table);
776
+ if (n.pagination) {
777
+ const pg = n.pagination;
778
+ const footer = document.createElement("div");
779
+ footer.className = "vms-table__pagination";
780
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
781
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
782
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
783
+ const range = document.createElement("span");
784
+ range.className = "vms-table__pagination-range";
785
+ range.textContent = `${from}–${to} of ${pg.totalRows}`;
786
+ footer.appendChild(range);
787
+ const pgAction = pg.action;
788
+ const mkBtn = (label, targetPage, disabled) => {
789
+ const b = document.createElement("button");
790
+ b.type = "button";
791
+ b.className = "vms-button vms-button--secondary vms-table__pagination-btn";
792
+ b.textContent = label;
793
+ b.disabled = disabled;
794
+ if (!disabled)
795
+ b.addEventListener("click", () => on({ name: pgAction.name, context: { ...(pgAction.context ?? {}), page: targetPage } }));
796
+ return b;
797
+ };
798
+ footer.appendChild(mkBtn("‹ Prev", pg.page - 1, pg.page <= 1));
799
+ footer.appendChild(mkBtn("Next ›", pg.page + 1, pg.page >= totalPages));
800
+ wrapper.appendChild(footer);
801
+ }
718
802
  parent.appendChild(wrapper);
719
803
  }
720
804
  copyButton(n, parent) {
package/dist/index.d.ts CHANGED
@@ -33,7 +33,7 @@ export interface Adapter {
33
33
  * async I/O errors surface via onError. */
34
34
  saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
35
35
  }
36
- export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
36
+ export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
37
37
  export interface PageNode {
38
38
  type: "page";
39
39
  title?: string;
@@ -131,7 +131,7 @@ export interface ButtonNode {
131
131
  export interface TextNode {
132
132
  type: "text";
133
133
  value: string;
134
- style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "pre";
134
+ style?: "heading" | "subheading" | "body" | "muted" | "strikethrough" | "error" | "warning" | "pre";
135
135
  }
136
136
  export interface LinkNode {
137
137
  type: "link";
@@ -140,6 +140,18 @@ export interface LinkNode {
140
140
  /** true = open outside current app context (browser: new tab + noopener) */
141
141
  external?: boolean;
142
142
  }
143
+ export interface ImageNode {
144
+ type: "image";
145
+ /** Image source URL (required). */
146
+ src: string;
147
+ /** Accessibility text. Non-browser adapters (TUI) degrade to this. */
148
+ alt?: string;
149
+ /** Design-system sizing hint → `.vms-image--{size}`. Omit for intrinsic size
150
+ * (capped at 100% of the container). NOT free-form CSS. */
151
+ size?: "small" | "medium" | "large" | "full";
152
+ /** `"circle"` → square-cropped circular image (avatars). */
153
+ shape?: "circle";
154
+ }
143
155
  export interface StatBarNode {
144
156
  type: "stat-bar";
145
157
  stats: Array<{
@@ -190,6 +202,32 @@ export interface TableRow {
190
202
  action?: ActionEvent;
191
203
  variant?: string;
192
204
  }
205
+ export interface TableSelection {
206
+ /** Row ids currently selected — server-truth. The adapter checks each row
207
+ * whose `id` is in this list and emits `.vms-table__row--selected` on it.
208
+ * Selection survives sort/filter/pagination because it round-trips in state,
209
+ * independent of which rows are currently in `rows`. */
210
+ selectedIds: string[];
211
+ /** Dispatched on a selection toggle. The adapter merges `{ id, checked }` for
212
+ * a per-row checkbox, or `{ all: true, checked }` for the header select-all
213
+ * checkbox (where "all" means the rows currently rendered — i.e. the current
214
+ * page, never unloaded rows). The "select all N matching" affordance, when an
215
+ * app wants it, is the app's own node composed above the table — the
216
+ * framework gives the primitive, not the policy. */
217
+ action: ActionEvent;
218
+ }
219
+ export interface TablePagination {
220
+ /** 1-based current page. */
221
+ page: number;
222
+ /** Rows per page. Drives the "X–Y of N" range label and the last-page calc. */
223
+ pageSize: number;
224
+ /** Total rows across all pages — server-truth. The adapter renders the range
225
+ * label and enables/disables prev/next from this; it does NOT slice. */
226
+ totalRows: number;
227
+ /** Dispatched on a page-control click. The adapter merges `{ page }` — the
228
+ * target 1-based page. */
229
+ action: ActionEvent;
230
+ }
193
231
  export interface TableNode {
194
232
  type: "table";
195
233
  columns: TableColumn[];
@@ -200,6 +238,18 @@ export interface TableNode {
200
238
  sortAction?: ActionEvent;
201
239
  /** Base action. Adapter merges { column, value, filters } into context on Enter. */
202
240
  filterAction?: ActionEvent;
241
+ /** Per-row multi-select. When set, the adapter renders a leading checkbox
242
+ * column + a header select-all checkbox and tints selected rows. `TableRow.id`
243
+ * is REQUIRED on every row when selection is set — it's the address the
244
+ * toggle action reports back. */
245
+ selection?: TableSelection;
246
+ /** Server-driven pagination. When set, the adapter renders an "X–Y of N"
247
+ * range + prev/next controls below the table. **The server slices `rows` to
248
+ * the current page** — the adapter never paginates client-side (that would
249
+ * break for DB-backed tables, which are most of them). By convention
250
+ * `sortAction` / `filterAction` reset `page` to 1 on the server side, since
251
+ * the row window changes underneath them. */
252
+ pagination?: TablePagination;
203
253
  }
204
254
  export interface CopyButtonNode {
205
255
  type: "copy-button";
package/dist/tui.d.ts CHANGED
@@ -8,6 +8,16 @@ interface TuiOpts {
8
8
  * of the available width; the remainder fills with the rest of the children. */
9
9
  sidebarFraction?: number;
10
10
  }
11
+ /**
12
+ * Renders a ViewModel Shell view tree to a terminal via OpenTUI (Bun runtime).
13
+ *
14
+ * @experimental The terminal adapter is incomplete and under active design —
15
+ * scrolling, keyboard/focus ergonomics, and layout coverage all need more
16
+ * work. Its API and behavior may change or be removed without a major-version
17
+ * bump. Constructing one prints a one-time stderr notice (silence with
18
+ * `VMS_TUI_SILENCE_EXPERIMENTAL=1`). The browser/server/core packages are
19
+ * stable; only `@ashley-shrok/viewmodel-shell/tui` + `vms-tui` are experimental.
20
+ */
11
21
  export declare class TuiAdapter implements Adapter {
12
22
  private renderer;
13
23
  private root;
@@ -74,5 +84,9 @@ export declare class TuiAdapter implements Adapter {
74
84
  * landed (parity with the Ink adapter's _peekSession). */
75
85
  _peekSession(key: string): string | undefined;
76
86
  }
87
+ /**
88
+ * @experimental Part of the experimental terminal target (see {@link TuiAdapter}).
89
+ * A static, non-mounting render path used by the cross-adapter conformance suite.
90
+ */
77
91
  export declare function renderTree(vm: ViewNode): React.ReactNode;
78
92
  export {};
package/dist/tui.js CHANGED
@@ -3,6 +3,34 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { spawn } from "node:child_process";
6
+ // ─── Experimental notice ───────────────────────────────────────────────────
7
+ // The terminal adapter is EXPERIMENTAL (see the @experimental tag on
8
+ // TuiAdapter). We emit a one-time stderr heads-up the first time a TuiAdapter
9
+ // is constructed in a process — covering both `vms-tui` (which constructs one)
10
+ // and programmatic consumers. Fires once per process; silence with
11
+ // VMS_TUI_SILENCE_EXPERIMENTAL=1 for deliberate users who don't want the nag.
12
+ let experimentalNoticeShown = false;
13
+ function warnExperimental() {
14
+ if (experimentalNoticeShown)
15
+ return;
16
+ experimentalNoticeShown = true;
17
+ if (process.env.VMS_TUI_SILENCE_EXPERIMENTAL)
18
+ return;
19
+ process.stderr.write("[vms-tui] ⚠ The terminal adapter (TuiAdapter) is EXPERIMENTAL: incomplete, " +
20
+ "under-tested, and subject to breaking change or removal without a major-version " +
21
+ "bump. Not recommended for production. The browser/server/core packages are " +
22
+ "stable and unaffected. Silence this notice with VMS_TUI_SILENCE_EXPERIMENTAL=1.\n");
23
+ }
24
+ /**
25
+ * Renders a ViewModel Shell view tree to a terminal via OpenTUI (Bun runtime).
26
+ *
27
+ * @experimental The terminal adapter is incomplete and under active design —
28
+ * scrolling, keyboard/focus ergonomics, and layout coverage all need more
29
+ * work. Its API and behavior may change or be removed without a major-version
30
+ * bump. Constructing one prints a one-time stderr notice (silence with
31
+ * `VMS_TUI_SILENCE_EXPERIMENTAL=1`). The browser/server/core packages are
32
+ * stable; only `@ashley-shrok/viewmodel-shell/tui` + `vms-tui` are experimental.
33
+ */
6
34
  export class TuiAdapter {
7
35
  renderer = null;
8
36
  root = null;
@@ -38,6 +66,7 @@ export class TuiAdapter {
38
66
  // pending state, no per-button cleanup wiring needed).
39
67
  pendingButtonKey = null;
40
68
  constructor(opts) {
69
+ warnExperimental();
41
70
  this.viewport = opts?.viewport ?? "fill";
42
71
  const f = opts?.sidebarFraction ?? 1 / 3;
43
72
  this.sidebarFraction = Math.min(0.6, Math.max(0.15, f));
@@ -672,6 +701,7 @@ function renderNode(node, ctx, key) {
672
701
  case "section": return _jsx(SectionView, { node: node, ctx: ctx }, key);
673
702
  case "text": return _jsx(TextView, { node: node }, key);
674
703
  case "link": return _jsx(LinkView, { node: node, ctx: ctx }, key);
704
+ case "image": return _jsx(ImageView, { node: node }, key);
675
705
  case "list": return _jsx(ListView, { node: node, ctx: ctx }, key);
676
706
  case "list-item": return _jsx(ListItemView, { node: node, ctx: ctx }, key);
677
707
  case "table": return _jsx(TableView, { node: node, ctx: ctx }, key);
@@ -775,6 +805,7 @@ const STYLE_ATTRS = {
775
805
  muted: { fg: "#888888" },
776
806
  strikethrough: { attributes: 16 /* STRIKETHROUGH */, fg: "#888888" },
777
807
  error: { fg: "#ff5555" },
808
+ warning: { fg: "#e0a823" },
778
809
  pre: { fg: "#cccccc" },
779
810
  };
780
811
  function TextView({ node }) {
@@ -804,6 +835,14 @@ function LinkView({ node, ctx }) {
804
835
  : undefined;
805
836
  return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(onMouseDown ? { onMouseDown } : {}), children: inner }));
806
837
  }
838
+ // ── image ─────────────────────────────────────────────────────────────────
839
+ // Terminals can't render raster images, so the TUI degrades to the alt text
840
+ // (the wire's accessibility intent) — the multi-target-safe contract from the
841
+ // ImageNode design. size/shape are browser-only layout hints and are ignored.
842
+ function ImageView({ node }) {
843
+ const alt = node.alt && node.alt.trim().length > 0 ? node.alt : "image";
844
+ return _jsxs("text", { fg: "#888888", children: ["[image: ", alt, "]"] });
845
+ }
807
846
  // ── list / list-item ──────────────────────────────────────────────────────
808
847
  // A top-level `list` (direct child of page) is a pane on its own; nested
809
848
  // lists scroll as part of their containing section. list-item variants
@@ -875,38 +914,75 @@ function TableView({ node, ctx }) {
875
914
  context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
876
915
  });
877
916
  };
878
- return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => {
879
- const isSorted = node.sortColumn === c.key;
880
- const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
881
- // B5 only sortable headers respond to clicks (matches BrowserAdapter).
882
- const clickable = c.sortable && node.sortAction != null;
883
- const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
884
- return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
885
- }) }), node.columns.some((c) => c.filterable) ? (_jsx("box", { flexDirection: "row", gap: 2, children: node.columns.map((c) => (_jsx("text", { fg: "#888888", children: c.filterable ? (c.filterValue ? `[${c.filterValue}]` : "[filter]") : "" }, c.key))) })) : null, node.rows.map((row, ri) => {
917
+ // Selection leading [x]/[ ] column. Dispatch payloads match BrowserAdapter:
918
+ // { id, checked } per row, { all: true, checked } for select-all.
919
+ const sel = node.selection;
920
+ const selectedSet = sel ? new Set(sel.selectedIds) : null;
921
+ const allOnPage = sel != null && node.rows.length > 0 &&
922
+ node.rows.every((r) => r.id != null && selectedSet.has(r.id));
923
+ const onToggleAll = sel
924
+ ? () => ctx.onAction({
925
+ name: sel.action.name,
926
+ context: { ...(sel.action.context ?? {}), all: true, checked: !allOnPage },
927
+ })
928
+ : undefined;
929
+ const onToggleRow = sel
930
+ ? (rowId, checked) => ctx.onAction({
931
+ name: sel.action.name,
932
+ context: { ...(sel.action.context ?? {}), id: rowId, checked },
933
+ })
934
+ : undefined;
935
+ return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [_jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, ...(onToggleAll ? { onMouseDown: onToggleAll } : {}), children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
936
+ const isSorted = node.sortColumn === c.key;
937
+ const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
938
+ // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
939
+ const clickable = c.sortable && node.sortAction != null;
940
+ const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
941
+ return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
942
+ })] }), node.columns.some((c) => c.filterable) ? (_jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? _jsx("text", { fg: "#888888", children: " " }) : null, node.columns.map((c) => (_jsx("text", { fg: "#888888", children: c.filterable ? (c.filterValue ? `[${c.filterValue}]` : "[filter]") : "" }, c.key)))] })) : null, node.rows.map((row, ri) => {
886
943
  // B5 — row click dispatches row.action when present.
887
944
  const onRowClick = row.action
888
945
  ? () => ctx.onAction(row.action)
889
946
  : undefined;
890
- return (_jsx("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: node.columns.map((c) => {
891
- const cell = row.cells[c.key] ?? "";
892
- if (c.linkLabel != null && cell.length > 0) {
893
- // Cell is a link emit OSC-8 for external links, plain
894
- // underlined text otherwise. External links are clickable
895
- // natively via the terminal's OSC-8 support; internal cell
896
- // links route through navigate (B5).
897
- const ESC = String.fromCharCode(27);
898
- const BEL = String.fromCharCode(7);
899
- const inner = c.linkExternal
900
- ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
901
- : c.linkLabel;
902
- const cellClick = !c.linkExternal && cell.length > 0
903
- ? () => ctx.navigate(cell)
947
+ return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
948
+ const isSel = row.id != null && selectedSet.has(row.id);
949
+ const rowId = row.id;
950
+ const onBox = rowId != null && onToggleRow
951
+ ? () => onToggleRow(rowId, !isSel)
904
952
  : undefined;
905
- return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
906
- }
907
- return _jsx("text", { children: cell }, c.key);
908
- }) }, row.id ?? ri));
909
- })] }) }));
953
+ return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", ...(onBox ? { onMouseDown: onBox } : {}), children: isSel ? "[x]" : "[ ]" }));
954
+ })() : null, node.columns.map((c) => {
955
+ const cell = row.cells[c.key] ?? "";
956
+ if (c.linkLabel != null && cell.length > 0) {
957
+ // Cell is a link — emit OSC-8 for external links, plain
958
+ // underlined text otherwise. External links are clickable
959
+ // natively via the terminal's OSC-8 support; internal cell
960
+ // links route through navigate (B5).
961
+ const ESC = String.fromCharCode(27);
962
+ const BEL = String.fromCharCode(7);
963
+ const inner = c.linkExternal
964
+ ? `${ESC}]8;;${cell}${BEL}${c.linkLabel}${ESC}]8;;${BEL}`
965
+ : c.linkLabel;
966
+ const cellClick = !c.linkExternal && cell.length > 0
967
+ ? () => ctx.navigate(cell)
968
+ : undefined;
969
+ return (_jsx("text", { attributes: 4 /* UNDERLINE */, fg: "#6688cc", ...(cellClick ? { onMouseDown: cellClick } : {}), children: inner }, c.key));
970
+ }
971
+ return _jsx("text", { children: cell }, c.key);
972
+ })] }, row.id ?? ri));
973
+ }), node.pagination ? (() => {
974
+ const pg = node.pagination;
975
+ const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
976
+ const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
977
+ const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
978
+ const go = (p) => ctx.onAction({
979
+ name: pg.action.name,
980
+ context: { ...(pg.action.context ?? {}), page: p },
981
+ });
982
+ const canPrev = pg.page > 1;
983
+ const canNext = pg.page < totalPages;
984
+ return (_jsxs("box", { flexDirection: "row", gap: 2, children: [_jsx("text", { fg: "#888888", children: `${from}–${to} of ${pg.totalRows}` }), _jsx("text", { fg: canPrev ? "#88aaff" : "#555555", ...(canPrev ? { onMouseDown: () => go(pg.page - 1) } : {}), children: "‹ Prev" }), _jsx("text", { fg: canNext ? "#88aaff" : "#555555", ...(canNext ? { onMouseDown: () => go(pg.page + 1) } : {}), children: "Next ›" })] }));
985
+ })() : null] }) }));
910
986
  }
911
987
  // ── minimum-viable text surface for the rest of the node set ───────────────
912
988
  // Same as B1 — text only, no interactivity. Full widgets land in B3/B4.
@@ -1202,6 +1278,10 @@ function UnsupportedView({ type }) {
1202
1278
  // test/conformance.tui.test.ts) invokes the components directly. Note that
1203
1279
  // the App component is hooks-free — focus state is passed via props with a
1204
1280
  // safe default — so the walker works without a React reconciler.
1281
+ /**
1282
+ * @experimental Part of the experimental terminal target (see {@link TuiAdapter}).
1283
+ * A static, non-mounting render path used by the cross-adapter conformance suite.
1284
+ */
1205
1285
  export function renderTree(vm) {
1206
1286
  return _jsx(App, { vm: vm, onAction: () => { } });
1207
1287
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.10.0",
3
+ "version": "0.12.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",
@@ -28,12 +28,15 @@
28
28
  --vms-done-text: #9090a0;
29
29
  --vms-error: #c2453d;
30
30
  --vms-error-glow: rgba(194, 69, 61, 0.10);
31
- --vms-warning: #a37510;
31
+ --vms-warning: #8a630d;
32
32
  --vms-priority-high: #d76410;
33
33
  --vms-success: #2da359;
34
34
  --vms-info: #2277dd;
35
35
  --vms-radius: 10px;
36
36
  --vms-radius-sm: 6px;
37
+ --vms-image-small: 4rem;
38
+ --vms-image-medium: 8rem;
39
+ --vms-image-large: 16rem;
37
40
  --vms-font-body: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
38
41
  --vms-font-head: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
39
42
  --vms-font-mono: ui-monospace, 'Cascadia Code', 'Fira Code', Menlo, Consolas, 'Liberation Mono', monospace;
@@ -458,6 +461,7 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
458
461
  .vms-text--muted { color: var(--vms-text-muted); font-size: var(--vms-text-sm); }
459
462
  .vms-text--strikethrough { color: var(--vms-done-text); text-decoration: line-through; }
460
463
  .vms-text--error { color: var(--vms-error); font-size: var(--vms-text-base); }
464
+ .vms-text--warning { color: var(--vms-warning); font-size: var(--vms-text-base); }
461
465
  .vms-text--pre { font-family: var(--vms-font-mono); white-space: pre; }
462
466
 
463
467
  /* ── Link ── */
@@ -473,6 +477,15 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
473
477
  }
474
478
  .vms-link:hover { border-bottom-color: var(--vms-accent); }
475
479
 
480
+ /* ── Image ── */
481
+ .vms-image { max-width: 100%; height: auto; display: block; }
482
+ .vms-image--small { width: var(--vms-image-small); }
483
+ .vms-image--medium { width: var(--vms-image-medium); }
484
+ .vms-image--large { width: var(--vms-image-large); }
485
+ .vms-image--full { width: 100%; }
486
+ /* Avatars: square-crop to a circle (object-fit avoids distortion on non-square sources). */
487
+ .vms-image--circle { border-radius: 50%; aspect-ratio: 1; object-fit: cover; }
488
+
476
489
  /* ── Progress ── */
477
490
  .vms-progress { height: 3px; background: var(--vms-surface-2); border-radius: 99px; overflow: hidden; }
478
491
  .vms-progress__bar { height: 100%; background: var(--vms-accent); border-radius: 99px; transition: width 0.3s ease; }
@@ -601,6 +614,30 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
601
614
  .vms-table__link { color: var(--vms-accent); text-decoration: none; }
602
615
  .vms-table__link:hover { text-decoration: underline; }
603
616
 
617
+ /* Selection — leading checkbox column + selected-row tint. The tint derives
618
+ from --vms-accent via color-mix, so a custom :root theme recolors it
619
+ automatically (same pattern as the status variants above). Selected wins
620
+ over the clickable-hover background so a selected row stays legible. */
621
+ .vms-table__th--select,
622
+ .vms-table__td--select { width: 1%; white-space: nowrap; text-align: center; padding-right: 0; }
623
+ .vms-table__select { accent-color: var(--vms-accent); cursor: pointer; }
624
+ .vms-table__row--selected { background: color-mix(in srgb, var(--vms-accent) 12%, transparent); }
625
+ .vms-table__row--selected.vms-table__row--clickable:hover { background: color-mix(in srgb, var(--vms-accent) 18%, transparent); }
626
+
627
+ /* Pagination footer — range label + prev/next, separated from the table by a
628
+ hairline. Buttons reuse .vms-button styling; only sizing/disabled is added. */
629
+ .vms-table__pagination {
630
+ display: flex;
631
+ align-items: center;
632
+ gap: var(--vms-space-sm);
633
+ padding-top: var(--vms-space-sm);
634
+ margin-top: var(--vms-space-xs);
635
+ border-top: 1px solid var(--vms-border);
636
+ }
637
+ .vms-table__pagination-range { color: var(--vms-text-muted); font-size: var(--vms-text-sm); margin-right: auto; }
638
+ .vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
639
+ .vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
640
+
604
641
  /* ── Recommended error-banner pattern ──
605
642
  Apps typically render this from the `onError` callback. Not emitted by
606
643
  the framework; included here so apps can use the convention without
@@ -13,7 +13,7 @@
13
13
  --vms-done-text: #9090a0;
14
14
  --vms-error: #c2453d;
15
15
  --vms-error-glow: rgba(194, 69, 61, 0.10);
16
- --vms-warning: #c89610;
16
+ --vms-warning: #8a630d;
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
@@ -13,7 +13,7 @@
13
13
  --vms-done-text: #9090a0;
14
14
  --vms-error: #c2453d;
15
15
  --vms-error-glow: rgba(194, 69, 61, 0.10);
16
- --vms-warning: #c89610;
16
+ --vms-warning: #8a630d;
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
@@ -13,7 +13,7 @@
13
13
  --vms-done-text: #9090a0;
14
14
  --vms-error: #c2453d;
15
15
  --vms-error-glow: rgba(194, 69, 61, 0.10);
16
- --vms-warning: #c89610;
16
+ --vms-warning: #8a630d;
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
@@ -16,7 +16,7 @@
16
16
  --vms-done-text: #9090a0;
17
17
  --vms-error: #c2453d;
18
18
  --vms-error-glow: rgba(194, 69, 61, 0.10);
19
- --vms-warning: #c89610;
19
+ --vms-warning: #8a630d;
20
20
  --vms-priority-high:#d76410;
21
21
  --vms-success: #2da359;
22
22
  --vms-info: #2277dd;
@@ -13,7 +13,7 @@
13
13
  --vms-done-text: #9090a0;
14
14
  --vms-error: #c2453d;
15
15
  --vms-error-glow: rgba(194, 69, 61, 0.10);
16
- --vms-warning: #c89610;
16
+ --vms-warning: #8a630d;
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;
@@ -13,7 +13,7 @@
13
13
  --vms-done-text: #9090a0;
14
14
  --vms-error: #c2453d;
15
15
  --vms-error-glow: rgba(194, 69, 61, 0.10);
16
- --vms-warning: #c89610;
16
+ --vms-warning: #8a630d;
17
17
  --vms-priority-high:#d76410;
18
18
  --vms-success: #2da359;
19
19
  --vms-info: #2277dd;