import type { Page } from "@playwright/test" /** * Injects a visible CSS cursor overlay that follows mouse movements. * Features a glowing blue dot with an expanding ripple ring on click. * * Must be called BEFORE the first page.goto() (uses addInitScript). */ export async function injectVisibleCursor(page: Page, options?: { color?: string; size?: number }): Promise { const color = options?.color ?? "59, 130, 246" // blue-500 const size = options?.size ?? 24 await page.addInitScript( ({ color, size }) => { document.addEventListener("DOMContentLoaded", () => { // Avoid duplicates on soft-navigations if (document.getElementById("pw-cursor")) return // ── Cursor dot ── const cursor = document.createElement("div") cursor.id = "pw-cursor" Object.assign(cursor.style, { width: `${size}px`, height: `${size}px`, borderRadius: "50%", background: `radial-gradient(circle, rgba(${color}, 0.9) 0%, rgba(${color}, 0.4) 60%, transparent 100%)`, boxShadow: `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)`, position: "fixed", zIndex: "999999", pointerEvents: "none", transition: "transform 0.12s cubic-bezier(0.25, 0.46, 0.45, 0.94), box-shadow 0.12s ease", transform: "translate(-50%, -50%)", top: "-100px", left: "-100px", }) document.body.appendChild(cursor) // ── Click ripple animation ── const style = document.createElement("style") style.textContent = ` @keyframes pw-ripple { 0% { transform: translate(-50%, -50%) scale(0.3); opacity: 0.8; } 100% { transform: translate(-50%, -50%) scale(2.5); opacity: 0; } } .pw-ripple { position: fixed; width: ${size * 2.5}px; height: ${size * 2.5}px; border-radius: 50%; border: 2.5px solid rgba(${color}, 0.7); pointer-events: none; z-index: 999998; animation: pw-ripple 0.5s cubic-bezier(0, 0, 0.2, 1) forwards; } ` document.head.appendChild(style) let cx = -100, cy = -100 document.addEventListener("mousemove", (e) => { cx = e.clientX cy = e.clientY cursor.style.left = cx + "px" cursor.style.top = cy + "px" }) document.addEventListener("mousedown", () => { // Shrink cursor + intensify glow cursor.style.transform = "translate(-50%, -50%) scale(0.5)" cursor.style.boxShadow = `0 0 ${size}px rgba(${color}, 0.8), 0 0 ${size * 1.5}px rgba(${color}, 0.4)` // Spawn expanding ripple ring const ripple = document.createElement("div") ripple.className = "pw-ripple" ripple.style.left = cx + "px" ripple.style.top = cy + "px" document.body.appendChild(ripple) ripple.addEventListener("animationend", () => ripple.remove()) }) document.addEventListener("mouseup", () => { cursor.style.transform = "translate(-50%, -50%) scale(1)" cursor.style.boxShadow = `0 0 ${size / 2}px rgba(${color}, 0.5), 0 0 ${size}px rgba(${color}, 0.2)` }) }) }, { color, size } ) } /** * Moves the visible cursor smoothly to a target element before clicking. * This ensures mousemove events fire so the injected cursor follows along. */ export async function moveThenClick(page: Page, locator: ReturnType): Promise { const box = await locator.boundingBox() if (box) { const x = box.x + box.width / 2 const y = box.y + box.height / 2 // Smooth move in steps await page.mouse.move(x, y, { steps: 15 }) await page.waitForTimeout(150) } await locator.click() } /** * Moves the visible cursor to a target element and types text character by character. * Simulates realistic typing for demo purposes. */ export async function moveThenType( page: Page, locator: ReturnType, text: string, options?: { delay?: number } ): Promise { const box = await locator.boundingBox() if (box) { const x = box.x + box.width / 2 const y = box.y + box.height / 2 await page.mouse.move(x, y, { steps: 15 }) await page.waitForTimeout(150) } await locator.click() await page.waitForTimeout(200) await page.keyboard.type(text, { delay: options?.delay ?? 80 }) } /** * Scrolls the page smoothly to a given position. */ export async function smoothScroll(page: Page, y: number, duration = 600): Promise { await page.evaluate( ({ y, duration }) => { window.scrollTo({ top: y, behavior: "smooth" }) return new Promise((r) => setTimeout(r, duration)) }, { y, duration } ) }