@ashley-shrok/viewmodel-shell 0.16.0 → 1.1.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/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.1.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",
@@ -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);
@@ -599,6 +605,10 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
599
605
  .vms-table__row:not(:last-child) .vms-table__td { border-bottom: 1px solid var(--vms-border); }
600
606
  .vms-table__row--clickable { cursor: pointer; }
601
607
  .vms-table__row--clickable:hover { background: var(--vms-surface); }
608
+ .vms-table__row--clickable:focus-visible {
609
+ outline: 2px solid var(--vms-accent);
610
+ outline-offset: -2px;
611
+ }
602
612
  /* Row status variants — tints derive from theme vars (color-mix), so a
603
613
  custom :root theme recolors them automatically; no app CSS, no literals. */
604
614
  .vms-table__row--done { opacity: 0.6; }