#184: accent theme engine — presets and free color (ADR 0018 stage B) #185

Merged
stwaidele merged 2 commits from feat/184-theme-engine into main 2026-07-29 10:11:31 +02:00
14 changed files with 832 additions and 7 deletions

View File

@ -0,0 +1,97 @@
import AxeBuilder from '@axe-core/playwright';
import { expect, test, type Page } from '@playwright/test';
import { contextForUser } from './helpers';
/**
* Akzent-Theming (issue #184, ADR 0018 Stufe B): Presets und freie Farbe
* laufen durch dieselbe Ableitung; die Wahl wirkt sofort, überlebt den
* Reload (ui.theme.css-Cache via theme-init.js) und bleibt per Konstruktion
* lesbar der axe-Smoke prüft das exemplarisch mit einer grellen freien
* Farbe in beiden Modi. Klassen-Hooks statt lokalisierter Texte.
*/
const BASE = process.env.E2E_BASE_URL ?? 'http://localhost:5173';
const accentRadio = (page: Page, value: string) =>
page.locator(`input[name="theme-accent"][value="${value}"]`);
const effectiveAccent = (page: Page) =>
page.evaluate(() =>
getComputedStyle(document.documentElement).getPropertyValue('--color-accent').trim(),
);
test('accent choice applies instantly, persists, and default removes the override', 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('input[name="theme-accent"]').first().waitFor();
// Default: Teichgrün, kein Override-Style.
await expect(accentRadio(page, 'pond-green')).toBeChecked();
expect(await effectiveAccent(page)).toBe('#2f6f4f');
expect(await page.locator('#user-theme').count()).toBe(0);
// Preset: wirkt sofort über das user-theme-Style-Element.
await accentRadio(page, 'lake-blue').check();
expect(await effectiveAccent(page)).toBe('#2b5f8f');
expect(await page.locator('#user-theme').count()).toBe(1);
// Persistenz: theme-init.js injiziert den ui.theme.css-Cache pre-paint.
await page.reload();
await page.locator('input[name="theme-accent"]').first().waitFor();
await expect(accentRadio(page, 'lake-blue')).toBeChecked();
expect(await effectiveAccent(page)).toBe('#2b5f8f');
// Der Akzent gilt je Modus abgeleitet — Dunkelmodus bekommt einen
// helleren Wert als das Light-Preset (nie denselben Hex).
await page.locator('.topbar__theme').click(); // system -> light
await page.locator('.topbar__theme').click(); // light -> dark
const darkAccent = await effectiveAccent(page);
expect(darkAccent).not.toBe('#2b5f8f');
expect(darkAccent).toMatch(/^#[0-9a-f]{6}$/);
// Zurück auf Standard: Override und Cache verschwinden.
await page.locator('.topbar__theme').click(); // dark -> system
await accentRadio(page, 'pond-green').check();
expect(await page.locator('#user-theme').count()).toBe(0);
expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.css'))).toBeNull();
await context.close();
});
test('a garish custom accent is derived readable — axe passes in both modes', 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('input[name="theme-accent"]').first().waitFor();
// Grelles Gelb als freie Farbe: die Ableitung muss hell einen deutlich
// dunkleren Wert liefern — nie den Roh-Hex.
await page.locator('.accent-color-input').fill('#ffff00');
await expect(accentRadio(page, 'custom')).toBeChecked();
const lightAccent = await effectiveAccent(page);
expect(lightAccent).not.toBe('#ffff00');
const TAGS = ['wcag2a', 'wcag21a', 'wcag2aa', 'wcag21aa'];
for (const scheme of ['light', 'dark'] as const) {
await page.emulateMedia({ colorScheme: scheme });
// Modus konkret machen: System folgt emulateMedia (theme.ts-Listener).
await expect
.poll(() => page.evaluate(() => document.documentElement.dataset.theme))
.toBe(scheme);
const results = await new AxeBuilder({ page }).withTags(TAGS).analyze();
expect(
results.violations.map((v) => ({ rule: v.id, help: v.help })),
`axe mit grellem Akzent (${scheme})`,
).toEqual([]);
}
await context.close();
});

View File

