@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 +2 -0
- package/dist/browser.d.ts +1 -0
- package/dist/browser.js +84 -0
- package/dist/index.d.ts +52 -2
- package/dist/tui.d.ts +14 -0
- package/dist/tui.js +107 -27
- package/package.json +1 -1
- package/styles/default.css +38 -1
- package/styles/themes/light-amber.css +1 -1
- package/styles/themes/light-blue.css +1 -1
- package/styles/themes/light-green.css +1 -1
- package/styles/themes/light-purple.css +1 -1
- package/styles/themes/light-rose.css +1 -1
- package/styles/themes/light-teal.css +1 -1
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
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
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
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 (
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
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", {
|
|
906
|
-
}
|
|
907
|
-
|
|
908
|
-
|
|
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.
|
|
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",
|
package/styles/default.css
CHANGED
|
@@ -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: #
|
|
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
|