#180: dark-mode test fence, both-scheme a11y pack, theme e2e, ADR 0018
theme-contrast.test.ts parses tokens.css and asserts every real UI colour pairing (4.5:1 text, 3:1 UI) for BOTH palettes, so palette drift fails unit tests instead of review. theme.test.ts covers resolve/apply logic (Node >= 22 ships a shadowing undefined localStorage global — the test brings its own in-memory storage). The a11y pack now runs its four scans in light AND dark via emulateMedia; the new theme pack exercises the three-way switch end to end (instant apply, reload persistence, live OS follow in system mode, override beats OS). ADR 0018 records the theming model broadly: modes now, accent themes by derivation later. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
This commit is contained in:
parent
2c571f9f5e
commit
4e0ad82220
@ -521,18 +521,26 @@ jobs:
|
||||
sleep 2
|
||||
done
|
||||
|
||||
# Two logins per run → reset first (see note above).
|
||||
# Six logins per run since #180 doubled the scans (3 contexts × light/
|
||||
# dark, limit is 10/min) → reset first (see note above).
|
||||
- name: Reset login rate limit before a11y pack
|
||||
run: |
|
||||
echo "DELETE FROM rate_limits WHERE key LIKE 'login%';" | \
|
||||
pnpm --filter @dorfteich/api exec prisma db execute --stdin --url "$DATABASE_URL"
|
||||
|
||||
# WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens.
|
||||
# WCAG-A/AA-Regressionsschutz (issue #171): axe-Scan der Kernscreens,
|
||||
# seit #180 in beiden Farbschemata.
|
||||
- name: Run a11y pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/a11y.spec.ts
|
||||
|
||||
# Hell/Dunkel/System-Umschalter (issue #180).
|
||||
- name: Run theme pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5173 \
|
||||
pnpm --filter @dorfteich/web exec playwright test e2e/theme.spec.ts
|
||||
|
||||
- name: Run setup wizard pack
|
||||
run: |
|
||||
E2E_BASE_URL=http://localhost:5175 E2E_SETUP=1 \
|
||||
|
||||
@ -10,10 +10,15 @@ import { contextForUser } from './helpers';
|
||||
* der Issues #162–#170 verletzungsfrei; jede neue Verletzung bricht den
|
||||
* Build. Best-Practice-Regeln (axe-Tag best-practice) prüfen wir hier
|
||||
* bewusst NICHT, nur normative WCAG-Kriterien.
|
||||
*
|
||||
* Seit issue #180 läuft jeder Scan in BEIDEN Farbschemata: emulateMedia
|
||||
* setzt prefers-color-scheme, theme-init.js löst den Default „System“ zum
|
||||
* konkreten data-theme auf, axe misst dann die echten Dark-Token-Farben.
|
||||
*/
|
||||
|
||||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
|
||||
const SCHEMES = ['light', 'dark'] as const;
|
||||
|
||||
/** Bewusst tolerierte Regel-IDs — nur mit Begründung ergänzen. */
|
||||
const ALLOWED_RULES: string[] = [];
|
||||
@ -32,42 +37,52 @@ async function expectClean(page: Page, label: string): Promise<void> {
|
||||
).toEqual([]);
|
||||
}
|
||||
|
||||
test('login page passes the axe WCAG A/AA scan', async ({ page }) => {
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, '/login');
|
||||
});
|
||||
for (const scheme of SCHEMES) {
|
||||
test.describe(`${scheme} scheme`, () => {
|
||||
test(`login page passes the axe WCAG A/AA scan (${scheme})`, async ({ page }) => {
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/login');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, `/login (${scheme})`);
|
||||
});
|
||||
|
||||
test('reading and editing a page passes the axe WCAG A/AA scan', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/p/content-fixtures/every-element');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, 'Lesemodus every-element');
|
||||
test(`reading and editing a page passes the axe WCAG A/AA scan (${scheme})`, async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/p/content-fixtures/every-element');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, `Lesemodus every-element (${scheme})`);
|
||||
|
||||
await page.locator('.editor-page__mode-toggle').click();
|
||||
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await expectClean(page, 'Editor every-element');
|
||||
await context.close();
|
||||
});
|
||||
await page.locator('.editor-page__mode-toggle').click();
|
||||
await page.locator('.ProseMirror[contenteditable="true"]').waitFor({ timeout: 10_000 });
|
||||
await page.waitForTimeout(500);
|
||||
await expectClean(page, `Editor every-element (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('user settings pass the axe WCAG A/AA scan', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, '/settings');
|
||||
await context.close();
|
||||
});
|
||||
test(`user settings pass the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/settings');
|
||||
await page.waitForLoadState('networkidle');
|
||||
await expectClean(page, `/settings (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
|
||||
test('admin area passes the axe WCAG A/AA scan', async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-admin');
|
||||
const page = await context.newPage();
|
||||
await page.goto('/admin');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
|
||||
await page.locator('.user-manager__table .user-row').first().waitFor();
|
||||
await expectClean(page, '/admin');
|
||||
await context.close();
|
||||
});
|
||||
test(`admin area passes the axe WCAG A/AA scan (${scheme})`, async ({ browser }) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-admin');
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ colorScheme: scheme });
|
||||
await page.goto('/admin');
|
||||
await page.waitForLoadState('networkidle');
|
||||
// Personenliste sichtbar, inkl. der Icon-Aktionen (issue #175).
|
||||
await page.locator('.user-manager__table .user-row').first().waitFor();
|
||||
await expectClean(page, `/admin (${scheme})`);
|
||||
await context.close();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
66
apps/web/e2e/theme.spec.ts
Normal file
66
apps/web/e2e/theme.spec.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
import { contextForUser } from './helpers';
|
||||
|
||||
/**
|
||||
* Theme-Umschalter (issue #180): die Hell/Dunkel/System-Wahl in den
|
||||
* Einstellungen wirkt sofort, überlebt den Reload (localStorage) und folgt
|
||||
* im System-Modus Live-Änderungen des OS-Schemas. Klassen-Hooks statt
|
||||
* lokalisierter Texte (UI-Sprache folgt dem Profil-Locale).
|
||||
*/
|
||||
|
||||
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
|
||||
|
||||
const radio = (page: Page, value: string) =>
|
||||
page.locator(`.settings-fieldset input[value="${value}"]`);
|
||||
|
||||
const effectiveTheme = (page: Page) => page.evaluate(() => document.documentElement.dataset.theme);
|
||||
|
||||
test('theme choice applies instantly, persists, and system mode follows the OS', async ({
|
||||
browser,
|
||||
}) => {
|
||||
const context = await contextForUser(browser, BASE, 'fixture-user');
|
||||
const page = await context.newPage();
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await page.goto('/settings');
|
||||
await page.locator('.settings-fieldset').waitFor();
|
||||
|
||||
// Default: System, auf einem hellen OS also light.
|
||||
await expect(radio(page, 'system')).toBeChecked();
|
||||
expect(await effectiveTheme(page)).toBe('light');
|
||||
|
||||
// Explizit Dunkel: wirkt sofort, ohne Reload, trotz hellem OS.
|
||||
await radio(page, 'dark').check();
|
||||
expect(await effectiveTheme(page)).toBe('dark');
|
||||
await expect(page.locator('meta[name="theme-color"]').first()).toHaveAttribute(
|
||||
'content',
|
||||
'#10161d',
|
||||
);
|
||||
|
||||
// Persistenz: Wahl und Theme überleben den Reload (theme-init.js liest
|
||||
// denselben localStorage-Key vor dem ersten Paint).
|
||||
await page.reload();
|
||||
await page.locator('.settings-fieldset').waitFor();
|
||||
await expect(radio(page, 'dark')).toBeChecked();
|
||||
expect(await effectiveTheme(page)).toBe('dark');
|
||||
expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.mode'))).toBe('"dark"');
|
||||
|
||||
// Zurück auf System: folgt dem hellen OS …
|
||||
await radio(page, 'system').check();
|
||||
expect(await effectiveTheme(page)).toBe('light');
|
||||
|
||||
// … und Live-Wechseln des OS-Schemas ohne Interaktion.
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await expect
|
||||
.poll(async () => effectiveTheme(page), { message: 'system mode follows OS change' })
|
||||
.toBe('dark');
|
||||
await page.emulateMedia({ colorScheme: 'light' });
|
||||
await expect.poll(async () => effectiveTheme(page)).toBe('light');
|
||||
|
||||
// Explizite Wahl gewinnt gegen das OS-Schema.
|
||||
await page.emulateMedia({ colorScheme: 'dark' });
|
||||
await radio(page, 'light').check();
|
||||
expect(await effectiveTheme(page)).toBe('light');
|
||||
|
||||
await context.close();
|
||||
});
|
||||
100
apps/web/src/theme/theme-contrast.test.ts
Normal file
100
apps/web/src/theme/theme-contrast.test.ts
Normal file
@ -0,0 +1,100 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
/**
|
||||
* Mechanical contrast fence for BOTH token palettes (issue #180, ADR 0018):
|
||||
* parses tokens.css, so any future palette tweak is re-checked against the
|
||||
* WCAG 2.1 AA thresholds of ADR 0017 — text ≥ 4.5:1, non-text UI ≥ 3:1 —
|
||||
* without anyone having to remember the ratios. Pattern follows
|
||||
* user-color.test.ts.
|
||||
*/
|
||||
|
||||
const css = readFileSync(new URL('../styles/tokens.css', import.meta.url), 'utf8');
|
||||
|
||||
function parseVars(block: string): Record<string, string> {
|
||||
const vars: Record<string, string> = {};
|
||||
for (const match of block.matchAll(/(--color-[\w-]+):\s*([^;]+);/g)) {
|
||||
vars[match[1]!] = match[2]!.trim();
|
||||
}
|
||||
return vars;
|
||||
}
|
||||
|
||||
function blockFor(selectorStart: string): string {
|
||||
const start = css.indexOf(selectorStart);
|
||||
expect(start, `selector ${selectorStart} present in tokens.css`).toBeGreaterThanOrEqual(0);
|
||||
return css.slice(start, css.indexOf('}', start));
|
||||
}
|
||||
|
||||
const lightVars = parseVars(blockFor(':root {'));
|
||||
const darkVars = { ...lightVars, ...parseVars(blockFor(":root[data-theme='dark']")) };
|
||||
|
||||
/** WCAG relative luminance of an sRGB hex colour. */
|
||||
function luminance(hex: string): number {
|
||||
expect(hex, `hex colour, got "${hex}"`).toMatch(/^#[0-9a-f]{6}$/i);
|
||||
const channels = [1, 3, 5].map((i) => {
|
||||
const c = parseInt(hex.slice(i, i + 2), 16) / 255;
|
||||
return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
|
||||
}
|
||||
|
||||
/** Contrast ratio between two colours (WCAG 2.x). */
|
||||
function contrast(a: string, b: string): number {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((x, y) => y - x);
|
||||
return (hi! + 0.05) / (lo! + 0.05);
|
||||
}
|
||||
|
||||
/** [foreground, background, minimum] — only pairings the UI really renders. */
|
||||
const PAIRS: [string, string, number][] = [
|
||||
// Body text on every background surface.
|
||||
['--color-text', '--color-bg', 4.5],
|
||||
['--color-text', '--color-bg-subtle', 4.5],
|
||||
['--color-text', '--color-surface', 4.5],
|
||||
['--color-text', '--color-surface-muted', 4.5],
|
||||
// Muted text (hints, metadata) — not used on surface-muted.
|
||||
['--color-text-muted', '--color-bg', 4.5],
|
||||
['--color-text-muted', '--color-bg-subtle', 4.5],
|
||||
['--color-text-muted', '--color-surface', 4.5],
|
||||
// Accent doubles as link/text colour.
|
||||
['--color-accent', '--color-bg', 4.5],
|
||||
['--color-accent', '--color-bg-subtle', 4.5],
|
||||
['--color-accent', '--color-surface', 4.5],
|
||||
['--color-accent-contrast', '--color-accent', 4.5],
|
||||
// Danger as text (overdue dates, error notes) and on danger buttons.
|
||||
['--color-danger', '--color-bg', 4.5],
|
||||
['--color-danger', '--color-bg-subtle', 4.5],
|
||||
['--color-danger', '--color-surface', 4.5],
|
||||
['--color-danger-contrast', '--color-danger', 4.5],
|
||||
['--color-danger-contrast', '--color-danger-strong', 4.5],
|
||||
// Success text.
|
||||
['--color-ok', '--color-bg', 4.5],
|
||||
['--color-ok', '--color-surface', 4.5],
|
||||
// Status badges (admin system page).
|
||||
['--color-badge-ok-text', '--color-badge-ok-bg', 4.5],
|
||||
['--color-badge-error-text', '--color-badge-error-bg', 4.5],
|
||||
['--color-badge-warn-text', '--color-badge-warn-bg', 4.5],
|
||||
// Non-text UI (WCAG 1.4.11): input borders, favorite icons.
|
||||
['--color-border-input', '--color-bg', 3],
|
||||
['--color-border-input', '--color-surface', 3],
|
||||
['--color-favorite', '--color-bg', 3],
|
||||
];
|
||||
|
||||
describe.each([
|
||||
['light', lightVars],
|
||||
['dark', darkVars],
|
||||
] as const)('%s palette', (name, vars) => {
|
||||
it.each(PAIRS)('%s on %s clears %s:1', (fg, bg, min) => {
|
||||
const ratio = contrast(vars[fg]!, vars[bg]!);
|
||||
expect(ratio, `${fg} (${vars[fg]}) on ${bg} (${vars[bg]}) in ${name}`).toBeGreaterThanOrEqual(
|
||||
min,
|
||||
);
|
||||
});
|
||||
|
||||
it('defines every token the pair list references', () => {
|
||||
for (const [fg, bg] of PAIRS) {
|
||||
expect(vars[fg], fg).toBeDefined();
|
||||
expect(vars[bg], bg).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
94
apps/web/src/theme/theme.test.ts
Normal file
94
apps/web/src/theme/theme.test.ts
Normal file
@ -0,0 +1,94 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { applyTheme, readStoredThemeMode, resolveTheme, THEME_MODE_KEY } from './theme';
|
||||
|
||||
/** Node ≥ 22 ships its own (unconfigured, undefined) localStorage global
|
||||
* that shadows jsdom's — give the tests a real in-memory one. */
|
||||
function stubLocalStorage(): void {
|
||||
const store = new Map<string, string>();
|
||||
Object.defineProperty(window, 'localStorage', {
|
||||
configurable: true,
|
||||
value: {
|
||||
getItem: (key: string) => store.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => void store.set(key, String(value)),
|
||||
removeItem: (key: string) => void store.delete(key),
|
||||
clear: () => store.clear(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** jsdom has no matchMedia — stub the single query the module uses. */
|
||||
function stubMatchMedia(prefersDark: boolean): void {
|
||||
vi.stubGlobal(
|
||||
'matchMedia',
|
||||
(query: string) =>
|
||||
({
|
||||
matches: query.includes('dark') && prefersDark,
|
||||
addEventListener: () => undefined,
|
||||
}) as unknown as MediaQueryList,
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
stubLocalStorage();
|
||||
stubMatchMedia(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
delete document.documentElement.dataset.theme;
|
||||
});
|
||||
|
||||
describe('readStoredThemeMode', () => {
|
||||
it('defaults to system when nothing is stored', () => {
|
||||
expect(readStoredThemeMode()).toBe('system');
|
||||
});
|
||||
|
||||
it('reads the JSON-encoded value usePersistentState writes', () => {
|
||||
window.localStorage.setItem(THEME_MODE_KEY, JSON.stringify('dark'));
|
||||
expect(readStoredThemeMode()).toBe('dark');
|
||||
});
|
||||
|
||||
it('falls back to system on broken JSON and unknown values', () => {
|
||||
window.localStorage.setItem(THEME_MODE_KEY, 'not json');
|
||||
expect(readStoredThemeMode()).toBe('system');
|
||||
window.localStorage.setItem(THEME_MODE_KEY, JSON.stringify('purple'));
|
||||
expect(readStoredThemeMode()).toBe('system');
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveTheme', () => {
|
||||
it('returns explicit choices untouched', () => {
|
||||
expect(resolveTheme('light')).toBe('light');
|
||||
expect(resolveTheme('dark')).toBe('dark');
|
||||
});
|
||||
|
||||
it('resolves system via prefers-color-scheme', () => {
|
||||
expect(resolveTheme('system')).toBe('light');
|
||||
stubMatchMedia(true);
|
||||
expect(resolveTheme('system')).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyTheme', () => {
|
||||
it('sets a concrete data-theme, color-scheme, and both theme-color metas', () => {
|
||||
for (const media of ['(prefers-color-scheme: light)', '(prefers-color-scheme: dark)']) {
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('name', 'theme-color');
|
||||
meta.setAttribute('media', media);
|
||||
document.head.appendChild(meta);
|
||||
}
|
||||
|
||||
applyTheme('dark');
|
||||
expect(document.documentElement.dataset.theme).toBe('dark');
|
||||
expect(document.documentElement.style.colorScheme).toBe('dark');
|
||||
const metas = [...document.querySelectorAll('meta[name="theme-color"]')];
|
||||
expect(metas).toHaveLength(2);
|
||||
for (const meta of metas) expect(meta.getAttribute('content')).toBe('#10161d');
|
||||
|
||||
applyTheme('system'); // stubbed OS is light
|
||||
expect(document.documentElement.dataset.theme).toBe('light');
|
||||
for (const meta of metas) expect(meta.getAttribute('content')).toBe('#2f6f4f');
|
||||
});
|
||||
});
|
||||
77
docs/architecture/adr/0018-color-theming.md
Normal file
77
docs/architecture/adr/0018-color-theming.md
Normal file
@ -0,0 +1,77 @@
|
||||
# ADR 0018: Color theming — modes now, accent themes by derivation later
|
||||
|
||||
- Status: accepted
|
||||
- Date: 2026-07-28
|
||||
|
||||
## Context
|
||||
|
||||
Until issue #180 the SPA was light-only; the no-JS public shell carried a
|
||||
minimal dark block as a contrast fix (#167). ADR 0016 established the
|
||||
token pattern for fonts (`--font-*` slots, pond-scoped overrides via
|
||||
custom properties); ADR 0017 makes WCAG 2.1 AA contrast a hard
|
||||
requirement. The product direction is a staged theming vision: dark mode
|
||||
first, then user-selectable accent themes, then pond-level accents.
|
||||
|
||||
This ADR fixes the model for the whole roadmap so the later stages do not
|
||||
need new architecture decisions.
|
||||
|
||||
## Decision
|
||||
|
||||
**Model: theme = mode × accent.** The _mode_ (light/dark) selects one of
|
||||
two complete neutral palettes; the _accent_ (later stages) recolors only
|
||||
the accent token family. The two axes are orthogonal.
|
||||
|
||||
1. **One token palette per mode, in `tokens.css` only.** Light values
|
||||
live on `:root`, dark values in a single `:root[data-theme='dark']`
|
||||
block. There is deliberately **no `@media` duplicate**: JS always
|
||||
resolves the user's Light/Dark/System choice to a _concrete_
|
||||
`data-theme` on `<html>` (`theme/theme.ts`; System via `matchMedia`
|
||||
plus a live change listener). This single selector is the stable
|
||||
contract later stages mirror.
|
||||
2. **Every color is a token pair.** New colors MUST be added to both
|
||||
blocks (or deliberately inherit) and their real UI pairings MUST pass
|
||||
the mechanical contrast fence `theme-contrast.test.ts` (text ≥ 4.5:1,
|
||||
non-text UI ≥ 3:1). `color-scheme` flips with the palette so native
|
||||
controls and scrollbars follow.
|
||||
3. **The mode preference is device-local.** `ui.theme.mode` in
|
||||
localStorage (like the single-key-shortcut toggle, #170) — never
|
||||
server-persisted. The `ui.theme.*` namespace is reserved for the
|
||||
accent stage (`ui.theme.accent`, `ui.theme.css` pre-derived CSS
|
||||
cache).
|
||||
4. **Flash prevention is a classic external script.** The prod CSP
|
||||
(`script-src 'self'`) forbids inline scripts, so
|
||||
`public/theme-init.js` runs during `<head>` parsing, before first
|
||||
paint and the module bundle, and already injects the (future)
|
||||
`ui.theme.css` cache. A parse-time `<meta name="color-scheme">` plus
|
||||
paired `theme-color` metas cover the interval before it runs;
|
||||
`applyTheme()` rewrites both metas so an explicit override beats the
|
||||
OS scheme.
|
||||
5. **Accent stages derive, they do not validate.** A future "color
|
||||
theme" is ONE accent hex (presets = curated hexes, custom = free
|
||||
pick); `deriveAccentTokens(hex, mode)` computes accent tokens whose
|
||||
contrast is guaranteed _by construction_ against the canonical mode
|
||||
backgrounds. Full-palette themes (recoloring neutrals) are explicitly
|
||||
out of scope: the contrast surface explodes, and plugin CSS
|
||||
(`section-styles-basic`) relies on near-neutral backgrounds without
|
||||
knowing host tokens. Pond theming recolors the accent family only, as
|
||||
a scoped wrapper like `PondFontScope` (ADR 0016), with precedence
|
||||
pond > user > default via the natural custom-property cascade;
|
||||
`useEffectiveTheme()` exists for that stage.
|
||||
6. **The no-JS public shell stays self-contained.** `html-shell.ts`
|
||||
keeps its own inline light/dark CSS via `@media` (no JS there) with
|
||||
colors matching the SPA dark tokens; it is deliberately not
|
||||
user/pond-themed for now.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Adding a color means adding a _pair_ plus a fence entry — forgetting
|
||||
the dark value or an inadequate ratio fails unit tests, not review.
|
||||
- The a11y e2e pack scans every core screen in both schemes; axe measures
|
||||
real computed dark colors.
|
||||
- Accepted trade-offs: `USER_COLORS` (presence/carets) stay
|
||||
theme-independent — their accessible signal is the white text on them;
|
||||
the PWA manifest splash stays light (manifests cannot media-query);
|
||||
the mode preference does not roam across devices.
|
||||
- `--color-primary` no longer exists; the accent family is
|
||||
`--color-accent`/`--color-accent-contrast` (plus the danger family for
|
||||
destructive surfaces).
|
||||
Loading…
Reference in New Issue
Block a user