@ -23,7 +23,7 @@ test('theme choice applies instantly, persists, and system mode follows the OS',
const page = await context.newPage();
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/settings');
await page.locator('.settings-fieldset').waitFor();
await page.locator('input[name="theme-mode"]').first().waitFor();
// Default: System, auf einem hellen OS also light.
await expect(radio(page, 'system')).toBeChecked();
@ -40,7 +40,7 @@ test('theme choice applies instantly, persists, and system mode follows the OS',
// 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 page.locator('input[name="theme-mode"]').first().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"');
@ -72,7 +72,7 @@ test('the top-bar toggle cycles the mode and stays in sync with the radios', asy
const page = await context.newPage();
await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/settings');
await page.locator('.settings-fieldset').waitFor();
await page.locator('input[name="theme-mode"]').first().waitFor();
const toggle = page.locator('.topbar__theme');
// Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell,
@ -97,7 +97,7 @@ test('the top-bar toggle cycles the mode and stays in sync with the radios', asy
// Persistenz wie bei den Radios (gleicher localStorage-Key).
await toggle.click(); // → light
await page.reload();
await page.locator('.settings-fieldset').waitFor();
await page.locator('input[name="theme-mode"]').first().waitFor();
await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light');

View File

@ -8,6 +8,7 @@ import { AuthProvider } from './auth/auth-context';
import { ToastProvider } from './components/Toast';
import './i18n';
import { ApiError } from './lib/api';
import { initUserTheme } from './theme/apply-theme';
import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
import './styles/tokens.css';
import './styles/base.css';
@ -17,6 +18,9 @@ import './styles/base.css';
// listener keeps 'system' users in sync with live OS scheme changes.
applyTheme(readStoredThemeMode());
initSystemThemeListener();
// Accent choice (#184): re-derive from the stored choice — heals a stale
// ui.theme.css cache after app updates; theme-init.js only bridges paint.
initUserTheme();
const queryClient = new QueryClient({
defaultOptions: {

View File

@ -1,5 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod';
import { changePasswordInputSchema, updateProfileInputSchema } from '@dorfteich/shared';
import {
BASE_PALETTE,
changePasswordInputSchema,
DEFAULT_THEME_PRESET_ID,
deriveAccentTokens,
THEME_PRESETS,
updateProfileInputSchema,
} from '@dorfteich/shared';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
@ -17,6 +24,7 @@ import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title';
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
import { usePersistentState } from '../lib/use-persistent-state';
import { useAccentChoice } from '../theme/apply-theme';
import { useThemeMode, type ThemeMode } from '../theme/theme';
interface SessionView {
id: string;
@ -78,10 +86,95 @@ function AppearanceSection(): React.JSX.Element {
))}
</fieldset>
<p className="field__hint">{t('settings:appearance.hint')}</p>
<AccentFieldset />
</section>
);
}
/** Per-mode preview of a derived accent. Decorative only the visible
* preset/custom NAME carries the meaning, color is never the only cue
* (ADR 0017). The rings show each mode's canonical background. */
function AccentSwatches({ light, dark }: { light: string; dark: string }): React.JSX.Element {
return (
<span className="accent-swatches" aria-hidden>
<span
className="accent-swatch"
style={{ background: light, boxShadow: `0 0 0 3px ${BASE_PALETTE.light.bg}` }}
/>
<span
className="accent-swatch"
style={{ background: dark, boxShadow: `0 0 0 3px ${BASE_PALETTE.dark.bg}` }}
/>
</span>
);
}
/** Akzentfarbe (issue #184, ADR 0018 stage B): presets and a free color as
* ONE mechanism both run through deriveAccentTokens, so any pick stays
* readable by construction. Same section as the mode radios (the jump-nav
* fence pins the section count). */
function AccentFieldset(): React.JSX.Element {
const { t } = useTranslation();
const [choice, setChoice] = useAccentChoice();
const isCustom = typeof choice === 'object';
// The color input keeps the last custom pick while a preset is selected,
// so re-selecting "custom" restores it instead of jumping to a default.
const [customHex, setCustomHex] = useState(isCustom ? choice.custom : '#2f6f4f');
const derivedPair = (accent: string): { light: string; dark: string } => ({
light: deriveAccentTokens(accent, 'light').accent,
dark: deriveAccentTokens(accent, 'dark').accent,
});
return (
<fieldset className="settings-fieldset">
<legend>{t('settings:appearance.accentLegend')}</legend>
{THEME_PRESETS.map((preset) => (
<label key={preset.id} className="settings-checkbox">
<input
type="radio"
name="theme-accent"
value={preset.id}
checked={choice === preset.id}
onChange={() => setChoice(preset.id)}
/>
{t(`settings:appearance.presets.${preset.id}`)}
<AccentSwatches
// The default preset applies NO override — preview the
// hand-tuned tokens.css pair instead of a derived stand-in.
{...(preset.id === DEFAULT_THEME_PRESET_ID
? { light: '#2f6f4f', dark: '#5cb88a' }
: derivedPair(preset.accent))}
/>
</label>
))}
<div className="settings-checkbox">
<input
type="radio"
id="theme-accent-custom"
name="theme-accent"
value="custom"
checked={isCustom}
onChange={() => setChoice({ custom: customHex })}
/>
<label htmlFor="theme-accent-custom">{t('settings:appearance.custom')}</label>
<input
type="color"
className="accent-color-input"
value={isCustom ? choice.custom : customHex}
aria-label={t('settings:appearance.customPick')}
onChange={(event) => {
setCustomHex(event.target.value);
setChoice({ custom: event.target.value });
}}
/>
{isCustom && <AccentSwatches {...derivedPair(choice.custom)} />}
</div>
<p className="field__hint">{t('settings:appearance.accentHint')}</p>
</fieldset>
);
}
/** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel
* abschaltbar machen lokale Geräte-Einstellung, kein Server-Zustand. */
function InteractionSection(): React.JSX.Element {

View File

@ -3690,6 +3690,31 @@ ul[data-type='task_list'] li p:last-of-type {
font-weight: 600;
}
/* Accent picker (issue #184): the two dots preview the DERIVED accent per
mode; their ring (inline box-shadow) shows each mode's canonical
background, so the auto-adjustment is visible before choosing. */
.accent-swatches {
display: inline-flex;
gap: var(--space-3);
margin-left: var(--space-2);
}
.accent-swatch {
width: 0.875rem;
height: 0.875rem;
border-radius: 50%;
border: 1px solid var(--color-chip-outline);
}
.accent-color-input {
inline-size: 2.25rem;
block-size: 1.5rem;
padding: 0;
border: 1px solid var(--color-border-input);
border-radius: var(--radius);
background: none;
}
.pond-settings-page__header {
display: flex;
align-items: center;

View File

@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import {
accentHexForChoice,
applyUserTheme,
buildUserThemeCss,
initUserTheme,
readStoredAccentChoice,
THEME_ACCENT_KEY,
THEME_CSS_KEY,
} from './apply-theme';
/** Node 22 ships its own (unconfigured, undefined) localStorage global
* that shadows jsdom's give the tests a real in-memory one (pattern
* theme.test.ts). */
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(),
},
});
}
beforeEach(() => {
stubLocalStorage();
});
afterEach(() => {
document.getElementById('user-theme')?.remove();
});
describe('readStoredAccentChoice', () => {
it('defaults to the default preset on empty, broken, or unknown values', () => {
expect(readStoredAccentChoice()).toBe('pond-green');
window.localStorage.setItem(THEME_ACCENT_KEY, 'not json');
expect(readStoredAccentChoice()).toBe('pond-green');
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('no-such-preset'));
expect(readStoredAccentChoice()).toBe('pond-green');
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify({ custom: 'teal' }));
expect(readStoredAccentChoice()).toBe('pond-green');
});
it('accepts preset ids and normalized custom hexes', () => {
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('lake-blue'));
expect(readStoredAccentChoice()).toBe('lake-blue');
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify({ custom: '#AABBCC' }));
expect(readStoredAccentChoice()).toEqual({ custom: '#aabbcc' });
});
});
describe('accentHexForChoice', () => {
it('maps the default preset to null (= no override)', () => {
expect(accentHexForChoice('pond-green')).toBeNull();
expect(accentHexForChoice('lake-blue')).toBe('#2b5f8f');
expect(accentHexForChoice({ custom: '#A0B0C0' })).toBe('#a0b0c0');
});
});
describe('buildUserThemeCss', () => {
it('emits both mode blocks per the data-theme selector contract', () => {
const css = buildUserThemeCss('#2b5f8f');
// Doubled :root beats tokens.css regardless of document order.
expect(css).toContain(':root:root {');
expect(css).toContain(":root:root[data-theme='dark'] {");
expect(css.match(/--color-accent:/g)).toHaveLength(2);
expect(css.match(/--color-accent-contrast:/g)).toHaveLength(2);
});
});
describe('applyUserTheme', () => {
it('writes the style element and caches choice + css', () => {
applyUserTheme('lake-blue');
const style = document.getElementById('user-theme');
expect(style?.textContent).toContain('--color-accent:');
expect(window.localStorage.getItem(THEME_ACCENT_KEY)).toBe('"lake-blue"');
expect(JSON.parse(window.localStorage.getItem(THEME_CSS_KEY)!)).toBe(style?.textContent);
});
it('removes override and cache for the default preset', () => {
applyUserTheme({ custom: '#ff0000' });
expect(document.getElementById('user-theme')).not.toBeNull();
applyUserTheme('pond-green');
expect(document.getElementById('user-theme')).toBeNull();
expect(window.localStorage.getItem(THEME_CSS_KEY)).toBeNull();
});
});
describe('initUserTheme', () => {
it('re-derives from the stored choice, healing a stale css cache', () => {
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify('lake-blue'));
window.localStorage.setItem(THEME_CSS_KEY, JSON.stringify('/* stale */'));
initUserTheme();
expect(document.getElementById('user-theme')?.textContent).toContain('--color-accent:');
expect(JSON.parse(window.localStorage.getItem(THEME_CSS_KEY)!)).toContain('--color-accent:');
});
it('drops a leftover style when the stored choice is the default', () => {
const stale = document.createElement('style');
stale.id = 'user-theme';
document.head.appendChild(stale);
window.localStorage.setItem(THEME_CSS_KEY, JSON.stringify('/* stale */'));
initUserTheme();
expect(document.getElementById('user-theme')).toBeNull();
expect(window.localStorage.getItem(THEME_CSS_KEY)).toBeNull();
});
});

