Template
Harden agent guardrails and fix audited bugs
Gate hardening:
- strict + noUncheckedIndexedAccess in tsconfig.app.json; fix resulting errors
- check-tokens.mjs: scan inline style={{ }} in ts/tsx, ban named colors and
rgb()/hsl()/oklch()/lab(), read multi-line values, fail on undefined var(--x)
- vitest (24 tests: overlay, icon-registry, useTimeout, token guard) wired into
npm run check; GitHub Actions CI workflow
Single-source refactors:
- new Overlay primitive owns focus trap/Escape/scroll-lock/focus-restore and
conditional aria-labelledby/aria-describedby; Dialog + Sheet are thin skins
(overlayBehavior.ts exports trapTabKey/useOverlayLifecycle for SearchPalette)
- icon-registry.ts is the one icon map: strict IconName, aliases typed, dev
throws on unknown names instead of silently rendering <Layers/>; IconMatrix
consumes the shared registry (41 curated)
- useTimeout hook replaces fire-and-forget setTimeout in DeploymentModal,
SettingsPanel, IconMatrix, CodeSample (timers die on close/unmount)
Bug fixes:
- SearchPalette: role=dialog/aria-modal, focus trap + restore, combobox/listbox
semantics, labelled input, selection clamped to results
- theme: pre-paint script in index.html (localStorage -> prefers-color-scheme ->
dark), useTheme no longer writes DOM during state init
- StatCard: direction (arrow) decoupled from trend (sentiment); latency drop
renders green "↓ -4.2ms" instead of "↑ -4.2ms"
- sidebar collapse button wired (aria-pressed + collapsed rail CSS); pattern
routing keys on patternId from library.ts, not English labels
- remove 25 dead legacy tokens (--panel*, --line*, --text-soft, --critical,
--pink, --blue, ...); migrate --bg/--text/--positive/--warning usages
- title "Northstar Design System", real README, AGENTS.md structure/theme/
overlay/registry/test docs updated; dev error sink (#dev-errors +
ErrorBoundary) so agents can read runtime errors via inspect
This commit is contained in:
1 parent
c0a94d3b6d
commit
f6e81afe5e
48 files changed
+3272
-669
No files matched your search
@@ -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
|
||||
@@ -1,14 +1,21 @@
|
||||
# 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
|
||||
|
||||
| Task | Command |
|
||||
|---|---|
|
||||
| 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) |
|
||||
| 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
|
||||
|
||||
@@ -18,20 +25,24 @@ React 19 + Vite 8 + TypeScript design-system showcase. Dark-first, token-driven
|
||||
## 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:
|
||||
- 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`).
|
||||
- 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.
|
||||
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)`.
|
||||
- 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']`, `.light`): Pure white daylight base (`lab(100% 0 0)`), crisp charcoal primary (`lab(7.78201% -.0000149012 0)`), and high-contrast surfaces.
|
||||
- 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`.
|
||||
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
|
||||
|
||||
- **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-*`).
|
||||
- **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`).
|
||||
- **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
|
||||
|
||||
@@ -42,22 +53,44 @@ src/
|
||||
index.css ← fonts, tokens & utils import, base resets
|
||||
App.css ← application component styles (token-driven, plain CSS)
|
||||
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/
|
||||
cn.ts ← conditional classnames utility
|
||||
useTimeout.ts ← auto-cleaning setTimeout hook
|
||||
components/
|
||||
ui/ ← composable UI primitives (Dialog, Sheet, Tabs, Alert, EmptyState, Table, Progress, Switch, Badge, Card, StatCard, Skeleton, Tooltip, Kbd, Icon)
|
||||
examples/ ← reusable pattern components (MetricsDashboard, SettingsPanel, ServiceTable, DeploymentModal)
|
||||
ui/ ← composable UI primitives (Overlay, Dialog, Sheet, Tabs, Alert,
|
||||
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
|
||||
IconMatrix.tsx ← interactive Lucide icon explorer & search
|
||||
Topbar.tsx ← owns the theme toggle (.dark & data-theme)
|
||||
SearchPalette.tsx ← ⌘K command palette (Overlay behavior + combobox a11y)
|
||||
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,
|
||||
Sidebar.tsx, Section.tsx, FormField.tsx, TokenGrid.tsx,
|
||||
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx, Icon.tsx
|
||||
MarkdownRenderer.tsx, CodeEditor.tsx, Button.tsx
|
||||
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)
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
1. Change the token (or component markup) — never a literal.
|
||||
|
||||
@@ -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)
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||
|
||||
## 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 }]
|
||||
}
|
||||
}
|
||||
```bash
|
||||
npm install
|
||||
npm run dev # dev server on 0.0.0.0:5173
|
||||
```
|
||||
|
||||
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
@@ -4,10 +4,33 @@
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<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>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<div id="dev-errors"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+1124
-1
File diff suppressed because it is too large.
Load diff
+8
-2
@@ -7,7 +7,9 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -16,12 +18,16 @@
|
||||
"react-dom": "^19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/jest-dom": "^7.0.1",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^24.13.3",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.7",
|
||||
"@vitejs/plugin-react": "^6.1.1",
|
||||
"jsdom": "^30.0.1",
|
||||
"oxlint": "^1.81.0",
|
||||
"typescript": "~6.0.2",
|
||||
"vite": "^8.3.0"
|
||||
"vite": "^8.3.0",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
+233
-22
@@ -1,46 +1,257 @@
|
||||
#!/usr/bin/env node
|
||||
/* Token contract guard — runs as part of `npm run check`.
|
||||
Bans hardcoded values in application CSS so every visual change
|
||||
goes through src/tokens.css. */
|
||||
Bans hardcoded colors/fonts so every visual change 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 { 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'])
|
||||
|
||||
// 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 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)) {
|
||||
const full = join(dir, entry)
|
||||
if (statSync(full).isDirectory()) {
|
||||
walk(full)
|
||||
} else if (full.endsWith('.css') && !EXEMPT.has(entry)) {
|
||||
checkFile(full, readFileSync(full, 'utf8'))
|
||||
yield* walk(full)
|
||||
} else {
|
||||
yield full
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkFile(file, css) {
|
||||
css.split('\n').forEach((line, i) => {
|
||||
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)`)
|
||||
}
|
||||
})
|
||||
function stripComments(text) {
|
||||
return text.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
}
|
||||
|
||||
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) {
|
||||
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)
|
||||
} 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
@@ -202,6 +202,28 @@
|
||||
grid-template-columns: 240px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
/* Collapsed via the sidebar heading's ⌘ button — a slim rail with the
|
||||
expand button stays visible. */
|
||||
.body-layout.sidebar-collapsed {
|
||||
grid-template-columns: 56px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar {
|
||||
padding: var(--space-4) 0;
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar-heading {
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.body-layout.sidebar-collapsed .sidebar-intro,
|
||||
.body-layout.sidebar-collapsed .sidebar nav,
|
||||
.body-layout.sidebar-collapsed .sidebar-bottom {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Sidebar ────────────────────────────────────────────────── */
|
||||
.sidebar {
|
||||
border-right: 1px solid var(--sidebar-border);
|
||||
@@ -766,19 +788,6 @@
|
||||
border: 1px solid var(--border) !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border: 2px solid color-mix(in srgb, var(--muted-foreground) 25%, transparent);
|
||||
border-top-color: var(--primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.showcase-foot {
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 0;
|
||||
@@ -1885,6 +1894,9 @@ footer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.search-palette-item {
|
||||
|
||||
+39
-11
@@ -16,6 +16,7 @@ import { Section } from './components/Section'
|
||||
import { RightRail } from './components/RightRail'
|
||||
import { SearchPalette, type SearchItem } from './components/SearchPalette'
|
||||
import { Icon } from './components/Icon'
|
||||
import { Avatar, Spinner, Switch, Tabs, TabsContent, TabsList, TabsTrigger } from './components/ui'
|
||||
import { buttonExamples, buttonTabs, colorTokens, navigationGroups, pageSections } from './data/library'
|
||||
|
||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
||||
@@ -27,6 +28,7 @@ export default function App() {
|
||||
const [visible, setVisible] = useState(false)
|
||||
const [menu, setMenu] = useState(false)
|
||||
const [pattern, setPattern] = useState<PatternId>('metrics')
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
@@ -39,15 +41,12 @@ export default function App() {
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [])
|
||||
|
||||
const handleNavigate = (item: { label: string; id?: string }) => {
|
||||
const handleNavigate = (item: { label: string; id?: string; patternId?: PatternId }) => {
|
||||
setActive(item.label)
|
||||
setMenu(false)
|
||||
|
||||
// Synchronize active pattern when clicking pattern items
|
||||
if (item.label === 'Metrics Dashboard') setPattern('metrics')
|
||||
else if (item.label === 'Settings Panel') setPattern('settings')
|
||||
else if (item.label === 'Service Table') setPattern('table')
|
||||
else if (item.label === 'Deployment Flow') setPattern('deploy')
|
||||
// Synchronize active pattern — routing keys on patternId, never on display labels.
|
||||
if (item.patternId) setPattern(item.patternId)
|
||||
|
||||
const targetId = item.id || item.label.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
const el = document.getElementById(targetId)
|
||||
@@ -73,11 +72,13 @@ export default function App() {
|
||||
onSelect={handleSearchSelect}
|
||||
/>
|
||||
|
||||
<div className={`body-layout ${menu ? 'menu-open' : ''}`}>
|
||||
<div className={`body-layout ${menu ? 'menu-open' : ''} ${sidebarCollapsed ? 'sidebar-collapsed' : ''}`}>
|
||||
<Sidebar
|
||||
active={active}
|
||||
groups={navigationGroups}
|
||||
onSelect={handleNavigate}
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggleCollapse={() => setSidebarCollapsed(prev => !prev)}
|
||||
/>
|
||||
|
||||
<main className="main-content">
|
||||
@@ -121,7 +122,7 @@ export default function App() {
|
||||
index="03 / COMPONENTS"
|
||||
title="Icons (Lucide)"
|
||||
description="Crisp, scalable SVG icons powered by Lucide. Tree-shakeable, stroke-matched, and token-aware."
|
||||
meta={<span className="api-pill">lucide-react · 1,400+ icons</span>}
|
||||
meta={<span className="api-pill">iconRegistry · 41 curated · lucide 1,400+</span>}
|
||||
>
|
||||
<IconMatrix />
|
||||
</Section>
|
||||
@@ -173,10 +174,37 @@ export default function App() {
|
||||
<b>Enable notifications</b>
|
||||
<small>Get updates about deployments and incidents.</small>
|
||||
</span>
|
||||
<button className="toggle on" aria-label="Toggle notifications">
|
||||
<span />
|
||||
</button>
|
||||
<Switch defaultChecked aria-label="Toggle notifications" />
|
||||
</label>
|
||||
<div className="toggle-field">
|
||||
<span>
|
||||
<b>Primitives in action</b>
|
||||
<small>Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives.</small>
|
||||
</span>
|
||||
<span className="row-md">
|
||||
<Avatar name="Mats Tornberg" size="sm" status="online" />
|
||||
<Avatar name="Grace Hopper" size="sm" />
|
||||
<Spinner size="sm" label="Loading session" />
|
||||
</span>
|
||||
</div>
|
||||
<div className="toggle-field">
|
||||
<span>
|
||||
<b>Tabs primitive</b>
|
||||
<small>Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard.</small>
|
||||
</span>
|
||||
<Tabs defaultValue="preview" className="stack-xs">
|
||||
<TabsList>
|
||||
<TabsTrigger value="preview">Preview</TabsTrigger>
|
||||
<TabsTrigger value="code">Code</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="preview">
|
||||
<small>Token-driven preview pane.</small>
|
||||
</TabsContent>
|
||||
<TabsContent value="code">
|
||||
<small>import {'{ Tabs }'} from components/ui</small>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Button } from './Button'
|
||||
import { Spinner } from './ui'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export type ButtonExample = {
|
||||
@@ -52,7 +53,7 @@ export function ButtonMatrix({ examples, tabs, filter = '', activeTab, onTabChan
|
||||
<Icon name="copy" size={15} />
|
||||
</Button>
|
||||
<Button variant={example.variant} className="loading">
|
||||
<span className="spinner" />
|
||||
<Spinner size="xs" />
|
||||
Loading
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTimeout } from '../utils/useTimeout'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
const INSTALL_CMD = 'pnpm add @northstar/ui @northstar/tokens'
|
||||
@@ -19,11 +20,12 @@ const CODE_LINES: Segment[] = [
|
||||
|
||||
export function CodeSample() {
|
||||
const [copied, setCopied] = useState(false)
|
||||
const { schedule } = useTimeout()
|
||||
|
||||
const copy = () => {
|
||||
navigator.clipboard?.writeText(INSTALL_CMD)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 1600)
|
||||
schedule(() => setCopied(false), 1600)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+15
-104
@@ -1,108 +1,10 @@
|
||||
import type { ComponentType, SVGProps } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Menu,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
SlidersHorizontal,
|
||||
Code2,
|
||||
Cpu,
|
||||
Database,
|
||||
User,
|
||||
Shield,
|
||||
Folder,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
type LucideIcon,
|
||||
type LucideProps,
|
||||
} from 'lucide-react'
|
||||
import { Layers, type LucideIcon, type LucideProps } from 'lucide-react'
|
||||
import { iconMap, type IconName } from './icon-registry'
|
||||
|
||||
export type { IconName, IconCategory, IconRegistryEntry } from './icon-registry'
|
||||
export type { LucideIcon, LucideProps }
|
||||
|
||||
const iconMap: Record<string, LucideIcon> = {
|
||||
// Northstar legacy shorthands
|
||||
search: Search,
|
||||
sun: Sun,
|
||||
moon: Moon,
|
||||
copy: Copy,
|
||||
check: Check,
|
||||
eye: Eye,
|
||||
arrow: ArrowRight,
|
||||
'arrow-right': ArrowRight,
|
||||
'arrow-left': ArrowLeft,
|
||||
menu: Menu,
|
||||
layers: Layers,
|
||||
sparkle: Sparkles,
|
||||
sparkles: Sparkles,
|
||||
terminal: Terminal,
|
||||
|
||||
// Common UI primitives
|
||||
close: X,
|
||||
x: X,
|
||||
plus: Plus,
|
||||
minus: Minus,
|
||||
trash: Trash2,
|
||||
'trash-2': Trash2,
|
||||
settings: Settings,
|
||||
filter: Filter,
|
||||
sliders: SlidersHorizontal,
|
||||
refresh: RefreshCw,
|
||||
download: Download,
|
||||
upload: Upload,
|
||||
external: ExternalLink,
|
||||
'external-link': ExternalLink,
|
||||
info: Info,
|
||||
warning: AlertTriangle,
|
||||
'alert-triangle': AlertTriangle,
|
||||
alert: AlertCircle,
|
||||
'alert-circle': AlertCircle,
|
||||
'check-circle': CheckCircle2,
|
||||
help: HelpCircle,
|
||||
'chevron-down': ChevronDown,
|
||||
'chevron-right': ChevronRight,
|
||||
'chevron-left': ChevronLeft,
|
||||
'chevron-up': ChevronUp,
|
||||
code: Code2,
|
||||
cpu: Cpu,
|
||||
database: Database,
|
||||
user: User,
|
||||
shield: Shield,
|
||||
folder: Folder,
|
||||
file: FileText,
|
||||
calendar: Calendar,
|
||||
clock: Clock,
|
||||
}
|
||||
|
||||
export type IconName = keyof typeof iconMap | string
|
||||
|
||||
export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
name?: IconName
|
||||
icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }>
|
||||
@@ -113,8 +15,9 @@ export type IconProps = Omit<SVGProps<SVGSVGElement>, 'ref'> & {
|
||||
|
||||
/**
|
||||
* Universal Icon component backed by lucide-react.
|
||||
* Accepts either a shorthand name (e.g. name="sparkle", name="check")
|
||||
* or a direct Lucide icon component (e.g. icon={Sparkles}).
|
||||
* Accepts either a registry name (e.g. name="sparkles") or a direct Lucide
|
||||
* icon component (e.g. icon={Sparkles}). Unknown names throw in dev — so the
|
||||
* dev error sink surfaces them — and fall back to <Layers /> in prod.
|
||||
*/
|
||||
export function Icon({
|
||||
name,
|
||||
@@ -124,7 +27,15 @@ export function Icon({
|
||||
className,
|
||||
...props
|
||||
}: IconProps) {
|
||||
const Component = IconComponent || (name ? iconMap[name.toLowerCase()] : undefined) || Layers
|
||||
const namedIcon = name ? iconMap[name] : undefined
|
||||
|
||||
if (!IconComponent && !namedIcon) {
|
||||
const message = `[Icon] Unknown icon name "${String(name)}". Add it to iconMap in src/components/icon-registry.ts or pass an explicit \`icon\` prop.`
|
||||
if (import.meta.env.DEV) throw new Error(message)
|
||||
console.error(message)
|
||||
}
|
||||
|
||||
const Component = IconComponent ?? namedIcon ?? Layers
|
||||
|
||||
return (
|
||||
<Component
|
||||
|
||||
@@ -1,109 +1,15 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Sun,
|
||||
Moon,
|
||||
Copy,
|
||||
Check,
|
||||
Eye,
|
||||
ArrowRight,
|
||||
ArrowLeft,
|
||||
Menu,
|
||||
Layers,
|
||||
Sparkles,
|
||||
Terminal,
|
||||
AlertTriangle,
|
||||
AlertCircle,
|
||||
Info,
|
||||
CheckCircle2,
|
||||
X,
|
||||
Plus,
|
||||
Minus,
|
||||
Trash2,
|
||||
ExternalLink,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
ChevronLeft,
|
||||
ChevronUp,
|
||||
Filter,
|
||||
SlidersHorizontal,
|
||||
Code2,
|
||||
Cpu,
|
||||
Database,
|
||||
User,
|
||||
Shield,
|
||||
Folder,
|
||||
FileText,
|
||||
Calendar,
|
||||
Clock,
|
||||
RefreshCw,
|
||||
Download,
|
||||
Upload,
|
||||
Settings,
|
||||
HelpCircle,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react'
|
||||
import { useTimeout } from '../utils/useTimeout'
|
||||
import { Icon } from './Icon'
|
||||
import { iconRegistry, type IconRegistryEntry } from './icon-registry'
|
||||
import { Kbd } from './ui/Kbd'
|
||||
import { Badge } from './ui/Badge'
|
||||
|
||||
type IconItem = {
|
||||
name: string
|
||||
pascalName: string
|
||||
category: 'Navigation' | 'Interface' | 'System' | 'Status'
|
||||
component: LucideIcon
|
||||
}
|
||||
type IconItem = IconRegistryEntry
|
||||
|
||||
const ICONS: IconItem[] = [
|
||||
// Navigation
|
||||
{ name: 'arrow-right', pascalName: 'ArrowRight', category: 'Navigation', component: ArrowRight },
|
||||
{ name: 'arrow-left', pascalName: 'ArrowLeft', category: 'Navigation', component: ArrowLeft },
|
||||
{ name: 'chevron-right', pascalName: 'ChevronRight', category: 'Navigation', component: ChevronRight },
|
||||
{ name: 'chevron-left', pascalName: 'ChevronLeft', category: 'Navigation', component: ChevronLeft },
|
||||
{ name: 'chevron-down', pascalName: 'ChevronDown', category: 'Navigation', component: ChevronDown },
|
||||
{ name: 'chevron-up', pascalName: 'ChevronUp', category: 'Navigation', component: ChevronUp },
|
||||
{ name: 'external-link', pascalName: 'ExternalLink', category: 'Navigation', component: ExternalLink },
|
||||
{ name: 'menu', pascalName: 'Menu', category: 'Navigation', component: Menu },
|
||||
|
||||
// System & AI
|
||||
{ name: 'sparkles', pascalName: 'Sparkles', category: 'System', component: Sparkles },
|
||||
{ name: 'terminal', pascalName: 'Terminal', category: 'System', component: Terminal },
|
||||
{ name: 'code', pascalName: 'Code2', category: 'System', component: Code2 },
|
||||
{ name: 'cpu', pascalName: 'Cpu', category: 'System', component: Cpu },
|
||||
{ name: 'layers', pascalName: 'Layers', category: 'System', component: Layers },
|
||||
{ name: 'sun', pascalName: 'Sun', category: 'System', component: Sun },
|
||||
{ name: 'moon', pascalName: 'Moon', category: 'System', component: Moon },
|
||||
{ name: 'database', pascalName: 'Database', category: 'System', component: Database },
|
||||
|
||||
// Interface & Actions
|
||||
{ name: 'search', pascalName: 'Search', category: 'Interface', component: Search },
|
||||
{ name: 'copy', pascalName: 'Copy', category: 'Interface', component: Copy },
|
||||
{ name: 'settings', pascalName: 'Settings', category: 'Interface', component: Settings },
|
||||
{ name: 'sliders', pascalName: 'SlidersHorizontal', category: 'Interface', component: SlidersHorizontal },
|
||||
{ name: 'filter', pascalName: 'Filter', category: 'Interface', component: Filter },
|
||||
{ name: 'plus', pascalName: 'Plus', category: 'Interface', component: Plus },
|
||||
{ name: 'minus', pascalName: 'Minus', category: 'Interface', component: Minus },
|
||||
{ name: 'trash', pascalName: 'Trash2', category: 'Interface', component: Trash2 },
|
||||
{ name: 'refresh', pascalName: 'RefreshCw', category: 'Interface', component: RefreshCw },
|
||||
{ name: 'download', pascalName: 'Download', category: 'Interface', component: Download },
|
||||
{ name: 'upload', pascalName: 'Upload', category: 'Interface', component: Upload },
|
||||
{ name: 'folder', pascalName: 'Folder', category: 'Interface', component: Folder },
|
||||
{ name: 'file', pascalName: 'FileText', category: 'Interface', component: FileText },
|
||||
{ name: 'user', pascalName: 'User', category: 'Interface', component: User },
|
||||
{ name: 'calendar', pascalName: 'Calendar', category: 'Interface', component: Calendar },
|
||||
{ name: 'clock', pascalName: 'Clock', category: 'Interface', component: Clock },
|
||||
|
||||
// Status & Feedback
|
||||
{ name: 'check', pascalName: 'Check', category: 'Status', component: Check },
|
||||
{ name: 'check-circle', pascalName: 'CheckCircle2', category: 'Status', component: CheckCircle2 },
|
||||
{ name: 'warning', pascalName: 'AlertTriangle', category: 'Status', component: AlertTriangle },
|
||||
{ name: 'alert', pascalName: 'AlertCircle', category: 'Status', component: AlertCircle },
|
||||
{ name: 'info', pascalName: 'Info', category: 'Status', component: Info },
|
||||
{ name: 'close', pascalName: 'X', category: 'Status', component: X },
|
||||
{ name: 'shield', pascalName: 'Shield', category: 'Status', component: Shield },
|
||||
{ name: 'help', pascalName: 'HelpCircle', category: 'Status', component: HelpCircle },
|
||||
{ name: 'eye', pascalName: 'Eye', category: 'Status', component: Eye },
|
||||
]
|
||||
/* The curated showcase set — components come straight from the shared
|
||||
registry in Icon.tsx, so there is no second icon list to drift. */
|
||||
const ICONS: IconItem[] = iconRegistry
|
||||
|
||||
const CATEGORIES = ['All', 'Navigation', 'Interface', 'System', 'Status'] as const
|
||||
type Category = (typeof CATEGORIES)[number]
|
||||
@@ -113,6 +19,7 @@ export function IconMatrix() {
|
||||
const [search, setSearch] = useState('')
|
||||
const [size, setSize] = useState<number>(20)
|
||||
const [copiedName, setCopiedName] = useState<string | null>(null)
|
||||
const { schedule } = useTimeout()
|
||||
|
||||
const filteredIcons = useMemo(() => {
|
||||
return ICONS.filter(icon => {
|
||||
@@ -128,7 +35,7 @@ export function IconMatrix() {
|
||||
const code = `<${icon.pascalName} size={${size}} />`
|
||||
navigator.clipboard?.writeText(code)
|
||||
setCopiedName(icon.name)
|
||||
setTimeout(() => setCopiedName(null), 2000)
|
||||
schedule(() => setCopiedName(null), 2000)
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -140,6 +47,7 @@ export function IconMatrix() {
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search icons (e.g. arrow, check, sparkles)..."
|
||||
aria-label="Search icons"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
className="icon-search-input"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
import { useState, useEffect, useMemo, useRef, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||
import { Icon, type IconName } from './Icon'
|
||||
import { Badge } from './ui/Badge'
|
||||
import { Kbd } from './ui/Kbd'
|
||||
import { trapTabKey, useOverlayLifecycle } from './ui/overlayBehavior'
|
||||
import type { PatternId } from './AIWorkflowSection'
|
||||
|
||||
export type SearchItem = {
|
||||
@@ -9,7 +10,7 @@ export type SearchItem = {
|
||||
title: string
|
||||
description: string
|
||||
category: 'Patterns' | 'Components' | 'Foundations' | 'Documentation'
|
||||
icon: string
|
||||
icon: IconName
|
||||
targetId: string
|
||||
patternId?: PatternId
|
||||
}
|
||||
@@ -59,7 +60,7 @@ const SEARCH_INDEX: SearchItem[] = [
|
||||
title: 'Buttons & Action States',
|
||||
description: 'Primary, secondary, destructive, ghost variants with resting, hover, active and focus states',
|
||||
category: 'Components',
|
||||
icon: 'arrow',
|
||||
icon: 'arrow-right',
|
||||
targetId: 'buttons',
|
||||
},
|
||||
{
|
||||
@@ -131,11 +132,20 @@ type SearchPaletteProps = {
|
||||
initialQuery?: string
|
||||
}
|
||||
|
||||
const LISTBOX_ID = 'search-palette-listbox'
|
||||
|
||||
/** Command-palette modal built on the same overlay behavior as Dialog/Sheet:
|
||||
focus trap, focus restore, scroll lock, Escape — plus combobox semantics
|
||||
(role=listbox + aria-activedescendant) for the results. */
|
||||
export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: SearchPaletteProps) {
|
||||
const [query, setQuery] = useState(initialQuery)
|
||||
const [selectedIndex, setSelectedIndex] = useState(0)
|
||||
const panelRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
/* Same lifecycle as DialogContent: scroll lock + focus capture/restore. */
|
||||
useOverlayLifecycle(isOpen, panelRef)
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
inputRef.current?.focus()
|
||||
@@ -153,7 +163,11 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
)
|
||||
}, [query])
|
||||
|
||||
// Keyboard navigation
|
||||
/* Keep the selection inside the result set when the query shrinks it. */
|
||||
const activeIndex = results.length === 0 ? -1 : Math.min(selectedIndex, results.length - 1)
|
||||
const activeItem = activeIndex >= 0 ? results[activeIndex] : undefined
|
||||
|
||||
// Keyboard navigation (combobox pattern: arrows move aria-activedescendant)
|
||||
useEffect(() => {
|
||||
if (!isOpen) return
|
||||
|
||||
@@ -163,32 +177,49 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
onClose()
|
||||
} else if (e.key === 'ArrowDown') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev + 1) % (results.length || 1))
|
||||
setSelectedIndex(prev => (results.length ? (prev + 1) % results.length : 0))
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault()
|
||||
setSelectedIndex(prev => (prev - 1 + results.length) % (results.length || 1))
|
||||
} else if (e.key === 'Enter' && results[selectedIndex]) {
|
||||
setSelectedIndex(prev => (results.length ? (prev - 1 + results.length) % results.length : 0))
|
||||
} else if (e.key === 'Enter' && activeItem) {
|
||||
e.preventDefault()
|
||||
onSelect(results[selectedIndex])
|
||||
onSelect(activeItem)
|
||||
onClose()
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [isOpen, results, selectedIndex, onClose, onSelect])
|
||||
}, [isOpen, results.length, activeItem, onClose, onSelect])
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
const handlePanelKeyDown = (e: ReactKeyboardEvent<HTMLDivElement>) => {
|
||||
trapTabKey(e, panelRef.current)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-palette-backdrop" onClick={onClose}>
|
||||
<div className="search-palette-modal" onClick={e => e.stopPropagation()}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Search"
|
||||
tabIndex={-1}
|
||||
className="search-palette-modal"
|
||||
onKeyDown={handlePanelKeyDown}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* Search Input Bar */}
|
||||
<div className="search-palette-head">
|
||||
<Icon name="search" size={18} className="search-palette-icon" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
role="combobox"
|
||||
aria-expanded={results.length > 0}
|
||||
aria-controls={LISTBOX_ID}
|
||||
aria-activedescendant={activeItem ? `search-option-${activeItem.id}` : undefined}
|
||||
aria-label="Search components, tokens, and docs"
|
||||
className="search-palette-input"
|
||||
placeholder="Type a command, component, or token..."
|
||||
value={query}
|
||||
@@ -198,7 +229,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="icon-button" onClick={() => setQuery('')} aria-label="Clear search">
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={() => {
|
||||
setQuery('')
|
||||
setSelectedIndex(0)
|
||||
}}
|
||||
aria-label="Clear search"
|
||||
>
|
||||
<Icon name="close" size={14} />
|
||||
</button>
|
||||
)}
|
||||
@@ -208,12 +247,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
{/* Results List */}
|
||||
<div className="search-palette-body">
|
||||
{results.length > 0 ? (
|
||||
<div className="search-palette-results">
|
||||
<ul id={LISTBOX_ID} role="listbox" aria-label="Search results" className="search-palette-results">
|
||||
{results.map((item, idx) => {
|
||||
const isSelected = idx === selectedIndex
|
||||
const isSelected = idx === activeIndex
|
||||
return (
|
||||
<div
|
||||
<li
|
||||
key={item.id}
|
||||
id={`search-option-${item.id}`}
|
||||
role="option"
|
||||
aria-selected={isSelected}
|
||||
className={`search-palette-item ${isSelected ? 'selected' : ''}`}
|
||||
onClick={() => {
|
||||
onSelect(item)
|
||||
@@ -236,14 +278,16 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }:
|
||||
<Kbd>↵</Kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</ul>
|
||||
) : (
|
||||
<div className="search-palette-empty">
|
||||
<Icon name="search" size={24} />
|
||||
<p>No results found for “{query}”</p>
|
||||
<p>
|
||||
No results found for “{query}”
|
||||
</p>
|
||||
<span>Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,23 +1,32 @@
|
||||
import { Icon } from './Icon'
|
||||
import type { PatternId } from './AIWorkflowSection'
|
||||
|
||||
export type NavigationItem = { label: string; count?: number; id?: string }
|
||||
export type NavigationItem = { label: string; count?: number; id?: string; patternId?: PatternId }
|
||||
export type NavigationGroup = { label: string; items: NavigationItem[] }
|
||||
|
||||
type SidebarProps = {
|
||||
active: string
|
||||
groups: NavigationGroup[]
|
||||
onSelect: (item: NavigationItem) => void
|
||||
collapsed?: boolean
|
||||
onToggleCollapse?: () => void
|
||||
}
|
||||
|
||||
export function Sidebar({ active, groups, onSelect }: SidebarProps) {
|
||||
export function Sidebar({ active, groups, onSelect, collapsed = false, onToggleCollapse }: SidebarProps) {
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
<div className="sidebar-heading">
|
||||
<div className="sidebar-library">
|
||||
<Icon name="layers" size={13} /> Library
|
||||
</div>
|
||||
<button className="sidebar-collapse" aria-label="Collapse sidebar">
|
||||
⌘
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar-collapse"
|
||||
onClick={onToggleCollapse}
|
||||
aria-pressed={collapsed}
|
||||
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||
>
|
||||
{collapsed ? '⌘+' : '⌘−'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
+54
-38
@@ -1,45 +1,56 @@
|
||||
import { useState } from 'react'
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { Icon } from './Icon'
|
||||
|
||||
export type Theme = 'dark' | 'light'
|
||||
|
||||
/**
|
||||
* Read the theme the pre-paint script in index.html already applied to
|
||||
* <html> — that keeps first paint flash-free and honors both the saved
|
||||
* preference and the OS `prefers-color-scheme` when nothing is saved.
|
||||
* This hook never touches the DOM during render/state-initialization.
|
||||
*/
|
||||
function getInitialTheme(): Theme {
|
||||
const attr = document.documentElement.getAttribute('data-theme')
|
||||
return attr === 'light' ? 'light' : 'dark'
|
||||
}
|
||||
|
||||
/** App theme is a data attribute + class on <html>; tokens.css defines both palettes. */
|
||||
function applyTheme(theme: Theme) {
|
||||
const root = document.documentElement
|
||||
root.dataset.theme = theme
|
||||
root.classList.toggle('dark', theme === 'dark')
|
||||
root.classList.toggle('light', theme === 'light')
|
||||
}
|
||||
|
||||
function useTheme() {
|
||||
const [theme, setThemeState] = useState<Theme>(getInitialTheme)
|
||||
|
||||
useEffect(() => {
|
||||
applyTheme(theme)
|
||||
}, [theme])
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
const next: Theme = theme === 'dark' ? 'light' : 'dark'
|
||||
setThemeState(next)
|
||||
persistTheme(next)
|
||||
}, [theme])
|
||||
|
||||
return { theme, toggle }
|
||||
}
|
||||
|
||||
function persistTheme(theme: Theme) {
|
||||
try {
|
||||
localStorage.setItem('ns-theme', theme)
|
||||
} catch {
|
||||
/* private mode — theme just won't persist */
|
||||
}
|
||||
}
|
||||
|
||||
type TopbarProps = {
|
||||
onOpenSearch: () => void
|
||||
onMenuToggle: () => void
|
||||
}
|
||||
|
||||
/** App theme is a data attribute on <html>; tokens.css defines both palettes. */
|
||||
function useTheme() {
|
||||
const [theme, setTheme] = useState<'dark' | 'light'>(() => {
|
||||
const saved = typeof window !== 'undefined' ? localStorage.getItem('ns-theme') : null
|
||||
const initial = saved === 'light' ? 'light' : 'dark'
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.dataset.theme = initial
|
||||
if (initial === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.classList.remove('light')
|
||||
} else {
|
||||
document.documentElement.classList.add('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
}
|
||||
return initial
|
||||
})
|
||||
|
||||
const toggle = () => {
|
||||
const next = theme === 'dark' ? 'light' : 'dark'
|
||||
setTheme(next)
|
||||
document.documentElement.dataset.theme = next
|
||||
if (next === 'dark') {
|
||||
document.documentElement.classList.add('dark')
|
||||
document.documentElement.classList.remove('light')
|
||||
} else {
|
||||
document.documentElement.classList.add('light')
|
||||
document.documentElement.classList.remove('dark')
|
||||
}
|
||||
localStorage.setItem('ns-theme', next)
|
||||
}
|
||||
return { theme, toggle }
|
||||
}
|
||||
|
||||
export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
const { theme, toggle } = useTheme()
|
||||
|
||||
@@ -67,13 +78,18 @@ export function Topbar({ onOpenSearch, onMenuToggle }: TopbarProps) {
|
||||
<nav className="top-links">
|
||||
<a href="#docs">Docs</a>
|
||||
<a href="#github">GitHub ↗</a>
|
||||
<button className="icon-button" onClick={toggle} aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} theme`}
|
||||
>
|
||||
<Icon name={theme === 'dark' ? 'sun' : 'moon'} size={17} />
|
||||
</button>
|
||||
<button className="avatar">MS</button>
|
||||
<button type="button" className="avatar">MS</button>
|
||||
</nav>
|
||||
|
||||
<button className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
|
||||
<button type="button" className="mobile-menu icon-button" onClick={onMenuToggle} aria-label="Toggle navigation">
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTimeout } from '../../utils/useTimeout'
|
||||
import {
|
||||
Dialog,
|
||||
DialogTrigger,
|
||||
@@ -22,13 +23,21 @@ export function DeploymentModal() {
|
||||
const [deploying, setDeploying] = useState(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [deployed, setDeployed] = useState(false)
|
||||
const { schedule: scheduleDeploy, clear: clearDeploy } = useTimeout()
|
||||
const { schedule: scheduleReset } = useTimeout()
|
||||
|
||||
/* Closing cancels in-flight rollout timers so they never fire on a hidden dialog.
|
||||
The post-close content reset uses its own timer so it survives this cleanup. */
|
||||
useEffect(() => {
|
||||
if (!open) clearDeploy()
|
||||
}, [open, clearDeploy])
|
||||
|
||||
const handleStartDeploy = () => {
|
||||
setDeploying(true)
|
||||
setProgress(15)
|
||||
setTimeout(() => setProgress(45), 500)
|
||||
setTimeout(() => setProgress(85), 1000)
|
||||
setTimeout(() => {
|
||||
scheduleDeploy(() => setProgress(45), 500)
|
||||
scheduleDeploy(() => setProgress(85), 1000)
|
||||
scheduleDeploy(() => {
|
||||
setProgress(100)
|
||||
setDeploying(false)
|
||||
setDeployed(true)
|
||||
@@ -37,7 +46,7 @@ export function DeploymentModal() {
|
||||
|
||||
const handleReset = () => {
|
||||
setOpen(false)
|
||||
setTimeout(() => {
|
||||
scheduleReset(() => {
|
||||
setDeploying(false)
|
||||
setProgress(0)
|
||||
setDeployed(false)
|
||||
@@ -46,7 +55,7 @@ export function DeploymentModal() {
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger>
|
||||
<DialogTrigger asChild>
|
||||
<button className="button primary">
|
||||
<Icon name="sparkles" size={15} /> Promote Release to Canary
|
||||
</button>
|
||||
@@ -109,7 +118,7 @@ export function DeploymentModal() {
|
||||
<DialogFooter>
|
||||
{!deployed ? (
|
||||
<>
|
||||
<DialogClose>
|
||||
<DialogClose asChild>
|
||||
<button className="button secondary" disabled={deploying}>
|
||||
Cancel
|
||||
</button>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react'
|
||||
import { useTimeout } from '../../utils/useTimeout'
|
||||
import { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from '../ui/Card'
|
||||
import { Switch } from '../ui/Switch'
|
||||
import { Badge } from '../ui/Badge'
|
||||
@@ -18,16 +19,17 @@ export function SettingsPanel() {
|
||||
const [autoRollback, setAutoRollback] = useState(false)
|
||||
|
||||
const apiKey = 'nsk_live_98a72ef01bc34d89a2'
|
||||
const { schedule } = useTimeout()
|
||||
|
||||
const copyKey = () => {
|
||||
navigator.clipboard?.writeText(apiKey)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
schedule(() => setCopied(false), 2000)
|
||||
}
|
||||
|
||||
const handleSave = () => {
|
||||
setSaved(true)
|
||||
setTimeout(() => setSaved(false), 2500)
|
||||
schedule(() => setSaved(false), 2500)
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -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,
|
||||
}))
|
||||
@@ -39,7 +39,7 @@ export function AlertTitle({ className, children, ...props }: HTMLAttributes<HTM
|
||||
|
||||
export function AlertDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<div
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
margin: 0,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,15 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
export type BadgeVariant = 'primary' | 'secondary' | 'destructive' | 'positive' | 'outline'
|
||||
|
||||
type BadgeProps = {
|
||||
children: ReactNode
|
||||
type BadgeProps = HTMLAttributes<HTMLSpanElement> & {
|
||||
variant?: BadgeVariant
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function Badge({ children, variant = 'primary', className }: BadgeProps) {
|
||||
export function Badge({ variant = 'primary', className, children, ...props }: BadgeProps) {
|
||||
return (
|
||||
<span className={cn('badge', `badge-${variant}`, className)}>
|
||||
<span className={cn('badge', `badge-${variant}`, className)} {...props}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
|
||||
+41
-106
@@ -1,18 +1,17 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
Overlay,
|
||||
OverlayClose,
|
||||
OverlayContent,
|
||||
OverlayDescription,
|
||||
OverlayFooter,
|
||||
OverlayHeader,
|
||||
OverlayTitle,
|
||||
OverlayTrigger,
|
||||
} from './Overlay'
|
||||
|
||||
type DialogContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
|
||||
const DialogContext = createContext<DialogContextType | null>(null)
|
||||
|
||||
function useDialog() {
|
||||
const ctx = useContext(DialogContext)
|
||||
if (!ctx) throw new Error('Dialog components must be used within a <Dialog>')
|
||||
return ctx
|
||||
}
|
||||
/* Dialog is a thin skin over the shared Overlay primitive — all behavior
|
||||
(focus trap, Escape, scroll lock, focus restore, aria wiring) lives there. */
|
||||
|
||||
export type DialogProps = {
|
||||
children: ReactNode
|
||||
@@ -21,124 +20,60 @@ export type DialogProps = {
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function Dialog({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: DialogProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
export function Dialog(props: DialogProps) {
|
||||
return <Overlay {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</DialogContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogTrigger({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useDialog()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function DialogTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
|
||||
return <OverlayTrigger {...props} />
|
||||
}
|
||||
|
||||
export function DialogContent({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { open, setOpen } = useDialog()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="dialog-backdrop" onClick={() => setOpen(false)}>
|
||||
<div className={cn('dialog-panel', className)} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="dialog-close-btn"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close dialog"
|
||||
<OverlayContent
|
||||
backdropClassName="dialog-backdrop"
|
||||
panelClassName="dialog-panel"
|
||||
closeButtonLabel="Close dialog"
|
||||
className={className}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function DialogHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-4)' }} {...props} />
|
||||
export function DialogHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-4)', ...style }
|
||||
return <OverlayHeader className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(className)}
|
||||
style={{
|
||||
export function DialogTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-xl)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.03em',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
...style,
|
||||
}
|
||||
return <OverlayTitle className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
export function DialogDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
...style,
|
||||
}
|
||||
return <OverlayDescription className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-between', className)}
|
||||
style={{
|
||||
marginTop: 'var(--space-6)',
|
||||
paddingTop: 'var(--space-4)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
gap: 'var(--space-3)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function DialogFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginTop: 'var(--space-6)', ...style }
|
||||
return <OverlayFooter className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function DialogClose({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useDialog()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function DialogClose(props: Parameters<typeof OverlayClose>[0]) {
|
||||
return <OverlayClose {...props} />
|
||||
}
|
||||
@@ -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} />
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { useId, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type ProgressProps = HTMLAttributes<HTMLDivElement> & {
|
||||
@@ -16,17 +16,28 @@ export function Progress({
|
||||
className,
|
||||
...props
|
||||
}: ProgressProps) {
|
||||
const percentage = Math.min(Math.max(Math.round((value / max) * 100), 0), 100)
|
||||
/* Guard NaN from max={0}: clamp against a sane fallback instead. */
|
||||
const safeMax = max > 0 ? max : 100
|
||||
const percentage = Math.min(Math.max(Math.round((value / safeMax) * 100), 0), 100)
|
||||
const labelId = useId()
|
||||
|
||||
return (
|
||||
<div className={cn('stack-xs', className)} {...props}>
|
||||
{(label || !indeterminate) && (
|
||||
<div className="flex-between" style={{ fontSize: 'var(--text-xs)', color: 'var(--muted-foreground)' }}>
|
||||
{label && <span>{label}</span>}
|
||||
{label && <span id={labelId}>{label}</span>}
|
||||
{!indeterminate && <span style={{ fontFamily: 'var(--font-mono)' }}>{percentage}%</span>}
|
||||
</div>
|
||||
)}
|
||||
<div className="progress-container">
|
||||
<div
|
||||
className="progress-container"
|
||||
role="progressbar"
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-valuenow={indeterminate ? undefined : percentage}
|
||||
aria-busy={indeterminate ? true : undefined}
|
||||
aria-labelledby={label ? labelId : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn('progress-fill', indeterminate && 'progress-indeterminate')}
|
||||
style={!indeterminate ? { width: `${percentage}%` } : undefined}
|
||||
|
||||
+43
-107
@@ -1,144 +1,80 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
import type { CSSProperties, HTMLAttributes, ReactNode } from 'react'
|
||||
import {
|
||||
Overlay,
|
||||
OverlayClose,
|
||||
OverlayContent,
|
||||
OverlayDescription,
|
||||
OverlayFooter,
|
||||
OverlayHeader,
|
||||
OverlayTitle,
|
||||
OverlayTrigger,
|
||||
} from './Overlay'
|
||||
|
||||
type SheetContextType = {
|
||||
open: boolean
|
||||
setOpen: (open: boolean) => void
|
||||
}
|
||||
/* Sheet is the slide-over skin of the shared Overlay primitive — identical
|
||||
focus trap / Escape / scroll-lock behavior as Dialog, only different
|
||||
type-scale defaults and panel classes. */
|
||||
|
||||
const SheetContext = createContext<SheetContextType | null>(null)
|
||||
|
||||
function useSheet() {
|
||||
const ctx = useContext(SheetContext)
|
||||
if (!ctx) throw new Error('Sheet components must be used within <Sheet>')
|
||||
return ctx
|
||||
}
|
||||
|
||||
type SheetProps = {
|
||||
export type SheetProps = {
|
||||
children: ReactNode
|
||||
open?: boolean
|
||||
defaultOpen?: boolean
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
export function Sheet({ children, open: controlledOpen, defaultOpen = false, onOpenChange }: SheetProps) {
|
||||
const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen)
|
||||
const isControlled = controlledOpen !== undefined
|
||||
const open = isControlled ? controlledOpen : uncontrolledOpen
|
||||
|
||||
const setOpen = (next: boolean) => {
|
||||
if (!isControlled) setUncontrolledOpen(next)
|
||||
onOpenChange?.(next)
|
||||
export function Sheet(props: SheetProps) {
|
||||
return <Overlay {...props} />
|
||||
}
|
||||
|
||||
return (
|
||||
<SheetContext.Provider value={{ open, setOpen }}>
|
||||
{children}
|
||||
</SheetContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function SheetTrigger({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useSheet()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(true)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function SheetTrigger(props: Parameters<typeof OverlayTrigger>[0]) {
|
||||
return <OverlayTrigger {...props} />
|
||||
}
|
||||
|
||||
export function SheetContent({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { open, setOpen } = useSheet()
|
||||
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setOpen(false)
|
||||
}
|
||||
if (open) {
|
||||
document.addEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = 'hidden'
|
||||
}
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown)
|
||||
document.body.style.overflow = ''
|
||||
}
|
||||
}, [open, setOpen])
|
||||
|
||||
if (!open) return null
|
||||
|
||||
return (
|
||||
<div className="sheet-backdrop" onClick={() => setOpen(false)}>
|
||||
<div className={cn('sheet-panel', className)} onClick={e => e.stopPropagation()}>
|
||||
<button
|
||||
className="dialog-close-btn"
|
||||
onClick={() => setOpen(false)}
|
||||
aria-label="Close sheet"
|
||||
<OverlayContent
|
||||
backdropClassName="sheet-backdrop"
|
||||
panelClassName="sheet-panel"
|
||||
closeButtonLabel="Close sheet"
|
||||
className={className}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</OverlayContent>
|
||||
)
|
||||
}
|
||||
|
||||
export function SheetHeader({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('stack-xs', className)} style={{ marginBottom: 'var(--space-6)' }} {...props} />
|
||||
export function SheetHeader({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginBottom: 'var(--space-6)', ...style }
|
||||
return <OverlayHeader className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetTitle({ className, children, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
return (
|
||||
<h2
|
||||
className={cn(className)}
|
||||
style={{
|
||||
export function SheetTitle({ className, style, ...props }: HTMLAttributes<HTMLHeadingElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-lg)',
|
||||
fontWeight: 700,
|
||||
color: 'var(--foreground)',
|
||||
letterSpacing: '-0.02em',
|
||||
}}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</h2>
|
||||
)
|
||||
...style,
|
||||
}
|
||||
return <OverlayTitle className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetDescription({ className, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
return (
|
||||
<p
|
||||
className={cn(className)}
|
||||
style={{
|
||||
export function SheetDescription({ className, style, ...props }: HTMLAttributes<HTMLParagraphElement>) {
|
||||
const defaultStyle: CSSProperties = {
|
||||
margin: 0,
|
||||
fontSize: 'var(--text-sm)',
|
||||
color: 'var(--muted-foreground)',
|
||||
lineHeight: 1.5,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
...style,
|
||||
}
|
||||
return <OverlayDescription className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetFooter({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return (
|
||||
<div
|
||||
className={cn('flex-between', className)}
|
||||
style={{
|
||||
marginTop: 'auto',
|
||||
paddingTop: 'var(--space-4)',
|
||||
borderTop: '1px solid var(--border)',
|
||||
gap: 'var(--space-3)',
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
export function SheetFooter({ className, style, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const defaultStyle: CSSProperties = { marginTop: 'auto', ...style }
|
||||
return <OverlayFooter className={className} style={defaultStyle} {...props} />
|
||||
}
|
||||
|
||||
export function SheetClose({ children, className }: { children: ReactNode; className?: string }) {
|
||||
const { setOpen } = useSheet()
|
||||
return (
|
||||
<span className={className} onClick={() => setOpen(false)}>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
export function SheetClose(props: Parameters<typeof OverlayClose>[0]) {
|
||||
return <OverlayClose {...props} />
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -7,21 +7,36 @@ type StatCardProps = {
|
||||
title: string
|
||||
value: string | number
|
||||
change?: string
|
||||
/** Sentiment — drives the badge color. Up = good news, down = bad news. */
|
||||
trend?: 'up' | 'down' | 'neutral'
|
||||
/**
|
||||
* Arrow direction — which way the number moved. Defaults to the sign of
|
||||
* `change`, so "trend=up" (good) with change="-4.2ms" renders a green
|
||||
* "↓ -4.2ms" instead of the nonsensical "↑ -4.2ms".
|
||||
*/
|
||||
direction?: 'up' | 'down' | 'neutral'
|
||||
caption?: string
|
||||
icon?: ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/** "↓ good latency" needs an arrow that follows the sign of the change. */
|
||||
function inferDirection(change?: string): 'up' | 'down' | 'neutral' {
|
||||
if (!change || !/[0-9]/.test(change)) return 'neutral'
|
||||
return change.trim().startsWith('-') ? 'down' : 'up'
|
||||
}
|
||||
|
||||
export function StatCard({
|
||||
title,
|
||||
value,
|
||||
change,
|
||||
trend = 'up',
|
||||
trend = 'neutral',
|
||||
direction,
|
||||
caption,
|
||||
icon,
|
||||
className,
|
||||
}: StatCardProps) {
|
||||
const arrowDirection = direction ?? inferDirection(change)
|
||||
return (
|
||||
<Card className={cn('hover-lift', className)}>
|
||||
<div className="flex-between" style={{ marginBottom: 'var(--space-3)' }}>
|
||||
@@ -65,7 +80,7 @@ export function StatCard({
|
||||
<div className="flex-between" style={{ marginTop: 'var(--space-2)' }}>
|
||||
{change && (
|
||||
<Badge variant={trend === 'up' ? 'positive' : trend === 'down' ? 'destructive' : 'secondary'}>
|
||||
{trend === 'up' ? '↑' : trend === 'down' ? '↓' : '→'} {change}
|
||||
{arrowDirection === 'up' ? '↑' : arrowDirection === 'down' ? '↓' : '→'} {change}
|
||||
</Badge>
|
||||
)}
|
||||
{caption && (
|
||||
|
||||
@@ -1,9 +1,18 @@
|
||||
import { createContext, useContext, useState, type ReactNode, type HTMLAttributes } from 'react'
|
||||
import {
|
||||
createContext,
|
||||
useContext,
|
||||
useId,
|
||||
useState,
|
||||
type HTMLAttributes,
|
||||
type KeyboardEvent,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type TabsContextType = {
|
||||
value: string
|
||||
setValue: (value: string) => void
|
||||
baseId: string
|
||||
}
|
||||
|
||||
const TabsContext = createContext<TabsContextType | null>(null)
|
||||
@@ -14,6 +23,11 @@ function useTabs() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
/** Stable, SSR-safe pairing between a trigger and its panel. */
|
||||
function partId(baseId: string, kind: 'trigger' | 'content', value: string) {
|
||||
return `${baseId}-${kind}-${value.replace(/[^A-Za-z0-9_-]+/g, '-')}`
|
||||
}
|
||||
|
||||
type TabsProps = {
|
||||
defaultValue?: string
|
||||
value?: string
|
||||
@@ -24,6 +38,7 @@ type TabsProps = {
|
||||
|
||||
export function Tabs({ defaultValue = '', value: controlledValue, onValueChange, children, className }: TabsProps) {
|
||||
const [uncontrolledValue, setUncontrolledValue] = useState(defaultValue)
|
||||
const baseId = useId()
|
||||
const isControlled = controlledValue !== undefined
|
||||
const value = isControlled ? controlledValue : uncontrolledValue
|
||||
|
||||
@@ -33,14 +48,66 @@ export function Tabs({ defaultValue = '', value: controlledValue, onValueChange,
|
||||
}
|
||||
|
||||
return (
|
||||
<TabsContext.Provider value={{ value, setValue }}>
|
||||
<TabsContext.Provider value={{ value, setValue, baseId }}>
|
||||
<div className={cn('ui-tabs-root', className)}>{children}</div>
|
||||
</TabsContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function TabsList({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('ui-tabs-list', className)} role="tablist" {...props} />
|
||||
const NAV_KEYS = new Set(['ArrowRight', 'ArrowLeft', 'Home', 'End'])
|
||||
|
||||
export function TabsList({ className, onKeyDown, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
const { setValue } = useTabs()
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
onKeyDown?.(event)
|
||||
if (event.defaultPrevented || !NAV_KEYS.has(event.key)) return
|
||||
|
||||
const tabs = Array.from(
|
||||
event.currentTarget.querySelectorAll<HTMLButtonElement>('[role="tab"]:not([disabled])'),
|
||||
)
|
||||
if (tabs.length === 0) return
|
||||
|
||||
const focused = tabs.indexOf(document.activeElement as HTMLButtonElement)
|
||||
const selected = tabs.findIndex((tab) => tab.getAttribute('aria-selected') === 'true')
|
||||
const current = focused >= 0 ? focused : selected
|
||||
if (current < 0) return
|
||||
|
||||
let next: number
|
||||
switch (event.key) {
|
||||
case 'ArrowRight':
|
||||
next = (current + 1) % tabs.length
|
||||
break
|
||||
case 'ArrowLeft':
|
||||
next = (current - 1 + tabs.length) % tabs.length
|
||||
break
|
||||
case 'Home':
|
||||
next = 0
|
||||
break
|
||||
default:
|
||||
next = tabs.length - 1
|
||||
break
|
||||
}
|
||||
|
||||
const nextTab = tabs[next]
|
||||
if (nextTab === undefined) return
|
||||
const nextValue = nextTab.dataset.value
|
||||
if (nextValue === undefined) return
|
||||
|
||||
event.preventDefault()
|
||||
nextTab.focus()
|
||||
setValue(nextValue)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn('ui-tabs-list', className)}
|
||||
role="tablist"
|
||||
aria-orientation="horizontal"
|
||||
onKeyDown={handleKeyDown}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
@@ -48,14 +115,18 @@ type TabsTriggerProps = HTMLAttributes<HTMLButtonElement> & {
|
||||
}
|
||||
|
||||
export function TabsTrigger({ value, className, children, ...props }: TabsTriggerProps) {
|
||||
const { value: selectedValue, setValue } = useTabs()
|
||||
const { value: selectedValue, setValue, baseId } = useTabs()
|
||||
const isActive = selectedValue === value
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
id={partId(baseId, 'trigger', value)}
|
||||
role="tab"
|
||||
aria-selected={isActive}
|
||||
aria-controls={partId(baseId, 'content', value)}
|
||||
tabIndex={isActive ? 0 : -1}
|
||||
data-value={value}
|
||||
className={cn('ui-tabs-trigger', isActive && 'active', className)}
|
||||
onClick={() => setValue(value)}
|
||||
{...props}
|
||||
@@ -70,11 +141,19 @@ type TabsContentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
}
|
||||
|
||||
export function TabsContent({ value, className, children, ...props }: TabsContentProps) {
|
||||
const { value: selectedValue } = useTabs()
|
||||
if (selectedValue !== value) return null
|
||||
const { value: selectedValue, baseId } = useTabs()
|
||||
const isActive = selectedValue === value
|
||||
|
||||
return (
|
||||
<div className={cn('ui-tabs-content', className)} role="tabpanel" {...props}>
|
||||
<div
|
||||
id={partId(baseId, 'content', value)}
|
||||
role="tabpanel"
|
||||
aria-labelledby={partId(baseId, 'trigger', value)}
|
||||
tabIndex={0}
|
||||
hidden={!isActive}
|
||||
className={cn('ui-tabs-content', className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import {
|
||||
Fragment,
|
||||
cloneElement,
|
||||
isValidElement,
|
||||
useId,
|
||||
useState,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
} from 'react'
|
||||
import { cn } from '../../utils/cn'
|
||||
|
||||
type TooltipProps = {
|
||||
@@ -7,11 +15,66 @@ type TooltipProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
type ChildProps = {
|
||||
onClick?: unknown
|
||||
tabIndex?: number
|
||||
href?: string
|
||||
'aria-describedby'?: string
|
||||
}
|
||||
|
||||
/** Intrinsic elements that already participate in the tab order. */
|
||||
const NATURALLY_FOCUSABLE = new Set(['button', 'input', 'select', 'textarea', 'summary'])
|
||||
|
||||
function childIsFocusable(child: ReactElement): boolean {
|
||||
const props = child.props as ChildProps
|
||||
if (props.tabIndex !== undefined) return props.tabIndex >= 0
|
||||
if (props.onClick !== undefined) return true
|
||||
if (typeof child.type === 'string') {
|
||||
if (child.type === 'a') return props.href !== undefined
|
||||
return NATURALLY_FOCUSABLE.has(child.type)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
export function Tooltip({ content, children, className }: TooltipProps) {
|
||||
const id = useId()
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// Plain text / fragments cannot carry ARIA props, so wrap them in a span first.
|
||||
const isPlainText = typeof children === 'string' || typeof children === 'number'
|
||||
const child = (
|
||||
!isPlainText && isValidElement(children) && children.type !== Fragment
|
||||
? children
|
||||
: <span>{children}</span>
|
||||
) as ReactElement<ChildProps>
|
||||
|
||||
const existingDescribedBy = child.props['aria-describedby']
|
||||
const describedBy = existingDescribedBy ? `${existingDescribedBy} ${id}` : id
|
||||
const clonedChild = cloneElement(child, { 'aria-describedby': describedBy })
|
||||
|
||||
// Only make the wrapper focusable when the child itself cannot receive focus.
|
||||
const wrapperNeedsFocus = !childIsFocusable(child)
|
||||
|
||||
return (
|
||||
<span className={cn('tooltip-wrapper', className)} tabIndex={0}>
|
||||
{children}
|
||||
<span className="tooltip-box" role="tooltip">
|
||||
<span
|
||||
className={cn('tooltip-wrapper', className)}
|
||||
tabIndex={wrapperNeedsFocus ? 0 : undefined}
|
||||
onMouseEnter={() => setOpen(true)}
|
||||
onMouseLeave={() => setOpen(false)}
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape') setOpen(false)
|
||||
}}
|
||||
>
|
||||
{clonedChild}
|
||||
<span
|
||||
id={id}
|
||||
className="tooltip-box"
|
||||
role="tooltip"
|
||||
aria-hidden={!open}
|
||||
data-open={open ? 'true' : 'false'}
|
||||
>
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './Badge'
|
||||
export * from './Card'
|
||||
export * from './StatCard'
|
||||
export * from './Kbd'
|
||||
export * from './Overlay'
|
||||
export * from './Dialog'
|
||||
export * from './Alert'
|
||||
export * from './EmptyState'
|
||||
@@ -12,4 +13,7 @@ export * from './Skeleton'
|
||||
export * from './Table'
|
||||
export * from './Sheet'
|
||||
export * from './Tooltip'
|
||||
export * from './Avatar'
|
||||
export * from './Spinner'
|
||||
export * from '../Icon'
|
||||
export * from '../Button'
|
||||
@@ -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])
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
+6
-6
@@ -1,22 +1,22 @@
|
||||
import type { NavigationGroup } from '../components/Sidebar'
|
||||
import type { ButtonExample } from '../components/ButtonMatrix'
|
||||
import type { Token } from '../components/TokenGrid'
|
||||
import type { NavigationGroup } from '../components/Sidebar'
|
||||
|
||||
export const navigationGroups: NavigationGroup[] = [
|
||||
{
|
||||
label: 'Application Patterns',
|
||||
items: [
|
||||
{ label: 'Metrics Dashboard', id: 'application-patterns' },
|
||||
{ label: 'Settings Panel', id: 'application-patterns' },
|
||||
{ label: 'Service Table', id: 'application-patterns' },
|
||||
{ label: 'Deployment Flow', id: 'application-patterns' },
|
||||
{ label: 'Metrics Dashboard', id: 'application-patterns', patternId: 'metrics' },
|
||||
{ label: 'Settings Panel', id: 'application-patterns', patternId: 'settings' },
|
||||
{ label: 'Service Table', id: 'application-patterns', patternId: 'table' },
|
||||
{ label: 'Deployment Flow', id: 'application-patterns', patternId: 'deploy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'Components',
|
||||
items: [
|
||||
{ label: 'Buttons', count: 12, id: 'buttons' },
|
||||
{ label: 'Icons', count: 42, id: 'icons' },
|
||||
{ label: 'Icons', count: 41, id: 'icons' },
|
||||
{ label: 'Form controls', id: 'form-controls' },
|
||||
{ label: 'Composable code', id: 'composable-by-default' },
|
||||
],
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Mono:wght@400;500&family=Manrope:wght@400;500;600;700;800&display=swap');
|
||||
@import './tokens.css';
|
||||
@import './utils.css';
|
||||
:root { font-family:var(--font-sans); color:var(--text); background:var(--bg); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
|
||||
:root { font-family:var(--font-sans); color:var(--foreground); background:var(--background); font-synthesis:none; text-rendering:optimizeLegibility; -webkit-font-smoothing:antialiased; }
|
||||
* { box-sizing:border-box; } html { scroll-behavior:smooth; } body { margin:0; min-width:320px; } button,input { font:inherit; } button { cursor:pointer; }
|
||||
@@ -2,9 +2,17 @@ import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import './index.css'
|
||||
import App from './App.tsx'
|
||||
import { ErrorBoundary } from './dev/ErrorBoundary.tsx'
|
||||
import { installDevErrorSink } from './dev/errorSink.ts'
|
||||
|
||||
/* Surface runtime errors in #dev-errors — failures render into the DOM so
|
||||
agents without console access (and humans) see messages, not white screens. */
|
||||
installDevErrorSink()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<App />
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -96,33 +96,6 @@
|
||||
--sidebar-ring: lab(42% 0 0);
|
||||
--dot-color: lab(7.78201% -.0000149012 0 / .05);
|
||||
|
||||
/* ── Legacy Northstar mappings for light theme ── */
|
||||
--bg: var(--background);
|
||||
--bg-deep: var(--sidebar);
|
||||
--panel: var(--card);
|
||||
--panel-2: var(--secondary);
|
||||
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
|
||||
--panel-hover: color-mix(in srgb, var(--card) 70%, var(--border));
|
||||
--panel-active: color-mix(in srgb, var(--secondary) 85%, var(--border));
|
||||
|
||||
--line: var(--border);
|
||||
--line-soft: var(--sidebar-border);
|
||||
--line-hover: var(--ring);
|
||||
--line-focus: var(--ring);
|
||||
|
||||
--text: var(--foreground);
|
||||
--text-bright: var(--foreground);
|
||||
--text-soft: var(--muted-foreground);
|
||||
--text-code: var(--foreground);
|
||||
--subtle: color-mix(in srgb, var(--muted-foreground) 75%, transparent);
|
||||
|
||||
--accent-brand: var(--primary);
|
||||
--accent-contrast: var(--primary-foreground);
|
||||
--positive: var(--chart-2);
|
||||
--warning: var(--chart-3);
|
||||
--critical: var(--destructive);
|
||||
--pink: var(--chart-5);
|
||||
--blue: var(--chart-4);
|
||||
}
|
||||
|
||||
/* ── Dark Palette (Exact lab() Specification) ────────────────── */
|
||||
@@ -165,33 +138,6 @@
|
||||
--sidebar-ring: lab(89.9321% -47.9163 -15.7303);
|
||||
--dot-color: lab(89.9321% -47.9163 -15.7303 / .045);
|
||||
|
||||
/* ── Legacy Northstar mappings for dark theme ── */
|
||||
--bg: var(--background);
|
||||
--bg-deep: var(--sidebar);
|
||||
--panel: var(--card);
|
||||
--panel-2: var(--secondary);
|
||||
--panel-3: color-mix(in srgb, var(--card) 85%, var(--border));
|
||||
--panel-hover: color-mix(in srgb, var(--card) 60%, var(--border));
|
||||
--panel-active: color-mix(in srgb, var(--secondary) 75%, var(--border));
|
||||
|
||||
--line: var(--border);
|
||||
--line-soft: var(--sidebar-border);
|
||||
--line-hover: color-mix(in srgb, var(--ring) 50%, var(--border));
|
||||
--line-focus: var(--ring);
|
||||
|
||||
--text: var(--foreground);
|
||||
--text-bright: var(--foreground);
|
||||
--text-soft: var(--muted-foreground);
|
||||
--text-code: var(--foreground);
|
||||
--subtle: color-mix(in srgb, var(--muted-foreground) 65%, transparent);
|
||||
|
||||
--accent-brand: var(--primary);
|
||||
--accent-contrast: var(--primary-foreground);
|
||||
--positive: var(--chart-2);
|
||||
--warning: var(--chart-3);
|
||||
--critical: var(--destructive);
|
||||
--pink: var(--chart-5);
|
||||
--blue: var(--chart-4);
|
||||
}
|
||||
|
||||
html {
|
||||
|
||||
+7
-2
@@ -516,8 +516,7 @@
|
||||
transition: opacity 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
|
||||
.tooltip-wrapper:hover .tooltip-box,
|
||||
.tooltip-wrapper:focus-within .tooltip-box {
|
||||
.tooltip-box[data-open='true'] {
|
||||
opacity: 1;
|
||||
transform: translateX(-50%) translateY(-8px);
|
||||
}
|
||||
@@ -543,3 +542,9 @@
|
||||
.skeleton-rect {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
/* ── Shared Focus Ring ──────────────────────────────────────── */
|
||||
:where(.ui-tabs-trigger, .switch-root, .dialog-close-btn):focus-visible {
|
||||
outline: var(--focus-ring);
|
||||
outline-offset: var(--focus-offset);
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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/)
|
||||
})
|
||||
})
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,8 @@
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/// <reference types="vitest/config" />
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
@@ -9,4 +10,10 @@ export default defineConfig({
|
||||
port: 5173,
|
||||
allowedHosts: ['.preview.k6n.net'],
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./tests/setup.ts'],
|
||||
include: ['tests/**/*.test.{ts,tsx,mjs}', 'src/**/*.test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
Reference in new issue
Block a user