/** * lib/mouse-cursor.ts * * Draws a visible mouse cursor in Alice's recorded window and provides * human-feel movement helpers. * * Playwright drives real native mouse events but the OS pointer is NOT * captured by the browser's screen recorder - so without this overlay * the viewer sees clicks happen "by themselves". This module injects a * small DOM overlay (SVG arrow + click ripple) that listens to the * `mousemove`, `mousedown` and `mouseup` events Playwright already * generates, so the cursor follows every pointer move naturally. * * On top of that, `humanMove` / `humanClick` / `humanHover` perform the * move itself with intermediate steps and a cubic ease so the cursor * glides to its target instead of teleporting, which is what Playwright's * default `locator.click()` does (single-step move). */ import type { Locator, Page } from "playwright"; const OVERLAY_ID = "__demo-mouse-cursor"; /** * JS source that installs the overlay inside the page. Kept as a plain * string (content-script) so Playwright doesn't have to serialize a * TypeScript function - the tsx transpile pipeline occasionally kept * type assertions in the `.toString()` output which quietly broke * execution. A string is final and cannot drift. */ const OVERLAY_SCRIPT = ` (() => { const OVERLAY_ID = ${JSON.stringify(OVERLAY_ID)}; try { if (window.top !== window) return; } catch (_) { /* cross-origin top; still install */ } const mount = () => { if (document.getElementById(OVERLAY_ID)) return; const host = document.createElement("div"); host.id = OVERLAY_ID; host.setAttribute("aria-hidden", "true"); // Use the HTML popover API so the overlay sits in the top-layer, // above every native . Regular z-index would // lose to the browser's top-layer stacking order. Falls back to // plain fixed positioning if the user agent is too old to // support popover (our Chromium is Chrome 114+, fine). try { host.setAttribute("popover", "manual"); } catch (_) { /* ignore */ } host.style.position = "fixed"; host.style.inset = "0"; host.style.top = "0"; host.style.left = "0"; host.style.width = "100vw"; host.style.height = "100vh"; host.style.pointerEvents = "none"; host.style.zIndex = "2147483646"; host.style.margin = "0"; host.style.padding = "0"; host.style.border = "0"; host.style.background = "transparent"; host.style.overflow = "visible"; const NS = "http://www.w3.org/2000/svg"; const pointer = document.createElementNS(NS, "svg"); pointer.setAttribute("width", "26"); pointer.setAttribute("height", "26"); pointer.setAttribute("viewBox", "0 0 26 26"); pointer.setAttribute("fill", "none"); pointer.style.position = "absolute"; pointer.style.left = "0"; pointer.style.top = "0"; pointer.style.transform = "translate3d(-100px, -100px, 0)"; pointer.style.filter = "drop-shadow(0 2px 4px rgba(0,0,0,0.45))"; pointer.style.willChange = "transform"; const arrow = document.createElementNS(NS, "path"); arrow.setAttribute( "d", "M4 3 L4 20 L9 15.5 L12 22 L14.8 20.8 L11.9 14.3 L19 14.3 Z" ); arrow.setAttribute("fill", "#ffffff"); arrow.setAttribute("stroke", "#0a0a0a"); arrow.setAttribute("stroke-width", "1.4"); arrow.setAttribute("stroke-linejoin", "round"); pointer.appendChild(arrow); const ripple = document.createElement("div"); ripple.style.position = "absolute"; ripple.style.left = "0"; ripple.style.top = "0"; ripple.style.width = "22px"; ripple.style.height = "22px"; ripple.style.borderRadius = "50%"; ripple.style.border = "2px solid rgba(255, 186, 70, 0.95)"; ripple.style.background = "rgba(255, 200, 80, 0.22)"; ripple.style.transform = "translate3d(-100px, -100px, 0) scale(0)"; ripple.style.opacity = "0"; ripple.style.pointerEvents = "none"; const style = document.createElement("style"); style.textContent = "@keyframes __demo-mouse-ripple {" + " 0% { transform: translate3d(var(--rx), var(--ry), 0) scale(0.15); opacity: 0.95; }" + " 80% { opacity: 0.35; }" + " 100% { transform: translate3d(var(--rx), var(--ry), 0) scale(2.6); opacity: 0; }" + "}" + "#" + OVERLAY_ID + " [data-ripple-active] {" + " animation: __demo-mouse-ripple 0.6s cubic-bezier(0.2, 0.6, 0.2, 1) forwards;" + "}"; host.appendChild(style); host.appendChild(pointer); host.appendChild(ripple); document.documentElement.appendChild(host); // Promote into the top-layer so the cursor stays above any // , popovers, or other top-layer elements. try { const anyHost = host; if (typeof anyHost.showPopover === "function") { anyHost.showPopover(); } } catch (_) { /* fall back to plain fixed positioning */ } let x = -100; let y = -100; const updatePointer = () => { pointer.style.transform = "translate3d(" + x + "px, " + y + "px, 0)"; }; updatePointer(); window.addEventListener("mousemove", (ev) => { x = ev.clientX; y = ev.clientY; updatePointer(); }, true); window.addEventListener("mousedown", () => { ripple.removeAttribute("data-ripple-active"); ripple.style.setProperty("--rx", (x - 11) + "px"); ripple.style.setProperty("--ry", (y - 11) + "px"); // Force reflow so the animation restarts on every click. void ripple.offsetWidth; ripple.setAttribute("data-ripple-active", "true"); }, true); // Breadcrumb so callers can verify the overlay mounted via the // DOM (page.evaluate) without needing access to the page console. document.documentElement.setAttribute("data-demo-mouse-cursor", "on"); // Re-promote ourselves in the top-layer whenever another element // joins it (a or a sibling popover). The // browser stacks top-layer elements in LIFO order, so the last // one promoted sits on top. Without this, our cursor would end // up UNDER the author dialog as soon as Alice opens it. const repromote = () => { try { const anyHost = host; if (typeof anyHost.hidePopover === "function") { anyHost.hidePopover(); } if (typeof anyHost.showPopover === "function") { anyHost.showPopover(); } } catch (_) { /* ignore */ } }; const isTopLayerCandidate = (el) => { if (!el || el === host) return false; if (el.nodeName === "DIALOG" && el.hasAttribute("open")) return true; if (el.hasAttribute && el.hasAttribute("popover")) return true; return false; }; const domObserver = new MutationObserver((mutations) => { for (const m of mutations) { if (m.type === "attributes") { if (isTopLayerCandidate(m.target)) { repromote(); return; } } else if (m.type === "childList") { for (const n of Array.from(m.addedNodes)) { if (n.nodeType === 1 && isTopLayerCandidate(n)) { repromote(); return; } } } } }); try { domObserver.observe(document.documentElement, { subtree: true, childList: true, attributes: true, attributeFilter: ["open", "popover"], }); } catch (_) { /* ignore */ } }; if (document.readyState === "loading") { document.addEventListener("DOMContentLoaded", mount, { once: true }); } else { mount(); } // Re-mount on history/SPA navigations that don't reload the document // (e.g. the published article link kept inside the same SPA tree). // We don't have such cases today, but the check is cheap. const observer = new MutationObserver(() => { if (!document.getElementById(OVERLAY_ID) && document.documentElement) { mount(); } }); try { observer.observe(document.documentElement, { childList: true }); } catch (_) { /* ignore */ } })(); `; /** * Register the cursor overlay as an init script on the given context * (or page). Must run BEFORE the first navigation so the overlay is * live on every document the page loads (including the published * article the demo opens in the same tab). */ export async function injectMouseCursor(target: { addInitScript: ( script: { content: string } | string | ((arg: unknown) => unknown), arg?: unknown, ) => Promise; }): Promise { await target.addInitScript({ content: OVERLAY_SCRIPT }); } /** * Verify the overlay actually mounted on the given page. Returns true * if the breadcrumb attribute is present on ``. Callers log a * warning otherwise so a silent failure doesn't eat the whole demo. */ export async function verifyMouseCursor(page: Page): Promise { try { return await page.evaluate( () => document.documentElement.getAttribute("data-demo-mouse-cursor") === "on", ); } catch { return false; } } // --------------------------------------------------------------------------- // Movement helpers // --------------------------------------------------------------------------- interface MoveOpts { /** Total move duration in ms. Defaults depend on distance. */ durationMs?: number; /** Steps used by `page.mouse.move(x, y, { steps })`. */ steps?: number; /** Random offset (px) added to the target to feel less robotic. */ jitter?: number; } let lastX = 40; let lastY = 40; function easeInOutCubic(t: number): number { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } /** * Move the mouse from its last known position to `(x, y)` with a cubic * ease so the overlay cursor glides instead of teleporting. We track * the last position ourselves because Playwright doesn't expose the * current mouse coordinates. */ export async function humanMove( page: Page, x: number, y: number, opts: MoveOpts = {}, ): Promise { const jitter = opts.jitter ?? 2; const tx = x + (Math.random() * 2 - 1) * jitter; const ty = y + (Math.random() * 2 - 1) * jitter; const dx = tx - lastX; const dy = ty - lastY; const dist = Math.hypot(dx, dy); const steps = opts.steps ?? Math.max(10, Math.min(40, Math.round(dist / 18))); const duration = opts.durationMs ?? Math.max(260, Math.min(700, dist * 0.9)); const stepDelay = duration / steps; // Quadratic Bezier control point offset perpendicular to the // straight line, so the path arcs slightly. Side alternates per // move so consecutive trips don't bend the same way. const perp = { x: -dy, y: dx }; const perpLen = Math.hypot(perp.x, perp.y) || 1; const curveSide = Math.random() < 0.5 ? 1 : -1; const curveAmp = Math.min(60, dist * 0.12) * curveSide; const ctrl = { x: lastX + dx * 0.5 + (perp.x / perpLen) * curveAmp, y: lastY + dy * 0.5 + (perp.y / perpLen) * curveAmp, }; for (let i = 1; i <= steps; i++) { const t = easeInOutCubic(i / steps); const u = 1 - t; const px = u * u * lastX + 2 * u * t * ctrl.x + t * t * tx; const py = u * u * lastY + 2 * u * t * ctrl.y + t * t * ty; await page.mouse.move(px, py); if (stepDelay > 0) { await new Promise((r) => setTimeout(r, stepDelay)); } } lastX = tx; lastY = ty; } /** * Move the mouse to the center of `locator` with `humanMove`. Uses * `boundingBox()` so the helper works for any element, including * overlays like the chat panel or the settings drawer. */ export async function humanHover( page: Page, locator: Locator, opts: MoveOpts = {}, ): Promise { const handle = await locator.first().elementHandle(); if (!handle) return false; const box = await handle.boundingBox(); if (!box) return false; const cx = box.x + box.width / 2; const cy = box.y + box.height / 2; await humanMove(page, cx, cy, opts); return true; } /** * Smoothly move the mouse to `locator`, pause briefly, then click. * Falls back to `locator.click()` if the element has no bounding box * (detached, display:none, etc.) so callers don't have to branch. */ export async function humanClick( page: Page, locator: Locator, opts: MoveOpts & { settleMs?: [number, number] } = {}, ): Promise { const moved = await humanHover(page, locator, opts); if (!moved) { await locator.click(); return; } const [lo, hi] = opts.settleMs ?? [60, 140]; const pause = lo + Math.random() * (hi - lo); await new Promise((r) => setTimeout(r, pause)); await page.mouse.down(); await new Promise((r) => setTimeout(r, 30 + Math.random() * 30)); await page.mouse.up(); } /** * Reset the cached mouse position. Useful at the start of a new run. */ export function resetMouseState(x = 40, y = 40): void { lastX = x; lastY = y; }