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

+19
View File
@@ -0,0 +1,19 @@
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
# The full gate: oxlint → token contract → vitest → tsc + vite build.
- run: npm run check
+47 -14
View File
@@ -1,14 +1,21 @@
# Northstar Design System — agent handbook # Northstar Design System — agent handbook
React 19 + Vite 8 + TypeScript design-system showcase. Dark-first, token-driven UI with a light theme. React 19 + Vite 8 + TypeScript (strict) design-system showcase. Dark-first, token-driven UI with a light theme.
## Commands ## Commands
| Task | Command | | Task | Command |
|---|---| |---|---|
| Dev server | `npm run dev` (vite.config.ts pins `0.0.0.0:5173`) | | Dev server | `npm run dev` (vite.config.ts pins `0.0.0.0:5173`) |
| Full gate: lint + token contract + typecheck + build | `npm run check` — run before declaring any change done | | Full gate: lint + token contract + tests + typecheck + build | `npm run check` — run before declaring any change done |
| Lint only | `npm run lint` (oxlint) | | Lint only | `npm run lint` (oxlint) |
| Tests | `npm run test` (vitest, jsdom; suites in `tests/`) |
TypeScript is **strict** (`strict` + `noUncheckedIndexedAccess` in `tsconfig.app.json`) — implicit `any` fails the build, not just the reviewer.
## Debugging without a console
Browser tooling here has no console access. Runtime errors therefore render into a `#dev-errors` element (see `src/dev/errorSink.ts` + `src/dev/ErrorBoundary.tsx`): after loading the page, `wait_for` / `inspect` `[data-dev-error]` to read error messages, and `wait_for vite-error-overlay` for build/HMR errors. If `#root` is empty but no `[data-dev-error]` exists, check the dev-server stdout.
## Sandboxed worker dev flow ## Sandboxed worker dev flow
@@ -18,20 +25,24 @@ React 19 + Vite 8 + TypeScript design-system showcase. Dark-first, token-driven
## Design tokens — the contract ## Design tokens — the contract
1. **Single source of truth: `src/tokens.css`.** Every font size, color, spacing, radius, and shadow color comes from a token. The palette is defined in modern CSS Color 4 `lab(...)` color space: 1. **Single source of truth: `src/tokens.css`.** Every font size, color, spacing, radius, and shadow color comes from a token. The palette is defined in modern CSS Color 4 `lab(...)` color space:
- Dark theme (`.dark`, `[data-theme='dark']`): Pitch black base (`lab(0% 0 0)`), deep cards (`lab(.903296% 0 0)`), radiant electric mint/cyan primary (`lab(89.9321% -47.9163 -15.7303)`), vivid destructive (`lab(54.5811% 71.684 50.9279)`), chart colors 1–5, and subtle dot grid (`--dot-color`). - Dark theme (`:root:not([data-theme='light'])`, `[data-theme='dark']`, `.dark`): Pitch black base (`lab(0% 0 0)`), deep cards (`lab(.903296% 0 0)`), radiant electric mint/cyan primary (`lab(89.9321% -47.9163 -15.7303)`), vivid destructive (`lab(54.5811% 71.684 50.9279)`), chart colors 1–5, and subtle dot grid (`--dot-color`).
- Light theme (`:root`, `[data-theme='light']`): Pure white daylight base (`lab(100% 0 0)`), crisp charcoal primary (`lab(7.78201% -.0000149012 0)`), and high-contrast surfaces. - Light theme (`:root`, `[data-theme='light']`, `.light`): Pure white daylight base (`lab(100% 0 0)`), crisp charcoal primary (`lab(7.78201% -.0000149012 0)`), and high-contrast surfaces.
2. The guard `scripts/check-tokens.mjs` (in `npm run check`) bans hardcoded hex colors, px font sizes, and raw `rgba()` in application CSS — new colors go in as tokens, accent washes use `color-mix(in srgb, var(--primary) N%, transparent)`. - There are **no** legacy aliases (`--panel*`, `--line*`, `--text-soft`, …) anymore — canonical names only.
2. The guard `scripts/check-tokens.mjs` (in `npm run check`) bans hardcoded colors — hex, named colors, `rgb()/hsl()/oklch()/lab()`, even across multiple lines — plus px font sizes and `var(--x)` references that no CSS file defines. It scans **CSS files and inline `style={{ … }}` in `.ts/.tsx`**. Allowed: `var(--token)`, `color-mix(in srgb, var(--token) N%, transparent)`, `light-dark(var(--a), var(--b))`, and neutral black/white rgba shines.
3. Typography scale (`--text-3xs` … `--text-2xl`, `--text-hero`) is rem-based on a 9→28px grid. Longform body `--text-md`, buttons/nav `--text-xs`, meta/eyebrows `--text-2xs`/`--text-3xs`. 3. Typography scale (`--text-3xs` … `--text-2xl`, `--text-hero`) is rem-based on a 9→28px grid. Longform body `--text-md`, buttons/nav `--text-xs`, meta/eyebrows `--text-2xs`/`--text-3xs`.
4. **Global density knob: `--root-font-size`** (default `112%` readability preset, baseline 16px = `100%`). One line scales every token-driven size app-wide; spacing tokens follow. 4. **Global density knob: `--root-font-size`** (default `112%` readability preset, baseline 16px = `100%`). One line scales every token-driven size app-wide; spacing tokens follow.
5. **Theming:** components read only `var(--*)` and never know the theme. Toggle synchronizes both `document.documentElement.dataset.theme` and `.dark`/`.light` classes. 5. **Theming:** components read only `var(--*)` and never know the theme. A pre-paint script in `index.html` applies `localStorage['ns-theme']` or `prefers-color-scheme` before first paint (no flash); `useTheme` in `Topbar.tsx` reads what it set and keeps `data-theme` **and** the `.dark`/`.light` classes in sync on toggle.
## Smart Utils & AI Workflow ## Smart Utils & AI Workflow
- **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles. - **Class helper**: `src/utils/cn.ts` — zero-dependency classnames merging utility for conditional component styles.
- **Timer helper**: `src/utils/useTimeout.ts` — `schedule(fn, ms)` / `clear()`; auto-cancels pending timers on unmount. Never fire-and-forget a raw `setTimeout` in a component.
- **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`, `.progress-*`, `.ui-table-*`, `.dialog-*`, `.sheet-*`, `.alert-*`, `.ui-tabs-*`, `.switch-*`). - **Smart CSS**: `src/utils.css` — ready-to-use helpers (`.dot-canvas`, `.surface-card`, `.surface-sidebar`, `.badge-*`, `.hover-lift`, `.kbd-tag`, `.skeleton-pulse`, `.progress-*`, `.ui-table-*`, `.dialog-*`, `.sheet-*`, `.alert-*`, `.ui-tabs-*`, `.switch-*`).
- **Composable Primitives**: `src/components/ui/` (`Dialog`, `Sheet`, `Tabs`, `Alert`, `EmptyState`, `Table`, `Progress`, `Switch`, `Badge`, `Card`, `StatCard`, `Skeleton`, `Tooltip`, `Kbd`, `Icon`). - **Overlays — one primitive**: `src/components/ui/Overlay.tsx` owns focus trap, Escape-to-close, scroll lock, focus restore, and conditional `aria-labelledby`/`aria-describedby` (wired only when a title/description is actually rendered). `Dialog` and `Sheet` are thin skins over it; `SearchPalette` reuses `trapTabKey` + `useOverlayLifecycle` from `src/components/ui/overlayBehavior.ts`. Build new modals on Overlay, not from scratch.
- **Composable Primitives**: `src/components/ui/` (`Overlay`, `Dialog`, `Sheet`, `Tabs`, `Alert`, `EmptyState`, `Table`, `Progress`, `Switch`, `Badge`, `Card`, `StatCard`, `Skeleton`, `Tooltip`, `Kbd`, `Avatar`, `Spinner`; `Icon` lives at `src/components/Icon.tsx` and is re-exported from the barrel).
- **StatCard**: `trend` is sentiment (badge color), `direction` is the arrow and defaults to the sign of `change` — a "good" latency drop is `trend="up"` with `change="-4.2ms"` rendering a green `↓ -4.2ms`.
- **Reusable Application Patterns**: `src/components/examples/` (`MetricsDashboard`, `SettingsPanel`, `ServiceTable`, `DeploymentModal`). - **Reusable Application Patterns**: `src/components/examples/` (`MetricsDashboard`, `SettingsPanel`, `ServiceTable`, `DeploymentModal`).
- **Icon Library**: `lucide-react` — 1,400+ tree-shaken SVGs with uniform stroke and color inheritance (`<Icon name="..." />` or direct import). - **Icon Library**: the registry in `src/components/icon-registry.ts` maps every `IconName` to a lucide component (strictly typed — typos fail `tsc`, unknown names at runtime **throw in dev** so the error sink surfaces them). 41 curated icons power the showcase; `lucide-react` ships 1,400+ for direct import.
## Structure ## Structure
@@ -42,22 +53,44 @@ src/
index.css ← fonts, tokens & utils import, base resets index.css ← fonts, tokens & utils import, base resets
App.css ← application component styles (token-driven, plain CSS) App.css ← application component styles (token-driven, plain CSS)
App.tsx ← page composition & showcase layout App.tsx ← page composition & showcase layout
main.tsx ← mounts App inside the ErrorBoundary, installs the dev error sink
dev/
ErrorBoundary.tsx, errorSink.ts ← render runtime errors into #dev-errors
utils/ utils/
cn.ts ← conditional classnames utility cn.ts ← conditional classnames utility
useTimeout.ts ← auto-cleaning setTimeout hook
components/ components/
ui/ ← composable UI primitives (Dialog, Sheet, Tabs, Alert, EmptyState, Table, Progress, Switch, Badge, Card, StatCard, Skeleton, Tooltip, Kbd, Icon) ui/ ← composable UI primitives (Overlay, Dialog, Sheet, Tabs, Alert,
examples/ ← reusable pattern components (MetricsDashboard, SettingsPanel, ServiceTable, DeploymentModal) EmptyState, Table, Progress, Switch, Badge, Card, StatCard,
Skeleton, Tooltip, Kbd, Avatar, Spinner) + overlayBehavior.ts
icon-registry.ts← the one icon name → lucide component map (single source)
examples/ ← reusable pattern components (MetricsDashboard, SettingsPanel,
ServiceTable, DeploymentModal)
AIWorkflowSection.tsx ← interactive pattern switcher & showcase AIWorkflowSection.tsx ← interactive pattern switcher & showcase
IconMatrix.tsx ← interactive Lucide icon explorer & search SearchPalette.tsx ← ⌘K command palette (Overlay behavior + combobox a11y)
Topbar.tsx ← owns the theme toggle (.dark & data-theme) TypographySection.tsx ← type-scale showcase
IconMatrix.tsx ← interactive Lucide icon explorer (renders iconRegistry)
Icon.tsx ← <Icon name="…" /> component (types from icon-registry.ts)
Topbar.tsx ← owns the theme toggle (data-theme + .dark/.light)
Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx, Hero.tsx, CodeSample.tsx, RightRail.tsx, ButtonMatrix.tsx,
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx, Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx, Icon.tsx MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
data/ data/
library.ts ← demo content and color token data for the showcase library.ts ← demo content, nav groups (patternId routing) & color tokens
tests/ ← vitest suites (jsdom): overlay, icon-registry, useTimeout,
check-tokens guard
scripts/check-tokens.mjs ← token contract guard (part of npm run check) scripts/check-tokens.mjs ← token contract guard (part of npm run check)
``` ```
## Parallel subagent workflow
Multiple agents can work the same checkout concurrently if tasks are **file-disjoint**:
- Each agent gets an explicit allow-list of files; shared files (barrels like `components/ui/index.ts`, `utils.css`, `App.tsx`) are edited by the orchestrator only, after agents finish.
- Agents verify with `npx oxlint <files>`, `npx tsc --noEmit -p tsconfig.app.json`, and `node scripts/check-tokens.mjs` — never `npm run check`/`vite build` in parallel (incremental build info and `dist/` would race).
- Read-only investigation tasks use the read-only spawn mode; they can run freely alongside writers.
- New primitives: agents create self-contained files (component + own `.css` import if keyframes/classes are needed); the orchestrator registers exports and showcase usage.
## Iteration workflow for visual changes ## Iteration workflow for visual changes
1. Change the token (or component markup) — never a literal. 1. Change the token (or component markup) — never a literal.
+58 -27
View File
@@ -1,32 +1,63 @@
# React + TypeScript + Vite # Northstar Design System
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. A token-driven design system showcase built for AI-assisted development workflows.
React 19 · Vite 8 · TypeScript (strict) · Vitest · oxlint.
Currently, two official plugins are available: ## Quick start
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) ```bash
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) npm install
npm run dev # dev server on 0.0.0.0:5173
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
``` ```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. ## The quality gate
```bash
npm run check
```
One command, four gates, in order:
1. **`oxlint`** — lint over every source file.
2. **`scripts/check-tokens.mjs`** — the token contract: bans hardcoded colors
(hex / named / `rgb()` / `hsl()` / `oklch()` / `lab()`), px font sizes and
undefined `var(--x)` references — in **both** CSS files and inline
`style={{ … }}` objects. Values may span lines; comments are ignored.
3. **`vitest run`** — unit + component tests (`tests/`).
4. **`tsc -b && vite build`** — strict typecheck, then production build.
CI runs the same gate on every push and PR (`.github/workflows/ci.yml`).
## Design tokens
`src/tokens.css` is the single source of truth. Every color, font size,
spacing, radius and shadow goes through a `var(--token)` — components never
hardcode values. Themes are plain CSS: `:root[data-theme='light']` /
`.light` and `:root[data-theme='dark']` / `.dark`, with a pre-paint script in
`index.html` that applies the saved (or OS-preferred) theme before first paint.
The global density knob is `--root-font-size` — one line scales the whole app.
## Layout
```
src/
tokens.css ← lab() design tokens, dark & light themes
index.css ← fonts, token import, base resets
utils.css ← token-compliant utility classes
App.css ← application showcase styles
components/
ui/ ← composable primitives (Overlay, Dialog, Sheet, Tabs, Alert, …)
icon-registry.ts← the one icon name → lucide component map
examples/ ← reusable patterns (MetricsDashboard, SettingsPanel, …)
dev/ ← ErrorBoundary + dev error sink (#dev-errors)
utils/ ← cn(), useTimeout()
tests/ ← vitest suites (jsdom)
scripts/
check-tokens.mjs ← token contract guard
```
## For agents
Read [`AGENTS.md`](./AGENTS.md) before changing anything — it documents the
token contract, the overlay/icon registries, and the parallel-subagent rules.
+24 -1
View File
@@ -4,10 +4,33 @@
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>design-system</title> <meta name="color-scheme" content="dark light" />
<title>Northstar Design System</title>
<script>
/* Pre-paint theme: apply the saved (or OS-preferred) theme before any
CSS paints so there is no dark→light flash. Keep in sync with
applyTheme() in src/components/Topbar.tsx. */
(function () {
try {
var saved = localStorage.getItem('ns-theme')
var theme =
saved === 'light' || saved === 'dark'
? saved
: window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches
? 'light'
: 'dark'
var root = document.documentElement
root.setAttribute('data-theme', theme)
root.classList.add(theme)
} catch (e) {
/* localStorage unavailable — tokens.css defaults to dark */
}
})()
</script>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
<div id="dev-errors"></div>
<script type="module" src="/src/main.tsx"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>
+1124 -1
View File
File diff suppressed because it is too large. Load diff
+8 -2
View File
@@ -7,7 +7,9 @@
"dev": "vite", "dev": "vite",
"build": "tsc -b && vite build", "build": "tsc -b && vite build",
"lint": "oxlint", "lint": "oxlint",
"check": "oxlint && node scripts/check-tokens.mjs && npm run build", "test": "vitest run",
"test:watch": "vitest",
"check": "oxlint && node scripts/check-tokens.mjs && npm run test && npm run build",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
@@ -16,12 +18,16 @@
"react-dom": "^19.2.8" "react-dom": "^19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@types/node": "^24.13.3", "@types/node": "^24.13.3",
"@types/react": "^19.2.18", "@types/react": "^19.2.18",
"@types/react-dom": "^19.2.7", "@types/react-dom": "^19.2.7",
"@vitejs/plugin-react": "^6.1.1", "@vitejs/plugin-react": "^6.1.1",
"jsdom": "^30.0.1",
"oxlint": "^1.81.0", "oxlint": "^1.81.0",
"typescript": "~6.0.2", "typescript": "~6.0.2",
"vite": "^8.3.0" "vite": "^8.3.0",
"vitest": "^5.0.0"
} }
} }
+233 -22
View File
@@ -1,46 +1,257 @@
#!/usr/bin/env node #!/usr/bin/env node
/* Token contract guard — runs as part of `npm run check`. /* Token contract guard — runs as part of `npm run check`.
Bans hardcoded values in application CSS so every visual change Bans hardcoded colors/fonts so every visual change goes through src/tokens.css.
goes through src/tokens.css. */
What it checks:
• every CSS file under src/ (except tokens.css): hex colors anywhere, named
colors, rgb()/rgba(), hsl()/oklch()/lab()/…, px font sizes — including
values split across multiple lines.
• every .ts/.tsx file under src/: the same color rules inside inline
`style={{ ... }}` objects.
• every `var(--x)` reference (CSS + inline styles) must be defined in some CSS
file, so a typo'd token fails the check instead of silently rendering nothing.
*/
import { readFileSync, readdirSync, statSync } from 'node:fs' import { readFileSync, readdirSync, statSync } from 'node:fs'
import { join } from 'node:path' import { join } from 'node:path'
const CSS_ROOTS = ['src'] const ROOTS = process.argv.length > 2 ? process.argv.slice(2) : ['src']
const EXEMPT = new Set(['tokens.css']) const EXEMPT = new Set(['tokens.css'])
// Neutral shine/shadow literals stay allowed (they are surfaces, not palette). // Neutral shine/shadow literals stay allowed (they are surfaces, not palette).
const RGB_ALLOW = [/rgba\(\s*0\s*,\s*0\s*,\s*0/, /rgba\(\s*255\s*,\s*255\s*,\s*255/] const RGB_ALLOW = [/rgba\(\s*0\s*,\s*0\s*,\s*0/, /rgba\(\s*255\s*,\s*255\s*,\s*255/]
const violations = [] // CSS named colors (CSS Color 4 list; 'transparent'/'currentColor' are keywords, not colors).
const NAMED_COLORS = (
'aliceblue,antiquewhite,aqua,aquamarine,azure,beige,bisque,black,blanchedalmond,blue,blueviolet,brown,' +
'burlywood,cadetblue,chartreuse,chocolate,coral,cornflowerblue,cornsilk,crimson,cyan,darkblue,darkcyan,' +
'darkgoldenrod,darkgray,darkgreen,darkgrey,darkkhaki,darkmagenta,darkolivegreen,darkorange,darkorchid,' +
'darkred,darksalmon,darkseagreen,darkslateblue,darkslategray,darkslategrey,darkturquoise,darkviolet,' +
'deeppink,deepskyblue,dimgray,dimgrey,dodgerblue,firebrick,floralwhite,forestgreen,fuchsia,gainsboro,' +
'ghostwhite,gold,goldenrod,gray,green,greenyellow,grey,honeydew,hotpink,indianred,indigo,ivory,khaki,' +
'lavender,lavenderblush,lawngreen,lemonchiffon,lightblue,lightcoral,lightcyan,lightgoldenrodyellow,' +
'lightgray,lightgreen,lightgrey,lightpink,lightsalmon,lightseagreen,lightskyblue,lightslategray,' +
'lightslategrey,lightsteelblue,lightyellow,lime,limegreen,linen,magenta,maroon,mediumaquamarine,' +
'mediumblue,mediumorchid,mediumpurple,mediumseagreen,mediumslateblue,mediumspringgreen,mediumturquoise,' +
'mediumvioletred,midnightblue,mintcream,mistyrose,moccasin,navajowhite,navy,oldlace,olive,olivedrab,' +
'orange,orangered,orchid,palegoldenrod,palegreen,paleturquoise,palevioletred,papayawhip,peachpuff,peru,' +
'pink,plum,powderblue,purple,rebeccapurple,red,rosybrown,royalblue,saddlebrown,salmon,sandybrown,' +
'seagreen,seashell,sienna,silver,skyblue,slateblue,slategray,slategrey,snow,springgreen,steelblue,tan,' +
'teal,thistle,tomato,turquoise,violet,wheat,white,whitesmoke,yellow,yellowgreen'
).split(',')
const NAMED_COLOR_RE = new RegExp(`\\b(${NAMED_COLORS.join('|')})\\b`, 'gi')
function walk(dir) { // Functions whose *arguments* may legitimately contain colors only via tokens/color-mix.
const COLOR_FN_RE = /\b(rgb|rgba|hsl|hsla|hwb|oklch|oklcha|lab|lch|lcha|color|color-mix)\(/i
const violations = []
const definedTokens = new Set()
const usedTokens = new Map() // token name -> first usage site
function* walk(dir) {
for (const entry of readdirSync(dir)) { for (const entry of readdirSync(dir)) {
const full = join(dir, entry) const full = join(dir, entry)
if (statSync(full).isDirectory()) { if (statSync(full).isDirectory()) {
walk(full) yield* walk(full)
} else if (full.endsWith('.css') && !EXEMPT.has(entry)) { } else {
checkFile(full, readFileSync(full, 'utf8')) yield full
} }
} }
} }
function checkFile(file, css) { function stripComments(text) {
css.split('\n').forEach((line, i) => { return text.replace(/\/\*[\s\S]*?\*\//g, '')
const n = `${file}:${i + 1}`
const hex = line.match(/#[0-9a-fA-F]{3,8}\b/)
if (hex) violations.push(`${n} hex color ${hex[0]} — add a token in tokens.css instead`)
if (/font(-size)?\s*:[^;}]*\d+px/.test(line)) violations.push(`${n} px font size — use var(--text-*)`)
const rgb = line.match(/rgba?\(/)
if (rgb && !RGB_ALLOW.some(re => re.test(line))) {
violations.push(`${n} raw rgba() — use color-mix(in srgb, var(--token) N%, transparent)`)
}
})
} }
CSS_ROOTS.forEach(walk) function lineOf(text, index) {
let line = 1
for (let i = 0; i < index && i < text.length; i++) {
if (text[i] === '\n') line++
}
return line
}
function noteTokens(value, site) {
for (const m of value.matchAll(/var\(\s*(--[\w-]+)/g)) {
if (!usedTokens.has(m[1])) usedTokens.set(m[1], site)
}
}
/** Colors are only allowed as var() references or color-mix/light-dark over tokens.
Strategy: strip every token-driven construct, then flag any color literal left. */
function stripTokenColors(value) {
let v = value
for (let i = 0; i < 6; i++) {
const next = v
.replace(/var\([^()]*\)/gi, ' ')
.replace(/color-mix\([^()]*\)/gi, ' ')
.replace(/light-dark\([^()]*\)/gi, ' ')
if (next === v) return v
v = next
}
return v
}
function isTokenOnlyValue(value) {
if (RGB_ALLOW.some(re => re.test(value))) return true
const residue = stripTokenColors(value)
return !COLOR_FN_RE.test(residue) && !NAMED_COLOR_RE.test(residue) && !/#[0-9a-fA-F]{3,8}\b/.test(residue)
}
function checkColorValue(value, site, what) {
const hex = value.match(/#[0-9a-fA-F]{3,8}\b/)
if (hex) {
violations.push(`${site} hex color ${hex[0]} in ${what} — add a token in tokens.css instead`)
return
}
if (isTokenOnlyValue(value)) return
const fn = value.match(COLOR_FN_RE)
if (fn) {
violations.push(
`${site} ${fn[1]}() color in ${what} — use var(--token) or color-mix(in srgb, var(--token) N%, transparent)`,
)
return
}
const named = value.match(NAMED_COLOR_RE)
if (named) {
violations.push(`${site} named color "${named[0]}" in ${what} — add a token in tokens.css instead`)
}
}
function checkFontPx(value, site, what) {
const px = value.match(/-?\d+(\.\d+)?px/i)
if (px) violations.push(`${site} px font size (${px[0]}) in ${what} — use var(--text-*)`)
}
const COLOR_PROP_RE =
/(^|-)(color|background|border|fill|stroke|shadow|outline|caret|accent|text-decoration|column-rule)/i
const FONT_PROP_RE = /^font(-?size)?$/i
/* ── CSS files ──────────────────────────────────────────────────── */
function checkCssFile(file, css) {
const text = stripComments(css)
// Token definitions are collected from every CSS file (tokens.css included).
for (const m of text.matchAll(/--[\w-]+\s*:/g)) {
definedTokens.add(m[0].replace(/\s*:.*$/s, '').trim())
}
// tokens.css is the source of truth — collect definitions, skip value checks.
if (EXEMPT.has(file.split('/').pop())) return
// Hex anywhere (same contract as the original line-based guard, but comment-safe).
for (const m of text.matchAll(/#[0-9a-fA-F]{3,8}\b/g)) {
violations.push(
`${file}:${lineOf(css, m.index ?? 0)} hex color ${m[0]} — add a token in tokens.css instead`,
)
}
// Declarations may span lines: `prop: value;` up to the next `;` or `}`.
// Lookbehind keeps the anchor zero-width so every declaration in a block matches.
for (const m of text.matchAll(/(^|(?<=[{;]))\s*([-a-zA-Z]+)\s*:\s*([^;{}]+)[;}]/g)) {
const prop = m[2].trim().toLowerCase()
const value = m[3].trim()
const site = `${file}:${lineOf(css, m.index ?? 0)}`
const what = `"${prop}" declaration`
if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what)
if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what)
noteTokens(value, site)
}
// Named colors can also hide inside longhand gradients/shadows — sweep the whole file,
// skipping property-name positions (e.g. `white-space`).
for (const m of text.matchAll(NAMED_COLOR_RE)) {
const idx = m.index ?? 0
const declStart =
Math.max(text.lastIndexOf(';', idx), text.lastIndexOf('{', idx), text.lastIndexOf('}', idx)) + 1
const segment = text.slice(declStart, idx)
const colon = segment.lastIndexOf(':')
if (colon === -1) continue // match is in a selector, not a value
const prop = segment.slice(0, colon).trim().toLowerCase()
if (prop.startsWith('--')) continue
if (!isTokenOnlyValue(text.slice(idx - 2, idx + m[0].length + 20))) {
violations.push(
`${file}:${lineOf(css, idx)} named color "${m[0]}" in "${prop}" — add a token in tokens.css instead`,
)
}
}
}
/* ── TS/TSX inline styles ───────────────────────────────────────── */
/** Find balanced `style={{ ... }}` object literals; returns their bodies. */
function extractStyleObjects(text) {
const objects = []
const re = /style\s*=\s*\{\s*\{/g
let m
while ((m = re.exec(text))) {
let depth = 2
let i = re.lastIndex
let inString = null
for (; i < text.length && depth > 0; i++) {
const ch = text[i]
if (inString) {
if (ch === '\\') i++
else if (ch === inString) inString = null
} else if (ch === "'" || ch === '"' || ch === '`') {
inString = ch
} else if (ch === '{') {
depth++
} else if (ch === '}') {
depth--
}
}
objects.push({ body: text.slice(re.lastIndex, i - 1), index: re.lastIndex })
re.lastIndex = i
}
return objects
}
function checkTsxFile(file, source) {
const text = stripComments(source)
for (const { body, index } of extractStyleObjects(text)) {
// Split `key: value` pairs on commas that are followed by another `key:`
// (whitespace/newlines allowed between them).
for (const m of body.matchAll(/([-a-zA-Z]+)\s*:\s*((?:[^,]|\,(?!\s*[a-zA-Z-]+\s*:))*)/g)) {
const prop = m[1].trim().toLowerCase()
const value = m[2].trim()
if (!value || value === "'{" || prop.startsWith('--')) continue
const site = `${file}:${lineOf(source, index)}`
const what = `style={{ ${prop} }}`
if (COLOR_PROP_RE.test(prop)) checkColorValue(value, site, what)
if (FONT_PROP_RE.test(prop)) checkFontPx(value, site, what)
noteTokens(value, site)
}
}
}
/* ── Run ────────────────────────────────────────────────────────── */
const cssFiles = []
const tsFiles = []
for (const root of ROOTS) {
for (const f of walk(root)) {
if (f.endsWith('.css')) cssFiles.push(f)
else if (f.endsWith('.tsx') || f.endsWith('.ts')) tsFiles.push(f)
}
}
for (const f of cssFiles) checkCssFile(f, readFileSync(f, 'utf8'))
for (const f of tsFiles) checkTsxFile(f, readFileSync(f, 'utf8'))
// Cross-check: every referenced token must exist (catches typos that silently render nothing).
for (const [token, site] of usedTokens) {
if (!definedTokens.has(token)) {
violations.push(`${site} var(${token}) is never defined — typo, or add it to tokens.css`)
}
}
if (violations.length) { if (violations.length) {
console.error(`\n✗ token contract violations:\n\n${violations.join('\n')}\n`) console.error(`\n✗ token contract violations:\n\n${[...new Set(violations)].join('\n')}\n`)
process.exit(1) process.exit(1)
} else { } else {
console.log('✓ token contract OK — no hardcoded colors or font sizes found') console.log(
`✓ token contract OK — ${cssFiles.length} css + ${tsFiles.length} ts/tsx files clean, ` +
`${usedTokens.size} var() references all defined`,
)
} }
+25 -13
View File
@@ -202,6 +202,28 @@
grid-template-columns: 240px minmax(0, 1fr); 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 ────────────────────────────────────────────────── */
.sidebar { .sidebar {
border-right: 1px solid var(--sidebar-border); border-right: 1px solid var(--sidebar-border);
@@ -766,19 +788,6 @@
border: 1px solid var(--border) !important; 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 { .showcase-foot {
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
border-bottom: 0; border-bottom: 0;
@@ -1885,6 +1894,9 @@ footer {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 2px; gap: 2px;
margin: 0;
padding: 0;
list-style: none;
} }
.search-palette-item { .search-palette-item {
+39 -11
View File
@@ -16,6 +16,7 @@ import { Section } from './components/Section'
import { RightRail } from './components/RightRail' import { RightRail } from './components/RightRail'
import { SearchPalette, type SearchItem } from './components/SearchPalette' import { SearchPalette, type SearchItem } from './components/SearchPalette'
import { Icon } from './components/Icon' 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' import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens' const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
@@ -27,6 +28,7 @@ export default function App() {
const [visible, setVisible] = useState(false) const [visible, setVisible] = useState(false)
const [menu, setMenu] = useState(false) const [menu, setMenu] = useState(false)
const [pattern, setPattern] = useState<PatternId>('metrics') const [pattern, setPattern] = useState<PatternId>('metrics')
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
@@ -39,15 +41,12 @@ export default function App() {
return () => window.removeEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown)
}, []) }, [])
const handleNavigate = (item: { label: string; id?: string }) => { const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
setActive(item.label) setActive(item.label)
setMenu(false) setMenu(false)
// Synchronize active pattern when clicking pattern items // Synchronize active pattern — routing keys on patternId, never on display labels.
if (item.label === 'Metrics Dashboard') setPattern('metrics') if (item.patternId) setPattern(item.patternId)
else if (item.label === 'Settings Panel') setPattern('settings')
else if (item.label === 'Service Table') setPattern('table')
else if (item.label === 'Deployment Flow') setPattern('deploy')
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '') const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
const el = document.getElementById(targetId) const el = document.getElementById(targetId)
@@ -73,11 +72,13 @@ export default function App() {
onSelect={handleSearchSelect} onSelect={handleSearchSelect}
/> />
<div className={`body-layout ${menu ? 'menu-open' : ''}`}> <div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
<Sidebar <Sidebar
active={active} active={active}
groups={navigationGroups} groups={navigationGroups}
onSelect={handleNavigate} onSelect={handleNavigate}
collapsed={sidebarCollapsed}
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
/> />
<main className="main-content"> <main className="main-content">
@@ -121,7 +122,7 @@ export default function App() {
index="03 / COMPONENTS" index="03 / COMPONENTS"
title="Icons (Lucide)" title="Icons (Lucide)"
description="Crisp, scalable SVG icons powered by Lucide. Tree-shakeable, stroke-matched, and token-aware." 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 /> <IconMatrix />
</Section> </Section>
@@ -173,10 +174,37 @@ export default function App() {
<b>Enable notifications</b> <b>Enable notifications</b>
<small>Get updates about deployments and incidents.</small> <small>Get updates about deployments and incidents.</small>
</span> </span>
<button className="toggle on" aria-label="Toggle notifications"> <Switch defaultChecked aria-label="Toggle notifications" />
<span />
</button>
</label> </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> </div>
</Section> </Section>
+2 -1
View File
@@ -1,4 +1,5 @@
import { Button } from './Button' import { Button } from './Button'
import { Spinner } from './ui'
import { Icon } from './Icon' import { Icon } from './Icon'
export type ButtonExample = { export type ButtonExample = {
@@ -52,7 +53,7 @@ export function ButtonMatrix({ examples, tabs, filter = '', activeTab, onTabChan
<Icon name="copy" size={15} /> <Icon name="copy" size={15} />
</Button> </Button>
<Button variant={example.variant} className="loading"> <Button variant={example.variant} className="loading">
<span className="spinner" /> <Spinner size="xs" />
Loading Loading
</Button> </Button>
</div> </div>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useTimeout } from '../utils/useTimeout'
import { Icon } from './Icon' import { Icon } from './Icon'
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens' const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
@@ -19,11 +20,12 @@ const CODE_LINES: Segment[] = [
export function CodeSample() { export function CodeSample() {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
const { schedule } = useTimeout()
const copy = () => { const copy = () => {
navigator.clipboard?.writeText(INSTALL_CMD) navigator.clipboard?.writeText(INSTALL_CMD)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 1600) schedule(() => setCopied(false), 1600)
} }
return ( return (
+15 -104
View File
@@ -1,108 +1,10 @@
import type { ComponentType, SVGProps } from 'react' import type { ComponentType, SVGProps } from 'react'
import { import { Layers, type LucideIcon, type LucideProps } from 'lucide-react'
Search, import { iconMap, type IconName } from './icon-registry'
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'
export type { IconName, IconCategory, IconRegistryEntry } from './icon-registry'
export type { LucideIcon, LucideProps } 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'> & { export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
name?: IconName name?: IconName
icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }> 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. * Universal Icon component backed by lucide-react.
* Accepts either a shorthand name (e.g. name="sparkle", name="check") * Accepts either a registry name (e.g. name="sparkles") or a direct Lucide
* or a direct Lucide icon component (e.g. icon={Sparkles}). * 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({ export function Icon({
name, name,
@@ -124,7 +27,15 @@ export function Icon({
className, className,
...props ...props
}: IconProps) { }: 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 ( return (
<Component <Component
+9 -101
View File
@@ -1,109 +1,15 @@
import { useState, useMemo } from 'react' import { useState, useMemo } from 'react'
import { import { useTimeout } from '../utils/useTimeout'
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 { Icon } from './Icon' import { Icon } from './Icon'
import { iconRegistry, type IconRegistryEntry } from './icon-registry'
import { Kbd } from './ui/Kbd' import { Kbd } from './ui/Kbd'
import { Badge } from './ui/Badge' import { Badge } from './ui/Badge'
type IconItem = { type IconItem = IconRegistryEntry
name: string
pascalName: string
category: 'Navigation' | 'Interface' | 'System' | 'Status'
component: LucideIcon
}
const ICONS: IconItem[] = [ /* The curated showcase set — components come straight from the shared
// Navigation registry in Icon.tsx, so there is no second icon list to drift. */
{ name: 'arrow-right', pascalName: 'ArrowRight', category: 'Navigation', component: ArrowRight }, const ICONS: IconItem[] = iconRegistry
{ 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 },
]
const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const
type Category = (typeof CATEGORIES)[number] type Category = (typeof CATEGORIES)[number]
@@ -113,6 +19,7 @@ export function IconMatrix() {
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [size, setSize] = useState<number>(20) const [size, setSize] = useState<number>(20)
const [copiedName, setCopiedName] = useState<string | null>(null) const [copiedName, setCopiedName] = useState<string | null>(null)
const { schedule } = useTimeout()
const filteredIcons = useMemo(() => { const filteredIcons = useMemo(() => {
return ICONS.filter(icon => { return ICONS.filter(icon => {
@@ -128,7 +35,7 @@ export function IconMatrix() {
const code = `<${icon.pascalName} size={${size}} />` const code = `<${icon.pascalName} size={${size}} />`
navigator.clipboard?.writeText(code) navigator.clipboard?.writeText(code)
setCopiedName(icon.name) setCopiedName(icon.name)
setTimeout(() => setCopiedName(null), 2000) schedule(() => setCopiedName(null), 2000)
} }
return ( return (
@@ -140,6 +47,7 @@ export function IconMatrix() {
<input <input
type="text" type="text"
placeholder="Search icons (e.g. arrow, check, sparkles)..." placeholder="Search icons (e.g. arrow, check, sparkles)..."
aria-label="Search icons"
value={search} value={search}
onChange={e => setSearch(e.target.value)} onChange={e => setSearch(e.target.value)}
className="icon-search-input" className="icon-search-input"
+63 -19
View File
@@ -1,7 +1,8 @@
import { useState, useEffect, useMemo, useRef } from 'react' import { useState, useEffect, useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import { Icon } from './Icon' import { Icon, type IconName } from './Icon'
import { Badge } from './ui/Badge' import { Badge } from './ui/Badge'
import { Kbd } from './ui/Kbd' import { Kbd } from './ui/Kbd'
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
import type { PatternId } from './AIWorkflowSection' import type { PatternId } from './AIWorkflowSection'
export type SearchItem = { export type SearchItem = {
@@ -9,7 +10,7 @@ export type SearchItem = {
title: string title: string
description: string description: string
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation' category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
icon: string icon: IconName
targetId: string targetId: string
patternId?: PatternId patternId?: PatternId
} }
@@ -59,7 +60,7 @@ const SEARCH_INDEX: SearchItem[] = [
title: 'Buttons & Action States', title: 'Buttons & Action States',
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states', description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
category: 'Components', category: 'Components',
icon: 'arrow', icon: 'arrow-right',
targetId: 'buttons', targetId: 'buttons',
}, },
{ {
@@ -131,11 +132,20 @@ type SearchPaletteProps = {
initialQuery?: string 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) { export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: SearchPaletteProps) {
const [query, setQuery] = useState(initialQuery) const [query, setQuery] = useState(initialQuery)
const [selectedIndex, setSelectedIndex] = useState(0) const [selectedIndex, setSelectedIndex] = useState(0)
const panelRef = useRef<HTMLDivElement>(null)
const inputRef = useRef<HTMLInputElement>(null) const inputRef = useRef<HTMLInputElement>(null)
/* Same lifecycle as DialogContent: scroll lock + focus capture/restore. */
useOverlayLifecycle(isOpen, panelRef)
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
inputRef.current?.focus() inputRef.current?.focus()
@@ -153,7 +163,11 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
) )
}, [query]) }, [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(() => { useEffect(() => {
if (!isOpen) return if (!isOpen) return
@@ -163,32 +177,49 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
onClose() onClose()
} else if (e.key === 'ArrowDown') { } else if (e.key === 'ArrowDown') {
e.preventDefault() e.preventDefault()
setSelectedIndex(prev => (prev + 1) % (results.length || 1)) setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
} else if (e.key === 'ArrowUp') { } else if (e.key === 'ArrowUp') {
e.preventDefault() e.preventDefault()
setSelectedIndex(prev => (prev - 1 + results.length) % (results.length || 1)) setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
} else if (e.key === 'Enter' && results[selectedIndex]) { } else if (e.key === 'Enter' && activeItem) {
e.preventDefault() e.preventDefault()
onSelect(results[selectedIndex]) onSelect(activeItem)
onClose() onClose()
} }
} }
window.addEventListener('keydown', handleKeyDown) window.addEventListener('keydown', handleKeyDown)
return () => window.removeEventListener('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 ( return (
<div className="search-palette-backdrop" onClick={onClose}> <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 */} {/* Search Input Bar */}
<div className="search-palette-head"> <div className="search-palette-head">
<Icon name="search" size={18} className="search-palette-icon" /> <Icon name="search" size={18} className="search-palette-icon" />
<input <input
ref={inputRef} ref={inputRef}
type="text" 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" className="search-palette-input"
placeholder="Type a command, component, or token..." placeholder="Type a command, component, or token..."
value={query} value={query}
@@ -198,7 +229,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
}} }}
/> />
{query && ( {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} /> <Icon name="close" size={14} />
</button> </button>
)} )}
@@ -208,12 +247,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
{/* Results List */} {/* Results List */}
<div className="search-palette-body"> <div className="search-palette-body">
{results.length > 0 ? ( {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) => { {results.map((item, idx) => {
const isSelected = idx === selectedIndex const isSelected = idx === activeIndex
return ( return (
<div <li
key={item.id} key={item.id}
id={`search-option-${item.id}`}
role="option"
aria-selected={isSelected}
className={`search-palette-item ${isSelected ? 'selected' : ''}`} className={`search-palette-item ${isSelected ? 'selected' : ''}`}
onClick={() => { onClick={() => {
onSelect(item) onSelect(item)
@@ -236,14 +278,16 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
<Kbd>↵</Kbd> <Kbd>↵</Kbd>
</div> </div>
)} )}
</div> </li>
) )
})} })}
</div> </ul>
) : ( ) : (
<div className="search-palette-empty"> <div className="search-palette-empty">
<Icon name="search" size={24} /> <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> <span>Try searching for &ldquo;Buttons&rdquo;, &ldquo;Metrics&rdquo;, &ldquo;Tokens&rdquo;, or &ldquo;Icons&rdquo;.</span>
</div> </div>
)} )}
+13 -4
View File
@@ -1,23 +1,32 @@
import { Icon } from './Icon' 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[] } export type NavigationGroup = { label: string; items: NavigationItem[] }
type SidebarProps = { type SidebarProps = {
active: string active: string
groups: NavigationGroup[] groups: NavigationGroup[]
onSelect: (item: NavigationItem) => void 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 ( return (
<aside className="sidebar"> <aside className="sidebar">
<div className="sidebar-heading"> <div className="sidebar-heading">
<div className="sidebar-library"> <div className="sidebar-library">
<Icon name="layers" size={13} /> Library <Icon name="layers" size={13} /> Library
</div> </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> </button>
</div> </div>
+54 -38
View File
@@ -1,45 +1,56 @@
import { useState } from 'react' import { useCallback, useEffect, useState } from 'react'
import { Icon } from './Icon' 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 = { type TopbarProps = {
onOpenSearch: () => void onOpenSearch: () => void
onMenuToggle: () => 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) { export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
const { theme, toggle } = useTheme() const { theme, toggle } = useTheme()
@@ -67,13 +78,18 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
<nav className="top-links"> <nav className="top-links">
<a href="#docs">Docs</a> <a href="#docs">Docs</a>
<a href="#github">GitHub ↗</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} /> <Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
</button> </button>
<button className="avatar">MS</button> <button type="button" className="avatar">MS</button>
</nav> </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" /> <Icon name="menu" />
</button> </button>
</header> </header>
+16 -7
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import { import {
Dialog, Dialog,
DialogTrigger, DialogTrigger,
@@ -22,13 +23,21 @@ export function DeploymentModal() {
const [deploying, setDeploying] = useState(false) const [deploying, setDeploying] = useState(false)
const [progress, setProgress] = useState(0) const [progress, setProgress] = useState(0)
const [deployed, setDeployed] = useState(false) 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 = () => { const handleStartDeploy = () => {
setDeploying(true) setDeploying(true)
setProgress(15) setProgress(15)
setTimeout(() => setProgress(45), 500) scheduleDeploy(() => setProgress(45), 500)
setTimeout(() => setProgress(85), 1000) scheduleDeploy(() => setProgress(85), 1000)
setTimeout(() => { scheduleDeploy(() => {
setProgress(100) setProgress(100)
setDeploying(false) setDeploying(false)
setDeployed(true) setDeployed(true)
@@ -37,7 +46,7 @@ export function DeploymentModal() {
const handleReset = () => { const handleReset = () => {
setOpen(false) setOpen(false)
setTimeout(() => { scheduleReset(() => {
setDeploying(false) setDeploying(false)
setProgress(0) setProgress(0)
setDeployed(false) setDeployed(false)
@@ -46,7 +55,7 @@ export function DeploymentModal() {
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger> <DialogTrigger asChild>
<button className="button primary"> <button className="button primary">
<Icon name="sparkles" size={15} /> Promote Release to Canary <Icon name="sparkles" size={15} /> Promote Release to Canary
</button> </button>
@@ -109,7 +118,7 @@ export function DeploymentModal() {
<DialogFooter> <DialogFooter>
{!deployed ? ( {!deployed ? (
<> <>
<DialogClose> <DialogClose asChild>
<button className="button secondary" disabled={deploying}> <button className="button secondary" disabled={deploying}>
Cancel Cancel
</button> </button>
+4 -2
View File
@@ -1,4 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { useTimeout } from '../../utils/useTimeout'
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card' import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'
import { Switch } from '../ui/Switch' import { Switch } from '../ui/Switch'
import { Badge } from '../ui/Badge' import { Badge } from '../ui/Badge'
@@ -18,16 +19,17 @@ export function SettingsPanel() {
const [autoRollback, setAutoRollback] = useState(false) const [autoRollback, setAutoRollback] = useState(false)
const apiKey = 'nsk_live_98a72ef01bc34d89a2' const apiKey = 'nsk_live_98a72ef01bc34d89a2'
const { schedule } = useTimeout()
const copyKey = () => { const copyKey = () => {
navigator.clipboard?.writeText(apiKey) navigator.clipboard?.writeText(apiKey)
setCopied(true) setCopied(true)
setTimeout(() => setCopied(false), 2000) schedule(() => setCopied(false), 2000)
} }
const handleSave = () => { const handleSave = () => {
setSaved(true) setSaved(true)
setTimeout(() => setSaved(false), 2500) schedule(() => setSaved(false), 2500)
} }
return ( 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>) { export function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
return ( return (
<div <p
className={cn(className)} className={cn(className)}
style={{ style={{
margin: 0, 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' import { cn } from '../../utils/cn'
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline' export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
type BadgeProps = { type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
children: ReactNode
variant?: BadgeVariant variant?: BadgeVariant
className?: string
} }
export function Badge({ children, variant = 'primary', className }: BadgeProps) { export function Badge({ variant = 'primary', className, children, ...props }: BadgeProps) {
return ( return (
<span className={cn('badge', `badge-${variant}`, className)}> <span className={cn('badge', `badge-${variant}`, className)} {...props}>
{children} {children}
</span> </span>
) )
+54 -119
View File
@@ -1,18 +1,17 @@
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react' import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import { cn } from '../../utils/cn' import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
type DialogContextType = { /* Dialog is a thin skin over the shared Overlay primitive — all behavior
open: boolean (focus trap, Escape, scroll lock, focus restore, aria wiring) lives there. */
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
}
export type DialogProps = { export type DialogProps = {
children: ReactNode children: ReactNode
@@ -21,124 +20,60 @@ export type DialogProps = {
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
} }
export function Dialog({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: DialogProps) { export function Dialog(props: DialogProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen) return <Overlay {...props} />
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 DialogTrigger({ children, className }: { children: ReactNode; className?: string }) { export function DialogTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
const { setOpen } = useDialog() return <OverlayTrigger {...props} />
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
} }
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) { 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 ( return (
<div className="dialog-backdrop" onClick={() => setOpen(false)}> <OverlayContent
<div className={cn('dialog-panel', className)} onClick={e => e.stopPropagation()}> backdropClassName="dialog-backdrop"
<button panelClassName="dialog-panel"
className="dialog-close-btn" closeButtonLabel="Close dialog"
onClick={() => setOpen(false)} className={className}
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}
> >
{children} {children}
</h2> </OverlayContent>
) )
} }
export function DialogDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) { export function DialogHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
return ( const defaultStyle: CSSProperties = { marginBottom: 'var(--space-4)', ...style }
<p return <OverlayHeader className={className} style={defaultStyle} {...props} />
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
} }
export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) { export function DialogTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return ( const defaultStyle: CSSProperties = {
<div margin: 0,
className={cn('flex-between', className)} fontSize: 'var(--text-xl)',
style={{ fontWeight: 700,
marginTop: 'var(--space-6)', color: 'var(--foreground)',
paddingTop: 'var(--space-4)', letterSpacing: '-0.03em',
borderTop: '1px solid var(--border)', ...style,
gap: 'var(--space-3)', }
}} return <OverlayTitle className={className} style={defaultStyle} {...props} />
{...props}
/>
)
} }
export function DialogClose({ children, className }: { children: ReactNode; className?: string }) { export function DialogDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const { setOpen } = useDialog() const defaultStyle: CSSProperties = {
return ( margin: 0,
<span className={className} onClick={() => setOpen(false)}> fontSize: 'var(--text-sm)',
{children} color: 'var(--muted-foreground)',
</span> 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' import { cn } from '../../utils/cn'
type ProgressProps = HTMLAttributes<HTMLDivElement> & { type ProgressProps = HTMLAttributes<HTMLDivElement> & {
@@ -16,17 +16,28 @@ export function Progress({
className, className,
...props ...props
}: ProgressProps) { }: 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 ( return (
<div className={cn('stack-xs', className)} {...props}> <div className={cn('stack-xs', className)} {...props}>
{(label || !indeterminate) && ( {(label || !indeterminate) && (
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}> <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>} {!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
</div> </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 <div
className={cn('progress-fill', indeterminate && 'progress-indeterminate')} className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
style={!indeterminate ? { width: `${percentage}%` } : undefined} 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 type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
import { cn } from '../../utils/cn' import {
Overlay,
OverlayClose,
OverlayContent,
OverlayDescription,
OverlayFooter,
OverlayHeader,
OverlayTitle,
OverlayTrigger,
} from './Overlay'
type SheetContextType = { /* Sheet is the slide-over skin of the shared Overlay primitive — identical
open: boolean focus trap / Escape / scroll-lock behavior as Dialog, only different
setOpen: (open: boolean) => void type-scale defaults and panel classes. */
}
const SheetContext = createContext<SheetContextType | null>(null) export type SheetProps = {
function useSheet() {
const ctx = useContext(SheetContext)
if (!ctx) throw new Error('Sheet components must be used within <Sheet>')
return ctx
}
type SheetProps = {
children: ReactNode children: ReactNode
open?: boolean open?: boolean
defaultOpen?: boolean defaultOpen?: boolean
onOpenChange?: (open: boolean) => void onOpenChange?: (open: boolean) => void
} }
export function Sheet({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: SheetProps) { export function Sheet(props: SheetProps) {
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen) return <Overlay {...props} />
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 SheetTrigger({ children, className }: { children: ReactNode; className?: string }) { export function SheetTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
const { setOpen } = useSheet() return <OverlayTrigger {...props} />
return (
<span className={className} onClick={() => setOpen(true)}>
{children}
</span>
)
} }
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) { 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 ( return (
<div className="sheet-backdrop" onClick={() => setOpen(false)}> <OverlayContent
<div className={cn('sheet-panel', className)} onClick={e => e.stopPropagation()}> backdropClassName="sheet-backdrop"
<button panelClassName="sheet-panel"
className="dialog-close-btn" closeButtonLabel="Close sheet"
onClick={() => setOpen(false)} className={className}
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}
> >
{children} {children}
</h2> </OverlayContent>
) )
} }
export function SheetDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) { export function SheetHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
return ( const defaultStyle: CSSProperties = { marginBottom: 'var(--space-6)', ...style }
<p return <OverlayHeader className={className} style={defaultStyle} {...props} />
className={cn(className)}
style={{
margin: 0,
fontSize: 'var(--text-sm)',
color: 'var(--muted-foreground)',
lineHeight: 1.5,
}}
{...props}
/>
)
} }
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) { export function SheetTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
return ( const defaultStyle: CSSProperties = {
<div margin: 0,
className={cn('flex-between', className)} fontSize: 'var(--text-lg)',
style={{ fontWeight: 700,
marginTop: 'auto', color: 'var(--foreground)',
paddingTop: 'var(--space-4)', letterSpacing: '-0.02em',
borderTop: '1px solid var(--border)', ...style,
gap: 'var(--space-3)', }
}} return <OverlayTitle className={className} style={defaultStyle} {...props} />
{...props}
/>
)
} }
export function SheetClose({ children, className }: { children: ReactNode; className?: string }) { export function SheetDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
const { setOpen } = useSheet() const defaultStyle: CSSProperties = {
return ( margin: 0,
<span className={className} onClick={() => setOpen(false)}> fontSize: 'var(--text-sm)',
{children} color: 'var(--muted-foreground)',
</span> 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 title: string
value: string | number value: string | number
change?: string change?: string
/** Sentiment — drives the badge color. Up = good news, down = bad news. */
trend?: 'up' | 'down' | 'neutral' 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 caption?: string
icon?: ReactNode icon?: ReactNode
className?: string 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({ export function StatCard({
title, title,
value, value,
change, change,
trend = 'up', trend = 'neutral',
direction,
caption, caption,
icon, icon,
className, className,
}: StatCardProps) { }: StatCardProps) {
const arrowDirection = direction ?? inferDirection(change)
return ( return (
<Card className={cn('hover-lift', className)}> <Card className={cn('hover-lift', className)}>
<div className="flex-between" style={{ marginBottom: 'var(--space-3)' }}> <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)' }}> <div className="flex-between" style={{ marginTop: 'var(--space-2)' }}>
{change && ( {change && (
<Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}> <Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}>
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} {change} {arrowDirection === 'up' ? '↑' : arrowDirection === 'down' ? '↓' : '→'} {change}
</Badge> </Badge>
)} )}
{caption && ( {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' import { cn } from '../../utils/cn'
type TabsContextType = { type TabsContextType = {
value: string value: string
setValue: (value: string) => void setValue: (value: string) => void
baseId: string
} }
const TabsContext = createContext<TabsContextType | null>(null) const TabsContext = createContext<TabsContextType | null>(null)
@@ -14,6 +23,11 @@ function useTabs() {
return ctx 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 = { type TabsProps = {
defaultValue?: string defaultValue?: string
value?: string value?: string
@@ -24,6 +38,7 @@ type TabsProps = {
export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) { export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) {
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue) const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
const baseId = useId()
const isControlled = controlledValue !== undefined const isControlled = controlledValue !== undefined
const value = isControlled ? controlledValue : uncontrolledValue const value = isControlled ? controlledValue : uncontrolledValue
@@ -33,14 +48,66 @@ export function Tabs({ defaultValue = '', value: controlledValue, onValueChange,
} }
return ( return (
<TabsContext.Provider value={{ value, setValue }}> <TabsContext.Provider value={{ value, setValue, baseId }}>
<div className={cn('ui-tabs-root', className)}>{children}</div> <div className={cn('ui-tabs-root', className)}>{children}</div>
</TabsContext.Provider> </TabsContext.Provider>
) )
} }
export function TabsList({ className, ...props }: HTMLAttributes<HTMLDivElement>) { const NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End'])
return <div className={cn('ui-tabs-list', className)} role="tablist" {...props} />
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> & { type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
@@ -48,14 +115,18 @@ type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
} }
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) { export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
const { value: selectedValue, setValue } = useTabs() const { value: selectedValue, setValue, baseId } = useTabs()
const isActive = selectedValue === value const isActive = selectedValue === value
return ( return (
<button <button
type="button" type="button"
id={partId(baseId, 'trigger', value)}
role="tab" role="tab"
aria-selected={isActive} aria-selected={isActive}
aria-controls={partId(baseId, 'content', value)}
tabIndex={isActive ? 0 : -1}
data-value={value}
className={cn('ui-tabs-trigger', isActive && 'active', className)} className={cn('ui-tabs-trigger', isActive && 'active', className)}
onClick={() => setValue(value)} onClick={() => setValue(value)}
{...props} {...props}
@@ -70,11 +141,19 @@ type TabsContentProps = HTMLAttributes<HTMLDivElement> & {
} }
export function TabsContent({ value, className, children, ...props }: TabsContentProps) { export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
const { value: selectedValue } = useTabs() const { value: selectedValue, baseId } = useTabs()
if (selectedValue !== value) return null const isActive = selectedValue === value
return ( 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} {children}
</div> </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' import { cn } from '../../utils/cn'
type TooltipProps = { type TooltipProps = {
@@ -7,11 +15,66 @@ type TooltipProps = {
className?: string 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) { 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 ( return (
<span className={cn('tooltip-wrapper', className)} tabIndex={0}> <span
{children} className={cn('tooltip-wrapper', className)}
<span className="tooltip-box" role="tooltip"> 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} {content}
</span> </span>
</span> </span>
+4
View File
@@ -2,6 +2,7 @@ export * from './Badge'
export * from './Card' export * from './Card'
export * from './StatCard' export * from './StatCard'
export * from './Kbd' export * from './Kbd'
export * from './Overlay'
export * from './Dialog' export * from './Dialog'
export * from './Alert' export * from './Alert'
export * from './EmptyState' export * from './EmptyState'
@@ -12,4 +13,7 @@ export * from './Skeleton'
export * from './Table' export * from './Table'
export * from './Sheet' export * from './Sheet'
export * from './Tooltip' export * from './Tooltip'
export * from './Avatar'
export * from './Spinner'
export * from '../Icon' 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 { ButtonExample } from '../components/ButtonMatrix'
import type { Token } from '../components/TokenGrid' import type { Token } from '../components/TokenGrid'
import type { NavigationGroup } from '../components/Sidebar'
export const navigationGroups: NavigationGroup[] = [ export const navigationGroups: NavigationGroup[] = [
{ {
label: 'Application Patterns', label: 'Application Patterns',
items: [ items: [
{ label: 'Metrics Dashboard', id: 'application-patterns' }, { label: 'Metrics Dashboard', id: 'application-patterns', patternId: 'metrics' },
{ label: 'Settings Panel', id: 'application-patterns' }, { label: 'Settings Panel', id: 'application-patterns', patternId: 'settings' },
{ label: 'Service Table', id: 'application-patterns' }, { label: 'Service Table', id: 'application-patterns', patternId: 'table' },
{ label: 'Deployment Flow', id: 'application-patterns' }, { label: 'Deployment Flow', id: 'application-patterns', patternId: 'deploy' },
], ],
}, },
{ {
label: 'Components', label: 'Components',
items: [ items: [
{ label: 'Buttons', count: 12, id: 'buttons' }, { 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: 'Form controls', id: 'form-controls' },
{ label: 'Composable code', id: 'composable-by-default' }, { 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 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 './tokens.css';
@import './utils.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; } * { 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 { createRoot } from 'react-dom/client'
import './index.css' import './index.css'
import App from './App.tsx' 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( createRoot(document.getElementById('root')!).render(
<StrictMode> <StrictMode>
<App /> <ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>, </StrictMode>,
) )
-54
View File
@@ -96,33 +96,6 @@
--sidebar-ring: lab(42% 0 0); --sidebar-ring: lab(42% 0 0);
--dot-color: lab(7.78201% -.0000149012 0 / .05); --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) ────────────────── */ /* ── Dark Palette (Exact lab() Specification) ────────────────── */
@@ -165,33 +138,6 @@
--sidebar-ring: lab(89.9321% -47.9163 -15.7303); --sidebar-ring: lab(89.9321% -47.9163 -15.7303);
--dot-color: lab(89.9321% -47.9163 -15.7303 / .045); --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 { html {
+7 -2
View File
@@ -516,8 +516,7 @@
transition: opacity 0.15s ease, transform 0.15s ease; transition: opacity 0.15s ease, transform 0.15s ease;
} }
.tooltip-wrapper:hover .tooltip-box, .tooltip-box[data-open='true'] {
.tooltip-wrapper:focus-within .tooltip-box {
opacity: 1; opacity: 1;
transform: translateX(-50%) translateY(-8px); transform: translateX(-50%) translateY(-8px);
} }
@@ -543,3 +542,9 @@
.skeleton-rect { .skeleton-rect {
border-radius: var(--radius-lg); 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 }
}
+95
View File
@@ -0,0 +1,95 @@
import { execFileSync } from 'node:child_process'
import { mkdtempSync, writeFileSync, mkdirSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
const GUARD = join(process.cwd(), 'scripts', 'check-tokens.mjs')
function runIn(files) {
const dir = mkdtempSync(join(tmpdir(), 'token-guard-'))
mkdirSync(join(dir, 'src'))
for (const [name, content] of Object.entries(files)) {
writeFileSync(join(dir, 'src', name), content)
}
try {
execFileSync(process.execPath, [GUARD, join(dir, 'src')], { encoding: 'utf8' })
return { code: 0, output: '' }
} catch (err) {
return { code: err.status, output: err.stdout + String(err.stderr ?? '') }
}
}
const TOKENS = ':root { --primary: lab(50% 0 0); --space-2: 0.5rem; }\n'
describe('check-tokens guard', () => {
it('passes a clean file', () => {
const result = runIn({
'tokens.css': TOKENS,
'a.tsx': `export const A = () => <div style={{ color: 'var(--primary)', padding: 'var(--space-2)' }} />\n`,
})
expect(result.code).toBe(0)
})
it('flags hex, named colors and hsl()/oklch() inside style={{ }}', () => {
const result = runIn({
'tokens.css': TOKENS,
'a.tsx': `export const A = () => (
<div style={{
color: '#fff',
background: 'oklch(70% 0.1 200)',
borderColor: 'red',
}} />
)\n`,
})
expect(result.code).toBe(1)
expect(result.output).toMatch(/hex color #fff/)
expect(result.output).toMatch(/oklch\(\) color/)
expect(result.output).toMatch(/named color "red"/)
})
it('allows color-mix over tokens and neutral rgba shines', () => {
const result = runIn({
'tokens.css': TOKENS,
'a.tsx': `export const A = () => (
<div style={{
background: 'color-mix(in srgb, var(--primary) 12%, transparent)',
boxShadow: '0 8px 24px rgba(0, 0, 0, 0.2)',
}} />
)\n`,
})
expect(result.code).toBe(0)
})
it('reads values split across lines in css', () => {
const result = runIn({
'tokens.css': TOKENS,
'b.css': `.b {\n background:\n linear-gradient(\n to right,\n crimson,\n navy\n );\n}\n`,
})
expect(result.code).toBe(1)
expect(result.output).toMatch(/named color "crimson"/)
})
it('flags px font sizes in css and tsx', () => {
const result = runIn({
'tokens.css': TOKENS,
'b.css': `.b { font-size: 14px; }\n`,
'a.tsx': `export const A = () => <div style={{ fontSize: '14px' }} />\n`,
})
expect(result.code).toBe(1)
expect(result.output.match(/px font size/g)).toHaveLength(2)
})
it('fails on var(--x) that no css file defines', () => {
const result = runIn({
'tokens.css': TOKENS,
'a.tsx': `export const A = () => <div style={{ color: 'var(--priamry)' }} />\n`,
})
expect(result.code).toBe(1)
expect(result.output).toMatch(/var\(--priamry\) is never defined/)
})
it('keeps the real repo clean', () => {
expect(() => execFileSync(process.execPath, [GUARD], { stdio: 'pipe' })).not.toThrow()
})
})
+33
View File
@@ -0,0 +1,33 @@
import { render } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import { Icon, type IconName } from '../src/components/Icon'
import { iconMap, iconRegistry } from '../src/components/icon-registry'
describe('icon registry (single source of truth)', () => {
it('has unique names', () => {
const names = iconRegistry.map(entry => entry.name)
expect(new Set(names).size).toBe(names.length)
})
it('maps every name (including aliases) to a real registry component', () => {
const components = new Set(iconRegistry.map(entry => entry.component))
for (const [name, component] of Object.entries(iconMap)) {
expect(components.has(component), `icon "${name}" has no canonical twin in the registry`).toBe(true)
}
})
it('resolves every registry entry through iconMap', () => {
for (const entry of iconRegistry) {
expect(iconMap[entry.name]).toBe(entry.component)
}
})
it('renders a registry icon by name', () => {
const { container } = render(<Icon name="sparkles" data-testid="sparkles" />)
expect(container.querySelector('svg')).toBeInTheDocument()
})
it('throws on unknown names in dev instead of silently rendering a wrong glyph', () => {
expect(() => render(<Icon name={'definitely-not-an-icon' as IconName} />)).toThrow(/Unknown icon name/)
})
})
+170
View File
@@ -0,0 +1,170 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { useState } from 'react'
import { describe, expect, it } from 'vitest'
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '../src/components/ui/Dialog'
import { Sheet, SheetClose, SheetContent, SheetTitle } from '../src/components/ui/Sheet'
describe('Dialog (Overlay primitive)', () => {
it('renders a modal dialog with the panel focused when open', () => {
render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
const panel = screen.getByRole('dialog')
expect(panel).toHaveClass('dialog-panel')
expect(panel).toHaveAttribute('aria-modal', 'true')
expect(panel).toHaveFocus()
})
it('wires aria-labelledby only when a title is rendered', () => {
const { rerender } = render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
const panel = screen.getByRole('dialog')
const labelledBy = panel.getAttribute('aria-labelledby')
expect(labelledBy).toBeTruthy()
expect(document.getElementById(labelledBy!)).toHaveTextContent('Deploy')
// No DialogTitle → a dangling IDREF would announce nothing.
rerender(
<Dialog defaultOpen>
<DialogContent>
<div>body only</div>
</DialogContent>
</Dialog>,
)
expect(screen.getByRole('dialog')).not.toHaveAttribute('aria-labelledby')
})
it('wires aria-describedby only when a description is rendered', () => {
render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
<DialogDescription>To production</DialogDescription>
</DialogContent>
</Dialog>,
)
const describedBy = screen.getByRole('dialog').getAttribute('aria-describedby')
expect(describedBy).toBeTruthy()
expect(document.getElementById(describedBy!)).toHaveTextContent('To production')
})
it('restores focus to the trigger after closing', () => {
function Controlled() {
const [open, setOpen] = useState(false)
return (
<>
<button onClick={() => setOpen(true)}>Open</button>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>
</>
)
}
render(<Controlled />)
const trigger = screen.getByRole('button', { name: 'Open' })
// jsdom does not move focus on synthetic clicks — focus the trigger like a real user would.
trigger.focus()
fireEvent.click(trigger)
expect(screen.getByRole('dialog')).toHaveFocus()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
expect(trigger).toHaveFocus()
})
it('closes on Escape when open', () => {
render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
expect(screen.getByRole('dialog')).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
expect(screen.queryByRole('dialog')).toBeNull()
})
it('closes on backdrop click', () => {
render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
fireEvent.click(screen.getByRole('dialog').parentElement!)
expect(screen.queryByRole('dialog')).toBeNull()
})
it('locks body scroll while open and restores it on close', () => {
const { rerender } = render(
<Dialog defaultOpen>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
expect(document.body.style.overflow).toBe('hidden')
rerender(
<Dialog open={false}>
<DialogContent>
<DialogTitle>Deploy</DialogTitle>
</DialogContent>
</Dialog>,
)
expect(document.body.style.overflow).not.toBe('hidden')
})
})
describe('Sheet (Overlay skin)', () => {
it('renders with sheet classes and its own close label', () => {
render(
<Sheet defaultOpen>
<SheetContent>
<SheetTitle>Filters</SheetTitle>
<SheetClose>Done</SheetClose>
</SheetContent>
</Sheet>,
)
const panel = screen.getByRole('dialog')
expect(panel).toHaveClass('sheet-panel')
expect(screen.getByRole('button', { name: 'Close sheet' })).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Done' }))
expect(screen.queryByRole('dialog')).toBeNull()
})
it('keeps Tab focus inside the panel (wrap from last to first)', () => {
render(
<Sheet defaultOpen>
<SheetContent>
<SheetTitle>Filters</SheetTitle>
<button>Only control</button>
</SheetContent>
</Sheet>,
)
const panel = screen.getByRole('dialog')
const closeBtn = screen.getByRole('button', { name: 'Close sheet' })
const last = screen.getByRole('button', { name: 'Only control' })
// Tab from the last control wraps to the first.
last.focus()
fireEvent.keyDown(panel, { key: 'Tab' })
expect(closeBtn).toHaveFocus()
// Shift+Tab from the first control wraps to the last.
fireEvent.keyDown(panel, { key: 'Tab', shiftKey: true })
expect(last).toHaveFocus()
})
})
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
+50
View File
@@ -0,0 +1,50 @@
import { act, renderHook } from '@testing-library/react'
import { vi } from 'vitest'
import { useTimeout } from '../src/utils/useTimeout'
describe('useTimeout', () => {
it('fires the callback after the delay', () => {
vi.useFakeTimers()
const { result } = renderHook(() => useTimeout())
const fn = vi.fn()
act(() => {
result.current.schedule(fn, 1000)
})
expect(fn).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1000)
})
expect(fn).toHaveBeenCalledTimes(1)
vi.useRealTimers()
})
it('clear() cancels pending timers', () => {
vi.useFakeTimers()
const { result } = renderHook(() => useTimeout())
const fn = vi.fn()
act(() => {
result.current.schedule(fn, 1000)
result.current.clear()
})
act(() => {
vi.advanceTimersByTime(5000)
})
expect(fn).not.toHaveBeenCalled()
vi.useRealTimers()
})
it('clears pending timers on unmount (no setState-after-unmount leaks)', () => {
vi.useFakeTimers()
const { result, unmount } = renderHook(() => useTimeout())
const fn = vi.fn()
act(() => {
result.current.schedule(fn, 1000)
})
unmount()
act(() => {
vi.advanceTimersByTime(5000)
})
expect(fn).not.toHaveBeenCalled()
vi.useRealTimers()
})
})
+2
View File
@@ -17,6 +17,8 @@
"jsx": "react-jsx", "jsx": "react-jsx",
/* Linting */ /* Linting */
"strict": true,
"noUncheckedIndexedAccess": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"erasableSyntaxOnly": true, "erasableSyntaxOnly": true,
+7
View File
@@ -1,3 +1,4 @@
/// <reference types="vitest/config" />
import react from '@vitejs/plugin-react' import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
@@ -9,4 +10,10 @@ export default defineConfig({
port: 5173, port: 5173,
allowedHosts: ['.preview.k6n.net'], allowedHosts: ['.preview.k6n.net'],
}, },
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./tests/setup.ts'],
include: ['tests/**/*.test.{ts,tsx,mjs}', 'src/**/*.test.{ts,tsx}'],
},
}) })