Template
Harden agent guardrails and fix audited bugs
Gate hardening:
- strict + noUncheckedIndexedAccess in tsconfig.app.json; fix resulting errors
- check-tokens.mjs: scan inline style={{ }} in ts/tsx, ban named colors and
rgb()/hsl()/oklch()/lab(), read multi-line values, fail on undefined var(--x)
- vitest (24 tests: overlay, icon-registry, useTimeout, token guard) wired into
npm run check; GitHub Actions CI workflow
Single-source refactors:
- new Overlay primitive owns focus trap/Escape/scroll-lock/focus-restore and
conditional aria-labelledby/aria-describedby; Dialog + Sheet are thin skins
(overlayBehavior.ts exports trapTabKey/useOverlayLifecycle for SearchPalette)
- icon-registry.ts is the one icon map: strict IconName, aliases typed, dev
throws on unknown names instead of silently rendering <Layers/>; IconMatrix
consumes the shared registry (41 curated)
- useTimeout hook replaces fire-and-forget setTimeout in DeploymentModal,
SettingsPanel, IconMatrix, CodeSample (timers die on close/unmount)
Bug fixes:
- SearchPalette: role=dialog/aria-modal, focus trap + restore, combobox/listbox
semantics, labelled input, selection clamped to results
- theme: pre-paint script in index.html (localStorage -> prefers-color-scheme ->
dark), useTheme no longer writes DOM during state init
- StatCard: direction (arrow) decoupled from trend (sentiment); latency drop
renders green "↓ -4.2ms" instead of "↑ -4.2ms"
- sidebar collapse button wired (aria-pressed + collapsed rail CSS); pattern
routing keys on patternId from library.ts, not English labels
- remove 25 dead legacy tokens (--panel*, --line*, --text-soft, --critical,
--pink, --blue, ...); migrate --bg/--text/--positive/--warning usages
- title "Northstar Design System", real README, AGENTS.md structure/theme/
overlay/registry/test docs updated; dev error sink (#dev-errors +
ErrorBoundary) so agents can read runtime errors via inspect
This commit is contained in:
1 parent
c0a94d3b6d
commit
f6e81afe5e
48 files changed
+3299
-696
No files matched your search
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useState, useEffect, useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
import { Badge } from './ui/Badge'
|
||||
import { Kbd } from './ui/Kbd'
|
||||
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
|
||||
import type { PatternId } from './AIWorkflowSection'
|
||||
|
||||
export type SearchItem = {
|
||||
@@ -9,7 +10,7 @@ export type SearchItem = {
|
||||
title: string
|
||||
description: string
|
||||
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
|
||||
icon: string
|
||||
icon: IconName
|
||||
targetId: string
|
||||
patternId?: PatternId
|
||||
}
|
||||
@@ -59,7 +60,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
||||
title: 'Buttons & Action States',
|
||||
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
|
||||
category: 'Components',
|
||||
icon: 'arrow',
|
||||
icon: 'arrow-right',
|
||||
targetId: 'buttons',
|
||||
},
|
||||
{
|
||||
@@ -131,11 +132,20 @@ type SearchPaletteProps = {
|
||||
initialQuery?: string
|
||||
}
|
||||
|
||||
const LISTBOX_ID = 'search-palette-listbox'
|
||||
|
||||
/** Command-palette modal built on the same overlay behavior as Dialog/Sheet:
|
||||
focus trap, focus restore, scroll lock, Escape — plus combobox semantics
|
||||
(role=listbox + aria-activedescendant) for the results. */
|
||||
export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: SearchPaletteProps) {
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/* Same lifecycle as DialogContent: scroll lock + focus capture/restore. */
|
||||
useOverlayLifecycle(isOpen, panelRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus()
|
||||
@@ -153,7 +163,11 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
)
|
||||
}, [query])
|
||||
|
||||
// Keyboard navigation
|
||||
/* Keep the selection inside the result set when the query shrinks it. */
|
||||
const activeIndex = results.length === 0 ? -1 : Math.min(selectedIndex, results.length - 1)
|
||||
const activeItem = activeIndex >= 0 ? results[activeIndex] : undefined
|
||||
|
||||
// Keyboard navigation (combobox pattern: arrows move aria-activedescendant)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
@@ -163,32 +177,49 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev + 1) % (results.length || 1))
|
||||
setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev - 1 + results.length) % (results.length || 1))
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
|
||||
} else if (e.key === 'Enter' && activeItem) {
|
||||
e.preventDefault()
|
||||
onSelect(results[selectedIndex])
|
||||
onSelect(activeItem)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, results, selectedIndex, onClose, onSelect])
|
||||
}, [isOpen, results.length, activeItem, onClose, onSelect])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
trapTabKey(e, panelRef.current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-palette-backdrop" onClick={onClose}>
|
||||
<div className="search-palette-modal" onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search"
|
||||
tabIndex={-1}
|
||||
className="search-palette-modal"
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Input Bar */}
|
||||
<div className="search-palette-head">
|
||||
<Icon name="search" size={18} className="search-palette-icon" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={activeItem ? `search-option-${activeItem.id}` : undefined}
|
||||
aria-label="Search components, tokens, and docs"
|
||||
className="search-palette-input"
|
||||
placeholder="Type a command, component, or token..."
|
||||
value={query}
|
||||
@@ -198,7 +229,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="icon-button" onClick={() => setQuery('')} aria-label="Clear search">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
@@ -208,12 +247,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
{/* Results List */}
|
||||
<div className="search-palette-body">
|
||||
{results.length > 0 ? (
|
||||
<div className="search-palette-results">
|
||||
<ul id={LISTBOX_ID} role="listbox" aria-label="Search results" className="search-palette-results">
|
||||
{results.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
const isSelected = idx === activeIndex
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
key={item.id}
|
||||
id={`search-option-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`search-palette-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
@@ -236,14 +278,16 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
<Kbd>↵</Kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
) : (
|
||||
<div className="search-palette-empty">
|
||||
<Icon name="search" size={24} />
|
||||
<p>No results found for “{query}”</p>
|
||||
<p>
|
||||
No results found for “{query}”
|
||||
</p>
|
||||
<span>Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user