From 506b8b550fb51a916baf0c3ac0a7a0466274619b Mon Sep 17 00:00:00 2001 From: "rafael r. camargo" <66796237+rafaelrcamargo@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:32:21 -0300 Subject: [PATCH 1/6] feat(core): add vivid option to keep ink visible on dark surfaces 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. --- .changeset/vivid-dark-surface.md | 10 +++++ README.md | 2 +- packages/core/__tests__/blend.test.ts | 37 ++++++++++++++++++ packages/core/__tests__/config.test.ts | 7 ++++ packages/core/__tests__/render.test.ts | 53 ++++++++++++++++++++++++++ packages/core/src/config/defaults.ts | 1 + packages/core/src/config/merge.ts | 1 + packages/core/src/render/blend.ts | 5 +++ packages/core/src/render/tier-a-svg.ts | 4 +- packages/core/src/render/tier-b-css.ts | 4 +- packages/core/src/types.ts | 11 ++++++ 11 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 .changeset/vivid-dark-surface.md diff --git a/.changeset/vivid-dark-surface.md b/.changeset/vivid-dark-surface.md new file mode 100644 index 0000000..45b717f --- /dev/null +++ b/.changeset/vivid-dark-surface.md @@ -0,0 +1,10 @@ +--- +"@highlighters/core": minor +--- + +Add a `vivid` option for keeping the ink visible on dark or saturated surfaces, where the default `multiply` optic (`backdrop x ink`) drives a colour toward black and the band disappears. When set, the ink composites on a private escape layer against the page instead of sinking under the shared multiply container. Reuses the existing dark-surface escape path; wins over `blendMode`; keeps the ink's own blend for self-overlaps (a mark crossing itself still darkens); no effect on the flat Tier C (Custom Highlight API) path. + +- `true`: a translucent colour wash (`normal` blend). Works on any surface, needs no backdrop detection (deterministic, SSR-safe), but the band sits over the text so light text on a dark surface is muted. +- `"screen"`: composite the band with `screen`. On a dark surface this mirrors `multiply` on light paper: a bright band that keeps light text legible. It washes out on light surfaces. + +Default `false`, so behaviour is unchanged for existing callers. diff --git a/README.md b/README.md index 596bf81..c380682 100644 --- a/README.md +++ b/README.md @@ -144,7 +144,7 @@ Box, circle/encircle, and bracket annotations are out of v1 scope. - **Target anything** - an element or selector, a `Range` or the live `Selection`, every match of a string/`RegExp`, or the whole page with named exclusions (exclusion always wins over inclusion). - **Real-highlighter parameter model** - `tip` (type/angle/overshoot), `ink` (flow, viscosity, feathering, streakiness, dryout, bidirectional `startEndBuildup` for pooling *or* anti-pool guardrails, directional `flowFade`), `edge` (waviness/roughness/cap/radius), and `paper` absorbency. - **Curated palettes** - harmonized `fluorescent`, `mild`, `vintage`, `neutral`, and `calm` families designed for legible colour-coding; default is **mild yellow**. Pass a `{ palette, swatch }` reference, any CSS colour string, or a gradient. -- **True ink optics** - `mix-blend-mode: multiply` by default; a near-white ink auto-adapts so it stays visible (a bright wash on dark backgrounds, a soft off-white on light ones) instead of vanishing; optional additive fluorescence/glow that reads brighter than the page. +- **True ink optics** - `mix-blend-mode: multiply` by default; a near-white ink auto-adapts so it stays visible (a bright wash on dark backgrounds, a soft off-white on light ones) instead of vanishing; opt into `vivid` to keep *any* colour visible on dark or saturated surfaces, where multiply would sink the band toward black; `vivid: true` lays a clean translucent wash, and `vivid: "screen"` mirrors multiply for dark backdrops so the band reads bright while the text stays legible; optional additive fluorescence/glow that reads brighter than the page. - **Multiline as one swipe** - one band per visual line, shared noise field and seed, wrap edges that overshoot to connect. - **Three renderer tiers behind one API** - SVG (realistic, default), CSS gradient (lightweight), and the native Custom Highlight API (flat, maximally safe), with principled auto-degrade and a pinnable tier. - **Snap-to-bounds** - clamp marks to `word`, `line`, or `glyph` so they never overshoot into whitespace. diff --git a/packages/core/__tests__/blend.test.ts b/packages/core/__tests__/blend.test.ts index 4657a35..a862016 100644 --- a/packages/core/__tests__/blend.test.ts +++ b/packages/core/__tests__/blend.test.ts @@ -86,4 +86,41 @@ describe("effectiveInk", () => { color: "#fff14d", }); }); + + describe("vivid", () => { + it("true lifts any ink onto a normal escape layer, even a saturated ink on a light backdrop", () => { + expect(effectiveInk("multiply", "#fff14d", backdrop("#fff"), doc, true)).toEqual({ + layer: "normal", + color: "#fff14d", + }); + }); + + it('"screen" composites the band with screen, on any backdrop', () => { + expect(effectiveInk("multiply", "#fff14d", backdrop("#0a0a0a"), doc, "screen")).toEqual({ + layer: "screen", + color: "#fff14d", + }); + expect(effectiveInk("multiply", "#fff14d", backdrop("#fff"), doc, "screen")).toEqual({ + layer: "screen", + color: "#fff14d", + }); + }); + + it("wins over an explicit blendMode", () => { + expect(effectiveInk("screen", "#fff14d", backdrop("#0a0a0a"), doc, true).layer).toBe("normal"); + expect(effectiveInk("darken", "#fff14d", backdrop("#0a0a0a"), doc, "screen").layer).toBe("screen"); + }); + + it("never substitutes the colour, and is deterministic (no backdrop probe)", () => { + // backdrop null: the near-white path would probe, but vivid short-circuits before it. + expect(effectiveInk("multiply", "#ffffff", null, doc, true)).toEqual({ layer: "normal", color: "#ffffff" }); + expect(effectiveInk("multiply", "#ffffff", null, doc, "screen")).toEqual({ layer: "screen", color: "#ffffff" }); + }); + + it("is a no-op when false (identical to the default path)", () => { + expect(effectiveInk("multiply", "#fff14d", backdrop("#fff"), doc, false)).toEqual( + effectiveInk("multiply", "#fff14d", backdrop("#fff"), doc), + ); + }); + }); }); diff --git a/packages/core/__tests__/config.test.ts b/packages/core/__tests__/config.test.ts index e726029..ada5742 100644 --- a/packages/core/__tests__/config.test.ts +++ b/packages/core/__tests__/config.test.ts @@ -41,6 +41,7 @@ describe("DEFAULT_OPTIONS", () => { it("encodes the documented defaults and is frozen", () => { expect(DEFAULT_OPTIONS.markType).toBe("highlight"); expect(DEFAULT_OPTIONS.blendMode).toBe("multiply"); + expect(DEFAULT_OPTIONS.vivid).toBe(false); expect(DEFAULT_OPTIONS.color).toBe(defaultSwatch("mild")); expect(DEFAULT_OPTIONS.snap).toBe("word"); expect(DEFAULT_OPTIONS.renderer).toBe("auto"); @@ -107,9 +108,15 @@ describe("resolveOptions", () => { expect(r.color).toBe(PALETTES.mild.swatches.yellow); expect(r.opacity).toBe(0.55); expect(r.blendMode).toBe("multiply"); + expect(r.vivid).toBe(false); expect(r.snap).toBe("word"); }); + it("passes vivid through when set", () => { + expect(resolveOptions({ vivid: true }).vivid).toBe(true); + expect(resolveOptions({ vivid: "screen" }).vivid).toBe("screen"); + }); + it("applies the precedence defaults → user (user wins)", () => { // User opacity beats the default. expect(resolveOptions({ opacity: 0.33 }).opacity).toBe(0.33); diff --git a/packages/core/__tests__/render.test.ts b/packages/core/__tests__/render.test.ts index f57ff27..e1c8648 100644 --- a/packages/core/__tests__/render.test.ts +++ b/packages/core/__tests__/render.test.ts @@ -341,6 +341,26 @@ describe("createCssRenderer", () => { renderer.unmount(); expect(container.querySelectorAll("div").length).toBe(0); }); + + it("under vivid, mounts bands into a normal escape layer beside the multiply container", () => { + const renderer = createCssRenderer(); + const container = createOverlayContainer(host); + renderer.mount({ container, options: resolved({ vivid: true }), lines: [geometry(0), geometry(20)], ranges: [] }); + + // A sibling escape layer is created on the host, with normal blend (not the container's multiply). + const layer = Array.from(host.children).find( + (el) => el !== container && (el as HTMLElement).style.mixBlendMode === "normal", + ) as HTMLElement | undefined; + expect(layer).toBeDefined(); + expect(layer!.style.isolation).toBe("isolate"); + // The wrappers ride the escape layer, leaving the multiply container empty. + expect(container.children.length).toBe(0); + expect(layer!.children.length).toBe(2); + + // Teardown drops the escape layer too. + renderer.unmount(); + expect(host.contains(layer!)).toBe(false); + }); }); describe("createSvgRenderer", () => { @@ -399,6 +419,39 @@ describe("createSvgRenderer", () => { ); expect(screenNodes.length).toBe(1); }); + + it("under vivid, escapes the multiply container into a sibling normal layer on the host", () => { + const renderer = createSvgRenderer(); + const container = createOverlayContainer(host); + renderer.mount({ container, options: resolved({ vivid: true }), lines: [geometry(0)], ranges: [] }); + + const layer = Array.from(host.children).find( + (el) => el !== container && (el as HTMLElement).style.mixBlendMode === "normal", + ) as HTMLElement | undefined; + expect(layer).toBeDefined(); + expect(layer!.style.isolation).toBe("isolate"); + // The per-line wrapper (and its ink node) live on the escape layer, not the multiply container. + expect(container.children.length).toBe(0); + expect(layer!.querySelector("div")).not.toBeNull(); + + renderer.unmount(); + expect(host.contains(layer!)).toBe(false); + }); + + it('under vivid: "screen", the escape layer uses the screen blend', () => { + const renderer = createSvgRenderer(); + const container = createOverlayContainer(host); + renderer.mount({ container, options: resolved({ vivid: "screen" }), lines: [geometry(0)], ranges: [] }); + + const layer = Array.from(host.children).find( + (el) => el !== container && (el as HTMLElement).style.mixBlendMode === "screen", + ) as HTMLElement | undefined; + expect(layer).toBeDefined(); + expect(layer!.style.isolation).toBe("isolate"); + expect(container.children.length).toBe(0); + + renderer.unmount(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/config/defaults.ts b/packages/core/src/config/defaults.ts index 0a5d153..bda43a4 100644 --- a/packages/core/src/config/defaults.ts +++ b/packages/core/src/config/defaults.ts @@ -9,6 +9,7 @@ export const DEFAULT_OPTIONS: ResolvedOptions = Object.freeze({ gradient: null, opacity: 0.55, blendMode: "multiply", + vivid: false, tip: Object.freeze({ type: "chisel", width: 16, diff --git a/packages/core/src/config/merge.ts b/packages/core/src/config/merge.ts index 2c8250c..7916caa 100644 --- a/packages/core/src/config/merge.ts +++ b/packages/core/src/config/merge.ts @@ -180,6 +180,7 @@ export function resolveOptions(input: HighlightOptions = {}): ResolvedOptions { gradient: merged.gradient ?? d.gradient, opacity: Math.max(0, Math.min(1, finiteOr(merged.opacity, d.opacity))), blendMode, + vivid: merged.vivid ?? d.vivid, tip, ink, speed, diff --git a/packages/core/src/render/blend.ts b/packages/core/src/render/blend.ts index 00be320..f4f8a80 100644 --- a/packages/core/src/render/blend.ts +++ b/packages/core/src/render/blend.ts @@ -98,13 +98,18 @@ function backdropIsLight(el: Element | null, doc: Document): boolean { * multiply container but darkens to a soft off-white (`layer: null`); on a dark backdrop it needs its * own `normal`-blend layer to escape multiply (`layer: "normal"`). Every other colour, and any * explicit non-multiply blend, is left to the shared container untouched (`layer: null`). + * + * When `vivid` is set, any ink escapes onto a private layer (no near-white gate): `true` paints a + * translucent `normal` wash, `"screen"` a `screen` band. It wins over `blendMode` and uses no probe. */ export function effectiveInk( blendMode: BlendMode, color: ColorValue, backdrop: Element | null, doc: Document, + vivid: boolean | "screen" = false, ): InkPlan { + if (vivid) return { layer: vivid === "screen" ? "screen" : "normal", color }; if (blendMode !== "multiply") return { layer: null, color }; const min = minChannel(color, doc); if (min === null || min < NEAR_WHITE_MIN) return { layer: null, color }; diff --git a/packages/core/src/render/tier-a-svg.ts b/packages/core/src/render/tier-a-svg.ts index 9cd65d1..f25f5f0 100644 --- a/packages/core/src/render/tier-a-svg.ts +++ b/packages/core/src/render/tier-a-svg.ts @@ -243,8 +243,8 @@ export function createSvgRenderer(): Renderer { function render(context: RenderContext): void { container = context.container; const doc = container.ownerDocument; - // Near-white ink on a dark backdrop gets its own normal-blend layer; everything else uses the shared container. - const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc); + // Near-white ink on a dark backdrop, or any ink under vivid, escapes onto its own blend layer; everything else uses the shared container. + const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc, context.options.vivid); const host = container.parentElement; const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; const inkColor = plan.color === context.options.color ? undefined : plan.color; diff --git a/packages/core/src/render/tier-b-css.ts b/packages/core/src/render/tier-b-css.ts index 366754f..e419bec 100644 --- a/packages/core/src/render/tier-b-css.ts +++ b/packages/core/src/render/tier-b-css.ts @@ -102,8 +102,8 @@ export function createCssRenderer(): Renderer { function render(context: RenderContext): void { container = context.container; const doc = container.ownerDocument; - // Near-white ink on a dark backdrop gets its own normal-blend layer; everything else uses the shared container. - const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc); + // Near-white ink on a dark backdrop, or any ink under vivid, escapes onto its own blend layer; everything else uses the shared container. + const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc, context.options.vivid); const host = container.parentElement; const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; const inkColor = plan.color === context.options.color ? undefined : plan.color; diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7cc1790..d4e3061 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -245,6 +245,16 @@ export interface HighlightOptions { */ blendMode?: BlendMode; + /** + * Keep the ink visible on dark or saturated surfaces, where the default `multiply` (backdrop x ink) + * sinks any non-near-white colour toward black. `vivid` lifts the ink onto a private layer over the + * page instead of under the shared `multiply` container; it wins over `blendMode` and keeps the ink's + * own blend for self-overlaps. `true` paints a translucent `normal` wash (works anywhere); `"screen"` + * mirrors `multiply` on dark for a bright band that keeps light text legible, but washes out on light. + * Default `false`; lifted text isn't guaranteed WCAG-legible (see `contrastBackground`). No effect on Tier C. + */ + vivid?: boolean | "screen"; + /** Nib geometry. */ tip?: TipOptions; /** Ink behavior. */ @@ -370,6 +380,7 @@ export interface ResolvedOptions { gradient: GradientConfig | null; opacity: number; blendMode: BlendMode; + vivid: boolean | "screen"; tip: ResolvedTip; ink: ResolvedInk; speed: ResolvedSpeedDynamics; From 1747d388ace3d0f7197ae735d55e63fdeac85ab1 Mon Sep 17 00:00:00 2001 From: "rafael r. camargo" <66796237+rafaelrcamargo@users.noreply.github.com> Date: Wed, 10 Jun 2026 19:32:26 -0300 Subject: [PATCH 2/6] feat(website): add a vivid demo card to the docs playground 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. --- .../website/src/components/docs/PaperCard.tsx | 11 ++- .../src/components/docs/ScribbleLegend.tsx | 6 +- .../website/src/playground/DocsPlayground.tsx | 4 + apps/website/src/playground/Preview.tsx | 10 +- apps/website/src/playground/quote-render.tsx | 9 +- .../src/playground/sections/VividDemo.tsx | 93 +++++++++++++++++++ 6 files changed, 122 insertions(+), 11 deletions(-) create mode 100644 apps/website/src/playground/sections/VividDemo.tsx diff --git a/apps/website/src/components/docs/PaperCard.tsx b/apps/website/src/components/docs/PaperCard.tsx index 5dfa960..08aeca0 100644 --- a/apps/website/src/components/docs/PaperCard.tsx +++ b/apps/website/src/components/docs/PaperCard.tsx @@ -16,11 +16,15 @@ export function PaperCard({ children, className, style, + invert = false, }: { children?: ReactNode; className?: string; style?: CSSProperties; + invert?: boolean; }) { + // Invert only the sheet layer (not the content), so the same torn-paper card reads as dark paper. + const sheetFilter = invert ? "invert(1) hue-rotate(180deg)" : undefined; // Namespace the SVG's filter/gradient ids per instance, else every card resolves to the first's defs. const rootRef = useRef(null); const uid = useId().replace(/[^a-zA-Z0-9]/g, ""); @@ -51,18 +55,19 @@ export function PaperCard({ aria-hidden className="pointer-events-none absolute left-1/2 top-0 -z-10 -translate-x-1/2 bg-[length:100%_100%] bg-no-repeat" // 313/288 height: the 288-tall sheet covers the card; the 25px bleed hangs below. - style={{ ...SHEET_SIZE, backgroundImage: "url(/paper-sheet.webp)" }} + style={{ ...SHEET_SIZE, backgroundImage: "url(/paper-sheet.webp)", filter: sheetFilter }} /> ) : (
)} -
{children}
+ {/* invert drops content's z-[1] so the marks composite against the dark sheet (else multiply has no backdrop). */} +
{children}
); } diff --git a/apps/website/src/components/docs/ScribbleLegend.tsx b/apps/website/src/components/docs/ScribbleLegend.tsx index a66e13d..0fb28cf 100644 --- a/apps/website/src/components/docs/ScribbleLegend.tsx +++ b/apps/website/src/components/docs/ScribbleLegend.tsx @@ -15,11 +15,13 @@ export function ScribbleLegend({ value, onChange, ariaLabel, + ink = INK, }: { options: ReadonlyArray<{ value: string; label: string }>; value: string; onChange: (value: string) => void; ariaLabel: string; + ink?: string; }) { const [squiggle, setSquiggle] = useState(nextSquiggle); const [focused, setFocused] = useState(null); @@ -61,7 +63,7 @@ export function ScribbleLegend({ style={{ fontSize: 14, fontWeight: 500, - color: INK, + color: ink, letterSpacing: "-0.25px", whiteSpace: "nowrap", opacity: isActive ? 1 : isPreview ? 0.85 : 0.6, @@ -83,7 +85,7 @@ export function ScribbleLegend({ > diff --git a/apps/website/src/playground/DocsPlayground.tsx b/apps/website/src/playground/DocsPlayground.tsx index 789ad0c..569afd1 100644 --- a/apps/website/src/playground/DocsPlayground.tsx +++ b/apps/website/src/playground/DocsPlayground.tsx @@ -3,6 +3,7 @@ import { Stagger } from "../components/Stagger.tsx"; import { RowGrid } from "../components/RowGrid.tsx"; import { PlaygroundOptionsProvider } from "./options-context.tsx"; import { OptionDemo, OPTION_DEMOS } from "./sections/OptionDemo.tsx"; +import { VividDemo } from "./sections/VividDemo.tsx"; import { MoreSection } from "./sections/MoreSection.tsx"; import { buildCuratedQuotes } from "./quote-marks.ts"; @@ -21,6 +22,9 @@ export function DocsPlayground() { ))} + + + diff --git a/apps/website/src/playground/Preview.tsx b/apps/website/src/playground/Preview.tsx index 278c440..4c147fd 100644 --- a/apps/website/src/playground/Preview.tsx +++ b/apps/website/src/playground/Preview.tsx @@ -18,6 +18,10 @@ interface PreviewProps { strategy: MarkStrategy; /** Pin the nib type regardless of the shared tip.type, so a one-nib demo (slant needs chisel) keeps reading. */ lockTipType?: TipType; + /** Route the ink onto an escape layer so it stays visible on a dark/coloured preview surface (`"screen"` keeps light text crisp). */ + vivid?: boolean | "screen"; + /** Quote ink, lifted to a light colour on a dark surface. Defaults to the paper {@link QUOTE_INK}. */ + textColor?: string; } // Beat held after the card's text fades in before marks draw on, so the highlighter reads as marking @@ -39,7 +43,7 @@ function useMarksReady(): boolean { return ready; } -export function Preview({ quote, strategy, lockTipType }: PreviewProps) { +export function Preview({ quote, strategy, lockTipType, vivid, textColor }: PreviewProps) { const previewOptions = usePreviewOptions(); const entered = useMarksReady(); // Scope marks to this card's positioned wrapper so they ride the page-exit fade. Falls back to body if null. @@ -87,7 +91,7 @@ export function Preview({ quote, strategy, lockTipType }: PreviewProps) { const quoteBody = (color: HighlightOptions["color"]) => { // Band stays on multiply so the Stack toggle fades the overlap rather than flipping blend mode // (a blend-mode flip can't be tweened). - const opts: HighlightOptions = { ...core, markType: swap.markType, color, opacity: liveOpacity, blendMode: "multiply" }; + const opts: HighlightOptions = { ...core, markType: swap.markType, color, opacity: liveOpacity, blendMode: "multiply", vivid }; return buildQuotePieces( words, plan, @@ -97,7 +101,7 @@ export function Preview({ quote, strategy, lockTipType }: PreviewProps) { }; return ( - + {"“"} {quoteBody(core.color)} {"”"} diff --git a/apps/website/src/playground/quote-render.tsx b/apps/website/src/playground/quote-render.tsx index db6c593..9460fc2 100644 --- a/apps/website/src/playground/quote-render.tsx +++ b/apps/website/src/playground/quote-render.tsx @@ -27,6 +27,7 @@ export function QuoteFrame({ author, children, markOpacity, + textColor = QUOTE_INK, }: { hostRef?: Ref; pRef?: Ref; @@ -34,10 +35,12 @@ export function QuoteFrame({ children: ReactNode; /** Compositor opacity for the marks layer (the mark-type swap fade). Defaults to fully opaque. */ markOpacity?: MotionValue; + /** Text ink, so a dark-surface preview can lift the quote to a light colour. Defaults to the paper {@link QUOTE_INK}. */ + textColor?: string; }) { return (
-
+
{/* Marks layer. Absolute inset-0 keeps the overlay's coordinate origin identical to the wrapper while letting the mark-type swap fade it by compositor opacity, no re-raster. */} + {"“"} {quote.text} {"”"} diff --git a/apps/website/src/playground/sections/VividDemo.tsx b/apps/website/src/playground/sections/VividDemo.tsx new file mode 100644 index 0000000..c7d6579 --- /dev/null +++ b/apps/website/src/playground/sections/VividDemo.tsx @@ -0,0 +1,93 @@ +import { useEffect, useRef, useState } from "react"; +import { Section } from "../../components/playground/Section.tsx"; +import { PaperCard } from "../../components/docs/PaperCard.tsx"; +import { ScribbleLegend } from "../../components/docs/ScribbleLegend.tsx"; +import { Preview } from "../Preview.tsx"; +import { StaticQuote } from "../quote-render.tsx"; +import { QUOTES } from "../quotes.ts"; + +// The `vivid` demo, on a dark `invert` card so multiply's dark-theme failure (and the fixes) show. +// `vivid` is local to this card, not the shared options - else it would re-route every other card too. + +// Warm off-white ink so the quote reads on the dark inverted paper. +const DARK_PAPER_INK = "#e9e3d8"; +type VividMode = "off" | "on" | "screen"; +const VIVID_OPTS = [ + { value: "off", label: "Off" }, + { value: "on", label: "On" }, + { value: "screen", label: "Screen" } +] as const; +// A real quote with a clean central band (strategy "central" marks one phrase, no overlap doubles). +const QUOTE = QUOTES[10]; // "If you judge a fish by its ability to climb a tree..." + +// Defer the live preview until the card nears the viewport, matching OptionDemo. One-way latch. +function useSeen(rootMargin = "350px") { + const ref = useRef(null); + const [seen, setSeen] = useState(false); + useEffect(() => { + if (seen) return; + const el = ref.current; + if (!el) return; + if (typeof IntersectionObserver === "undefined") { + setSeen(true); + return; + } + const io = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) { + setSeen(true); + io.disconnect(); + } + }, + { rootMargin } + ); + io.observe(el); + return () => io.disconnect(); + }, [seen, rootMargin]); + return { ref, seen }; +} + +export function VividDemo() { + const { ref, seen } = useSeen(); + // Default to `screen` - the variant that keeps light text crisp on the dark surface. + const [mode, setMode] = useState("screen"); + // Map the control onto the core option: off is false, on is true (vivid: true), screen is "screen". + const vivid: boolean | "screen" = + mode === "off" ? false : mode === "screen" ? "screen" : true; + + return ( +
+
+ Vivid{" "} + + (vivid) + + + } + description="Multiply is tuned for light paper, so on a dark surface the ink sinks toward black (Off). On floats the ink onto its own layer as a flat translucent band, visible, but it veils the text. Screen mirrors multiply for dark surfaces: a bright band that keeps the light text crisp." + > + + {!seen ? ( + + ) : ( + + )} + setMode(v as VividMode)} + ink={DARK_PAPER_INK} + /> + +
+
+ ); +} From 448a8badc44715e146ee8c6353a69e0be031bf44 Mon Sep 17 00:00:00 2001 From: Jace Date: Fri, 12 Jun 2026 13:50:46 +0100 Subject: [PATCH 3/6] fix(core): reconcile the vivid escape layer across re-renders 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. --- packages/core/__tests__/render.test.ts | 48 ++++++++++++++++++++++++++ packages/core/src/render/blend.ts | 7 ++-- packages/core/src/render/renderer.ts | 37 +++++++++++++++++--- packages/core/src/render/tier-a-svg.ts | 6 ++-- packages/core/src/render/tier-b-css.ts | 6 ++-- 5 files changed, 91 insertions(+), 13 deletions(-) diff --git a/packages/core/__tests__/render.test.ts b/packages/core/__tests__/render.test.ts index e1c8648..17410b4 100644 --- a/packages/core/__tests__/render.test.ts +++ b/packages/core/__tests__/render.test.ts @@ -356,11 +356,35 @@ describe("createCssRenderer", () => { // The wrappers ride the escape layer, leaving the multiply container empty. expect(container.children.length).toBe(0); expect(layer!.children.length).toBe(2); + // The band itself keeps the ink's own blend (multiply), so a self-overlap still darkens. + const bandBlends = Array.from(layer!.querySelectorAll("div")).map((n) => (n as HTMLElement).style.mixBlendMode); + expect(bandBlends).toContain("multiply"); // Teardown drops the escape layer too. renderer.unmount(); expect(host.contains(layer!)).toBe(false); }); + + it("reconciles the escape layer when vivid changes across updates (re-blends, tears down)", () => { + const renderer = createCssRenderer(); + const container = createOverlayContainer(host); + const escapeLayers = () => + Array.from(host.children).filter((el) => el !== container) as HTMLElement[]; + + // screen -> true must re-blend the surviving layer, not keep the stale screen blend. + renderer.mount({ container, options: resolved({ vivid: "screen" }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().map((l) => l.style.mixBlendMode)).toEqual(["screen"]); + renderer.update({ container, options: resolved({ vivid: true }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().map((l) => l.style.mixBlendMode)).toEqual(["normal"]); + expect(container.children.length).toBe(0); + + // vivid off must drop the layer entirely and move the band back into the multiply container. + renderer.update({ container, options: resolved({ vivid: false }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().length).toBe(0); + expect(container.children.length).toBe(1); + + renderer.unmount(); + }); }); describe("createSvgRenderer", () => { @@ -433,6 +457,9 @@ describe("createSvgRenderer", () => { // The per-line wrapper (and its ink node) live on the escape layer, not the multiply container. expect(container.children.length).toBe(0); expect(layer!.querySelector("div")).not.toBeNull(); + // The ink keeps multiply on the escape layer, so a self-overlap still darkens. + const inkBlends = Array.from(layer!.querySelectorAll("div")).map((n) => (n as HTMLElement).style.mixBlendMode); + expect(inkBlends).toContain("multiply"); renderer.unmount(); expect(host.contains(layer!)).toBe(false); @@ -452,6 +479,27 @@ describe("createSvgRenderer", () => { renderer.unmount(); }); + + it("reconciles the escape layer when vivid changes across updates (re-blends, tears down)", () => { + const renderer = createSvgRenderer(); + const container = createOverlayContainer(host); + const escapeLayers = () => + Array.from(host.children).filter((el) => el !== container) as HTMLElement[]; + + // screen -> true must re-blend the surviving layer, not keep the stale screen blend. + renderer.mount({ container, options: resolved({ vivid: "screen" }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().map((l) => l.style.mixBlendMode)).toEqual(["screen"]); + renderer.update({ container, options: resolved({ vivid: true }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().map((l) => l.style.mixBlendMode)).toEqual(["normal"]); + expect(container.children.length).toBe(0); + + // vivid off must drop the layer entirely and move the band back into the multiply container. + renderer.update({ container, options: resolved({ vivid: false }), lines: [geometry(0)], ranges: [] }); + expect(escapeLayers().length).toBe(0); + expect(container.children.length).toBe(1); + + renderer.unmount(); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/core/src/render/blend.ts b/packages/core/src/render/blend.ts index f4f8a80..071045a 100644 --- a/packages/core/src/render/blend.ts +++ b/packages/core/src/render/blend.ts @@ -23,9 +23,10 @@ const NEAR_WHITE_MIN = 217; const OFF_WHITE = "#d6d6d6"; /** - * How to paint an ink. `layer` is the blend for a private overlay layer the mark needs (only a - * near-white ink on a dark backdrop, which must escape the shared multiply container), or `null` to - * use the shared multiply container as-is. `color` is the ink, possibly substituted to an off-white. + * How to paint an ink. `layer` is the blend for a private overlay layer the mark needs to escape the + * shared multiply container (a near-white ink on a dark backdrop, or any ink under `vivid`; see + * {@link effectiveInk}), or `null` to use the shared multiply container as-is. `color` is the ink, + * possibly substituted to an off-white. */ export interface InkPlan { layer: BlendMode | null; diff --git a/packages/core/src/render/renderer.ts b/packages/core/src/render/renderer.ts index ddf942a..346cf07 100644 --- a/packages/core/src/render/renderer.ts +++ b/packages/core/src/render/renderer.ts @@ -121,17 +121,46 @@ function styleOverlayLayer(el: HTMLElement, blend: BlendMode): void { /** * An extra overlay layer on `host` with a non-default blend, for a mark whose ink can't use the shared - * multiply container - a near-white ink on a dark backdrop needs `normal` to stay visible. Fills the - * host identically to the shared container (so host-relative line boxes line up), but is uncached and - * unflagged: the renderer that creates it owns its lifetime, and it never disturbs sibling marks. + * multiply container - a near-white ink on a dark backdrop, or any ink under `vivid`, needs its own blend + * to stay visible. Fills the host identically to the shared container (so host-relative line boxes line + * up), but is uncached and unflagged, so it never disturbs sibling marks. Its lifetime is owned by + * {@link resolveBlendTarget}. */ -export function createBlendLayer(host: Element, blend: BlendMode): HTMLElement { +function createBlendLayer(host: Element, blend: BlendMode): HTMLElement { const layer = host.ownerDocument.createElement("div"); styleOverlayLayer(layer, blend); host.appendChild(layer); return layer; } +/** + * Pick where this frame's mark nodes mount, reconciling the renderer's private escape layer in place. + * + * Most marks paint into the shared `multiply` container (`layerBlend` null). A mark that must escape it + * gets its own isolated layer whose blend is baked in at creation, so this owns that layer across + * re-renders: it keeps the cached one while the blend still matches, rebuilds it when the blend changes + * (e.g. `vivid` flipped `"screen"` -> `true`), and removes it once no escape is needed (`vivid` off). + * Pass the cached layer in as `current` and store the returned `layer` back, so the renderer holds a + * single source of truth for its lifetime. + */ +export function resolveBlendTarget( + host: Element | null, + container: HTMLElement, + current: HTMLElement | null, + layerBlend: BlendMode | null, +): { target: HTMLElement; layer: HTMLElement | null } { + if (!layerBlend || !host) { + current?.remove(); + return { target: container, layer: null }; + } + if (current && current.style.mixBlendMode === layerBlend) { + return { target: current, layer: current }; + } + current?.remove(); + const layer = createBlendLayer(host, layerBlend); + return { target: layer, layer }; +} + /** * Tear down a mark's overlay container after its renderer unmounted. The container is shared by every * mark on the same host, so this only strips it once no marks remain: removing one mark never tears diff --git a/packages/core/src/render/tier-a-svg.ts b/packages/core/src/render/tier-a-svg.ts index f25f5f0..f82304c 100644 --- a/packages/core/src/render/tier-a-svg.ts +++ b/packages/core/src/render/tier-a-svg.ts @@ -17,7 +17,7 @@ */ import type { Renderer, RenderContext, MarkGeometry, ResolvedOptions, ColorValue } from "../types.js"; -import { NodePool, applyBoxPosition, setVendorPrefixed, setStyleOnce, backdropElement, createBlendLayer } from "./renderer.js"; +import { NodePool, applyBoxPosition, setVendorPrefixed, setStyleOnce, backdropElement, resolveBlendTarget } from "./renderer.js"; import { poolGradientToCss } from "./tier-b-css.js"; import { effectiveInk } from "./blend.js"; @@ -245,8 +245,8 @@ export function createSvgRenderer(): Renderer { const doc = container.ownerDocument; // Near-white ink on a dark backdrop, or any ink under vivid, escapes onto its own blend layer; everything else uses the shared container. const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc, context.options.vivid); - const host = container.parentElement; - const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; + const { target, layer } = resolveBlendTarget(container.parentElement, container, blendLayer, plan.layer); + blendLayer = layer; const inkColor = plan.color === context.options.color ? undefined : plan.color; // Intern the edge filter once: it depends only on doc + options, so it's invariant across a mark's lines. const defs = getSharedDefs(doc); diff --git a/packages/core/src/render/tier-b-css.ts b/packages/core/src/render/tier-b-css.ts index e419bec..e51a2a9 100644 --- a/packages/core/src/render/tier-b-css.ts +++ b/packages/core/src/render/tier-b-css.ts @@ -10,7 +10,7 @@ */ import type { Renderer, RenderContext, MarkGeometry, PoolGradient, ColorValue } from "../types.js"; -import { NodePool, applyBoxPosition, backdropElement, createBlendLayer } from "./renderer.js"; +import { NodePool, applyBoxPosition, backdropElement, resolveBlendTarget } from "./renderer.js"; import { effectiveInk } from "./blend.js"; /** @@ -104,8 +104,8 @@ export function createCssRenderer(): Renderer { const doc = container.ownerDocument; // Near-white ink on a dark backdrop, or any ink under vivid, escapes onto its own blend layer; everything else uses the shared container. const plan = effectiveInk(context.options.blendMode, context.options.color, backdropElement(context), doc, context.options.vivid); - const host = container.parentElement; - const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; + const { target, layer } = resolveBlendTarget(container.parentElement, container, blendLayer, plan.layer); + blendLayer = layer; const inkColor = plan.color === context.options.color ? undefined : plan.color; const keep = new Set(); From c3552a96124bd364b6eb165c20a5fe9ef5bbc1de Mon Sep 17 00:00:00 2001 From: Jace Date: Fri, 12 Jun 2026 13:50:46 +0100 Subject: [PATCH 4/6] docs(core): scope the vivid wording to its real surface behaviour "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. --- .changeset/vivid-dark-surface.md | 2 +- packages/core/src/types.ts | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.changeset/vivid-dark-surface.md b/.changeset/vivid-dark-surface.md index 45b717f..2aa08c1 100644 --- a/.changeset/vivid-dark-surface.md +++ b/.changeset/vivid-dark-surface.md @@ -4,7 +4,7 @@ Add a `vivid` option for keeping the ink visible on dark or saturated surfaces, where the default `multiply` optic (`backdrop x ink`) drives a colour toward black and the band disappears. When set, the ink composites on a private escape layer against the page instead of sinking under the shared multiply container. Reuses the existing dark-surface escape path; wins over `blendMode`; keeps the ink's own blend for self-overlaps (a mark crossing itself still darkens); no effect on the flat Tier C (Custom Highlight API) path. -- `true`: a translucent colour wash (`normal` blend). Works on any surface, needs no backdrop detection (deterministic, SSR-safe), but the band sits over the text so light text on a dark surface is muted. +- `true`: a translucent colour wash (`normal` blend). Deterministic and SSR-safe (no backdrop detection), but the band sits over the text, so light text on a dark surface is muted; pair it with a saturated ink. - `"screen"`: composite the band with `screen`. On a dark surface this mirrors `multiply` on light paper: a bright band that keeps light text legible. It washes out on light surfaces. Default `false`, so behaviour is unchanged for existing callers. diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index d4e3061..6d98fe6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -249,9 +249,11 @@ export interface HighlightOptions { * Keep the ink visible on dark or saturated surfaces, where the default `multiply` (backdrop x ink) * sinks any non-near-white colour toward black. `vivid` lifts the ink onto a private layer over the * page instead of under the shared `multiply` container; it wins over `blendMode` and keeps the ink's - * own blend for self-overlaps. `true` paints a translucent `normal` wash (works anywhere); `"screen"` - * mirrors `multiply` on dark for a bright band that keeps light text legible, but washes out on light. - * Default `false`; lifted text isn't guaranteed WCAG-legible (see `contrastBackground`). No effect on Tier C. + * own blend for self-overlaps. `true` paints a translucent `normal` wash (deterministic, no backdrop + * probe); `"screen"` mirrors `multiply` on dark for a brighter band that keeps light text legible, but + * washes out on light. Tune the ink itself for the surface: `true` shows best with a saturated colour, + * `"screen"` with a light one. Default `false`; lifted text isn't guaranteed WCAG-legible (see + * `contrastBackground`). No effect on the flat Tier C (Custom Highlight API) path. */ vivid?: boolean | "screen"; From 4028a2c851649c55628145f40728b928deadc780 Mon Sep 17 00:00:00 2001 From: Jace Date: Fri, 12 Jun 2026 13:50:46 +0100 Subject: [PATCH 5/6] refactor(website): share the useSeen viewport hook between demos VividDemo copied OptionDemo's IntersectionObserver latch verbatim. Extract it to hooks/useSeen.ts and import it in both, dropping the now-unused React imports. --- apps/website/src/hooks/useSeen.ts | 29 ++++++++++++++++++ .../src/playground/sections/OptionDemo.tsx | 30 ++----------------- .../src/playground/sections/VividDemo.tsx | 30 ++----------------- 3 files changed, 33 insertions(+), 56 deletions(-) create mode 100644 apps/website/src/hooks/useSeen.ts diff --git a/apps/website/src/hooks/useSeen.ts b/apps/website/src/hooks/useSeen.ts new file mode 100644 index 0000000..cf930c6 --- /dev/null +++ b/apps/website/src/hooks/useSeen.ts @@ -0,0 +1,29 @@ +import { useEffect, useRef, useState } from "react"; + +// Defer a section's live Preview until it nears the viewport. One-way latch: once seen it never resets, +// so a painted card stays mounted. Where IntersectionObserver is absent (SSR/old engines), it's seen at once. +export function useSeen(rootMargin = "350px") { + const ref = useRef(null); + const [seen, setSeen] = useState(false); + useEffect(() => { + if (seen) return; + const el = ref.current; + if (!el) return; + if (typeof IntersectionObserver === "undefined") { + setSeen(true); + return; + } + const io = new IntersectionObserver( + ([e]) => { + if (e.isIntersecting) { + setSeen(true); + io.disconnect(); + } + }, + { rootMargin }, + ); + io.observe(el); + return () => io.disconnect(); + }, [seen, rootMargin]); + return { ref, seen }; +} diff --git a/apps/website/src/playground/sections/OptionDemo.tsx b/apps/website/src/playground/sections/OptionDemo.tsx index 144e630..67c274c 100644 --- a/apps/website/src/playground/sections/OptionDemo.tsx +++ b/apps/website/src/playground/sections/OptionDemo.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import type { ShapeType } from "@highlighters/core"; import { AnimatePresence, m } from "framer-motion"; import { Section } from "../../components/playground/Section.tsx"; @@ -14,33 +14,7 @@ import { strategyFor } from "../quote-marks.ts"; import type { Quote } from "../quotes.ts"; import { usePlaygroundOptions, colorToHex, type PlaygroundOptions } from "../options-context.tsx"; import { playCircleSound, primeMarkerAudio } from "../../lib/marker-audio.ts"; - -// Defer each Preview until its section nears the viewport. One-way latch: once painted it never unmounts. -function useSeen(rootMargin = "350px") { - const ref = useRef(null); - const [seen, setSeen] = useState(false); - useEffect(() => { - if (seen) return; - const el = ref.current; - if (!el) return; - if (typeof IntersectionObserver === "undefined") { - setSeen(true); - return; - } - const io = new IntersectionObserver( - ([e]) => { - if (e.isIntersecting) { - setSeen(true); - io.disconnect(); - } - }, - { rootMargin }, - ); - io.observe(el); - return () => io.disconnect(); - }, [seen, rootMargin]); - return { ref, seen }; -} +import { useSeen } from "../../hooks/useSeen.ts"; function readPath(o: PlaygroundOptions, path: string): unknown { const s = path.split("."); diff --git a/apps/website/src/playground/sections/VividDemo.tsx b/apps/website/src/playground/sections/VividDemo.tsx index c7d6579..010b86e 100644 --- a/apps/website/src/playground/sections/VividDemo.tsx +++ b/apps/website/src/playground/sections/VividDemo.tsx @@ -1,10 +1,11 @@ -import { useEffect, useRef, useState } from "react"; +import { useState } from "react"; import { Section } from "../../components/playground/Section.tsx"; import { PaperCard } from "../../components/docs/PaperCard.tsx"; import { ScribbleLegend } from "../../components/docs/ScribbleLegend.tsx"; import { Preview } from "../Preview.tsx"; import { StaticQuote } from "../quote-render.tsx"; import { QUOTES } from "../quotes.ts"; +import { useSeen } from "../../hooks/useSeen.ts"; // The `vivid` demo, on a dark `invert` card so multiply's dark-theme failure (and the fixes) show. // `vivid` is local to this card, not the shared options - else it would re-route every other card too. @@ -20,33 +21,6 @@ const VIVID_OPTS = [ // A real quote with a clean central band (strategy "central" marks one phrase, no overlap doubles). const QUOTE = QUOTES[10]; // "If you judge a fish by its ability to climb a tree..." -// Defer the live preview until the card nears the viewport, matching OptionDemo. One-way latch. -function useSeen(rootMargin = "350px") { - const ref = useRef(null); - const [seen, setSeen] = useState(false); - useEffect(() => { - if (seen) return; - const el = ref.current; - if (!el) return; - if (typeof IntersectionObserver === "undefined") { - setSeen(true); - return; - } - const io = new IntersectionObserver( - ([e]) => { - if (e.isIntersecting) { - setSeen(true); - io.disconnect(); - } - }, - { rootMargin } - ); - io.observe(el); - return () => io.disconnect(); - }, [seen, rootMargin]); - return { ref, seen }; -} - export function VividDemo() { const { ref, seen } = useSeen(); // Default to `screen` - the variant that keeps light text crisp on the dark surface. From acf919da0ca9eef9c068910531350dbb520998aa Mon Sep 17 00:00:00 2001 From: Jace Date: Fri, 12 Jun 2026 14:03:19 +0100 Subject: [PATCH 6/6] style(website): drop a redundant vivid-mapping comment The comment enumerated exactly what the typed ternary on the next line already states; the mapping is self-evident. --- apps/website/src/playground/sections/VividDemo.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/website/src/playground/sections/VividDemo.tsx b/apps/website/src/playground/sections/VividDemo.tsx index 010b86e..cb29bf5 100644 --- a/apps/website/src/playground/sections/VividDemo.tsx +++ b/apps/website/src/playground/sections/VividDemo.tsx @@ -25,7 +25,6 @@ export function VividDemo() { const { ref, seen } = useSeen(); // Default to `screen` - the variant that keeps light text crisp on the dark surface. const [mode, setMode] = useState("screen"); - // Map the control onto the core option: off is false, on is true (vivid: true), screen is "screen". const vivid: boolean | "screen" = mode === "off" ? false : mode === "screen" ? "screen" : true;