diff --git a/apps/extension/src/content/ControlOverlay.tsx b/apps/extension/src/content/ControlOverlay.tsx new file mode 100644 index 0000000..02544b4 --- /dev/null +++ b/apps/extension/src/content/ControlOverlay.tsx @@ -0,0 +1,183 @@ +import { useTranslation } from "@browser-skill/i18n/react"; +import { RiStopCircleLine } from "@remixicon/react"; +import { useEffect, useRef, useState } from "react"; +import logoUrl from "../../assets/logo.png"; + +export interface ControlOverlayProps { + visible: boolean; + interrupting: boolean; + automationBypass: boolean; + onInterrupt: () => void; +} + +export function ControlOverlay({ + visible, + interrupting, + automationBypass, + onInterrupt, +}: ControlOverlayProps) { + const { t } = useTranslation("extension"); + const [show, setShow] = useState(false); + const blockerRef = useRef(null); + + useEffect(() => { + if (visible) { + const raf = requestAnimationFrame(() => setShow(true)); + return () => cancelAnimationFrame(raf); + } + setShow(false); + }, [visible]); + + useEffect(() => { + const blocker = blockerRef.current; + if (!blocker) return; + const stopScroll = (event: WheelEvent | TouchEvent) => { + if (automationBypass) return; + event.preventDefault(); + event.stopPropagation(); + }; + blocker.addEventListener("wheel", stopScroll, { passive: false }); + blocker.addEventListener("touchmove", stopScroll, { passive: false }); + return () => { + blocker.removeEventListener("wheel", stopScroll); + blocker.removeEventListener("touchmove", stopScroll); + }; + }, [automationBypass]); + + useEffect(() => { + if (!visible || automationBypass) return; + const stopScroll = (event: WheelEvent | TouchEvent) => { + event.preventDefault(); + event.stopPropagation(); + }; + window.addEventListener("wheel", stopScroll, { capture: true, passive: false }); + window.addEventListener("touchmove", stopScroll, { capture: true, passive: false }); + return () => { + window.removeEventListener("wheel", stopScroll, { capture: true }); + window.removeEventListener("touchmove", stopScroll, { capture: true }); + }; + }, [visible, automationBypass]); + + if (!visible) return null; + + const pointerEvents = automationBypass ? "none" : "auto"; + + return ( + <> + + +
+ +
{ + if (automationBypass) return; + event.preventDefault(); + event.stopPropagation(); + }} + onClick={(event) => { + if (automationBypass) return; + event.preventDefault(); + event.stopPropagation(); + }} + style={{ + position: "fixed", + inset: 0, + zIndex: 2147483646, + pointerEvents, + background: "transparent", + opacity: show ? 1 : 0, + transition: "opacity 300ms ease-out", + }} + /> + +
+ browser-skill + + {t("controlOverlay.status")} + + +
+ + ); +} diff --git a/apps/extension/src/content/HelpRequestOverlay.tsx b/apps/extension/src/content/HelpRequestOverlay.tsx index e3f867d..087df65 100644 --- a/apps/extension/src/content/HelpRequestOverlay.tsx +++ b/apps/extension/src/content/HelpRequestOverlay.tsx @@ -48,6 +48,7 @@ const VIEWPORT_MARGIN = 16; const PANEL_WIDTH = 420; const FALLBACK_PANEL_H = 180; const FALLBACK_PANEL_H_COLLAPSED = 44; +const EMPTY_SELECTORS: string[] = []; function clamp(value: number, min: number, max: number): number { return Math.max(min, Math.min(max, value)); @@ -177,7 +178,7 @@ function measure(selectors: string[]): Box[] { export function HelpRequestOverlay({ request }: Props) { const { t } = useTranslation("extension"); const isCompact = request?.displayMode === "compact"; - const effectiveSelectors = isCompact ? [] : (request?.selectors ?? []); + const effectiveSelectors = isCompact ? EMPTY_SELECTORS : (request?.selectors ?? EMPTY_SELECTORS); const [note, setNote] = useState(""); const [collapsed, setCollapsed] = useState(false); const [boxes, setBoxes] = useState([]); @@ -226,7 +227,7 @@ export function HelpRequestOverlay({ request }: Props) { // Keep highlight boxes aligned with the page as it scrolls / resizes. useEffect(() => { if (!request) { - setBoxes([]); + setBoxes((prev) => (prev.length === 0 ? prev : [])); return; } const update = () => { diff --git a/apps/extension/src/content/__tests__/ControlOverlay.test.tsx b/apps/extension/src/content/__tests__/ControlOverlay.test.tsx new file mode 100644 index 0000000..0a3e4f7 --- /dev/null +++ b/apps/extension/src/content/__tests__/ControlOverlay.test.tsx @@ -0,0 +1,64 @@ +import { cleanup, render } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ControlOverlay } from "../ControlOverlay"; + +describe("ControlOverlay", () => { + afterEach(() => { + cleanup(); + }); + + it("keeps page blocker none under automationBypass but Interrupt stays clickable", () => { + const { container } = render( + {}} + />, + ); + + const blocker = container.querySelector("[data-slot='control-overlay-blocker']"); + expect(blocker).toBeTruthy(); + expect((blocker as HTMLElement).style.pointerEvents).toBe("none"); + + const pill = container.querySelector("[data-slot='control-overlay-pill']"); + expect(pill).toBeTruthy(); + expect((pill as HTMLElement).style.pointerEvents).toBe("auto"); + + const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']"); + expect(stopBtn).toBeTruthy(); + expect((stopBtn as HTMLElement).style.pointerEvents).toBe("auto"); + }); + + it("uses pointer-events auto on blocker when automationBypass is false", () => { + const { container } = render( + {}} + />, + ); + + const blocker = container.querySelector("[data-slot='control-overlay-blocker']"); + expect(blocker).toBeTruthy(); + expect((blocker as HTMLElement).style.pointerEvents).toBe("auto"); + }); + + it("calls onInterrupt from the stop button", () => { + const onInterrupt = vi.fn(); + const { container } = render( + , + ); + + const stopBtn = container.querySelector("[data-slot='control-overlay-stop-all']"); + (stopBtn as HTMLButtonElement).click(); + + expect(onInterrupt).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/extension/src/content/__tests__/HelpRequestOverlay.test.tsx b/apps/extension/src/content/__tests__/HelpRequestOverlay.test.tsx index 98eba65..284d2e2 100644 --- a/apps/extension/src/content/__tests__/HelpRequestOverlay.test.tsx +++ b/apps/extension/src/content/__tests__/HelpRequestOverlay.test.tsx @@ -5,7 +5,7 @@ import { createElement } from "react"; import { afterEach, describe, expect, it, vi } from "vitest"; import { type HelpRequestData, HelpRequestOverlay } from "../HelpRequestOverlay"; -function renderOverlay(req: HelpRequestData) { +function renderOverlay(req: HelpRequestData | null) { return render( createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: req })), ); @@ -38,6 +38,16 @@ describe("HelpRequestOverlay", () => { expect(screen.getByText("Please complete the captcha")).toBeTruthy(); }); + it("keeps the inactive render stable", () => { + const { container, rerender } = renderOverlay(null); + + rerender( + createElement(I18nextProvider, { i18n }, createElement(HelpRequestOverlay, { request: null })), + ); + + expect(container.querySelector("[data-slot='help-request-banner']")).toBeNull(); + }); + it("renders a compact status without full request controls", () => { const el = document.createElement("div"); el.id = "login"; diff --git a/apps/extension/src/content/overlay.css b/apps/extension/src/content/overlay.css index 8ce0a81..328d3c5 100644 --- a/apps/extension/src/content/overlay.css +++ b/apps/extension/src/content/overlay.css @@ -1,2 +1,16 @@ @import "tailwindcss"; @source "../content"; + +:host([data-bsk-overlay-surface]) { + position: fixed !important; + inset: 0 !important; + display: block !important; + width: 100vw !important; + height: 100vh !important; + z-index: 2147483647 !important; + pointer-events: none !important; +} + +:host([data-bsk-overlay-surface][data-bsk-overlay-blocking]) { + pointer-events: auto !important; +} diff --git a/apps/extension/src/entrypoints/content.ts b/apps/extension/src/entrypoints/content.ts index ed10b0b..cd576a6 100644 --- a/apps/extension/src/entrypoints/content.ts +++ b/apps/extension/src/entrypoints/content.ts @@ -1,8 +1,10 @@ import { i18n } from "@browser-skill/i18n"; import { I18nextProvider } from "@browser-skill/i18n/react"; import React from "react"; +import { flushSync } from "react-dom"; import ReactDOM from "react-dom/client"; import { BorrowConfirmationOverlay } from "@/content/BorrowConfirmationOverlay"; +import { ControlOverlay } from "@/content/ControlOverlay"; import { HelpRequestOverlay } from "@/content/HelpRequestOverlay"; import overlayCss from "@/content/overlay.css?inline"; import { OverlayController, shouldShowAgentControlOverlay } from "@/content/overlay-controller"; @@ -45,7 +47,6 @@ import type { BorrowRequestMessage, BorrowResponseMessage, } from "@/tools/borrow-confirmation"; -import logoUrl from "../../assets/logo.png"; // Run at document_end so the overlay does not block first paint. Only attach // in the top-level frame so iframes do not double-render overlays. @@ -64,7 +65,6 @@ export default defineContentScript({ let reactRoot: ReactDOM.Root | null = null; let overlayHost: HTMLElement | null = null; let overlayContainer: HTMLElement | null = null; - let controlRoot: HTMLElement | null = null; let activeAgentState: OverlayAgentStateMessage | null = null; let hostLossReported = false; let remountInProgress = false; @@ -91,29 +91,23 @@ export default defineContentScript({ onRemove(root) { overlayHost = null; overlayContainer = null; - controlRoot = null; root?.unmount(); reactRoot = null; }, }); - function removeControlOverlay(): void { - controlRoot?.remove(); - controlRoot = null; - } - - function setControlSurfaceActive(active: boolean): void { + function setOverlaySurfaceState(active: boolean, blocking: boolean): void { const host = overlayHost; const container = overlayContainer; if (!host || !container) return; if (active) { - Object.assign(host.style, { - position: "fixed", - inset: "0", - zIndex: "2147483647", - pointerEvents: "auto", - }); + host.setAttribute("data-bsk-overlay-surface", ""); + if (blocking) { + host.setAttribute("data-bsk-overlay-blocking", ""); + } else { + host.removeAttribute("data-bsk-overlay-blocking"); + } Object.assign(container.style, { position: "fixed", inset: "0", @@ -122,226 +116,73 @@ export default defineContentScript({ return; } - host.style.removeProperty("position"); - host.style.removeProperty("inset"); - host.style.removeProperty("z-index"); - host.style.removeProperty("pointer-events"); + host.removeAttribute("data-bsk-overlay-surface"); + host.removeAttribute("data-bsk-overlay-blocking"); container.style.removeProperty("position"); container.style.removeProperty("inset"); container.style.removeProperty("pointer-events"); } - function ensureControlOverlayRoot(): HTMLElement | null { - if (!overlayContainer) return null; - if (!controlRoot) { - controlRoot = document.createElement("div"); - controlRoot.className = "bsk-control-overlay"; - controlRoot.style.opacity = "0"; - controlRoot.style.transition = "opacity 300ms ease-out"; - overlayContainer.append(controlRoot); - window.requestAnimationFrame(() => { - if (controlRoot) controlRoot.style.opacity = "1"; - }); - } - return controlRoot; - } + function setOverlayHostHiddenFromAccessibility(hidden: boolean): void { + const host = overlayHost; + if (!host) return; - function createStopCircleIcon(): SVGSVGElement { - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute("width", "18"); - svg.setAttribute("height", "18"); - svg.setAttribute("viewBox", "0 0 24 24"); - svg.setAttribute("fill", "currentColor"); - svg.setAttribute("aria-hidden", "true"); - svg.style.color = "#fff"; - svg.style.flexShrink = "0"; - const path = document.createElementNS("http://www.w3.org/2000/svg", "path"); - path.setAttribute( - "d", - "M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10Zm0-2a8 8 0 1 0 0-16 8 8 0 0 0 0 16ZM9 9h6v6H9V9Z", - ); - svg.append(path); - return svg; - } - - function renderControlOverlay(): void { - const overlayState = overlays.snapshot(); - if (!shouldShowAgentControlOverlay(overlayState)) { - removeControlOverlay(); - setControlSurfaceActive(false); + if (!hidden) { + host.removeAttribute("aria-hidden"); return; } - const root = ensureControlOverlayRoot(); - if (!root) return; - - const pointerEvents = overlayState.automationBypassCount > 0 ? "none" : "auto"; - setControlSurfaceActive(true); - root.innerHTML = ""; - Object.assign(root.style, { - position: "fixed", - inset: "0", - pointerEvents: "none", - }); - - const style = document.createElement("style"); - style.textContent = ` - @keyframes bsk-breathe { - 0%, 100% { box-shadow: inset 0 0 20px 4px rgba(249,115,22,0.25); } - 50% { box-shadow: inset 0 0 40px 8px rgba(249,115,22,0.5); } - } - `; - - const border = document.createElement("div"); - border.setAttribute("data-slot", "control-overlay"); - Object.assign(border.style, { - position: "fixed", - inset: "0", - zIndex: "2147483646", - pointerEvents: "none", - animation: "bsk-breathe 3s ease-in-out infinite", - }); - - const blocker = document.createElement("div"); - blocker.setAttribute("data-slot", "control-overlay-blocker"); - Object.assign(blocker.style, { - position: "fixed", - inset: "0", - zIndex: "2147483646", - pointerEvents, - background: "transparent", - }); - blocker.addEventListener("pointerdown", (event) => { - if (overlayState.automationBypassCount > 0) return; - event.preventDefault(); - event.stopPropagation(); - }); - blocker.addEventListener("click", (event) => { - if (overlayState.automationBypassCount > 0) return; - event.preventDefault(); - event.stopPropagation(); - }); - blocker.addEventListener( - "wheel", - (event) => { - if (overlayState.automationBypassCount > 0) return; - event.preventDefault(); - event.stopPropagation(); - }, - { passive: false }, - ); - blocker.addEventListener( - "touchmove", - (event) => { - if (overlayState.automationBypassCount > 0) return; - event.preventDefault(); - event.stopPropagation(); - }, - { passive: false }, - ); - - const pill = document.createElement("div"); - pill.setAttribute("data-slot", "control-overlay-pill"); - Object.assign(pill.style, { - position: "fixed", - bottom: "32px", - left: "50%", - transform: "translateX(-50%)", - zIndex: "2147483647", - pointerEvents: "auto", - display: "flex", - alignItems: "center", - gap: "12px", - borderRadius: "999px", - backgroundColor: "#fff", - padding: "10px 10px 10px 20px", - boxShadow: "0 8px 32px rgba(124,45,18,0.16), 0 2px 8px rgba(0,0,0,0.1)", - fontFamily: - '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif', - }); - - const logo = document.createElement("img"); - logo.src = logoUrl; - logo.alt = "browser-skill"; - Object.assign(logo.style, { - width: "24px", - height: "24px", - borderRadius: "4px", - flexShrink: "0", - }); - - const label = document.createElement("span"); - label.textContent = i18n.t("controlOverlay.status", { ns: "extension" }); - Object.assign(label.style, { - fontSize: "16px", - fontWeight: "500", - color: "#333", - whiteSpace: "nowrap", - userSelect: "none", - }); - - const button = document.createElement("button"); - button.type = "button"; - button.setAttribute("data-slot", "control-overlay-stop-all"); - button.disabled = overlayState.interrupting; - Object.assign(button.style, { - pointerEvents: "auto", - display: "flex", - alignItems: "center", - gap: "6px", - border: "none", - borderRadius: "999px", - padding: "8px 20px 8px 16px", - fontSize: "15px", - fontWeight: "600", - color: "#fff", - backgroundColor: overlayState.interrupting ? "#9ca3af" : "#f97316", - cursor: overlayState.interrupting ? "default" : "pointer", - opacity: overlayState.interrupting ? "0.7" : "1", - transition: "background-color 150ms ease-out, opacity 150ms ease-out", - whiteSpace: "nowrap", - lineHeight: "1", - }); - button.append( - createStopCircleIcon(), - document.createTextNode( - overlayState.interrupting - ? i18n.t("controlOverlay.interrupting", { ns: "extension" }) - : i18n.t("controlOverlay.interrupt", { ns: "extension" }), - ), - ); - button.addEventListener("click", (event) => { - event.preventDefault(); - event.stopPropagation(); - handleInterrupt(); - }); - - pill.append(logo, label, button); - root.append(style, border, blocker, pill); + const focusedInShadow = host.shadowRoot?.activeElement; + if (focusedInShadow instanceof HTMLElement) { + focusedInShadow.blur(); + } + if (document.activeElement === host) { + host.blur(); + } + host.setAttribute("aria-hidden", "true"); } function renderReactOverlays(): void { const overlayState = overlays.snapshot(); - reactRoot?.render( - React.createElement( - I18nextProvider, - { i18n }, - React.createElement( - React.Fragment, - null, - React.createElement(BorrowConfirmationOverlay, { - requests: overlayState.borrowRequests, - }), - React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }), - React.createElement(RecordOverlay, { request: overlayState.activeRecord }), - ), - ), + const controlOverlayVisible = shouldShowAgentControlOverlay(overlayState); + const interactiveOverlayVisible = + overlayState.borrowRequests.length > 0 || + overlayState.activeHelp !== null || + overlayState.activeRecord !== null; + setOverlayHostHiddenFromAccessibility(!interactiveOverlayVisible); + setOverlaySurfaceState( + controlOverlayVisible, + controlOverlayVisible && overlayState.automationBypassCount === 0, ); + const root = reactRoot; + if (!root) return; + flushSync(() => { + root.render( + React.createElement( + I18nextProvider, + { i18n }, + React.createElement( + React.Fragment, + null, + React.createElement(BorrowConfirmationOverlay, { + requests: overlayState.borrowRequests, + }), + React.createElement(HelpRequestOverlay, { request: overlayState.activeHelp }), + React.createElement(RecordOverlay, { request: overlayState.activeRecord }), + React.createElement(ControlOverlay, { + visible: controlOverlayVisible, + interrupting: overlayState.interrupting, + automationBypass: overlayState.automationBypassCount > 0, + onInterrupt: handleInterrupt, + }), + ), + ), + ); + }); } function renderAll(): void { renderReactOverlays(); - renderControlOverlay(); } async function waitForRenderedOverlayUpdate(): Promise {