#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:
parent
5034b7a80f
commit
2c571f9f5e
@ -3,7 +3,17 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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>
|
||||
<!-- 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
|
||||
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. -->
|
||||
|
||||
34
apps/web/public/theme-init.js
Normal file
34
apps/web/public/theme-init.js
Normal 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. */
|
||||
}
|
||||
})();
|
||||
@ -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: {
|
||||
|
||||
@ -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 {
|
||||
<WatchesSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<AppearanceSection />
|
||||
<InteractionSection />
|
||||
<DataExportSection />
|
||||
</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
|
||||
* abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */
|
||||
function InteractionSection(): React.JSX.Element {
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
* <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);
|
||||
}
|
||||
|
||||
84
apps/web/src/theme/theme.ts
Normal file
84
apps/web/src/theme/theme.ts
Normal 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;
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user