// 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', '