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

+25 -13
View File
@@ -202,6 +202,28 @@
grid-template-columns: 240px minmax(0, 1fr);
}
/* Collapsed via the sidebar heading's ⌘ button — a slim rail with the
expand button stays visible. */
.body-layout.sidebar-collapsed {
grid-template-columns: 56px minmax(0, 1fr);
}
.body-layout.sidebar-collapsed .sidebar {
padding: var(--space-4) 0;
}
.body-layout.sidebar-collapsed .sidebar-heading {
justify-content: center;
padding: 0;
border-bottom: 0;
}
.body-layout.sidebar-collapsed .sidebar-intro,
.body-layout.sidebar-collapsed .sidebar nav,
.body-layout.sidebar-collapsed .sidebar-bottom {
display: none;
}
/* ── Sidebar ────────────────────────────────────────────────── */
.sidebar {
border-right: 1px solid var(--sidebar-border);
@@ -766,19 +788,6 @@
border: 1px solid var(--border) !important;
}
.spinner {
width: 12px;
height: 12px;
border: 2px solid color-mix(in srgb, var(--muted-foreground) 25%, transparent);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.showcase-foot {
border-top: 1px solid var(--border);
border-bottom: 0;
@@ -1885,6 +1894,9 @@ footer {
display: flex;
flex-direction: column;
gap: 2px;
margin: 0;
padding: 0;
list-style: none;
}
.search-palette-item {
+39 -11
View File
@@ -16,6 +16,7 @@ import { Section } from './components/Section'
import { RightRail } from './components/RightRail'
import { SearchPalette, type SearchItem } from './components/SearchPalette'
import { Icon } from './components/Icon'
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui'
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
@@ -27,6 +28,7 @@ export default function App() {
const [visible, setVisible] = useState(false)
const [menu, setMenu] = useState(false)
const [pattern, setPattern] = useState<PatternId>('metrics')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
@@ -39,15 +41,12 @@ export default function App() {
return () => window.removeEventListener('keydown', handleKeyDown)
}, [])
const handleNavigate = (item: { label: string; id?: string }) => {
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
setActive(item.label)
setMenu(false)
// Synchronize active pattern when clicking pattern items
if (item.label === 'Metrics Dashboard') setPattern('metrics')
else if (item.label === 'Settings Panel') setPattern('settings')
else if (item.label === 'Service Table') setPattern('table')
else if (item.label === 'Deployment Flow') setPattern('deploy')
// Synchronize active pattern — routing keys on patternId, never on display labels.
if (item.patternId) setPattern(item.patternId)
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const el = document.getElementById(targetId)
@@ -73,11 +72,13 @@ export default function App() {
onSelect={handleSearchSelect}
/>
<div className={`body-layout ${menu ? 'menu-open' : ''}`}>
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar
active={active}
groups={navigationGroups}
onSelect={handleNavigate}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
/>
<main className="main-content">
@@ -121,7 +122,7 @@ export default function App() {
index="03 / COMPONENTS"
title="Icons (Lucide)"
description="Crisp, scalable SVG icons powered by Lucide. Tree-shakeable, stroke-matched, and token-aware."
meta={<span className="api-pill">lucide-react · 1,400+ icons</span>}
meta={<span className="api-pill">iconRegistry · 41 curated · lucide 1,400+</span>}
>
<IconMatrix />
</Section>
@@ -173,10 +174,37 @@ export default function App() {
<b>Enable notifications</b>
<small>Get updates about deployments and incidents.</small>
</span>
<button className="toggle on" aria-label="Toggle notifications">
<span />
</button>
<Switch defaultChecked aria-label="Toggle notifications" />
</label>
<div className="toggle-field">
<span>
<b>Primitives in action</b>
<small>Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives.</small>
</span>
<span className="row-md">
<Avatar name="Mats Tornberg" size="sm" status="online" />
<Avatar name="Grace Hopper" size="sm" />
<Spinner size="sm" label="Loading session" />
</span>
</div>
<div className="toggle-field">
<span>
<b>Tabs primitive</b>
<small>Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard.</small>
</span>
<Tabs defaultValue="preview" className="stack-xs">
<TabsList>
<TabsTrigger value="preview">Preview</TabsTrigger>
<TabsTrigger value="code">Code</TabsTrigger>
</TabsList>
<TabsContent value="preview">
<small>Token-driven preview pane.</small>
</TabsContent>
<TabsContent value="code">
<small>import {'{ Tabs }'} from components/ui</small>
</TabsContent>
</Tabs>
</div>
</div>
</Section>
+2 -1
View File
@@ -1,4 +1,5 @@
import { Button } from './Button'
import { Spinner } from './ui'
import { Icon } from './Icon'
export type ButtonExample = {
@@ -52,7 +53,7 @@ export function ButtonMatrix({ examples, tabs, filter = '', activeTab, onTabChan
<Icon name="copy" size={15} />
</Button>
<Button variant={example.variant} className="loading">
<span className="spinner" />
<Spinner size="xs" />
Loading
</Button>
</div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTimeout } from '../utils/useTimeout'
import { Icon } from './Icon'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
@@ -19,11 +20,12 @@ const CODE_LINES: Segment[] = [
export function CodeSample() {
const [copied, setCopied] = useState(false)
const { schedule } = useTimeout()
const copy = () => {
navigator.clipboard?.writeText(INSTALL_CMD)
setCopied(true)
setTimeout(() => setCopied(false), 1600)
schedule(() => setCopied(false), 1600)
}
return (
+15 -104
View File
@@ -1,108 +1,10 @@
import type { ComponentType, SVGProps } from 'react'
import {
Search,
Sun,
Moon,
Copy,
Check,
Eye,
ArrowRight,
ArrowLeft,
Menu,
Layers,
Sparkles,
Terminal,
AlertTriangle,
AlertCircle,
Info,
CheckCircle2,
X,
Plus,
Minus,
Trash2,
ExternalLink,
ChevronDown,
ChevronRight,
ChevronLeft,
ChevronUp,
Filter,
SlidersHorizontal,
Code2,
Cpu,
Database,
User,
Shield,
Folder,
FileText,
Calendar,
Clock,
RefreshCw,
Download,
Upload,
Settings,
HelpCircle,
type LucideIcon,
type LucideProps,
} from 'lucide-react'
import { Layers, type LucideIcon, type LucideProps } from 'lucide-react'
import { iconMap, type IconName } from './icon-registry'
export type { IconName, IconCategory, IconRegistryEntry } from './icon-registry'
export type { LucideIcon, LucideProps }
const iconMap: Record<string, LucideIcon> = {
// Northstar legacy shorthands
search: Search,
sun: Sun,
moon: Moon,
copy: Copy,
check: Check,
eye: Eye,
arrow: ArrowRight,
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
menu: Menu,
layers: Layers,
sparkle: Sparkles,
sparkles: Sparkles,
terminal: Terminal,
// Common UI primitives
close: X,
x: X,
plus: Plus,
minus: Minus,
trash: Trash2,
'trash-2': Trash2,
settings: Settings,
filter: Filter,
sliders: SlidersHorizontal,
refresh: RefreshCw,
download: Download,
upload: Upload,
external: ExternalLink,
'external-link': ExternalLink,
info: Info,
warning: AlertTriangle,
'alert-triangle': AlertTriangle,
alert: AlertCircle,
'alert-circle': AlertCircle,
'check-circle': CheckCircle2,
help: HelpCircle,
'chevron-down': ChevronDown,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-up': ChevronUp,
code: Code2,
cpu: Cpu,
database: Database,
user: User,
shield: Shield,
folder: Folder,
file: FileText,
calendar: Calendar,
clock: Clock,
}
export type IconName = keyof typeof iconMap | string
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
name?: IconName
icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }>
@@ -113,8 +15,9 @@ export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
/**
* Universal Icon component backed by lucide-react.
* Accepts either a shorthand name (e.g. name="sparkle", name="check")
* or a direct Lucide icon component (e.g. icon={Sparkles}).
* Accepts either a registry name (e.g. name="sparkles") or a direct Lucide
* icon component (e.g. icon={Sparkles}). Unknown names throw in dev — so the
* dev error sink surfaces them — and fall back to <Layers /> in prod.
*/
export function Icon({
name,
@@ -124,7 +27,15 @@ export function Icon({
className,
...props
}: IconProps) {
const Component = IconComponent || (name ? iconMap[name.toLowerCase()] : undefined) || Layers
const namedIcon = name ? iconMap[name] : undefined
if (!IconComponent && !namedIcon) {
const message = `[Icon] Unknown icon name "${String(name)}". Add it to iconMap in src/components/icon-registry.ts or pass an explicit \`icon\` prop.`
if (import.meta.env.DEV) throw new Error(message)
console.error(message)
}
const Component = IconComponent ?? namedIcon ?? Layers
return (
<Component
+9 -101
View File
@@ -1,109 +1,15 @@
import { useState, useMemo } from 'react'
import {
Search,
Sun,
Moon,
Copy,
Check,
Eye,
ArrowRight,
ArrowLeft,
Menu,
Layers,
Sparkles,
Terminal,
AlertTriangle,
AlertCircle,
Info,
CheckCircle2,
X,
Plus,
Minus,
Trash2,
ExternalLink,
ChevronDown,
ChevronRight,
ChevronLeft,
ChevronUp,
Filter,
SlidersHorizontal,
Code2,
Cpu,
Database,
User,
Shield,
Folder,
FileText,
Calendar,
Clock,
RefreshCw,
Download,
Upload,
Settings,
HelpCircle,
type LucideIcon,
} from 'lucide-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 = {
name: string
pascalName: string
category: 'Navigation' | 'Interface' | 'System' | 'Status'
component: LucideIcon
}
type IconItem = IconRegistryEntry
const ICONS: IconItem[] = [
// Navigation
{ name: 'arrow-right', pascalName: 'ArrowRight', category: 'Navigation', component: ArrowRight },
{ name: 'arrow-left', pascalName: 'ArrowLeft', category: 'Navigation', component: ArrowLeft },
{ name: 'chevron-right', pascalName: 'ChevronRight', category: 'Navigation', component: ChevronRight },
{ name: 'chevron-left', pascalName: 'ChevronLeft', category: 'Navigation', component: ChevronLeft },
{ name: 'chevron-down', pascalName: 'ChevronDown', category: 'Navigation', component: ChevronDown },
{ name: 'chevron-up', pascalName: 'ChevronUp', category: 'Navigation', component: ChevronUp },
{ name: 'external-link', pascalName: 'ExternalLink', category: 'Navigation', component: ExternalLink },
{ name: 'menu', pascalName: 'Menu', category: 'Navigation', component: Menu },
// System & AI
{ name: 'sparkles', pascalName: 'Sparkles', category: 'System', component: Sparkles },
{ name: 'terminal', pascalName: 'Terminal', category: 'System', component: Terminal },
{ name: 'code', pascalName: 'Code2', category: 'System', component: Code2 },
{ name: 'cpu', pascalName: 'Cpu', category: 'System', component: Cpu },
{ name: 'layers', pascalName: 'Layers', category: 'System', component: Layers },
{ name: 'sun', pascalName: 'Sun', category: 'System', component: Sun },
{ name: 'moon', pascalName: 'Moon', category: 'System', component: Moon },
{ name: 'database', pascalName: 'Database', category: 'System', component: Database },
// Interface & Actions
{ name: 'search', pascalName: 'Search', category: 'Interface', component: Search },
{ name: 'copy', pascalName: 'Copy', category: 'Interface', component: Copy },
{ name: 'settings', pascalName: 'Settings', category: 'Interface', component: Settings },
{ name: 'sliders', pascalName: 'SlidersHorizontal', category: 'Interface', component: SlidersHorizontal },
{ name: 'filter', pascalName: 'Filter', category: 'Interface', component: Filter },
{ name: 'plus', pascalName: 'Plus', category: 'Interface', component: Plus },
{ name: 'minus', pascalName: 'Minus', category: 'Interface', component: Minus },
{ name: 'trash', pascalName: 'Trash2', category: 'Interface', component: Trash2 },
{ name: 'refresh', pascalName: 'RefreshCw', category: 'Interface', component: RefreshCw },
{ name: 'download', pascalName: 'Download', category: 'Interface', component: Download },
{ name: 'upload', pascalName: 'Upload', category: 'Interface', component: Upload },
{ name: 'folder', pascalName: 'Folder', category: 'Interface', component: Folder },
{ name: 'file', pascalName: 'FileText', category: 'Interface', component: FileText },
{ name: 'user', pascalName: 'User', category: 'Interface', component: User },
{ name: 'calendar', pascalName: 'Calendar', category: 'Interface', component: Calendar },
{ name: 'clock', pascalName: 'Clock', category: 'Interface', component: Clock },
// Status & Feedback
{ name: 'check', pascalName: 'Check', category: 'Status', component: Check },
{ name: 'check-circle', pascalName: 'CheckCircle2', category: 'Status', component: CheckCircle2 },
{ name: 'warning', pascalName: 'AlertTriangle', category: 'Status', component: AlertTriangle },
{ name: 'alert', pascalName: 'AlertCircle', category: 'Status', component: AlertCircle },
{ name: 'info', pascalName: 'Info', category: 'Status', component: Info },
{ name: 'close', pascalName: 'X', category: 'Status', component: X },
{ name: 'shield', pascalName: 'Shield', category: 'Status', component: Shield },
{ name: 'help', pascalName: 'HelpCircle', category: 'Status', component: HelpCircle },
{ name: 'eye', pascalName: 'Eye', category: 'Status', component: Eye },
]
/* 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]
@@ -113,6 +19,7 @@ export function IconMatrix() {
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 => {
@@ -128,7 +35,7 @@ export function IconMatrix() {
const code = `<${icon.pascalName} size={${size}} />`
navigator.clipboard?.writeText(code)
setCopiedName(icon.name)
setTimeout(() => setCopiedName(null), 2000)
schedule(() => setCopiedName(null), 2000)
}
return (
@@ -140,6 +47,7 @@ export function IconMatrix() {
<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"
+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>
)}
+13 -4
View File
@@ -1,23 +1,32 @@
import { Icon } from './Icon'
import type { PatternId } from './AIWorkflowSection'
export type NavigationItem = { label: string; count?: number; id?: string }
export type NavigationItem = { label: string; count?: number; id?: string; patternId?: PatternId }
export type NavigationGroup = { label: string; items: NavigationItem[] }
type SidebarProps = {
active: string
groups: NavigationGroup[]
onSelect: (item: NavigationItem) => void
collapsed?: boolean
onToggleCollapse?: () => void
}
export function Sidebar({ active, groups, onSelect }: SidebarProps) {
export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleCollapse }: SidebarProps) {
return (
<aside className="sidebar">
<div className="sidebar-heading">
<div className="sidebar-library">
<Icon name="layers" size={13} /> Library
</div>
<button className="sidebar-collapse" aria-label="Collapse sidebar">
⌘
<button
type="button"
className="sidebar-collapse"
onClick={onToggleCollapse}
aria-pressed={collapsed}
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
>
{collapsed ? '⌘+' : '⌘−'}
</button>
</div>
+54 -38
View File
@@ -1,45 +1,56 @@
import { useState } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { Icon } from './Icon'
export type Theme = 'dark' | 'light'
/**
* Read the theme the pre-paint script in index.html already applied to
* <html> — that keeps first paint flash-free and honors both the saved
* preference and the OS `prefers-color-scheme` when nothing is saved.
* This hook never touches the DOM during render/state-initialization.
*/
function getInitialTheme(): Theme {
const attr = document.documentElement.getAttribute('data-theme')
return attr === 'light' ? 'light' : 'dark'
}
/** App theme is a data attribute + class on <html>; tokens.css defines both palettes. */
function applyTheme(theme: Theme) {
const root = document.documentElement
root.dataset.theme = theme
root.classList.toggle('dark', theme === 'dark')
root.classList.toggle('light', theme === 'light')
}
function useTheme() {
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
useEffect(() => {
applyTheme(theme)
}, [theme])
const toggle = useCallback(() => {
const next: Theme = theme === 'dark' ? 'light' : 'dark'
setThemeState(next)
persistTheme(next)
}, [theme])
return { theme, toggle }
}
function persistTheme(theme: Theme) {
try {
localStorage.setItem('ns-theme', theme)
} catch {
/* private mode — theme just won't persist */
}
}
type TopbarProps = {
onOpenSearch: () => void
onMenuToggle: () => void
}
/** App theme is a data attribute on <html>; tokens.css defines both palettes. */
function useTheme() {
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
const saved = typeof window !== 'undefined' ? localStorage.getItem('ns-theme') : null
const initial = saved === 'light' ? 'light' : 'dark'
if (typeof document !== 'undefined') {
document.documentElement.dataset.theme = initial
if (initial === 'dark') {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
}
return initial
})
const toggle = () => {
const next = theme === 'dark' ? 'light' : 'dark'
setTheme(next)
document.documentElement.dataset.theme = next
if (next === 'dark') {
document.documentElement.classList.add('dark')
document.documentElement.classList.remove('light')
} else {
document.documentElement.classList.add('light')
document.documentElement.classList.remove('dark')
}
localStorage.setItem('ns-theme', next)
}
return { theme, toggle }
}
export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
const { theme, toggle } = useTheme()
@@ -67,13 +78,18 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
<nav className="top-links">
<a href="#docs">Docs</a>
<a href="#github">GitHub ↗</a>
<button className="icon-button" onClick={toggle} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}>
<button
type="button"
className="icon-button"
onClick={toggle}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
>
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
</button>
<button className="avatar">MS</button>
<button type="button" className="avatar">MS</button>
</nav>
<button className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
<button type="button" className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
<Icon name="menu" />
</button>
</header>
+16 -7
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import {
Dialog,
DialogTrigger,
@@ -22,13 +23,21 @@ export function DeploymentModal() {
const [deploying, setDeploying] = useState(false)
const [progress, setProgress] = useState(0)
const [deployed, setDeployed] = useState(false)
const { schedule: scheduleDeploy, clear: clearDeploy } = useTimeout()
const { schedule: scheduleReset } = useTimeout()
/* Closing cancels in-flight rollout timers so they never fire on a hidden dialog.
The post-close content reset uses its own timer so it survives this cleanup. */
useEffect(() => {
if (!open) clearDeploy()
}, [open, clearDeploy])
const handleStartDeploy = () => {
setDeploying(true)
setProgress(15)
setTimeout(() => setProgress(45), 500)
setTimeout(() => setProgress(85), 1000)
setTimeout(() => {
scheduleDeploy(() => setProgress(45), 500)
scheduleDeploy(() => setProgress(85), 1000)
scheduleDeploy(() => {
setProgress(100)
setDeploying(false)
setDeployed(true)
@@ -37,7 +46,7 @@ export function DeploymentModal() {
const handleReset = () => {
setOpen(false)
setTimeout(() => {
scheduleReset(() => {
setDeploying(false)
setProgress(0)
setDeployed(false)
@@ -46,7 +55,7 @@ export function DeploymentModal() {
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger>
<DialogTrigger asChild>
<button className="button primary">
<Icon name="sparkles" size={15} /> Promote Release to Canary
</button>
@@ -109,7 +118,7 @@ export function DeploymentModal() {
<DialogFooter>
{!deployed ? (
<>
<DialogClose>
<DialogClose asChild>
<button className="button secondary" disabled={deploying}>
Cancel
</button>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'
import { Switch } from '../ui/Switch'
import { Badge } from '../ui/Badge'
@@ -18,16 +19,17 @@ export function SettingsPanel() {
const [autoRollback, setAutoRollback] = useState(false)
const apiKey = 'nsk_live_98a72ef01bc34d89a2'
const { schedule } = useTimeout()
const copyKey = () => {
navigator.clipboard?.writeText(apiKey)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
schedule(() => setCopied(false), 2000)
}
const handleSave = () => {
setSaved(true)
setTimeout(() => setSaved(false), 2500)
schedule(() => setSaved(false), 2500)
}
return (
+179
View File
@@ -0,0 +1,179 @@
/* The one icon registry — canonical names (lucide-style kebab) + components.
IconMatrix, SearchPalette and every <Icon name="…"/> consumer type-check
against this map. Kept in its own module so it can be imported without
pulling in components (and without breaking react-fast-refresh). */
import {
AlertCircle,
AlertTriangle,
ArrowLeft,
ArrowRight,
Calendar,
Check,
CheckCircle2,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronUp,
Clock,
Code2,
Copy,
Cpu,
Database,
Download,
ExternalLink,
Eye,
FileText,
Filter,
Folder,
HelpCircle,
Info,
Layers,
Menu,
Minus,
Moon,
Plus,
RefreshCw,
Search,
Settings,
Shield,
SlidersHorizontal,
Sparkles,
Sun,
Terminal,
Trash2,
Upload,
User,
X,
type LucideIcon,
} from 'lucide-react'
const iconComponents = {
// Navigation
'arrow-right': ArrowRight,
'arrow-left': ArrowLeft,
'chevron-right': ChevronRight,
'chevron-left': ChevronLeft,
'chevron-down': ChevronDown,
'chevron-up': ChevronUp,
'external-link': ExternalLink,
menu: Menu,
// System & AI
sparkles: Sparkles,
terminal: Terminal,
code: Code2,
cpu: Cpu,
layers: Layers,
sun: Sun,
moon: Moon,
database: Database,
// Interface & actions
search: Search,
copy: Copy,
settings: Settings,
sliders: SlidersHorizontal,
filter: Filter,
plus: Plus,
minus: Minus,
trash: Trash2,
refresh: RefreshCw,
download: Download,
upload: Upload,
folder: Folder,
file: FileText,
user: User,
calendar: Calendar,
clock: Clock,
// Status & feedback
check: Check,
'check-circle': CheckCircle2,
warning: AlertTriangle,
alert: AlertCircle,
info: Info,
close: X,
shield: Shield,
help: HelpCircle,
eye: Eye,
} as const
/** Legacy Northstar shorthands kept as aliases so old call sites keep working.
Values are direct references so iconMap stays Record<IconName, LucideIcon>. */
const iconAliases = {
arrow: ArrowRight,
sparkle: Sparkles,
x: X,
'trash-2': Trash2,
'alert-triangle': AlertTriangle,
'alert-circle': AlertCircle,
} as const
export const iconMap = { ...iconComponents, ...iconAliases }
export type IconName = keyof typeof iconMap
export type IconCategory = 'Navigation' | 'Interface' | 'System' | 'Status'
export type IconRegistryEntry = {
name: IconName
pascalName: string
category: IconCategory
component: LucideIcon
}
/* Curated showcase metadata — the compiler forces one entry per canonical
icon, so a rename in `iconComponents` breaks here loudly (and in tests). */
const iconMeta: Record<keyof typeof iconComponents, { pascalName: string; category: IconCategory }> = {
'arrow-right': { pascalName: 'ArrowRight', category: 'Navigation' },
'arrow-left': { pascalName: 'ArrowLeft', category: 'Navigation' },
'chevron-right': { pascalName: 'ChevronRight', category: 'Navigation' },
'chevron-left': { pascalName: 'ChevronLeft', category: 'Navigation' },
'chevron-down': { pascalName: 'ChevronDown', category: 'Navigation' },
'chevron-up': { pascalName: 'ChevronUp', category: 'Navigation' },
'external-link': { pascalName: 'ExternalLink', category: 'Navigation' },
menu: { pascalName: 'Menu', category: 'Navigation' },
sparkles: { pascalName: 'Sparkles', category: 'System' },
terminal: { pascalName: 'Terminal', category: 'System' },
code: { pascalName: 'Code2', category: 'System' },
cpu: { pascalName: 'Cpu', category: 'System' },
layers: { pascalName: 'Layers', category: 'System' },
sun: { pascalName: 'Sun', category: 'System' },
moon: { pascalName: 'Moon', category: 'System' },
database: { pascalName: 'Database', category: 'System' },
search: { pascalName: 'Search', category: 'Interface' },
copy: { pascalName: 'Copy', category: 'Interface' },
settings: { pascalName: 'Settings', category: 'Interface' },
sliders: { pascalName: 'SlidersHorizontal', category: 'Interface' },
filter: { pascalName: 'Filter', category: 'Interface' },
plus: { pascalName: 'Plus', category: 'Interface' },
minus: { pascalName: 'Minus', category: 'Interface' },
trash: { pascalName: 'Trash2', category: 'Interface' },
refresh: { pascalName: 'RefreshCw', category: 'Interface' },
download: { pascalName: 'Download', category: 'Interface' },
upload: { pascalName: 'Upload', category: 'Interface' },
folder: { pascalName: 'Folder', category: 'Interface' },
file: { pascalName: 'FileText', category: 'Interface' },
user: { pascalName: 'User', category: 'Interface' },
calendar: { pascalName: 'Calendar', category: 'Interface' },
clock: { pascalName: 'Clock', category: 'Interface' },
check: { pascalName: 'Check', category: 'Status' },
'check-circle': { pascalName: 'CheckCircle2', category: 'Status' },
warning: { pascalName: 'AlertTriangle', category: 'Status' },
alert: { pascalName: 'AlertCircle', category: 'Status' },
info: { pascalName: 'Info', category: 'Status' },
close: { pascalName: 'X', category: 'Status' },
shield: { pascalName: 'Shield', category: 'Status' },
help: { pascalName: 'HelpCircle', category: 'Status' },
eye: { pascalName: 'Eye', category: 'Status' },
}
/** Curated icon explorer data (used by IconMatrix). Aliases are not listed. */
export const iconRegistry: IconRegistryEntry[] = (
Object.entries(iconComponents) as [keyof typeof iconComponents, LucideIcon][]
).map(([name, component]) => ({
name: name as IconName,
pascalName: iconMeta[name].pascalName,
category: iconMeta[name].category,
component,
}))
+1 -1
View File
@@ -39,7 +39,7 @@ export function AlertTitle({ className, children, ...props }: HTMLAttributes<HTM
export function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<div
<p
className={cn(className)}
style={{
margin: 0,
+112
View File
@@ -0,0 +1,112 @@
import { cn } from '../../utils/cn'
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg'
export type AvatarStatus = 'online' | 'away' | 'offline'
export type AvatarProps = {
name: string
src?: string
size?: AvatarSize
status?: AvatarStatus
className?: string
}
const dimensions: Record<AvatarSize, string> = {
xs: 'var(--space-5)',
sm: 'var(--space-6)',
md: 'var(--space-7)',
lg: 'var(--space-9)',
}
const fontSizes: Record<AvatarSize, string> = {
xs: 'var(--text-3xs)',
sm: 'var(--text-2xs)',
md: 'var(--text-xs)',
lg: 'var(--text-base)',
}
const dotSizes: Record<AvatarSize, string> = {
xs: 'var(--space-1)',
sm: 'var(--space-1)',
md: 'var(--space-2)',
lg: 'var(--space-2)',
}
const statusColors: Record<AvatarStatus, string> = {
online: 'var(--chart-2)',
away: 'var(--chart-3)',
offline: 'var(--muted-foreground)',
}
function getInitials(name: string): string {
const initials = name
.trim()
.split(/\s+/)
.filter(Boolean)
.slice(0, 2)
.map((word) => word.charAt(0))
.join('')
.toUpperCase()
return initials || '?'
}
export function Avatar({ name, src, size = 'md', status, className }: AvatarProps) {
const dimension = dimensions[size]
return (
<span
className={cn('flex-center', className)}
role={src ? undefined : 'img'}
aria-label={src ? undefined : name || 'Avatar'}
style={{
position: 'relative',
flexShrink: 0,
width: dimension,
height: dimension,
borderRadius: 'var(--radius-pill)',
background: 'color-mix(in srgb, var(--primary) 16%, var(--background))',
color: 'var(--primary)',
fontFamily: 'var(--font-sans)',
fontSize: fontSizes[size],
fontWeight: 700,
lineHeight: 1,
letterSpacing: '0.02em',
userSelect: 'none',
}}
>
{src ? (
<img
src={src}
alt={name}
style={{
display: 'block',
width: '100%',
height: '100%',
objectFit: 'cover',
borderRadius: 'var(--radius-pill)',
}}
/>
) : (
<span aria-hidden="true">{getInitials(name)}</span>
)}
{status && (
<span
aria-hidden="true"
style={{
position: 'absolute',
right: 0,
bottom: 0,
boxSizing: 'content-box',
width: dotSizes[size],
height: dotSizes[size],
borderRadius: 'var(--radius-pill)',
background: statusColors[status],
border: '2px solid var(--background)',
}}
/>
)}
</span>
)
}
+4 -6
View File
@@ -1,17 +1,15 @@
import type { ReactNode } from 'react'
import type { HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
type BadgeProps = {
children: ReactNode
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
variant?: BadgeVariant
className?: string
}
export function Badge({ children, variant = 'primary', className }: BadgeProps) {
export function Badge({ variant = 'primary', className, children, ...props }: BadgeProps) {
return (
<span className={cn('badge', `badge-${variant}`, className)}>
<span className={cn('badge', `badge-${variant}`, className)} {...props}>
{children}
</span>
)
+54 -119
View File
@@ -1,18 +1,17 @@
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
type DialogContextType = {
open: boolean
setOpen: (open: boolean) => void
}
const DialogContext = createContext<DialogContextType | null>(null)
function useDialog() {
const ctx = useContext(DialogContext)
if (!ctx) throw new Error('Dialog components must be used within a <Dialog>')
return ctx
}
/* Dialog is a thin skin over the shared Overlay primitive — all behavior
(focus trap, Escape, scroll lock, focus restore, aria wiring) lives there. */
export type DialogProps = {
children: ReactNode
@@ -21,124 +20,60 @@ export type DialogProps = {
onOpenChange?: (open: boolean) => void
}
export function Dialog({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<DialogContext.Provider value={{ open, setOpen }}>
{children}
</DialogContext.Provider>
)
export function Dialog(props: DialogProps) {
return <Overlay {...props} />
}
export function DialogTrigger({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useDialog()
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
export function DialogTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
return <OverlayTrigger {...props} />
}
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
const { open, setOpen } = useDialog()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, setOpen])
if (!open) return null
return (
<div className="dialog-backdrop" onClick={() => setOpen(false)}>
<div className={cn('dialog-panel', className)} onClick={e => e.stopPropagation()}>
<button
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label="Close dialog"
>
✕
</button>
{children}
</div>
</div>
)
}
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-4)' }} {...props} />
}
export function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-xl)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.03em',
}}
{...props}
<OverlayContent
backdropClassName="dialog-backdrop"
panelClassName="dialog-panel"
closeButtonLabel="Close dialog"
className={className}
>
{children}
</h2>
</OverlayContent>
)
}
export function DialogDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
export function DialogHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-4)', ...style }
return <OverlayHeader className={className} style={defaultStyle} {...props} />
}
export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex-between', className)}
style={{
marginTop: 'var(--space-6)',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
}}
{...props}
/>
)
export function DialogTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-xl)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.03em',
...style,
}
return <OverlayTitle className={className} style={defaultStyle} {...props} />
}
export function DialogClose({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useDialog()
return (
<span className={className} onClick={() => setOpen(false)}>
{children}
</span>
)
export function DialogDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
...style,
}
return <OverlayDescription className={className} style={defaultStyle} {...props} />
}
export function DialogFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginTop: 'var(--space-6)', ...style }
return <OverlayFooter className={className} style={defaultStyle} {...props} />
}
export function DialogClose(props: Parameters<typeof OverlayClose>[0]) {
return <OverlayClose {...props} />
}
+294
View File
@@ -0,0 +1,294 @@
import {
createContext,
useContext,
useState,
useEffect,
useRef,
useCallback,
useId,
useMemo,
cloneElement,
isValidElement,
type ReactNode,
type ReactElement,
type HTMLAttributes,
type ButtonHTMLAttributes,
type CSSProperties,
type MouseEvent as ReactMouseEvent,
type KeyboardEvent as ReactKeyboardEvent,
} from 'react'
import { cn } from '../../utils/cn'
import { trapTabKey, useOverlayLifecycle } from './overlayBehavior'
/* ── Overlay: the one modal primitive ─────────────────────────────
Focus trap, Escape-to-close, scroll lock, focus restore, and the
aria wiring all live here. Dialog and Sheet are thin wrappers that
only contribute class names and type-scale defaults. */
type OverlayContextType = {
open: boolean
setOpen: (open: boolean) => void
titleId: string
descriptionId: string
/** True while a <OverlayTitle> is mounted — aria-labelledby is only wired then. */
hasTitle: boolean
/** True while a <OverlayDescription> is mounted. */
hasDescription: boolean
registerTitle: (present: boolean) => void
registerDescription: (present: boolean) => void
}
const OverlayContext = createContext<OverlayContextType | null>(null)
function useOverlay() {
const ctx = useContext(OverlayContext)
if (!ctx) throw new Error('Overlay components must be used within an <Overlay> (Dialog/Sheet)')
return ctx
}
/* ── Trigger / Close buttons ────────────────────────────────────── */
/* Neutralize UA button chrome so the trigger/close buttons stay visually
identical to the spans they replaced. */
const neutralButtonStyle: CSSProperties = {
background: 'none',
border: 0,
padding: 0,
font: 'inherit',
color: 'inherit',
cursor: 'pointer',
}
type OverlayActionProps = ButtonHTMLAttributes<HTMLButtonElement> & {
/** Attach open/close behavior to the caller's own element instead of rendering a wrapper <button>. */
asChild?: boolean
}
/* Shared Trigger/Close body. `asChild` merges the click behavior into the
caller's element so styled buttons don't end up nested inside a button. */
function OverlayButton({
next,
setOpen,
children,
className,
onClick,
style,
asChild = false,
...props
}: OverlayActionProps & { next: boolean; setOpen: (next: boolean) => void }) {
const handleActivate = (e: ReactMouseEvent<HTMLElement>) => {
onClick?.(e as ReactMouseEvent<HTMLButtonElement>)
if (!e.defaultPrevented) setOpen(next)
}
if (asChild && isValidElement(children)) {
const child = children as ReactElement<{
className?: string
style?: CSSProperties
}>
const merged: { onClick: typeof handleActivate; className?: string; style?: CSSProperties } = {
onClick: handleActivate,
}
if (className) merged.className = cn(child.props.className, className)
if (style) merged.style = { ...child.props.style, ...style }
return cloneElement(child, merged)
}
return (
<button
{...props}
type="button"
className={className}
style={{ ...neutralButtonStyle, ...style }}
onClick={handleActivate}
>
{children}
</button>
)
}
/* ── Root ───────────────────────────────────────────────────────── */
export type OverlayProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Overlay({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: OverlayProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const [hasTitle, setHasTitle] = useState(false)
const [hasDescription, setHasDescription] = useState(false)
const titleId = useId()
const descriptionId = useId()
const setOpen = useCallback(
(next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
},
[isControlled, onOpenChange],
)
const registerTitle = useCallback((present: boolean) => setHasTitle(present), [])
const registerDescription = useCallback((present: boolean) => setHasDescription(present), [])
const contextValue = useMemo(
() => ({
open,
setOpen,
titleId,
descriptionId,
hasTitle,
hasDescription,
registerTitle,
registerDescription,
}),
[open, setOpen, titleId, descriptionId, hasTitle, hasDescription, registerTitle, registerDescription],
)
return <OverlayContext.Provider value={contextValue}>{children}</OverlayContext.Provider>
}
export function OverlayTrigger(props: OverlayActionProps) {
const { setOpen } = useOverlay()
return <OverlayButton {...props} setOpen={setOpen} next />
}
/* ── Content: backdrop + panel + close button ───────────────────── */
export type OverlayContentProps = {
children: ReactNode
className?: string
/** Backdrop class from the wrapper (e.g. `dialog-backdrop`). */
backdropClassName: string
/** Panel class from the wrapper (e.g. `dialog-panel`). */
panelClassName: string
/** Accessible label for the ✕ button ("Close dialog" / "Close sheet"). */
closeButtonLabel: string
}
export function OverlayContent({
children,
className,
backdropClassName,
panelClassName,
closeButtonLabel,
}: OverlayContentProps) {
const { open, setOpen, titleId, descriptionId, hasTitle, hasDescription } = useOverlay()
const panelRef = useRef<HTMLDivElement>(null)
useOverlayLifecycle(open, panelRef)
/* Escape-to-close: subscribed once per open, not on every render. */
useEffect(() => {
if (!open) return
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', handleKeyDown)
return () => document.removeEventListener('keydown', handleKeyDown)
}, [open, setOpen])
if (!open) return null
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
trapTabKey(e, panelRef.current)
}
return (
<div className={backdropClassName} onClick={() => setOpen(false)}>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={hasTitle ? titleId : undefined}
aria-describedby={hasDescription ? descriptionId : undefined}
tabIndex={-1}
className={cn(panelClassName, className)}
onKeyDown={handlePanelKeyDown}
onClick={e => e.stopPropagation()}
>
<button
type="button"
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label={closeButtonLabel}
>
✕
</button>
{children}
</div>
</div>
)
}
/* ── Header / Title / Description / Footer ──────────────────────── */
export function OverlayHeader({
className,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
return <div className={cn('stack-xs', className)} style={style} {...props} />
}
export function OverlayTitle({
className,
style,
...props
}: HTMLAttributes<HTMLHeadingElement> & { style?: CSSProperties }) {
const { titleId, registerTitle } = useOverlay()
/* Register so the panel's aria-labelledby only points at a mounted title. */
useEffect(() => {
registerTitle(true)
return () => registerTitle(false)
}, [registerTitle])
return <h2 id={titleId} className={cn(className)} style={style} {...props} />
}
export function OverlayDescription({
className,
style,
...props
}: HTMLAttributes<HTMLParagraphElement> & { style?: CSSProperties }) {
const { descriptionId, registerDescription } = useOverlay()
useEffect(() => {
registerDescription(true)
return () => registerDescription(false)
}, [registerDescription])
return <p id={descriptionId} className={cn(className)} style={style} {...props} />
}
export function OverlayFooter({
className,
style,
...props
}: HTMLAttributes<HTMLDivElement> & { style?: CSSProperties }) {
return (
<div
className={cn('flex-between', className)}
style={{
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
...style,
}}
{...props}
/>
)
}
export function OverlayClose(props: OverlayActionProps) {
const { setOpen } = useOverlay()
return <OverlayButton {...props} setOpen={setOpen} next={false} />
}
+15 -4
View File
@@ -1,4 +1,4 @@
import type { HTMLAttributes } from 'react'
import { useId, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
type ProgressProps = HTMLAttributes<HTMLDivElement> & {
@@ -16,17 +16,28 @@ export function Progress({
className,
...props
}: ProgressProps) {
const percentage = Math.min(Math.max(Math.round((value / max) * 100), 0), 100)
/* Guard NaN from max={0}: clamp against a sane fallback instead. */
const safeMax = max > 0 ? max : 100
const percentage = Math.min(Math.max(Math.round((value / safeMax) * 100), 0), 100)
const labelId = useId()
return (
<div className={cn('stack-xs', className)} {...props}>
{(label || !indeterminate) && (
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
{label && <span>{label}</span>}
{label && <span id={labelId}>{label}</span>}
{!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
</div>
)}
<div className="progress-container">
<div
className="progress-container"
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={indeterminate ? undefined : percentage}
aria-busy={indeterminate ? true : undefined}
aria-labelledby={label ? labelId : undefined}
>
<div
className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
style={!indeterminate ? { width: `${percentage}%` } : undefined}
+56 -120
View File
@@ -1,144 +1,80 @@
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
import { cn } from '../../utils/cn'
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
type SheetContextType = {
open: boolean
setOpen: (open: boolean) => void
}
/* Sheet is the slide-over skin of the shared Overlay primitive — identical
focus trap / Escape / scroll-lock behavior as Dialog, only different
type-scale defaults and panel classes. */
const SheetContext = createContext<SheetContextType | null>(null)
function useSheet() {
const ctx = useContext(SheetContext)
if (!ctx) throw new Error('Sheet components must be used within <Sheet>')
return ctx
}
type SheetProps = {
export type SheetProps = {
children: ReactNode
open?: boolean
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
}
export function Sheet({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: SheetProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
const isControlled = controlledOpen !== undefined
const open = isControlled ? controlledOpen : uncontrolledOpen
const setOpen = (next: boolean) => {
if (!isControlled) setUncontrolledOpen(next)
onOpenChange?.(next)
}
return (
<SheetContext.Provider value={{ open, setOpen }}>
{children}
</SheetContext.Provider>
)
export function Sheet(props: SheetProps) {
return <Overlay {...props} />
}
export function SheetTrigger({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useSheet()
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
export function SheetTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
return <OverlayTrigger {...props} />
}
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) {
const { open, setOpen } = useSheet()
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false)
}
if (open) {
document.addEventListener('keydown', handleKeyDown)
document.body.style.overflow = 'hidden'
}
return () => {
document.removeEventListener('keydown', handleKeyDown)
document.body.style.overflow = ''
}
}, [open, setOpen])
if (!open) return null
return (
<div className="sheet-backdrop" onClick={() => setOpen(false)}>
<div className={cn('sheet-panel', className)} onClick={e => e.stopPropagation()}>
<button
className="dialog-close-btn"
onClick={() => setOpen(false)}
aria-label="Close sheet"
>
✕
</button>
{children}
</div>
</div>
)
}
export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-6)' }} {...props} />
}
export function SheetTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return (
<h2
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
}}
{...props}
<OverlayContent
backdropClassName="sheet-backdrop"
panelClassName="sheet-panel"
closeButtonLabel="Close sheet"
className={className}
>
{children}
</h2>
</OverlayContent>
)
}
export function SheetDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return (
<p
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
export function SheetHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-6)', ...style }
return <OverlayHeader className={className} style={defaultStyle} {...props} />
}
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div
className={cn('flex-between', className)}
style={{
marginTop: 'auto',
paddingTop: 'var(--space-4)',
borderTop: '1px solid var(--border)',
gap: 'var(--space-3)',
}}
{...props}
/>
)
export function SheetTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-lg)',
fontWeight: 700,
color: 'var(--foreground)',
letterSpacing: '-0.02em',
...style,
}
return <OverlayTitle className={className} style={defaultStyle} {...props} />
}
export function SheetClose({ children, className }: { children: ReactNode; className?: string }) {
const { setOpen } = useSheet()
return (
<span className={className} onClick={() => setOpen(false)}>
{children}
</span>
)
export function SheetDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const defaultStyle: CSSProperties = {
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
...style,
}
return <OverlayDescription className={className} style={defaultStyle} {...props} />
}
export function SheetFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
const defaultStyle: CSSProperties = { marginTop: 'auto', ...style }
return <OverlayFooter className={className} style={defaultStyle} {...props} />
}
export function SheetClose(props: Parameters<typeof OverlayClose>[0]) {
return <OverlayClose {...props} />
}
+61
View File
@@ -0,0 +1,61 @@
import type { CSSProperties } from 'react'
import { cn } from '../../utils/cn'
import './spinner.css'
export type SpinnerSize = 'xs' | 'sm' | 'md' | 'lg'
export type SpinnerProps = {
size?: SpinnerSize
label?: string
className?: string
}
/** Ring geometry — sizes come from the spacing scale, never from literals. */
const sizeStyles: Record<SpinnerSize, CSSProperties> = {
xs: { width: 'var(--space-3)', height: 'var(--space-3)', borderWidth: '1px' },
sm: { width: 'var(--space-4)', height: 'var(--space-4)', borderWidth: '2px' },
md: { width: 'var(--space-6)', height: 'var(--space-6)', borderWidth: '2px' },
lg: { width: 'var(--space-8)', height: 'var(--space-8)', borderWidth: '3px' },
}
/** Screen-reader-only clip pattern, kept inline so no CSS file has to change. */
const visuallyHidden: CSSProperties = {
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0 0 0 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}
export function Spinner({ size = 'md', label, className }: SpinnerProps) {
return (
<div
role="status"
aria-live="polite"
className={cn('ui-spinner', `ui-spinner-${size}`, className)}
>
<span
aria-hidden="true"
style={{
display: 'inline-block',
boxSizing: 'border-box',
borderStyle: 'solid',
borderColor: 'var(--border)',
borderTopColor: 'var(--primary)',
borderRadius: '50%',
animation: 'spin 0.8s linear infinite',
...sizeStyles[size],
}}
/>
{label ? (
<span className="visually-hidden" style={visuallyHidden}>
{label}
</span>
) : null}
</div>
)
}
+17 -2
View File
@@ -7,21 +7,36 @@ type StatCardProps = {
title: string
value: string | number
change?: string
/** Sentiment — drives the badge color. Up = good news, down = bad news. */
trend?: 'up' | 'down' | 'neutral'
/**
* Arrow direction — which way the number moved. Defaults to the sign of
* `change`, so "trend=up" (good) with change="-4.2ms" renders a green
* "↓ -4.2ms" instead of the nonsensical "↑ -4.2ms".
*/
direction?: 'up' | 'down' | 'neutral'
caption?: string
icon?: ReactNode
className?: string
}
/** "↓ good latency" needs an arrow that follows the sign of the change. */
function inferDirection(change?: string): 'up' | 'down' | 'neutral' {
if (!change || !/[0-9]/.test(change)) return 'neutral'
return change.trim().startsWith('-') ? 'down' : 'up'
}
export function StatCard({
title,
value,
change,
trend = 'up',
trend = 'neutral',
direction,
caption,
icon,
className,
}: StatCardProps) {
const arrowDirection = direction ?? inferDirection(change)
return (
<Card className={cn('hover-lift', className)}>
<div className="flex-between" style={{ marginBottom: 'var(--space-3)' }}>
@@ -65,7 +80,7 @@ export function StatCard({
<div className="flex-between" style={{ marginTop: 'var(--space-2)' }}>
{change && (
<Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} {change}
{arrowDirection === 'up' ? '↑' : arrowDirection === 'down' ? '↓' : '→'} {change}
</Badge>
)}
{caption && (
+87 -8
View File
@@ -1,9 +1,18 @@
import { createContext, useContext, useState, type ReactNode, type HTMLAttributes } from 'react'
import {
createContext,
useContext,
useId,
useState,
type HTMLAttributes,
type KeyboardEvent,
type ReactNode,
} from 'react'
import { cn } from '../../utils/cn'
type TabsContextType = {
value: string
setValue: (value: string) => void
baseId: string
}
const TabsContext = createContext<TabsContextType | null>(null)
@@ -14,6 +23,11 @@ function useTabs() {
return ctx
}
/** Stable, SSR-safe pairing between a trigger and its panel. */
function partId(baseId: string, kind: 'trigger' | 'content', value: string) {
return `${baseId}-${kind}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
}
type TabsProps = {
defaultValue?: string
value?: string
@@ -24,6 +38,7 @@ type TabsProps = {
export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
const baseId = useId()
const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue
@@ -33,14 +48,66 @@ export function Tabs({ defaultValue = '', value: controlledValue, onValueChange,
}
return (
<TabsContext.Provider value={{ value, setValue }}>
<TabsContext.Provider value={{ value, setValue, baseId }}>
<div className={cn('ui-tabs-root', className)}>{children}</div>
</TabsContext.Provider>
)
}
export function TabsList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
return <div className={cn('ui-tabs-list', className)} role="tablist" {...props} />
const NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End'])
export function TabsList({ className, onKeyDown, ...props }: HTMLAttributes<HTMLDivElement>) {
const { setValue } = useTabs()
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || !NAV_KEYS.has(event.key)) return
const tabs = Array.from(
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'),
)
if (tabs.length === 0) return
const focused = tabs.indexOf(document.activeElement as HTMLButtonElement)
const selected = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true')
const current = focused >= 0 ? focused : selected
if (current < 0) return
let next: number
switch (event.key) {
case 'ArrowRight':
next = (current + 1) % tabs.length
break
case 'ArrowLeft':
next = (current - 1 + tabs.length) % tabs.length
break
case 'Home':
next = 0
break
default:
next = tabs.length - 1
break
}
const nextTab = tabs[next]
if (nextTab === undefined) return
const nextValue = nextTab.dataset.value
if (nextValue === undefined) return
event.preventDefault()
nextTab.focus()
setValue(nextValue)
}
return (
<div
className={cn('ui-tabs-list', className)}
role="tablist"
aria-orientation="horizontal"
onKeyDown={handleKeyDown}
{...props}
/>
)
}
type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
@@ -48,14 +115,18 @@ type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
}
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
const { value: selectedValue, setValue } = useTabs()
const { value: selectedValue, setValue, baseId } = useTabs()
const isActive = selectedValue === value
return (
<button
type="button"
id={partId(baseId, 'trigger', value)}
role="tab"
aria-selected={isActive}
aria-controls={partId(baseId, 'content', value)}
tabIndex={isActive ? 0 : -1}
data-value={value}
className={cn('ui-tabs-trigger', isActive && 'active', className)}
onClick={() => setValue(value)}
{...props}
@@ -70,11 +141,19 @@ type TabsContentProps = HTMLAttributes<HTMLDivElement> & {
}
export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
const { value: selectedValue } = useTabs()
if (selectedValue !== value) return null
const { value: selectedValue, baseId } = useTabs()
const isActive = selectedValue === value
return (
<div className={cn('ui-tabs-content', className)} role="tabpanel" {...props}>
<div
id={partId(baseId, 'content', value)}
role="tabpanel"
aria-labelledby={partId(baseId, 'trigger', value)}
tabIndex={0}
hidden={!isActive}
className={cn('ui-tabs-content', className)}
{...props}
>
{children}
</div>
)
+67 -4
View File
@@ -1,4 +1,12 @@
import type { ReactNode } from 'react'
import {
Fragment,
cloneElement,
isValidElement,
useId,
useState,
type ReactElement,
type ReactNode,
} from 'react'
import { cn } from '../../utils/cn'
type TooltipProps = {
@@ -7,11 +15,66 @@ type TooltipProps = {
className?: string
}
type ChildProps = {
onClick?: unknown
tabIndex?: number
href?: string
'aria-describedby'?: string
}
/** Intrinsic elements that already participate in the tab order. */
const NATURALLY_FOCUSABLE = new Set(['button', 'input', 'select', 'textarea', 'summary'])
function childIsFocusable(child: ReactElement): boolean {
const props = child.props as ChildProps
if (props.tabIndex !== undefined) return props.tabIndex >= 0
if (props.onClick !== undefined) return true
if (typeof child.type === 'string') {
if (child.type === 'a') return props.href !== undefined
return NATURALLY_FOCUSABLE.has(child.type)
}
return false
}
export function Tooltip({ content, children, className }: TooltipProps) {
const id = useId()
const [open, setOpen] = useState(false)
// Plain text / fragments cannot carry ARIA props, so wrap them in a span first.
const isPlainText = typeof children === 'string' || typeof children === 'number'
const child = (
!isPlainText && isValidElement(children) && children.type !== Fragment
? children
: <span>{children}</span>
) as ReactElement<ChildProps>
const existingDescribedBy = child.props['aria-describedby']
const describedBy = existingDescribedBy ? `${existingDescribedBy} ${id}` : id
const clonedChild = cloneElement(child, { 'aria-describedby': describedBy })
// Only make the wrapper focusable when the child itself cannot receive focus.
const wrapperNeedsFocus = !childIsFocusable(child)
return (
<span className={cn('tooltip-wrapper', className)} tabIndex={0}>
{children}
<span className="tooltip-box" role="tooltip">
<span
className={cn('tooltip-wrapper', className)}
tabIndex={wrapperNeedsFocus ? 0 : undefined}
onMouseEnter={() => setOpen(true)}
onMouseLeave={() => setOpen(false)}
onFocus={() => setOpen(true)}
onBlur={() => setOpen(false)}
onKeyDown={(event) => {
if (event.key === 'Escape') setOpen(false)
}}
>
{clonedChild}
<span
id={id}
className="tooltip-box"
role="tooltip"
aria-hidden={!open}
data-open={open ? 'true' : 'false'}
>
{content}
</span>
</span>
+4
View File
@@ -2,6 +2,7 @@ export * from './Badge'
export * from './Card'
export * from './StatCard'
export * from './Kbd'
export * from './Overlay'
export * from './Dialog'
export * from './Alert'
export * from './EmptyState'
@@ -12,4 +13,7 @@ export * from './Skeleton'
export * from './Table'
export * from './Sheet'
export * from './Tooltip'
export * from './Avatar'
export * from './Spinner'
export * from '../Icon'
export * from '../Button'
+53
View File
@@ -0,0 +1,53 @@
import { useEffect, type RefObject } from 'react'
import type { KeyboardEvent as ReactKeyboardEvent } from 'react'
/* Behavior shared by every overlay (Dialog, Sheet, SearchPalette).
Kept in a behavior module so component files stay fast-refresh friendly. */
const focusableSelector =
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
/** Keep Tab / Shift+Tab inside `container`. Call from an onKeyDown handler. */
export function trapTabKey(e: ReactKeyboardEvent | KeyboardEvent, container: HTMLElement | null) {
if (e.key !== 'Tab' || !container) return
const focusable = Array.from(container.querySelectorAll<HTMLElement>(focusableSelector))
if (focusable.length === 0) {
e.preventDefault()
container.focus()
return
}
const first = focusable[0]
const last = focusable[focusable.length - 1]
if (!first || !last) return
const active = document.activeElement
if (e.shiftKey) {
if (active === first || active === container) {
e.preventDefault()
last.focus()
}
} else if (active === last) {
e.preventDefault()
first.focus()
}
}
/**
* Modal open/close lifecycle shared by every overlay: lock body scroll while
* open (restoring the previous value) and restore focus to whatever held it
* before the overlay opened. Escape handling stays with the caller so overlays
* can ignore or intercept it (e.g. SearchPalette just closes).
*/
export function useOverlayLifecycle(open: boolean, panelRef: RefObject<HTMLElement | null>) {
useEffect(() => {
if (!open) return
const previouslyFocused = document.activeElement as HTMLElement | null
const previousOverflow = document.body.style.overflow
document.body.style.overflow = 'hidden'
panelRef.current?.focus()
return () => {
document.body.style.overflow = previousOverflow
if (previouslyFocused && document.contains(previouslyFocused)) previouslyFocused.focus()
}
}, [open, panelRef])
}
+5
View File
@@ -0,0 +1,5 @@
@keyframes spin {
to {
transform: rotate(360deg);
}
}
+6 -6
View File
@@ -1,22 +1,22 @@
import type { NavigationGroup } from '../components/Sidebar'
import type { ButtonExample } from '../components/ButtonMatrix'
import type { Token } from '../components/TokenGrid'
import type { NavigationGroup } from '../components/Sidebar'
export const navigationGroups: NavigationGroup[] = [
{
label: 'Application Patterns',
items: [
{ label: 'Metrics Dashboard', id: 'application-patterns' },
{ label: 'Settings Panel', id: 'application-patterns' },
{ label: 'Service Table', id: 'application-patterns' },
{ label: 'Deployment Flow', id: 'application-patterns' },
{ label: 'Metrics Dashboard', id: 'application-patterns', patternId: 'metrics' },
{ label: 'Settings Panel', id: 'application-patterns', patternId: 'settings' },
{ label: 'Service Table', id: 'application-patterns', patternId: 'table' },
{ label: 'Deployment Flow', id: 'application-patterns', patternId: 'deploy' },
],
},
{
label: 'Components',
items: [
{ label: 'Buttons', count: 12, id: 'buttons' },
{ label: 'Icons', count: 42, id: 'icons' },
{ label: 'Icons', count: 41, id: 'icons' },
{ label: 'Form controls', id: 'form-controls' },
{ label: 'Composable code', id: 'composable-by-default' },
],
+59
View File
@@ -0,0 +1,59 @@
import { Component, type ErrorInfo, type ReactNode } from 'react'
type ErrorBoundaryProps = {
children: ReactNode
}
type ErrorBoundaryState = {
error: Error | null
}
/** Catches render-time crashes: in dev the message + stack are shown inline and
mirrored into #dev-errors (see src/dev/errorSink.ts); in prod we degrade to
a plain message instead of a white screen. */
export class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorBoundaryState> {
state: ErrorBoundaryState = { error: null }
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { error }
}
componentDidCatch(error: Error, info: ErrorInfo) {
if (import.meta.env.DEV) {
const host = document.getElementById('dev-errors')
if (host) {
const box = document.createElement('div')
box.setAttribute('role', 'alert')
box.setAttribute('data-dev-error', 'react-render')
box.textContent = `React render error: ${error.message}\n${info.componentStack ?? ''}`
host.appendChild(box)
}
}
}
render() {
const { error } = this.state
if (!error) return this.props.children
return (
<div
style={{
padding: 'var(--space-6)',
margin: 'var(--space-6) auto',
maxWidth: '42rem',
fontFamily: 'var(--font-mono)',
fontSize: 'var(--text-sm)',
color: 'var(--foreground)',
background: 'var(--card)',
border: '1px solid var(--destructive)',
borderRadius: 'var(--radius-lg)',
whiteSpace: 'pre-wrap',
}}
>
<strong>Something broke while rendering.</strong>
{'\n\n'}
{error.message}
</div>
)
}
}
+55
View File
@@ -0,0 +1,55 @@
/* Dev-only error sink.
Agents and headless verification often have no console access, so runtime
errors are rendered into the #dev-errors element in index.html where they
can be read with plain DOM inspection (or by humans — no white screens). */
type ErrorPart = { title: string; detail: string }
function renderError({ title, detail }: ErrorPart) {
const host = document.getElementById('dev-errors')
if (!host) return
const box = document.createElement('div')
box.setAttribute('role', 'alert')
box.setAttribute('data-dev-error', 'true')
box.style.cssText = [
'position: relative',
'z-index: 2147483647',
'margin: 8px',
'padding: 12px 14px',
'border: 1px solid var(--destructive, red)',
'border-radius: var(--radius-lg, 8px)',
'background: var(--destructive, red)',
'color: var(--destructive-foreground, white)',
'font-family: var(--font-mono, monospace)',
'font-size: 12px',
'white-space: pre-wrap',
'word-break: break-word',
].join(';')
const heading = document.createElement('strong')
heading.textContent = title
box.appendChild(heading)
const body = document.createElement('div')
body.textContent = detail
box.appendChild(body)
host.appendChild(box)
}
export function installDevErrorSink() {
if (!import.meta.env.DEV) return
window.onerror = (message, source, line, column, error) => {
renderError({
title: `window.onerror: ${String(message)}`,
detail: `${source ?? ''}:${line ?? 0}:${column ?? 0}\n${error?.stack ?? ''}`,
})
}
window.addEventListener('unhandledrejection', event => {
const reason = event.reason instanceof Error ? (event.reason.stack ?? event.reason.message) : String(event.reason)
renderError({ title: 'unhandledrejection', detail: reason })
})
}
+1 -1
View File
@@ -1,5 +1,5 @@
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
@import './tokens.css';
@import './utils.css';
:root { font-family:var(--font-sans); color:var(--text); background:var(--bg); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
:root { font-family:var(--font-sans); color:var(--foreground); background:var(--background); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-width:320px; } button,input { font:inherit; } button { cursor:pointer; }
+9 -1
View File
@@ -2,9 +2,17 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
import { installDevErrorSink } from './dev/errorSink.ts'
/* Surface runtime errors in #dev-errors — failures render into the DOM so
agents without console access (and humans) see messages, not white screens. */
installDevErrorSink()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
-54
View File
@@ -96,33 +96,6 @@
--sidebar-ring: lab(42% 0 0);
--dot-color: lab(7.78201% -.0000149012 0 / .05);
/* ── Legacy Northstar mappings for light theme ── */
--bg: var(--background);
--bg-deep: var(--sidebar);
--panel: var(--card);
--panel-2: var(--secondary);
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
--panel-hover: color-mix(in srgb, var(--card) 70%, var(--border));
--panel-active: color-mix(in srgb, var(--secondary) 85%, var(--border));
--line: var(--border);
--line-soft: var(--sidebar-border);
--line-hover: var(--ring);
--line-focus: var(--ring);
--text: var(--foreground);
--text-bright: var(--foreground);
--text-soft: var(--muted-foreground);
--text-code: var(--foreground);
--subtle: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
--accent-brand: var(--primary);
--accent-contrast: var(--primary-foreground);
--positive: var(--chart-2);
--warning: var(--chart-3);
--critical: var(--destructive);
--pink: var(--chart-5);
--blue: var(--chart-4);
}
/* ── Dark Palette (Exact lab() Specification) ────────────────── */
@@ -165,33 +138,6 @@
--sidebar-ring: lab(89.9321% -47.9163 -15.7303);
--dot-color: lab(89.9321% -47.9163 -15.7303 / .045);
/* ── Legacy Northstar mappings for dark theme ── */
--bg: var(--background);
--bg-deep: var(--sidebar);
--panel: var(--card);
--panel-2: var(--secondary);
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
--panel-hover: color-mix(in srgb, var(--card) 60%, var(--border));
--panel-active: color-mix(in srgb, var(--secondary) 75%, var(--border));
--line: var(--border);
--line-soft: var(--sidebar-border);
--line-hover: color-mix(in srgb, var(--ring) 50%, var(--border));
--line-focus: var(--ring);
--text: var(--foreground);
--text-bright: var(--foreground);
--text-soft: var(--muted-foreground);
--text-code: var(--foreground);
--subtle: color-mix(in srgb, var(--muted-foreground) 65%, transparent);
--accent-brand: var(--primary);
--accent-contrast: var(--primary-foreground);
--positive: var(--chart-2);
--warning: var(--chart-3);
--critical: var(--destructive);
--pink: var(--chart-5);
--blue: var(--chart-4);
}
html {
+7 -2
View File
@@ -516,8 +516,7 @@
transition: opacity 0.15s ease, transform 0.15s ease;
}
.tooltip-wrapper:hover .tooltip-box,
.tooltip-wrapper:focus-within .tooltip-box {
.tooltip-box[data-open='true'] {
opacity: 1;
transform: translateX(-50%) translateY(-8px);
}
@@ -543,3 +542,9 @@
.skeleton-rect {
border-radius: var(--radius-lg);
}
/* ── Shared Focus Ring ──────────────────────────────────────── */
:where(.ui-tabs-trigger, .switch-root, .dialog-close-btn):focus-visible {
outline: var(--focus-ring);
outline-offset: var(--focus-offset);
}
+39
View File
@@ -0,0 +1,39 @@
import { useCallback, useEffect, useRef } from 'react'
/**
* setTimeout that cleans up after itself: every pending timer is cleared when
* the component unmounts, and `clear()` cancels them early (e.g. when a dialog
* closes mid-animation). Prevents the classic "setState after close/unmount"
* leak from fire-and-forget `setTimeout(...)` calls.
*
* Usage:
* const { schedule, clear } = useTimeout()
* schedule(() => setCopied(false), 2000)
*/
export function useTimeout() {
const timers = useRef<Set<ReturnType<typeof setTimeout>>>(new Set())
useEffect(() => {
const pending = timers.current
return () => {
pending.forEach(clearTimeout)
pending.clear()
}
}, [])
const schedule = useCallback((fn: () => void, ms: number) => {
const id = setTimeout(() => {
timers.current.delete(id)
fn()
}, ms)
timers.current.add(id)
return id
}, [])
const clear = useCallback(() => {
timers.current.forEach(clearTimeout)
timers.current.clear()
}, [])
return { schedule, clear }
}