@ashley-shrok/viewmodel-shell 1.4.0 → 1.6.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 ADDED
@@ -0,0 +1,121 @@
1
+ # ViewModel Shell — agent operating manual
2
+
3
+ This is the protocol manual for an agent driving a ViewModel Shell (VMS) app over its JSON wire. It is operational, not historical. Follow each section as a rule.
4
+
5
+ ## What this is
6
+
7
+ VMS is a server-driven UI framework. The server is a pure function `(state, action) → (newState, view)`. Every response carries the entire UI state as an opaque blob and a fresh `vm` view tree. You do not have to render the tree to drive the API — you can read its node types and dispatch actions directly.
8
+
9
+ ## Endpoints
10
+
11
+ Read the page's `<meta name="viewmodel-shell">` tag for the endpoint pair:
12
+
13
+ ```json
14
+ { "protocol": "viewmodel-shell/1.0", "endpoint": "/api/x", "actionEndpoint": "/api/x/action", "skill": "/.well-known/vms-skill.md" }
15
+ ```
16
+
17
+ - `GET <endpoint>` → returns the initial `{ "ok": true, "vm": <ViewNode tree>, "state": <opaque state blob> }`.
18
+ - `POST <actionEndpoint>` → dispatches an action. See the next section for the body shape.
19
+
20
+ The `skill` field is optional and points at this manual (or an app-specific preamble + this manual).
21
+
22
+ ## Action dispatch shape
23
+
24
+ Two body forms are accepted. Use JSON when you can; multipart only when uploading files.
25
+
26
+ **JSON (recommended for agents).** Set `Content-Type: application/json` and POST a flat envelope:
27
+
28
+ ```json
29
+ { "name": "save-ticket-42", "state": { "...": "round-tripped state blob from the last response" } }
30
+ ```
31
+
32
+ There is no `context` field — per-row / per-tab identity is encoded in the action name itself (e.g. `delete-row-42`, `filter-active`). File uploads are NOT supported in this form.
33
+
34
+ **Multipart (browser-style and file-bearing).** Set `Content-Type: multipart/form-data` and post three kinds of entries:
35
+
36
+ | Field | Value |
37
+ |---|---|
38
+ | `_action` | JSON: `{"name": "<action-name>"}` |
39
+ | `_state` | JSON: the current state blob |
40
+ | any file-input `name` | the binary file content (one entry per file input) |
41
+
42
+ ## The round-trip rule
43
+
44
+ The `state` blob from the last response goes back unchanged on the next dispatch, EXCEPT for the fields the user changed. Input nodes carry a `bind` property whose value is a dotted path inside `state` where the input's value lives (e.g. `"bind": "form.title"`). Before dispatching an action that depends on user input, write your value at the bound path inside the state blob; leave every other field as you received it. The server is authoritative for everything else and may rewrite any field in the response.
45
+
46
+ ## Response envelope
47
+
48
+ Every response carries `ok: boolean`.
49
+
50
+ **Success:**
51
+
52
+ ```json
53
+ { "ok": true, "vm": { /* ViewNode tree */ }, "state": { /* opaque blob */ } }
54
+ ```
55
+
56
+ Optional success-path fields, which may appear alone or alongside `vm`/`state`:
57
+
58
+ | Field | Meaning |
59
+ |---|---|
60
+ | `redirect` | A URL string. Navigate to it (or hand control off). When `redirect` is present, `vm` and `state` may be omitted. |
61
+ | `sideEffects` | An array of side-effect verbs (see next section). Applied in order before any redirect or re-render. |
62
+ | `nextPollIn` | Milliseconds. Schedule a `{"name": "poll"}` dispatch after this delay. |
63
+ | `busy` | Boolean. While `true`, drop user-initiated dispatches. Polls bypass. The next response that omits or sets `false` clears the lock. |
64
+ | `preventUnload` | Boolean. While `true`, treat the page as having unsaved work — warn before navigating away. |
65
+
66
+ **Failure:**
67
+
68
+ ```json
69
+ { "ok": false, "errors": [ { "message": "...", "code": "parse_error", "path": "form.title" } ] }
70
+ ```
71
+
72
+ `errors[].path` and `errors[].code` are optional. HTTP status is 4xx or 5xx on failure. Check `ok` once at the response edge; do not branch on HTTP status.
73
+
74
+ ## Side-effect verbs
75
+
76
+ `sideEffects[]` entries each carry a `type` discriminator. Built-in verbs as of `viewmodel-shell/1.0`:
77
+
78
+ ```json
79
+ { "sideEffects": [
80
+ { "type": "set-local-storage", "key": "auth_jwt", "value": "eyJ..." },
81
+ { "type": "set-session-storage", "key": "draft_id", "value": "42" },
82
+ { "type": "download", "url": "/api/invoices/42/pdf", "filename": "invoice-42.pdf" }
83
+ ] }
84
+ ```
85
+
86
+ | Verb | Effect |
87
+ |---|---|
88
+ | `set-local-storage` | Write `key`/`value` to platform localStorage (or your agent's equivalent persistent store). |
89
+ | `set-session-storage` | Same as above for session-scoped storage. |
90
+ | `download` | Fetch `url` (re-presenting your auth headers — see *Auth*) and save the bytes. Filename precedence: `Content-Disposition` > side-effect `filename` > URL basename > `"download"`. |
91
+
92
+ **Forward-compat rule — silently ignore unknown verbs.** A future minor release may add new verbs. If you see a `type` you do not recognize, skip it; do not error. Honor or ignore per your policy.
93
+
94
+ ## Errors
95
+
96
+ `ok: false` responses always carry `errors[]`. The framework uses a stable code vocabulary at the protocol edge:
97
+
98
+ | Code | Meaning |
99
+ |---|---|
100
+ | `parse_error` | The request body could not be parsed (malformed JSON, missing required field). |
101
+ | `unknown_action` | The `name` in your action envelope does not match any handler in the current tree. |
102
+ | `invalid_tree` | The server built a tree that violates a wire invariant (this is a server bug, not yours). |
103
+ | `uncaught_exception` | The action handler threw. Treat as a 500-class failure. |
104
+
105
+ Stop on `ok: false`. Surface the message to the user. Do not retry blindly — most of these are deterministic.
106
+
107
+ ## Auth
108
+
109
+ The wire does not mandate an auth shape. If the app needs credentials, the app preamble above (or its own README) names them. Common patterns: a `Bearer` token in `Authorization`, a CSRF/anti-forgery token in a custom header, a session cookie. Send the same headers on every request, including polls and downloads. The `download` side-effect re-presents your auth headers when the agent fetches the file.
110
+
111
+ ## Polling
112
+
113
+ If a response carries `nextPollIn: N`, schedule a POST `{ "name": "poll", "state": <last state> }` against the same `actionEndpoint` after `N` milliseconds. The server may continue returning `nextPollIn` until the workflow reaches a terminal state, at which point the field will be absent. Polls run silently — they are not user-initiated.
114
+
115
+ ## Files
116
+
117
+ 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.
118
+
119
+ ## Versioning
120
+
121
+ 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
@@ -265,6 +265,58 @@ export class BrowserAdapter {
265
265
  parent.appendChild(el);
266
266
  return;
267
267
  }
268
+ // 1.5.0 — SectionNode.link URL-wrapper variant (issue #21). When set,
269
+ // emit a wrapping <a href> element instead of <section> so every native
270
+ // browser link affordance works for free (middle-click / Ctrl/Cmd-click
271
+ // new tab, right-click context menu, drag-to-bookmarks, status-bar URL).
272
+ // Validation guarantees link + action and link + collapsible are
273
+ // mutually exclusive, and link cannot be nested inside another link or
274
+ // action — see validateSectionAction in server.ts.
275
+ if (n.link) {
276
+ const a = document.createElement("a");
277
+ a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}`;
278
+ a.href = n.link.url;
279
+ // Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
280
+ // byte-for-byte: target=_blank + rel=noopener noreferrer when external.
281
+ if (n.link.external) {
282
+ a.target = "_blank";
283
+ a.rel = "noopener noreferrer";
284
+ }
285
+ if (n.heading) {
286
+ const h = document.createElement("h2");
287
+ h.className = "vms-section__heading";
288
+ h.textContent = n.heading;
289
+ a.appendChild(h);
290
+ }
291
+ this.kids(n.children, a, on);
292
+ // Containment: clicks on nested interactive controls must NOT trigger
293
+ // the wrapper anchor's navigation. For non-anchor controls, stopPropagation
294
+ // is enough — the wrapper anchor's default navigation only fires on the
295
+ // anchor element itself, and stopPropagation prevents bubbled re-fires.
296
+ // For nested anchors (cell linkLabels), we additionally preventDefault on
297
+ // the click so a bubbled click cannot re-trigger the wrapper anchor's
298
+ // default navigation in browsers that handle nested <a> ambiguously. The
299
+ // catch-all `a[href]` selector includes the wrapper itself — skip it via
300
+ // `ctrl === a` so the wrapper's own click is NOT preventDefaulted.
301
+ //
302
+ // TODO: LinkNode-inside-section.link is left to the existing LinkNode
303
+ // renderer; spec-wise nested <a> is invalid HTML (issue #21 deliberately
304
+ // does NOT block it because the tree-validation rule only catches the
305
+ // sibling SectionNode-level case). A follow-up runtime warning when an
306
+ // inner LinkNode lives inside a section.link wrapper could surface this
307
+ // to consumers; until then, consumers can avoid the combo.
308
+ a.querySelectorAll(".vms-button, .vms-checkbox__input, .vms-checkbox, .vms-field__input, .vms-table__link, a[href]").forEach(ctrl => {
309
+ if (ctrl === a)
310
+ return;
311
+ ctrl.addEventListener("click", (e) => {
312
+ e.stopPropagation();
313
+ if (ctrl instanceof HTMLAnchorElement)
314
+ e.preventDefault();
315
+ });
316
+ });
317
+ parent.appendChild(a);
318
+ return;
319
+ }
268
320
  const el = document.createElement("section");
269
321
  el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.action ? " vms-section--clickable" : ""}`;
270
322
  if (n.heading) {
package/dist/index.d.ts CHANGED
@@ -111,6 +111,51 @@ export interface SectionNode {
111
111
  * Omitted = today's section rendering, byte-identical (no class drift, no
112
112
  * extra attrs, no listeners). */
113
113
  action?: ActionEvent;
114
+ /** URL-link navigator variant of the clickable-card primitive — the sibling
115
+ * of `action` (1.4.0). Set this to make the entire section a navigational
116
+ * anchor: the BrowserAdapter emits a wrapping `<a href={url}>` element so
117
+ * every NATIVE browser link affordance works for free — left-click navigate,
118
+ * middle-click new tab, Ctrl/Cmd-click new tab, Shift-click new window,
119
+ * right-click context menu, drag-to-bookmarks, status-bar URL preview, and
120
+ * accessible link semantics. No JS substitute exists for those; browsers
121
+ * implement them at the anchor-element level.
122
+ *
123
+ * Reach for `link` (this field) when the card is conceptually a
124
+ * NAVIGATIONAL target (docs tile, gallery item, launcher tile). Reach for
125
+ * `action` (the sibling above) when the card is a DISPATCHER that runs
126
+ * server-side work. Closes [issue #21](https://github.com/ashley-shrok/ViewModelShell/issues/21).
127
+ *
128
+ * Wire shape — INLINE object `{ url, external? }`, not flat sibling fields.
129
+ * When `external: true` is set, the renderer additionally adds
130
+ * `target="_blank"` and `rel="noopener noreferrer"` (mirroring LinkNode's
131
+ * external attribute pattern byte-for-byte). Clicks on nested ButtonNode /
132
+ * CheckboxNode / FieldNode / LinkNode / cell `linkLabel` anchors INSIDE a
133
+ * linked card do NOT also fire the wrapper anchor's navigation (the
134
+ * renderer stops propagation on those targets; for nested anchors it
135
+ * additionally `preventDefault`s the wrapper's default so the inner anchor
136
+ * wins). No `role`, no `tabindex`, no `aria-label` — the anchor element
137
+ * provides every link / keyboard / focus / a11y semantic natively.
138
+ *
139
+ * Tree validation rejects four invalid combos at the server edge with
140
+ * `invalid_tree`:
141
+ * (a) `link` set together with `action` on the same section — a
142
+ * SectionNode is either a dispatcher (action) or a navigator (link);
143
+ * they create different user expectations of what a click means.
144
+ * Pick one.
145
+ * (b) `link` set together with `collapsible: true` on the same section —
146
+ * same rationale as action+collapsible (the summary IS the click
147
+ * target; a linked card makes the whole section the click target).
148
+ * (c) a SectionNode with `link` nested inside another SectionNode with
149
+ * `link` — HTML5 prohibits nested `<a>` elements.
150
+ * (d) a SectionNode with `link` nested inside a SectionNode with `action`
151
+ * (or vice versa) — click-ownership in the overlap is ambiguous.
152
+ *
153
+ * Omitted = today's section rendering, byte-identical (no `<a>` wrapper,
154
+ * no class drift, no extra attrs). */
155
+ link?: {
156
+ url: string;
157
+ external?: boolean;
158
+ };
114
159
  children: ViewNode[];
115
160
  }
116
161
  export interface ListNode {
package/dist/server.d.ts CHANGED
@@ -16,12 +16,13 @@ export * from "./index.js";
16
16
  */
17
17
  export declare function validateActionNames(vm: ViewNode): void;
18
18
  /**
19
- * Walk a ViewNode tree and reject two invalid SectionNode.action combos:
20
- * (a) action + collapsible:true on the same section, and (b) a clickable
21
- * section nested inside another clickable section. Pure check does not
22
- * mutate the tree.
19
+ * Walk a ViewNode tree and reject five invalid SectionNode.action / .link
20
+ * combos: (a) action + collapsible:true; (b) nested action-in-action or
21
+ * action-in-link; (c) action + link on the same section; (d) link +
22
+ * collapsible:true; (e) nested link-in-link or link-in-action. Pure check —
23
+ * does not mutate the tree.
23
24
  *
24
- * @throws Error when either invalid combo is found. The message names the
25
+ * @throws Error when any invalid combo is found. The message names the
25
26
  * offending section(s) by heading (or `(headingless)`).
26
27
  */
27
28
  export declare function validateSectionAction(vm: ViewNode): void;
@@ -67,6 +68,63 @@ export declare const shellSideEffect: {
67
68
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
68
69
  download: (url: string, filename?: string) => ShellSideEffect;
69
70
  };
71
+ /**
72
+ * Mount the canonical VMS agent skill markdown as an HTTP handler.
73
+ *
74
+ * The skill is a self-contained operating manual for the VMS wire protocol
75
+ * (action dispatch shape, state round-trip rules, response envelope vocabulary,
76
+ * side-effect verbs, polling, errors, file uploads). Advertise it to agents
77
+ * driving your app via the `skill` field on the
78
+ * `<meta name="viewmodel-shell">` discoverability tag, pointing at whatever URL
79
+ * you mount this handler at (recommended: `/.well-known/vms-skill.md`).
80
+ *
81
+ * **Canonical source:** `viewmodel-shell/agent-skill.md` (shipped in the npm
82
+ * package's `files` array; the .NET package embeds a byte-identical copy at
83
+ * `viewmodel-shell-dotnet/AgentSkill.md` — see `parity/check-skill.ts`).
84
+ *
85
+ * **Preamble shape.** When `appPreamble` is supplied (non-empty after trim),
86
+ * the served body is:
87
+ *
88
+ * ```
89
+ * ## App-specific notes
90
+ *
91
+ * <preamble verbatim>
92
+ *
93
+ * ---
94
+ *
95
+ * <canonical-skill-body verbatim>
96
+ * ```
97
+ *
98
+ * When omitted (or whitespace-only), the served body is the canonical skill
99
+ * verbatim. The body is computed ONCE at handler-creation time; per-request
100
+ * cost is a single `new Response(body)`. Multiple handlers with different
101
+ * preambles are cheap and fully independent.
102
+ *
103
+ * **Cross-runtime.** Pure Web Fetch API — works in Bun, Deno, Hono, Cloudflare
104
+ * Workers, and Node 18+. The application's router owns method/path routing;
105
+ * the handler accepts any request and unconditionally serves the markdown body
106
+ * with `Content-Type: text/markdown; charset=utf-8`.
107
+ *
108
+ * @example
109
+ * ```ts
110
+ * import { createAgentSkillHandler } from "@ashley-shrok/viewmodel-shell/server";
111
+ * const skillHandler = createAgentSkillHandler({
112
+ * appPreamble: "This is the foo app. Auth: Bearer JWT in Authorization.",
113
+ * });
114
+ * Bun.serve({
115
+ * async fetch(req) {
116
+ * const url = new URL(req.url);
117
+ * if (url.pathname === "/.well-known/vms-skill.md" && req.method === "GET") {
118
+ * return skillHandler(req);
119
+ * }
120
+ * // ... your other routes
121
+ * },
122
+ * });
123
+ * ```
124
+ */
125
+ export declare function createAgentSkillHandler(opts?: {
126
+ appPreamble?: string;
127
+ }): (req: Request) => Response;
70
128
  /**
71
129
  * Thrown by an action handler to signal a malformed/invalid request. The
72
130
  * createAction wrapper catches this and returns a 400 with the error
package/dist/server.js CHANGED
@@ -8,6 +8,21 @@
8
8
  // Re-export the ViewNode hierarchy and wire types so a backend can import
9
9
  // everything it needs from one place.
10
10
  export * from "./index.js";
11
+ // ─── Canonical agent skill load (1.6.0 / 1.5.0) ──────────────────────────────
12
+ // Loaded once at module init so createAgentSkillHandler can bake the body at
13
+ // handler-creation time (no per-request fs work). The path resolution works for
14
+ // BOTH layouts:
15
+ // - Source/dev (vitest): src/server.ts → ../agent-skill.md
16
+ // - Published tarball: dist/server.js → ../agent-skill.md (file is in
17
+ // package root, listed in package.json `files`)
18
+ // If `agent-skill.md` is missing at module init the import throws — the
19
+ // fail-loud rule (see AGENTS.md capability-seam doc) applies: a silent 404
20
+ // from the skill endpoint would defeat the purpose.
21
+ import { readFileSync as __vmsReadFileSync } from "node:fs";
22
+ import { fileURLToPath as __vmsFileURLToPath } from "node:url";
23
+ import { dirname as __vmsDirname, join as __vmsJoin } from "node:path";
24
+ const __vmsAgentSkillDir = __vmsDirname(__vmsFileURLToPath(import.meta.url));
25
+ const AGENT_SKILL_MARKDOWN = __vmsReadFileSync(__vmsJoin(__vmsAgentSkillDir, "..", "agent-skill.md"), "utf8");
11
26
  /**
12
27
  * Walk a ViewNode tree and assert that every dispatch-bearing action name names
13
28
  * exactly one operation. Two occurrences are considered "the same operation"
@@ -174,68 +189,113 @@ function collectActions(node, enclosingForm, out) {
174
189
  function recordAction(action, enclosingForm, out) {
175
190
  out.push({ name: action.name, enclosingForm });
176
191
  }
177
- // ─── SectionNode.action shape check (1.4.0 / 260614-9hq) ─────────────────────
192
+ // ─── SectionNode.action / .link shape checks (1.4.0 / 1.5.0) ─────────────────
178
193
  //
179
- // Two invalid combos a clickable-card primitive can produce at build time:
194
+ // Five invalid combos a clickable-card or linked-card primitive can produce at
195
+ // build time:
180
196
  // (a) SectionNode.action set together with collapsible:true on the same
181
197
  // section — a collapsible section's <summary> IS the click target; a
182
198
  // clickable card makes the whole section the click target. Pick one.
183
199
  // (b) A SectionNode with .action nested inside another SectionNode with
184
- // .action — nested role="button" is an a11y violation, and
185
- // click-ownership in the overlap is ambiguous.
186
- // A styling-only SectionNode { variant: "card" } (no .action) inside a
187
- // clickable card with internal buttons is VALID only nested .action errors.
200
+ // .action OR .link — nested role="button"/nested-<a> is an a11y/HTML5
201
+ // violation, and click-ownership in the overlap is ambiguous.
202
+ // (c) (1.5.0) SectionNode.action set together with SectionNode.link on the
203
+ // same section a section is either a dispatcher (action) or a
204
+ // navigator (link); they create different user expectations of a click.
205
+ // (d) (1.5.0) SectionNode.link set together with collapsible:true — same
206
+ // rationale as (a).
207
+ // (e) (1.5.0) A SectionNode with .link nested inside another SectionNode
208
+ // with .link OR .action — HTML5 prohibits nested <a> elements; the
209
+ // mixed case is ambiguous click-ownership.
210
+ // A styling-only SectionNode { variant: "card" } (no .action and no .link)
211
+ // inside a clickable or linked card with internal buttons is VALID — only
212
+ // nested .action / .link errors.
188
213
  //
189
214
  // Mirrors viewmodel-shell-dotnet/ViewModels.cs's
190
215
  // ViewTreeValidation.ValidateSectionAction. The createAction wrapper invokes
191
216
  // this alongside validateActionNames so a server-built tree that violates
192
- // either rule surfaces as a 500 with code "invalid_tree" before the response
217
+ // any rule surfaces as a 500 with code "invalid_tree" before the response
193
218
  // leaves the wire.
194
219
  /**
195
- * Walk a ViewNode tree and reject two invalid SectionNode.action combos:
196
- * (a) action + collapsible:true on the same section, and (b) a clickable
197
- * section nested inside another clickable section. Pure check does not
198
- * mutate the tree.
220
+ * Walk a ViewNode tree and reject five invalid SectionNode.action / .link
221
+ * combos: (a) action + collapsible:true; (b) nested action-in-action or
222
+ * action-in-link; (c) action + link on the same section; (d) link +
223
+ * collapsible:true; (e) nested link-in-link or link-in-action. Pure check —
224
+ * does not mutate the tree.
199
225
  *
200
- * @throws Error when either invalid combo is found. The message names the
226
+ * @throws Error when any invalid combo is found. The message names the
201
227
  * offending section(s) by heading (or `(headingless)`).
202
228
  */
203
229
  export function validateSectionAction(vm) {
204
230
  walkForSectionAction(vm, null);
205
231
  }
206
- function walkForSectionAction(node, outerClickable) {
232
+ function walkForSectionAction(node, outerInteractive) {
207
233
  switch (node.type) {
208
234
  case "page": {
209
235
  const page = node;
210
236
  for (const child of page.children)
211
- walkForSectionAction(child, outerClickable);
237
+ walkForSectionAction(child, outerInteractive);
212
238
  return;
213
239
  }
214
240
  case "section": {
215
241
  const section = node;
216
- // (a) action + collapsible:true invalid.
242
+ const hdr = section.heading && section.heading.length > 0
243
+ ? section.heading
244
+ : "(headingless)";
245
+ // (c) action + link on the same section — invalid. Checked FIRST so
246
+ // the most actionable message wins when the consumer accidentally sets
247
+ // both (they get told "pick action OR link" instead of any nested or
248
+ // collapsible message that follows from a still-ambiguous tree).
249
+ if (section.action != null && section.link != null) {
250
+ throw new Error(`SectionNode '${hdr}' has both Action and Link set. ` +
251
+ "A SectionNode is either a dispatcher (action) or a navigator (link) — " +
252
+ "they create different user expectations of what a click means. Pick one.");
253
+ }
254
+ // (d) link + collapsible:true — invalid.
255
+ if (section.link != null && section.collapsible === true) {
256
+ throw new Error(`SectionNode '${hdr}' has both Link and Collapsible: true set. ` +
257
+ "A collapsible section's summary IS the click target; a linked card " +
258
+ "makes the whole section the click target. Pick one.");
259
+ }
260
+ // (a) action + collapsible:true — invalid (existing, unchanged).
217
261
  if (section.action != null && section.collapsible === true) {
218
- const hdr = section.heading && section.heading.length > 0
219
- ? section.heading
220
- : "(headingless)";
221
262
  throw new Error(`SectionNode '${hdr}' has both Action and Collapsible: true set. ` +
222
263
  "A collapsible section's summary IS the click target; a clickable card " +
223
264
  "makes the whole section the click target. Pick one.");
224
265
  }
225
- // (b) nested action-in-action — invalid.
226
- if (section.action != null && outerClickable !== null) {
227
- const innerHdr = section.heading && section.heading.length > 0
228
- ? section.heading
266
+ // (e) nested link-in-link / link-in-action — invalid.
267
+ if (section.link != null && outerInteractive !== null) {
268
+ const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
269
+ ? outerInteractive.heading
229
270
  : "(headingless)";
230
- const outerHdr = outerClickable.heading && outerClickable.heading.length > 0
231
- ? outerClickable.heading
271
+ if (outerInteractive.link != null) {
272
+ throw new Error(`Nested SectionNode.Link: inner section '${hdr}' is inside linked outer ` +
273
+ `section '${outerHdr}'. HTML5 prohibits nested <a> elements.`);
274
+ }
275
+ else {
276
+ throw new Error(`SectionNode.Link inner section '${hdr}' is inside clickable outer ` +
277
+ `SectionNode.Action '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
278
+ "a linked card inside a dispatcher card creates two competing primary interactions.");
279
+ }
280
+ }
281
+ // (b) nested action-in-action / action-in-link — invalid.
282
+ if (section.action != null && outerInteractive !== null) {
283
+ const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
284
+ ? outerInteractive.heading
232
285
  : "(headingless)";
233
- throw new Error(`Nested SectionNode.Action: inner section '${innerHdr}' is inside clickable outer ` +
234
- `section '${outerHdr}'. Nested role='button' elements are an accessibility violation, ` +
235
- "and click-ownership in the overlap is ambiguous. Use a styling-only inner section " +
236
- "(variant: 'card', no Action) with internal buttons instead.");
286
+ if (outerInteractive.action != null) {
287
+ throw new Error(`Nested SectionNode.Action: inner section '${hdr}' is inside clickable outer ` +
288
+ `section '${outerHdr}'. Nested role='button' elements are an accessibility violation, ` +
289
+ "and click-ownership in the overlap is ambiguous. Use a styling-only inner section " +
290
+ "(variant: 'card', no Action) with internal buttons instead.");
291
+ }
292
+ else {
293
+ throw new Error(`SectionNode.Action inner section '${hdr}' is inside linked outer ` +
294
+ `SectionNode.Link '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
295
+ "a dispatcher card inside a linked card creates two competing primary interactions.");
296
+ }
237
297
  }
238
- const nextOuter = section.action != null ? section : outerClickable;
298
+ const nextOuter = (section.action != null || section.link != null) ? section : outerInteractive;
239
299
  for (const child of section.children)
240
300
  walkForSectionAction(child, nextOuter);
241
301
  return;
@@ -243,28 +303,28 @@ function walkForSectionAction(node, outerClickable) {
243
303
  case "list": {
244
304
  const list = node;
245
305
  for (const child of list.children)
246
- walkForSectionAction(child, outerClickable);
306
+ walkForSectionAction(child, outerInteractive);
247
307
  return;
248
308
  }
249
309
  case "list-item": {
250
310
  const li = node;
251
311
  for (const child of li.children)
252
- walkForSectionAction(child, outerClickable);
312
+ walkForSectionAction(child, outerInteractive);
253
313
  return;
254
314
  }
255
315
  case "form": {
256
316
  const form = node;
257
317
  for (const child of form.children)
258
- walkForSectionAction(child, outerClickable);
318
+ walkForSectionAction(child, outerInteractive);
259
319
  return;
260
320
  }
261
321
  case "modal": {
262
322
  const modal = node;
263
323
  for (const child of modal.children)
264
- walkForSectionAction(child, outerClickable);
324
+ walkForSectionAction(child, outerInteractive);
265
325
  if (modal.footer) {
266
326
  for (const child of modal.footer)
267
- walkForSectionAction(child, outerClickable);
327
+ walkForSectionAction(child, outerInteractive);
268
328
  }
269
329
  return;
270
330
  }
@@ -331,6 +391,71 @@ export const shellSideEffect = {
331
391
  * JSON wire, matching the .NET WhenWritingNull null-omission contract. */
332
392
  download: (url, filename) => ({ type: "download", url, ...(filename != null ? { filename } : {}) }),
333
393
  };
394
+ // ─── Canonical agent skill mount helper (1.6.0 / 1.5.0) ──────────────────────
395
+ /**
396
+ * Mount the canonical VMS agent skill markdown as an HTTP handler.
397
+ *
398
+ * The skill is a self-contained operating manual for the VMS wire protocol
399
+ * (action dispatch shape, state round-trip rules, response envelope vocabulary,
400
+ * side-effect verbs, polling, errors, file uploads). Advertise it to agents
401
+ * driving your app via the `skill` field on the
402
+ * `<meta name="viewmodel-shell">` discoverability tag, pointing at whatever URL
403
+ * you mount this handler at (recommended: `/.well-known/vms-skill.md`).
404
+ *
405
+ * **Canonical source:** `viewmodel-shell/agent-skill.md` (shipped in the npm
406
+ * package's `files` array; the .NET package embeds a byte-identical copy at
407
+ * `viewmodel-shell-dotnet/AgentSkill.md` — see `parity/check-skill.ts`).
408
+ *
409
+ * **Preamble shape.** When `appPreamble` is supplied (non-empty after trim),
410
+ * the served body is:
411
+ *
412
+ * ```
413
+ * ## App-specific notes
414
+ *
415
+ * <preamble verbatim>
416
+ *
417
+ * ---
418
+ *
419
+ * <canonical-skill-body verbatim>
420
+ * ```
421
+ *
422
+ * When omitted (or whitespace-only), the served body is the canonical skill
423
+ * verbatim. The body is computed ONCE at handler-creation time; per-request
424
+ * cost is a single `new Response(body)`. Multiple handlers with different
425
+ * preambles are cheap and fully independent.
426
+ *
427
+ * **Cross-runtime.** Pure Web Fetch API — works in Bun, Deno, Hono, Cloudflare
428
+ * Workers, and Node 18+. The application's router owns method/path routing;
429
+ * the handler accepts any request and unconditionally serves the markdown body
430
+ * with `Content-Type: text/markdown; charset=utf-8`.
431
+ *
432
+ * @example
433
+ * ```ts
434
+ * import { createAgentSkillHandler } from "@ashley-shrok/viewmodel-shell/server";
435
+ * const skillHandler = createAgentSkillHandler({
436
+ * appPreamble: "This is the foo app. Auth: Bearer JWT in Authorization.",
437
+ * });
438
+ * Bun.serve({
439
+ * async fetch(req) {
440
+ * const url = new URL(req.url);
441
+ * if (url.pathname === "/.well-known/vms-skill.md" && req.method === "GET") {
442
+ * return skillHandler(req);
443
+ * }
444
+ * // ... your other routes
445
+ * },
446
+ * });
447
+ * ```
448
+ */
449
+ export function createAgentSkillHandler(opts = {}) {
450
+ const preamble = opts.appPreamble?.trim() ?? "";
451
+ const body = preamble.length === 0
452
+ ? AGENT_SKILL_MARKDOWN
453
+ : `## App-specific notes\n\n${preamble}\n\n---\n\n${AGENT_SKILL_MARKDOWN}`;
454
+ return (_req) => new Response(body, {
455
+ status: 200,
456
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
457
+ });
458
+ }
334
459
  // ─── Action handler factory ──────────────────────────────────────────────────
335
460
  /**
336
461
  * Thrown by an action handler to signal a malformed/invalid request. The
package/dist/tui.js CHANGED
@@ -292,6 +292,12 @@ export class TuiAdapter {
292
292
  else if (a.type === "link") {
293
293
  this.navigate(a.href);
294
294
  }
295
+ else if (a.type === "section-link") {
296
+ // 1.5.0 — SectionNode.link parity for the TUI: focused pane Enter
297
+ // navigates to the section's URL via the same `navigate` verb that
298
+ // LinkNode uses. Mirrors the BrowserAdapter's <a href> wrapper.
299
+ this.navigate(a.url);
300
+ }
295
301
  else if (a.type === "copy-button") {
296
302
  this.copy(a.text);
297
303
  }
@@ -586,6 +592,15 @@ function focusedPaneSummary(vm, index) {
586
592
  let hasInputs = false;
587
593
  let primaryActionable = null;
588
594
  let primaryCheckbox = null;
595
+ // 1.5.0 — when the pane IS a section with `link.url` set, surface it as a
596
+ // synthetic link-actionable BEFORE scanning descendants so Enter on the
597
+ // focused pane navigates via the wrapper anchor's URL (mirrors how the
598
+ // BrowserAdapter gives a `<a href>` wrapper native link semantics for free).
599
+ // Descendant LinkNode/Button/etc. can still win if no section-level link
600
+ // is set; the section-link only seeds primaryActionable when present.
601
+ if (pane.type === "section" && pane.link && pane.link.url.trim().length > 0) {
602
+ primaryActionable = { type: "section-link", url: pane.link.url };
603
+ }
589
604
  const scan = (node) => {
590
605
  if (node.type === "field")
591
606
  hasInputs = true;
@@ -687,6 +702,12 @@ function paneActivationHint(summary) {
687
702
  label = a.label;
688
703
  else if (a.type === "link")
689
704
  label = a.label;
705
+ else if (a.type === "section-link") {
706
+ // 1.5.0 — synthetic actionable from SectionNode.link. No `label` field;
707
+ // the section's heading is shown separately by StatusBar, so use the
708
+ // pane's heading when available (else generic "open").
709
+ label = summary.heading ?? "open";
710
+ }
690
711
  else
691
712
  label = a.label ?? "copy"; // copy-button
692
713
  // Truncate long labels so the status bar fits a single line cleanly.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "1.4.0",
3
+ "version": "1.6.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",
@@ -21,6 +21,7 @@
21
21
  "files": [
22
22
  "dist",
23
23
  "styles",
24
+ "agent-skill.md",
24
25
  "README.md",
25
26
  "LICENSE"
26
27
  ],
@@ -234,6 +234,23 @@ body {
234
234
  outline-offset: 2px;
235
235
  }
236
236
 
237
+ /* ── Section: linked (1.5.0 — SectionNode.link URL-wrapper variant, issue #21) ──
238
+ Sibling of .vms-section--clickable, applied to a wrapping <a href> instead of
239
+ a <section role="button">. Mirrors the clickable rules for cursor + hover +
240
+ :focus-visible so a linked card has the same affordance shape as a dispatcher
241
+ card. Additionally resets the anchor-default text styling: without these
242
+ `color: inherit` + `text-decoration: none` rules a linked card would render
243
+ with browser-default blue underlined heading text, since the wrapper element
244
+ is now an <a>. The `:focus-visible` outline uses the same AA-passing
245
+ --vms-accent token gated by the existing pair coverage, so all 12 themes
246
+ pass the contrast guard without additional work. */
247
+ .vms-section--linked { cursor: pointer; color: inherit; text-decoration: none; }
248
+ .vms-section--linked:hover { box-shadow: 0 0 0 1px var(--vms-accent-dim); }
249
+ .vms-section--linked:focus-visible {
250
+ outline: 2px solid var(--vms-accent);
251
+ outline-offset: 2px;
252
+ }
253
+
237
254
  /* ── Section: collapsible (1.2.0 — client-side disclosure via native <details>) ──
238
255
  Open/closed state is DOM-local; the BrowserAdapter snapshots and restores it
239
256
  across server-driven re-renders the same way it does for focus and scroll.