···22import { useMemo, useState } from "react";
33import { parseColor } from "react-aria-components";
44import { useEntity } from "src/replicache";
55-import { getColorDifference } from "./themeUtils";
65import { useColorAttribute, colorToString } from "./useColorAttribute";
76import { BaseThemeProvider, CardBorderHiddenContext } from "./ThemeProvider";
87import { PubLeafletPublication, PubLeafletThemeColor } from "lexicons/api";
+31-30
components/ThemeManager/ThemeProvider.tsx
···2828 PublicationBackgroundProvider,
2929 PublicationThemeProvider,
3030} from "./PublicationThemeProvider";
3131-import { getColorDifference } from "./themeUtils";
3131+import { compareColors } from "./themeUtils";
3232import {
3333 getFontConfig,
3434 getGoogleFontsUrl,
···170170 !showPageBackground && !hasBackgroundImage ? bgLeaflet : bgPageProp;
171171172172 let accentContrast;
173173+ let bgRef = colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb");
174174+ let primaryStr = colorToString(primary, "rgb");
175175+173176 let sortedAccents = [accent1, accent2].sort((a, b) => {
174177 // sort accents by contrast against the background
175178 return (
176176- getColorDifference(
177177- colorToString(b, "rgb"),
178178- colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
179179- ) -
180180- getColorDifference(
181181- colorToString(a, "rgb"),
182182- colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
183183- )
179179+ compareColors(colorToString(b, "rgb"), bgRef).distance -
180180+ compareColors(colorToString(a, "rgb"), bgRef).distance
184181 );
185182 });
183183+184184+ let bestVsText = compareColors(
185185+ colorToString(sortedAccents[0], "rgb"),
186186+ primaryStr,
187187+ );
188188+ let altVsBg = compareColors(colorToString(sortedAccents[1], "rgb"), bgRef);
189189+186190 if (
187191 // if the contrast-y accent is too similar to text color
188188- getColorDifference(
189189- colorToString(sortedAccents[0], "rgb"),
190190- colorToString(primary, "rgb"),
191191- ) < 0.15 &&
192192+ // (close in distance AND not distinguishable by hue/chroma)
193193+ bestVsText.distance < 0.15 &&
194194+ bestVsText.chromaDiff < 0.05 &&
192195 // and if the other accent is different enough from the background
193193- getColorDifference(
194194- colorToString(sortedAccents[1], "rgb"),
195195- colorToString(showPageBackground ? bgPage : bgLeaflet, "rgb"),
196196- ) > 0.31
196196+ altVsBg.distance > 0.31
197197 ) {
198198 //then choose the less contrast-y accent
199199 accentContrast = sortedAccents[1];
···202202 accentContrast = sortedAccents[0];
203203 }
204204205205- // Check if the final accent contrast color is very similar to the text color
205205+ // Check if the accent contrast color is visually similar to the text color.
206206+ // We check both overall OKLab distance AND chroma (hue/saturation) difference
207207+ // because dark colors are compressed in OKLab lightness — a dark blue accent
208208+ // vs black text can have a small OKLab distance yet be clearly distinguishable
209209+ // by hue. If the chroma difference is significant, the colors are visually
210210+ // distinct and don't need an underline to tell them apart.
211211+ let accentVsText = compareColors(
212212+ colorToString(accentContrast, "rgb"),
213213+ primaryStr,
214214+ );
206215 let accentContrastSimilarToText =
207207- getColorDifference(
208208- colorToString(accentContrast, "rgb"),
209209- colorToString(primary, "rgb"),
210210- ) < 0.2;
216216+ accentVsText.distance < 0.45 && accentVsText.chromaDiff < 0.05;
211217212218 // Get font configs for CSS variables.
213219 // When using the default font (Quattro), use var(--font-quattro) which is
···418424 let accentContrast =
419425 bgPage && accent1 && accent2
420426 ? [accent1, accent2].sort((a, b) => {
427427+ let bgStr = colorToString(bgPage, "rgb");
421428 return (
422422- getColorDifference(
423423- colorToString(b, "rgb"),
424424- colorToString(bgPage, "rgb"),
425425- ) -
426426- getColorDifference(
427427- colorToString(a, "rgb"),
428428- colorToString(bgPage, "rgb"),
429429- )
429429+ compareColors(colorToString(b, "rgb"), bgStr).distance -
430430+ compareColors(colorToString(a, "rgb"), bgStr).distance
430431 );
431432 })[0]
432433 : null;
+16-4
components/ThemeManager/themeUtils.ts
···11-import { parse, ColorSpace, sRGB, distance, OKLab } from "colorjs.io/fn";
11+import { parse, ColorSpace, sRGB, distance, OKLab, to } from "colorjs.io/fn";
2233// define the color defaults for everything
44export const ThemeDefaults = {
···1616 "theme/accent-contrast": "#57822B",
1717};
18181919-// used to calculate the contrast between page and accent1, accent2, and determin which is higher contrast
2020-export function getColorDifference(color1: string, color2: string) {
1919+// Compares two RGB color strings in OKLab space and returns both the overall
2020+// perceptual distance and the chroma (hue/saturation) difference.
2121+//
2222+// Why both? Dark colors are compressed in OKLab lightness, so two colors can
2323+// have a small overall distance yet be clearly distinguishable by hue (e.g.
2424+// dark blue vs black). Checking chromaDiff lets callers tell apart "two
2525+// similar grays" from "a dark chromatic color next to a gray/black".
2626+export function compareColors(color1: string, color2: string) {
2127 ColorSpace.register(sRGB);
2228 ColorSpace.register(OKLab);
23292430 let parsedColor1 = parse(`rgb(${color1})`);
2531 let parsedColor2 = parse(`rgb(${color2})`);
26322727- return distance(parsedColor1, parsedColor2, "oklab");
3333+ let [, a1, b1] = to(parsedColor1, "oklab").coords;
3434+ let [, a2, b2] = to(parsedColor2, "oklab").coords;
3535+3636+ return {
3737+ distance: distance(parsedColor1, parsedColor2, "oklab"),
3838+ chromaDiff: Math.sqrt((a1 - a2) ** 2 + (b1 - b2) ** 2),
3939+ };
2840}