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) => ({ 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
) 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 `