#180: dark mode — Light/Dark/System setting with token-based dark palette

The dark palette lives as a single :root[data-theme='dark'] block in
tokens.css; theme.ts and the pre-paint public/theme-init.js (external file
because the prod CSP forbids inline scripts) always resolve the stored
ui.theme.mode to a concrete data-theme, so 'system' needs no @media
duplicate and follows live OS changes via matchMedia. color-scheme flips
per theme (native controls/scrollbars), paired theme-color metas track the
effective theme, and the new Appearance settings section offers the
three-way choice as native radios (device-local, like #170). Label chips
gain a chip-outline ring so arbitrary user colors stay separated on the
dark canvas; useEffectiveTheme() is exported for the later pond-scoped
theming stage (ADR 0018).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX
This commit is contained in:
Claude Fable 5 2026-07-28 20:40:47 +02:00
parent 5034b7a80f
commit 2c571f9f5e
10 changed files with 253 additions and 0 deletions

View File

@ -3,7 +3,17 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<!-- Applies at parse time, before any script or stylesheet: the UA
canvas is already dark for system-dark users, killing the white
flash. theme-init.js then narrows it to the resolved theme. -->
<meta name="color-scheme" content="light dark" />
<meta name="theme-color" media="(prefers-color-scheme: light)" content="#2f6f4f" />
<meta name="theme-color" media="(prefers-color-scheme: dark)" content="#10161d" />
<title>Dorfteich</title> <title>Dorfteich</title>
<!-- Classic (non-module) script: executes during head parsing, before
first paint and before the deferred module bundle. External file
because the prod CSP forbids inline scripts (issue #180). -->
<script src="/theme-init.js"></script>
<!-- Self-hosted catalog @font-face rules (ADR 0016), baked into the image <!-- Self-hosted catalog @font-face rules (ADR 0016), baked into the image
by deploy/fonts/build-fonts.mjs. Absent in a plain dev build → the app by deploy/fonts/build-fonts.mjs. Absent in a plain dev build → the app
falls back to system fonts; never references a third-party origin. --> falls back to system fonts; never references a third-party origin. -->

View File

@ -0,0 +1,34 @@
/* Pre-paint theme boot (issue #180). Must stay a plain classic script in a
* separate file: the prod CSP (nginx.conf, script-src 'self') forbids inline
* scripts, and executing during <head> parsing before the deferred module
* bundle is what prevents a white flash for dark users. Reads the same
* JSON-encoded localStorage keys as src/theme/theme.ts; keep them in sync. */
(function () {
var theme = 'light';
try {
var raw = window.localStorage.getItem('ui.theme.mode');
var mode = raw === null ? 'system' : JSON.parse(raw);
var dark =
mode === 'dark' ||
(mode !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
if (dark) theme = 'dark';
} catch {
/* Broken storage/JSON: default to light. */
}
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
try {
/* Hook for the accent-theming stage (ADR 0018): a pre-derived stylesheet
* cached under ui.theme.css is injected before first paint so custom
* accents do not flash either. Unset until that stage ships. */
var css = window.localStorage.getItem('ui.theme.css');
if (css) {
var style = document.createElement('style');
style.id = 'user-theme';
style.textContent = JSON.parse(css);
document.head.appendChild(style);
}
} catch {
/* Optional enhancement only. */
}
})();

View File

@ -8,9 +8,16 @@ 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 { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
import './styles/tokens.css'; import './styles/tokens.css';
import './styles/base.css'; import './styles/base.css';
// theme-init.js already themed the document pre-paint; re-applying here is
// a no-op safety net for contexts serving index.html without it, and the
// listener keeps 'system' users in sync with live OS scheme changes.
applyTheme(readStoredThemeMode());
initSystemThemeListener();
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { queries: {

View File

@ -17,6 +17,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 { applyTheme, THEME_MODE_KEY, type ThemeMode } from '../theme/theme';
interface SessionView { interface SessionView {
id: string; id: string;
createdAt: string; createdAt: string;
@ -38,6 +39,7 @@ export function SettingsPage(): React.JSX.Element {
<WatchesSection /> <WatchesSection />
<ApiTokensSection /> <ApiTokensSection />
<FeedTokensSection /> <FeedTokensSection />
<AppearanceSection />
<InteractionSection /> <InteractionSection />
<DataExportSection /> <DataExportSection />
</SettingsLayout> </SettingsLayout>
@ -45,6 +47,43 @@ export function SettingsPage(): React.JSX.Element {
); );
} }
/** Erscheinungsbild (issue #180): Hell/Dunkel/System wie
* InteractionSection eine lokale Geräte-Einstellung, kein Server-Zustand. */
function AppearanceSection(): React.JSX.Element {
const { t } = useTranslation();
const [mode, setMode] = usePersistentState<ThemeMode>(THEME_MODE_KEY, 'system');
const choose = (value: ThemeMode): void => {
setMode(value);
applyTheme(value);
};
const options: { value: ThemeMode; label: string }[] = [
{ value: 'light', label: t('settings:appearance.light') },
{ value: 'dark', label: t('settings:appearance.dark') },
{ value: 'system', label: t('settings:appearance.system') },
];
return (
<section className="settings-section">
<h2>{t('settings:appearance.title')}</h2>
<fieldset className="settings-fieldset">
<legend>{t('settings:appearance.legend')}</legend>
{options.map((option) => (
<label key={option.value} className="settings-checkbox">
<input
type="radio"
name="theme-mode"
value={option.value}
checked={mode === option.value}
onChange={() => choose(option.value)}
/>
{option.label}
</label>
))}
</fieldset>
<p className="field__hint">{t('settings:appearance.hint')}</p>
</section>
);
}
/** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel /** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel
* abschaltbar machen lokale Geräte-Einstellung, kein Server-Zustand. */ * abschaltbar machen lokale Geräte-Einstellung, kein Server-Zustand. */
function InteractionSection(): React.JSX.Element { function InteractionSection(): React.JSX.Element {

View File

@ -1912,6 +1912,9 @@ ul[data-type='task_list'] li p:last-of-type {
font-size: 0.72rem; font-size: 0.72rem;
line-height: 1.5; line-height: 1.5;
white-space: nowrap; white-space: nowrap;
/* Invisible on light; separates arbitrary user label colors from the
dark canvas (issue #180). */
border: 1px solid var(--color-chip-outline);
} }
.label-chip__swatch { .label-chip__swatch {
@ -3675,6 +3678,18 @@ ul[data-type='task_list'] li p:last-of-type {
margin: var(--space-2) 0; margin: var(--space-2) 0;
} }
/* Radio groups in settings sections (issue #180). */
.settings-fieldset {
border: none;
margin: 0;
padding: 0;
}
.settings-fieldset legend {
padding: 0;
font-weight: 600;
}
.pond-settings-page__header { .pond-settings-page__header {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -4,6 +4,10 @@
* one-line changes. The look is deliberately plain and professional. * one-line changes. The look is deliberately plain and professional.
*/ */
:root { :root {
/* Native controls/scrollbars follow the active theme; the dark block
below flips this together with the palette (issue #180). */
color-scheme: light;
/* Font slots per ADR 0016; the real self-hosted fonts arrive with issue #66. */ /* Font slots per ADR 0016; the real self-hosted fonts arrive with issue #66. */
--font-heading: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-heading: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif;
--font-body: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif; --font-body: 'Roboto', system-ui, -apple-system, 'Segoe UI', sans-serif;
@ -66,3 +70,36 @@
--topbar-height: 3rem; --topbar-height: 3rem;
--sidebar-width: 16rem; --sidebar-width: 16rem;
} }
/*
* Dark palette (issue #180). public/theme-init.js and theme/theme.ts always
* resolve the user's Light/Dark/System choice to a CONCRETE data-theme on
* <html> there is deliberately no @media fallback here, so this single
* selector is the whole contract for later theming stages (ADR 0018).
* Every pair is contrast-asserted by theme-contrast.test.ts; border-input
* and favorite pass 3:1 against both palettes and stay unchanged.
*/
:root[data-theme='dark'] {
color-scheme: dark;
--color-text: #e2e8f0;
--color-text-muted: #a7b3c0;
--color-bg: #10161d;
--color-bg-subtle: #161d26;
--color-surface: #1b2430;
--color-surface-muted: #26303c;
--color-border: #323e4d;
--color-accent: #5cb88a;
--color-accent-contrast: #10161d;
--color-danger: #f87171;
--color-danger-contrast: #10161d;
--color-danger-strong: #fa8f8f;
--color-ok: #4ade80;
--color-badge-ok-bg: #1b3d2b;
--color-badge-ok-text: #8fdcb0;
--color-badge-error-bg: #43181d;
--color-badge-error-text: #f3a8a8;
--color-badge-warn-bg: #3c3113;
--color-badge-warn-text: #e3c06b;
--color-chip-outline: rgba(255, 255, 255, 0.18);
}

View File

@ -0,0 +1,84 @@
import { useEffect, useState } from 'react';
/** localStorage key for the Light/Dark/System choice (issue #180) a
* device-local preference like SINGLE_KEY_SHORTCUTS_KEY, JSON-encoded via
* usePersistentState. public/theme-init.js reads the same key before the
* bundle loads; keep the two in sync. The ui.theme.* namespace is shared
* with the later accent-theming stage (ui.theme.accent, ui.theme.css). */
export const THEME_MODE_KEY = 'ui.theme.mode';
export type ThemeMode = 'light' | 'dark' | 'system';
export type EffectiveTheme = 'light' | 'dark';
/** Meta theme-color per effective theme; must match tokens.css
* (--color-accent light, --color-bg dark) and the metas in index.html. */
const THEME_COLOR: Record<EffectiveTheme, string> = {
light: '#2f6f4f',
dark: '#10161d',
};
export function readStoredThemeMode(): ThemeMode {
try {
const raw = window.localStorage.getItem(THEME_MODE_KEY);
const parsed = raw === null ? 'system' : (JSON.parse(raw) as unknown);
return parsed === 'light' || parsed === 'dark' ? parsed : 'system';
} catch {
return 'system';
}
}
export function resolveTheme(mode: ThemeMode): EffectiveTheme {
if (mode !== 'system') return mode;
try {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
} catch {
return 'light';
}
}
/** Resolve the mode to a CONCRETE theme on <html> tokens.css keys its
* dark palette solely off data-theme (no @media fallback), so this is the
* one place the choice takes effect. Also mirrors color-scheme (native
* controls) and both media-paired theme-color metas: with an explicit
* override the OS scheme must not win, and under 'system' both metas
* resolve to the same value anyway. */
export function applyTheme(mode: ThemeMode): void {
const theme = resolveTheme(mode);
const root = document.documentElement;
root.dataset.theme = theme;
root.style.colorScheme = theme;
document
.querySelectorAll('meta[name="theme-color"]')
.forEach((meta) => meta.setAttribute('content', THEME_COLOR[theme]));
}
/** Follow OS scheme changes live while the stored mode is 'system'.
* Called once at startup (main.tsx). */
export function initSystemThemeListener(): void {
try {
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (readStoredThemeMode() === 'system') applyTheme('system');
});
} catch {
// matchMedia may be absent in exotic embedders; the theme simply stays
// as applied until the next explicit change.
}
}
/** The theme currently in effect, reactive to setting AND OS changes
* observes data-theme on <html> instead of duplicating the resolve logic.
* Pond-scoped theming (ADR 0018 stage C) will build on this. */
export function useEffectiveTheme(): EffectiveTheme {
const read = (): EffectiveTheme =>
document.documentElement.dataset.theme === 'dark' ? 'dark' : 'light';
const [theme, setTheme] = useState<EffectiveTheme>(read);
useEffect(() => {
const observer = new MutationObserver(() => setTheme(read()));
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
return () => observer.disconnect();
}, []);
return theme;
}

View File

@ -36,6 +36,17 @@ export default tseslint.config(
}, },
}, },
}, },
{
// Classic browser scripts served verbatim from public/ (theme-init.js,
// issue #180): no bundler, no modules, browser globals only.
files: ['apps/web/public/*.js'],
languageOptions: {
globals: {
window: 'readonly',
document: 'readonly',
},
},
},
{ {
rules: { rules: {
// Unused values are usually bugs; underscore-prefix marks intentional ones. // Unused values are usually bugs; underscore-prefix marks intentional ones.

View File

@ -57,6 +57,14 @@
"save": "Startseite speichern", "save": "Startseite speichern",
"saved": "Gespeichert." "saved": "Gespeichert."
}, },
"appearance": {
"title": "Erscheinungsbild",
"legend": "Farbschema",
"light": "Hell",
"dark": "Dunkel",
"system": "Systemeinstellung",
"hint": "„Systemeinstellung“ folgt dem Hell-/Dunkel-Modus des Geräts. Gilt für dieses Gerät."
},
"interaction": { "interaction": {
"title": "Bedienung", "title": "Bedienung",
"disableSingleKey": "Einzeltasten-Kürzel deaktivieren", "disableSingleKey": "Einzeltasten-Kürzel deaktivieren",

View File

@ -57,6 +57,14 @@
"save": "Save landing page", "save": "Save landing page",
"saved": "Saved." "saved": "Saved."
}, },
"appearance": {
"title": "Appearance",
"legend": "Color scheme",
"light": "Light",
"dark": "Dark",
"system": "System setting",
"hint": "“System setting” follows the devices light/dark mode. Applies to this device."
},
"interaction": { "interaction": {
"title": "Interaction", "title": "Interaction",
"disableSingleKey": "Disable single-key shortcuts", "disableSingleKey": "Disable single-key shortcuts",