A theme rule gated by html:has(...) / body:not(:has(...)) made the browser restyle the whole page for every node the site inserted. Measured live on x.com under any theme: about 440 elements restyled per insertion, against 1.5 without a theme; with the gates gone, 2.5. Sites name page states as selectors (ThemeRuntimeSite.flags). The engine keeps html[data-wb-<name>] in step, checked once per DOM hub frame and also inside the site's open shadow roots. It passes the flags that hold to css(), so CSS for one state can be left out of the other. Changing flags rebuild the theme (they are part of its render key). Tests: pageFlags, flagAttribute.
273 lines
18 KiB
TypeScript
273 lines
18 KiB
TypeScript
// The theme engine's site-independent parts: theme documents (sanitizing, import, roles, legibility),
|
|
// the choice catalog and the first-frame snapshot. Site behavior (X's palette remap and structure
|
|
// CSS) is tested in the app.
|
|
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { FONT_ROLE_NAMES, MONO_FONT, contrastRatio } from '../src/themeApi.ts';
|
|
import { choiceForId, createThemeCatalog, serializeChoice, MAX_USER_THEMES } from '../src/themeChoice.ts';
|
|
import { SNAPSHOT_LOCAL_MAX, collectRootRules, collectRules, flagAttribute, pageFlags, readSnapshot, resolvedFonts, writeSnapshot, type ThemeSnapshot } from '../src/themeRuntime.ts';
|
|
import {
|
|
BASE_EFFECTS, BASE_FONT, BASE_LAYOUT, BASE_SHAPE, FONT_CHOICES, ROLE_LIMITS, applyOverrides, backgroundLayers, ensureContrast, fontChoiceOf, fontStack, isNeutral,
|
|
pageBackgroundCss, parseThemeJson, resolveFontRoles, sanitizeSpec, withFamily, withRoles, type ThemeSpec,
|
|
} from '../src/themeSpec.ts';
|
|
|
|
const own: ThemeSpec = { id: 'site', name: 'Site', description: '', author: '', version: 1, mode: 'auto', colors: null, font: BASE_FONT, shape: BASE_SHAPE, layout: BASE_LAYOUT, background: { kind: 'none' }, effects: BASE_EFFECTS, css: '' };
|
|
const night: ThemeSpec = sanitizeSpec({ id: 'night', name: 'Night', colors: { bg: '#07080c', text: '#eef0f7', accent: '#5b8cff' }, font: { family: ['Geist', 'system-ui'] } });
|
|
|
|
test('sanitizeSpec drops anything that could escape into CSS', () => {
|
|
const hostile = sanitizeSpec({
|
|
id: 'Bad Id!', name: ' Evil\u0000 theme ',
|
|
colors: { bg: 'red', surface: '#000', elevated: '#000000', border: '#000000', text: '#ffffff', muted: '#999999', accent: '#1d9bf0' },
|
|
font: { family: ['Inter;}body{display:none', 'Geist', 'x"y', 'serif'], scale: 9, weight: 'bold', leading: 99 },
|
|
shape: { radius: -4, button: 'pill', avatar: 'star' },
|
|
layout: { column: 99999, density: 'tiny', cards: 'yes' },
|
|
background: { kind: 'image', url: 'https://evil.test/a.png' },
|
|
effects: { motion: 1e9, hover: 'explode' },
|
|
});
|
|
assert.equal(hostile.id, 'evil-theme');
|
|
assert.equal(hostile.name, 'Evil theme');
|
|
assert.equal(hostile.colors, null);
|
|
assert.deepEqual(hostile.font.family, ['Geist', 'serif']);
|
|
assert.equal(hostile.font.scale, 1.2);
|
|
assert.equal(hostile.font.leading, 1.8);
|
|
assert.deepEqual(hostile.shape, { radius: 0, button: 9999, avatar: 'circle' });
|
|
assert.equal(hostile.layout.column, 820);
|
|
assert.equal(hostile.layout.density, 'default');
|
|
assert.equal(hostile.layout.cards, false);
|
|
assert.deepEqual(hostile.background, { kind: 'none' });
|
|
assert.equal(hostile.effects.motion, 400);
|
|
assert.equal(hostile.effects.hover, 'none');
|
|
for (const url of ['data:image/svg+xml;base64,PHN2Zz4=', 'data:text/html;base64,AAAA', 'data:image/png;base64,AA)A', 'javascript:alert(1)']) {
|
|
assert.deepEqual(sanitizeSpec({ name: 'a', background: { kind: 'image', url } }).background, { kind: 'none' }, url);
|
|
}
|
|
assert.equal(sanitizeSpec({ name: 'a', background: { kind: 'image', url: 'data:image/png;base64,iVBORw0KGgo=' } }).background.kind, 'image');
|
|
// The schema's defaults change nothing.
|
|
assert.ok(isNeutral(own));
|
|
assert.ok(!isNeutral(night));
|
|
});
|
|
|
|
test('parseThemeJson explains what it rejected or changed', () => {
|
|
assert.deepEqual(parseThemeJson('{nope'), { error: 'This file is not valid JSON.' });
|
|
assert.deepEqual(parseThemeJson('{"id":"a"}'), { error: 'A theme needs a "name".' });
|
|
const parsed = parseThemeJson(JSON.stringify({ name: 'Mine', css: 'a{}', colors: { bg: 'red' } }));
|
|
assert.ok('theme' in parsed);
|
|
if ('theme' in parsed) {
|
|
assert.equal(parsed.theme.id, 'mine');
|
|
assert.equal(parsed.warnings.length, 2);
|
|
}
|
|
});
|
|
|
|
test('a palette from three colors, then legibility: any accent stays readable on every surface', () => {
|
|
assert.ok(night.colors && night.colors.surface && night.colors.border, 'derived roles are filled in');
|
|
for (const accent of ['#1d9bf0', '#ffffff', '#000000', '#fffe00']) {
|
|
const c = applyOverrides(night, {}, accent).colors!;
|
|
for (const surface of [c.bg, c.surface, c.elevated]) assert.ok(contrastRatio(c.accent, surface) >= 4.5, accent);
|
|
assert.ok(contrastRatio(c.onInverse!, c.inverse!) >= 4.5, `${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('old theme files migrate and roles win over the shorthand', () => {
|
|
const old = sanitizeSpec({ name: 'Old', font: { family: ['Georgia', 'serif'], tweet: ['Charter'] } });
|
|
assert.deepEqual(old.font.roles.interface, { family: ['Georgia', 'serif'] });
|
|
assert.deepEqual(old.font.roles.post, { family: ['Charter'] });
|
|
assert.deepEqual(old.font, withRoles({ ...BASE_FONT, family: ['Georgia', 'serif'], tweet: ['Charter'] }));
|
|
const doc = { name: 'New', font: { family: ['Arial'], roles: { interface: { family: ['Inter', 'sans-serif'], source: 'google' }, numbers: { family: [], tabular: true, weight: 650 } } } };
|
|
const theme = sanitizeSpec(doc);
|
|
assert.deepEqual(theme.font.family, ['Inter', 'sans-serif']);
|
|
assert.deepEqual(theme.font.roles.numbers, { family: [], weight: 650, tabular: true });
|
|
assert.deepEqual(sanitizeSpec(JSON.parse(JSON.stringify(theme))), theme);
|
|
});
|
|
|
|
test('role sanitizing: clamps, drops unknown keys and unsafe names, interface takes only a family', () => {
|
|
const theme = sanitizeSpec({ name: 't', font: { roles: {
|
|
interface: { family: ['Inter'], weight: 900, size: 2 },
|
|
post: { family: ['Lora', '</style><script>'], weight: 5000, size: 9, tracking: -1, tabular: 'yes', script: 'x' },
|
|
heading: { family: ['serif'], source: 'google' },
|
|
numbers: {},
|
|
mono: 'monospace',
|
|
chrome: { family: ['Space Grotesk'], source: 'evil' },
|
|
nope: { family: ['Inter'] },
|
|
} } });
|
|
assert.deepEqual(theme.font.roles.interface, { family: ['Inter'] });
|
|
assert.deepEqual(theme.font.roles.post, { family: ['Lora'], weight: ROLE_LIMITS.weight[1], size: ROLE_LIMITS.size[1], tracking: ROLE_LIMITS.tracking[0] });
|
|
assert.deepEqual(theme.font.roles.heading, { family: ['serif'] }, 'a generic family is never a Google font');
|
|
assert.equal(theme.font.roles.numbers, 'inherit');
|
|
assert.equal(theme.font.roles.mono, 'inherit');
|
|
assert.deepEqual(theme.font.roles.chrome, { family: ['Space Grotesk'] });
|
|
assert.equal('nope' in theme.font.roles, false);
|
|
assert.deepEqual(withFamily({ family: ['Inter'], source: 'google', weight: 600 }, ['Georgia']), { family: ['Georgia'], weight: 600 });
|
|
assert.equal(withFamily({ family: ['Inter'] }, []), 'inherit');
|
|
});
|
|
|
|
test('font roles resolve: inherit the interface (the site font when it inherits), mono keeps a monospaced stack', () => {
|
|
const site = 'SiteSans, sans-serif';
|
|
const roles = resolveFontRoles(own.font, site);
|
|
for (const role of FONT_ROLE_NAMES) assert.equal(roles[role].inherited, true, role);
|
|
assert.equal(roles.interface.stack, site);
|
|
assert.equal(roles.post.stack, site);
|
|
assert.match(roles.mono.stack, /Cascadia Mono/);
|
|
const reported = resolvedFonts(night.font, site);
|
|
assert.equal(reported.interface.stack, fontStack(['Geist', 'system-ui']));
|
|
assert.equal(reported.interface.inherited, false);
|
|
assert.match(fontStack(['Geist']), /^"Geist Variable", "Geist"/, 'bundled families use their registered name first');
|
|
assert.match(MONO_FONT, /^"Cascadia Mono"/);
|
|
assert.equal(fontChoiceOf(FONT_CHOICES.find(choice => choice.id === 'serif')!.family), 'serif');
|
|
assert.equal(fontChoiceOf(['Nope']), 'custom');
|
|
});
|
|
|
|
test('backgrounds are values, painted on one fixed layer behind the page', () => {
|
|
const css = pageBackgroundCss({ kind: 'pattern', pattern: 'dots', ink: '#ffffff', opacity: 0.2, size: 20 }, '#000000').join('\n');
|
|
assert.match(css, /html:root::before\{[^}]*position:fixed/);
|
|
assert.match(css, /html:root body\{background:transparent !important\}/);
|
|
assert.doesNotMatch(backgroundLayers({ kind: 'gradient', from: '#000000', to: '#111111', angle: 90 }, '#000000', false), /fixed/);
|
|
assert.match(backgroundLayers({ kind: 'gradient', from: '#000000', to: '#111111', angle: 90 }, '#000000'), / fixed/);
|
|
});
|
|
|
|
test('a site catalog: the first built-in is the default; imported themes are kept, bounded and never shadow a built-in', () => {
|
|
const catalog = createThemeCatalog([own, night]);
|
|
assert.deepEqual(catalog.defaultChoice, { preset: 'site', accent: null, overrides: {}, themes: [] });
|
|
const many = Array.from({ length: MAX_USER_THEMES + 5 }, (_, n) => ({ id: `mine-${n}`, name: `Mine ${n}` }));
|
|
const choice = catalog.normalize({ preset: 'mine-3', accent: '#FF0000', themes: [{ id: 'night', name: 'Fake night' }, ...many], overrides: { font: { scale: 1.1 }, nope: { a: 1 }, css: 'x' } });
|
|
assert.equal(choice.themes.length, MAX_USER_THEMES - 1, 'the shadowing theme is dropped, the rest capped');
|
|
assert.equal(choice.preset, 'mine-3');
|
|
assert.equal(choice.accent, '#ff0000');
|
|
assert.deepEqual(Object.keys(choice.overrides), ['font']);
|
|
assert.equal(catalog.normalize({ preset: 'gone' }).preset, 'site');
|
|
assert.equal(catalog.base({ ...choice, preset: 'night' }).name, 'Night');
|
|
assert.equal(catalog.resolve({ ...choice, preset: 'site' }).font.scale, 1.1);
|
|
assert.deepEqual(catalog.palette({ ...catalog.defaultChoice, accent: '#00ff00' }), { accent: '#00ff00' });
|
|
assert.equal(catalog.palette(catalog.defaultChoice), null);
|
|
assert.equal(catalog.all(choice).length, 2 + choice.themes.length);
|
|
assert.equal(choiceForId(choice, choice.preset), choice);
|
|
assert.deepEqual(choiceForId(choice, 'night'), { preset: 'night', accent: null, overrides: {}, themes: choice.themes });
|
|
assert.deepEqual(serializeChoice({ ...catalog.defaultChoice, overrides: { font: {} } }), { preset: 'site', accent: null });
|
|
assert.throws(() => createThemeCatalog([]));
|
|
});
|
|
|
|
// ---- First-frame snapshot ----------------------------------------------------------------------
|
|
|
|
class MemoryStorage {
|
|
data = new Map<string, string>();
|
|
quota = Infinity;
|
|
getItem(key: string) { return this.data.get(key) ?? null; }
|
|
setItem(key: string, value: string) { if (value.length > this.quota) throw new Error('QuotaExceededError'); this.data.set(key, value); }
|
|
removeItem(key: string) { this.data.delete(key); }
|
|
}
|
|
const storages = () => {
|
|
const session = new MemoryStorage();
|
|
const local = new MemoryStorage();
|
|
return { session, local, stores: { session: session as unknown as Storage, local: local as unknown as Storage } };
|
|
};
|
|
const snap = (css: string, preset = 'nord'): ThemeSnapshot => ({ key: `{"preset":"${preset}"}|dark`, choice: { preset }, css });
|
|
const presetOf = (snapshot: ThemeSnapshot | null) => (snapshot?.choice as { preset: string } | undefined)?.preset;
|
|
|
|
test('the tab\'s own snapshot wins over the account-wide one', () => {
|
|
const { stores } = storages();
|
|
writeSnapshot(stores, snap('html{background:#2e3440 !important}'));
|
|
assert.equal(presetOf(readSnapshot(stores)), 'nord');
|
|
// Another tab of the account showed a different theme later: only localStorage changes.
|
|
stores.local!.setItem('wb:theme-snapshot', JSON.stringify(snap('html{background:#282a36 !important}', 'dracula')));
|
|
assert.equal(presetOf(readSnapshot(stores)), 'nord');
|
|
// A new tab (empty sessionStorage) falls back to the account's last theme.
|
|
assert.equal(presetOf(readSnapshot({ session: null, local: stores.local })), 'dracula');
|
|
});
|
|
|
|
test('the site\'s own look clears the snapshot', () => {
|
|
const { stores, session, local } = storages();
|
|
writeSnapshot(stores, snap('a{}'));
|
|
writeSnapshot(stores, null);
|
|
assert.equal(session.data.size + local.data.size, 0);
|
|
assert.equal(readSnapshot(stores), null);
|
|
});
|
|
|
|
test('big snapshots stay out of the site\'s localStorage; quota errors never throw', () => {
|
|
const { stores, session, local } = storages();
|
|
writeSnapshot(stores, snap('x'.repeat(SNAPSHOT_LOCAL_MAX + 10)));
|
|
assert.equal(session.data.size, 1, 'the tab keeps it for reloads');
|
|
assert.equal(local.data.size, 0, 'too big to share the site\'s localStorage quota');
|
|
session.quota = 10;
|
|
assert.doesNotThrow(() => writeSnapshot(stores, snap('y'.repeat(100))));
|
|
});
|
|
|
|
test('garbage in storage is no snapshot', () => {
|
|
const { stores } = storages();
|
|
stores.session!.setItem('wb:theme-snapshot', '{not json');
|
|
stores.local!.setItem('wb:theme-snapshot', JSON.stringify({ key: 1, css: 'a{}' }));
|
|
assert.equal(readSnapshot(stores), null);
|
|
});
|
|
|
|
// collectRules: the site's rules as they take effect
|
|
|
|
type FakeRule = { selectorText?: string; style?: { getPropertyValue(name: string): string; length: number }; cssRules?: FakeRule[]; conditionText?: string; type?: number };
|
|
const decl = (values: Record<string, string>) => ({ getPropertyValue: (name: string) => values[name] ?? '', length: Object.keys(values).length });
|
|
const styleRule = (selectorText: string, values: Record<string, string>, cssRules: FakeRule[] = []): FakeRule => ({ selectorText, style: decl(values), cssRules });
|
|
const supportsRule = (conditionText: string, cssRules: FakeRule[]): FakeRule => ({ type: 12, conditionText, cssRules });
|
|
const mediaRule = (conditionText: string, cssRules: FakeRule[]): FakeRule => ({ type: 4, conditionText, cssRules });
|
|
const fakeDoc = (rules: FakeRule[], supports = (_condition: string) => true) => ({ styleSheets: [{ ownerNode: null, cssRules: rules }], defaultView: { CSS: { supports } } }) as unknown as Document;
|
|
const MIX = '(color: color-mix(in lab, red, red))';
|
|
|
|
test('collectRules reads a Tailwind opacity utility as its nested @supports value, not its solid fallback', () => {
|
|
const rules = collectRules(fakeDoc([
|
|
styleRule('.dark\:bg-white\/15', { 'background-color': 'var(--x-white)', 'border-radius': '9999px' }, [
|
|
supportsRule(MIX, [{ style: decl({ 'background-color': 'color-mix(in oklab, var(--x-white) 15%, transparent)' }) }]),
|
|
]),
|
|
styleRule('.plain', { color: 'rgb(1, 2, 3)' }),
|
|
]), null);
|
|
assert.equal(rules.length, 2, 'one rule per selector, the nested declarations are not a rule of their own');
|
|
assert.equal(rules[0]!.selectorText, '.dark\:bg-white\/15');
|
|
assert.equal(rules[0]!.style.getPropertyValue('background-color'), 'color-mix(in oklab, var(--x-white) 15%, transparent)');
|
|
assert.equal(rules[0]!.style.getPropertyValue('border-radius'), '9999px', 'what the group does not re-declare stays the rule\'s own');
|
|
assert.equal(rules[0]!.style.length, 2);
|
|
assert.equal(rules[1]!.style.getPropertyValue('color'), 'rgb(1, 2, 3)');
|
|
});
|
|
|
|
test('collectRules: the `& { }` form of nested declarations counts, a nested rule with its own selector does not', () => {
|
|
const [rule] = collectRules(fakeDoc([
|
|
styleRule('.border-accent\/50', { 'border-color': 'hsl(var(--accent))' }, [
|
|
supportsRule(MIX, [styleRule('&', { 'border-color': 'color-mix(in oklab, hsl(var(--accent)) 50%, transparent)' }), styleRule('&:hover', { 'border-color': 'red' })]),
|
|
mediaRule('(hover: hover)', [{ style: decl({ 'border-color': 'blue' }) }]),
|
|
]),
|
|
]), null);
|
|
assert.equal(rule!.style.getPropertyValue('border-color'), 'color-mix(in oklab, hsl(var(--accent)) 50%, transparent)');
|
|
});
|
|
|
|
test('collectRules keeps the fallback where the browser fails the @supports condition, and skips such top-level groups', () => {
|
|
const rules = collectRules(fakeDoc([
|
|
styleRule('.bg-white\/15', { 'background-color': 'var(--x-white)' }, [supportsRule(MIX, [{ style: decl({ 'background-color': 'color-mix(in oklab, var(--x-white) 15%, transparent)' }) }])]),
|
|
supportsRule(MIX, [styleRule('.hover\:bg-white\/10:hover', { 'background-color': 'color-mix(in oklab, var(--x-white) 10%, transparent)' })]),
|
|
mediaRule('(hover: hover)', [styleRule('.in-media', { color: 'red' })]),
|
|
], () => false), null);
|
|
assert.deepEqual(rules.map(rule => rule.selectorText), ['.bg-white\/15', '.in-media']);
|
|
assert.equal(rules[0]!.style.getPropertyValue('background-color'), 'var(--x-white)');
|
|
});
|
|
|
|
test('collectRules leaves the engine\'s own sheet out and survives a cross-origin sheet', () => {
|
|
const own = { id: 'own' } as unknown as HTMLStyleElement;
|
|
const doc = { styleSheets: [
|
|
{ ownerNode: own, cssRules: [styleRule('.ours', { color: 'red' })] },
|
|
{ ownerNode: null, get cssRules(): FakeRule[] { throw new Error('SecurityError'); } },
|
|
{ ownerNode: null, cssRules: [supportsRule(MIX, [mediaRule('(hover: hover)', [styleRule('.deep', { color: 'red' })])])] },
|
|
], defaultView: null } as unknown as Document;
|
|
assert.deepEqual(collectRules(doc, own).map(rule => rule.selectorText), ['.deep']);
|
|
});
|
|
|
|
test('collectRootRules reads a shadow root\'s adopted sheets and <style> elements, never the engine\'s own sheet (X chat embed, 2026-10-01)', () => {
|
|
const own = { cssRules: [styleRule('.ours', { color: 'red' })] };
|
|
const rules = collectRootRules({
|
|
adoptedStyleSheets: [{ cssRules: [styleRule(':host, [data-theme="dark"]', { '--x-bg-primary': '#000' })] }, own],
|
|
styleSheets: [{ cssRules: [styleRule('.bg-white\/15', { 'background-color': 'var(--x-white)' }, [supportsRule(MIX, [{ style: decl({ 'background-color': 'color-mix(in oklab, var(--x-white) 15%, transparent)' }) }])])] }],
|
|
} as never, own as never, { supports: () => true });
|
|
assert.deepEqual(rules.map(rule => rule.selectorText), [':host, [data-theme="dark"]', '.bg-white\/15']);
|
|
assert.equal(rules[1]!.style.getPropertyValue('background-color'), 'color-mix(in oklab, var(--x-white) 15%, transparent)');
|
|
});
|
|
|
|
test('page flags: a flag holds while its selector matches in the page or one of its shadow roots', () => {
|
|
const root = (hits: string[]) => ({ querySelector: (selector: string) => (hits.includes(selector) ? {} : null) });
|
|
const flags = { chat: '[data-testid="dm-container"]', 'signed-out': 'body:not(:has(main))', bad: '::nope(' };
|
|
assert.deepEqual(pageFlags([root([]), root(['[data-testid="dm-container"]'])], flags), ['chat'], 'a match in a shadow root counts');
|
|
assert.deepEqual(pageFlags([root([])], flags), []);
|
|
assert.deepEqual(pageFlags([{ querySelector: () => { throw new Error('bad'); } }], { bad: 'x' }), [], 'a selector that throws is never on');
|
|
assert.equal(flagAttribute('chat'), 'data-wb-chat');
|
|
});
|