@ashley-shrok/viewmodel-shell 0.16.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/server.js CHANGED
@@ -8,6 +8,172 @@
8
8
  // Re-export the ViewNode hierarchy and wire types so a backend can import
9
9
  // everything it needs from one place.
10
10
  export * from "./index.js";
11
+ /**
12
+ * Walk a ViewNode tree and assert that every dispatch-bearing action name names
13
+ * exactly one operation. Two occurrences are considered "the same operation"
14
+ * iff they share the same enclosing FormNode reference; otherwise a duplicate
15
+ * action name is a violation.
16
+ *
17
+ * Call this from your GET handler before returning the initial response if you
18
+ * want the same protection at initial-load time — the action-handler wrapper
19
+ * (`createAction`) calls it automatically on every response that carries `vm`.
20
+ *
21
+ * @throws Error when a violation is found. The message names the colliding
22
+ * action and suggests the two fixes (rename one node, or move both into the
23
+ * same enclosing form).
24
+ */
25
+ export function validateActionNames(vm) {
26
+ const occurrences = [];
27
+ collectActions(vm, null, occurrences);
28
+ // Group by action name; for each group, verify all occurrences share the
29
+ // same enclosing FormNode (and that form is non-null). Anything else is a
30
+ // violation.
31
+ const byName = new Map();
32
+ for (const occ of occurrences) {
33
+ const bucket = byName.get(occ.name);
34
+ if (bucket)
35
+ bucket.push(occ);
36
+ else
37
+ byName.set(occ.name, [occ]);
38
+ }
39
+ for (const [name, group] of byName) {
40
+ if (group.length < 2)
41
+ continue;
42
+ const firstForm = group[0].enclosingForm;
43
+ // Allowed iff every occurrence is inside the SAME non-null form.
44
+ const allInSameForm = firstForm !== null && group.every((o) => o.enclosingForm === firstForm);
45
+ if (!allInSameForm) {
46
+ throw new Error(`Duplicate action name '${name}' dispatched from semantically distinct nodes. ` +
47
+ `Each action name must name exactly one operation. Either rename one of the ` +
48
+ `nodes (e.g. '${name}-X' / '${name}-Y') or move them into the same surrounding ` +
49
+ `form if they are intended to fire the same operation.`);
50
+ }
51
+ }
52
+ }
53
+ function collectActions(node, enclosingForm, out) {
54
+ switch (node.type) {
55
+ case "page": {
56
+ const page = node;
57
+ for (const child of page.children)
58
+ collectActions(child, enclosingForm, out);
59
+ return;
60
+ }
61
+ case "section": {
62
+ const section = node;
63
+ for (const child of section.children)
64
+ collectActions(child, enclosingForm, out);
65
+ return;
66
+ }
67
+ case "list": {
68
+ const list = node;
69
+ for (const child of list.children)
70
+ collectActions(child, enclosingForm, out);
71
+ return;
72
+ }
73
+ case "list-item": {
74
+ const li = node;
75
+ for (const child of li.children)
76
+ collectActions(child, enclosingForm, out);
77
+ return;
78
+ }
79
+ case "form": {
80
+ const form = node;
81
+ if (form.submitAction)
82
+ recordAction(form.submitAction, form, out);
83
+ if (form.buttons) {
84
+ for (const btn of form.buttons)
85
+ recordAction(btn.action, form, out);
86
+ }
87
+ for (const child of form.children)
88
+ collectActions(child, form, out);
89
+ return;
90
+ }
91
+ case "field": {
92
+ const field = node;
93
+ if (field.action)
94
+ recordAction(field.action, enclosingForm, out);
95
+ return;
96
+ }
97
+ case "checkbox": {
98
+ const cb = node;
99
+ if (cb.action)
100
+ recordAction(cb.action, enclosingForm, out);
101
+ return;
102
+ }
103
+ case "button": {
104
+ const btn = node;
105
+ recordAction(btn.action, enclosingForm, out);
106
+ return;
107
+ }
108
+ case "tabs": {
109
+ const tabs = node;
110
+ for (const tab of tabs.tabs)
111
+ recordAction(tab.action, enclosingForm, out);
112
+ return;
113
+ }
114
+ case "modal": {
115
+ const modal = node;
116
+ if (modal.dismissAction)
117
+ recordAction(modal.dismissAction, enclosingForm, out);
118
+ for (const child of modal.children)
119
+ collectActions(child, enclosingForm, out);
120
+ if (modal.footer) {
121
+ for (const child of modal.footer)
122
+ collectActions(child, enclosingForm, out);
123
+ }
124
+ return;
125
+ }
126
+ case "table": {
127
+ const table = node;
128
+ if (table.sortActions) {
129
+ for (const action of Object.values(table.sortActions)) {
130
+ recordAction(action, enclosingForm, out);
131
+ }
132
+ }
133
+ if (table.filterAction)
134
+ recordAction(table.filterAction, enclosingForm, out);
135
+ if (table.pagination?.prevAction) {
136
+ recordAction(table.pagination.prevAction, enclosingForm, out);
137
+ }
138
+ if (table.pagination?.nextAction) {
139
+ recordAction(table.pagination.nextAction, enclosingForm, out);
140
+ }
141
+ for (const row of table.rows) {
142
+ if (row.actions) {
143
+ // row.actions is ViewNode[] — it can include bind-only nodes (e.g. a
144
+ // per-row CheckboxNode used for selection has no .action). Filter to
145
+ // ButtonNodes the same way the .NET validator does (OfType<ButtonNode>())
146
+ // before recording — otherwise the validator throws on a non-button
147
+ // entry's missing .action property. Phase 6 surfaced this when
148
+ // TableSelection was removed and per-row selection moved into
149
+ // row.actions as bound CheckboxNodes (06-04).
150
+ for (const node of row.actions) {
151
+ // TableRow.actions is typed ButtonNode[] but Phase 6 (06-04)
152
+ // started using it for bind-only CheckboxNodes too — the .NET
153
+ // twin types it IReadOnlyList<ViewNode>. Narrow through unknown
154
+ // so both branches type-check until the TS type widens.
155
+ const n = node;
156
+ if (n.type === "button") {
157
+ recordAction(n.action, enclosingForm, out);
158
+ }
159
+ else if (n.type === "checkbox") {
160
+ if (n.action)
161
+ recordAction(n.action, enclosingForm, out);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ return;
167
+ }
168
+ // Nodes with no dispatch-bearing actions of their own:
169
+ // text, link, image, stat-bar, progress, copy-button
170
+ default:
171
+ return;
172
+ }
173
+ }
174
+ function recordAction(action, enclosingForm, out) {
175
+ out.push({ name: action.name, enclosingForm });
176
+ }
11
177
  /** Parse a multipart/form-data action body — the wire format the TypeScript shell uses. */
12
178
  export function parseFormDataAction(formData) {
13
179
  const actionRaw = formData.get("_action");
@@ -30,19 +196,20 @@ export function parseFormDataAction(formData) {
30
196
  }
31
197
  return {
32
198
  name: action.name,
33
- context: action.context ?? null,
34
199
  state,
35
200
  files,
36
201
  };
37
202
  }
38
- /** Parse a flat JSON action body — { name, context, state }. For curl/agent callers. */
203
+ /** Parse a flat JSON action body — `{name, state}`. For curl/agent callers. */
39
204
  export function parseJsonAction(body) {
40
205
  const parsed = typeof body === "string"
41
206
  ? JSON.parse(body)
42
207
  : body;
208
+ if (typeof parsed.name !== "string" || parsed.name === "") {
209
+ throw new Error("Missing required 'name' field in action payload");
210
+ }
43
211
  return {
44
212
  name: parsed.name,
45
- context: parsed.context ?? null,
46
213
  state: parsed.state,
47
214
  files: {},
48
215
  };
@@ -64,6 +231,92 @@ export const shellSideEffect = {
64
231
  download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
65
232
  };
66
233
  // ─── Action handler factory ──────────────────────────────────────────────────
234
+ /**
235
+ * Thrown by an action handler to signal a malformed/invalid request. The
236
+ * createAction wrapper catches this and returns a 400 with the error
237
+ * message in the body, matching the .NET twin's BadRequest("...") path.
238
+ * Reserved for "structurally invalid request the user can't see" (missing
239
+ * required action field, etc.) — NOT for routine app validation (that stays
240
+ * state-based per gotcha #4). No `code` is set on the wire entry (D-08).
241
+ */
242
+ export class BadRequestError extends Error {
243
+ constructor(message) {
244
+ super(message);
245
+ this.name = "BadRequestError";
246
+ }
247
+ }
248
+ /**
249
+ * Thrown by an action handler to signal that the dispatched action name is
250
+ * not recognised. The createAction wrapper catches this and returns a 400
251
+ * with `code: "unknown_action"` in the envelope, allowing agents to distinguish
252
+ * "I sent a name your tree doesn't expose" from "your handler crashed."
253
+ *
254
+ * Usage — add a default arm to your dispatch switch:
255
+ * default: throw new UnknownActionError(payload.name);
256
+ *
257
+ * Mirrors .NET `UnknownActionException` — both backends use the same wire code.
258
+ */
259
+ export class UnknownActionError extends Error {
260
+ /** The offending action name sent by the client. */
261
+ actionName;
262
+ constructor(actionName) {
263
+ super(`Unknown action: ${actionName}`);
264
+ this.name = "UnknownActionError";
265
+ this.actionName = actionName;
266
+ }
267
+ }
268
+ /**
269
+ * Stable, framework-only error code vocabulary. Apps MUST NOT set these —
270
+ * the framework sets `code` on framework-detected failures only. Agents
271
+ * that want generic handling check `ok`; agents that want to branch by
272
+ * failure class check `code` against these constants.
273
+ *
274
+ * D-03 lock: "small, stable, framework-only set."
275
+ */
276
+ export const ERR_CODES = {
277
+ /** Malformed / unparseable request body. HTTP 400. */
278
+ PARSE: "parse_error",
279
+ /** App threw `UnknownActionError` (action name not recognised). HTTP 400. */
280
+ UNKNOWN_ACTION: "unknown_action",
281
+ /** Built view tree violates the action-name uniqueness rule. HTTP 500. */
282
+ INVALID_TREE: "invalid_tree",
283
+ /** App handler threw an unrecognised exception. HTTP 500. */
284
+ UNCAUGHT: "uncaught_exception",
285
+ };
286
+ /**
287
+ * Build a JSON-stringified `{ok: false, errors: [...]}` envelope.
288
+ * Uses the conditional-spread pattern to omit `path` and `code` when
289
+ * undefined — matching the .NET WhenWritingNull null-omission contract.
290
+ */
291
+ function errorEnvelope(entries) {
292
+ const serialized = entries.map(({ message, path, code }) => ({
293
+ message,
294
+ ...(path != null ? { path } : {}),
295
+ ...(code != null ? { code } : {}),
296
+ }));
297
+ return JSON.stringify({ ok: false, errors: serialized });
298
+ }
299
+ /**
300
+ * Derive a safe wire message from an unknown thrown value.
301
+ * T1 info-disclosure mitigation: only copies `Error.prototype.message`
302
+ * for Error instances; substitutes a generic string for non-Error throws
303
+ * so unknown shapes never reach the wire.
304
+ */
305
+ function errorMessageFromUnknownThrow(err) {
306
+ if (err instanceof Error)
307
+ return err.message;
308
+ return "Internal server error";
309
+ }
310
+ /**
311
+ * Shared response factory — deduplicates the JSON + Content-Type header
312
+ * boilerplate across the four error cases and the success path.
313
+ */
314
+ function jsonResponse(body, status) {
315
+ return new Response(body, {
316
+ status,
317
+ headers: { "Content-Type": "application/json" },
318
+ });
319
+ }
67
320
  /**
68
321
  * Web Fetch API–native request handler factory. Auto-detects content-type
69
322
  * (application/json vs multipart/form-data), parses the body, calls your
@@ -92,14 +345,42 @@ export function createAction(handler) {
92
345
  }
93
346
  }
94
347
  catch (err) {
95
- return new Response(JSON.stringify({ error: err.message }), {
96
- status: 400,
97
- headers: { "Content-Type": "application/json" },
98
- });
99
- }
100
- const result = await handler(payload);
101
- return new Response(JSON.stringify(result), {
102
- headers: { "Content-Type": "application/json" },
103
- });
348
+ // Parse failure client sent malformed input. 400.
349
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.PARSE }]), 400);
350
+ }
351
+ let result;
352
+ try {
353
+ result = await handler(payload);
354
+ }
355
+ catch (err) {
356
+ if (err instanceof BadRequestError) {
357
+ // Structurally invalid request — no `code` per D-08.
358
+ return jsonResponse(errorEnvelope([{ message: err.message }]), 400);
359
+ }
360
+ if (err instanceof UnknownActionError) {
361
+ // App threw to signal an unknown action name. 400 per D-11.
362
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.UNKNOWN_ACTION }]), 400);
363
+ }
364
+ // Any other throw: server-side failure. Log server-side for observability
365
+ // (T1: stack trace stays on the server, only safe message reaches the wire).
366
+ console.error("[ViewModelShell] Uncaught exception in action handler:", err);
367
+ return jsonResponse(errorEnvelope([{ message: errorMessageFromUnknownThrow(err), code: ERR_CODES.UNCAUGHT }]), 500);
368
+ }
369
+ // Phase 06 / WIRE-05 — enforce action-name uniqueness on the built tree
370
+ // before it leaves the server. A violation here is a server-side bug, so
371
+ // we surface it as a 500 (the parse-error path above is a 400 because the
372
+ // client sent malformed input). Only run when the response carries a vm
373
+ // (redirect-only responses have nothing to walk).
374
+ if (result.vm) {
375
+ try {
376
+ validateActionNames(result.vm);
377
+ }
378
+ catch (err) {
379
+ return jsonResponse(errorEnvelope([{ message: err.message, code: ERR_CODES.INVALID_TREE }]), 500);
380
+ }
381
+ }
382
+ // Phase 07 / ERROR-01 — every successful response acquires ok:true at the
383
+ // response edge. Controllers / app handlers do NOT set ok themselves.
384
+ return jsonResponse(JSON.stringify({ ok: true, ...result }), 200);
104
385
  };
105
386
  }
package/dist/tui.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Adapter, ActionEvent, ViewNode } from "./index.js";
1
+ import type { Adapter, ActionEvent, ViewNode, StateAccess } from "./index.js";
2
2
  interface TuiOpts {
3
3
  /** "fill" (default) takes the full terminal via alternate-screen buffer;
4
4
  * "content" renders intrinsic content size in the inline scrollback. */
@@ -49,7 +49,7 @@ export declare class TuiAdapter implements Adapter {
49
49
  private setPendingButton;
50
50
  private copy;
51
51
  private resolveFieldValue;
52
- render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
52
+ render(vm: ViewNode, onAction: (action: ActionEvent) => void, _stateAccess?: StateAccess): void;
53
53
  private init;
54
54
  private flushPending;
55
55
  private navigateForLinks;
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
- this.pending.onAction({
302
- name: c.action.name,
303
- context: { ...(c.action.context ?? {}), checked: !c.checked },
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
- // B5 header click toggles sort. Direction policy: clicking the
906
- // currently-sorted column flips asc↔desc; clicking any other column
907
- // starts at asc. Matches BrowserAdapter table semantics.
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
- if (!node.sortAction)
913
+ const a = node.sortActions?.[columnKey];
914
+ if (!a)
910
915
  return;
911
- const direction = node.sortColumn === columnKey && node.sortDirection === "asc" ? "desc" : "asc";
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
- // Selection leading [x]/[ ] column. TUI is render-only: checkboxes display
918
- // selectedIds; clicks are inert (the TUI doesn't track DOM-equivalent state
919
- // across the hook-less conformance walker). Bulk actions live in
920
- // selection.buttons[]; the harvest reads sel.selectedIds (server's
921
- // pre-selection). The browser carries the interactive surface.
922
- const sel = node.selection;
923
- const effectiveSet = sel ? new Set(sel.selectedIds) : null;
924
- const allOnPage = sel != null && node.rows.length > 0 &&
925
- node.rows.every((r) => r.id != null && effectiveSet.has(r.id));
926
- return (_jsx("scrollbox", { focused: focused, focusable: isPaneFocusable, borderColor: focused ? "#88aaff" : "#555555", focusedBorderColor: "#88aaff", flexGrow: 1, flexShrink: 1, children: _jsxs("box", { flexDirection: "column", children: [sel?.buttons && sel.buttons.length > 0 ? (_jsx("box", { flexDirection: "row", gap: 1, children: sel.buttons.map((btn, i) => {
927
- const harvestCtx = {
928
- ...ctx,
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
- // B5row click dispatches row.action when present.
947
- const onRowClick = row.action
948
- ? () => ctx.onAction(row.action)
949
- : undefined;
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 go = (p) => ctx.onAction({
978
- name: pg.action.name,
979
- context: { ...(pg.action.context ?? {}), page: p },
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: () => go(pg.page - 1) } : {}), children: "‹ Prev" }), _jsx("text", { fg: canNext ? "#88aaff" : "#555555", ...(canNext ? { onMouseDown: () => go(pg.page + 1) } : {}), children: "Next ›" })] }));
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
- const glyph = node.checked ? "[x]" : "[ ]";
1011
- // B5 mouse click toggles. When node.action is defined, dispatch it with
1012
- // the NEW checked value merged into context (matches BrowserAdapter's
1013
- // checkbox onChange wire: `{checked: !node.checked}`). When no action,
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
- // 0.10.0 (#15)harvest this form's current field values, merge into the
1113
- // given action's context, and dispatch. Generalized from the single-submit
1114
- // closure so the default submit AND each buttons[] entry can call it with
1115
- // a DIFFERENT action carrying the SAME live field values.
1094
+ // Phase 6context-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
- const merged = { ...(base.context ?? {}) };
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
- const wireValue = node.value ?? "";
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.16.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",
@@ -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);