@ashley-shrok/viewmodel-shell 3.9.0 → 4.0.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/agent-skill.md CHANGED
@@ -139,6 +139,8 @@ If a response carries `nextPollIn: N`, schedule a POST `{ "name": "poll", "state
139
139
 
140
140
  File uploads use the multipart form above. One form entry per file input, keyed by the input's `name` attribute (from the corresponding node's `name` field in the tree). The file's binary content is the entry's value. JSON-body dispatch cannot carry files; use multipart.
141
141
 
142
+ **A file rides only the action(s) its input declares.** Each file `FieldNode` carries an `uploadOn` array of action names. Send a file's binary entry **only** when the action you are dispatching (`_action.name`) is listed in that file input's `uploadOn`; if you dispatch any other action, do **not** include the file. A file input with no `uploadOn` (absent or empty) rides **nothing** — its binary is never sent. This mirrors the browser, where the same declaration decides which click sends the file: an agent should not attach a file to an action a human's click could not have sent it with. (There is no positional/implicit rule — the file's own `uploadOn` is the whole contract.)
143
+
142
144
  ## Versioning
143
145
 
144
146
  This manual applies to protocol token `viewmodel-shell/1.0` — the value of the `protocol` field on the discoverability meta tag. The protocol token tracks the wire shape, NOT the package version: a 1.5.x or 1.6.x package release may still carry protocol `viewmodel-shell/1.0` because the wire has not undergone a breaking change. A future major-version bump (`viewmodel-shell/2.0`) signals a breaking change and invalidates this manual; expect a new skill at the same `/.well-known/vms-skill.md` URL.
package/dist/browser.js CHANGED
@@ -601,11 +601,26 @@ export class BrowserAdapter {
601
601
  const form = document.createElement("form");
602
602
  form.className = `vms-form${n.layout && n.layout !== "stack" ? ` vms-form--${n.layout}` : ""}`;
603
603
  form.noValidate = true;
604
- this.kids(n.children, form, on);
604
+ // File collection is by DECLARED intent, not button position: a file input
605
+ // rides an action iff that action's name is listed in the input's `uploadOn`
606
+ // (carried here via the data-vms-upload-on attribute set in field()). EVERY
607
+ // trigger inside the form — submit, buttons[], a ButtonNode or
608
+ // FieldNode.action nested in children — routes through this one path, so
609
+ // where a trigger sits is irrelevant; the file's own uploadOn decides. An
610
+ // input with no uploadOn rides nothing (there is no positional fallback).
605
611
  const dispatchWithFiles = (action) => {
606
612
  const files = {};
607
613
  form.querySelectorAll("input[type=file]").forEach(inp => {
608
- if (inp.name && inp.files?.[0])
614
+ if (!inp.name || !inp.files?.[0])
615
+ return;
616
+ let uploadOn = [];
617
+ try {
618
+ uploadOn = JSON.parse(inp.dataset.vmsUploadOn ?? "[]");
619
+ }
620
+ catch {
621
+ uploadOn = [];
622
+ }
623
+ if (uploadOn.includes(action.name))
609
624
  files[inp.name] = inp.files[0];
610
625
  });
611
626
  const ev = { name: action.name };
@@ -613,6 +628,10 @@ export class BrowserAdapter {
613
628
  ev.files = files;
614
629
  on(ev);
615
630
  };
631
+ // Children dispatch through the file-aware path too — so a ButtonNode (or a
632
+ // FieldNode.action Enter) nested anywhere in the form carries files per the
633
+ // uploadOn contract, identical to a footer buttons[] trigger.
634
+ this.kids(n.children, form, dispatchWithFiles);
616
635
  // #22 — submitButton takes precedence: the form renders the consumer's own
617
636
  // button (its label + emphasis/tone/size/width) as the submit and fires its
618
637
  // action; submitLabel/submitAction for the implicit button are then ignored.
@@ -809,6 +828,10 @@ export class BrowserAdapter {
809
828
  inp.className = "vms-field__input";
810
829
  inp.id = `vms-${n.name}`;
811
830
  inp.name = n.name;
831
+ // Carry the declared upload routing to dispatch time — form()'s
832
+ // dispatchWithFiles reads this and attaches the file only to an action
833
+ // named here. Absent/empty => the file rides no action.
834
+ inp.dataset.vmsUploadOn = JSON.stringify(n.uploadOn ?? []);
812
835
  // File-input persistence: re-apply any registered file to the new node.
813
836
  const existingFile = this.fileRegistry.get(n.name);
814
837
  if (existingFile) {
@@ -823,6 +846,13 @@ export class BrowserAdapter {
823
846
  const file = inp.files?.[0];
824
847
  if (file) {
825
848
  this.fileRegistry.set(n.name, file);
849
+ // [vms:orphan-file] — a picked file that declares no uploadOn action
850
+ // will never be sent (the binary rides an action, and this input
851
+ // names none). Silent under-attach is the dangerous failure, so warn.
852
+ if (!n.uploadOn || n.uploadOn.length === 0) {
853
+ this.warnOnce("orphan-file:" + n.name, "[vms:orphan-file] file field '" + n.name + "' has a picked file but no uploadOn action — " +
854
+ "its binary will not be sent; add uploadOn:[\"<action>\"] naming the action that should carry it.");
855
+ }
826
856
  // [vms:type-mismatch] — OBSERVABLE-SUBSET diagnostic. The client is
827
857
  // untyped JS: it CANNOT know a state slot's *declared* server type, so
828
858
  // it only catches the observable case where a file object overwrites a
package/dist/index.d.ts CHANGED
@@ -255,7 +255,10 @@ export interface FormNode {
255
255
  * `bind` path and travel with the dispatch's `_state` payload. Mirrors
256
256
  * HTML's multiple submit buttons / `formaction` — different action per
257
257
  * button, same underlying state. A plain ButtonNode placed in `children`
258
- * has identical dispatch semantics; the buttons[] slot is a layout hint. */
258
+ * has identical dispatch semantics: both flow through the same file-aware
259
+ * dispatch, so file collection is governed by each file input's `uploadOn`
260
+ * (below), NOT by whether the trigger sits in `buttons[]` or `children`. The
261
+ * buttons[] slot is purely a layout hint. */
259
262
  buttons?: ButtonNode[];
260
263
  /** Opt-in: bare Enter inside a descendant <textarea> dispatches submitAction
261
264
  * (chat-composer "Enter sends, Shift/Ctrl/Meta/Alt+Enter = newline"). No-op
@@ -320,6 +323,15 @@ export interface FieldNode {
320
323
  /** Dispatched when Enter is pressed (text-like inputs only). Carries an
321
324
  * action name only — the current value is already in state at the bind path. */
322
325
  action?: ActionEvent;
326
+ /** FILE INPUTS ONLY. The action name(s) whose dispatch carries this file's
327
+ * binary over the multipart wire. A file rides an action iff that action's
328
+ * name is listed here — declared on the *file*, so which trigger sends it no
329
+ * longer depends on where a button sits (the trigger can live anywhere in the
330
+ * form; footer `buttons[]`, `children`, submit, and Enter all honor this
331
+ * equally). An absent or empty `uploadOn` means the file rides **nothing**
332
+ * (there is no positional fallback); the browser warns `[vms:orphan-file]`
333
+ * when a file is picked with no `uploadOn`. Ignored on non-file inputs. */
334
+ uploadOn?: string[];
323
335
  }
324
336
  export interface CheckboxNode {
325
337
  type: "checkbox";
package/dist/vite.d.ts ADDED
@@ -0,0 +1,68 @@
1
+ import type { Plugin } from "vite";
2
+ /**
3
+ * Hash the raw bytes of a Vite `manifest.json` into a VMS client build-id.
4
+ *
5
+ * The LOCKED cross-backend contract: SHA-256 of the **raw file bytes on disk**
6
+ * → the **first `hashLength` hex chars, LOWERCASE**. No re-serialize, no JSON
7
+ * normalization, no BOM handling — both the npm plugin and the .NET
8
+ * `VmsManifestBuildId.Compute` hash the exact same bytes so a client's
9
+ * compiled-in id matches the server-computed id byte-for-byte across the fleet.
10
+ *
11
+ * Exported so the cross-backend hash-lock test can call it directly against the
12
+ * shared fixture bytes.
13
+ *
14
+ * @param bytes Raw `manifest.json` file bytes.
15
+ * @param hashLength Number of leading hex chars to keep (default 12).
16
+ */
17
+ export declare function vmsHashManifestBytes(bytes: Uint8Array, hashLength?: number): string;
18
+ /**
19
+ * Options for {@link vmsBuildIdPlugin}.
20
+ */
21
+ export interface VmsBuildIdPluginOptions {
22
+ /**
23
+ * File extensions of emitted chunks whose source should have the build-id
24
+ * placeholder substituted. Default `[".js"]` — the shell bundle is JS, so CSS
25
+ * and other assets are left untouched (and never carry the placeholder).
26
+ */
27
+ extensions?: string[];
28
+ /**
29
+ * Number of leading hex chars of the SHA-256 to use as the build id.
30
+ * Default 12 (the locked contract shared with the .NET side).
31
+ */
32
+ hashLength?: number;
33
+ }
34
+ /**
35
+ * Vite plugin that stamps a content-hash of the built `manifest.json` into the
36
+ * client bundle so `ShellOptions.clientBuildId` matches the server's
37
+ * `AddVmsShellVersioning()` hash for version-skew detection.
38
+ *
39
+ * ## How it works (the chicken-and-egg fix)
40
+ * Vite writes chunk content-hashes into `manifest.json` during the bundle, so
41
+ * the final manifest hash isn't known until after the bundle is written. The
42
+ * plugin:
43
+ *
44
+ * 1. `config` hook — injects `import.meta.env.VITE_VMS_BUILD` as a unique
45
+ * internal placeholder token via `define`, and (if unset) turns on
46
+ * `build.manifest = "manifest.json"` at the server-read path.
47
+ * 2. `writeBundle` hook — reads the emitted `manifest.json` raw bytes, computes
48
+ * the build id via {@link vmsHashManifestBytes}, and string-replaces the
49
+ * placeholder in every emitted chunk whose filename ends in one of
50
+ * `extensions` **that actually contains the placeholder** (the skip-guard —
51
+ * so non-shell chunks are never rewritten).
52
+ *
53
+ * ## The `build.manifest` gotcha it kills
54
+ * Vite 5+ defaults the manifest to `.vite/manifest.json`, but the .NET server
55
+ * reads `wwwroot/manifest.json`. If those diverge the two sides hash different
56
+ * inputs and skew detection silently breaks. This plugin sets
57
+ * `build.manifest = "manifest.json"` when unset. If the app has ALREADY set a
58
+ * DIFFERENT manifest path, the plugin does NOT override it — it emits a `[vms]`
59
+ * warning so the divergence is loud, not silent.
60
+ *
61
+ * ## Fleet constraint — no post-build modification of `manifest.json`
62
+ * The server hashes `manifest.json` at startup; the client compiled-in id is
63
+ * hashed at build time. A deploy-pipeline step that minifies / prettifies /
64
+ * re-formats `manifest.json` between Vite emit and .NET startup changes the raw
65
+ * bytes and diverges the two hashes. Ship the manifest byte-for-byte as Vite
66
+ * wrote it.
67
+ */
68
+ export declare function vmsBuildIdPlugin(options?: VmsBuildIdPluginOptions): Plugin;
package/dist/vite.js ADDED
@@ -0,0 +1,116 @@
1
+ // ─── ViewModel Shell — vite subpath (3.11.0) ─────────────────────────────────
2
+ // A Vite plugin that packages the version-skew (3.8.0) client build-id contract
3
+ // so adopters stop hand-rolling the ~40-line placeholder/writeBundle glue.
4
+ //
5
+ // Adoption drops to two lines:
6
+ // // vite.config.ts
7
+ // import { vmsBuildIdPlugin } from "@ashley-shrok/viewmodel-shell/vite";
8
+ // export default { plugins: [vmsBuildIdPlugin()] };
9
+ // // shell init
10
+ // new ViewModelShell({ …, clientBuildId: import.meta.env.VITE_VMS_BUILD });
11
+ //
12
+ // The `vite` import is TYPE-ONLY (`import type`), so the built `dist/vite.js`
13
+ // carries NO runtime `require("vite")` — non-Vite consumers of the root package
14
+ // never pull vite in. Node builtins (`fs`/`crypto`/`path`) are fine here: the
15
+ // core-globals CI guard only scopes `src/index.ts`.
16
+ import { createHash, randomBytes } from "node:crypto";
17
+ import { readFileSync, writeFileSync } from "node:fs";
18
+ import { join } from "node:path";
19
+ /**
20
+ * Hash the raw bytes of a Vite `manifest.json` into a VMS client build-id.
21
+ *
22
+ * The LOCKED cross-backend contract: SHA-256 of the **raw file bytes on disk**
23
+ * → the **first `hashLength` hex chars, LOWERCASE**. No re-serialize, no JSON
24
+ * normalization, no BOM handling — both the npm plugin and the .NET
25
+ * `VmsManifestBuildId.Compute` hash the exact same bytes so a client's
26
+ * compiled-in id matches the server-computed id byte-for-byte across the fleet.
27
+ *
28
+ * Exported so the cross-backend hash-lock test can call it directly against the
29
+ * shared fixture bytes.
30
+ *
31
+ * @param bytes Raw `manifest.json` file bytes.
32
+ * @param hashLength Number of leading hex chars to keep (default 12).
33
+ */
34
+ export function vmsHashManifestBytes(bytes, hashLength = 12) {
35
+ return createHash("sha256").update(bytes).digest("hex").slice(0, hashLength);
36
+ }
37
+ /**
38
+ * Vite plugin that stamps a content-hash of the built `manifest.json` into the
39
+ * client bundle so `ShellOptions.clientBuildId` matches the server's
40
+ * `AddVmsShellVersioning()` hash for version-skew detection.
41
+ *
42
+ * ## How it works (the chicken-and-egg fix)
43
+ * Vite writes chunk content-hashes into `manifest.json` during the bundle, so
44
+ * the final manifest hash isn't known until after the bundle is written. The
45
+ * plugin:
46
+ *
47
+ * 1. `config` hook — injects `import.meta.env.VITE_VMS_BUILD` as a unique
48
+ * internal placeholder token via `define`, and (if unset) turns on
49
+ * `build.manifest = "manifest.json"` at the server-read path.
50
+ * 2. `writeBundle` hook — reads the emitted `manifest.json` raw bytes, computes
51
+ * the build id via {@link vmsHashManifestBytes}, and string-replaces the
52
+ * placeholder in every emitted chunk whose filename ends in one of
53
+ * `extensions` **that actually contains the placeholder** (the skip-guard —
54
+ * so non-shell chunks are never rewritten).
55
+ *
56
+ * ## The `build.manifest` gotcha it kills
57
+ * Vite 5+ defaults the manifest to `.vite/manifest.json`, but the .NET server
58
+ * reads `wwwroot/manifest.json`. If those diverge the two sides hash different
59
+ * inputs and skew detection silently breaks. This plugin sets
60
+ * `build.manifest = "manifest.json"` when unset. If the app has ALREADY set a
61
+ * DIFFERENT manifest path, the plugin does NOT override it — it emits a `[vms]`
62
+ * warning so the divergence is loud, not silent.
63
+ *
64
+ * ## Fleet constraint — no post-build modification of `manifest.json`
65
+ * The server hashes `manifest.json` at startup; the client compiled-in id is
66
+ * hashed at build time. A deploy-pipeline step that minifies / prettifies /
67
+ * re-formats `manifest.json` between Vite emit and .NET startup changes the raw
68
+ * bytes and diverges the two hashes. Ship the manifest byte-for-byte as Vite
69
+ * wrote it.
70
+ */
71
+ export function vmsBuildIdPlugin(options = {}) {
72
+ const extensions = options.extensions ?? [".js"];
73
+ const hashLength = options.hashLength ?? 12;
74
+ // Unique per plugin instance — an internal implementation detail, NOT an
75
+ // option, so two co-configured tools can't collide on the token text.
76
+ const placeholder = "__VMS_BUILD_" + randomBytes(4).toString("hex") + "__";
77
+ return {
78
+ name: "vms-build-id",
79
+ apply: "build",
80
+ config(userConfig) {
81
+ const existing = userConfig.build?.manifest;
82
+ if (existing !== undefined && existing !== "manifest.json") {
83
+ // eslint-disable-next-line no-console
84
+ console.warn(`[vms] build.manifest is set to ${JSON.stringify(existing)}, but the .NET ` +
85
+ `server reads "manifest.json" (wwwroot/manifest.json). The client and server ` +
86
+ `will hash different files and version-skew detection will not work. ` +
87
+ `Set build.manifest to "manifest.json" (or remove it and let vmsBuildIdPlugin set it).`);
88
+ }
89
+ // Return a partial (merge-friendly) config per Vite's `config` hook contract.
90
+ return {
91
+ define: {
92
+ "import.meta.env.VITE_VMS_BUILD": JSON.stringify(placeholder),
93
+ },
94
+ ...(existing === undefined
95
+ ? { build: { manifest: "manifest.json" } }
96
+ : {}),
97
+ };
98
+ },
99
+ writeBundle(outputOptions, bundle) {
100
+ const dir = outputOptions.dir ?? "";
101
+ const manifestBytes = readFileSync(join(dir, "manifest.json"));
102
+ const buildId = vmsHashManifestBytes(manifestBytes, hashLength);
103
+ for (const fileName of Object.keys(bundle)) {
104
+ if (!extensions.some((ext) => fileName.endsWith(ext)))
105
+ continue;
106
+ const p = join(dir, fileName);
107
+ const src = readFileSync(p, "utf-8");
108
+ // Skip-guard: only rewrite chunks that actually carry the placeholder,
109
+ // so non-shell chunks are never touched (no double-writes).
110
+ if (src.includes(placeholder)) {
111
+ writeFileSync(p, src.replaceAll(placeholder, buildId));
112
+ }
113
+ }
114
+ },
115
+ };
116
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "3.9.0",
3
+ "version": "4.0.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 \u2014 a .NET reference backend ships with the repo, but any language can produce the JSON contract.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -47,6 +47,7 @@
47
47
  "jsdom": "^25.0.1",
48
48
  "react": "^19.2.0",
49
49
  "typescript": "^5.4.0",
50
+ "vite": ">=5",
50
51
  "vitest": "^2.1.4"
51
52
  },
52
53
  "optionalDependencies": {
@@ -54,6 +55,14 @@
54
55
  "@opentui/react": "^0.2.14",
55
56
  "react": "^19.2.0"
56
57
  },
58
+ "peerDependencies": {
59
+ "vite": ">=5"
60
+ },
61
+ "peerDependenciesMeta": {
62
+ "vite": {
63
+ "optional": true
64
+ }
65
+ },
57
66
  "exports": {
58
67
  ".": {
59
68
  "types": "./dist/index.d.ts",
@@ -71,6 +80,10 @@
71
80
  "types": "./dist/tui.d.ts",
72
81
  "default": "./dist/tui.js"
73
82
  },
83
+ "./vite": {
84
+ "types": "./dist/vite.d.ts",
85
+ "default": "./dist/vite.js"
86
+ },
74
87
  "./styles.css": "./styles/default.css",
75
88
  "./themes/dark-blue.css": "./styles/themes/dark-blue.css",
76
89
  "./themes/dark-green.css": "./styles/themes/dark-green.css",