diff --git a/README.md b/README.md index 8e2dd943..662b40a8 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,8 @@ pnpm link - **Feedback controls** - Native controls (radios, checkboxes, inputs, selects, buttons, labels, disclosure summaries, contenteditable) are interactive automatically, so they do not need `data-lavish-action`. For reversible choices, let option clicks update local state, then queue exactly one final answer from a per-question submit or Queue answer button with `window.lavish.queuePrompt()`. Mark only custom (non-native) clickable elements with `data-lavish-action` so Lavish does not annotate them, and use `data-lavish-question` or `queueKey` when pre-send updates for the same question should replace each other. + With Annotate enabled, right-click annotatable content to choose **Annotate element** or **Annotate selection**; ordinary selection, left-click, mouseup, and keyboard copy remain native. + Browsers do not let page scripts add an item to their native context menu, so Lavish shows its own menu there and keeps **Copy selection** as the first action when text is selected; native controls retain the browser menu. The browser chrome keeps editing actions in the overflow menu (copy path, reload artifact, copy DOM snapshot, export standalone HTML, publish link, end session) and can submit queued prompts with **Send & end session**, which sends the prompts and user-ended attribution together. - **Keyboard shortcuts** - In the chrome composer, Enter sends queued prompts and Shift+Enter inserts a newline. In the annotation card, Enter queues the annotation, Shift+Enter inserts a newline, and Ctrl+Enter (Cmd+Enter on macOS) queues it and sends all queued prompts immediately. diff --git a/src/artifact-sdk.js b/src/artifact-sdk.js index 3eada6a1..a463ca64 100644 --- a/src/artifact-sdk.js +++ b/src/artifact-sdk.js @@ -207,7 +207,6 @@ export function createArtifactSdk( let annotationMode = true; let hovered = null; let selected = null; - let ignoreNextClick = false; let shadow = null; let counter = 0; const ids = new WeakMap(); @@ -530,7 +529,7 @@ export function createArtifactSdk( style = document.createElement("style"); style.id = "lavish-cursor-style"; style.textContent = - ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}*{cursor:default!important}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}input,textarea,[contenteditable]:not([contenteditable='false']){cursor:text!important}button,select,label,option,input[type='button'],input[type='submit'],input[type='reset'],input[type='checkbox'],input[type='radio'],input[type='file'],input[type='color'],input[type='range'],input[type='image']{cursor:pointer!important}"; + ":root{--lavish-accent:#f4c95d;--lavish-annotate-outline:2px solid var(--lavish-accent);--lavish-annotate-offset:2px}[data-lavish-action],[data-lavish-action] *{cursor:pointer!important}"; document.head.appendChild(style); } if (!annotationMode && style) style.remove(); @@ -949,14 +948,14 @@ export function createArtifactSdk( shadow = host.attachShadow({ mode: "open" }); const style = document.createElement("style"); - style.textContent = `:host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;--ink-900:#0f1115;--ink-800:#11141a;--ink-700:#171a21;--ink-600:#1c212b;--steel-700:#2a2f3a;--steel-600:#303745;--steel-500:#3c4557;--steel-400:#8c96aa;--steel-300:#aeb6c6;--steel-200:#b9c0cf;--steel-100:#d8deea;--cream-50:#fffbf3;--cream-100:#f7f3ea;--cream-200:#e8e1cf;--brass-500:#f4c95d;--brass-400:#ffd877;--brass-ink:#17130a;--bg:var(--ink-900);--bg-panel:var(--ink-800);--bg-elevated:var(--ink-600);--fg:var(--cream-100);--fg-faint:var(--steel-300);--border:var(--steel-600);--accent:#f4c95d;--accent-hover:#ffd877;--font-sans:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius-md:10px;--radius-xl:14px;--shadow-floating:0 20px 70px rgba(0,0,0,.35);font-family:var(--font-sans)}*{box-sizing:border-box}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.lavish-text-highlight{position:fixed;pointer-events:none;background:rgba(244,201,93,.28);border-radius:2px;box-shadow:0 0 0 1px rgba(244,201,93,.45)}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:var(--radius-xl);background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:14px/1.4 var(--font-sans)}.lavish-heading{font-weight:700;margin-bottom:6px}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:9px;font:inherit;font-family:var(--font-sans)}.lavish-annotation-card textarea::placeholder{color:var(--fg-faint)}.lavish-annotation-card .lavish-hint{margin-top:6px;font-size:11px;color:var(--fg-faint)}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:var(--radius-md);padding:8px 10px;font-family:var(--font-sans);font-size:13px;font-weight:700;cursor:pointer}.lavish-annotation-card button:active{opacity:.85}.lavish-annotation-card .lavish-send{background:var(--accent);color:var(--brass-ink)}.lavish-annotation-card .lavish-send:hover{background:var(--accent-hover)}.lavish-annotation-card .lavish-cancel{background:var(--steel-700);color:var(--fg)}`; + style.textContent = `:host{all:initial;position:fixed;z-index:2147483647;left:0;top:0;color-scheme:dark;--ink-900:#0f1115;--ink-800:#11141a;--ink-700:#171a21;--ink-600:#1c212b;--steel-700:#2a2f3a;--steel-600:#303745;--steel-500:#3c4557;--steel-400:#8c96aa;--steel-300:#aeb6c6;--steel-200:#b9c0cf;--steel-100:#d8deea;--cream-50:#fffbf3;--cream-100:#f7f3ea;--cream-200:#e8e1cf;--brass-500:#f4c95d;--brass-400:#ffd877;--brass-ink:#17130a;--bg:var(--ink-900);--bg-panel:var(--ink-800);--bg-elevated:var(--ink-600);--fg:var(--cream-100);--fg-faint:var(--steel-300);--border:var(--steel-600);--accent:#f4c95d;--accent-hover:#ffd877;--font-sans:Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;--font-mono:"Geist Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;--radius-md:10px;--radius-xl:14px;--shadow-floating:0 20px 70px rgba(0,0,0,.35);font-family:var(--font-sans)}*{box-sizing:border-box}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}.lavish-text-highlight{position:fixed;pointer-events:none;background:rgba(244,201,93,.28);border-radius:2px;box-shadow:0 0 0 1px rgba(244,201,93,.45)}.lavish-context-menu{position:fixed;display:grid;min-width:180px;padding:6px;border-radius:var(--radius-md);background:var(--bg-panel);color:var(--fg);border:1px solid var(--border);box-shadow:var(--shadow-floating);font:13px/1.3 var(--font-sans)}.lavish-context-menu button{border:0;border-radius:7px;padding:8px 10px;background:transparent;color:var(--fg);font:inherit;text-align:left;cursor:pointer}.lavish-context-menu button:hover,.lavish-context-menu button:focus-visible{background:var(--steel-700)}.lavish-context-menu .lavish-annotate-action{color:var(--accent)}.lavish-annotation-card{position:fixed;width:min(320px,calc(100vw - 24px));padding:12px;border-radius:var(--radius-xl);background:var(--bg-panel);color:var(--fg);border:1px solid var(--accent);box-shadow:var(--shadow-floating);font:14px/1.4 var(--font-sans)}.lavish-heading{font-weight:700;margin-bottom:6px}.lavish-annotation-card textarea{width:100%;min-height:86px;resize:vertical;border-radius:var(--radius-md);border:1px solid var(--border);background:var(--bg);color:var(--fg);padding:9px;font:inherit;font-family:var(--font-sans)}.lavish-annotation-card textarea::placeholder{color:var(--fg-faint)}.lavish-annotation-card .lavish-hint{margin-top:6px;font-size:11px;color:var(--fg-faint)}.lavish-annotation-card .lavish-row{display:flex;gap:8px;justify-content:flex-end;margin-top:8px}.lavish-annotation-card button{border:0;border-radius:var(--radius-md);padding:8px 10px;font-family:var(--font-sans);font-size:13px;font-weight:700;cursor:pointer}.lavish-annotation-card button:active{opacity:.85}.lavish-annotation-card .lavish-send{background:var(--accent);color:var(--brass-ink)}.lavish-annotation-card .lavish-send:hover{background:var(--accent-hover)}.lavish-annotation-card .lavish-cancel{background:var(--steel-700);color:var(--fg)}`; shadow.appendChild(style); return shadow; } function closeCard() { if (shadow) { - for (const el of [...shadow.querySelectorAll(".lavish-annotation-card")]) el.remove(); + for (const el of [...shadow.querySelectorAll(".lavish-annotation-card,.lavish-context-menu")]) el.remove(); } clearHighlight(hovered); clearHighlight(selected); @@ -965,6 +964,62 @@ export function createArtifactSdk( selected = null; } + function copySelectedText(text) { + let copied = false; + try { + copied = !!document.execCommand?.("copy"); + } catch { + // Clipboard access differs across sandbox and browser versions; try the + // asynchronous API below without disrupting the current selection. + } + if (!copied && navigator.clipboard?.writeText) { + navigator.clipboard.writeText(text).catch(() => {}); + } + } + + function showAnnotationMenu(target, options = {}) { + const root = ensureShadow(); + closeCard(); + + const c = options.context || context(target); + if (options.range) { + highlightTextRange(options.range); + } else { + selected = target; + highlightElement(selected); + } + + const menu = document.createElement("div"); + menu.className = "lavish-context-menu"; + menu.setAttribute("role", "menu"); + menu.addEventListener("mousedown", (event) => event.preventDefault()); + + if (c.tag === "text") { + const selectedText = document.getSelection()?.toString() || c.text; + const copyButton = document.createElement("button"); + copyButton.type = "button"; + copyButton.textContent = "Copy selection"; + copyButton.onclick = () => { + copySelectedText(selectedText); + closeCard(); + }; + menu.appendChild(copyButton); + } + + const annotateButton = document.createElement("button"); + annotateButton.type = "button"; + annotateButton.className = "lavish-annotate-action"; + annotateButton.textContent = c.tag === "text" ? "Annotate selection" : "Annotate element"; + annotateButton.onclick = () => showAnnotationCard(target, options); + menu.appendChild(annotateButton); + root.appendChild(menu); + + const left = Math.min(Math.max(8, Number(options.x) || 0), window.innerWidth - menu.offsetWidth - 8); + const top = Math.min(Math.max(8, Number(options.y) || 0), window.innerHeight - menu.offsetHeight - 8); + menu.style.left = left + "px"; + menu.style.top = top + "px"; + } + function showAnnotationCard(target, options = {}) { const root = ensureShadow(); closeCard(); @@ -1113,7 +1168,7 @@ export function createArtifactSdk( ); document.addEventListener( - "mouseup", + "contextmenu", (event) => { if ( !annotationMode || @@ -1123,32 +1178,23 @@ export function createArtifactSdk( ) return; - const c = textSelectionContext(document.getSelection()); - if (!c) return; - - ignoreNextClick = true; - showAnnotationCard(c.element, { context: c, range: c.range }); - }, - true, - ); + let c = textSelectionContext(document.getSelection()); + if (c && c.range.intersectsNode) { + try { + if (!c.range.intersectsNode(event.target)) c = null; + } catch { + c = null; + } + } - document.addEventListener( - "click", - (event) => { - if ( - !annotationMode || - isLavishUi(event.target) || - isLavishAction(event.target) || - isInteractiveControl(event.target) - ) - return; event.preventDefault(); event.stopPropagation(); - if (ignoreNextClick) { - ignoreNextClick = false; - return; - } - showAnnotationCard(event.target); + showAnnotationMenu(c?.element || event.target, { + context: c || undefined, + range: c?.range, + x: event.clientX, + y: event.clientY, + }); }, true, ); diff --git a/src/server.js b/src/server.js index 99ebb2f2..df62c3ee 100644 --- a/src/server.js +++ b/src/server.js @@ -873,8 +873,9 @@ export function createChromeHtml(session, { layoutGateEnabled = true } = {}) {
LavishEditor
-
+
+
Checking layout.
One moment.

Lavish is waiting for fonts and final geometry before revealing this artifact.

diff --git a/test/artifact-sdk-interaction.test.js b/test/artifact-sdk-interaction.test.js new file mode 100644 index 00000000..d91c1b9e --- /dev/null +++ b/test/artifact-sdk-interaction.test.js @@ -0,0 +1,457 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createArtifactSdk } from "../src/artifact-sdk.js"; + +class FakeElement { + constructor(tagName, ownerDocument) { + this.ownerDocument = ownerDocument; + this.nodeType = 1; + this.tagName = String(tagName).toUpperCase(); + this.nodeName = this.tagName; + this.parentElement = null; + this.parentNode = null; + this.children = []; + this.childNodes = []; + this.style = {}; + this.attributes = new Map(); + this.className = ""; + this.id = ""; + this.textContent = ""; + this.innerText = ""; + this.hidden = false; + this.onclick = null; + this.listeners = new Map(); + this._innerHTML = ""; + } + + set innerHTML(value) { + this._innerHTML = String(value); + if (!this.className.includes("lavish-annotation-card")) return; + const textarea = new FakeElement("textarea", this.ownerDocument); + const cancel = new FakeElement("button", this.ownerDocument); + cancel.className = "lavish-cancel"; + const send = new FakeElement("button", this.ownerDocument); + send.className = "lavish-send"; + this.appendChild(textarea); + this.appendChild(cancel); + this.appendChild(send); + } + + get innerHTML() { + return this._innerHTML; + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + if (name === "id") this.id = String(value); + } + + getAttribute(name) { + return this.attributes.has(name) ? this.attributes.get(name) : null; + } + + appendChild(child) { + child.parentNode = this; + if (child.nodeType === 1) { + child.parentElement = this.nodeType === 1 ? this : null; + this.children.push(child); + } + this.childNodes.push(child); + return child; + } + + remove() { + if (!this.parentNode) return; + this.parentNode.children = this.parentNode.children.filter((child) => child !== this); + this.parentNode.childNodes = this.parentNode.childNodes.filter((child) => child !== this); + this.parentNode = null; + this.parentElement = null; + } + + attachShadow() { + this.shadowRoot = new FakeShadowRoot(this.ownerDocument); + return this.shadowRoot; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) || []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + dispatch(type, event = {}) { + for (const listener of this.listeners.get(type) || []) listener(event); + } + + click() { + this.onclick?.({ target: this, preventDefault() {}, stopPropagation() {} }); + } + + focus() { + this.ownerDocument.activeElement = this; + if (this.tagName === "TEXTAREA") this.ownerDocument.selection.clear(); + } + + closest(selector) { + const nativeTags = new Set(["button", "input", "select", "textarea", "option", "optgroup", "label", "summary"]); + let node = this; + while (node) { + if (selector === "[data-lavish-ui]" && node.attributes?.has("data-lavish-ui")) return node; + if (selector === "[data-lavish-action]" && node.attributes?.has("data-lavish-action")) return node; + if (selector.includes("button,input,select,textarea") && nativeTags.has(node.tagName?.toLowerCase())) return node; + node = node.parentElement; + } + return null; + } + + contains(other) { + if (other === this) return true; + return this.children.some((child) => child.contains(other)); + } + + querySelector(selector) { + return this.querySelectorAll(selector)[0] || null; + } + + querySelectorAll(selector) { + const selectors = selector.split(",").map((item) => item.trim()); + const matches = []; + const visit = (node) => { + for (const child of node.children || []) { + if ( + selectors.some((candidate) => { + if (candidate.startsWith(".")) return child.className.split(/\s+/).includes(candidate.slice(1)); + return child.tagName.toLowerCase() === candidate.toLowerCase(); + }) + ) { + matches.push(child); + } + visit(child); + } + }; + visit(this); + return matches; + } + + getBoundingClientRect() { + return { left: 20, top: 20, right: 220, bottom: 60, width: 200, height: 40 }; + } +} + +class FakeShadowRoot extends FakeElement { + constructor(ownerDocument) { + super("shadow-root", ownerDocument); + this.nodeType = 11; + } +} + +function fakeEvent(target, extra = {}) { + return { + target, + clientX: 40, + clientY: 40, + defaultPrevented: false, + propagationStopped: false, + preventDefault() { + this.defaultPrevented = true; + }, + stopPropagation() { + this.propagationStopped = true; + }, + ...extra, + }; +} + +function createInteractionHarness({ selectedText = "Copy this exact review comment", execCommandResult = true } = {}) { + let selectionText = selectedText; + const listeners = new Map(); + const clipboardWrites = []; + const execCommands = []; + const postedMessages = []; + + const document = { + readyState: "loading", + activeElement: null, + fonts: { ready: Promise.resolve() }, + addEventListener(type, listener) { + const handlers = listeners.get(type) || []; + handlers.push(listener); + listeners.set(type, handlers); + }, + dispatch(type, event) { + for (const listener of listeners.get(type) || []) listener(event); + }, + createElement(tagName) { + return new FakeElement(tagName, document); + }, + getElementById(id) { + return document.documentElement.querySelectorAll("style").find((element) => element.id === id) || null; + }, + querySelectorAll(selector) { + return document.documentElement.querySelectorAll(selector); + }, + getSelection() { + return document.selection; + }, + execCommand(command) { + execCommands.push(command); + return command === "copy" && execCommandResult; + }, + }; + + document.documentElement = new FakeElement("html", document); + document.head = new FakeElement("head", document); + document.body = new FakeElement("body", document); + document.documentElement.appendChild(document.head); + document.documentElement.appendChild(document.body); + + const paragraph = new FakeElement("p", document); + paragraph.id = "review-finding"; + paragraph.textContent = selectedText; + paragraph.innerText = selectedText; + document.body.appendChild(paragraph); + + const range = { + collapsed: false, + commonAncestorContainer: paragraph, + startContainer: paragraph, + startOffset: 0, + endContainer: paragraph, + endOffset: selectedText.length, + cloneRange() { + return this; + }, + intersectsNode(node) { + return node === paragraph || paragraph.contains(node); + }, + getClientRects() { + return [paragraph.getBoundingClientRect()]; + }, + getBoundingClientRect() { + return paragraph.getBoundingClientRect(); + }, + }; + document.selection = { + rangeCount: selectedText ? 1 : 0, + getRangeAt() { + return range; + }, + toString() { + return selectionText; + }, + clear() { + selectionText = ""; + }, + }; + + const windowListeners = new Map(); + const window = { + innerWidth: 1280, + innerHeight: 900, + scrollX: 0, + scrollY: 0, + addEventListener(type, listener) { + const handlers = windowListeners.get(type) || []; + handlers.push(listener); + windowListeners.set(type, handlers); + }, + requestAnimationFrame(callback) { + callback(); + return 1; + }, + setTimeout, + clearTimeout, + scrollTo() {}, + }; + + const globals = { + CSS: globalThis.CSS, + Element: globalThis.Element, + MutationObserver: globalThis.MutationObserver, + ResizeObserver: globalThis.ResizeObserver, + document: globalThis.document, + getComputedStyle: globalThis.getComputedStyle, + navigator: globalThis.navigator, + parent: globalThis.parent, + window: globalThis.window, + }; + + Object.assign(globalThis, { + CSS: { escape: (value) => String(value) }, + Element: FakeElement, + MutationObserver: class { + observe() {} + disconnect() {} + }, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + document, + getComputedStyle: () => ({ + display: "block", + visibility: "visible", + opacity: "1", + overflowX: "visible", + overflowY: "visible", + position: "static", + borderLeftWidth: "0", + borderRightWidth: "0", + borderTopWidth: "0", + borderBottomWidth: "0", + paddingLeft: "0", + paddingRight: "0", + paddingTop: "0", + paddingBottom: "0", + textOverflow: "clip", + webkitLineClamp: "0", + }), + parent: { postMessage: (message) => postedMessages.push(message) }, + window, + }); + Object.defineProperty(globalThis, "navigator", { + configurable: true, + value: { + platform: "MacIntel", + clipboard: { + async writeText(value) { + clipboardWrites.push(value); + }, + }, + }, + }); + + createArtifactSdk(() => ""); + + return { + clipboardWrites, + document, + execCommands, + paragraph, + postedMessages, + restore() { + for (const [name, value] of Object.entries(globals)) { + if (name === "navigator") { + Object.defineProperty(globalThis, name, { configurable: true, value }); + } else if (value === undefined) { + delete globalThis[name]; + } else { + globalThis[name] = value; + } + } + }, + selectionText: () => selectionText, + shadowRoot: () => document.documentElement.querySelector(".lavish-annotation-root")?.shadowRoot || null, + }; +} + +async function withHarness(options, callback) { + const harness = createInteractionHarness(options); + try { + await callback(harness); + } finally { + harness.restore(); + } +} + +test("ordinary left mouseup preserves selected text while annotation is enabled", async () => { + await withHarness({}, async (harness) => { + const event = fakeEvent(harness.paragraph); + harness.document.dispatch("mouseup", event); + await new Promise((resolve) => setTimeout(resolve, 5)); + + assert.equal(event.defaultPrevented, false); + assert.equal(harness.selectionText(), "Copy this exact review comment"); + assert.ok(!harness.shadowRoot()?.querySelector(".lavish-annotation-card")); + }); +}); + +test("ordinary left click and keyboard copy remain native", async () => { + await withHarness({}, async (harness) => { + const click = fakeEvent(harness.paragraph); + const copy = fakeEvent(harness.paragraph, { key: "c", metaKey: true }); + + harness.document.dispatch("click", click); + harness.document.dispatch("keydown", copy); + + assert.equal(click.defaultPrevented, false); + assert.equal(copy.defaultPrevented, false); + assert.ok(!harness.shadowRoot()?.querySelector(".lavish-annotation-card")); + }); +}); + +test("right click on selected text offers Copy before explicit Annotate", async () => { + const selectedText = "Copy this exact review comment\nwithout normalizing it"; + await withHarness({ selectedText, execCommandResult: false }, async (harness) => { + const event = fakeEvent(harness.paragraph); + harness.document.dispatch("contextmenu", event); + + assert.equal(event.defaultPrevented, true); + assert.equal(harness.selectionText(), selectedText); + + const menu = harness.shadowRoot()?.querySelector(".lavish-context-menu"); + assert.ok(menu); + assert.deepEqual( + menu.children.map((child) => child.textContent), + ["Copy selection", "Annotate selection"], + ); + + menu.children[0].click(); + await Promise.resolve(); + assert.deepEqual(harness.execCommands, ["copy"]); + assert.deepEqual(harness.clipboardWrites, [selectedText]); + assert.equal(harness.selectionText(), selectedText); + assert.ok(!harness.shadowRoot()?.querySelector(".lavish-annotation-card")); + }); +}); + +test("right click can explicitly annotate selected text", async () => { + await withHarness({}, async (harness) => { + harness.document.dispatch("contextmenu", fakeEvent(harness.paragraph)); + const menu = harness.shadowRoot()?.querySelector(".lavish-context-menu"); + assert.ok(menu); + + menu.children[1].click(); + await new Promise((resolve) => setTimeout(resolve, 5)); + + assert.ok(harness.shadowRoot()?.querySelector(".lavish-annotation-card")); + }); +}); + +test("right click can explicitly annotate an element without selected text", async () => { + await withHarness({ selectedText: "" }, async (harness) => { + const event = fakeEvent(harness.paragraph); + harness.document.dispatch("contextmenu", event); + + assert.equal(event.defaultPrevented, true); + const menu = harness.shadowRoot()?.querySelector(".lavish-context-menu"); + assert.ok(menu); + assert.deepEqual( + menu.children.map((child) => child.textContent), + ["Annotate element"], + ); + }); +}); + +test("disclosure summaries and data-lavish-action controls keep native behavior", async () => { + await withHarness({}, async (harness) => { + const summary = new FakeElement("summary", harness.document); + const copyControl = new FakeElement("button", harness.document); + copyControl.setAttribute("data-lavish-action", "copy-comment"); + harness.document.body.appendChild(summary); + harness.document.body.appendChild(copyControl); + + for (const target of [summary, copyControl]) { + const click = fakeEvent(target); + const contextMenu = fakeEvent(target); + const keyboard = fakeEvent(target, { key: "Enter" }); + harness.document.dispatch("click", click); + harness.document.dispatch("contextmenu", contextMenu); + harness.document.dispatch("keydown", keyboard); + assert.equal(click.defaultPrevented, false); + assert.equal(contextMenu.defaultPrevented, false); + assert.equal(keyboard.defaultPrevented, false); + } + }); +}); diff --git a/test/server.test.js b/test/server.test.js index 7964ec2a..56aa320b 100644 --- a/test/server.test.js +++ b/test/server.test.js @@ -113,6 +113,12 @@ test("chrome sandbox does not grant modal prompts", () => { assert.doesNotMatch(html, /sandbox="[^"]*allow-modals/); }); +test("chrome delegates clipboard writes to the sandboxed artifact", () => { + const html = createChromeHtml({ key: "abc", file: "/tmp/artifact.html" }); + + assert.match(html, /