@ashley-shrok/viewmodel-shell 1.4.0 → 1.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/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;
package/dist/server.js CHANGED
@@ -174,68 +174,113 @@ function collectActions(node, enclosingForm, out) {
174
174
  function recordAction(action, enclosingForm, out) {
175
175
  out.push({ name: action.name, enclosingForm });
176
176
  }
177
- // ─── SectionNode.action shape check (1.4.0 / 260614-9hq) ─────────────────────
177
+ // ─── SectionNode.action / .link shape checks (1.4.0 / 1.5.0) ─────────────────
178
178
  //
179
- // Two invalid combos a clickable-card primitive can produce at build time:
179
+ // Five invalid combos a clickable-card or linked-card primitive can produce at
180
+ // build time:
180
181
  // (a) SectionNode.action set together with collapsible:true on the same
181
182
  // section — a collapsible section's <summary> IS the click target; a
182
183
  // clickable card makes the whole section the click target. Pick one.
183
184
  // (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.
185
+ // .action OR .link — nested role="button"/nested-<a> is an a11y/HTML5
186
+ // violation, and click-ownership in the overlap is ambiguous.
187
+ // (c) (1.5.0) SectionNode.action set together with SectionNode.link on the
188
+ // same section a section is either a dispatcher (action) or a
189
+ // navigator (link); they create different user expectations of a click.
190
+ // (d) (1.5.0) SectionNode.link set together with collapsible:true — same
191
+ // rationale as (a).
192
+ // (e) (1.5.0) A SectionNode with .link nested inside another SectionNode
193
+ // with .link OR .action — HTML5 prohibits nested <a> elements; the
194
+ // mixed case is ambiguous click-ownership.
195
+ // A styling-only SectionNode { variant: "card" } (no .action and no .link)
196
+ // inside a clickable or linked card with internal buttons is VALID — only
197
+ // nested .action / .link errors.
188
198
  //
189
199
  // Mirrors viewmodel-shell-dotnet/ViewModels.cs's
190
200
  // ViewTreeValidation.ValidateSectionAction. The createAction wrapper invokes
191
201
  // this alongside validateActionNames so a server-built tree that violates
192
- // either rule surfaces as a 500 with code "invalid_tree" before the response
202
+ // any rule surfaces as a 500 with code "invalid_tree" before the response
193
203
  // leaves the wire.
194
204
  /**
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.
205
+ * Walk a ViewNode tree and reject five invalid SectionNode.action / .link
206
+ * combos: (a) action + collapsible:true; (b) nested action-in-action or
207
+ * action-in-link; (c) action + link on the same section; (d) link +
208
+ * collapsible:true; (e) nested link-in-link or link-in-action. Pure check —
209
+ * does not mutate the tree.
199
210
  *
200
- * @throws Error when either invalid combo is found. The message names the
211
+ * @throws Error when any invalid combo is found. The message names the
201
212
  * offending section(s) by heading (or `(headingless)`).
202
213
  */
203
214
  export function validateSectionAction(vm) {
204
215
  walkForSectionAction(vm, null);
205
216
  }
206
- function walkForSectionAction(node, outerClickable) {
217
+ function walkForSectionAction(node, outerInteractive) {
207
218
  switch (node.type) {
208
219
  case "page": {
209
220
  const page = node;
210
221
  for (const child of page.children)
211
- walkForSectionAction(child, outerClickable);
222
+ walkForSectionAction(child, outerInteractive);
212
223
  return;
213
224
  }
214
225
  case "section": {
215
226
  const section = node;
216
- // (a) action + collapsible:true invalid.
227
+ const hdr = section.heading && section.heading.length > 0
228
+ ? section.heading
229
+ : "(headingless)";
230
+ // (c) action + link on the same section — invalid. Checked FIRST so
231
+ // the most actionable message wins when the consumer accidentally sets
232
+ // both (they get told "pick action OR link" instead of any nested or
233
+ // collapsible message that follows from a still-ambiguous tree).
234
+ if (section.action != null && section.link != null) {
235
+ throw new Error(`SectionNode '${hdr}' has both Action and Link set. ` +
236
+ "A SectionNode is either a dispatcher (action) or a navigator (link) — " +
237
+ "they create different user expectations of what a click means. Pick one.");
238
+ }
239
+ // (d) link + collapsible:true — invalid.
240
+ if (section.link != null && section.collapsible === true) {
241
+ throw new Error(`SectionNode '${hdr}' has both Link and Collapsible: true set. ` +
242
+ "A collapsible section's summary IS the click target; a linked card " +
243
+ "makes the whole section the click target. Pick one.");
244
+ }
245
+ // (a) action + collapsible:true — invalid (existing, unchanged).
217
246
  if (section.action != null && section.collapsible === true) {
218
- const hdr = section.heading && section.heading.length > 0
219
- ? section.heading
220
- : "(headingless)";
221
247
  throw new Error(`SectionNode '${hdr}' has both Action and Collapsible: true set. ` +
222
248
  "A collapsible section's summary IS the click target; a clickable card " +
223
249
  "makes the whole section the click target. Pick one.");
224
250
  }
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
251
+ // (e) nested link-in-link / link-in-action — invalid.
252
+ if (section.link != null && outerInteractive !== null) {
253
+ const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
254
+ ? outerInteractive.heading
229
255
  : "(headingless)";
230
- const outerHdr = outerClickable.heading && outerClickable.heading.length > 0
231
- ? outerClickable.heading
256
+ if (outerInteractive.link != null) {
257
+ throw new Error(`Nested SectionNode.Link: inner section '${hdr}' is inside linked outer ` +
258
+ `section '${outerHdr}'. HTML5 prohibits nested <a> elements.`);
259
+ }
260
+ else {
261
+ throw new Error(`SectionNode.Link inner section '${hdr}' is inside clickable outer ` +
262
+ `SectionNode.Action '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
263
+ "a linked card inside a dispatcher card creates two competing primary interactions.");
264
+ }
265
+ }
266
+ // (b) nested action-in-action / action-in-link — invalid.
267
+ if (section.action != null && outerInteractive !== null) {
268
+ const outerHdr = outerInteractive.heading && outerInteractive.heading.length > 0
269
+ ? outerInteractive.heading
232
270
  : "(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.");
271
+ if (outerInteractive.action != null) {
272
+ throw new Error(`Nested SectionNode.Action: inner section '${hdr}' is inside clickable outer ` +
273
+ `section '${outerHdr}'. Nested role='button' elements are an accessibility violation, ` +
274
+ "and click-ownership in the overlap is ambiguous. Use a styling-only inner section " +
275
+ "(variant: 'card', no Action) with internal buttons instead.");
276
+ }
277
+ else {
278
+ throw new Error(`SectionNode.Action inner section '${hdr}' is inside linked outer ` +
279
+ `SectionNode.Link '${outerHdr}'. Click-ownership in the overlap is ambiguous — ` +
280
+ "a dispatcher card inside a linked card creates two competing primary interactions.");
281
+ }
237
282
  }
238
- const nextOuter = section.action != null ? section : outerClickable;
283
+ const nextOuter = (section.action != null || section.link != null) ? section : outerInteractive;
239
284
  for (const child of section.children)
240
285
  walkForSectionAction(child, nextOuter);
241
286
  return;
@@ -243,28 +288,28 @@ function walkForSectionAction(node, outerClickable) {
243
288
  case "list": {
244
289
  const list = node;
245
290
  for (const child of list.children)
246
- walkForSectionAction(child, outerClickable);
291
+ walkForSectionAction(child, outerInteractive);
247
292
  return;
248
293
  }
249
294
  case "list-item": {
250
295
  const li = node;
251
296
  for (const child of li.children)
252
- walkForSectionAction(child, outerClickable);
297
+ walkForSectionAction(child, outerInteractive);
253
298
  return;
254
299
  }
255
300
  case "form": {
256
301
  const form = node;
257
302
  for (const child of form.children)
258
- walkForSectionAction(child, outerClickable);
303
+ walkForSectionAction(child, outerInteractive);
259
304
  return;
260
305
  }
261
306
  case "modal": {
262
307
  const modal = node;
263
308
  for (const child of modal.children)
264
- walkForSectionAction(child, outerClickable);
309
+ walkForSectionAction(child, outerInteractive);
265
310
  if (modal.footer) {
266
311
  for (const child of modal.footer)
267
- walkForSectionAction(child, outerClickable);
312
+ walkForSectionAction(child, outerInteractive);
268
313
  }
269
314
  return;
270
315
  }
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.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",
@@ -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.