diff --git a/src/camera.ts b/src/camera.ts index 574bb48..a4816f2 100644 --- a/src/camera.ts +++ b/src/camera.ts @@ -114,10 +114,15 @@ export async function spotlight( 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, @@ -223,10 +228,12 @@ 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. overlay.style.cssText = ` position: fixed; inset: 0; z-index: 99990; pointer-events: none; background: rgba(0,0,0,${1 - dimOpacity}); 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, diff --git a/tests/e2e/camera.e2e.test.ts b/tests/e2e/camera.e2e.test.ts new file mode 100644 index 0000000..fc60dc6 --- /dev/null +++ b/tests/e2e/camera.e2e.test.ts @@ -0,0 +1,66 @@ +import { describe, it, expect } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { spotlight } from '../../src/camera.js'; + +async function canLaunchChromium(): Promise { + try { + const browser = await chromium.launch({ headless: true }); + await browser.close(); + return true; + } catch { + return false; + } +} + +const describeCameraE2E = (await canLaunchChromium()) ? describe : describe.skip; + +/** White target on a mid-grey field, so the scrim's effect on each is obvious. */ +const FIXTURE = ` + +`; + +describeCameraE2E('E2E: camera effects', () => { + it('spotlight leaves the target untouched and dims everything else', 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())!; + // Both regions sit clear of the cutout edge, so antialiasing cannot skew it. + 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, padding: 12 }); + await page.waitForTimeout(150); + + const targetAfter = await page.screenshot({ clip: insideTarget }); + const elsewhereAfter = await page.screenshot({ clip: awayFromTarget }); + + // Comparing encoded bytes avoids an image-decoding dependency: + // identical pixels from the same browser encode to an identical PNG. + expect( + targetAfter.equals(targetBefore), + 'spotlight painted over its own target: the cutout did not clear a hole', + ).toBe(true); + expect( + elsewhereAfter.equals(elsewhereBefore), + 'spotlight did not dim anything, so the hole assertion above proves nothing', + ).toBe(false); + } finally { + await browser?.close(); + } + }, 30_000); +});