Task 18: Add command palette (Ctrl+K)

- Create CommandPalette.tsx with overlay, search input, grouped results,
  keyboard navigation (arrows, Enter, Escape), and footer hints
- Rebuild search.ts with PaletteItem model: 24 entries across 6 sections
  (Experience, Core Skills, Active Projects, Achievements, Education,
  Quick Actions) matching concept HTML structure
- Fuzzy search via fuse.js with weighted keys (title, subtitle, keywords)
- Wire into DashboardLayout with global Ctrl+K listener and TopBar click
- Action system: scroll-to-tile, expand-item, external links, download CV
- Add data-tile-id to all Card/tile components for scroll targeting
- CSS animations: palette-overlay-in, palette-modal-in with
  prefers-reduced-motion support
- Maintain backward-compatible legacy exports for ClinicalSidebar
  (will be removed in Task 21)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 17:54:31 +00:00
parent acee97a579
commit aafdeba93e
13 changed files with 836 additions and 80 deletions
+3 -1
View File
@@ -4,9 +4,10 @@ interface CardProps {
children: React.ReactNode
full?: boolean // spans both grid columns
className?: string
tileId?: string // data-tile-id for command palette scroll targeting
}
export function Card({ children, full, className }: CardProps) {
export function Card({ children, full, className, tileId }: CardProps) {
const [isHovered, setIsHovered] = React.useState(false)
const baseStyles: React.CSSProperties = {
@@ -25,6 +26,7 @@ export function Card({ children, full, className }: CardProps) {
<div
style={baseStyles}
className={className}
data-tile-id={tileId}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
+2 -2
View File
@@ -12,7 +12,7 @@ import {
} from 'lucide-react'
import type { ViewId } from '../types/pmr'
import { useAccessibility } from '../contexts/AccessibilityContext'
import { buildSearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
import { buildLegacySearchIndex, groupResultsBySection, type SearchResult } from '../lib/search'
import type { FuseResult } from 'fuse.js'
interface NavItem {
@@ -55,7 +55,7 @@ export function ClinicalSidebar({ activeView, onViewChange, isTablet = false }:
const { focusAfterLoginRef, setExpandedItem } = useAccessibility()
// Build search index once on mount
const searchIndex = useMemo(() => buildSearchIndex(), [])
const searchIndex = useMemo(() => buildLegacySearchIndex(), [])
const handleNavClick = useCallback(
(view: ViewId) => {
+432
View File
@@ -0,0 +1,432 @@
import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
import {
Search,
User,
Activity,
Monitor,
Award,
GraduationCap,
Zap,
type LucideIcon,
} from 'lucide-react'
import {
buildPaletteData,
buildSearchIndex,
groupBySection,
} from '@/lib/search'
import type { PaletteItem, PaletteAction, IconColorVariant } from '@/lib/search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
interface CommandPaletteProps {
isOpen: boolean
onClose: () => void
onAction?: (action: PaletteAction) => void
}
// Icon mapping by type
const iconByType: Record<string, LucideIcon> = {
role: User,
skill: Activity,
project: Monitor,
achievement: Award,
edu: GraduationCap,
action: Zap,
}
// Color variant → CSS variable mapping for icon containers
const iconColorStyles: Record<IconColorVariant, { background: string; color: string }> = {
teal: { background: 'var(--accent-light)', color: 'var(--accent)' },
green: { background: 'var(--success-light)', color: 'var(--success)' },
amber: { background: 'var(--amber-light)', color: 'var(--amber)' },
purple: { background: 'rgba(124,58,237,0.08)', color: '#7C3AED' },
}
export function CommandPalette({ isOpen, onClose, onAction }: CommandPaletteProps) {
const [query, setQuery] = useState('')
const [selectedIndex, setSelectedIndex] = useState(-1)
const inputRef = useRef<HTMLInputElement>(null)
const resultsRef = useRef<HTMLDivElement>(null)
const overlayRef = useRef<HTMLDivElement>(null)
// Build data and search index once
const paletteData = useMemo(() => buildPaletteData(), [])
const searchIndex = useMemo(() => buildSearchIndex(paletteData), [paletteData])
// Compute visible items based on query
const visibleItems = useMemo(() => {
if (!query.trim()) {
return paletteData
}
return searchIndex.search(query).map(result => result.item)
}, [query, paletteData, searchIndex])
// Group visible items by section
const groupedResults = useMemo(() => groupBySection(visibleItems), [visibleItems])
// Flat list for keyboard navigation
const flatItems = useMemo(() => {
const flat: PaletteItem[] = []
for (const group of groupedResults) {
for (const item of group.items) {
flat.push(item)
}
}
return flat
}, [groupedResults])
// Reset state when opening/closing
useEffect(() => {
if (isOpen) {
setQuery('')
setSelectedIndex(-1)
// Focus input on next frame
requestAnimationFrame(() => {
inputRef.current?.focus()
})
}
}, [isOpen])
// Reset selection when query changes
useEffect(() => {
setSelectedIndex(-1)
}, [query])
// Global Ctrl+K listener
useEffect(() => {
function handleGlobalKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
if (!isOpen) {
// Parent controls isOpen, so we need onAction or an onOpen callback
// For now, the parent will handle Ctrl+K via its own listener
}
}
}
document.addEventListener('keydown', handleGlobalKeyDown)
return () => document.removeEventListener('keydown', handleGlobalKeyDown)
}, [isOpen])
// Execute action for a palette item
const executeAction = useCallback((item: PaletteItem) => {
onClose()
if (onAction) {
onAction(item.action)
} else {
// Fallback: handle link and download actions directly
const { action } = item
if (action.type === 'link') {
window.open(action.url, '_blank', 'noopener,noreferrer')
}
}
}, [onClose, onAction])
// Keyboard navigation within the palette
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev + 1
return next >= flatItems.length ? 0 : next
})
break
}
case 'ArrowUp': {
e.preventDefault()
setSelectedIndex(prev => {
const next = prev - 1
return next < 0 ? flatItems.length - 1 : next
})
break
}
case 'Enter': {
e.preventDefault()
if (selectedIndex >= 0 && selectedIndex < flatItems.length) {
executeAction(flatItems[selectedIndex])
}
break
}
case 'Escape': {
e.preventDefault()
onClose()
break
}
}
}, [flatItems, selectedIndex, executeAction, onClose])
// Auto-scroll selected item into view
useEffect(() => {
if (selectedIndex < 0 || !resultsRef.current) return
const selectedEl = resultsRef.current.querySelector(`[data-palette-index="${selectedIndex}"]`)
if (selectedEl) {
selectedEl.scrollIntoView({ block: 'nearest' })
}
}, [selectedIndex])
// Click on overlay (outside modal) to close
const handleOverlayClick = useCallback((e: React.MouseEvent) => {
if (e.target === overlayRef.current) {
onClose()
}
}, [onClose])
if (!isOpen) return null
// Track flat index across groups
let flatIndex = 0
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
role="dialog"
aria-modal="true"
aria-label="Command palette"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(26,43,42,0.45)',
zIndex: 1000,
display: 'flex',
alignItems: 'flex-start',
justifyContent: 'center',
paddingTop: '12vh',
backdropFilter: 'blur(4px)',
WebkitBackdropFilter: 'blur(4px)',
animation: prefersReducedMotion ? 'none' : 'palette-overlay-in 0.2s ease-out forwards',
}}
onKeyDown={handleKeyDown}
>
{/* Palette modal */}
<div
style={{
width: '580px',
maxWidth: 'calc(100vw - 32px)',
maxHeight: '520px',
background: 'var(--surface)',
borderRadius: '12px',
boxShadow: '0 20px 60px rgba(26,43,42,0.2), 0 0 0 1px rgba(26,43,42,0.08)',
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
animation: prefersReducedMotion ? 'none' : 'palette-modal-in 0.2s cubic-bezier(0.4,0,0.2,1) forwards',
}}
>
{/* Search input row */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '14px 18px',
borderBottom: '1px solid var(--border-light)',
}}
>
<Search
size={18}
style={{ color: 'var(--accent)', flexShrink: 0 }}
aria-hidden="true"
/>
<input
ref={inputRef}
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search records, experience, skills..."
autoComplete="off"
className="font-ui"
style={{
flex: 1,
border: 'none',
outline: 'none',
background: 'transparent',
fontSize: '15px',
color: 'var(--text-primary)',
}}
aria-label="Search"
aria-activedescendant={
selectedIndex >= 0 ? `palette-item-${flatItems[selectedIndex]?.id}` : undefined
}
role="combobox"
aria-expanded="true"
aria-controls="palette-results"
aria-autocomplete="list"
/>
<kbd
className="font-geist"
style={{
fontSize: '10px',
color: 'var(--text-tertiary)',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '2px 7px',
borderRadius: '4px',
flexShrink: 0,
lineHeight: 1,
}}
>
ESC
</kbd>
</div>
{/* Results area */}
<div
id="palette-results"
ref={resultsRef}
role="listbox"
aria-label="Search results"
className="pmr-scrollbar"
style={{
overflowY: 'auto',
padding: '8px',
flex: 1,
}}
>
{flatItems.length === 0 ? (
<div
style={{
textAlign: 'center',
padding: '32px 16px',
color: 'var(--text-tertiary)',
fontSize: '13px',
}}
>
No results found for &ldquo;{query}&rdquo;
</div>
) : (
groupedResults.map((group) => {
const sectionItems = group.items.map((item) => {
const currentIndex = flatIndex
flatIndex++
const isSelected = currentIndex === selectedIndex
const IconComponent = iconByType[item.iconType]
const colorStyle = iconColorStyles[item.iconVariant]
return (
<div
key={item.id}
id={`palette-item-${item.id}`}
role="option"
aria-selected={isSelected}
data-palette-index={currentIndex}
onClick={() => executeAction(item)}
onMouseEnter={() => setSelectedIndex(currentIndex)}
style={{
display: 'flex',
alignItems: 'center',
gap: '10px',
padding: '9px 10px',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
transition: 'background 0.1s',
fontSize: '13px',
color: 'var(--text-primary)',
background: isSelected ? 'var(--accent-light)' : 'transparent',
outline: isSelected ? '1.5px solid var(--accent-border)' : 'none',
}}
>
{/* Icon container */}
<div
style={{
width: '28px',
height: '28px',
borderRadius: '6px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: colorStyle.background,
color: colorStyle.color,
}}
>
{IconComponent && <IconComponent size={14} />}
</div>
{/* Text */}
<div style={{ flex: 1, minWidth: 0 }}>
<div style={{ fontWeight: 500 }}>{item.title}</div>
<div
style={{
fontSize: '11px',
color: 'var(--text-tertiary)',
marginTop: '1px',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
}}
>
{item.subtitle}
</div>
</div>
</div>
)
})
return (
<div key={group.section}>
{/* Section label */}
<div
style={{
fontSize: '10px',
fontWeight: 600,
textTransform: 'uppercase',
letterSpacing: '0.08em',
color: 'var(--text-tertiary)',
padding: '8px 10px 5px',
}}
>
{group.section}
</div>
{sectionItems}
</div>
)
})
)}
</div>
{/* Footer with keyboard hints */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 18px',
borderTop: '1px solid var(--border-light)',
fontSize: '11px',
color: 'var(--text-tertiary)',
}}
>
<span>
<Kbd>\u2191</Kbd> <Kbd>\u2193</Kbd> Navigate
</span>
<span>
<Kbd>Enter</Kbd> Select
</span>
<span>
<Kbd>Esc</Kbd> Close
</span>
</div>
</div>
</div>
)
}
// Small kbd element for the footer
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd
className="font-geist"
style={{
fontSize: '10px',
background: 'var(--bg-dashboard)',
border: '1px solid var(--border)',
padding: '1px 5px',
borderRadius: '3px',
color: 'var(--text-secondary)',
}}
>
{children}
</kbd>
)
}
+61 -3
View File
@@ -1,7 +1,8 @@
import { useState } from 'react'
import { useState, useEffect, useCallback } from 'react'
import { motion } from 'framer-motion'
import { TopBar } from './TopBar'
import Sidebar from './Sidebar'
import { CommandPalette } from './CommandPalette'
import { PatientSummaryTile } from './tiles/PatientSummaryTile'
import { LatestResultsTile } from './tiles/LatestResultsTile'
import { CoreSkillsTile } from './tiles/CoreSkillsTile'
@@ -9,6 +10,7 @@ import { LastConsultationTile } from './tiles/LastConsultationTile'
import { CareerActivityTile } from './tiles/CareerActivityTile'
import { EducationTile } from './tiles/EducationTile'
import { ProjectsTile } from './tiles/ProjectsTile'
import type { PaletteAction } from '@/lib/search'
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
@@ -45,12 +47,63 @@ const contentVariants = {
}
export function DashboardLayout() {
const [, setCommandPaletteOpen] = useState(false)
const [commandPaletteOpen, setCommandPaletteOpen] = useState(false)
const handleSearchClick = () => {
setCommandPaletteOpen(true)
}
const handlePaletteClose = useCallback(() => {
setCommandPaletteOpen(false)
}, [])
// Global Ctrl+K listener to open command palette
useEffect(() => {
function handleKeyDown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault()
setCommandPaletteOpen(prev => !prev)
}
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [])
// Handle palette actions (scroll to tile, expand item, open link, download)
const handlePaletteAction = useCallback((action: PaletteAction) => {
switch (action.type) {
case 'scroll': {
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
break
}
case 'expand': {
const tileEl = document.querySelector(`[data-tile-id="${action.tileId}"]`)
if (tileEl) {
tileEl.scrollIntoView({ behavior: 'smooth', block: 'start' })
// Dispatch a custom event that the tile can listen for to expand the item
const expandEvent = new CustomEvent('palette-expand', {
detail: { tileId: action.tileId, itemId: action.itemId },
})
document.dispatchEvent(expandEvent)
}
break
}
case 'link': {
window.open(action.url, '_blank', 'noopener,noreferrer')
break
}
case 'download': {
// For now, open the CV file or trigger a download
// This can be wired to an actual PDF when available
window.open('/References/CV_v4.md', '_blank')
break
}
}
}, [])
return (
<div
className="font-ui"
@@ -123,7 +176,12 @@ export function DashboardLayout() {
</motion.main>
</div>
{/* Command palette will be rendered here (Task 18) */}
{/* Command palette overlay */}
<CommandPalette
isOpen={commandPaletteOpen}
onClose={handlePaletteClose}
onAction={handlePaletteAction}
/>
</div>
)
}
+1 -1
View File
@@ -374,7 +374,7 @@ export const CareerActivityTile: React.FC = () => {
)
return (
<Card full>
<Card full tileId="career-activity">
<CardHeader dotColor="teal" title="CAREER ACTIVITY" rightText="Full timeline" />
<div className="activity-grid">
+1 -1
View File
@@ -233,7 +233,7 @@ export function CoreSkillsTile() {
)
return (
<Card>
<Card tileId="core-skills">
<CardHeader dotColor="amber" title="REPEAT MEDICATIONS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
{skills.map((skill) => (
+1 -1
View File
@@ -22,7 +22,7 @@ export function EducationTile() {
]
return (
<Card full>
<Card full tileId="education">
<CardHeader dotColor="purple" title="EDUCATION" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '10px' }}>
@@ -30,7 +30,7 @@ export const LastConsultationTile: React.FC = () => {
}
return (
<Card full>
<Card full tileId="last-consultation">
<CardHeader dotColor="green" title="LAST CONSULTATION" rightText="Most recent role" />
{/* Header info row */}
+1 -1
View File
@@ -109,7 +109,7 @@ export function LatestResultsTile() {
}
return (
<Card>
<Card tileId="latest-results">
<CardHeader dotColor="teal" title="LATEST RESULTS" rightText="Updated May 2025" />
<div style={gridStyles}>
{kpis.map((kpi) => (
+1 -1
View File
@@ -10,7 +10,7 @@ export function PatientSummaryTile() {
}
return (
<Card full>
<Card full tileId="patient-summary">
<CardHeader dotColor="teal" title="PATIENT SUMMARY" />
<div style={bodyStyles}>{personalStatement}</div>
</Card>
+1 -1
View File
@@ -257,7 +257,7 @@ export function ProjectsTile() {
)
return (
<Card full>
<Card full tileId="projects">
<CardHeader dotColor="amber" title="ACTIVE PROJECTS" />
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>