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:
developer committed 2026-09-13 00:22:16 +00:00
1 parent c0a94d3b6d
commit f6e81afe5e
48 files changed
+3299 -696

No files matched your search

+63 -19
View File
@@ -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 &ldquo;{query}&rdquo;</p>
<p>
No results found for &ldquo;{query}&rdquo;
</p>
<span>Try searching for &ldquo;Buttons&rdquo;, &ldquo;Metrics&rdquo;, &ldquo;Tokens&rdquo;, or &ldquo;Icons&rdquo;.</span>
</div>
)}