feat(core): add vivid option to keep ink visible on dark surfaces#53
Conversation
The default multiply optic (backdrop x ink) drives any non-near-white ink toward black on a dark backdrop, so the band vanishes. vivid lifts the ink onto a private escape layer that composites against the page instead of sinking under the shared multiply container, reusing the existing near-white escape path. - true: a translucent normal wash (deterministic, works on any surface) - "screen": a screen band that mirrors multiply on dark, keeping light text legible Wins over blendMode, keeps the ink's own blend for self-overlaps, and is a no-op on the flat Tier C (Custom Highlight API) path. Default false, so behaviour is unchanged for existing callers.
A dark "inverted paper" card (PaperCard invert) that shows multiply's dark-theme failure and the vivid fixes via an Off / On / Screen control. vivid is local to this card so toggling it doesn't re-route the other light-paper cards through the escape layer. Threads optional vivid/textColor through Preview and QuoteFrame, an ink override through ScribbleLegend, and an invert mode through PaperCard.
The escape layer that lifts ink out of the shared multiply container was created once and never reconciled on update: changing vivid from "screen" to true kept the stale screen blend, and turning vivid off orphaned an empty layer on the host. Both are reachable through the public update() API and exercised live by the docs demo (the legend toggle routes through handle.update(), not a remount). Fold the layer lifecycle into a single resolveBlendTarget helper shared by both renderer tiers: it reuses the cached layer while the blend matches, rebuilds it when the blend changes, and removes it when no escape is needed. Covers the pre-existing near-white path too. Adds round-trip and teardown regression tests for both tiers plus the self-overlap invariant.
"Works on any surface" overstated vivid: true, which is a translucent normal wash: a near-white ink on a light surface is then near-invisible. Describe true as deterministic (no backdrop probe) and note which ink suits each mode instead of implying universal visibility.
VividDemo copied OptionDemo's IntersectionObserver latch verbatim. Extract it to hooks/useSeen.ts and import it in both, dropping the now-unused React imports.
The comment enumerated exactly what the typed ternary on the next line already states; the mapping is self-evident.
|
Really happy with this one. Using I ended up just pushing a few commits on top rather than piling on review notes—things were straightforward enough to just fix directly: There was a real bug in here. The escape layer was getting created once and then never updated if things changed. You could only hit it through Docs needed a tweak. That "works on any surface" line for Couple of small cleanups too. Everything's green: 240 tests, types check, knip's happy, size limit's fine. I also wrote up |
Highlighter marks disappear on dark backgrounds today. The default
multiplyblend is built for light paper, it blends the ink with whatever is behind it, so on a dark surface the color sinks toward black and the band vanishes.vividfixes this by compositing the ink on its own layer over the page, instead of under the shared multiply container (the same escape path already used for near-white inks).vivid?: boolean | "screen", defaultfalse:true: a translucent wash (normalblend). Deterministic, works on any surface."screen": ascreenband that mirrors multiply on a dark surface, so the light text underneath stays legible.Each mode on a dark site:
It wins over
blendModeand keeps the ink's own blend for self-overlaps. No effect on the flat Custom Highlight API tier. Existing callers are unchanged.Also adds a demo card to the docs with an Off / On / Screen toggle on a dark surface:
CleanShot.2026-06-10.at.19.36.19.mp4
Test plan:
pnpm test,pnpm typecheck,pnpm build,pnpm knip, andpnpm sizepass. Checked the Vivid card in the browser across Off / On / Screen.