A theme may now say how each piece of a site is built, not only one radius and one density: - components (themeComponents.ts): 36 generic parts with clamped tokens (radius, border incl. 98.css-style bevels, fill by color role / glass / gradient, padding, sizes, type, shadow, shape, hover/active/focus), presets and parents; compiled over a site's part map as soft values (--wb-part-*) and rigid rules; text on a chosen fill keeps its contrast. - variants (themeVariants.ts): fixed words on six axes; the owner holds an axis or switches layout changes off; the look of a variant is the site's skin. - decoration (themeDecor.ts): dividers, chrome (title bar, masthead, prompt), a focus ring that is always visible, selection, scrollbars, links, motion under reduced-motion. - icon packs (themeIcons.ts): a standalone document of glyphs by semantic name (path data, colored layers or a small PNG, never an SVG document), with license, source and credits; a theme only names a pack; the owner's icon choice is separate; drawn as CSS masks of the current color over the site's own svg boxes, behind forced-colors and mask-support guards, per group. - a theme document gains the optional groups components, variants, decor, icons and colors.actions; a theme without them is sanitized to exactly what it was. - theme.schema.json and the new icons.schema.json are generated from the same tables (scripts/theme-schema.mjs; a test fails when they are stale). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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('/ 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('<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 / 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('<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']);
|
|
});
|