release: v0.20.0
This commit is contained in:
148
lib/__tests__/accent-text.test.ts
Normal file
148
lib/__tests__/accent-text.test.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import {
|
||||
THEME_PRESETS,
|
||||
resolveTheme,
|
||||
accentForMode,
|
||||
accentText,
|
||||
contrastRatio,
|
||||
} from '../config/theme';
|
||||
|
||||
/**
|
||||
* Accent-coloured text (`--accent-text`), input borders and the draft badge
|
||||
* are checked against every hex surface token of each preset and mode, read
|
||||
* from app/tokens.css — the studio-modern red measured 3.66:1 as text on its
|
||||
* own dark page, and the input hairlines 1.15–1.29:1.
|
||||
*/
|
||||
const root = process.cwd();
|
||||
const tokens = fs.readFileSync(path.join(root, 'app', 'tokens.css'), 'utf8');
|
||||
|
||||
function block(selector: string): string {
|
||||
const escaped = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
return new RegExp(`(?:^|\\n)${escaped}\\s*\\{([^}]*)\\}`).exec(tokens)?.[1] ?? '';
|
||||
}
|
||||
|
||||
function tokenIn(css: string, name: string): string | undefined {
|
||||
return new RegExp(`${name}:\\s*(#[0-9a-fA-F]{3,6})\\b`).exec(css)?.[1];
|
||||
}
|
||||
|
||||
type Mode = 'dark' | 'light';
|
||||
const SURFACES = ['--bg-primary', '--bg-secondary', '--bg-card', '--bg-card-hover'];
|
||||
|
||||
/** A preset's own value for a token, falling back to the mode's defaults. */
|
||||
function token(preset: string, mode: Mode, name: string): string {
|
||||
const own =
|
||||
mode === 'dark'
|
||||
? tokenIn(block(`[data-preset='${preset}']`), name)
|
||||
: tokenIn(block(`[data-preset='${preset}'][data-theme='light']`), name);
|
||||
const fallback = tokenIn(block(mode === 'dark' ? ':root' : "[data-theme='light']"), name);
|
||||
const value = own ?? fallback;
|
||||
if (!value) throw new Error(`${name} missing for ${preset}/${mode}`);
|
||||
return value;
|
||||
}
|
||||
|
||||
const MODES: Mode[] = ['dark', 'light'];
|
||||
|
||||
describe('accentText', () => {
|
||||
for (const name of Object.keys(THEME_PRESETS)) {
|
||||
for (const mode of MODES) {
|
||||
it(`${name} (${mode}): accent text reaches 4.5:1 on every surface`, () => {
|
||||
const text = accentText(accentForMode(resolveTheme(name), mode), mode);
|
||||
for (const surface of SURFACES) {
|
||||
expect(contrastRatio(text, token(name, mode, surface))!).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
it('lifts the studio-modern red on dark and darkens it on light', () => {
|
||||
// #e60012 is 3.66:1 on #121212 — the reported failure.
|
||||
expect(contrastRatio('#e60012', '#121212')!).toBeLessThan(4.5);
|
||||
const dark = accentText('#e60012', 'dark');
|
||||
const light = accentText('#e60012', 'light');
|
||||
expect(dark).not.toBe('#e60012');
|
||||
expect(light).not.toBe('#e60012');
|
||||
expect(contrastRatio(dark, '#121212')!).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(light, '#fafaf8')!).toBeGreaterThanOrEqual(4.5);
|
||||
});
|
||||
|
||||
it('keeps an accent that already passes', () => {
|
||||
expect(accentText('#ffffff', 'dark')).toBe('#ffffff');
|
||||
expect(accentText('#000000', 'light')).toBe('#000000');
|
||||
expect(accentText('#c8c8c8', 'dark')).toBe('#c8c8c8');
|
||||
});
|
||||
|
||||
it('reaches AA for any owner accent, including ones near the page colour', () => {
|
||||
const samples = ['#000000', '#0a1f44', '#1d4ed8', '#ffea00', '#ffffff', '#808080', '#6366f1'];
|
||||
for (const accent of samples) {
|
||||
expect(contrastRatio(accentText(accent, 'dark'), '#262626')!).toBeGreaterThanOrEqual(4.5);
|
||||
expect(contrastRatio(accentText(accent, 'light'), '#e8e8e3')!).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
});
|
||||
|
||||
it('widens short hex and passes non-hex through', () => {
|
||||
expect(accentText('#fff', 'dark')).toBe('#ffffff');
|
||||
expect(accentText('red', 'dark')).toBe('red');
|
||||
});
|
||||
|
||||
it('matches the fallbacks written into tokens.css', () => {
|
||||
expect(tokens).toContain(
|
||||
`--accent-text: var(--accent-text-dark, ${accentText('#e60012', 'dark')})`,
|
||||
);
|
||||
expect(tokens).toContain(
|
||||
`--accent-text: var(--accent-text-light, ${accentText('#c8000f', 'light')})`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('public styles use the text token for accent-coloured text', () => {
|
||||
const files = [
|
||||
'app/globals.css',
|
||||
'app/leaflet.css',
|
||||
'app/journal/journal.css',
|
||||
'app/contact/contact.css',
|
||||
...fs.readdirSync(path.join(root, 'app', 'themes')).map((f) => `app/themes/${f}`),
|
||||
...fs
|
||||
.readdirSync(path.join(root, 'components'))
|
||||
.filter((f) => f.endsWith('.module.css'))
|
||||
.map((f) => `components/${f}`),
|
||||
];
|
||||
|
||||
for (const file of files) {
|
||||
it(`${file} has no text coloured with the raw accent`, () => {
|
||||
const css = fs.readFileSync(path.join(root, file), 'utf8');
|
||||
expect(css).not.toMatch(/(^|[^-])color:\s*var\(--(sm-)?accent[,)]/m);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('mode tokens', () => {
|
||||
it('declares a color-scheme per mode', () => {
|
||||
expect(block(':root')).toMatch(/color-scheme:\s*dark/);
|
||||
expect(block("[data-theme='light']")).toMatch(/color-scheme:\s*light/);
|
||||
});
|
||||
|
||||
it('input borders reach 3:1 on the page surfaces (WCAG 1.4.11)', () => {
|
||||
expect(block(':root')).toMatch(/--input-border:\s*var\(--text-muted\)/);
|
||||
for (const name of Object.keys(THEME_PRESETS)) {
|
||||
for (const mode of MODES) {
|
||||
const border = token(name, mode, '--text-muted');
|
||||
for (const surface of ['--bg-primary', '--bg-secondary']) {
|
||||
expect(contrastRatio(border, token(name, mode, surface))!).toBeGreaterThanOrEqual(3);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('the warning colour reads at 4.5:1 on every surface', () => {
|
||||
for (const name of Object.keys(THEME_PRESETS)) {
|
||||
for (const mode of MODES) {
|
||||
const warning = token(name, mode, '--warning');
|
||||
for (const surface of SURFACES) {
|
||||
expect(contrastRatio(warning, token(name, mode, surface))!).toBeGreaterThanOrEqual(4.5);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user