import { useState, useEffect } from 'react'; import { useRouter } from 'next/router'; import Link from 'next/link'; import { useTheme } from '../lib/theme-context'; import MobileNavigation from './MobileNavigation'; import DailyEmberWidget from './DailyEmberWidget'; import TopSearchBar from './ui/TopSearchBar'; import CommandPaletteModal from './ui/CommandPaletteModal'; import { VOCAB } from '../lib/collection-vocabulary.js'; // User Profile Dropdown Component function UserProfileDropdown({ user, onMobileMenuClose }) { // Hook order is fixed for both branches; do not move this below the // null-user early return — see rules-of-hooks (AGENTS.md Gotcha #11.5). const [isDropdownOpen, setIsDropdownOpen] = useState(false); // Logged-out: replace avatar + email + dropdown with a Sign-in CTA. if (!user) { return (
Sign in
); } const profileMenuItems = [ { name: 'Profile', href: '/profile', icon: 'user' }, { name: 'Settings', href: '/settings', icon: 'settings' }, ...(user?.role === 'admin' ? [ { name: 'Admin Tools', href: '/admin', icon: 'admin' } ] : []), { name: 'Logout', href: '/logout', icon: 'logout', isLogout: true } ]; const getProfileIcon = (iconName) => { const icons = { user: ( ), settings: ( ), admin: ( ), logout: ( ) }; return icons[iconName] || icons.user; }; return (
{/* Dropdown Menu */} {isDropdownOpen && ( <> {/* Backdrop */}
setIsDropdownOpen(false)} /> {/* Menu — Liquid Glass popover, high-tint w/ ember-subtle rim. */}
{profileMenuItems.map((item) => (
{ setIsDropdownOpen(false); onMobileMenuClose(); }} > {item.name}
))}
)} {/* Profile Button */}
); } // Navigation Content Component - Shared between desktop and mobile function NavigationContent({ user, router, onItemClick }) { const [isCommunityExpanded, setIsCommunityExpanded] = useState( router.pathname.startsWith('/community') ); const [lastCommunityPath, setLastCommunityPath] = useState(router.pathname); if ( router.pathname.startsWith('/community') && router.pathname !== lastCommunityPath ) { setLastCommunityPath(router.pathname); if (!isCommunityExpanded) { setIsCommunityExpanded(true); } } else if (router.pathname !== lastCommunityPath) { setLastCommunityPath(router.pathname); } // Flat product nav — dashboard-home-realignment convoy (2026-08-15). // Primary destinations for signed-in users; secondary catalog + // community below a divider. Admin lives in TopSearchBar UserMenu // only — not duplicated here. const primaryNavigation = user ? [ { name: 'Dashboard', href: '/dashboard', icon: 'grid', active: router.pathname === '/dashboard', }, { name: VOCAB.MY_COLLECTION, href: '/my-cards', icon: 'collection', active: router.pathname === '/my-cards', }, { name: VOCAB.LISTS, href: '/collections', icon: 'lists', active: router.pathname === '/collections' || router.pathname.startsWith('/collection/'), }, { name: 'Decks', href: '/decks', icon: 'deck', active: router.pathname === '/decks' || router.pathname.startsWith('/deck/'), }, { name: 'Designer', href: '/my-designs', icon: 'designer', active: router.pathname === '/designer' || router.pathname === '/my-designs', }, { name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner', }, ] : []; const secondaryNavigation = [ { name: 'Cards', href: '/cards', icon: 'card', active: router.pathname === '/cards', }, ...(user ? [] : [ { name: 'Scanner', href: '/scanner', icon: 'scanner', active: router.pathname === '/scanner', }, ]), ]; const communityNavigation = { name: 'Community', icon: 'community', active: router.pathname.startsWith('/community'), expanded: isCommunityExpanded, items: [ { name: 'Lists', href: '/community/collections', active: router.pathname === '/community/collections' }, { name: 'Decks', href: '/community/decks', active: router.pathname === '/community/decks' }, { name: 'Forums', href: '/community/forums', active: router.pathname === '/community/forums' } ] }; const getIcon = (iconName) => { const icons = { grid: ( ), collection: ( ), lists: ( ), card: ( ), deck: ( ), analytics: ( ), community: ( ), settings: ( ), logout: ( ), admin: ( ), notifications: ( ), activity: ( ), scanner: ( ), designer: ( ) }; return icons[iconName] || icons.grid; }; const renderNavLink = (item) => (
{item.name}
); return ( <> {primaryNavigation.map(renderNavLink)} {primaryNavigation.length > 0 && (
)} {secondaryNavigation.map(renderNavLink)}
setIsCommunityExpanded(!isCommunityExpanded)} role="menuitem" aria-expanded={isCommunityExpanded} aria-haspopup="menu" tabIndex={0} onKeyDown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); setIsCommunityExpanded(!isCommunityExpanded); } }} >
{communityNavigation.name}
{/* Community Sub-items */} {isCommunityExpanded && (
{communityNavigation.items.map((subItem) => (
{ if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onItemClick(); } }} > {subItem.name}
))}
)}
); } export default function Layout({ children, user = null, showSearch = false, chrome = 'default', }) { const router = useRouter(); const { theme, toggleTheme } = useTheme(); const isImmersive = chrome === 'immersive'; const [searchQuery, setSearchQuery] = useState(''); const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false); // Command palette (redesign-v2 sub-convoy #3, 2026-06-04). Opens // globally on ⌘K / Ctrl+K for authenticated users. The keyboard // listener is attached at Layout-scope (not _app.js) so it doesn't // fire on the public marketing pages that don't mount . const [isCommandPaletteOpen, setIsCommandPaletteOpen] = useState(false); useEffect(() => { if (!user) return undefined; const handler = (event) => { const isShortcut = (event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'k'; if (isShortcut) { event.preventDefault(); setIsCommandPaletteOpen((prev) => !prev); } }; document.addEventListener('keydown', handler); return () => document.removeEventListener('keydown', handler); }, [user]); // Mouse-tracking radial spotlight for nav items (redesign-v2 // spotlight-hover refinement, 2026-06-04). Delegated single // mousemove listener at the document level — cheaper than // attaching per-item React onMouseMove handlers to the 10+ // nav surfaces in NavigationContent. Writes --mouse-x and // --mouse-y as percentages onto the closest matching element; // the CSS ::before in globals.css consumes those vars to // position the radial-gradient. closest() returns null when // the cursor isn't over a nav item, which short-circuits 99% // of pointer events so the listener cost is negligible. // prefers-reduced-motion users still see the bg-color hover // tint (the ::before opacity transition is collapsed to 0.01ms // by the global reduced-motion sweep, but the spotlight itself // is visually static once the mouse stops moving — that's a // positional update, not an animation, so this respects WCAG // SC 2.3.3 without special-casing). useEffect(() => { const selector = '.nav-item, .nav-item-bottom, .nav-item-hover'; const handler = (event) => { const el = event.target.closest?.(selector); if (!el) return; const rect = el.getBoundingClientRect(); if (rect.width === 0 || rect.height === 0) return; const x = ((event.clientX - rect.left) / rect.width) * 100; const y = ((event.clientY - rect.top) / rect.height) * 100; el.style.setProperty('--mouse-x', `${x}%`); el.style.setProperty('--mouse-y', `${y}%`); }; document.addEventListener('mousemove', handler); return () => document.removeEventListener('mousemove', handler); }, []); return ( // Outer shell — `md:p-4 md:gap-4` pulls both the sidebar and the // main column in from the viewport edges so the sidebar and the // top header read as floating chrome chips (operator feedback // 2026-06-04: "pull out the left nav to make it look like it is // its own floating section versus attached to the top-left"). The // `md:gap-4` on this row also creates the visible page-body // background between the sidebar's right edge and the main // column, which replaces the previous hard divider — "all feels // like one large page" is achieved by the hearth gradient // showing through the gap. Mobile (< 768px) keeps the current // flush layout because the sidebar is `hidden md:flex` anyway.
{/* Mobile Navigation - Bottom bar for mobile */} {!isImmersive && ( setIsMobileMenuOpen(true)} /> )} {/* Mobile Overlay — Liquid Glass scrim consistent with . */} {isMobileMenuOpen && (
setIsMobileMenuOpen(false)} /> )} {/* Mobile Menu Drawer - Slides in from left when "More" is tapped */}
{/* Mobile Header with Close Button */}

