195 lines
13 KiB
TypeScript
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');
|
|
});
|