From 35a5db78239055012495e042ba28d67bb52e723f Mon Sep 17 00:00:00 2001 From: Kranti Ghatpande <119097691+krantighatpande@users.noreply.github.com> Date: Thu, 22 Oct 2026 13:02:00 +0530 Subject: [PATCH 1/2] docs: Add design tokens reference and integration guides (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Phase 1: Documentation + CSS Verification Adds comprehensive documentation for WaysNX UI Kit design tokens and integration patterns. ### Added - `docs/theming/TOKENS.md` - Complete design token reference (44 tokens, source-verified) - `docs/integrations/EXISTING_APPS.md` - Integration strategies with technically accurate CSS guidance ### Changed - `README.md` - Updated Design System section with accurate token count ### Verified - ✅ All 44 token values source-verified against `packages/ui-core/src/styles/tokens.css` - ✅ Token counts accurate (20 colors, 5 spacing, 8 typography, 4 radius, 5 shadows, 2 component) - ✅ CSS visual inspection in Storybook (all colors correct, no inconsistencies) - ✅ Storybook builds successfully - ✅ P0 regression tests pass (no regressions introduced) - ✅ Token inheritance verified in Button.css and Sidebar.css source ### Technical Accuracy - CSS cascade layer documentation uses correct terminology (precedence, not specificity) - Font-size table shows actual `var(--wx-accessibility-font-scale, 1)` formulas - Integration guidance reflects actual build setup (tsup bundling) - Verification claims limited to sampled components ### Scope - Documentation only (no code changes) - Phase 1 of incremental UI Kit improvements ### Review Notes This PR is approved for PR review. Merging and publishing will follow normal final checks. --- README.md | 32 ++ docs/integrations/EXISTING_APPS.md | 496 ++++++++++++++++++++++++++++ docs/theming/TOKENS.md | 506 +++++++++++++++++++++++++++++ 3 files changed, 1034 insertions(+) create mode 100644 docs/integrations/EXISTING_APPS.md create mode 100644 docs/theming/TOKENS.md diff --git a/README.md b/README.md index 1199e35..cab2f60 100644 --- a/README.md +++ b/README.md @@ -290,6 +290,38 @@ WaysNX UI Kit uses shared design tokens and CSS variables to provide consistent: - Interaction states - Themes +All tokens are prefixed with `--wx-` and can be customized by overriding CSS variables. + +### Design Resources + +- **[Design Tokens Reference](docs/theming/TOKENS.md)** — Complete catalog of all 44 CSS custom properties with usage examples +- **[Integrating into Existing Apps](docs/integrations/EXISTING_APPS.md)** — Best practices for adopting WaysNX UI Kit in applications with existing design systems + +### Quick Token Reference + +Common tokens you'll use frequently: + +| Category | Token | Value | Usage | +|----------|-------|-------|-------| +| Brand | `--wx-color-primary` | `#f19924` | Primary actions, links | +| Spacing | `--wx-spacing-md` | `16px` | Default padding/margins | +| Text | `--wx-color-text` | `#1e293b` | Body text | +| Surface | `--wx-color-surface` | `#ffffff` | Card backgrounds | +| Radius | `--wx-radius-md` | `8px` | Default rounding | + +See the [complete token reference](docs/theming/TOKENS.md) for all 44 tokens. + +### Customization Example + +Override tokens to match your brand: + +```css +:root { + --wx-color-primary: #007bff; /* Your brand blue */ + --wx-spacing-md: 20px; /* Larger spacing */ +} +``` + Applications should prefer the public UI Kit tokens and components rather than creating parallel implementations of the same design patterns. --- diff --git a/docs/integrations/EXISTING_APPS.md b/docs/integrations/EXISTING_APPS.md new file mode 100644 index 0000000..97492ee --- /dev/null +++ b/docs/integrations/EXISTING_APPS.md @@ -0,0 +1,496 @@ +# Integrating WaysNX UI Kit into Existing Applications + +This guide covers best practices for adopting WaysNX UI Kit components in applications with existing design systems or UI libraries. + +--- + +## Overview + +When integrating WaysNX UI Kit into an existing application, conflicts may arise from: + +- **CSS variable collisions** — existing and WaysNX token names overlap +- **Global style resets** — conflicting normalize/reset stylesheets +- **Component replacement** — swapping existing UI with WaysNX components mid-project +- **Theming conflicts** — multiple design systems active simultaneously + +This document provides strategies to minimize disruption and maintain both design systems during transition. + +--- + +## CSS Isolation Strategies + +### 1. Namespace CSS Variables (Recommended) + +Scope WaysNX tokens to avoid collisions with existing variables: + +```css +/* Your existing app tokens */ +:root { + --color-primary: #007bff; + --spacing-md: 16px; +} + +/* WaysNX tokens scoped under .waysnx-scope */ +.waysnx-scope { + --wx-color-primary: #f19924; + --wx-spacing-md: 1rem; +} +``` + +Wrap WaysNX components in the scoped container: + +```tsx +import { Sidebar, Menu } from '@waysnx/ui-navigation'; + +function App() { + return ( +
+ + + +
+ ); +} +``` + +**Tradeoff:** Requires wrapping every WaysNX component subtree. + +### 2. CSS Layer Isolation (Modern Browsers) + +Use CSS `@layer` to control cascade order. **Note:** Because WaysNX packages use tsup for bundling (not a build tool with native `@layer` support), you must import the CSS files normally, then wrap your overrides in application layers: + +```css +/* main.css - Import WaysNX styles globally */ +@import '@waysnx/ui-core/dist/index.css'; +@import '@waysnx/ui-navigation/dist/index.css'; + +/* Define your application layers for cascade control */ +@layer app { + /* Layered styles - see limitation below */ + .button { + /* Attempts to override WaysNX button */ + } +} +``` + +**Limitation:** The imported WaysNX CSS is not wrapped in a cascade layer, so normal unlayered declarations take precedence over normal declarations inside `@layer app`, regardless of selector specificity. To override them, place your overrides in unlayered CSS, or use `!important` only when necessary. + +**Alternative (unlayered overrides):** + +```css +/* Import WaysNX styles */ +@import '@waysnx/ui-core/dist/index.css'; + +/* Place your overrides in unlayered CSS (not in @layer) */ +.my-app { + /* Override tokens */ + --wx-color-primary: #007bff; +} + +.my-app .wx-button--primary { + /* Override component classes directly */ + font-weight: 600; +} +``` + +**Browser support:** Chrome 99+, Firefox 97+, Safari 15.4+ + +### 3. Shadow DOM Encapsulation (Advanced) + +**Status:** Illustrative example. Requires bundler configuration and has not been tested against WaysNX package output. + +For maximum isolation, render WaysNX components in Shadow DOM: + +```tsx +import { createRoot } from 'react-dom/client'; +import { Sidebar } from '@waysnx/ui-navigation'; + +// Import CSS as a raw string (requires Vite raw loader or similar) +import navigationStyles from '@waysnx/ui-navigation/dist/index.css?raw'; + +function mountIsolatedSidebar(container: HTMLElement) { + const shadow = container.attachShadow({ mode: 'open' }); + const root = document.createElement('div'); + shadow.appendChild(root); + + // Inject WaysNX styles into shadow root + const style = document.createElement('style'); + style.textContent = navigationStyles; + shadow.appendChild(style); + + createRoot(root).render({/* ... */}); +} +``` + +**Requirements:** +- Configure your bundler to import CSS as raw text: + - **Vite:** Use `?raw` suffix (as shown above) + - **Webpack:** Use `raw-loader` + - **Other bundlers:** Consult bundler documentation for CSS-as-string imports +- Verify that WaysNX component styles are self-contained and do not depend on global selectors + +**Tradeoff:** Complex setup, event propagation quirks, no SSR support, requires bundler configuration. + +**Validation needed:** This approach has not been verified against the actual WaysNX package build output. Test thoroughly in your environment before production use. + +--- + +## Component Migration Patterns + +### Incremental Adoption + +Replace components one page/route at a time: + +```tsx +// OLD: Existing design system +import { OldButton } from '@yourlib/ui'; + +// NEW: WaysNX component +import { Button } from '@waysnx/ui-core'; + +function CheckoutPage() { + return ( + <> + {/* Keep old button for now */} + Back + + {/* Adopt new button */} + + + ); +} +``` + +**Strategy:** Start with low-risk pages (admin panels, internal tools) before public-facing routes. + +### Adapter Components + +Create compatibility wrappers to match your existing API: + +```tsx +// your-ui-adapters/Button.tsx +import { Button as WaysNXButton } from '@waysnx/ui-core'; + +export function Button({ + type = 'default', // Your old API + ...props +}) { + // Map old props to WaysNX props + const variant = type === 'default' ? 'secondary' : type; + + return ; +} +``` + +Replace imports gradually: + +```diff +- import { Button } from '@yourlib/ui'; ++ import { Button } from '@/ui-adapters'; +``` + +**Benefit:** Minimize code changes during migration. + +### + + Parallel Implementation + +Run both systems side-by-side with clear boundaries: + +```tsx +// App.tsx +import '@yourlib/ui/styles.css'; // Legacy styles +import '@waysnx/ui-kit/dist/styles.css'; // WaysNX styles + +function App() { + return ( +
+ {/* Legacy UI */} +
+ +
+ + {/* WaysNX UI */} + + +
+ {children} +
+
+ ); +} +``` + +--- + +## Theming Coexistence + +### Token Bridge + +Map WaysNX tokens to your existing system: + +```css +:root { + /* Your tokens */ + --app-primary: #007bff; + --app-spacing: 16px; + + /* Bridge WaysNX to your tokens */ + --wx-color-primary: var(--app-primary); + --wx-spacing-md: var(--app-spacing); +} +``` + +**Benefit:** WaysNX components adopt your brand automatically. + +### Two-Theme Setup + +Explicit scoping for each design system: + +```css +/* Scope legacy tokens */ +.legacy-header, .legacy-main { + --color-primary: #007bff; +} + +/* Scope WaysNX tokens */ +.waysnx-scope { + --wx-color-primary: #f19924; +} +``` + +--- + +## Common Integration Pitfalls + +### ❌ Global CSS Reset Conflicts + +**Problem:** WaysNX and your app both apply normalize/reset stylesheets, causing unpredictable rendering. + +**Solution:** Import only one global reset. If both are needed, audit for conflicts and merge manually. + +```css +/* Pick one: */ +@import 'your-reset.css'; /* OR */ +@import '@waysnx/ui-core/dist/reset.css'; +``` + +### ❌ Navigation Replacement Breaks Layout + +**Problem:** Replacing your existing `
` with `` changes layout structure, breaking page flow. + +**Solution:** Keep your header initially; add Sidebar alongside it. Refactor layout in a dedicated sprint. + +```tsx +// Phase 1: Add sidebar, keep header +
+ +
+ {/* New */} +
{children}
+
+
+ +// Phase 2: Remove header after layout refactor +
+ {/* Header content moved here */} +
{children}
+
+``` + +### ❌ TypeScript Errors from Duplicate Dependencies + +**Problem:** Your app and WaysNX both depend on `react` 19, but npm/yarn installs two copies, causing type mismatches. + +**Solution:** Use peer dependencies and ensure single React installation: + +```bash +# Check for duplicate React +npm ls react + +# Deduplicate +npm dedupe +# or +pnpm dedupe +``` + +### ❌ Dark Mode Fighting + +**Problem:** Your app and WaysNX both implement dark mode via `prefers-color-scheme`, but with different token values. + +**Solution:** Disable one dark mode implementation. Either: + +1. Use WaysNX dark mode exclusively (override your tokens with WaysNX values in dark mode) +2. Use your dark mode exclusively (override `--wx-*` tokens with your dark values) + +```css +/* Option 1: Use WaysNX dark mode */ +@media (prefers-color-scheme: dark) { + :root { + --app-bg: var(--wx-color-surface); /* Inherit from WaysNX */ + } +} + +/* Option 2: Override WaysNX tokens */ +@media (prefers-color-scheme: dark) { + :root { + --wx-color-surface: var(--app-bg); /* Force WaysNX to use your tokens */ + } +} +``` + +### ❌ Z-Index Conflicts + +**Problem:** Modals, dropdowns, and tooltips from both systems compete for stacking order. + +**Solution:** Establish a z-index scale and map both systems to it: + +```css +:root { + /* Your z-index scale */ + --z-dropdown: 1000; + --z-modal: 2000; + --z-tooltip: 3000; + + /* Map WaysNX components */ + --wx-z-dropdown: var(--z-dropdown); + --wx-z-modal: var(--z-modal); + --wx-z-tooltip: var(--z-tooltip); +} +``` + +--- + +## Testing During Migration + +### Visual Regression Testing + +Capture before/after screenshots to detect unintended style changes: + +```bash +# Baseline: before WaysNX +npm run test:visual:baseline + +# Install WaysNX +pnpm add @waysnx/ui-kit + +# Compare +npm run test:visual:compare +``` + +Tools: Percy, Chromatic, BackstopJS, or Playwright screenshots. + +### Isolated Component Testing + +Test WaysNX components in isolation before integrating: + +```tsx +// storybook/WaysNXButton.stories.tsx +import { Button } from '@waysnx/ui-core'; + +export default { + title: 'Migration/WaysNX/Button', + component: Button, +}; + +export const Primary = () => ; +``` + +Validate: +- Matches design mockups +- Works with your theme tokens +- Keyboard navigation +- Screen reader labels + +### Integration Smoke Tests + +After adding WaysNX to a route, verify: + +1. **Layout integrity** — page structure unchanged +2. **No console errors** — check browser DevTools +3. **Click paths work** — critical user flows still function +4. **Responsive behavior** — mobile/tablet viewports render correctly + +--- + +## Performance Considerations + +### Bundle Size Impact + +WaysNX UI Kit is modular. Import only what you need: + +```tsx +// ❌ Imports entire aggregate (large bundle) +import { Button, Sidebar, Menu } from '@waysnx/ui-kit'; + +// ✅ Import specific libraries (smaller bundle) +import { Button } from '@waysnx/ui-core'; +import { Sidebar, Menu } from '@waysnx/ui-navigation'; +``` + +**Tip:** Use Webpack Bundle Analyzer or Vite's `rollup-plugin-visualizer` to audit bundle size before/after. + +### CSS Loading Strategy + +Avoid loading all WaysNX CSS upfront. Use route-based code splitting: + +```tsx +// Lazy-load WaysNX CSS when user navigates to admin panel +const AdminPanel = lazy(async () => { + await import('@waysnx/ui-navigation/dist/styles.css'); + return import('./AdminPanel'); +}); +``` + +--- + +## Rollback Strategy + +Plan for rollback if integration causes critical issues: + +1. **Feature flag** — gate WaysNX components behind a flag: + ```tsx + {featureFlags.useWaysNX ? : } + ``` + +2. **Git branch strategy** — keep WaysNX work in a feature branch until validated in staging. + +3. **Canary deployment** — roll out to 5% of users first, monitor errors, expand gradually. + +--- + +## When to Avoid WaysNX UI Kit + +**Do not integrate** if: + +- Your app ships in 2 weeks and design is locked +- You have zero tolerance for visual regressions +- Your team lacks capacity for testing and iteration +- Your existing design system is mature, well-tested, and meets all needs + +**Do integrate** if: + +- You're starting a greenfield project +- Your current UI library is unmaintained +- You need components your system lacks (e.g., `Sidebar`, `Breadcrumb`, `Chart`) +- You're consolidating multiple apps under a shared design system + +--- + +## Getting Help + +- **Documentation:** https://uikit.waysnx.tech +- **Examples:** See `examples/waysnx-admin-demo/` in the repository +- **Issues:** Open issues on the WaysNX UI Kit GitHub repository with: + - Your integration approach (CSS layers, namespacing, etc.) + - Minimal reproduction of the conflict + - Screenshots or video of unexpected behavior + +--- + +**Last Updated:** October 2026 +**Applies to:** WaysNX UI Kit v1.0.0+ diff --git a/docs/theming/TOKENS.md b/docs/theming/TOKENS.md new file mode 100644 index 0000000..5614e62 --- /dev/null +++ b/docs/theming/TOKENS.md @@ -0,0 +1,506 @@ +# Design Tokens Reference + +WaysNX UI Kit uses CSS custom properties (CSS variables) as design tokens to provide consistent theming across all components. + +All tokens are prefixed with `--wx-` to avoid naming collisions with existing application styles. + +--- + +## Color Tokens (20 total) + +### Brand Colors (4 tokens) + +Primary color used for buttons, links, and interactive elements: + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-color-primary` | `#f19924` | Primary brand color | +| `--wx-color-primary-hover` | `#e08916` | Hover state for primary elements | +| `--wx-color-primary-contrast` | `#ffffff` | Text on primary backgrounds | +| `--wx-color-primary-light` | `#fff5e6` | Subtle primary backgrounds | + +**Example:** +```css +.my-button { + background-color: var(--wx-color-primary); + color: var(--wx-color-primary-contrast); +} + +.my-button:hover { + background-color: var(--wx-color-primary-hover); +} +``` + +### Text Colors (3 tokens) + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-color-text` | `#1e293b` | Primary body text | +| `--wx-color-text-muted` | `#64748b` | Secondary text, labels | +| `--wx-color-text-light` | `#94a3b8` | Tertiary text, placeholders | + +**Example:** +```css +.my-label { + color: var(--wx-color-text-muted); +} + +.my-placeholder { + color: var(--wx-color-text-light); +} +``` + +### Surface Colors (3 tokens) + +Background colors for cards, panels, and surfaces: + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-color-surface` | `#ffffff` | Card backgrounds, panels | +| `--wx-color-surface-alt` | `#f8fafc` | Alternate surface (zebra stripes, disabled inputs) | +| `--wx-color-surface-hover` | `#f1f5f9` | Hover state for interactive surfaces | + +**Example:** +```css +.my-card { + background-color: var(--wx-color-surface); + border: 1px solid var(--wx-color-border); +} + +.my-card:hover { + background-color: var(--wx-color-surface-hover); +} +``` + +### Border Colors (1 token) + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-color-border` | `#e2e8f0` | Default border color | + +### Status Colors (9 tokens) + +Colors for feedback states (error, success, warning, info): + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-color-error` | `#ef4444` | Error states, destructive actions | +| `--wx-color-error-hover` | `#b91c1c` | Error hover state | +| `--wx-color-error-light` | `#fef2f2` | Error background (alerts, badges) | +| `--wx-color-success` | `#22c55e` | Success states, confirmations | +| `--wx-color-success-light` | `#f0fdf4` | Success background | +| `--wx-color-warning` | `#f59e0b` | Warning states | +| `--wx-color-info` | `#3b82f6` | Informational states | +| `--wx-color-destructive` | `#dc2626` | Alias for error (used in buttons) | +| `--wx-color-destructive-hover` | `#b91c1c` | Destructive hover state | + +**Example:** +```css +.alert--error { + background-color: var(--wx-color-error-light); + color: var(--wx-color-error); + border-left: 4px solid var(--wx-color-error); +} + +.alert--success { + background-color: var(--wx-color-success-light); + color: var(--wx-color-success); +} +``` + +--- + +## Spacing Tokens (5 total) + +Consistent spacing scale for padding, margins, and gaps: + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-spacing-xs` | `4px` | Tight spacing (icon padding, inline gaps) | +| `--wx-spacing-sm` | `8px` | Small spacing (button padding, compact layouts) | +| `--wx-spacing-md` | `16px` | Medium spacing (default padding, content gaps) | +| `--wx-spacing-lg` | `24px` | Large spacing (section padding) | +| `--wx-spacing-xl` | `32px` | Extra-large spacing (page margins) | + +**Example:** +```css +.my-container { + padding: var(--wx-spacing-md); + gap: var(--wx-spacing-sm); +} + +.my-section { + margin-bottom: var(--wx-spacing-lg); +} +``` + +--- + +## Typography Tokens (8 total) + +### Font Family (1 token) + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-font-family` | `inherit` | Inherits from application base font | + +**Override example:** +```css +:root { + --wx-font-family: 'Inter', system-ui, sans-serif; +} +``` + +### Font Sizes (7 tokens) + +All font sizes support accessibility font scaling: + +| Token | Base Size | Actual Definition | Usage | +|-------|-----------|-------------------|-------| +| `--wx-font-size-xs` | `12px` | `calc(12px * var(--wx-accessibility-font-scale, 1))` | Small labels, captions | +| `--wx-font-size-sm` | `14px` | `calc(14px * var(--wx-accessibility-font-scale, 1))` | Secondary text, form labels | +| `--wx-font-size-md` | `15px` | `calc(15px * var(--wx-accessibility-font-scale, 1))` | Body text (default) | +| `--wx-font-size-lg` | `18px` | `calc(18px * var(--wx-accessibility-font-scale, 1))` | Subheadings | +| `--wx-font-size-xl` | `20px` | `calc(20px * var(--wx-accessibility-font-scale, 1))` | Headings (H4) | +| `--wx-font-size-2xl` | `24px` | `calc(24px * var(--wx-accessibility-font-scale, 1))` | Headings (H3) | +| `--wx-font-size-3xl` | `32px` | `calc(32px * var(--wx-accessibility-font-scale, 1))` | Headings (H1, H2) | + +**Note:** `--wx-accessibility-font-scale` is a **multiplier variable** (not a declared token). It is referenced in font-size token definitions via `calc()` but is not provided a default value in `tokens.css`. + +**Source code:** Each font-size token uses this pattern: +```css +--wx-font-size-md: calc(15px * var(--wx-accessibility-font-scale, 1)); +``` + +This means: +- **Default behavior:** If you don't define `--wx-accessibility-font-scale`, the fallback value `1` is used (no scaling) +- **Scaling enabled:** Define `--wx-accessibility-font-scale` at `:root` or on a parent element to scale all WaysNX font sizes proportionally + +**Accessibility scaling:** +```css +/* User prefers larger text */ +:root { + --wx-accessibility-font-scale: 1.25; /* All font sizes × 1.25 */ +} +``` + +**Result:** `--wx-font-size-md` becomes `calc(15px * 1.25)` = `18.75px` + +**Important:** This is a **multiplication factor only**. WaysNX does not automatically detect user font preferences or apply scaling. Your application must implement the detection and set the variable accordingly (e.g., via media queries or JavaScript). + +**Example:** +```css +.my-heading { + font-size: var(--wx-font-size-2xl); +} + +.my-body-text { + font-size: var(--wx-font-size-md); +} +``` + +--- + +## Border Radius Tokens (4 total) + +Consistent rounding for corners: + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-radius-sm` | `4px` | Buttons, badges, small elements | +| `--wx-radius-md` | `8px` | Cards, inputs, default rounding | +| `--wx-radius-lg` | `12px` | Panels, modals | +| `--wx-radius-xl` | `16px` | Large surfaces | + +**Example:** +```css +.my-input { + border-radius: var(--wx-radius-sm); +} + +.my-card { + border-radius: var(--wx-radius-md); +} +``` + +--- + +## Shadow Tokens (5 total) + +Elevation shadows for depth: + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift (buttons, inputs) | +| `--wx-shadow-md` | `0 4px 6px -1px rgba(0,0,0,0.07)` | Cards, dropdowns | +| `--wx-shadow-lg` | `0 10px 15px -3px rgba(0,0,0,0.08)` | Modals, drawers | +| `--wx-shadow-xl` | `0 20px 25px -5px rgba(0,0,0,0.08)` | Maximum elevation (overlays) | +| `--wx-shadow-focus` | `0 0 0 3px rgba(241, 153, 36, 0.1)` | Focus ring | + +**Example:** +```css +.my-modal { + box-shadow: var(--wx-shadow-lg); +} + +.my-button:focus-visible { + box-shadow: var(--wx-shadow-focus); +} +``` + +--- + +## Component-Specific Tokens (2 total) + +### Input (1 token) + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-input-height` | `40px` | Default input height | + +### Overlay (1 token) + +| Token | Value | Usage | +|-------|-------|-------| +| `--wx-overlay-bg` | `rgba(0, 0, 0, 0.4)` | Modal/drawer backdrop | + +**Usage:** +```css +.my-backdrop { + background-color: var(--wx-overlay-bg); +} +``` + +--- + +## Navigation-Specific Variables + +The `@waysnx/ui-navigation` package uses unprefixed CSS variables for component theming (these are **not** core WaysNX tokens): + +### Menu Component Variables + +| Variable | Default | Usage | +|----------|---------|-------| +| `--nav-menu-bg` | `var(--wx-color-surface)` | Menu background | +| `--nav-menu-text` | `var(--wx-color-text)` | Menu text color | +| `--nav-menu-item-height` | `2.5rem` | Menu item height | +| `--nav-menu-item-padding` | `0.5rem 1rem` | Menu item padding | +| `--nav-menu-gap` | `0.5rem` | Gap between menu items (horizontal) | +| `--nav-menu-submenu-indent` | `1.5rem` | Submenu indentation | + +**Example:** +```css +/* Override Menu styling */ +.compact-menu { + --nav-menu-item-height: 2rem; + --nav-menu-item-padding: 0.25rem 0.75rem; +} +``` + +**Note:** These `--nav-menu-*` variables are component-scoped customization hooks, not core design tokens. The Sidebar component uses dynamic inline styles for width rather than design tokens. + +--- + +## Application-Level Token Examples + +The WaysNX UI Kit does **not** provide z-index tokens (`--wx-z-*`). Z-index layering is application-specific and should be managed by your application's CSS architecture. + +**Example application-level z-index system:** +```css +/* your-app/styles/tokens.css */ +:root { + --app-z-dropdown: 1000; + --app-z-modal: 2000; + --app-z-toast: 3000; +} +``` + +Similarly, some layout concerns (viewport sizing, application-specific spacing multipliers) are best handled at the application level rather than through the design system. + +--- + +## Customizing Tokens + +### Global Override + +Override tokens at the `:root` level to rebrand the entire UI Kit: + +```css +/* your-theme.css */ +:root { + --wx-color-primary: #007bff; /* Change brand color to blue */ + --wx-spacing-md: 20px; /* Increase default spacing */ + --wx-radius-md: 12px; /* More rounded corners */ +} +``` + +Import your theme **after** WaysNX styles: + +```tsx +import '@waysnx/ui-kit/dist/styles.css'; // Base tokens +import './your-theme.css'; // Override tokens +``` + +### Scoped Override + +Override tokens for specific sections of your app: + +```css +/* Admin panel uses different brand color */ +.admin-panel { + --wx-color-primary: #8b5cf6; /* Purple */ +} +``` + +```tsx +
+ {/* Purple button */} +
+``` + +**Token Inheritance:** CSS custom properties inherit down the DOM tree. **Verified in sampled components:** Button and Sidebar components use `var(--wx-*)` with fallback values, which respect token overrides from parent elements: + +```tsx +
+ + {/* Also uses green primary color */} +
+``` + +**How it works:** WaysNX components define styles like `background: var(--wx-color-primary, #f19924)`. When a parent element sets `--wx-color-primary: #10b981`, CSS inheritance makes that value available to all descendant elements, so the component uses `#10b981` instead of the fallback `#f19924`. + +**Verified in source:** +- `packages/ui-core/src/components/Button/Button.css` uses `var(--wx-color-primary, #f19924)` +- `packages/ui-navigation/src/components/Sidebar/Sidebar.css` uses `var(--wx-color-surface, #ffffff)`, `var(--wx-color-text, #1e293b)`, etc. + +The sampled components (Button, Sidebar) follow the `var(--wx-*, fallback)` pattern. This pattern is expected to be consistent across the UI Kit, ensuring token inheritance works for components that follow this convention. + +### Component-Level Override + +Override tokens for individual components: + +```css +.my-custom-button { + --wx-color-primary: #10b981; /* Green button */ +} +``` + +```tsx + +``` + +--- + +## Dark Mode Support + +WaysNX UI Kit supports dark mode via `prefers-color-scheme`. To enable: + +```css +/* Override tokens in dark mode */ +@media (prefers-color-scheme: dark) { + :root { + --wx-color-surface: #0f172a; + --wx-color-surface-alt: #1e293b; + --wx-color-text: #e2e8f0; + --wx-color-text-muted: #94a3b8; + --wx-color-border: #334155; + } +} +``` + +Some components (e.g., `Sidebar`) support explicit dark mode via `colorScheme` prop: + +```tsx + {/* Force dark mode */} + {children} + +``` + +--- + +## Token Naming Convention + +All WaysNX tokens follow this pattern: + +``` +--wx--[-] +``` + +**Examples:** +- `--wx-color-primary` → category: `color`, property: `primary` +- `--wx-spacing-md` → category: `spacing`, property: `md` +- `--wx-color-error-light` → category: `color`, property: `error`, variant: `light` + +**Why `--wx-` prefix?** +- Avoids collisions with existing CSS variables +- Easy to search/grep in codebase +- Clear ownership (WaysNX UI Kit tokens) + +--- + +## Browser Support + +CSS custom properties (CSS variables) are supported in: + +- Chrome 49+ +- Firefox 31+ +- Safari 9.1+ +- Edge 15+ + +For older browsers, consider a fallback: + +```css +.my-element { + color: #f19924; /* Fallback */ + color: var(--wx-color-primary); /* Modern browsers */ +} +``` + +--- + +## Complete Token Reference + +### All Tokens at a Glance + +**Colors (20 tokens):** +- Brand: primary, primary-hover, primary-contrast, primary-light (4) +- Text: text, text-muted, text-light (3) +- Surface: surface, surface-alt, surface-hover (3) +- Border: border (1) +- Status: error, error-hover, error-light, success, success-light, warning, info, destructive, destructive-hover (9) + +**Spacing (5 tokens):** +- xs, sm, md, lg, xl + +**Typography (8 tokens):** +- font-family +- font-size-xs, sm, md, lg, xl, 2xl, 3xl + +**Border Radius (4 tokens):** +- radius-sm, md, lg, xl + +**Shadows (5 tokens):** +- shadow-sm, md, lg, xl, focus + +**Component (2 tokens):** +- input-height +- overlay-bg + +**Total: 44 CSS custom properties** (declared in `packages/ui-core/src/styles/tokens.css`) + +**Note:** `--wx-accessibility-font-scale` is referenced as a fallback multiplier in font-size tokens but is not declared with a default value. Applications can optionally define it to scale all font sizes proportionally. + +--- + +## Getting Help + +- **Full token list:** `packages/ui-core/src/styles/tokens.css` +- **Documentation:** https://uikit.waysnx.tech +- **Examples:** See `examples/waysnx-admin-demo/` for token usage patterns + +--- + +**Last Updated:** October 2026 +**Applies to:** WaysNX UI Kit v1.0.0+ From 9a09ad89653c1a667e0c51d735631ac0f6325c2b Mon Sep 17 00:00:00 2001 From: Kranti Ghatpande <119097691+krantighatpande@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:32:26 +0530 Subject: [PATCH 2/2] test: add vitest infrastructure for ui-core and ui-navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Establish comprehensive unit test infrastructure for WaysNX UI Kit component packages. Phase 2: Test Infrastructure Setup (Steps 1 & 2) Packages Configured: - @waysnx/ui-core: Added vitest setup with Button test suite (9 tests) - @waysnx/ui-navigation: Added vitest setup with Sidebar, Menu, and integration tests (68 tests) Test Coverage: - Total: 77 tests across 2 packages, 100% passing - ui-core: Button component (variants, interactions, disabled state, styling) - ui-navigation: Sidebar (collapse, persistence, SSR, accessibility) - ui-navigation: Menu (expansion, interaction, permissions, accessibility) - ui-navigation: Integration tests (Sidebar + Menu coordination) Phase 3 Items Documented (11 tests): Tests document current behavior and mark limitations for Phase 3 implementation: - Keyboard navigation (ArrowDown, ArrowUp, Home, End, Escape) - 6 tests - Controlled isCollapsed prop - 2 tests - Inline style prop forwarding - 2 tests - SSR localStorage safety - 1 test Configuration: - Vitest v3.2.4 (ui-core) / v3.2.7 (ui-navigation) - @testing-library/react v16.0.0 for component testing - Non-globals mode with explicit imports - jsdom environment for React components Test Quality: - Menu expansion: Verified aria-expanded changes - Permission filtering: Explicit negative assertions - SSR test: Finally block for guaranteed cleanup - Controlled state: Documented uncontrolled-only behavior - Keyboard nav: Documented current limitations with Phase 3 markers Files Changed: - packages/ui-core/vitest.config.ts (new) - packages/ui-core/tests/Button.test.tsx (new) - packages/ui-core/package.json (added test deps + scripts) - packages/ui-navigation/vitest.config.ts (new) - packages/ui-navigation/tests/Sidebar.test.tsx (new) - packages/ui-navigation/tests/Menu.test.tsx (new) - packages/ui-navigation/tests/integration.test.tsx (new) - packages/ui-navigation/package.json (added test deps + scripts) - pnpm-lock.yaml (test dependency tree) - PHASE2_STEP1_FINAL_REPORT.md (new) - PHASE2_STEP2_FINAL_COMPLETION_REPORT.md (new) Verification: ✓ All 77 tests passing (ui-core: 9/9, ui-navigation: 68/68) ✓ Builds successful for both packages ✓ No production code changes ✓ No regressions introduced Part of Phase 2: Test Infrastructure Setup --- packages/ui-core/package.json | 12 +- packages/ui-core/tests/Button.test.tsx | 85 +++ packages/ui-core/vitest.config.ts | 16 + packages/ui-navigation/package.json | 12 +- packages/ui-navigation/tests/Menu.test.tsx | 506 ++++++++++++++++++ packages/ui-navigation/tests/Sidebar.test.tsx | 334 ++++++++++++ .../ui-navigation/tests/integration.test.tsx | 350 ++++++++++++ packages/ui-navigation/vitest.config.ts | 16 + pnpm-lock.yaml | 407 +++++++++++++- 9 files changed, 1732 insertions(+), 6 deletions(-) create mode 100644 packages/ui-core/tests/Button.test.tsx create mode 100644 packages/ui-core/vitest.config.ts create mode 100644 packages/ui-navigation/tests/Menu.test.tsx create mode 100644 packages/ui-navigation/tests/Sidebar.test.tsx create mode 100644 packages/ui-navigation/tests/integration.test.tsx create mode 100644 packages/ui-navigation/vitest.config.ts diff --git a/packages/ui-core/package.json b/packages/ui-core/package.json index 84eabde..2f22bcb 100644 --- a/packages/ui-core/package.json +++ b/packages/ui-core/package.json @@ -37,19 +37,27 @@ "react-number-format": "^5.4.4" }, "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", "@types/dompurify": "^3.0.5", "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-datepicker": "^7.0.0", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.0", "copyfiles": "^2.4.1", + "jsdom": "^25.0.0", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.2.4" }, "scripts": { "build": "tsup && npm run copy-css", "copy-css": "copyfiles -u 1 \"src/**/*.css\" dist", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" }, "exports": { "./dist/index.css": "./dist/index.css", diff --git a/packages/ui-core/tests/Button.test.tsx b/packages/ui-core/tests/Button.test.tsx new file mode 100644 index 0000000..665f731 --- /dev/null +++ b/packages/ui-core/tests/Button.test.tsx @@ -0,0 +1,85 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Button } from "../src/components/Button"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// Extend vitest's expect with jest-dom matchers +expect.extend(matchers); + +describe("Button", () => { + // Clean up DOM after each test + afterEach(() => { + cleanup(); + }); + it("renders with children text", () => { + render(); + expect(screen.getByText("Click me")).toBeInTheDocument(); + }); + + it("renders with primary variant by default", () => { + render(); + const button = screen.getByRole("button", { name: "Primary" }); + expect(button).toHaveClass("wx-button"); + expect(button).toHaveClass("wx-button--primary"); + }); + + it("renders with specified variant", () => { + render(); + const button = screen.getByRole("button", { name: "Secondary" }); + expect(button).toHaveClass("wx-button--secondary"); + }); + + it("invokes click handler when clicked", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render(); + const button = screen.getByRole("button", { name: "Click me" }); + + await user.click(button); + + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it("does not invoke click handler when disabled", async () => { + const user = userEvent.setup(); + const handleClick = vi.fn(); + + render(); + const button = screen.getByRole("button", { name: "Disabled" }); + + expect(button).toBeDisabled(); + + await user.click(button); + + expect(handleClick).not.toHaveBeenCalled(); + }); + + it("applies custom className alongside default classes", () => { + render(); + const button = screen.getByRole("button", { name: "Custom" }); + + expect(button).toHaveClass("wx-button"); + expect(button).toHaveClass("wx-button--primary"); + expect(button).toHaveClass("custom-class"); + }); + + it("renders destructive variant correctly", () => { + render(); + const button = screen.getByRole("button", { name: "Delete" }); + expect(button).toHaveClass("wx-button--destructive"); + }); + + it("renders outline variant correctly", () => { + render(); + const button = screen.getByRole("button", { name: "Outline" }); + expect(button).toHaveClass("wx-button--outline"); + }); + + it("renders ghost variant correctly", () => { + render(); + const button = screen.getByRole("button", { name: "Ghost" }); + expect(button).toHaveClass("wx-button--ghost"); + }); +}); diff --git a/packages/ui-core/vitest.config.ts b/packages/ui-core/vitest.config.ts new file mode 100644 index 0000000..0bdecb6 --- /dev/null +++ b/packages/ui-core/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: false, + environment: "jsdom", + include: ["tests/**/*.test.{ts,tsx}"], + css: { + modules: { + classNameStrategy: "non-scoped", + }, + }, + }, +}); diff --git a/packages/ui-navigation/package.json b/packages/ui-navigation/package.json index 26c4845..5800ab3 100644 --- a/packages/ui-navigation/package.json +++ b/packages/ui-navigation/package.json @@ -19,17 +19,25 @@ "@waysnx/ui-i18n": "workspace:*" }, "devDependencies": { + "@testing-library/jest-dom": "^6.5.0", + "@testing-library/react": "^16.0.0", + "@testing-library/user-event": "^14.5.0", "@types/node": "^25.0.3", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^4.3.0", "copyfiles": "^2.4.1", + "jsdom": "^25.0.0", "tsup": "^8.5.1", - "typescript": "^5.9.3" + "typescript": "^5.9.3", + "vitest": "^3.2.4" }, "scripts": { "build": "tsup && npm run copy-css", "copy-css": "copyfiles -u 1 \"src/**/*.css\" dist", - "clean": "rm -rf dist" + "clean": "rm -rf dist", + "test": "vitest run", + "test:watch": "vitest" }, "exports": { "./dist/index.css": "./dist/index.css", diff --git a/packages/ui-navigation/tests/Menu.test.tsx b/packages/ui-navigation/tests/Menu.test.tsx new file mode 100644 index 0000000..ba4e8a1 --- /dev/null +++ b/packages/ui-navigation/tests/Menu.test.tsx @@ -0,0 +1,506 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Menu } from "../src/components/Menu"; +import type { NavigationItem, SecurityContext } from "../src/types"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// Extend vitest's expect with jest-dom matchers +expect.extend(matchers); + +describe("Menu", () => { + // Mock navigation items + const mockItems: NavigationItem[] = [ + { id: "1", label: "Home", href: "/" }, + { id: "2", label: "About", href: "/about" }, + { id: "3", label: "Contact", href: "/contact" }, + ]; + + const mockItemsWithChildren: NavigationItem[] = [ + { id: "1", label: "Dashboard", href: "/dashboard" }, + { + id: "2", + label: "Products", + href: "/products", + children: [ + { id: "2-1", label: "Electronics", href: "/products/electronics" }, + { id: "2-2", label: "Clothing", href: "/products/clothing" }, + ], + }, + { id: "3", label: "Support", href: "/support" }, + ]; + + // Clean up DOM after each test + afterEach(() => { + cleanup(); + }); + + describe("Rendering", () => { + it("renders all menu items", () => { + render(); + + expect(screen.getByText("Home")).toBeInTheDocument(); + expect(screen.getByText("About")).toBeInTheDocument(); + expect(screen.getByText("Contact")).toBeInTheDocument(); + }); + + it("renders with vertical orientation by default", () => { + const { container } = render(); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--vertical"); + }); + + it("renders with horizontal orientation when specified", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--horizontal"); + }); + + it("renders parent items with expandable indicators", () => { + const { container } = render(); + + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Products")).toBeInTheDocument(); + expect(screen.getByText("Support")).toBeInTheDocument(); + + // Parent with children should have expandable class + const productsItem = screen.getByText("Products").closest("li"); + expect(productsItem).toHaveClass("nav-menu-item--expandable"); + + // Children items are not rendered until expanded (collapsed by default) + }); + + it("applies density class", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--compact"); + }); + + it("applies variant class", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--minimal"); + }); + + it("applies custom className", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu"); + expect(menu).toHaveClass("custom-menu"); + }); + }); + + describe("Item Expansion", () => { + it("marks items with children as expandable and toggles aria-expanded", async () => { + const user = userEvent.setup(); + const { container } = render(); + + const productsItem = screen.getByText("Products").closest("li"); + const productsLink = screen.getByText("Products").closest("a"); + + // Should have expandable class and aria-expanded=false initially + expect(productsItem).toHaveClass("nav-menu-item--expandable"); + expect(productsLink).toHaveAttribute("aria-expanded", "false"); + + // Click to expand + await user.click(screen.getByText("Products")); + + // Verify aria-expanded changes to true after click + expect(productsLink).toHaveAttribute("aria-expanded", "true"); + + // Note: Child visibility depends on MenuItem implementation + // The expandable state and aria-expanded are the contract + }); + + it("nested children become visible when parent is expanded", async () => { + const user = userEvent.setup(); + render(); + + const productsLink = screen.getByText("Products").closest("a"); + + // Initially collapsed + expect(productsLink).toHaveAttribute("aria-expanded", "false"); + + // Children should not be visible initially (or should verify actual behavior) + // If implementation renders children collapsed, verify they exist but are hidden + // If implementation doesn't render until expanded, verify they're absent + + // Click to expand + await user.click(screen.getByText("Products")); + + // Verify expanded state + expect(productsLink).toHaveAttribute("aria-expanded", "true"); + + // Verify children are now visible + expect(screen.getByText("Electronics")).toBeVisible(); + expect(screen.getByText("Clothing")).toBeVisible(); + }); + }); + + describe("Item Interaction", () => { + it("invokes onItemClick when item is clicked", async () => { + const user = userEvent.setup(); + const handleItemClick = vi.fn(); + + render(); + + await user.click(screen.getByText("Home")); + + expect(handleItemClick).toHaveBeenCalledTimes(1); + expect(handleItemClick).toHaveBeenCalledWith( + expect.objectContaining({ id: "1", label: "Home" }) + ); + }); + + it("invokes onActiveChange when item is clicked", async () => { + const user = userEvent.setup(); + const handleActiveChange = vi.fn(); + + render(); + + await user.click(screen.getByText("About")); + + expect(handleActiveChange).toHaveBeenCalledTimes(1); + expect(handleActiveChange).toHaveBeenCalledWith( + expect.objectContaining({ id: "2", label: "About" }) + ); + }); + + it("highlights active item", () => { + const activeItem = mockItems[1]; // "About" + const { container } = render( + + ); + + const aboutItem = screen.getByText("About").closest("li"); + expect(aboutItem).toHaveClass("nav-menu-item--active"); + }); + }); + + describe("Keyboard Navigation", () => { + it("documents keyboard nav limitation - ArrowDown (Phase 3)", async () => { + // KNOWN LIMITATION - Phase 3: Menu does not implement custom keyboard navigation + // Expected: Arrow keys should move focus between items + // Current: Focus does not move (standard browser behavior only) + + const user = userEvent.setup(); + render(); + + const firstLink = screen.getByText("Home").closest("a"); + const secondLink = screen.getByText("About").closest("a"); + + // Focus first item + firstLink?.focus(); + expect(document.activeElement).toBe(firstLink); + + // Press ArrowDown + await user.keyboard("{ArrowDown}"); + + // CURRENT BEHAVIOR: Focus does not move + expect(document.activeElement).toBe(firstLink); + + // TODO Phase 3: Implement arrow key navigation + // Expected: expect(document.activeElement).toBe(secondLink); + }); + + it("documents keyboard nav limitation - ArrowUp (Phase 3)", async () => { + // KNOWN LIMITATION - Phase 3: Arrow key navigation not implemented + + const user = userEvent.setup(); + render(); + + const firstLink = screen.getByText("Home").closest("a"); + const secondLink = screen.getByText("About").closest("a"); + + // Focus second item + secondLink?.focus(); + expect(document.activeElement).toBe(secondLink); + + // Press ArrowUp + await user.keyboard("{ArrowUp}"); + + // CURRENT BEHAVIOR: Focus does not move + expect(document.activeElement).toBe(secondLink); + + // TODO Phase 3: expect(document.activeElement).toBe(firstLink); + }); + + it("Enter key triggers native link behavior", async () => { + const user = userEvent.setup(); + const handleItemClick = vi.fn(); + render( + + ); + + const firstLink = screen.getByText("Home").closest("a"); + + // Focus first item + firstLink?.focus(); + expect(document.activeElement).toBe(firstLink); + + // Press Enter + await user.keyboard("{Enter}"); + + // Enter triggers click via native link behavior + expect(handleItemClick).toHaveBeenCalledTimes(1); + expect(handleItemClick).toHaveBeenCalledWith( + expect.objectContaining({ id: "1", label: "Home" }) + ); + }); + + it("documents keyboard nav limitation - Home key (Phase 3)", async () => { + // KNOWN LIMITATION - Phase 3: Home/End keys not implemented + + const user = userEvent.setup(); + render(); + + const lastLink = screen.getByText("Contact").closest("a"); + const firstLink = screen.getByText("Home").closest("a"); + + // Focus last item + lastLink?.focus(); + expect(document.activeElement).toBe(lastLink); + + // Press Home + await user.keyboard("{Home}"); + + // CURRENT BEHAVIOR: Focus does not move + expect(document.activeElement).toBe(lastLink); + + // TODO Phase 3: expect(document.activeElement).toBe(firstLink); + }); + + it("documents keyboard nav limitation - End key (Phase 3)", async () => { + // KNOWN LIMITATION - Phase 3: Home/End keys not implemented + + const user = userEvent.setup(); + render(); + + const firstLink = screen.getByText("Home").closest("a"); + const lastLink = screen.getByText("Contact").closest("a"); + + // Focus first item + firstLink?.focus(); + expect(document.activeElement).toBe(firstLink); + + // Press End + await user.keyboard("{End}"); + + // CURRENT BEHAVIOR: Focus does not move + expect(document.activeElement).toBe(firstLink); + + // TODO Phase 3: expect(document.activeElement).toBe(lastLink); + }); + + it("documents keyboard nav limitation - Escape key (Phase 3)", async () => { + // KNOWN LIMITATION - Phase 3: Escape key collapse not implemented + + const user = userEvent.setup(); + render(); + + const productsLink = screen.getByText("Products").closest("a"); + + // Click to expand + await user.click(screen.getByText("Products")); + + // Verify expanded + expect(productsLink).toHaveAttribute("aria-expanded", "true"); + + // Focus the item + productsLink?.focus(); + + // Press Escape + await user.keyboard("{Escape}"); + + // CURRENT BEHAVIOR: Does not collapse + expect(productsLink).toHaveAttribute("aria-expanded", "true"); + + // TODO Phase 3: Implement Escape key handler + // Expected: expect(productsLink).toHaveAttribute("aria-expanded", "false"); + }); + + it("keyboard navigation prop is available but not fully implemented", async () => { + const user = userEvent.setup(); + render(); + + const firstLink = screen.getByText("Home").closest("a"); + const secondLink = screen.getByText("About").closest("a"); + + // Focus first item + firstLink?.focus(); + expect(document.activeElement).toBe(firstLink); + + // Press ArrowDown + await user.keyboard("{ArrowDown}"); + + // With keyboardNav false, custom nav should not work (currently not implemented either way) + expect(firstLink).toBeInTheDocument(); + expect(secondLink).toBeInTheDocument(); + }); + }); + + describe("Permission-Based Filtering", () => { + const itemsWithPermissions: NavigationItem[] = [ + { id: "1", label: "Public Page", href: "/public" }, + { id: "2", label: "Admin Page", href: "/admin", permissions: ["admin"] }, + { id: "3", label: "User Page", href: "/user", roles: ["user"] }, + ]; + + it("shows all items when no security context provided", () => { + render(); + + // All items should be visible without security filtering + expect(screen.getByText("Public Page")).toBeInTheDocument(); + expect(screen.getByText("Admin Page")).toBeInTheDocument(); + expect(screen.getByText("User Page")).toBeInTheDocument(); + }); + + it("filters items based on security context permissions", () => { + const securityContext: SecurityContext = { + permissions: ["admin"], + roles: [], + }; + + render( + + ); + + // Should show public and admin items + expect(screen.getByText("Public Page")).toBeInTheDocument(); + expect(screen.getByText("Admin Page")).toBeInTheDocument(); + + // Should NOT show user page (requires 'user' role which is not present) + expect(screen.queryByText("User Page")).not.toBeInTheDocument(); + }); + + it("filters items based on security context roles", () => { + const securityContext: SecurityContext = { + permissions: [], + roles: ["user"], + }; + + render( + + ); + + // Should show public and user items + expect(screen.getByText("Public Page")).toBeInTheDocument(); + expect(screen.getByText("User Page")).toBeInTheDocument(); + + // Should NOT show admin page (requires 'admin' permission which is not present) + expect(screen.queryByText("Admin Page")).not.toBeInTheDocument(); + }); + + it("filters multiple items when permissions are missing", () => { + const securityContext: SecurityContext = { + permissions: [], + roles: [], + }; + + render( + + ); + + // Should only show public item + expect(screen.getByText("Public Page")).toBeInTheDocument(); + + // Should NOT show restricted items + expect(screen.queryByText("Admin Page")).not.toBeInTheDocument(); + expect(screen.queryByText("User Page")).not.toBeInTheDocument(); + }); + }); + + describe("Accessibility", () => { + it("has menu role by default", () => { + render(); + + const menu = screen.getByRole("menu"); + expect(menu).toBeInTheDocument(); + }); + + it("has navigation role when specified", () => { + render(); + + const nav = screen.getByRole("navigation"); + expect(nav).toBeInTheDocument(); + }); + + it("has accessible label", () => { + render(); + + expect(screen.getByLabelText("Main menu")).toBeInTheDocument(); + }); + + it("uses default label when ariaLabel not provided", () => { + render(); + + expect(screen.getByLabelText("Menu")).toBeInTheDocument(); + }); + }); + + describe("Icons and Badges", () => { + const itemsWithExtras: NavigationItem[] = [ + { + id: "1", + label: "Dashboard", + href: "/dashboard", + icon: 📊, + }, + { + id: "2", + label: "Messages", + href: "/messages", + badge: 5, + }, + ]; + + it("shows icons when showIcons is true", () => { + render(); + + expect(screen.getByTestId("icon-dashboard")).toBeInTheDocument(); + }); + + it("shows badges when showBadges is true", () => { + render(); + + expect(screen.getByText("5")).toBeInTheDocument(); + }); + }); + + describe("Custom Styling", () => { + it("documents inline styles limitation (Phase 3)", () => { + // KNOWN LIMITATION - Phase 3: Menu does not apply inline styles from style prop + // Expected: Inline styles should be applied to menu element + // Current: style prop is accepted but not applied + + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + // Menu element exists + expect(menu).toBeInTheDocument(); + + // CURRENT BEHAVIOR: Inline styles are not applied + // Style prop is accepted but ignored by component implementation + + // TODO Phase 3: Implement style prop forwarding + // Expected after fix: + // expect(menu).toHaveStyle({ backgroundColor: "blue" }); + // expect(menu).toHaveStyle({ padding: "20px" }); + }); + }); +}); diff --git a/packages/ui-navigation/tests/Sidebar.test.tsx b/packages/ui-navigation/tests/Sidebar.test.tsx new file mode 100644 index 0000000..ec4ff9b --- /dev/null +++ b/packages/ui-navigation/tests/Sidebar.test.tsx @@ -0,0 +1,334 @@ +import { describe, it, expect, vi, afterEach, beforeEach } from "vitest"; +import { render, screen, cleanup } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Sidebar } from "../src/components/Sidebar"; +import type { NavigationItem } from "../src/types"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// Extend vitest's expect with jest-dom matchers +expect.extend(matchers); + +describe("Sidebar", () => { + // Mock navigation items + const mockItems: NavigationItem[] = [ + { id: "1", label: "Dashboard", href: "/dashboard" }, + { id: "2", label: "Projects", href: "/projects" }, + { + id: "3", + label: "Settings", + href: "/settings", + children: [ + { id: "3-1", label: "Profile", href: "/settings/profile" }, + { id: "3-2", label: "Security", href: "/settings/security" }, + ], + }, + ]; + + // Clean up DOM and localStorage after each test + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + describe("Rendering", () => { + it("renders with navigation items", () => { + render(); + + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("renders with logo", () => { + const logo =
Brand Logo
; + render(); + + expect(screen.getByTestId("logo")).toBeInTheDocument(); + expect(screen.getByText("Brand Logo")).toBeInTheDocument(); + }); + + it("renders with footer content", () => { + const footer =
Footer Content
; + render(); + + expect(screen.getByTestId("footer")).toBeInTheDocument(); + expect(screen.getByText("Footer Content")).toBeInTheDocument(); + }); + + it("renders with header content", () => { + const header =
Header Content
; + render(); + + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByText("Header Content")).toBeInTheDocument(); + }); + + it("applies correct position class", () => { + const { container } = render(); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar--right"); + }); + + it("applies default left position", () => { + const { container } = render(); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar--left"); + }); + }); + + describe("Collapse State - Uncontrolled Mode", () => { + it("renders in expanded state by default", () => { + const { container } = render(); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar--expanded"); + expect(sidebar).not.toHaveClass("nav-sidebar--collapsed"); + }); + + it("renders in collapsed state when isCollapsed is true", () => { + const { container } = render(); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar--collapsed"); + expect(sidebar).not.toHaveClass("nav-sidebar--expanded"); + }); + + it("toggles collapse state when toggle button is clicked", async () => { + const user = userEvent.setup(); + const { container } = render(); + + const sidebar = container.querySelector(".nav-sidebar"); + const toggleButton = screen.getByLabelText("Collapse sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar--expanded"); + + await user.click(toggleButton); + + expect(sidebar).toHaveClass("nav-sidebar--collapsed"); + expect(screen.getByLabelText("Expand sidebar")).toBeInTheDocument(); + }); + + it("invokes onCollapseChange callback when toggled", async () => { + const user = userEvent.setup(); + const handleCollapseChange = vi.fn(); + + render( + + ); + + const toggleButton = screen.getByLabelText("Collapse sidebar"); + + await user.click(toggleButton); + + expect(handleCollapseChange).toHaveBeenCalledTimes(1); + expect(handleCollapseChange).toHaveBeenCalledWith(true); + + await user.click(screen.getByLabelText("Expand sidebar")); + + expect(handleCollapseChange).toHaveBeenCalledTimes(2); + expect(handleCollapseChange).toHaveBeenCalledWith(false); + }); + + it("does not render toggle button when collapsible is false", () => { + render(); + + expect(screen.queryByLabelText("Collapse sidebar")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Expand sidebar")).not.toBeInTheDocument(); + }); + }); + + describe("Persistence", () => { + const storageKey = "test-sidebar-collapsed"; + + it("persists collapsed state to localStorage when persistState is true", async () => { + const user = userEvent.setup(); + + render( + + ); + + const toggleButton = screen.getByLabelText("Collapse sidebar"); + await user.click(toggleButton); + + const storedValue = localStorage.getItem(storageKey); + expect(storedValue).toBe("true"); + }); + + it("restores collapsed state from localStorage on mount", () => { + localStorage.setItem(storageKey, "true"); + + const { container } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + expect(sidebar).toHaveClass("nav-sidebar--collapsed"); + }); + + it("does not persist state when persistState is false", async () => { + const user = userEvent.setup(); + + render( + + ); + + const toggleButton = screen.getByLabelText("Collapse sidebar"); + await user.click(toggleButton); + + const storedValue = localStorage.getItem(storageKey); + expect(storedValue).toBeNull(); + }); + }); + + describe("SSR Safety", () => { + it("documents known SSR issue with localStorage access", () => { + // KNOWN ISSUE - Phase 3: Sidebar currently throws when localStorage is unavailable + // Expected behavior: Should gracefully handle missing localStorage in SSR environments + // Current behavior: Throws error during render + + // Mock localStorage to throw (simulating SSR environment) + const originalLocalStorage = global.localStorage; + + try { + Object.defineProperty(global, "localStorage", { + get: () => { + throw new Error("localStorage is not available"); + }, + configurable: true, + }); + + // Currently throws - this documents the issue for Phase 3 fix + expect(() => { + render( + + ); + }).toThrow("localStorage is not available"); + + // TODO Phase 3: Update Sidebar to use try-catch around localStorage access + // and provide SSR-safe fallback behavior + } finally { + // Restore localStorage even if assertion fails + Object.defineProperty(global, "localStorage", { + value: originalLocalStorage, + configurable: true, + }); + } + }); + }); + + describe("Custom Styling", () => { + it("applies custom className", () => { + const { container} = render( + + ); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveClass("nav-sidebar"); + expect(sidebar).toHaveClass("custom-sidebar"); + }); + + it("documents inline styles limitation (Phase 3)", () => { + // KNOWN LIMITATION - Phase 3: Sidebar does not apply inline styles from style prop + // Expected: Inline styles should be applied to sidebar element + // Current: style prop is accepted but not applied + + const { container } = render( + + ); + const sidebar = container.querySelector(".nav-sidebar"); + + // Sidebar element exists + expect(sidebar).toBeInTheDocument(); + + // CURRENT BEHAVIOR: Inline styles are not applied + // Style prop is accepted but ignored by component implementation + + // TODO Phase 3: Implement style prop forwarding + // Expected after fix: + // expect(sidebar).toHaveStyle({ backgroundColor: "red" }); + // expect(sidebar).toHaveStyle({ minWidth: "300px" }); + }); + + it("applies color scheme attribute", () => { + const { container } = render( + + ); + const sidebar = container.querySelector(".nav-sidebar"); + + expect(sidebar).toHaveAttribute("data-color-scheme", "dark"); + }); + }); + + describe("Accessibility", () => { + it("has complementary role", () => { + render(); + + const sidebar = screen.getByRole("complementary"); + expect(sidebar).toBeInTheDocument(); + }); + + it("has accessible label", () => { + render(); + + expect(screen.getByLabelText("Sidebar navigation")).toBeInTheDocument(); + }); + + it("toggle button has correct aria-expanded attribute", async () => { + const user = userEvent.setup(); + render(); + + const toggleButton = screen.getByLabelText("Collapse sidebar"); + + expect(toggleButton).toHaveAttribute("aria-expanded", "true"); + + await user.click(toggleButton); + + const expandButton = screen.getByLabelText("Expand sidebar"); + expect(expandButton).toHaveAttribute("aria-expanded", "false"); + }); + }); + + describe("Variants and Density", () => { + it("applies variant class to menu", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--minimal"); + }); + + it("applies density class to menu", () => { + const { container } = render( + + ); + const menu = container.querySelector(".nav-menu"); + + expect(menu).toHaveClass("nav-menu--compact"); + }); + }); +}); diff --git a/packages/ui-navigation/tests/integration.test.tsx b/packages/ui-navigation/tests/integration.test.tsx new file mode 100644 index 0000000..1f8c984 --- /dev/null +++ b/packages/ui-navigation/tests/integration.test.tsx @@ -0,0 +1,350 @@ +import { describe, it, expect, vi, afterEach } from "vitest"; +import { render, screen, cleanup, within } from "@testing-library/react"; +import { userEvent } from "@testing-library/user-event"; +import { Sidebar } from "../src/components/Sidebar"; +import type { NavigationItem } from "../src/types"; +import * as matchers from "@testing-library/jest-dom/matchers"; + +// Extend vitest's expect with jest-dom matchers +expect.extend(matchers); + +describe("Sidebar + Menu Integration", () => { + const mockNavItems: NavigationItem[] = [ + { id: "1", label: "Dashboard", href: "/dashboard" }, + { + id: "2", + label: "Projects", + href: "/projects", + children: [ + { id: "2-1", label: "Active", href: "/projects/active" }, + { id: "2-2", label: "Archived", href: "/projects/archived" }, + ], + }, + { id: "3", label: "Settings", href: "/settings" }, + ]; + + afterEach(() => { + cleanup(); + localStorage.clear(); + }); + + describe("Sidebar with Menu Integration", () => { + it("renders complete sidebar with menu items", () => { + render(); + + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + expect(screen.getByText("Projects")).toBeInTheDocument(); + expect(screen.getByText("Settings")).toBeInTheDocument(); + }); + + it("maintains menu functionality when sidebar is collapsed", async () => { + const user = userEvent.setup(); + const handleItemClick = vi.fn(); + + render( + + ); + + // Collapse sidebar + const toggleButton = screen.getByLabelText("Collapse sidebar"); + await user.click(toggleButton); + + // Menu items should still be clickable + await user.click(screen.getByText("Dashboard")); + + expect(handleItemClick).toHaveBeenCalledWith( + expect.objectContaining({ id: "1", label: "Dashboard" }) + ); + }); + + it("menu receives correct variant from sidebar", () => { + const { container } = render( + + ); + + const menu = container.querySelector(".nav-menu"); + expect(menu).toHaveClass("nav-menu--minimal"); + }); + + it("menu receives correct density from sidebar", () => { + const { container } = render( + + ); + + const menu = container.querySelector(".nav-menu"); + expect(menu).toHaveClass("nav-menu--compact"); + }); + + it("renders parent items with expandable indicators", async () => { + const { container } = render(); + + // Parent item should be visible + expect(screen.getByText("Projects")).toBeInTheDocument(); + + // Parent with children should have expandable class + const projectsItem = screen.getByText("Projects").closest("li"); + expect(projectsItem).toHaveClass("nav-menu-item--expandable"); + + // Children are collapsed by default and not rendered in DOM + }); + }); + + describe("State Coordination", () => { + it("sidebar collapse state does not affect menu active state", async () => { + const user = userEvent.setup(); + const activeItem = mockNavItems[0]; // Dashboard + + render( + + ); + + const dashboardItem = screen.getByText("Dashboard").closest("li"); + expect(dashboardItem).toHaveClass("nav-menu-item--active"); + + // Collapse sidebar + await user.click(screen.getByLabelText("Collapse sidebar")); + + // Active state should persist + expect(dashboardItem).toHaveClass("nav-menu-item--active"); + }); + + it("menu callbacks propagate through sidebar", async () => { + const user = userEvent.setup(); + const handleItemClick = vi.fn(); + const handleActiveChange = vi.fn(); + + render( + + ); + + await user.click(screen.getByText("Settings")); + + expect(handleItemClick).toHaveBeenCalledTimes(1); + expect(handleItemClick).toHaveBeenCalledWith( + expect.objectContaining({ id: "3", label: "Settings" }) + ); + + expect(handleActiveChange).toHaveBeenCalledTimes(1); + expect(handleActiveChange).toHaveBeenCalledWith( + expect.objectContaining({ id: "3", label: "Settings" }) + ); + }); + }); + + describe("Complex Layout Scenarios", () => { + it("renders sidebar with all sections (header, logo, menu, footer)", () => { + const logo =
Logo
; + const header =
Header
; + const footer =
Footer
; + + render( + + ); + + // All sections should be rendered + expect(screen.getByTestId("logo")).toBeInTheDocument(); + expect(screen.getByTestId("header")).toBeInTheDocument(); + expect(screen.getByTestId("footer")).toBeInTheDocument(); + expect(screen.getByText("Dashboard")).toBeInTheDocument(); + }); + + it("maintains correct structural layout order", () => { + const logo =
Logo
; + const header =
Header
; + const footer =
Footer
; + + const { container } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + expect(sidebar).toBeInTheDocument(); + + // Verify structural elements exist in expected order + const sidebarHeader = sidebar?.querySelector(".nav-sidebar__header"); + const sidebarContent = sidebar?.querySelector(".nav-sidebar__content"); + const sidebarFooter = sidebar?.querySelector(".nav-sidebar__footer"); + + expect(sidebarHeader).toBeInTheDocument(); + expect(sidebarContent).toBeInTheDocument(); + + // Header should appear before content in DOM order + const headerIndex = Array.from(sidebar?.children || []).indexOf(sidebarHeader!); + const contentIndex = Array.from(sidebar?.children || []).indexOf(sidebarContent!); + + expect(headerIndex).toBeLessThan(contentIndex); + + // Footer should appear after content + if (sidebarFooter) { + const footerIndex = Array.from(sidebar?.children || []).indexOf(sidebarFooter); + expect(footerIndex).toBeGreaterThan(contentIndex); + } + }); + + it("handles empty menu items gracefully", () => { + render(); + + const sidebar = screen.getByRole("complementary"); + expect(sidebar).toBeInTheDocument(); + + // Menu should render but with no items + const menu = sidebar.querySelector(".nav-menu"); + expect(menu).toBeInTheDocument(); + + // No menu items should be present + const menuItems = menu?.querySelectorAll(".nav-menu-item"); + expect(menuItems).toHaveLength(0); + }); + }); + + describe("Controlled State Behavior", () => { + it("documents uncontrolled behavior - isCollapsed is initial state only (Phase 3)", () => { + // KNOWN LIMITATION - Phase 3: Sidebar does not support controlled isCollapsed prop + // Current behavior: isCollapsed sets initial state but updates to prop are ignored + // Expected behavior (Phase 3): Sidebar should respond to isCollapsed prop changes + + const { container, rerender } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + + // Should be expanded when isCollapsed=false (initial) + expect(sidebar).toHaveClass("nav-sidebar--expanded"); + expect(sidebar).not.toHaveClass("nav-sidebar--collapsed"); + + // Rerender with isCollapsed=true + rerender(); + + // CURRENT BEHAVIOR: State does NOT update (isCollapsed only read on mount) + // Sidebar remains expanded despite isCollapsed=true + expect(sidebar).toHaveClass("nav-sidebar--expanded"); + expect(sidebar).not.toHaveClass("nav-sidebar--collapsed"); + + // TODO Phase 3: Implement controlled mode + // Expected after Phase 3 fix: + // expect(sidebar).toHaveClass("nav-sidebar--collapsed"); + // expect(sidebar).not.toHaveClass("nav-sidebar--expanded"); + }); + + it("invokes onCollapseChange in uncontrolled mode (current behavior)", async () => { + const user = userEvent.setup(); + const handleCollapseChange = vi.fn(); + + const { container } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + + // Initial state: expanded + expect(sidebar).toHaveClass("nav-sidebar--expanded"); + + // Click toggle button + const toggleButton = screen.getByLabelText("Collapse sidebar"); + await user.click(toggleButton); + + // Callback should be invoked + expect(handleCollapseChange).toHaveBeenCalledTimes(1); + expect(handleCollapseChange).toHaveBeenCalledWith(true); + + // CURRENT BEHAVIOR: State changes (uncontrolled) + expect(sidebar).toHaveClass("nav-sidebar--collapsed"); + + // NOTE: In controlled mode (Phase 3), parent would update isCollapsed prop + // and state would only change when prop changes, not from toggle button + }); + }); + + describe("Security Context Integration", () => { + const itemsWithPermissions: NavigationItem[] = [ + { id: "1", label: "Public", href: "/public" }, + { id: "2", label: "Admin Only", href: "/admin", permissions: ["admin"] }, + { id: "3", label: "User Only", href: "/user", roles: ["user"] }, + ]; + + it("menu respects security context passed through sidebar", () => { + const securityContext = { + permissions: ["admin"], + roles: [], + }; + + render( + + ); + + expect(screen.getByText("Public")).toBeInTheDocument(); + expect(screen.getByText("Admin Only")).toBeInTheDocument(); + // User Only should be filtered out + }); + }); + + describe("Responsive Behavior", () => { + it("applies correct CSS variables for width", () => { + const { container } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + const style = sidebar?.getAttribute("style"); + + expect(style).toContain("--wx-nav-sidebar-width"); + }); + + it("updates width variable when collapsed", async () => { + const user = userEvent.setup(); + const { container } = render( + + ); + + const sidebar = container.querySelector(".nav-sidebar"); + let style = sidebar?.getAttribute("style"); + expect(style).toContain("20rem"); + + // Collapse sidebar + await user.click(screen.getByLabelText("Collapse sidebar")); + + style = sidebar?.getAttribute("style"); + expect(style).toContain("5rem"); + }); + }); +}); diff --git a/packages/ui-navigation/vitest.config.ts b/packages/ui-navigation/vitest.config.ts new file mode 100644 index 0000000..0bdecb6 --- /dev/null +++ b/packages/ui-navigation/vitest.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "vitest/config"; +import react from "@vitejs/plugin-react"; + +export default defineConfig({ + plugins: [react()], + test: { + globals: false, + environment: "jsdom", + include: ["tests/**/*.test.{ts,tsx}"], + css: { + modules: { + classNameStrategy: "non-scoped", + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a43416..ab62748 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,15 @@ importers: specifier: ^5.4.4 version: 5.4.5(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: + '@testing-library/jest-dom': + specifier: ^6.5.0 + version: 6.5.0 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.5.0 + version: 14.5.2(@testing-library/dom@10.4.0) '@types/dompurify': specifier: ^3.0.5 version: 3.2.0 @@ -194,15 +203,24 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.5(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.3(@types/node@25.9.5)) copyfiles: specifier: ^2.4.1 version: 2.4.1 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@25.9.5)(jsdom@25.0.1) packages/ui-dashboard: dependencies: @@ -636,6 +654,15 @@ importers: specifier: '>=18' version: 19.2.8(react@19.2.8) devDependencies: + '@testing-library/jest-dom': + specifier: ^6.5.0 + version: 6.5.0 + '@testing-library/react': + specifier: ^16.0.0 + version: 16.3.3(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + '@testing-library/user-event': + specifier: ^14.5.0 + version: 14.5.2(@testing-library/dom@10.4.0) '@types/node': specifier: ^25.0.3 version: 25.9.5 @@ -645,15 +672,24 @@ importers: '@types/react-dom': specifier: ^19.2.3 version: 19.2.5(@types/react@19.2.18) + '@vitejs/plugin-react': + specifier: ^4.3.0 + version: 4.7.0(vite@6.4.3(@types/node@25.9.5)) copyfiles: specifier: ^2.4.1 version: 2.4.1 + jsdom: + specifier: ^25.0.0 + version: 25.0.1 tsup: specifier: ^8.5.1 version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) typescript: specifier: ^5.9.3 version: 5.9.3 + vitest: + specifier: ^3.2.4 + version: 3.2.7(@types/debug@4.1.13)(@types/node@25.9.5)(jsdom@25.0.1) packages/ui-security: dependencies: @@ -867,13 +903,16 @@ importers: version: 8.5.1(postcss@8.5.26)(typescript@5.9.3) vitest: specifier: ^3.2.4 - version: 3.2.7(@types/debug@4.1.13)(@types/node@25.9.5) + version: 3.2.7(@types/debug@4.1.13)(@types/node@25.9.5)(jsdom@25.0.1) packages: '@adobe/css-tools@4.5.0': resolution: {integrity: sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==} + '@asamuzakjp/css-color@3.2.0': + resolution: {integrity: sha512-K1A6z8tS3XsmCMM86xoWdn7Fkdn9m6RSVtocUrJYIwZnFVkng/PvkEoWtOWmP+Scc6saYWHWZYbndEEXxl24jw==} + '@babel/code-frame@7.29.7': resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==} engines: {node: '>=6.9.0'} @@ -965,6 +1004,34 @@ packages: resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} engines: {node: '>=6.9.0'} + '@csstools/color-helpers@5.1.0': + resolution: {integrity: sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA==} + engines: {node: '>=18'} + + '@csstools/css-calc@2.1.4': + resolution: {integrity: sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-color-parser@3.1.0': + resolution: {integrity: sha512-nbtKwh3a6xNVIp/VRuXV64yTKnb1IjTAEEh3irzS+HkKjAOYLTGNb9pmVNntZ8iVBHcWDA2Dof0QtPgFI1BaTA==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-parser-algorithms': ^3.0.5 + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-parser-algorithms@3.0.5': + resolution: {integrity: sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ==} + engines: {node: '>=18'} + peerDependencies: + '@csstools/css-tokenizer': ^3.0.4 + + '@csstools/css-tokenizer@3.0.4': + resolution: {integrity: sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw==} + engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.21.5': resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} engines: {node: '>=12'} @@ -1820,6 +1887,21 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/react@16.3.3': + resolution: {integrity: sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg==} + 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 + '@testing-library/user-event@14.5.2': resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} engines: {node: '>=12', npm: '>=6'} @@ -2011,6 +2093,10 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + agent-base@7.1.4: + resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==} + engines: {node: '>= 14'} + ansi-regex@5.0.1: resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} engines: {node: '>=8'} @@ -2052,6 +2138,9 @@ packages: async@3.2.6: resolution: {integrity: sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==} + asynckit@0.4.0: + resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + available-typed-arrays@1.0.7: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} @@ -2183,6 +2272,10 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + combined-stream@1.0.8: + resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} + engines: {node: '>= 0.8'} + comma-separated-tokens@1.0.8: resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==} @@ -2231,6 +2324,10 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + cssstyle@4.6.0: + resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} + engines: {node: '>=18'} + csstype@3.2.3: resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} @@ -2278,6 +2375,10 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + data-urls@5.0.0: + resolution: {integrity: sha512-ZYP5VBHshaDAiVZxjbRVcFJpc+4xGgT0bK3vzy1HLN8jTO975HEbuYzZJcHoQEY5K1a0z8YayJkyVETa08eNTg==} + engines: {node: '>=18'} + date-fns@4.4.0: resolution: {integrity: sha512-+1UMbeh68lH1SegH83CGWwpb6OHHbpSgr3+s5Eww5M4CAgswBpoWS0AjTOfEJ33HiYKz1hdj/KTFprzXHmq/6w==} @@ -2297,6 +2398,9 @@ packages: decimal.js-light@2.5.1: resolution: {integrity: sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==} + decimal.js@10.6.0: + resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==} + decode-named-character-reference@1.3.0: resolution: {integrity: sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==} @@ -2312,6 +2416,10 @@ packages: resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} engines: {node: '>=8'} + delayed-stream@1.0.0: + resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} + engines: {node: '>=0.4.0'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -2354,6 +2462,10 @@ packages: emoji-regex@9.2.2: resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + entities@6.0.1: + resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} + engines: {node: '>=0.12'} + es-define-property@1.0.1: resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} engines: {node: '>= 0.4'} @@ -2369,6 +2481,10 @@ packages: resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} engines: {node: '>= 0.4'} + es-set-tostringtag@2.1.0: + resolution: {integrity: sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==} + engines: {node: '>= 0.4'} + esbuild-register@3.6.0: resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} peerDependencies: @@ -2465,6 +2581,10 @@ packages: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} + form-data@4.0.6: + resolution: {integrity: sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==} + engines: {node: '>= 6'} + format@0.2.2: resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==} engines: {node: '>=0.4.x'} @@ -2563,9 +2683,17 @@ packages: resolution: {integrity: sha512-oWv4T4yJ52iKrufjnyZPkrN0CH3QnrUqdB6In1g5Fe1mia8GmF36gnfNySxoZtxD5+NmYw1EElVXiBk93UeskA==} engines: {node: '>=12'} + html-encoding-sniffer@4.0.0: + resolution: {integrity: sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ==} + engines: {node: '>=18'} + html-url-attributes@3.0.1: resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==} + http-proxy-agent@7.0.2: + resolution: {integrity: sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==} + engines: {node: '>= 14'} + http-proxy@1.18.1: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} @@ -2575,6 +2703,10 @@ packages: engines: {node: '>=12'} hasBin: true + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.6.3: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} @@ -2654,6 +2786,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-potential-custom-element-name@1.0.1: + resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2692,6 +2827,15 @@ packages: resolution: {integrity: sha512-iZ8Bdb84lWRuGHamRXFyML07r21pcwBrLkHEuHgEY5UbCouBwv7ECknDRKzsQIXMiqpPymqtIf8TC/shYKB5rw==} engines: {node: '>=12.0.0'} + jsdom@25.0.1: + resolution: {integrity: sha512-8i7LzZj7BF8uplX+ZyOlIz86V6TAsSs+np6m1kpW9u0JWi4z/1t+FzcK1aek+ybTnAC4KhBL4uXCNT0wcUIeCw==} + engines: {node: '>=18'} + peerDependencies: + canvas: ^2.11.2 + peerDependenciesMeta: + canvas: + optional: true + jsesc@3.1.0: resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==} engines: {node: '>=6'} @@ -2851,6 +2995,14 @@ packages: micromark@4.0.2: resolution: {integrity: sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==} + mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + + mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -2900,6 +3052,9 @@ packages: noms@0.0.0: resolution: {integrity: sha512-lNDU9VJaOPxUmXcLb+HQFeUgQQPtMI24Gt6hgfuMHRJgMRHMF/qZ4HJD3GDru4sSw9IQl2jPjAYnQrdIeLbwow==} + nwsapi@2.2.28: + resolution: {integrity: sha512-IlVB7OS7qrOsVYlpnFIkETjMwT9jwvmocJmmM+GZU/PAB3uGi9Ezd7vcWhWBUnSc0ya4ppmQITOyP1ez9gg8cg==} + object-assign@4.1.1: resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} engines: {node: '>=0.10.0'} @@ -2948,6 +3103,9 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5@7.3.0: + resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + path-exists@4.0.0: resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} engines: {node: '>=8'} @@ -3064,6 +3222,10 @@ packages: property-information@7.2.0: resolution: {integrity: sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==} + punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + qrcode@1.5.4: resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} engines: {node: '>=10.13.0'} @@ -3228,6 +3390,12 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + rrweb-cssom@0.7.1: + resolution: {integrity: sha512-TrEMa7JGdVm0UThDJSx7ddw5nVm3UJS9o9CCIZ72B1vSyEZoziDqBYP3XIoi/12lKrJR8rE3jeFHMok2F/Mnsg==} + + rrweb-cssom@0.8.0: + resolution: {integrity: sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw==} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -3238,6 +3406,10 @@ packages: safer-buffer@2.1.2: resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + saxes@6.0.0: + resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==} + engines: {node: '>=v12.22.7'} + scheduler@0.27.0: resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==} @@ -3386,6 +3558,9 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + symbol-tree@3.2.4: + resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==} + tabbable@6.5.0: resolution: {integrity: sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA==} @@ -3432,6 +3607,21 @@ packages: resolution: {integrity: sha512-u8KszXvGfU68hVcZpRHKG28T0krMuv2G5nDhiHaMLen/gIuFEgIJhaJuO69qjnXg5paSrbPMFfx3brNuN8eVSg==} engines: {node: '>=14.0.0'} + tldts-core@6.1.86: + resolution: {integrity: sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA==} + + tldts@6.1.86: + resolution: {integrity: sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ==} + hasBin: true + + tough-cookie@5.1.2: + resolution: {integrity: sha512-FVDYdxtnj0G6Qm/DhNPSb8Ju59ULcup3tuJxkFb5K8Bv2pUXILbf0xZWU8PX8Ov19OXljbUyveOFwRMwkXzO+A==} + engines: {node: '>=16'} + + tr46@5.1.1: + resolution: {integrity: sha512-hdF5ZgjTqgAntKkklYw0R03MG2x/bSzTtkxmIRw/sTNV8YXsCJ1tfLAX23lhxhHJlEf3CRCOCGGWw3vI3GaSPw==} + engines: {node: '>=18'} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -3654,6 +3844,14 @@ packages: jsdom: optional: true + w3c-xmlserializer@5.0.0: + resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} + engines: {node: '>=18'} + + webidl-conversions@7.0.0: + resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} + engines: {node: '>=12'} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -3662,6 +3860,19 @@ packages: engines: {node: '>=12'} deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + + whatwg-url@14.2.0: + resolution: {integrity: sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw==} + engines: {node: '>=18'} + which-module@2.0.1: resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} @@ -3706,6 +3917,13 @@ packages: utf-8-validate: optional: true + xml-name-validator@5.0.0: + resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==} + engines: {node: '>=18'} + + xmlchars@2.2.0: + resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==} + xtend@4.0.2: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} @@ -3747,6 +3965,14 @@ snapshots: '@adobe/css-tools@4.5.0': {} + '@asamuzakjp/css-color@3.2.0': + dependencies: + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-color-parser': 3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + lru-cache: 10.4.3 + '@babel/code-frame@7.29.7': dependencies: '@babel/helper-validator-identifier': 7.29.7 @@ -3865,6 +4091,26 @@ snapshots: '@babel/helper-string-parser': 7.29.7 '@babel/helper-validator-identifier': 7.29.7 + '@csstools/color-helpers@5.1.0': {} + + '@csstools/css-calc@2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-color-parser@3.1.0(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/color-helpers': 5.1.0 + '@csstools/css-calc': 2.1.4(@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4))(@csstools/css-tokenizer@3.0.4) + '@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4) + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-parser-algorithms@3.0.5(@csstools/css-tokenizer@3.0.4)': + dependencies: + '@csstools/css-tokenizer': 3.0.4 + + '@csstools/css-tokenizer@3.0.4': {} + '@esbuild/aix-ppc64@0.21.5': optional: true @@ -4511,6 +4757,16 @@ snapshots: lodash: 4.18.1 redent: 3.0.0 + '@testing-library/react@16.3.3(@testing-library/dom@10.4.0)(@types/react-dom@19.2.5(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.8(react@19.2.8))(react@19.2.8)': + dependencies: + '@babel/runtime': 7.29.7 + '@testing-library/dom': 10.4.0 + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + optionalDependencies: + '@types/react': 19.2.18 + '@types/react-dom': 19.2.5(@types/react@19.2.18) + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -4743,6 +4999,8 @@ snapshots: acorn@8.18.0: {} + agent-base@7.1.4: {} + ansi-regex@5.0.1: {} ansi-regex@6.3.0: {} @@ -4771,6 +5029,8 @@ snapshots: async@3.2.6: {} + asynckit@0.4.0: {} + available-typed-arrays@1.0.7: dependencies: possible-typed-array-names: 1.1.0 @@ -4898,6 +5158,10 @@ snapshots: color-name@1.1.4: {} + combined-stream@1.0.8: + dependencies: + delayed-stream: 1.0.0 + comma-separated-tokens@1.0.8: {} comma-separated-tokens@2.0.3: {} @@ -4938,6 +5202,11 @@ snapshots: css.escape@1.5.1: {} + cssstyle@4.6.0: + dependencies: + '@asamuzakjp/css-color': 3.2.0 + rrweb-cssom: 0.8.0 + csstype@3.2.3: {} d3-array@3.2.4: @@ -4978,6 +5247,11 @@ snapshots: d3-timer@3.0.1: {} + data-urls@5.0.0: + dependencies: + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + date-fns@4.4.0: {} debug@4.4.3: @@ -4988,6 +5262,8 @@ snapshots: decimal.js-light@2.5.1: {} + decimal.js@10.6.0: {} + decode-named-character-reference@1.3.0: dependencies: character-entities: 2.0.2 @@ -5002,6 +5278,8 @@ snapshots: define-lazy-prop@2.0.0: {} + delayed-stream@1.0.0: {} + dequal@2.0.3: {} devlop@1.1.0: @@ -5041,6 +5319,8 @@ snapshots: emoji-regex@9.2.2: {} + entities@6.0.1: {} + es-define-property@1.0.1: {} es-errors@1.3.0: {} @@ -5051,6 +5331,13 @@ snapshots: dependencies: es-errors: 1.3.0 + es-set-tostringtag@2.1.0: + dependencies: + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + has-tostringtag: 1.0.2 + hasown: 2.0.4 + esbuild-register@3.6.0(esbuild@0.25.12): dependencies: debug: 4.4.3 @@ -5199,6 +5486,14 @@ snapshots: cross-spawn: 7.0.6 signal-exit: 4.1.0 + form-data@4.0.6: + dependencies: + asynckit: 0.4.0 + combined-stream: 1.0.8 + es-set-tostringtag: 2.1.0 + hasown: 2.0.4 + mime-types: 2.1.35 + format@0.2.2: {} fs.realpath@1.0.0: {} @@ -5315,8 +5610,19 @@ snapshots: dependencies: whatwg-encoding: 2.0.0 + html-encoding-sniffer@4.0.0: + dependencies: + whatwg-encoding: 3.1.1 + html-url-attributes@3.0.1: {} + http-proxy-agent@7.0.2: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + http-proxy@1.18.1: dependencies: eventemitter3: 4.0.7 @@ -5344,6 +5650,13 @@ snapshots: - debug - supports-color + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 @@ -5412,6 +5725,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-potential-custom-element-name@1.0.1: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -5447,6 +5762,34 @@ snapshots: jsdoc-type-pratt-parser@4.8.0: {} + jsdom@25.0.1: + dependencies: + cssstyle: 4.6.0 + data-urls: 5.0.0 + decimal.js: 10.6.0 + form-data: 4.0.6 + html-encoding-sniffer: 4.0.0 + http-proxy-agent: 7.0.2 + https-proxy-agent: 7.0.6 + is-potential-custom-element-name: 1.0.1 + nwsapi: 2.2.28 + parse5: 7.3.0 + rrweb-cssom: 0.7.1 + saxes: 6.0.0 + symbol-tree: 3.2.4 + tough-cookie: 5.1.2 + w3c-xmlserializer: 5.0.0 + webidl-conversions: 7.0.0 + whatwg-encoding: 3.1.1 + whatwg-mimetype: 4.0.0 + whatwg-url: 14.2.0 + ws: 8.21.3 + xml-name-validator: 5.0.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + jsesc@3.1.0: {} json5@2.2.3: {} @@ -5726,6 +6069,12 @@ snapshots: transitivePeerDependencies: - supports-color + mime-db@1.52.0: {} + + mime-types@2.1.35: + dependencies: + mime-db: 1.52.0 + mime@1.6.0: {} min-indent@1.0.1: {} @@ -5768,6 +6117,8 @@ snapshots: inherits: 2.0.4 readable-stream: 1.0.34 + nwsapi@2.2.28: {} + object-assign@4.1.1: {} object-inspect@1.13.4: {} @@ -5823,6 +6174,10 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5@7.3.0: + dependencies: + entities: 6.0.1 + path-exists@4.0.0: {} path-is-absolute@1.0.1: {} @@ -5913,6 +6268,8 @@ snapshots: property-information@7.2.0: {} + punycode@2.3.1: {} + qrcode@1.5.4: dependencies: dijkstrajs: 1.0.3 @@ -6157,6 +6514,10 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.63.1 fsevents: 2.3.3 + rrweb-cssom@0.7.1: {} + + rrweb-cssom@0.8.0: {} + safe-buffer@5.1.2: {} safe-regex-test@1.1.0: @@ -6167,6 +6528,10 @@ snapshots: safer-buffer@2.1.2: {} + saxes@6.0.0: + dependencies: + xmlchars: 2.2.0 + scheduler@0.27.0: {} secure-compare@3.0.1: {} @@ -6315,6 +6680,8 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + symbol-tree@3.2.4: {} + tabbable@6.5.0: {} thenify-all@1.6.0: @@ -6351,6 +6718,20 @@ snapshots: tinyspy@4.0.6: {} + tldts-core@6.1.86: {} + + tldts@6.1.86: + dependencies: + tldts-core: 6.1.86 + + tough-cookie@5.1.2: + dependencies: + tldts: 6.1.86 + + tr46@5.1.1: + dependencies: + punycode: 2.3.1 + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -6538,7 +6919,7 @@ snapshots: '@types/node': 25.9.5 fsevents: 2.3.3 - vitest@3.2.7(@types/debug@4.1.13)(@types/node@25.9.5): + vitest@3.2.7(@types/debug@4.1.13)(@types/node@25.9.5)(jsdom@25.0.1): dependencies: '@types/chai': 5.2.3 '@vitest/expect': 3.2.7 @@ -6566,6 +6947,7 @@ snapshots: optionalDependencies: '@types/debug': 4.1.13 '@types/node': 25.9.5 + jsdom: 25.0.1 transitivePeerDependencies: - jiti - less @@ -6580,12 +6962,29 @@ snapshots: - tsx - yaml + w3c-xmlserializer@5.0.0: + dependencies: + xml-name-validator: 5.0.0 + + webidl-conversions@7.0.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@2.0.0: dependencies: iconv-lite: 0.6.3 + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + + whatwg-mimetype@4.0.0: {} + + whatwg-url@14.2.0: + dependencies: + tr46: 5.1.1 + webidl-conversions: 7.0.0 + which-module@2.0.1: {} which-typed-array@1.1.22: @@ -6629,6 +7028,10 @@ snapshots: ws@8.21.3: {} + xml-name-validator@5.0.0: {} + + xmlchars@2.2.0: {} + xtend@4.0.2: {} y18n@4.0.3: {}