diff --git a/apps/web/app/globals.css b/apps/web/app/globals.css index 471b404..d08d313 100644 --- a/apps/web/app/globals.css +++ b/apps/web/app/globals.css @@ -78,6 +78,40 @@ h1 { min-width: 220px; } +.header-controls { + display: flex; + gap: 12px; + align-items: center; + justify-content: flex-end; +} + +.theme-picker { + display: flex; + align-items: center; + gap: 8px; +} + +.theme-label { + font-size: 0.75rem; + letter-spacing: 0.12em; + text-transform: uppercase; + color: #6f5b39; +} + +.theme-select { + border: 1px solid rgba(111, 91, 57, 0.35); + border-radius: 12px; + padding: 6px 10px; + background: #fffdf7; + color: #1d1d1b; + font-size: 0.9rem; +} + +.theme-select:focus-visible { + outline: 2px solid rgba(47, 109, 79, 0.3); + outline-offset: 2px; +} + .filter-bar { display: flex; align-items: center; diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index c27753d..482e26d 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,5 +1,6 @@ import "./globals.css"; import type { ReactNode } from "react"; +import { ThemeProvider } from "./providers/theme"; export const metadata = { title: "Islandflow", @@ -13,7 +14,9 @@ type RootLayoutProps = { export default function RootLayout({ children }: RootLayoutProps) { return ( - {children} + + {children} + ); } diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 9da86ff..b1f736f 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -1,6 +1,14 @@ "use client"; -import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type ChangeEvent +} from "react"; import type { AlertEvent, ClassifierHitEvent, @@ -11,6 +19,7 @@ import type { OptionNBBO, OptionPrint } from "@islandflow/types"; +import { useTheme, type Theme } from "./providers/theme"; const MAX_ITEMS = 500; const NBBO_MAX_AGE_MS = Number(process.env.NEXT_PUBLIC_NBBO_MAX_AGE_MS); @@ -1503,6 +1512,7 @@ export default function HomePage() { const [selectedAlert, setSelectedAlert] = useState(null); const [selectedDarkEvent, setSelectedDarkEvent] = useState(null); const [filterInput, setFilterInput] = useState(""); + const { theme, setTheme } = useTheme(); const optionsScroll = useListScroll(); const equitiesScroll = useListScroll(); const flowScroll = useListScroll(); @@ -1874,6 +1884,11 @@ export default function HomePage() { setMode((prev) => (prev === "live" ? "replay" : "live")); }; + const handleThemeChange = (event: ChangeEvent) => { + const nextTheme = event.target.value as Theme; + setTheme(nextTheme); + }; + return (
@@ -1889,9 +1904,19 @@ export default function HomePage() { {lastSeen ? formatTime(lastSeen) : "Waiting for data"} - +
+ + +
diff --git a/apps/web/app/providers/theme.tsx b/apps/web/app/providers/theme.tsx new file mode 100644 index 0000000..3ebbcb7 --- /dev/null +++ b/apps/web/app/providers/theme.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { createContext, useContext, useEffect, useMemo, useState, type ReactNode } from "react"; + +const STORAGE_KEY = "theme"; + +export type Theme = "default" | "bbg" | "system"; + +type ThemeContextValue = { + theme: Theme; + setTheme: (theme: Theme) => void; +}; + +const isTheme = (value: string | null): value is Theme => { + return value === "default" || value === "bbg" || value === "system"; +}; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState("default"); + + useEffect(() => { + const stored = localStorage.getItem(STORAGE_KEY); + if (isTheme(stored)) { + setTheme(stored); + } else if (stored !== null && !isTheme(stored)) { + localStorage.removeItem(STORAGE_KEY); + } + }, []); + + useEffect(() => { + document.documentElement.dataset.theme = theme; + localStorage.setItem(STORAGE_KEY, theme); + }, [theme]); + + const value = useMemo(() => ({ theme, setTheme }), [theme]); + + return {children}; +} + +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +}