diff --git a/skills/htmlit/SKILL.md b/skills/htmlit/SKILL.md index 59299a7..6d1b9f6 100644 --- a/skills/htmlit/SKILL.md +++ b/skills/htmlit/SKILL.md @@ -303,7 +303,8 @@ The injected client auto-renders, on first load and after every morph: diagram element tracks it as it moves. A minimal zoom control sits at the bottom-left showing the current **zoom %** (relative to the fitted view); click the percentage to reset to fit. A **Code** button at the top-right toggles the - diagram for its Mermaid source (read or copy the syntax in place). A diagram + diagram for its Mermaid source, shown as a syntax-highlighted code block (line + numbers) with a **Copy** button to grab the syntax in place. A diagram inside a hidden tab or a closed `
` renders **automatically the moment it is revealed**, so it is safe to put diagrams in tabs/accordions - they never collapse. Manual @@ -314,10 +315,10 @@ The injected client auto-renders, on first load and after every morph: shows the normal arrow cursor at rest and switches to a grabbing cursor only while you actually drag.) - **Code** - `
...
` 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]*/ }, + { 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.

+ + + + +
CommandWhat 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(); + } +});