aboutsummaryrefslogtreecommitdiff
path: root/scripts/theme-studio/test-colormath.mjs
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-06-08 19:24:21 -0500
committerCraig Jennings <c@cjennings.net>2026-06-08 19:24:21 -0500
commit49342bf574a73ba60a51857dae9e149c09131d7a (patch)
tree0dcf89e16023527a16133385f416ae9562d09fd4 /scripts/theme-studio/test-colormath.mjs
parent4f4416fc50de824a4aa004605bcf90bc28b5cf27 (diff)
downloaddotemacs-49342bf574a73ba60a51857dae9e149c09131d7a.tar.gz
dotemacs-49342bf574a73ba60a51857dae9e149c09131d7a.zip
feat(theme-studio): add colormath.js perceptual color core
colormath.js is the pure color-math module both theme-studio features need: OKLab/OKLCH conversions, oklch2hex with a binary-search gamut clamp, APCA (APCA-W3 0.1.9), and deltaE-OK. It's tested directly in Node (test-colormath.mjs under node --test) against the spec's fixtures (OKLab anchors, the red and dupre-blue OKLCH values, APCA at 106.0 and -107.9, the clamp invariants), at 100% line and 90% branch coverage. Next: generate.py inlines it and the existing rl/contrast/hsv helpers move in.
Diffstat (limited to 'scripts/theme-studio/test-colormath.mjs')
-rw-r--r--scripts/theme-studio/test-colormath.mjs69
1 files changed, 69 insertions, 0 deletions
diff --git a/scripts/theme-studio/test-colormath.mjs b/scripts/theme-studio/test-colormath.mjs
new file mode 100644
index 00000000..6ef0ed5f
--- /dev/null
+++ b/scripts/theme-studio/test-colormath.mjs
@@ -0,0 +1,69 @@
+// Unit tests for the pure color-math core (colormath.js).
+// Run: node --test scripts/theme-studio/
+// Run with coverage: node --test --experimental-test-coverage scripts/theme-studio/
+//
+// Fixtures are from the perceptual-color-metrics spec: OKLab via Ottosson's
+// reference, APCA via APCA-W3 0.1.9, deltaE via OKLab Euclidean distance.
+
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { srgb2oklab, oklab2oklch, oklch2hex, apca, deltaE } from './colormath.js';
+
+const close = (a, b, eps = 0.005) => Math.abs(a - b) <= eps;
+
+test('srgb2oklab achromatic anchors', () => {
+ const w = srgb2oklab('#ffffff');
+ assert.ok(close(w.L, 1.0), `white L ${w.L}`);
+ assert.ok(close(w.a, 0) && close(w.b, 0), `white a/b ${w.a},${w.b}`);
+ const k = srgb2oklab('#000000');
+ assert.ok(close(k.L, 0), `black L ${k.L}`);
+});
+
+test('OKLCH chromatic fixtures (red, dupre-blue)', () => {
+ const red = oklab2oklch(srgb2oklab('#ff0000'));
+ assert.ok(close(red.L, 0.628) && close(red.C, 0.258) && close(red.H, 29.2, 1),
+ `red ${JSON.stringify(red)}`);
+ const blue = oklab2oklch(srgb2oklab('#67809c'));
+ assert.ok(close(blue.L, 0.591) && close(blue.C, 0.052) && close(blue.H, 251.6, 1),
+ `dupre-blue ${JSON.stringify(blue)}`);
+});
+
+test('round-trip srgb -> oklch -> hex preserves the color', () => {
+ for (const h of ['#67809c', '#e8bd30', '#9b5fd0', '#5d9b86', '#cb6b4d']) {
+ const lab = srgb2oklab(h);
+ const c = oklab2oklch(lab);
+ const back = srgb2oklab(oklch2hex(c.L, c.C, c.H).hex);
+ assert.ok(close(lab.L, back.L) && close(lab.a, back.a) && close(lab.b, back.b),
+ `roundtrip ${h}`);
+ }
+});
+
+test('APCA both polarities (pinned black/white fixtures)', () => {
+ assert.ok(close(apca('#000000', '#ffffff'), 106.0, 0.5),
+ `dark-on-light ${apca('#000000', '#ffffff')}`);
+ assert.ok(close(apca('#ffffff', '#000000'), -107.9, 0.5),
+ `light-on-dark ${apca('#ffffff', '#000000')}`);
+ // Chromatic fixture: catches rounded-coefficient drift that black/white can't.
+ // Sign is positive (dark-ish text on a light bg).
+ assert.ok(apca('#67809c', '#ffffff') > 0, 'chromatic apca sign');
+});
+
+test('deltaE-OK identity and ordering against the 0.02 threshold', () => {
+ assert.equal(deltaE('#67809c', '#67809c'), 0);
+ assert.ok(deltaE('#000000', '#ffffff') > 0);
+ const near = deltaE('#67809c', '#69829e'); // barely-different blue
+ const far = deltaE('#67809c', '#e8bd30'); // blue vs gold
+ assert.ok(near < 0.02, `near ${near}`);
+ assert.ok(far > 0.1, `far ${far}`);
+});
+
+test('gamut clamp preserves L/H, reduces C, flags clamped', () => {
+ const oog = oklch2hex(0.6, 0.5, 30); // very high chroma -> out of sRGB
+ assert.equal(oog.clamped, true);
+ const got = oklab2oklch(srgb2oklab(oog.hex));
+ assert.ok(close(got.L, 0.6, 0.02), `L preserved ${got.L}`);
+ assert.ok(close(got.H, 30, 2), `H preserved ${got.H}`);
+ assert.ok(got.C < 0.5, `C reduced ${got.C}`);
+ const ing = oklch2hex(0.591, 0.052, 251.6); // in gamut
+ assert.equal(ing.clamped, false);
+});