#184: accent theme engine — presets and free color (ADR 0018 stage B) #185
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();
|
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');
|
||||||
|
|
||||||
|
|||||||
@ -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: {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
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 { 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']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -64,7 +64,18 @@
|
|||||||
"dark": "Dark",
|
"dark": "Dark",
|
||||||
"system": "System setting",
|
"system": "System setting",
|
||||||
"hint": "“System setting” follows the device’s light/dark mode. Applies to this device.",
|
"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": {
|
"interaction": {
|
||||||
"title": "Interaction",
|
"title": "Interaction",
|
||||||
|
|||||||
@ -33,5 +33,6 @@ export * from './ponds';
|
|||||||
export * from './public-api';
|
export * from './public-api';
|
||||||
export * from './quotas';
|
export * from './quotas';
|
||||||
export * from './text-diff';
|
export * from './text-diff';
|
||||||
|
export * from './theme';
|
||||||
export * from './tree';
|
export * from './tree';
|
||||||
export * from './watches';
|
export * from './watches';
|
||||||
|
|||||||
109
packages/shared/src/theme.test.ts
Normal file
109
packages/shared/src/theme.test.ts
Normal 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))}`;
|
||||||
|
}
|
||||||
177
packages/shared/src/theme.ts
Normal file
177
packages/shared/src/theme.ts
Normal 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 };
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user