From ee66345420444ab0f85dee5745dc21f4704780a3 Mon Sep 17 00:00:00 2001 From: Jonathan Yang <14588641+Joilence@users.noreply.github.com> Date: Sat, 15 Aug 2026 07:37:07 +0200 Subject: [PATCH] feat(camera): round and feather the spotlight The hole was a clip-path polygon, which can express exactly one shape: a hard-edged rectangle. Rounding its corners means emitting arcs by hand, and softening its edge is not possible at all. On a pill button the square cutout reads as a crop rather than as a highlight, and the one-pixel step from clear to full scrim is the harshest part of the effect. An SVG mask says both in two numbers. A white full-viewport rect keeps the scrim, a black rounded rect punches through it, and blurring that black rect is what feathers the hole. `radius` and `feather` are new options, both defaulted so existing callers get a softer effect without changing anything, and `feather: 0, radius: 0` restores the old look. The fades move to a cubic-bezier ease rather than ease-out, which is what makes the arrival read as a transition instead of a switch. Two details the mask needs and would fail quietly without. `color-interpolation-filters="sRGB"` because SVG defaults to linearRGB, which ramps the scrim far too fast right at the edge. And a filter region widened to 200%, because the default object bounding box clips the blur partway through its ramp on a small target. The four cutout defaults live in SPOTLIGHT_DEFAULTS and are passed through, since the page function is serialized and cannot close over module scope. Written twice, once in the signature and once as a fallback inside the page function, the two could disagree about what a default is. Measured against Chromium by reading pixels rather than markup. At the shipped defaults the scrim ramps 76, 80, 88, 98, 114, 134, 157, 180, 202, 221, 235, 245, 251 across roughly 26px, where before it stepped from 76 to 255 in a single pixel. --- src/camera.ts | 105 +++++++++++++++++++++++++---------- tests/camera.test.ts | 14 +++++ tests/e2e/camera.e2e.test.ts | 3 +- 3 files changed, 92 insertions(+), 30 deletions(-) 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),