waybetter-for-x/apps/desktop/tests/themeRoles.test.ts
2026-10-01 06:01:13 +07:00

151 lines
11 KiB
TypeScript

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-<role> 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);
});