import assert from 'node:assert/strict'; import test from 'node:test'; import { X_PALETTES, paletteFor, type ThemeChoice } from '../src/inject/theme.ts'; import { BUILTIN_THEMES } from '../src/inject/themeSpec.ts'; import { contrastRatio, hexToRgb, relativeLuminance } from '../../../waybetter/packages/runtime/src/themeApi.ts'; import { buildColorModel, oklabToRgb, projectColor, rgbToOklab, type Usage } from '../src/inject/themeTokens.ts'; const choice = (preset: string): ThemeChoice => ({ preset, accent: null, overrides: {}, themes: [] }); const themed = BUILTIN_THEMES.filter(theme => theme.colors).map(theme => ({ id: theme.id, palette: paletteFor(choice(theme.id))! })); const hex = (rgb: number[]) => `#${rgb.map(v => v.toString(16).padStart(2, '0')).join('')}`; test('OKLab round trip is exact to one step', () => { for (const rgb of [[0, 0, 0], [255, 255, 255], [29, 155, 240], [22, 24, 28], [83, 100, 113]] as [number, number, number][]) { const back = oklabToRgb(rgbToOklab(rgb)); rgb.forEach((v, i) => assert.ok(Math.abs(v - back[i]!) <= 1, `${rgb} -> ${back}`)); } }); test('every color X lists for a role becomes exactly that theme role, per usage', () => { const roleFor: Record = { background: ['bg', 'surface', 'elevated'], border: ['border'], text: ['text', 'muted'] }; for (const { id, palette } of themed) { for (const [mode, source] of Object.entries(X_PALETTES)) { for (const usage of ['background', 'border', 'text'] as Usage[]) { const model = buildColorModel(source, palette, usage)!; for (const role of roleFor[usage]) { const want = palette[role as keyof typeof palette]; if (!want) continue; // The first key of a role is always that role for its own usage (later keys may be shared). const key = source[role as keyof typeof source][0]!; assert.equal(hex(projectColor(model, key.split(',').map(Number) as [number, number, number])!), want.toLowerCase(), `${id}/${mode}/${usage}/${role} ${key}`); } } } } }); test('no neutral X paints keeps X gray: every step of X\'s background→text axis is themed, in order', () => { for (const { id, palette } of themed) { for (const [mode, source] of Object.entries(X_PALETTES)) { const model = buildColorModel(source, palette, 'text')!; const [from, to] = [source.bg[0]!, source.text[0]!].map(key => key.split(',').map(Number)); let last = -1; for (let i = 0; i <= 20; i++) { const grey = from!.map((v, c) => Math.round(v + (to![c]! - v) * (i / 20))) as [number, number, number]; const out = projectColor(model, grey); assert.ok(out, `${id}/${mode}: ${grey} left as X painted it`); // Distance from the theme background grows monotonically along X's axis. const distance = contrastRatio(hex(out!), palette.bg!); assert.ok(distance >= last - 0.35, `${id}/${mode}: step ${i} ${grey} -> ${out} goes backwards (${distance.toFixed(2)} < ${last.toFixed(2)})`); last = Math.max(last, distance); } } } }); test('X blue shades follow the theme accent; meaningful colors (likes, reposts, errors) stay X\'s', () => { for (const { id, palette } of themed) { const model = buildColorModel(X_PALETTES.dark, palette, 'background')!; const hover = projectColor(model, [26, 140, 216])!; const base = projectColor(model, [29, 155, 240])!; assert.ok(relativeLuminance(hex(hover)) <= relativeLuminance(hex(base)) + 0.001, `${id}: X's darker hover blue must stay darker`); const [ar, ag, ab] = hexToRgb(palette.accent!); const [r, g, b] = base; assert.ok(Math.abs(r - ar) + Math.abs(g - ag) + Math.abs(b - ab) <= 6, `${id}: X blue -> ${base}, accent ${palette.accent}`); for (const meaningful of [[249, 24, 128], [0, 186, 124], [244, 33, 46]] as [number, number, number][]) { assert.equal(projectColor(model, meaningful), null, `${id}: ${meaningful} must stay X's`); } } }); test('a light theme over Lights out and a dark theme over Default both invert along the axis', () => { const classic = paletteFor(choice('classic'))!; const nightInk = paletteFor(choice('night-ink'))!; const onDark = buildColorModel(X_PALETTES.dark, classic, 'background')!; const onLight = buildColorModel(X_PALETTES.light, nightInk, 'background')!; // An unlisted Lights out hover grey is near X's black: on Classic it must be near Classic's white. assert.ok(relativeLuminance(hex(projectColor(onDark, [12, 12, 13])!)) > 0.8); // An unlisted Default grey is near X's white: on Night Ink it must be near Night Ink's black. assert.ok(relativeLuminance(hex(projectColor(onLight, [250, 251, 251])!)) < 0.05); }); test('every built-in theme follows the base structure order: surface, raised, border, secondary text, text', async () => { const { rolePositions } = await import('../src/inject/themeTokens.ts'); for (const theme of BUILTIN_THEMES) { if (!theme.colors) continue; const p = rolePositions(theme.colors); // High Contrast draws white rules on purpose; its surfaces and text still follow the order. const borderOk = theme.id === 'high-contrast' || (p.elevated <= p.border + 0.02 && p.border < p.muted); assert.ok(p.surface >= 0 && p.surface <= p.elevated && borderOk && p.muted < 1, `${theme.id}: ${JSON.stringify(p)}`); } }); test('a theme that names only background, text and accent gets the base structure', async () => { const { deriveColors, rolePositions, BASE_STEPS } = await import('../src/inject/themeTokens.ts'); const { sanitizeSpec } = await import('../src/inject/themeSpec.ts'); const theme = sanitizeSpec({ id: 'mine', name: 'Mine', colors: { bg: '#101418', text: '#f0f2f5', accent: '#ff7a00' } }); assert.ok(theme.colors, 'three colors are enough'); const p = rolePositions(theme.colors!); for (const role of ['surface', 'elevated', 'border', 'muted'] as const) assert.ok(Math.abs(p[role] - BASE_STEPS.dark[role]) < 0.02, `${role} ${p[role]}`); // Named roles are kept as given. assert.equal(deriveColors({ bg: '#ffffff', text: '#000000', accent: '#0000ff', border: '#123456' }).border, '#123456'); assert.equal(sanitizeSpec({ id: 'bad', name: 'Bad', colors: { bg: '#fff', text: '#000000', accent: '#0000ff' } }).colors, null); }); test('X\'s dimmed foregrounds (the composer toolbar icons) reach the secondary text contrast in every theme and X mode', async () => { // Live x.com (2026-09-30, Lights out): the composer toolbar icons are `svg.r-1otekoa{color:rgb(51,54,57)}`, // one of X's border grays (1.7:1 on X's own black). Placed by axis position it landed between a // theme's background and its secondary text: Classic painted it rgb(148,157,165) on white, 2.75:1. const { mapColor } = await import('../src/inject/theme.ts'); const dimmed: Record<'dark' | 'dim' | 'light', string[]> = { dark: ['51,54,57', '47,51,54', '62,65,68'], dim: ['56,68,77', '66,83,100'], light: ['207,217,222', '170,184,194'] }; for (const { id, palette } of themed) { for (const mode of ['dark', 'dim', 'light'] as const) { for (const key of dimmed[mode]) { for (const property of ['color', 'fill']) { const mapped = mapColor(property, `rgb(${key.split(',').join(', ')})`, X_PALETTES[mode], palette)!; const color = hex(mapped.match(/\d+/g)!.map(Number)); for (const surface of [palette.bg!, palette.surface!, palette.elevated!]) { assert.ok(contrastRatio(color, surface) >= 4.5, `${id} / ${mode}: ${property} ${key} -> ${mapped} on ${surface} = ${contrastRatio(color, surface).toFixed(2)}`); } } } } // As a border or a background the same gray is still the theme's border, not its text. assert.equal(mapColor('border-color', 'rgb(51, 54, 57)', X_PALETTES.dark, palette), `rgb(${hexToRgb(palette.border!).join(', ')})`, id); } });