From 530cf6cddca25d6f5a77e94d215da47982865d18 Mon Sep 17 00:00:00 2001 From: Johnny Bouder Date: Fri, 10 Jul 2026 06:59:35 -0400 Subject: [PATCH] Support dark mode toggling. --- .../Nebari-Logo-Horizontal-Lockup-Dark.svg | 1 + frontend/index.html | 14 ++++ frontend/src/chat/chatinput.tsx | 2 +- frontend/src/chat/sidebartools.tsx | 12 ++- frontend/src/hooks/themecontext.tsx | 28 +++++++ frontend/src/hooks/uselocalstoragestate.ts | 49 ++++++++++++ frontend/src/hooks/usethemepreference.ts | 75 +++++++++++++++++++ frontend/src/main.css | 65 +++++++++++----- frontend/src/main.tsx | 11 ++- frontend/src/sidebar/header.tsx | 1 + frontend/src/sidebar/userprofile.tsx | 66 +++++++++++++++- 11 files changed, 296 insertions(+), 28 deletions(-) create mode 100644 frontend/assets/Nebari-Logo-Horizontal-Lockup-Dark.svg create mode 100644 frontend/src/hooks/themecontext.tsx create mode 100644 frontend/src/hooks/uselocalstoragestate.ts create mode 100644 frontend/src/hooks/usethemepreference.ts diff --git a/frontend/assets/Nebari-Logo-Horizontal-Lockup-Dark.svg b/frontend/assets/Nebari-Logo-Horizontal-Lockup-Dark.svg new file mode 100644 index 0000000..a920e49 --- /dev/null +++ b/frontend/assets/Nebari-Logo-Horizontal-Lockup-Dark.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/index.html b/frontend/index.html index 2f9bb6e..3731723 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -8,6 +8,20 @@ Nebari Chat + diff --git a/frontend/src/chat/chatinput.tsx b/frontend/src/chat/chatinput.tsx index cda5992..355daff 100644 --- a/frontend/src/chat/chatinput.tsx +++ b/frontend/src/chat/chatinput.tsx @@ -283,7 +283,7 @@ export function ChatInput(): ReactNode { return (
{ try { @@ -102,6 +107,7 @@ namespace Private { return ( void; +}; + +const ThemeContext = createContext(null); + +export function ThemeProvider({ children }: { children: ReactNode }) { + const theme = useThemePreference(); + return ( + {children} + ); +} + +export function useTheme(): ThemeContextValue { + const ctx = useContext(ThemeContext); + if (!ctx) throw new Error('useTheme must be used within ThemeProvider'); + return ctx; +} diff --git a/frontend/src/hooks/uselocalstoragestate.ts b/frontend/src/hooks/uselocalstoragestate.ts new file mode 100644 index 0000000..7b5da10 --- /dev/null +++ b/frontend/src/hooks/uselocalstoragestate.ts @@ -0,0 +1,49 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) 2025-present, OpenTeams Inc. +|----------------------------------------------------------------------------*/ + +import { useEffect, useState } from 'react'; + +/** + * Read a value from localStorage, returning null when storage is unavailable + * (private browsing, disabled, security errors) instead of throwing. + */ +export function getStoredValue(key: string): string | null { + try { + return localStorage.getItem(key); + } catch { + return null; + } +} + +/** + * Write a value to localStorage, swallowing errors when storage is unavailable + * or the quota is exceeded. + */ +export function setStoredValue(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch { + console.error(`Failed to persist "${key}" to localStorage`); + } +} + +/** + * State persisted to localStorage as a plain string. Reads and writes are + * guarded so disabled or unavailable storage never throws. + * + * `deserialize` turns the raw stored string (or null when absent) into the + * initial value — also the place to validate and run one-off migrations. + */ +export function useLocalStorageState( + key: string, + deserialize: (raw: string | null) => T, +): [T, (value: T) => void] { + const [value, setValue] = useState(() => deserialize(getStoredValue(key))); + + useEffect(() => { + setStoredValue(key, value); + }, [key, value]); + + return [value, setValue]; +} diff --git a/frontend/src/hooks/usethemepreference.ts b/frontend/src/hooks/usethemepreference.ts new file mode 100644 index 0000000..25f5335 --- /dev/null +++ b/frontend/src/hooks/usethemepreference.ts @@ -0,0 +1,75 @@ +/*----------------------------------------------------------------------------- +| Copyright (c) 2025-present, OpenTeams Inc. +|----------------------------------------------------------------------------*/ + +import { useEffect, useState } from 'react'; + +import { useLocalStorageState } from './uselocalstoragestate'; + +export const THEME_MODES = ['light', 'dark', 'system'] as const; +export type ThemeMode = (typeof THEME_MODES)[number]; + +export function isThemeMode(value: string): value is ThemeMode { + return (THEME_MODES as readonly string[]).includes(value); +} + +/** + * The localStorage key under which the theme preference is persisted. Kept in + * sync with the inline bootstrap script in `index.html` that prevents a flash + * of the wrong theme on initial load. + */ +export const THEME_MODE_STORAGE_KEY = 'nebari-chat:themeMode'; + +function prefersDark(): boolean { + try { + return window.matchMedia('(prefers-color-scheme: dark)').matches; + } catch { + return false; + } +} + +function readStoredMode(raw: string | null): ThemeMode { + if (raw !== null && isThemeMode(raw)) { + return raw; + } + + return 'system'; +} + +/** + * Track the user's theme preference, persist it, and keep the root `dark` class + * in sync so the whole app responds. In `system` mode the OS preference is + * followed live via `prefers-color-scheme`. + */ +export function useThemePreference() { + const [themeMode, setThemeMode] = useLocalStorageState( + THEME_MODE_STORAGE_KEY, + readStoredMode, + ); + const [systemPrefersDark, setSystemPrefersDark] = + useState(prefersDark); + + // Keep "system" mode in sync with the OS preference as it changes. + useEffect(() => { + let mediaQuery: MediaQueryList; + try { + mediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); + } catch { + return; + } + + const onChange = (event: MediaQueryListEvent) => + setSystemPrefersDark(event.matches); + mediaQuery.addEventListener('change', onChange); + return () => mediaQuery.removeEventListener('change', onChange); + }, []); + + const isDarkMode = + themeMode === 'system' ? systemPrefersDark : themeMode === 'dark'; + + useEffect(() => { + document.documentElement.classList.toggle('dark', isDarkMode); + }, [isDarkMode]); + + return { themeMode, isDarkMode, setThemeMode }; +} diff --git a/frontend/src/main.css b/frontend/src/main.css index 6bc281e..86158e2 100644 --- a/frontend/src/main.css +++ b/frontend/src/main.css @@ -13,7 +13,6 @@ html { body { font-family: Inter; font-weight: 400; - color: #1e1e1e; height: 100%; font-size: 14px; } @@ -153,31 +152,27 @@ body { } @theme { - /* Background Colors */ - --color-bg-neutral-dark: #ededed; - --color-bg-neutral-xdark: #d5d5d5; - --color-bg-neutral-default: #f5f5f5; - --color-bg-neutral-white: #fbfbfb; - --color-bg-brand-default: #9b3dcc; - --color-bg-brand-secondary: #e8d7fb; - --color-bg-white: #ffffff; - - /* Border Colors */ - --color-bd-neutral-default: #d9d9d9; - --color-bd-neutral-secondary: #b3b3b3; - --color-bd-brand-default: #682888; - - /* Text Colors */ - --color-text-brand-on-brand: #fbfbfb; - --color-text-neutral-default: #1e1e1e; - --color-text-neutral-secondary: #757575; - --thread-max-width: 44rem; } /*---break---*/ @theme inline { + /* Custom brand tokens, driven by CSS variables so they respond to `.dark`. */ + --color-bg-neutral-dark: var(--bg-neutral-dark); + --color-bg-neutral-xdark: var(--bg-neutral-xdark); + --color-bg-neutral-default: var(--bg-neutral-default); + --color-bg-neutral-white: var(--bg-neutral-white); + --color-bg-brand-default: var(--bg-brand-default); + --color-bg-brand-secondary: var(--bg-brand-secondary); + --color-bg-white: var(--bg-white); + --color-bd-neutral-default: var(--bd-neutral-default); + --color-bd-neutral-secondary: var(--bd-neutral-secondary); + --color-bd-brand-default: var(--bd-brand-default); + --color-text-brand-on-brand: var(--text-brand-on-brand); + --color-text-neutral-default: var(--text-neutral-default); + --color-text-neutral-secondary: var(--text-neutral-secondary); + --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -218,6 +213,21 @@ body { /*---break---*/ :root { + /* Custom brand tokens (light). */ + --bg-neutral-dark: #ededed; + --bg-neutral-xdark: #d5d5d5; + --bg-neutral-default: #f5f5f5; + --bg-neutral-white: #fbfbfb; + --bg-brand-default: #9b3dcc; + --bg-brand-secondary: #e8d7fb; + --bg-white: #ffffff; + --bd-neutral-default: #d9d9d9; + --bd-neutral-secondary: #b3b3b3; + --bd-brand-default: #682888; + --text-brand-on-brand: #fbfbfb; + --text-neutral-default: #1e1e1e; + --text-neutral-secondary: #757575; + --radius: 0.625rem; --background: oklch(1 0 0); --foreground: oklch(0.145 0 0); @@ -255,6 +265,21 @@ body { /*---break---*/ .dark { + /* Custom brand tokens (dark). */ + --bg-neutral-dark: #333333; + --bg-neutral-xdark: #404040; + --bg-neutral-default: #2a2a2a; + --bg-neutral-white: #262626; + --bg-brand-default: #9b3dcc; + --bg-brand-secondary: #3a2352; + --bg-white: #1e1e1e; + --bd-neutral-default: #404040; + --bd-neutral-secondary: #525252; + --bd-brand-default: #b45ee0; + --text-brand-on-brand: #fbfbfb; + --text-neutral-default: #ededed; + --text-neutral-secondary: #a3a3a3; + --background: oklch(0.145 0 0); --foreground: oklch(0.985 0 0); --card: oklch(0.205 0 0); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index cd2a517..db4b7be 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -14,6 +14,7 @@ import { createRoot } from 'react-dom/client'; import { ErrorBoundary, ErrorFallback } from '@/components/error-boundary'; import { Toaster } from '@/components/ui/sonner'; +import { ThemeProvider } from '@/hooks/themecontext'; import { notifyError } from '@/lib/notifications'; import { routeTree } from './routeTree.gen'; @@ -52,10 +53,12 @@ function App() { return ( - - - - + + + + + + ); diff --git a/frontend/src/sidebar/header.tsx b/frontend/src/sidebar/header.tsx index 90f725b..810ecf1 100644 --- a/frontend/src/sidebar/header.tsx +++ b/frontend/src/sidebar/header.tsx @@ -28,6 +28,7 @@ export function Header(props: Header.Props): ReactNode { to="/" className={cn( 'bg-[url(/assets/Nebari-Logo-Horizontal-Lockup.svg)] bg-[auto_100px]', + 'dark:bg-[url(/assets/Nebari-Logo-Horizontal-Lockup-Dark.svg)]', 'bg-center bg-no-repeat w-[100px] cursor-pointer ml-2', isSidebarOpen ? '' : 'hidden', )} diff --git a/frontend/src/sidebar/userprofile.tsx b/frontend/src/sidebar/userprofile.tsx index 48acc8b..c58eef9 100644 --- a/frontend/src/sidebar/userprofile.tsx +++ b/frontend/src/sidebar/userprofile.tsx @@ -2,8 +2,9 @@ | Copyright (c) 2025-present, OpenTeams Inc. |----------------------------------------------------------------------------*/ +import * as DropdownMenuPrimitive from '@radix-ui/react-dropdown-menu'; import { Link } from '@tanstack/react-router'; -import { ChevronsUpDown } from 'lucide-react'; +import { ChevronsUpDown, Monitor, Moon, Sun } from 'lucide-react'; import type { ReactNode } from 'react'; import * as auth from '@/auth'; @@ -17,10 +18,15 @@ import { DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, + DropdownMenuRadioGroup, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { useTheme } from '@/hooks/themecontext'; +import { isThemeMode, type ThemeMode } from '@/hooks/usethemepreference'; +import { cn } from '@/lib/utils'; + /** * A react component that renders the user profile in the sidebar. */ @@ -31,6 +37,9 @@ export function UserProfile(props: UserProfile.Props): ReactNode { // Get the user profile. const profile = auth.getUserProfile(); + // Get the current theme preference and setter. + const { themeMode, setThemeMode } = useTheme(); + // Bail early if the user is not logged in. if (!profile) { return null; @@ -55,7 +64,7 @@ export function UserProfile(props: UserProfile.Props): ReactNode { className="h-12 cursor-pointer w-full rounded-none" > - + {profile.name.charAt(0).toUpperCase()} @@ -69,6 +78,29 @@ export function UserProfile(props: UserProfile.Props): ReactNode { > {profile.email} +
+ { + if (isThemeMode(value)) setThemeMode(value); + }} + className="flex items-center gap-1 rounded-md bg-muted p-1" + > + + + + + + + + + + + + +
+ Logout @@ -80,6 +112,36 @@ export function UserProfile(props: UserProfile.Props): ReactNode { ); } +/** + * A single segmented option within the theme toggle. Keeps the menu open after + * switching so the theme change is immediately visible. + */ +function ThemeOption(props: { + value: ThemeMode; + label: string; + text: string; + children: ReactNode; +}): ReactNode { + const { value, label, text, children } = props; + + return ( + event.preventDefault()} + className={cn( + 'flex flex-1 cursor-pointer items-center justify-center gap-1.5 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50', + 'text-muted-foreground hover:text-foreground', + 'data-[state=checked]:bg-background data-[state=checked]:text-foreground data-[state=checked]:shadow-sm', + )} + > + {children} + {text} + + ); +} + /** * The namespace for the `UserProfile` statics. */