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

195 lines
13 KiB
TypeScript

import assert from 'node:assert/strict';
import test from 'node:test';
import {
BASE_LAYOUT, BUILTIN_THEMES, MAX_IMAGE_CHARS, applyOverrides, backgroundLayers, buildStructureCss, ensureContrast, fontStack, isNeutral,
sanitizeSpec, type ThemeSpec,
} from '../src/inject/themeSpec.ts';
import { contrastRatio } from '../../../waybetter/packages/runtime/src/themeApi.ts';
import { ACCENTS, buildFullCss, normalizeTheme, resolveTheme, structureColors } from '../src/inject/theme.ts';
const rule = (selectorText: string, style: Record<string, string>) => ({ selectorText, style: { getPropertyValue: (name: string) => style[name] ?? '', length: Object.keys(style).length } });
const theme = (id: string) => BUILTIN_THEMES.find(item => item.id === id)!;
const X_RULES = [
rule('.r-chirp', { 'font-family': 'TwitterChirp, -apple-system, sans-serif' }),
rule('.r-bold', { 'font-weight': '700' }),
rule('.r-a023e6', { 'font-size': '15px' }),
rule('.r-rjixqe', { 'line-height': '20px' }),
rule('.r-sdzlij', { 'border-top-left-radius': '9999px', 'border-top-right-radius': '9999px', 'border-bottom-right-radius': '9999px', 'border-bottom-left-radius': '9999px' }),
rule('.r-card', { 'border-top-left-radius': '16px' }),
rule('.r-ttdzmv', { 'padding-top': '12px' }),
rule('.r-kzbkwu', { 'padding-bottom': '12px' }),
rule('.r-qklmqi', { 'border-bottom-width': '1px' }),
rule('.r-1hycxz', { width: '350px' }),
// X's real layout classes (live x.com, 2026-09-27): column cap, wide row, narrow row.
rule('.r-1ye8kvj', { 'max-width': '600px' }),
rule('.r-113js5t', { width: '1050px' }),
rule('.r-narrow', { width: '600px' }),
];
test('built-in themes are complete documents that survive a JSON round trip', () => {
assert.deepEqual(BUILTIN_THEMES.map(t => t.id), [
'x', 'classic', 'night-ink', 'terminal', 'paper', 'dracula', 'nord', 'tokyo-night', 'solarized-light',
'frutiger-aero', 'windows-98', 'vaporwave', 'high-contrast', 'midnight-oled',
]);
assert.equal(new Set(BUILTIN_THEMES.map(t => t.name)).size, BUILTIN_THEMES.length);
for (const builtin of BUILTIN_THEMES) assert.deepEqual(sanitizeSpec(JSON.parse(JSON.stringify(builtin))), builtin, builtin.id);
assert.ok(isNeutral(theme('x')));
// More than recolors: every non-X theme changes type, shape and layout, not just colors.
for (const t of BUILTIN_THEMES.filter(t => t.id !== 'x')) {
assert.ok(t.font.family.length && t.shape.radius !== 16 && t.layout.column !== BASE_LAYOUT.column && t.background.kind !== 'none', t.id);
}
});
test('every built-in palette is legible on the page canvas and every column surface', () => {
for (const t of BUILTIN_THEMES.filter(t => t.colors)) {
const c = resolveTheme(normalizeTheme({ preset: t.id })).colors!;
for (const surface of [c.bg, c.surface, c.elevated, c.page!]) {
assert.ok(contrastRatio(c.text, surface) >= 7, `${t.id} text on ${surface}`);
assert.ok(contrastRatio(c.muted, surface) >= 4.5, `${t.id} muted on ${surface}`);
}
for (const surface of [c.bg, c.surface, c.elevated]) assert.ok(contrastRatio(c.accent, surface) >= 4.5, `${t.id} accent on ${surface}`);
assert.ok(contrastRatio(c.onInverse!, c.inverse!) >= 4.5, `${t.id} button label`);
}
});
test('the newer built-ins are authored legible: the contrast pass changes none of their colors', () => {
for (const id of ['dracula', 'nord', 'tokyo-night', 'solarized-light', 'frutiger-aero', 'windows-98', 'vaporwave', 'high-contrast', 'midnight-oled']) {
const t = theme(id);
assert.deepEqual(ensureContrast(t.colors!), { ...t.colors, inverse: t.colors!.inverse, onInverse: t.colors!.onInverse }, id);
}
// High Contrast goes beyond AA: AAA for secondary text and links too.
const hc = theme('high-contrast').colors!;
for (const surface of [hc.bg, hc.surface, hc.elevated]) for (const role of ['text', 'muted', 'accent'] as const) assert.ok(contrastRatio(hc[role], surface) >= 7, role);
assert.equal(theme('high-contrast').effects.motion, 0);
});
test('built-in art ships as data: WebP within the import limit, and built-in CSS names no URLs', () => {
for (const t of BUILTIN_THEMES.filter(t => t.background.kind === 'image')) {
const background = t.background as Extract<ThemeSpec['background'], { kind: 'image' }>;
assert.match(background.url, /^data:image\/webp;base64,/, t.id);
assert.ok(background.url.length <= MAX_IMAGE_CHARS, t.id);
}
for (const t of BUILTIN_THEMES) assert.ok(!/url\(|@import|https?:/i.test(t.css), t.id);
});
test('any accent and any imported palette is forced to stay readable', () => {
for (const t of BUILTIN_THEMES.filter(t => t.colors)) {
for (const accent of [...ACCENTS, '#ffffff', '#000000', '#fffe00']) {
const c = applyOverrides(t, {}, accent).colors!;
for (const surface of [c.bg, c.surface, c.elevated]) assert.ok(contrastRatio(c.accent, surface) >= 4.5, `${t.id} ${accent}`);
assert.ok(contrastRatio(c.onInverse!, c.inverse!) >= 4.5, `${t.id} ${accent} button`);
}
}
const bad = ensureContrast({ bg: '#888888', surface: '#8a8a8a', elevated: '#909090', border: '#999999', text: '#777777', muted: '#7a7a7a', accent: '#8888ff' });
for (const role of ['text', 'muted', 'accent'] as const) assert.ok(contrastRatio(bad[role], '#909090') >= 4.5, role);
});
test('structure CSS remaps X type, shape, density and layout through X own rules', () => {
const nightInk = resolveTheme(normalizeTheme({ preset: 'night-ink' }));
const css = buildStructureCss(X_RULES, nightInk, structureColors(nightInk, 'dark'));
assert.ok(css.includes(`.r-chirp{font-family:${fontStack(nightInk.font.family)} !important}`));
assert.ok(fontStack(['Geist']).startsWith('"Geist Variable", "Geist"'));
assert.ok(css.includes('.r-bold{font-weight:600 !important}'));
assert.ok(css.includes('.r-a023e6{font-size:14.4px !important}'));
assert.ok(css.includes('.r-rjixqe{line-height:19.2px !important}'));
assert.ok(css.includes('.r-sdzlij{border-top-left-radius:10px !important;'));
assert.ok(css.includes('.r-card{border-top-left-radius:12px !important}'));
assert.ok(css.includes('main[role="main"] article[data-testid="tweet"] :is(.r-ttdzmv){padding-top:8px !important}'));
assert.ok(css.includes('main[role="main"] article[data-testid="tweet"] :is(.r-kzbkwu){padding-bottom:8px !important}'));
assert.ok(css.includes('[data-testid="primaryColumn"]:is(.r-1ye8kvj){max-width:640px !important}'));
assert.ok(css.includes('[data-testid="sidebarColumn"]:is(.r-1hycxz),[data-testid="sidebarColumn"] :is(.r-1hycxz){width:340px !important}'));
assert.ok(css.includes('[data-tesid') === false);
// Cards remove X's dividers inside timeline cells only.
const nord = resolveTheme(normalizeTheme({ preset: 'nord' }));
const nordCss = buildStructureCss(X_RULES, nord, structureColors(nord, 'light'));
assert.ok(nordCss.includes('main[role="main"] [data-testid="cellInnerDiv"] :is(.r-qklmqi){border-bottom-width:0 !important}'));
// Paper is a broadsheet (theme engine v2): no cards, hairlines instead; its serif stays.
const paper = resolveTheme(normalizeTheme({ preset: 'paper' }));
const paperCss = buildStructureCss(X_RULES, paper, structureColors(paper, 'light'));
assert.ok(!paperCss.includes(':is(.r-qklmqi){border-bottom-width:0 !important}'));
assert.ok(paperCss.includes('font-family:"Charter"'));
// X default emits nothing; an override on X default emits only that override.
assert.equal(buildFullCss(X_RULES, normalizeTheme({ preset: 'x' }), 'dark'), '');
const scaled = buildFullCss(X_RULES, normalizeTheme({ preset: 'x', overrides: { font: { scale: 1.1 } } }), 'dark');
assert.ok(scaled.includes('.r-a023e6{font-size:16.5px !important}'));
assert.ok(!scaled.includes('font-family'));
});
test('backgrounds are built from values, never from foreign URLs', () => {
const all = BUILTIN_THEMES.map(t => backgroundLayers(t.background, '#000000')).join(' ');
// Only data: images, and in-document references inside those SVGs.
assert.ok(!/url\((?!"?data:image\/|%23)/.test(all));
const withCss = (t: ThemeSpec) => buildStructureCss([], t, structureColors(t, 'dark'));
for (const t of BUILTIN_THEMES) assert.ok(!/https?:/.test(withCss(resolveTheme(normalizeTheme({ preset: t.id })))), t.id);
});
test('a theme background is one fixed layer behind X, never a copy on <body> that scrolls away', () => {
// Owner bug: X's <html>/<body> are one viewport tall; a background on both let <body> paint its
// own copy over its box, so after a partial scroll the glow showed only below a hard seam.
const withBackground = BUILTIN_THEMES.filter(t => t.background.kind !== 'none');
assert.ok(withBackground.some(t => t.background.kind === 'gradient') && withBackground.some(t => t.background.kind === 'image') && withBackground.some(t => t.background.kind === 'pattern'));
for (const t of withBackground) {
for (const mode of ['dark', 'light'] as const) {
const css = buildFullCss(X_RULES, normalizeTheme({ preset: t.id }), mode);
const layer = css.match(/html:root::before\{([^}]*)\}/);
assert.ok(layer, `${t.id}: fixed background layer`);
assert.match(layer[1]!, /position:fixed;inset:0;z-index:-1;pointer-events:none/);
assert.ok(!/\bfixed\b/.test(layer[1]!.replace('position:fixed', '')), `${t.id}: no background-attachment:fixed`);
// Nothing paints layers on <body> (or html,body): only colors, and <body> ends up transparent.
for (const [, selector, body] of css.matchAll(/(^|\})([^{}]*\bbody\b[^{}]*)\{([^}]*)\}/g)) {
if (/background/.test(body ?? '')) assert.ok(!/gradient|url\(/.test(body!), `${t.id}: ${selector} paints layers`);
}
assert.ok(css.includes('html:root body{background:transparent !important}'), `${t.id}: transparent body`);
}
}
// The preview box scrolls, so it keeps the fixed attachments.
assert.match(backgroundLayers(theme('nord').background, '#000000'), / fixed, #000000$/);
assert.doesNotMatch(backgroundLayers(theme('nord').background, '#000000', false), /\bfixed\b/);
});
test('imported themes are kept, selectable and cannot shadow a built-in', () => {
const mine = { ...theme('night-ink'), id: 'mine', name: 'Mine' };
const choice = normalizeTheme({ preset: 'mine', themes: [mine, { ...mine, id: 'classic' }, mine] });
assert.equal(choice.themes.length, 1);
assert.equal(choice.preset, 'mine');
assert.equal(normalizeTheme({ preset: 'gone', themes: [] }).preset, 'x');
assert.deepEqual(normalizeTheme({ preset: 'x', overrides: { font: { scale: 1.1 }, script: {}, shape: 'round' } }).overrides, { font: { scale: 1.1 } });
});
test('layout never collapses or overflows a route: widths follow X own declarations, capped at the pane', () => {
for (const t of BUILTIN_THEMES.filter(t => t.id !== 'x')) {
const resolved = resolveTheme(normalizeTheme({ preset: t.id }));
const css = buildStructureCss(X_RULES, resolved, structureColors(resolved, 'dark'));
// The old rules shrank X's row to its content (Articles lost its column, split panes got a 310px column).
assert.ok(!/main\[role="main"\] > div\{width:auto/.test(css), t.id);
assert.ok(!/\[data-testid="primaryColumn"\]\{([^}]*;)?width:/.test(css), `${t.id}: no fixed column width`);
assert.ok(!/display:\s*none/.test(css), `${t.id}: never hides containers`);
const capped = ':has([data-testid="primaryColumn"]:is(.r-1ye8kvj))';
const side = ':has([data-testid="sidebarColumn"]:is(.r-1hycxz))';
const expect = (width: number, condition: string, delta: number) => {
if (!delta) return;
const rule = `main[role="main"] > div:is(.${width === 1050 ? 'r-113js5t' : 'r-narrow'})${condition}{width:${width + delta}px !important}`;
// Narrower rows always fit; wider rows only apply when the window has room next to X's nav.
assert.ok(css.includes(delta < 0 ? rule : `@media (min-width:${width + delta + (width >= 900 ? 291 : 104)}px){${rule}}`), `${t.id} ${width} ${condition}`);
};
const column = resolved.layout.column - 600, sidebar = resolved.layout.sidebar - 350;
expect(1050, capped + side, column + sidebar);
expect(1050, `${capped}:not(${side})`, column);
expect(600, `${capped}:not(${side})`, column);
assert.ok(!css.includes('min(') && !css.includes('100%) !important'), t.id);
// Cards, density and hover lift stay in X's page, never in #layers.
for (const line of css.split('\n').filter(l => /cellInnerDiv|UserCell|padding-left:/.test(l) && !l.includes('transition'))) {
assert.ok(line.includes('main[role="main"]'), `${t.id}: ${line.slice(0, 90)}`);
}
}
});
test('Theme CSS edited in the panel replaces the theme document CSS, survives normalize and reaches the page CSS', () => {
const choice = normalizeTheme({ preset: 'classic', overrides: { css: { text: '.bt-theme-css-probe { color: red }' } } });
assert.equal(choice.overrides.css?.text, '.bt-theme-css-probe { color: red }');
const theme = resolveTheme(choice);
assert.equal(theme.css, '.bt-theme-css-probe { color: red }');
assert.ok(buildFullCss(X_RULES, choice, 'dark').includes('.bt-theme-css-probe'), 'the CSS is in what the page gets');
assert.equal(resolveTheme(normalizeTheme({ preset: 'classic' })).css, BUILTIN_THEMES.find(t => t.id === 'classic')!.css, 'no override keeps the theme CSS');
assert.equal(normalizeTheme({ preset: 'x', overrides: { css: 'not an object' } }).overrides.css, undefined, 'a malformed override is dropped');
});