diff --git a/frontend/src/client/app.ts b/frontend/src/client/app.ts index f070290..8503995 100644 --- a/frontend/src/client/app.ts +++ b/frontend/src/client/app.ts @@ -1,14 +1,17 @@ -// app.ts — universal client bundle injected on every page. Three jobs: +// app.ts — universal client bundle injected on every page. Four jobs: // 1. Wire the BottomNav (was previously written but never bundled — m // reproduced the broken [+] and Menü buttons in production). // 2. Register the service worker so the site qualifies for PWA install. // 3. Surface the install prompt (Chromium banner / iOS share-sheet hint). +// 4. Init the theme listener so the OS-level prefers-color-scheme change +// flips the page when the user's pref is "auto" (mAi/paliad#2). // // Per-page bundles still register their own behaviour; this script is // orthogonal and only touches DOM nodes it owns. import { initBottomNav } from "./bottom-nav"; import { initInstallPrompt } from "./pwa-install"; +import { initTheme } from "./theme"; function registerServiceWorker(): void { if (!("serviceWorker" in navigator)) return; @@ -26,6 +29,7 @@ function registerServiceWorker(): void { function boot(): void { initBottomNav(); initInstallPrompt(); + initTheme(); } if (document.readyState === "loading") { diff --git a/frontend/src/client/i18n.ts b/frontend/src/client/i18n.ts index cc43563..a8809ef 100644 --- a/frontend/src/client/i18n.ts +++ b/frontend/src/client/i18n.ts @@ -43,6 +43,17 @@ const translations: Record> = { "nav.neuigkeiten": "Neuigkeiten", "nav.soon.tooltip": "Bald verf\u00fcgbar", + // Theme toggle (mAi/paliad#2). The button cycles auto \u2192 light \u2192 dark + // \u2192 auto. The "toggle." keys show the *current* pref next to + // the icon; the "cycle." keys describe WHAT the next click will + // do (read by aria-label / tooltip). + "theme.toggle.auto": "Auto", + "theme.toggle.light": "Hell", + "theme.toggle.dark": "Dunkel", + "theme.toggle.cycle.auto": "Auf Hell-Modus wechseln", + "theme.toggle.cycle.light": "Auf Dunkel-Modus wechseln", + "theme.toggle.cycle.dark": "Auf Auto wechseln (System)", + // BottomNav (mobile) "bottomnav.add": "Anlegen", "bottomnav.menu": "Menü", @@ -1353,6 +1364,14 @@ const translations: Record> = { "nav.neuigkeiten": "What's New", "nav.soon.tooltip": "Coming soon", + // Theme toggle (mAi/paliad#2) + "theme.toggle.auto": "Auto", + "theme.toggle.light": "Light", + "theme.toggle.dark": "Dark", + "theme.toggle.cycle.auto": "Switch to light theme", + "theme.toggle.cycle.light": "Switch to dark theme", + "theme.toggle.cycle.dark": "Switch to auto (system)", + // BottomNav (mobile) "bottomnav.add": "New", "bottomnav.menu": "Menu", diff --git a/frontend/src/client/sidebar.ts b/frontend/src/client/sidebar.ts index 308671d..379bc47 100644 --- a/frontend/src/client/sidebar.ts +++ b/frontend/src/client/sidebar.ts @@ -3,6 +3,8 @@ import { initGlobalSearch } from "./search"; import { getChangelogSeen } from "./changelog-seen"; +import { cycleTheme, getThemePref, onThemeChange, type ThemePref } from "./theme"; +import { t } from "./i18n"; const PIN_KEY = "paliad-sidebar-pinned"; const LEGACY_PIN_KEY = "patholo-sidebar-pinned"; const WIDTH_KEY = "paliad-sidebar-width"; @@ -69,6 +71,7 @@ export function initSidebar() { initGlobalSearch(); initChangelogBadge(); initAdminGroup(); + initThemeToggle(); const sidebar = document.querySelector(".sidebar"); if (!sidebar) return; initSidebarResize(sidebar); @@ -83,10 +86,22 @@ export function initSidebar() { return window.innerWidth < 1024; } - // Restore pin state on desktop + // Restore pin state on desktop. We mirror the `sidebar-pinned` class on + // both and : the runtime path historically set it on body, + // but the pre-paint FOUC script in PWAHead.tsx can only reach + // (body doesn't exist yet at that point). Keeping both in sync from + // every site that toggles pin state means the CSS selectors + // `.has-sidebar.sidebar-pinned` (body-rooted) and + // `:root.sidebar-pinned .has-sidebar` (html-rooted) never disagree. if (localStorage.getItem(PIN_KEY) === "true" && !isMobile()) { sidebar.classList.add("pinned"); document.body.classList.add("sidebar-pinned"); + document.documentElement.classList.add("sidebar-pinned"); + } else { + // Pre-paint script may have added it (e.g. last visit was wide enough + // and pinned) but current viewport is mobile — clear so the CSS rule + // doesn't fight the responsive @media block. + document.documentElement.classList.remove("sidebar-pinned"); } // Desktop: hover expand with 150ms delay @@ -117,11 +132,13 @@ export function initSidebar() { if (wasPinned) { sidebar.classList.remove("pinned"); document.body.classList.remove("sidebar-pinned"); + document.documentElement.classList.remove("sidebar-pinned"); localStorage.setItem(PIN_KEY, "false"); } else { sidebar.classList.add("pinned"); sidebar.classList.remove("expanded"); document.body.classList.add("sidebar-pinned"); + document.documentElement.classList.add("sidebar-pinned"); localStorage.setItem(PIN_KEY, "true"); } }); @@ -167,11 +184,13 @@ export function initSidebar() { if (nowMobile) { sidebar.classList.remove("expanded", "pinned"); document.body.classList.remove("sidebar-pinned"); + document.documentElement.classList.remove("sidebar-pinned"); } else { closeMobile(); if (localStorage.getItem(PIN_KEY) === "true") { sidebar.classList.add("pinned"); document.body.classList.add("sidebar-pinned"); + document.documentElement.classList.add("sidebar-pinned"); } } }); @@ -295,6 +314,64 @@ function initChangelogBadge(): void { }); } +// initThemeToggle wires the sun/moon button at the bottom of the sidebar +// (mAi/paliad#2). The pre-paint inline script in PWAHead.tsx already set +// the data-theme attribute on ; this function only owns the post- +// hydration UI: cycling on click, swapping the icon/label to match the +// current preference, and re-rendering when the OS-level scheme flips +// while the user is on "auto". +const THEME_ICONS: Record = { + auto: '', + light: '', + dark: '', +}; + +const THEME_LABEL_KEYS: Record = { + auto: "theme.toggle.auto", + light: "theme.toggle.light", + dark: "theme.toggle.dark", +}; + +function initThemeToggle(): void { + const btn = document.getElementById("sidebar-theme-toggle") as HTMLButtonElement | null; + const icon = document.getElementById("sidebar-theme-icon") as HTMLElement | null; + const label = document.getElementById("sidebar-theme-label") as HTMLElement | null; + if (!btn || !icon || !label) return; + + function render(): void { + const pref = getThemePref(); + if (icon) icon.innerHTML = THEME_ICONS[pref]; + if (label) { + const key = THEME_LABEL_KEYS[pref]; + label.setAttribute("data-i18n", key); + label.textContent = t(key); + } + if (btn) { + btn.setAttribute("aria-label", t(key_for_aria(pref))); + btn.setAttribute("title", t(key_for_aria(pref))); + } + } + + function key_for_aria(pref: ThemePref): "theme.toggle.cycle.auto" | "theme.toggle.cycle.light" | "theme.toggle.cycle.dark" { + // The aria-label describes WHAT happens on click, not the current + // state — assistive tech reads "Switch to dark theme" rather than + // just "Auto". Cycle order: auto → light → dark → auto. + if (pref === "auto") return "theme.toggle.cycle.auto"; // → light + if (pref === "light") return "theme.toggle.cycle.light"; // → dark + return "theme.toggle.cycle.dark"; // → auto + } + + btn.addEventListener("click", () => { + cycleTheme(); + }); + + // theme.ts notifies on user-cycle AND on OS-level prefers-color-scheme + // changes while the user is on "auto" — a single subscription covers + // both, so the icon/label always tracks the live preference. + onThemeChange(render); + render(); +} + // initAdminGroup reveals the Admin section in the sidebar when the caller's // /api/me lookup confirms global_role='global_admin'. The markup is in the // DOM with display:none for everyone — flipping it on after the fetch lands diff --git a/frontend/src/client/theme.ts b/frontend/src/client/theme.ts new file mode 100644 index 0000000..1a44e85 --- /dev/null +++ b/frontend/src/client/theme.ts @@ -0,0 +1,112 @@ +// theme.ts — owns the runtime side of the dark-mode toggle. +// +// The pre-paint inline