Skip to content

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

Merged
shreyaskarnik merged 3 commits into
shreyaskarnik:mainfrom
Joilence:pr/spotlight-soft-edges
Sep 15, 2026
Merged

shreyaskarnik merged 3 commits 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

Joilence and others added 3 commits August 31, 2026 21:21
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.
…adding

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

Copy link
Copy Markdown
Owner

Thanks @Joilence, this is a big visual step up. A rounded, feathered cutout reads as a highlight, where the old hard rectangle read as a crop, and the JSDoc on feather even says where it starts to outgrow the filter region. That kind of note saves the next person real time. The color-interpolation-filters="sRGB" detail is a good catch too.

I pushed one commit to your branch (1d70caa) with two fixes, both for regressions from the clip-path div to SVG that fail silently:

Page CSS can override SVG presentation attributes. A reset like svg rect { fill: currentColor } (icon libraries ship these) repainted both mask rects the same color, so on those sites the spotlight dimmed nothing at all. I measured it in Chromium: the field stayed at full brightness. 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 can't override. I checked the fix in chromium, firefox and webkit, since CSS mask: url() support on SVG elements has differed between engines in the past.

Negative padding was being clamped to 0. Negative padding shrinks the cutout inside the target, for example to leave out a transparent margin, and the old version drew that correctly. The clamp now applies to the resulting width and height, which are the values that must stay non-negative. The comment's worry about a blank scrim was right for a negative radius, which is still clamped.

Both have e2e tests that fail without the change. One detail: the stylesheet test needed a dark text color to fail. With white text the repainted scrim is white, and white over a white target looks untouched, so my first version of that test passed on the broken code.

Merging. Thanks again!

@shreyaskarnik
shreyaskarnik merged commit f496706 into shreyaskarnik:main Sep 15, 2026
6 checks passed
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.

2 participants