Source: runner/cursorOverlay.js

/**
 * @module runner/cursorOverlay
 * @description DIF-014 — Cursor overlay for live browser view.
 *
 * Injects an animated cursor dot, click ripple, and keystroke toast into the
 * page via `page.evaluate()`. The overlay is purely visual — it does not
 * interfere with test execution or DOM assertions.
 *
 * Ported from the Assrt `CURSOR_INJECT_SCRIPT` pattern referenced in the
 * roadmap (DIF-014).
 *
 * The script is re-injected after each navigation (via the `page.on("load")`
 * handler in `executeTest.js`) because `page.evaluate()` scripts do not
 * survive cross-document navigations.
 *
 * @example
 * import { injectCursorOverlay } from "./cursorOverlay.js";
 * await injectCursorOverlay(page);
 */

/**
 * Inject the cursor overlay script into a Playwright page.
 *
 * Creates:
 * - A small red dot that follows the mouse cursor (visible in CDP screencast).
 * - A click ripple animation that expands and fades on each click.
 * - A keystroke toast that briefly shows typed characters near the cursor.
 *
 * All overlay elements use `pointer-events: none` and high `z-index` so they
 * never interfere with the page under test.
 *
 * @param {Object} page - Playwright Page instance.
 * @returns {Promise<void>}
 */
export async function injectCursorOverlay(page) {
  await page.evaluate(() => {
    // Guard: don't inject twice on the same document
    if (document.getElementById("__sentri_cursor")) return;

    // ── Cursor dot ──────────────────────────────────────────────────────
    const dot = document.createElement("div");
    dot.id = "__sentri_cursor";
    Object.assign(dot.style, {
      position: "fixed",
      top: "0",
      left: "0",
      width: "12px",
      height: "12px",
      borderRadius: "50%",
      background: "rgba(239, 68, 68, 0.85)",
      boxShadow: "0 0 4px rgba(239, 68, 68, 0.5)",
      pointerEvents: "none",
      zIndex: "2147483647",
      transform: "translate(-50%, -50%)",
      transition: "top 0.05s linear, left 0.05s linear",
    });
    document.body.appendChild(dot);

    // ── Keystroke toast container ────────────────────────────────────────
    const toast = document.createElement("div");
    toast.id = "__sentri_toast";
    Object.assign(toast.style, {
      position: "fixed",
      top: "0",
      left: "0",
      padding: "2px 6px",
      borderRadius: "4px",
      background: "rgba(0, 0, 0, 0.75)",
      color: "#fff",
      fontSize: "11px",
      fontFamily: "monospace",
      pointerEvents: "none",
      zIndex: "2147483647",
      opacity: "0",
      transition: "opacity 0.15s",
      whiteSpace: "pre",
      maxWidth: "200px",
      overflow: "hidden",
    });
    document.body.appendChild(toast);

    let toastTimer = null;
    let toastText = "";

    // ── CSS keyframes for click ripple ───────────────────────────────────
    const style = document.createElement("style");
    style.textContent = `
      @keyframes __sentri_ripple {
        0%   { transform: translate(-50%, -50%) scale(0); opacity: 0.6; }
        100% { transform: translate(-50%, -50%) scale(1); opacity: 0; }
      }
    `;
    document.head.appendChild(style);

    // ── Mouse move → update dot position ────────────────────────────────
    document.addEventListener("mousemove", (e) => {
      dot.style.top = e.clientY + "px";
      dot.style.left = e.clientX + "px";
    }, true);

    // ── Click → ripple animation ────────────────────────────────────────
    document.addEventListener("click", (e) => {
      const ripple = document.createElement("div");
      Object.assign(ripple.style, {
        position: "fixed",
        top: e.clientY + "px",
        left: e.clientX + "px",
        width: "30px",
        height: "30px",
        borderRadius: "50%",
        border: "2px solid rgba(239, 68, 68, 0.7)",
        pointerEvents: "none",
        zIndex: "2147483646",
        animation: "__sentri_ripple 0.4s ease-out forwards",
      });
      document.body.appendChild(ripple);
      setTimeout(() => ripple.remove(), 450);
    }, true);

    // ── Keypress → toast near cursor ────────────────────────────────────
    document.addEventListener("keydown", (e) => {
      if (e.key.length > 1 && !["Backspace", "Enter", "Tab", "Escape", "Space"].includes(e.key)) return;
      const display = e.key === " " ? "␣" : e.key === "Enter" ? "↵" : e.key === "Tab" ? "⇥" : e.key === "Backspace" ? "⌫" : e.key === "Escape" ? "⎋" : e.key;
      toastText += display;
      if (toastText.length > 20) toastText = toastText.slice(-20);
      toast.textContent = toastText;
      toast.style.opacity = "1";
      toast.style.top = (parseFloat(dot.style.top) + 18) + "px";
      toast.style.left = (parseFloat(dot.style.left) + 12) + "px";

      clearTimeout(toastTimer);
      toastTimer = setTimeout(() => {
        toast.style.opacity = "0";
        toastText = "";
      }, 800);
    }, true);
  }).catch(() => {
    // Page may be navigating or closed — safe to ignore.
  });
}