waybetter-for-x/apps/desktop/tests/themeChat.test.ts
2026-10-01 06:01:13 +07:00

223 lines
16 KiB
TypeScript

// X's chat (XChat) under a theme: the token table and the chat surfaces keep the engine's contrast
// guarantees on every built-in theme and on arbitrary palettes, translucent fills stay translucent,
// and nothing here declares layout. Shapes are from live x.com (2026-09-30); the rendered check is
// the theme matrix (scripts/theme-matrix.mjs, the `chat-*` cells).
import assert from 'node:assert/strict';
import test from 'node:test';
import { contrastRatio, hexToRgb, mix } from '../../../waybetter/packages/runtime/src/themeApi.ts';
import { ensureContrast, type Palette } from '../../../waybetter/packages/runtime/src/themeSpec.ts';
import { X_PALETTES, buildThemeCss, mapColor, paletteFor, resolveDeclared, type ThemeChoice } from '../src/inject/theme.ts';
import { X_PRIMITIVE_VARS, accentLabel, chatColors, chatSurfaceCss, fadedAlpha, hexToHslTriple, xTokenOverrides } from '../src/inject/themeChat.ts';
import { BUILTIN_THEMES } from '../src/inject/themeSpec.ts';
const rule = (selectorText: string, style: Record<string, string>) => ({ selectorText, style: { getPropertyValue: (name: string) => style[name] ?? '' } });
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)) as Palette }));
const VARS = { '--x-white': '#ffffff', '--x-black': '#000000', '--x-white-15': 'rgba(255, 255, 255, 0.15)', '--x-white-60': 'rgba(255, 255, 255, 0.6)' };
const toHex = (rgb: number[]) => `#${rgb.map(v => Math.round(v).toString(16).padStart(2, '0')).join('')}`;
const hslToHex = (triple: string) => {
const [h, s, l] = triple.replace(/%/g, '').split(' ').map(Number) as [number, number, number];
const f = (k: number) => { const a = (s / 100) * Math.min(l / 100, 1 - l / 100); const x = (k + h / 30) % 12; return l / 100 - a * Math.max(-1, Math.min(x - 3, 9 - x, 1)); };
return toHex([f(0), f(8), f(4)].map(v => v * 255));
};
/** A token's value as the color it paints over `base` (hex, `rgba()` composited, or an HSL triple). */
function painted(value: string, base: string): string {
if (value.startsWith('#')) return value;
const m = /^rgba\((\d+), (\d+), (\d+), ([\d.]+)\)$/.exec(value);
if (m) return mix(base, toHex([+m[1]!, +m[2]!, +m[3]!]), +m[4]!);
return hslToHex(value);
}
const tokensOf = (palette: Palette) => new Map(xTokenOverrides(palette));
/** Every pair the chat draws, as `[label, foreground, background, minimum ratio]`. */
function pairs(palette: Palette): Array<[string, string, string, number]> {
const t = tokensOf(palette);
const c = chatColors(palette);
const get = (name: string, base = c.bg) => painted(t.get(name)!, base);
const out: Array<[string, string, string, number]> = [];
const text = get('--x-fg-primary'), muted = get('--x-fg-secondary'), placeholder = get('--x-fg-tertiary');
// Panels, received bubbles, menus.
for (const [name, surface] of [['bg', get('--x-bg-primary')], ['surface', get('--x-bg-secondary')], ['elevated', get('--x-bg-tertiary')], ['card', get('--card')], ['gray-0', get('--color-gray-0')], ['gray-50 (received bubble)', get('--color-gray-50')], ['modal', get('--x-bg-modal')]] as const) {
out.push([`text on ${name}`, text, surface, 7], [`secondary on ${name}`, muted, surface, 4.5], [`link on ${name}`, get('--x-fg-brand'), surface, 4.5]);
out.push([`shadcn text on ${name}`, get('--color-text'), surface, 7], [`gray-700 on ${name}`, get('--color-gray-700'), surface, 4.5], [`chat accent on ${name}`, get('--chat-accent'), surface, 4.5]);
out.push([`destructive on ${name}`, get('--x-fg-destructive'), surface, 4.5]);
}
// Translucent text-colored tints over the panel and over a menu: composited before measuring.
for (const base of [c.bg, c.surface]) {
for (const tint of ['--x-btn-tertiary', '--x-btn-tertiary-hover', '--x-bg-inputs', '--x-bg-quaternary', '--x-btn-ghost-hover', '--x-hover-subtle', '--x-fg-alpha-05', '--x-fg-alpha-10']) {
const surface = get(tint, base);
out.push([`text on ${tint} over ${base}`, text, surface, 7], [`secondary on ${tint} over ${base}`, muted, surface, 4.5]);
}
}
out.push(['placeholder in the search field', placeholder, get('--x-bg-inputs'), 4.5]);
// The floating header and composer swap the tertiary button for its opaque form.
out.push(['text in the composer', text, get('--x-btn-tertiary-opaque'), 7], ['placeholder in the composer', placeholder, get('--x-btn-tertiary-opaque'), 4.5]);
out.push(['text on a secondary button', text, get('--x-btn-secondary'), 7], ['text on a hovered secondary button', text, get('--x-btn-secondary-hover'), 7]);
// Fills with their own labels.
for (const state of ['', '-hover', '-pressed']) {
out.push([`label on the primary button${state}`, get('--x-fg-inverted'), get(`--x-btn-primary${state}`), 4.5]);
out.push([`label on the brand button${state}`, c.onAccent, get(`--x-btn-brand${state}`), 4.5]);
}
out.push(['label in a sent bubble', c.onAccent, c.accent, 4.5], ['shadcn accent label', get('--chat-accent-foreground'), get('--chat-accent'), 4.5]);
out.push(['tooltip', get('--x-tooltip-fg'), get('--x-tooltip-bg'), 7], ['highlighted row', get('--accent-foreground'), get('--accent'), 7]);
// The pinned-message pill.
out.push(['pinned title', c.text, c.floating, 7], ['pinned body', c.muted, c.floating, 4.5]);
// The link card's icon tile (X fills it with a divider step of its gray ramp).
out.push(['icon on a tile', c.text, c.tile, 7], ['secondary text on a tile', c.muted, c.tile, 4.5]);
return out;
}
test('every built-in theme: each chat pair keeps its contrast (tints composited over their surface)', () => {
for (const { id, palette } of themed) {
for (const [label, fg, bg, min] of pairs(palette)) {
const ratio = contrastRatio(fg, bg);
assert.ok(ratio >= min - 0.05, `${id}: ${label} = ${ratio.toFixed(2)} (needs ${min}); ${fg} on ${bg}`);
}
}
});
test('any palette the engine accepts keeps the same guarantees (400 generated palettes)', () => {
let seed = 20260930;
const next = () => { seed = (seed * 1664525 + 1013904223) >>> 0; return seed / 2 ** 32; };
const hex = () => toHex([next() * 255, next() * 255, next() * 255]);
let checked = 0;
for (let i = 0; i < 400; i++) {
// Surfaces sit next to the background, as the engine derives them; text, secondary text and accent are anything.
const bg = hex(), toward = hex();
const palette = ensureContrast({ bg, surface: mix(bg, toward, 0.04 + next() * 0.08), elevated: mix(bg, toward, 0.1 + next() * 0.12), border: hex(), text: hex(), muted: hex(), accent: hex() }) as Palette;
// A mid-tone background has no text color at 7:1 (black on it is the best there is): nothing to guarantee.
if ([palette.bg, palette.surface, palette.elevated].some(surface => contrastRatio(palette.text, surface) < 7 || contrastRatio(palette.muted, surface) < 4.5 || contrastRatio(palette.accent, surface) < 4.5)) continue;
checked++;
for (const [label, fg, bg, min] of pairs(palette)) {
const ratio = contrastRatio(fg, bg);
assert.ok(ratio >= min - 0.05, `#${i} ${JSON.stringify(palette)}: ${label} = ${ratio.toFixed(2)} (needs ${min})`);
}
}
assert.ok(checked >= 200, `${checked} palettes had room for the guarantees`);
});
test('the label on an accent fill always reaches 4.5:1, and prefers white or the page color', () => {
assert.equal(accentLabel('#1d9bf0', '#000000'), '#000000');
assert.equal(accentLabel('#a8321f', '#fbf8f1'), '#ffffff');
assert.equal(accentLabel('#88c0d0', '#2e3440'), '#2e3440');
for (let r = 0; r <= 255; r += 17) for (let g = 0; g <= 255; g += 17) for (let b = 0; b <= 255; b += 51) {
const accent = toHex([r, g, b]);
for (const bg of ['#000000', '#2e3440', '#6b7280', '#fbf8f1', '#ffffff']) {
const ratio = contrastRatio(accent, accentLabel(accent, bg));
assert.ok(ratio >= 4.5, `${accent} on a ${bg} theme: ${ratio.toFixed(2)}`);
}
}
});
test('a faded label in a bubble (X\'s time stamp) is only as faint as 4.5:1 allows', () => {
for (const { id, palette } of themed) {
const c = chatColors(palette);
const alpha = fadedAlpha(c.onAccent, c.accent, 0.75);
assert.ok(alpha >= 0.75 && alpha <= 1);
assert.ok(contrastRatio(mix(c.accent, c.onAccent, alpha), c.accent) >= 4.5 || alpha === 1, `${id}: ${alpha}`);
}
});
test('Tailwind\'s opacity form resolves to the color it paints', () => {
assert.equal(resolveDeclared('color-mix(in oklab, var(--x-white) 15%, transparent)', VARS), 'rgba(255, 255, 255, 0.15)');
assert.equal(resolveDeclared('color-mix(in srgb, #000000 40%, transparent)', VARS), 'rgba(0, 0, 0, 0.4)');
assert.equal(resolveDeclared('color-mix(in oklab, var(--x-white-60) 50%, transparent)', VARS), 'rgba(255, 255, 255, 0.3)');
assert.equal(resolveDeclared('var(--x-white)', VARS), '#ffffff');
// Tokens the theme re-declares, and forms that are not "a color at N%", stay as written.
assert.equal(resolveDeclared('var(--x-btn-tertiary)', VARS), 'var(--x-btn-tertiary)');
assert.equal(resolveDeclared('color-mix(in oklab, hsl(var(--chat-accent)) 50%, transparent)', VARS), 'color-mix(in oklab, hsl(var(--chat-accent)) 50%, transparent)');
assert.equal(resolveDeclared('color-mix(in srgb, var(--x-white) 10%, var(--x-bg-primary))', VARS), 'color-mix(in srgb, #ffffff 10%, var(--x-bg-primary))');
assert.equal(resolveDeclared('hsl(var(--color-brand) / 0.1)', VARS), 'hsl(var(--color-brand) / 0.1)');
});
test('the pinned-message pill: a 15% white fill stays a 15% tint of the theme text, never a solid fill', () => {
// Live x.com: `.dark:bg-white/15` is `var(--x-white)` with the real value in a nested `@supports`
// (the framework's collectRules hands over the nested value). Read as its fallback it is a solid
// white fill, which a theme maps to its filled-button color: Nord's pill turned #88c0d0 under
// near-white text (1.74:1) and secondary text (1.06:1).
const nord = paletteFor(choice('nord')) as Palette;
const selector = '.dark\\:bg-white\\/15:where([data-theme="dark"], [data-theme="dark"] *)';
const css = buildThemeCss([rule(selector, { 'background-color': 'color-mix(in oklab, var(--x-white) 15%, transparent)' })], X_PALETTES.dark, nord, VARS);
const [r, g, b] = hexToRgb(nord.text);
assert.ok(css.includes(`${selector}{background-color:rgba(${r}, ${g}, ${b}, 0.15) !important}`), 'translucent, in the theme\'s text color');
assert.equal(mapColor('background-color', resolveDeclared('var(--x-white)', VARS), X_PALETTES.dark, nord), `rgb(${hexToRgb(nord.inverse!).join(', ')})`, 'the fallback alone is what went wrong');
// Whatever scrolls under it, the pill itself is an opaque raised surface with readable lines.
const c = chatColors(nord);
assert.ok(css.includes(`[data-testid="pinned-message-banner"] > div{background-color:${c.floating} !important;border-color:${c.border} !important}`));
assert.ok(contrastRatio(c.text, c.floating) >= 7 && contrastRatio(c.muted, c.floating) >= 4.5);
});
test('a translucent fill on a light theme over Lights out becomes a tint of the dark text, not white', () => {
const paper = paletteFor(choice('paper')) as Palette;
const mapped = mapColor('background-color', resolveDeclared('color-mix(in oklab, var(--x-white) 10%, transparent)', VARS), X_PALETTES.dark, paper);
assert.equal(mapped, `rgba(${hexToRgb(paper.text).join(', ')}, 0.1)`);
const surface = mix(paper.bg, paper.text, 0.1);
assert.ok(contrastRatio(paper.text, surface) >= 7 && contrastRatio(paper.muted, surface) >= 4.5);
});
test('the token table covers X\'s semantic layer once, on every root X declares it on', () => {
const nord = paletteFor(choice('nord')) as Palette;
const table = xTokenOverrides(nord);
const names = table.map(([name]) => name);
assert.equal(new Set(names).size, names.length, 'one value per token');
for (const name of ['--x-bg-primary', '--x-bg-modal', '--x-bg-inputs', '--x-fg-primary', '--x-fg-tertiary', '--x-fg-inverted', '--x-border-normal', '--x-btn-primary', '--x-btn-secondary',
'--x-btn-tertiary', '--x-btn-tertiary-opaque', '--x-btn-ghost-hover', '--x-btn-brand', '--x-hover-subtle', '--x-fg-alpha-10', '--x-bg-alpha-70', '--x-blue-500', '--color-blue-500',
'--background', '--color-background', '--color-text', '--card', '--popover', '--muted', '--muted-foreground', '--border', '--chat-accent', '--chat-accent-foreground', '--color-gray-50', '--color-gray-1100']) {
assert.ok(names.includes(name), name);
}
const css = buildThemeCss([], X_PALETTES.dark, nord);
const root = css.split('\n').find(line => line.startsWith(':root,:host,[data-theme],.dark,.light,.dark-theme,.light-theme{'))!;
for (const [name, value] of table) assert.ok(root.includes(`${name}:${value} !important`), name);
assert.equal(root.split('--x-bg-primary:').length, 2, 'declared once');
// X's status colors, its scrims and its shadows are not the theme's to change.
for (const name of ['--x-red-500', '--x-overlay-normal', '--x-shadow-popup', '--x-fg-on-color']) assert.ok(!names.includes(name), name);
// The header's fade into the page is an inline `hsl(var(--color-background))` gradient.
assert.equal(new Map(table).get('--color-background'), hexToHslTriple(nord.bg));
});
test('an accent-only choice keeps X\'s tokens and only moves the accent', () => {
const css = buildThemeCss([], X_PALETTES.dark, { accent: '#f91880' });
assert.ok(css.includes(`--chat-accent:${hexToHslTriple('#f91880')} !important`));
assert.ok(!css.includes('--x-bg-primary') && !css.includes('--x-btn-tertiary') && !css.includes('pinned-message-banner'));
});
test('the on-color label inside an accent fill follows the fill', () => {
const nord = paletteFor(choice('nord')) as Palette;
const css = buildThemeCss([rule('.bg-chat-accent', { 'background-color': 'hsl(var(--chat-accent))' }), rule('.bg-btn-brand', { 'background-color': 'var(--x-blue-500)' })], X_PALETTES.dark, nord, { '--x-blue-500': '#1d9bf0' });
const line = css.split('\n').find(l => l.startsWith(':is(.bg-chat-accent,.bg-btn-brand,') && l.includes('--x-fg-on-color'));
assert.ok(line?.endsWith(`{--x-fg-on-color:${accentLabel(nord.accent, nord.bg)} !important}`), line);
});
test('chat surface rules: X\'s test ids and token classes, colors only', () => {
for (const { id, palette } of themed) {
const css = chatSurfaceCss(palette);
for (const needle of ['[data-testid="pinned-message-banner"] > div{', ':is(.bg-gray-100,.bg-gray-200){', '[data-testid="dm-container"] .bg-chat-accent{', '[data-testid="dm-container"] .text-chat-accent{', '[data-slot="xds-menu-content"]{', '.scrollbar-thin-custom{scrollbar-color:']) {
assert.ok(css.includes(needle), `${id}: ${needle}`);
}
// No size, position, display, spacing or overflow: X's panel layout, its floating header and its composer stay X's.
for (const block of css.split('\n')) {
const body = block.slice(block.indexOf('{') + 1, -1);
for (const declaration of body.split(';')) {
const property = declaration.split(':')[0]!.trim();
assert.ok(['background-color', 'border-color', 'color', 'scrollbar-color', 'text-decoration'].includes(property), `${id}: ${property} in ${block.slice(0, 60)}`);
}
}
}
});
test('the primitives the remap resolves are X\'s white, black, their alpha steps and the neutral scale', () => {
for (const name of ['--x-white', '--x-black', '--x-white-15', '--x-white-60', '--x-black-40', '--x-neutral-900', '--x-neutral-1000']) assert.ok(X_PRIMITIVE_VARS.includes(name), name);
assert.ok(X_PRIMITIVE_VARS.every(name => !/^--x-(bg|fg|btn|border)-/.test(name)), 'semantic tokens are re-declared, never resolved into rules');
});
test('HSL triples keep one decimal, so a pair at its threshold survives the round trip', () => {
assert.equal(hexToHslTriple('#000000'), '0 0% 0%');
assert.equal(hexToHslTriple('#ffffff'), '0 0% 100%');
for (const { id, palette } of themed) {
for (const role of ['bg', 'surface', 'elevated', 'border', 'text', 'muted', 'accent'] as const) {
const back = hexToRgb(hslToHex(hexToHslTriple(palette[role])));
hexToRgb(palette[role]).forEach((v, i) => assert.ok(Math.abs(v - back[i]!) <= 1, `${id} ${role}: ${palette[role]} -> ${back}`));
}
}
});