waybetter-for-x/apps/desktop/tests/theme.test.ts

273 lines
19 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import { contrastRatio, hexToRgb } from '../../../waybetter/packages/runtime/src/themeApi.ts';
import { PRESETS, THEME_FLAGS, X_PALETTES, buildThemeCss, detectXPalette, hexToHslTriple, mapColor, mapInlineColor, normalizeTheme, paletteFor, type ThemeChoice } from '../src/inject/theme.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, accent: string | null = null): ThemeChoice => ({ preset, accent, overrides: {}, themes: [] });
const nightInk = paletteFor(choice('night-ink'))!;
test('normalizeTheme accepts only known presets and hex accents', () => {
assert.deepEqual(normalizeTheme({ preset: 'classic', accent: '#ABCDEF' }), { ...choice('classic'), accent: '#abcdef' });
assert.deepEqual(normalizeTheme({ preset: 'evil', accent: 'red;x' }), choice('x'));
assert.deepEqual(normalizeTheme(null), choice('x'));
});
test('X default without accent applies nothing; accent alone only recolors the accent', () => {
assert.equal(paletteFor(choice('x')), null);
assert.deepEqual(paletteFor(choice('x', '#f91880')), { accent: '#f91880' });
const css = buildThemeCss([rule('.a', { 'background-color': 'rgb(0, 0, 0)' }), rule('.b', { color: 'rgb(29, 155, 240)' })], X_PALETTES.dark, { accent: '#f91880' });
assert.ok(!css.includes('.a{'));
assert.ok(css.includes('.b{color:rgb(249, 29, 128) !important}') || css.includes('.b{color:rgb(249, 24, 128) !important}'));
});
test('palette colors are remapped by role per property, keeping alpha', () => {
const dark = X_PALETTES.dark;
assert.equal(mapColor('background-color', 'rgb(0, 0, 0)', dark, nightInk), 'rgb(7, 8, 12)');
assert.equal(mapColor('border-bottom-color', 'rgb(47, 51, 54)', dark, nightInk), 'rgb(28, 33, 48)');
assert.equal(mapColor('color', 'rgb(113, 118, 123)', dark, nightInk), 'rgb(142, 149, 171)');
assert.equal(mapColor('background-color', 'rgba(231, 233, 234, 0.1)', dark, nightInk), 'rgba(238, 240, 247, 0.1)');
// A solid text-colored background is X's filled button: it becomes the theme's inverse fill.
assert.equal(mapColor('background-color', 'rgb(231, 233, 234)', dark, nightInk), 'rgb(238, 240, 247)');
// Non-palette colors (likes, reposts, verified) are untouched.
assert.equal(mapColor('color', 'rgb(249, 24, 128)', dark, nightInk), null);
});
test('buildThemeCss re-declares X rules, overrides X variables and resolves var() references', () => {
const css = buildThemeCss([
rule('.r-kemksi', { 'background-color': 'rgb(0, 0, 0)' }),
rule('.chat', { color: 'var(--x-white)' }),
rule('.untouched', { 'background-color': 'rgb(120, 86, 255)' }),
], X_PALETTES.dark, nightInk, { '--x-white': '#ffffff' });
assert.ok(css.includes('.r-kemksi{background-color:rgb(7, 8, 12) !important}'));
assert.ok(css.includes('.chat{color:rgb(238, 240, 247) !important}'));
assert.ok(!css.includes('.untouched'));
assert.ok(css.includes('--x-bg-primary:#07080c !important'));
assert.ok(css.includes(`--background:${hexToHslTriple('#07080c')} !important`));
assert.ok(css.includes('html,body{background-color:#07080c !important}'));
});
test('XChat sent bubble: a variable accent fill keeps its label readable', () => {
// Live x.com (2026-09-27): `.bg-chat-accent{background-color:hsl(var(--chat-accent))}` with a
// `.text-white{color:var(--x-white)}` label. Terminal's light green accent needs a dark label.
const terminal = paletteFor(choice('terminal'))!;
const css = buildThemeCss([
rule('.bg-chat-accent', { 'background-color': 'hsl(var(--chat-accent))' }),
rule('.text-white', { color: 'var(--x-white)' }),
rule('.text-white-60', { color: 'var(--x-white-60)' }),
rule('.bg-gray-50', { 'background-color': 'hsl(var(--color-gray-50))' }),
rule('.bg-brand-tint', { 'background-color': 'hsl(var(--color-brand) / 0.1)' }),
], X_PALETTES.dark, terminal, { '--x-white': '#ffffff', '--x-white-60': 'rgba(255, 255, 255, 0.6)' });
const [r, g, b] = hexToRgb(terminal.bg!);
const lines = css.split('\n');
const label = lines.find(line => line.startsWith(':is(.bg-chat-accent,') && line.includes(') :is(.text-white,'));
assert.ok(label, 'the chat bubble counts as an accent fill for white labels');
assert.ok(label.endsWith(`{color:${terminal.bg} !important}`), 'label is the dark page color on a light accent');
assert.ok(lines.some(line => line.startsWith(':is(.bg-chat-accent,') && line.endsWith(`:is(.text-white-60){color:rgba(${r}, ${g}, ${b}, 0.75) !important}`)));
// Other variable fills (gray surfaces) and faint accent tints are not accent fills.
assert.ok(!css.includes('.bg-gray-50,') && !css.includes('.bg-brand-tint'));
});
test('detectXPalette recognizes Default, Dim and Lights out', () => {
assert.equal(detectXPalette('rgb(255, 255, 255)'), 'light');
assert.equal(detectXPalette('rgb(21, 32, 43)'), 'dim');
assert.equal(detectXPalette('rgb(0, 0, 0)'), 'dark');
assert.equal(hexToHslTriple('#000000'), '0 0% 0%');
});
// WCAG 2.x relative luminance / contrast ratio.
function luminance(color: string): number {
const [r, g, b] = /rgba?\((\d+), (\d+), (\d+)/.exec(color)!.slice(1).map(Number).map(v => {
const c = v / 255;
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}) as [number, number, number];
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
}
function contrast(a: string, b: string): number {
const [x, y] = [luminance(a), luminance(b)].sort((m, n) => n - m) as [number, number];
return (x + 0.05) / (y + 0.05);
}
const rgb = (key: string) => `rgb(${key.split(',').join(', ')})`;
test('every preset keeps WCAG contrast for every X text role over every X surface, in all X modes', () => {
const failures: string[] = [];
for (const preset of PRESETS.filter(item => item.palette)) {
const palette = paletteFor(choice(preset.id))!;
for (const [mode, source] of Object.entries(X_PALETTES)) {
const surfaces = (['bg', 'surface', 'elevated'] as const).flatMap(role => source[role].map(key => [role, key] as const));
for (const via of ['class', 'inline'] as const) {
const map = (property: string, key: string) => via === 'class' ? mapColor(property, rgb(key), source, palette) : mapInlineColor(property, rgb(key), source, palette);
for (const [bgRole, bgKey] of surfaces) {
const bg = map('background-color', bgKey);
assert.ok(bg, `${preset.id}/${mode}/${via}: ${bgRole} ${bgKey} must be remapped`);
for (const fgRole of ['text', 'muted', 'accent'] as const) {
for (const fgKey of source[fgRole]) {
const fg = map('color', fgKey);
assert.ok(fg, `${preset.id}/${mode}/${via}: ${fgRole} ${fgKey} must be remapped`);
const ratio = contrast(fg, bg);
if (ratio < 4.5) failures.push(`${preset.id}/${mode}/${via}: ${fgRole} ${fg} on ${bgRole} ${bg} = ${ratio.toFixed(2)}`);
}
}
}
}
// X's filled button (Post/Tweet, Follow): inline background with an inline label inside it.
for (const fillKey of source.inverse) {
const fill = mapInlineColor('background-color', rgb(fillKey), source, palette)!;
assert.ok(fill, `${preset.id}/${mode}: inverse ${fillKey} must be remapped`);
for (const labelKey of source.onInverse) {
const label = mapInlineColor('color', rgb(labelKey), source, palette, true)!;
const ratio = contrast(label, fill);
if (ratio < 4.5) failures.push(`${preset.id}/${mode}: button label ${label} on ${fill} = ${ratio.toFixed(2)}`);
}
// The button must stand out from the page (non-text UI contrast).
const page = mapInlineColor('background-color', rgb(source.bg[0]!), source, palette)!;
if (contrast(fill, page) < 3) failures.push(`${preset.id}/${mode}: button ${fill} on page ${page} = ${contrast(fill, page).toFixed(2)}`);
}
}
}
assert.deepEqual(failures, []);
});
test('a light theme over Lights out inverts by role: text goes dark, the Tweet button keeps a readable label', () => {
const classic = paletteFor(choice('classic'))!;
const css = buildThemeCss([], X_PALETTES.dark, classic);
// Display names / tweet text / selected tab are inline `color: rgb(231, 233, 234)` on X.
assert.match(css, /\[style\^="color: rgb\(231, 233, 234\)"\][^{]*\{color:rgb\(41, 47, 51\) !important\}/);
// `background-color:` must not be caught by the `color:` selector.
assert.ok(!css.includes('[style*="color: rgb(0, 0, 0)"]'));
// Filled button: X's light fill becomes the classic blue with a white label inside it.
assert.match(css, /\[style\^="background-color: rgb\(239, 243, 244\)"\][^{]*\{background-color:rgb\(0, 119, 176\) !important\}/);
assert.ok(css.includes(':is([style^="background-color: rgb(239, 243, 244)"],[style*="; background-color: rgb(239, 243, 244)"]) :is([style^="color: rgb(15, 20, 25)"],[style*="; color: rgb(15, 20, 25)"]),'));
assert.equal(mapInlineColor('color', 'rgb(15, 20, 25)', X_PALETTES.dark, classic, true), 'rgb(255, 255, 255)');
// Night Ink over Lights out: light button, dark label.
const nightInkCss = buildThemeCss([], X_PALETTES.dark, nightInk);
assert.ok(nightInkCss.includes('{color:rgb(7, 8, 12) !important}'));
});
test('verification badges keep X colors while the accent is remapped', () => {
const css = buildThemeCss([rule('.r-1cvl2hr', { color: 'rgb(29, 155, 240)' }), rule('.r-x::placeholder', { color: 'rgb(29, 155, 240)' })], X_PALETTES.dark, { accent: '#f91880' });
assert.ok(css.includes('.r-1cvl2hr{color:rgb(249, 24, 128) !important}'));
assert.ok(css.includes(':is(svg[data-testid="icon-verified"],svg[aria-label*="Verified"]):is(.r-1cvl2hr){color:rgb(29, 155, 240) !important}'));
assert.ok(!css.includes(':is(.r-x::placeholder)'));
assert.ok(css.includes(':is(svg[data-testid="icon-verified"],svg[aria-label*="Verified"]):is([style^="color: rgb(29, 155, 240)"],[style*="; color: rgb(29, 155, 240)"]){color:rgb(29, 155, 240) !important}'));
});
test('transparent is never a palette color: X base rgba(0,0,0,0) is not re-declared over unknown surfaces', () => {
const classic = paletteFor(choice('classic'))!;
const css = buildThemeCss([
rule('.css-g5y9jx', { 'background-color': 'rgba(0, 0, 0, 0)' }),
rule('.r-cl2sl0', { 'background-color': 'rgb(20, 20, 20)' }),
], X_PALETTES.dark, classic);
assert.equal(mapColor('background-color', 'rgba(0, 0, 0, 0)', X_PALETTES.dark, classic), null);
assert.ok(!css.includes('.css-g5y9jx{'));
// Lights out layers (More menu, hover cards, modal sheets) are rgb(20, 20, 20): a surface.
assert.ok(css.includes('.r-cl2sl0{background-color:rgb(245, 248, 250) !important}'));
});
test('X layers always get an opaque themed surface, hairline and shadow; the modal mask stays a scrim', () => {
for (const preset of PRESETS.filter(item => item.palette)) {
const palette = paletteFor(choice(preset.id))!;
const css = buildThemeCss([], X_PALETTES.dark, palette);
const layer = css.split('\n').find(line => line.startsWith(':is(#layers [role="menu"]'))!;
assert.ok(layer, preset.id);
for (const target of ['[data-testid="HoverCard"]', '[data-testid="Dropdown"]', '[data-testid="sheetDialog"]']) assert.ok(layer.includes(target), `${preset.id} ${target}`);
assert.ok(layer.includes(`background-color:${palette.surface} !important`) && layer.includes('border:1px solid') && layer.includes('box-shadow:'), preset.id);
assert.ok(!layer.includes('aria-modal'), 'the photo viewer shares the modal attributes and must stay dark');
assert.match(css, /#layers \[data-testid="mask"\]\{background-color:rgba\([\d, ]+, 0\.\d+\) !important\}/);
}
});
test('Creator Studio colors with variable alpha are remapped, keeping the variable', () => {
const classic = paletteFor(choice('classic'))!;
assert.equal(mapColor('background-color', 'rgba(0, 0, 0, var(--jbgo))', X_PALETTES.dark, classic), 'rgba(255, 255, 255, var(--jbgo))');
assert.equal(mapColor('color', 'rgba(255, 255, 255, var(--jtxo))', X_PALETTES.dark, classic), 'rgba(41, 47, 51, var(--jtxo))');
const css = buildThemeCss([rule('[data-theme="dark"] .jetfuel-style-root .x', { '--jf-text-color': 'rgba(115, 115, 115, var(--jtxo))' })], X_PALETTES.dark, classic);
assert.ok(css.includes(`[data-theme="dark"] .jetfuel-style-root .x{--jf-text-color:rgba(${hexToRgb(classic.muted!).join(', ')}, var(--jtxo)) !important}`));
});
test('X gray ramp (chat, Articles, settings) is re-derived by role, and its text steps stay legible', () => {
for (const preset of PRESETS.filter(item => item.palette)) {
const palette = paletteFor(choice(preset.id))!;
const css = buildThemeCss([], X_PALETTES.dark, palette);
const root = css.split('\n').find(line => line.startsWith(':root,:host,[data-theme],.dark,.light,.dark-theme,.light-theme{'))!;
const step = (n: number) => new RegExp(`--color-gray-${n}:([0-9.]+) ([0-9.]+)% ([0-9.]+)% !important`).exec(root);
for (const n of [0, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000, 1100]) assert.ok(step(n), `${preset.id} gray-${n}`);
const hslToRgb = (h: number, s: number, l: number) => {
s /= 100; l /= 100;
const f = (k: number) => { const a = s * Math.min(l, 1 - l); const x = (k + h / 30) % 12; return l - a * Math.max(-1, Math.min(x - 3, 9 - x, 1)); };
return `rgb(${[f(0), f(8), f(4)].map(v => Math.round(v * 255)).join(', ')})`;
};
const color = (n: number) => { const m = step(n)!; return hslToRgb(+m[1]!, +m[2]!, +m[3]!); };
for (const text of [700, 800, 900, 1000, 1100]) for (const surface of [0, 50]) {
const ratio = contrast(color(text), color(surface));
assert.ok(ratio >= 4.5, `${preset.id}: gray-${text} on gray-${surface} = ${ratio.toFixed(2)}`);
}
}
});
test('white labels on class-based accent fills (unread badges) stay readable over Lights out', () => {
const classic = paletteFor(choice('classic'))!;
const css = buildThemeCss([rule('.r-fill', { 'background-color': 'rgb(29, 155, 240)' }), rule('.r-white', { color: 'rgb(255, 255, 255)' })], X_PALETTES.dark, classic);
const line = css.split('\n').find(l => l.startsWith(':is(.r-fill,'))!;
assert.ok(line.includes(' :is(.r-white,[style^="color: rgb(255, 255, 255)"]') && line.endsWith('{color:#ffffff !important}'), line);
const terminal = paletteFor(choice('terminal'))!;
const green = buildThemeCss([rule('.r-fill', { 'background-color': 'rgb(29, 155, 240)' }), rule('.r-white', { color: 'rgb(255, 255, 255)' })], X_PALETTES.dark, terminal);
assert.ok(green.includes(`{color:${terminal.bg} !important}`));
});
test('buildThemeCss leaves react-native-web base classes alone (inline like/repost colors stay visible)', () => {
// X's base Text class declares black; over Lights out that sits on the background end of X's axis.
const light = PRESETS.find(p => p.palette && p.palette.bg && p.palette.bg.toLowerCase() === '#ffffff')?.palette ?? { bg: '#ffffff', surface: '#f7f9f9', elevated: '#eff3f4', border: '#cfd9de', text: '#0f1419', muted: '#536471', accent: '#1d9bf0' };
const css = buildThemeCss([rule('.css-146c3p1', { color: 'rgb(0, 0, 0)' }), rule('.r-1nao33i', { color: 'rgb(231, 233, 234)' })], X_PALETTES.dark, light);
assert.ok(!css.includes('.css-146c3p1{'), 'base class not re-declared');
assert.ok(css.includes('.r-1nao33i{color:'), 'atomic text colors are still themed');
});
test("X's signed-out flow (login/signup, no <main>) gets one legible surface instead of its own literal black/white/gray", () => {
// Read live on x.com, logged out, 2026-09-28: the "See what's happening" login dialog and the
// logged-out landing page (`.jetfuel-style-root`, plain `<footer>`) are a Tailwind app that paints
// literal rgb(0,0,0)/rgb(255,255,255)/grays, not X's own palette. `buildThemeCss` cannot tell those
// literals apart from X's real dark-mode canvas (also literal black) and page text (also includes
// literal white), so it must not rely on the generic per-rule remap here at all.
for (const preset of PRESETS.filter(item => item.palette)) {
const palette = paletteFor(choice(preset.id))!;
const css = buildThemeCss([], X_PALETTES.dark, palette);
const lines = css.split('\n');
const root = lines.find(l => l.startsWith('html:not([data-wb-app]) body){') || l.startsWith('html:not([data-wb-app]) body'));
assert.ok(root, `${preset.id}: no signed-out root rule`);
assert.ok(lines.some(l => l.includes('background-color:' + palette.bg + ' !important') && l.startsWith('html:not([data-wb-app]) body')), `${preset.id}: canvas`);
assert.ok(lines.some(l => l.includes('[role="dialog"]') && l.includes(`background-color:${palette.surface} !important`)), `${preset.id}: dialog surface`);
const textRule = lines.find(l => l.includes(':is(h1,h2,h3,h4,p,span,label,li,div){color:'))!;
assert.ok(textRule?.includes(`color:${palette.text} !important`), `${preset.id}: forced text`);
const linkRule = lines.find(l => / a\{color:/.test(l))!;
const page = BUILTIN_THEMES.find(item => item.id === preset.id)?.colors?.page ?? palette.bg;
const link = contrastRatio(palette.accent!, palette.bg!) >= 4.5 && contrastRatio(palette.accent!, page!) >= 4.5 ? palette.accent : palette.text;
assert.ok(linkRule?.includes(`color:${link} !important`), `${preset.id}: readable link`);
// A button label without a wrapping span/p/div (X's own filled CTA) is covered by the button rule
// itself, not only its descendants.
const buttonRule = lines.find(l => l.includes(':is(button,[role="button"])') && l.includes('border-color'))!;
assert.ok(buttonRule?.includes(`color:${palette.text} !important`), `${preset.id}: button label`);
assert.ok(lines.some(l => l.includes('button:has(svg):has(.text-text > p.jf-element) > .jf-element.flex') && l.includes('background-color:#f8fafc !important')), `${preset.id}: social pill surface`);
assert.ok(lines.some(l => l.includes('.jf-gsi-face > span.jf-element') && l.includes('-webkit-text-fill-color:#0f1419 !important')), `${preset.id}: social labels`);
}
});
test('signed-out links read against the tinted page canvas as well as the body background', () => {
for (const id of ['classic', 'solarized-light']) {
const palette = paletteFor(choice(id))!;
const page = BUILTIN_THEMES.find(item => item.id === id)!.colors!.page!;
const css = buildThemeCss([], X_PALETTES.dark, { ...palette, page });
assert.ok(css.includes(`html:not([data-wb-app]) body a{color:${palette.text} !important}`), `${id}: link uses text color on tinted page`);
}
});
test('signed-out root does not fire on a real logged-in shell (chat/settings have no primaryColumn, but do have <main role="main">)', () => {
const classic = paletteFor(choice('classic'))!;
const css = buildThemeCss([], X_PALETTES.dark, classic);
assert.ok(css.includes('html:not([data-wb-app]) body'), 'the rule exists at all');
// The guard is the engine's page flag for X's app shell (<main role="main">, on chat and settings too),
// never a page-level :has() (every node X inserted re-matched the whole page, 1.0.0).
assert.equal(THEME_FLAGS.app, 'main[role="main"]');
assert.ok(!css.includes('body:not(:has('), 'no page-level :has() gate');
});