dorfteich/apps/web/src/theme/apply-theme.ts
Claude Fable 5 83a2fe470e
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
#184: user accent theming — presets and free color as one mechanism
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
2026-07-29 08:59:27 +02:00

141 lines
5.1 KiB
TypeScript

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];
}