From 438a6e3e45b54a048f050feb5e81c796ec224530 Mon Sep 17 00:00:00 2001 From: anti Date: Sat, 9 May 2026 04:01:24 -0400 Subject: [PATCH] feat(decnet_web/Layout): topbar dark/light toggle with circular reveal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-facing theme toggle ships now that the design system has been audited end-to-end. A Sun/Moon button lives between the threat indicator and the SYSTEM status pill in the topbar — same slim 28x28 voice as the rest of the topbar controls, no chrome shouting at the user. Click coords drive a View Transitions API circle clip-path that grows from the cursor to the farthest viewport corner over 520ms with the project's standard --ease curve. Browsers without startViewTransition (older Firefox, Safari < 18) fall through to an unanimated swap — the hook returns instantly in that case. Persistence is two-tier: - localStorage decnet_theme — the user's saved preference, the thing the topbar toggle writes. Survives reloads, applies everywhere. - sessionStorage decnet_theme_lab — dev-mode lab override (Task 3). Tab-scoped, wins on boot so devs can A/B without nuking the saved preference. App.tsx hydrates both on first mount in the right order so the correct theme is on before the first paint. useThemeToggle is a small hook in lib/ rather than a Layout-only helper so the same toggle can be reused later from a settings page or hotkey. --- decnet_web/src/App.tsx | 22 ++-- decnet_web/src/components/Layout.css | 23 ++++ decnet_web/src/components/Layout.tsx | 23 +++- decnet_web/src/icons.ts | 2 + decnet_web/src/index.css | 18 +++ .../src/lib/__tests__/useThemeToggle.test.ts | 71 +++++++++++ decnet_web/src/lib/useThemeToggle.ts | 112 ++++++++++++++++++ 7 files changed, 262 insertions(+), 9 deletions(-) create mode 100644 decnet_web/src/lib/__tests__/useThemeToggle.test.ts create mode 100644 decnet_web/src/lib/useThemeToggle.ts diff --git a/decnet_web/src/App.tsx b/decnet_web/src/App.tsx index bba561b9..dc1c22e9 100644 --- a/decnet_web/src/App.tsx +++ b/decnet_web/src/App.tsx @@ -183,16 +183,22 @@ function App() { } catch { /* fall through to default */ } document.documentElement.setAttribute('data-accent', accent); - /* Lab theme persists in sessionStorage so a tab reload keeps the - * dev's chosen theme without leaking to other tabs or users. The - * production user-facing toggle (localStorage `decnet_theme`) - * arrives with the Config-page setting in a later task. */ + /* Theme hydration order on boot: + * 1. localStorage `decnet_theme` — the saved user preference + * from the topbar Sun/Moon toggle. Default = 'dark'. + * 2. sessionStorage `decnet_theme_lab` — dev-mode lab override + * (set from /theme-lab). Tab-scoped, wins on top so devs + * can A/B without clobbering their saved preference. */ + let theme: 'dark' | 'light' = 'dark'; try { - const labTheme = sessionStorage.getItem('decnet_theme_lab'); - if (labTheme === 'light' || labTheme === 'dark') { - document.documentElement.setAttribute('data-theme', labTheme); - } + const saved = localStorage.getItem('decnet_theme'); + if (saved === 'light' || saved === 'dark') theme = saved; } catch { /* ignore */ } + try { + const lab = sessionStorage.getItem('decnet_theme_lab'); + if (lab === 'light' || lab === 'dark') theme = lab; + } catch { /* ignore */ } + document.documentElement.setAttribute('data-theme', theme); }, []); const handleLogin = (newToken: string) => setToken(newToken); diff --git a/decnet_web/src/components/Layout.css b/decnet_web/src/components/Layout.css index 92aa67c0..080c043a 100644 --- a/decnet_web/src/components/Layout.css +++ b/decnet_web/src/components/Layout.css @@ -212,6 +212,29 @@ flex-shrink: 0; } +/* Dark/light toggle in topbar. + * Sits between the threat indicator and the system status pill, + * matching the slim/quiet voice of the rest of the topbar. */ +.theme-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + background: transparent; + border: 1px solid var(--border); + color: var(--accent); + cursor: pointer; + transition: border-color var(--dur-quick) var(--ease), + color var(--dur-quick) var(--ease), + background var(--dur-quick) var(--ease); +} +.theme-toggle-btn:hover { + background: var(--accent-tint-10); + border-color: var(--accent); +} + .crumbs { display: flex; align-items: center; diff --git a/decnet_web/src/components/Layout.tsx b/decnet_web/src/components/Layout.tsx index 66e5683e..452bce9b 100644 --- a/decnet_web/src/components/Layout.tsx +++ b/decnet_web/src/components/Layout.tsx @@ -4,9 +4,10 @@ import { Menu, X, Search, Activity, LayoutDashboard, Terminal, Settings, LogOut, Server, Archive, Package, Network, ChevronDown, ChevronRight, HardDrive, ShieldAlert, Bell, Webhook, Lock, Crosshair, Fingerprint, Zap, Cpu, Mail, - Target, FileText, Sliders, + Target, FileText, Sliders, Sun, Moon, } from '../icons'; import { prefetchRoute } from '../routePrefetch'; +import { useThemeToggle } from '../lib/useThemeToggle'; import './Layout.css'; type ThreatLevel = 'nominal' | 'elevated' | 'critical'; @@ -207,6 +208,7 @@ const Layout: React.FC = ({ THREAT: {threatLabel} )} +
= ({ ); }; +/* Topbar dark/light toggle. Click coords drive the circular reveal + * animation in useThemeToggle so the swap visually propagates from + * the cursor outward — see lib/useThemeToggle.ts. */ +const ThemeToggleButton: React.FC = () => { + const { theme, toggle } = useThemeToggle(); + const isLight = theme === 'light'; + return ( + + ); +}; + interface NavItemProps { to: string; icon: React.ReactNode; diff --git a/decnet_web/src/icons.ts b/decnet_web/src/icons.ts index 3d1fdb67..17fcea8c 100644 --- a/decnet_web/src/icons.ts +++ b/decnet_web/src/icons.ts @@ -92,6 +92,8 @@ export { default as Terminal } from 'lucide-react/dist/esm/icons/terminal'; export { default as Timer } from 'lucide-react/dist/esm/icons/timer'; export { default as Trash2 } from 'lucide-react/dist/esm/icons/trash-2'; export { default as Upload } from 'lucide-react/dist/esm/icons/upload'; +export { default as Moon } from 'lucide-react/dist/esm/icons/moon'; +export { default as Sun } from 'lucide-react/dist/esm/icons/sun'; export { default as UploadCloud } from 'lucide-react/dist/esm/icons/cloud-upload'; export { default as UserPlus } from 'lucide-react/dist/esm/icons/user-plus'; export { default as Users } from 'lucide-react/dist/esm/icons/users'; diff --git a/decnet_web/src/index.css b/decnet_web/src/index.css index 3bf63983..95079177 100644 --- a/decnet_web/src/index.css +++ b/decnet_web/src/index.css @@ -337,6 +337,24 @@ input:focus { background-size: var(--grid-size) var(--grid-size); } +/* ── Theme transition ─────────────────────────── + * Disables the default cross-fade so the JS-driven + * circle clip-path in useThemeToggle.ts owns the + * reveal entirely. The new theme grows from the + * click point; the old theme stays put and is + * uncovered by the expanding circle. */ +::view-transition-old(root), +::view-transition-new(root) { + animation: none; + mix-blend-mode: normal; +} +::view-transition-old(root) { + z-index: 0; +} +::view-transition-new(root) { + z-index: 1; +} + /* ── Scrollbar ─────────────────────────────────── */ * { scrollbar-width: thin; diff --git a/decnet_web/src/lib/__tests__/useThemeToggle.test.ts b/decnet_web/src/lib/__tests__/useThemeToggle.test.ts new file mode 100644 index 00000000..7d56f263 --- /dev/null +++ b/decnet_web/src/lib/__tests__/useThemeToggle.test.ts @@ -0,0 +1,71 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { act, renderHook } from '@testing-library/react'; +import { + useThemeToggle, + readSavedTheme, + readEffectiveBootTheme, + THEME_STORAGE_KEY, + LAB_THEME_STORAGE_KEY, +} from '../useThemeToggle'; + +describe('useThemeToggle', () => { + beforeEach(() => { + localStorage.clear(); + sessionStorage.clear(); + delete document.documentElement.dataset.theme; + }); + + it('readSavedTheme defaults to dark', () => { + expect(readSavedTheme()).toBe('dark'); + }); + + it('readSavedTheme returns light when saved', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'light'); + expect(readSavedTheme()).toBe('light'); + }); + + it('readSavedTheme rejects junk values', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'banana'); + expect(readSavedTheme()).toBe('dark'); + }); + + it('readEffectiveBootTheme prefers sessionStorage lab over localStorage', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'dark'); + sessionStorage.setItem(LAB_THEME_STORAGE_KEY, 'light'); + expect(readEffectiveBootTheme()).toBe('light'); + }); + + it('readEffectiveBootTheme falls back to localStorage when no lab override', () => { + localStorage.setItem(THEME_STORAGE_KEY, 'light'); + expect(readEffectiveBootTheme()).toBe('light'); + }); + + it('toggle flips theme, writes localStorage, sets data-theme', () => { + const { result } = renderHook(() => useThemeToggle()); + expect(result.current.theme).toBe('dark'); + + act(() => result.current.toggle({ clientX: 100, clientY: 50 })); + expect(result.current.theme).toBe('light'); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('light'); + expect(document.documentElement.dataset.theme).toBe('light'); + + act(() => result.current.toggle({ clientX: 100, clientY: 50 })); + expect(result.current.theme).toBe('dark'); + expect(localStorage.getItem(THEME_STORAGE_KEY)).toBe('dark'); + expect(document.documentElement.dataset.theme).toBe('dark'); + }); + + it('toggle without click coords still flips theme', () => { + const { result } = renderHook(() => useThemeToggle()); + act(() => result.current.toggle()); + expect(result.current.theme).toBe('light'); + expect(document.documentElement.dataset.theme).toBe('light'); + }); + + it('hydrates from data-theme attribute set pre-mount by App.tsx', () => { + document.documentElement.dataset.theme = 'light'; + localStorage.setItem(THEME_STORAGE_KEY, 'light'); + const { result } = renderHook(() => useThemeToggle()); + expect(result.current.theme).toBe('light'); + }); +}); diff --git a/decnet_web/src/lib/useThemeToggle.ts b/decnet_web/src/lib/useThemeToggle.ts new file mode 100644 index 00000000..62d9fb2d --- /dev/null +++ b/decnet_web/src/lib/useThemeToggle.ts @@ -0,0 +1,112 @@ +import { useCallback, useEffect, useState } from 'react'; + +/* User-facing dark/light theme management. + * + * Two persistence layers: + * - localStorage `decnet_theme` — the user's saved preference. + * Survives reloads, applies to every tab. + * - sessionStorage `decnet_theme_lab` — dev-mode lab override + * (set from /theme-lab). Tab-scoped, wins over localStorage on + * boot so devs can A/B without nuking their saved preference. + * + * Both write the same `data-theme` attribute on . The toggle + * called from the topbar updates localStorage; the lab toggle + * (Task 3) updates sessionStorage. App.tsx hydrates on boot. + * + * Animation: when supported, the swap rides the View Transitions + * API with a circle clip-path that grows from the click point to + * cover the viewport diagonal. Browsers without the API still get + * the theme swap, just without the reveal. */ + +export const THEME_STORAGE_KEY = 'decnet_theme'; +export const LAB_THEME_STORAGE_KEY = 'decnet_theme_lab'; + +export type Theme = 'dark' | 'light'; + +function isTheme(v: unknown): v is Theme { + return v === 'dark' || v === 'light'; +} + +export function readSavedTheme(): Theme { + try { + const v = localStorage.getItem(THEME_STORAGE_KEY); + return isTheme(v) ? v : 'dark'; + } catch { + return 'dark'; + } +} + +export function readEffectiveBootTheme(): Theme { + // sessionStorage (lab) wins over localStorage on boot. + try { + const lab = sessionStorage.getItem(LAB_THEME_STORAGE_KEY); + if (isTheme(lab)) return lab; + } catch { /* ignore */ } + return readSavedTheme(); +} + +interface ViewTransitionDoc { + startViewTransition?: (cb: () => void | Promise) => { + ready: Promise; + finished: Promise; + }; +} + +/* Animate the theme swap with a circle clip-path that grows from + * (x, y) to the farthest viewport corner. Falls back to an + * unanimated swap when View Transitions aren't available. */ +function animateSwap(next: Theme, x: number, y: number): void { + const docVT = document as unknown as ViewTransitionDoc; + const apply = () => { document.documentElement.dataset.theme = next; }; + + if (typeof docVT.startViewTransition !== 'function') { + apply(); + return; + } + + const transition = docVT.startViewTransition(apply)!; + transition.ready.then(() => { + const endRadius = Math.hypot( + Math.max(x, window.innerWidth - x), + Math.max(y, window.innerHeight - y), + ); + document.documentElement.animate( + { + clipPath: [ + `circle(0px at ${x}px ${y}px)`, + `circle(${endRadius}px at ${x}px ${y}px)`, + ], + }, + { + duration: 520, + easing: 'cubic-bezier(0.4, 0, 0.2, 1)', + pseudoElement: '::view-transition-new(root)', + }, + ); + }).catch(() => { /* user-cancelled or unsupported pseudo, ignore */ }); +} + +export function useThemeToggle() { + const [theme, setTheme] = useState(() => readSavedTheme()); + + /* Keep React state aligned with whatever boot-time hydration set + * on , in case App.tsx already wrote the attribute. */ + useEffect(() => { + const fromAttr = document.documentElement.dataset.theme; + if (isTheme(fromAttr) && fromAttr !== theme) { + setTheme(fromAttr); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const toggle = useCallback((e?: { clientX?: number; clientY?: number }) => { + const next: Theme = theme === 'dark' ? 'light' : 'dark'; + const x = e?.clientX ?? window.innerWidth - 32; + const y = e?.clientY ?? 32; + try { localStorage.setItem(THEME_STORAGE_KEY, next); } catch { /* ignore */ } + animateSwap(next, x, y); + setTheme(next); + }, [theme]); + + return { theme, toggle }; +}