diff --git a/scripts/livepass.py b/scripts/livepass.py index 96977c59..e3ce135f 100755 --- a/scripts/livepass.py +++ b/scripts/livepass.py @@ -95,6 +95,30 @@ Task-agent harness (/taskagent/livepass.html): the task_agent card — a task success, TASKAGENT-FAILED-... / TASKAGENT-ERROR when routing breaks, so a broken card can't screenshot green. +Copy harness (/copy/livepass.html): the copy-to-clipboard affordances — the + per-bubble copy button in .msg-actions and the floating block-copy button + over hovered fences / mermaid diagrams / tables (pointer-only; keyboard + copies with Enter on the focused block) — driven through the REAL + InteractivePane (replayHistory plus a live handleEvent stream turn, so the + retry-holder buttons coexist with the persistent copy buttons on the last + bubble; the turn ends idle, matching the affordances' idle-only gate). + navigator.clipboard is stubbed to a recorder, hover/focus/keys are + dispatched synthetically, and every copied payload is compared byte-exact + against the SOURCE (fences, pipes, mermaid text, the bubble's raw + markdown). + &theme=light. document.title stamps + COPY-READY-- only when every probe copied exact source; + COPY-FAILED- otherwise. &kbd=1 probes the KEYBOARD path: focus a + block, dispatch Enter — the block's source lands on the clipboard, the + block carries the outcome flash class, and the floating button stays out + of it — stamps COPY-KBD-READY / COPY-KBD-FAILED-. + Screenshot states: &flash=1 (visual-only + run — no probes; floating button + ✓ state on the fence, holder bar + revealed via focus) and &bare=1 (single hover, no decoration). Known + capture artifact: the DARK-theme &flash=1 shot can omit the floating + button's pixels (headless software compositor; the DOM state is correct + and light theme paints) — judge the dark floating button from &bare=1 + and the ✓ state from the light shot. &stepmax=N bisects a paint + regression to the interaction that triggers it. Perf harness (/perf/livepass.html): long-session performance baseline for the interactive pane — mounts the REAL InteractivePane at real scroll geometry (fixed-height mount, production CSS chain) and drives production-shaped @@ -957,7 +981,24 @@ ATTACH_TEMPLATE = """ # is exercised, not just the leaf builders. The page frame is harness-only # chrome; the .conv-batch / task_agent card is what's under review. # -------------------------------------------------------------------------- -TASKAGENT_TEMPLATE = """ +# The host seams a mounted InteractivePane provides, stubbed once for every +# harness that drives the REAL pane (taskagent, copy). A new required seam +# gets added HERE — a harness left with a stale stub set does not fail at +# review time, it throws HARNESS ERROR at run time. +PANE_STUB_JS = """\ + // Drive the REAL pane; stub only the host seams a mounted pane provides. + const pane = new InteractivePane("demo-ws"); + pane.messagesEl = messages; + pane.inputEl = document.createElement("textarea"); + pane.sendBtn = document.createElement("button"); + pane.isNearBottom = () => false; + pane.scrollToBottom = () => {}; + pane.removeEmptyState = () => {}; + pane.removeThinkingIndicator = () => {}; + pane.setBusy = () => {};""" + +TASKAGENT_TEMPLATE = ( + """ @@ -1007,16 +1048,9 @@ TASKAGENT_TEMPLATE = """ const messages = document.getElementById("messages"); try { - // Drive the REAL pane; stub only the host seams a mounted pane provides. - const pane = new InteractivePane("demo-ws"); - pane.messagesEl = messages; - pane.inputEl = document.createElement("textarea"); - pane.sendBtn = document.createElement("button"); - pane.isNearBottom = () => false; - pane.scrollToBottom = () => {}; - pane.removeEmptyState = () => {}; - pane.removeThinkingIndicator = () => {}; - pane.setBusy = () => {}; +""" + + PANE_STUB_JS + + """ const ev = (e) => pane.handleEvent(e); // ?recall=1: exercise the RECALL path — replayHistory rebuilding the @@ -1165,6 +1199,266 @@ TASKAGENT_TEMPLATE = """ """ +) + + +# -------------------------------------------------------------------------- +# Copy harness — the copy-to-clipboard affordances over the REAL pane. The +# bubbles come from the REAL replayHistory / handleEvent paths so the copy +# sources are the ones production stashes (_copySource, the mermaid / table +# data attributes), and the probes drive the REAL buttons and key path and +# compare what landed on the (stubbed) clipboard byte-exact against the +# source. +# -------------------------------------------------------------------------- +COPY_TEMPLATE = ( + """ + + + + + copy livepass + + + + + + + + + +
+
conversation — copy affordances (real InteractivePane)
+
+
+ + + + +""" +) # -------------------------------------------------------------------------- @@ -1612,6 +1906,12 @@ def build(out: Path) -> None: (ta / "livepass.html").write_text(TASKAGENT_TEMPLATE, encoding="utf-8") print(f"{ta}/livepass.html — task_agent card (real Pane.handleEvent routing)") + cp = out / "copy" + cp.mkdir(parents=True, exist_ok=True) + symlink(cp / "shared", ROOT / "turnstone/shared_static") + (cp / "livepass.html").write_text(COPY_TEMPLATE, encoding="utf-8") + print(f"{cp}/livepass.html — copy affordances (bubble bars + block button)") + pf = out / "perf" pf.mkdir(parents=True, exist_ok=True) symlink(pf / "shared", ROOT / "turnstone/shared_static") diff --git a/tests/_js_harness_helpers.py b/tests/_js_harness_helpers.py new file mode 100644 index 00000000..3fe8d191 --- /dev/null +++ b/tests/_js_harness_helpers.py @@ -0,0 +1,45 @@ +"""Shared helpers for the Python-driven node harnesses that evaluate the +``shared_static`` ES modules with script semantics.""" + +from __future__ import annotations + +import re +import shutil +from typing import TYPE_CHECKING + +import pytest + +if TYPE_CHECKING: + from pathlib import Path + + +def has_node() -> bool: + return shutil.which("node") is not None + + +# Module-level ``pytestmark = node_skip`` in each harness suite — the node +# detection lives here once, so a future change (version floor, env +# override) cannot land in one suite and silently miss another. +node_skip = 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. + + Single-sourced here for every JS harness: a new module syntax form + (``export default``, re-exports) must be handled once, not per suite — + a divergence between per-file copies surfaces as a confusing + ``vm.runInThisContext`` SyntaxError in whichever suite lagged. + """ + 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 diff --git a/tests/test_app_js.py b/tests/test_app_js.py index 159a0d40..2abd7dfc 100644 --- a/tests/test_app_js.py +++ b/tests/test_app_js.py @@ -626,8 +626,8 @@ def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None: 1. **Strict DOM-construction** (``ui/static/app.js``, ``shared_static/utils.js``, ``shared_static/auth.js``, ``shared_static/kb.js``, ``coordinator.js`` chat entry, - ``console/static/app.js``): renderer output routes through - ``setMarkdown`` (or ``setSafeHtml`` for pre-baked HTML strings); + ``console/static/app.js``): renderer output routes through the + streaming helpers or ``setSafeHtml`` (for pre-baked HTML strings); every other site uses ``createElement`` + ``textContent`` + ``append`` / ``replaceChildren``. Missing escapes are structurally impossible — no HTML string is ever interpolated. @@ -666,8 +666,8 @@ def test_no_unsafe_code_sinks_in_static_assets(label: str, path: Path) -> None: f"{label}:\n" + "\n".join(f" line {n}: {line}" for n, line in offenders[:10]) + "\nUse DOM construction (createElement + textContent + " - "append/replaceChildren) or route renderer output through " - "setMarkdown() / setSafeHtml() in shared/utils.js." + "append/replaceChildren) or route trusted HTML through " + "setSafeHtml() in shared/utils.js." ) @@ -784,18 +784,20 @@ def test_model_response_controls_are_capability_driven_and_sparse() -> None: assert 'apiSurfEl.addEventListener("change", _onModelFieldChange)' in admin -def test_shared_utils_defines_set_markdown_helper() -> None: - """The ``setMarkdown`` helper in ``shared/utils.js`` is the single - audited entry point for rendering markdown content into a DOM - element from ``app.js``. It parses ``renderMarkdown``'s output via - ``DOMParser`` (avoiding the unsafe sink entirely) and runs - ``postRenderMarkdown`` on the result. A refactor that drops or - renames it would break the two interactive call sites silently at +def test_shared_utils_defines_set_safe_html_helper() -> None: + """``setSafeHtml`` in ``shared/utils.js`` is the single audited entry + point for installing trusted HTML strings into a DOM element outside + renderer.js. It parses via ``DOMParser`` (avoiding the unsafe sink + entirely); its markdown callers use it directly (coordinator + ``appendMsg``, preview.js), while renderer.js's streaming helpers + write their own sanctioned in-file ``innerHTML`` — which is exactly + why renderer.js is excluded from the sink scan. A refactor that + drops or renames the helper would break its call sites silently at runtime.""" body = _UTILS_JS.read_text(encoding="utf-8") - assert "function setMarkdown(el, content)" in body, ( - "shared/utils.js must define setMarkdown(el, content) — " - "app.js routes both renderer-output sites through this helper." + assert "function setSafeHtml(el, html)" in body, ( + "shared/utils.js must define setSafeHtml(el, html) — the " + "sanctioned trusted-HTML installation chokepoint." ) # The DOMParser path is what avoids the unsafe sink. The absence # of the unsafe assignment inside the helper is pinned by the @@ -804,7 +806,7 @@ def test_shared_utils_defines_set_markdown_helper() -> None: # to e.g. ``Range.createContextualFragment`` forces an explicit # reviewer decision. assert "DOMParser()" in body, ( - "setMarkdown must parse via DOMParser, not the unsafe DOM-write " + "setSafeHtml must parse via DOMParser, not the unsafe DOM-write " "sink — that is what keeps the audit surface at one location." ) @@ -3422,3 +3424,241 @@ def test_every_system_turn_source_has_a_fallback_label() -> None: labelled = {ln.split(":", 1)[0].strip() for ln in block.splitlines() if ":" in ln} missing = set(SYSTEM_TURN_SOURCES) - labelled - {"compaction"} assert not missing, f"system turn sources with no operator label: {sorted(missing)}" + + +def test_copy_button_survives_retry_teardown_in_both_clients() -> None: + """Every assistant bubble carries a persistent copy button in its + ``.msg-actions`` bar; the retry-holder teardown in BOTH clients must + remove only the transient buttons (``.msg-retry-btn`` / + ``.msg-tts-btn``), never the bar — removing the bar (the pre-copy + behavior) silently strips copy from the previous holder bubble on + every busy→idle edge, and only manual testing would notice. + + Interactive: the tracked-holder teardown targets the button classes. + Coordinator: ``_refreshRetryButton``'s sweep collects + ``.msg-retry-btn`` nodes, not whole ``.msg-actions`` bars.""" + interactive = _INTERACTIVE_JS.read_text(encoding="utf-8") + start = _pane_method_offset(interactive, "_attachRetryToLastAssistant") + end = _pane_method_offset(interactive, "announceToolBlock") + teardown = interactive[start:end] + assert '".msg-retry-btn, .msg-tts-btn"' in teardown, ( + "interactive teardown must remove the transient retry/TTS buttons by class" + ) + assert "oldBar.remove()" not in teardown, ( + "interactive teardown removes the whole .msg-actions bar — that " + "strips the persistent copy button from the previous holder bubble" + ) + coord = _COORD_JS.read_text(encoding="utf-8") + refresh_start = coord.index("function _refreshRetryButton()") + refresh_end = coord.index("async function init()", refresh_start) + refresh = coord[refresh_start:refresh_end] + assert '".msg.assistant .msg-retry-btn"' in refresh, ( + "coordinator _refreshRetryButton must sweep retry BUTTONS" + ) + assert '".msg.assistant .msg-actions"' not in refresh, ( + "coordinator _refreshRetryButton sweeps whole .msg-actions bars — " + "that strips every bubble's persistent copy button" + ) + + +def test_every_assistant_bubble_creation_path_attaches_copy() -> None: + """The copy button attaches at bubble CREATION on every assistant + render path — both live-stream branches and the history replay in the + interactive pane, and the single ``appendMsg`` chokepoint in the + coordinator (guarded on the literal role, so the unknown-role + fallback variant doesn't grow one). A path that skips the attach + ships bubbles that silently cannot be copied.""" + interactive = _INTERACTIVE_JS.read_text(encoding="utf-8") + # Structural call statements only (line-anchored), so a comment or + # string mentioning the method cannot satisfy or break the count. + # The two live-stream branches share one creation helper; the replay + # branch attaches on its own bubble. + stream_calls = len(re.findall(r"^\s+this\._newAssistantBubble\(", interactive, re.M)) + assert stream_calls == 2, ( + "expected the content + in_progress_snapshot branches to create " + f"bubbles via this._newAssistantBubble(); found {stream_calls} call sites" + ) + calls = len(re.findall(r"^\s+this\._addCopyAction\(", interactive, re.M)) + assert calls == 2, ( + "expected the shared creation helper + the replay branch to call " + f"this._addCopyAction(…); found {calls} call sites" + ) + replay_start = _pane_method_offset(interactive, "replayHistory") + replay_end = _pane_method_offset(interactive, "_attachRetryToLastAssistant") + replay = interactive[replay_start:replay_end] + assert "streamingRenderFinalize(bodyEl, msg.content)" in replay, ( + "replayed assistant bubbles must render through " + "streamingRenderFinalize — the finalize helper is what stashes " + "the raw markdown the copy affordance reads" + ) + coord = _COORD_JS.read_text(encoding="utf-8") + append_start = coord.index("function appendMsg(role, html, opts)") + append_end = coord.index("function appendText(", append_start) + append = coord[append_start:append_end] + assert 'role === "assistant"' in append and "buildMsgCopyButton(el)" in append, ( + "coordinator appendMsg must attach the copy bar for assistant bubbles at creation" + ) + + +def test_block_copy_btn_css_box_matches_js_constants() -> None: + """The floating copy button's placement clamps (copy_actions.js FAB_W / + FAB_H) mirror the CSS box (.block-copy-btn width/height in shared + chat.css). The CSS is the source of truth; this pin is what makes a + CSS resize fail loudly instead of silently desyncing every clamp — + placement itself has no unit assertions (the livepass harness owns + rendered placement).""" + ca = (_REPO_ROOT / "turnstone/shared_static/copy_actions.js").read_text(encoding="utf-8") + css = (_REPO_ROOT / "turnstone/shared_static/chat.css").read_text(encoding="utf-8") + w = re.search(r"const FAB_W = (\d+);", ca) + h = re.search(r"const FAB_H = (\d+);", ca) + assert w and h, "FAB_W / FAB_H constants not found in copy_actions.js" + # The box must be declared in exactly ONE .block-copy-btn rule: a + # second declaration (a media query, a state variant) would override + # the box while this pin kept watching the base rule — silent desync. + rules = re.findall(r"\.block-copy-btn[^{}]*\{[^}]*\}", css) + assert rules, ".block-copy-btn rules not found in chat.css" + declaring = [r for r in rules if re.search(r"\b(width|height)\s*:", r)] + assert len(declaring) == 1, ( + ".block-copy-btn width/height must live in exactly one rule so the " + "JS mirror has one source of truth; found: " + repr(declaring) + ) + block = declaring[0] + assert f"width: {w.group(1)}px" in block, ( + ".block-copy-btn width in chat.css no longer matches FAB_W — " + "update the JS mirror (and its clamps) with the CSS box" + ) + assert f"height: {h.group(1)}px" in block, ( + ".block-copy-btn height in chat.css no longer matches FAB_H — " + "update the JS mirror (and its clamps) with the CSS box" + ) + + +def test_retry_and_token_copy_degrade_without_the_module_lane() -> None: + """Two classic-script affordances predate the shared ES-module lane and + must keep working when it fails to load (script fetch error on a + degraded LAN): the coordinator's retry button and the admin one-time + token dialog's copy. Both prefer the bridged helpers and must carry a + zero-dependency fallback — a silent early-return (retry never renders) + or an unguarded bridge call (uncaught TypeError in the token modal) + regresses main's contract.""" + coord = _COORD_JS.read_text(encoding="utf-8") + retry_start = coord.index("function _addRetryAction(el)") + retry_end = coord.index("function _refreshRetryButton()", retry_start) + retry = coord[retry_start:retry_end] + assert 'typeof ensureMsgActionsBar === "function"' in retry, ( + "coordinator retry must feature-detect the bridged helpers" + ) + assert 'document.createElement("button")' in retry and "msg-retry-btn" in retry, ( + "coordinator retry lost its zero-dependency fallback — with the " + "module lane down the retry button must still be built in place" + ) + admin = _CONSOLE_ADMIN_JS.read_text(encoding="utf-8") + copy_start = admin.index("function copyCreatedToken()") + copy_fn = admin[copy_start : admin.index("\nfunction ", copy_start + 1)] + assert 'typeof window.copyTextToClipboard !== "function"' in copy_fn, ( + "admin token copy must feature-detect the bridged helper — an " + "unguarded bridge call throws with the module lane down" + ) + # The no-bridge branch is not selection-only: it attempts the native + # async API directly (the degraded page is usually an HTTPS console + # where it works) before degrading to select-the-text. + no_bridge = copy_fn[ + copy_fn.index('typeof window.copyTextToClipboard !== "function"') : copy_fn.index( + "window.copyTextToClipboard(_lastCreatedToken)" + ) + ] + assert "navigator.clipboard" in no_bridge and ".writeText(_lastCreatedToken)" in no_bridge, ( + "the no-bridge branch must attempt navigator.clipboard.writeText " + "directly before falling back to manual selection" + ) + assert copy_fn.count("selectTokenFallback") >= 3, ( + "admin token copy lost its select-the-text fallback (needed for " + "the clipboard-less, the copy-rejected and the bridge-failed paths)" + ) + + +def test_copy_affordances_are_idle_only() -> None: + """Every copy affordance is unavailable while the pane is busy, gated on + the ONE pane-level fact both clients already maintain (data-busy on + the messages container) — there is no per-bubble streaming stamp. + Three pins keep the contract honest: + + * no ``.is-streaming`` mechanism anywhere in either client or the + shared stylesheet (its per-bubble gate was replaced wholesale); + * the chat.css busy rule covers ALL action buttons — no copy + exemption; + * the module enforces the gate in JS at each activation path (bubble + click, floating-button click, Enter keydown, hover reveal, and the + within-block fast path's dismissal) — CSS pointer-events alone is + keyboard-bypassable.""" + interactive = _INTERACTIVE_JS.read_text(encoding="utf-8") + coord = _COORD_JS.read_text(encoding="utf-8") + css = (_REPO_ROOT / "turnstone/shared_static/chat.css").read_text(encoding="utf-8") + for label, body in ( + ("interactive.js", interactive), + ("coordinator.js", coord), + ("chat.css", css), + ): + assert "is-streaming" not in body, ( + f"{label} resurrects the per-bubble is-streaming gate — the busy " + "gate is pane-level data-busy only" + ) + assert '[data-busy="true"] .msg-action-btn {' in css, ( + "chat.css must disable every .msg-action-btn while busy" + ) + assert ":not(.msg-copy-btn)" not in css, ( + "the busy rule must not exempt the copy button — copy is idle-only" + ) + ca = (_REPO_ROOT / "turnstone/shared_static/copy_actions.js").read_text(encoding="utf-8") + assert "'[data-busy=\"true\"]'" in ca, ( + "copy_actions.js lost its JS-side busy gate (_isBusy) — the CSS " + "pointer-events rule alone is keyboard-bypassable" + ) + for path_marker in ( + # bubble copy click + "if (_isBusy(msgEl)) {", + # floating-button click guard + "if (_isBusy(target)) {", + # Enter-on-block keydown + "if (_isBusy(block)) {", + # hover reveal + "&& !_isBusy(block)", + # within-block fast path — a turn starting under a shown button + # must dismiss on the next move, not once the pointer leaves + "if (_isBusy(_fabTarget)) _hideFab();", + ): + assert path_marker in ca, ( + f"copy_actions.js busy gate missing at an activation path: {path_marker!r}" + ) + + +def test_coordinator_tool_output_pres_are_focusable() -> None: + """Any pre inside a .msg-body hosts the pointer copy affordance, and + the keyboard copy path acts on the FOCUSED block — so every
 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:
     ``

