Operator feedback 2026-06-04: "increase the gradient in the
background that goes from our nice warm red to a nice purpley
blue. And then let's have the corners or the edges of our sections
respond as if those two are light sources."
Two coordinated changes that together create the "lit by two light
sources" effect.
1) Body gradient pushed harder + diagonal warm↔cool story
(styles/globals.css body { ... } + [data-theme="dark"] body)
- Dark theme:
* Warm ember pool bottom-left: alpha 0.55 → 0.72, ellipse
enlarged from 75%/55% to 95%/70%.
* Top-right corner: switched from secondary ember + dim
purple-magenta (0.26) to a unified bright purpley-blue pool
(alpha 0.58, indigo→violet ramp, 90%/70% ellipse).
* Base linear-gradient: 180deg vertical wash → 45deg diagonal
(warm aubergine bottom-left → indigo top-right) so the whole
page reads as one continuous warm↔cool dialogue, not just
two corner pools.
- Light theme:
* Top-right gold accent replaced with purpley-blue (indigo
0.42 → violet 0.20 → light violet 0.08) so both themes share
the same diagonal story.
* Bottom-left ember boosted (0.42 → 0.55, ellipse enlarged).
* Base linear-gradient: 180deg → 45deg diagonal (warm-cream
→ cool-violet).
2) Edge-light tokens on every floating chip's shadow stack
(styles/globals.css + components/Layout.js + components/ui/TopSearchBar.js)
- New design tokens in :root and [data-theme="dark"]:
--edge-light-warm (offset -X +Y → glow on the chip's
bottom-left edge, matching the warm
ember light source)
--edge-light-cool (offset +X -Y → glow on the chip's
top-right edge, matching the cool
purpley-blue light source)
Light: 0.16 warm / 0.12 cool. Dark: 0.34 warm / 0.28 cool.
- .glass-panel and .glass-panel-strong shadow stacks updated to
prepend the two edge-light tokens. Every glass surface in the
app now picks up the directional rim glows from the body
gradient without per-component changes (DailyEmberWidget,
CommandPaletteModal popover, the logged-out sidebar Support/
Theme tray, UserMenu dropdown, etc).
- components/Layout.js nav-chip and components/ui/TopSearchBar.js
inline boxShadow strings prepended with the same two tokens
(they use inline styles rather than the .glass-panel class so
they need the explicit shadow stack).
Result: the body now visibly transitions from warm ember (bottom-
left) through a neutral midpoint to purpley-blue (top-right). Every
floating chip's bottom-left and top-right edges pick up subtle
directional glow from the matching corner, reading as light wrapping
around the chip's rim from the two off-screen-ish light sources.
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>
448 lines
18 KiB
JavaScript
448 lines
18 KiB
JavaScript
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { useTheme } from '../../lib/theme-context';
|
|
|
|
/**
|
|
* 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).
|
|
* - 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.
|
|
*/
|
|
function isMacPlatform() {
|
|
if (typeof window === 'undefined' || !window.navigator) return true;
|
|
return /Mac|iPhone|iPad|iPod/.test(window.navigator.platform);
|
|
}
|
|
|
|
/**
|
|
* 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>
|
|
);
|
|
}
|
|
|
|
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
|
|
// 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"
|
|
style={{
|
|
background: 'var(--glass-surface-mid)',
|
|
backdropFilter: 'blur(12px) saturate(180%)',
|
|
WebkitBackdropFilter: 'blur(12px) saturate(180%)',
|
|
// 2026-06-04 two-light-source pass: prepended edge-light
|
|
// tokens so the header's bottom-left and top-right edges
|
|
// pick up warm + cool directional glows. The header sits
|
|
// near the top-right of the viewport so the cool source
|
|
// reads stronger on it (the purple light is right above);
|
|
// the warm rim still appears on the bottom-left edge of the
|
|
// chip, just dimmer at this distance from the ember pool.
|
|
boxShadow:
|
|
'var(--edge-light-warm), var(--edge-light-cool), var(--rim-light-inner), var(--elevation-ambient)',
|
|
}}
|
|
>
|
|
{/* 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>
|
|
|
|
<UserMenu user={user} />
|
|
</div>
|
|
</header>
|
|
);
|
|
}
|