fix(camera): spotlight() paints over its own target - #44
Open
Joilence wants to merge 2 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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(...). CSSpolygon()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 tononzero, 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:nonzeroevenoddBefore, every pixel is multiplied by 0.3. After, only the field is.
Reach. The effect is public API, documented as "Dark overlay with hole around target element", and called at five sites in three shipped demos:
argo-launch.demo.ts#L35#hero-command, 5000 msshowcase.demo.ts#L54#hero-command, 4800 msshowcase.demo.ts#L113#effect-spotlight, the card advertising the effectmobile.demo.ts#L46mobile.demo.ts#L58#order-btnWhy it survived. Two blind spots, both worth knowing before reviewing:
tests/camera.test.ts#L151mockspage.evaluateand asserts the arguments handed to it, so no existing test can observe whether the hole appeared.pointer-events: none, soelementFromPointreaches 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
evenoddto 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.evenoddmakes 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()andzoomTo()have no cutout and are untouched.The second commit only trims comments, to keep the note in
camera.tsin the register of the surrounding file.Test
New
tests/e2e/camera.e2e.test.ts, gated oncanLaunchChromium()the same waypreview.e2e.test.ts#L27is. CI installs chromium beforenpm test, so it runs rather than skips on both matrix nodes.mainspotlight painted over its own target: the cutout did not clear a holeHow 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.