From 2c571f9f5e9ca03fd454bd57bf890c89eec8e0ea Mon Sep 17 00:00:00 2001 From: Claude Fable 5 Date: Tue, 28 Jul 2026 20:40:47 +0200 Subject: [PATCH] =?UTF-8?q?#180:=20dark=20mode=20=E2=80=94=20Light/Dark/Sy?= =?UTF-8?q?stem=20setting=20with=20token-based=20dark=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01QRtCnB3uLdQtFmvp9HXcRX --- apps/web/index.html | 10 ++++ apps/web/public/theme-init.js | 34 +++++++++++ apps/web/src/main.tsx | 7 +++ apps/web/src/pages/SettingsPage.tsx | 39 +++++++++++++ apps/web/src/styles/base.css | 15 +++++ apps/web/src/styles/tokens.css | 37 ++++++++++++ apps/web/src/theme/theme.ts | 84 +++++++++++++++++++++++++++ eslint.config.mjs | 11 ++++ packages/shared/i18n/de/settings.json | 8 +++ packages/shared/i18n/en/settings.json | 8 +++ 10 files changed, 253 insertions(+) create mode 100644 apps/web/public/theme-init.js create mode 100644 apps/web/src/theme/theme.ts 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 ( +
+

{t('settings:appearance.title')}

+
+ {t('settings:appearance.legend')} + {options.map((option) => ( + + ))} +
+

{t('settings:appearance.hint')}

+
+ ); +} + /** 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",