...` is highlighted
- by highlight.js and rendered in a framed block with a **language-name header**
- and a **line-number gutter** (the gutter stays pinned while long lines scroll
+ by highlight.js and rendered with a **language name** and a **Copy** button on top
+ and a pinned **line-number gutter** (the gutter stays put while long lines scroll
horizontally). The language shown comes from the `language-xxx` class.
-- **Diffs** - write a fenced `diff` block or `...`. htmlit renders it with full-row red and green lines, aligned old and new line numbers, and a Unified/Split toggle. No custom CSS is needed.
+- **Diffs** - write a fenced `diff` block or `...`. htmlit renders it in a framed block with full-row red and green lines, aligned old and new line numbers, and a Unified/Split toggle (no Copy button). No custom CSS is needed.
- **Theme** - the panel's **Dark** toggle flips `html.dark`, `html[data-theme]`,
`color-scheme`, the highlight.js theme, and Mermaid's theme. Style your
diff --git a/skills/htmlit/client/client.css b/skills/htmlit/client/client.css
index ac9b44f..d8df0ef 100644
--- a/skills/htmlit/client/client.css
+++ b/skills/htmlit/client/client.css
@@ -168,7 +168,7 @@
.rail .card { position: fixed; width: 276px; box-sizing: border-box; pointer-events: auto; background: var(--elev); color: var(--text); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 8px; box-shadow: var(--shadow); padding: 10px 12px 10px 11px; transition: top 0.12s ease, left 0.12s ease; }
.rail .card.draft { border-left-color: var(--muted); border-left-style: dashed; }
.rail .card-draft { font-size: 11px; color: var(--muted); margin: -2px 0 7px; }
-.rail .card-q { font-size: 11.5px; color: var(--muted); background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px; padding: 3px 7px; margin: 0 0 8px; max-height: 34px; overflow: hidden; cursor: pointer; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.rail .card-q { font-size: 11.5px; line-height: 1.5; color: var(--muted); background: color-mix(in srgb, var(--accent) 10%, transparent); border-radius: 4px; padding: 3px 7px; margin: 0 0 8px; max-height: calc(3em + 6px); overflow: hidden; cursor: pointer; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
.rail .card-q::before { content: "\201C"; } .rail .card-q::after { content: "\201D"; }
.rail .card-thread { display: flex; flex-direction: column; gap: 6px; }
.rail .cmt { display: flex; align-items: flex-start; gap: 6px; }
diff --git a/skills/htmlit/client/codeblock.js b/skills/htmlit/client/codeblock.js
new file mode 100644
index 0000000..899cad5
--- /dev/null
+++ b/skills/htmlit/client/codeblock.js
@@ -0,0 +1,79 @@
+import { copyText } from "./util.js";
+
+// A lightweight highlight.js grammar for Mermaid source, registered on first use, so
+// the diagram "Code" view reads like code (diagram-type keywords, directions, quoted
+// labels, edge-label pipes, arrows/links and %% comments). highlight.js ships no
+// Mermaid language of its own, and the raw diagram block is turned into an SVG rather
+// than highlighted, so this only styles the toggled source view.
+function mermaidGrammar(hljs) {
+ var TYPE = "graph flowchart sequenceDiagram classDiagram stateDiagram stateDiagram-v2 " +
+ "erDiagram journey gantt pie gitGraph mindmap timeline quadrantChart requirementDiagram " +
+ "sankey-beta xychart-beta block-beta";
+ var KW = "subgraph end participant actor note over loop alt opt par and rect activate " +
+ "deactivate direction click class style classDef linkStyle state as call href section " +
+ "title autonumber";
+ return {
+ name: "mermaid",
+ case_insensitive: true,
+ keywords: { keyword: TYPE + " " + KW, literal: "LR RL TB TD BT true false" },
+ contains: [
+ hljs.COMMENT("%%", "$"),
+ hljs.QUOTE_STRING_MODE,
+ hljs.APOS_STRING_MODE,
+ { className: "string", begin: /\|/, end: /\|/ },
+ { className: "operator", begin: /[ox]?(?:-{2,}|-\.-+|={2,}|~{2,})[->ox]*/ },
+ { className: "number", begin: /\b\d+(?:\.\d+)?\b/ },
+ ],
+ };
+}
+
+var mermaidRegistered = false;
+// Syntax-highlight a element holding Mermaid source, in place. No-op when
+// highlight.js is unavailable (offline with no vendored copy) - the plain source
+// still shows.
+export function highlightMermaidSource(codeEl) {
+ var hljs = window.hljs;
+ if (!codeEl || !hljs || !hljs.registerLanguage) return;
+ if (!mermaidRegistered) {
+ try { hljs.registerLanguage("mermaid", mermaidGrammar); mermaidRegistered = true; } catch (e) { return; }
+ }
+ codeEl.className = "language-mermaid";
+ codeEl.removeAttribute("data-highlighted");
+ try { hljs.highlightElement(codeEl); } catch (e) {}
+}
+
+// A "Copy" button that copies getText() to the clipboard, with brief feedback.
+export function makeCopyButton(getText) {
+ var btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "htmlit-copy";
+ btn.textContent = "Copy";
+ btn.title = "Copy to clipboard";
+ btn.addEventListener("click", function (e) {
+ e.stopPropagation();
+ e.preventDefault();
+ Promise.resolve(copyText(getText())).catch(function () {});
+ btn.textContent = "Copied";
+ btn.classList.add("htmlit-copied");
+ clearTimeout(btn._copyReset);
+ btn._copyReset = setTimeout(function () { btn.textContent = "Copy"; btn.classList.remove("htmlit-copied"); }, 1400);
+ });
+ return btn;
+}
+
+// Line count of a source string (trailing blank lines trimmed), at least 1.
+export function lineCount(src) {
+ src = (src || "").replace(/\n+$/, "");
+ return src.length ? src.split("\n").length : 1;
+}
+
+// A right-aligned, non-selectable line-number gutter for a code-block body.
+export function makeLinenos(count) {
+ var gutter = document.createElement("span");
+ gutter.className = "htmlit-linenos";
+ gutter.setAttribute("aria-hidden", "true");
+ var nums = [];
+ for (var i = 1; i <= count; i++) nums.push(i);
+ gutter.textContent = nums.join("\n");
+ return gutter;
+}
diff --git a/skills/htmlit/client/diagram.js b/skills/htmlit/client/diagram.js
index 8da225f..4534b42 100644
--- a/skills/htmlit/client/diagram.js
+++ b/skills/htmlit/client/diagram.js
@@ -4,6 +4,7 @@ import { root, shadow, ui } from "./dom.js";
import { theme } from "./theme.js";
import { renderHighlights, scheduleReposition } from "./overlays.js";
import { positionRail } from "./rail.js";
+import { makeCopyButton, makeLinenos, lineCount, highlightMermaidSource } from "./codeblock.js";
import { clearPending, diagramClick } from "./annotate.js";
export const diagramLayouts = {}; // diagram source text -> saved arrangement
@@ -180,7 +181,7 @@ export function enhanceMermaid() {
// (the bar is data-htmlit, so the morph won't delete it and relocates it
// instead). Drop any bar that is no longer parented by a .mermaid frame
// before (re)building - this also cleans up any already-stranded bar.
- document.querySelectorAll("[data-htmlit-tools],[data-htmlit-codebtn],[data-htmlit-code]").forEach(function (barEl) {
+ document.querySelectorAll("[data-htmlit-tools],[data-htmlit-codebtn],[data-htmlit-code],[data-htmlit-codecopy]").forEach(function (barEl) {
var pp = barEl.parentElement;
if (!pp || !pp.classList || !pp.classList.contains("mermaid")) barEl.remove();
});
@@ -665,18 +666,59 @@ if (box && box.classList && box.classList.contains("mermaid")) {
bar.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
}
- // "Code" toggle (top-right): reveal the diagram's Mermaid source in place, so a
- // reader can inspect or copy the syntax without leaving the page. Added once and
- // cleaned up on morph alongside the zoom bar.
+ // "Code" toggle (top-right): reveal the diagram's Mermaid source in place - as a
+ // real code block (line numbers + a Copy button) - so a reader can inspect or copy
+ // the syntax without leaving the page. Added once and cleaned up on morph alongside
+ // the zoom bar.
if (box && box.classList && box.classList.contains("mermaid") && box.dataset.htmlitSrc && !box.querySelector("[data-htmlit-codebtn]")) {
var cdark = theme === "dark";
- var codeView = document.createElement("pre");
+ var mSrc = box.dataset.htmlitSrc;
+
+ // The source view mirrors a normal enhanced code block: a titled header
+ // ("MERMAID" + Copy) over a line-numbered, syntax-highlighted body, reusing the
+ // same classes so it shares the chrome and background. It overlays the diagram
+ // and is opened by the floating "Code" button; the header's "Diagram" button
+ // (and Escape) closes it again.
+ var codeView = document.createElement("div");
+ codeView.className = "htmlit-code";
codeView.setAttribute("data-htmlit", "");
codeView.setAttribute("data-htmlit-code", "");
- codeView.textContent = box.dataset.htmlitSrc;
- codeView.style.cssText = "position:absolute;inset:0;margin:0;display:none;z-index:6;overflow:auto;padding:14px 16px 44px;box-sizing:border-box;" +
- "font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;white-space:pre;tab-size:2;-webkit-user-select:text;user-select:text;" +
- "background:" + (cdark ? "#0d1117" : "#f6f8fa") + ";color:" + (cdark ? "#e6edf3" : "#1f2328") + ";";
+ codeView.style.cssText = "position:absolute;inset:0;margin:0;display:none;z-index:6;overflow:auto;box-sizing:border-box;border-radius:inherit;";
+
+ var codeHd = document.createElement("div");
+ codeHd.className = "htmlit-code-hd";
+ var codeLang = document.createElement("span");
+ codeLang.className = "htmlit-code-lang";
+ codeLang.textContent = "mermaid";
+ var codeActions = document.createElement("span");
+ codeActions.className = "htmlit-code-actions";
+ var copyBtn = makeCopyButton(function () { return mSrc; });
+ copyBtn.setAttribute("data-htmlit", "");
+ copyBtn.setAttribute("data-htmlit-codecopy", "");
+ copyBtn.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
+ var backBtn = document.createElement("button");
+ backBtn.type = "button";
+ backBtn.className = "htmlit-copy";
+ backBtn.setAttribute("data-htmlit", "");
+ backBtn.textContent = "Diagram";
+ backBtn.title = "Show the diagram";
+ backBtn.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
+ codeActions.appendChild(copyBtn);
+ codeActions.appendChild(backBtn);
+ codeHd.appendChild(codeLang);
+ codeHd.appendChild(codeActions);
+ codeView.appendChild(codeHd);
+
+ var codeBody = document.createElement("div");
+ codeBody.className = "htmlit-code-body";
+ codeBody.appendChild(makeLinenos(lineCount(mSrc)));
+ var codePre = document.createElement("pre");
+ var codeEl = document.createElement("code");
+ codeEl.textContent = mSrc;
+ codePre.appendChild(codeEl);
+ highlightMermaidSource(codeEl);
+ codeBody.appendChild(codePre);
+ codeView.appendChild(codeBody);
codeView.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
box.appendChild(codeView);
@@ -691,17 +733,17 @@ if (box && box.classList && box.classList.contains("mermaid")) {
"background:" + (cdark ? "rgba(36,38,45,.92)" : "rgba(255,255,255,.94)") + ";color:" + (cdark ? "#e8e8ea" : "#222") +
";border:1px solid " + (cdark ? "rgba(255,255,255,.14)" : "rgba(0,0,0,.12)") + ";box-shadow:0 2px 8px rgba(0,0,0,.16);";
codeBtn.addEventListener("pointerdown", function (ev) { ev.stopPropagation(); });
- codeBtn.addEventListener("click", function (ev) {
- ev.stopPropagation(); ev.preventDefault();
- var opening = codeView.style.display === "none";
+
+ function showCode(opening) {
codeView.style.display = opening ? "block" : "none";
+ codeBtn.style.display = opening ? "none" : "";
var svgEl = box.querySelector("svg");
if (svgEl) svgEl.style.display = opening ? "none" : "";
var zbar = box.querySelector("[data-htmlit-tools]");
if (zbar) zbar.style.display = opening ? "none" : "inline-flex";
- codeBtn.textContent = opening ? "Diagram" : "Code";
- codeBtn.title = opening ? "Show the diagram" : "Show the Mermaid source";
- });
+ }
+ codeBtn.addEventListener("click", function (ev) { ev.stopPropagation(); ev.preventDefault(); showCode(true); });
+ backBtn.addEventListener("click", function (ev) { ev.stopPropagation(); ev.preventDefault(); showCode(false); });
box.appendChild(codeBtn);
}
diff --git a/skills/htmlit/client/exporting.js b/skills/htmlit/client/exporting.js
index 0862609..82a61e7 100644
--- a/skills/htmlit/client/exporting.js
+++ b/skills/htmlit/client/exporting.js
@@ -55,16 +55,32 @@ var ARTIFACT_CSS =
'.mermaid{position:relative!important;width:100%;height:clamp(260px,52vh,480px)!important;min-height:0!important;margin:14px 0;box-sizing:border-box;overflow:hidden;border:1px solid rgba(0,0,0,.13);border-radius:12px;background:#fcfcfd;}' +
'.mermaid svg{max-width:100%!important;width:100%!important;height:100%!important;display:block;}' +
'html.dark .mermaid{border-color:rgba(255,255,255,.13);background:#1b1c21;}' +
- '.htmlit-code{margin:14px 0;border:1px solid rgba(128,128,140,.28);border-radius:8px;overflow:hidden;background:#fff;}' +
- 'html.dark .htmlit-code{border-color:rgba(140,140,160,.26);background:#0d1117;}' +
+ '.htmlit-code{margin:14px 0;border-radius:8px;overflow:hidden;background:#fff;}' +
+ 'html.dark .htmlit-code{background:#0d1117;}' +
+ // A diff keeps its framed box and titled header; a plain code block is borderless.
+ '.htmlit-code.htmlit-diff{border:1px solid rgba(128,128,140,.28);}' +
+ 'html.dark .htmlit-code.htmlit-diff{border-color:rgba(140,140,160,.26);}' +
'.htmlit-code-hd{font:600 11px/1.6 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;letter-spacing:.06em;text-transform:uppercase;color:#6b7280;background:rgba(128,128,140,.09);padding:6px 12px;border-bottom:1px solid rgba(128,128,140,.22);}' +
'html.dark .htmlit-code-hd{color:#8b949e;background:rgba(255,255,255,.04);border-bottom-color:rgba(140,140,160,.2);}' +
+ // Plain code header: no bar, just the language on the left and Copy on the right.
+ '.htmlit-code:not(.htmlit-diff)>.htmlit-code-hd{display:flex;align-items:center;justify-content:space-between;gap:10px;background:transparent;border-bottom:0;padding:4px 6px 2px;}' +
+ '.htmlit-copy{font:500 11px/1 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;text-transform:none;letter-spacing:0;color:#57606a;background:rgba(128,128,140,.1);border:1px solid rgba(128,128,140,.3);border-radius:6px;padding:4px 9px;cursor:pointer;}' +
+ '.htmlit-copy:hover{background:rgba(128,128,140,.2);color:#1f2328;}' +
+ 'html.dark .htmlit-copy{color:#9198a1;background:rgba(255,255,255,.06);border-color:rgba(140,140,160,.24);}' +
+ 'html.dark .htmlit-copy:hover{background:rgba(255,255,255,.11);color:#e6edf3;}' +
+ '.htmlit-copy.htmlit-copied{color:#1a7f37;border-color:rgba(26,127,55,.5);}' +
+ 'html.dark .htmlit-copy.htmlit-copied{color:#3fb950;border-color:rgba(63,185,80,.5);}' +
+ '.htmlit-code-actions{display:inline-flex;align-items:center;gap:6px;}' +
+ // The Mermaid source view overlays the (scrollable) diagram, so keep its titled
+ // header pinned and opaque - reusing the code bg - instead of scrolling away.
+ '.htmlit-code[data-htmlit-code]>.htmlit-code-hd{position:sticky;top:0;z-index:2;background:#fff;}' +
+ 'html.dark .htmlit-code[data-htmlit-code]>.htmlit-code-hd{background:#0d1117;}' +
'.htmlit-code-body{display:flex;overflow-x:auto;background:#fff;}' +
'html.dark .htmlit-code-body{background:#0d1117;}' +
'.htmlit-linenos{position:sticky;left:0;z-index:1;flex:0 0 auto;box-sizing:border-box;text-align:right;padding:12px 12px;white-space:pre;user-select:none;color:#c2c8d0;background:inherit;border-right:1px solid rgba(128,128,140,.2);}' +
'html.dark .htmlit-linenos{color:#4b5563;}' +
'.htmlit-code-body>pre{margin:0!important;padding:12px 16px!important;flex:1 1 auto;min-width:0;overflow:visible!important;background:transparent!important;border:0!important;border-radius:0!important;}' +
- '.htmlit-code-body>pre>code{background:transparent!important;padding:0!important;overflow:visible!important;white-space:pre!important;display:block;}' +
+ '.htmlit-code-body>pre>code{background:transparent!important;border:0!important;border-radius:0!important;box-shadow:none!important;padding:0!important;overflow:visible!important;white-space:pre!important;display:block;}' +
'.htmlit-code-body>pre>code,.htmlit-linenos{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace!important;font-size:13px!important;line-height:1.55!important;tab-size:2;}' +
'.htmlit-diff .htmlit-code-hd{display:flex;align-items:center;justify-content:space-between;gap:12px;-webkit-user-select:none;user-select:none;}' +
'.htmlit-diff-toggle{display:inline-flex;align-items:center;gap:2px;padding:2px;border:1px solid rgba(128,128,140,.22);border-radius:999px;background:rgba(255,255,255,.55);}' +
diff --git a/skills/htmlit/client/morph.js b/skills/htmlit/client/morph.js
index e9546e9..de96d6e 100644
--- a/skills/htmlit/client/morph.js
+++ b/skills/htmlit/client/morph.js
@@ -1,6 +1,6 @@
import { CFG, KEY, Presence, SseEvent, state as appState } from "./state.js";
import { root, ui, applyDocTitle } from "./dom.js";
-import { api, esc } from "./util.js";
+import { api, copyText, esc } from "./util.js";
import { decorateAnswers } from "./exporting.js";
import { detectNewContent, highlightNew, initContent } from "./content.js";
import { refreshFavicon } from "./favicon.js";
@@ -61,13 +61,6 @@ export function reload() {
}
export function resumeCmd() { return "htmlit resume " + (CFG.file || ""); }
-export function copyText(t) {
- try { if (navigator.clipboard) return navigator.clipboard.writeText(t); } catch (e) {}
- try {
- var ta = document.createElement("textarea"); ta.value = t; ta.setAttribute("data-htmlit", "");
- document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove();
- } catch (e) {}
-}
// Render the "kept - here's how to resume" box. `mode`: "live" (still open) or "ended".
export function renderKeepHint(mode) {
if (!ui.keephint) return;
diff --git a/skills/htmlit/client/rail.js b/skills/htmlit/client/rail.js
index 107b094..8f4a61a 100644
--- a/skills/htmlit/client/rail.js
+++ b/skills/htmlit/client/rail.js
@@ -61,8 +61,19 @@ export function anchorContainerEl(h) {
export function anchorRight(h) {
var el = anchorContainerEl(h);
if (!el) return null;
- var r = el.getBoundingClientRect();
- return (r.width || r.height) ? r.right : null;
+ // The rail hugs the right of the content COLUMN, not the immediate container: a
+ // comment inside a narrow cell, grid card or span would otherwise drag its
+ // card into the middle of the page. Take the widest block-level ancestor's right
+ // edge (up to, but not including, ) - that is the text column's edge.
+ var best = null;
+ for (var n = el; n && n !== document.body && n.nodeType === 1; n = n.parentElement) {
+ var display;
+ try { display = getComputedStyle(n).display; } catch (e) { continue; }
+ if (display === "inline") continue; // inline spans don't define the column width
+ var r = n.getBoundingClientRect();
+ if ((r.width || r.height) && (best == null || r.right > best)) best = r.right;
+ }
+ return best;
}
export function setRailMargin(on) {
if (on === railOn) return;
diff --git a/skills/htmlit/client/render.js b/skills/htmlit/client/render.js
index 351197d..f984260 100644
--- a/skills/htmlit/client/render.js
+++ b/skills/htmlit/client/render.js
@@ -3,6 +3,7 @@ import { inChrome, loadScript } from "./util.js";
import { theme } from "./theme.js";
import { enhanceMermaid } from "./diagram.js";
import { renderDiff } from "./diffview.js";
+import { makeCopyButton, makeLinenos, lineCount } from "./codeblock.js";
var mermaidReady = false;
var hljsReady = false;
@@ -152,23 +153,20 @@ export function enhanceCode() {
var m = /language-([\w-]+)/.exec(code.className);
var lang = m ? m[1] : "code";
var src = code.textContent.replace(/\n+$/, "");
- var count = src.length ? src.split("\n").length : 1;
var fig = document.createElement("div");
fig.className = "htmlit-code";
var hd = document.createElement("div");
hd.className = "htmlit-code-hd";
- hd.textContent = lang;
+ var langEl = document.createElement("span");
+ langEl.className = "htmlit-code-lang";
+ langEl.textContent = lang;
+ hd.appendChild(langEl);
+ hd.appendChild(makeCopyButton(function () { return src; }));
var body = document.createElement("div");
body.className = "htmlit-code-body";
- var gutter = document.createElement("span");
- gutter.className = "htmlit-linenos";
- gutter.setAttribute("aria-hidden", "true");
- var nums = [];
- for (var i = 1; i <= count; i++) nums.push(i);
- gutter.textContent = nums.join("\n");
+ body.appendChild(makeLinenos(lineCount(src)));
parent.insertBefore(fig, pre);
fig.appendChild(hd);
- body.appendChild(gutter);
body.appendChild(pre);
fig.appendChild(body);
});
diff --git a/skills/htmlit/client/util.js b/skills/htmlit/client/util.js
index 060b8eb..e4b792e 100644
--- a/skills/htmlit/client/util.js
+++ b/skills/htmlit/client/util.js
@@ -19,15 +19,19 @@ export function loadScript(src) {
document.head.appendChild(s);
});
}
-// a CSS selector for the element - sent to the agent, never shown to the user
+// A CSS selector that re-resolves to this exact element - sent to the agent and
+// used to re-anchor a selection after a morph. It must be rooted (at an ancestor
+// id, otherwise ): an unrooted path such as "div:nth-of-type(1) > ul > li"
+// floats, so document.querySelector can match a deeper, earlier subtree with the
+// same tag/nth-of-type skeleton and resolve to the wrong element.
export function cssPath(el) {
if (!el || el.nodeType !== 1) return "";
if (el.id) return "#" + CSS.escape(el.id);
var parts = [];
var node = el;
- while (node && node.nodeType === 1 && node !== document.body && parts.length < 6) {
+ while (node && node.nodeType === 1 && node !== document.body) {
+ if (node.id) { parts.unshift("#" + CSS.escape(node.id)); return parts.join(" > "); }
var sel = node.nodeName.toLowerCase();
- if (node.id) { parts.unshift("#" + CSS.escape(node.id)); break; }
var parent = node.parentElement;
if (parent) {
var same = Array.prototype.filter.call(parent.children, function (c) { return c.nodeName === node.nodeName; });
@@ -36,5 +40,14 @@ export function cssPath(el) {
parts.unshift(sel);
node = node.parentElement;
}
+ parts.unshift("body");
return parts.join(" > ");
}
+// Copy text to the clipboard, falling back to a hidden textarea + execCommand.
+export function copyText(t) {
+ try { if (navigator.clipboard) return navigator.clipboard.writeText(t); } catch (e) {}
+ try {
+ var ta = document.createElement("textarea"); ta.value = t; ta.setAttribute("data-htmlit", "");
+ document.body.appendChild(ta); ta.select(); document.execCommand("copy"); ta.remove();
+ } catch (e) {}
+}
diff --git a/tests/client/code-copy.test.mjs b/tests/client/code-copy.test.mjs
new file mode 100644
index 0000000..dcc116d
--- /dev/null
+++ b/tests/client/code-copy.test.mjs
@@ -0,0 +1,162 @@
+/**
+ * Browser test for code-block chrome: a plain code block is borderless and carries a
+ * Copy button, a diff stays framed with NO copy button, and the Mermaid "Code" toggle
+ * reveals the source as a real code block (line numbers) with its own Copy button.
+ *
+ * Skips rather than fails when no browser or Python is available.
+ */
+
+import assert from "node:assert/strict";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { after, before, test } from "node:test";
+
+import { startReview } from "./harness.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const FIXTURE = join(HERE, "fixtures", "code-blocks.html");
+const ARTIFACT_BORDER = join(HERE, "fixtures", "code-artifact-border.html");
+const state = { browser: null, unavailable: null };
+
+before(async () => {
+ try {
+ const { default: puppeteer } = await import("puppeteer");
+ state.browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"] });
+ } catch (err) {
+ state.unavailable = `no browser available: ${err.message}`;
+ }
+});
+
+after(async () => {
+ if (state.browser) await state.browser.close();
+});
+
+test("code blocks are borderless with a Copy button; diffs stay framed without one", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(FIXTURE);
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1000, height: 900 });
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.querySelector(".htmlit-code:not(.htmlit-diff)") && document.querySelector(".mermaid svg"), { timeout: 20000 });
+ await new Promise((r) => setTimeout(r, 400));
+
+ const info = await page.evaluate(() => {
+ const code = document.querySelector(".htmlit-code:not(.htmlit-diff)");
+ const diff = document.querySelector(".htmlit-diff");
+ return {
+ codeBorder: getComputedStyle(code).borderTopWidth,
+ codeHasCopy: !!code.querySelector(".htmlit-copy"),
+ codeHasLinenos: !!code.querySelector(".htmlit-linenos"),
+ diffBorder: getComputedStyle(diff).borderTopWidth,
+ diffHasCopy: !!diff.querySelector(".htmlit-copy"),
+ };
+ });
+ assert.equal(info.codeBorder, "0px", "a plain code block should have no border");
+ assert.ok(info.codeHasCopy, "a code block should have a Copy button");
+ assert.ok(info.codeHasLinenos, "a code block should keep its line numbers");
+ assert.notEqual(info.diffBorder, "0px", "a diff should keep its frame");
+ assert.ok(!info.diffHasCopy, "a diff should not have a Copy button");
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
+
+test("an artifact's own code{border} does not leak into the enhanced code block", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(ARTIFACT_BORDER);
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1000, height: 900 });
+ await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.querySelector(".htmlit-code-body>pre>code"), { timeout: 20000 });
+ await new Promise((r) => setTimeout(r, 400));
+
+ const info = await page.evaluate(() => {
+ const code = document.querySelector(".htmlit-code-body>pre>code");
+ const inline = document.querySelector("p > code");
+ return {
+ enhancedBorder: getComputedStyle(code).borderTopWidth,
+ inlineBorder: getComputedStyle(inline).borderTopWidth,
+ };
+ });
+ // The enhanced block owns its own (borderless) frame; the artifact's generic
+ // code{border} used to bleed a stray 1px box around the highlighted source.
+ assert.equal(info.enhancedBorder, "0px", "the enhanced code block's should have no border");
+ // The artifact's inline code is left untouched - only the enhanced block is reset.
+ assert.notEqual(info.inlineBorder, "0px", "inline code should keep the artifact's own border");
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
+
+test("the Mermaid Code view mirrors a normal code block: a MERMAID title, matching background, Copy and Diagram toggle", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(FIXTURE);
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1000, height: 900 });
+ await page.emulateMediaFeatures([{ name: "prefers-color-scheme", value: "light" }]);
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(() => document.querySelector(".mermaid [data-htmlit-codebtn]"), { timeout: 20000 });
+ await new Promise((r) => setTimeout(r, 400));
+
+ const before = await page.evaluate(() => {
+ const view = document.querySelector(".mermaid [data-htmlit-code]");
+ return { viewHidden: view ? getComputedStyle(view).display === "none" : null };
+ });
+ assert.equal(before.viewHidden, true, "the source view is hidden until Code is clicked");
+
+ await page.evaluate(() => document.querySelector(".mermaid [data-htmlit-codebtn]").click());
+ await new Promise((r) => setTimeout(r, 200));
+
+ const after = await page.evaluate(() => {
+ const view = document.querySelector(".mermaid [data-htmlit-code]");
+ const lang = view.querySelector(".htmlit-code-hd .htmlit-code-lang");
+ const enhanced = document.querySelector(".htmlit-code:not(.htmlit-diff):not([data-htmlit-code]) .htmlit-code-body");
+ const copy = view.querySelector("[data-htmlit-codecopy]");
+ copy.click();
+ return {
+ isCodeClass: view.classList.contains("htmlit-code"),
+ hasHeader: !!view.querySelector(".htmlit-code-hd"),
+ langText: lang ? lang.textContent : null,
+ langUppercased: lang ? getComputedStyle(lang).textTransform : null,
+ viewBg: getComputedStyle(view).backgroundColor,
+ enhancedBg: enhanced ? getComputedStyle(enhanced.parentElement).backgroundColor : null,
+ hasLinenos: !!view.querySelector(".htmlit-linenos"),
+ highlighted: !!view.querySelector("code.hljs span[class^='hljs-']"),
+ copyVisible: copy.offsetParent !== null,
+ copyLabel: copy.textContent,
+ };
+ });
+ assert.ok(after.isCodeClass, "the source view reuses the .htmlit-code chrome");
+ assert.ok(after.hasHeader, "the source view has a titled header like a normal block");
+ assert.equal(after.langText, "mermaid", "the header labels the block MERMAID");
+ assert.equal(after.langUppercased, "uppercase", "the label is uppercased like a normal block");
+ assert.equal(after.viewBg, after.enhancedBg, "the source view background matches a normal code block");
+ assert.ok(after.hasLinenos, "the Mermaid source should render with line numbers");
+ assert.ok(after.highlighted, "the Mermaid source should be syntax-highlighted");
+ assert.ok(after.copyVisible, "the Copy button shows in the header with the code view");
+ assert.equal(after.copyLabel, "Copied", "clicking Copy should give feedback");
+
+ // The header's Diagram button closes the source view and restores the diagram.
+ await page.evaluate(() => [...document.querySelectorAll(".mermaid [data-htmlit-code] .htmlit-copy")].find((b) => b.textContent === "Diagram").click());
+ await new Promise((r) => setTimeout(r, 150));
+ const closed = await page.evaluate(() => {
+ const view = document.querySelector(".mermaid [data-htmlit-code]");
+ const codeBtn = document.querySelector(".mermaid [data-htmlit-codebtn]");
+ return { viewHidden: getComputedStyle(view).display === "none", codeBtnVisible: codeBtn.offsetParent !== null };
+ });
+ assert.equal(closed.viewHidden, true, "Diagram closes the source view");
+ assert.ok(closed.codeBtnVisible, "the Code button returns on the diagram");
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
diff --git a/tests/client/fixtures/code-artifact-border.html b/tests/client/fixtures/code-artifact-border.html
new file mode 100644
index 0000000..42653c8
--- /dev/null
+++ b/tests/client/fixtures/code-artifact-border.html
@@ -0,0 +1,24 @@
+
+
+
+
+ htmlit code-block artifact-border fixture
+
+
+
+ Artifact-styled code
+
+ Inline SISMEMBER keeps the artifact's own border; that is fine.
+
+ def hello(name: str) -> str:
+ return f"hello, {name}"
+
+
diff --git a/tests/client/fixtures/code-blocks.html b/tests/client/fixtures/code-blocks.html
new file mode 100644
index 0000000..26a9d5a
--- /dev/null
+++ b/tests/client/fixtures/code-blocks.html
@@ -0,0 +1,25 @@
+
+
+
+
+ htmlit code-block fixture
+
+
+
+ Code, diff and diagram rendering
+
+ def hello(name: str) -> str:
+ return f"hello, {name}"
+
+ @@ -1,2 +1,2 @@
+ def hello(name):
+- return "hi " + name
++ return f"hello, {name}"
+
+ flowchart LR
+ A["start"] --> B["end"]
+
+
diff --git a/tests/client/fixtures/rail-longquote.html b/tests/client/fixtures/rail-longquote.html
new file mode 100644
index 0000000..aea1406
--- /dev/null
+++ b/tests/client/fixtures/rail-longquote.html
@@ -0,0 +1,14 @@
+
+
+
+
+ Rail long quote
+
+
+
+ Cancellation trade-offs
+ At our cancel volume (rare, tiny messages) the broadcast cost is negligible, so classic first is the pragmatic call; the durable set means a missed message is never fatal anyway.
+
+
diff --git a/tests/client/fixtures/rail-narrow-anchor.html b/tests/client/fixtures/rail-narrow-anchor.html
new file mode 100644
index 0000000..f5d1598
--- /dev/null
+++ b/tests/client/fixtures/rail-narrow-anchor.html
@@ -0,0 +1,26 @@
+
+
+
+
+ htmlit rail-position fixture
+
+
+
+
+ Command reference
+ A wide, centered document. A comment on a command in the table must put its
+ note card on the right of the content column, not in the middle of the page.
+
+ Command What it does
+ SISMEMBERCheck whether a member is in a set - a narrow cell far from the column edge.
+ XAUTOCLAIMReclaim pending stream entries from a dead consumer.
+
+
+
+
diff --git a/tests/client/fixtures/rail-stacked.html b/tests/client/fixtures/rail-stacked.html
new file mode 100644
index 0000000..ddf80a4
--- /dev/null
+++ b/tests/client/fixtures/rail-stacked.html
@@ -0,0 +1,23 @@
+
+
+
+
+ htmlit stacked-cards fixture
+
+
+
+
+ Several comments high up must not block lower content
+ Line one references alpha in the pipeline.
+ Line two references bravo in the pipeline.
+ Line three references charlie in the pipeline.
+ Line four references delta in the pipeline.
+ A leaked testbed lock line lower down that must stay selectable and annotatable.
+
+
+
diff --git a/tests/client/fixtures/selector-collision.html b/tests/client/fixtures/selector-collision.html
new file mode 100644
index 0000000..98eb20d
--- /dev/null
+++ b/tests/client/fixtures/selector-collision.html
@@ -0,0 +1,34 @@
+
+
+
+
+ Selector collision
+
+
+
+ Cancellation design
+
+
+
+
+
+ - Checkpoint cadence
+ - Checkpoint polls the queue on a timer
+
+
+
+
+ Failure modes
+
+ - A stale reservation that never clears.
+ - A leaked testbed lock whose task is terminal.
+
+
+
+
diff --git a/tests/client/rail-position.test.mjs b/tests/client/rail-position.test.mjs
new file mode 100644
index 0000000..4f9300c
--- /dev/null
+++ b/tests/client/rail-position.test.mjs
@@ -0,0 +1,166 @@
+/**
+ * Browser regression test for comment-card placement. A comment anchored inside a
+ * narrow container (a table cell / span) used to drag its rail card into the
+ * middle of the page, because the card hugged the immediate container's right edge.
+ * The card must sit at the right of the content column instead - on the side.
+ *
+ * Skips rather than fails when no browser or Python is available.
+ */
+
+import assert from "node:assert/strict";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { after, before, test } from "node:test";
+
+import { startReview } from "./harness.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const FIXTURE = join(HERE, "fixtures", "rail-narrow-anchor.html");
+const STACKED = join(HERE, "fixtures", "rail-stacked.html");
+const LONGQUOTE = join(HERE, "fixtures", "rail-longquote.html");
+const state = { browser: null, unavailable: null };
+
+const SEED = {
+ highlights: [{
+ id: "cm1",
+ kind: "comment",
+ prompt: "explain SISMEMBER",
+ comments: ["explain SISMEMBER"],
+ text: "SISMEMBER",
+ range: { container: "#cmd", start: 0, end: 9, text: "SISMEMBER" },
+ }],
+};
+
+before(async () => {
+ try {
+ const { default: puppeteer } = await import("puppeteer");
+ state.browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"] });
+ } catch (err) {
+ state.unavailable = `no browser available: ${err.message}`;
+ }
+});
+
+after(async () => {
+ if (state.browser) await state.browser.close();
+});
+
+test("a comment in a narrow cell places its card on the content-column side, not mid-page", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(FIXTURE);
+ await fetch(`${review.base}/api/${review.key}/highlights`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(SEED),
+ });
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 2000, height: 1000 }); // wide, so the rail has room
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => { const c = document.getElementById("htmlit-chrome"); return c && c.shadowRoot && c.shadowRoot.querySelector(".rail .card"); },
+ { timeout: 20000 },
+ );
+ await new Promise((r) => setTimeout(r, 500));
+
+ const m = await page.evaluate(() => {
+ const card = document.getElementById("htmlit-chrome").shadowRoot.querySelector(".rail .card");
+ const cardLeft = card.getBoundingClientRect().left;
+ const codeRight = document.getElementById("cmd").getBoundingClientRect().right;
+ const contentRight = document.querySelector(".layout").getBoundingClientRect().right;
+ return { cardLeft, codeRight, contentRight };
+ });
+
+ // The bug: the card sat at the narrow 's right edge, in the middle of the
+ // page. It must instead align to the content column's right edge.
+ assert.ok(m.cardLeft > m.codeRight + 300, `card should not hug the narrow anchor (card ${Math.round(m.cardLeft)}, code ${Math.round(m.codeRight)})`);
+ assert.ok(m.cardLeft >= m.contentRight - 20, `card should sit at the content-column right (card ${Math.round(m.cardLeft)}, column ${Math.round(m.contentRight)})`);
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
+
+test("stacked comment cards do not cover (and block selection of) lower content", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(STACKED);
+ // Four comments high up, on narrow spans - their cards stack downward.
+ const seed = { highlights: [
+ { id: "s1", kind: "comment", prompt: "n1", comments: ["n1"], text: "alpha", range: { container: "#c1", start: 0, end: 5, text: "alpha" } },
+ { id: "s2", kind: "comment", prompt: "n2", comments: ["n2"], text: "bravo", range: { container: "#c2", start: 0, end: 5, text: "bravo" } },
+ { id: "s3", kind: "comment", prompt: "n3", comments: ["n3"], text: "charlie", range: { container: "#c3", start: 0, end: 7, text: "charlie" } },
+ { id: "s4", kind: "comment", prompt: "n4", comments: ["n4"], text: "delta", range: { container: "#c4", start: 0, end: 5, text: "delta" } },
+ ] };
+ await fetch(`${review.base}/api/${review.key}/highlights`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(seed),
+ });
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1600, height: 900 });
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => { const c = document.getElementById("htmlit-chrome"); return c && c.shadowRoot && c.shadowRoot.querySelectorAll(".rail .card").length >= 4; },
+ { timeout: 20000 },
+ );
+ await new Promise((r) => setTimeout(r, 600));
+
+ // Every point along the lower target line must hit the page, not a card in the
+ // chrome. Under the old mid-page placement a stacked card covered the line and
+ // stole the mouse, so it could not be selected or commented on.
+ const covered = await page.evaluate(() => {
+ const t = document.getElementById("target").getBoundingClientRect();
+ const y = t.top + t.height / 2;
+ for (let x = t.left + 4; x < t.right - 4; x += 20) {
+ const hit = document.elementFromPoint(x, y);
+ if (hit && hit.closest && hit.closest("#htmlit-chrome")) return { x: Math.round(x), tag: hit.tagName };
+ }
+ return null;
+ });
+ assert.equal(covered, null, `no card should cover the target line (covered at ${covered && covered.x}px)`);
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
+
+test("a long comment quote previews two full lines instead of a clipped one", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(LONGQUOTE);
+ const quote = "At our cancel volume (rare, tiny messages) the broadcast cost is negligible, so classic first is the pragmatic call; the durable set means a missed message is never fatal anyway.";
+ await fetch(`${review.base}/api/${review.key}/highlights`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ highlights: [{
+ id: "lq", kind: "comment", prompt: "note", comments: ["agree"], text: quote,
+ range: { container: "#q", start: 0, end: quote.length, text: quote },
+ }] }),
+ });
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1600, height: 900 });
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => { const c = document.getElementById("htmlit-chrome"); return c && c.shadowRoot && c.shadowRoot.querySelector(".rail .card .card-q"); },
+ { timeout: 20000 },
+ );
+ await new Promise((r) => setTimeout(r, 400));
+
+ const m = await page.evaluate(() => {
+ const q = document.getElementById("htmlit-chrome").shadowRoot.querySelector(".rail .card .card-q");
+ const cs = getComputedStyle(q);
+ const pad = parseFloat(cs.paddingTop) + parseFloat(cs.paddingBottom);
+ return { contentH: q.clientHeight - pad, lineHeight: parseFloat(cs.lineHeight) };
+ });
+ // The bug clamped the box to ~1.5 lines (a hard max-height cut the second line
+ // mid-glyph). The -webkit-line-clamp:2 preview must show two whole lines.
+ assert.ok(m.contentH >= 1.9 * m.lineHeight, `quote should show ~2 lines (content ${Math.round(m.contentH)}px, line ${Math.round(m.lineHeight)}px)`);
+ assert.ok(m.contentH <= 2.2 * m.lineHeight, `quote should stay clamped to 2 lines (content ${Math.round(m.contentH)}px, line ${Math.round(m.lineHeight)}px)`);
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});
diff --git a/tests/client/robustness.test.mjs b/tests/client/robustness.test.mjs
index 3ac2002..4565913 100644
--- a/tests/client/robustness.test.mjs
+++ b/tests/client/robustness.test.mjs
@@ -89,16 +89,17 @@ test("a rendered diagram exposes a Code toggle for its source", async (t) => {
const shown = {
codeShown: Boolean(view) && getComputedStyle(view).display !== "none",
svgHidden: getComputedStyle(box.querySelector("svg")).display === "none",
- labelAfter: btn && btn.textContent,
+ codeBtnHiddenWhenOpen: btn.offsetParent === null,
showsSource: Boolean(view) && /flowchart/.test(view.textContent),
};
- if (btn) btn.click();
+ const back = view && [...view.querySelectorAll(".htmlit-copy")].find((b) => b.textContent === "Diagram");
+ if (back) back.click();
const bar = box.querySelector("[data-htmlit-tools]");
const restored = {
barDisplay: bar ? getComputedStyle(bar).display : "none",
svgVisibleAgain: getComputedStyle(box.querySelector("svg")).display !== "none",
codeHiddenAgain: Boolean(view) && getComputedStyle(view).display === "none",
- labelBack: btn && btn.textContent,
+ codeBtnVisibleAgain: btn.offsetParent !== null,
};
return { ...before, ...shown, ...restored };
});
@@ -106,12 +107,12 @@ test("a rendered diagram exposes a Code toggle for its source", async (t) => {
assert.equal(result.label, "Code");
assert.equal(result.codeShown, true);
assert.equal(result.svgHidden, true);
- assert.equal(result.labelAfter, "Diagram");
+ assert.equal(result.codeBtnHiddenWhenOpen, true);
assert.equal(result.showsSource, true);
assert.match(result.barDisplay, /flex/);
assert.equal(result.svgVisibleAgain, true);
assert.equal(result.codeHiddenAgain, true);
- assert.equal(result.labelBack, "Code");
+ assert.equal(result.codeBtnVisibleAgain, true);
} finally {
if (ctx) { await ctx.page.close(); await ctx.review.stop(); }
}
diff --git a/tests/client/selector-anchor.test.mjs b/tests/client/selector-anchor.test.mjs
new file mode 100644
index 0000000..c12270c
--- /dev/null
+++ b/tests/client/selector-anchor.test.mjs
@@ -0,0 +1,97 @@
+/**
+ * Browser regression test for annotation-selector anchoring.
+ *
+ * When a selection's anchor element had no id, the client built an *unrooted* CSS
+ * path for it ("div:nth-of-type(1) > ul > li:nth-of-type(2) > strong"). If an
+ * earlier subtree in the document shared that same tag/nth-of-type skeleton
+ * (e.g. a nested in a card grid), document.querySelector re-resolved the
+ * path to that decoy instead. resolveRange's text guard then failed, pendingRects
+ * came back empty, and openSelMenu -> clearPending closed the menu and wiped the
+ * selection - so the line could not be highlighted or commented on at all.
+ *
+ * The fixture reproduces exactly that collision. Selecting the target
+ * must open the annotation menu (rooting the path at makes it resolve to
+ * the correct element).
+ *
+ * Skips rather than fails when no browser or Python is available.
+ */
+
+import assert from "node:assert/strict";
+import { dirname, join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { after, before, test } from "node:test";
+
+import { startReview } from "./harness.mjs";
+
+const HERE = dirname(fileURLToPath(import.meta.url));
+const FIXTURE = join(HERE, "fixtures", "selector-collision.html");
+const state = { browser: null, unavailable: null };
+
+before(async () => {
+ try {
+ const { default: puppeteer } = await import("puppeteer");
+ state.browser = await puppeteer.launch({ args: ["--no-sandbox", "--disable-setuid-sandbox"] });
+ } catch (err) {
+ state.unavailable = `no browser available: ${err.message}`;
+ }
+});
+
+after(async () => {
+ if (state.browser) await state.browser.close();
+});
+
+// Select the whole text of the first element matching `needle`, then fire the
+// mouseup the client listens for, and report the annotation menu's display.
+async function selectAndMenu(page, needle) {
+ return page.evaluate(async (needle) => {
+ function findText(n) {
+ const w = document.createTreeWalker(document.querySelector(".layout") || document.body, NodeFilter.SHOW_TEXT);
+ let x;
+ while ((x = w.nextNode())) if ((x.nodeValue || "").toLowerCase().includes(n)) return x;
+ return null;
+ }
+ const node = findText(needle.toLowerCase());
+ if (!node) return { found: false };
+ const host = node.parentElement;
+ host.scrollIntoView({ block: "center" });
+ await new Promise((r) => setTimeout(r, 100));
+
+ const range = document.createRange();
+ range.setStart(node, 0);
+ range.setEnd(node, node.nodeValue.length);
+ const sel = window.getSelection();
+ sel.removeAllRanges();
+ sel.addRange(range);
+
+ host.dispatchEvent(new MouseEvent("mouseup", { bubbles: true, composed: true }));
+ await new Promise((r) => setTimeout(r, 200)); // the client finalizes the selection in a setTimeout(0)
+
+ const menu = document.getElementById("htmlit-chrome").shadowRoot.querySelector(".selmenu");
+ return { found: true, selection: sel.toString(), menu: getComputedStyle(menu).display };
+ }, needle);
+}
+
+test("selecting a nested inline element opens the annotation menu despite a decoy subtree", async (t) => {
+ if (state.unavailable) return t.skip(state.unavailable);
+ let review, page;
+ try {
+ review = await startReview(FIXTURE);
+ page = await state.browser.newPage();
+ await page.setViewport({ width: 1440, height: 1000 });
+ await page.goto(review.url, { waitUntil: "domcontentloaded" });
+ await page.waitForFunction(
+ () => { const c = document.getElementById("htmlit-chrome"); return c && c.shadowRoot && c.shadowRoot.querySelector(".selmenu"); },
+ { timeout: 20000 },
+ );
+ await new Promise((r) => setTimeout(r, 300));
+
+ const target = await selectAndMenu(page, "leaked testbed lock");
+ assert.ok(target.found, "target line should exist in the fixture");
+ // Under the bug the unrooted path resolved to the decoy ("polls the
+ // queue"), the text guard failed, and the menu was force-closed (display:none).
+ assert.equal(target.menu, "flex", `annotation menu should open for the target line (got display:${target.menu})`);
+ } finally {
+ if (page) await page.close();
+ if (review) await review.stop();
+ }
+});