#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
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:
parent
b1643bbe68
commit
83a2fe470e
97
apps/web/e2e/theme-accent.spec.ts
Normal file
97
apps/web/e2e/theme-accent.spec.ts
Normal 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();
|
||||
});
|
||||
@ -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');
|
||||
|
||||
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
112
apps/web/src/theme/apply-theme.test.ts
Normal file
112
apps/web/src/theme/apply-theme.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
140
apps/web/src/theme/apply-theme.ts
Normal file
140
apps/web/src/theme/apply-theme.ts
Normal 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];
|
||||
}
|
||||
@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -64,7 +64,18 @@
|
||||
"dark": "Dark",
|
||||
"system": "System setting",
|
||||
"hint": "“System setting” follows the device’s 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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user