diff --git a/apps/app/index.html b/apps/app/index.html index 6bf9f6378..3a63afc4c 100644 --- a/apps/app/index.html +++ b/apps/app/index.html @@ -14,6 +14,59 @@ + diff --git a/apps/app/index.tsx b/apps/app/index.tsx index ac7193de2..1802f3773 100644 --- a/apps/app/index.tsx +++ b/apps/app/index.tsx @@ -6,6 +6,7 @@ import { createRoot } from "react-dom/client"; import { NotFound } from "./components/not-found"; import { queryClient } from "./lib/query"; import { routeTree } from "./lib/routeTree.gen"; +import { ThemeProvider } from "./lib/theme"; import "./styles/globals.css"; const router = createRouter({ @@ -21,7 +22,9 @@ const root = createRoot(container!); root.render( - + + + {import.meta.env.DEV && ( +
{theme}
+
{preference}
+ + + + + ); +} + +describe("ThemeProvider", () => { + const storage = new Map(); + let originalMatchMedia: typeof window.matchMedia | undefined; + let originalLocalStorage: Storage; + + function createMediaQueryList(matches: boolean): MediaQueryList { + return { + matches, + media: "(prefers-color-scheme: dark)", + onchange: null, + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + addListener: vi.fn(), + removeListener: vi.fn(), + dispatchEvent: vi.fn(), + } as unknown as MediaQueryList; + } + + const localStorageMock: Storage = { + getItem: (key: string) => storage.get(key) ?? null, + setItem: (key: string, value: string) => { + storage.set(key, value); + }, + removeItem: (key: string) => { + storage.delete(key); + }, + clear: () => { + storage.clear(); + }, + key: (index: number) => { + const keys = Array.from(storage.keys()); + return keys[index] ?? null; + }, + get length() { + return storage.size; + }, + }; + + beforeEach(() => { + originalMatchMedia = window.matchMedia; + originalLocalStorage = window.localStorage; + + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue(createMediaQueryList(false)), + }); + + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: localStorageMock, + }); + + window.localStorage.clear(); + document.documentElement.classList.remove("dark"); + document.documentElement.dataset.themeColorLight = "#fafafa"; + document.documentElement.dataset.themeColorDark = "#0f0f0f"; + + const existingMeta = document.querySelector('meta[name="theme-color"]'); + if (!existingMeta) { + const meta = document.createElement("meta"); + meta.setAttribute("name", "theme-color"); + meta.setAttribute("content", "#fafafa"); + document.head.appendChild(meta); + } else { + existingMeta.setAttribute("content", "#fafafa"); + } + }); + + afterEach(() => { + cleanup(); + + if (originalMatchMedia) { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: originalMatchMedia, + }); + } + + Object.defineProperty(window, "localStorage", { + configurable: true, + writable: true, + value: originalLocalStorage, + }); + + delete document.documentElement.dataset.themeColorLight; + delete document.documentElement.dataset.themeColorDark; + + vi.restoreAllMocks(); + }); + + it("defaults to system preference when nothing is stored", () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue(createMediaQueryList(true)), + }); + + render( + + + , + ); + + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("system"); + }); + + it("uses persisted explicit preference", () => { + window.localStorage.setItem("app-theme-preference", "dark"); + + render( + + + , + ); + + expect(screen.getByTestId("preference")).toHaveTextContent("dark"); + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect( + document + .querySelector('meta[name="theme-color"]') + ?.getAttribute("content"), + ).toBe("#0f0f0f"); + }); + + it("migrates legacy app-theme values", () => { + window.localStorage.setItem("app-theme", "light"); + + render( + + + , + ); + + expect(screen.getByTestId("preference")).toHaveTextContent("light"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("light"); + }); + + it("updates preference and stores only the preference", () => { + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: "set-dark" })); + expect(screen.getByTestId("preference")).toHaveTextContent("dark"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("dark"); + expect( + document + .querySelector('meta[name="theme-color"]') + ?.getAttribute("content"), + ).toBe("#0f0f0f"); + + fireEvent.click(screen.getByRole("button", { name: "set-light" })); + expect(screen.getByTestId("preference")).toHaveTextContent("light"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("light"); + expect( + document + .querySelector('meta[name="theme-color"]') + ?.getAttribute("content"), + ).toBe("#fafafa"); + + fireEvent.click(screen.getByRole("button", { name: "set-system" })); + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("system"); + }); + + it("reacts to OS theme changes while preference is system", async () => { + const listeners = new Set<(event: MediaQueryListEvent) => void>(); + + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue({ + ...createMediaQueryList(false), + addEventListener: ( + _: string, + cb: (event: MediaQueryListEvent) => void, + ) => listeners.add(cb), + removeEventListener: ( + _: string, + cb: (event: MediaQueryListEvent) => void, + ) => listeners.delete(cb), + }), + }); + + render( + + + , + ); + + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("light"); + + listeners.forEach((listener) => + listener({ matches: true } as MediaQueryListEvent), + ); + + await waitFor(() => { + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark"); + expect(document.documentElement.classList.contains("dark")).toBe(true); + expect( + document + .querySelector('meta[name="theme-color"]') + ?.getAttribute("content"), + ).toBe("#0f0f0f"); + }); + }); + + it("syncs preference across tabs via storage events", async () => { + render( + + + , + ); + + const event = new Event("storage"); + Object.defineProperty(event, "key", { value: "app-theme-preference" }); + Object.defineProperty(event, "newValue", { value: "dark" }); + window.dispatchEvent(event); + + await waitFor(() => { + expect(screen.getByTestId("preference")).toHaveTextContent("dark"); + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark"); + }); + }); + + it("does not overwrite preference with resolved system theme", () => { + Object.defineProperty(window, "matchMedia", { + configurable: true, + writable: true, + value: vi.fn().mockReturnValue(createMediaQueryList(true)), + }); + + render( + + + , + ); + + expect(screen.getByTestId("preference")).toHaveTextContent("system"); + expect(screen.getByTestId("resolved-theme")).toHaveTextContent("dark"); + expect(window.localStorage.getItem("app-theme-preference")).toBe("system"); + }); +}); diff --git a/apps/app/lib/theme.tsx b/apps/app/lib/theme.tsx new file mode 100644 index 000000000..7a978ddf9 --- /dev/null +++ b/apps/app/lib/theme.tsx @@ -0,0 +1,189 @@ +import { + createContext, + type ReactNode, + use, + useEffect, + useLayoutEffect, + useMemo, + useState, +} from "react"; + +type Theme = "light" | "dark"; +export type ThemePreference = Theme | "system"; + +interface ThemeContextValue { + theme: Theme; + preference: ThemePreference; + setPreference: (preference: ThemePreference) => void; +} + +const ThemeContext = createContext(null); + +const STORAGE_KEY = "app-theme-preference"; +const LEGACY_STORAGE_KEY = "app-theme"; +const FALLBACK_LIGHT_THEME_COLOR = "#fafafa"; +const FALLBACK_DARK_THEME_COLOR = "#0f0f0f"; + +function getThemeColor(theme: Theme): string { + const rootDataset = document.documentElement.dataset; + const light = rootDataset.themeColorLight || FALLBACK_LIGHT_THEME_COLOR; + const dark = rootDataset.themeColorDark || FALLBACK_DARK_THEME_COLOR; + + return theme === "dark" ? dark : light; +} + +// Keep this runtime resolution in sync with the inline bootstrap script in index.html. + +function getSystemTheme(): Theme { + if (typeof window === "undefined") return "light"; + + if (window.matchMedia?.("(prefers-color-scheme: dark)").matches) { + return "dark"; + } + + return "light"; +} + +function parseThemePreference(value: string | null): ThemePreference | null { + if (value === "light" || value === "dark" || value === "system") { + return value; + } + + return null; +} + +function getInitialPreference(): ThemePreference { + if (typeof window === "undefined") return "system"; + + try { + const storedPreference = parseThemePreference( + window.localStorage.getItem(STORAGE_KEY), + ); + if (storedPreference) { + return storedPreference; + } + + const legacyTheme = parseThemePreference( + window.localStorage.getItem(LEGACY_STORAGE_KEY), + ); + if (legacyTheme === "light" || legacyTheme === "dark") { + return legacyTheme; + } + } catch { + return "system"; + } + + return "system"; +} + +function resolveTheme(preference: ThemePreference, systemTheme: Theme): Theme { + if (preference === "system") { + return systemTheme; + } + + return preference; +} + +function setThemeColorMeta(theme: Theme) { + const meta = document.querySelector('meta[name="theme-color"]'); + if (!meta) return; + meta.setAttribute("content", getThemeColor(theme)); +} + +function applyThemeWithoutTransitions(theme: Theme) { + const root = document.documentElement; + const style = document.createElement("style"); + style.textContent = "*{transition:none!important}"; + document.head.appendChild(style); + + if (theme === "dark") { + root.classList.add("dark"); + } else { + root.classList.remove("dark"); + } + + setThemeColorMeta(theme); + + requestAnimationFrame(() => { + requestAnimationFrame(() => { + style.remove(); + }); + }); +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [preference, setPreference] = + useState(getInitialPreference); + const [systemTheme, setSystemTheme] = useState(getSystemTheme); + const theme = useMemo( + () => resolveTheme(preference, systemTheme), + [preference, systemTheme], + ); + + useLayoutEffect(() => { + applyThemeWithoutTransitions(theme); + }, [theme]); + + useEffect(() => { + const media = window.matchMedia?.("(prefers-color-scheme: dark)"); + if (!media) return; + + const onChange = (event: MediaQueryListEvent) => { + setSystemTheme(event.matches ? "dark" : "light"); + }; + + media.addEventListener("change", onChange); + return () => { + media.removeEventListener("change", onChange); + }; + }, []); + + useEffect(() => { + try { + window.localStorage.setItem(STORAGE_KEY, preference); + } catch { + // Ignore storage write failures (e.g., private browsing mode). + } + }, [preference]); + + useEffect(() => { + const onStorage = (event: StorageEvent) => { + if (event.key !== STORAGE_KEY && event.key !== LEGACY_STORAGE_KEY) return; + + if (event.key === STORAGE_KEY) { + const nextPreference = parseThemePreference(event.newValue) ?? "system"; + setPreference(nextPreference); + return; + } + + const legacyTheme = parseThemePreference(event.newValue); + if (legacyTheme === "light" || legacyTheme === "dark") { + setPreference(legacyTheme); + } + }; + + window.addEventListener("storage", onStorage); + return () => { + window.removeEventListener("storage", onStorage); + }; + }, []); + + const value = useMemo( + () => ({ + theme, + preference, + setPreference, + }), + [theme, preference], + ); + + return {children}; +} + +export function useTheme() { + const ctx = use(ThemeContext); + if (!ctx) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return ctx; +} diff --git a/apps/app/routes/(app)/settings.tsx b/apps/app/routes/(app)/settings.tsx index 7604fb834..015ec5338 100644 --- a/apps/app/routes/(app)/settings.tsx +++ b/apps/app/routes/(app)/settings.tsx @@ -1,3 +1,7 @@ +import { auth } from "@/lib/auth"; +import { useBillingQuery } from "@/lib/queries/billing"; +import { useSessionQuery } from "@/lib/queries/session"; +import { type ThemePreference, useTheme } from "@/lib/theme"; import { Button, Card, @@ -10,17 +14,80 @@ import { Separator, Switch, } from "@repo/ui"; +import { useCallback, useId, useRef } from "react"; import { createFileRoute } from "@tanstack/react-router"; -import { Bell, CreditCard, Palette, Shield, User } from "lucide-react"; -import { auth } from "@/lib/auth"; -import { useBillingQuery } from "@/lib/queries/billing"; -import { useSessionQuery } from "@/lib/queries/session"; +import { + Bell, + CreditCard, + Monitor, + Moon, + Palette, + Shield, + Sun, + User, +} from "lucide-react"; export const Route = createFileRoute("/(app)/settings")({ component: Settings, }); +const THEME_OPTIONS: Array<{ + value: ThemePreference; + label: string; + icon: typeof Sun; +}> = [ + { value: "light", label: "Light", icon: Sun }, + { value: "dark", label: "Dark", icon: Moon }, + { value: "system", label: "System", icon: Monitor }, +]; + function Settings() { + const { preference, setPreference } = useTheme(); + const themeOptionRef = useRef>([]); + const themeLabelId = useId(); + + const setOptionRef = useCallback( + (index: number, el: HTMLButtonElement | null) => { + themeOptionRef.current[index] = el; + }, + [], + ); + + const handleThemeKeyDown = useCallback( + (event: React.KeyboardEvent, index: number) => { + const isNextKey = event.key === "ArrowRight" || event.key === "ArrowDown"; + const isPrevKey = event.key === "ArrowLeft" || event.key === "ArrowUp"; + + if ( + !isNextKey && + !isPrevKey && + event.key !== "Home" && + event.key !== "End" + ) { + return; + } + + event.preventDefault(); + + let nextIndex = index; + + if (isNextKey) { + nextIndex = (index + 1) % THEME_OPTIONS.length; + } else if (isPrevKey) { + nextIndex = (index - 1 + THEME_OPTIONS.length) % THEME_OPTIONS.length; + } else if (event.key === "Home") { + nextIndex = 0; + } else if (event.key === "End") { + nextIndex = THEME_OPTIONS.length - 1; + } + + const nextOption = THEME_OPTIONS[nextIndex]; + setPreference(nextOption.value); + themeOptionRef.current[nextIndex]?.focus(); + }, + [setPreference], + ); + return (
@@ -129,12 +196,44 @@ function Settings() {
- +

- Toggle dark mode theme + Choose light, dark, or follow your OS setting.

- +
+ {THEME_OPTIONS.map((option, index) => { + const Icon = option.icon; + const isSelected = preference === option.value; + + return ( + + ); + })} +
diff --git a/vitest.config.ts b/vitest.config.ts index 65862d26e..63da3d692 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -8,6 +8,6 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ cacheDir: "./.cache/vite", test: { - projects: ["apps/api", "apps/app"], + projects: ["apps/api/vitest.config.ts", "apps/app/vite.config.ts"], }, });