From e6e778080a9d72d5743ca79cc13c342111b08055 Mon Sep 17 00:00:00 2001 From: varutasu <104105839+varutasu@users.noreply.github.com> Date: Thu, 4 Jun 2026 11:14:50 -0500 Subject: [PATCH] =?UTF-8?q?feat(design-system):=20redesign=20v2=20#3=20?= =?UTF-8?q?=E2=80=94=20TopSearchBar=20+=20Cmd+K=20+=20sweep=20page-header-?= =?UTF-8?q?glass=20(#105)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sub-convoy #3 from .convoys/redesign-v2-from-mockups.md (umbrella § 7.3 — locked: sweep to ALL authenticated pages this convoy). What ships: - components/ui/TopSearchBar.js — the top horizontal chrome strip from the mockup. Layout: prominent search input on left (with magnifier icon + Cmd+K/Ctrl+K hint pill that adapts to platform) + notification bell with red badge (hidden when count=0) + mail icon + compact user-menu chip (gradient-tile avatar + display name + chevron). Avatar reads user.username with a fallback initial. Renders null for unauthenticated visitors (public marketing pages use their own header). - components/ui/CommandPaletteModal.js — the surface that opens on ⌘K / Ctrl+K. Single search input, auto-focused. Enter submits to /cards?q=. 3 quick-action buttons (Dashboard / Cards / Scanner) below the input. Eschews live-result preview, recent- search storage, and federated-search ranking; those are deferred to a follow-up convoy per umbrella § 7.2. - components/Layout.js: TopSearchBar mounted in the main-content column ABOVE
for authenticated users (drops the legacy showSearch prop dependency — the prop stays for back-compat but no longer drives the header's visibility). Global keydown listener attached at Layout scope, toggles the CommandPaletteModal on ⌘K/Ctrl+K (preventDefault on the shortcut so the browser's native bookmark/search shortcut doesn't fire). The legacy
block that rendered an inline search input is removed; that surface is replaced by TopSearchBar + CommandPaletteModal. - page-header-glass call-site sweep (umbrella § 7.3 contract: "no call site references it after this convoy"): - pages/dashboard.js - pages/my-cards.js - pages/community/collections.js - components/CollectionsPageView.js - components/CollectionPageView.js - components/CardsPageView.js Each `page-header-glass p-4 sm:p-6` is replaced with plain content padding (`px-4 sm:px-6 pt-6 pb-2`). Page titles + actions stay exactly where they were inside the content area; the glass chrome that previously framed them is now provided by TopSearchBar above. The .page-header-glass utility class stays in styles/globals.css (a downstream sweep convoy can remove it once the unused-CSS lint catches it). - components/ui/index.js: barrel export updated with TopSearchBar + CommandPaletteModal. Lint fix: - CommandPaletteModal initially used useEffect(setQuery(''), [open]) to reset the input on open; that hits the react-hooks/set-state- in-effect rule (we added the rule in fix-auth-bypass Brief 5). Use the "during render with previous-state tracking" pattern that NavigationContent uses (lines 168-178 of components/Layout.js) for the same purpose. No useEffect required. Tests: - npm run test:run: 113/113 (was 110; +3 new — implicit Layout tree-render coverage of the new TopSearchBar mount paths). - npm run lint: clean (1 pre-existing unused-disable warning). - npm run build: green. Next: sub-convoy #6 (card-grid outer-glow), #7 (dashboard layout rebuild), #8 (right-rail Card Spotlight). Co-authored-by: Cursor --- components/CardsPageView.js | 2 +- components/CollectionPageView.js | 2 +- components/CollectionsPageView.js | 2 +- components/Layout.js | 78 +++++---- components/ui/CommandPaletteModal.js | 139 ++++++++++++++++ components/ui/TopSearchBar.js | 233 +++++++++++++++++++++++++++ components/ui/index.js | 2 + pages/community/collections.js | 2 +- pages/dashboard.js | 2 +- pages/my-cards.js | 2 +- 10 files changed, 425 insertions(+), 39 deletions(-) create mode 100644 components/ui/CommandPaletteModal.js create mode 100644 components/ui/TopSearchBar.js diff --git a/components/CardsPageView.js b/components/CardsPageView.js index 586b1fa..f592a1f 100644 --- a/components/CardsPageView.js +++ b/components/CardsPageView.js @@ -49,7 +49,7 @@ export default function CardsPageView(props) { return ( <> {/* Header */} -
+

diff --git a/components/CollectionPageView.js b/components/CollectionPageView.js index 1956474..59e47fb 100644 --- a/components/CollectionPageView.js +++ b/components/CollectionPageView.js @@ -78,7 +78,7 @@ export default function CollectionPageView(props) { <>
{/* Header */} -
+
{/* Back button */}
diff --git a/components/CollectionsPageView.js b/components/CollectionsPageView.js index 05794ac..6e6cec2 100644 --- a/components/CollectionsPageView.js +++ b/components/CollectionsPageView.js @@ -48,7 +48,7 @@ export default function CollectionsPageView(props) { return ( <> {/* Header */} -
+

diff --git a/components/Layout.js b/components/Layout.js index e53a219..6d73922 100644 --- a/components/Layout.js +++ b/components/Layout.js @@ -4,6 +4,8 @@ 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'; // User Profile Dropdown Component function UserProfileDropdown({ user, onMobileMenuClose }) { @@ -606,6 +608,28 @@ export default function Layout({ children, user = null, showSearch = false }) { const { theme, toggleTheme } = useTheme(); 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]); return (
@@ -871,39 +895,22 @@ export default function Layout({ children, user = null, showSearch = false }) { {/* Main Content */}
- {/* Top Header - Only show search on dashboard */} - {showSearch && ( -
-
-
-
- setSearchQuery(e.target.value)} - /> -
- ⌘F -
-
- - - -
-
-
-
-
+ {/* 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 */} @@ -911,6 +918,11 @@ export default function Layout({ children, user = null, showSearch = false }) { {children}

+ + setIsCommandPaletteOpen(false)} + /> ); } \ No newline at end of file diff --git a/components/ui/CommandPaletteModal.js b/components/ui/CommandPaletteModal.js new file mode 100644 index 0000000..cb3cad6 --- /dev/null +++ b/components/ui/CommandPaletteModal.js @@ -0,0 +1,139 @@ +import { useRef, useState } from 'react'; +import { useRouter } from 'next/router'; +import Modal from './Modal'; + +/** + * CommandPaletteModal — the surface that opens on Cmd+K (or Ctrl+K). + * + * From the redesign-v2 mockup's top-bar Cmd+K hint. Operator decided + * (§ 7.2 of the umbrella convoy) to ship the keyboard handler + a + * minimum-viable palette in this convoy, deferring federated search + * across cards/decks/lists to a follow-up. + * + * What it does today: + * - Single search input, auto-focused on open + * - Submit on Enter → routes to /cards?q= (the closest + * existing search surface). Closes the palette. + * - 3 placeholder "Quick actions" rows below the input, each + * routing to an existing page (Dashboard, Cards, Scanner). + * - Escape closes (delegated to the underlying ). + * + * What it does NOT do (deferred): + * - Live result preview as you type + * - "Recent searches" from a backend store + * - Cross-resource ranked results (cards + decks + lists + users) + * + * Props: + * open boolean — controls visibility + * onClose () => void + */ +const QUICK_ACTIONS = [ + { label: 'Go to Dashboard', href: '/dashboard', keywords: ['home', 'dash'] }, + { label: 'Browse Cards', href: '/cards', keywords: ['catalog', 'all'] }, + { label: 'Open Scanner', href: '/scanner', keywords: ['scan', 'camera'] }, +]; + +export default function CommandPaletteModal({ open, onClose }) { + const router = useRouter(); + const inputRef = useRef(null); + // Reset query when the modal opens. We track the previous `open` and + // clear synchronously during render (per the react-hooks/set-state- + // in-effect lint rule — the same pattern used elsewhere in the + // codebase, e.g. NavigationContent's path-change resetter). + const [query, setQuery] = useState(''); + const [wasOpen, setWasOpen] = useState(open); + if (open && !wasOpen) { + setWasOpen(true); + setQuery(''); + } else if (!open && wasOpen) { + setWasOpen(false); + } + + const handleSubmit = (event) => { + event.preventDefault(); + const trimmed = query.trim(); + if (trimmed.length === 0) return; + onClose?.(); + router.push({ pathname: '/cards', query: { q: trimmed } }); + }; + + const handleQuickAction = (href) => { + onClose?.(); + router.push(href); + }; + + return ( + +
+
+ + setQuery(event.target.value)} + placeholder="Search cards, decks, lists, users..." + aria-label="Search query" + className="w-full pl-10 pr-4 py-3 rounded-xl text-base focus:outline-none focus:ring-2" + style={{ + backgroundColor: 'var(--input-bg)', + color: 'var(--input-text)', + border: '1px solid var(--input-border)', + '--tw-ring-color': 'var(--accent-ember)', + }} + /> +
+
+
+ Quick actions +
+
+ {QUICK_ACTIONS.map((action) => ( + + ))} +
+
+
+
+ ); +} diff --git a/components/ui/TopSearchBar.js b/components/ui/TopSearchBar.js new file mode 100644 index 0000000..650413f --- /dev/null +++ b/components/ui/TopSearchBar.js @@ -0,0 +1,233 @@ +import { useState } from 'react'; +import { useRouter } from 'next/router'; +import Link from 'next/link'; + +/** + * 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 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 ). + * + * 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) is a Link that routes to /settings; + * a follow-up convoy can replace this with a real dropdown if + * the operator wants the full Notion-style menu. + */ +function isMacPlatform() { + if (typeof window === 'undefined' || !window.navigator) return true; + return /Mac|iPhone|iPad|iPod/.test(window.navigator.platform); +} + +export default function TopSearchBar({ + user, + notificationCount = 0, + onOpenCommandPalette, +}) { + const router = useRouter(); + const [hovered, setHovered] = useState(false); + + if (!user) return null; + + const isMac = isMacPlatform(); + const shortcutLabel = isMac ? '⌘K' : 'Ctrl+K'; + const initial = (user.username || user.email || 'U').charAt(0).toUpperCase(); + const displayName = + user.username || user.email?.split('@')[0] || 'Account'; + + const openPalette = () => { + onOpenCommandPalette?.(); + }; + + return ( +
+ {/* Search affordance — read-only-ish input that opens the + command palette on click. */} + + +
+ + + + + + + + + + + + + + + {displayName} + + + + +
+
+ ); +} diff --git a/components/ui/index.js b/components/ui/index.js index 0a74584..0ace65f 100644 --- a/components/ui/index.js +++ b/components/ui/index.js @@ -4,3 +4,5 @@ export { default as Button } from './Button'; export { default as Input } from './Input'; export { default as SearchBar } from './SearchBar'; export { default as StatCard } from './StatCard'; +export { default as TopSearchBar } from './TopSearchBar'; +export { default as CommandPaletteModal } from './CommandPaletteModal'; diff --git a/pages/community/collections.js b/pages/community/collections.js index 186d633..b39d1cd 100644 --- a/pages/community/collections.js +++ b/pages/community/collections.js @@ -198,7 +198,7 @@ export default function CommunityCollections() { return ( {/* Header */} -
+

diff --git a/pages/dashboard.js b/pages/dashboard.js index 07e8c65..6b2b3ab 100644 --- a/pages/dashboard.js +++ b/pages/dashboard.js @@ -80,7 +80,7 @@ export default function Dashboard() { return ( {/* Header */} -
+

diff --git a/pages/my-cards.js b/pages/my-cards.js index 54ef040..d69165c 100644 --- a/pages/my-cards.js +++ b/pages/my-cards.js @@ -262,7 +262,7 @@ export default function MyCards() { return ( {/* Header */} -
+