Deck Hearth

{/* Mobile Navigation Content */} {/* Mobile Bottom Section. 2026-06-04 dropdown-migration pass: Support + Theme moved into the TopSearchBar dropdown for authenticated users (the avatar chip in the top bar is reachable from mobile too — the chevron/name are hidden via `md:` but the chip itself is always visible). Logged-out visitors keep the Sign-in CTA + Support + Theme here since they have no top-bar menu. */} {!user && (
setIsMobileMenuOpen(false)} />
)}
{/* Desktop Sidebar — Hidden on mobile. 2026-06-04 split-into-two- chips pass: per operator feedback ("make the navigation its own section, separated from the badges and progress"), the sidebar column now holds TWO independent floating glass chips with a visible body-gradient gap between them: 1. nav-chip (flex-1) — logo, wordmark, NavigationContent, and the Sign-in CTA when logged-out. 2. bottom-chip — for authenticated users this is the (which is already a glass-panel and renders as its own chip without further wrapping). For logged-out visitors, this is a small glass-panel that holds the Support + Theme icon row (since logged-out users have no TopSearchBar dropdown to host those). Support + Theme were removed from the authenticated path entirely — they live in TopSearchBar's dropdown now. The shared `boxShadow` stack is rim-light-inner + elevation-ambient (matches the prior floating-chrome pass — rim-light-outer stays dropped so there's no 1px ring that would re-introduce a divider against the body gradient). */} {/* Main Content. `md:gap-4` adds vertical breathing room between the floating TopSearchBar header and the page content so they read as two distinct floating chrome elements over the body gradient (rather than the header sitting flush against the content). `min-w-0` prevents flex children from blowing past the column width when long card titles or table cells refuse to wrap. */}
{/* Global top bar — redesign-v2 sub-convoy #3 (2026-06-04). Rendered for ALL authenticated pages so the search + notifications + user-menu chrome is consistent everywhere (operator decision § 7.3 of the umbrella convoy: "sweep to all authenticated pages in this convoy"). Unauthenticated visitors see no top bar — public landing has its own marketing header. The legacy `showSearch` prop is honored via fall-through but no longer drives visibility; it can be removed in a cleanup convoy along with the dead `searchQuery` state. */} {user && (
setIsCommandPaletteOpen(true)} />
)} {/* Page Content */}
{children}
setIsCommandPaletteOpen(false)} />
); }