diff --git a/src/camera.ts b/src/camera.ts index a4816f2..e2182a3 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,69 @@ 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, a negative radius or a negative size renders as + // a blank scrim with no hole. Padding itself may be negative — it shrinks + // the hole inside the target, as the clip-path version always allowed — so + // the clamp goes on the resulting width and height instead. 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 = 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 = Math.max(0, rect.width + pad * 2); + const h = Math.max(0, 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. + // + // Fills go in inline `style`, not presentation attributes: any page rule + // that matches beats an attribute, and icon libraries ship resets like + // `svg rect { fill: currentColor }`. That repaints both mask rects alike and + // the spotlight silently dims nothing. The old clip-path div was immune for + // the same reason, being styled inline. + svg.innerHTML = ` + + + + + + + + + + `; - 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 +282,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..e5ac333 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), @@ -71,4 +72,71 @@ describeCameraE2E('E2E: camera effects', () => { await browser?.close(); } }, 30_000); + + // The mask is SVG, and CSS beats SVG presentation attributes. Icon libraries + // routinely ship resets like this one; with `fill="…"` attributes both mask + // rects repaint the same colour and the spotlight silently dims nothing. + it('survives a page stylesheet that restyles SVG rects', async () => { + let browser: Browser | undefined; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + await page.setContent(FIXTURE + ''); + + const box = (await page.locator('#target').boundingBox())!; + const insideTarget = { x: Math.round(box.x + 40), y: Math.round(box.y + 25), width: 120, height: 50 }; + const awayFromTarget = { x: 40, y: 40, width: 120, height: 50 }; + const targetBefore = await page.screenshot({ clip: insideTarget }); + const elsewhereBefore = await page.screenshot({ clip: awayFromTarget }); + + await spotlight(page, '#target', { duration: 8000, fadeIn: 0 }); + await page.waitForTimeout(150); + + expect( + (await page.screenshot({ clip: awayFromTarget })).equals(elsewhereBefore), + 'page CSS overrode the mask fills, so the spotlight dimmed nothing', + ).toBe(false); + expect( + (await page.screenshot({ clip: insideTarget })).equals(targetBefore), + 'page CSS overrode the mask fills and the hole was painted over', + ).toBe(true); + } finally { + await browser?.close(); + } + }, 30_000); + + // Negative padding shrinks the cutout inside the target's box, e.g. to leave + // out a transparent margin. It rendered correctly before the SVG rewrite, so + // clamping it to 0 would silently widen existing demos' spotlights. + it('honours negative padding by shrinking the hole inside the target', async () => { + let browser: Browser | undefined; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage({ viewport: { width: 1280, height: 720 } }); + await page.setContent(FIXTURE); + + const box = (await page.locator('#target').boundingBox())!; + // Hard edge, square corners: with padding -20 the hole is exactly the + // target inset by 20px, so a 10px band at the target's edge must dim + // while its centre stays clear. + const edgeBand = { x: Math.round(box.x + 2), y: Math.round(box.y + 2), width: 10, height: 10 }; + const centre = { x: Math.round(box.x + 100), y: Math.round(box.y + 40), width: 100, height: 20 }; + const edgeBefore = await page.screenshot({ clip: edgeBand }); + const centreBefore = await page.screenshot({ clip: centre }); + + await spotlight(page, '#target', { duration: 8000, fadeIn: 0, padding: -20, feather: 0, radius: 0 }); + await page.waitForTimeout(150); + + expect( + (await page.screenshot({ clip: edgeBand })).equals(edgeBefore), + 'negative padding was ignored: the hole still covers the whole target', + ).toBe(false); + expect( + (await page.screenshot({ clip: centre })).equals(centreBefore), + 'negative padding closed the hole entirely', + ).toBe(true); + } finally { + await browser?.close(); + } + }, 30_000); });