From 16d42daf5450ac364fcb6715d19d572a452061eb Mon Sep 17 00:00:00 2001
From: dirtydishes <35477874+dirtydishes@users.noreply.github.com>
Date: Tue, 6 Jan 2026 14:59:59 -0500
Subject: [PATCH] Add theme selector with persisted context
---
apps/web/app/globals.css | 34 ++++++++++++++++++++++
apps/web/app/layout.tsx | 5 +++-
apps/web/app/page.tsx | 33 +++++++++++++++++++---
apps/web/app/providers/theme.tsx | 48 ++++++++++++++++++++++++++++++++
4 files changed, 115 insertions(+), 5 deletions(-)
create mode 100644 apps/web/app/providers/theme.tsx
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 (
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;
+}