#184: user accent theming — presets and free color as one mechanism
Some checks failed
CI / Lint, typecheck, test (pull_request) Successful in 4m41s
CI / Build container images (pull_request) Successful in 4m4s
CI / Auth e2e pack (pull_request) Successful in 11m40s
CI / Import/export fidelity gate (pull_request) Successful in 52s
CD / Deploy to Test (push) Blocked by required conditions
CD / Smoke tests against Test (push) Blocked by required conditions
CD / Promote to Int (push) Blocked by required conditions
CI / Auth e2e pack (push) Blocked by required conditions
CI / Import/export fidelity gate (push) Blocked by required conditions
CI / Build container images (push) Blocked by required conditions
CD / Build and push images (push) Has been cancelled
CI / Lint, typecheck, test (push) Has been cancelled

apply-theme.ts derives BOTH modes' accent tokens from the stored choice
(ui.theme.accent: preset id or {custom:'#hex'}) and writes them as
<style id="user-theme"> with :root:root + :root:root[data-theme='dark']
blocks — the doubled :root beats tokens.css regardless of document
order, since theme-init.js injects the ui.theme.css cache during <head>
parsing, before the bundle styles. The default preset means NO override
(hand-tuned tokens.css values stay). main.tsx re-derives from the
choice at startup, healing stale caches after app updates.

Settings: accent radiogroup inside the Appearance section (visible
names, color never the only cue) with per-mode preview swatches on
each mode's canonical background, plus a custom color input; i18n
de+en. The second fieldset made bare .settings-fieldset locators
ambiguous — theme specs now scope via input[name] (fence stays).

Tests: apply-theme unit pack, BASE_PALETTE<->tokens.css drift fence in
theme-contrast.test.ts, e2e theme-accent.spec (instant apply, pre-paint
persistence, default removes override, axe smoke with garish yellow in
both modes). ADR 0018 amendment documents the stage-B details.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
This commit is contained in:
Claude Fable 5 2026-07-29 08:59:27 +02:00
parent b1643bbe68
commit 83a2fe470e
11 changed files with 545 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(); const page = await context.newPage();
await page.emulateMedia({ colorScheme: 'light' }); await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/settings'); 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. // Default: System, auf einem hellen OS also light.
await expect(radio(page, 'system')).toBeChecked(); 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 // Persistenz: Wahl und Theme überleben den Reload (theme-init.js liest
// denselben localStorage-Key vor dem ersten Paint). // denselben localStorage-Key vor dem ersten Paint).
await page.reload(); await page.reload();
await page.locator('.settings-fieldset').waitFor(); await page.locator('input[name="theme-mode"]').first().waitFor();
await expect(radio(page, 'dark')).toBeChecked(); await expect(radio(page, 'dark')).toBeChecked();
expect(await effectiveTheme(page)).toBe('dark'); expect(await effectiveTheme(page)).toBe('dark');
expect(await page.evaluate(() => window.localStorage.getItem('ui.theme.mode'))).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(); const page = await context.newPage();
await page.emulateMedia({ colorScheme: 'light' }); await page.emulateMedia({ colorScheme: 'light' });
await page.goto('/settings'); 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'); const toggle = page.locator('.topbar__theme');
// Default System → ein Klick zykelt in Radio-Reihenfolge weiter zu Hell, // 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). // Persistenz wie bei den Radios (gleicher localStorage-Key).
await toggle.click(); // → light await toggle.click(); // → light
await page.reload(); await page.reload();
await page.locator('.settings-fieldset').waitFor(); await page.locator('input[name="theme-mode"]').first().waitFor();
await expect(radio(page, 'light')).toBeChecked(); await expect(radio(page, 'light')).toBeChecked();
expect(await effectiveTheme(page)).toBe('light'); expect(await effectiveTheme(page)).toBe('light');

View File

@ -8,6 +8,7 @@ import { AuthProvider } from './auth/auth-context';
import { ToastProvider } from './components/Toast'; import { ToastProvider } from './components/Toast';
import './i18n'; import './i18n';
import { ApiError } from './lib/api'; import { ApiError } from './lib/api';
import { initUserTheme } from './theme/apply-theme';
import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme'; import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.css'; import './styles/base.css';
@ -17,6 +18,9 @@ import './styles/base.css';
// listener keeps 'system' users in sync with live OS scheme changes. // listener keeps 'system' users in sync with live OS scheme changes.
applyTheme(readStoredThemeMode()); applyTheme(readStoredThemeMode());
initSystemThemeListener(); 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({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {

View File

@ -1,5 +1,12 @@
import { zodResolver } from '@hookform/resolvers/zod'; 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react'; import { useState } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
@ -17,6 +24,7 @@ import { WatchesSection } from '../watches/WatchesSection';
import { useDocumentTitle } from '../lib/use-document-title'; import { useDocumentTitle } from '../lib/use-document-title';
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts'; import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
import { usePersistentState } from '../lib/use-persistent-state'; import { usePersistentState } from '../lib/use-persistent-state';
import { useAccentChoice } from '../theme/apply-theme';
import { useThemeMode, type ThemeMode } from '../theme/theme'; import { useThemeMode, type ThemeMode } from '../theme/theme';
interface SessionView { interface SessionView {
id: string; id: string;
@ -78,10 +86,95 @@ function AppearanceSection(): React.JSX.Element {
))} ))}
</fieldset> </fieldset>
<p className="field__hint">{t('settings:appearance.hint')}</p> <p className="field__hint">{t('settings:appearance.hint')}</p>
<AccentFieldset />
</section> </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 /** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel
* abschaltbar machen lokale Geräte-Einstellung, kein Server-Zustand. */ * abschaltbar machen lokale Geräte-Einstellung, kein Server-Zustand. */
function InteractionSection(): React.JSX.Element { function InteractionSection(): React.JSX.Element {

View File

@ -3690,6 +3690,31 @@ ul[data-type='task_list'] li p:last-of-type {
font-weight: 600; 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 { .pond-settings-page__header {
display: flex; display: flex;
align-items: center; 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 { readFileSync } from 'node:fs';
import { BASE_PALETTE } from '@dorfteich/shared';
import { describe, expect, it } from 'vitest'; 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-primary` no longer exists; the accent family is
`--color-accent`/`--color-accent-contrast` (plus the danger family for `--color-accent`/`--color-accent-contrast` (plus the danger family for
destructive surfaces). 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", "dark": "Dunkel",
"system": "Systemeinstellung", "system": "Systemeinstellung",
"hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät.", "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": { "interaction": {
"title": "Bedienung", "title": "Bedienung",

View File

@ -64,7 +64,18 @@
"dark": "Dark", "dark": "Dark",
"system": "System setting", "system": "System setting",
"hint": "“System setting” follows the devices light/dark mode. Applies to this device.", "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": { "interaction": {
"title": "Interaction", "title": "Interaction",