@ashley-shrok/viewmodel-shell 0.15.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
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 {
@@ -324,6 +334,10 @@ export interface ShellResponse {
324
334
  * `preventUnload: true` from each response; clear it when the work
325
335
  * completes (typically via polling). See `Adapter.setPreventUnload`. */
326
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;
327
341
  }
328
342
  export declare class ViewModelShell {
329
343
  private options;
@@ -331,7 +345,10 @@ export declare class ViewModelShell {
331
345
  private currentState;
332
346
  private dispatching;
333
347
  private pollTimer;
348
+ private serverBusy;
349
+ private userDispatching;
334
350
  constructor(options: ShellOptions);
351
+ private syncBusy;
335
352
  load(params?: Record<string, string>): Promise<void>;
336
353
  dispatch(action: ActionEvent, silent?: boolean): Promise<void>;
337
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.15.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