carbon-tokenization / backend /demo /lib /mouse-cursor.ts
tfrere's picture
tfrere HF Staff
feat(demo): full showcase polish - mouse cursor, robust selection, HF affiliations
f469961
Raw
History Blame Contribute Delete
12.9 kB
/**
* 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 <dialog showModal()>. 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
// <dialog showModal()>, 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 <dialog showModal()> 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<void>;
}): Promise<void> {
await target.addInitScript({ content: OVERLAY_SCRIPT });
}
/**
* Verify the overlay actually mounted on the given page. Returns true
* if the breadcrumb attribute is present on `<html>`. Callers log a
* warning otherwise so a silent failure doesn't eat the whole demo.
*/
export async function verifyMouseCursor(page: Page): Promise<boolean> {
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<void> {
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<boolean> {
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<void> {
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;
}