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 (
-
+
+
diff --git a/src/routes/_authenticated.subscribe.tsx b/src/routes/_authenticated.subscribe.tsx
index cd0b126..d665cb5 100644
--- a/src/routes/_authenticated.subscribe.tsx
+++ b/src/routes/_authenticated.subscribe.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AutumnProvider, useCustomer } from "autumn-js/react";
import { useEffect, useState } from "react";
import { User } from "lucide-react";
+import { ThemePreferenceMenuItems } from "@/client/components/ThemePreferenceMenuItems";
import { authClient, useSession } from "@/lib/auth-client";
import { getSignInHrefForLocation } from "@/lib/auth-redirect";
import { getStandardErrorMessage } from "@/client/lib/error-messages";
@@ -164,11 +165,7 @@ function SubscribePageContent() {
);
}
-function SubscribePageAccountMenu({
- email,
-}: {
- email: string | undefined;
-}) {
+function SubscribePageAccountMenu({ email }: { email: string | undefined }) {
if (!email) return null;
const handleSignOut = () => {
@@ -200,6 +197,7 @@ function SubscribePageAccountMenu({
{email}
+