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); });