``, which a real browser splits into a stray empty ``

`` 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\n
sx
\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("  
xy
") 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 =
-          '
'; + '
'; html += ""; for (var k = 0; k < hdrCells.length; k++) { var align = aligns[k] || "left"; @@ -793,17 +870,19 @@ function _renderMarkdownBody(text) { // Append footnote section if any definitions were collected. // // Each definition body is rendered by a recursive renderMarkdown call. The - // body was collected AFTER the inline-code/math passes, so it may carry - // outer-scope sentinels (e.g. `code` in a footnote -> a \x00IC\x00 sentinel). - // The recursion can't resolve those against its own fresh, empty arrays, but - // the restore guard (Fix 2) leaves the sentinel intact instead of emitting - // "undefined"; because this section is appended to `result` BEFORE the - // restore passes below — whose inlineCodes/mathBlocks are still populated — - // the OUTER restore resolves it, so inline code / math in a footnote renders - // correctly. A FENCED block continuing a footnote definition works the same - // way: the fence pass re-emits its 2-space indent before the sentinel, so - // the continuation scan still collects it and the round-trip restores the - // code inside the footnote item. + // body was collected AFTER the inline-span masks, so it carries outer-scope + // span sentinels — restored to RAW source first (restoreRawSpans, the same + // recipe the
pass uses for fenced bodies) so the recursion + // re-masks the spans itself and its own table stashes resolve against its + // own raw twins (an outer sentinel reaching a recursive stash would be + // stripped as residue: silent loss in the copied source). BLOCK sentinels + // keep the original path: the recursion leaves a fence's \x00CB\x00 inert + // (the restore guard emits the matched sentinel, never "undefined"), and + // because this section is appended to `result` BEFORE the restore passes + // below — whose codeBlocks array is still populated — the OUTER restore + // resolves it, so a FENCED block continuing a footnote definition renders + // correctly (the fence pass re-emits its 2-space indent before the + // sentinel, so the continuation scan still collects it). var fnKeys = Object.keys(footnoteDefs); if (fnKeys.length > 0) { var fnHtml = @@ -818,7 +897,7 @@ function _renderMarkdownBody(text) { "-def-" + safeFid + '">' + - renderMarkdown(footnoteDefs[fid]) + + renderMarkdown(restoreRawSpans(footnoteDefs[fid])) + ' \x00BQ(\d+)\x00<\/p>/g, _restorer(bqBlocks)); result = result.replace(/\x00BQ(\d+)\x00/g, _restorer(bqBlocks)); - result = result.replace(/

