@@ -31,6 +31,7 @@ import {
3131import { captureInteractionFrames , captureScrollFrames , captureShot , DESKTOP_VIEWPORT , MOBILE_VIEWPORT , type InteractionAction , type ShotTheme , type Viewport } from "./shot" ;
3232import { compareCapturedScreenshots , isVisualDiffAvailable , type VisualDiffOutcome } from "./pixel-diff" ;
3333import { encodeScrollGif , isScrollGifAvailable } from "./scroll-gif" ;
34+ import { detectAutoHoverInteractions , type ChangedCssFile } from "./interaction-detection" ;
3435
3536const NAMESPACE = "loopover" ;
3637const DEFAULT_ROUTES = [ "/" ] ;
@@ -608,6 +609,12 @@ export type VisualCaptureConfig = {
608609 * ⇒ byte-identical to today, no interaction capture. Capped at MAX_INTERACTIONS regardless of how many
609610 * are configured. */
610611 interactions ?: readonly VisualInteractionInput [ ] | null | undefined ;
612+ /** `review.visual.autoDetectInteractions` (#auto-interaction-detection): capture a hover-interaction GIF
613+ * for any CSS selector this PR's OWN diff newly adds a `:hover`/`:focus-visible` rule for — no maintainer
614+ * selector-authoring needed, unlike `interactions` above (the two compose, deduped against each other).
615+ * false/absent (default) ⇒ byte-identical to today. Requires `changedCssFiles` (below) to be passed too;
616+ * without it there is nothing to detect against regardless of this flag. */
617+ autoDetectInteractions ?: boolean | null | undefined ;
611618} ;
612619
613620/**
@@ -616,7 +623,19 @@ export type VisualCaptureConfig = {
616623 * collapsible). Fully fail-safe — a missing preview / failed render degrades to placeholders or dashes; this
617624 * NEVER throws (the caller also wraps it in try/catch so a capture failure can't sink a review).
618625 */
619- export async function buildCapture ( env : Env , token : string , target : CaptureTarget , visualFiles : string [ ] , rateLimitAdmissionKey ?: GitHubRateLimitAdmissionKey | undefined , visualConfig ?: VisualCaptureConfig | null | undefined ) : Promise < CaptureResult > {
626+ export async function buildCapture (
627+ env : Env ,
628+ token : string ,
629+ target : CaptureTarget ,
630+ visualFiles : string [ ] ,
631+ rateLimitAdmissionKey ?: GitHubRateLimitAdmissionKey | undefined ,
632+ visualConfig ?: VisualCaptureConfig | null | undefined ,
633+ // #auto-interaction-detection: the SAME changed-file set visualFiles is derived from, but carrying each
634+ // file's diff patch text too (visualFiles alone is bare paths) -- only ever read when
635+ // visualConfig.autoDetectInteractions is true. Absent/undefined (every pre-existing call site) ⇒
636+ // byte-identical to today, no auto-detection attempted regardless of the config flag.
637+ changedCssFiles ?: readonly ChangedCssFile [ ] | undefined ,
638+ ) : Promise < CaptureResult > {
620639 const repo = parseRepo ( target . repoFullName ) ;
621640 const apiVersion = "2022-11-28" ;
622641 // before = production. review.visual.production_url (#3611 follow-up) ALWAYS wins when set -- PUBLIC_SITE_ORIGIN
@@ -791,7 +810,29 @@ export async function buildCapture(env: Env, token: string, target: CaptureTarge
791810 // interaction target" shape. Gated on isScrollGifAvailable() (reused: the encode step is frame-source-
792811 // agnostic, see scroll-gif.ts) since there is no point capturing frames this build can never assemble into
793812 // a GIF -- self-host only, same as the scroll-GIF path above.
794- const interactionsConfigured = ( visualConfig ?. interactions ?? [ ] ) . slice ( 0 , MAX_INTERACTIONS ) ;
813+ const manualInteractions = visualConfig ?. interactions ?? [ ] ;
814+ // #auto-interaction-detection: a maintainer-configured selector always wins on overlap -- an explicit
815+ // entry may carry a label/path/action the detector could never infer, so a hand-authored duplicate is
816+ // dropped from the auto-detected set rather than the other way around. Both selector sets are compared
817+ // case-insensitively, matching detectAutoHoverInteractions' own dedup.
818+ const manualSelectors = new Set ( manualInteractions . map ( ( interaction ) => interaction . selector . toLowerCase ( ) ) ) ;
819+ const autoDetectedInteractions : VisualInteractionInput [ ] =
820+ visualConfig ?. autoDetectInteractions && changedCssFiles
821+ ? detectAutoHoverInteractions ( changedCssFiles )
822+ . filter ( ( selector ) => ! manualSelectors . has ( selector . toLowerCase ( ) ) )
823+ . map ( ( selector ) => ( {
824+ selector,
825+ action : "hover" as const ,
826+ // captureRoutes[0] is unreachable-undefined by construction here, not a reachable false case:
827+ // `themes` above is always at least `[undefined]` and `routes` (resolveVisualRoutes ->
828+ // mapFilesToRoutes) always falls back to DEFAULT_ROUTES when nothing else resolves, so the
829+ // routes x themes double loop above always pushes at least one entry -- noUncheckedIndexedAccess
830+ // still requires the optional chaining at the type level.
831+ /* v8 ignore next */
832+ path : captureRoutes [ 0 ] ?. path ?? null ,
833+ } ) )
834+ : [ ] ;
835+ const interactionsConfigured = [ ...manualInteractions , ...autoDetectedInteractions ] . slice ( 0 , MAX_INTERACTIONS ) ;
795836 const interactionRoutes : CaptureInteractionRoute [ ] = [ ] ;
796837 // Interactions aren't multiplied per-theme (see comment above) -- when review.visual.themes configures more
797838 // than one, the first configured theme is what interaction GIFs render in; themes[0] is `undefined` by
0 commit comments