@ashley-shrok/viewmodel-shell 0.7.1 → 0.9.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.js CHANGED
@@ -471,7 +471,20 @@ export class BrowserAdapter {
471
471
  btn.type = "button";
472
472
  btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
473
473
  btn.textContent = n.label;
474
- btn.addEventListener("click", () => on(n.action));
474
+ btn.addEventListener("click", () => {
475
+ // 0.8.0 (#11) — pendingLabel: instant client-side feedback. Swap text +
476
+ // add .vms-button--pending BEFORE handing off to the dispatcher. On
477
+ // success the next render replaces the button entirely. On dispatch
478
+ // error, the shell's dispatch() catch re-renders this.currentVm so
479
+ // the original label snaps back automatically — no per-button cleanup
480
+ // wiring needed in the adapter. Pure-client ephemeral state; never
481
+ // round-trips through the wire.
482
+ if (n.pendingLabel) {
483
+ btn.textContent = n.pendingLabel;
484
+ btn.classList.add("vms-button--pending");
485
+ }
486
+ on(n.action);
487
+ });
475
488
  parent.appendChild(btn);
476
489
  }
477
490
  text(n, parent) {
@@ -677,7 +690,10 @@ export class BrowserAdapter {
677
690
  copyButton(n, parent) {
678
691
  const btn = document.createElement("button");
679
692
  btn.type = "button";
680
- btn.className = "vms-button";
693
+ // 0.9.0 (#14): variant modifier class, mirroring button() exactly. The
694
+ // existing .vms-button--{primary,secondary,danger} CSS rules apply
695
+ // automatically — no new style surface.
696
+ btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
681
697
  btn.textContent = n.label ?? "Copy";
682
698
  btn.addEventListener("click", () => {
683
699
  const write = navigator.clipboard?.writeText(n.text);
package/dist/index.d.ts CHANGED
@@ -107,6 +107,13 @@ export interface ButtonNode {
107
107
  label: string;
108
108
  action: ActionEvent;
109
109
  variant?: "primary" | "secondary" | "danger";
110
+ /** Transient label shown from click until the dispatch resolves (response
111
+ * arrives or dispatch errors). Mirrors `CopyButtonNode.copiedLabel`'s
112
+ * lifecycle pattern at a different beat: shown DURING the round-trip
113
+ * rather than AFTER it. The adapter additionally adds `.vms-button--pending`
114
+ * while in this state so the button visibly disables (cursor + opacity).
115
+ * Omitted = no pending feedback (existing instant-click behavior). */
116
+ pendingLabel?: string;
110
117
  }
111
118
  export interface TextNode {
112
119
  type: "text";
@@ -189,6 +196,11 @@ export interface CopyButtonNode {
189
196
  label?: string;
190
197
  /** Ephemeral label shown after a successful copy, reverts after ~1.5 s. Adapter default: "Copied!". */
191
198
  copiedLabel?: string;
199
+ /** Visual variant — mirrors ButtonNode.variant (issue #14). Adapter emits
200
+ * `vms-button vms-button--{variant}` (browser) / variant-tinted text
201
+ * (TUI), so a copy-button can read distinctly from neighboring default
202
+ * buttons. Closed union; omitted = current behavior (no modifier). */
203
+ variant?: "primary" | "secondary" | "danger";
192
204
  }
193
205
  export interface ShellOptions {
194
206
  endpoint: string;
package/dist/index.js CHANGED
@@ -77,6 +77,15 @@ export class ViewModelShell {
77
77
  catch (err) {
78
78
  const error = err instanceof Error ? err : new Error(String(err));
79
79
  onError ? onError(error) : console.error("[ViewModelShell]", error);
80
+ // 0.8.0 (#11) — re-render the current VM on dispatch error. Adapters
81
+ // may have applied client-side ephemeral state in onAction handlers
82
+ // (e.g., BrowserAdapter swaps button text for ButtonNode.pendingLabel).
83
+ // Re-rendering snaps that back to the authoritative server state.
84
+ // Skipped when no VM has loaded yet (pre-initial-load dispatch is
85
+ // already an error case handled above; currentVm stays null there).
86
+ if (this.currentVm !== null) {
87
+ this.options.adapter.render(this.currentVm, (a) => this.dispatch(a));
88
+ }
80
89
  }
81
90
  finally {
82
91
  this.dispatching = false;
package/dist/tui.d.ts CHANGED
@@ -23,6 +23,7 @@ export declare class TuiAdapter implements Adapter {
23
23
  private readonly fieldWireValues;
24
24
  private copiedKey;
25
25
  private copiedTimer;
26
+ private pendingButtonKey;
26
27
  constructor(opts?: TuiOpts);
27
28
  private setFieldValue;
28
29
  /** Resolve a field's display value at render time. The draft-preservation
@@ -34,6 +35,8 @@ export declare class TuiAdapter implements Adapter {
34
35
  * 2. Wire value unchanged since last render — preserve the edit value.
35
36
  * 3. Wire value differs from the prior wire baseline — server intent
36
37
  * change → reset the edit value to match. */
38
+ private buttonKey;
39
+ private setPendingButton;
37
40
  private copy;
38
41
  private resolveFieldValue;
39
42
  render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
package/dist/tui.js CHANGED
@@ -31,6 +31,12 @@ export class TuiAdapter {
31
31
  // fine UX-wise and avoids needing per-button unique keys.
32
32
  copiedKey = null;
33
33
  copiedTimer = null;
34
+ // 0.8.0 (#11) — pending-button state. ButtonView reads ctx.pendingButtonKey
35
+ // and renders pendingLabel when matched. Set from ButtonView onMouseDown
36
+ // and from activatePane's button branch. Cleared on every external render()
37
+ // call (so server-driven re-render — success OR error — naturally clears
38
+ // pending state, no per-button cleanup wiring needed).
39
+ pendingButtonKey = null;
34
40
  constructor(opts) {
35
41
  this.viewport = opts?.viewport ?? "fill";
36
42
  const f = opts?.sidebarFraction ?? 1 / 3;
@@ -68,6 +74,20 @@ export class TuiAdapter {
68
74
  // ESC ] 52 ; c ; <base64> BEL. ESC = \x1b, BEL = \x07. Terminals without
69
75
  // OSC-52 support ignore the escape (no clipboard write, but the visual
70
76
  // "Copied!" feedback still fires, which is honest behavior).
77
+ // Returns a stable key for ButtonNode identity within a render. Uses
78
+ // action name + visible label — duplicates are rare and merely cause
79
+ // two buttons to flash together (acceptable, matches "same action
80
+ // is in-flight" intuition).
81
+ buttonKey(action, label) {
82
+ return `${action}::${label}`;
83
+ }
84
+ // Set the pending-button key + flush the React tree so ButtonView re-renders
85
+ // with the swapped label. Called from ButtonView.onMouseDown and from
86
+ // activatePane's button branch.
87
+ setPendingButton = (action, label) => {
88
+ this.pendingButtonKey = this.buttonKey(action, label);
89
+ this.flushPending();
90
+ };
71
91
  copy = (text) => {
72
92
  const b64 = Buffer.from(text, "utf8").toString("base64");
73
93
  const seq = `\x1b]52;c;${b64}\x07`;
@@ -105,9 +125,15 @@ export class TuiAdapter {
105
125
  // createCliRenderer is async, so we init lazily: the first render() kicks
106
126
  // off initialization; subsequent renders are sync. Late renders that
107
127
  // arrive before init resolves are coalesced into `pending` (last-write-wins).
128
+ //
129
+ // 0.8.0 (#11) — every external render clears the pending-button state
130
+ // (label swap) so server-driven re-renders (success path AND the dispatch-
131
+ // error re-render path) naturally revert any in-flight UI without per-
132
+ // button cleanup wiring.
108
133
  render(vm, onAction) {
109
134
  if (this.disposed)
110
135
  return;
136
+ this.pendingButtonKey = null;
111
137
  this.pending = { vm, onAction };
112
138
  if (this.renderer == null) {
113
139
  if (this.initPromise == null)
@@ -178,7 +204,7 @@ export class TuiAdapter {
178
204
  this.focusedPaneIndex = 0;
179
205
  }
180
206
  this.lastPaneCount = paneCount;
181
- this.root.render(_jsx(App, { vm: vm, onAction: onAction, focusedPaneIndex: this.focusedPaneIndex, sidebarFraction: this.sidebarFraction, setFieldValue: this.setFieldValue, resolveFieldValue: this.resolveFieldValue, copiedKey: this.copiedKey, copy: this.copy, navigate: this.navigateForLinks }));
207
+ this.root.render(_jsx(App, { vm: vm, onAction: onAction, focusedPaneIndex: this.focusedPaneIndex, sidebarFraction: this.sidebarFraction, setFieldValue: this.setFieldValue, resolveFieldValue: this.resolveFieldValue, copiedKey: this.copiedKey, copy: this.copy, navigate: this.navigateForLinks, pendingButtonKey: this.pendingButtonKey, setPendingButton: this.setPendingButton }));
182
208
  }
183
209
  // B5 — bound reference to navigate(), used as the App prop. We bind once
184
210
  // (in the property initializer below) so React sees a stable function
@@ -226,6 +252,10 @@ export class TuiAdapter {
226
252
  if (a == null)
227
253
  return;
228
254
  if (a.type === "button") {
255
+ // 0.8.0 (#11) — mirror ButtonView.onMouseDown's pending-state set.
256
+ // Enter activation is structurally identical to a mouse click here.
257
+ if (a.pendingLabel != null)
258
+ this.setPendingButton(a.action.name, a.label);
229
259
  this.pending.onAction(a.action);
230
260
  }
231
261
  else if (a.type === "link") {
@@ -553,7 +583,7 @@ function focusedPaneSummary(vm, index) {
553
583
  scan(c);
554
584
  return { heading, hasInputs, primaryActionable, primaryCheckbox };
555
585
  }
556
- function App({ vm, onAction, focusedPaneIndex = 0, sidebarFraction = 1 / 3, setFieldValue, resolveFieldValue, copiedKey = null, copy, navigate, }) {
586
+ function App({ vm, onAction, focusedPaneIndex = 0, sidebarFraction = 1 / 3, setFieldValue, resolveFieldValue, copiedKey = null, copy, navigate, pendingButtonKey = null, setPendingButton, }) {
557
587
  // B4 — detect modal at the top of every render so the focus-trap + overlay
558
588
  // wiring is consistent end-to-end. When a modal exists in the tree, the
559
589
  // inline ModalView returns null (so it doesn't render in-place); we render
@@ -579,6 +609,10 @@ function App({ vm, onAction, focusedPaneIndex = 0, sidebarFraction = 1 / 3, setF
579
609
  // B5: link click target. Default no-op for the conformance walker (which
580
610
  // doesn't have an adapter to call). Real renders thread TuiAdapter.navigate.
581
611
  navigate: navigate ?? (() => { }),
612
+ // 0.8.0 (#11): pending-button plumbing. Defaults make renderTree (the
613
+ // static conformance walker) safe to invoke without an adapter.
614
+ pendingButtonKey,
615
+ setPendingButton: setPendingButton ?? (() => { }),
582
616
  modalActive: modal != null,
583
617
  insideModal: false,
584
618
  };
@@ -880,11 +914,22 @@ function ButtonView({ node, ctx }) {
880
914
  const fg = node.variant === "danger" ? "#ff5555"
881
915
  : node.variant === "primary" ? "#88aaff"
882
916
  : undefined;
917
+ // 0.8.0 (#11) — pendingLabel: when set + this button's key matches the
918
+ // adapter's pendingButtonKey, render the pending label instead of the
919
+ // normal label. Cleared on the next external render() (success path =
920
+ // server returns a new VM; error path = shell re-renders currentVm).
921
+ const key = `${node.action.name}::${node.label}`;
922
+ const isPending = node.pendingLabel != null && ctx.pendingButtonKey === key;
923
+ const label = isPending ? node.pendingLabel : node.label;
883
924
  // B5 — mouse click dispatches the button's action. Keyboard activation
884
925
  // (Enter on the focused pane's primary actionable) is wired at the
885
926
  // renderer key handler, not here.
886
- const onMouseDown = () => ctx.onAction(node.action);
887
- return (_jsxs("text", { ...(fg != null ? { fg } : {}), onMouseDown: onMouseDown, children: ["[ ", node.label, " ]"] }));
927
+ const onMouseDown = () => {
928
+ if (node.pendingLabel != null)
929
+ ctx.setPendingButton(node.action.name, node.label);
930
+ ctx.onAction(node.action);
931
+ };
932
+ return (_jsxs("text", { ...(fg != null ? { fg } : {}), ...(isPending ? { dimColor: true } : {}), onMouseDown: onMouseDown, children: ["[ ", label, " ]"] }));
888
933
  }
889
934
  function CheckboxView({ node, ctx }) {
890
935
  const glyph = node.checked ? "[x]" : "[ ]";
@@ -974,7 +1019,13 @@ function CopyButtonView({ node, ctx }) {
974
1019
  const label = isCopied
975
1020
  ? (node.copiedLabel ?? "Copied!")
976
1021
  : (node.label ?? "Copy");
977
- return (_jsxs("text", { onMouseDown: () => ctx.copy(node.text), children: ["[ ", label, " ]"] }));
1022
+ // 0.9.0 (#14): variant coloring mirrors ButtonView's fg derivation
1023
+ // verbatim. Adapter-medium-adaptation parity: the browser uses CSS
1024
+ // classes; the TUI uses ANSI fg colors. Same semantic mapping.
1025
+ const fg = node.variant === "danger" ? "#ff5555"
1026
+ : node.variant === "primary" ? "#88aaff"
1027
+ : undefined;
1028
+ return (_jsxs("text", { ...(fg != null ? { fg } : {}), onMouseDown: () => ctx.copy(node.text), children: ["[ ", label, " ]"] }));
978
1029
  }
979
1030
  // ── form ──────────────────────────────────────────────────────────────────
980
1031
  // Renders children + the submit button (decorative for B3 — Enter on any
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.7.1",
3
+ "version": "0.9.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",
@@ -345,6 +345,18 @@ select.vms-field__input { cursor: pointer; padding-right: var(--vms-space-xl);
345
345
  }
346
346
  .vms-button--danger:hover { background: var(--vms-error-glow); border-color: var(--vms-error); }
347
347
 
348
+ /* ── Pending state (0.8.0 / issue #11) ──
349
+ Applied by BrowserAdapter when a ButtonNode with `pendingLabel` is clicked.
350
+ The button visibly disables (dimmed + wait cursor + ignores pointer events)
351
+ for the duration of the dispatch round-trip; the next render either
352
+ replaces the button (success path) or re-renders the current VM (error
353
+ path) so this state always clears without per-button cleanup. */
354
+ .vms-button--pending {
355
+ opacity: 0.55;
356
+ cursor: progress;
357
+ pointer-events: none;
358
+ }
359
+
348
360
  /* Inside a list item, a danger button is treated as the row's "X" affordance:
349
361
  muted and hidden until the row is hovered, then visible and red on hover. */
350
362
  .vms-list-item .vms-button--danger {