release: v0.20.0

This commit is contained in:
Ralksta
2026-10-02 18:14:23 +02:00
committed by GitHub
parent 81ce886b01
commit 5f831f719a
209 changed files with 13807 additions and 803 deletions

View 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);
}
}
}
});
});