diff --git a/apps/web/index.html b/apps/web/index.html
index 64aa490..284207c 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -3,7 +3,17 @@
+
+
+
+
Dorfteich
+
+
diff --git a/apps/web/public/theme-init.js b/apps/web/public/theme-init.js
new file mode 100644
index 0000000..aa1e8c9
--- /dev/null
+++ b/apps/web/public/theme-init.js
@@ -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 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. */
+ }
+})();
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 7d4c714..e8aa9d1 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,9 +8,16 @@ import { AuthProvider } from './auth/auth-context';
import { ToastProvider } from './components/Toast';
import './i18n';
import { ApiError } from './lib/api';
+import { applyTheme, initSystemThemeListener, readStoredThemeMode } from './theme/theme';
import './styles/tokens.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({
defaultOptions: {
queries: {
diff --git a/apps/web/src/pages/SettingsPage.tsx b/apps/web/src/pages/SettingsPage.tsx
index 2f92ff9..d72e4e9 100644
--- a/apps/web/src/pages/SettingsPage.tsx
+++ b/apps/web/src/pages/SettingsPage.tsx
@@ -17,6 +17,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 { applyTheme, THEME_MODE_KEY, type ThemeMode } from '../theme/theme';
interface SessionView {
id: string;
createdAt: string;
@@ -38,6 +39,7 @@ export function SettingsPage(): React.JSX.Element {
+
@@ -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(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 (
+
+ );
+}
+
/** 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 {
diff --git a/apps/web/src/styles/base.css b/apps/web/src/styles/base.css
index 2f70fc1..00b8407 100644
--- a/apps/web/src/styles/base.css
+++ b/apps/web/src/styles/base.css
@@ -1912,6 +1912,9 @@ ul[data-type='task_list'] li p:last-of-type {
font-size: 0.72rem;
line-height: 1.5;
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 {
@@ -3675,6 +3678,18 @@ ul[data-type='task_list'] li p:last-of-type {
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 {
display: flex;
align-items: center;
diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css
index 0119119..fa49081 100644
--- a/apps/web/src/styles/tokens.css
+++ b/apps/web/src/styles/tokens.css
@@ -4,6 +4,10 @@
* one-line changes. The look is deliberately plain and professional.
*/
: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-heading: '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;
--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
+ * — 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);
+}
diff --git a/apps/web/src/theme/theme.ts b/apps/web/src/theme/theme.ts
new file mode 100644
index 0000000..ea3f97a
--- /dev/null
+++ b/apps/web/src/theme/theme.ts
@@ -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 = {
+ 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 — 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 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(read);
+ useEffect(() => {
+ const observer = new MutationObserver(() => setTheme(read()));
+ observer.observe(document.documentElement, {
+ attributes: true,
+ attributeFilter: ['data-theme'],
+ });
+ return () => observer.disconnect();
+ }, []);
+ return theme;
+}
diff --git a/eslint.config.mjs b/eslint.config.mjs
index 7e1782e..8968a3f 100644
--- a/eslint.config.mjs
+++ b/eslint.config.mjs
@@ -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: {
// Unused values are usually bugs; underscore-prefix marks intentional ones.
diff --git a/packages/shared/i18n/de/settings.json b/packages/shared/i18n/de/settings.json
index bc06e69..f9ec6a0 100644
--- a/packages/shared/i18n/de/settings.json
+++ b/packages/shared/i18n/de/settings.json
@@ -57,6 +57,14 @@
"save": "Startseite speichern",
"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": {
"title": "Bedienung",
"disableSingleKey": "Einzeltasten-Kürzel deaktivieren",
diff --git a/packages/shared/i18n/en/settings.json b/packages/shared/i18n/en/settings.json
index 9d208dc..f34d578 100644
--- a/packages/shared/i18n/en/settings.json
+++ b/packages/shared/i18n/en/settings.json
@@ -57,6 +57,14 @@
"save": "Save landing page",
"saved": "Saved."
},
+ "appearance": {
+ "title": "Appearance",
+ "legend": "Color scheme",
+ "light": "Light",
+ "dark": "Dark",
+ "system": "System setting",
+ "hint": "“System setting” follows the device’s light/dark mode. Applies to this device."
+ },
"interaction": {
"title": "Interaction",
"disableSingleKey": "Disable single-key shortcuts",