126 lines
7.7 KiB
TypeScript
126 lines
7.7 KiB
TypeScript
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<Usage, string[]> = { 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);
|
|
}
|
|
});
|