@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/browser.d.ts +19 -13
- package/dist/browser.js +165 -270
- package/dist/index.d.ts +125 -45
- package/dist/index.js +230 -9
- package/dist/server.d.ts +73 -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 +15 -9
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 — {
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
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);
|