View File

@ -0,0 +1,140 @@
import {
DEFAULT_THEME_PRESET_ID,
deriveAccentTokens,
isHexColor,
normalizeHexColor,
THEME_PRESETS,
} from '@dorfteich/shared';
import { useEffect, useState } from 'react';
/**
* User accent theme (ADR 0018 stage B, issue #184). The choice a preset id
* or a free hex lives in localStorage like the mode (device-local, never
* server-side). Applying derives BOTH modes' accent tokens and writes them
* as a <style id="user-theme"> whose two blocks mirror the data-theme
* selector contract from #180, so mode switches need no re-derivation. The
* finished CSS text is cached under ui.theme.css for public/theme-init.js
* to inject before first paint.
*/
export const THEME_ACCENT_KEY = 'ui.theme.accent';
export const THEME_CSS_KEY = 'ui.theme.css';
const USER_THEME_STYLE_ID = 'user-theme';
const ACCENT_EVENT = 'dorfteich:theme-accent';
/** A preset id string, or a free pick as `{ custom: '#rrggbb' }`. */
export type AccentChoice = string | { custom: string };
export function readStoredAccentChoice(): AccentChoice {
try {
const raw = window.localStorage.getItem(THEME_ACCENT_KEY);
if (raw === null) return DEFAULT_THEME_PRESET_ID;
const parsed = JSON.parse(raw) as unknown;
if (typeof parsed === 'string' && THEME_PRESETS.some((preset) => preset.id === parsed)) {
return parsed;
}
if (
typeof parsed === 'object' &&
parsed !== null &&
'custom' in parsed &&
typeof (parsed as { custom: unknown }).custom === 'string' &&
isHexColor((parsed as { custom: string }).custom)
) {
return { custom: normalizeHexColor((parsed as { custom: string }).custom) };
}
return DEFAULT_THEME_PRESET_ID;
} catch {
return DEFAULT_THEME_PRESET_ID;
}
}
/**
* The accent hex a choice stands for `null` for the default preset, which
* deliberately means "no override": the hand-tuned tokens.css values (e.g.
* the dark green #5cb88a) stay in charge instead of a derived approximation.
*/
export function accentHexForChoice(choice: AccentChoice): string | null {
if (typeof choice === 'string') {
if (choice === DEFAULT_THEME_PRESET_ID) return null;
return THEME_PRESETS.find((preset) => preset.id === choice)?.accent ?? null;
}
return normalizeHexColor(choice.custom);
}
/**
* The derived stylesheet for one accent both mode blocks, ADR 0018. The
* doubled `:root:root` raises specificity above the tokens.css blocks so the
* override wins regardless of DOCUMENT ORDER: theme-init.js injects this
* style during <head> parsing, i.e. BEFORE the bundle's stylesheets.
*/
export function buildUserThemeCss(accentHex: string): string {
const light = deriveAccentTokens(accentHex, 'light');
const dark = deriveAccentTokens(accentHex, 'dark');
return [
':root:root {',
` --color-accent: ${light.accent};`,
` --color-accent-contrast: ${light.accentContrast};`,
'}',
":root:root[data-theme='dark'] {",
` --color-accent: ${dark.accent};`,
` --color-accent-contrast: ${dark.accentContrast};`,
'}',
].join('\n');
}
/** Persist a choice and make it effective now + on future page loads. */
export function applyUserTheme(choice: AccentChoice): void {
const accentHex = accentHexForChoice(choice);
const css = accentHex === null ? null : buildUserThemeCss(accentHex);
try {
window.localStorage.setItem(THEME_ACCENT_KEY, JSON.stringify(choice));
if (css === null) window.localStorage.removeItem(THEME_CSS_KEY);
else window.localStorage.setItem(THEME_CSS_KEY, JSON.stringify(css));
} catch {
// Storage may be unavailable (private mode); still style this page.
}
const existing = document.getElementById(USER_THEME_STYLE_ID);
if (css === null) {
existing?.remove();
} else if (existing) {
existing.textContent = css;
} else {
const style = document.createElement('style');
style.id = USER_THEME_STYLE_ID;
style.textContent = css;
document.head.appendChild(style);
}
window.dispatchEvent(new Event(ACCENT_EVENT));
}
/**
* Startup hook (main.tsx): re-derive from the stored CHOICE rather than
* trusting the ui.theme.css cache theme-init.js injected after an app
* update the derivation may have improved, and this heals stale caches.
*/
export function initUserTheme(): void {
const choice = readStoredAccentChoice();
if (accentHexForChoice(choice) === null) {
// Default: make sure no stale override survives.
document.getElementById(USER_THEME_STYLE_ID)?.remove();
try {
window.localStorage.removeItem(THEME_CSS_KEY);
} catch {
/* ignore */
}
return;
}
applyUserTheme(choice);
}
/** Reactive view of the stored accent choice, shared across components
* same pattern as useThemeMode() in theme.ts. */
export function useAccentChoice(): [AccentChoice, (choice: AccentChoice) => void] {
const [choice, setChoice] = useState<AccentChoice>(readStoredAccentChoice);
useEffect(() => {
const onChange = (): void => setChoice(readStoredAccentChoice());
window.addEventListener(ACCENT_EVENT, onChange);
return () => window.removeEventListener(ACCENT_EVENT, onChange);
}, []);
return [choice, applyUserTheme];
}

