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
141 lines
5.1 KiB
TypeScript
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];
|
|
}
|