Files
immich-folio/lib/__tests__/accent-text.test.ts
2026-10-02 18:14:23 +02:00

149 lines
5.4 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}
}
});
});