From 3081bc624a83b7c76d22d8f9f664068f6a5e72e3 Mon Sep 17 00:00:00 2001 From: Ben Senescu <44480372+bensenescu@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:58:49 -0400 Subject: [PATCH] feat: add theme switcher with system/light/dark segmented control (#75) * feat: add theme switcher with system/light/dark segmented control Add a three-way theme toggle (system, light, dark) as a segmented icon control in the account dropdown menu. Rename placeholder daisyUI theme names from "todo" to "openseo". * refactor: always resolve data-theme explicitly, remove duplicated dark CSS Instead of removing data-theme in "system" mode and relying on @media (prefers-color-scheme: dark) CSS, always resolve to an explicit theme name via matchMedia. This eliminates the duplicated dark-mode CSS overrides. --- .../components/ThemePreferenceMenuItems.tsx | 60 ++++++++ src/client/layout/AppShell.tsx | 61 ++++---- src/client/lib/theme.ts | 133 ++++++++++++++++++ src/client/styles/app.css | 112 ++++++++------- src/routes/__root.tsx | 6 +- src/routes/_authenticated.subscribe.tsx | 8 +- 6 files changed, 286 insertions(+), 94 deletions(-) create mode 100644 src/client/components/ThemePreferenceMenuItems.tsx create mode 100644 src/client/lib/theme.ts diff --git a/src/client/components/ThemePreferenceMenuItems.tsx b/src/client/components/ThemePreferenceMenuItems.tsx new file mode 100644 index 0000000..e1f0346 --- /dev/null +++ b/src/client/components/ThemePreferenceMenuItems.tsx @@ -0,0 +1,60 @@ +import { Monitor, Moon, Sun } from "lucide-react"; +import { type ThemePreference, useThemePreference } from "@/client/lib/theme"; + +const THEME_OPTIONS: { + value: ThemePreference; + label: string; + icon: typeof Sun; +}[] = [ + { value: "system", label: "System", icon: Monitor }, + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, +]; + +export function ThemePreferenceMenuItems() { + const { themePreference, setThemePreference } = useThemePreference(); + + return ( + <> +
  • + Theme +
  • + +
  • +
    + {THEME_OPTIONS.map((option) => { + const isActive = option.value === themePreference; + const Icon = option.icon; + + return ( +
    + +
    + ); + })} +
    +
  • + + ); +} diff --git a/src/client/layout/AppShell.tsx b/src/client/layout/AppShell.tsx index ef6f8b7..7e3d3a2 100644 --- a/src/client/layout/AppShell.tsx +++ b/src/client/layout/AppShell.tsx @@ -6,6 +6,7 @@ import { MissingSeoSetupModal, SeoApiStatusBanners, } from "@/client/layout/AppShellParts"; +import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems"; import { getProjectNavItems } from "@/client/navigation/items"; import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { authClient, useSession } from "@/lib/auth-client"; @@ -208,26 +209,19 @@ function TopNav({ - + - + ); } -function HostedSessionActions({ - mobileOnly = false, -}: { - mobileOnly?: boolean; -}) { +function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) { const { data: session } = useSession(); const isHostedMode = isHostedClientAuthMode(); - - if (!isHostedMode || !session?.user?.email) { - return null; - } + const email = session?.user?.email; const handleSignOut = () => { const signInHref = getSignInHrefForLocation(window.location); @@ -255,26 +249,31 @@ function HostedSessionActions({ tabIndex={0} className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg" > -
  • - - {session.user.email} - -
  • -
  • - - - Billing - -
  • -
  • - -
  • + {email ? ( +
  • + {email} +
  • + ) : null} + + {isHostedMode ? ( +
  • + + + Billing + +
  • + ) : null} + {isHostedMode && email ? ( +
  • + +
  • + ) : null} diff --git a/src/client/lib/theme.ts b/src/client/lib/theme.ts new file mode 100644 index 0000000..e59012d --- /dev/null +++ b/src/client/lib/theme.ts @@ -0,0 +1,133 @@ +import * as React from "react"; + +export type ThemePreference = "system" | "light" | "dark"; + +const LIGHT_THEME_NAME = "openseo"; +const DARK_THEME_NAME = "openseo-dark"; + +const THEME_STORAGE_KEY = "theme-preference"; +const THEME_CHANGE_EVENT = "theme-preference-change"; + +function readThemePreference(): ThemePreference { + if (typeof window === "undefined") { + return "system"; + } + + try { + const stored = window.localStorage.getItem(THEME_STORAGE_KEY); + if (stored === "light" || stored === "dark") { + return stored; + } + return "system"; + } catch { + return "system"; + } +} + +function writeThemePreference(themePreference: ThemePreference) { + try { + if (themePreference === "system") { + window.localStorage.removeItem(THEME_STORAGE_KEY); + } else { + window.localStorage.setItem(THEME_STORAGE_KEY, themePreference); + } + } catch { + // localStorage can be unavailable in private browsing or strict browser modes. + } +} + +function resolveThemeName(themePreference: ThemePreference): string { + if (themePreference === "light") return LIGHT_THEME_NAME; + if (themePreference === "dark") return DARK_THEME_NAME; + + // "system" — resolve from OS preference + if ( + typeof window !== "undefined" && + window.matchMedia("(prefers-color-scheme: dark)").matches + ) { + return DARK_THEME_NAME; + } + return LIGHT_THEME_NAME; +} + +function applyThemePreference(themePreference: ThemePreference) { + if (typeof document === "undefined") { + return; + } + + document.documentElement.setAttribute( + "data-theme", + resolveThemeName(themePreference), + ); +} + +function subscribeToThemePreference(onStoreChange: () => void) { + if (typeof window === "undefined") { + return () => {}; + } + + const handleThemeChange = () => { + onStoreChange(); + }; + + const handleStorage = (event: StorageEvent) => { + if (event.key && event.key !== THEME_STORAGE_KEY) { + return; + } + + onStoreChange(); + }; + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + const handleMediaChange = () => { + // Re-apply when OS preference changes so "system" mode stays in sync + applyThemePreference(readThemePreference()); + onStoreChange(); + }; + + window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange); + window.addEventListener("storage", handleStorage); + mediaQuery.addEventListener("change", handleMediaChange); + + return () => { + window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange); + window.removeEventListener("storage", handleStorage); + mediaQuery.removeEventListener("change", handleMediaChange); + }; +} + +export function useThemePreference() { + const themePreference = React.useSyncExternalStore( + subscribeToThemePreference, + readThemePreference, + () => "system", + ); + + React.useEffect(() => { + applyThemePreference(themePreference); + }, [themePreference]); + + const setThemePreference = React.useCallback( + (nextThemePreference: ThemePreference) => { + writeThemePreference(nextThemePreference); + applyThemePreference(nextThemePreference); + window.dispatchEvent(new Event(THEME_CHANGE_EVENT)); + }, + [], + ); + + return { themePreference, setThemePreference }; +} + +export const themePreferenceInitScript = `(() => { + try { + var p = window.localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)}); + var t; + if (p === "light") t = ${JSON.stringify(LIGHT_THEME_NAME)}; + else if (p === "dark") t = ${JSON.stringify(DARK_THEME_NAME)}; + else t = window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(DARK_THEME_NAME)} : ${JSON.stringify(LIGHT_THEME_NAME)}; + document.documentElement.setAttribute("data-theme", t); + } catch { + document.documentElement.setAttribute("data-theme", ${JSON.stringify(LIGHT_THEME_NAME)}); + } +})();`; diff --git a/src/client/styles/app.css b/src/client/styles/app.css index 9412161..afce82d 100644 --- a/src/client/styles/app.css +++ b/src/client/styles/app.css @@ -22,7 +22,7 @@ /* Light theme */ @plugin "daisyui/theme" { - name: "todo"; + name: "openseo"; default: true; prefersdark: false; color-scheme: light; @@ -58,7 +58,7 @@ /* Dark theme - auto-activates via prefers-color-scheme */ @plugin "daisyui/theme" { - name: "todo-dark"; + name: "openseo-dark"; prefersdark: true; color-scheme: dark; --color-base-100: oklch(18% 0 0); @@ -198,59 +198,57 @@ select { background-color: #dc2626; } -@media (prefers-color-scheme: dark) { - :root { - --trend-grid-color: oklch(78% 0.01 255 / 0.22); - --trend-axis-color: oklch(84% 0.01 255 / 0.62); - --trend-fill-start-opacity: 0.24; - --trend-fill-end-opacity: 0.03; - --trend-tooltip-bg: oklch(21% 0.01 255 / 0.95); - --trend-tooltip-border: oklch(40% 0.01 255 / 0.65); - --trend-tooltip-shadow: oklch(0% 0 0 / 0.35); - } - - .score-badge { - color: oklch(96% 0.01 95); - filter: saturate(1.26) brightness(1.14); - } - - .score-tier-1 { - background-color: oklch(58% 0.12 148); - } - - .score-tier-2 { - background-color: oklch(60% 0.115 132); - } - - .score-tier-3 { - background-color: oklch(66% 0.11 92); - } - - .score-tier-4 { - background-color: oklch(64% 0.12 56); - } - - .score-tier-5 { - background-color: oklch(62% 0.12 36); - } - - .score-tier-6 { - background-color: oklch(58% 0.11 30); - } - - .alert-warning { - @apply border-warning/60 bg-warning/20; - } - - .alert-info { - @apply border-info/60 bg-info/20; - } - - .alert-success { - @apply border-success/60 bg-success/20; - } - - .alert-error { - @apply border-error/60 bg-error/20; - } +html[data-theme="openseo-dark"] { + --trend-grid-color: oklch(78% 0.01 255 / 0.22); + --trend-axis-color: oklch(84% 0.01 255 / 0.62); + --trend-fill-start-opacity: 0.24; + --trend-fill-end-opacity: 0.03; + --trend-tooltip-bg: oklch(21% 0.01 255 / 0.95); + --trend-tooltip-border: oklch(40% 0.01 255 / 0.65); + --trend-tooltip-shadow: oklch(0% 0 0 / 0.35); +} + +html[data-theme="openseo-dark"] .score-badge { + color: oklch(96% 0.01 95); + filter: saturate(1.26) brightness(1.14); +} + +html[data-theme="openseo-dark"] .score-tier-1 { + background-color: oklch(58% 0.12 148); +} + +html[data-theme="openseo-dark"] .score-tier-2 { + background-color: oklch(60% 0.115 132); +} + +html[data-theme="openseo-dark"] .score-tier-3 { + background-color: oklch(66% 0.11 92); +} + +html[data-theme="openseo-dark"] .score-tier-4 { + background-color: oklch(64% 0.12 56); +} + +html[data-theme="openseo-dark"] .score-tier-5 { + background-color: oklch(62% 0.12 36); +} + +html[data-theme="openseo-dark"] .score-tier-6 { + background-color: oklch(58% 0.11 30); +} + +html[data-theme="openseo-dark"] .alert-warning { + @apply border-warning/60 bg-warning/20; +} + +html[data-theme="openseo-dark"] .alert-info { + @apply border-info/60 bg-info/20; +} + +html[data-theme="openseo-dark"] .alert-success { + @apply border-success/60 bg-success/20; +} + +html[data-theme="openseo-dark"] .alert-error { + @apply border-error/60 bg-error/20; } diff --git a/src/routes/__root.tsx b/src/routes/__root.tsx index 62058e5..a45ba7d 100644 --- a/src/routes/__root.tsx +++ b/src/routes/__root.tsx @@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools"; import { QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; +import { themePreferenceInitScript } from "@/client/lib/theme"; import { initPostHog } from "@/client/lib/posthog"; import { NotFound } from "@/client/components/NotFound"; import appCss from "@/client/styles/app.css?url"; @@ -93,8 +94,11 @@ function RootDocument({ children }: { children: React.ReactNode }) { import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS !== "false"; return ( - + +