#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>
|
<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. -->
|
||||||
|
|||||||
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 { 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: {
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
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: {
|
rules: {
|
||||||
// Unused values are usually bugs; underscore-prefix marks intentional ones.
|
// Unused values are usually bugs; underscore-prefix marks intentional ones.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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 device’s light/dark mode. Applies to this device."
|
||||||
|
},
|
||||||
"interaction": {
|
"interaction": {
|
||||||
"title": "Interaction",
|
"title": "Interaction",
|
||||||
"disableSingleKey": "Disable single-key shortcuts",
|
"disableSingleKey": "Disable single-key shortcuts",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user