import assert from 'node:assert/strict'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { BUILTIN_THEMES, FONT_ROLE_NAMES, ROLE_LIMITS, applyOverrides, buildStructureCss, bundledFontsUsed, googleFontsUsed, resolveFontRoles, sanitizeSpec, withFamily, } from '../src/inject/themeSpec.ts'; import { X_FONT_STACK, resolvedTheme, THEME_DEFAULT } from '../src/inject/theme.ts'; import { themeVars, normalizeResolved } from '../../../waybetter/packages/runtime/src/themeApi.ts'; import { SCOPES_DEFAULT, assign, normalizeScopes, prunePanes, themeIdFor, MAX_PANE_THEMES } from '../../../waybetter/packages/shell/src/frame/themeScopes.ts'; import { choiceForId } from '../../../waybetter/packages/runtime/src/themeChoice.ts'; import { contrastReport, draftFrom, themeJson, uniqueId } from '../../../waybetter/packages/shell/src/panel/builderModel.ts'; import { X_THEMES } from '../src/inject/theme.ts'; import { chromeVars, CHROME_VAR_NAMES } from '../src/shell/chromeTheme.ts'; const SCHEMA = JSON.parse(readFileSync(new URL('../../../waybetter/packages/waybetter/theme.schema.json', import.meta.url), 'utf8')); test('old theme files migrate: font.family and font.tweet become the interface and post roles', () => { const theme = sanitizeSpec({ name: 'Old', font: { family: ['Georgia', 'serif'], tweet: ['Charter'] } }); assert.deepEqual(theme.font.roles.interface, { family: ['Georgia', 'serif'] }); assert.deepEqual(theme.font.roles.post, { family: ['Charter'] }); for (const role of ['heading', 'numbers', 'mono', 'chrome', 'dialog'] as const) assert.equal(theme.font.roles[role], 'inherit'); // The shorthand stays in step with the roles, so older apps read the same fonts. assert.deepEqual([theme.font.family, theme.font.tweet], [['Georgia', 'serif'], ['Charter']]); // Every built-in keeps its look: its stacks are exactly its roles. for (const builtin of BUILTIN_THEMES) { assert.deepEqual(sanitizeSpec(JSON.parse(JSON.stringify(builtin))).font, builtin.font, builtin.id); } }); test('overrides patch one role at a time and legacy saved overrides still apply', () => { const paper = BUILTIN_THEMES.find(theme => theme.id === 'paper')!; const withHeading = applyOverrides(paper, { font: { roles: { heading: { family: ['Fraunces', 'serif'], source: 'google', weight: 700 } } } }); assert.deepEqual(withHeading.font.roles.heading, { family: ['Fraunces', 'serif'], source: 'google', weight: 700 }); assert.deepEqual(withHeading.font.roles.post, paper.font.roles.post, 'other roles untouched'); const legacy = applyOverrides(paper, { font: { family: ['Geist', 'system-ui'] } }); assert.deepEqual(legacy.font.roles.interface, { family: ['Geist', 'system-ui'] }); assert.deepEqual(legacy.font.family, ['Geist', 'system-ui']); // A new family drops the Google source unless it is the same font; extras stay. assert.deepEqual(withFamily({ family: ['Inter'], source: 'google', weight: 600 }, ['Georgia']), { family: ['Georgia'], weight: 600 }); assert.equal(withFamily({ family: ['Inter'] }, []), 'inherit'); }); test('resolution: everything inherits the interface, mono keeps a monospace face, chrome uses the app font when X keeps Chirp', () => { const x = resolveFontRoles(BUILTIN_THEMES[0]!.font, X_FONT_STACK); for (const role of FONT_ROLE_NAMES) { assert.equal(x[role].inherited, true, role); if (role !== 'mono') assert.equal(x[role].stack, X_FONT_STACK, role); } assert.match(x.mono.stack, /monospace/); const shell = resolveFontRoles(BUILTIN_THEMES[0]!.font, 'APP'); assert.equal(shell.chrome.stack, 'APP'); const theme = sanitizeSpec({ name: 't', font: { roles: { interface: { family: ['Inter'], source: 'google' }, numbers: { family: [], tabular: true, size: 1.1 }, mono: { family: ['Space Mono'], source: 'google' } } } }); const roles = resolveFontRoles(theme.font, X_FONT_STACK); assert.match(roles.post.stack, /^"Inter"/); assert.equal(roles.post.google, 'Inter'); assert.deepEqual([roles.numbers.tabular, roles.numbers.size, roles.numbers.inherited], [true, 1.1, true]); assert.match(roles.numbers.stack, /^"Inter"/); assert.equal(roles.mono.google, 'Space Mono'); assert.deepEqual(googleFontsUsed(theme), ['Inter', 'Space Mono']); assert.deepEqual(googleFontsUsed(theme, ['post', 'heading']), []); // Plugins see every role, and --wb-font- exists for CSS. const resolved = resolvedTheme({ ...THEME_DEFAULT, preset: 't', themes: [theme] }, 'dark'); assert.match(resolved.fonts!.numbers.stack, /Inter/); assert.equal(resolved.fonts!.numbers.tabular, true); const vars = themeVars(resolved); for (const role of FONT_ROLE_NAMES) assert.ok(vars[`--wb-font-${role}`], role); // An adapter without roles still gets all of them. const bare = normalizeResolved({ id: 'a', name: 'a', mode: 'dark', siteDefault: true, colors: resolved.colors, radius: 8, buttonRadius: 8, font: 'Foo' }); assert.equal(bare.fonts!.dialog.stack, 'Foo'); assert.match(bare.fonts!.mono.stack, /monospace/); }); test('structure CSS: roles reach X selectors, inherit emits nothing, bundled fonts per role', () => { const plain = buildStructureCss([], sanitizeSpec({ name: 't', font: { roles: { interface: { family: ['Geist'] } } } }), { bg: '#000000', surface: '#111111', elevated: '#222222', border: '#333333', text: '#ffffff', muted: '#999999', accent: '#1d9bf0', page: '#000000' }); assert.doesNotMatch(plain, /tabular-nums|zoom:/); const theme = sanitizeSpec({ name: 't', font: { roles: { heading: { family: ['Fraunces', 'serif'], source: 'google', weight: 800 }, numbers: { family: ['Geist Mono'], tabular: true, size: 0.9 }, post: { family: [], tracking: 0.01 }, } } }); const css = buildStructureCss([], theme, { bg: '#000000', surface: '#111111', elevated: '#222222', border: '#333333', text: '#ffffff', muted: '#999999', accent: '#1d9bf0', page: '#000000' }); assert.match(css, /\[data-testid="User-Name"\] > div:first-child[^{]*\{font-family:"Fraunces", serif[^}]*font-weight:800 !important/); assert.match(css, /app-text-transition-container[^{]*\{font-family:"Geist Mono Variable", "Geist Mono"[^}]*font-variant-numeric:tabular-nums !important\}/); assert.match(css, /app-text-transition-container"\],a:is[^{]*\{zoom:0\.9\}/); assert.match(css, /\[data-testid="tweetText"\][^{]*\{letter-spacing:0\.01em !important\}/); assert.deepEqual(bundledFontsUsed(theme), ['Geist Mono']); assert.deepEqual(bundledFontsUsed(sanitizeSpec({ name: 't', font: { roles: { chrome: { family: ['Geist'] } } } })), [], 'chrome is not drawn in X'); }); test('the JSON Schema describes the roles with the sanitizer bounds', () => { const role = SCHEMA.$defs.fontRole.oneOf[1].properties; assert.deepEqual([role.weight.minimum, role.weight.maximum], ROLE_LIMITS.weight); assert.deepEqual([role.size.minimum, role.size.maximum], ROLE_LIMITS.size); assert.deepEqual([role.tracking.minimum, role.tracking.maximum], ROLE_LIMITS.tracking); assert.deepEqual(Object.keys(SCHEMA.properties.font.properties.roles.properties), [...FONT_ROLE_NAMES]); assert.equal(SCHEMA.$defs.interfaceRole.oneOf[1].properties.weight, undefined); }); test('scopes: pane beats account beats global, unknown ids fall through, bounded and pruned', () => { const known = (id: string) => ['x', 'paper', 'nord', 'mine'].includes(id); const scopes = normalizeScopes({ chrome: 'own', profiles: { p1: 'paper', default: 'gone', 'bad id': 'nord' }, panes: { t1: 'nord', t2: 'NOPE' } }); assert.equal(scopes.chrome, 'own'); assert.deepEqual(scopes.profiles, { p1: 'paper', default: 'gone' }); assert.deepEqual(scopes.panes, { t1: 'nord' }); assert.deepEqual(themeIdFor(scopes, { id: 't1', profile: 'p1' }, 'mine', known), { id: 'nord', source: 'pane' }); assert.deepEqual(themeIdFor(scopes, { id: 't9', profile: 'p1' }, 'mine', known), { id: 'paper', source: 'account' }); assert.deepEqual(themeIdFor(scopes, { id: 't9', profile: 'default' }, 'mine', known), { id: 'mine', source: 'global' }); assert.equal(normalizeScopes(null).chrome, SCOPES_DEFAULT.chrome); const many = Object.fromEntries(Array.from({ length: MAX_PANE_THEMES + 10 }, (_, i) => [`t${i}`, 'paper'])); assert.equal(Object.keys(normalizeScopes({ panes: many }).panes).length, MAX_PANE_THEMES); assert.deepEqual(prunePanes(scopes, ['t2']).panes, {}); assert.equal(prunePanes(scopes, ['t1']), scopes, 'unchanged scopes keep their identity'); assert.deepEqual(assign(scopes, 'panes', 't1', null).panes, {}); assert.deepEqual(assign(scopes, 'profiles', 'p2', 'nord').profiles, { p1: 'paper', default: 'gone', p2: 'nord' }); // The global theme keeps its customizations; another theme comes bare. const global = { preset: 'mine', accent: '#ff0000', overrides: { font: { scale: 1.1 } }, themes: [] }; assert.equal(choiceForId(global, 'mine'), global); assert.deepEqual(choiceForId(global, 'paper'), { preset: 'paper', accent: null, overrides: {}, themes: [] }); }); test('builder on X: X default starts from the Lights out palette; built-in ids are taken; the export is a theme file', () => { // The Theme Builder's model is the framework's (waybetter/packages/shell/tests/panel.test.ts); // these are the values src/ui/Panel.tsx gives it. const site = { colors: { bg: '#000000', text: '#e7e9ea', accent: '#1d9bf0' }, siteTheme: X_THEMES.defaultChoice.preset }; const draft = draftFrom(BUILTIN_THEMES[0]!, site, 'Mine'); assert.ok(draft.colors, 'X default gets a real palette to edit'); assert.equal(draft.colors!.bg, '#000000'); assert.equal(draft.description, ''); assert.equal(draftFrom(BUILTIN_THEMES.find(theme => theme.id === 'paper')!, site).description, 'Based on Paper.'); assert.ok(contrastReport(draft.colors!).every(check => check.pass), 'the Lights out palette passes every check'); const builtins = X_THEMES.builtins.map(theme => theme.id); assert.equal(uniqueId('Paper', builtins), 'paper-2'); assert.equal(uniqueId('Mine', [...builtins, 'mine']), 'mine-2'); const json = JSON.parse(themeJson(draft)); assert.equal(json.$schema, SCHEMA.$id); assert.deepEqual(sanitizeSpec(json), sanitizeSpec(draft)); }); test('chrome: palette themes recolor the window, X default only changes fonts, "own" changes nothing', () => { const nord = BUILTIN_THEMES.find(theme => theme.id === 'nord')!; const vars = chromeVars(nord); assert.equal(vars['--wb-c-ground'], nord.colors!.bg); assert.equal(vars['--wb-p-text'], nord.colors!.text); assert.equal(vars['--wb-c-scheme'], 'dark'); assert.match(vars['--wb-f-chrome']!, /Segoe UI Variable Text/, 'the chrome inherits the interface font'); assert.deepEqual(chromeVars(nord, true), {}); assert.deepEqual(Object.keys(chromeVars(BUILTIN_THEMES[0]!)), [], 'X default keeps the Night Ink chrome'); const paper = chromeVars(BUILTIN_THEMES.find(theme => theme.id === 'paper')!); assert.equal(paper['--wb-c-scheme'], 'light'); for (const name of Object.keys(vars)) assert.ok(CHROME_VAR_NAMES.includes(name), name); });