@ashley-shrok/viewmodel-shell 0.7.0 → 0.8.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 +32 -2
- package/dist/index.d.ts +7 -0
- package/dist/index.js +9 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +49 -4
- package/package.json +1 -1
- package/styles/default.css +12 -0
package/dist/browser.js
CHANGED
|
@@ -25,6 +25,15 @@ export class BrowserAdapter {
|
|
|
25
25
|
const focusId = active?.id || null;
|
|
26
26
|
const selStart = active?.selectionStart ?? null;
|
|
27
27
|
const selEnd = active?.selectionEnd ?? null;
|
|
28
|
+
// 0.7.1 (#7) — snapshot the WINDOW scroll position alongside element-level
|
|
29
|
+
// scroll. Without this, an action-driven re-render rebuilds the entire
|
|
30
|
+
// subtree and the viewport jumps (to top, or wherever HTMLElement.focus()
|
|
31
|
+
// scrolled the restored-focus element into view). Same preservation
|
|
32
|
+
// contract as element scroll: preserve unless the server explicitly
|
|
33
|
+
// navigates (a redirect IS a navigation, so it correctly does NOT
|
|
34
|
+
// round-trip through render — it goes through navigate()).
|
|
35
|
+
const winScrollX = window.scrollX;
|
|
36
|
+
const winScrollY = window.scrollY;
|
|
28
37
|
const scrollMap = new Map();
|
|
29
38
|
this.container.querySelectorAll("[id]").forEach(el => {
|
|
30
39
|
if (el.scrollTop !== 0 || el.scrollLeft !== 0)
|
|
@@ -44,7 +53,11 @@ export class BrowserAdapter {
|
|
|
44
53
|
if (focusId) {
|
|
45
54
|
const el = this.container.querySelector(`#${CSS.escape(focusId)}`);
|
|
46
55
|
if (el) {
|
|
47
|
-
|
|
56
|
+
// 0.7.1 (#7) — preventScroll stops focus() from yanking the viewport
|
|
57
|
+
// to the focused element. The window-scroll restore below still
|
|
58
|
+
// overrides any scroll that snuck in, but preventing the scroll in
|
|
59
|
+
// the first place is the cleaner contract.
|
|
60
|
+
el.focus({ preventScroll: true });
|
|
48
61
|
if (selStart !== null && selEnd !== null) {
|
|
49
62
|
try {
|
|
50
63
|
el.setSelectionRange(selStart, selEnd);
|
|
@@ -60,6 +73,10 @@ export class BrowserAdapter {
|
|
|
60
73
|
el.scrollLeft = left;
|
|
61
74
|
}
|
|
62
75
|
});
|
|
76
|
+
// 0.7.1 (#7) — restore the window scroll LAST so any defensive
|
|
77
|
+
// browser behavior earlier in this method (e.g. a future element
|
|
78
|
+
// bringing itself into view) gets overridden by the snapshot.
|
|
79
|
+
window.scrollTo(winScrollX, winScrollY);
|
|
63
80
|
}
|
|
64
81
|
navigate(url) {
|
|
65
82
|
window.location.href = url;
|
|
@@ -454,7 +471,20 @@ export class BrowserAdapter {
|
|
|
454
471
|
btn.type = "button";
|
|
455
472
|
btn.className = `vms-button${n.variant ? ` vms-button--${n.variant}` : ""}`;
|
|
456
473
|
btn.textContent = n.label;
|
|
457
|
-
btn.addEventListener("click", () =>
|
|
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
|
+
});
|
|
458
488
|
parent.appendChild(btn);
|
|
459
489
|
}
|
|
460
490
|
text(n, parent) {
|
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";
|
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 = () =>
|
|
887
|
-
|
|
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]" : "[ ]";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ashley-shrok/viewmodel-shell",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.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",
|
package/styles/default.css
CHANGED
|
@@ -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 {
|