The site-independent theme engine moves from wayBetter for X into the framework: - runtime/themeSpec.ts: theme document types, the schema's defaults (BASE_*), sanitizing, import, overrides, ensureContrast, font roles/stacks, font choices, backgrounds. - runtime/themeChoice.ts: ThemeChoice and createThemeCatalog(builtins) (normalize, base, resolve, palette, all), choiceForId, serializeChoice. - runtime/themeRuntime.ts: installThemeRuntime(win, site) (live CSS over the site's rules, --wb-* vars, site color mode, first-frame snapshot), collectRules, resolvedFonts. - runtime/fonts.ts: bundled fonts and owner-downloaded Google Fonts from bytes. - themeApi exports hexToRgb, mix and MONO_FAMILIES (one copy: themeTokens and themeSpec use them). - host theme.rs: ThemeChoice/ThemeScopes over a site's ThemeSite (envelope checks against theme.schema.json, which theme a tab shows, page color, delivery script). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
199 lines
12 KiB
TypeScript
199 lines
12 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, 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);
|
|
});
|