Live 2026-10-01: X's search-field icon is a 28x16 box with 12px of padding; sized at 100% of that width a pixel glyph lost its top and bottom and sat left of X's own icon. Glyphs are now sized with contain (a scaled pack by the box's height) and placed in the content box, for masks and images alike.
278 lines
24 KiB
TypeScript
278 lines
24 KiB
TypeScript
// 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}<script>' },
|
|
focusRing: { width: 0, style: 'wavy', color: 'surface', offset: 40 }, selection: { fill: 'accent', text: 'accent' },
|
|
scrollbar: { style: 'classic', thumb: 'surface', track: 'elevated', width: 'huge' }, links: { underline: 'always' }, motion: { duration: 9999, curve: 'steps', press: 0.5 }, cursor: 'url(x)',
|
|
})!;
|
|
assert.deepEqual(decor.dividers, { style: 'dashed', color: 'muted', width: 3 });
|
|
assert.deepEqual(decor.chrome, { kind: 'titlebar', title: 'Timelinebodydisplay:none' });
|
|
assert.deepEqual(decor.focusRing, { width: 2, color: 'surface', offset: 4 });
|
|
assert.deepEqual(decor.motion, { duration: 400, curve: 'steps', press: 0.96 });
|
|
assert.ok(!('cursor' in decor));
|
|
assert.equal(sanitizeDecor({ chrome: { kind: 'none' }, selection: { fill: 'accent' } }), null);
|
|
assert.equal(chromeTitle('C:\\WINDOWS> dir & "x"'), 'C:WINDOWS> dir + x');
|
|
assert.equal(cssString('a"b\\c'), '"a\\22 b\\5c c"');
|
|
|
|
const targets = { divider: '[data-cell]', focusable: 'a,button', link: '[data-text] a', moving: 'a,button', pressable: 'button', chrome: { titlebar: (title: string) => `[data-col]::before{content:${cssString(title)}}` } };
|
|
const out = compileDecor(decor, targets, { colors: COLORS }).join('\n');
|
|
assert.ok(out.includes(':root:root :is([data-cell]){border-bottom:3px dashed #5b6b78 !important}'));
|
|
// The theme asked for a ring in its surface color (1.07:1 on the page): the ring takes a visible role instead.
|
|
const ring = /outline:2px solid (#[0-9a-f]{6})/.exec(out)![1]!;
|
|
for (const surface of [COLORS.bg, COLORS.surface, COLORS.page]) assert.ok(contrastRatio(ring, surface) >= 3, `${ring} on ${surface}`);
|
|
assert.ok(out.includes('::selection{background-color:#0077b0 !important;color:#ffffff !important}'), 'selection text is readable on its fill');
|
|
assert.ok(out.includes('::-webkit-scrollbar-thumb') && out.includes('[data-col]::before{content:"Timelinebodydisplay:none"}'));
|
|
assert.ok(out.includes('@media (prefers-reduced-motion:no-preference){') && out.includes('steps(3,end)') && out.includes('button):active{transform:scale(0.96)'));
|
|
assert.deepEqual(compileDecor(decor, {}, { colors: COLORS }).filter(rule => !rule.includes('::selection') && !rule.includes('scrollbar')), [], 'a site without targets draws only what needs none');
|
|
});
|
|
|
|
const PACK: IconPack = sanitizeIconPack({
|
|
id: 'test-pack', name: 'Test pack', author: 'Someone', license: 'own', source: 'drawn for the test', credits: 'Someone',
|
|
style: { stroke: 2, cap: 'square', join: 'miter', scale: 0.9 },
|
|
glyphs: {
|
|
home: { d: 'M4 11l8-7 8 7v9H4z', stroke: true }, like: { d: 'M12 20l-8-8a5 5 0 0 1 8-6 5 5 0 0 1 8 6z' }, 'like.on': { d: 'M12 20l-8-8a5 5 0 0 1 8-6 5 5 0 0 1 8 6z' },
|
|
reply: { layers: [{ d: 'M3 4h18v12H9l-5 4z', fill: { from: '#ffffff', to: '#88ccff' } }, { d: 'M6 7h12v2H6z', fill: 'accent', opacity: 0.8 }] },
|
|
share: { png: 'data:image/png;base64,iVBORw0KGgo=', pixel: true },
|
|
},
|
|
labels: { reply: 'reply', like: 'fav' }, symbols: { like: 'star' },
|
|
})!;
|
|
const TABLE: IconTable = {
|
|
home: { ctx: '[data-nav="home"] svg', group: 'nav', core: true },
|
|
explore: { ctx: '[data-nav="explore"] svg', group: 'nav', core: true },
|
|
like: { ctx: '[data-act="like"] svg', group: 'actions', core: true },
|
|
'like.on': { ctx: '[data-act="unlike"] svg', group: 'actions' },
|
|
reply: { ctx: '[data-act="reply"] svg', group: 'actions', core: true },
|
|
share: { sig: ['M12 2.59l5.7 5.7'], group: 'actions' },
|
|
grok: { sig: ['M2.2 12"><script>'], group: 'nav' },
|
|
};
|
|
|
|
test('a glyph is path data, colored layers or a small PNG; nothing else gets in', () => {
|
|
for (const hostile of [
|
|
{ d: 'M0 0<script>alert(1)</script>' }, { 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: '<svg/>' }, '<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 <b>', 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('/ auto 90% no-repeat content-box'));
|
|
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('<linearGradient id="g0"'));
|
|
assert.ok(out.includes('svg:has(> g > path[d^="M12 2.59l5.7 5.7"]){background:url("data:image/png;base64,iVBORw0KGgo=") center / auto 90% no-repeat content-box !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('<svg>'));
|
|
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']);
|
|
});
|