@ashley-shrok/viewmodel-shell 6.1.0 → 6.2.1

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
@@ -832,6 +832,8 @@ export class BrowserAdapter {
832
832
  const el = document.createElement("details");
833
833
  el.className = `vms-section vms-section--collapsible${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
834
834
  el.dataset.sectionKey = finalKey;
835
+ if (n.id)
836
+ el.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
835
837
  // Initial render is always closed — the post-render restore loop in
836
838
  // render() re-applies `open=true` for keys the user had open before.
837
839
  const summary = document.createElement("summary");
@@ -856,6 +858,8 @@ export class BrowserAdapter {
856
858
  const a = document.createElement("a");
857
859
  a.className = `vms-section vms-section--linked${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}`;
858
860
  a.href = n.link.url;
861
+ if (n.id)
862
+ a.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
859
863
  // Mirror LinkNode's external-attribute pattern (browser.ts ~line 666)
860
864
  // byte-for-byte: target=_blank + rel=noopener noreferrer when external.
861
865
  if (n.link.external) {
@@ -899,6 +903,8 @@ export class BrowserAdapter {
899
903
  }
900
904
  const el = document.createElement("section");
901
905
  el.className = `vms-section${n.variant === "card" ? " vms-section--card" : ""}${n.tone ? ` vms-section--${n.tone}` : ""}${n.layout && n.layout !== "stack" ? ` vms-section--${n.layout}` : ""}${n.fill === true ? " vms-section--fill" : ""}${n.arrange ? ` vms-arrange--${n.arrange}` : ""}${n.align ? ` vms-align--${n.align}` : ""}${n.threshold ? ` vms-switch--${n.threshold}` : ""}${n.limit ? ` vms-switch-limit--${n.limit}` : ""}${n.minItem ? ` vms-cards-min--${n.minItem}` : ""}${n.alignSelf ? ` vms-self--${n.alignSelf}` : ""}${n.maxWidth ? ` vms-maxw--${n.maxWidth}` : ""}${n.action ? " vms-section--clickable" : ""}`;
906
+ if (n.id)
907
+ el.id = n.id; // addressable DOM id (e.g. CopyButtonNode.copyTargetId target)
902
908
  // SectionNode.followTail — mark this as an append-only feed so render()'s
903
909
  // snapshot/restore keeps its newest content in view (see render() + the
904
910
  // FOLLOW_TAIL_STICK_THRESHOLD_PX constant). No CSS/class — the scroll comes
@@ -3274,25 +3280,67 @@ export class BrowserAdapter {
3274
3280
  btn.type = "button";
3275
3281
  btn.className = `vms-button${n.emphasis ? ` vms-button--${n.emphasis}` : ""}${n.tone ? ` vms-button--${n.tone}` : ""}${n.size ? ` vms-button--${n.size}` : ""}${n.width === "full" ? " vms-button--full" : ""}`;
3276
3282
  btn.textContent = n.label ?? "Copy";
3277
- btn.addEventListener("click", () => {
3278
- const write = navigator.clipboard?.writeText(n.text);
3283
+ const showCopied = () => {
3284
+ btn.textContent = n.copiedLabel ?? "Copied!";
3285
+ setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
3286
+ };
3287
+ // Plain-text copy (today's behavior, unchanged): async writeText with the
3288
+ // execCommand fallback for engines without the async Clipboard API.
3289
+ const copyPlain = (text) => {
3290
+ const write = navigator.clipboard?.writeText(text);
3279
3291
  if (write) {
3280
- write.then(() => {
3281
- btn.textContent = n.copiedLabel ?? "Copied!";
3282
- setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
3283
- }).catch(() => {
3284
- if (legacyCopy(n.text)) {
3285
- btn.textContent = n.copiedLabel ?? "Copied!";
3286
- setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
3287
- }
3288
- });
3292
+ write.then(showCopied).catch(() => { if (legacyCopy(text))
3293
+ showCopied(); });
3289
3294
  }
3290
3295
  else {
3291
- if (legacyCopy(n.text)) {
3292
- btn.textContent = n.copiedLabel ?? "Copied!";
3293
- setTimeout(() => { btn.textContent = n.label ?? "Copy"; }, 1500);
3296
+ if (legacyCopy(text))
3297
+ showCopied();
3298
+ }
3299
+ };
3300
+ btn.addEventListener("click", () => {
3301
+ // RICH COPY — resolve the two representations by precedence: copyTargetId > html > plain.
3302
+ let html = null;
3303
+ let plain = n.text;
3304
+ if (n.copyTargetId != null) {
3305
+ // Harvest route: lift both representations off the already-rendered region.
3306
+ const target = document.getElementById(n.copyTargetId);
3307
+ if (target) {
3308
+ html = target.outerHTML;
3309
+ plain = target.textContent ?? n.text;
3310
+ }
3311
+ else {
3312
+ // Fail LOUD — a copy target that resolves to nothing is a bug, not a
3313
+ // silent no-op — then still give the user the plain fallback.
3314
+ console.error(`[viewmodel-shell] CopyButtonNode.copyTargetId "${n.copyTargetId}" matched no ` +
3315
+ `element on the page; falling back to plain-text copy. The target must be a ` +
3316
+ `described region that emits that DOM id (e.g. SectionNode.id / ListNode.id).`);
3317
+ copyPlain(n.text);
3318
+ return;
3294
3319
  }
3295
3320
  }
3321
+ else if (n.html != null) {
3322
+ // Server-provided route: the formatted representation the server authored.
3323
+ html = n.html;
3324
+ }
3325
+ // No rich representation → today's plain-text write.
3326
+ if (html === null) {
3327
+ copyPlain(plain);
3328
+ return;
3329
+ }
3330
+ // Two representations at once via the async Clipboard API. A destination that
3331
+ // understands formatting takes text/html; a plain one takes text/plain. If the
3332
+ // API is unavailable or the write is rejected (permissions, older engine),
3333
+ // degrade to the plain representation so the button is never dead.
3334
+ if (navigator.clipboard?.write && typeof ClipboardItem === "function") {
3335
+ const item = new ClipboardItem({
3336
+ "text/html": new Blob([html], { type: "text/html" }),
3337
+ "text/plain": new Blob([plain], { type: "text/plain" }),
3338
+ });
3339
+ navigator.clipboard.write([item]).then(showCopied).catch(() => copyPlain(plain));
3340
+ }
3341
+ else {
3342
+ copyPlain(plain);
3343
+ }
3296
3344
  });
3297
3345
  parent.appendChild(btn);
3298
3346
  }
package/dist/index.d.ts CHANGED
@@ -154,7 +154,14 @@ export interface SectionNode {
154
154
  alignSelf?: "start" | "center" | "end";
155
155
  /** Bounded content-width cap — a CLOSED token set (NOT raw CSS, per P2), implemented with `max-inline-size` (writing-mode-safe). Fractional values are proportional to the container (half→50%, two-thirds→66.6667%, three-quarters→75%) — the chat-gutter case, scaling with width; `prose` caps at the readable measure (min(65ch,100%), the Tailwind `max-w-prose` / Every-Layout `--measure` cap). The section still shrinks to its content below the cap. Omitted = no class → no cap (today's full-width behavior) = byte-identical. Emits .vms-maxw--{value}. Closed union (CHILD-02). */
156
156
  maxWidth?: "half" | "two-thirds" | "three-quarters" | "prose";
157
- /** Optional stable preservation key for the renderer's collapsible-section open-state snapshot. Used only when `collapsible: true`. Provide when `heading` isn't unique within a page or is absent otherwise the renderer falls back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal. Omitted = use the heading fallback. */
157
+ /** Optional stable identifier for the section. Two uses: (1) it is emitted as the
158
+ * rendered element's DOM `id`, which makes the section addressable — e.g. as a
159
+ * `CopyButtonNode.copyTargetId` harvest target; and (2) when `collapsible: true`
160
+ * it is the renderer's open-state preservation key. Provide when `heading` isn't
161
+ * unique within a page or is absent — otherwise the collapsible snapshot falls
162
+ * back to `heading ?? "vms-section-anon"`, disambiguated by per-render ordinal.
163
+ * DOM ids must be unique on a page (the same uniqueness the collapse-key already
164
+ * wants). Omitted = no DOM id emitted and the heading fallback is used for collapse. */
158
165
  id?: string;
159
166
  /** When true, the section renders as a native `<details>`/`<summary>` disclosure widget (closed by default). Aesthetic, client-side primitive — the open/closed state is DOM-local and the server does NOT round-trip it (same conceptual model as draft text values in unsubmitted form inputs). The browser adapter snapshots `<details>.open` before each re-render and restores it after, keyed by `id ?? heading ?? "vms-section-anon"` (disambiguated by per-render ordinal); a re-key drops the preserved state (the documented escape hatch for rare server-driven expansion). The summary label is the section's `heading`; a headingless collapsible section uses the fallback string `"Show details"`. If a section needs to start open, do not mark it collapsible. Omitted/false = today's `<section>` rendering, byte-identical. */
160
167
  collapsible?: boolean;
@@ -864,8 +871,38 @@ export interface TableNode {
864
871
  }
865
872
  export interface CopyButtonNode {
866
873
  type: "copy-button";
867
- /** The string to write to the clipboard on click. */
874
+ /** The plain-text string written to the clipboard on click. Always the
875
+ * `text/plain` representation and the universal fallback: it is what a plain
876
+ * destination (a code box, a terminal, a plain-text field) receives, and what
877
+ * is copied when neither rich-copy route below applies. Required. */
868
878
  text: string;
879
+ /** RICH COPY — harvest route (adapter-side, the default; no server authoring).
880
+ * The DOM id of an already-rendered region to copy. On click the adapter reads
881
+ * that element's rendered markup as the `text/html` representation and its plain
882
+ * text as `text/plain`, writing BOTH to the clipboard at once — so a paste into a
883
+ * formatted destination (email, doc) keeps its structure while a plain
884
+ * destination still gets clean text. The framework carries its look in
885
+ * class-keyed styling rather than inline, so the app's theme naturally falls away
886
+ * on paste and the *semantic structure* (headings, tables, bold, lists) survives —
887
+ * the desired outcome, achieved by the conventional native-copy mechanism (the
888
+ * destination sanitizes; the framework invents no cleaning pass). The target must
889
+ * be a described region carrying an emitted DOM id (e.g. `SectionNode.id`,
890
+ * `ListNode.id`); because ids only live on described nodes, this can never point
891
+ * at undescribed chrome, so the intent stays fully readable from the wire. Point
892
+ * it at a *content* region, not one containing this button. If the id resolves to
893
+ * no element the adapter fails LOUD (console error) and falls back to writing
894
+ * `text` — never a silent dead button. Absent = no harvest.
895
+ * Precedence: `copyTargetId` > `html` > plain `text`. */
896
+ copyTargetId?: string;
897
+ /** RICH COPY — server-provided route (opt-in). A ready-made formatted
898
+ * representation the server authored, written as `text/html` alongside `text`
899
+ * (`text/plain`). Reach for this only when the content to copy is NOT already
900
+ * rendered on the page (a cleaner or richer export than what's shown) — otherwise
901
+ * prefer `copyTargetId`, which authors nothing. This is a WRITE-ONLY clipboard
902
+ * export bound for a foreign destination; it never re-enters the rendered view
903
+ * (that would be decoration and is forbidden). Ignored when `copyTargetId` is set.
904
+ * Absent = no server-provided rich representation. */
905
+ html?: string;
869
906
  /** Label shown on the button before copying. Adapter default: "Copy". */
870
907
  label?: string;
871
908
  /** Ephemeral label shown after a successful copy, reverts after ~1.5 s. Adapter default: "Copied!". */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@ashley-shrok/viewmodel-shell",
3
- "version": "6.1.0",
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.",
3
+ "version": "6.2.1",
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",
7
7
  "author": "ashley-shrok",
@@ -286,6 +286,14 @@ body:has(.vms-page--fill) { margin: 0; }
286
286
  }
287
287
  .vms-page--sidebar > .vms-page__title,
288
288
  .vms-section--sidebar > .vms-section__heading { flex: 0 0 100%; }
289
+ /* Flex items default to min-width: auto, so a child whose MIN-content is wider than
290
+ its flex track (e.g. a plain section wrapping a wide/nowrap TableNode) can't shrink
291
+ and forces the flex line to wrap — rail alone on line 1, main dropped below with dead
292
+ space. min-width: 0 lets the child shrink to its track and the inner overflow
293
+ container (.vms-table-wrapper{overflow-x:auto}, long strings) scroll instead. Mirrors
294
+ the split/cards min-width:0 rule above, which was NOT applied to sidebar (#17). */
295
+ .vms-page--sidebar > *,
296
+ .vms-section--sidebar > * { min-width: 0; }
289
297
 
290
298
  /* ── Layout preset: row (D-30 / 1.11.0) — left-aligned wrapping horizontal row;
291
299
  children hug their content (no forced grow), wrap to the next line on overflow.