Skip to content

fix(camera): spotlight() paints over its own target - #44

Open
Joilence wants to merge 2 commits into
shreyaskarnik:mainfrom
Joilence:pr/spotlight-evenodd
Open

fix(camera): spotlight() paints over its own target#44
Joilence wants to merge 2 commits into
shreyaskarnik:mainfrom
Joilence:pr/spotlight-evenodd

Conversation

@Joilence

@Joilence Joilence commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Why

spotlight() has never cut its hole. It paints a flat scrim over the whole viewport for its full duration, target included.

Mechanism. The overlay traces the viewport rectangle and then the target rectangle inside a single clip-path: polygon(...). CSS polygon() accepts one path, so whether the second ring clears the first is decided entirely by the <fill-rule> it is given. None is declared, so it defaults to nonzero, which clears an inner ring only when that ring winds opposite to the outer one. Both are written in the same order, so the interior fills instead.

Measured. Chromium, white target on a mid-grey field, default opacity: 0.7:

fill rule target (255) field (128) hole
before nonzero 76 38 no
after evenodd 255 38 yes

Before, every pixel is multiplied by 0.3. After, only the field is.

spotlight-before-upstream-main spotlight-after-evenodd

Reach. The effect is public API, documented as "Dark overlay with hole around target element", and called at five sites in three shipped demos:

Call site Target
argo-launch.demo.ts#L35 #hero-command, 5000 ms
showcase.demo.ts#L54 #hero-command, 4800 ms
showcase.demo.ts#L113 #effect-spotlight, the card advertising the effect
mobile.demo.ts#L46 menu item
mobile.demo.ts#L58 #order-btn

Why it survived. Two blind spots, both worth knowing before reviewing:

  1. tests/camera.test.ts#L151 mocks page.evaluate and asserts the arguments handed to it, so no existing test can observe whether the hole appeared.
  2. Hit-testing cannot see it either. The overlay is pointer-events: none, so elementFromPoint reaches the target through the scrim whether or not the hole exists. That produced a false negative for me before I switched to reading pixels.

Nothing errors along the way, since both polygons are legal shapes. The only signal is a dark frame.

What

Prepend evenodd to both cutout polygons, which counts ray crossings and ignores their direction.

Reversing the inner ring would also work under nonzero, but that fix lives entirely in the order of eight coordinates, so any later tidy-up silently re-breaks the effect. evenodd makes the cutout independent of vertex order.

Scope, for the reviewer:

  • spotlight() uses the cutout unconditionally, so it is always affected.
  • dimAround() shares the markup, but only on its Locator fallback path. Given a CSS selector it dims siblings by opacity and is unaffected. Fixed anyway, since the two share the cutout.
  • focusRing() and zoomTo() have no cutout and are untouched.

The second commit only trims comments, to keep the note in camera.ts in the register of the surrounding file.

Test

New tests/e2e/camera.e2e.test.ts, gated on canLaunchChromium() the same way preview.e2e.test.ts#L27 is. CI installs chromium before npm test, so it runs rather than skips on both matrix nodes.

Check Result
Against pre-fix main fails: spotlight painted over its own target: the cutout did not clear a hole
Against this branch passes
Full suite, locally 741 passing, 64 files, no environmental failures
CI all six checks green

How it works, since the approach is unusual. It renders the effect and compares screenshot bytes for two clip regions, one inside the target and one away from it. Identical pixels from the same browser encode to an identical PNG, so byte equality answers the question without an image-decoding dependency. The second region is what stops the first assertion from passing vacuously: an overlay that failed to render at all would also leave the target untouched, so the scrim has to be shown to exist somewhere.

Not verified

The five demo call sites above are reach derived from the code path. I have not re-rendered those demos to confirm the effect frame by frame.

spotlight() builds a full-viewport scrim and cuts a hole in it by tracing the
viewport rectangle and then the target rectangle inside the same polygon. CSS
polygon() expresses a single path, so whether that second ring clears the
first is decided entirely by the fill-rule. None is declared, so it defaults
to nonzero, which clears an inner ring only when that ring winds opposite to
the outer one. Both are written in the same order, top-left, bottom-left,
bottom-right, top-right, so nonzero counts the interior as inside and fills
it.

The result is that spotlight() has never cut a hole. It paints a flat scrim
at the full opacity over the entire viewport for its whole duration, target
included, with no exception and no console warning. Measured in Chromium
against a white target on a mid-grey field at the default opacity of 0.7:
every pixel is multiplied by 0.3, so the target reads 76 of 255 rather than
255, and the field around it reads 38. With evenodd the target stays at 255
while the field still reads 38.

dimAround() carries the same markup but only on its Locator fallback path,
where sibling dimming is not possible. Given a CSS selector it dims siblings
by opacity and is unaffected.

evenodd also makes the cutout independent of vertex order, so a later
refactor that reorders these points cannot silently reintroduce this.

The regression test renders the effect and compares screenshot bytes of two
clip regions, one inside the target and one away from it. The existing camera
tests mock page.evaluate and assert the arguments handed to it, which cannot
observe whether the hole appeared, and that is why this survived. Comparing
encoded bytes keeps the test free of an image-decoding dependency, and the
second region is what stops the first assertion from passing vacuously: an
overlay that never rendered would also leave the target untouched.
The eight line note added with the fix was the longest comment in a file
whose next longest is five and whose median is one, and most of it retold
a failure narrative that belongs in the PR rather than beside the code.
What survives is why `evenodd` rather than reversing a ring.

The dimAround copy restated it instead of pointing at it, and the test
explained in prose what its own assertion messages already say.

Comments only. No behaviour change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant