release: v0.20.0
This commit is contained in:
173
lib/__tests__/preset-registry.test.ts
Normal file
173
lib/__tests__/preset-registry.test.ts
Normal file
@@ -0,0 +1,173 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { PRESET_REGISTRY, PRESET_IDS } from '../config/presets';
|
||||
import {
|
||||
THEME_PRESETS,
|
||||
DEFAULT_PRESET,
|
||||
resolveTheme,
|
||||
contrastRatio,
|
||||
accentForMode,
|
||||
} from '../config/theme';
|
||||
import { parseFamilies, MAX_FAMILIES } from '../fonts';
|
||||
|
||||
/**
|
||||
* A preset is declared once, in PRESET_REGISTRY. The places that cannot import
|
||||
* the registry — the stylesheets, the docs, a plain-node script — are held to
|
||||
* it here, so adding a preset fails loudly where a step was missed instead of
|
||||
* shipping a preset with no palette or no stylesheet.
|
||||
*/
|
||||
const root = process.cwd();
|
||||
const read = (rel: string) => fs.readFileSync(path.join(root, rel), 'utf8');
|
||||
|
||||
const tokensCss = read('app/tokens.css').replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
const globalsCss = read('app/globals.css');
|
||||
|
||||
/** Custom properties declared by the rule with exactly this selector. */
|
||||
function block(selector: string): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
for (const m of tokensCss.matchAll(/([^{}]+)\{([^}]*)\}/g)) {
|
||||
if (m[1].trim() !== selector) continue;
|
||||
for (const d of m[2].matchAll(/(--[\w-]+)\s*:\s*([^;]+);/g)) out[d[1]] = d[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** The surface and text tokens a preset palette has to define in each mode. */
|
||||
const PALETTE_TOKENS = [
|
||||
'--bg-primary',
|
||||
'--bg-secondary',
|
||||
'--bg-card',
|
||||
'--bg-card-hover',
|
||||
'--bg-overlay',
|
||||
'--text-primary',
|
||||
'--text-secondary',
|
||||
'--text-muted',
|
||||
'--border-subtle',
|
||||
'--border-hover',
|
||||
];
|
||||
|
||||
/**
|
||||
* Surfaces the text tokens are held to 4.5:1 on. Light mode includes
|
||||
* --bg-card-hover: studio-modern's light --text-muted (#73736e) measured 4.17:1
|
||||
* on --bg-secondary and 4.36:1 on --bg-card-hover before it was darkened.
|
||||
* Dark mode leaves --bg-card-hover out, where muted text sits at 4.1–4.3:1 in
|
||||
* several presets.
|
||||
*/
|
||||
const TEXT_SURFACES = {
|
||||
dark: ['--bg-primary', '--bg-secondary', '--bg-card'],
|
||||
light: ['--bg-primary', '--bg-secondary', '--bg-card', '--bg-card-hover'],
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* The first seven presets use their accent for dots, rules and fills, held to
|
||||
* 3:1 by preset-contrast.test.ts. Presets added since also set links and
|
||||
* labels in it, so their accent has to read as text: 4.5:1 on every surface.
|
||||
*/
|
||||
const ACCENT_AS_DECORATION_ONLY = new Set([
|
||||
'studio-modern',
|
||||
'studio',
|
||||
'minimal',
|
||||
'editorial',
|
||||
'classic',
|
||||
'noir',
|
||||
'monograph',
|
||||
]);
|
||||
|
||||
describe('preset registry', () => {
|
||||
it('has unique ids and contains the default preset', () => {
|
||||
expect(new Set(PRESET_IDS).size).toBe(PRESET_IDS.length);
|
||||
expect(PRESET_IDS).toContain(DEFAULT_PRESET);
|
||||
});
|
||||
|
||||
it('is what THEME_PRESETS and resolveTheme serve', () => {
|
||||
expect(Object.keys(THEME_PRESETS)).toEqual([...PRESET_IDS]);
|
||||
for (const id of PRESET_IDS) expect(resolveTheme(id).preset).toBe(id);
|
||||
});
|
||||
|
||||
it('leaves only studio without its own styles', () => {
|
||||
expect(PRESET_REGISTRY.filter((p) => !p.ownStyles).map((p) => p.id)).toEqual(['studio']);
|
||||
});
|
||||
|
||||
it('keeps scripts/contrast-audit.mjs in step', () => {
|
||||
const list = /const PRESETS = \[([^\]]*)\]/.exec(read('scripts/contrast-audit.mjs'))?.[1];
|
||||
expect(list).toBeDefined();
|
||||
const ids = [...list!.matchAll(/'([^']+)'/g)].map((m) => m[1]);
|
||||
expect(ids).toEqual([...PRESET_IDS]);
|
||||
});
|
||||
|
||||
for (const preset of PRESET_REGISTRY) {
|
||||
describe(preset.id, () => {
|
||||
it('has a label, an install tagline and an admin card description', () => {
|
||||
expect(preset.label.trim()).not.toBe('');
|
||||
expect(preset.tagline.trim()).not.toBe('');
|
||||
expect(preset.description.trim()).not.toBe('');
|
||||
for (const c of [preset.card.bg, preset.card.tile, preset.card.accent]) {
|
||||
expect(c).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('names fonts lib/fonts.ts can serve', () => {
|
||||
const { heading, body, caption } = preset.theme.fonts;
|
||||
const families = parseFamilies([heading, body, caption]);
|
||||
expect(families).not.toBeNull();
|
||||
expect(families!.length).toBeLessThanOrEqual(MAX_FAMILIES);
|
||||
});
|
||||
|
||||
it('is documented', () => {
|
||||
expect(read('docs/theming.md')).toContain(`| **${preset.id}**`);
|
||||
expect(read('content/settings.yaml.example')).toContain(preset.id);
|
||||
});
|
||||
|
||||
if (!preset.ownStyles) return;
|
||||
|
||||
it('has a stylesheet that globals.css imports', () => {
|
||||
expect(fs.existsSync(path.join(root, 'app/themes', `${preset.id}.css`))).toBe(true);
|
||||
expect(globalsCss).toContain(`@import url('./themes/${preset.id}.css');`);
|
||||
});
|
||||
|
||||
it('defines its palette in app/tokens.css for both colour modes', () => {
|
||||
const dark = block(`[data-preset='${preset.id}']`);
|
||||
const light = block(`[data-preset='${preset.id}'][data-theme='light']`);
|
||||
for (const token of PALETTE_TOKENS) {
|
||||
expect(dark[token], `${token} (dark)`).toBeDefined();
|
||||
expect(light[token], `${token} (light)`).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps its text tokens at 4.5:1 on every surface, in both modes', () => {
|
||||
for (const mode of ['dark', 'light'] as const) {
|
||||
const vars = {
|
||||
...block(`[data-preset='${preset.id}']`),
|
||||
...(mode === 'light' ? block(`[data-preset='${preset.id}'][data-theme='light']`) : {}),
|
||||
};
|
||||
for (const text of ['--text-primary', '--text-secondary', '--text-muted']) {
|
||||
for (const surface of TEXT_SURFACES[mode]) {
|
||||
const ratio = contrastRatio(vars[text], vars[surface]);
|
||||
// Palettes written as rgba() are measured by the browser audit
|
||||
// (npm run audit:contrast), not here.
|
||||
if (ratio === null) continue;
|
||||
expect(ratio, `${mode} ${text} on ${surface}`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (ACCENT_AS_DECORATION_ONLY.has(preset.id)) return;
|
||||
|
||||
it('keeps its accent readable as text on every surface, in both modes', () => {
|
||||
for (const mode of ['dark', 'light'] as const) {
|
||||
const vars = {
|
||||
...block(`[data-preset='${preset.id}']`),
|
||||
...(mode === 'light' ? block(`[data-preset='${preset.id}'][data-theme='light']`) : {}),
|
||||
};
|
||||
const accent = accentForMode(resolveTheme(preset.id), mode);
|
||||
for (const surface of ['--bg-primary', '--bg-secondary', '--bg-card']) {
|
||||
const ratio = contrastRatio(accent, vars[surface]);
|
||||
expect(ratio, `${mode} accent on ${surface}`).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user