Skip to content

feat(camera): round and feather the spotlight - #49

Open
Joilence wants to merge 1 commit into
shreyaskarnik:mainfrom
Joilence:pr/spotlight-soft-edges
Open

feat(camera): round and feather the spotlight#49
Joilence wants to merge 1 commit into
shreyaskarnik:mainfrom
Joilence:pr/spotlight-soft-edges

Conversation

@Joilence

@Joilence Joilence commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Why

The cutout was a clip-path: polygon: a hard-edged rectangle, and nothing else. Rounding the corners means emitting arcs by hand; softening the edge is not possible at all. On a pill button that reads as a crop rather than a highlight, and the one-pixel step to full scrim is the harshest part of the effect.

C-spotlight-before-after

Same page, target and options, both sides calling argo's exported spotlight(). Bottom row crops the cutout's corner, upscaled nearest-neighbour so the interpolator cannot invent the soft edge in question.

Scrim luma across the cutout's left edge, at the shipped defaults:

before:  76, 76, 76, ... 76, 255                                    one-pixel step
after:   76, 77, 78, 80, 82, 84, 88, 93, 98, 105, 114, 124, 134,
         145, 157, 169, 180, 191, 202, 212, 221, 229, 235, 241,
         245, 248, 251                                              ramp over ~26px

What

  • Every existing caller gets the softer edge without opting in. radius: 0, feather: 0 restores the old look exactly.
  • Fades move to a cubic-bezier ease, so the arrival reads as a transition rather than a switch.
  • Two mask attributes that fail quietly if dropped:
    • color-interpolation-filters="sRGB": SVG defaults to linearRGB, which ramps the scrim far too fast at the hole's edge.
    • Filter region widened to 200%: the default object bounding box clips the blur partway through its ramp on a small target. Holds while feather stays under about a third of the cutout's shorter side, which the option documents.
  • The four defaults are passed into the page function rather than closed over, since Playwright serializes it. Written twice they could disagree.
  • A NaN or a negative renders as a blank scrim with no hole, hence the clamps. padding is clamped for the first time here: on main a large negative value still leaves a hole, under the mask it removes one.

Known limitation: svg.innerHTML is a Trusted Types sink. On a page serving require-trusted-types-for 'script' the assignment throws, runCameraEffect swallows it, and no spotlight renders where main's style.cssText would have. Left as is because building the nodes costs the mask markup's readability. Happy to convert.

Test

  • 774 tests on main

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.
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