@ashley-shrok/viewmodel-shell 0.15.0 → 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/browser.d.ts +20 -9
- package/dist/browser.js +168 -266
- package/dist/index.d.ts +142 -45
- package/dist/index.js +262 -11
- package/dist/server.d.ts +79 -2
- package/dist/server.js +293 -12
- package/dist/tui.d.ts +2 -2
- package/dist/tui.js +62 -97
- package/package.json +1 -1
- package/styles/default.css +27 -9
package/dist/tui.js
CHANGED
|
@@ -159,7 +159,9 @@ export class TuiAdapter {
|
|
|
159
159
|
// (label swap) so server-driven re-renders (success path AND the dispatch-
|
|
160
160
|
// error re-render path) naturally revert any in-flight UI without per-
|
|
161
161
|
// button cleanup wiring.
|
|
162
|
-
render(vm, onAction
|
|
162
|
+
render(vm, onAction,
|
|
163
|
+
// TODO Phase 7: implement bindable input flow for terminal — currently inputs are read-only display
|
|
164
|
+
_stateAccess) {
|
|
163
165
|
if (this.disposed)
|
|
164
166
|
return;
|
|
165
167
|
this.pendingButtonKey = null;
|
|
@@ -298,10 +300,10 @@ export class TuiAdapter {
|
|
|
298
300
|
const c = summary.primaryCheckbox;
|
|
299
301
|
if (c == null || c.action == null)
|
|
300
302
|
return;
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
});
|
|
303
|
+
// Phase 6: action name only — the checked value lives in state at the
|
|
304
|
+
// checkbox's bind path. TUI bindable input flow is TODO for Phase 7;
|
|
305
|
+
// until then this dispatches the action without flipping local state.
|
|
306
|
+
this.pending.onAction({ name: c.action.name });
|
|
305
307
|
}
|
|
306
308
|
}
|
|
307
309
|
/** Teardown — restore terminal cleanly. Idempotent. */
|
|
@@ -902,51 +904,36 @@ function TableView({ node, ctx }) {
|
|
|
902
904
|
const isPaneFocusable = !ctx.modalActive || ctx.insideModal;
|
|
903
905
|
const paneIndex = isPaneFocusable ? ctx.paneCounter.current++ : -1;
|
|
904
906
|
const focused = isPaneFocusable && paneIndex === ctx.focusedPaneIndex;
|
|
905
|
-
//
|
|
906
|
-
//
|
|
907
|
-
//
|
|
907
|
+
// Phase 6 wire-shape: per-column sortActions keyed by column key. TUI input
|
|
908
|
+
// is stubbed until Phase 7; the click dispatches the per-column action by
|
|
909
|
+
// name only — sort intent should be written to state at node.sortBind by a
|
|
910
|
+
// future bindable TUI implementation.
|
|
911
|
+
// TODO Phase 7: implement bindable sort/filter/pagination state writes via stateAccess.
|
|
908
912
|
const onHeaderClick = (columnKey) => {
|
|
909
|
-
|
|
913
|
+
const a = node.sortActions?.[columnKey];
|
|
914
|
+
if (!a)
|
|
910
915
|
return;
|
|
911
|
-
|
|
912
|
-
ctx.onAction({
|
|
913
|
-
name: node.sortAction.name,
|
|
914
|
-
context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
|
|
915
|
-
});
|
|
916
|
+
ctx.onAction({ name: a.name });
|
|
916
917
|
};
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
//
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
const
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
onAction: (action) => {
|
|
930
|
-
const ids = [...effectiveSet];
|
|
931
|
-
ctx.onAction({
|
|
932
|
-
name: action.name,
|
|
933
|
-
context: { ...(action.context ?? {}), selectedIds: ids },
|
|
934
|
-
});
|
|
935
|
-
},
|
|
936
|
-
};
|
|
937
|
-
return _jsx(ButtonView, { node: btn, ctx: harvestCtx }, i);
|
|
938
|
-
}) })) : null, _jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
|
|
939
|
-
const isSorted = node.sortColumn === c.key;
|
|
940
|
-
const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
|
|
941
|
-
// B5 — only sortable headers respond to clicks (matches BrowserAdapter).
|
|
942
|
-
const clickable = c.sortable && node.sortAction != null;
|
|
918
|
+
// Phase 6 removed TableSelection from the framework. Per-row selection now
|
|
919
|
+
// expressed as bound CheckboxNode cells; bulk-action toolbars are plain
|
|
920
|
+
// ButtonNodes. TUI render is reduced accordingly.
|
|
921
|
+
const sel = undefined;
|
|
922
|
+
const effectiveSet = null;
|
|
923
|
+
const allOnPage = false;
|
|
924
|
+
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 */, children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
|
|
925
|
+
// Phase 6 — sortBind holds {column, direction}. TUI display of the
|
|
926
|
+
// sort caret is TODO Phase 7 (would read from stateAccess.read).
|
|
927
|
+
const isSorted = false;
|
|
928
|
+
const caret = isSorted ? " ↑" : "";
|
|
929
|
+
const clickable = c.sortable && node.sortActions?.[c.key] != null;
|
|
943
930
|
const onMouseDown = clickable ? () => onHeaderClick(c.key) : undefined;
|
|
944
931
|
return (_jsxs("text", { attributes: 1 /* BOLD */, ...(onMouseDown ? { onMouseDown } : {}), children: [c.label, caret] }, c.key));
|
|
945
932
|
})] }), 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) => {
|
|
946
|
-
//
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
933
|
+
// Phase 6 — TableRow.action → TableRow.actions[]. Per-row buttons
|
|
934
|
+
// render as ButtonNodes; entire-row click is no longer a row-level
|
|
935
|
+
// concept. Apps that want a clickable row composed via row.actions[].
|
|
936
|
+
const onRowClick = undefined;
|
|
950
937
|
return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
|
|
951
938
|
const isSel = row.id != null && effectiveSet.has(row.id);
|
|
952
939
|
return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", children: isSel ? "[x]" : "[ ]" }));
|
|
@@ -974,13 +961,13 @@ function TableView({ node, ctx }) {
|
|
|
974
961
|
const totalPages = Math.max(1, Math.ceil(pg.totalRows / pg.pageSize));
|
|
975
962
|
const from = pg.totalRows === 0 ? 0 : (pg.page - 1) * pg.pageSize + 1;
|
|
976
963
|
const to = Math.min(pg.page * pg.pageSize, pg.totalRows);
|
|
977
|
-
const
|
|
978
|
-
name: pg.
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
const canPrev = pg.page > 1;
|
|
982
|
-
const canNext = pg.page < totalPages;
|
|
983
|
-
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:
|
|
964
|
+
const goPrev = () => { if (pg.prevAction)
|
|
965
|
+
ctx.onAction({ name: pg.prevAction.name }); };
|
|
966
|
+
const goNext = () => { if (pg.nextAction)
|
|
967
|
+
ctx.onAction({ name: pg.nextAction.name }); };
|
|
968
|
+
const canPrev = pg.page > 1 && pg.prevAction != null;
|
|
969
|
+
const canNext = pg.page < totalPages && pg.nextAction != null;
|
|
970
|
+
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: goPrev } : {}), children: "‹ Prev" }), _jsx("text", { fg: canNext ? "#88aaff" : "#555555", ...(canNext ? { onMouseDown: goNext } : {}), children: "Next ›" })] }));
|
|
984
971
|
})() : null] }) }));
|
|
985
972
|
}
|
|
986
973
|
// ── minimum-viable text surface for the rest of the node set ───────────────
|
|
@@ -1007,19 +994,14 @@ function ButtonView({ node, ctx }) {
|
|
|
1007
994
|
return (_jsxs("text", { ...(fg != null ? { fg } : {}), ...(isPending ? { dimColor: true } : {}), onMouseDown: onMouseDown, children: ["[ ", label, " ]"] }));
|
|
1008
995
|
}
|
|
1009
996
|
function CheckboxView({ node, ctx }) {
|
|
1010
|
-
|
|
1011
|
-
//
|
|
1012
|
-
|
|
1013
|
-
//
|
|
1014
|
-
// the click has no semantic effect — the visual state is server-owned
|
|
1015
|
-
// and only changes on the next server response that flips node.checked.
|
|
997
|
+
// Phase 6 — checkbox.checked removed; value lives in state at node.bind.
|
|
998
|
+
// TUI bindable read is TODO Phase 7; rendering as unchecked until then.
|
|
999
|
+
const glyph = "[ ]";
|
|
1000
|
+
// Click dispatches the action name; bindable write-back is TODO Phase 7.
|
|
1016
1001
|
const onMouseDown = () => {
|
|
1017
1002
|
if (!node.action)
|
|
1018
1003
|
return;
|
|
1019
|
-
ctx.onAction({
|
|
1020
|
-
name: node.action.name,
|
|
1021
|
-
context: { ...(node.action.context ?? {}), checked: !node.checked },
|
|
1022
|
-
});
|
|
1004
|
+
ctx.onAction({ name: node.action.name });
|
|
1023
1005
|
};
|
|
1024
1006
|
return _jsxs("text", { onMouseDown: onMouseDown, children: [glyph, " ", node.label ?? ""] });
|
|
1025
1007
|
}
|
|
@@ -1028,13 +1010,13 @@ function CheckboxView({ node, ctx }) {
|
|
|
1028
1010
|
// value: tab.value }` merged. Selected tab renders bold; unselected tabs
|
|
1029
1011
|
// render dim. Keyboard activation (Tab on focused tab-bar → cycle) is B5.
|
|
1030
1012
|
function TabsView({ node, ctx }) {
|
|
1013
|
+
// Phase 6 — TabsNode.action removed; each tab carries its own unique
|
|
1014
|
+
// action name. The renderer writes tab.value to state at node.bind before
|
|
1015
|
+
// dispatching (TODO Phase 7 — bindable write here is a no-op for now).
|
|
1031
1016
|
return (_jsx("box", { flexDirection: "row", gap: 1, children: node.tabs.map((t) => {
|
|
1032
1017
|
const selected = t.value === node.selected;
|
|
1033
1018
|
const onMouseDown = () => {
|
|
1034
|
-
ctx.onAction({
|
|
1035
|
-
name: node.action.name,
|
|
1036
|
-
context: { ...(node.action.context ?? {}), value: t.value },
|
|
1037
|
-
});
|
|
1019
|
+
ctx.onAction({ name: t.action.name });
|
|
1038
1020
|
};
|
|
1039
1021
|
return (_jsx("text", { onMouseDown: onMouseDown, attributes: selected ? 1 /* BOLD */ : 0, ...(selected ? {} : { fg: "#888888" }), children: t.label }, t.value));
|
|
1040
1022
|
}) }));
|
|
@@ -1109,31 +1091,12 @@ function CopyButtonView({ node, ctx }) {
|
|
|
1109
1091
|
// reads each from the adapter's fieldValues map (falling back to wire), and
|
|
1110
1092
|
// dispatches submitAction with `{ [name]: value, ... }` merged into context.
|
|
1111
1093
|
function FormView({ node, ctx }) {
|
|
1112
|
-
//
|
|
1113
|
-
//
|
|
1114
|
-
//
|
|
1115
|
-
//
|
|
1094
|
+
// Phase 6 — context-assembly removed. Field values live in state at their
|
|
1095
|
+
// bind paths; the server reads them from `state`. The form dispatches just
|
|
1096
|
+
// the action name. TUI form-harvest behavior under the new wire is TODO
|
|
1097
|
+
// Phase 7 (would write each field value to state via stateAccess.write).
|
|
1116
1098
|
const submitFormWith = (base) => {
|
|
1117
|
-
|
|
1118
|
-
const collect = (n) => {
|
|
1119
|
-
if (n.type === "field") {
|
|
1120
|
-
const wireValue = n.value ?? "";
|
|
1121
|
-
// The map may not have an entry for fields the user hasn't touched
|
|
1122
|
-
// (or for hidden fields we register on render). Fall back to the
|
|
1123
|
-
// wire value in that case — the resolveFieldValue plumbing keeps the
|
|
1124
|
-
// two in sync for fields that DID render.
|
|
1125
|
-
const v = ctx.resolveFieldValue(n.name, wireValue);
|
|
1126
|
-
// Checkbox-typed fields submit as boolean, not string.
|
|
1127
|
-
merged[n.name] = n.inputType === "checkbox" ? v === "true" : v;
|
|
1128
|
-
}
|
|
1129
|
-
const children = n.children;
|
|
1130
|
-
if (children)
|
|
1131
|
-
for (const c of children)
|
|
1132
|
-
collect(c);
|
|
1133
|
-
};
|
|
1134
|
-
for (const child of node.children)
|
|
1135
|
-
collect(child);
|
|
1136
|
-
ctx.onAction({ name: base.name, context: merged });
|
|
1099
|
+
ctx.onAction({ name: base.name });
|
|
1137
1100
|
};
|
|
1138
1101
|
// Enter-in-a-field submits the default action — only wired when present.
|
|
1139
1102
|
const submitAction = node.submitAction;
|
|
@@ -1185,7 +1148,13 @@ function FormView({ node, ctx }) {
|
|
|
1185
1148
|
// initialValue=…> — the conformance walker is extended in this phase to
|
|
1186
1149
|
// read those props as user-visible information.
|
|
1187
1150
|
function FieldView({ node, ctx }) {
|
|
1188
|
-
|
|
1151
|
+
// Phase 6 — FieldNode.value removed; current value lives in state at
|
|
1152
|
+
// node.bind. Until TUI bindable input flow ships (Phase 7), the wire value
|
|
1153
|
+
// is treated as empty and the local field-values map remains the source of
|
|
1154
|
+
// displayed text. The `bind` field is recorded so the field can be wired
|
|
1155
|
+
// when stateAccess plumbing arrives.
|
|
1156
|
+
const wireValue = "";
|
|
1157
|
+
void node.bind;
|
|
1189
1158
|
// Resolve through the adapter so draft preservation runs as a side effect
|
|
1190
1159
|
// even for hidden fields (the form needs their wire value at submit time).
|
|
1191
1160
|
const currentValue = ctx.resolveFieldValue(node.name, wireValue);
|
|
@@ -1199,6 +1168,8 @@ function FieldView({ node, ctx }) {
|
|
|
1199
1168
|
const focused = ctx.inFocusedPane && inputIndex === 0;
|
|
1200
1169
|
// Submit handler — common to text/textarea/select. Wired to the parent
|
|
1201
1170
|
// form (if any), else dispatches the field's own action (immediate-dispatch).
|
|
1171
|
+
// Phase 6 — action carries name only; bindable write of latestValue to
|
|
1172
|
+
// state at node.bind is TODO Phase 7.
|
|
1202
1173
|
const handleSubmit = (latestValue) => {
|
|
1203
1174
|
if (latestValue !== undefined)
|
|
1204
1175
|
ctx.setFieldValue(node.name, latestValue);
|
|
@@ -1207,13 +1178,7 @@ function FieldView({ node, ctx }) {
|
|
|
1207
1178
|
return;
|
|
1208
1179
|
}
|
|
1209
1180
|
if (node.action) {
|
|
1210
|
-
ctx.onAction({
|
|
1211
|
-
name: node.action.name,
|
|
1212
|
-
context: {
|
|
1213
|
-
...(node.action.context ?? {}),
|
|
1214
|
-
[node.name]: latestValue ?? ctx.resolveFieldValue(node.name, wireValue),
|
|
1215
|
-
},
|
|
1216
|
-
});
|
|
1181
|
+
ctx.onAction({ name: node.action.name });
|
|
1217
1182
|
}
|
|
1218
1183
|
};
|
|
1219
1184
|
// ── textarea / code: multi-line editor ─────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.1",
|
|
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
|
@@ -133,6 +133,15 @@ body {
|
|
|
133
133
|
.vms-page--wide { max-width: var(--vms-page-max-wide); }
|
|
134
134
|
.vms-page--full { max-width: none; }
|
|
135
135
|
|
|
136
|
+
/* ── Section ── */
|
|
137
|
+
.vms-section { display: flex; flex-direction: column; gap: var(--vms-space-sm); }
|
|
138
|
+
.vms-section__heading {
|
|
139
|
+
font-size: var(--vms-text-xs);
|
|
140
|
+
letter-spacing: 0.08em;
|
|
141
|
+
text-transform: uppercase;
|
|
142
|
+
color: var(--vms-text-muted);
|
|
143
|
+
}
|
|
144
|
+
|
|
136
145
|
/* ── Layout preset: cards (LAYOUT-03, D-04) ──
|
|
137
146
|
auto-fit intrinsically collapses toward 1 column as the container narrows;
|
|
138
147
|
min(...,100%) floor prevents single-column overflow on a narrow viewport
|
|
@@ -168,6 +177,12 @@ body {
|
|
|
168
177
|
grid-column: 1 / -1;
|
|
169
178
|
}
|
|
170
179
|
|
|
180
|
+
/* Grid items default to min-width: auto; setting min-width: 0 lets wide media (full-width images, long text) shrink to the track instead of blowing it out. */
|
|
181
|
+
.vms-page--cards > *,
|
|
182
|
+
.vms-section--cards > *,
|
|
183
|
+
.vms-page--split > *,
|
|
184
|
+
.vms-section--split > * { min-width: 0; }
|
|
185
|
+
|
|
171
186
|
/* ── Layout preset: sidebar (D-28) — thin + wide app shell ──
|
|
172
187
|
The common real-app shell: a narrow first column + a wide main that
|
|
173
188
|
grows to fill. Flex-wrap "holy grail": main grows aggressively so the
|
|
@@ -194,15 +209,6 @@ body {
|
|
|
194
209
|
.vms-page--sidebar > .vms-page__title,
|
|
195
210
|
.vms-section--sidebar > .vms-section__heading { flex: 0 0 100%; }
|
|
196
211
|
|
|
197
|
-
/* ── Section ── */
|
|
198
|
-
.vms-section { display: flex; flex-direction: column; gap: var(--vms-space-sm); }
|
|
199
|
-
.vms-section__heading {
|
|
200
|
-
font-size: var(--vms-text-xs);
|
|
201
|
-
letter-spacing: 0.08em;
|
|
202
|
-
text-transform: uppercase;
|
|
203
|
-
color: var(--vms-text-muted);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
212
|
/* ── Section variant: card (THEME-04) — grouped surface, existing seam vars ── */
|
|
207
213
|
.vms-section--card {
|
|
208
214
|
background: var(--vms-surface);
|
|
@@ -649,6 +655,18 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
|
|
|
649
655
|
.vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
|
|
650
656
|
.vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
|
|
651
657
|
|
|
658
|
+
/* 0.16.0 — `.vms-busy` is applied by BrowserAdapter when the shell is in a
|
|
659
|
+
busy state (a user-initiated dispatch is in flight OR the server returned
|
|
660
|
+
ShellResponse.busy: true). cursor: wait communicates the state; pointer-
|
|
661
|
+
events: none on interactive descendants makes the lock honest — rapid
|
|
662
|
+
clicks during a round-trip never visually flip checkboxes / depress buttons
|
|
663
|
+
because the click never reaches them. Polls (silent dispatches) bypass the
|
|
664
|
+
class so a long-running action stays continuously locked across many ticks
|
|
665
|
+
without flickering. */
|
|
666
|
+
.vms-busy { cursor: wait; }
|
|
667
|
+
.vms-busy a, .vms-busy button, .vms-busy input, .vms-busy textarea,
|
|
668
|
+
.vms-busy select, .vms-busy [role="button"], .vms-busy [data-id] { pointer-events: none; }
|
|
669
|
+
|
|
652
670
|
/* ── Recommended error-banner pattern ──
|
|
653
671
|
Apps typically render this from the `onError` callback. Not emitted by
|
|
654
672
|
the framework; included here so apps can use the convention without
|