View File

@ -1,5 +1,6 @@
import { readFileSync } from 'node:fs';
import { BASE_PALETTE } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest';
/**
@ -98,3 +99,19 @@ describe.each([
}
});
});
/**
* Drift fence (issue #184): the shared accent engine derives against
* BASE_PALETTE its "canonical backgrounds" must be the REAL tokens.css
* values, or the by-construction guarantee silently rots.
*/
describe('BASE_PALETTE mirrors tokens.css', () => {
it.each([
['light', lightVars],
['dark', darkVars],
] as const)('%s backgrounds match', (mode, vars) => {
expect(BASE_PALETTE[mode].bg).toBe(vars['--color-bg']);
expect(BASE_PALETTE[mode].bgSubtle).toBe(vars['--color-bg-subtle']);
expect(BASE_PALETTE[mode].surface).toBe(vars['--color-surface']);
});
});

View File

@ -75,3 +75,31 @@ the accent token family. The two axes are orthogonal.
- `--color-primary` no longer exists; the accent family is
`--color-accent`/`--color-accent-contrast` (plus the danger family for
destructive surfaces).
## Amendment: stage B — accent engine shipped (issue #184)
The derivation from decision 5 is implemented as dependency-free
`packages/shared/src/theme.ts`:
- `deriveAccentTokens(hex, mode)` keeps hue/saturation and binary-searches
lightness until the accent clears **4.5:1 against bg, bg-subtle AND
surface** of the mode (light darkens, dark lightens; a passing hex is
kept verbatim). `--color-accent-contrast` needs no search: 4.5:1
against the mode's bg makes white (light) resp. the dark bg color a
conforming text color on the accent by symmetry.
- `BASE_PALETTE` holds the canonical mode backgrounds;
`theme-contrast.test.ts` fences it against tokens.css drift, and a
sweep test (hue × saturation × lightness × both modes) asserts the
by-construction guarantee for arbitrary input.
- **Presets and free color are ONE mechanism** (`THEME_PRESETS` = curated
hexes). The default preset `pond-green` maps to **no override at all**
— the hand-tuned tokens.css values (e.g. dark `#5cb88a`) stay in
charge; only non-default choices write the derived
`<style id="user-theme">` with `:root:root` +
`:root:root[data-theme='dark']` blocks — the doubled `:root` raises
specificity above tokens.css, so the override wins regardless of
document order (theme-init.js injects it before the bundle styles).
- Persistence: `ui.theme.accent` (preset id or `{custom:'#hex'}`) plus
the pre-derived CSS cache `ui.theme.css`, which `theme-init.js`
injects before first paint; `main.tsx` re-derives from the CHOICE at
startup so app updates heal stale caches.

