@ashley-shrok/viewmodel-shell 0.14.0 → 0.16.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/browser.d.ts CHANGED
@@ -10,6 +10,11 @@ export declare class BrowserAdapter implements Adapter {
10
10
  * contentType is informational — the Blob's own .type takes precedence in
11
11
  * browsers. We accept the arg for adapter symmetry (other adapters use it). */
12
12
  saveFile(data: Blob, filename: string, _contentType: string): void;
13
+ /** 0.16.0 — toggle the `.vms-busy` class on the container. Default CSS makes
14
+ * every interactive descendant non-clickable (cursor:wait + pointer-events:
15
+ * none), so a rapid checkbox click during a round-trip never visually flips
16
+ * the box. Idempotent (classList.toggle with a force value). */
17
+ setBusy(active: boolean): void;
13
18
  /** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
14
19
  * calls this on every response with `response.preventUnload ?? false`, so
15
20
  * the lock-and-clear cycle is just "server sets preventUnload:true while
package/dist/browser.js CHANGED
@@ -105,6 +105,13 @@ export class BrowserAdapter {
105
105
  setTimeout(() => URL.revokeObjectURL(url), 0);
106
106
  }
107
107
  }
108
+ /** 0.16.0 — toggle the `.vms-busy` class on the container. Default CSS makes
109
+ * every interactive descendant non-clickable (cursor:wait + pointer-events:
110
+ * none), so a rapid checkbox click during a round-trip never visually flips
111
+ * the box. Idempotent (classList.toggle with a force value). */
112
+ setBusy(active) {
113
+ this.container.classList.toggle("vms-busy", active);
114
+ }
108
115
  /** 0.14.0 — install / clear the browser's `beforeunload` guard. The shell
109
116
  * calls this on every response with `response.preventUnload ?? false`, so
110
117
  * the lock-and-clear cycle is just "server sets preventUnload:true while
@@ -674,24 +681,18 @@ export class BrowserAdapter {
674
681
  box.checked = allOnPage;
675
682
  box.indeterminate = someOnPage && !allOnPage;
676
683
  box.addEventListener("change", () => {
677
- const selAction = sel.action;
678
- if (selAction) {
679
- // Server-truth mode: dispatch; server re-renders with new selectedIds.
680
- on({ name: selAction.name, context: { ...(selAction.context ?? {}), all: true, checked: box.checked } });
681
- }
682
- else {
683
- // Local mode (0.13.0): toggle every row checkbox + class in this table
684
- // to match the header. No dispatch — the dispatch guard can't drop anything.
685
- const want = box.checked;
686
- table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
687
- if (rowBox.disabled)
688
- return;
689
- rowBox.checked = want;
690
- const tr = rowBox.closest(".vms-table__row");
691
- if (tr)
692
- tr.classList.toggle("vms-table__row--selected", want);
693
- });
694
- }
684
+ // Toggle every row checkbox + class in this table to match the header.
685
+ // No dispatch — selection is purely DOM-local; bulk actions harvest the
686
+ // checked rows when a `selection.buttons[]` entry is clicked.
687
+ const want = box.checked;
688
+ table.querySelectorAll("tbody input.vms-table__select").forEach(rowBox => {
689
+ if (rowBox.disabled)
690
+ return;
691
+ rowBox.checked = want;
692
+ const tr = rowBox.closest(".vms-table__row");
693
+ if (tr)
694
+ tr.classList.toggle("vms-table__row--selected", want);
695
+ });
695
696
  });
696
697
  th.appendChild(box);
697
698
  headerRow.appendChild(th);
@@ -783,23 +784,18 @@ export class BrowserAdapter {
783
784
  // data-id is what selection.buttons[] harvest reads on click.
784
785
  box.dataset.id = rowId;
785
786
  box.addEventListener("change", () => {
786
- const selAction = sel.action;
787
- if (selAction) {
788
- on({ name: selAction.name, context: { ...(selAction.context ?? {}), id: rowId, checked: box.checked } });
789
- }
790
- else {
791
- // Local mode (0.13.0): flip the row class to mirror the box, then
792
- // reconcile the header select-all (could now be all / some / none).
793
- tr.classList.toggle("vms-table__row--selected", box.checked);
794
- const headerBox = table.querySelector("thead input.vms-table__select--all");
795
- if (headerBox) {
796
- const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
797
- let checked = 0;
798
- all.forEach(b => { if (b.checked)
799
- checked++; });
800
- headerBox.checked = all.length > 0 && checked === all.length;
801
- headerBox.indeterminate = checked > 0 && checked < all.length;
802
- }
787
+ // Flip the row class to mirror the box, then reconcile the header
788
+ // select-all (could now be all / some / none). No dispatch — bulk
789
+ // actions read the DOM via the selection.buttons[] harvest.
790
+ tr.classList.toggle("vms-table__row--selected", box.checked);
791
+ const headerBox = table.querySelector("thead input.vms-table__select--all");
792
+ if (headerBox) {
793
+ const all = table.querySelectorAll("tbody input.vms-table__select:not(:disabled)");
794
+ let checked = 0;
795
+ all.forEach(b => { if (b.checked)
796
+ checked++; });
797
+ headerBox.checked = all.length > 0 && checked === all.length;
798
+ headerBox.indeterminate = checked > 0 && checked < all.length;
803
799
  }
804
800
  });
805
801
  }
package/dist/index.d.ts CHANGED
@@ -42,6 +42,16 @@ export interface Adapter {
42
42
  * targets (TUI) have no terminal equivalent. Modern browsers show a
43
43
  * generic "Leave site?" dialog; the message is not customizable. */
44
44
  setPreventUnload?(active: boolean): void;
45
+ /** 0.16.0 — visually lock the UI during periods where user dispatches will
46
+ * be dropped. Called by the shell on every transition with `active = true`
47
+ * when EITHER a user-initiated dispatch is in flight OR the server returned
48
+ * `ShellResponse.busy: true` on its most recent response. Polls (silent
49
+ * dispatches) bypass — they're the only way out of a server-busy state.
50
+ * `BrowserAdapter` toggles `.vms-busy` on its container; default CSS makes
51
+ * every interactive descendant non-clickable (cursor: wait + pointer-events:
52
+ * none), so a rapid checkbox click during an in-flight round-trip never
53
+ * visually flips the box. Fail-quiet by absence (TUI has no equivalent). */
54
+ setBusy?(active: boolean): void;
45
55
  }
