diff --git a/docs/THEME_COLOUR_GENERATION.md b/docs/THEME_COLOUR_GENERATION.md index 4fc88176..5659bbdc 100644 --- a/docs/THEME_COLOUR_GENERATION.md +++ b/docs/THEME_COLOUR_GENERATION.md @@ -137,7 +137,7 @@ step 12), mixed with transparent: `color.overlay.hover` at 5% and `color.overlay The authored modal backdrop stays separate as `color.overlay.backdrop`. There is no generated per-family alpha track. -`validateContrast` composites those washes over the canvas and recessed surfaces in OKLab, then -measures the ghost Button foregrounds (`text.primary`, `foreground.accent.rest`, -`foreground.danger.rest`) against the resulting opaque colour. A `color-mix()` value is never parsed -as an opaque colour. +`validateContrast` treats each wash as a translucent colour (the `color-mix()` with transparent), +paints it over the canvas and recessed surfaces, then measures the ghost Button foregrounds +(`text.primary`, `foreground.accent.rest`, `foreground.danger.rest`) against that opaque result. A +`color-mix()` value is never parsed as an opaque colour. diff --git a/packages/@luke-ui/react/src/theme/color.test.ts b/packages/@luke-ui/react/src/theme/color.test.ts index 638146c7..e020cb5c 100644 --- a/packages/@luke-ui/react/src/theme/color.test.ts +++ b/packages/@luke-ui/react/src/theme/color.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vite-plus/test'; -import { contrastRatio, formatOklch, gamutMapOklch, mixInOklab, parseColor } from './color.js'; +import { compositeOver, contrastRatio, formatOklch, gamutMapOklch, parseColor } from './color.js'; describe('parseColor', () => { it('round-trips a hex colour through OKLCH formatting', () => { @@ -24,18 +24,37 @@ describe('parseColor', () => { }); }); -describe('mixInOklab', () => { - it('returns the background at amount 0 and the foreground at amount 1', () => { - const foreground = parseColor('oklch(0.3 0 0)'); - const background = parseColor('oklch(0.99 0 0)'); - expect(mixInOklab(foreground, background, 0)).toEqual(background); - expect(mixInOklab(foreground, background, 1)).toEqual(foreground); +describe('compositeOver', () => { + function srgbToLinear(channel: number): number { + return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; + } + + function contrastFromLuminance(a: number, b: number): number { + const lighter = Math.max(a, b); + const darker = Math.min(a, b); + return (lighter + 0.05) / (darker + 0.05); + } + + it('paints 50% black over white as sRGB gray, not an OKLab mix', () => { + const result = compositeOver(parseColor('#000000'), parseColor('#ffffff'), 0.5); + const grayLuminance = srgbToLinear(0.5); + expect(contrastRatio(parseColor('#000000'), result)).toBeCloseTo( + contrastFromLuminance(0, grayLuminance), + 5, + ); + expect(contrastRatio(parseColor('#ffffff'), result)).toBeCloseTo( + contrastFromLuminance(1, grayLuminance), + 5, + ); }); - it('mixes 10% of a dark foreground into a light background in OKLab', () => { - const mixed = mixInOklab(parseColor('oklch(0.3 0 0)'), parseColor('oklch(0.99 0 0)'), 0.1); - expect(mixed.l).toBeCloseTo(0.921, 5); - expect(mixed.c).toBeCloseTo(0, 5); + it('paints 50% blue over white using sRGB source-over', () => { + const result = compositeOver(parseColor('#0000ff'), parseColor('#ffffff'), 0.5); + const paintedLuminance = 0.2126 * srgbToLinear(0.5) + 0.7152 * srgbToLinear(0.5) + 0.0722; + expect(contrastRatio(parseColor('#000000'), result)).toBeCloseTo( + contrastFromLuminance(0, paintedLuminance), + 5, + ); }); }); diff --git a/packages/@luke-ui/react/src/theme/color.ts b/packages/@luke-ui/react/src/theme/color.ts index 5a3426b7..c0ecaa5f 100644 --- a/packages/@luke-ui/react/src/theme/color.ts +++ b/packages/@luke-ui/react/src/theme/color.ts @@ -40,19 +40,20 @@ export function parseColor(input: string): Oklch { } /** - * Mixes `foreground` over `background` in OKLab. Matches CSS - * `color-mix(in oklab, foreground %, transparent)` composited over an opaque - * background: the result is `amount` of the foreground and the rest of the background. + * Paints opaque `source` at `alpha` over opaque `backdrop` with CSS source-over in sRGB. + * + * `color-mix(in oklab, source N%, transparent)` only produces a translucent colour. This is the + * subsequent paint over the surface, not an OKLab interpolation with that surface. */ -export function mixInOklab(foreground: Oklch, background: Oklch, amount: number): Oklch { - const t = clampUnit(amount); - const fg = oklchToOklab(foreground); - const bg = oklchToOklab(background); - return oklabToOklch({ - a: bg.a * (1 - t) + fg.a * t, - b: bg.b * (1 - t) + fg.b * t, - l: bg.l * (1 - t) + fg.l * t, - }); +export function compositeOver(source: Oklch, backdrop: Oklch, alpha: number): Oklch { + const t = clampUnit(alpha); + const src = oklchToSrgb(source); + const dst = oklchToSrgb(backdrop); + return srgbToOklch([ + dst[0] * (1 - t) + src[0] * t, + dst[1] * (1 - t) + src[1] * t, + dst[2] * (1 - t) + src[2] * t, + ]); } /** @@ -172,6 +173,19 @@ function srgbToLinear(channel: number): number { return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4; } +function linearToSrgb(channel: number): number { + return channel <= 0.0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - 0.055; +} + +function oklchToSrgb(color: Oklch): SrgbTriple { + const [r, g, b] = oklchToLinearSrgb(gamutMapOklch(color)); + return [linearToSrgb(clampUnit(r)), linearToSrgb(clampUnit(g)), linearToSrgb(clampUnit(b))]; +} + +function srgbToOklch(rgb: SrgbTriple): Oklch { + return linearSrgbToOklch([srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])]); +} + function isInSrgbGamut(color: Oklch): boolean { return oklchToLinearSrgb(color).every( (channel) => channel >= -GAMUT_EPSILON && channel <= 1 + GAMUT_EPSILON, diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts index ab8fb6bb..8f9af16f 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.test.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from 'vite-plus/test'; -import { extractValue, paperFoundation, splitBlocks, tactileFoundation } from './__fixtures__/theme-css.js'; +import { + extractValue, + paperFoundation, + splitBlocks, + tactileFoundation, +} from './__fixtures__/theme-css.js'; import { buildTheme, compileTheme, ThemeContrastError } from './build-theme.js'; import { flattenThemeContract } from './contract.js'; import { SEMANTIC_ROLES } from './contrast-policy.js'; @@ -142,6 +147,24 @@ describe('buildTheme contrast failures', () => { /"color.overlay.hover" must be color-mix\(in oklab/, ); }); + + it('measures ghost overlay contrast from the painted sRGB result', () => { + const { values } = modeColorValues('light'); + values['color.surface.canvas'] = 'oklch(1 0 0)'; + values['color.overlay.hover'] = 'color-mix(in oklab, oklch(0 0 0) 50%, transparent)'; + values['color.text.primary'] = 'oklch(0 0 0)'; + + const { checks } = validateContrast('light', values); + const check = checks.find((candidate) => { + return ( + candidate.foreground === 'color.text.primary' && + candidate.background === 'color.overlay.hover over color.surface.canvas' + ); + }); + expect(check).toBeDefined(); + const grayLuminance = ((0.5 + 0.055) / 1.055) ** 2.4; + expect(check?.ratio).toBeCloseTo((grayLuminance + 0.05) / 0.05, 5); + }); }); describe('contrast validation matrix', () => { diff --git a/packages/@luke-ui/react/src/theme/contrast-validation.ts b/packages/@luke-ui/react/src/theme/contrast-validation.ts index bf63a60a..30164d75 100644 --- a/packages/@luke-ui/react/src/theme/contrast-validation.ts +++ b/packages/@luke-ui/react/src/theme/contrast-validation.ts @@ -5,7 +5,7 @@ */ import type { Oklch } from './color.js'; -import { contrastRatio, mixInOklab, parseColor } from './color.js'; +import { compositeOver, contrastRatio, parseColor } from './color.js'; import { SEMANTIC_ROLES, TEXT_RATIO, UI_RATIO } from './contrast-policy.js'; import type { ContrastCheck } from './diagnostics.js'; import type { SemanticColorValues } from './semantic-map.js'; @@ -146,8 +146,8 @@ export function validateContrast( } } // Ghost Button (and IconButton) keep tone-specific foregrounds while hover and pressed paint a - // translucent overlay. Composite that wash over the base surfaces in OKLab, then measure the - // resulting opaque pair. Do not parse the `color-mix()` string as an opaque colour. + // translucent overlay. Mix with transparent sets alpha; painting composites that colour over the + // surface. Do not parse the `color-mix()` string as an opaque colour. for (const overlayPath of INTERACTION_OVERLAYS) { const overlayValue = colorValues[overlayPath]; if (overlayValue === undefined) throw new Error(`buildTheme did not generate "${overlayPath}"`); @@ -163,7 +163,7 @@ export function validateContrast( return { checks, failures }; } -/** Reads an emitted interaction overlay and composites it over an opaque surface in OKLab. */ +/** Reads an emitted `color-mix(..., transparent)` overlay and paints it over an opaque surface. */ function compositeOverlay(overlayValue: string, overlayPath: string, surface: Oklch): Oklch { const match = INTERACTION_OVERLAY_VALUE.exec(overlayValue); const sourceValue = match?.[1]; @@ -173,5 +173,5 @@ function compositeOverlay(overlayValue: string, overlayPath: string, surface: Ok `"${overlayPath}" must be color-mix(in oklab, oklch(...) N%, transparent); received "${overlayValue}"`, ); } - return mixInOklab(parseColor(sourceValue), surface, Number(percentText) / 100); + return compositeOver(parseColor(sourceValue), surface, Number(percentText) / 100); }