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.
This commit is contained in:
Ben Senescu 2026-04-04 15:58:49 -04:00 committed by Ben Senescu
parent bd10955a8d
commit 3081bc624a
6 changed files with 286 additions and 94 deletions

View File

@ -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 (
<>
<li className="menu-title pt-2">
<span>Theme</span>
</li>
<li>
<div
role="radiogroup"
aria-label="Theme preference"
className="flex gap-0.5 rounded-lg bg-base-200 p-0.5"
>
{THEME_OPTIONS.map((option) => {
const isActive = option.value === themePreference;
const Icon = option.icon;
return (
<div
key={option.value}
className="tooltip tooltip-bottom flex flex-1 before:whitespace-nowrap"
data-tip={option.label}
>
<button
type="button"
role="radio"
aria-checked={isActive}
aria-label={option.label}
className={`flex flex-1 items-center justify-center rounded-md px-2.5 py-1.5 transition-colors ${
isActive
? "bg-base-100 text-base-content shadow-sm"
: "text-base-content/50 hover:text-base-content/80"
}`}
onClick={() => setThemePreference(option.value)}
>
<Icon className="size-4" />
</button>
</div>
);
})}
</div>
</li>
</>
);
}

View File

@ -6,6 +6,7 @@ import {
MissingSeoSetupModal, MissingSeoSetupModal,
SeoApiStatusBanners, SeoApiStatusBanners,
} from "@/client/layout/AppShellParts"; } from "@/client/layout/AppShellParts";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { getProjectNavItems } from "@/client/navigation/items"; import { getProjectNavItems } from "@/client/navigation/items";
import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { getSignInHrefForLocation } from "@/lib/auth-redirect";
import { authClient, useSession } from "@/lib/auth-client"; import { authClient, useSession } from "@/lib/auth-client";
@ -208,26 +209,19 @@ function TopNav({
</button> </button>
</div> </div>
<HostedSessionActions /> <AccountMenu />
</div> </div>
</div> </div>
<HostedSessionActions mobileOnly /> <AccountMenu mobileOnly />
</div> </div>
); );
} }
function HostedSessionActions({ function AccountMenu({ mobileOnly = false }: { mobileOnly?: boolean }) {
mobileOnly = false,
}: {
mobileOnly?: boolean;
}) {
const { data: session } = useSession(); const { data: session } = useSession();
const isHostedMode = isHostedClientAuthMode(); const isHostedMode = isHostedClientAuthMode();
const email = session?.user?.email;
if (!isHostedMode || !session?.user?.email) {
return null;
}
const handleSignOut = () => { const handleSignOut = () => {
const signInHref = getSignInHrefForLocation(window.location); const signInHref = getSignInHrefForLocation(window.location);
@ -255,26 +249,31 @@ function HostedSessionActions({
tabIndex={0} 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" className="dropdown-content z-20 menu mt-3 min-w-56 rounded-box border border-base-300 bg-base-100 p-2 shadow-lg"
> >
<li className="menu-title max-w-full"> {email ? (
<span className="truncate text-base-content"> <li className="menu-title max-w-full">
{session.user.email} <span className="truncate text-base-content">{email}</span>
</span> </li>
</li> ) : null}
<li> <ThemePreferenceMenuItems />
<a href={BILLING_ROUTE} className="flex items-center gap-2"> {isHostedMode ? (
<CreditCard className="h-4 w-4" /> <li>
Billing <a href={BILLING_ROUTE} className="flex items-center gap-2">
</a> <CreditCard className="h-4 w-4" />
</li> Billing
<li> </a>
<button </li>
type="button" ) : null}
className="text-error" {isHostedMode && email ? (
onClick={handleSignOut} <li>
> <button
Sign out type="button"
</button> className="text-error"
</li> onClick={handleSignOut}
>
Sign out
</button>
</li>
) : null}
</ul> </ul>
</div> </div>
</div> </div>

133
src/client/lib/theme.ts Normal file
View File

@ -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<ThemePreference>(
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)});
}
})();`;

View File

@ -22,7 +22,7 @@
/* Light theme */ /* Light theme */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "todo"; name: "openseo";
default: true; default: true;
prefersdark: false; prefersdark: false;
color-scheme: light; color-scheme: light;
@ -58,7 +58,7 @@
/* Dark theme - auto-activates via prefers-color-scheme */ /* Dark theme - auto-activates via prefers-color-scheme */
@plugin "daisyui/theme" { @plugin "daisyui/theme" {
name: "todo-dark"; name: "openseo-dark";
prefersdark: true; prefersdark: true;
color-scheme: dark; color-scheme: dark;
--color-base-100: oklch(18% 0 0); --color-base-100: oklch(18% 0 0);
@ -198,59 +198,57 @@ select {
background-color: #dc2626; background-color: #dc2626;
} }
@media (prefers-color-scheme: dark) { html[data-theme="openseo-dark"] {
:root { --trend-grid-color: oklch(78% 0.01 255 / 0.22);
--trend-grid-color: oklch(78% 0.01 255 / 0.22); --trend-axis-color: oklch(84% 0.01 255 / 0.62);
--trend-axis-color: oklch(84% 0.01 255 / 0.62); --trend-fill-start-opacity: 0.24;
--trend-fill-start-opacity: 0.24; --trend-fill-end-opacity: 0.03;
--trend-fill-end-opacity: 0.03; --trend-tooltip-bg: oklch(21% 0.01 255 / 0.95);
--trend-tooltip-bg: oklch(21% 0.01 255 / 0.95); --trend-tooltip-border: oklch(40% 0.01 255 / 0.65);
--trend-tooltip-border: oklch(40% 0.01 255 / 0.65); --trend-tooltip-shadow: oklch(0% 0 0 / 0.35);
--trend-tooltip-shadow: oklch(0% 0 0 / 0.35); }
}
html[data-theme="openseo-dark"] .score-badge {
.score-badge { color: oklch(96% 0.01 95);
color: oklch(96% 0.01 95); filter: saturate(1.26) brightness(1.14);
filter: saturate(1.26) brightness(1.14); }
}
html[data-theme="openseo-dark"] .score-tier-1 {
.score-tier-1 { background-color: oklch(58% 0.12 148);
background-color: oklch(58% 0.12 148); }
}
html[data-theme="openseo-dark"] .score-tier-2 {
.score-tier-2 { background-color: oklch(60% 0.115 132);
background-color: oklch(60% 0.115 132); }
}
html[data-theme="openseo-dark"] .score-tier-3 {
.score-tier-3 { background-color: oklch(66% 0.11 92);
background-color: oklch(66% 0.11 92); }
}
html[data-theme="openseo-dark"] .score-tier-4 {
.score-tier-4 { background-color: oklch(64% 0.12 56);
background-color: oklch(64% 0.12 56); }
}
html[data-theme="openseo-dark"] .score-tier-5 {
.score-tier-5 { background-color: oklch(62% 0.12 36);
background-color: oklch(62% 0.12 36); }
}
html[data-theme="openseo-dark"] .score-tier-6 {
.score-tier-6 { background-color: oklch(58% 0.11 30);
background-color: oklch(58% 0.11 30); }
}
html[data-theme="openseo-dark"] .alert-warning {
.alert-warning { @apply border-warning/60 bg-warning/20;
@apply border-warning/60 bg-warning/20; }
}
html[data-theme="openseo-dark"] .alert-info {
.alert-info { @apply border-info/60 bg-info/20;
@apply border-info/60 bg-info/20; }
}
html[data-theme="openseo-dark"] .alert-success {
.alert-success { @apply border-success/60 bg-success/20;
@apply border-success/60 bg-success/20; }
}
html[data-theme="openseo-dark"] .alert-error {
.alert-error { @apply border-error/60 bg-error/20;
@apply border-error/60 bg-error/20;
}
} }

View File

@ -11,6 +11,7 @@ import { TanStackDevtools } from "@tanstack/react-devtools";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
import * as React from "react"; import * as React from "react";
import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary"; import { DefaultCatchBoundary } from "@/client/components/DefaultCatchBoundary";
import { themePreferenceInitScript } from "@/client/lib/theme";
import { initPostHog } from "@/client/lib/posthog"; import { initPostHog } from "@/client/lib/posthog";
import { NotFound } from "@/client/components/NotFound"; import { NotFound } from "@/client/components/NotFound";
import appCss from "@/client/styles/app.css?url"; 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"; import.meta.env.DEV && import.meta.env.VITE_SHOW_DEVTOOLS !== "false";
return ( return (
<html> <html suppressHydrationWarning>
<head> <head>
<script
dangerouslySetInnerHTML={{ __html: themePreferenceInitScript }}
/>
<HeadContent /> <HeadContent />
</head> </head>
<body> <body>

View File

@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react"; import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { User } from "lucide-react"; import { User } from "lucide-react";
import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { authClient, useSession } from "@/lib/auth-client"; import { authClient, useSession } from "@/lib/auth-client";
import { getSignInHrefForLocation } from "@/lib/auth-redirect"; import { getSignInHrefForLocation } from "@/lib/auth-redirect";
import { getStandardErrorMessage } from "@/client/lib/error-messages"; import { getStandardErrorMessage } from "@/client/lib/error-messages";
@ -164,11 +165,7 @@ function SubscribePageContent() {
); );
} }
function SubscribePageAccountMenu({ function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
email,
}: {
email: string | undefined;
}) {
if (!email) return null; if (!email) return null;
const handleSignOut = () => { const handleSignOut = () => {
@ -200,6 +197,7 @@ function SubscribePageAccountMenu({
<li className="menu-title max-w-full"> <li className="menu-title max-w-full">
<span className="truncate text-base-content">{email}</span> <span className="truncate text-base-content">{email}</span>
</li> </li>
<ThemePreferenceMenuItems />
<li> <li>
<button <button
type="button" type="button"