46
56
  export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | ImageNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
47
57
  export interface PageNode {
@@ -213,31 +223,19 @@ export interface TableRow {
213
223
  variant?: string;
214
224
  }
215
225
  export interface TableSelection {
216
- /** Row ids that should render PRE-SELECTED on this render. In server-truth
217
- * mode (`action` set) this is the live selection, round-tripped in state and
218
- * authoritative every render. In local mode (`action` omitted) this is the
219
- * server's initial pre-selection only — subsequent toggles are purely
220
- * client-side DOM state and the server doesn't see them until a `buttons[]`
221
- * click harvests them. */
226
+ /** Row ids that should render PRE-SELECTED on initial render the server's
227
+ * initial pre-selection. Subsequent toggles are purely client-side DOM state;
228
+ * the server doesn't see them until a `buttons[]` click harvests them. */
222
229
  selectedIds: string[];
223
- /** OPTIONAL (0.13.0). When present: server-truth mode every checkbox toggle
224
- * dispatches this action with merged `{ id, checked }` per row or
225
- * `{ all: true, checked }` for the header select-all (where "all" = the
226
- * rendered page). Selection survives sort/filter/pagination via the state
227
- * round-trip. When OMITTED: local mode the adapter toggles the DOM
228
- * checkbox + `.vms-table__row--selected` class purely client-side with no
229
- * dispatch. Local mode is the recommended pattern for rapid-selection
230
- * workflows (no dropped clicks under the dispatch guard); see `buttons` for
231
- * how bulk actions read the resulting selection. */
232
- action?: ActionEvent;
233
- /** OPTIONAL (0.13.0). When present, the adapter renders these as a bulk-action
234
- * toolbar ABOVE the table. On click, each button harvests the currently
235
- * checked rows from the DOM and dispatches its `action` with
236
- * `{ selectedIds: [...] }` merged into its `context`. Designed primarily to
237
- * pair with local mode (`action` absent) — it's how the server learns the
238
- * selection without a per-toggle round-trip — but works in server-truth mode
239
- * too (the harvest matches `selectedIds` since the DOM reflects server-truth
240
- * after each render). */
230
+ /** When present, the adapter renders these as a bulk-action toolbar ABOVE
231
+ * the table. On click, each button harvests the currently-checked rows from
232
+ * the DOM and dispatches its `action` with `{ selectedIds: [...] }` merged
233
+ * into its `context`. This is the only way to act on the selection — there
234
+ * is no per-toggle dispatch (0.15.0 removed the `action` mode that did,
235
+ * because rapid clicks were dropped by the dispatch guard and the in-flight
236
+ * response wiped the DOM). If a future release needs cross-page persistence
237
+ * or live "N selected" indicators, the way back is a redesigned wire shape
238
+ * (dispatch queueing + optimistic preservation), not the old `action` mode. */
241
239
  buttons?: ButtonNode[];
242
240
  }
243
241
  export interface TablePagination {
@@ -336,6 +334,10 @@ export interface ShellResponse {
336
334
  * `preventUnload: true` from each response; clear it when the work
337
335
  * completes (typically via polling). See `Adapter.setPreventUnload`. */
338
336
  preventUnload?: boolean;
337
+ /** 0.16.0 — when true, the shell locks the UI (drops user-initiated
338
+ * dispatches; the adapter applies `.vms-busy` for the visual cue). Polls
339
+ * bypass so the server can clear the state. See `Adapter.setBusy`. */
340
+ busy?: boolean;
339
341
  }
340
342
  export declare class ViewModelShell {
341
343
  private options;
@@ -343,7 +345,10 @@ export declare class ViewModelShell {
343
345
  private currentState;
344
346
  private dispatching;
345
347
  private pollTimer;
348
+ private serverBusy;
349
+ private userDispatching;
346
350
  constructor(options: ShellOptions);
351
+ private syncBusy;
347
352
  load(params?: Record<string, string>): Promise<void>;
348
353
  dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
349
354
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
package/dist/index.js CHANGED
@@ -5,9 +5,18 @@ export class ViewModelShell {
5
5
  currentState = null;
6
6
  dispatching = false;
7
7
  pollTimer = null;
8
+ // 0.16.0 — busy = serverBusy OR a user-initiated dispatch is in flight.
9
+ // Polls (silent=true dispatches) don't flip userDispatching so they never
10
+ // toggle the busy class — that's how a server-busy state stays continuously
11
+ // locked across many ticks without flicker.
12
+ serverBusy = false;
13
+ userDispatching = false;
8
14
  constructor(options) {
9
15
  this.options = options;
10
16
  }
17
+ syncBusy() {
18
+ this.options.adapter.setBusy?.(this.serverBusy || this.userDispatching);
19
+ }
11
20
  async load(params) {
12
21
  const { endpoint, adapter, onError, onLoading } = this.options;
13
22
  this.stopPolling();
@@ -25,6 +34,9 @@ export class ViewModelShell {
25
34
  // server may legitimately want it on at first paint (e.g. the page was
26
35
  // refreshed mid-work and the long action is still pending server-side).
27
36
  adapter.setPreventUnload?.(body.preventUnload ?? false);
37
+ // 0.16.0 — same for the busy lockout.
38
+ this.serverBusy = body.busy ?? false;
39
+ this.syncBusy();
28
40
  adapter.render(body.vm, (action) => this.dispatch(action));
29
41
  this.schedulePoll(body.nextPollIn);
30
42
  }
@@ -37,6 +49,10 @@ export class ViewModelShell {
37
49
  }
38
50
  }
39
51
  async dispatch(action, silent = false) {
52
+ // 0.16.0 — drop user-initiated dispatches while server-busy. Polls (silent)
53
+ // bypass so the server can clear the busy state.
54
+ if (!silent && this.serverBusy)
55
+ return;
40
56
  if (this.dispatching)
41
57
  return;
42
58
  const { actionEndpoint, onError, onLoading } = this.options;
@@ -48,8 +64,16 @@ export class ViewModelShell {
48
64
  }
49
65
  try {
50
66
  this.dispatching = true;
51
- if (!silent)
67
+ if (!silent) {
68
+ // 0.16.0 — flag a user dispatch as in-flight + apply .vms-busy. This
69
+ // is what kills the "rapid clicks during a round-trip silently flip the
70
+ // checkbox" UX bug: by the time the user's second click arrives, the
71
+ // container has pointer-events: none and the click never reaches the
72
+ // input.
73
+ this.userDispatching = true;
74
+ this.syncBusy();
52
75
  onLoading?.(true);
76
+ }
53
77
  const form = new FormData();
54
78
  form.append("_action", JSON.stringify({ name: action.name, context: action.context ?? {} }));
55
79
  form.append("_state", JSON.stringify(this.currentState));
@@ -93,8 +117,11 @@ export class ViewModelShell {
93
117
  }
94
118
  finally {
95
119
  this.dispatching = false;
96
- if (!silent)
120
+ if (!silent) {
121
+ this.userDispatching = false;
122
+ this.syncBusy();
97
123
  onLoading?.(false);
124
+ }
98
125
  }
99
126
  }
100
127
  /** Feed a pre-parsed ShellResponse into the shell — for SSE/WebSocket integrations. */
@@ -147,6 +174,9 @@ export class ViewModelShell {
147
174
  // wants a redirect to NOT be blocked by its own guard simply omits
148
175
  // preventUnload (or sets it false) on that response — standard pattern.
149
176
  adapter.setPreventUnload?.(body.preventUnload ?? false);
177
+ // 0.16.0 — likewise for the busy lockout.
178
+ this.serverBusy = body.busy ?? false;
179
+ this.syncBusy();
150
180
  if (body.redirect) {
151
181
  if (this.options.onRedirect) {
152
182
  this.options.onRedirect(body.redirect);
package/dist/server.d.ts CHANGED
@@ -22,6 +22,12 @@ export interface ShellResponseBody<TState> {
22
22
  * (or set false) to clear; set true while a long-running server action is
23
23
  * in flight so an accidental tab-close doesn't lose work. */
24
24
  preventUnload?: boolean;
25
+ /** 0.16.0 — lock the UI: the shell drops user-initiated dispatches client-
26
+ * side and the BrowserAdapter applies `.vms-busy` (cursor:wait + pointer-
27
+ * events:none on interactive descendants). Polls bypass so the server can
28
+ * clear the state. Naturally paired with `preventUnload` for long-running
29
+ * server actions. */
30
+ busy?: boolean;
25
31
  }
26
32
  /** Build a redirect response (Vm and State omitted; shell navigates the browser). */
27
33
  export declare function shellRedirect<TState = unknown>(url: string): ShellResponseBody<TState>;
package/dist/tui.js CHANGED
@@ -914,30 +914,15 @@ function TableView({ node, ctx }) {
914
914
  context: { ...(node.sortAction.context ?? {}), column: columnKey, direction },
915
915
  });
916
916
  };
917
- // Selection — leading [x]/[ ] column. Dispatch payloads match BrowserAdapter:
918
- // { id, checked } per row, { all: true, checked } for select-all (server-truth
919
- // mode). 0.13.0: if `action` is omitted, this is LOCAL mode. The TUI renders
920
- // checkboxes from `selectedIds` (the server's pre-selection); clicks are
921
- // inert because the TUI doesn't track DOM-equivalent state across the
922
- // hook-less conformance walker. The browser carries the real local-mode
923
- // workflow; TUI is experimental — use the browser for interactive bulk
924
- // selection. The selection.buttons[] toolbar still renders below.
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.
925
922
  const sel = node.selection;
926
923
  const effectiveSet = sel ? new Set(sel.selectedIds) : null;
927
924
  const allOnPage = sel != null && node.rows.length > 0 &&
928
925
  node.rows.every((r) => r.id != null && effectiveSet.has(r.id));
929
- const onToggleAll = sel?.action
930
- ? () => ctx.onAction({
931
- name: sel.action.name,
932
- context: { ...(sel.action.context ?? {}), all: true, checked: !allOnPage },
933
- })
934
- : undefined;
935
- const onToggleRow = sel?.action
936
- ? (rowId, checked) => ctx.onAction({
937
- name: sel.action.name,
938
- context: { ...(sel.action.context ?? {}), id: rowId, checked },
939
- })
940
- : undefined;
941
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) => {
942
927
  const harvestCtx = {
943
928
  ...ctx,
@@ -950,7 +935,7 @@ function TableView({ node, ctx }) {
950
935
  },
951
936
  };
952
937
  return _jsx(ButtonView, { node: btn, ctx: harvestCtx }, i);
953
- }) })) : null, _jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, ...(onToggleAll ? { onMouseDown: onToggleAll } : {}), children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
938
+ }) })) : null, _jsxs("box", { flexDirection: "row", gap: 2, children: [sel ? (_jsx("text", { attributes: 1 /* BOLD */, children: allOnPage ? "[x]" : "[ ]" })) : null, node.columns.map((c) => {
954
939
  const isSorted = node.sortColumn === c.key;
955
940
  const caret = isSorted ? (node.sortDirection === "desc" ? " ↓" : " ↑") : "";
956
941
  // B5 — only sortable headers respond to clicks (matches BrowserAdapter).
@@ -964,11 +949,7 @@ function TableView({ node, ctx }) {
964
949
  : undefined;
965
950
  return (_jsxs("box", { flexDirection: "row", gap: 2, ...(onRowClick ? { onMouseDown: onRowClick } : {}), children: [sel ? (() => {
966
951
  const isSel = row.id != null && effectiveSet.has(row.id);
967
- const rowId = row.id;
968
- const onBox = rowId != null && onToggleRow
969
- ? () => onToggleRow(rowId, !isSel)
970
- : undefined;
971
- return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", ...(onBox ? { onMouseDown: onBox } : {}), children: isSel ? "[x]" : "[ ]" }));
952
+ return (_jsx("text", { fg: isSel ? "#88ff88" : "#888888", children: isSel ? "[x]" : "[ ]" }));
972
953
  })() : null, node.columns.map((c) => {
973
954
  const cell = row.cells[c.key] ?? "";
974
955
  if (c.linkLabel != null && cell.length > 0) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -649,6 +649,18 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
649
649
  .vms-table__pagination-btn { padding: var(--vms-space-xs) var(--vms-space-sm); font-size: var(--vms-text-sm); }
650
650
  .vms-table__pagination-btn:disabled { opacity: 0.45; cursor: default; }
651
651
 
652
+ /* 0.16.0 — `.vms-busy` is applied by BrowserAdapter when the shell is in a
653
+ busy state (a user-initiated dispatch is in flight OR the server returned
654
+ ShellResponse.busy: true). cursor: wait communicates the state; pointer-
655
+ events: none on interactive descendants makes the lock honest — rapid
656
+ clicks during a round-trip never visually flip checkboxes / depress buttons
657
+ because the click never reaches them. Polls (silent dispatches) bypass the
658
+ class so a long-running action stays continuously locked across many ticks
659
+ without flickering. */
660
+ .vms-busy { cursor: wait; }
661
+ .vms-busy a, .vms-busy button, .vms-busy input, .vms-busy textarea,
662
+ .vms-busy select, .vms-busy [role="button"], .vms-busy [data-id] { pointer-events: none; }
663
+
652
664
  /* ── Recommended error-banner pattern ──
653
665
  Apps typically render this from the `onError` callback. Not emitted by
654
666
  the framework; included here so apps can use the convention without