View File

@ -64,7 +64,18 @@
"dark": "Dunkel",
"system": "Systemeinstellung",
"hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät.",
"cycle": "Farbschema wechseln (aktuell: {{mode}})"
"cycle": "Farbschema wechseln (aktuell: {{mode}})",
"accentLegend": "Akzentfarbe",
"presets": {
"pond-green": "Teichgrün (Standard)",
"lake-blue": "Seeblau",
"iris-violet": "Irisviolett",
"reed-teal": "Schilftürkis",
"stone-slate": "Steingrau"
},
"custom": "Eigene Farbe",
"customPick": "Eigene Akzentfarbe wählen",
"accentHint": "Die gewählte Farbe wird je Modus automatisch heller oder dunkler angepasst, damit Texte lesbar bleiben. Gilt für dieses Gerät."
},
"interaction": {
"title": "Bedienung",

View File

@ -64,7 +64,18 @@
"dark": "Dark",
"system": "System setting",
"hint": "“System setting” follows the devices light/dark mode. Applies to this device.",
"cycle": "Switch color scheme (current: {{mode}})"
"cycle": "Switch color scheme (current: {{mode}})",
"accentLegend": "Accent color",
"presets": {
"pond-green": "Pond green (default)",
"lake-blue": "Lake blue",
"iris-violet": "Iris violet",
"reed-teal": "Reed teal",
"stone-slate": "Stone slate"
},
"custom": "Custom color",
"customPick": "Pick a custom accent color",
"accentHint": "The chosen color is automatically lightened or darkened per mode so text stays readable. Applies to this device."
},
"interaction": {
"title": "Interaction",

View File

@ -33,5 +33,6 @@ export * from './ponds';
export * from './public-api';
export * from './quotas';
export * from './text-diff';
export * from './theme';
export * from './tree';
export * from './watches';

View File

@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import {
BASE_PALETTE,
contrastRatio,
DEFAULT_THEME_PRESET_ID,
deriveAccentTokens,
isHexColor,
normalizeHexColor,
relativeLuminance,
THEME_PRESETS,
type ThemeModeName,
} from './theme';
const MODES: ThemeModeName[] = ['light', 'dark'];
function backgroundsOf(mode: ThemeModeName): string[] {
const { bg, bgSubtle, surface } = BASE_PALETTE[mode];
return [bg, bgSubtle, surface];
}
describe('color math', () => {
it('matches the WCAG anchor points', () => {
expect(relativeLuminance('#ffffff')).toBeCloseTo(1, 5);
expect(relativeLuminance('#000000')).toBeCloseTo(0, 5);
expect(contrastRatio('#000000', '#ffffff')).toBeCloseTo(21, 3);
expect(contrastRatio('#ffffff', '#ffffff')).toBeCloseTo(1, 5);
// Symmetry: order of arguments must not matter.
expect(contrastRatio('#2f6f4f', '#ffffff')).toBeCloseTo(contrastRatio('#ffffff', '#2f6f4f'), 8);
});
it('normalizes and validates hex colors', () => {
expect(normalizeHexColor(' #2F6F4F ')).toBe('#2f6f4f');
expect(normalizeHexColor('2f6f4f')).toBe('#2f6f4f');
expect(isHexColor('#123abc')).toBe(true);
expect(isHexColor('#123')).toBe(false);
expect(() => normalizeHexColor('teal')).toThrow();
});
});
describe('deriveAccentTokens', () => {
it('keeps a hex verbatim when it already passes the mode', () => {
// The default pond green passes light mode as-is (it IS the light token).
expect(deriveAccentTokens('#2f6f4f', 'light').accent).toBe('#2f6f4f');
});
it('derives conforming tokens for every preset in both modes', () => {
for (const preset of THEME_PRESETS) {
for (const mode of MODES) {
const tokens = deriveAccentTokens(preset.accent, mode);
for (const background of backgroundsOf(mode)) {
expect(
contrastRatio(tokens.accent, background),
`${preset.id} ${mode} vs ${background}`,
).toBeGreaterThanOrEqual(4.5);
}
expect(
contrastRatio(tokens.accent, tokens.accentContrast),
`${preset.id} ${mode} accent text`,
).toBeGreaterThanOrEqual(4.5);
}
}
});
it('derives conforming tokens across the whole color space (sweep)', () => {
// 36 hues × 3 saturations × 3 lightnesses × both modes — garish neon
// yellow and near-black picks included. Conformance must hold BY
// CONSTRUCTION for any input (ADR 0018), so this sweep is the fence.
for (let hue = 0; hue < 360; hue += 10) {
for (const saturation of [0.15, 0.6, 1]) {
for (const lightness of [0.05, 0.5, 0.95]) {
const input = hslInputHex(hue / 360, saturation, lightness);
for (const mode of MODES) {
const tokens = deriveAccentTokens(input, mode);
for (const background of backgroundsOf(mode)) {
expect(
contrastRatio(tokens.accent, background),
`${input} ${mode} vs ${background}`,
).toBeGreaterThanOrEqual(4.5);
}
expect(contrastRatio(tokens.accent, tokens.accentContrast)).toBeGreaterThanOrEqual(4.5);
}
}
}
}
});
it('has a default preset whose id marks "no override"', () => {
expect(THEME_PRESETS[0]!.id).toBe(DEFAULT_THEME_PRESET_ID);
});
});
/** Test-local HSL→hex so the sweep does not trust the module under test. */
function hslInputHex(h: number, s: number, l: number): string {
const hue = (p: number, q: number, t: number): number => {
const x = ((t % 1) + 1) % 1;
if (x < 1 / 6) return p + (q - p) * 6 * x;
if (x < 1 / 2) return q;
if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
return p;
};
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
const toHex = (c: number): string =>
Math.round(c * 255)
.toString(16)
.padStart(2, '0');
return `#${toHex(hue(p, q, h + 1 / 3))}${toHex(hue(p, q, h))}${toHex(hue(p, q, h - 1 / 3))}`;
}

View File

@ -0,0 +1,177 @@
/**
* Accent-theme engine (ADR 0018 stage B, issue #184). A color theme is ONE
* accent hex presets are curated hexes, a custom pick is any hex and
* `deriveAccentTokens` COMPUTES per-mode token values whose WCAG 2.1 AA
* contrast holds by construction, so no runtime validation or "save refused"
* flow exists. Dependency-free on purpose: the web app derives at pick time,
* and the no-JS shell could reuse this later.
*/
export type ThemeModeName = 'light' | 'dark';
/**
* Canonical neutral backgrounds per mode MUST mirror tokens.css (the
* fence in apps/web/src/theme/theme-contrast.test.ts asserts the sync).
* Derived accents are searched against all three, so they also clear the
* lighter/darker of the pair and text ON the accent stays readable.
*/
export const BASE_PALETTE: Record<
ThemeModeName,
{ bg: string; bgSubtle: string; surface: string }
> = {
light: { bg: '#ffffff', bgSubtle: '#f5f7fa', surface: '#ffffff' },
dark: { bg: '#10161d', bgSubtle: '#161d26', surface: '#1b2430' },
};
export interface ThemePreset {
/** Stable id, persisted in localStorage (`ui.theme.accent`). */
id: string;
/** The light-mode accent hex; both modes derive from this one value. */
accent: string;
}
/**
* Curated accents. `pond-green` is the product default and deliberately maps
* to NO override at all the hand-tuned tokens.css values stay in charge.
*/
export const DEFAULT_THEME_PRESET_ID = 'pond-green';
export const THEME_PRESETS: ThemePreset[] = [
{ id: DEFAULT_THEME_PRESET_ID, accent: '#2f6f4f' },
{ id: 'lake-blue', accent: '#2b5f8f' },
{ id: 'iris-violet', accent: '#6d4fa0' },
{ id: 'reed-teal', accent: '#1f6f6b' },
{ id: 'stone-slate', accent: '#55606c' },
];
const HEX_PATTERN = /^#?([0-9a-f]{6})$/i;
export function isHexColor(value: string): boolean {
return HEX_PATTERN.test(value.trim());
}
export function normalizeHexColor(value: string): string {
const match = HEX_PATTERN.exec(value.trim());
if (!match?.[1]) throw new Error(`not a #rrggbb color: ${value}`);
return `#${match[1].toLowerCase()}`;
}
/** WCAG 2.1 relative luminance of an sRGB hex color. */
export function relativeLuminance(hex: string): number {
const value = normalizeHexColor(hex);
const channels = [1, 3, 5].map((i) => {
const c = parseInt(value.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]!;
}
/** WCAG 2.1 contrast ratio between two hex colors (1..21). */
export function contrastRatio(a: string, b: string): number {
const [hi, lo] = [relativeLuminance(a), relativeLuminance(b)].sort((x, y) => y - x);
return (hi! + 0.05) / (lo! + 0.05);
}
interface Hsl {
h: number;
s: number;
l: number;
}
function hexToHsl(hex: string): Hsl {
const value = normalizeHexColor(hex);
const r = parseInt(value.slice(1, 3), 16) / 255;
const g = parseInt(value.slice(3, 5), 16) / 255;
const b = parseInt(value.slice(5, 7), 16) / 255;
const max = Math.max(r, g, b);
const min = Math.min(r, g, b);
const l = (max + min) / 2;
const d = max - min;
if (d === 0) return { h: 0, s: 0, l };
const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
let h: number;
if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) / 6;
else if (max === g) h = ((b - r) / d + 2) / 6;
else h = ((r - g) / d + 4) / 6;
return { h, s, l };
}
function hslToHex({ h, s, l }: Hsl): string {
const hueToChannel = (p: number, q: number, t: number): number => {
let x = t;
if (x < 0) x += 1;
if (x > 1) x -= 1;
if (x < 1 / 6) return p + (q - p) * 6 * x;
if (x < 1 / 2) return q;
if (x < 2 / 3) return p + (q - p) * (2 / 3 - x) * 6;
return p;
};
let r: number;
let g: number;
let b: number;
if (s === 0) {
r = g = b = l;
} else {
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
const p = 2 * l - q;
r = hueToChannel(p, q, h + 1 / 3);
g = hueToChannel(p, q, h);
b = hueToChannel(p, q, h - 1 / 3);
}
const toHex = (c: number): string =>
Math.round(Math.min(1, Math.max(0, c)) * 255)
.toString(16)
.padStart(2, '0');
return `#${toHex(r)}${toHex(g)}${toHex(b)}`;
}
export interface AccentTokens {
/** `--color-accent` for the mode; ≥ 4.5:1 against bg, bg-subtle, surface. */
accent: string;
/** `--color-accent-contrast` (text ON accent); ≥ 4.5:1 by symmetry. */
accentContrast: string;
}
const MIN_CONTRAST = 4.5;
function minContrastAgainstMode(hex: string, mode: ThemeModeName): number {
const backgrounds = BASE_PALETTE[mode];
return Math.min(
contrastRatio(hex, backgrounds.bg),
contrastRatio(hex, backgrounds.bgSubtle),
contrastRatio(hex, backgrounds.surface),
);
}
/**
* Derive the accent token pair for a mode from ONE user-chosen hex: keep hue
* and saturation, and only move lightness (binary search) until the color
* clears 4.5:1 against every canonical background of the mode light mode
* darkens, dark mode lightens. A hex that already passes is kept verbatim.
* Extremes always exist (pure black/white pass against both palettes), so
* the search converges for every input.
*
* `accentContrast` needs no search: 4.5:1 against the mode's bg makes the
* bg color itself (dark) resp. white (light) a conforming text color on the
* accent, matching the hand-tuned pairs in tokens.css.
*/
export function deriveAccentTokens(accentHex: string, mode: ThemeModeName): AccentTokens {
const normalized = normalizeHexColor(accentHex);
const accentContrast = mode === 'light' ? '#ffffff' : BASE_PALETTE.dark.bg;
if (minContrastAgainstMode(normalized, mode) >= MIN_CONTRAST) {
return { accent: normalized, accentContrast };
}
const { h, s } = hexToHsl(normalized);
// Lightness is monotonic in luminance at fixed hue/saturation, so the
// passing region is an interval ending at the mode's extreme; binary
// search finds the edge closest to the user's pick.
let passing = mode === 'light' ? 0 : 1;
let failing = hexToHsl(normalized).l;
for (let i = 0; i < 24; i += 1) {
const mid = (passing + failing) / 2;
if (minContrastAgainstMode(hslToHex({ h, s, l: mid }), mode) >= MIN_CONTRAST) passing = mid;
else failing = mid;
}
return { accent: hslToHex({ h, s, l: passing }), accentContrast };
}