waybetter/packages/runtime/tests/themeEngine.test.ts

254 lines
16 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, collectRules, 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']);
});