From 76f1834712fadb78143abe331d0da2f3a6df3f9c Mon Sep 17 00:00:00 2001 From: Anais Saraiva Date: Tue, 22 Sep 2026 08:06:47 +0200 Subject: [PATCH] sunset theme following --- docs/FRONTEND.md | 4 ++- frontend/src/App.tsx | 20 +++++++++---- frontend/src/components/AdminCenter.tsx | 14 ++++++++- frontend/src/components/AdminPanel.tsx | 1 + frontend/src/components/dialogs.tsx | 1 + frontend/src/lib/i18n.ts | 12 ++++++++ frontend/src/lib/solarTheme.ts | 39 +++++++++++++++++++++++++ src/index.ts | 8 ++--- 8 files changed, 88 insertions(+), 11 deletions(-) create mode 100644 frontend/src/lib/solarTheme.ts diff --git a/docs/FRONTEND.md b/docs/FRONTEND.md index 5064506..027ac03 100644 --- a/docs/FRONTEND.md +++ b/docs/FRONTEND.md @@ -85,7 +85,9 @@ No router library — a tiny history wrapper (`lib/route.ts`: `navigate()`, Tailwind `darkMode: "class"`. `applyTheme()` in `App.tsx` toggles the `dark` class from the account theme (`auto` follows `prefers-color-scheme` with a -live media-query listener). Accent: emerald; shapes: `rounded-2xl/3xl` +live media-query listener; `sunset` follows the browser's local time — +light 07:00–19:00, dark otherwise — re-applied at each boundary via +`lib/solarTheme.ts`). Accent: emerald; shapes: `rounded-2xl/3xl` everywhere; every surface needs a `dark:` variant. UI text is English only. ## Mobile diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2091163..34d8c05 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -27,6 +27,7 @@ import { ReviewPage } from "./components/ReviewPage.tsx"; import { ConfirmDialog, LoadingScreen } from "./components/ui.tsx"; import { Toasts } from "./components/Toasts.tsx"; import { pushToast } from "./lib/toasts.ts"; +import { msUntilNextSolarSwitch, resolveThemeDark } from "./lib/solarTheme.ts"; import { useT } from "./lib/i18n.ts"; function shareIdFromPath(): string | null { @@ -65,10 +66,7 @@ const MAX_RETRIES = 3; function applyTheme(theme: string) { const root = document.documentElement; - const dark = - theme === "dark" || - (theme !== "light" && - window.matchMedia("(prefers-color-scheme: dark)").matches); + const dark = resolveThemeDark(theme); root.classList.toggle("dark", dark); root.style.colorScheme = dark ? "dark" : "light"; } @@ -272,8 +270,20 @@ export function App() { .catch(() => setMessages([])); }, [activeId]); - // Follow OS theme when account theme is auto + // Follow OS theme when account theme is auto; follow local time (07:00–19:00) + // when account theme is sunset — re-applied at each day/night boundary. useEffect(() => { + if (user?.theme === "sunset") { + let timer: number | undefined; + const schedule = () => { + timer = window.setTimeout(() => { + applyTheme("sunset"); + schedule(); + }, msUntilNextSolarSwitch()); + }; + schedule(); + return () => window.clearTimeout(timer); + } if (user?.theme !== "auto") return; const mq = window.matchMedia("(prefers-color-scheme: dark)"); const h = () => applyTheme("auto"); diff --git a/frontend/src/components/AdminCenter.tsx b/frontend/src/components/AdminCenter.tsx index c7c154c..53e8963 100644 --- a/frontend/src/components/AdminCenter.tsx +++ b/frontend/src/components/AdminCenter.tsx @@ -8,13 +8,14 @@ import { ReportsPanel } from "./ReportsPanel.tsx"; import { ModelsPanel } from "./ModelsPanel.tsx"; import { Button, CopyButton, FlowerMark, Input, LoadingScreen, Spinner, Switch } from "./ui.tsx"; import { cn } from "../lib/cn.ts"; +import { msUntilNextSolarSwitch, resolveThemeDark } from "../lib/solarTheme.ts"; import { useT } from "../lib/i18n.ts"; type Tab = "accounts" | "access" | "reports" | "models" | "features" | "mail"; function applyTheme(theme: string) { const root = document.documentElement; - const dark = theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches); + const dark = resolveThemeDark(theme); root.classList.toggle("dark", dark); root.style.colorScheme = dark ? "dark" : "light"; } @@ -39,6 +40,7 @@ export function AdminCenter() { ]; useEffect(() => { + let timer: number | undefined; api.verify() .then((r) => { if (!r.user.isAdmin) { @@ -47,9 +49,19 @@ export function AdminCenter() { } setAllowed(true); applyTheme(r.user.theme); + if (r.user.theme === "sunset") { + const schedule = (): void => { + timer = window.setTimeout(() => { + applyTheme("sunset"); + schedule(); + }, msUntilNextSolarSwitch()); + }; + schedule(); + } api.adminGetSettings().then((s) => setSettings(s.settings)).catch((e) => setError(e instanceof Error ? e.message : t("admin.loadFailed"))); }) .catch(() => setAllowed(false)); + return () => window.clearTimeout(timer); }, []); // Live badges on the Access/Reports tabs: the admin firehose pushes every access diff --git a/frontend/src/components/AdminPanel.tsx b/frontend/src/components/AdminPanel.tsx index 6a102ed..34963d2 100644 --- a/frontend/src/components/AdminPanel.tsx +++ b/frontend/src/components/AdminPanel.tsx @@ -459,6 +459,7 @@ function EditUserDialog({ user, onClose, onSaved }: { user: User | null; onClose align="left" options={[ { value: "auto", label: t("common.themeAuto") }, + { value: "sunset", label: t("common.themeSunset") }, { value: "light", label: t("common.light") }, { value: "dark", label: t("common.dark") }, ]} diff --git a/frontend/src/components/dialogs.tsx b/frontend/src/components/dialogs.tsx index 4c02e6b..1f14f65 100644 --- a/frontend/src/components/dialogs.tsx +++ b/frontend/src/components/dialogs.tsx @@ -550,6 +550,7 @@ export function SettingsModal({ align="left" options={[ { value: "auto", label: t("common.themeAutoFull") }, + { value: "sunset", label: t("common.themeSunsetFull") }, { value: "light", label: t("common.light") }, { value: "dark", label: t("common.dark") }, ]} diff --git a/frontend/src/lib/i18n.ts b/frontend/src/lib/i18n.ts index d141c3e..3f76241 100644 --- a/frontend/src/lib/i18n.ts +++ b/frontend/src/lib/i18n.ts @@ -35,6 +35,8 @@ const en = { "common.theme": "Theme", "common.themeAuto": "Auto", "common.themeAutoFull": "Auto (follows system)", + "common.themeSunset": "Sunset", + "common.themeSunsetFull": "Sunset (follows local time)", "common.light": "Light", "common.dark": "Dark", "common.saveFailed": "Save failed", @@ -585,6 +587,8 @@ const es: Dict = { "common.theme": "Tema", "common.themeAuto": "Automático", "common.themeAutoFull": "Automático (según el sistema)", + "common.themeSunset": "Puesta de sol", + "common.themeSunsetFull": "Puesta de sol (hora local)", "common.light": "Claro", "common.dark": "Oscuro", "common.saveFailed": "No se pudo guardar", @@ -1125,6 +1129,8 @@ const fr: Dict = { "common.theme": "Thème", "common.themeAuto": "Auto", "common.themeAutoFull": "Auto (suit le système)", + "common.themeSunset": "Coucher de soleil", + "common.themeSunsetFull": "Coucher de soleil (heure locale)", "common.light": "Clair", "common.dark": "Sombre", "common.saveFailed": "Échec de l'enregistrement", @@ -1665,6 +1671,8 @@ const ru: Dict = { "common.theme": "Тема", "common.themeAuto": "Авто", "common.themeAutoFull": "Авто (как в системе)", + "common.themeSunset": "Закат", + "common.themeSunsetFull": "Закат (местное время)", "common.light": "Светлая", "common.dark": "Тёмная", "common.saveFailed": "Не удалось сохранить", @@ -2192,6 +2200,8 @@ const it: Dict = { "common.theme": "Tema", "common.themeAuto": "Auto", "common.themeAutoFull": "Auto (segue il sistema)", + "common.themeSunset": "Tramonto", + "common.themeSunsetFull": "Tramonto (ora locale)", "common.light": "Chiaro", "common.dark": "Scuro", "common.saveFailed": "Salvataggio non riuscito", @@ -2725,6 +2735,8 @@ const pt: Dict = { "common.theme": "Tema", "common.themeAuto": "Auto", "common.themeAutoFull": "Auto (segue o sistema)", + "common.themeSunset": "Pôr do sol", + "common.themeSunsetFull": "Pôr do sol (hora local)", "common.light": "Claro", "common.dark": "Escuro", "common.saveFailed": "Falha ao guardar", diff --git a/frontend/src/lib/solarTheme.ts b/frontend/src/lib/solarTheme.ts new file mode 100644 index 0000000..2354ec5 --- /dev/null +++ b/frontend/src/lib/solarTheme.ts @@ -0,0 +1,39 @@ +/** + * "Sunset" theme: follows the browser's local time instead of the OS setting. + * Day (07:00–19:00 local) -> light, night -> dark. No geolocation needed. + */ + +export const SOLAR_DAY_START_HOUR = 7; +export const SOLAR_NIGHT_START_HOUR = 19; + +/** True when the given local time falls in the night range (dark theme). */ +export function isSolarDark(now: Date = new Date()): boolean { + const h = now.getHours(); + return h < SOLAR_DAY_START_HOUR || h >= SOLAR_NIGHT_START_HOUR; +} + +/** Milliseconds from `now` until the next 07:00 / 19:00 local boundary. */ +export function msUntilNextSolarSwitch(now: Date = new Date()): number { + const next = new Date(now); + const h = now.getHours(); + if (h < SOLAR_DAY_START_HOUR) { + next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0); + } else if (h < SOLAR_NIGHT_START_HOUR) { + next.setHours(SOLAR_NIGHT_START_HOUR, 0, 0, 0); + } else { + next.setDate(next.getDate() + 1); + next.setHours(SOLAR_DAY_START_HOUR, 0, 0, 0); + } + return Math.max(1000, next.getTime() - now.getTime()); +} + +/** Resolve a stored theme value to dark/light. `sunset` uses local time. */ +export function resolveThemeDark(theme: string, now: Date = new Date()): boolean { + if (theme === "dark") return true; + if (theme === "light") return false; + if (theme === "sunset") return isSolarDark(now); + if (typeof window !== "undefined" && window.matchMedia) { + return window.matchMedia("(prefers-color-scheme: dark)").matches; + } + return false; +} diff --git a/src/index.ts b/src/index.ts index 79e40ee..b11652a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -183,7 +183,7 @@ function cleanOptionalStr(v: unknown, max: number): string | null { } function validTheme(v: unknown): v is string { - return v === "auto" || v === "light" || v === "dark"; + return v === "auto" || v === "light" || v === "dark" || v === "sunset"; } function validAvatar(v: unknown): string | null { @@ -633,7 +633,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ patch.avatarUrl = a; } if (body.theme !== undefined) { - if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400); + if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset" }, 400); patch.theme = body.theme; } if (body.email !== undefined) { @@ -763,7 +763,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ const avatarUrl = body.avatarUrl === undefined ? "" : validAvatar(body.avatarUrl); if (avatarUrl === null) return json({ error: "avatarUrl must be an http(s) URL or empty" }, 400); const theme = body.theme === undefined ? "auto" : body.theme; - if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark" }, 400); + if (!validTheme(theme)) return json({ error: "theme must be auto|light|dark|sunset" }, 400); const email = body.email === undefined ? "" : validEmail(body.email); if (email === null) return json({ error: "invalid email address" }, 400); const key = newAccessKey(); @@ -809,7 +809,7 @@ const server = Bun.serve<{ ticketId: string | null; isAdmin: boolean }>({ patch.avatarUrl = a; } if (body.theme !== undefined) { - if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark" }, 400); + if (!validTheme(body.theme)) return json({ error: "theme must be auto|light|dark|sunset|sunset" }, 400); patch.theme = body.theme; } if (body.email !== undefined) {