deckhearth/components/ui/CommandPaletteModal.js
Randall Stillwell a72a5878de feat(design-system): redesign v2 #3 — TopSearchBar + Cmd+K + sweep page-header-glass
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=<query>. 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 <main> 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 <header>
  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 <cursoragent@cursor.com>
2026-06-04 11:10:46 -05:00

139 lines
4.6 KiB
JavaScript

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=<query> (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 <Modal>).
*
* 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 (
<Modal
open={open}
onClose={onClose}
title="Search"
description="Search cards, decks, and lists. Press Enter to search Cards."
size="md"
initialFocusRef={inputRef}
hideCloseButton
>
<form onSubmit={handleSubmit}>
<div className="relative mb-4">
<span
className="absolute inset-y-0 left-0 flex items-center pl-3 pointer-events-none"
aria-hidden="true"
>
<svg
className="h-5 w-5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
style={{ color: 'var(--text-secondary)' }}
>
<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>
<input
ref={inputRef}
type="text"
value={query}
onChange={(event) => 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)',
}}
/>
</div>
<div>
<div
className="text-xs font-semibold uppercase tracking-wide mb-2"
style={{ color: 'var(--text-secondary)' }}
>
Quick actions
</div>
<div className="space-y-1">
{QUICK_ACTIONS.map((action) => (
<button
key={action.href}
type="button"
onClick={() => handleQuickAction(action.href)}
className="w-full text-left px-3 py-2 rounded-lg transition-colors focus:outline-none focus:ring-2 nav-item-hover"
style={{
color: 'var(--text-primary)',
'--tw-ring-color': 'var(--accent-ember)',
}}
>
{action.label}
</button>
))}
</div>
</div>
</form>
</Modal>
);
}