- Rich sentences (a link or button inside text) can be replaced by data: the inventory gives a rich entry `slots`, a pack value places the site's own elements with {e0}, {e1}, ... (each once, the last may be left out; no markup, plural or other parameters). The engine returns a pieces + element order template; the page inventory carries `rich` slot counts.
- Packs may carry `when: { languages }` (applies only while the site shows one of them; `en` matches `en-GB`) and `rewrite: { words }` (whole-word rewording of the site's own text, messages keep their parameters' values; no sentences of the site in the pack). The hook tells the engine the site's language; a rewording pack makes the delivery carry the whole inventory.
- Apps can ship built-in packs (BuiltInPacks) delivered before installed ones; syncStringPacks hands the tabs a delivery at start only when it changed.
- Translate mode words are a function the app supplies (asked at each draw) and gain a `plural` word; host validates `when` and `rewrite` envelopes.
- Theme doctor: every finding says where (token path or CSS rule), what and how to fix, as catalog keys wb.doctor.*; the Theme Builder shows the full report; the import hint is translated. Doctor words are in the 17 supported catalogs.
- waybetter strings check describes conditions and rewrites.
75 lines
4.2 KiB
TypeScript
75 lines
4.2 KiB
TypeScript
import assert from 'node:assert/strict';
|
|
import test from 'node:test';
|
|
import { DOCTOR_MESSAGES, themeDoctor } from '../src/themeDoctor.ts';
|
|
import { sanitizeSpec } from '../src/themeSpec.ts';
|
|
|
|
const ids = (raw: unknown) => themeDoctor(sanitizeSpec(raw)).map(advice => advice.id);
|
|
|
|
test('a theme made of tokens and component settings gets no advice', () => {
|
|
assert.deepEqual(ids({ name: 'Clean', colors: { bg: '#000000', text: '#ffffff', accent: '#1d9bf0' }, components: { button: { radius: 4 } } }), []);
|
|
});
|
|
|
|
test('free-form CSS is pointed at the lighter mechanism and its costs', () => {
|
|
const css = [
|
|
'a { border-radius: 0; }',
|
|
'.x { display: none; }',
|
|
'.y { color: #ff0000; }',
|
|
'.z { font-family: Comic Sans MS; }',
|
|
'div:has(img) { opacity: .9; }',
|
|
'* { box-sizing: border-box; }',
|
|
'.g { backdrop-filter: blur(8px); }',
|
|
'button:focus { outline: none; }',
|
|
'.m { transition: all .2s; }',
|
|
'.b { box-shadow: inset 1px 1px #fff, inset -1px -1px #000, inset 2px 2px #ddd; }',
|
|
].join('\n');
|
|
const found = ids({ name: 'Heavy', css });
|
|
for (const id of ['css-radius', 'css-hide', 'css-color', 'css-font', 'css-has', 'css-universal', 'css-blur', 'css-outline-none', 'css-motion', 'css-bevel']) {
|
|
assert.ok(found.includes(id), `${id} is reported`);
|
|
}
|
|
});
|
|
|
|
test('advice is a hint, never a change: the theme is left as it was', () => {
|
|
const theme = sanitizeSpec({ name: 'Keep', css: '.x { display: none; }' });
|
|
const before = JSON.stringify(theme);
|
|
themeDoctor(theme);
|
|
assert.equal(JSON.stringify(theme), before);
|
|
});
|
|
|
|
test('unreadable colors are named with their contrast', () => {
|
|
const advice = themeDoctor(sanitizeSpec({ name: 'Low', colors: { bg: '#000000', text: '#111111', accent: '#1d9bf0' } }));
|
|
const text = advice.find(item => item.id === 'contrast-text');
|
|
assert.ok(text, 'text contrast is reported');
|
|
assert.equal(text.kind, 'contrast');
|
|
assert.match(text.text, /\d\.\d\d:1/);
|
|
});
|
|
|
|
test('every finding says where it is (token or selector), what it found and how to fix it', () => {
|
|
const css = '.card { border-radius: 4px; }\n.row { border-radius: 2px; }\n.row { border-radius: 3px; }\n@media (min-width: 1px) { .nav a { display: none; } }\n* { margin: 0 }';
|
|
const theme = sanitizeSpec({ name: 'Where', css, colors: { bg: '#000000', text: '#111111', accent: '#1d9bf0' }, components: { postText: { fontSize: 13 } }, variants: { nav: 'top' } });
|
|
const found = themeDoctor(theme);
|
|
const radius = found.filter(item => item.id === 'css-radius').map(item => item.where);
|
|
assert.deepEqual(radius, ['.card { border-radius }', '.row { border-radius }'], 'one finding per rule, not per property');
|
|
assert.ok(found.some(item => item.id === 'css-hide' && item.where === '.nav a { display }'), 'a rule inside @media names its own selector');
|
|
assert.ok(found.some(item => item.id === 'css-universal'));
|
|
const token = found.find(item => item.id === 'contrast-text')!;
|
|
assert.equal(token.where, 'colors.text');
|
|
assert.equal(token.key, 'contrast');
|
|
assert.deepEqual(Object.keys(token.params), ['token', 'ratio']);
|
|
assert.match(token.text, /^The text color was adjusted/);
|
|
assert.ok(found.some(item => item.where === 'components.postText.fontSize' && item.params.size === '13'));
|
|
assert.ok(found.some(item => item.where === 'variants.nav' && item.params.position === 'top'));
|
|
for (const item of found) {
|
|
assert.ok(item.fix.length > 10, `${item.id} has a fix hint`);
|
|
assert.ok(`wb.doctor.${item.key}.found` in DOCTOR_MESSAGES && `wb.doctor.${item.key}.fix` in DOCTOR_MESSAGES, `${item.key} has catalog words`);
|
|
assert.ok(!/\{\w+\}/.test(item.text + item.fix), `${item.id}: every slot is filled`);
|
|
}
|
|
});
|
|
|
|
test('the doctor\'s words are plain text with only the slots its findings fill', () => {
|
|
for (const [key, text] of Object.entries(DOCTOR_MESSAGES)) {
|
|
assert.ok(!/[<>]/.test(text), `${key} has no markup`);
|
|
for (const slot of text.matchAll(/\{(\w+)\}/g)) assert.ok(['token', 'ratio', 'size', 'position'].includes(slot[1]!), `${key}: {${slot[1]}}`);
|
|
}
|
|
const ids = new Set(Object.keys(DOCTOR_MESSAGES).map(key => key.replace(/\.(found|fix)$/, '')));
|
|
for (const id of ids) assert.ok(`${id}.found` in DOCTOR_MESSAGES && `${id}.fix` in DOCTOR_MESSAGES, id);
|
|
});
|