Files
design-system/src/components/IconMatrix.tsx
T
developer f6e81afe5e 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
2026-09-13 00:22:16 +00:00

142 lines
4.8 KiB
TypeScript

import { useState, useMemo } from 'react'
import { useTimeout } from '../utils/useTimeout'
import { Icon } from './Icon'
import { iconRegistry, type IconRegistryEntry } from './icon-registry'
import { Kbd } from './ui/Kbd'
import { Badge } from './ui/Badge'
type IconItem = IconRegistryEntry
/* The curated showcase set — components come straight from the shared
registry in Icon.tsx, so there is no second icon list to drift. */
const ICONS: IconItem[] = iconRegistry
const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const
type Category = (typeof CATEGORIES)[number]
export function IconMatrix() {
const [activeCategory, setActiveCategory] = useState<Category>('All')
const [search, setSearch] = useState('')
const [size, setSize] = useState<number>(20)
const [copiedName, setCopiedName] = useState<string | null>(null)
const { schedule } = useTimeout()
const filteredIcons = useMemo(() => {
return ICONS.filter(icon => {
const matchesCategory = activeCategory === 'All' || icon.category === activeCategory
const matchesSearch =
icon.name.toLowerCase().includes(search.toLowerCase()) ||
icon.pascalName.toLowerCase().includes(search.toLowerCase())
return matchesCategory && matchesSearch
})
}, [activeCategory, search])
const copyUsage = (icon: IconItem) => {
const code = `<${icon.pascalName} size={${size}} />`
navigator.clipboard?.writeText(code)
setCopiedName(icon.name)
schedule(() => setCopiedName(null), 2000)
}
return (
<div className="icon-matrix-container">
{/* Header controls */}
<div className="icon-matrix-header">
<div className="icon-search-bar">
<Icon name="search" size={16} />
<input
type="text"
placeholder="Search icons (e.g. arrow, check, sparkles)..."
aria-label="Search icons"
value={search}
onChange={e => setSearch(e.target.value)}
className="icon-search-input"
/>
{search && (
<button className="icon-clear-btn" onClick={() => setSearch('')}>
<Icon name="close" size={14} />
</button>
)}
</div>
<div className="icon-size-picker">
<span className="mono muted">Size:</span>
{[16, 20, 24].map(s => (
<button
key={s}
className={`icon-size-btn ${size === s ? 'active' : ''}`}
onClick={() => setSize(s)}
>
{s}px
</button>
))}
</div>
</div>
{/* Category tabs */}
<div className="icon-category-tabs">
{CATEGORIES.map(cat => (
<button
key={cat}
className={`icon-cat-btn ${activeCategory === cat ? 'active' : ''}`}
onClick={() => setActiveCategory(cat)}
>
{cat}
<span className="icon-cat-count">
{cat === 'All' ? ICONS.length : ICONS.filter(i => i.category === cat).length}
</span>
</button>
))}
</div>
{/* Grid of icons */}
<div className="icon-grid">
{filteredIcons.map(icon => {
const isCopied = copiedName === icon.name
return (
<button
key={icon.name}
className={`icon-tile ${isCopied ? 'copied' : ''}`}
onClick={() => copyUsage(icon)}
title={`Click to copy <${icon.pascalName} />`}
>
<div className="icon-tile-glyph">
{isCopied ? (
<Icon name="check" size={size} className="accent" />
) : (
<icon.component size={size} strokeWidth={1.8} />
)}
</div>
<span className="icon-tile-name">{icon.name}</span>
<span className="icon-tile-action">
{isCopied ? 'Copied!' : <Kbd>Copy</Kbd>}
</span>
</button>
)
})}
</div>
{filteredIcons.length === 0 && (
<div className="empty-state">
<div className="empty-state-icon">
<Icon name="search" size={24} />
</div>
<h4 style={{ margin: '0 0 var(--space-2)' }}>No icons found</h4>
<p className="muted" style={{ margin: 0, fontSize: 'var(--text-xs)' }}>
No icon matches "{search}". You can use any of Lucide's 1,400+ icons directly via{' '}
<code>lucide-react</code>.
</p>
</div>
)}
{/* Footer usage tip */}
<div className="icon-matrix-footer">
<div className="icon-footer-pill">
<Badge variant="outline">lucide-react</Badge>
<span>Over 1,400+ SVG icons available. Fully tree-shaken and styled via <code>currentColor</code>.</span>
</div>
</div>
</div>
)
}