A site may color an icon or text with one of its border grays (X's composer toolbar: 1.7:1 on its own black). Placed by axis position it landed between a theme's background and its secondary text, under every contrast a theme guarantees. In text usage that stretch of the axis (above the most raised surface, below secondary text) now maps to the theme's secondary text; surface-dark foregrounds (labels on a filled surface), borders and backgrounds are unchanged. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
72 lines
4.6 KiB
TypeScript
72 lines
4.6 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { axisPosition, buildColorModel, deriveColors, oklabToRgb, projectColor, rgbToOklab, rolePositions, BASE_STEPS, type SourceAxis } from '../src/themeTokens.ts';
|
|
|
|
// A made-up dark site palette: every role lists its colors as `r,g,b`, the first is canonical.
|
|
const SOURCE: SourceAxis = {
|
|
bg: ['10,10,12'],
|
|
surface: ['20,21,24'],
|
|
elevated: ['32,33,38'],
|
|
border: ['50,52,58'],
|
|
muted: ['140,144,150'],
|
|
text: ['235,236,238'],
|
|
accent: ['83,252,24'],
|
|
};
|
|
const THEME = { bg: '#fdf6e3', surface: '#f5eedb', elevated: '#eee8d5', border: '#d9d2bf', muted: '#657b83', text: '#073642', accent: '#268bd2' };
|
|
const rgb = (key: string) => key.split(',').map(Number) as [number, number, number];
|
|
const hex = (value: number[]) => `#${value.map(v => v.toString(16).padStart(2, '0')).join('')}`;
|
|
|
|
test('OKLab round trip is exact to one step', () => {
|
|
for (const color of [[0, 0, 0], [255, 255, 255], [29, 155, 240], [22, 24, 28], [83, 100, 113]] as [number, number, number][]) {
|
|
const back = oklabToRgb(rgbToOklab(color));
|
|
color.forEach((v, i) => assert.ok(Math.abs(v - back[i]!) <= 1, `${color} -> ${back}`));
|
|
}
|
|
});
|
|
|
|
test('listed site colors become exactly the theme role, per usage', () => {
|
|
const background = buildColorModel(SOURCE, THEME, 'background')!;
|
|
assert.equal(hex(projectColor(background, rgb(SOURCE.bg[0]!))!), THEME.bg);
|
|
assert.equal(hex(projectColor(background, rgb(SOURCE.surface[0]!))!), THEME.surface);
|
|
const text = buildColorModel(SOURCE, THEME, 'text')!;
|
|
assert.equal(hex(projectColor(text, rgb(SOURCE.text[0]!))!), THEME.text);
|
|
assert.equal(hex(projectColor(text, rgb(SOURCE.accent[0]!))!), THEME.accent, 'accent text is exactly the theme accent');
|
|
});
|
|
|
|
test('unlisted neutrals land on the theme curve in order; chromatic non-accent colors stay', () => {
|
|
const model = buildColorModel(SOURCE, THEME, 'background')!;
|
|
const steps = [15, 60, 120, 200].map(v => projectColor(model, [v, v, v + 2])!);
|
|
const lightness = steps.map(step => rgbToOklab(step)[0]);
|
|
for (let i = 1; i < lightness.length; i++) assert.ok(lightness[i]! < lightness[i - 1]!, 'light theme: darker as the site gets lighter');
|
|
assert.equal(projectColor(model, [249, 24, 128]), null, 'a pink like-heart is left alone');
|
|
assert.equal(axisPosition(model, [249, 24, 128]), null);
|
|
assert.equal(buildColorModel(SOURCE, { accent: '#ffffff' }), null, 'a theme needs a background and text');
|
|
});
|
|
|
|
test('derived roles follow the base structure', () => {
|
|
const dark = deriveColors({ bg: '#15202b', text: '#e7e9ea', accent: '#1d9bf0' });
|
|
const positions = rolePositions(dark);
|
|
for (const role of ['surface', 'elevated', 'border', 'muted'] as const) {
|
|
assert.ok(Math.abs(positions[role] - BASE_STEPS.dark[role]) < 0.03, `${role}: ${positions[role]}`);
|
|
}
|
|
assert.equal(deriveColors({ bg: '#ffffff', text: '#000000', accent: '#000000', border: '#123456' }).border, '#123456');
|
|
});
|
|
|
|
test('a foreground the site paints dimmer than its secondary text becomes the theme\'s secondary text', () => {
|
|
const hex = (h: string) => [parseInt(h.slice(1, 3), 16), parseInt(h.slice(3, 5), 16), parseInt(h.slice(5, 7), 16)];
|
|
const text = buildColorModel(SOURCE, THEME, 'text')!;
|
|
// The site's border gray used as an icon color, and grays around it: all between the most raised surface and the secondary text.
|
|
for (const dim of ['50,52,58', '44,46,50', '70,72,78', '110,114,120']) assert.deepEqual(projectColor(text, rgb(dim)), hex(THEME.muted), dim);
|
|
// The ends keep their place: a surface-dark foreground is a label on a filled surface, brighter than secondary is text.
|
|
assert.deepEqual(projectColor(text, rgb('10,10,12')), hex(THEME.bg));
|
|
assert.notDeepEqual(projectColor(text, rgb('20,21,24')), hex(THEME.muted));
|
|
assert.notDeepEqual(projectColor(text, rgb('32,33,38')), hex(THEME.muted));
|
|
assert.deepEqual(projectColor(text, rgb('140,144,150')), hex(THEME.muted));
|
|
assert.notDeepEqual(projectColor(text, rgb('190,192,196')), hex(THEME.muted));
|
|
assert.deepEqual(projectColor(text, rgb('235,236,238')), hex(THEME.text));
|
|
// Backgrounds and borders are not foregrounds: the same gray stays the theme's border.
|
|
assert.deepEqual(projectColor(buildColorModel(SOURCE, THEME, 'border')!, rgb('50,52,58')), hex(THEME.border));
|
|
assert.deepEqual(projectColor(buildColorModel(SOURCE, THEME, 'background')!, rgb('50,52,58')), hex(THEME.border));
|
|
assert.equal(buildColorModel(SOURCE, THEME, 'background')!.dim, null);
|
|
// A theme without a secondary text color has nothing to raise it to.
|
|
assert.equal(buildColorModel(SOURCE, { bg: THEME.bg, text: THEME.text }, 'text')!.dim, null);
|
|
});
|