diff --git a/.changeset/vivid-dark-surface.md b/.changeset/vivid-dark-surface.md new file mode 100644 index 0000000..2aa08c1 --- /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). 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/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/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/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/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/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 new file mode 100644 index 0000000..cb29bf5 --- /dev/null +++ b/apps/website/src/playground/sections/VividDemo.tsx @@ -0,0 +1,66 @@ +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. + +// 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..." + +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"); + 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} + /> + +
+
+ ); +} 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..17410b4 100644 --- a/packages/core/__tests__/render.test.ts +++ b/packages/core/__tests__/render.test.ts @@ -341,6 +341,50 @@ 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); + // 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", () => { @@ -399,6 +443,63 @@ 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(); + // 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); + }); + + 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(); + }); + + 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/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..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; @@ -98,13 +99,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/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 9cd65d1..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"; @@ -243,10 +243,10 @@ 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); - const host = container.parentElement; - const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; + // 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 { 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 366754f..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"; /** @@ -102,10 +102,10 @@ 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); - const host = container.parentElement; - const target = plan.layer && host ? (blendLayer ??= createBlendLayer(host, plan.layer)) : container; + // 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 { 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(); diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 7cc1790..6d98fe6 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -245,6 +245,18 @@ 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 (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"; + /** Nib geometry. */ tip?: TipOptions; /** Ink behavior. */ @@ -370,6 +382,7 @@ export interface ResolvedOptions { gradient: GradientConfig | null; opacity: number; blendMode: BlendMode; + vivid: boolean | "screen"; tip: ResolvedTip; ink: ResolvedInk; speed: ResolvedSpeedDynamics;