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 1/2] 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), From 1d70caa4f6cb2356ec79e9c28a340623c04cf923 Mon Sep 17 00:00:00 2001 From: Shreyas Karnik Date: Tue, 15 Sep 2026 09:55:35 -0700 Subject: [PATCH 2/2] fix(camera): keep the SVG spotlight immune to page CSS and negative padding Two regressions from the clip-path div to the SVG mask, both silent. Page CSS beats SVG presentation attributes. A reset of the kind icon libraries ship, `svg rect { fill: currentColor }`, repainted both mask rects the same colour, so on those sites the spotlight dimmed nothing at all. The old div was immune because it was styled inline. Fills, the mask reference and the feather filter now go in inline `style`, which a stylesheet cannot override. Negative padding was clamped to 0. It shrinks the cutout inside the target, e.g. to leave out a transparent margin, and rendered correctly before the rewrite, so existing demos would have got a wider hole with no warning. The clamp moves to the resulting width and height, which is what actually needs to stay non-negative. Both cases have e2e tests that fail without this change. The first test's fixture needed a dark text colour to fail honestly: with white text the repainted scrim is white, and white over a white target looks untouched. Verified in chromium, firefox and webkit. --- src/camera.ts | 26 +++++++++----- tests/e2e/camera.e2e.test.ts | 67 ++++++++++++++++++++++++++++++++++++ 2 files changed, 84 insertions(+), 9 deletions(-) diff --git a/src/camera.ts b/src/camera.ts index 0985e41..e2182a3 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -137,11 +137,13 @@ export async function spotlight( // 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. + // 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 = Math.max(0, num(padding, defaults.padding)); + 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))); @@ -149,8 +151,8 @@ export async function spotlight( 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 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'); @@ -165,18 +167,24 @@ export async function spotlight( // 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 = ` - + 0 ? `filter="url(#${id}-feather)"` : ''} /> + style="fill:black;stroke:none${soft > 0 ? `;filter:url(#${id}-feather)` : ''}" /> - + `; document.body.appendChild(svg); requestAnimationFrame(() => { svg.style.opacity = '1'; }); diff --git a/tests/e2e/camera.e2e.test.ts b/tests/e2e/camera.e2e.test.ts index 446e707..e5ac333 100644 --- a/tests/e2e/camera.e2e.test.ts +++ b/tests/e2e/camera.e2e.test.ts @@ -72,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); });