273 lines
19 KiB
TypeScript
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');
|
|
});
|