@ashley-shrok/viewmodel-shell 0.4.8 → 0.5.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/README.md CHANGED
@@ -33,6 +33,22 @@ If your backend is .NET: copy `demo/Tasks/AspNetCore/ViewModels.cs` from the [Gi
33
33
 
34
34
  For other backends, implement the same JSON shape: a `GET` returning `{ vm, state }`, and a `POST` that takes `multipart/form-data` with `_action` and `_state` form fields and returns the next `{ vm, state }`. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md) for the full wire format.
35
35
 
36
+ ## Authenticated downloads
37
+
38
+ When a header-authenticated consumer (e.g. `Authorization: Bearer <jwt>` via `ShellOptions.getRequestHeaders()`) needs to offer a file download, return a `"download"` side-effect from your action handler — the shell fetches the URL with the same headers merged in, parses `Content-Disposition` + `Content-Type`, and triggers a browser "Save As":
39
+
40
+ ```csharp
41
+ return new ShellResponse<MyState>(BuildVm(state), state)
42
+ .WithEffect(ShellSideEffect.Download("/api/invoices/42/pdf", "invoice-42.pdf"));
43
+ ```
44
+
45
+ ```typescript
46
+ return { vm: buildVm(state), state,
47
+ sideEffects: [shellSideEffect.download("/api/invoices/42/pdf", "invoice-42.pdf")] };
48
+ ```
49
+
50
+ The download endpoint stays auth-gated and the server authorizes in the action handler — no signed-URL machinery. See [AGENTS.md](https://github.com/ashley-shrok/ViewModelShell/blob/main/AGENTS.md#client-side-effects) for the wire format and pattern.
51
+
36
52
  ## Terminal (TUI)
37
53
 
38
54
  The same backend renders in a terminal — same wire, no backend change. Point the CLI at any ViewModel Shell endpoint:
package/dist/browser.d.ts CHANGED
@@ -6,6 +6,10 @@ export declare class BrowserAdapter implements Adapter {
6
6
  render(vm: ViewNode, onAction: (action: ActionEvent) => void): void;
7
7
  navigate(url: string): void;
8
8
  storage(scope: "local" | "session", key: string, value: string): void;
9
+ /** Save an authenticated-download blob via the browser's native Save-As.
10
+ * contentType is informational — the Blob's own .type takes precedence in
11
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
12
+ saveFile(data: Blob, filename: string, _contentType: string): void;
9
13
  transport(input: string, init: {
10
14
  method?: string;
11
15
  headers?: Record<string, string>;
package/dist/browser.js CHANGED
@@ -68,6 +68,26 @@ export class BrowserAdapter {
68
68
  const store = scope === "session" ? sessionStorage : localStorage;
69
69
  store.setItem(key, value);
70
70
  }
71
+ /** Save an authenticated-download blob via the browser's native Save-As.
72
+ * contentType is informational — the Blob's own .type takes precedence in
73
+ * browsers. We accept the arg for adapter symmetry (other adapters use it). */
74
+ saveFile(data, filename, _contentType) {
75
+ const url = URL.createObjectURL(data);
76
+ try {
77
+ const a = document.createElement("a");
78
+ a.href = url;
79
+ a.download = filename;
80
+ a.style.display = "none";
81
+ document.body.appendChild(a);
82
+ a.click();
83
+ a.remove();
84
+ }
85
+ finally {
86
+ // Revoke async so the browser has time to start the download. The 0ms
87
+ // setTimeout is the established pattern (Chromium/Firefox/Safari).
88
+ setTimeout(() => URL.revokeObjectURL(url), 0);
89
+ }
90
+ }
71
91
  async transport(input, init, hooks) {
72
92
  const onUploadProgress = hooks?.onUploadProgress;
73
93
  if (!onUploadProgress) {
package/dist/index.d.ts CHANGED
@@ -23,6 +23,15 @@ export interface Adapter {
23
23
  }, hooks?: {
24
24
  onUploadProgress?: (sent: number, total: number) => void;
25
25
  }): Promise<Response>;
26
+ /** Save an authenticated-download blob to platform-appropriate storage. The
27
+ * shell fetches the URL with getRequestHeaders() merged, parses
28
+ * Content-Disposition filename + Content-Type, and calls saveFile().
29
+ * No safe no-op exists — a silently-dropped authenticated download is a
30
+ * correctness/security bug (cf. navigate/storage). If a "download"
31
+ * side-effect arrives and this method is absent, the shell fails loudly.
32
+ * May return void or Promise<void>; the shell awaits the return value so
33
+ * async I/O errors surface via onError. */
34
+ saveFile?(data: Blob, filename: string, contentType: string): void | Promise<void>;
26
35
  }
27
36
  export type ViewNode = PageNode | SectionNode | ListNode | ListItemNode | FormNode | FieldNode | CheckboxNode | ButtonNode | TextNode | LinkNode | StatBarNode | TabsNode | ProgressNode | ModalNode | TableNode | CopyButtonNode;
28
37
  export interface PageNode {
@@ -199,10 +208,17 @@ export interface ShellOptions {
199
208
  pollInterval?: number;
200
209
  }
201
210
  export interface ShellSideEffect {
202
- /** "set-local-storage" | "set-session-storage" — unknown types are silently ignored. */
211
+ /** "set-local-storage" | "set-session-storage" | "download" — unknown types are silently ignored. */
203
212
  type: string;
213
+ /** For "set-local-storage" / "set-session-storage": the storage key. */
204
214
  key?: string;
215
+ /** For "set-local-storage" / "set-session-storage": the storage value. */
205
216
  value?: string;
217
+ /** For "download": the URL to fetch (shell merges getRequestHeaders() into the request). */
218
+ url?: string;
219
+ /** For "download": optional filename hint. Response Content-Disposition wins
220
+ * when present; this is the fallback before the URL basename. */
221
+ filename?: string;
206
222
  }
207
223
  export interface ShellResponse {
208
224
  vm: ViewNode;
@@ -231,4 +247,18 @@ export declare class ViewModelShell {
231
247
  private failCapability;
232
248
  private processResponse;
233
249
  private schedulePoll;
250
+ /**
251
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
252
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
253
+ * type, and hand the bytes to the adapter's platform-specific save verb.
254
+ *
255
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
256
+ * transport override (XHR for upload-progress) constructs Response from
257
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
258
+ * progress is a future, opt-in extension on its own seam.
259
+ *
260
+ * Errors (missing capability / non-OK status / adapter throw) surface
261
+ * via onError; the download() call itself never throws into the caller.
262
+ */
263
+ private download;
234
264
  }
package/dist/index.js CHANGED
@@ -101,9 +101,10 @@ export class ViewModelShell {
101
101
  failCapability(capability, detail) {
102
102
  const err = new Error(`[ViewModelShell] Adapter is missing the "${capability}" capability but the ` +
103
103
  `server response requires it (${detail}). This is a hard failure, not a no-op: ` +
104
- `a silently-dropped ${capability} (e.g. an auth token never persisted, or a ` +
105
- `redirect that never happens) is a correctness/security bug. Implement ` +
106
- `${capability}() on your Adapter, or (for redirect) pass ShellOptions.onRedirect.`);
104
+ `a silently-dropped ${capability} (e.g. an auth token never persisted, a ` +
105
+ `redirect that never happens, or an authenticated download silently swallowed) ` +
106
+ `is a correctness/security bug. Implement ${capability}() on your Adapter, ` +
107
+ `or (for redirect) pass ShellOptions.onRedirect.`);
107
108
  this.options.onError ? this.options.onError(err) : console.error("[ViewModelShell]", err);
108
109
  }
109
110
  processResponse(body) {
@@ -121,6 +122,12 @@ export class ViewModelShell {
121
122
  else
122
123
  this.failCapability("storage", `side-effect "${effect.type}" key="${effect.key}"`);
123
124
  }
125
+ else if (effect.type === "download" && effect.url != null) {
126
+ // Fire-and-forget. Surfaces errors via onError. Does not block the
127
+ // render/redirect branch below — downloads are a side channel, like
128
+ // storage, and a slow download MUST NOT delay the user-visible update.
129
+ void this.download(effect.url, effect.filename);
130
+ }
124
131
  }
125
132
  if (body.redirect) {
126
133
  if (this.options.onRedirect) {
@@ -150,4 +157,93 @@ export class ViewModelShell {
150
157
  this.dispatch({ name: "poll" }, true);
151
158
  }, delay);
152
159
  }
160
+ /**
161
+ * Authenticated download: fetch the URL with getRequestHeaders() merged
162
+ * (Bearer / anti-forgery / etc.), parse the response filename + content
163
+ * type, and hand the bytes to the adapter's platform-specific save verb.
164
+ *
165
+ * Deliberately uses core `fetch`, NOT adapter.transport. The existing
166
+ * transport override (XHR for upload-progress) constructs Response from
167
+ * xhr.responseText (a string) and would corrupt binary blobs. Download
168
+ * progress is a future, opt-in extension on its own seam.
169
+ *
170
+ * Errors (missing capability / non-OK status / adapter throw) surface
171
+ * via onError; the download() call itself never throws into the caller.
172
+ */
173
+ async download(url, hintFilename) {
174
+ const adapter = this.options.adapter;
175
+ if (!adapter.saveFile) {
176
+ this.failCapability("saveFile", `download from "${url}"`);
177
+ return;
178
+ }
179
+ try {
180
+ const extraHeaders = this.options.getRequestHeaders
181
+ ? await this.options.getRequestHeaders()
182
+ : {};
183
+ const res = await fetch(url, { headers: extraHeaders });
184
+ if (!res.ok) {
185
+ throw new Error(`Download from ${url} failed: ${res.status} ${res.statusText}`);
186
+ }
187
+ const contentType = res.headers.get("Content-Type") ?? "application/octet-stream";
188
+ const filename = parseContentDispositionFilename(res.headers.get("Content-Disposition")) ??
189
+ hintFilename ??
190
+ basenameFromUrl(url) ??
191
+ "download";
192
+ const blob = await res.blob();
193
+ await adapter.saveFile(blob, filename, contentType);
194
+ }
195
+ catch (err) {
196
+ const error = err instanceof Error ? err : new Error(String(err));
197
+ this.options.onError ? this.options.onError(error) : console.error("[ViewModelShell]", error);
198
+ }
199
+ }
200
+ }
201
+ // ─── Download helpers (file-private; no platform globals) ────────────────────
202
+ // `Blob` and `URL` are universals (browser, Node 18+, Deno, Bun) — confirmed
203
+ // off the check:core-globals denylist. `URL.createObjectURL` is browser-only
204
+ // and stays in BrowserAdapter behind the capability seam.
205
+ /**
206
+ * Parse a `Content-Disposition` header for a filename. RFC 5987 `filename*`
207
+ * (UTF-8, percent-encoded, internationalized) wins over the plain `filename`
208
+ * parameter when both are present — that's the spec-canonical preference.
209
+ * Returns null when the header is absent or no filename parameter is found.
210
+ */
211
+ function parseContentDispositionFilename(header) {
212
+ if (header == null)
213
+ return null;
214
+ // RFC 5987 extended form: filename*=UTF-8''<percent-encoded>
215
+ const ext = /filename\*\s*=\s*([^']*)'[^']*'([^;]+)/i.exec(header);
216
+ if (ext) {
217
+ try {
218
+ const decoded = decodeURIComponent(ext[2].trim());
219
+ if (decoded.length > 0)
220
+ return decoded;
221
+ }
222
+ catch {
223
+ // Malformed percent-encoding — fall through to plain `filename`.
224
+ }
225
+ }
226
+ // Plain form: filename="..." or filename=...
227
+ const plain = /filename\s*=\s*"([^"]*)"|filename\s*=\s*([^;]+)/i.exec(header);
228
+ if (plain) {
229
+ const val = (plain[1] ?? plain[2] ?? "").trim();
230
+ if (val.length > 0)
231
+ return val;
232
+ }
233
+ return null;
234
+ }
235
+ /**
236
+ * Extract a basename from a URL (relative or absolute). Returns null if the
237
+ * path is empty or ends with `/`. Uses a dummy base so relative URLs parse;
238
+ * the base is discarded.
239
+ */
240
+ function basenameFromUrl(url) {
241
+ try {
242
+ const u = new URL(url, "http://_/");
243
+ const last = u.pathname.split("/").pop();
244
+ return last && last.length > 0 ? decodeURIComponent(last) : null;
245
+ }
246
+ catch {
247
+ return null;
248
+ }
153
249
  }
package/dist/server.d.ts CHANGED
@@ -25,6 +25,13 @@ export declare function shellRedirect<TState = unknown>(url: string): ShellRespo
25
25
  export declare const shellSideEffect: {
26
26
  setLocalStorage: (key: string, value: string) => ShellSideEffect;
27
27
  setSessionStorage: (key: string, value: string) => ShellSideEffect;
28
+ /** Server-decided authenticated download. The shell fetches `url` with
29
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
30
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
31
+ * `filename` is a fallback used only when Content-Disposition is absent.
32
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
33
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
34
+ download: (url: string, filename?: string) => ShellSideEffect;
28
35
  };
29
36
  /**
30
37
  * Web Fetch API–native request handler factory. Auto-detects content-type
package/dist/server.js CHANGED
@@ -50,6 +50,13 @@ export function shellRedirect(url) {
50
50
  export const shellSideEffect = {
51
51
  setLocalStorage: (key, value) => ({ type: "set-local-storage", key, value }),
52
52
  setSessionStorage: (key, value) => ({ type: "set-session-storage", key, value }),
53
+ /** Server-decided authenticated download. The shell fetches `url` with
54
+ * getRequestHeaders() merged (Bearer / anti-forgery / etc.), parses
55
+ * Content-Disposition + Content-Type, and saves via Adapter.saveFile.
56
+ * `filename` is a fallback used only when Content-Disposition is absent.
57
+ * The conditional spread keeps `filename` ABSENT (not undefined) on the
58
+ * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
59
+ download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
53
60
  };
54
61
  // ─── Action handler factory ──────────────────────────────────────────────────
55
62
  /**
package/dist/tui.d.ts CHANGED
@@ -62,12 +62,18 @@ export declare class TuiAdapter implements Adapter {
62
62
  * interactive TTY; "content" = legacy intrinsic-content size, no
63
63
  * alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
64
64
  private readonly viewport;
65
+ /** `sidebar` rail width as a fraction of the terminal (fill path only).
66
+ * Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
67
+ * the layout. Adapter-level styling knob (NOT wire — appearance, not
68
+ * arrangement). */
69
+ private readonly sidebarFraction;
65
70
  /** Set once ESC[?1049h was written, so dispose() emits the paired
66
71
  * ESC[?1049l exactly once (idempotent restore — same discipline as the
67
72
  * cursor restore Ink does on unmount). */
68
73
  private altEntered;
69
74
  constructor(opts?: {
70
75
  viewport?: "fill" | "content";
76
+ sidebarFraction?: number;
71
77
  });
72
78
  /** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
73
79
  * mode, is delivered as input 0x03 and never raises SIGINT) reach the
@@ -112,6 +118,13 @@ export declare class TuiAdapter implements Adapter {
112
118
  * or an unparseable EXISTING store surfaces the loud interstitial + a
113
119
  * stderr line and does NOT clobber a possibly-unrelated user file. */
114
120
  storage(scope: "local" | "session", key: string, value: string): void;
121
+ /** Save an authenticated-download blob to the terminal user's filesystem.
122
+ * Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
123
+ * process.cwd(). The chosen path is printed to stderr so the operator
124
+ * can find it. The filename is sanitized (no path components / no `..`)
125
+ * so a hostile/buggy server can't escape the download dir; this is the
126
+ * TUI counterpart to the browser's built-in download-name sanitization. */
127
+ saveFile(data: Blob, filename: string, _contentType: string): Promise<void>;
115
128
  /** Test-only: read back a session value. The wire contract has no storage
116
129
  * read; this exists solely so a unit test can prove the write landed
117
130
  * (same rationale as exporting osc52 for a deterministic test). */
package/dist/tui.js CHANGED
@@ -4,7 +4,7 @@ import { render as inkRender, Box, Text, useInput, useStdin, useStdout, } from "
4
4
  import TextInput from "ink-text-input";
5
5
  import SelectInput from "ink-select-input";
6
6
  import { spawn } from "node:child_process";
7
- import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
8
8
  import { homedir } from "node:os";
9
9
  import { join } from "node:path";
10
10
  const NO_CTX = {
@@ -990,6 +990,7 @@ function App(props) {
990
990
  interactive,
991
991
  fill: fillViewport,
992
992
  fillCols: fillViewport ? vp.cols : undefined,
993
+ railFraction: props.railFraction,
993
994
  draft: (k) => draftFor(k),
994
995
  onFieldChange: (k, v) => setDraft((s) => ({ ...s, [k]: v })),
995
996
  onFieldSubmit,
@@ -1046,12 +1047,19 @@ export class TuiAdapter {
1046
1047
  * interactive TTY; "content" = legacy intrinsic-content size, no
1047
1048
  * alt-screen (the opt-out escape hatch — pre-0.4.5 behavior). */
1048
1049
  viewport;
1050
+ /** `sidebar` rail width as a fraction of the terminal (fill path only).
1051
+ * Default 1/3; clamped to a sane [0.15, 0.6] so a bad value can't break
1052
+ * the layout. Adapter-level styling knob (NOT wire — appearance, not
1053
+ * arrangement). */
1054
+ sidebarFraction;
1049
1055
  /** Set once ESC[?1049h was written, so dispose() emits the paired
1050
1056
  * ESC[?1049l exactly once (idempotent restore — same discipline as the
1051
1057
  * cursor restore Ink does on unmount). */
1052
1058
  altEntered = false;
1053
1059
  constructor(opts) {
1054
1060
  this.viewport = opts?.viewport ?? "fill";
1061
+ const f = opts?.sidebarFraction ?? 1 / 3;
1062
+ this.sidebarFraction = Math.min(0.6, Math.max(0.15, f));
1055
1063
  }
1056
1064
  /** Injected by tui-cli.ts: lets a keyboard Ctrl-C (which, under Ink's raw
1057
1065
  * mode, is delivered as input 0x03 and never raises SIGINT) reach the
@@ -1107,7 +1115,7 @@ export class TuiAdapter {
1107
1115
  }
1108
1116
  /** The interactive element used by render() and by interaction unit tests. */
1109
1117
  createApp(vm, onAction, opts) {
1110
- return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
1118
+ return (_jsx(App, { vm: vm, onAction: onAction, requestExit: opts?.requestExit ?? this.requestExit, interstitial: this.interstitial, viewport: this.viewport, railFraction: this.sidebarFraction, renderWith: (v, rctx) => this.renderNode(v, 0, "comfortable", undefined, rctx) }));
1111
1119
  }
1112
1120
  /** Render a full-screen loud notice through the SINGLE existing Ink instance
1113
1121
  * (never a 2nd inkRender, never a raw stdout write that would corrupt Ink's
@@ -1182,6 +1190,37 @@ export class TuiAdapter {
1182
1190
  this.showInterstitial(`Storage write FAILED — your data was NOT saved.\n\n key: ${key}\n error: ${m}`);
1183
1191
  }
1184
1192
  }
1193
+ /** Save an authenticated-download blob to the terminal user's filesystem.
1194
+ * Directory precedence: $XDG_DOWNLOAD_DIR → ~/Downloads (if it exists) →
1195
+ * process.cwd(). The chosen path is printed to stderr so the operator
1196
+ * can find it. The filename is sanitized (no path components / no `..`)
1197
+ * so a hostile/buggy server can't escape the download dir; this is the
1198
+ * TUI counterpart to the browser's built-in download-name sanitization. */
1199
+ async saveFile(data, filename, _contentType) {
1200
+ const xdg = process.env.XDG_DOWNLOAD_DIR;
1201
+ const home = homedir();
1202
+ const dir = xdg && xdg.trim()
1203
+ ? xdg
1204
+ : existsSync(join(home, "Downloads")) ? join(home, "Downloads") : process.cwd();
1205
+ mkdirSync(dir, { recursive: true });
1206
+ // Strip path separators (POSIX + Windows) and any leading dots so a
1207
+ // server-supplied "../etc/passwd" or "foo/bar" becomes a flat basename.
1208
+ // Empty/dot-only results fall back to literal "download".
1209
+ const sanitized = filename
1210
+ .split(/[/\\]/).pop() // drop everything before the last separator
1211
+ .replace(/^\.+/, "") // strip leading dots (no hidden files via traversal)
1212
+ .trim();
1213
+ const safeName = sanitized.length > 0 ? sanitized : "download";
1214
+ const out = join(dir, safeName);
1215
+ const buf = Buffer.from(await data.arrayBuffer());
1216
+ writeFileSync(out, buf);
1217
+ try {
1218
+ process.stderr.write(`vms-tui: saved ${out}\n`);
1219
+ }
1220
+ catch {
1221
+ /* stderr unavailable — the file landed regardless */
1222
+ }
1223
+ }
1185
1224
  /** Test-only: read back a session value. The wire contract has no storage
1186
1225
  * read; this exists solely so a unit test can prove the write landed
1187
1226
  * (same rationale as exporting osc52 for a deterministic test). */
@@ -1275,7 +1314,15 @@ export class TuiAdapter {
1275
1314
  // ORIGINAL flex props, byte-identical.
1276
1315
  const cols = topLevel && rctx.fillCols ? rctx.fillCols : 0;
1277
1316
  const filling = cols > 0;
1278
- const RAIL = 24;
1317
+ // 0.4.9 PROPORTIONAL rail (default 1/3, clamped [24,56]) instead of
1318
+ // a hardcoded 24 (unusable on wide terminals: 24/146 ≈ 16%). Never
1319
+ // below the old 24 on small terminals; capped so ultra-wide keeps the
1320
+ // detail pane dominant. Tunable via `new TuiAdapter({ sidebarFraction })`.
1321
+ // Only the filling branches use RAIL; the non-fill path keeps the
1322
+ // literal flexBasis:24/minWidth:18 (byte-identical).
1323
+ const RAIL = filling
1324
+ ? Math.min(56, Math.max(24, Math.round(cols * (rctx.railFraction ?? 1 / 3))))
1325
+ : 24;
1279
1326
  const g = sp.gap || 1;
1280
1327
  const mw = Math.max(1, cols - RAIL - g);
1281
1328
  if (rest.length === 0) {
@@ -1284,7 +1331,11 @@ export class TuiAdapter {
1284
1331
  // numeric width (a default-row box would not width-stretch it).
1285
1332
  _jsx(Box, { flexDirection: "column", flexShrink: 0, ...fillW, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail }));
1286
1333
  }
1287
- return (_jsxs(Box, { flexDirection: "row", gap: g, ...fillW, children: [filling ? (_jsx(Box, { flexShrink: 0, width: RAIL, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail })), filling ? (
1334
+ return (_jsxs(Box, { flexDirection: "row", gap: g, ...fillW, children: [filling ? (
1335
+ // flexDirection:column ⇒ the rail's section align-stretches to
1336
+ // the numeric RAIL width (a default-row box sizes it to content
1337
+ // — same lesson as the single-child branch).
1338
+ _jsx(Box, { flexDirection: "column", flexShrink: 0, width: RAIL, children: rail })) : (_jsx(Box, { flexShrink: 0, flexBasis: 24, minWidth: 18, children: rail })), filling ? (
1288
1339
  // Single numeric-width column directly holding the detail
1289
1340
  // sections — exactly the `stack` shape the oracle proves scales
1290
1341
  // (a card section align-stretches to a numeric-width parent).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "0.4.8",
3
+ "version": "0.5.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",