Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .changeset/vivid-dark-surface.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 8 additions & 3 deletions apps/website/src/components/docs/PaperCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLDivElement>(null);
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
Expand Down Expand Up @@ -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 }}
/>
) : (
<div
aria-hidden
className="pointer-events-none absolute left-1/2 top-0 -z-10 -translate-x-1/2"
style={{ ...SHEET_SIZE, maxWidth: "none" }}
style={{ ...SHEET_SIZE, maxWidth: "none", filter: sheetFilter }}
// SVG is our own build artifact (paperBg.ts), not user input.
dangerouslySetInnerHTML={{ __html: svg }}
/>
)}
<div className="relative z-[1] flex flex-1 flex-col">{children}</div>
{/* invert drops content's z-[1] so the marks composite against the dark sheet (else multiply has no backdrop). */}
<div className={`relative flex flex-1 flex-col ${invert ? "" : "z-[1]"}`}>{children}</div>
</div>
);
}
6 changes: 4 additions & 2 deletions apps/website/src/components/docs/ScribbleLegend.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -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,
Expand All @@ -83,7 +85,7 @@ export function ScribbleLegend({
>
<MarkUnderline
squiggle={SQUIGGLES[squiggle]}
color={INK}
color={ink}
opacity={1}
animate={isActive}
/>
Expand Down
29 changes: 29 additions & 0 deletions apps/website/src/hooks/useSeen.ts
Original file line number Diff line number Diff line change
@@ -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<HTMLDivElement | null>(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 };
}
4 changes: 4 additions & 0 deletions apps/website/src/playground/DocsPlayground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -21,6 +22,9 @@ export function DocsPlayground() {
</Stagger>
))}
<Stagger index={1 + OPTION_DEMOS.length}>
<VividDemo />
</Stagger>
<Stagger index={2 + OPTION_DEMOS.length}>
<MoreSection />
</Stagger>
</RowGrid>
Expand Down
10 changes: 7 additions & 3 deletions apps/website/src/playground/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -97,7 +101,7 @@ export function Preview({ quote, strategy, lockTipType }: PreviewProps) {
};

return (
<QuoteFrame hostRef={setHost} author={quote.author} markOpacity={swap.fade}>
<QuoteFrame hostRef={setHost} author={quote.author} markOpacity={swap.fade} textColor={textColor}>
{"“"}
{quoteBody(core.color)}
{"”"}
Expand Down
9 changes: 6 additions & 3 deletions apps/website/src/playground/quote-render.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,17 +27,20 @@ export function QuoteFrame({
author,
children,
markOpacity,
textColor = QUOTE_INK,
}: {
hostRef?: Ref<HTMLDivElement>;
pRef?: Ref<HTMLParagraphElement>;
author: string;
children: ReactNode;
/** Compositor opacity for the marks layer (the mark-type swap fade). Defaults to fully opaque. */
markOpacity?: MotionValue<number>;
/** 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 (
<div className="flex w-full flex-1 select-none items-center justify-center overflow-hidden px-6 py-4">
<div className="relative flex max-w-[420px] flex-col items-center gap-[10px] text-center" style={{ color: QUOTE_INK }}>
<div className="relative flex max-w-[420px] flex-col items-center gap-[10px] text-center" style={{ color: textColor }}>
{/* 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. */}
<m.div
Expand All @@ -59,9 +62,9 @@ export function QuoteFrame({

// The quote at final size with NO marks, shown before Preview mounts. Frame matches Preview's so the
// swap is height-neutral (a resize would re-raster the paper/scribble SVGs); no per-frame subscription.
export function StaticQuote({ quote }: { quote: Quote }) {
export function StaticQuote({ quote, textColor }: { quote: Quote; textColor?: string }) {
return (
<QuoteFrame author={quote.author}>
<QuoteFrame author={quote.author} textColor={textColor}>
{"“"}
{quote.text}
{"”"}
Expand Down
30 changes: 2 additions & 28 deletions apps/website/src/playground/sections/OptionDemo.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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<HTMLDivElement | null>(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(".");
Expand Down
66 changes: 66 additions & 0 deletions apps/website/src/playground/sections/VividDemo.tsx
Original file line number Diff line number Diff line change
@@ -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<VividMode>("screen");
const vivid: boolean | "screen" =
mode === "off" ? false : mode === "screen" ? "screen" : true;

return (
<div ref={ref} className="cv-demo">
<Section
title={
<>
Vivid{" "}
<span className="text-[0.8em] leading-none font-normal text-text-secondary">
(vivid)
</span>
</>
}
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."
>
<PaperCard invert>
{!seen ? (
<StaticQuote quote={QUOTE} textColor={DARK_PAPER_INK} />
) : (
<Preview
quote={QUOTE}
strategy="central"
vivid={vivid}
textColor={DARK_PAPER_INK}
/>
)}
<ScribbleLegend
ariaLabel="Vivid"
options={VIVID_OPTS}
value={mode}
onChange={(v) => setMode(v as VividMode)}
ink={DARK_PAPER_INK}
/>
</PaperCard>
</Section>
</div>
);
}
37 changes: 37 additions & 0 deletions packages/core/__tests__/blend.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
);
});
});
});
7 changes: 7 additions & 0 deletions packages/core/__tests__/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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);
Expand Down
Loading
Loading