diff --git a/src/camera.ts b/src/camera.ts index a4816f2..0985e41 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -16,6 +16,18 @@ interface CameraOptions { export interface SpotlightOptions extends CameraOptions { opacity?: number; padding?: number; + /** + * Corner radius of the cutout, in px. Default 10. Pass 0 for square corners, + * which on a pill button read as a crop rather than as a highlight. + */ + radius?: number; + /** + * Blur radius applied to the cutout's edge, in px. Default 12; 0 gives a hard + * edge. The visible ramp runs a little over twice this value. Past about a + * third of the cutout's shorter side it outgrows the filter region below and + * the ramp is cut off partway, showing as a faint square edge. + */ + feather?: number; } export interface FocusRingOptions extends CameraOptions { @@ -90,6 +102,12 @@ async function runCameraEffect( } } +/** + * Resolved in the signature and reused as the page function's fallback, so the + * two cannot drift into disagreeing about what a default is. + */ +const SPOTLIGHT_DEFAULTS = { opacity: 0.7, padding: 12, radius: 10, feather: 12 } as const; + export async function spotlight( page: Page, selectorOrLocator: SelectorOrLocator, @@ -98,13 +116,15 @@ export async function spotlight( const duration = opts?.duration ?? 3000; const fadeIn = opts?.fadeIn ?? 400; const fadeOut = opts?.fadeOut ?? 400; - const opacity = opts?.opacity ?? 0.7; - const padding = opts?.padding ?? 12; + const opacity = opts?.opacity ?? SPOTLIGHT_DEFAULTS.opacity; + const padding = opts?.padding ?? SPOTLIGHT_DEFAULTS.padding; + const radius = opts?.radius ?? SPOTLIGHT_DEFAULTS.radius; + const feather = opts?.feather ?? SPOTLIGHT_DEFAULTS.feather; const wait = opts?.wait ?? false; const { selector, rect: preRect } = await resolveRect(page, selectorOrLocator); - await runCameraEffect(page, ({ selector, preRect, duration, fadeIn, fadeOut, opacity, padding, attr }: any) => { + await runCameraEffect(page, ({ selector, preRect, duration, fadeIn, fadeOut, opacity, padding, radius, feather, defaults, attr }: any) => { const rect = preRect ?? (() => { const target = document.querySelector(selector); if (!target) { console.warn('[argo] camera effect: no element found for selector "' + selector + '"'); return null; } @@ -112,35 +132,61 @@ export async function spotlight( })(); if (!rect) return; - const overlay = document.createElement('div'); - overlay.setAttribute(attr, 'spotlight'); - // `evenodd` because both rings of the cutout are written in the same - // winding order, so the default `nonzero` fills the interior instead of - // clearing it and the overlay paints as a solid scrim with no hole. It - // also keeps the cutout independent of vertex order. - overlay.style.cssText = ` - position: fixed; inset: 0; z-index: 99990; pointer-events: none; - background: rgba(0,0,0,${opacity}); - clip-path: polygon( - evenodd, - 0% 0%, 0% 100%, 100% 100%, 100% 0%, 0% 0%, - ${rect.left - padding}px ${rect.top - padding}px, - ${rect.left - padding}px ${rect.bottom + padding}px, - ${rect.right + padding}px ${rect.bottom + padding}px, - ${rect.right + padding}px ${rect.top - padding}px, - ${rect.left - padding}px ${rect.top - padding}px - ); - opacity: 0; transition: opacity ${fadeIn}ms ease-out; + // A white full-viewport rect keeps the scrim, a black rounded rect punched + // through it drops the scrim, and blurring that black rect is what feathers + // the hole. A clip-path polygon can express none of that. + + // Interpolated into markup, and TypeScript does not reach a JS caller, so + // coerce and clamp: a NaN or a negative renders as a blank scrim with no + // hole. The rect needs none of it, coming from getBoundingClientRect or + // Playwright. + const num = (v: unknown, fallback: number) => (Number.isFinite(Number(v)) ? Number(v) : fallback); + const pad = Math.max(0, num(padding, defaults.padding)); + const rad = Math.max(0, num(radius, defaults.radius)); + const soft = Math.max(0, num(feather, defaults.feather)); + const dim = Math.min(1, Math.max(0, num(opacity, defaults.opacity))); + + const id = 'argo-spot-' + Math.random().toString(36).slice(2, 10); + const x = rect.left - pad; + const y = rect.top - pad; + const w = rect.width + pad * 2; + const h = rect.height + pad * 2; + + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute(attr, 'spotlight'); + svg.style.cssText = ` + position: fixed; inset: 0; width: 100%; height: 100%; + z-index: 99990; pointer-events: none; + opacity: 0; transition: opacity ${fadeIn}ms cubic-bezier(0.4, 0, 0.2, 1); + `; + // No viewBox, so user units map 1:1 onto the CSS pixels that + // getBoundingClientRect and Playwright's boundingBox both report. + // + // Two quiet failures: SVG defaults to linearRGB, which ramps the scrim far + // too fast right at the hole's edge, and the default -10%/120% filter + // region clips the blur partway through its ramp on a small target. + svg.innerHTML = ` + + + + + + + 0 ? `filter="url(#${id}-feather)"` : ''} /> + + + `; - document.body.appendChild(overlay); - requestAnimationFrame(() => { overlay.style.opacity = '1'; }); + document.body.appendChild(svg); + requestAnimationFrame(() => { svg.style.opacity = '1'; }); setTimeout(() => { - overlay.style.transition = `opacity ${fadeOut}ms ease-out`; - overlay.style.opacity = '0'; - setTimeout(() => overlay.remove(), fadeOut); + svg.style.transition = `opacity ${fadeOut}ms cubic-bezier(0.4, 0, 0.2, 1)`; + svg.style.opacity = '0'; + setTimeout(() => svg.remove(), fadeOut); }, duration); - }, { selector, preRect, duration, fadeIn, fadeOut, opacity, padding, attr: CAMERA_ATTR }, duration + fadeOut, wait); + }, { selector, preRect, duration, fadeIn, fadeOut, opacity, padding, radius, feather, defaults: SPOTLIGHT_DEFAULTS, attr: CAMERA_ATTR }, duration + fadeOut, wait); } export async function focusRing( @@ -228,7 +274,8 @@ export async function dimAround( const padding = 0; const overlay = document.createElement('div'); overlay.setAttribute(attr, 'dim-around'); - // `evenodd` for the same reason as in spotlight() above. + // `evenodd`: both rings share a winding order, so `nonzero` would fill + // the interior instead of clearing it and paint a scrim with no hole. overlay.style.cssText = ` position: fixed; inset: 0; z-index: 99990; pointer-events: none; background: rgba(0,0,0,${1 - dimOpacity}); diff --git a/tests/camera.test.ts b/tests/camera.test.ts index b872c27..e844eb2 100644 --- a/tests/camera.test.ts +++ b/tests/camera.test.ts @@ -183,6 +183,20 @@ describe('spotlight', () => { expect(args.padding).toBe(20); }); + it('carries cutout shape through, defaulted or overridden', async () => { + await spotlight(page, '#btn'); + const [, args] = (page.evaluate as any).mock.calls[0]; + expect(args.radius).toBe(10); + expect(args.feather).toBe(12); + // Read eagerly in the page function, so dropping it throws into a swallowed warning. + expect(args.defaults).toEqual({ opacity: 0.7, padding: 12, radius: 10, feather: 12 }); + + await spotlight(page, '#btn', { radius: 0, feather: 0 }); + const [, squared] = (page.evaluate as any).mock.calls[1]; + expect(squared.radius).toBe(0); + expect(squared.feather).toBe(0); + }); + it('accepts a Locator and pre-resolves bounding box', async () => { const locator = createMockLocator({ x: 50, y: 100, width: 200, height: 80 }); await spotlight(page, locator); diff --git a/tests/e2e/camera.e2e.test.ts b/tests/e2e/camera.e2e.test.ts index 0cc4d08..446e707 100644 --- a/tests/e2e/camera.e2e.test.ts +++ b/tests/e2e/camera.e2e.test.ts @@ -39,7 +39,8 @@ describeCameraE2E('E2E: camera effects', () => { await page.setContent(FIXTURE); const box = (await page.locator('#target').boundingBox())!; - // Both regions sit clear of the cutout edge, so antialiasing cannot skew it. + // Both regions sit clear of the cutout edge; the default feather ramps + // ~14px inward of it, so these insets must stay well above that. const insideTarget = { x: Math.round(box.x + 40), y: Math.round(box.y + 25),