feat: add persistent light/dark mode toggle

- Add CSS custom property token system to globals.css (dark + light variants)
- Add anti-FOUC inline script in layout.tsx head to apply stored theme before hydration
- Create useTheme hook (src/hooks/useTheme.ts) backed by usePersistentState
- Add Sun/Moon toggle button to LeftToolbar above Settings
- Wire useTheme into ThreePanelLayout and pass theme/onThemeToggle to LeftToolbar
- Migrate hardcoded hex values to CSS vars in LeftToolbar and ThreePanelLayout

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
“BeeRad”
2026-03-17 19:32:06 +11:00
co-authored by Claude Sonnet 4.6
parent 4802187f5e
commit bf175ad125
5 changed files with 144 additions and 13 deletions
+67 -6
View File
@@ -36,6 +36,67 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
:root,
html[data-theme="dark"] {
color-scheme: dark;
--rah-bg-base: #0a0a0a;
--rah-bg-surface: #111111;
--rah-bg-subtle: #121212;
--rah-bg-panel: #141414;
--rah-bg-hover: #151515;
--rah-bg-elevated: #161616;
--rah-bg-active: #1a1a1a;
--rah-bg-modal: #141414;
--rah-border: #1a1a1a;
--rah-border-strong: #2a2a2a;
--rah-border-stronger: #3a3a3a;
--rah-scrollbar: #353535;
--rah-scrollbar-hover: #4a4a4a;
--rah-text-base: #e5e5e5;
--rah-text-secondary: #c7c7c7;
--rah-text-muted: #7a7a7a;
--rah-text-soft: #a3a3a3;
--rah-text-active: #f0f0f0;
--rah-text-inverse: #0a0a0a;
--rah-accent-green: #22c55e;
--rah-accent-green-hover: #16a34a;
--rah-accent-green-soft: rgba(34, 197, 94, 0.14);
--rah-accent-green-soft-strong: rgba(34, 197, 94, 0.22);
--rah-backdrop: rgba(0, 0, 0, 0.85);
--rah-shadow-modal: 0 8px 32px rgba(0, 0, 0, 0.8), 0 0 0 1px rgba(255, 255, 255, 0.05);
--rah-shadow-floating: 0 4px 12px rgba(0, 0, 0, 0.4);
}
html[data-theme="light"] {
color-scheme: light;
--rah-bg-base: #ffffff;
--rah-bg-surface: #fafafa;
--rah-bg-subtle: #f7f7f7;
--rah-bg-panel: #f5f5f5;
--rah-bg-hover: #f0f0f0;
--rah-bg-elevated: #ececec;
--rah-bg-active: #e8e8e8;
--rah-bg-modal: #ffffff;
--rah-border: #e5e5e5;
--rah-border-strong: #d4d4d4;
--rah-border-stronger: #bdbdbd;
--rah-scrollbar: #d4d4d4;
--rah-scrollbar-hover: #b5b5b5;
--rah-text-base: #111111;
--rah-text-secondary: #333333;
--rah-text-muted: #6b7280;
--rah-text-soft: #4b5563;
--rah-text-active: #000000;
--rah-text-inverse: #ffffff;
--rah-accent-green: #16a34a;
--rah-accent-green-hover: #15803d;
--rah-accent-green-soft: rgba(22, 163, 74, 0.12);
--rah-accent-green-soft-strong: rgba(22, 163, 74, 0.2);
--rah-backdrop: rgba(0, 0, 0, 0.5);
--rah-shadow-modal: 0 10px 36px rgba(0, 0, 0, 0.18), 0 0 0 1px rgba(0, 0, 0, 0.04);
--rah-shadow-floating: 0 6px 20px rgba(0, 0, 0, 0.16);
}
/* Modern Clean Design System - RA-H */ /* Modern Clean Design System - RA-H */
* { * {
margin: 0; margin: 0;
@@ -46,8 +107,8 @@
html, body { html, body {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background-color: #0a0a0a; background-color: var(--rah-bg-base);
color: #e5e5e5; color: var(--rah-text-base);
/* Geist Sans for optimal reading/writing experience */ /* Geist Sans for optimal reading/writing experience */
font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-family: 'Geist', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 14px; font-size: 14px;
@@ -71,21 +132,21 @@ html, body {
} }
::-webkit-scrollbar-track { ::-webkit-scrollbar-track {
background: #0a0a0a; background: var(--rah-bg-base);
} }
::-webkit-scrollbar-thumb { ::-webkit-scrollbar-thumb {
background: #353535; background: var(--rah-scrollbar);
border-radius: 2px; border-radius: 2px;
} }
::-webkit-scrollbar-thumb:hover { ::-webkit-scrollbar-thumb:hover {
background: #4a4a4a; background: var(--rah-scrollbar-hover);
} }
/* Utility classes */ /* Utility classes */
.border-divider { .border-divider {
border-color: #1a1a1a; border-color: var(--rah-border);
} }
.text-muted { .text-muted {
+25 -1
View File
@@ -13,7 +13,31 @@ export default function RootLayout({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<html lang="en"> <html lang="en" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `
(function() {
try {
var rawTheme = localStorage.getItem('ui.theme');
var theme = 'dark';
if (rawTheme !== null) {
try {
theme = JSON.parse(rawTheme) === 'light' ? 'light' : 'dark';
} catch (parseError) {
theme = rawTheme === 'light' ? 'light' : 'dark';
}
}
document.documentElement.setAttribute('data-theme', theme);
} catch (error) {
document.documentElement.setAttribute('data-theme', 'dark');
}
})();
`,
}}
/>
</head>
<body> <body>
<DimensionIconsProvider> <DimensionIconsProvider>
<ExternalNavigationManager /> <ExternalNavigationManager />
+18 -5
View File
@@ -13,8 +13,11 @@ import {
Settings, Settings,
PanelLeftClose, PanelLeftClose,
PanelLeftOpen, PanelLeftOpen,
Sun,
Moon,
} from 'lucide-react'; } from 'lucide-react';
import type { PaneType } from '../panes/types'; import type { PaneType } from '../panes/types';
import type { Theme } from '@/hooks/useTheme';
interface LeftToolbarProps { interface LeftToolbarProps {
onSearchClick: () => void; onSearchClick: () => void;
@@ -26,6 +29,8 @@ interface LeftToolbarProps {
onToggleExpanded: () => void; onToggleExpanded: () => void;
openTabTypes: Set<PaneType>; openTabTypes: Set<PaneType>;
activeTabType: PaneType | null; activeTabType: PaneType | null;
theme: Theme;
onThemeToggle: () => void;
} }
const NAV_WIDTH_COLLAPSED = 50; const NAV_WIDTH_COLLAPSED = 50;
@@ -57,7 +62,7 @@ function NavButton({
activeTone?: 'neutral' | 'green'; activeTone?: 'neutral' | 'green';
}) { }) {
const [hovered, setHovered] = useState(false); const [hovered, setHovered] = useState(false);
const activeColor = activeTone === 'green' ? '#22c55e' : '#f0f0f0'; const activeColor = activeTone === 'green' ? 'var(--rah-accent-green)' : 'var(--rah-text-active)';
return ( return (
<button <button
@@ -70,8 +75,8 @@ function NavButton({
height: '36px', height: '36px',
borderRadius: '8px', borderRadius: '8px',
border: 'none', border: 'none',
background: active ? '#151515' : (hovered ? '#121212' : 'transparent'), background: active ? 'var(--rah-bg-hover)' : (hovered ? 'var(--rah-bg-subtle)' : 'transparent'),
color: active ? activeColor : (hovered ? '#c7c7c7' : '#7a7a7a'), color: active ? activeColor : (hovered ? 'var(--rah-text-secondary)' : 'var(--rah-text-muted)'),
cursor: 'pointer', cursor: 'pointer',
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
@@ -104,6 +109,8 @@ export default function LeftToolbar({
onToggleExpanded, onToggleExpanded,
openTabTypes, openTabTypes,
activeTabType, activeTabType,
theme,
onThemeToggle,
}: LeftToolbarProps) { }: LeftToolbarProps) {
return ( return (
<div <div
@@ -134,7 +141,7 @@ export default function LeftToolbar({
<NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} /> <NavButton icon={RefreshCw} label="Refresh" expanded={isExpanded} onClick={onRefreshClick} />
</div> </div>
<div style={{ borderTop: '1px solid #1a1a1a', paddingTop: '14px', marginTop: '10px' }}> <div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '14px', marginTop: '10px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '4px', paddingTop: '4px' }}>
{VIEW_ITEMS.map((item) => ( {VIEW_ITEMS.map((item) => (
<NavButton <NavButton
@@ -151,7 +158,13 @@ export default function LeftToolbar({
</div> </div>
</div> </div>
<div style={{ borderTop: '1px solid #1a1a1a', paddingTop: '8px' }}> <div style={{ borderTop: '1px solid var(--rah-border)', paddingTop: '8px' }}>
<NavButton
icon={theme === 'dark' ? Sun : Moon}
label={theme === 'dark' ? 'Light Mode' : 'Dark Mode'}
expanded={isExpanded}
onClick={onThemeToggle}
/>
<NavButton icon={Settings} label="Settings" expanded={isExpanded} onClick={onSettingsClick} /> <NavButton icon={Settings} label="Settings" expanded={isExpanded} onClick={onSettingsClick} />
</div> </div>
</div> </div>
+6 -1
View File
@@ -6,6 +6,7 @@ import SearchModal from '../nodes/SearchModal';
import { Node } from '@/types/database'; import { Node } from '@/types/database';
import { DatabaseEvent } from '@/services/events'; import { DatabaseEvent } from '@/services/events';
import { usePersistentState } from '@/hooks/usePersistentState'; import { usePersistentState } from '@/hooks/usePersistentState';
import { useTheme } from '@/hooks/useTheme';
// ChatMessage import removed - chat disabled in rah-light // ChatMessage import removed - chat disabled in rah-light
// Stub type for delegation (delegation system removed in rah-light) // Stub type for delegation (delegation system removed in rah-light)
@@ -40,6 +41,8 @@ import QuickAddInput from '../agents/QuickAddInput';
import type { PaneType, SlotState, PaneAction } from '../panes/types'; import type { PaneType, SlotState, PaneAction } from '../panes/types';
export default function ThreePanelLayout() { export default function ThreePanelLayout() {
const [theme, toggleTheme] = useTheme();
// Container ref for resize calculations // Container ref for resize calculations
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
@@ -1014,7 +1017,7 @@ export default function ThreePanelLayout() {
display: 'flex', display: 'flex',
height: '100vh', height: '100vh',
width: '100vw', width: '100vw',
background: '#0a0a0a', background: 'var(--rah-bg-base)',
overflow: 'hidden' overflow: 'hidden'
}} }}
> >
@@ -1032,6 +1035,8 @@ export default function ThreePanelLayout() {
openTabTypes={new Set([slotA?.type, slotB?.type].filter((t): t is PaneType => t != null))} openTabTypes={new Set([slotA?.type, slotB?.type].filter((t): t is PaneType => t != null))}
activeTabType={activePane === 'A' ? slotA?.type ?? null : slotB?.type ?? null} activeTabType={activePane === 'A' ? slotA?.type ?? null : slotB?.type ?? null}
onRefreshClick={handleRefreshAll} onRefreshClick={handleRefreshAll}
theme={theme}
onThemeToggle={toggleTheme}
/> />
{/* Main content area */} {/* Main content area */}
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { useEffect } from 'react';
import { usePersistentState } from './usePersistentState';
export type Theme = 'dark' | 'light';
function getInitialTheme(): Theme {
if (typeof document === 'undefined') {
return 'dark';
}
return document.documentElement.getAttribute('data-theme') === 'light' ? 'light' : 'dark';
}
export function useTheme(): [Theme, () => void] {
const [theme, setTheme] = usePersistentState<Theme>('ui.theme', getInitialTheme());
useEffect(() => {
document.documentElement.setAttribute('data-theme', theme);
}, [theme]);
const toggleTheme = () => {
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
return [theme, toggleTheme];
}