// A theme's structure groups (components, variants, decoration, icons) and icon packs: what the // sanitizers accept, what the compilers emit, and that the published schemas are generated from the // same tables. Site selectors here are made up; a site's own part map is tested in that site's app. import assert from 'node:assert/strict'; import { spawnSync } from 'node:child_process'; import { readFileSync } from 'node:fs'; import test from 'node:test'; import { fileURLToPath } from 'node:url'; import { contrastRatio } from '../src/themeApi.ts'; import { COMPONENTS, COMPONENT_IDS, PRESETS, TOKEN_LIMITS, compileComponents, partRule, readableRole, resolveComponents, sanitizeComponents, type ComponentContext, type PartMap, type RoleColors, } from '../src/themeComponents.ts'; import { chromeTitle, compileDecor, cssString, sanitizeDecor } from '../src/themeDecor.ts'; import { BASE_ICON_STYLE, ICON_LIMITS, ICON_NAMES, PATH_GRAMMAR, compileIcons, createIconCatalog, glyphSvg, iconGroupsPresent, normalizeIconChoice, parseIconPackJson, sanitizeGlyph, sanitizeIconPack, sanitizeThemeIcons, type IconPack, type IconTable, } from '../src/themeIcons.ts'; import { applyOverrides, hasStructure, isNeutral, sanitizeSpec } from '../src/themeSpec.ts'; import { VARIANT_AXES, compileVariants, resolveVariants, sanitizeVariants } from '../src/themeVariants.ts'; const COLORS: RoleColors = { bg: '#ffffff', surface: '#f5f8fa', elevated: '#e8f1f7', page: '#c0deed', border: '#e1e8ed', text: '#292f33', muted: '#5b6b78', accent: '#0077b0', onAccent: '#ffffff', inverse: '#0077b0', onInverse: '#ffffff', }; const CTX: ComponentContext = { colors: COLORS, fonts: { interface: 'Helvetica, sans-serif', post: 'Georgia, serif', heading: 'Helvetica, sans-serif', numbers: 'Helvetica, sans-serif', mono: 'monospace', chrome: 'system-ui', dialog: 'system-ui' }, }; const PARTS: PartMap = { post: { sel: '[data-part="post"]' }, postText: { sel: '[data-part="text"]', text: '[data-part="text"],[data-part="text"] :is(span,a)' }, buttonPrimary: { sel: '[data-part="primary"]', text: '[data-part="primary"] span' }, buttonSecondary: { sel: '[data-part="secondary"]' }, avatar: { sel: '[data-part="avatar"]', skip: ['size'] }, handle: { sel: '[data-part="handle"]' }, search: { sel: '[data-part="search"]' }, }; const css = (components: unknown) => { const out = compileComponents(sanitizeComponents(components) ?? {}, PARTS, CTX); return { ...out, text: [...out.values, ...out.rules].join('\n') }; }; test('component tokens are clamped, unknown names and tokens a component does not take are dropped', () => { const clean = sanitizeComponents({ post: { radius: 999, borderWidth: -3, borderStyle: 'groove', fill: '#ff0000', padX: 12.6, shadow: 'soft', visible: false, fontSize: 40, corner: 'cut', nonsense: 1 }, avatar: { shape: 'hexagon', size: 3, visible: false, corner: 'cut' }, postText: { fontSize: 99, case: 'upper', weight: 433, tracking: 9, leading: 0.2, font: 'post' }, buttonPrimary: { preset: 'bevel', radius: 'pill', fill: { from: 'accent', to: 'surface', angle: 720 }, hover: { lift: 40, scale: 2, underline: true, fill: 'url(x)' } }, topBar: { fill: 'glass', height: 1 }, handle: { fill: 'glass', preset: 'bevel' }, rocket: { radius: 4 }, nav: 'javascript:alert(1)', })!; assert.deepEqual(clean.post, { radius: TOKEN_LIMITS.radius[1], borderWidth: 0, padX: 13, shadow: 'soft', corner: 'cut' }, 'no literal color, no unknown style, no font size or visibility on a post'); assert.deepEqual(clean.avatar, { size: 20, shape: 'hexagon', visible: false }); assert.deepEqual(clean.postText, { fontSize: 24, font: 'post', weight: 450, tracking: 0.2, leading: 1.1 }, 'post text never changes case'); assert.deepEqual(clean.buttonPrimary, { preset: 'bevel', radius: 'pill', fill: { from: 'accent', to: 'surface', angle: 360 }, hover: { lift: 4, scale: 1.04, underline: true } }); assert.deepEqual(clean.topBar, { fill: 'glass', height: 40 }); assert.equal(clean.handle, undefined, 'glass is for bars and panels; a handle has no presets'); assert.ok(!('rocket' in clean) && !('nav' in clean)); assert.equal(sanitizeComponents({ post: {} }), null); assert.equal(sanitizeComponents('nope'), null); }); test('presets and parents: own tokens win over the preset, the preset over the parent', () => { const resolved = resolveComponents(sanitizeComponents({ button: { radius: 4, weight: 700 }, buttonPrimary: { preset: 'pill', borderWidth: 2 }, panel: { preset: 'window' }, menu: { radius: 8 } })!); assert.deepEqual(resolved.buttonPrimary, { radius: 'pill', weight: 700, borderWidth: 2 }); assert.deepEqual(resolved.buttonSecondary, { radius: 4, weight: 700 }); assert.equal(resolved.menu!.radius, 8); assert.equal(resolved.menu!.borderStyle, 'bevel-out'); assert.equal(resolved.dialog!.radius, 0); // Every preset is itself valid vocabulary for a component of its family. for (const [family, presets] of Object.entries(PRESETS)) { const id = COMPONENT_IDS.find(item => (COMPONENTS[item] as { presets?: string }).presets === family)!; for (const [name, tokens] of Object.entries(presets)) assert.deepEqual(sanitizeComponents({ [id]: tokens })![id], tokens, `${family}.${name}`); } }); test('compiled components: soft values, rigid rules, nothing for a part the site does not have', () => { const out = css({ post: { radius: 0, borderWidth: 1, borderColor: 'border', padX: 16, fill: 'surface' }, postText: { fontSize: 17, font: 'post', leading: 1.5 }, nav: { width: 200 }, avatar: { size: 32, shape: 'square' } }); assert.ok(out.values.includes('--wb-part-post-radius:0px') && out.values.includes('--wb-part-postText-font-size:17px') && out.values.includes('--wb-part-postText-line-height:1.5')); assert.ok(out.rules.includes(':root:root [data-part="post"]{background-color:var(--wb-part-post-fill) !important;background-image:var(--wb-part-post-fill-1) !important;border-radius:var(--wb-part-post-radius) !important;border-width:var(--wb-part-post-border-width) !important;border-style:var(--wb-part-post-border-style) !important;border-color:var(--wb-part-post-border-color) !important;padding-inline:var(--wb-part-post-pad-x) !important}'), out.rules[0]); assert.ok(out.rules.some(rule => rule.startsWith(':root:root [data-part="text"],:root:root [data-part="text"] :is(span,a){') && rule.includes('font-family:var(--wb-part-postText-font)')), 'type lands on the text leaves'); assert.ok(!out.text.includes('nav') && !out.text.includes('--wb-part-avatar-size'), 'no part, or a skipped token: no rule'); assert.ok(out.text.includes('--wb-part-avatar-shape:2px')); // Every rule is important and every value it reads was emitted with it. for (const rule of out.rules) for (const name of rule.match(/var\((--[\w-]+)\)/g) ?? []) assert.ok(out.values.some(value => value.startsWith(`${name.slice(4, -1)}:`)), name); assert.equal(partRule('a, b:is(c,d)'), ':root:root a,:root:root b:is(c,d)'); assert.equal(compileComponents({}, PARTS, CTX).rules.length, 0); }); test('text on a fill the theme chose keeps its contrast: the role is swapped for a readable one', () => { // Accent text on an accent button (1:1), and a post filled with the theme's text color. const out = css({ buttonPrimary: { fill: 'accent', text: 'accent' }, post: { fill: 'text' }, postText: { text: 'muted' }, avatar: { fill: 'surface' } }); assert.ok(out.values.includes(`--wb-part-buttonPrimary-text:${COLORS.onAccent}`)); assert.ok(out.notes.some(note => note.startsWith('buttonPrimary:'))); assert.ok(out.values.includes(`--wb-part-postText-text:${COLORS.muted}`), 'no fill of its own: the theme\'s role stands (the palette guarantees it on the theme\'s surfaces)'); assert.ok(!out.text.includes('--wb-part-avatar-text'), 'a fill alone colors no text on a part that is not a text part'); assert.equal(readableRole('#000000', COLORS), 'onAccent'); for (const role of ['accent', 'text', 'surface', 'inverse'] as const) { const label = css({ buttonPrimary: { fill: role } }).values.find(value => value.startsWith('--wb-part-buttonPrimary-text:'))!.split(':')[1]!; assert.ok(contrastRatio(label, COLORS[role]) >= 4.5, `${role}: ${label}`); } }); test('bevels are four inset lines from the part\'s fill; states move only without reduced motion', () => { const out = css({ buttonPrimary: { preset: 'bevel', hover: { lift: 2, fill: 'elevated' }, active: { shadow: 'inset', scale: 0.98 }, focus: { borderColor: 'accent' } } }); const bevel = out.values.find(value => value.startsWith('--wb-part-buttonPrimary-bevel:'))!; assert.equal(bevel.match(/inset/g)!.length, 4); assert.ok(out.values.includes('--wb-part-buttonPrimary-border-style:none')); assert.ok(out.rules.some(rule => rule.startsWith(':root:root [data-part="primary"]:hover{background-color:#e8f1f7 !important'))); assert.ok(out.rules.some(rule => rule.startsWith('@media (prefers-reduced-motion:no-preference){:root:root [data-part="primary"]:hover{transform:translateY(-2px) !important}'))); assert.ok(out.rules.some(rule => rule.includes('[data-part="primary"]:active{box-shadow:inset') && rule.match(/inset/g)!.length === 4), 'a pressed bevel is the sunken bevel'); assert.ok(out.rules.some(rule => rule.startsWith(':root:root [data-part="primary"]:focus-visible{border-color:#0077b0 !important}'))); assert.ok(out.rules.filter(rule => rule.includes('transform')).every(rule => rule.startsWith('@media (prefers-reduced-motion:no-preference)'))); }); test('variants: known axes and options only; the owner holds an axis or switches layout changes off', () => { assert.deepEqual(sanitizeVariants({ nav: 'top', post: 'default', actions: 'explode', header: 'bar', zoom: 'in' }), { nav: 'top', header: 'bar' }); assert.equal(sanitizeVariants({ nav: 'labels', post: 'default' }), null, 'the site\'s own options are not a variant'); assert.deepEqual(resolveVariants({ nav: 'top', post: 'compact', sidebar: 'hidden' }, { owned: ['sidebar'] }), { nav: 'top', post: 'compact' }); assert.deepEqual(resolveVariants({ nav: 'top' }, { allowed: false }), {}); assert.deepEqual(resolveVariants(null), {}); const skins = { nav: { top: (ctx: { bar: string }) => `nav{top:0;background:${ctx.bar}}` }, post: { compact: () => 'article{padding:4px}' } }; assert.deepEqual(compileVariants({ post: 'compact', nav: 'top', actions: 'labels' }, skins, { bar: 'red' }), ['nav{top:0;background:red}', 'article{padding:4px}'], 'axis order; no skin, no rule'); for (const options of Object.values(VARIANT_AXES)) assert.ok(options.length >= 2); }); test('an owner\'s per-axis say rides on the theme: the site\'s own option removes the axis', () => { const theme = sanitizeSpec({ name: 'T', variants: { nav: 'top', post: 'compact' } }); assert.deepEqual(applyOverrides(theme, { variants: { nav: 'labels', actions: 'labels' } }).variants, { post: 'compact', actions: 'labels' }); assert.deepEqual(applyOverrides(theme).variants, { nav: 'top', post: 'compact' }); assert.equal(applyOverrides(theme, { variants: { nav: 'labels', post: 'default' } }).variants, undefined); }); test('decoration: clamps, plain titles, a focus ring that is always visible', () => { const decor = sanitizeDecor({ dividers: { style: 'dashed', color: 'muted', width: 9 }, chrome: { kind: 'titlebar', title: 'Timeline"};body{display:none}' }, { d: 'M0 0" onload="x' }, { d: 'url(javascript:1)' }, { d: '<' }, { d: 'M0 0'.padEnd(ICON_LIMITS.pathChars + 1, '0') }, { d: '' }, { layers: [{ d: 'M0 0h1', fill: 'url(#x)' }] }, { layers: [{ d: 'M0 0h1', fill: 'red' }] }, { layers: [] }, { png: 'data:image/svg+xml;base64,AAAA' }, { png: 'https://example.com/a.png' }, { png: `data:image/png;base64,${'A'.repeat(ICON_LIMITS.pngChars + 8)}` }, { svg: '' }, '', null, ]) assert.equal(sanitizeGlyph(hostile), null, JSON.stringify(hostile).slice(0, 60)); assert.deepEqual(sanitizeGlyph({ d: ' M4 4L20 20 ', rule: 'evenodd', stroke: true, extra: 1 }), { d: 'M4 4L20 20', rule: 'evenodd', stroke: true }); assert.equal((sanitizeGlyph({ layers: Array.from({ length: 9 }, () => ({ d: 'M0 0h1v1z', fill: '#AABBCC' })) }) as { layers: unknown[] }).layers.length, ICON_LIMITS.layers); assert.ok(PATH_GRAMMAR.test('M12 21a9 9 0 1 1 0-18 9 9 0 0 1 0 18zM3 12h18') && !PATH_GRAMMAR.test('M0 0;')); }); test('an icon pack document: id, name and at least one glyph; unknown names dropped; license stated or flagged', () => { assert.equal(PACK.license, 'own'); assert.deepEqual(Object.keys(PACK.glyphs), ['home', 'reply', 'like', 'like.on', 'share']); assert.deepEqual(PACK.style, { stroke: 2, cap: 'square', join: 'miter', scale: 0.9 }); assert.equal(sanitizeIconPack({ id: 'x', name: 'X', glyphs: { rocket: { d: 'M0 0h1' } } }), null); assert.equal(sanitizeIconPack({ id: 'Bad Id', name: 'X', glyphs: { home: { d: 'M0 0h1' } } }), null); const parsed = parseIconPackJson(JSON.stringify({ id: 'p', name: 'P ', glyphs: { home: { d: 'M0 0h1' }, rocket: { d: 'M0 0' }, like: { d: '<' } } })); assert.ok('pack' in parsed && parsed.pack.name === 'P b' && parsed.pack.license === 'unknown' && parsed.warnings.length === 2); assert.ok('error' in parseIconPackJson('{')); assert.ok('error' in parseIconPackJson(JSON.stringify({ id: 'p', name: 'P', glyphs: {} }))); assert.ok('error' in parseIconPackJson(' '.repeat(ICON_LIMITS.packBytes + 1))); assert.equal(new Set(ICON_NAMES).size, ICON_NAMES.length); assert.ok(ICON_NAMES.length <= ICON_LIMITS.names); }); test('a theme names a pack and may adjust it a little; the owner\'s icon choice is separate', () => { const icons = sanitizeThemeIcons({ pack: 'test-pack', style: { stroke: 9 }, overrides: { home: { d: 'M1 1h2' }, rocket: { d: 'M0 0' } }, symbols: { like: 'star', verified: 'nope' }, labels: { reply: 'reply', like: 'a"b', share: 'way too long a label' }, glyphs: { home: { d: 'M0 0' } } })!; assert.deepEqual(icons, { pack: 'test-pack', style: { stroke: 2.5 }, overrides: { home: { d: 'M1 1h2' } }, symbols: { like: 'star' }, labels: { reply: 'reply' } }); assert.equal(sanitizeThemeIcons({ pack: 'NOPE!' }), null); const catalog = createIconCatalog([PACK]); assert.equal(catalog.resolve('theme', icons), PACK); assert.equal(catalog.resolve('theme', null), null); assert.equal(catalog.resolve('site', icons), null, 'the owner asked for the site\'s own icons'); assert.equal(catalog.resolve('missing-pack', icons), null, 'a pack that is not installed falls back to the site\'s icons'); assert.equal(catalog.resolve('test-pack', null), PACK, 'an explicit pick needs no theme'); assert.equal(normalizeIconChoice('test-pack'), 'test-pack'); assert.equal(normalizeIconChoice({}), 'theme'); }); test('icons are drawn as masks of the current color (colored glyphs as images), behind guards, per group', () => { const out = compileIcons(PACK, null, TABLE, { colors: COLORS, gate: 'data-wb-icons' }); assert.ok(out.startsWith('@media (forced-colors:none){@supports ((mask-image:url("")) or (-webkit-mask-image:url(""))){')); const home = out.split('\n').find(line => line.startsWith('html[data-wb-icons~="nav"] [data-nav="home"] svg{'))!; assert.ok(home.includes('background-color:currentColor !important') && home.includes('-webkit-mask:url("data:image/svg+xml,') && home.includes('mask:url("data:image/svg+xml,') && home.includes('/ 90% no-repeat')); const svgOf = (line: string) => decodeURIComponent(/url\("data:image\/svg\+xml,([^"]*)"\)/.exec(line)![1]!); assert.ok(svgOf(home).includes('stroke-width="2" stroke-linecap="square" stroke-linejoin="miter"'), 'the pack\'s stroke style'); const reply = out.split('\n').find(line => line.startsWith('html[data-wb-icons~="actions"] [data-act="reply"] svg{'))!; assert.ok(reply.includes('background:url("data:image/svg+xml,') && !reply.includes('mask'), 'layers carry their own colors'); assert.ok(svgOf(reply).includes(`fill="${COLORS.accent}" opacity="0.8"`) && svgOf(reply).includes(' g > path[d^="M12 2.59l5.7 5.7"]){background:url("data:image/png;base64,iVBORw0KGgo=") center / 90% no-repeat !important;image-rendering:pixelated;}')); assert.ok(out.includes('[data-act="unlike"] svg > *') && out.includes('{opacity:0 !important}'), 'the site\'s own paths are hidden, not removed'); assert.ok(!out.includes('explore') && !out.includes('script'), 'no glyph: the site\'s icon stays; a signature that is not path data is never written'); // No encoded quote can end the url("…") early. for (const url of out.match(/url\("data:image\/svg\+xml,[^)]*\)/g)!) assert.ok(!url.slice(5, -2).includes('"')); assert.equal(compileIcons(PACK, null, TABLE, { colors: COLORS, allowed: false }), ''); assert.equal(compileIcons(null, null, TABLE, { colors: COLORS }), ''); assert.ok(compileIcons(null, { overrides: { explore: { d: 'M2 2h20v20H2z' } } }, TABLE, { colors: null }).includes('[data-nav="explore"] svg{'), 'a theme\'s own override needs no pack'); assert.equal(glyphSvg({ png: 'data:image/png;base64,AAAA' }, BASE_ICON_STYLE), null); }); test('a group is drawn only when all of its core icons are on the page', () => { const present = (selectors: string[]) => iconGroupsPresent(TABLE, selector => selectors.includes(selector)); assert.deepEqual(present(['[data-nav="home"] svg', '[data-nav="explore"] svg', '[data-act="like"] svg', '[data-act="reply"] svg']), ['nav', 'actions', 'composer', 'misc']); assert.deepEqual(present(['[data-nav="home"] svg', '[data-act="like"] svg', '[data-act="reply"] svg']), ['actions', 'composer', 'misc'], 'a rail never shows half ours, half the site\'s'); assert.deepEqual(iconGroupsPresent(TABLE, () => { throw new Error('bad selector'); }), ['composer', 'misc']); }); test('a theme document: structure groups are optional, additive and dropped when empty', () => { const v1 = sanitizeSpec({ name: 'Plain', colors: { bg: '#ffffff', text: '#111111', accent: '#0066cc' } }); for (const key of ['components', 'variants', 'decor', 'icons']) assert.ok(!(key in v1), `${key} absent from a theme that does not set it`); assert.ok(!hasStructure(v1)); const v2 = sanitizeSpec({ name: 'Built', colors: { bg: '#ffffff', text: '#111111', accent: '#0066cc', actions: { like: '#F5A623', reply: 'red' } }, components: { post: { preset: 'card' }, nope: {} }, variants: { nav: 'top' }, decor: { links: { underline: 'always' } }, icons: { pack: 'wb-classic' }, }); assert.deepEqual(v2.colors!.actions, { like: '#f5a623' }); assert.deepEqual([v2.components, v2.variants, v2.decor, v2.icons], [{ post: { preset: 'card' } }, { nav: 'top' }, { links: { underline: 'always' } }, { pack: 'wb-classic' }]); assert.ok(hasStructure(v2)); assert.ok(!isNeutral(sanitizeSpec({ name: 'Only structure', variants: { nav: 'top' } })), 'a theme that only moves the navigation is not the site\'s own look'); assert.ok(!hasStructure(sanitizeSpec({ name: 'Nothing', components: { post: {} }, variants: { nav: 'labels' } })), 'empty groups and the site\'s own options are no structure'); }); test('the published schemas are generated from these tables and current', () => { const script = fileURLToPath(new URL('../../waybetter/scripts/theme-schema.mjs', import.meta.url)); const run = spawnSync(process.execPath, [script, '--check'], { encoding: 'utf8' }); assert.equal(run.status, 0, run.stderr); const schema = JSON.parse(readFileSync(new URL('../../waybetter/theme.schema.json', import.meta.url), 'utf8')); assert.deepEqual(Object.keys(schema.properties.components.properties), COMPONENT_IDS); assert.deepEqual([schema.properties.components.properties.post.properties.radius.oneOf[0].minimum, schema.properties.components.properties.post.properties.radius.oneOf[0].maximum], [...TOKEN_LIMITS.radius]); assert.deepEqual(schema.properties.variants.properties.nav.enum, [...VARIANT_AXES.nav]); assert.equal(schema.properties.components.properties.postText.properties.case, undefined); assert.ok(new RegExp(schema.$defs.pathData.pattern).test('M4 4L20 20') && !new RegExp(schema.$defs.pathData.pattern).test('')); const icons = JSON.parse(readFileSync(new URL('../../waybetter/icons.schema.json', import.meta.url), 'utf8')); assert.deepEqual(Object.keys(icons.properties.glyphs.properties), [...ICON_NAMES]); assert.deepEqual(icons.required, ['id', 'name', 'license', 'glyphs']); });