From 729a02a8339d152c7a283430e56dc0b41d14f5c6 Mon Sep 17 00:00:00 2001
From: Patrick Buckley the
+ coordinator's tool-output formatter emits must carry tabindex, or the
+ same content a pointer user copies in one click is unreachable by
+ keyboard. Pinned on every
exit of renderToolOutput so a new
+ exit cannot ship pointer-only."""
+ coord = _COORD_JS.read_text(encoding="utf-8")
+ start = coord.index("function renderToolOutput(")
+ fn = coord[start : coord.index("\n }", start)]
+ pres = re.findall(r"]*>", fn)
+ assert pres, "renderToolOutput no longer emits
blocks"
+ assert all('tabindex="0"' in p for p in pres), (
+ "a renderToolOutput exit lost its tabindex — pointer-copyable "
+ "but keyboard-unreachable:\n" + "\n".join(pres)
+ )
+ # The MCP-error card's raw-payload pre reaches .msg-body through the
+ # coordinator's orphan-result path — same invariant, shared module.
+ mcp = _MCP_ERROR_JS.read_text(encoding="utf-8")
+ assert 'pre.setAttribute("tabindex", "0")' in mcp, (
+ "the MCP-error card's raw-payload pre must stay focusable — it is "
+ "pointer-copyable wherever the card mounts inside a .msg-body"
+ )
+ # And an emptied action bar may not linger as a zero-control toolbar
+ # landmark on the coordinator's degraded (no-bridge) lane, where the
+ # fallback bar holds ONLY the transient retry button.
+ assert "if (!bar.children.length) bar.remove();" in coord, (
+ "the coordinator retry sweep must remove a bar emptied of its last "
+ "control — screen readers announce empty 'Message actions' toolbars"
+ )
diff --git a/tests/test_copy_actions_js.py b/tests/test_copy_actions_js.py
new file mode 100644
index 00000000..f900e7af
--- /dev/null
+++ b/tests/test_copy_actions_js.py
@@ -0,0 +1,1359 @@
+"""Behavior tests for ``turnstone/shared_static/copy_actions.js``.
+
+Same node + browser-shim approach as ``test_renderer_js.py``: the modules are
+demodulized and evaluated with script semantics against stub DOM elements.
+The harness records delegated listeners by type, so the tests drive the REAL
+interaction layer — the mouseover/keydown delegation, the show/hide
+lifecycle, and the pane-level busy gate — not just the exported resolvers.
+Placement geometry (clamps, collision, scroll-follow coordinates) is
+deliberately NOT asserted here: the stub rects are uniform; the livepass copy
+harness (scripts/livepass.py, COPY-READY / COPY-KBD-READY verdicts) owns
+rendered placement.
+"""
+
+from __future__ import annotations
+
+import json
+import subprocess
+from pathlib import Path
+
+from tests._js_harness_helpers import demodulize, node_skip
+
+_REPO_ROOT = Path(__file__).resolve().parent.parent
+_UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js"
+_TOAST_JS = _REPO_ROOT / "turnstone/shared_static/toast.js"
+_COPY_ACTIONS_JS = _REPO_ROOT / "turnstone/shared_static/copy_actions.js"
+
+
+pytestmark = node_skip
+
+
+# The three interpolated sources never change between tests — read,
+# demodulize, and JSON-encode them once so each test costs exactly one
+# node spawn.
+_UTILS_SRC = json.dumps(demodulize(_UTILS_JS))
+_TOAST_SRC = json.dumps(demodulize(_TOAST_JS))
+_COPY_ACTIONS_SRC = json.dumps(demodulize(_COPY_ACTIONS_JS))
+
+
+# The stub element models what the tested seams touch: class/attr/parent
+# plumbing, a selector subset for closest()/matches (type, .class, [attr] and
+# [attr="value"], comma lists — everything the module's selectors use),
+# listener capture with a synthetic .click(), per-element rects, and a focus
+# spy that tracks document.activeElement. document.addEventListener records
+# handlers by type so tests can fire delegated events (fireDoc merges extra
+# event fields, e.g. {key} for keydown); the flash timer is real (node
+# setTimeout) and the template exits the process after each body so the 1.4s
+# revert never holds it open.
+_HARNESS_TEMPLATE = """
+const vm = require('vm');
+
+class StubEl {}
+global.Element = StubEl;
+
+function hasClass(el, c) {
+ return (
+ (el._classes && el._classes.has(c)) ||
+ (el.className || '').split(/\\s+/).includes(c)
+ );
+}
+
+function matchesSel(el, sel) {
+ return sel.split(',').some((s) => {
+ s = s.trim();
+ if (s.startsWith('.')) return hasClass(el, s.slice(1));
+ if (s.startsWith('[')) {
+ const body = s.slice(1, -1);
+ const eq = body.indexOf('=');
+ if (eq === -1) return el.getAttribute(body) !== null;
+ let want = body.slice(eq + 1);
+ if (want.startsWith('"') || want.startsWith("'")) want = want.slice(1, -1);
+ return el.getAttribute(body.slice(0, eq)) === want;
+ }
+ return el.tagName === s.toUpperCase();
+ });
+}
+
+function makeEl(tag) {
+ const el = new StubEl();
+ Object.assign(el, {
+ tagName: String(tag || 'div').toUpperCase(),
+ children: [],
+ attrs: {},
+ style: {},
+ listeners: {},
+ _classes: new Set(),
+ textContent: '',
+ value: '',
+ title: '',
+ className: '',
+ type: '',
+ isConnected: true,
+ parentNode: null,
+ parentElement: null,
+ });
+ el.setAttribute = (n, v) => { el.attrs[n] = String(v); };
+ el.getAttribute = (n) => (n in el.attrs ? el.attrs[n] : null);
+ el.removeAttribute = (n) => { delete el.attrs[n]; };
+ el.appendChild = (c) => {
+ if (c.parentNode) {
+ const i = c.parentNode.children.indexOf(c);
+ if (i !== -1) c.parentNode.children.splice(i, 1);
+ }
+ el.children.push(c);
+ c.parentNode = el;
+ c.parentElement = el;
+ return c;
+ };
+ el.remove = () => { el._removed = true; };
+ el.select = () => {};
+ // Focus succeeds only for focusable elements — mirroring the browser is
+ // what lets tests catch focus plumbing that silently no-ops on a
+ // non-focusable target (form controls, tabindex attr, or a programmatic
+ // tabIndex assignment).
+ el.tabIndex = undefined;
+ el.focus = (opts) => {
+ const focusable =
+ el.tabIndex !== undefined ||
+ 'tabindex' in el.attrs ||
+ ['INPUT', 'TEXTAREA', 'BUTTON', 'SELECT'].includes(el.tagName);
+ if (!focusable) return;
+ el._focused = (el._focused || 0) + 1;
+ el._focusOpts = opts || null;
+ global.document.activeElement = el;
+ };
+ el.hasAttribute = (n) => n in el.attrs;
+ el.addEventListener = (t, fn) => {
+ (el.listeners[t] = el.listeners[t] || []).push(fn);
+ };
+ el.click = (evt) =>
+ (el.listeners.click || []).forEach((fn) =>
+ fn(evt || { stopPropagation() {} }));
+ el.querySelector = () => null;
+ el.querySelectorAll = (sel) => {
+ const out = [];
+ const walk = (n) => {
+ for (const c of n.children) {
+ if (matchesSel(c, sel)) out.push(c);
+ walk(c);
+ }
+ };
+ walk(el);
+ return out;
+ };
+ el.closest = (sel) => {
+ for (let n = el; n; n = n.parentElement) {
+ if (matchesSel(n, sel)) return n;
+ }
+ return null;
+ };
+ el.contains = (x) => {
+ for (let n = x; n; n = n.parentNode) if (n === el) return true;
+ return false;
+ };
+ el.getBoundingClientRect = () =>
+ el._rect || { top: 10, bottom: 110, left: 0, right: 200, width: 200, height: 100 };
+ el.classList = {
+ add: (...cs) => cs.forEach((c) => el._classes.add(c)),
+ remove: (...cs) => cs.forEach((c) => el._classes.delete(c)),
+ contains: (c) => hasClass(el, c),
+ };
+ return el;
+}
+
+const createdEls = [];
+const toastEl = makeEl('div');
+const docListeners = {};
+// Ranges clone like the real Range: a restored clone must itself be
+// cloneable, or the SECOND copy's save pass crashes on it.
+function makeRange(marker, clone) {
+ return {
+ marker,
+ clone: !!clone,
+ cloneRange() { return makeRange(this.marker, true); },
+ };
+}
+const prevRange = makeRange('prev', false);
+const selection = {
+ _ranges: [prevRange],
+ get rangeCount() { return this._ranges.length; },
+ getRangeAt(i) { return this._ranges[i]; },
+ removeAllRanges() { this._ranges = []; },
+ addRange(r) { this._ranges.push(r); },
+};
+global.__execResult = true;
+global.document = {
+ createElement: (tag) => {
+ const el = makeEl(tag);
+ createdEls.push(el);
+ return el;
+ },
+ body: makeEl('body'),
+ addEventListener: (t, fn) => {
+ (docListeners[t] = docListeners[t] || []).push(fn);
+ },
+ getSelection: () => selection,
+ getElementById: (id) => (id === 'toast' ? toastEl : null),
+ querySelector: () => null,
+ execCommand: () => global.__execResult,
+ activeElement: undefined,
+};
+global.window = global;
+global.innerWidth = 800;
+global.innerHeight = 600;
+global.getComputedStyle = () => ({ overflowY: 'visible' });
+global.requestAnimationFrame = (fn) => { fn(); return 0; };
+// Node >= 21 ships globalThis.navigator as a built-in accessor with no
+// setter, so a plain `global.navigator = ...` silently no-ops and the
+// secure-context branch would never see the stub clipboard. Install by
+// property definition instead.
+function setNavigator(nav) {
+ Object.defineProperty(global, 'navigator', {
+ value: nav,
+ configurable: true,
+ writable: true,
+ });
+}
+setNavigator({});
+global.isSecureContext = false;
+
+const fireDoc = (type, target, props) =>
+ (docListeners[type] || []).forEach((fn) =>
+ fn(Object.assign({ target }, props || {})));
+const fabEl = () =>
+ createdEls.find((e) => hasClass(e, 'block-copy-btn')) || null;
+// The announce idiom is clear-then-set on a short timer; outcome
+// announcements are only observable after it fires.
+const settle = (ms) => new Promise((r) => setTimeout(r, ms == null ? 40 : ms));
+
+vm.runInThisContext(%(utils_src)s);
+vm.runInThisContext(%(toast_src)s);
+vm.runInThisContext(%(copy_actions_src)s);
+// The module's aria-live region is the first sr-only appended to body.
+const liveRegion = document.body.children.find((c) => hasClass(c, 'sr-only'));
+
+// A bubble fixture: .msg > (.msg-body > table.table-wrap) + bar with a
+// copy button — the DOM shape both chat clients build, with the REAL
+// layout's geometry: the bar hugs the bubble's top-right corner and the
+// block sits below it (chat.css .msg-actions top/right 4px). Overlapping
+// default rects would make every reveal read as an unresolvable bar
+// collision.
+function makeBubble(sourceText) {
+ const msg = makeEl('div');
+ msg.className = 'msg assistant';
+ msg._rect = { top: 0, bottom: 160, left: 0, right: 200, width: 200, height: 160 };
+ const body = makeEl('div');
+ body.className = 'msg-body';
+ const table = makeEl('div');
+ table._classes.add('table-wrap');
+ table.setAttribute('data-md-source', sourceText || '| a |');
+ table.setAttribute('tabindex', '0');
+ table._rect = { top: 40, bottom: 150, left: 0, right: 200, width: 200, height: 110 };
+ const bar = makeEl('div');
+ bar.className = 'msg-actions';
+ bar._rect = { top: 4, bottom: 28, left: 150, right: 196, width: 46, height: 24 };
+ const barBtn = makeEl('button');
+ barBtn.className = 'msg-action-btn msg-copy-btn';
+ document.body.appendChild(msg);
+ msg.appendChild(body);
+ body.appendChild(table);
+ msg.appendChild(bar);
+ bar.appendChild(barBtn);
+ return { msg, body, table, bar, barBtn };
+}
+
+// Wrap a bubble in a messages container carrying the pane-level busy
+// stamp — the DOM fact both chat clients maintain (data-busy="true"
+// while a turn is in flight, "false" at idle).
+function makeBusyWrap(bubble, busy) {
+ const wrap = makeEl('div');
+ wrap.className = 'messages';
+ wrap.setAttribute('data-busy', busy === false ? 'false' : 'true');
+ document.body.appendChild(wrap);
+ wrap.appendChild(bubble.msg);
+ return wrap;
+}
+
+(async () => {
+ const out = await (async () => { %(body)s })();
+ process.stdout.write(JSON.stringify(out));
+ process.exit(0);
+})().catch((e) => {
+ console.error((e && e.stack) || e);
+ process.exit(1);
+});
+"""
+
+
+def _run(body: str) -> dict[str, object]:
+ harness = _HARNESS_TEMPLATE % {
+ "utils_src": _UTILS_SRC,
+ "toast_src": _TOAST_SRC,
+ "copy_actions_src": _COPY_ACTIONS_SRC,
+ "body": body,
+ }
+ result = subprocess.run(
+ ["node", "-e", harness],
+ capture_output=True,
+ text=True,
+ timeout=10,
+ check=True,
+ )
+ parsed: dict[str, object] = json.loads(result.stdout)
+ return parsed
+
+
+# ---------------------------------------------------------------------------
+# Source resolution — what a copy click actually lifts
+# ---------------------------------------------------------------------------
+
+
+def test_block_copy_source_resolves_each_block_kind() -> None:
+ """Each rendered block kind resolves to its SOURCE: mermaid containers
+ via ``data-mermaid-source`` (the source the diagram was rendered from —
+ the pre it replaced is gone from the DOM), tables via the render-time
+ ``data-md-source`` stash, and code fences via ``code.textContent``
+ (escapeHtml round-trips; hljs spans don't change text content)."""
+ out = _run(
+ """
+ const mermaid = makeEl('div');
+ mermaid._classes.add('mermaid-container');
+ mermaid.attrs['data-mermaid-source'] = 'graph TD;\\n A-->B';
+ const table = makeEl('div');
+ table._classes.add('table-wrap');
+ table.attrs['data-md-source'] = '| a |\\n|---|\\n| 1 |';
+ const code = makeEl('code');
+ code.textContent = 'x = 1\\nprint(x)';
+ const pre = makeEl('pre');
+ pre.querySelector = (sel) => (sel === 'code' ? code : null);
+ const barePre = makeEl('pre');
+ barePre.textContent = 'no code child';
+ return {
+ mermaid: blockCopySource(mermaid),
+ table: blockCopySource(table),
+ pre: blockCopySource(pre),
+ barePre: blockCopySource(barePre),
+ missing: blockCopySource(null),
+ };
+ """
+ )
+ assert out["mermaid"] == "graph TD;\n A-->B"
+ assert out["table"] == "| a |\n|---|\n| 1 |"
+ assert out["pre"] == "x = 1\nprint(x)"
+ assert out["barePre"] == "no code child"
+ assert out["missing"] == ""
+
+
+def test_msg_copy_source_priority_chain() -> None:
+ """A bubble copies the raw markdown the render pipeline stashed on its
+ ``.msg-body`` (``_copySource`` — set unconditionally per applied
+ frame, including frames whose markdown render threw and painted as
+ plain text). The visible textContent is the honest last resort for a
+ body that never went through the render pipeline. The stash is
+ whole-source by contract: it may carry syntax the render does not
+ display."""
+ out = _run(
+ """
+ const body = makeEl('div');
+ body._copySource = 'full **source**';
+ body.textContent = 'rendered';
+ const msg = makeEl('div');
+ msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
+ const bareBody = makeEl('div');
+ bareBody.textContent = 'plain rendered text';
+ const bare = makeEl('div');
+ bare.querySelector = (sel) => (sel === '.msg-body' ? bareBody : null);
+ return {
+ copySourceWins: msgCopySource(msg),
+ textFallback: msgCopySource(bare),
+ noBody: msgCopySource(makeEl('div')),
+ };
+ """
+ )
+ assert out["copySourceWins"] == "full **source**"
+ assert out["textFallback"] == "plain rendered text"
+ assert out["noBody"] == ""
+
+
+# ---------------------------------------------------------------------------
+# Clipboard transport — secure-context API vs plain-HTTP fallback
+# ---------------------------------------------------------------------------
+
+
+def test_copy_text_fallback_restores_selection_and_focus() -> None:
+ """Plain-HTTP LAN nodes have no ``navigator.clipboard``; the legacy
+ hidden-textarea + execCommand path must carry the copy, surface the
+ command's verdict, and leave the user's world as it found it: the
+ prior selection re-added and the previously focused element
+ re-focused (execCommand copies the textarea's selection, so both are
+ disturbed mid-flight). The textarea itself is marked as the
+ clipboard shim so delegated UI listeners can ignore it."""
+ out = _run(
+ """
+ const prevFocusEl = makeEl('input');
+ document.activeElement = prevFocusEl;
+ const okResult = await copyTextToClipboard('fence content');
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ global.__execResult = false;
+ const failResult = await copyTextToClipboard('x');
+ return {
+ okResult,
+ failResult,
+ taValue: ta ? ta.value : null,
+ taRemoved: ta ? !!ta._removed : null,
+ taShimMarked: ta ? ta.getAttribute('data-clipboard-shim') !== null : null,
+ rangesAfter: selection._ranges.map(
+ (r) => r.marker + (r.clone ? ':clone' : '')),
+ refocusCount: prevFocusEl._focused || 0,
+ refocusPreventScroll: prevFocusEl._focusOpts
+ ? prevFocusEl._focusOpts.preventScroll === true
+ : false,
+ };
+ """
+ )
+ assert out["okResult"] is True
+ assert out["failResult"] is False
+ assert out["taValue"] == "fence content"
+ assert out["taRemoved"] is True
+ assert out["taShimMarked"] is True
+ # Restore invariants: after both copies the selection holds exactly one
+ # range carrying the original's content, and it is a CLONE — getRangeAt
+ # returns live ranges the shim's select() can collapse in place, so a
+ # live ref re-added here would "restore" the collapsed range. No
+ # accumulation across copies, and the previously focused element got
+ # focus({preventScroll}) once per copy.
+ assert out["rangesAfter"] == ["prev:clone"]
+ assert out["refocusCount"] == 2
+ assert out["refocusPreventScroll"] is True
+
+
+def test_copy_text_uses_async_clipboard_in_secure_context() -> None:
+ """On HTTPS / localhost the async Clipboard API is the transport; the
+ legacy path must not run at all (no stray textarea, no execCommand)."""
+ out = _run(
+ """
+ let wrote = null;
+ let execCalled = false;
+ global.isSecureContext = true;
+ setNavigator({
+ clipboard: {
+ writeText: (v) => { wrote = v; return Promise.resolve(); },
+ },
+ });
+ global.document.execCommand = () => { execCalled = true; return true; };
+ const ok = await copyTextToClipboard('secret token');
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return { ok, wrote, execCalled, madeTextarea: !!ta };
+ """
+ )
+ assert out["ok"] is True
+ assert out["wrote"] == "secret token"
+ assert out["execCalled"] is False
+ assert out["madeTextarea"] is False
+
+
+def test_copy_text_clipboard_rejection_falls_through_to_legacy() -> None:
+ """A secure-context permission rejection is not a dead end — the legacy
+ path still gets its shot, and its verdict is the caller's answer."""
+ out = _run(
+ """
+ global.isSecureContext = true;
+ setNavigator({
+ clipboard: { writeText: () => Promise.reject(new Error('denied')) },
+ });
+ const ok = await copyTextToClipboard('salvaged');
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return { ok, taValue: ta ? ta.value : null };
+ """
+ )
+ assert out["ok"] is True
+ assert out["taValue"] == "salvaged"
+
+
+# ---------------------------------------------------------------------------
+# The bubble copy button — shape, click behavior, busy gate
+# ---------------------------------------------------------------------------
+
+
+def test_msg_copy_button_click_copies_and_flashes_copied() -> None:
+ """A click lifts the stashed markdown through the clipboard helper and
+ flashes the ✓ state (class + plain-language title); the button carries
+ the toolbar chrome the .msg-actions bars expect, and the outcome is
+ announced via the live region (clear-then-set, so it lands a beat
+ after the flash)."""
+ out = _run(
+ """
+ const body = makeEl('div');
+ body._copySource = 'raw **markdown**';
+ const msg = makeEl('div');
+ msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
+ const btn = buildMsgCopyButton(msg);
+ btn.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ className: btn.className,
+ type: btn.type,
+ aria: btn.attrs['aria-label'],
+ iconClass: btn.children[0].className,
+ flash: [...btn._classes],
+ title: btn.title,
+ copied: ta ? ta.value : null,
+ announced: liveRegion ? liveRegion.textContent : null,
+ toast: toastEl.textContent,
+ };
+ """
+ )
+ assert out["className"] == "msg-action-btn msg-copy-btn"
+ assert out["type"] == "button"
+ assert out["aria"] == "Copy message"
+ assert out["iconClass"] == "icon-copy"
+ assert out["flash"] == ["is-copied"]
+ assert out["title"] == "Copied"
+ assert out["copied"] == "raw **markdown**"
+ assert out["announced"] == "Copied"
+ assert out["toast"] == "", "success must not raise a toast"
+
+
+def test_empty_source_copies_empty_string_with_honest_outcome() -> None:
+ """A source that RESOLVES empty is copied like any other: the write is
+ attempted (the clipboard genuinely ends up holding the empty string)
+ and the flash reports the transport's verdict — never a manufactured
+ failure whose "select the text" hint points at nothing."""
+ out = _run(
+ """
+ const btn = buildMsgCopyButton(makeEl('div'));
+ btn.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ flash: [...btn._classes],
+ title: btn.title,
+ madeTextarea: !!ta,
+ taValue: ta ? ta.value : null,
+ announced: liveRegion ? liveRegion.textContent : null,
+ toast: toastEl.textContent,
+ liveRegionEager: !!liveRegion,
+ };
+ """
+ )
+ assert out["flash"] == ["is-copied"]
+ assert out["title"] == "Copied"
+ assert out["madeTextarea"] is True
+ assert out["taValue"] == ""
+ # Outcome surfaces at the button only — flash, title, and ONE
+ # aria-live announcement. No toast: identical behavior on all three
+ # pages beats louder surfacing (the empty stub host is what makes
+ # this a real assertion).
+ assert out["toast"] == ""
+ assert out["announced"] == "Copied"
+ # The region must predate the first announcement — AT only announces
+ # changes to a region already in the accessibility tree.
+ assert out["liveRegionEager"] is True
+
+
+def test_failed_copy_flashes_failure_state() -> None:
+ """A transport failure (execCommand false on a plain-HTTP node) flashes
+ the ✗ state with the manual-copy hint — never a false ✓."""
+ out = _run(
+ """
+ global.__execResult = false;
+ const body = makeEl('div');
+ body._copySource = 'content';
+ const msg = makeEl('div');
+ msg.querySelector = (sel) => (sel === '.msg-body' ? body : null);
+ const btn = buildMsgCopyButton(msg);
+ btn.click();
+ await settle();
+ return { flash: [...btn._classes], title: btn.title };
+ """
+ )
+ assert out["flash"] == ["is-copy-failed"]
+ assert out["title"] == "Copy failed — select the text and copy manually"
+
+
+def test_busy_pane_refuses_bubble_copy_click() -> None:
+ """Every copy affordance is idle-only. The bubble button's click path
+ must refuse in JS while the messages container carries
+ data-busy="true" — the chat.css pointer-events gate alone cannot stop
+ Enter on a button that already holds focus. No clipboard write, but
+ the refusal ANSWERS: ✗ flash plus a busy-specific announcement — a
+ keyboard user must be able to tell "refused while streaming" from
+ "keystroke lost". The same click works once the container returns
+ to idle."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ const wrap = makeBusyWrap(a, true);
+ const btn = buildMsgCopyButton(a.msg);
+ a.bar.appendChild(btn);
+ a.body._copySource = 'the reply';
+ a.msg.querySelector = (sel) => (sel === '.msg-body' ? a.body : null);
+ btn.click();
+ await settle();
+ const taBusy = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ const busyState = {
+ madeTextarea: !!taBusy,
+ flash: [...btn._classes],
+ busyTitle: btn.title,
+ announced: liveRegion.textContent,
+ };
+ wrap.setAttribute('data-busy', 'false');
+ btn.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ ...busyState,
+ idleCopied: ta ? ta.value : null,
+ idleFlash: [...btn._classes],
+ };
+ """
+ )
+ assert out["madeTextarea"] is False
+ assert out["flash"] == ["is-copy-failed"]
+ assert out["busyTitle"] == "Copy is available when the reply finishes"
+ assert out["announced"] == "Copy is available when the reply finishes"
+ assert out["idleCopied"] == "the reply"
+ assert out["idleFlash"] == ["is-copied"]
+
+
+# ---------------------------------------------------------------------------
+# The action bar helpers — the shared ARIA contract
+# ---------------------------------------------------------------------------
+
+
+def test_ensure_msg_actions_bar_contract_and_reuse() -> None:
+ """One helper owns the bar's ARIA contract for every adder in both
+ clients: role=toolbar + accessible name, direct-child placement, and
+ reuse of an existing bar instead of stacking a second one."""
+ out = _run(
+ """
+ const msg = makeEl('div');
+ msg.className = 'msg';
+ const bar1 = ensureMsgActionsBar(msg);
+ const bar2 = ensureMsgActionsBar(msg);
+ return {
+ sameBar: bar1 === bar2,
+ cls: bar1.className,
+ role: bar1.attrs['role'],
+ label: bar1.attrs['aria-label'],
+ isDirectChild: msg.children.includes(bar1),
+ found: findMsgActionsBar(msg) === bar1,
+ foundOnBare: findMsgActionsBar(makeEl('div')),
+ };
+ """
+ )
+ assert out["sameBar"] is True
+ assert out["cls"] == "msg-actions"
+ assert out["role"] == "toolbar"
+ assert out["label"] == "Message actions"
+ assert out["isDirectChild"] is True
+ assert out["found"] is True
+ assert out["foundOnBare"] is None
+
+
+# ---------------------------------------------------------------------------
+# The floating button — pointer-only reveal, unconditional dismissal
+# ---------------------------------------------------------------------------
+
+
+def test_hover_reveals_and_click_copies_block_source() -> None:
+ """The pointer path end to end: hovering a block reveals the floating
+ button (mounted once in , permanently out of the tab order —
+ keyboard has its own Enter path), and a click lands the block's
+ SOURCE on the clipboard with a ✓ flash."""
+ out = _run(
+ """
+ const a = makeBubble('| a |\\n|---|\\n| 1 |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ fab.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ visible: fab.classList.contains('is-visible'),
+ inBody: fab.parentNode === document.body,
+ tabindex: fab.getAttribute('tabindex'),
+ copied: ta ? ta.value : null,
+ flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
+ announced: liveRegion.textContent,
+ };
+ """
+ )
+ assert out["visible"] is True
+ assert out["inBody"] is True
+ assert out["tabindex"] == "-1"
+ assert out["copied"] == "| a |\n|---|\n| 1 |"
+ assert out["flash"] == ["is-copied"]
+ assert out["announced"] == "Copied"
+
+
+def test_pointer_dismissal_is_unconditional() -> None:
+ """Dismissal has no focus or keyboard gating left: a mouseover outside
+ any block hides the button, mouseleave (pointer leaving the window)
+ hides it, a scroll in its context hides it — even while an element
+ inside the block-and-button world holds focus — and a fresh hover
+ re-reveals after every dismissal. Without these pins, deleting a
+ dismissal listener ships green while the button strands painted over
+ unrelated content."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ const outsider = makeEl('div');
+ document.body.appendChild(outsider);
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const shown = fab.classList.contains('is-visible');
+ fireDoc('mouseover', outsider);
+ const hiddenByMouseover = !fab.classList.contains('is-visible');
+ fireDoc('mouseover', a.table);
+ const reshown = fab.classList.contains('is-visible');
+ fireDoc('mouseleave', outsider);
+ const hiddenByMouseleave = !fab.classList.contains('is-visible');
+ fireDoc('mouseover', a.table);
+ fireDoc('scroll', document);
+ const hiddenByScroll = !fab.classList.contains('is-visible');
+ // Focus anywhere in the old "keyboard-owned" world must not spare
+ // the reveal: pointer dismissal is unconditional now.
+ fireDoc('mouseover', a.table);
+ document.activeElement = a.table;
+ fireDoc('mouseover', outsider);
+ const hiddenDespiteBlockFocus = !fab.classList.contains('is-visible');
+ fireDoc('mouseover', a.table);
+ document.activeElement = fab;
+ fireDoc('mouseover', outsider);
+ const hiddenDespiteFabFocus = !fab.classList.contains('is-visible');
+ return {
+ shown,
+ hiddenByMouseover,
+ reshown,
+ hiddenByMouseleave,
+ hiddenByScroll,
+ hiddenDespiteBlockFocus,
+ hiddenDespiteFabFocus,
+ };
+ """
+ )
+ assert out["shown"] is True
+ assert out["hiddenByMouseover"] is True
+ assert out["reshown"] is True
+ assert out["hiddenByMouseleave"] is True
+ assert out["hiddenByScroll"] is True
+ assert out["hiddenDespiteBlockFocus"] is True
+ assert out["hiddenDespiteFabFocus"] is True
+
+
+def test_clipboard_shim_hover_does_not_dismiss_button() -> None:
+ """The legacy copy path's off-screen textarea sits at the viewport
+ origin; a pointer event targeting it mid-copy must not read as "the
+ pointer left the block" and dismiss the button whose outcome flash is
+ about to land. The shim marker (set in utils.js) exempts it."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const shim = makeEl('textarea');
+ shim.setAttribute('data-clipboard-shim', '');
+ document.body.appendChild(shim);
+ fireDoc('mouseover', shim);
+ return { afterShimHover: fab.classList.contains('is-visible') };
+ """
+ )
+ assert out["afterShimHover"] is True
+
+
+def test_disconnected_target_click_flashes_failure() -> None:
+ """The click guard is target-liveness only: when the revealed block has
+ left the DOM by click time (transcript wipe under the pointer), the
+ click fails with an honest ✗ — it never silently copies a stale node
+ and never retargets to whatever now occupies the space, even when a
+ same-kind replacement exists."""
+ out = _run(
+ """
+ const a = makeBubble('| gone |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ a.body.children.length = 0;
+ a.table.isConnected = false;
+ a.table.parentNode = null;
+ a.table.parentElement = null;
+ const fresh = makeEl('div');
+ fresh._classes.add('table-wrap');
+ fresh.setAttribute('data-md-source', '| fresh |');
+ a.body.appendChild(fresh);
+ fab.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
+ madeTextarea: !!ta,
+ toast: toastEl.textContent,
+ };
+ """
+ )
+ assert out["flash"] == ["is-copy-failed"]
+ assert out["madeTextarea"] is False
+ assert out["toast"] == ""
+
+
+def test_nested_error_pre_lifts_to_container() -> None:
+ """The mermaid ERROR state draws its message + source as a INSIDE
+ the .mermaid-container. That pre is a rendering detail, not a copy
+ target: hovering it must reveal the button FOR THE CONTAINER (whose
+ data-mermaid-source is the honest source), and an Enter keydown
+ reaching the nested pre must copy the container too — both paths lift
+ through the same helper."""
+ out = _run(
+ """
+ const a = makeBubble('| t |');
+ const mermaid = makeEl('div');
+ mermaid._classes.add('mermaid-container');
+ mermaid.setAttribute('data-mermaid-source', 'graph TD');
+ mermaid.setAttribute('tabindex', '0');
+ const errPre = makeEl('pre');
+ errPre.textContent = 'mermaid error source';
+ a.body.appendChild(mermaid);
+ mermaid.appendChild(errPre);
+ fireDoc('mouseover', errPre);
+ const hoverTarget = blockCopySource(_fabTarget);
+ fireDoc('keydown', errPre, { key: 'Enter' });
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ hoverTarget,
+ kbdCopied: ta ? ta.value : null,
+ flashOnContainer: mermaid.classList.contains('is-copied'),
+ flashOnErrPre: errPre.classList.contains('is-copied'),
+ };
+ """
+ )
+ assert out["hoverTarget"] == "graph TD"
+ assert out["kbdCopied"] == "graph TD"
+ assert out["flashOnContainer"] is True
+ assert out["flashOnErrPre"] is False
+
+
+def test_busy_pane_gates_fab_reveal_and_click() -> None:
+ """The floating button must never REVEAL while the pane is busy (the
+ turn is mutating the transcript under it), and a click on a button
+ revealed at idle whose pane went busy before the click refuses with
+ an honest ✗ and the busy-specific announcement instead of copying —
+ or silently swallowing — the gesture. Idle again, the same hover
+ reveals normally."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ const wrap = makeBusyWrap(a, true);
+ fireDoc('mouseover', a.table);
+ const revealedWhileBusy = !!fabEl() && fabEl().classList.contains('is-visible');
+ wrap.setAttribute('data-busy', 'false');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const revealedAtIdle = fab.classList.contains('is-visible');
+ wrap.setAttribute('data-busy', 'true');
+ fab.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ revealedWhileBusy,
+ revealedAtIdle,
+ clickFlash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
+ announced: liveRegion.textContent,
+ madeTextarea: !!ta,
+ };
+ """
+ )
+ assert out["revealedWhileBusy"] is False
+ assert out["revealedAtIdle"] is True
+ assert out["clickFlash"] == ["is-copy-failed"]
+ assert out["announced"] == "Copy is available when the reply finishes"
+ assert out["madeTextarea"] is False
+
+
+def test_within_block_move_dismisses_fab_when_turn_starts() -> None:
+ """The mouseover fast path (pointer moving WITHIN the already-targeted
+ block) must still honor the busy gate: a turn starting under a shown
+ button dismisses it on the NEXT pointer move — not only once the
+ pointer eventually leaves the block."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ const wrap = makeBusyWrap(a, false);
+ const cell = makeEl('span');
+ a.table.appendChild(cell);
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const revealedAtIdle = fab.classList.contains('is-visible');
+ wrap.setAttribute('data-busy', 'true');
+ fireDoc('mouseover', cell);
+ return {
+ revealedAtIdle,
+ visibleAfterBusyMove: fab.classList.contains('is-visible'),
+ };
+ """
+ )
+ assert out["revealedAtIdle"] is True
+ assert out["visibleAfterBusyMove"] is False
+
+
+def test_scroll_dismissal_follows_containment() -> None:
+ """Scroll hides the button exactly when the scrolled thing MOVES the
+ block: the document, or any scrollable ancestor holding the block,
+ dismisses; a pan INSIDE the block (a fence's internal horizontal
+ scroll) and an unrelated scroller (a sidebar) keep it — regardless
+ of whether the surface has an overflow container at all."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ const wrap = makeBusyWrap(a, false);
+ const sidebar = makeEl('div');
+ document.body.appendChild(sidebar);
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const shown = fab.classList.contains('is-visible');
+ fireDoc('scroll', a.table);
+ const keptOnInternalPan = fab.classList.contains('is-visible');
+ fireDoc('scroll', sidebar);
+ const keptOnSidebar = fab.classList.contains('is-visible');
+ fireDoc('scroll', wrap);
+ const hiddenOnAncestor = !fab.classList.contains('is-visible');
+ fireDoc('mouseover', a.table);
+ fireDoc('scroll', document);
+ const hiddenOnDocument = !fab.classList.contains('is-visible');
+ return {
+ shown,
+ keptOnInternalPan,
+ keptOnSidebar,
+ hiddenOnAncestor,
+ hiddenOnDocument,
+ };
+ """
+ )
+ assert out["shown"] is True
+ assert out["keptOnInternalPan"] is True
+ assert out["keptOnSidebar"] is True
+ assert out["hiddenOnAncestor"] is True
+ assert out["hiddenOnDocument"] is True
+
+
+def test_inflight_copy_outcome_is_orphaned_by_retarget() -> None:
+ """The clipboard write settles asynchronously; a re-target inside that
+ gap must orphan the pending outcome — otherwise the ✓ and its
+ announcement land on a block the user never copied."""
+ out = _run(
+ """
+ global.isSecureContext = true;
+ let resolveWrite = null;
+ setNavigator({
+ clipboard: {
+ writeText: () => new Promise((r) => { resolveWrite = r; }),
+ },
+ });
+ const a = makeBubble('| one |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ fab.click();
+ const b = makeBubble('| two |');
+ fireDoc('mouseover', b.table);
+ resolveWrite();
+ await settle();
+ return {
+ flash: [...fab._classes].filter((c) => c.startsWith('is-cop')),
+ announced: liveRegion.textContent,
+ };
+ """
+ )
+ assert out["flash"] == []
+ assert out["announced"] == ""
+
+
+def test_second_copy_during_flash_window_keeps_its_outcome() -> None:
+ """A copy started while the previous outcome flash is still showing
+ claims the element's outcome slot: the earlier flash's revert timer
+ is cancelled at copy start. Left armed, it fires mid-write, bumps
+ the generation, and the settling write paints nothing and announces
+ nothing — a silent copy, or worse a silent FAILURE the user reads as
+ success. The second test that waits out the real 1.4s timer."""
+ out = _run(
+ """
+ global.isSecureContext = true;
+ let resolveWrite = null;
+ let calls = 0;
+ setNavigator({
+ clipboard: {
+ writeText: () => {
+ calls += 1;
+ if (calls === 1) return Promise.resolve();
+ return new Promise((r) => { resolveWrite = r; });
+ },
+ },
+ });
+ const a = makeBubble('| a |');
+ const btn = buildMsgCopyButton(a.msg);
+ a.bar.appendChild(btn);
+ a.body._copySource = 'reply';
+ a.msg.querySelector = (sel) => (sel === '.msg-body' ? a.body : null);
+ btn.click();
+ await settle();
+ const firstFlash = [...btn._classes];
+ btn.click();
+ // Outlive the FIRST flash's 1400ms revert while the second write
+ // is still in flight — with the timer cancelled, nothing fires.
+ await settle(1600);
+ const inflightClasses = [...btn._classes];
+ liveRegion.textContent = '';
+ resolveWrite();
+ await settle();
+ return {
+ firstFlash,
+ inflightClasses,
+ finalFlash: [...btn._classes],
+ announced: liveRegion.textContent,
+ };
+ """
+ )
+ assert out["firstFlash"] == ["is-copied"]
+ # In flight, the element sits at idle (the new copy cleared the old
+ # flash); the orphaning revert never fires.
+ assert out["inflightClasses"] == []
+ assert out["finalFlash"] == ["is-copied"]
+ assert out["announced"] == "Copied"
+
+
+def test_aborted_reveal_on_sliver_block_does_not_strand_state() -> None:
+ """_showFabFor assigns the target refs BEFORE its sliver check; when
+ the check aborts a reveal that started with the button hidden, the
+ refs must still be cleared — stranded refs make the hover fast-path
+ treat the block as already handled, permanently killing its copy
+ affordance."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ // The block peeks 12px into the viewport: under the 32px sliver
+ // threshold, so the reveal aborts while the fab is still hidden.
+ a.table._rect = { top: 588, bottom: 700, left: 0, right: 200, width: 200, height: 112 };
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ const hiddenAfterSliver = !fab || !fab.classList.contains('is-visible');
+ // The block scrolls fully into view; the SAME interaction must now
+ // reveal the button.
+ a.table._rect = { top: 100, bottom: 220, left: 0, right: 200, width: 200, height: 120 };
+ fireDoc('mouseover', a.table);
+ const revealsAfterHover = fabEl().classList.contains('is-visible');
+ return { hiddenAfterSliver, revealsAfterHover };
+ """
+ )
+ assert out["hiddenAfterSliver"] is True
+ assert out["revealsAfterHover"] is True
+
+
+def test_retarget_clears_previous_outcome_flash() -> None:
+ """The singleton button must never carry one block's ✓/✗ onto another —
+ a fresh reveal starts idle, even inside the 1.4s flash window."""
+ out = _run(
+ """
+ const a = makeBubble('| one |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ fab.click();
+ await settle();
+ const flashedAfterCopy = fab.classList.contains('is-copied');
+ const b = makeBubble('| two |');
+ fireDoc('mouseover', b.table);
+ return {
+ flashedAfterCopy,
+ carried: [...fab._classes].filter((c) => c.startsWith('is-cop')),
+ title: fab.title,
+ };
+ """
+ )
+ assert out["flashedAfterCopy"] is True
+ assert out["carried"] == []
+ assert out["title"] == "Copy block"
+
+
+def test_unresolvable_bar_collision_hides_instead_of_stacking() -> None:
+ """When the band clamp leaves nowhere below the action bar, the
+ floating button must hide rather than paint on top of the bubble's own
+ copy button — two stacked identical glyphs make a click that targets
+ one silently hit the other."""
+ out = _run(
+ """
+ const a = makeBubble('| a |');
+ // The block hugs the bottom of the viewport band while the bar sits
+ // just above the only admissible strip: the collision push has no
+ // room below the bar.
+ a.table._rect = { top: 560, bottom: 700, left: 0, right: 200, width: 200, height: 140 };
+ a.bar._rect = { top: 555, bottom: 579, left: 150, right: 200, width: 50, height: 24 };
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ return { visible: !!fab && fab.classList.contains('is-visible') };
+ """
+ )
+ assert out["visible"] is False
+
+
+def test_failure_flash_orphans_prior_inflight_copy() -> None:
+ """A failure flash is a NEW outcome: a still-pending copy from an
+ earlier click must not settle afterwards and flip the ✗ (with its
+ recovery title) back to an unearned ✓."""
+ out = _run(
+ """
+ global.isSecureContext = true;
+ let resolveWrite = null;
+ setNavigator({
+ clipboard: {
+ writeText: () => new Promise((r) => { resolveWrite = r; }),
+ },
+ });
+ const a = makeBubble('| slow |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ fab.click();
+ // The bubble empties (no replacement), so the second click finds a
+ // disconnected target and flashes the failure.
+ a.body.children.length = 0;
+ a.table.isConnected = false;
+ a.table.parentNode = null;
+ a.table.parentElement = null;
+ fab.click();
+ const failedShown = fab.classList.contains('is-copy-failed');
+ // The first click's slow write now settles successfully.
+ resolveWrite(true);
+ await settle();
+ return {
+ failedShown,
+ stillFailed: fab.classList.contains('is-copy-failed'),
+ notFlippedToCopied: !fab.classList.contains('is-copied'),
+ title: fab.title,
+ };
+ """
+ )
+ assert out["failedShown"] is True
+ assert out["stillFailed"] is True
+ assert out["notFlippedToCopied"] is True
+ assert out["title"] == "Copy failed — select the text and copy manually"
+
+
+def test_click_survives_post_reveal_sliver_without_hiding() -> None:
+ """A click on a still-connected target must not re-run placement — a
+ reflow may have slivered the block since the reveal, and a reposition
+ would hide the button while the copy proceeds, making its outcome
+ flash invisible. The click path copies, flashes, and leaves the
+ button exactly where the user pressed it."""
+ out = _run(
+ """
+ const a = makeBubble('| here |');
+ fireDoc('mouseover', a.table);
+ const fab = fabEl();
+ // The block slivers AFTER the reveal (late reflow).
+ a.table._rect = { top: 588, bottom: 700, left: 0, right: 200, width: 200, height: 112 };
+ fab.click();
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ copied: ta ? ta.value : null,
+ stillVisible: fab.classList.contains('is-visible'),
+ flashed: fab.classList.contains('is-copied'),
+ };
+ """
+ )
+ assert out["copied"] == "| here |"
+ assert out["stillVisible"] is True
+ assert out["flashed"] is True
+
+
+# ---------------------------------------------------------------------------
+# The keyboard path — Enter on a focused block
+# ---------------------------------------------------------------------------
+
+
+def test_enter_on_block_copies_source_and_flashes_the_block() -> None:
+ """Enter on a focused block copies that block's source directly: no
+ floating button involved (none is created, let alone revealed), the
+ outcome flashes on the BLOCK (is-copied), and the live region
+ announces it. Enter reaching the listener from a descendant of the
+ block (a focusable child owns its own Enter semantics) and non-Enter
+ keys are ignored."""
+ out = _run(
+ """
+ const a = makeBubble('| kbd |\\n|---|\\n| 1 |');
+ const cell = makeEl('a');
+ a.table.appendChild(cell);
+ fireDoc('keydown', cell, { key: 'Enter' });
+ fireDoc('keydown', a.table, { key: 'a' });
+ await settle();
+ const taEarly = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ const ignored = {
+ madeTextarea: !!taEarly,
+ flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ };
+ fireDoc('keydown', a.table, { key: 'Enter' });
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ ...ignored,
+ copied: ta ? ta.value : null,
+ blockFlash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ blockTitle: a.table.title,
+ announced: liveRegion.textContent,
+ fabExists: !!fabEl(),
+ };
+ """
+ )
+ assert out["madeTextarea"] is False
+ assert out["flash"] == []
+ assert out["copied"] == "| kbd |\n|---|\n| 1 |"
+ assert out["blockFlash"] == ["is-copied"]
+ assert out["blockTitle"] == "Copied"
+ assert out["announced"] == "Copied"
+ assert out["fabExists"] is False
+
+
+def test_enter_on_block_refuses_while_busy() -> None:
+ """The keyboard path shares the pane-level busy gate: Enter on a
+ focused block inside a data-busy="true" container copies nothing but
+ ANSWERS — the block flashes the ✗ ring and the live region carries
+ the busy-specific explanation, because a silent refusal is
+ indistinguishable from a lost keystroke. The same Enter works once
+ the pane is idle."""
+ out = _run(
+ """
+ const a = makeBubble('| kbd |');
+ const wrap = makeBusyWrap(a, true);
+ fireDoc('keydown', a.table, { key: 'Enter' });
+ await settle();
+ const taBusy = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ const busyState = {
+ madeTextarea: !!taBusy,
+ flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ announced: liveRegion.textContent,
+ };
+ wrap.setAttribute('data-busy', 'false');
+ fireDoc('keydown', a.table, { key: 'Enter' });
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ ...busyState,
+ idleCopied: ta ? ta.value : null,
+ idleFlash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ };
+ """
+ )
+ assert out["madeTextarea"] is False
+ assert out["flash"] == ["is-copy-failed"]
+ assert out["announced"] == "Copy is available when the reply finishes"
+ assert out["idleCopied"] == "| kbd |"
+ assert out["idleFlash"] == ["is-copied"]
+
+
+def test_enter_chords_and_repeat_do_not_copy() -> None:
+ """Modifier-Enter chords belong to the browser / OS, and key repeat is
+ never a deliberate copy — a copy overwrites the user's clipboard, so
+ only a plain, single Enter fires."""
+ out = _run(
+ """
+ const a = makeBubble('| kbd |');
+ for (const props of [
+ { key: 'Enter', ctrlKey: true },
+ { key: 'Enter', altKey: true },
+ { key: 'Enter', metaKey: true },
+ { key: 'Enter', shiftKey: true },
+ { key: 'Enter', repeat: true },
+ ]) fireDoc('keydown', a.table, props);
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ const chordState = {
+ madeTextarea: !!ta,
+ flash: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ };
+ fireDoc('keydown', a.table, { key: 'Enter' });
+ await settle();
+ const ta2 = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return { ...chordState, plainCopied: ta2 ? ta2.value : null };
+ """
+ )
+ assert out["madeTextarea"] is False
+ assert out["flash"] == []
+ assert out["plainCopied"] == "| kbd |"
+
+
+def test_enter_with_empty_source_copies_empty_string() -> None:
+ """A focused block whose source resolves empty (a mermaid container
+ stripped of its stash) still attempts the write: the block flashes the
+ transport's verdict — never a manufactured failure whose "select the
+ text" hint points at a block with nothing to select."""
+ out = _run(
+ """
+ const a = makeBubble('| t |');
+ const mermaid = makeEl('div');
+ mermaid._classes.add('mermaid-container');
+ mermaid.setAttribute('tabindex', '0');
+ a.body.appendChild(mermaid);
+ fireDoc('keydown', mermaid, { key: 'Enter' });
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ madeTextarea: !!ta,
+ taValue: ta ? ta.value : null,
+ flash: [...mermaid._classes].filter((c) => c.startsWith('is-cop')),
+ announced: liveRegion.textContent,
+ };
+ """
+ )
+ assert out["madeTextarea"] is True
+ assert out["taValue"] == ""
+ assert out["flash"] == ["is-copied"]
+ assert out["announced"] == "Copied"
+
+
+def test_block_flash_reverts_clean_after_flash_ms() -> None:
+ """The outcome flash on a keyboard-copied BLOCK is transient: after
+ FLASH_MS the state class reverts and the title carries no residue — a
+ block (which unlike the buttons has no idle tooltip of its own)
+ returns to an empty title, never a literal "undefined". The one test
+ that waits out the real 1.4s revert timer."""
+ out = _run(
+ """
+ const a = makeBubble('| kbd |');
+ fireDoc('keydown', a.table, { key: 'Enter' });
+ await settle();
+ const flashTitle = a.table.title;
+ const flashed = [...a.table._classes].filter((c) => c.startsWith('is-cop'));
+ await settle(1500);
+ return {
+ flashed,
+ flashTitle,
+ idleClasses: [...a.table._classes].filter((c) => c.startsWith('is-cop')),
+ idleTitle: a.table.title,
+ };
+ """
+ )
+ assert out["flashed"] == ["is-copied"]
+ assert out["flashTitle"] == "Copied"
+ assert out["idleClasses"] == []
+ assert out["idleTitle"] == ""
+
+
+def test_enter_outside_msg_body_is_ignored() -> None:
+ """The keyboard path is scoped to blocks inside rendered chat bodies —
+ a focusable pre on some other surface (admin panes, previews) keeps
+ its own Enter semantics."""
+ out = _run(
+ """
+ const stray = makeEl('pre');
+ stray.setAttribute('tabindex', '0');
+ stray.textContent = 'not transcript content';
+ document.body.appendChild(stray);
+ fireDoc('keydown', stray, { key: 'Enter' });
+ await settle();
+ const ta = createdEls.find((e) => e.tagName === 'TEXTAREA');
+ return {
+ madeTextarea: !!ta,
+ flash: [...stray._classes].filter((c) => c.startsWith('is-cop')),
+ };
+ """
+ )
+ assert out["madeTextarea"] is False
+ assert out["flash"] == []
diff --git a/tests/test_renderer_js.py b/tests/test_renderer_js.py
index fffdde6c..e89effe7 100644
--- a/tests/test_renderer_js.py
+++ b/tests/test_renderer_js.py
@@ -12,7 +12,7 @@ the rendered HTML for a sample input. The assertions check the
resulting markup contains the expected ``…``
placeholder and not the raw delimiter.
-Both files are ES modules now; ``_demodulize`` strips the module syntax
+Both files are ES modules now; ``demodulize`` strips the module syntax
so the script-semantics harness keeps working. That is DELIBERATE, not
a shortcut: the mermaid harness pokes renderer-internal state
(``_mermaidState``) that script evaluation exposes as a context global
@@ -25,39 +25,20 @@ from __future__ import annotations
import json
import re
-import shutil
import subprocess
from pathlib import Path
from typing import Any
import pytest
+from tests._js_harness_helpers import demodulize, node_skip
+
_REPO_ROOT = Path(__file__).resolve().parent.parent
_UTILS_JS = _REPO_ROOT / "turnstone/shared_static/utils.js"
_RENDERER_JS = _REPO_ROOT / "turnstone/shared_static/renderer.js"
-def _has_node() -> bool:
- return shutil.which("node") is not None
-
-
-pytestmark = pytest.mark.skipif(not _has_node(), reason="node not available")
-
-
-def _demodulize(path: Path) -> str:
- """Strip ES-module syntax so ``vm.runInThisContext`` (script semantics)
- can evaluate the file: imports drop (the harness loads the whole
- dependency set into one shared context, so cross-file bindings resolve
- as context globals, exactly like the pre-module classic scripts), and
- ``export`` keywords peel off their declarations."""
- src = path.read_text(encoding="utf-8")
- src = re.sub(r"^import\s+\{[\s\S]*?\}\s+from\s+\"[^\"]+\";\s*$", "", src, flags=re.M)
- src = re.sub(r"^import\s+[^;\n]+;\s*$", "", src, flags=re.M)
- src = re.sub(
- r"^export\s+(?=(?:async\s+)?(?:function|const|let|var|class)\b)", "", src, flags=re.M
- )
- src = re.sub(r"^export\s*\{[^}]*\};\s*$", "", src, flags=re.M)
- return src
+pytestmark = node_skip
_HARNESS_TEMPLATE = """
@@ -93,8 +74,8 @@ process.stdout.write(renderMarkdown(input));
def _render(markdown: str) -> str:
"""Render ``markdown`` through renderer.js + return the HTML."""
harness = _HARNESS_TEMPLATE % {
- "utils_src": json.dumps(_demodulize(_UTILS_JS)),
- "renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
+ "utils_src": json.dumps(demodulize(_UTILS_JS)),
+ "renderer_src": json.dumps(demodulize(_RENDERER_JS)),
"input": json.dumps(markdown),
}
result = subprocess.run(
@@ -206,7 +187,7 @@ def test_latex_math_inside_inline_code_preserved() -> None:
def test_latex_math_inside_fenced_code_preserved() -> None:
r"""\(...\) inside a fenced block must stay literal."""
out = _render("```\nA \\(x\\) sample\n```")
- assert "" in out
+ assert '' in out
assert '' not in out
@@ -470,8 +451,8 @@ _mermaidState = 'ready';
def _run_mermaid_scenario(scenario_js: str) -> dict[str, Any]:
"""Run a JS snippet against the mermaid-aware harness, return JSON output."""
harness = _MERMAID_HARNESS_TEMPLATE % {
- "utils_src": json.dumps(_demodulize(_UTILS_JS)),
- "renderer_src": json.dumps(_demodulize(_RENDERER_JS)),
+ "utils_src": json.dumps(demodulize(_UTILS_JS)),
+ "renderer_src": json.dumps(demodulize(_RENDERER_JS)),
"scenario": scenario_js,
}
result = subprocess.run(
@@ -1577,7 +1558,7 @@ def test_forged_code_block_sentinel_does_not_duplicate_block() -> None:
forgery: exactly one code block, no leaked sentinel."""
md = "```python\nprint('hi')\n```\n\nprose " + _NUL + "CB0" + _NUL + " end"
out = _render(md)
- assert out.count("") == 1, "forged CB sentinel duplicated the block:\n" + out
+ assert out.count(" None:
only removes caller-supplied control chars, which are never valid data)."""
out = _render("Here is `inline` and a block:\n\n```py\nx = 1\n```")
assert "inline" in out
- assert " None:
``…
`` before the ``
``. The unwrap removes the wrapping paragraph."""
out = _render("```py\nx = 1\n```")
- assert "" not in out, "code block still wrapped in a paragraph:\n" + out
- assert out.strip().startswith(""), "code block should not be paragraph-wrapped:\n" + out
+ assert out.strip().startswith(" None:
fence pass now runs first and masks the region."""
out = _render("```text\nplain\n> quoted\nafter\n```")
assert "" not in out, "blockquote extracted from inside a fence:\n" + out
- assert " None:
would have swallowed the blockquoted fence as ``undefined``.)"""
out = _render("> ```\n> code\n> ```")
assert "" in out
- assert "code
" in out, "blockquoted fence lost its code:\n" + out
+ assert 'code
' in out, (
+ "blockquoted fence lost its code:\n" + out
+ )
assert "undefined" not in out
assert _NUL not in out
@@ -1715,7 +1698,9 @@ def test_indented_fence_still_renders_as_code() -> None:
paragraph of literal backticks. (A bare ``^`` anchor would drop it; the
deeper 4-space-indent case is pinned separately.)"""
out = _render(" ```py\n x = 1\n ```")
- assert " None:
fence is masked by the (earlier) fence pass and must stay literal escaped
code, never extracted into a real element."""
out = _render("```html\ns
x\n```")
- assert "" not in out, "details inside a fence was wrongly extracted:\n" + out
@@ -1795,7 +1780,7 @@ def test_code_block_in_details_renders_code() -> None:
render the CODE, not `undefined` and not an inert `CB0` sentinel."""
out = _render("\nx
\n\n```py\nsecret_code()\n```\n\n")
assert "secret_code()" in out, "code inside was lost:\n" + out
- assert " Non
tag; the fenced example renders as literal code inside the block."""
md = "\ns
\n\n```html\n\n```\n\n
"
out = _render(md)
- assert '' in out, "fenced example was swallowed:\n" + out
+ assert '' in out, (
+ "fenced example was swallowed:\n" + out
+ )
assert "</details>" in out, "example should be literal code:\n" + out
assert out.strip().startswith("s
"), out
assert out.rstrip().endswith(""), "real block closed early / stray text:\n" + out
@@ -1853,7 +1840,7 @@ def test_deeply_indented_fence_renders_as_code() -> None:
tokenises as a code block — the open anchor allows arbitrary indent, so we
don't regress deeply-nested code samples to literal backticks."""
out = _render(" ```py\n x = 1\n ```")
- assert " None:
backticks + language tag as text."""
for src in ["- ```py\n print(1)\n ```", "1. ```py\n print(1)\n ```"]:
out = _render(src)
- assert "" in out, "list structure lost:\n" + out
@@ -1879,7 +1868,7 @@ def test_nested_list_fence_stays_nested() -> None:
the indent flattened the code block to a top-level sibling of the parent."""
out = _render("- parent\n - ```py\n code\n ```")
assert "parent" in out
- assert "") == 2, "nested list fence flattened to a sibling:\n" + out
@@ -1888,7 +1877,7 @@ def test_big_ordered_marker_fence_is_protected() -> None:
protected — the marker alternation uses ``\d+``, matching the list pass,
not a capped ``\d{1,9}`` that would leave the fence unprotected."""
out = _render("1234567890. ```py\ncode\n```")
- assert " None:
out = _render(" x
y")
assert "x
" in out, "indented not extracted:\n" + out
assert "y" in out
+
+
+# ---------------------------------------------------------------------------
+# Table source stash — data-md-source for the copy affordance
+# ---------------------------------------------------------------------------
+
+
+def test_table_wrap_carries_exact_md_source() -> None:
+ """The table pass trims cells and inline-renders them, so the original
+ pipe / alignment lines are unrecoverable from the DOM. The copy
+ affordance (copy_actions.js blockCopySource) reads the exact source
+ region from ``data-md-source``, stashed on ``.table-wrap`` at render
+ time — this pins both the attribute's presence and its exactness
+ (all rows, alignment line included, no surrounding prose)."""
+ table = "| a | b |\n|---|:--:|\n| 1 | 2 |\n| 3 | 4 |"
+ out = _render("Intro line\n\n" + table + "\n\nOutro line")
+ assert 'data-md-source="' + table + '"' in out, (
+ "table-wrap missing or carrying an inexact data-md-source:\n" + out
+ )
+
+
+def test_table_md_source_restores_inline_spans_to_raw_source() -> None:
+ """Table cells holding inline code / math are NUL-sentinel-masked by the
+ time the table pass slices its source region, and the global restore
+ passes run AFTER the table restore — i.e. INSIDE the emitted attribute.
+ Unfixed, a backtick cell put ```` markup on the clipboard and a
+ math cell spliced KaTeX HTML whose quotes TERMINATED the attribute
+ (spurious ``katex"`` attribute + source text leaking as page content).
+ The stash must round-trip the RAW author text for all three span kinds,
+ and no sentinel may survive anywhere in the output."""
+ for src in (
+ "| `x=1` | b |\n|---|---|\n| 1 | 2 |",
+ "| \\(x^2\\) | square |\n|---|---|\n| 1 | 2 |",
+ "| $$x$$ | b |\n|---|---|\n| 1 | 2 |",
+ ):
+ out = _render(src)
+ m = re.search(r'data-md-source="([^"]*)"', out)
+ assert m is not None, "no intact data-md-source attribute:\n" + out
+ # Equality (not substring): the value is the exact raw source — no
+ # rendered substitutes, no stranded sentinels, and by construction
+ # no premature quote terminating the attribute early.
+ assert m.group(1) == src, (
+ "data-md-source did not round-trip raw for " + repr(src) + ":\n" + out
+ )
+ assert "<" not in m.group(1), "markup inside the source stash:\n" + out
+ assert "\x00" not in out, "a masking sentinel survived into the output"
+ # The rendered CELLS resolve too — tables restore before the inline-span
+ # passes, so display math in a cell renders as KaTeX instead of literal
+ # sentinel garbage (a pre-existing ordering bug this stash work exposed).
+ out = _render("| $$x$$ | b |\n|---|---|\n| 1 | 2 |")
+ assert '' in out, "display math in a table cell did not render:\n" + out
+
+
+def test_table_md_source_restores_nested_masks() -> None:
+ """A math span in a cell can SWALLOW an inline-code sentinel: code is
+ masked FIRST, so the math raw twin carries the code's sentinel rather
+ than its text. The attribute restore must run in reverse mask order
+ (math before inline code) — restored in mask order, the swallowed
+ sentinel surfaces after its own pass already ran, falls to the
+ foreign-residue strip, and the copied source silently loses the code
+ span under a success flash."""
+ md = "| $$x `y` z$$ | \\(p `q` r\\) |\n|---|---|\n| 1 | 2 |"
+ out = _render(md)
+ m = re.search(r'data-md-source="([^"]*)"', out)
+ assert m is not None, "no intact data-md-source attribute:\n" + out
+ assert m.group(1) == md, "nested code-in-math did not round-trip raw:\n" + out
+
+
+def test_table_md_source_escapes_attribute_breakout_chars() -> None:
+ """A cell containing quotes or angle brackets must stay entity-escaped
+ inside the attribute value — a raw ``"`` would end the attribute and
+ let cell text inject markup."""
+ out = _render('| say "hi" & | b |\n|---|---|\n| 1 | 2 |')
+ assert 'data-md-source="| say "hi" & <b> | b |' in out, (
+ "quote / angle-bracket escaping missing in data-md-source:\n" + out
+ )
+ assert 'data-md-source="| say "hi"' not in out
+
+
+def test_table_md_source_aligns_raw_twins_at_nonzero_index() -> None:
+ """The raw-twin restore is index-aligned with the mask arrays; spans in
+ PROSE before the table push the table's own spans to non-zero raw
+ indices, so a push-to-one-array-only regression would splice a
+ DIFFERENT span's source into the stash. Index 0 alone cannot catch
+ that."""
+ table = "| `x=1` | \\(y^2\\) |\n|---|---|\n| 1 | 2 |"
+ out = _render("Use `a` and \\(z\\) first.\n\n" + table)
+ m = re.search(r'data-md-source="([^"]*)"', out)
+ assert m is not None, "no intact data-md-source attribute:\n" + out
+ assert m.group(1) == table, (
+ "non-zero-index raw twins did not round-trip the table's own spans:\n" + out
+ )
+
+
+def test_footnote_frame_table_stash_round_trips_spans() -> None:
+ """A table inside a FOOTNOTE DEFINITION renders in a recursive frame.
+ The body is collected AFTER the outer inline-span masks, so its cells
+ carry OUTER-frame sentinels — restored to raw source before the
+ recursive render (restoreRawSpans), which re-masks them itself so its
+ own stash resolves them against its own raw twins. Left unrestored,
+ the stash's residue strip silently DELETED the cell content from the
+ copied source under a success flash (and without the strip, the
+ outer restores would splice rendered HTML — attribute-terminating
+ quotes included — into the attribute after the fact)."""
+ out = _render("Ref[^a].\n\n[^a]: note\n | h | v |\n |---|---|\n | $$y$$ | `c` |")
+ m = re.search(r'data-md-source="([^"]*)"', out)
+ assert m is not None, "no intact data-md-source attribute:\n" + out
+ assert m.group(1) == "| h | v |\n|---|---|\n| $$y$$ | `c` |", (
+ "footnote-frame table stash did not round-trip its cells' raw source:\n" + out
+ )
+ assert "\x00" not in out, "a masking sentinel survived into the output"
+ assert '' in out, (
+ "the footnote table's math cell should render in the recursive frame:\n" + out
+ )
+
+
+def test_table_md_source_carries_ragged_rows_whole() -> None:
+ """Copy is SOURCE copy, uniformly: a row wider than the header renders
+ truncated (the loop emits hdrCells.length cells) but the stash carries
+ the block's whole raw source — the same contract as message copy.
+ What is shown is the render's decision; what is copied is what was
+ written."""
+ src = "| a | b |\n|---|---|\n| 1 | 2 | extra |"
+ out = _render(src)
+ m = re.search(r'data-md-source="([^"]*)"', out)
+ assert m is not None, "no intact data-md-source attribute:\n" + out
+ assert m.group(1) == src, "ragged table did not round-trip its whole source:\n" + out
+ assert ">extra<" not in out, "overflow cell leaked into the RENDERED table"
diff --git a/tests/test_shell_js.py b/tests/test_shell_js.py
index af552d71..20cd0c4b 100644
--- a/tests/test_shell_js.py
+++ b/tests/test_shell_js.py
@@ -52,6 +52,7 @@ _ESM_BUNDLES = [
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js",
+ _SHARED / "copy_actions.js",
]
# Sink scan: everything except renderer.js — the one sanctioned HTML-string
@@ -74,6 +75,7 @@ _ESM_NO_VAR_BUNDLES = [
_SHARED / "preview.js",
_SHARED / "redact_credentials.js",
_SHARED / "mcp_error.js",
+ _SHARED / "copy_actions.js",
]
# The same unsafe DOM-write / dynamic-code sink set that ``test_app_js.py``
diff --git a/turnstone/console/static/admin.js b/turnstone/console/static/admin.js
index a898ad91..680ba51b 100644
--- a/turnstone/console/static/admin.js
+++ b/turnstone/console/static/admin.js
@@ -3670,12 +3670,9 @@ function hideTokenCreatedModal() {
function copyCreatedToken() {
if (!_lastCreatedToken) return;
- if (navigator.clipboard) {
- navigator.clipboard.writeText(_lastCreatedToken).then(function () {
- showToast("Token copied to clipboard");
- });
- } else {
- // Fallback: select the text
+ // Select the token text so a manual copy is one keystroke away — the
+ // landing spot whenever no automatic path succeeded.
+ function selectTokenFallback() {
const el = document.getElementById("token-created-value");
const range = document.createRange();
range.selectNodeContents(el);
@@ -3684,6 +3681,33 @@ function copyCreatedToken() {
sel.addRange(range);
showToast("Select and copy the token");
}
+ // copyTextToClipboard (utils.js) covers the plain-HTTP LAN case via its
+ // legacy execCommand path. The one-time token dialog must keep working
+ // with ZERO module dependencies (this is a classic script; the bridge
+ // is absent whenever the module lane failed), so with no bridge the
+ // native async API is still attempted directly — most degraded pages
+ // are HTTPS consoles where it works — before degrading to the manual
+ // selection, never to a throw.
+ if (typeof window.copyTextToClipboard !== "function") {
+ if (navigator.clipboard) {
+ navigator.clipboard
+ .writeText(_lastCreatedToken)
+ .then(function () {
+ showToast("Token copied to clipboard");
+ })
+ .catch(selectTokenFallback);
+ } else {
+ selectTokenFallback();
+ }
+ return;
+ }
+ window.copyTextToClipboard(_lastCreatedToken).then(function (ok) {
+ if (ok) {
+ showToast("Token copied to clipboard");
+ return;
+ }
+ selectTokenFallback();
+ });
}
// Escape closes any open settings help popover (the settings panels and the
diff --git a/turnstone/console/static/coordinator/coordinator.js b/turnstone/console/static/coordinator/coordinator.js
index 46ca56b0..80014a1b 100644
--- a/turnstone/console/static/coordinator/coordinator.js
+++ b/turnstone/console/static/coordinator/coordinator.js
@@ -881,6 +881,12 @@ function createCoordinatorPane(root, wsId, opts) {
return UNKNOWN_AUTO_APPROVE_REASON;
}
+ // The exits carry tabindex=0: any pre inside a .msg-body hosts
+ // the pointer copy affordance (copy_actions.js), and the keyboard copy
+ // path acts on the FOCUSED block — an unfocusable pre would be
+ // copyable in one click by pointer and unreachable by keyboard. They
+ // are horizontal scroll regions too, which keyboard users must be
+ // able to reach regardless.
function renderToolOutput(rawText) {
// Try parse JSON first — coordinator tool output is JSON-shaped.
let parsed = null;
@@ -901,7 +907,7 @@ function createCoordinatorPane(root, wsId, opts) {
}
if (rows.length === 0) {
return (
- "" +
+ '' +
esc(redactCredentials(JSON.stringify(parsed, null, 2))) +
""
);
@@ -934,7 +940,7 @@ function createCoordinatorPane(root, wsId, opts) {
" " + (link || esc("?")) + (meta.length ? " " + meta.join(" ") : "")
);
});
- return "" + lines.join("\n") + "";
+ return '' + lines.join("\n") + "";
}
// ------------------------------------------------------------------
@@ -1001,6 +1007,14 @@ function createCoordinatorPane(root, wsId, opts) {
body.className = "msg-body";
setSafeHtml(body, html);
el.appendChild(body);
+ // Every assistant bubble gets a persistent copy button at creation
+ // (O(1) per message — appendMsg is the single chokepoint for assistant
+ // bubbles). Keyed on the literal role, not the variant fallback, so
+ // unknown roles don't grow one. The transient retry button joins this
+ // bar on the last bubble via _addRetryAction / _refreshRetryButton.
+ if (role === "assistant" && typeof buildMsgCopyButton === "function") {
+ ensureMsgActionsBar(el).appendChild(buildMsgCopyButton(el));
+ }
messagesEl.appendChild(el);
_scheduleScroll();
return el;
@@ -5947,32 +5961,63 @@ function createCoordinatorPane(root, wsId, opts) {
}
function _addRetryAction(el) {
- let bar = el.querySelector(".msg-actions");
- if (!bar) {
- bar = document.createElement("div");
- bar.className = "msg-actions";
- bar.setAttribute("role", "toolbar");
- bar.setAttribute("aria-label", "Message actions");
- el.appendChild(bar);
+ // Retry must render with ZERO module dependencies — it predates the
+ // shared module lane and an operator on a degraded page (module fetch
+ // failure) still needs regenerate. The shared builders are preferred
+ // when bridged; the fallback twins ensureMsgActionsBar /
+ // buildMsgRetryButton exactly (same classes — the .msg-retry-btn
+ // selector is load-bearing for the teardown sweep — same
+ // role/labels), so the two paths render identically.
+ let bar = null;
+ let btn = null;
+ if (
+ typeof ensureMsgActionsBar === "function" &&
+ typeof buildMsgRetryButton === "function"
+ ) {
+ bar = ensureMsgActionsBar(el);
+ btn = buildMsgRetryButton(_retryLast);
+ } else {
+ for (let i = 0; i < el.children.length; i++) {
+ if (el.children[i].classList.contains("msg-actions")) {
+ bar = el.children[i];
+ break;
+ }
+ }
+ if (!bar) {
+ bar = document.createElement("div");
+ bar.className = "msg-actions";
+ bar.setAttribute("role", "toolbar");
+ bar.setAttribute("aria-label", "Message actions");
+ el.appendChild(bar);
+ }
+ btn = document.createElement("button");
+ btn.className = "msg-action-btn msg-retry-btn";
+ btn.title = "Retry (regenerate response)";
+ btn.setAttribute("aria-label", "Retry last response");
+ const icon = document.createElement("span");
+ icon.className = "icon-retry";
+ icon.setAttribute("aria-hidden", "true");
+ btn.appendChild(icon);
+ btn.addEventListener("click", (e) => {
+ e.stopPropagation();
+ _retryLast();
+ });
}
- const btn = document.createElement("button");
- btn.className = "msg-action-btn";
- btn.title = "Retry (regenerate response)";
- btn.setAttribute("aria-label", "Retry last response");
- const icon = document.createElement("span");
- icon.className = "icon-retry";
- icon.setAttribute("aria-hidden", "true");
- btn.appendChild(icon);
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- _retryLast();
- });
bar.insertBefore(btn, bar.firstChild);
}
function _refreshRetryButton() {
- const old = messagesEl.querySelectorAll(".msg.assistant .msg-actions");
- for (let i = 0; i < old.length; i++) old[i].parentNode.removeChild(old[i]);
+ // Remove only the transient retry buttons — a bar that still holds
+ // other controls (the copy button appendMsg attaches) persists. On
+ // the degraded no-bridge path the fallback bar held ONLY retry, and
+ // an empty toolbar is a screen-reader landmark with zero controls,
+ // so an emptied bar goes with its last button.
+ const old = messagesEl.querySelectorAll(".msg.assistant .msg-retry-btn");
+ for (let i = 0; i < old.length; i++) {
+ const bar = old[i].parentNode;
+ bar.removeChild(old[i]);
+ if (!bar.children.length) bar.remove();
+ }
// Skip retry when the most recent semantic turn is tool-only (last DOM
// child is a .conv-batch construct); walk back past operator-context
// rows first — the plain system bubble AND the structured watch-result /
diff --git a/turnstone/shared_static/chat.css b/turnstone/shared_static/chat.css
index de9b44ae..e83792a8 100644
--- a/turnstone/shared_static/chat.css
+++ b/turnstone/shared_static/chat.css
@@ -857,18 +857,9 @@
}
}
-/* ==========================================================================
- Reduced motion — disable chat.css transitions.
- ========================================================================== */
-@media (prefers-reduced-motion: reduce) {
- .msg-actions,
- .msg-action-btn,
- .composer-attach,
- .composer-input,
- .composer-send {
- transition: none;
- }
-}
+/* Reduced-motion overrides live in ONE block at the end of this file — a
+ transition: none here would lose to the equal-specificity component rules
+ declared below it. */
/* ==========================================================================
Message primitive — chat-style single-column message block. Semantic
@@ -1622,6 +1613,11 @@
outline-offset: 1px;
}
+/* Slot order is DOM order — retry prepends, TTS inserts before the
+ persistent copy button, copy anchors the corner on every bubble — so
+ visual order and tab order always agree (no flex `order`, which would
+ split the two). */
+
/* Touch-only: keep actions visible; hover-reveal isn't reachable.
`(pointer: coarse)` narrows the match to true touch devices —
`(hover: none)` alone fires on stylus and some laptops too. Matches
@@ -1644,7 +1640,7 @@
}
/* ==========================================================================
- Per-message rewind / edit / retry affordance (#549)
+ Per-message rewind / edit / retry (#549) + copy affordances
Icon glyphs rendered inside the .msg-action-btn toolbar above, plus the
inline edit-in-place form. Shared by the interactive pane (ui/static) and
the coordinator dashboard (console/static/coordinator); both load this file
@@ -1728,6 +1724,148 @@
left: 6px;
}
+/* Icon: copy (two offset sheets). The back sheet draws only its top and
+ right edges so the front sheet needs no occluding background — the
+ button renders over arbitrary surfaces (bubble, code, floating). */
+.icon-copy {
+ width: 12px;
+ height: 12px;
+ position: relative;
+}
+.icon-copy::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ right: 0;
+ width: 8px;
+ height: 8px;
+ border-top: 1.5px solid currentColor;
+ border-right: 1.5px solid currentColor;
+ border-radius: 0 2px 0 0;
+ box-sizing: border-box;
+}
+.icon-copy::after {
+ content: "";
+ position: absolute;
+ bottom: 0;
+ left: 0;
+ width: 9px;
+ height: 9px;
+ border: 1.5px solid currentColor;
+ border-radius: 2px;
+ box-sizing: border-box;
+}
+
+/* Copy outcome flash — the icon swaps to a check / cross alongside the
+ title text set by copy_actions.js (shape + prose, never colour alone).
+ The outcome classes are written only onto the two copy buttons, and the
+ .icon-copy descendant scopes the rules — one selector serves both. */
+.is-copied .icon-copy::before {
+ display: none;
+}
+.is-copied .icon-copy::after {
+ border: none;
+ border-left: 2px solid var(--ok);
+ border-bottom: 2px solid var(--ok);
+ border-radius: 0;
+ width: 9px;
+ height: 5px;
+ left: 1px;
+ bottom: 3px;
+ transform: rotate(-45deg);
+}
+.is-copy-failed .icon-copy::before,
+.is-copy-failed .icon-copy::after {
+ display: block;
+ border: none;
+ border-top: 2px solid var(--err);
+ border-radius: 1px;
+ width: 11px;
+ height: 0;
+ top: 5px;
+ left: 0;
+ right: auto;
+ bottom: auto;
+}
+.is-copy-failed .icon-copy::before {
+ transform: rotate(45deg);
+}
+.is-copy-failed .icon-copy::after {
+ transform: rotate(-45deg);
+}
+
+/* Floating block-copy button — positioned (fixed) by copy_actions.js over
+ the hovered code fence / mermaid diagram / table. Deliberately never
+ inside rendered block DOM: streaming innerHTML replacement and the
+ source-keyed hljs cache would eat an embedded button. Sits above pane
+ chrome (z 10) and below overlays (z 999). */
+.block-copy-btn {
+ /* Hidden via visibility (not display): the button stays out of the tab
+ order and hit-testing while hidden, and the show toggle repaints
+ reliably in headless captures (display:none -> flex on a late-inserted
+ fixed element skipped the compositor frame in the livepass shots). */
+ visibility: hidden;
+ opacity: 0;
+ display: inline-flex;
+ position: fixed;
+ z-index: 100;
+ align-items: center;
+ justify-content: center;
+ width: 28px;
+ height: 24px;
+ padding: 0;
+ color: var(--ink-3);
+ /* The button floats over arbitrary content whose backgrounds sit within
+ a token step of every elevated fill (in light theme --bg-elevated IS
+ the bubble colour), so the BORDER is the component boundary: mixed
+ from --ink-3 it holds ~3:1 against the panel tones in both themes
+ (WCAG 1.4.11), with the shadow as depth, not as the only separator. */
+ background: var(--bg-elevated);
+ border: 1px solid color-mix(in srgb, var(--ink-3) 45%, transparent);
+ border-radius: var(--r-sm);
+ cursor: pointer;
+ box-shadow: 0 2px 6px rgba(0, 0, 0, 0.25);
+ transition:
+ color 120ms ease,
+ border-color 120ms ease;
+}
+.block-copy-btn.is-visible {
+ visibility: visible;
+ opacity: 1;
+}
+.block-copy-btn:hover {
+ color: var(--ink);
+ border-color: var(--accent);
+}
+/* The focusable blocks (fences and diagram containers carry tabindex=0
+ for scrollable-region access and the Enter-to-copy keyboard path;
+ tables already did) get the house focus ring instead of the UA
+ default. */
+.msg-body pre:focus-visible,
+.table-wrap:focus-visible,
+.mermaid-container:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 1px;
+}
+
+/* Keyboard block copy (Enter on the focused block) flashes its outcome
+ on the block itself: solid ok ring for copied, dashed err ring for
+ failed — shape + colour, with the prose carried by the live-region
+ announcement. The classes are transient (copy_actions.js reverts
+ them after the flash). */
+.msg-body pre.is-copied,
+.table-wrap.is-copied,
+.mermaid-container.is-copied {
+ outline: 2px solid var(--ok);
+ outline-offset: 1px;
+}
+.msg-body pre.is-copy-failed,
+.table-wrap.is-copy-failed,
+.mermaid-container.is-copy-failed {
+ outline: 2px dashed var(--err);
+ outline-offset: 1px;
+}
+
/* Edit-in-place form */
.msg-edit-form {
display: flex;
@@ -1797,7 +1935,11 @@
display: none;
}
-/* Busy-state disables action buttons */
+/* Busy-state disables action buttons — copy included: every copy
+ affordance is idle-only (the pane-level data-busy stamp is the one
+ gate). Visual + pointer here; the click paths in copy_actions.js
+ refuse independently, since pointer-events cannot stop a keyboard
+ activation. */
[data-busy="true"] .msg-action-btn {
opacity: 0.3;
pointer-events: none;
@@ -1874,8 +2016,20 @@
.ws-status-bar.ws-sb-disconnected .ws-sb-turns {
opacity: 0.4;
}
+/* ==========================================================================
+ Reduced motion — the ONE consolidated block, kept at the END of the file:
+ these are equal-specificity overrides, so any component rule declared
+ after them would silently win (that is exactly how the previous copy of
+ this block near the top of the file was dead on arrival).
+ ========================================================================== */
@media (prefers-reduced-motion: reduce) {
- .ws-status-bar {
+ .ws-status-bar,
+ .msg-actions,
+ .msg-action-btn,
+ .block-copy-btn,
+ .composer-attach,
+ .composer-input,
+ .composer-send {
transition: none;
}
}
diff --git a/turnstone/shared_static/copy_actions.js b/turnstone/shared_static/copy_actions.js
new file mode 100644
index 00000000..0a4e14dc
--- /dev/null
+++ b/turnstone/shared_static/copy_actions.js
@@ -0,0 +1,517 @@
+/* copy_actions.js — copy-to-clipboard affordances for rendered chat content.
+
+ Three affordances share this module, and every one is IDLE-ONLY: while
+ a turn is in flight (the chat clients stamp data-busy="true" on their
+ messages container) nothing here copies. A busy transcript is mutating
+ under the affordance — streaming replaces bubble bodies per rAF tick,
+ and a mid-stream whole-message copy would land a silent prefix under a
+ success flash — so the gate is one pane-level fact, enforced in JS at
+ every click/activation path (the chat.css grey-out alone would be
+ keyboard-bypassable) and reflected visually by chat.css.
+
+ * Bubble copy: buildMsgCopyButton() builds the per-message copy button
+ the chat clients mount in their ``.msg-actions`` bars.
+
+ * Block copy, pointer: one floating button (``.block-copy-btn``) that
+ appears over the hovered markdown block — code fence, mermaid
+ diagram, or table — and copies that block's SOURCE. Pointer-only:
+ pointer entering the block reveals it; pointer leaving it, or any
+ scroll in its context, hides it, unconditionally. It sits out of
+ the tab order (tabindex -1) and never carries keyboard state.
+ Nothing is ever injected into rendered block DOM: streamingRender
+ replaces bubble innerHTML wholesale on every rAF tick, and
+ postRenderHljs caches code innerHTML by source, so an embedded
+ button would be wiped per tick or captured into the cache. Touch
+ has no hover: block copy is desktop-first, and touch keeps the
+ always-visible bubble copy.
+
+ * Block copy, keyboard: Enter on a FOCUSED block (pre, .table-wrap and
+ .mermaid-container all carry tabindex=0) copies that block directly
+ — the floating button is never involved. The outcome flashes on the
+ block itself (is-copied / is-copy-failed, chat.css) and the live
+ region announces it.
+
+ Copy resolves to SOURCE, not rendered text — pasting a lifted snippet
+ into an editor or another chat must keep the fences and pipes:
+
+ * whole message — the raw markdown the streaming pipeline stashes on
+ ``.msg-body`` (``_copySource``, renderer.js _streamingRenderApply);
+ * code fence — ``code.textContent`` (escapeHtml round-trips, and hljs
+ wraps tokens in spans without changing text content);
+ * mermaid — the container's ``data-mermaid-source`` (the autoquoted
+ source the diagram was actually rendered from);
+ * table — the ``data-md-source`` the table pass stashes at render time
+ (pipes and alignment markers are unrecoverable from rendered cells).
+
+ ES module; renderer.js imports it for side effects so the affordance
+ lands on every surface that renders markdown. Window bridge at the
+ bottom for the still-classic consumers (coordinator.js). */
+
+import { copyTextToClipboard, makeAnnouncer } from "./utils.js";
+
+const FLASH_MS = 1400;
+const FAIL_TEXT = "Copy failed — select the text and copy manually";
+const BUSY_TEXT = "Copy is available when the reply finishes";
+
+// Shared polite live region announcing copy outcomes to screen readers —
+// the visual outcome flash is not announced on its own. (Eager region
+// creation and the clear-then-set idiom live in makeAnnouncer.)
+const _announce = makeAnnouncer();
+
+// The pane-level busy gate. Both chat clients maintain data-busy on
+// their messages container for the whole turn (interactive Pane.setBusy,
+// coordinator setBusy), so any node inside a busy transcript resolves the
+// same one fact.
+function _isBusy(el) {
+ return !!el.closest('[data-busy="true"]');
+}
+
+// Return a flashed element — a copy button, or the block a keyboard copy
+// targeted — to its idle presentation: outcome classes off, idle title
+// back, any pending revert timer cancelled, and any in-flight copy
+// orphaned (its settle must not paint an outcome the element no longer
+// owns). Shared by the flash revert and the floating button's re-target
+// reset so the two cannot drift.
+function _clearFlash(el) {
+ el._copyGen = (el._copyGen || 0) + 1;
+ if (el._copyFlashTimer) clearTimeout(el._copyFlashTimer);
+ el._copyFlashTimer = 0;
+ el.classList.remove("is-copied", "is-copy-failed");
+ // Buttons restore their idle tooltip; flashed BLOCKS (the keyboard
+ // copy path) have none — restore to empty rather than stamping a
+ // literal "undefined".
+ el.title = el._copyIdleTitle || "";
+}
+
+// Flash an outcome: the state class swaps a button's icon to a check /
+// cross (or tints a keyboard-copied block), the title carries the
+// plain-language explanation, the live region announces the same
+// string, and everything reverts after FLASH_MS. ✓/✗ + prose only — no
+// partial states, and deliberately no toast or other notification
+// chrome: the outcome surfaces identically on every page, at the
+// element the user acted on.
+function _flashOutcome(el, ok, text) {
+ // Every flash is a NEW outcome: bump the generation so any still-in-
+ // flight copy on this element is orphaned — a slow success settling
+ // after a later failure must not flip the ✗ (and its recovery title)
+ // back to a ✓ the second interaction never earned.
+ el._copyGen = (el._copyGen || 0) + 1;
+ el.classList.remove("is-copied", "is-copy-failed");
+ el.classList.add(ok ? "is-copied" : "is-copy-failed");
+ el.title = text;
+ _announce(text);
+ if (el._copyFlashTimer) clearTimeout(el._copyFlashTimer);
+ el._copyFlashTimer = setTimeout(function () {
+ _clearFlash(el);
+ }, FLASH_MS);
+}
+function _flashCopyResult(el, ok) {
+ _flashOutcome(el, ok, ok ? "Copied" : FAIL_TEXT);
+}
+// A busy refusal is an OUTCOME, not a silent no-op: every activation
+// path answers, or a keyboard user cannot distinguish "refused because
+// the reply is streaming" from "keystroke lost". FAIL_TEXT's manual-
+// copy recovery would be misleading here — the content is still being
+// written — so the refusal carries its own explanation.
+function _flashBusyRefusal(el) {
+ _flashOutcome(el, false, BUSY_TEXT);
+}
+
+function _copyAndFlash(el, text) {
+ // The write is attempted for EVERY resolved source, empty included: a
+ // legitimately empty block copies the empty string, and the flash
+ // reports the transport's verdict — a manufactured failure (with its
+ // "select the text" recovery hint) on a block that has nothing to
+ // select would be a false error.
+ //
+ // A new copy claims the element's outcome slot outright: _clearFlash
+ // cancels a still-armed revert timer from the PREVIOUS flash — left
+ // running, it would fire mid-write, bump the generation, and orphan
+ // this copy (clipboard written, zero feedback) — and returns the
+ // element to idle while the write is in flight.
+ _clearFlash(el);
+ // Generation-stamped: the clipboard write settles asynchronously, and a
+ // re-target in that gap must orphan this copy's outcome — otherwise the
+ // ✓ (and its announcement) lands on a block the user never copied.
+ const gen = (el._copyGen = (el._copyGen || 0) + 1);
+ copyTextToClipboard(text).then(function (ok) {
+ if (el._copyGen === gen) _flashCopyResult(el, ok);
+ });
+}
+
+// The ASSISTANT-bubble action-bar adders (copy/retry/TTS in both chat
+// clients) share this pair so their ARIA contract cannot drift between
+// bubbles. (The user-bubble adders predate it and still build their bars
+// in place.) The bar is always a DIRECT child of .msg, so the finder
+// scans children instead of the whole rendered subtree (a bubble holding
+// a large table is thousands of nodes).
+export function findMsgActionsBar(el) {
+ for (const c of el.children) {
+ if (c.classList.contains("msg-actions")) return c;
+ }
+ return null;
+}
+
+export function ensureMsgActionsBar(el) {
+ let bar = findMsgActionsBar(el);
+ if (!bar) {
+ bar = document.createElement("div");
+ bar.className = "msg-actions";
+ bar.setAttribute("role", "toolbar");
+ bar.setAttribute("aria-label", "Message actions");
+ el.appendChild(bar);
+ }
+ return bar;
+}
+
+// Resolve the copyable source for a rendered markdown block.
+export function blockCopySource(el) {
+ if (!el) return "";
+ if (el.classList.contains("mermaid-container")) {
+ return el.getAttribute("data-mermaid-source") || "";
+ }
+ if (el.classList.contains("table-wrap")) {
+ return el.getAttribute("data-md-source") || "";
+ }
+ const code = el.querySelector("code");
+ return (code || el).textContent || "";
+}
+
+// Resolve the copyable source for a whole message bubble. _copySource is
+// the render pipeline's unconditional stash — set for every applied frame,
+// including ones whose markdown render threw and painted as plain text.
+// The contract is WHOLE-SOURCE: the clipboard carries exactly what the
+// model wrote, including syntax the renderer does not display (comment
+// constructs, over-wide table cells, reference definitions) — what is
+// SHOWN is the render's decision, what is COPIED is the source. Falls
+// back to the rendered text only for a body that never went through the
+// render pipeline at all: the visible text, an honest degrade.
+export function msgCopySource(msgEl) {
+ const body = msgEl && msgEl.querySelector(".msg-body");
+ if (!body) return "";
+ return body._copySource != null ? body._copySource : body.textContent || "";
+}
+
+// Glyph span for an action button. aria-hidden: the button's accessible
+// name lives in its aria-label — the icon is decoration.
+function _icon(cls) {
+ const icon = document.createElement("span");
+ icon.className = cls;
+ icon.setAttribute("aria-hidden", "true");
+ return icon;
+}
+
+// Build the transient retry button both chat clients prepend to the last
+// assistant bubble's bar. Lives here so the button's chrome — the
+// load-bearing ``msg-retry-btn`` class (both teardown sweeps select on
+// it), title and accessible name — cannot drift between clients.
+export function buildMsgRetryButton(onRetry) {
+ const btn = document.createElement("button");
+ btn.className = "msg-action-btn msg-retry-btn";
+ btn.title = "Retry (regenerate response)";
+ btn.setAttribute("aria-label", "Retry last response");
+ btn.appendChild(_icon("icon-retry"));
+ btn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ onRetry();
+ });
+ return btn;
+}
+
+// Build the per-message copy button for a ``.msg-actions`` bar. The
+// source resolves at CLICK time, so a button attached when the bubble is
+// created copies the final streamed content, not a creation-time snapshot.
+export function buildMsgCopyButton(msgEl) {
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "msg-action-btn msg-copy-btn";
+ btn.title = "Copy message";
+ btn._copyIdleTitle = "Copy message";
+ btn.setAttribute("aria-label", "Copy message");
+ btn.appendChild(_icon("icon-copy"));
+ btn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ // The busy refusal runs HERE, not just in chat.css: pointer-events
+ // cannot stop Enter on a button that already holds focus — and it
+ // answers (✗ + announce) rather than silently dropping a keystroke
+ // on a button assistive tech presents as enabled.
+ if (_isBusy(msgEl)) {
+ _flashBusyRefusal(btn);
+ return;
+ }
+ _copyAndFlash(btn, msgCopySource(msgEl));
+ });
+ return btn;
+}
+
+// ---------------------------------------------------------------------------
+// Block copy — pointer path (floating button) + keyboard path (Enter)
+// ---------------------------------------------------------------------------
+const BLOCK_SELECTOR = "pre, .mermaid-container, .table-wrap";
+
+// The button's box for the placement clamps below. The CSS is the source
+// (chat.css .block-copy-btn width/height); these are its pinned mirror —
+// a test asserts the two stay equal, so a CSS resize cannot silently
+// desync the clamps. FAB_GAP is placement-only and has no CSS twin.
+const FAB_W = 28;
+const FAB_H = 24;
+const FAB_GAP = 4;
+
+let _fab = null;
+// The ONE module-level DOM ref. Everything else placement needs — the
+// owning bubble, its scroller, the action bar — is derived from the
+// target at use time, so hide has exactly one ref to drop and a wiped
+// transcript leaves nothing else to strand.
+let _fabTarget = null;
+
+// A block nested inside another block is a rendering detail, not a copy
+// target: the mermaid error state draws its message + source as a
+// INSIDE the .mermaid-container, and copying that pre would lift the
+// error prose instead of the diagram source.
+function _isNestedBlock(el) {
+ return !!(el.parentElement && el.parentElement.closest(BLOCK_SELECTOR));
+}
+
+// Outermost copyable block for an event target: a hit on a nested block
+// lifts to its containing block, so the pointer and keyboard paths agree
+// on one copy target per rendered block.
+function _liftToOuterBlock(el) {
+ let block = el;
+ while (block && _isNestedBlock(block)) {
+ block = block.parentElement.closest(BLOCK_SELECTOR);
+ }
+ return block;
+}
+
+function _ensureFab() {
+ if (_fab) return _fab;
+ _fab = document.createElement("button");
+ _fab.type = "button";
+ _fab.className = "block-copy-btn";
+ _fab.title = "Copy block";
+ _fab._copyIdleTitle = "Copy block";
+ _fab.setAttribute("aria-label", "Copy block");
+ // Pointer-only: out of the tab order entirely. Keyboard users copy
+ // with Enter on the focused block (the delegated keydown below), so a
+ // tab stop here would only add a hover-dependent phantom to the page's
+ // focus order.
+ _fab.setAttribute("tabindex", "-1");
+ _fab.appendChild(_icon("icon-copy"));
+ _fab.addEventListener("click", function (e) {
+ e.stopPropagation();
+ // Reveal is idle-gated, so the DOM under a visible button is stable;
+ // what remains is a transcript wipe under the pointer (disconnected
+ // target → the generic ✗) or a turn starting between reveal and
+ // click (busy → its own refusal message). Both answer — never a
+ // silent no-op on a button that looks live, and never a hide before
+ // the outcome lands.
+ const target = _fabTarget;
+ if (!target || !target.isConnected) {
+ _flashCopyResult(_fab, false);
+ return;
+ }
+ if (_isBusy(target)) {
+ _flashBusyRefusal(_fab);
+ return;
+ }
+ _copyAndFlash(_fab, blockCopySource(target));
+ });
+ document.body.appendChild(_fab);
+ return _fab;
+}
+
+// Hide is stateless and unconditional: the pointer path carries no focus
+// or keyboard state to preserve, so hiding is the class off plus dropping
+// the target ref — a module-level ref must not pin a wiped transcript's
+// detached bubble. Runs for every dismissal (pointer exit, scroll,
+// window leave) and for the placement aborts in _showFabFor, so a reveal
+// aborted while the button was still hidden cannot strand a stale ref
+// that would dead-end the show fast-path for that block.
+function _hideFab() {
+ if (!_fab) return;
+ _fab.classList.remove("is-visible");
+ _fabTarget = null;
+}
+
+// Nearest scrolling ancestor — the band the button must stay inside so it
+// hugs the visible part of a tall block instead of pinning to the viewport
+// (where it detaches from its block and lands over pane chrome).
+// Memoized per bubble (WeakMap, so a wiped bubble is still collectable):
+// the walk reads computed styles per ancestor, and the hide/re-show churn
+// of an ordinary prose↔block pointer sweep would otherwise re-pay it on
+// every crossing.
+const _scrollerMemo = new WeakMap();
+function _scrollerOf(el) {
+ let sc = el.parentElement;
+ while (sc && sc !== document.body) {
+ const o = getComputedStyle(sc).overflowY;
+ if (o === "auto" || o === "scroll" || o === "overlay") return sc;
+ sc = sc.parentElement;
+ }
+ return null;
+}
+function _scrollerFor(host) {
+ let sc = _scrollerMemo.get(host);
+ if (sc === undefined) {
+ sc = _scrollerOf(host);
+ _scrollerMemo.set(host, sc);
+ }
+ return sc;
+}
+
+function _showFabFor(target) {
+ const fab = _ensureFab();
+ if (target !== _fabTarget) {
+ // A new target never inherits the previous one's outcome flash — a ✓
+ // carried across blocks would assert a copy the user never made there.
+ _clearFlash(fab);
+ _fabTarget = target;
+ }
+ const host = target.closest(".msg") || document.body;
+ const scroller = _scrollerFor(host);
+ const rect = target.getBoundingClientRect();
+ const band = scroller
+ ? scroller.getBoundingClientRect()
+ : { top: 0, bottom: window.innerHeight, left: 0, right: window.innerWidth };
+ // The button lives in the visible intersection of block and scroller; a
+ // sliver under button height means there is nothing sensible to anchor
+ // to, so hide instead of hovering over unrelated chrome.
+ const visTop = Math.max(rect.top, band.top);
+ const visBottom = Math.min(rect.bottom, band.bottom);
+ if (visBottom - visTop < FAB_H + 2 * FAB_GAP) {
+ _hideFab();
+ return;
+ }
+ let top = Math.min(visTop + FAB_GAP, visBottom - FAB_H - FAB_GAP);
+ let left = Math.max(rect.right - FAB_W - FAB_GAP, rect.left + FAB_GAP);
+ left = Math.min(
+ left,
+ window.innerWidth - FAB_W - 2 * FAB_GAP,
+ band.right - FAB_W - 2 * FAB_GAP,
+ );
+ left = Math.max(left, band.left + FAB_GAP);
+ // A block that opens the bubble puts this button on top of the bubble's
+ // own hover-revealed copy button (same glyph, different scope) — drop
+ // below the action bar when the two would collide. Looked up live (a
+ // few direct children), never cached: the retry/TTS holder grows the
+ // bar after the fact. When the band clamp leaves nowhere below the
+ // bar, hide instead: two stacked identical copy glyphs make a click
+ // that targets one silently hit the other.
+ const bar = host === document.body ? null : findMsgActionsBar(host);
+ if (bar) {
+ const br = bar.getBoundingClientRect();
+ if (
+ br.width > 0 &&
+ top < br.bottom + FAB_GAP &&
+ top + FAB_H > br.top &&
+ left < br.right + FAB_GAP &&
+ left + FAB_W > br.left
+ ) {
+ top = Math.min(br.bottom + FAB_GAP, visBottom - FAB_H - FAB_GAP);
+ if (top < br.bottom + FAB_GAP && top + FAB_H > br.top) {
+ _hideFab();
+ return;
+ }
+ }
+ }
+ fab.style.top = top + "px";
+ fab.style.left = left + "px";
+ fab.classList.add("is-visible");
+}
+
+// Delegated hover: reveal over the hovered block, hide otherwise. Scoped
+// to blocks inside rendered chat bodies (``.msg-body``) — other surfaces
+// (composer previews, admin panes) keep their own affordances — and gated
+// on the pane being idle: the button never REVEALS while a turn is in
+// flight, and a pointer move over a busy transcript hides any reveal a
+// just-started turn overtook.
+document.addEventListener("mouseover", function (e) {
+ const t = e.target;
+ if (!(t instanceof Element)) return;
+ if (_fab && _fab.contains(t)) return; // hovering the button itself
+ // Fast path: the pointer moving WITHIN the targeted block is the
+ // dominant case (hljs wraps every token in a span, so a sweep across a
+ // fence is hundreds of events) — a containment check plus the busy
+ // gate, no selector walks. The busy check rides the fast path too: a
+ // turn starting under a shown button dismisses it on the NEXT pointer
+ // move, not only once the pointer leaves the block.
+ if (_fabTarget && _fabTarget.contains(t)) {
+ if (_isBusy(_fabTarget)) _hideFab();
+ return;
+ }
+ // The clipboard fallback's off-screen textarea (utils.js) must not
+ // count as "pointer left the block" — copying would dismiss its own
+ // button mid-copy. It is childless, so it is always the event target
+ // itself: a plain attribute check, no ancestor walk.
+ if (t.hasAttribute("data-clipboard-shim")) return;
+ const block = _liftToOuterBlock(t.closest(BLOCK_SELECTOR));
+ if (block && block.closest(".msg-body") && !_isBusy(block)) {
+ if (block !== _fabTarget) _showFabFor(block);
+ } else {
+ _hideFab();
+ }
+});
+
+// A scroll that MOVES the block out from under the fixed-position button
+// — the document itself, or any scrollable ancestor holding the block —
+// hides it rather than chasing (the pointer re-reveals in place). A
+// scroll INSIDE the block (a fence's internal horizontal pan, a nested
+// scrollable) pans content within it and keeps the button, and a
+// scroller that does not contain the block (a sidebar) moves nothing the
+// button is anchored to. Containment is the whole rule — no scroller
+// identity to memoize or get wrong.
+document.addEventListener(
+ "scroll",
+ function (e) {
+ if (!_fab || !_fab.classList.contains("is-visible")) return;
+ const movesBlock =
+ !_fabTarget ||
+ e.target === document ||
+ (e.target instanceof Element &&
+ e.target !== _fabTarget &&
+ e.target.contains(_fabTarget));
+ if (movesBlock) _hideFab();
+ },
+ true,
+);
+
+// Pointer leaving the window would otherwise strand the button painted
+// over the page with nothing left to dismiss it.
+document.addEventListener("mouseleave", function () {
+ _hideFab();
+});
+
+// Keyboard path: Enter on a focused block copies it directly — the
+// floating button is never involved. Only the block ITSELF as the event
+// target counts (a focusable descendant, e.g. a link in a table cell,
+// keeps its own Enter semantics), a hit on a nested block lifts to its
+// container, and the busy gate applies like every other copy path. The
+// outcome flashes on the block (is-copied / is-copy-failed, chat.css)
+// and the live region announces it.
+document.addEventListener("keydown", function (e) {
+ if (e.key !== "Enter") return;
+ // Chords belong to the browser / OS (Ctrl+Enter, Alt+Enter…) and key
+ // repeat is never a deliberate copy — a copy overwrites the user's
+ // clipboard, so only a plain, single Enter qualifies.
+ if (e.repeat || e.ctrlKey || e.altKey || e.metaKey || e.shiftKey) return;
+ const t = e.target;
+ if (!(t instanceof Element)) return;
+ if (t.closest(BLOCK_SELECTOR) !== t) return;
+ const block = _liftToOuterBlock(t);
+ if (!block || !block.closest(".msg-body")) return;
+ if (_isBusy(block)) {
+ _flashBusyRefusal(block);
+ return;
+ }
+ _copyAndFlash(block, blockCopySource(block));
+});
+
+// --- Legacy window bridge ---------------------------------------------------
+// coordinator.js (still classic) reaches these as globals at message-append
+// time, well after this deferred module evaluated. Only its consumers are
+// bridged; module code imports instead.
+Object.assign(window, {
+ buildMsgCopyButton,
+ buildMsgRetryButton,
+ ensureMsgActionsBar,
+});
diff --git a/turnstone/shared_static/interactive.js b/turnstone/shared_static/interactive.js
index 5eb5adf2..5d5e2136 100644
--- a/turnstone/shared_static/interactive.js
+++ b/turnstone/shared_static/interactive.js
@@ -55,7 +55,13 @@ import {
} from "./composer_queue.js";
import { StatusBar } from "./status_bar.js";
import { streamingRender, streamingRenderFinalize } from "./renderer.js";
-import { setMarkdown, operatorSourceLabel } from "./utils.js";
+import {
+ buildMsgCopyButton,
+ buildMsgRetryButton,
+ ensureMsgActionsBar,
+ findMsgActionsBar,
+} from "./copy_actions.js";
+import { makeAnnouncer, operatorSourceLabel } from "./utils.js";
import {
OVERFLOW_TRIP_COUNT,
OVERFLOW_TRIP_WINDOW_MS,
@@ -107,45 +113,22 @@ function getVoiceRoles(base) {
// Visually-hidden polite live region for voice status (recording / playback)
// so screen-reader users perceive state changes otherwise conveyed only by
-// color/icon. Errors go through showToast (already a live region). Single
-// shared node; clear-then-set so repeated identical messages re-announce.
-let _voiceStatusEl = null;
-function voiceAnnounce(msg) {
- if (!_voiceStatusEl) {
- _voiceStatusEl = document.createElement("div");
- _voiceStatusEl.className = "sr-only";
- _voiceStatusEl.setAttribute("role", "status");
- _voiceStatusEl.setAttribute("aria-live", "polite");
- document.body.appendChild(_voiceStatusEl);
- }
- _voiceStatusEl.textContent = "";
- window.setTimeout(() => {
- if (_voiceStatusEl) _voiceStatusEl.textContent = msg;
- }, 30);
-}
+// color/icon. Errors go through showToast (already a live region).
+const voiceAnnounce = makeAnnouncer();
// Visually-hidden POLITE live region for the tool-call early paint
// (tool_pending) so screen-reader users hear a committed call land — and that
// they can Stop it — even though messagesEl is flipped to aria-live="off"
// during the token streaming that immediately precedes the call. Polite (not
// assertive): a committed call is worth surfacing but isn't the action-required
-// human gate, which keeps its own assertive announcement. Separate node from
-// the voice region so the two never clobber each other. Single shared node;
-// clear-then-set so repeated identical messages re-announce.
-let _toolStatusEl = null;
+// human gate, which keeps its own assertive announcement. Separate region
+// from the voice one so the two never clobber each other.
+const _toolStatus = makeAnnouncer();
+// The wrapper earns its keep with the empty-message guard —
+// _toolAnnounceText returns "" for a batch with no named tools.
function toolAnnounce(msg) {
if (!msg) return;
- if (!_toolStatusEl) {
- _toolStatusEl = document.createElement("div");
- _toolStatusEl.className = "sr-only";
- _toolStatusEl.setAttribute("role", "status");
- _toolStatusEl.setAttribute("aria-live", "polite");
- document.body.appendChild(_toolStatusEl);
- }
- _toolStatusEl.textContent = "";
- window.setTimeout(() => {
- if (_toolStatusEl) _toolStatusEl.textContent = msg;
- }, 30);
+ _toolStatus(msg);
}
// Terse SR summary for a committed tool batch: tool name(s) (capped at 3) +
@@ -2082,12 +2065,7 @@ class Pane {
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
- this.currentAssistantEl = document.createElement("div");
- this.currentAssistantEl.className = "msg assistant";
- this.currentAssistantBodyEl = document.createElement("div");
- this.currentAssistantBodyEl.className = "msg-body";
- this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
- this.messagesEl.appendChild(this.currentAssistantEl);
+ this._newAssistantBubble();
}
this.contentBuffer += evt.text;
streamingRender(this.currentAssistantBodyEl, this.contentBuffer);
@@ -2171,12 +2149,7 @@ class Pane {
this.currentReasoningEl = null;
}
if (!this.currentAssistantEl) {
- this.currentAssistantEl = document.createElement("div");
- this.currentAssistantEl.className = "msg assistant";
- this.currentAssistantBodyEl = document.createElement("div");
- this.currentAssistantBodyEl.className = "msg-body";
- this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
- this.messagesEl.appendChild(this.currentAssistantEl);
+ this._newAssistantBubble();
}
if (this.contentBuffer.length < evt.content.length) {
this.contentBuffer = evt.content;
@@ -2733,28 +2706,38 @@ class Pane {
el.appendChild(bar);
}
+ // The one creation path for live-streamed assistant bubbles (the
+ // content and in_progress_snapshot branches): bubble + body + the
+ // persistent copy action, with the streaming refs assigned as a unit
+ // so the two branches cannot drift.
+ _newAssistantBubble() {
+ this.currentAssistantEl = document.createElement("div");
+ this.currentAssistantEl.className = "msg assistant";
+ this.currentAssistantBodyEl = document.createElement("div");
+ this.currentAssistantBodyEl.className = "msg-body";
+ this.currentAssistantEl.appendChild(this.currentAssistantBodyEl);
+ this._addCopyAction(this.currentAssistantEl);
+ this.messagesEl.appendChild(this.currentAssistantEl);
+ }
+
+ // Every assistant bubble gets a persistent copy button at creation —
+ // O(1) per message, unlike the removed whole-transcript sweep this
+ // file's retry attach deliberately avoids (see
+ // _attachRetryToLastAssistant). The bar is shared with the transient
+ // retry / TTS buttons the holder mechanism adds and removes. Callers
+ // pass a JUST-CREATED bubble (both stream branches via
+ // _newAssistantBubble, and replay) — no dedup needed against an empty
+ // bar.
+ _addCopyAction(el) {
+ ensureMsgActionsBar(el).appendChild(buildMsgCopyButton(el));
+ }
+
_addRetryAction(el) {
- let bar = el.querySelector(".msg-actions");
- if (!bar) {
- bar = document.createElement("div");
- bar.className = "msg-actions";
- bar.setAttribute("role", "toolbar");
- bar.setAttribute("aria-label", "Message actions");
- el.appendChild(bar);
- }
- const btn = document.createElement("button");
- btn.className = "msg-action-btn";
- btn.title = "Retry (regenerate response)";
- btn.setAttribute("aria-label", "Retry last response");
- const icon = document.createElement("span");
- icon.className = "icon-retry";
- icon.setAttribute("aria-hidden", "true");
- btn.appendChild(icon);
- btn.addEventListener("click", (e) => {
- e.stopPropagation();
- this._retryLast();
- });
- bar.insertBefore(btn, bar.firstChild);
+ const bar = ensureMsgActionsBar(el);
+ bar.insertBefore(
+ buildMsgRetryButton(() => this._retryLast()),
+ bar.firstChild,
+ );
}
// -------------------------------------------------------------------------
@@ -3029,15 +3012,11 @@ class Pane {
}
_addTtsAction(el) {
- let bar = el.querySelector(".msg-actions");
- if (!bar) {
- bar = document.createElement("div");
- bar.className = "msg-actions";
- bar.setAttribute("role", "toolbar");
- bar.setAttribute("aria-label", "Message actions");
- el.appendChild(bar);
- }
+ const bar = ensureMsgActionsBar(el);
if (bar.querySelector(".msg-tts-btn")) return; // already added
+ // Inserted BEFORE the persistent copy button (below) so DOM order,
+ // tab order and visual order agree: [retry, tts, copy], with copy
+ // anchoring the corner on every bubble.
const btn = document.createElement("button");
btn.className = "msg-action-btn msg-tts-btn";
btn.title = "Play response aloud";
@@ -3051,7 +3030,7 @@ class Pane {
e.stopPropagation();
this._playMessageTTS(el, btn);
});
- bar.appendChild(btn);
+ bar.insertBefore(btn, bar.querySelector(".msg-copy-btn"));
}
// Strip code blocks / inline code / rendered math so TTS doesn't read source
@@ -3456,7 +3435,14 @@ class Pane {
const bodyEl = document.createElement("div");
bodyEl.className = "msg-body";
el.appendChild(bodyEl);
- setMarkdown(bodyEl, msg.content);
+ // Same render mechanism as the coordinator's history path: the
+ // finalize helper renders + post-renders AND stashes the raw
+ // markdown on the body for the copy affordance — replayed
+ // bubbles must copy identically to live-streamed ones. The
+ // copy action attaches BEFORE the render, matching the
+ // streaming path's attach-then-fill order.
+ this._addCopyAction(el);
+ streamingRenderFinalize(bodyEl, msg.content);
this.messagesEl.appendChild(el);
lastToolBlock = null;
}
@@ -3661,13 +3647,20 @@ class Pane {
}
_attachRetryToLastAssistant() {
- // Remove the previous holder's action bar via the tracked ref — the old
- // whole-transcript ".msg.assistant .msg-actions" sweep was O(N) per
- // busy→idle edge. At most one assistant bar exists (this method is its
- // only writer); a holder detached by a rebuild no-ops harmlessly.
+ // Remove the previous holder's retry / TTS buttons via the tracked ref —
+ // the old whole-transcript ".msg.assistant .msg-actions" sweep was O(N)
+ // per busy→idle edge. The bar itself stays: every assistant bubble owns
+ // one for its persistent copy button (_addCopyAction, at creation), and
+ // only the transient retry / TTS buttons move with the holder. At most
+ // one bubble carries them (this method is their only writer); a holder
+ // detached by a rebuild no-ops harmlessly.
if (this._retryHolderEl) {
- const oldBar = this._retryHolderEl.querySelector(".msg-actions");
- if (oldBar) oldBar.remove();
+ const oldBar = findMsgActionsBar(this._retryHolderEl);
+ if (oldBar) {
+ oldBar
+ .querySelectorAll(".msg-retry-btn, .msg-tts-btn")
+ .forEach((b) => b.remove());
+ }
this._retryHolderEl = null;
}
// Find the last assistant message with content and add retry.
diff --git a/turnstone/shared_static/mcp_error.js b/turnstone/shared_static/mcp_error.js
index b33c0817..929ae58b 100644
--- a/turnstone/shared_static/mcp_error.js
+++ b/turnstone/shared_static/mcp_error.js
@@ -176,6 +176,11 @@ export function buildMcpErrorEmbed(err, rawJson, onConsent) {
details.appendChild(summary);
const pre = document.createElement("pre");
pre.className = "tool-output";
+ // Focusable like every copyable block: where this card mounts inside a
+ // chat .msg-body the pre hosts the pointer copy affordance, and the
+ // keyboard copy path (copy_actions.js, Enter) acts on the FOCUSED
+ // block — and it is a horizontal scroll region regardless.
+ pre.setAttribute("tabindex", "0");
pre.textContent = tryPrettyJson(rawJson) || redactCredentials(rawJson);
details.appendChild(pre);
wrapper.appendChild(details);
diff --git a/turnstone/shared_static/renderer.js b/turnstone/shared_static/renderer.js
index 8680c815..5200c021 100644
--- a/turnstone/shared_static/renderer.js
+++ b/turnstone/shared_static/renderer.js
@@ -4,6 +4,10 @@
// globals). Window bridge at the bottom for the still-classic consumers.
import { escapeHtml } from "./utils.js";
+// Side-effect import: the copy-to-clipboard affordances (floating block
+// button + delegated listeners) ride the renderer so they land on every
+// surface that renders markdown — no per-page wiring to drift.
+import "./copy_actions.js";
// ---------------------------------------------------------------------------
// Inline formatting
@@ -377,8 +381,11 @@ function _renderMarkdownBody(text) {
/^([ \t]*)((?:[-*+]|\d+[.)])[ \t]+)?(```+)([^\s`]*)\n((?:(?!\3)[\s\S])*?)\3[ \t]*(?=\n|$)/gm,
function (m, indent, marker, _open, lang, code) {
var cssLang = _langToCssClass(lang);
+ // tabindex=0: a fence is a horizontal scroll region (keyboard users
+ // must be able to reach and scroll it), and a focused block is what
+ // the keyboard copy path acts on (copy_actions.js, Enter).
codeBlocks.push(
- "" +
// Strip the trailing newline plus any whitespace an indented close
@@ -525,9 +532,22 @@ function _renderMarkdownBody(text) {
// The reverse edge case (math containing backticks, e.g.
// ``$$ \verb|`x`| $$``) is much rarer and KaTeX would reject
// the verbatim syntax anyway.
+ // Raw-source twins for the inline-code / math sentinels, mirroring
+ // Raw twins: the table pass slices its data-md-source region from
+ // POST-masking text, so a cell's sentinels must be restored to the raw
+ // source the user wrote before the slice lands in an attribute — the
+ // rendered substitutes carry quotes/markup and are restored AFTER the
+ // table splice, i.e. inside the attribute value (the B-class breakout).
+ // Every mask pushes to its raw twin unconditionally, so the twins are
+ // index-aligned with the rendered arrays by construction — the cost is
+ // one string reference per span.
+ var inlineCodesRaw = [];
+ var mathBlocksRaw = [];
+ var inlineMathsRaw = [];
var inlineCodes = [];
text = text.replace(/`([^`\n]+)`/g, function (m, code) {
inlineCodes.push("" + escapeHtml(code) + "");
+ inlineCodesRaw.push(m);
return "\x00IC" + (inlineCodes.length - 1) + "\x00";
});
@@ -540,10 +560,12 @@ function _renderMarkdownBody(text) {
var mathBlocks = [];
text = text.replace(/\$\$([\s\S]+?)\$\$/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true));
+ mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
});
text = text.replace(/\\\[([\s\S]+?)\\\]/g, function (m, tex) {
mathBlocks.push(renderLatex(tex.trim(), true));
+ mathBlocksRaw.push(m);
return "\x00MB" + (mathBlocks.length - 1) + "\x00";
});
@@ -564,9 +586,33 @@ function _renderMarkdownBody(text) {
var inlineMaths = [];
text = text.replace(/\\\(([^\n]+?)\\\)/g, function (m, tex) {
inlineMaths.push(renderLatex(tex.trim(), false));
+ inlineMathsRaw.push(m);
return "\x00IM" + (inlineMaths.length - 1) + "\x00";
});
+ // One ordered spec drives every raw-span restore: REVERSE mask order
+ // (IM, MB, then IC), because a later mask's span can swallow an
+ // earlier mask's sentinel into its raw twin (math wrapping inline
+ // code) — each pass re-exposes the sentinels the passes after it
+ // resolve. A new span mask gets a row here alongside its raw twin.
+ // Two restore sites: a table's data-md-source stash, and
+ // footnote-definition bodies before their recursive render.
+ var rawSpanPasses = [
+ [/\x00IM(\d+)\x00/g, inlineMathsRaw],
+ [/\x00MB(\d+)\x00/g, mathBlocksRaw],
+ [/\x00IC(\d+)\x00/g, inlineCodesRaw],
+ ];
+ function restoreRawSpans(slice) {
+ if (slice.indexOf("\x00") === -1) return slice;
+ for (var rs = 0; rs < rawSpanPasses.length; rs++) {
+ slice = slice.replace(
+ rawSpanPasses[rs][0],
+ _restorer(rawSpanPasses[rs][1]),
+ );
+ }
+ return slice;
+ }
+
// Protect markdown tables (extract before line-by-line processing)
var tableBlocks = [];
(function () {
@@ -616,8 +662,39 @@ function _renderMarkdownBody(text) {
dataRows.push(row);
j++;
}
+ // The original pipe/alignment lines are unrecoverable from the
+ // rendered cells (trimmed, inline-rendered), so the copy affordance
+ // (copy_actions.js) needs them stashed here — the block's WHOLE
+ // raw source, by the same contract as message copy: the clipboard
+ // carries what was written, including source the render does not
+ // display (a row wider than the header renders truncated but
+ // copies whole). What is SHOWN is the render's decision; what is
+ // COPIED is the source.
+ //
+ // * The slice comes from POST-masking text: inline-code/math in
+ // cells are NUL sentinels whose global restores run AFTER the
+ // table restore — i.e. INSIDE this attribute — so they are
+ // resolved to their RAW sources first via restoreRawSpans
+ // (never into tlines: the rendered cells above keep theirs
+ // for the global passes). Every frame resolves its OWN span
+ // sentinels — footnote bodies are restored to raw before
+ // their recursive render — so the trailing strip is a pure
+ // backstop for a future recursion site that forgets that
+ // restore: an attribute must never carry a NUL sentinel,
+ // because the outer restores would splice rendered HTML into
+ // it after the fact.
+ //
+ // * The attribute escape is escapeHtml — the pipeline's one
+ // escaping helper — bound to safeMdSource for the
+ // double-quoted attribute value below.
+ var rawMdSource = restoreRawSpans(
+ tlines.slice(i, j).join("\n"),
+ ).replace(/\x00[A-Z]{2}\d+\x00/g, "");
+ var safeMdSource = escapeHtml(rawMdSource);
var html =
- '