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 <noreply@anthropic.com>
This commit is contained in:
parent
1791bf0f0a
commit
738e1919ce
11
CLAUDE.md
11
CLAUDE.md
@ -140,6 +140,17 @@ records.data → apply_transformations() →
|
|||||||
- Server.js has global error handler
|
- Server.js has global error handler
|
||||||
- Database functions return JSON with `success` boolean
|
- 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 `<html>`
|
||||||
|
- **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)
|
## 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.**
|
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.**
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react'
|
import { useState, useEffect } from 'react'
|
||||||
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
|
import { BrowserRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom'
|
||||||
import { api, setCredentials, clearCredentials } from './api'
|
import { api, setCredentials, clearCredentials } from './api'
|
||||||
|
import useTheme from './theme.jsx'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Sources from './pages/Sources'
|
import Sources from './pages/Sources'
|
||||||
import Import from './pages/Import'
|
import Import from './pages/Import'
|
||||||
@ -25,6 +26,7 @@ const NAV = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
|
const { dark, setDark } = useTheme()
|
||||||
const [authed, setAuthed] = useState(false)
|
const [authed, setAuthed] = useState(false)
|
||||||
const [loginUser, setLoginUser] = useState('')
|
const [loginUser, setLoginUser] = useState('')
|
||||||
const [sources, setSources] = useState([])
|
const [sources, setSources] = useState([])
|
||||||
@ -127,7 +129,32 @@ export default function App() {
|
|||||||
<div className="px-4 py-3 border-b border-gray-200">
|
<div className="px-4 py-3 border-b border-gray-200">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
<span className="text-sm font-semibold text-gray-800 tracking-wide uppercase">Dataflow</span>
|
||||||
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close">✕</button>
|
<div className="flex items-center gap-1 ml-auto">
|
||||||
|
<button
|
||||||
|
onClick={() => setDark(d => !d)}
|
||||||
|
className="w-6 h-6 flex items-center justify-center rounded hover:bg-gray-100 text-gray-400 hover:text-gray-600"
|
||||||
|
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
|
||||||
|
>
|
||||||
|
{dark ? (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<circle cx="12" cy="12" r="4"/>
|
||||||
|
<line x1="12" y1="2" x2="12" y2="5"/>
|
||||||
|
<line x1="12" y1="19" x2="12" y2="22"/>
|
||||||
|
<line x1="4.93" y1="4.93" x2="7.05" y2="7.05"/>
|
||||||
|
<line x1="16.95" y1="16.95" x2="19.07" y2="19.07"/>
|
||||||
|
<line x1="2" y1="12" x2="5" y2="12"/>
|
||||||
|
<line x1="19" y1="12" x2="22" y2="12"/>
|
||||||
|
<line x1="4.93" y1="19.07" x2="7.05" y2="16.95"/>
|
||||||
|
<line x1="16.95" y1="7.05" x2="19.07" y2="4.93"/>
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setSidebarOpen(false)} className="md:hidden text-gray-400 hover:text-gray-600 leading-none" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between mt-1">
|
<div className="flex items-center justify-between mt-1">
|
||||||
<span className="text-xs text-gray-400">{loginUser}</span>
|
<span className="text-xs text-gray-400">{loginUser}</span>
|
||||||
@ -173,7 +200,7 @@ export default function App() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<div className="flex h-screen bg-gray-50">
|
<div className="flex h-screen">
|
||||||
|
|
||||||
{/* Mobile overlay */}
|
{/* Mobile overlay */}
|
||||||
{sidebarOpen && (
|
{sidebarOpen && (
|
||||||
|
|||||||
@ -1,6 +1,97 @@
|
|||||||
@import "tailwindcss";
|
@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 {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: system-ui, -apple-system, sans-serif;
|
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; }
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
import { StrictMode } from 'react'
|
import { StrictMode } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import { ThemeProvider } from './theme.jsx'
|
||||||
import './index.css'
|
import './index.css'
|
||||||
import App from './App.jsx'
|
import App from './App.jsx'
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(
|
createRoot(document.getElementById('root')).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<ThemeProvider>
|
||||||
|
<App />
|
||||||
|
</ThemeProvider>
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useRef, useState, useCallback } from 'react'
|
import { useEffect, useRef, useState, useCallback } from 'react'
|
||||||
import { api } from '../api'
|
import { api } from '../api'
|
||||||
|
import useTheme from '../theme.jsx'
|
||||||
|
|
||||||
async function fetchAllRows(source) {
|
async function fetchAllRows(source) {
|
||||||
const res = await api.getViewData(source, 100000, 0)
|
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 }) {
|
export default function Pivot({ source }) {
|
||||||
|
const { dark } = useTheme()
|
||||||
const viewerRef = useRef()
|
const viewerRef = useRef()
|
||||||
const workerRef = useRef()
|
const workerRef = useRef()
|
||||||
const tableRef = useRef()
|
const tableRef = useRef()
|
||||||
@ -104,6 +106,10 @@ export default function Pivot({ source }) {
|
|||||||
|
|
||||||
useEffect(() => { api.getStacks().then(setStacks).catch(() => {}) }, [])
|
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
|
// When sidebar source changes, reset to that source
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (viewType === 'source') setSelectedView(source)
|
if (viewType === 'source') setSelectedView(source)
|
||||||
@ -247,6 +253,7 @@ export default function Pivot({ source }) {
|
|||||||
viewer.addEventListener('perspective-click', perspClickHandlerRef.current)
|
viewer.addEventListener('perspective-click', perspClickHandlerRef.current)
|
||||||
|
|
||||||
await viewer.load(worker)
|
await viewer.load(worker)
|
||||||
|
viewer.setAttribute('theme', dark ? 'Pro Dark' : 'Pro Light')
|
||||||
|
|
||||||
const plugin = await viewer.getPlugin()
|
const plugin = await viewer.getPlugin()
|
||||||
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
|
const savedLayout = localStorage.getItem(LAYOUT_KEY(selectedView))
|
||||||
|
|||||||
25
ui/src/theme.jsx
Normal file
25
ui/src/theme.jsx
Normal file
@ -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 (
|
||||||
|
<ThemeContext.Provider value={{ dark, setDark }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const useTheme = () => useContext(ThemeContext)
|
||||||
|
export default useTheme
|
||||||
Loading…
Reference in New Issue
Block a user