From 738e1919ce584d46f8fedbf74bf7a0353c75719f Mon Sep 17 00:00:00 2001 From: Paul Trowbridge Date: Sat, 2 May 2026 22:59:24 -0400 Subject: [PATCH] Add light/dark mode with Perspective theme sync Port light/dark mode from pf_app: ThemeProvider context, CSS custom properties (Pro Dark palette), dark overrides for Tailwind classes, and Perspective viewer theme sync in Pivot. Toggle button in sidebar header. Improve toggle icons to Feather-style stroke SVGs. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 11 +++++ ui/src/App.jsx | 31 +++++++++++++- ui/src/index.css | 91 ++++++++++++++++++++++++++++++++++++++++++ ui/src/main.jsx | 5 ++- ui/src/pages/Pivot.jsx | 7 ++++ ui/src/theme.jsx | 25 ++++++++++++ 6 files changed, 167 insertions(+), 3 deletions(-) create mode 100644 ui/src/theme.jsx diff --git a/CLAUDE.md b/CLAUDE.md index 6c198e8..8c38e14 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,17 @@ records.data → apply_transformations() → - Server.js has global error handler - Database functions return JSON with `success` boolean +## Light / dark mode + +Theme state lives in `ui/src/theme.jsx` — a React context (`ThemeContext`) with a `ThemeProvider` that wraps the app in `main.jsx`. + +- **Storage key:** `df_dark` in `localStorage`; falls back to `window.matchMedia('(prefers-color-scheme: dark)')` on first visit +- **Toggle:** button in the sidebar header in `App.jsx`; effect writes `localStorage` and toggles the `.dark` class on `` +- **CSS:** `ui/src/index.css` defines CSS custom properties under `:root` (light) and `.dark`. All Tailwind color overrides are written as `.dark .bg-white { ... }` etc. +- **Palette:** dark mode uses Perspective's "Pro Dark" colours (`--bg-primary: #242526`, panels `#2a2c2f`, gridlines `#3b3f46`, text `#c5c9d0`) +- **Perspective viewer:** `Pivot.jsx` calls `viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')` on initial load and in a `useEffect([dark])` so the viewer stays in sync when the toggle fires +- **Consuming the theme:** `import useTheme from '../theme.jsx'` then `const { dark, setDark } = useTheme()` + ## UI (React + Vite) The frontend lives in `ui/src/` and is built to `public/` via `npm run build` from the `ui/` directory. **Always run `npm run build` from `ui/` after any changes to `ui/src/` files.** diff --git a/ui/src/App.jsx b/ui/src/App.jsx index bac0aeb..e4f3025 100644 --- a/ui/src/App.jsx +++ b/ui/src/App.jsx @@ -1,6 +1,7 @@ import { useState, useEffect } from 'react' import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom' import { api, setCredentials, clearCredentials } from './api' +import useTheme from './theme.jsx' import Login from './pages/Login' import Sources from './pages/Sources' import Import from './pages/Import' @@ -25,6 +26,7 @@ const NAV = [ ] export default function App() { + const { dark, setDark } = useTheme() const [authed, setAuthed] = useState(false) const [loginUser, setLoginUser] = useState('') const [sources, setSources] = useState([]) @@ -127,7 +129,32 @@ export default function App() {
Dataflow - +
+ + +
{loginUser} @@ -173,7 +200,7 @@ export default function App() { return ( -
+
{/* Mobile overlay */} {sidebarOpen && ( diff --git a/ui/src/index.css b/ui/src/index.css index 5788086..d8895b6 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -1,6 +1,97 @@ @import "tailwindcss"; +:root, .light { + --bg-primary: #f3f4f6; + --bg-secondary: #ffffff; + --bg-tertiary: #f9fafb; + --text-primary: #1f2937; + --text-secondary: #374151; + --text-muted: #9ca3af; + --border-color: #e5e7eb; + --border-light: #f3f4f6; + --accent-bg: #eff6ff; + --accent-text: #1d4ed8; +} + +/* Dark palette tuned to Perspective's "Pro Dark" theme: + bg #242526, tooltip #2a2c2f, gridline #3b3f46, inactive #61656e, + inactive border #4c505b, active #2770a9, legend #c5c9d0. */ +.dark { + --bg-primary: #242526; + --bg-secondary: #2a2c2f; + --bg-tertiary: #3b3f46; + --text-primary: #ffffff; + --text-secondary: #c5c9d0; + --text-muted: #61656e; + --border-color: #4c505b; + --border-light: #3b3f46; + --accent-bg: rgba(39, 113, 170, 0.32); + --accent-text: #4778c2; +} + body { margin: 0; font-family: system-ui, -apple-system, sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); } + +.dark .bg-white { background-color: var(--bg-secondary); } +.dark .bg-gray-50 { background-color: var(--bg-tertiary); } +.dark .bg-gray-100 { background-color: var(--bg-tertiary); } +.dark .bg-gray-200 { background-color: var(--bg-tertiary); } +.dark .bg-gray-300 { background-color: var(--bg-tertiary); } +.dark .text-gray-300 { color: var(--text-muted); } +.dark .text-gray-400 { color: var(--text-muted); } +.dark .text-gray-500 { color: var(--text-muted); } +.dark .text-gray-600 { color: var(--text-secondary); } +.dark .text-gray-700 { color: var(--text-secondary); } +.dark .text-gray-800 { color: var(--text-primary); } +.dark .text-gray-900 { color: var(--text-primary); } +.dark .bg-blue-50 { background-color: var(--accent-bg); } +.dark .bg-blue-100 { background-color: var(--accent-bg); } +.dark .text-blue-400 { color: var(--accent-text); } +.dark .text-blue-600 { color: var(--accent-text); } +.dark .text-blue-700 { color: var(--accent-text); } +.dark .text-blue-800 { color: var(--accent-text); } +.dark .border-blue-200 { border-color: var(--accent-text); } +.dark .border-blue-300 { border-color: var(--accent-text); } +.dark .hover\:bg-blue-50:hover { background-color: var(--accent-bg); } + +/* Status accents — desaturated to sit on Pro Dark's neutral background */ +.dark .bg-green-50 { background-color: #1a3d2c; } +.dark .text-green-600 { color: #6ee7b7; } +.dark .text-green-700 { color: #6ee7b7; } +.dark .text-green-400 { color: #6ee7b7; } +.dark .bg-amber-50 { background-color: #3a2e14; } +.dark .text-amber-800 { color: #f5c66f; } +.dark .border-amber-200 { border-color: #5a4a26; } +.dark .bg-amber-200 { background-color: #5a4a26; } +.dark .hover\:bg-amber-300:hover { background-color: #6b5830; } +.dark .bg-red-50 { background-color: #3d1f1f; } +.dark .text-red-500 { color: #ff9485; } +.dark .text-red-700 { color: #ff9485; } +.dark .border-gray-100 { border-color: var(--border-light); } +.dark .border-gray-200 { border-color: var(--border-color); } +.dark .border-gray-300 { border-color: var(--border-color); } +.dark .border-blue-100 { border-color: var(--border-color); } +.dark .border-b { border-color: var(--border-color); } +.dark .border-t { border-color: var(--border-color); } +.dark .border-r { border-color: var(--border-color); } +.dark .border-l { border-color: var(--border-color); } +.dark .hover\:bg-gray-50:hover { background-color: var(--bg-tertiary); } +.dark .hover\:bg-gray-100:hover { background-color: var(--bg-tertiary); } +.dark .hover\:bg-gray-200:hover { background-color: var(--bg-tertiary); } +.dark .hover\:text-gray-500:hover { color: var(--text-secondary); } +.dark .hover\:text-gray-600:hover { color: var(--text-secondary); } +.dark .hover\:text-gray-700:hover { color: var(--text-primary); } +.dark .hover\:text-gray-800:hover { color: var(--text-primary); } +.dark .hover\:border-gray-300:hover { border-color: var(--border-color); } +.dark .hover\:border-gray-400:hover { border-color: var(--border-color); } +.dark .focus\:border-gray-300:focus { border-color: var(--border-color); } +.dark .focus\:border-blue-400:focus { border-color: var(--accent-text); } +.dark ::selection { background-color: var(--accent-bg); color: var(--text-primary); } +.dark input { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } +.dark select { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } +.dark textarea { background-color: var(--bg-secondary); color: var(--text-primary); border-color: var(--border-color); } +.dark .bg-transparent { background-color: transparent; } diff --git a/ui/src/main.jsx b/ui/src/main.jsx index b9a1a6d..28ad15f 100644 --- a/ui/src/main.jsx +++ b/ui/src/main.jsx @@ -1,10 +1,13 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { ThemeProvider } from './theme.jsx' import './index.css' import App from './App.jsx' createRoot(document.getElementById('root')).render( - + + + , ) diff --git a/ui/src/pages/Pivot.jsx b/ui/src/pages/Pivot.jsx index be6293f..9bb22a8 100644 --- a/ui/src/pages/Pivot.jsx +++ b/ui/src/pages/Pivot.jsx @@ -1,5 +1,6 @@ import { useEffect, useRef, useState, useCallback } from 'react' import { api } from '../api' +import useTheme from '../theme.jsx' async function fetchAllRows(source) { const res = await api.getViewData(source, 100000, 0) @@ -81,6 +82,7 @@ const DEFAULT_PLUGIN_CONFIG = { edit_mode: 'SELECT_REGION' } export default function Pivot({ source }) { + const { dark } = useTheme() const viewerRef = useRef() const workerRef = useRef() const tableRef = useRef() @@ -104,6 +106,10 @@ export default function Pivot({ source }) { useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, []) + useEffect(() => { + if (viewerRef.current) viewerRef.current.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light') + }, [dark]) + // When sidebar source changes, reset to that source useEffect(() => { if (viewType === 'source') setSelectedView(source) @@ -247,6 +253,7 @@ export default function Pivot({ source }) { viewer.addEventListener('perspective-click', perspClickHandlerRef.current) await viewer.load(worker) + viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light') const plugin = await viewer.getPlugin() const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView)) diff --git a/ui/src/theme.jsx b/ui/src/theme.jsx new file mode 100644 index 0000000..921982e --- /dev/null +++ b/ui/src/theme.jsx @@ -0,0 +1,25 @@ +import { createContext, useContext, useState, useEffect } from 'react' + +const ThemeContext = createContext() + +export function ThemeProvider({ children }) { + const [dark, setDark] = useState(() => { + const saved = localStorage.getItem('df_dark') + if (saved !== null) return saved === 'true' + return window.matchMedia('(prefers-color-scheme: dark)').matches + }) + + useEffect(() => { + localStorage.setItem('df_dark', dark) + document.documentElement.classList.toggle('dark', dark) + }, [dark]) + + return ( + + {children} + + ) +} + +const useTheme = () => useContext(ThemeContext) +export default useTheme