UI overhaul "Living Codex" C+D: Lucide icons + left-rail shell
- Replace the top nav with a left rail + top context bar ("GM mission control"):
gilt crown wordmark, nav grouped At the Table / Reference with Lucide icons and a
gold active state (accent-glow + gilt left edge), a collapse toggle
(uiStore.railCollapsed). A single responsive rail = icon strip (68px) on phones /
when collapsed, full labels on >= sm.
- Top bar: campaign switcher, a ⌘K search pill, and every existing control preserved
(PlayerSessionBadge, Session panel toggle, HandoutControl, SessionControl host,
theme toggle as Sun/Moon, Settings) — same aria-labels/testids so e2e is intact.
- bun add lucide-react. Rail is print:hidden (sheets export clean).
223 unit + 34 e2e + 2 realtime green; nav contract (aria-label="Primary", link names) preserved.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,7 @@
|
|||||||
"dexie-react-hooks": "^1.1.7",
|
"dexie-react-hooks": "^1.1.7",
|
||||||
"dompurify": "^3.2.3",
|
"dompurify": "^3.2.3",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
@@ -1026,6 +1027,8 @@
|
|||||||
|
|
||||||
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
"lru-cache": ["lru-cache@11.5.1", "", {}, "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A=="],
|
||||||
|
|
||||||
|
"lucide-react": ["lucide-react@1.17.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w=="],
|
||||||
|
|
||||||
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
"lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="],
|
||||||
|
|
||||||
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
"dexie-react-hooks": "^1.1.7",
|
"dexie-react-hooks": "^1.1.7",
|
||||||
"dompurify": "^3.2.3",
|
"dompurify": "^3.2.3",
|
||||||
"fuse.js": "^7.0.0",
|
"fuse.js": "^7.0.0",
|
||||||
|
"lucide-react": "^1.17.0",
|
||||||
"nanoid": "^5.0.9",
|
"nanoid": "^5.0.9",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
|
|||||||
+134
-55
@@ -1,5 +1,10 @@
|
|||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
import { Link, Outlet, useRouterState } from '@tanstack/react-router';
|
||||||
|
import {
|
||||||
|
Crown, LayoutDashboard, UserRound, Swords, Map as MapIcon, Dices, BookOpenText, RadioTower,
|
||||||
|
LibraryBig, Settings as SettingsIcon, Search, PanelLeftClose, PanelLeftOpen, Sun, Moon, PanelRight,
|
||||||
|
type LucideIcon,
|
||||||
|
} from 'lucide-react';
|
||||||
import { useUiStore } from '@/stores/uiStore';
|
import { useUiStore } from '@/stores/uiStore';
|
||||||
import { CommandPalette } from '@/components/CommandPalette';
|
import { CommandPalette } from '@/components/CommandPalette';
|
||||||
import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks';
|
import { useActiveCampaign, useCampaigns } from '@/features/campaigns/hooks';
|
||||||
@@ -16,14 +21,22 @@ import { PlayerSessionBadge } from '@/features/play/PlayerConnection';
|
|||||||
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
import { SessionSidebar } from '@/features/play/SessionSidebar';
|
||||||
import { useSessionStore } from '@/stores/sessionStore';
|
import { useSessionStore } from '@/stores/sessionStore';
|
||||||
|
|
||||||
const NAV = [
|
type NavItem = { to: string; label: string; icon: LucideIcon; exact: boolean; group: 'top' | 'play' | 'reference' };
|
||||||
{ to: '/', label: 'Campaigns', exact: true },
|
const NAV: NavItem[] = [
|
||||||
{ to: '/dashboard', label: 'Dashboard', exact: false },
|
{ to: '/', label: 'Campaigns', icon: LibraryBig, exact: true, group: 'top' },
|
||||||
{ to: '/characters', label: 'Characters', exact: false },
|
{ to: '/dashboard', label: 'Dashboard', icon: LayoutDashboard, exact: false, group: 'play' },
|
||||||
{ to: '/combat', label: 'Combat', exact: false },
|
{ to: '/characters', label: 'Characters', icon: UserRound, exact: false, group: 'play' },
|
||||||
{ to: '/dice', label: 'Dice', exact: false },
|
{ to: '/combat', label: 'Combat', icon: Swords, exact: false, group: 'play' },
|
||||||
{ to: '/compendium', label: 'Compendium', exact: false },
|
{ to: '/maps', label: 'Battle Map', icon: MapIcon, exact: false, group: 'play' },
|
||||||
] as const;
|
{ to: '/dice', label: 'Dice', icon: Dices, exact: false, group: 'play' },
|
||||||
|
{ to: '/compendium', label: 'Compendium', icon: BookOpenText, exact: false, group: 'reference' },
|
||||||
|
{ to: '/play', label: 'Live Session', icon: RadioTower, exact: false, group: 'reference' },
|
||||||
|
];
|
||||||
|
const GROUPS: { id: NavItem['group']; label: string | null }[] = [
|
||||||
|
{ id: 'top', label: null },
|
||||||
|
{ id: 'play', label: 'At the Table' },
|
||||||
|
{ id: 'reference', label: 'Reference' },
|
||||||
|
];
|
||||||
|
|
||||||
function CampaignSwitcher() {
|
function CampaignSwitcher() {
|
||||||
const campaigns = useCampaigns();
|
const campaigns = useCampaigns();
|
||||||
@@ -55,7 +68,7 @@ function ThemeToggle() {
|
|||||||
const toggle = useUiStore((s) => s.toggleTheme);
|
const toggle = useUiStore((s) => s.toggleTheme);
|
||||||
return (
|
return (
|
||||||
<Button size="icon" variant="ghost" onClick={toggle} aria-label="Toggle light/dark theme" title="Toggle theme">
|
<Button size="icon" variant="ghost" onClick={toggle} aria-label="Toggle light/dark theme" title="Toggle theme">
|
||||||
{theme === 'dark' ? '☀' : '☾'}
|
{theme === 'dark' ? <Sun size={18} aria-hidden /> : <Moon size={18} aria-hidden />}
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -65,6 +78,8 @@ export function RootLayout() {
|
|||||||
const [paletteOpen, setPaletteOpen] = useState(false);
|
const [paletteOpen, setPaletteOpen] = useState(false);
|
||||||
const sessionDockOpen = useUiStore((s) => s.sessionDockOpen);
|
const sessionDockOpen = useUiStore((s) => s.sessionDockOpen);
|
||||||
const setSessionDock = useUiStore((s) => s.setSessionDock);
|
const setSessionDock = useUiStore((s) => s.setSessionDock);
|
||||||
|
const railCollapsed = useUiStore((s) => s.railCollapsed);
|
||||||
|
const toggleRail = useUiStore((s) => s.toggleRail);
|
||||||
const sessionConnected = useSessionStore((s) => s.status === 'connected');
|
const sessionConnected = useSessionStore((s) => s.status === 'connected');
|
||||||
const sessionRole = useSessionStore((s) => s.role);
|
const sessionRole = useSessionStore((s) => s.role);
|
||||||
const joinIntent = useSessionStore((s) => s.joinIntent);
|
const joinIntent = useSessionStore((s) => s.joinIntent);
|
||||||
@@ -94,56 +109,120 @@ export function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [joinIntent, sessionRole, setSessionDock]);
|
}, [joinIntent, sessionRole, setSessionDock]);
|
||||||
|
|
||||||
return (
|
// Labels show only when expanded AND on >= sm; the rail is an icon strip (68px)
|
||||||
<div className="flex h-full flex-col">
|
// on phones and when collapsed, so a single nav serves every size.
|
||||||
<header className="flex items-center gap-4 border-b border-line bg-panel px-4 py-2">
|
const showLabel = !railCollapsed;
|
||||||
<Link to="/" className="font-display text-lg font-bold text-accent">
|
|
||||||
⚔ TTRPG Manager
|
|
||||||
</Link>
|
|
||||||
<nav className="flex items-center gap-1" aria-label="Primary">
|
|
||||||
{NAV.map((item) => {
|
|
||||||
const isActive = item.exact ? pathname === item.to : pathname.startsWith(item.to);
|
|
||||||
return (
|
|
||||||
<Link
|
|
||||||
key={item.to}
|
|
||||||
to={item.to}
|
|
||||||
className={cn(
|
|
||||||
'rounded-md px-3 py-1.5 text-sm transition-colors',
|
|
||||||
isActive ? 'bg-elevated text-accent' : 'text-muted hover:text-ink hover:bg-elevated',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</Link>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<PlayerSessionBadge />
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setSessionDock(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
|
|
||||||
☰ Session{sessionConnected && <span className="ml-1 inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
|
|
||||||
</Button>
|
|
||||||
{activeCampaign && <HandoutControl />}
|
|
||||||
<SessionControl />
|
|
||||||
<Button size="sm" variant="ghost" onClick={() => setPaletteOpen(true)} title="Command palette (Ctrl/Cmd+K)" aria-label="Open command palette">
|
|
||||||
⌘K
|
|
||||||
</Button>
|
|
||||||
<Link to="/settings" className="rounded-md p-1.5 text-sm text-muted hover:text-ink" title="Settings" aria-label="Settings">⚙</Link>
|
|
||||||
<CampaignSwitcher />
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex min-h-0 flex-1">
|
return (
|
||||||
<main className="flex-1 overflow-auto">
|
<>
|
||||||
<ErrorBoundary resetKey={pathname}>
|
<div className="grid h-full grid-cols-[auto_1fr]">
|
||||||
<Outlet />
|
{/* ---- Left nav rail (single, responsive: icon strip on phones / collapsed) ---- */}
|
||||||
</ErrorBoundary>
|
<aside className={cn('paper-grain relative z-20 flex flex-col border-r border-line bg-panel transition-[width] duration-200 print:hidden', railCollapsed ? 'w-[68px]' : 'w-[68px] sm:w-60')}>
|
||||||
</main>
|
<div className="relative z-[1] flex h-full flex-col">
|
||||||
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
|
<Link to="/" className={cn('flex h-[60px] flex-none items-center gap-3 border-b border-line px-4', railCollapsed ? 'justify-center px-0' : 'justify-center px-0 sm:justify-start sm:px-4')} title="TTRPG Manager">
|
||||||
|
<span className="grid h-9 w-9 flex-none place-items-center rounded-lg bg-gradient-to-b from-accent-soft to-accent text-accent-ink shadow-[inset_0_1px_0_rgba(255,255,255,0.3)]">
|
||||||
|
<Crown size={19} strokeWidth={2} aria-hidden />
|
||||||
|
</span>
|
||||||
|
{showLabel && (
|
||||||
|
<span className="hidden min-w-0 leading-tight sm:block">
|
||||||
|
<span className="block truncate font-display text-base font-semibold text-ink">TTRPG Manager</span>
|
||||||
|
<span className="smallcaps block" style={{ fontSize: 9, letterSpacing: '0.22em' }}>The Living Codex</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<nav aria-label="Primary" className="flex-1 overflow-y-auto py-2">
|
||||||
|
{GROUPS.map((group) => (
|
||||||
|
<div key={group.id}>
|
||||||
|
{group.label && showLabel && <div className="smallcaps hidden px-6 pt-4 pb-1.5 sm:block" style={{ fontSize: 9.5 }}>{group.label}</div>}
|
||||||
|
{NAV.filter((n) => n.group === group.id).map((item) => {
|
||||||
|
const active = item.exact ? pathname === item.to : pathname.startsWith(item.to);
|
||||||
|
const Ico = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
title={item.label}
|
||||||
|
className={cn(
|
||||||
|
'relative mx-2.5 my-px flex h-[42px] items-center gap-3 rounded-[10px] text-[14.5px] font-medium transition-colors',
|
||||||
|
'justify-center px-0',
|
||||||
|
showLabel && 'sm:justify-start sm:px-3.5',
|
||||||
|
active ? 'bg-accent-glow font-semibold text-accent-deep' : 'text-muted hover:bg-elevated hover:text-ink',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && <span className="absolute -left-2.5 top-2 bottom-2 w-[3px] rounded-r bg-accent" aria-hidden />}
|
||||||
|
<Ico size={19} strokeWidth={active ? 2 : 1.7} aria-hidden className="flex-none" />
|
||||||
|
{showLabel && <span className="hidden sm:inline">{item.label}</span>}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div className="hidden flex-none border-t border-line p-2.5 sm:block">
|
||||||
|
<button
|
||||||
|
onClick={toggleRail}
|
||||||
|
className={cn('flex h-[42px] w-full items-center gap-3 rounded-[10px] text-[14.5px] font-medium text-muted transition-colors hover:bg-elevated hover:text-ink', railCollapsed ? 'justify-center px-0' : 'justify-start px-3.5')}
|
||||||
|
title={railCollapsed ? 'Expand navigation' : 'Collapse navigation'}
|
||||||
|
aria-label={railCollapsed ? 'Expand navigation' : 'Collapse navigation'}
|
||||||
|
>
|
||||||
|
{railCollapsed ? <PanelLeftOpen size={18} aria-hidden /> : <PanelLeftClose size={18} aria-hidden />}
|
||||||
|
{!railCollapsed && <span>Collapse</span>}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* ---- Workarea: top context bar + canvas ---- */}
|
||||||
|
<div className="flex min-w-0 flex-col">
|
||||||
|
<header className="paper-grain relative z-10 flex h-[60px] flex-none items-center gap-2 border-b border-line bg-panel px-3 sm:px-5">
|
||||||
|
<div className="relative z-[1] flex w-full items-center gap-2">
|
||||||
|
<CampaignSwitcher />
|
||||||
|
<div className="flex-1" />
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => setPaletteOpen(true)}
|
||||||
|
aria-label="Open command palette"
|
||||||
|
title="Command palette (Ctrl/Cmd+K)"
|
||||||
|
className="hidden h-[38px] min-w-44 items-center gap-2 rounded-full border border-line bg-surface px-3 text-muted transition-[border-color,box-shadow] hover:border-accent focus-visible:border-accent focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-accent-glow md:flex"
|
||||||
|
>
|
||||||
|
<Search size={16} aria-hidden />
|
||||||
|
<span className="flex-1 text-left text-sm text-faint">Search the codex…</span>
|
||||||
|
<kbd className="rounded border border-line px-1.5 font-mono text-[10.5px] text-faint">⌘K</kbd>
|
||||||
|
</button>
|
||||||
|
<Button size="icon" variant="ghost" className="md:hidden" onClick={() => setPaletteOpen(true)} aria-label="Open command palette" title="Command palette">
|
||||||
|
<Search size={18} aria-hidden />
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<PlayerSessionBadge />
|
||||||
|
<Button size="sm" variant="subtle" onClick={() => setSessionDock(true)} title="Session panel — who's here, rolls, chat" aria-label="Open session panel">
|
||||||
|
<PanelRight size={15} aria-hidden />
|
||||||
|
<span className="hidden sm:inline">Session</span>
|
||||||
|
{sessionConnected && <span className="inline-block h-1.5 w-1.5 rounded-full bg-success align-middle" />}
|
||||||
|
</Button>
|
||||||
|
{activeCampaign && <HandoutControl />}
|
||||||
|
<SessionControl />
|
||||||
|
<ThemeToggle />
|
||||||
|
<Link to="/settings" className="grid h-9 w-9 flex-none place-items-center rounded-md text-muted hover:bg-elevated hover:text-ink" title="Settings" aria-label="Settings">
|
||||||
|
<SettingsIcon size={18} aria-hidden />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
|
<main className="flex-1 overflow-auto">
|
||||||
|
<ErrorBoundary resetKey={pathname}>
|
||||||
|
<Outlet />
|
||||||
|
</ErrorBoundary>
|
||||||
|
</main>
|
||||||
|
<SessionSidebar open={sessionDockOpen} onClose={() => setSessionDock(false)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<RollTray />
|
<RollTray />
|
||||||
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
{paletteOpen && <CommandPalette onClose={() => setPaletteOpen(false)} />}
|
||||||
</div>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ interface UiState {
|
|||||||
activeHandout: Handout | null;
|
activeHandout: Handout | null;
|
||||||
/** whether the docked session sidebar is open (persisted) */
|
/** whether the docked session sidebar is open (persisted) */
|
||||||
sessionDockOpen: boolean;
|
sessionDockOpen: boolean;
|
||||||
|
/** whether the left nav rail is collapsed to icons (persisted) */
|
||||||
|
railCollapsed: boolean;
|
||||||
setTheme: (theme: Theme) => void;
|
setTheme: (theme: Theme) => void;
|
||||||
toggleTheme: () => void;
|
toggleTheme: () => void;
|
||||||
setActiveCampaign: (id: string | null) => void;
|
setActiveCampaign: (id: string | null) => void;
|
||||||
@@ -24,6 +26,7 @@ interface UiState {
|
|||||||
setActiveMap: (id: string | null) => void;
|
setActiveMap: (id: string | null) => void;
|
||||||
setActiveHandout: (h: Handout | null) => void;
|
setActiveHandout: (h: Handout | null) => void;
|
||||||
setSessionDock: (open: boolean) => void;
|
setSessionDock: (open: boolean) => void;
|
||||||
|
toggleRail: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUiStore = create<UiState>()(
|
export const useUiStore = create<UiState>()(
|
||||||
@@ -35,6 +38,7 @@ export const useUiStore = create<UiState>()(
|
|||||||
activeMapId: null,
|
activeMapId: null,
|
||||||
activeHandout: null,
|
activeHandout: null,
|
||||||
sessionDockOpen: false,
|
sessionDockOpen: false,
|
||||||
|
railCollapsed: false,
|
||||||
setTheme: (theme) => {
|
setTheme: (theme) => {
|
||||||
applyTheme(theme);
|
applyTheme(theme);
|
||||||
set({ theme });
|
set({ theme });
|
||||||
@@ -45,6 +49,7 @@ export const useUiStore = create<UiState>()(
|
|||||||
setActiveMap: (id) => set({ activeMapId: id }),
|
setActiveMap: (id) => set({ activeMapId: id }),
|
||||||
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
setActiveHandout: (activeHandout) => set({ activeHandout }),
|
||||||
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
|
setSessionDock: (sessionDockOpen) => set({ sessionDockOpen }),
|
||||||
|
toggleRail: () => set((s) => ({ railCollapsed: !s.railCollapsed })),
|
||||||
}),
|
}),
|
||||||
{
|
{
|
||||||
name: 'ttrpg-ui',
|
name: 'ttrpg-ui',
|
||||||
|
|||||||
Reference in New Issue
Block a user