'use client'; import { useEffect } from 'react'; import { createPortal } from 'react-dom'; import { log } from '../logger'; import { SpotlightCanvas } from './SpotlightCanvas'; import type { PointDirective, SpotlightRect } from './types'; import { useHighlightTargets } from './useHighlightTargets'; import { type RefResolver } from './resolveRef'; /** Padding (px) drawn around each highlighted element. */ const HIGHLIGHT_PADDING = 6; /** Corner radius (px) of the spotlight cut-out. */ const HIGHLIGHT_RADIUS = 6; /** Props for `HighlightOverlay`. */ export interface HighlightOverlayProps { /** `point` directives from the AI's reply. Empty hides the overlay. */ directives: PointDirective[]; /** Resolves a CST ref to a live element (the snapshot's registry). */ resolver: RefResolver | null; /** Auto-dismiss after N ms; 0 keeps it until directives clear. */ ttlMs?: number; /** Called when the overlay dismisses (scrim click, Esc, or TTL). */ onDismiss?: () => void; } /** * Draws an AI-driven spotlight over elements the assistant pointed at. * * Read-only: it highlights and optionally focuses elements — it never * changes the user's data. The page stays interactive underneath; the * scrim is purely visual and dismissable. */ export function HighlightOverlay({ directives, resolver, ttlMs = 6000, onDismiss, }: HighlightOverlayProps) { const targets = useHighlightTargets(directives, resolver); // Trace why the overlay does or does not show — the common failure is // directives arriving with no resolver, or refs that resolve to nothing. useEffect(() => { log.info('HighlightOverlay render', { directives: directives.length, hasResolver: resolver !== null, resolvedTargets: targets.length, }); }, [directives, resolver, targets.length]); // Auto-dismiss after the TTL once something is showing. useEffect(() => { if (targets.length === 0 || ttlMs <= 0) return; const timer = window.setTimeout(() => onDismiss?.(), ttlMs); return () => window.clearTimeout(timer); }, [targets.length, ttlMs, onDismiss]); // Dismiss on Escape. useEffect(() => { if (targets.length === 0) return; const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onDismiss?.(); }; window.addEventListener('keydown', onKey); return () => window.removeEventListener('keydown', onKey); }, [targets.length, onDismiss]); if (targets.length === 0 || typeof document === 'undefined') return null; const rects: SpotlightRect[] = targets.map((t) => ({ rect: t.rect, padding: HIGHLIGHT_PADDING, radius: HIGHLIGHT_RADIUS, })); return createPortal(
{/* The scrim is click-dismissable; it does not block the page. */}
onDismiss?.()} />
{/* Captions beside each highlighted element. */} {targets.map((t, i) => t.label ? (
{t.label}
) : null, )}
, document.body, ); }