Files
DECNET/decnet_web/src/components/Layout.tsx
anti 438a6e3e45 feat(decnet_web/Layout): topbar dark/light toggle with circular reveal
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 <html> 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.
2026-05-09 04:01:24 -04:00

317 lines
12 KiB
TypeScript

import React, { useState, useEffect } from 'react';
import { NavLink, useLocation } from 'react-router-dom';
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, Sun, Moon,
} from '../icons';
import { prefetchRoute } from '../routePrefetch';
import { useThemeToggle } from '../lib/useThemeToggle';
import './Layout.css';
type ThreatLevel = 'nominal' | 'elevated' | 'critical';
interface LayoutProps {
children: React.ReactNode;
onLogout: () => void;
onSearch: (q: string) => void;
onOpenCmd?: () => void;
sector?: string;
persona?: string;
threat?: ThreatLevel;
alertCount?: number;
build?: string;
}
const ROUTE_LABELS: Record<string, string> = {
'/': 'DASHBOARD',
'/fleet': 'FLEET',
'/mazenet': 'MAZENET',
'/live-logs': 'LIVE LOGS',
'/webhooks': 'WEBHOOKS',
'/bounty': 'BOUNTY',
'/credentials': 'CREDENTIALS',
'/attackers': 'ATTACKERS',
'/identities': 'IDENTITIES',
'/campaigns': 'CAMPAIGNS',
'/orchestrator': 'ORCHESTRATOR',
'/persona-generation': 'PERSONA GENERATION',
'/synthetic-files': 'SYNTHETIC FILES',
'/realism-config': 'REALISM CONFIG',
'/canary-tokens': 'CANARY TOKENS',
'/config': 'CONFIG',
'/swarm-updates': 'REMOTE UPDATES',
'/swarm/hosts': 'SWARM HOSTS',
};
function labelForPath(pathname: string): string {
if (ROUTE_LABELS[pathname]) return ROUTE_LABELS[pathname];
const prefix = Object.keys(ROUTE_LABELS).find(p => p !== '/' && pathname.startsWith(p));
return prefix ? ROUTE_LABELS[prefix] : pathname.replace(/^\//, '').toUpperCase();
}
function formatClock(d: Date): string {
const pad = (n: number) => String(n).padStart(2, '0');
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
const Layout: React.FC<LayoutProps> = ({
children,
onLogout,
onSearch,
onOpenCmd,
sector = 'PRODUCTION',
persona = 'ADMIN',
threat: threatProp = 'nominal',
alertCount: alertCountProp = 0,
build = 'v0.1',
}) => {
const [sidebarOpen, setSidebarOpen] = useState(() => {
try { return localStorage.getItem('decnet_sidebar_open') !== 'false'; } catch { return true; }
});
const [search, setSearch] = useState('');
const [systemActive, setSystemActive] = useState(false);
const [clockTime, setClockTime] = useState(() => formatClock(new Date()));
const [threat, setThreat] = useState<ThreatLevel>(threatProp);
const [alertCount, setAlertCount] = useState<number>(alertCountProp);
const location = useLocation();
const handleSearchSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSearch(search);
};
useEffect(() => {
const onStats = (e: Event) => {
const detail = (e as CustomEvent).detail;
setSystemActive(detail.deployed_deckies > 0);
if (detail.threat) setThreat(detail.threat as ThreatLevel);
if (typeof detail.alert_count === 'number') setAlertCount(detail.alert_count);
};
window.addEventListener('decnet:stats', onStats);
return () => window.removeEventListener('decnet:stats', onStats);
}, []);
useEffect(() => {
const iv = setInterval(() => setClockTime(formatClock(new Date())), 1000);
return () => clearInterval(iv);
}, []);
const routeLabel = labelForPath(location.pathname);
const showThreat = threat !== 'nominal';
const threatLabel = threat.toUpperCase();
return (
<div className="layout-container">
{/* Sidebar */}
<aside className={`sidebar ${sidebarOpen ? 'open' : 'closed'}`}>
<div className="sidebar-header">
<Activity size={24} className="violet-accent" />
{sidebarOpen && <span className="logo-text">DECNET</span>}
<button className="toggle-btn" onClick={() => setSidebarOpen(v => {
const next = !v;
try { localStorage.setItem('decnet_sidebar_open', String(next)); } catch {}
return next;
})}>
{sidebarOpen ? <X size={20} /> : <Menu size={20} />}
</button>
</div>
<nav className="sidebar-nav">
<NavItem to="/" icon={<LayoutDashboard size={20} />} label="Dashboard" open={sidebarOpen} />
<NavGroup label="DEPLOY" icon={<Server size={20} />} open={sidebarOpen}>
<NavItem to="/fleet" icon={<Server size={18} />} label="Decoy Fleet" open={sidebarOpen} indent />
<NavItem to="/mazenet" icon={<Network size={18} />} label="MazeNET" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="ALERTS" icon={<Bell size={20} />} open={sidebarOpen}>
<NavItem
to="/live-logs"
icon={<Terminal size={18} />}
label="Live Logs"
open={sidebarOpen}
indent
badge={alertCount}
/>
<NavItem
to="/webhooks"
icon={<Webhook size={18} />}
label="Webhooks"
open={sidebarOpen}
indent
/>
</NavGroup>
<NavGroup label="THREAT DATA" icon={<Activity size={20} />} open={sidebarOpen}>
<NavItem to="/attackers" icon={<Activity size={18} />} label="Attackers" open={sidebarOpen} indent />
<NavItem to="/identities" icon={<Fingerprint size={18} />} label="Identities" open={sidebarOpen} indent />
<NavItem to="/campaigns" icon={<Crosshair size={18} />} label="Campaigns" open={sidebarOpen} indent />
<NavItem to="/credentials" icon={<Lock size={18} />} label="Credentials" open={sidebarOpen} indent />
<NavItem to="/bounty" icon={<Archive size={18} />} label="Bounty" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="AUTOMATION" icon={<Zap size={20} />} open={sidebarOpen}>
<NavItem to="/orchestrator" icon={<Cpu size={18} />} label="Orchestrator" open={sidebarOpen} indent />
<NavItem to="/persona-generation" icon={<Mail size={18} />} label="Persona Generation" open={sidebarOpen} indent />
<NavItem to="/synthetic-files" icon={<FileText size={18} />} label="Synthetic Files" open={sidebarOpen} indent />
<NavItem to="/realism-config" icon={<Sliders size={18} />} label="Realism Config" open={sidebarOpen} indent />
<NavItem to="/canary-tokens" icon={<Target size={18} />} label="Canary Tokens" open={sidebarOpen} indent />
</NavGroup>
<NavGroup label="SWARM" icon={<Network size={20} />} open={sidebarOpen}>
<NavItem to="/swarm/hosts" icon={<HardDrive size={18} />} label="SWARM Hosts" open={sidebarOpen} indent />
<NavItem to="/swarm-updates" icon={<Package size={18} />} label="Remote Updates" open={sidebarOpen} indent />
</NavGroup>
<NavItem to="/config" icon={<Settings size={20} />} label="Config" open={sidebarOpen} />
</nav>
<div className="sidebar-footer">
<button className="logout-btn" onClick={onLogout}>
<LogOut size={20} />
{sidebarOpen && <span>Logout</span>}
</button>
{sidebarOpen && (
<div className="sidebar-meta">
<div>SECTOR · {sector.toUpperCase()}</div>
<div>OPERATOR · {persona.toUpperCase()}</div>
<div>BUILD · {build.toUpperCase()}</div>
</div>
)}
</div>
</aside>
{/* Main Content Area */}
<main className="main-content">
{/* Topbar */}
<header className="topbar">
<div className="topbar-left">
<div className="crumbs">
<span className="crumb-sector">{sector.toUpperCase()}</span>
<span className="sep">/</span>
<span>{routeLabel}</span>
</div>
<form onSubmit={handleSearchSubmit} className="search-container">
<Search size={18} className="search-icon" />
<input
type="text"
placeholder="Search logs, deckies, IPs..."
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => onOpenCmd?.()}
/>
<span className="search-kbd">Alt+K</span>
</form>
</div>
<div className="topbar-right">
{showThreat && (
<div className="threat-level" title={`Threat: ${threatLabel}`}>
<span className="dot" />
<ShieldAlert size={12} />
<span>THREAT: {threatLabel}</span>
</div>
)}
<ThemeToggleButton />
<div className="topbar-status">
<span
className="matrix-text"
style={{ color: systemActive ? 'var(--text-color)' : 'var(--accent-color)' }}
>
SYSTEM: {systemActive ? 'ACTIVE' : 'INACTIVE'}
</span>
</div>
<div className="topbar-clock">{clockTime}</div>
</div>
</header>
{/* Dynamic Content */}
<div className="content-viewport">
{children}
</div>
</main>
</div>
);
};
/* 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 (
<button
type="button"
className="theme-toggle-btn"
onClick={(e) => toggle({ clientX: e.clientX, clientY: e.clientY })}
aria-label={`Switch to ${isLight ? 'dark' : 'light'} mode`}
title={`Switch to ${isLight ? 'dark' : 'light'} mode`}
>
{isLight ? <Moon size={14} /> : <Sun size={14} />}
</button>
);
};
interface NavItemProps {
to: string;
icon: React.ReactNode;
label: string;
open: boolean;
indent?: boolean;
badge?: number;
}
const NavItem: React.FC<NavItemProps> = ({ to, icon, label, open, indent, badge }) => (
<NavLink
to={to}
className={({ isActive }) => `nav-item ${isActive ? 'active' : ''} ${indent ? 'nav-subitem' : ''}`}
end={to === '/'}
onMouseEnter={() => prefetchRoute(to)}
onFocus={() => prefetchRoute(to)}
>
{icon}
{open && <span className="nav-label">{label}</span>}
{open && badge !== undefined && badge > 0 && (
<span className="nav-badge">{badge > 99 ? '99+' : badge}</span>
)}
</NavLink>
);
interface NavGroupProps {
label: string;
icon: React.ReactNode;
open: boolean;
children: React.ReactNode;
}
const NavGroup: React.FC<NavGroupProps> = ({ label, icon, open, children }) => {
const storageKey = `decnet_navgroup_${label.toLowerCase()}`;
const [expanded, setExpanded] = useState(() => {
try { return localStorage.getItem(storageKey) === 'true'; } catch { return false; }
});
const toggle = () => setExpanded(v => {
const next = !v;
try { localStorage.setItem(storageKey, String(next)); } catch {}
return next;
});
return (
<div className="nav-group">
<button
type="button"
className="nav-item nav-group-toggle"
onClick={toggle}
>
{icon}
{open && (
<>
<span className="nav-label">{label}</span>
<span className="nav-group-chevron">
{expanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</span>
</>
)}
</button>
{expanded && <div className="nav-group-children">{children}</div>}
</div>
);
};
export default Layout;