\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks)); - result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks)); + // Tables restore BEFORE the inline-span passes (MB/IC/IM): a table + // CELL's rendered content still holds its inline sentinels, which only + // resolve if the table's HTML is already spliced into `result` when + // those passes run. (MB used to run first, so display math in a cell + // rendered as literal sentinel garbage.) The data-md-source attribute + // is immune to this ordering either way — it is built sentinel-free + // from the raw twins at emission time. result = result.replace(/

\x00TB(\d+)\x00<\/p>/g, _restorer(tableBlocks)); result = result.replace(/\x00TB(\d+)\x00/g, _restorer(tableBlocks)); + result = result.replace(/

\x00MB(\d+)\x00<\/p>/g, _restorer(mathBlocks)); + result = result.replace(/\x00MB(\d+)\x00/g, _restorer(mathBlocks)); result = result.replace(/\x00IC(\d+)\x00/g, _restorer(inlineCodes)); result = result.replace(/\x00IM(\d+)\x00/g, _restorer(inlineMaths)); @@ -1332,6 +1418,17 @@ function postRenderMermaid(containerEl) { } var div = document.createElement("div"); div.setAttribute("data-mermaid-source", source); + // Focusable like the other copyable blocks (pre / .table-wrap carry + // tabindex=0): a focused block is what the keyboard copy path acts on + // (copy_actions.js, Enter), and the container is a scrollable region + // (overflow-x) that keyboard users must be able to reach. Set at + // creation so the loading and error states are reachable too — the + // rendered-SVG apply replaces innerHTML, which leaves host attributes + // intact. role=region (not img: the error state carries readable + // message + source that AT must not flatten away). + div.setAttribute("tabindex", "0"); + div.setAttribute("role", "region"); + div.setAttribute("aria-label", "Diagram"); // Use cache.has (not truthiness) so a future cached value of // empty string / falsy SVG doesn't masquerade as a miss. if (_mermaidSvgCache.has(source)) { @@ -1401,6 +1498,13 @@ export function reRenderAllMermaid() { // --------------------------------------------------------------------------- function _streamingRenderApply(el, buffer) { if (el._lastRenderedBuffer === buffer) return; + // Unconditional copy-source stash (read by copy_actions.msgCopySource): + // BOTH exits below display content for this buffer — the catch paints it + // as plain text — so the stash must not ride _lastRenderedBuffer, whose + // stays-unset-on-throw semantics exist to make the next frame re-attempt + // the render (a copy reading it would get the last SUCCESSFUL frame's + // prefix while the bubble displays the full reply). + el._copySource = buffer; try { el.innerHTML = renderMarkdown(buffer); } catch (e) { diff --git a/turnstone/shared_static/utils.js b/turnstone/shared_static/utils.js index 93505f24..d59600f0 100644 --- a/turnstone/shared_static/utils.js +++ b/turnstone/shared_static/utils.js @@ -2,9 +2,9 @@ ES module at the BOTTOM of the shared module graph: imports nothing, so renderer.js / auth.js / kb.js / cards.js can import from here without - cycles. The two helpers that call upward (setMarkdown → renderer, - exportWorkstreamDownload → toast/auth) late-bind through window at CALL - time instead — importing them here would close an import cycle. + cycles. The one helper that calls upward (exportWorkstreamDownload → + toast/auth) late-binds through window at CALL time instead — importing + here would close an import cycle. The window bridge at the bottom keeps the still-classic consumers (console app.js / admin.js / governance.js, ui app.js, inline onclick=) @@ -149,6 +149,95 @@ export function makeEmptyState(text) { return div; } +// Build a screen-reader live-region announcer and return its announce +// function. The sr-only region is appended to document.body EAGERLY, at +// factory time: assistive tech only announces changes to a region that +// was ALREADY in the accessibility tree, so a region born lazily with +// its first message is silent exactly once. Announcing is clear-then- +// set on a short timer so repeated identical messages re-announce. +// Callers keep one announcer per concern (voice status, tool early +// paint, copy outcomes) so two announcements never clobber each other +// inside one region. +export function makeAnnouncer() { + const region = document.createElement("span"); + region.className = "sr-only"; + region.setAttribute("role", "status"); + region.setAttribute("aria-live", "polite"); + document.body.appendChild(region); + return function announce(text) { + region.textContent = ""; + window.setTimeout(function () { + region.textContent = text; + }, 30); + }; +} + +// Copy text to the system clipboard; resolves true on success. The +// async Clipboard API exists only in secure contexts (HTTPS or +// localhost), and cluster nodes reached over plain HTTP on a LAN have +// no `navigator.clipboard` at all — those fall back to the legacy +// hidden-textarea + execCommand("copy") path. execCommand copies the +// textarea's selection, so the user's own selection and focus are +// captured first and restored after. +export async function copyTextToClipboard(text) { + const value = String(text == null ? "" : text); + if (window.isSecureContext && navigator.clipboard) { + try { + await navigator.clipboard.writeText(value); + return true; + } catch (e) { + /* permission denied — the legacy path below still has a shot */ + } + } + const prevFocus = document.activeElement; + const sel = document.getSelection(); + const prevRanges = []; + if (sel) { + // cloneRange: getRangeAt returns LIVE ranges, and moving the + // selection into the shim textarea below can collapse them in + // place — a live ref would "restore" the collapsed range. + for (let i = 0; i < sel.rangeCount; i++) { + prevRanges.push(sel.getRangeAt(i).cloneRange()); + } + } + const ta = document.createElement("textarea"); + ta.value = value; + ta.setAttribute("readonly", ""); + ta.setAttribute("aria-hidden", "true"); + // This off-screen node briefly becomes the focused / hit-tested + // element mid-copy; delegated UI listeners (the copy affordance's + // pointer-dismissal rule) must be able to recognize and ignore the + // shim, or the copy gesture dismisses its own button mid-copy. + ta.setAttribute("data-clipboard-shim", ""); + ta.style.position = "fixed"; + ta.style.top = "0"; + ta.style.left = "0"; + ta.style.width = "1px"; + ta.style.height = "1px"; + ta.style.opacity = "0"; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { + ok = document.execCommand("copy"); + } catch (e) { + ok = false; + } + ta.remove(); + if (sel) { + sel.removeAllRanges(); + for (let i = 0; i < prevRanges.length; i++) sel.addRange(prevRanges[i]); + } + if (prevFocus && typeof prevFocus.focus === "function") { + try { + prevFocus.focus({ preventScroll: true }); + } catch (e) { + /* focus restoration is best-effort */ + } + } + return ok; +} + // Parse a *trusted* HTML string into DOM nodes and install them as // the new children of ``el``. Callers must guarantee the HTML was // produced by an escaping / sanitising pipeline (escapeHtml, @@ -161,18 +250,6 @@ export function setSafeHtml(el, html) { el.replaceChildren(...Array.from(parsed.body.childNodes)); } -// Render markdown content into an element. renderMarkdown produces -// fully-escaped HTML (see renderer.js — every input runs through -// escapeHtml before any markdown ops; URLs are gated by an allow-list -// regex), so routing the result through setSafeHtml is safe as long -// as renderer.js is trusted. postRenderMarkdown finishes the job — -// hljs highlighting + mermaid SVG rendering for any code blocks the -// markdown emitted. -export function setMarkdown(el, content) { - setSafeHtml(el, window.renderMarkdown(content)); - window.postRenderMarkdown(el); -} - // Download a workstream's conversation as OpenAI-shaped JSON. Hits // GET {base}/v1/api/workstreams/{ws_id}/export, which streams a // ``{"messages":[...]}`` body with a Content-Disposition attachment @@ -259,7 +336,7 @@ Object.assign(window, { cssEscape, makeKeyLabel, makeEmptyState, + copyTextToClipboard, setSafeHtml, - setMarkdown, exportWorkstreamDownload, });