diff --git a/spec/companion-theme-contrast.spec.mjs b/spec/companion-theme-contrast.spec.mjs new file mode 100644 index 00000000..abdab9ad --- /dev/null +++ b/spec/companion-theme-contrast.spec.mjs @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// A companion theme is produced by inverting an authored palette, and inversion +// does not preserve contrast: an accent picked to read on a dark surface can +// land far too close to the inverted light one. Monokai's generated light +// companion sat at 1.30 before this floor existed -- effectively invisible. +// +// Authored palettes are deliberately not covered here. Those are an author's +// choice and are held to their own standard in theme-legibility.spec.mjs; this +// file only governs the palettes jedit generates on the author's behalf. + +const MIN_TOKEN_CONTRAST = 3; + +function channelLuminance(channel) { + const ratio = channel / 255; + return ratio <= 0.04045 ? ratio / 12.92 : Math.pow((ratio + 0.055) / 1.055, 2.4); +} + +function relativeLuminance(color) { + return 0.2126 * channelLuminance(color[0]) + + 0.7152 * channelLuminance(color[1]) + + 0.0722 * channelLuminance(color[2]); +} + +function contrastRatio(foreground, background) { + const first = relativeLuminance(foreground); + const second = relativeLuminance(background); + const lighter = Math.max(first, second); + const darker = Math.min(first, second); + return (lighter + 0.05) / (darker + 0.05); +} + +async function generatedCompanions() { + const themes = await importDist("ui", "jedit-themes.js"); + const companions = []; + for (const theme of themes.availableJeditThemes()) { + const companion = themes.oppositeJeditTheme(theme); + if (companion.variantSource === "generated") { + companions.push(companion); + } + } + return companions; +} + +test("generated companion themes exist to be checked", async () => { + const companions = await generatedCompanions(); + assert.ok(companions.length > 0, "expected at least one generated companion theme"); +}); + +// A syntax foreground keeps its colour when the current line repaints the +// background beneath it, and the settings drawer reuses the comment foreground +// over the drawer background. Checking only the workspace background passed +// tokens that were then rendered somewhere darker: monokai's light companion +// reported 3.03:1 on the workspace while sitting at 2.75:1 on the current line +// and 2.51:1 in the drawer. +function renderingSurfaces(theme) { + return [ + ["workspace", theme.surface.workspace.bgRGB], + ["currentLine", theme.surface.currentLine.bgRGB], + ["drawer", theme.surface.drawer.bgRGB], + ["header", theme.surface.header.bgRGB], + ["footer", theme.surface.footer.bgRGB], + ]; +} + +test("every generated companion keeps its tokens legible on every surface it renders on", async () => { + const offenders = []; + + for (const companion of await generatedCompanions()) { + for (const [name, background] of renderingSurfaces(companion)) { + for (const [token, style] of companion.source) { + const ratio = contrastRatio(style.fgRGB, background); + if (ratio < MIN_TOKEN_CONTRAST) { + offenders.push(`${companion.name} ${String(token)} on ${name} ${ratio.toFixed(2)}`); + } + } + for (const [token, style] of companion.markdown) { + if (style.fgRGB == null) { + continue; + } + const ratio = contrastRatio(style.fgRGB, background); + if (ratio < MIN_TOKEN_CONTRAST) { + offenders.push(`${companion.name} ${String(token)} on ${name} ${ratio.toFixed(2)}`); + } + } + } + } + + assert.deepEqual(offenders, []); +}); + +test("every generated companion keeps its syntax tokens legible", async () => { + const offenders = []; + + for (const companion of await generatedCompanions()) { + const background = companion.surface.workspace.bgRGB; + for (const [token, style] of companion.source) { + const ratio = contrastRatio(style.fgRGB, background); + if (ratio < MIN_TOKEN_CONTRAST) { + offenders.push(`${companion.name} ${String(token)} ${ratio.toFixed(2)}`); + } + } + } + + assert.deepEqual(offenders, []); +}); + +test("every generated companion keeps its markdown tokens legible", async () => { + const offenders = []; + + for (const companion of await generatedCompanions()) { + const background = companion.surface.workspace.bgRGB; + for (const [token, style] of companion.markdown) { + if (style.fgRGB == null) { + continue; + } + const ratio = contrastRatio(style.fgRGB, background); + if (ratio < MIN_TOKEN_CONTRAST) { + offenders.push(`${companion.name} ${String(token)} ${ratio.toFixed(2)}`); + } + } + } + + assert.deepEqual(offenders, []); +}); + +test("the contrast floor leaves an already-legible colour alone", async () => { + // catppuccin's companion cleared the floor before the adjustment existed, so + // a correction that fired unconditionally would show up as a changed colour + // here rather than as a contrast failure anywhere. + const companions = await generatedCompanions(); + const catppuccin = companions.find((theme) => theme.name.startsWith("catppuccin")); + assert.ok(catppuccin != null, "expected a generated catppuccin companion"); + + const background = catppuccin.surface.workspace.bgRGB; + let worst = Infinity; + for (const [, style] of catppuccin.source) { + worst = Math.min(worst, contrastRatio(style.fgRGB, background)); + } + assert.ok(worst > 4, `expected catppuccin to stay comfortably legible, got ${worst.toFixed(2)}`); +}); + +test("reaching the contrast floor does not wash a colour out to grey", async () => { + const { rgbToOklch } = await importDist("ui", "oklch.js"); + const themes = await importDist("ui", "jedit-themes.js"); + + // Blending toward black or white -- the obvious way to hit a contrast floor -- + // desaturates as it goes, so a theme's colours arrive muddied. Walking + // lightness in OKLCH holds chroma instead. Tokens that are already neutral in + // the authored theme (ink-derived ones such as variable and property) are + // meant to stay neutral, so only chromatic tokens are checked. + const CHROMATIC = 0.05; + const offenders = []; + + for (const theme of themes.availableJeditThemes()) { + const companion = themes.oppositeJeditTheme(theme); + if (companion.variantSource !== "generated") { + continue; + } + for (const [token, style] of theme.source) { + if (rgbToOklch(style.fgRGB).chroma < CHROMATIC) { + continue; + } + const companionStyle = companion.source.get(token); + const { chroma } = rgbToOklch(companionStyle.fgRGB); + if (chroma < CHROMATIC) { + offenders.push(`${companion.name} ${String(token)} fell to chroma ${chroma.toFixed(3)}`); + } + } + } + + assert.deepEqual(offenders, []); +}); + +test("the correction searches both directions, not just away from the surface", async () => { + const contrast = await importDist("ui", "theme-contrast.js"); + + // A token darker than a mid-dark surface. Walking darker tops out at 3.00 + // against this surface even at pure black, so a correction that commits to + // "away from the ground" by luminance alone can never reach the 4.5 floor -- + // while walking lighter reaches 7.00 comfortably. + const surface = [89, 89, 89]; // relative luminance 0.100 + const ink = [63, 63, 63]; // relative luminance 0.050, ratio 1.50 + + const adjusted = contrast.contrastAdjustedPalette({ + ink, + muted: ink, + accent: ink, + info: ink, + warning: ink, + success: ink, + surface, + surfaceRaised: surface, + surfaceMuted: surface, + }); + + const ratio = contrastRatio(adjusted.ink, surface); + assert.ok( + ratio >= 4.5, + `ink should have been corrected to clear 4.5:1, got ${ratio.toFixed(2)} at ${adjusted.ink}`, + ); +}); + +test("the correction takes the smaller lightness change when both directions pass", async () => { + const contrast = await importDist("ui", "theme-contrast.js"); + const { rgbToOklch, oklchToRgb } = await importDist("ui", "oklch.js"); + + // Mid grey surface: both black and white clear 3:1, so "walk away from the + // ground" is not a sufficient answer -- the correction has to pick the nearer + // side. Asserting merely "it got darker" would also pass for a correction + // that darkened all the way to black, so the expected landing point is + // computed here independently of the implementation. + const surface = [128, 128, 128]; + const ink = [110, 110, 110]; + const MIN_ACCENT_CONTRAST = 3; + + const origin = rgbToOklch(ink); + const passesAt = (lightness) => + contrastRatio(oklchToRgb({ ...origin, lightness }), surface) >= MIN_ACCENT_CONTRAST; + + // Finest-grain scan outward from the origin, both sides, independent of the + // implementation's step size. + const GRAIN = 0.001; + let optimalDelta; + for (let delta = 0; delta <= 1 && optimalDelta === undefined; delta += GRAIN) { + if (passesAt(Math.max(0, origin.lightness - delta)) + || passesAt(Math.min(1, origin.lightness + delta))) { + optimalDelta = delta; + } + } + assert.ok(optimalDelta !== undefined, "a passing colour should exist on this surface"); + + const adjusted = contrast.contrastAdjustedPalette({ + ink: [0, 0, 0], + muted: ink, + accent: ink, + info: ink, + warning: ink, + success: ink, + surface, + surfaceRaised: surface, + surfaceMuted: surface, + }); + + const actualDelta = Math.abs(rgbToOklch(adjusted.accent).lightness - origin.lightness); + + assert.ok( + contrastRatio(adjusted.accent, surface) >= MIN_ACCENT_CONTRAST, + `accent should clear ${MIN_ACCENT_CONTRAST}:1, got ${contrastRatio(adjusted.accent, surface).toFixed(2)}`, + ); + // One search step of slack: the walk samples a grid, so it can overshoot the + // true optimum by at most the step it moves in. + const STEP_SLACK = 0.025; + assert.ok( + actualDelta <= optimalDelta + STEP_SLACK, + `expected a change near the ${optimalDelta.toFixed(3)} optimum, moved ${actualDelta.toFixed(3)}`, + ); + assert.ok( + rgbToOklch(adjusted.accent).lightness < origin.lightness, + `expected the nearer (darker) solution, got ${adjusted.accent}`, + ); +}); diff --git a/spec/oklch.spec.mjs b/spec/oklch.spec.mjs new file mode 100644 index 00000000..3594f652 --- /dev/null +++ b/spec/oklch.spec.mjs @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { importDist } from "./dist-helpers.mjs"; + +// Checked against Ottosson's published reference values rather than against +// this implementation's own output, so a transcription error in a matrix +// constant fails here instead of quietly skewing every colour that uses it. +const REFERENCE = [ + { name: "white", rgb: [255, 255, 255], lightness: 1.0, chroma: 0.0 }, + { name: "black", rgb: [0, 0, 0], lightness: 0.0, chroma: 0.0 }, + { name: "red", rgb: [255, 0, 0], lightness: 0.6279554, chroma: 0.2576833, hue: 29.23 }, + { name: "green", rgb: [0, 255, 0], lightness: 0.8664396, chroma: 0.2948272, hue: 142.5 }, + { name: "blue", rgb: [0, 0, 255], lightness: 0.4520137, chroma: 0.3132143, hue: 264.05 }, +]; + +const LIGHTNESS_TOLERANCE = 0.0005; +const CHROMA_TOLERANCE = 0.0005; +const HUE_TOLERANCE = 0.1; + +test("rgbToOklch matches published reference values", async () => { + const { rgbToOklch } = await importDist("ui", "oklch.js"); + + for (const entry of REFERENCE) { + const actual = rgbToOklch(entry.rgb); + assert.ok( + Math.abs(actual.lightness - entry.lightness) < LIGHTNESS_TOLERANCE, + `${entry.name} lightness ${actual.lightness} != ${entry.lightness}`, + ); + assert.ok( + Math.abs(actual.chroma - entry.chroma) < CHROMA_TOLERANCE, + `${entry.name} chroma ${actual.chroma} != ${entry.chroma}`, + ); + if (entry.hue !== undefined) { + assert.ok( + Math.abs(actual.hue - entry.hue) < HUE_TOLERANCE, + `${entry.name} hue ${actual.hue} != ${entry.hue}`, + ); + } + } +}); + +test("oklchToRgb round-trips every channel of the sRGB cube", async () => { + const { rgbToOklch, oklchToRgb } = await importDist("ui", "oklch.js"); + const offenders = []; + + for (let red = 0; red <= 255; red += 17) { + for (let green = 0; green <= 255; green += 17) { + for (let blue = 0; blue <= 255; blue += 17) { + const original = [red, green, blue]; + const [r, g, b] = oklchToRgb(rgbToOklch(original)); + if (Math.abs(r - red) > 1 || Math.abs(g - green) > 1 || Math.abs(b - blue) > 1) { + offenders.push(`${original} -> ${[r, g, b]}`); + } + } + } + } + + assert.deepEqual(offenders.slice(0, 5), []); +}); + +test("mixHue crosses zero the short way round", async () => { + const { mixHue } = await importDist("ui", "oklch.js"); + + assert.ok(Math.abs(mixHue(350, 10, 0.5) - 0) < 0.001, "350 -> 10 should pass through 0"); + assert.ok(Math.abs(mixHue(10, 350, 0.5) - 0) < 0.001, "10 -> 350 should pass through 0"); + assert.ok(Math.abs(mixHue(0, 180, 0.5) - 90) < 0.001); + assert.equal(mixHue(120, 240, 0), 120); +}); diff --git a/src/ui/jedit-themes.ts b/src/ui/jedit-themes.ts index fc760848..56f3dc92 100644 --- a/src/ui/jedit-themes.ts +++ b/src/ui/jedit-themes.ts @@ -1,4 +1,5 @@ import type { JeditTheme, JeditThemeMode } from "./jedit-theme.js"; +import { contrastAdjustedPalette, contrastRatio } from "./theme-contrast.js"; import { JEDIT_THEME_MODE, JEDIT_THEME_VARIANT_SOURCE, @@ -35,16 +36,8 @@ const ACTIVE_EDGE_CHAR = "░"; const THEME_MODE_LABEL_DARK = "Dark"; const THEME_MODE_LABEL_LIGHT = "Light"; const COLOR_CHANNEL_MAX = 255; +// WCAG AA for body text; 3:1 is the non-text/large-text floor used for accents. const MIN_GUTTER_CONTRAST_RATIO = 3; -const CONTRAST_LUMINANCE_OFFSET = 0.05; -const SRGB_LINEAR_THRESHOLD = 0.04045; -const SRGB_LINEAR_DIVISOR = 12.92; -const SRGB_OFFSET = 0.055; -const SRGB_SCALE = 1.055; -const SRGB_EXPONENT = 2.4; -const LUMINANCE_RED_WEIGHT = 0.2126; -const LUMINANCE_GREEN_WEIGHT = 0.7152; -const LUMINANCE_BLUE_WEIGHT = 0.0722; const GUTTER_VARIANT = Object.freeze({ Normal: "normal", Dimmed: "dimmed", @@ -237,8 +230,13 @@ function oppositeThemeMode(mode: JeditThemeMode): JeditThemeMode { : JEDIT_THEME_MODE.Dark; } +// A companion palette is produced by inverting an authored one, and inversion +// is not contrast-preserving: a colour chosen to read against a dark surface +// can land far too close to the inverted light surface. Authored palettes are +// left exactly as their author set them; only the generated companion is +// corrected, and only where it falls short. function oppositePalette(palette: ThemePalette): ThemePalette { - return { + return contrastAdjustedPalette({ ink: invertColor(palette.ink), muted: invertColor(palette.muted), accent: invertColor(palette.accent), @@ -248,9 +246,12 @@ function oppositePalette(palette: ThemePalette): ThemePalette { surface: invertColor(palette.surface), surfaceRaised: invertColor(palette.surfaceRaised), surfaceMuted: invertColor(palette.surfaceMuted), - }; + }); } + + + function paletteFromTheme(theme: JeditTheme): ThemePalette { return { ink: variableRgb(theme, VARIABLE_INK, [226, 231, 236]), @@ -335,30 +336,6 @@ function readableGutterColor( : variables.ink; } -function contrastRatio(foreground: RgbTuple, background: RgbTuple): number { - const foregroundLuminance = relativeLuminance(foreground); - const backgroundLuminance = relativeLuminance(background); - const lighter = Math.max(foregroundLuminance, backgroundLuminance); - const darker = Math.min(foregroundLuminance, backgroundLuminance); - return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET); -} - -function relativeLuminance(color: RgbTuple): number { - const red = linearizedColorChannel(color[0]); - const green = linearizedColorChannel(color[1]); - const blue = linearizedColorChannel(color[2]); - return red * LUMINANCE_RED_WEIGHT - + green * LUMINANCE_GREEN_WEIGHT - + blue * LUMINANCE_BLUE_WEIGHT; -} - -function linearizedColorChannel(channel: number): number { - const normalized = channel / COLOR_CHANNEL_MAX; - return normalized <= SRGB_LINEAR_THRESHOLD - ? normalized / SRGB_LINEAR_DIVISOR - : ((normalized + SRGB_OFFSET) / SRGB_SCALE) ** SRGB_EXPONENT; -} - function applySurfaceThemeTokens( draft: JeditThemeDraft, variables: ThemeVariables, diff --git a/src/ui/oklch.ts b/src/ui/oklch.ts new file mode 100644 index 00000000..84756b09 --- /dev/null +++ b/src/ui/oklch.ts @@ -0,0 +1,133 @@ +// sRGB <-> OKLab/OKLCH, from Björn Ottosson's published derivation. +// +// Hand-rolled rather than pulled from culori because jedit ships no colour +// dependency today and this is the whole of what it needs: two matrices, a +// cube root, and the sRGB transfer function. The constants are exact values +// from the reference implementation, and oklch.spec.mjs checks them against +// known conversions rather than against themselves. +// +// OKLCH is used instead of HSL because the logo has to keep a stable perceived +// lightness across hues: HSL's L is a channel average, so recolouring artwork +// through it makes blues read far darker than yellows at the same nominal L. + +export interface Oklch { + readonly lightness: number; + readonly chroma: number; + readonly hue: number; +} + +export type Rgb = readonly [number, number, number]; + +// A triple in whatever space the surrounding step is working in -- linear sRGB, +// cone response (LMS), or Oklab. Named apart from Rgb so a cone-response triple +// is not mistaken for a colour. +type Triple = readonly [number, number, number]; +type Matrix3 = readonly [Triple, Triple, Triple]; + +// The four matrices of Ottosson's derivation, transcribed from the reference +// implementation. The two directions are inverses of one another, not shared +// coefficients, so each is written out rather than derived from its partner -- +// inverting at runtime would introduce error the published values do not have. +// oklch.spec.mjs checks them against his reference conversions. +const LINEAR_RGB_TO_CONE: Matrix3 = [ + [0.4122214708, 0.5363325363, 0.0514459929], + [0.2119034982, 0.6806995451, 0.1073969566], + [0.0883024619, 0.2817188376, 0.6299787005], +]; + +const CONE_TO_OKLAB: Matrix3 = [ + [0.2104542553, 0.7936177850, -0.0040720468], + [1.9779984951, -2.4285922050, 0.4505937099], + [0.0259040371, 0.7827717662, -0.8086757660], +]; + +const OKLAB_TO_CONE: Matrix3 = [ + [1, 0.3963377774, 0.2158037573], + [1, -0.1055613458, -0.0638541728], + [1, -0.0894841775, -1.2914855480], +]; + +const CONE_TO_LINEAR_RGB: Matrix3 = [ + [4.0767416621, -3.3077115913, 0.2309699292], + [-1.2684380046, 2.6097574011, -0.3413193965], + [-0.0041960863, -0.7034186147, 1.7076147010], +]; + +function transform(matrix: Matrix3, input: Triple): Triple { + return [ + dot(matrix[0], input), + dot(matrix[1], input), + dot(matrix[2], input), + ]; +} + +function dot(row: Triple, input: Triple): number { + return (row[0] * input[0]) + (row[1] * input[1]) + (row[2] * input[2]); +} + +const SRGB_MAX = 255; +const GAMMA_THRESHOLD_ENCODED = 0.04045; +const GAMMA_THRESHOLD_LINEAR = 0.0031308; +const GAMMA_LINEAR_SLOPE = 12.92; +const GAMMA_OFFSET = 0.055; +const GAMMA_SCALE = 1.055; +const GAMMA_EXPONENT = 2.4; +const GAMMA_INVERSE_EXPONENT = 1 / 2.4; +const CUBE = 3; +const DEGREES_PER_TURN = 360; +const HALF_TURN = 180; +const ZERO = 0; +const ONE = 1; + +function toLinear(channel: number): number { + const ratio = channel / SRGB_MAX; + return ratio <= GAMMA_THRESHOLD_ENCODED + ? ratio / GAMMA_LINEAR_SLOPE + : Math.pow((ratio + GAMMA_OFFSET) / GAMMA_SCALE, GAMMA_EXPONENT); +} + +function toEncoded(linear: number): number { + const clamped = Math.min(ONE, Math.max(ZERO, linear)); + const ratio = clamped <= GAMMA_THRESHOLD_LINEAR + ? clamped * GAMMA_LINEAR_SLOPE + : (GAMMA_SCALE * Math.pow(clamped, GAMMA_INVERSE_EXPONENT)) - GAMMA_OFFSET; + return Math.round(ratio * SRGB_MAX); +} + +export function rgbToOklch(rgb: Rgb): Oklch { + const linear: Triple = [toLinear(rgb[0]), toLinear(rgb[1]), toLinear(rgb[2])]; + const cone = transform(LINEAR_RGB_TO_CONE, linear); + // The cube root is what makes the space perceptual rather than linear. + const compressed: Triple = [Math.cbrt(cone[0]), Math.cbrt(cone[1]), Math.cbrt(cone[2])]; + const [lightness, greenRed, blueYellow] = transform(CONE_TO_OKLAB, compressed); + + const hue = (Math.atan2(blueYellow, greenRed) * DEGREES_PER_TURN) / (2 * Math.PI); + return { + lightness, + chroma: Math.hypot(greenRed, blueYellow), + hue: hue < ZERO ? hue + DEGREES_PER_TURN : hue, + }; +} + +export function oklchToRgb(color: Oklch): Rgb { + const radians = (color.hue * 2 * Math.PI) / DEGREES_PER_TURN; + const greenRed = color.chroma * Math.cos(radians); + const blueYellow = color.chroma * Math.sin(radians); + + const compressed = transform(OKLAB_TO_CONE, [color.lightness, greenRed, blueYellow]); + const cone: Triple = [compressed[0] ** CUBE, compressed[1] ** CUBE, compressed[2] ** CUBE]; + const linear = transform(CONE_TO_LINEAR_RGB, cone); + + return [toEncoded(linear[0]), toEncoded(linear[1]), toEncoded(linear[2])]; +} + +// Shortest way round the hue circle, so a rotation from 350 to 10 travels 20 +// degrees forward rather than 340 back. Exact opposites are equally far in +// both directions; the tie is broken forwards so the result is a stated +// convention rather than a consequence of how the modulo happens to land. +export function mixHue(from: number, to: number, amount: number): number { + const forward = (((to - from) % DEGREES_PER_TURN) + DEGREES_PER_TURN) % DEGREES_PER_TURN; + const delta = forward > HALF_TURN ? forward - DEGREES_PER_TURN : forward; + const mixed = (from + (delta * amount)) % DEGREES_PER_TURN; + return mixed < ZERO ? mixed + DEGREES_PER_TURN : mixed; +} diff --git a/src/ui/theme-contrast.ts b/src/ui/theme-contrast.ts new file mode 100644 index 00000000..d6054268 --- /dev/null +++ b/src/ui/theme-contrast.ts @@ -0,0 +1,153 @@ +// Contrast measurement and correction for theme palettes. +// +// Split out of jedit-themes.ts, which owns what a theme *is*; this owns the one +// question of whether a colour can actually be read on the surface behind it. + +import type { RgbTuple, ThemePalette } from './jedit-theme-palettes.js'; +import { oklchToRgb, rgbToOklch, type Oklch } from './oklch.js'; + +const COLOR_CHANNEL_MAX = 255; +// WCAG AA for body text; 3:1 is the non-text and large-text floor, which is +// what accents and syntax tokens are held to. +const MIN_SURFACE_TEXT_CONTRAST_RATIO = 4.5; +const MIN_ACCENT_CONTRAST_RATIO = 3; +const CONTRAST_LIGHTNESS_STEP = 0.02; +const MAX_CONTRAST_STEPS = 60; +const CONTRAST_LUMINANCE_OFFSET = 0.05; +const SRGB_LINEAR_THRESHOLD = 0.04045; +const SRGB_LINEAR_DIVISOR = 12.92; +const SRGB_OFFSET = 0.055; +const SRGB_SCALE = 1.055; +const SRGB_EXPONENT = 2.4; +const LUMINANCE_RED_WEIGHT = 0.2126; +const LUMINANCE_GREEN_WEIGHT = 0.7152; +const LUMINANCE_BLUE_WEIGHT = 0.0722; +const LIGHTNESS_FLOOR = 0; +const LIGHTNESS_CEILING = 1; +const DARKER = -1; +const LIGHTER = 1; + +export // Every token is held against every surface it can be drawn on, not just the +// workspace background. A syntax foreground keeps its colour when the current +// line repaints the background beneath it, and the settings drawer reuses the +// comment foreground over the drawer background, so checking only +// palette.surface passed tokens that were then rendered somewhere darker -- +// monokai's light companion sat at 2.75:1 on the current line and 2.51:1 in +// the drawer while reporting 3.03:1 on the workspace. +function contrastAdjustedPalette(palette: ThemePalette): ThemePalette { + const surfaces = [palette.surface, palette.surfaceRaised, palette.surfaceMuted]; + return { + ink: legibleOn(palette.ink, surfaces, MIN_SURFACE_TEXT_CONTRAST_RATIO), + muted: legibleOn(palette.muted, surfaces, MIN_ACCENT_CONTRAST_RATIO), + accent: legibleOn(palette.accent, surfaces, MIN_ACCENT_CONTRAST_RATIO), + info: legibleOn(palette.info, surfaces, MIN_ACCENT_CONTRAST_RATIO), + warning: legibleOn(palette.warning, surfaces, MIN_ACCENT_CONTRAST_RATIO), + success: legibleOn(palette.success, surfaces, MIN_ACCENT_CONTRAST_RATIO), + surface: palette.surface, + surfaceRaised: palette.surfaceRaised, + surfaceMuted: palette.surfaceMuted, + }; +} + +// Contrast is carried by lightness, so a colour short of the floor is walked +// away from the surfaces it sits on with its hue and chroma held. Blending +// toward black or white instead -- the obvious approach -- desaturates as it +// goes and drags the hue with it, so a theme's amber warning arrives washed out +// and slightly wrong rather than simply lighter. +function legibleOn( + color: RgbTuple, + backgrounds: readonly RgbTuple[], + minContrastRatio: number, +): RgbTuple { + const origin = rgbToOklch(color); + if (passesContrast(color, backgrounds, minContrastRatio)) { + return color; + } + // Both directions are searched rather than only the one leading away from the + // background's luminance. Picking a direction from the background alone can + // choose a dead end: a token at luminance 0.05 on a 0.10 surface only reaches + // 3.0 at pure black, but 7.0 going the other way. Whichever side clears the + // floor first wins, so the answer is also the smallest change that works. + for (let step = 1; step <= MAX_CONTRAST_STEPS; step += 1) { + const offset = step * CONTRAST_LIGHTNESS_STEP; + const nearer = nearestPassing(origin, offset, backgrounds, minContrastRatio); + if (nearer != null) { + return nearer; + } + } + return furthestFrom(origin, backgrounds); +} + +function nearestPassing( + origin: Oklch, + offset: number, + backgrounds: readonly RgbTuple[], + minContrastRatio: number, +): RgbTuple | undefined { + for (const direction of [DARKER, LIGHTER]) { + const lightness = clampLightness(origin.lightness + (direction * offset)); + const rgb = oklchToRgb({ ...origin, lightness }); + if (passesContrast(rgb, backgrounds, minContrastRatio)) { + return rgb; + } + } + return undefined; +} + +// Nothing on either side cleared the floor, which happens when the surfaces +// themselves are too close together to admit a passing colour. Returning the +// end with the most contrast available is the honest best effort; the spec +// suite is what catches a theme this actually bites. +function furthestFrom(origin: Oklch, backgrounds: readonly RgbTuple[]): RgbTuple { + const darkest = oklchToRgb({ ...origin, lightness: LIGHTNESS_FLOOR }); + const lightest = oklchToRgb({ ...origin, lightness: LIGHTNESS_CEILING }); + return worstContrast(darkest, backgrounds) >= worstContrast(lightest, backgrounds) + ? darkest + : lightest; +} + +function worstContrast(color: RgbTuple, backgrounds: readonly RgbTuple[]): number { + return backgrounds.reduce( + (worst, background) => Math.min(worst, contrastRatio(color, background)), + Number.POSITIVE_INFINITY, + ); +} + +function clampLightness(value: number): number { + return Math.min(LIGHTNESS_CEILING, Math.max(LIGHTNESS_FLOOR, value)); +} + + +function passesContrast( + color: RgbTuple, + backgrounds: readonly RgbTuple[], + minContrastRatio: number, +): boolean { + return backgrounds.every( + (background) => contrastRatio(color, background) >= minContrastRatio, + ); +} + +export function contrastRatio(foreground: RgbTuple, background: RgbTuple): number { + const foregroundLuminance = relativeLuminance(foreground); + const backgroundLuminance = relativeLuminance(background); + const lighter = Math.max(foregroundLuminance, backgroundLuminance); + const darker = Math.min(foregroundLuminance, backgroundLuminance); + return (lighter + CONTRAST_LUMINANCE_OFFSET) / (darker + CONTRAST_LUMINANCE_OFFSET); +} + +function relativeLuminance(color: RgbTuple): number { + const red = linearizedColorChannel(color[0]); + const green = linearizedColorChannel(color[1]); + const blue = linearizedColorChannel(color[2]); + return red * LUMINANCE_RED_WEIGHT + + green * LUMINANCE_GREEN_WEIGHT + + blue * LUMINANCE_BLUE_WEIGHT; +} + +function linearizedColorChannel(channel: number): number { + const normalized = channel / COLOR_CHANNEL_MAX; + return normalized <= SRGB_LINEAR_THRESHOLD + ? normalized / SRGB_LINEAR_DIVISOR + : ((normalized + SRGB_OFFSET) / SRGB_SCALE) ** SRGB_EXPONENT; +}