From f6e81afe5ea41e9071e38d0d8f5dacb760bd0ae2 Mon Sep 17 00:00:00 2001 From: developer Date: Sun, 13 Sep 2026 00:22:16 +0000 Subject: [PATCH] Harden agent guardrails and fix audited bugs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ; 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 --- .github/workflows/ci.yml | 19 + AGENTS.md | 61 +- README.md | 85 +- index.html | 25 +- package-lock.json | 1125 ++++++++++++++++++- package.json | 10 +- scripts/check-tokens.mjs | 255 ++++- src/App.css | 38 +- src/App.tsx | 50 +- src/components/ButtonMatrix.tsx | 3 +- src/components/CodeSample.tsx | 4 +- src/components/Icon.tsx | 119 +- src/components/IconMatrix.tsx | 110 +- src/components/SearchPalette.tsx | 82 +- src/components/Sidebar.tsx | 17 +- src/components/Topbar.tsx | 92 +- src/components/examples/DeploymentModal.tsx | 23 +- src/components/examples/SettingsPanel.tsx | 6 +- src/components/icon-registry.ts | 179 +++ src/components/ui/Alert.tsx | 2 +- src/components/ui/Avatar.tsx | 112 ++ src/components/ui/Badge.tsx | 10 +- src/components/ui/Dialog.tsx | 173 +-- src/components/ui/Overlay.tsx | 294 +++++ src/components/ui/Progress.tsx | 19 +- src/components/ui/Sheet.tsx | 176 +-- src/components/ui/Spinner.tsx | 61 + src/components/ui/StatCard.tsx | 19 +- src/components/ui/Tabs.tsx | 95 +- src/components/ui/Tooltip.tsx | 71 +- src/components/ui/index.ts | 4 + src/components/ui/overlayBehavior.ts | 53 + src/components/ui/spinner.css | 5 + src/data/library.ts | 12 +- src/dev/ErrorBoundary.tsx | 59 + src/dev/errorSink.ts | 55 + src/index.css | 2 +- src/main.tsx | 10 +- src/tokens.css | 54 - src/utils.css | 9 +- src/utils/useTimeout.ts | 39 + tests/check-tokens.test.mjs | 95 ++ tests/icon-registry.test.tsx | 33 + tests/overlay.test.tsx | 170 +++ tests/setup.ts | 1 + tests/useTimeout.test.tsx | 50 + tsconfig.app.json | 2 + vite.config.ts | 7 + 48 files changed, 3299 insertions(+), 696 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 src/components/icon-registry.ts create mode 100644 src/components/ui/Avatar.tsx create mode 100644 src/components/ui/Overlay.tsx create mode 100644 src/components/ui/Spinner.tsx create mode 100644 src/components/ui/overlayBehavior.ts create mode 100644 src/components/ui/spinner.css create mode 100644 src/dev/ErrorBoundary.tsx create mode 100644 src/dev/errorSink.ts create mode 100644 src/utils/useTimeout.ts create mode 100644 tests/check-tokens.test.mjs create mode 100644 tests/icon-registry.test.tsx create mode 100644 tests/overlay.test.tsx create mode 100644 tests/setup.ts create mode 100644 tests/useTimeout.test.tsx diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..594519e --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 diff --git a/AGENTS.md b/AGENTS.md index f417e48..c734192 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 (`` 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 ← 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 `, `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. diff --git a/README.md b/README.md index d6af7e3..6b8176f 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/index.html b/index.html index 9321b7f..2210280 100644 --- a/index.html +++ b/index.html @@ -4,10 +4,33 @@ - design-system + + Northstar Design System +
+
diff --git a/package-lock.json b/package-lock.json index 1bce8d5..d6eb898 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,13 +13,293 @@ "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" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.2.tgz", + "integrity": "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.13.tgz", + "integrity": "sha512-i9ZylF5QNhmNfPA9l0vHAWK4kPrbIp6g9lKgaiIFsIBz2F/WNB7OLrzlNNcCOm+h42bkaSD2v1PG+IBPHhc3ZA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" } }, "node_modules/@oxc-project/types": { @@ -659,6 +939,124 @@ "dev": true, "license": "MIT" }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-7.0.1.tgz", + "integrity": "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=22", + "npm": ">=6", + "yarn": ">=1" + }, + "peerDependencies": { + "@testing-library/dom": ">=10 <11", + "vitest": ">= 0.32" + }, + "peerDependenciesMeta": { + "vitest": { + "optional": true + } + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.3", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.3.tgz", + "integrity": "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.13.4", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.4.tgz", @@ -719,6 +1117,130 @@ } } }, + "node_modules/@vitest/mocker": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.0.tgz", + "integrity": "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "0.3.31", + "@vitest/spy": "5.0.0", + "estree-walker": "^3.0.3", + "magic-string": "^1.2.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/spy": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.0.tgz", + "integrity": "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/bidi-js": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.1.0.tgz", + "integrity": "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -726,6 +1248,52 @@ "dev": true, "license": "MIT" }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -736,6 +1304,54 @@ "node": ">=8" } }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", + "dev": true, + "license": "MIT" + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -769,6 +1385,85 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", @@ -1042,6 +1737,16 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/lucide-react": { "version": "1.45.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.45.0.tgz", @@ -1051,6 +1756,44 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.3.1.tgz", + "integrity": "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.6.0" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/nanoid": { "version": "3.3.19", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", @@ -1070,6 +1813,20 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/obug": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz", + "integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/oxlint": { "version": "1.82.0", "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.82.0.tgz", @@ -1119,6 +1876,19 @@ } } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1168,6 +1938,32 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/react": { "version": "19.3.0", "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz", @@ -1189,6 +1985,38 @@ "react": "^19.3.0" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rolldown": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.8.tgz", @@ -1223,12 +2051,32 @@ "@rolldown/binding-win32-x64-msvc": "1.2.8" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/scheduler": { "version": "0.28.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz", "integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==", "license": "MIT" }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1239,6 +2087,60 @@ "node": ">=0.10.0" } }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz", + "integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz", + "integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/tinyexec": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz", + "integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1256,6 +2158,52 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tldts": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.12.tgz", + "integrity": "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.12" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.12", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.12.tgz", + "integrity": "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/typescript": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", @@ -1270,6 +2218,16 @@ "node": ">=14.17" } }, + "node_modules/undici": { + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", @@ -1354,6 +2312,171 @@ "optional": true } } + }, + "node_modules/vitest": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.0.tgz", + "integrity": "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/chai": "^5.2.2", + "@vitest/mocker": "5.0.0", + "chai": "^6.2.2", + "es-module-lexer": "^2.3.2", + "expect-type": "^1.4.0", + "magic-string": "^1.2.3", + "obug": "^2.1.4", + "picomatch": "^4.0.7", + "std-env": "^4.2.0", + "tinybench": "6.1.4", + "tinyexec": "1.3.0", + "tinyglobby": "^0.2.17", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^22.12.0 || ^24.0.0 || >=26.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "5.0.0", + "@vitest/browser-preview": "5.0.0", + "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", + "@vitest/coverage-istanbul": "5.0.0", + "@vitest/coverage-v8": "5.0.0", + "@vitest/ui": "5.0.0", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.1.tgz", + "integrity": "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index fd64d16..f4dfbe8 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/scripts/check-tokens.mjs b/scripts/check-tokens.mjs index 62b7366..123df49 100644 --- a/scripts/check-tokens.mjs +++ b/scripts/check-tokens.mjs @@ -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`, + ) } diff --git a/src/App.css b/src/App.css index 99d6aa4..61cdc8f 100644 --- a/src/App.css +++ b/src/App.css @@ -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 { diff --git a/src/App.tsx b/src/App.tsx index 9a6088c..6043a5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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('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} /> -
+
setSidebarCollapsed(prev => !prev)} />
@@ -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={lucide-react · 1,400+ icons} + meta={iconRegistry · 41 curated · lucide 1,400+} > @@ -173,10 +174,37 @@ export default function App() { Enable notifications Get updates about deployments and incidents. - + +
+ + Primitives in action + Spinner, Avatar and Switch — token-driven, theme-aware components/ui primitives. + + + + + + +
+
+ + Tabs primitive + Roving tabindex, arrow-key navigation, aria-selected wiring — try the keyboard. + + + + Preview + Code + + + Token-driven preview pane. + + + import {'{ Tabs }'} from components/ui + + +
diff --git a/src/components/ButtonMatrix.tsx b/src/components/ButtonMatrix.tsx index b35eb2e..3ca5ed5 100644 --- a/src/components/ButtonMatrix.tsx +++ b/src/components/ButtonMatrix.tsx @@ -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
diff --git a/src/components/CodeSample.tsx b/src/components/CodeSample.tsx index ea4d038..cb3f64d 100644 --- a/src/components/CodeSample.tsx +++ b/src/components/CodeSample.tsx @@ -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 ( diff --git a/src/components/Icon.tsx b/src/components/Icon.tsx index 760d2e2..c418444 100644 --- a/src/components/Icon.tsx +++ b/src/components/Icon.tsx @@ -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 = { - // 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, 'ref'> & { name?: IconName icon?: LucideIcon | ComponentType<{ size?: number | string; strokeWidth?: number; className?: string }> @@ -113,8 +15,9 @@ export type IconProps = Omit, '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 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 ( (20) const [copiedName, setCopiedName] = useState(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() { setSearch(e.target.value)} className="icon-search-input" diff --git a/src/components/SearchPalette.tsx b/src/components/SearchPalette.tsx index b90ea3c..a8bca51 100644 --- a/src/components/SearchPalette.tsx +++ b/src/components/SearchPalette.tsx @@ -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(null) const inputRef = useRef(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) => { + trapTabKey(e, panelRef.current) + } return (
-
e.stopPropagation()}> +
e.stopPropagation()} + > {/* Search Input Bar */}
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 && ( - )} @@ -208,12 +247,15 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: {/* Results List */}
{results.length > 0 ? ( -
+
    {results.map((item, idx) => { - const isSelected = idx === selectedIndex + const isSelected = idx === activeIndex return ( -
    { onSelect(item) @@ -236,14 +278,16 @@ export function SearchPalette({ isOpen, onClose, onSelect, initialQuery = '' }: ↵
    )} -
+ ) })} -
+ ) : (
-

No results found for “{query}”

+

+ No results found for “{query}” +

Try searching for “Buttons”, “Metrics”, “Tokens”, or “Icons”.
)} diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 1869776..81356fa 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -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 (