aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-colormath.mjs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-08 21:41:38 -0500
committerCraig Jennings <c@cjennings.net>2026-06-08 21:41:38 -0500
commitf58a93cd3d3aa6d4e76506c8514538d1b2113719 (patch)
tree1b4f2136279a8478ae3ab70e95f9ef394ecdd6c1 /scripts/theme-studio/test-colormath.mjs
parentf045f7b94ea4c73e6bbba13d12bef2591ae1a3c1 (diff)
downloaddotemacs-f58a93cd3d3aa6d4e76506c8514538d1b2113719.tar.gz
dotemacs-f58a93cd3d3aa6d4e76506c8514538d1b2113719.zip
feat(theme-studio): render a Chroma×Lightness plane in OKLCH mode
Perceptual-metrics Phase 4b, the last piece of the OKLCH editor. In OKLCH mode the picker's square becomes a Chroma (x) by Lightness (y) plane at the current hue. The crosshair maps to (C, L) and the hue strip selects H. The unreachable region is greyed out so the sRGB gamut boundary at that hue is visible, and the AA/AAA contrast mask overlays on top of the reachable colors. The per-cell in-gamut test is forward-only: oklch to oklab to linear-rgb plus a channel-range check, never the binary search, which stays in oklch2hex for committing a chosen color. colormath.js now exports oklab2lrgb, inGamut, and lrgb2hex (with direct Node tests, including one that pins inGamut to oklch2hex's clamped flag so the plane and the commit path agree on the boundary). The rendered bitmap caches on hue, dimensions, mask, and background, so dragging C and L at a fixed hue reuses it. HSV stays untouched: the square keeps its saturation/value gradient and the existing contrast mask. A #planetest headless guard asserts the crosshair lands at the color's (C, L), an out-of-gamut cell renders as the grey fill, and an in-gamut cell renders as a real color.
Diffstat (limited to 'scripts/theme-studio/test-colormath.mjs')
-rw-r--r--scripts/theme-studio/test-colormath.mjs23
1 files changed, 22 insertions, 1 deletions
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
index 2e929b7b5..2a58ad610 100644
--- a/scripts/theme-studio/test-colormath.mjs
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -10,8 +10,9 @@ import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import {
- srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE,
+ srgb2oklab, oklab2oklch, oklch2oklab, oklch2hex, apca, deltaE,
hex2rgb, rl, contrast, rating, hsv2rgb, rgb2hsv, rgb2hex,
+ oklab2lrgb, inGamut, lrgb2hex,
} from './colormath.js';
const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
@@ -139,6 +140,26 @@ test('rgb2hex formats and clamps out-of-range channels', () => {
assert.equal(rgb2hex(-5, 300, 128), '#00ff80'); // clamps below 0 and above 255
});
+test('oklab2lrgb / lrgb2hex round-trip through known sRGB colors', () => {
+ for (const h of ['#000000', '#ffffff', '#67809c', '#e8bd30']) {
+ const lab = srgb2oklab(h);
+ assert.equal(lrgb2hex(oklab2lrgb(lab.L, lab.a, lab.b)), h, `round-trip ${h}`);
+ }
+});
+
+test('inGamut flags reachable vs unreachable OKLCH (forward-only gamut test)', () => {
+ // dupre-blue is a real sRGB color -> in gamut.
+ const ok = oklch2oklab(0.591, 0.052, 251.6);
+ assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), true, 'reachable');
+ // very high chroma at mid lightness -> outside sRGB.
+ const bad = oklch2oklab(0.7, 0.4, 140);
+ assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), false, 'unreachable');
+ // the in-gamut verdict must agree with oklch2hex's clamped flag (the plane and
+ // the commit path share one gamut boundary).
+ assert.equal(inGamut(oklab2lrgb(ok.L, ok.a, ok.b)), !oklch2hex(0.591, 0.052, 251.6).clamped);
+ assert.equal(inGamut(oklab2lrgb(bad.L, bad.a, bad.b)), !oklch2hex(0.7, 0.4, 140).clamped);
+});
+
// Guards the one-source-of-truth contract: the page must carry colormath.js's
// body (sans exports) verbatim, so the inlined copy and the tested module cannot
// drift. Requires `python3 generate.py` to have run first.