Skip to content

feat(core): add vivid option to keep ink visible on dark surfaces#53

Merged
JaceThings merged 6 commits into
JaceThings:mainfrom
rafaelrcamargo:feat/vivid-dark-surface
Jun 12, 2026
Merged

feat(core): add vivid option to keep ink visible on dark surfaces#53
JaceThings merged 6 commits into
JaceThings:mainfrom
rafaelrcamargo:feat/vivid-dark-surface

Conversation

@rafaelrcamargo

Copy link
Copy Markdown
Contributor

Highlighter marks disappear on dark backgrounds today. The default multiply blend 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. vivid fixes 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", default false:

  • true: a translucent wash (normal blend). Deterministic, works on any surface.
  • "screen": a screen band that mirrors multiply on a dark surface, so the light text underneath stays legible.

Each mode on a dark site:

Off On Screen
CleanShot 2026-06-10 at 19 39 33@2x CleanShot 2026-06-10 at 19 39 16@2x CleanShot 2026-06-10 at 19 38 35@2x

It wins over blendMode and 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, and pnpm size pass. Checked the Vivid card in the browser across Off / On / Screen.

rafaelrcamargo and others added 6 commits June 10, 2026 19:32
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.
@JaceThings

JaceThings commented Jun 12, 2026

Copy link
Copy Markdown
Owner

Really happy with this one. Using vivid for dark sites is the right call, and the fact that you reused the existing near-white escape path instead of building something new is clean. Nice instinct there.

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 update(), but the demo actually triggers it: flipping the legend uses handle.update() (the <Highlight> key doesn't include vivid), not a remount. So toggling Screen → On would keep the old screen blend running instead of switching to the normal wash, and turning vivid off left a dead <div> sitting on the host. I pulled the whole layer lifecycle into one resolveBlendTarget helper in renderer.ts that both tiers share now—it reuses the layer when the blend's the same, rebuilds when it changes, and cleans up when there's no escape needed. Added round-trip and teardown tests for both paths (they fail on the old code, pass now).

Docs needed a tweak. That "works on any surface" line for vivid: true was a bit of a landmine—a near-white ink on a light page with true is basically invisible white-on-white. I reframed it to "deterministic, no backdrop probe" and called out which ink works best in each mode.

Couple of small cleanups too. VividDemo was copy-pasting OptionDemo's IntersectionObserver latch, so I pulled that into a shared hooks/useSeen.ts. Dropped one comment that was just repeating the ternary below it.

Everything's green: 240 tests, types check, knip's happy, size limit's fine. I also wrote up vivid in the wiki (Options reference, Ink & Optics, an FAQ, and a recipe) and I'll push those docs the second this merges so nothing's pointing at an unreleased option. Thanks for the PR!

@JaceThings JaceThings merged commit 722fd16 into JaceThings:main Jun 12, 2026
4 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