refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
and progress"
- "move the help and theme toggle into the profile drop-down"
Changes:
components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
The sidebar column is now <aside className="md:flex md:flex-col
md:gap-4"> holding two independent floating glass chips:
1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
wordmark, NavigationContent, and the Sign-in CTA for logged-
out visitors only.
2. bottom-chip — for authenticated users this is the
<DailyEmberWidget /> (already a self-contained glass-panel).
For logged-out, it's a small glass-panel containing the
Support + Theme icon row so those affordances stay reachable
when there's no TopSearchBar dropdown.
The visible body-gradient gap between the two chips is the new
separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
`!user`. Authenticated mobile users access the same affordances
via the TopSearchBar avatar chip (visible on all viewports; the
name/chevron portion is hidden on < md but the chip itself stays).
components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
a real <UserMenu> dropdown sub-component:
- Profile, Settings, Admin Panel (admins only), Help & Support,
Theme toggle (button — toggles theme + closes menu),
separator, Logout.
- role="menu" + menuitem children; aria-haspopup + aria-expanded
on the trigger; Escape key closes; click-outside scrim closes.
- Uses useTheme() so the menu label flips between "Dark mode" /
"Light mode" with the matching sun/moon icon.
- Glass-surface-high background + rim-light + ember-rim-subtle
+ elevation-pronounced shadow stack so it reads as a popover
above the floating header chip.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:16:09 -04:00
|
|
|
import { useEffect, useState } from 'react';
|
2026-06-04 12:14:50 -04:00
|
|
|
import Link from 'next/link';
|
refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
and progress"
- "move the help and theme toggle into the profile drop-down"
Changes:
components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
The sidebar column is now <aside className="md:flex md:flex-col
md:gap-4"> holding two independent floating glass chips:
1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
wordmark, NavigationContent, and the Sign-in CTA for logged-
out visitors only.
2. bottom-chip — for authenticated users this is the
<DailyEmberWidget /> (already a self-contained glass-panel).
For logged-out, it's a small glass-panel containing the
Support + Theme icon row so those affordances stay reachable
when there's no TopSearchBar dropdown.
The visible body-gradient gap between the two chips is the new
separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
`!user`. Authenticated mobile users access the same affordances
via the TopSearchBar avatar chip (visible on all viewports; the
name/chevron portion is hidden on < md but the chip itself stays).
components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
a real <UserMenu> dropdown sub-component:
- Profile, Settings, Admin Panel (admins only), Help & Support,
Theme toggle (button — toggles theme + closes menu),
separator, Logout.
- role="menu" + menuitem children; aria-haspopup + aria-expanded
on the trigger; Escape key closes; click-outside scrim closes.
- Uses useTheme() so the menu label flips between "Dark mode" /
"Light mode" with the matching sun/moon icon.
- Glass-surface-high background + rim-light + ember-rim-subtle
+ elevation-pronounced shadow stack so it reads as a popover
above the floating header chip.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:16:09 -04:00
|
|
|
import { useTheme } from '../../lib/theme-context';
|
2026-06-04 12:14:50 -04:00
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* TopSearchBar — the top horizontal chrome strip from the operator's
|
|
|
|
|
* redesign-v2 mockup.
|
|
|
|
|
*
|
|
|
|
|
* Layout: prominent search input on the left (with magnifier + Cmd+K
|
|
|
|
|
* hint pill), then 3 right-side widgets — notification bell (with red
|
|
|
|
|
* badge when there are unread items), mail/inbox icon, and a compact
|
|
|
|
|
* user-menu chip (avatar + name + chevron).
|
|
|
|
|
*
|
|
|
|
|
* Mounted globally inside <Layout> for authenticated users; renders
|
|
|
|
|
* as a no-op `null` for unauthenticated visitors (the public landing
|
|
|
|
|
* uses its own marketing header).
|
|
|
|
|
*
|
|
|
|
|
* Props:
|
|
|
|
|
* user { username, email, avatar_url, ... }
|
|
|
|
|
* notificationCount number — drives the bell badge; 0 hides it
|
|
|
|
|
* onOpenCommandPalette () => void — fires when user clicks the
|
|
|
|
|
* search input or presses ⌘K (the global
|
|
|
|
|
* keydown listener lives in <Layout>).
|
|
|
|
|
*
|
|
|
|
|
* Implementation notes:
|
|
|
|
|
* - The search input is intentionally read-only-ish: clicking or
|
|
|
|
|
* focusing it opens the CommandPaletteModal instead of typing
|
|
|
|
|
* inline. This matches Linear / Notion / Vercel's pattern and
|
|
|
|
|
* avoids two competing search affordances on the page.
|
|
|
|
|
* - The "Cmd+K" hint adapts to the user's platform: ⌘K on Mac,
|
|
|
|
|
* Ctrl+K elsewhere. Detection is best-effort via navigator.platform
|
|
|
|
|
* and gracefully defaults to ⌘ on the server (matches the Mac
|
|
|
|
|
* audience's default expectation).
|
refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
and progress"
- "move the help and theme toggle into the profile drop-down"
Changes:
components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
The sidebar column is now <aside className="md:flex md:flex-col
md:gap-4"> holding two independent floating glass chips:
1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
wordmark, NavigationContent, and the Sign-in CTA for logged-
out visitors only.
2. bottom-chip — for authenticated users this is the
<DailyEmberWidget /> (already a self-contained glass-panel).
For logged-out, it's a small glass-panel containing the
Support + Theme icon row so those affordances stay reachable
when there's no TopSearchBar dropdown.
The visible body-gradient gap between the two chips is the new
separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
`!user`. Authenticated mobile users access the same affordances
via the TopSearchBar avatar chip (visible on all viewports; the
name/chevron portion is hidden on < md but the chip itself stays).
components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
a real <UserMenu> dropdown sub-component:
- Profile, Settings, Admin Panel (admins only), Help & Support,
Theme toggle (button — toggles theme + closes menu),
separator, Logout.
- role="menu" + menuitem children; aria-haspopup + aria-expanded
on the trigger; Escape key closes; click-outside scrim closes.
- Uses useTheme() so the menu label flips between "Dark mode" /
"Light mode" with the matching sun/moon icon.
- Glass-surface-high background + rim-light + ember-rim-subtle
+ elevation-pronounced shadow stack so it reads as a popover
above the floating header chip.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:16:09 -04:00
|
|
|
* - User menu (avatar + name + chevron) opens a real dropdown
|
|
|
|
|
* (see <UserMenu> below) with Profile, Settings, Admin (when
|
|
|
|
|
* applicable), Help & Support, Theme toggle, and Logout. The
|
|
|
|
|
* Help and Theme entries moved here from the desktop sidebar's
|
|
|
|
|
* bottom icon row 2026-06-04 per operator feedback.
|
2026-06-04 12:14:50 -04:00
|
|
|
*/
|
|
|
|
|
function isMacPlatform() {
|
|
|
|
|
if (typeof window === 'undefined' || !window.navigator) return true;
|
|
|
|
|
return /Mac|iPhone|iPad|iPod/.test(window.navigator.platform);
|
|
|
|
|
}
|
|
|
|
|
|
refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
and progress"
- "move the help and theme toggle into the profile drop-down"
Changes:
components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
The sidebar column is now <aside className="md:flex md:flex-col
md:gap-4"> holding two independent floating glass chips:
1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
wordmark, NavigationContent, and the Sign-in CTA for logged-
out visitors only.
2. bottom-chip — for authenticated users this is the
<DailyEmberWidget /> (already a self-contained glass-panel).
For logged-out, it's a small glass-panel containing the
Support + Theme icon row so those affordances stay reachable
when there's no TopSearchBar dropdown.
The visible body-gradient gap between the two chips is the new
separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
`!user`. Authenticated mobile users access the same affordances
via the TopSearchBar avatar chip (visible on all viewports; the
name/chevron portion is hidden on < md but the chip itself stays).
components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
a real <UserMenu> dropdown sub-component:
- Profile, Settings, Admin Panel (admins only), Help & Support,
Theme toggle (button — toggles theme + closes menu),
separator, Logout.
- role="menu" + menuitem children; aria-haspopup + aria-expanded
on the trigger; Escape key closes; click-outside scrim closes.
- Uses useTheme() so the menu label flips between "Dark mode" /
"Light mode" with the matching sun/moon icon.
- Glass-surface-high background + rim-light + ember-rim-subtle
+ elevation-pronounced shadow stack so it reads as a popover
above the floating header chip.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:16:09 -04:00
|
|
|
/**
|
|
|
|
|
* UserMenu — the dropdown that opens from the top-right avatar chip.
|
|
|
|
|
*
|
|
|
|
|
* Hosts the canonical authenticated-user menu items (Profile, Settings,
|
|
|
|
|
* Admin Panel for admins, Help & Support, Theme toggle, Logout). The
|
|
|
|
|
* Help and Theme entries moved here 2026-06-04 per operator feedback
|
|
|
|
|
* ("move the help and theme toggle into the profile drop-down") — the
|
|
|
|
|
* sidebar's bottom icon row is gone for authenticated users now, so
|
|
|
|
|
* this menu is the canonical entry point for both.
|
|
|
|
|
*
|
|
|
|
|
* The Theme entry is a button (not a Link) because toggling theme is
|
|
|
|
|
* a side-effect with no destination route. Clicking it flips the
|
|
|
|
|
* theme AND closes the menu, matching the "act and dismiss" feel of
|
|
|
|
|
* the other items. Profile / Settings / Admin / Support / Logout are
|
|
|
|
|
* Links and close the menu on click via onClick.
|
|
|
|
|
*
|
|
|
|
|
* a11y:
|
|
|
|
|
* - The trigger button has aria-haspopup="menu" + aria-expanded.
|
|
|
|
|
* - The dropdown is role="menu" with menuitem children.
|
|
|
|
|
* - Escape closes; click-outside closes.
|
|
|
|
|
* - The focus model is intentionally simple (no roving tabindex);
|
|
|
|
|
* each menuitem is in the natural tab order so Tab/Shift+Tab
|
|
|
|
|
* navigates between them — matches the existing sidebar
|
|
|
|
|
* UserProfileDropdown's pattern (Layout.js).
|
|
|
|
|
*/
|
|
|
|
|
function UserMenu({ user }) {
|
|
|
|
|
const { theme, toggleTheme } = useTheme();
|
|
|
|
|
const [open, setOpen] = useState(false);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!open) return undefined;
|
|
|
|
|
const onKeyDown = (event) => {
|
|
|
|
|
if (event.key === 'Escape') {
|
|
|
|
|
setOpen(false);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
document.addEventListener('keydown', onKeyDown);
|
|
|
|
|
return () => document.removeEventListener('keydown', onKeyDown);
|
|
|
|
|
}, [open]);
|
|
|
|
|
|
|
|
|
|
const initial = (user.username || user.email || 'U').charAt(0).toUpperCase();
|
|
|
|
|
const displayName =
|
|
|
|
|
user.username || user.email?.split('@')[0] || 'Account';
|
|
|
|
|
const isAdmin = user?.role === 'admin';
|
|
|
|
|
|
|
|
|
|
const close = () => setOpen(false);
|
|
|
|
|
|
|
|
|
|
const menuItems = [
|
|
|
|
|
{ id: 'profile', label: 'Profile', href: '/profile', icon: 'user' },
|
|
|
|
|
{ id: 'settings', label: 'Settings', href: '/settings', icon: 'settings' },
|
|
|
|
|
...(isAdmin
|
|
|
|
|
? [{ id: 'admin', label: 'Admin Panel', href: '/admin', icon: 'admin' }]
|
|
|
|
|
: []),
|
|
|
|
|
{ id: 'support', label: 'Help & Support', href: '/support', icon: 'support' },
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
const renderIcon = (name) => {
|
|
|
|
|
const iconProps = {
|
|
|
|
|
className: 'h-4 w-4 flex-shrink-0',
|
|
|
|
|
fill: 'none',
|
|
|
|
|
stroke: 'currentColor',
|
|
|
|
|
viewBox: '0 0 24 24',
|
|
|
|
|
'aria-hidden': true,
|
|
|
|
|
};
|
|
|
|
|
switch (name) {
|
|
|
|
|
case 'user':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'settings':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'admin':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'support':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.228 9c.549-1.165 2.03-2 3.772-2 2.21 0 4 1.343 4 3 0 1.4-1.278 2.575-3.006 2.907-.542.104-.994.54-.994 1.093m0 3h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'logout':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'sun':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
case 'moon':
|
|
|
|
|
return (
|
|
|
|
|
<svg {...iconProps}>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
|
|
|
|
|
</svg>
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="relative">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={() => setOpen((prev) => !prev)}
|
|
|
|
|
className="flex items-center gap-2 pl-2 pr-3 py-1.5 rounded-xl transition-colors focus:outline-none focus:ring-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'var(--text-primary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
}}
|
|
|
|
|
aria-haspopup="menu"
|
|
|
|
|
aria-expanded={open}
|
|
|
|
|
aria-label="Account menu"
|
|
|
|
|
>
|
|
|
|
|
<div
|
|
|
|
|
className="w-8 h-8 rounded-full flex items-center justify-center text-sm font-bold flex-shrink-0"
|
|
|
|
|
style={{
|
|
|
|
|
background:
|
|
|
|
|
'linear-gradient(135deg, rgb(255, 140, 30) 0%, rgb(216, 67, 21) 100%)',
|
|
|
|
|
color: 'rgb(255, 255, 255)',
|
|
|
|
|
boxShadow:
|
|
|
|
|
'0 1px 4px rgba(255, 110, 0, 0.40), inset 0 1px 0 rgba(255,255,255,0.20)',
|
|
|
|
|
}}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
{initial}
|
|
|
|
|
</div>
|
|
|
|
|
<span className="hidden md:inline text-sm font-medium truncate max-w-[10rem]">
|
|
|
|
|
{displayName}
|
|
|
|
|
</span>
|
|
|
|
|
<svg
|
|
|
|
|
className={`hidden md:inline h-4 w-4 flex-shrink-0 transition-transform duration-200 ${
|
|
|
|
|
open ? 'rotate-180' : ''
|
|
|
|
|
}`}
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
|
|
|
|
|
</svg>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{open && (
|
|
|
|
|
<>
|
|
|
|
|
{/* Click-outside scrim. fixed inset-0 catches taps anywhere
|
|
|
|
|
outside the menu. z-index sits between the menu (z-30)
|
|
|
|
|
and normal page content. */}
|
|
|
|
|
<div
|
|
|
|
|
className="fixed inset-0 z-20"
|
|
|
|
|
onClick={close}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
/>
|
|
|
|
|
<div
|
|
|
|
|
role="menu"
|
|
|
|
|
aria-label="Account menu"
|
|
|
|
|
className="absolute right-0 top-full mt-2 w-56 rounded-xl z-30 overflow-hidden"
|
|
|
|
|
style={{
|
|
|
|
|
background: 'var(--glass-surface-high)',
|
|
|
|
|
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
|
|
|
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))',
|
|
|
|
|
boxShadow:
|
|
|
|
|
'var(--rim-light-inner), var(--ember-rim-subtle), var(--elevation-pronounced)',
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="py-1">
|
|
|
|
|
{menuItems.map((item) => (
|
|
|
|
|
<Link key={item.id} href={item.href} passHref legacyBehavior>
|
|
|
|
|
<a
|
|
|
|
|
role="menuitem"
|
|
|
|
|
onClick={close}
|
|
|
|
|
className="flex items-center gap-3 px-3 py-2 text-sm transition-colors nav-item-hover"
|
|
|
|
|
style={{ color: 'var(--text-primary)' }}
|
|
|
|
|
>
|
|
|
|
|
{renderIcon(item.icon)}
|
|
|
|
|
<span className="font-medium">{item.label}</span>
|
|
|
|
|
</a>
|
|
|
|
|
</Link>
|
|
|
|
|
))}
|
|
|
|
|
|
|
|
|
|
{/* Theme toggle — button, not Link, because there's no
|
|
|
|
|
destination route. Closes the menu after toggling to
|
|
|
|
|
match the act-and-dismiss feel of the other items. */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
role="menuitem"
|
|
|
|
|
onClick={() => {
|
|
|
|
|
toggleTheme();
|
|
|
|
|
close();
|
|
|
|
|
}}
|
|
|
|
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm transition-colors nav-item-hover"
|
|
|
|
|
style={{ color: 'var(--text-primary)' }}
|
|
|
|
|
>
|
|
|
|
|
{renderIcon(theme === 'light' ? 'moon' : 'sun')}
|
|
|
|
|
<span className="font-medium">
|
|
|
|
|
{theme === 'light' ? 'Dark mode' : 'Light mode'}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
{/* Visual divider before the destructive Logout entry. */}
|
|
|
|
|
<div
|
|
|
|
|
role="separator"
|
|
|
|
|
className="my-1 mx-3 h-px"
|
|
|
|
|
style={{ backgroundColor: 'var(--border)' }}
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
<Link href="/logout" passHref legacyBehavior>
|
|
|
|
|
<a
|
|
|
|
|
role="menuitem"
|
|
|
|
|
onClick={close}
|
|
|
|
|
className="flex items-center gap-3 px-3 py-2 text-sm transition-colors nav-item-hover"
|
|
|
|
|
style={{ color: 'var(--accent-ember)' }}
|
|
|
|
|
>
|
|
|
|
|
{renderIcon('logout')}
|
|
|
|
|
<span className="font-medium">Logout</span>
|
|
|
|
|
</a>
|
|
|
|
|
</Link>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-04 12:14:50 -04:00
|
|
|
export default function TopSearchBar({
|
|
|
|
|
user,
|
|
|
|
|
notificationCount = 0,
|
|
|
|
|
onOpenCommandPalette,
|
|
|
|
|
}) {
|
|
|
|
|
const [hovered, setHovered] = useState(false);
|
|
|
|
|
|
|
|
|
|
if (!user) return null;
|
|
|
|
|
|
|
|
|
|
const isMac = isMacPlatform();
|
|
|
|
|
const shortcutLabel = isMac ? '⌘K' : 'Ctrl+K';
|
|
|
|
|
|
|
|
|
|
const openPalette = () => {
|
|
|
|
|
onOpenCommandPalette?.();
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<header
|
2026-06-04 13:07:17 -04:00
|
|
|
// Floating-chrome pass 2026-06-04: `md:rounded-2xl` so the bar
|
|
|
|
|
// reads as its own floating chip on desktop (Layout's
|
|
|
|
|
// `md:p-4 md:gap-4` puts visible body gradient on all four sides
|
|
|
|
|
// of it). Mobile keeps square corners since the header runs
|
|
|
|
|
// edge-to-edge there. The shadow stack pairs `rim-light-inner`
|
|
|
|
|
// (subtle top inset highlight kept from the prior pass) with
|
|
|
|
|
// `elevation-ambient` (drop shadow) so it floats over the body
|
|
|
|
|
// gradient. We deliberately did NOT add `rim-light-outer` here:
|
|
|
|
|
// the operator's "remove the divider" feedback from the prior
|
|
|
|
|
// pass applies to any visible 1px edge, and a hairline outer
|
|
|
|
|
// ring would re-introduce one on mobile where the bar abuts
|
|
|
|
|
// content directly.
|
|
|
|
|
className="px-4 sm:px-6 py-3 flex items-center gap-3 sm:gap-4 md:rounded-2xl"
|
2026-06-04 12:14:50 -04:00
|
|
|
style={{
|
refactor(design): move light response onto chip borders; cut directional drop shadows (#116)
Operator feedback 2026-06-04 (after PR #115's two-light-source pass):
"A lot of gradients happening, though I want the gradient in the
background to be the focus, and then the border gets a highlight,
perhaps, in a corner that's closest to the background gradients...
the borders are reflecting the color, not necessarily the drop
shadows behind the cards. In fact, you could probably reduce those
box shadows quite a bit. At least they shouldn't span the entire
edge of the card. Just a subtle bit in the corner."
PR #115's outer directional drop-shadows (`--edge-light-warm` /
`--edge-light-cool`) DID make the chips respond to the two light
sources but spanned the entire bottom-left + top-right edges, which
competed visually with the body gradient. This PR moves the
warm/cool response onto the chip's 1px BORDER, concentrated in the
two corners closest to the matching light sources, and cuts the
directional drop-shadows entirely (just rim-light-inner + a single
neutral elevation-ambient remain).
Technique: the modern gradient-border pattern.
background:
/* surface fill, clipped to padding-box */
linear-gradient(surface, surface) padding-box,
/* warm catch-light at bottom-left, border-box-clipped */
radial-gradient(at 0% 100%, warm 0%, transparent 42%) border-box,
/* cool catch-light at top-right, border-box-clipped */
radial-gradient(at 100% 0%, cool 0%, transparent 42%) border-box,
/* faint diagonal white tint for the rest of the rim */
chip-border-base border-box;
border: 1px solid transparent;
The transparent border is what exposes the border-box-clipped
gradient layers as the visible rim.
Token changes (styles/globals.css):
- Retired: --edge-light-warm, --edge-light-cool (both themes).
These were complete box-shadow declarations with offsets spanning
the full bottom-left + top-right edges of every chip.
- Added: --corner-light-warm, --corner-light-cool — plain RGBA
colors for radial-gradient consumption.
Light: 0.50 warm / 0.38 cool.
Dark: 0.68 warm / 0.58 cool (bumped so catch-lights read
against the deep substrate).
- Added: --chip-border-base — faint 135deg white tint so the rest
of the rim isn't invisible (sensible chip edge far from either
light source). Lower alpha on dark.
Application sites:
- styles/globals.css `.glass-panel` and `.glass-panel-strong` —
updated to multi-layer background + transparent border. Picks
up DailyEmberWidget, CommandPaletteModal popover, UserMenu
dropdown, the logged-out sidebar Support/Theme tray, etc.
- components/Layout.js nav-chip inline style — same treatment.
- components/ui/TopSearchBar.js inline style — same treatment.
Shadow stacks across all four sites are now just:
var(--rim-light-inner), var(--elevation-ambient)
(or elevation-pronounced for .glass-panel-strong). The body
gradient stays unchanged from PR #115 — it remains the dominant
gradient story per operator's "I want the gradient in the
background to be the focus" framing.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:31:53 -04:00
|
|
|
// 2026-06-04 corner-border pass: same gradient-border
|
|
|
|
|
// technique as .glass-panel — the chip's fill clipped to
|
|
|
|
|
// padding-box, with border-box-clipped warm bottom-left +
|
|
|
|
|
// cool top-right radial highlights exposed through a 1px
|
|
|
|
|
// transparent border. The directional outer drop shadows
|
|
|
|
|
// from PR #115 are gone — corner catch-lights only now.
|
|
|
|
|
background:
|
|
|
|
|
'linear-gradient(var(--glass-surface-mid), var(--glass-surface-mid)) padding-box, ' +
|
|
|
|
|
'radial-gradient(at 0% 100%, var(--corner-light-warm) 0%, transparent 42%) border-box, ' +
|
|
|
|
|
'radial-gradient(at 100% 0%, var(--corner-light-cool) 0%, transparent 42%) border-box, ' +
|
|
|
|
|
'var(--chip-border-base) border-box',
|
|
|
|
|
border: '1px solid transparent',
|
2026-06-04 12:14:50 -04:00
|
|
|
backdropFilter: 'blur(12px) saturate(180%)',
|
|
|
|
|
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
refactor(design): move light response onto chip borders; cut directional drop shadows (#116)
Operator feedback 2026-06-04 (after PR #115's two-light-source pass):
"A lot of gradients happening, though I want the gradient in the
background to be the focus, and then the border gets a highlight,
perhaps, in a corner that's closest to the background gradients...
the borders are reflecting the color, not necessarily the drop
shadows behind the cards. In fact, you could probably reduce those
box shadows quite a bit. At least they shouldn't span the entire
edge of the card. Just a subtle bit in the corner."
PR #115's outer directional drop-shadows (`--edge-light-warm` /
`--edge-light-cool`) DID make the chips respond to the two light
sources but spanned the entire bottom-left + top-right edges, which
competed visually with the body gradient. This PR moves the
warm/cool response onto the chip's 1px BORDER, concentrated in the
two corners closest to the matching light sources, and cuts the
directional drop-shadows entirely (just rim-light-inner + a single
neutral elevation-ambient remain).
Technique: the modern gradient-border pattern.
background:
/* surface fill, clipped to padding-box */
linear-gradient(surface, surface) padding-box,
/* warm catch-light at bottom-left, border-box-clipped */
radial-gradient(at 0% 100%, warm 0%, transparent 42%) border-box,
/* cool catch-light at top-right, border-box-clipped */
radial-gradient(at 100% 0%, cool 0%, transparent 42%) border-box,
/* faint diagonal white tint for the rest of the rim */
chip-border-base border-box;
border: 1px solid transparent;
The transparent border is what exposes the border-box-clipped
gradient layers as the visible rim.
Token changes (styles/globals.css):
- Retired: --edge-light-warm, --edge-light-cool (both themes).
These were complete box-shadow declarations with offsets spanning
the full bottom-left + top-right edges of every chip.
- Added: --corner-light-warm, --corner-light-cool — plain RGBA
colors for radial-gradient consumption.
Light: 0.50 warm / 0.38 cool.
Dark: 0.68 warm / 0.58 cool (bumped so catch-lights read
against the deep substrate).
- Added: --chip-border-base — faint 135deg white tint so the rest
of the rim isn't invisible (sensible chip edge far from either
light source). Lower alpha on dark.
Application sites:
- styles/globals.css `.glass-panel` and `.glass-panel-strong` —
updated to multi-layer background + transparent border. Picks
up DailyEmberWidget, CommandPaletteModal popover, UserMenu
dropdown, the logged-out sidebar Support/Theme tray, etc.
- components/Layout.js nav-chip inline style — same treatment.
- components/ui/TopSearchBar.js inline style — same treatment.
Shadow stacks across all four sites are now just:
var(--rim-light-inner), var(--elevation-ambient)
(or elevation-pronounced for .glass-panel-strong). The body
gradient stays unchanged from PR #115 — it remains the dominant
gradient story per operator's "I want the gradient in the
background to be the focus" framing.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:31:53 -04:00
|
|
|
boxShadow: 'var(--rim-light-inner), var(--elevation-ambient)',
|
2026-06-04 12:14:50 -04:00
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{/* Search affordance — read-only-ish input that opens the
|
|
|
|
|
command palette on click. */}
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
onClick={openPalette}
|
|
|
|
|
onMouseEnter={() => setHovered(true)}
|
|
|
|
|
onMouseLeave={() => setHovered(false)}
|
|
|
|
|
className="flex-1 max-w-2xl flex items-center gap-2 px-4 py-2 rounded-xl text-left transition-colors focus:outline-none focus:ring-2"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: hovered
|
|
|
|
|
? 'var(--bg-tertiary)'
|
|
|
|
|
: 'var(--input-bg)',
|
|
|
|
|
border: '1px solid var(--input-border)',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Open command palette to search"
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
className="h-5 w-5 flex-shrink-0"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
<span className="flex-1 text-sm truncate">
|
|
|
|
|
Search cards, sets, decks, users…
|
|
|
|
|
</span>
|
|
|
|
|
<span
|
|
|
|
|
className="hidden sm:inline-flex items-center gap-0.5 px-2 py-0.5 rounded-md text-xs font-mono"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'var(--bg-tertiary)',
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
border: '1px solid var(--border)',
|
|
|
|
|
}}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
{shortcutLabel}
|
|
|
|
|
</span>
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<div className="flex items-center gap-2 sm:gap-3 flex-shrink-0">
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className="relative p-2 rounded-xl transition-colors focus:outline-none focus:ring-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
}}
|
|
|
|
|
aria-label={
|
|
|
|
|
notificationCount > 0
|
|
|
|
|
? `${notificationCount} unread notifications`
|
|
|
|
|
: 'Notifications'
|
|
|
|
|
}
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
className="h-5 w-5"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
{notificationCount > 0 && (
|
|
|
|
|
<span
|
|
|
|
|
className="absolute -top-0.5 -right-0.5 min-w-[1.25rem] h-5 px-1 rounded-full text-xs font-bold flex items-center justify-center"
|
|
|
|
|
style={{
|
|
|
|
|
backgroundColor: 'rgb(239, 68, 68)',
|
|
|
|
|
color: 'rgb(255, 255, 255)',
|
|
|
|
|
boxShadow: '0 1px 3px rgba(239, 68, 68, 0.5)',
|
|
|
|
|
}}
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
>
|
|
|
|
|
{notificationCount > 99 ? '99+' : notificationCount}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
<Link href="/settings#messages" passHref legacyBehavior>
|
|
|
|
|
<a
|
|
|
|
|
className="p-2 rounded-xl transition-colors focus:outline-none focus:ring-2 nav-item-hover"
|
|
|
|
|
style={{
|
|
|
|
|
color: 'var(--text-secondary)',
|
|
|
|
|
'--tw-ring-color': 'var(--accent-ember)',
|
|
|
|
|
}}
|
|
|
|
|
aria-label="Messages"
|
|
|
|
|
>
|
|
|
|
|
<svg
|
|
|
|
|
className="h-5 w-5"
|
|
|
|
|
fill="none"
|
|
|
|
|
stroke="currentColor"
|
|
|
|
|
viewBox="0 0 24 24"
|
|
|
|
|
>
|
|
|
|
|
<path
|
|
|
|
|
strokeLinecap="round"
|
|
|
|
|
strokeLinejoin="round"
|
|
|
|
|
strokeWidth={2}
|
|
|
|
|
d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"
|
|
|
|
|
/>
|
|
|
|
|
</svg>
|
|
|
|
|
</a>
|
|
|
|
|
</Link>
|
|
|
|
|
|
refactor(layout): split sidebar into nav+badge chips; move help/theme to topbar dropdown (#114)
Operator feedback 2026-06-04:
- "make the navigation its own section, separated from the badges
and progress"
- "move the help and theme toggle into the profile drop-down"
Changes:
components/Layout.js
- Desktop sidebar restructured: the single full-height chip is gone.
The sidebar column is now <aside className="md:flex md:flex-col
md:gap-4"> holding two independent floating glass chips:
1. nav-chip (flex-1, rounded-3xl, glass-surface-mid) — logo,
wordmark, NavigationContent, and the Sign-in CTA for logged-
out visitors only.
2. bottom-chip — for authenticated users this is the
<DailyEmberWidget /> (already a self-contained glass-panel).
For logged-out, it's a small glass-panel containing the
Support + Theme icon row so those affordances stay reachable
when there's no TopSearchBar dropdown.
The visible body-gradient gap between the two chips is the new
separation the operator asked for.
- Authenticated path no longer renders Support / Theme icons in the
sidebar bottom — they live in the TopSearchBar <UserMenu> dropdown.
- Mobile drawer: bottom Support+Theme row is now gated behind
`!user`. Authenticated mobile users access the same affordances
via the TopSearchBar avatar chip (visible on all viewports; the
name/chevron portion is hidden on < md but the chip itself stays).
components/ui/TopSearchBar.js
- The user-menu chip (previously a <Link href="/settings">) is now
a real <UserMenu> dropdown sub-component:
- Profile, Settings, Admin Panel (admins only), Help & Support,
Theme toggle (button — toggles theme + closes menu),
separator, Logout.
- role="menu" + menuitem children; aria-haspopup + aria-expanded
on the trigger; Escape key closes; click-outside scrim closes.
- Uses useTheme() so the menu label flips between "Dark mode" /
"Light mode" with the matching sun/moon icon.
- Glass-surface-high background + rim-light + ember-rim-subtle
+ elevation-pronounced shadow stack so it reads as a popover
above the floating header chip.
Tests:
- npm run test:run: 113/113
- npm run lint: 0 errors (1 pre-existing warning, not from this PR)
- npm run build: green
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-04 13:16:09 -04:00
|
|
|
<UserMenu user={user} />
|
2026-06-04 12:14:50 -04:00
|
|
|
</div>
|
|
|
|
|
</header>
|
|
|
|
|
);
|
|
|
|
|
}
|