diff --git a/.github/agents/ag-grid-styling.agent.md b/.github/agents/ag-grid-styling.agent.md index e234e4e3..7b491ba2 100644 --- a/.github/agents/ag-grid-styling.agent.md +++ b/.github/agents/ag-grid-styling.agent.md @@ -40,8 +40,8 @@ AG Grid provides a comprehensive theming system built around **themes**, **param import { themeQuartz, themeBalham, themeMaterial } from 'ag-grid-community'; const gridOptions = { - theme: themeQuartz, - // ... other options + theme: themeQuartz, + // ... other options }; ``` @@ -49,8 +49,8 @@ const gridOptions = { ```typescript const gridOptions = { - theme: themeQuartz, - loadThemeGoogleFonts: true, // Automatically loads theme fonts from Google CDN + theme: themeQuartz, + loadThemeGoogleFonts: true, // Automatically loads theme fonts from Google CDN }; ``` @@ -61,6 +61,7 @@ const gridOptions = { Parameters follow a suffix-based type system for validation and IDE support: #### Length Values + - **Suffixes**: Width, Height, Padding, Spacing (or no suffix) - **Supported values**: ```typescript @@ -73,6 +74,7 @@ Parameters follow a suffix-based type system for validation and IDE support: ``` #### Color Values + - **Suffix**: Color - **Supported values**: ```typescript @@ -85,6 +87,7 @@ Parameters follow a suffix-based type system for validation and IDE support: ``` #### Border Values + - **Suffix**: Border - **Supported values**: ```typescript @@ -103,26 +106,29 @@ Parameters follow a suffix-based type system for validation and IDE support: ### Key Parameters for Design Systems #### Core Colors + ```typescript const myTheme = themeQuartz.withParams({ - backgroundColor: 'rgb(249, 245, 227)', // Page background - foregroundColor: 'rgb(126, 46, 132)', // Text color - accentColor: '#2196F3', // Brand/highlight color - borderColor: 'rgba(0, 0, 0, 0.12)', // Default border color + backgroundColor: 'rgb(249, 245, 227)', // Page background + foregroundColor: 'rgb(126, 46, 132)', // Text color + accentColor: '#2196F3', // Brand/highlight color + borderColor: 'rgba(0, 0, 0, 0.12)', // Default border color }); ``` #### Layout & Spacing + ```typescript const myTheme = themeQuartz.withParams({ - spacing: 8, // Base spacing unit - rowHeight: '48px', // Fixed row height - headerHeight: '56px', // Header height - rowVerticalPaddingScale: 1.2, // Scale padding + spacing: 8, // Base spacing unit + rowHeight: '48px', // Fixed row height + headerHeight: '56px', // Header height + rowVerticalPaddingScale: 1.2, // Scale padding }); ``` #### Typography + ```typescript const myTheme = themeQuartz.withParams({ fontFamily: ['Inter', 'system-ui', 'sans-serif'], @@ -145,22 +151,23 @@ const myTheme = themeQuartz.withParams({ Parts are modular components that handle specific features: ```typescript -import { - themeQuartz, - colorSchemeDark, +import { + themeQuartz, + colorSchemeDark, iconSetMaterial, - inputStyleUnderlined + inputStyleUnderlined, } from 'ag-grid-community'; const myTheme = themeQuartz - .withPart(colorSchemeDark) // Dark color scheme - .withPart(iconSetMaterial) // Material icons - .withPart(inputStyleUnderlined); // Material-style inputs + .withPart(colorSchemeDark) // Dark color scheme + .withPart(iconSetMaterial) // Material icons + .withPart(inputStyleUnderlined); // Material-style inputs ``` ### Available Parts by Feature #### Color Schemes + - `colorSchemeVariable` - Default, mode-responsive - `colorSchemeLight` - Neutral light - `colorSchemeLightWarm`/`colorSchemeLightCold` - Tinted light schemes @@ -168,17 +175,20 @@ const myTheme = themeQuartz - `colorSchemeDarkBlue` - Blue-tinted dark (used on AG Grid website) #### Icon Sets + - `iconSetQuartz` - Default icons (customizable stroke width) - `iconSetMaterial` - Material Design icons - `iconSetAlpine` - Alpine theme icons - `iconSetBalham` - Balham theme icons #### Input Styles + - `inputStyleBase` - Unstyled base - `inputStyleBordered` - Bordered inputs - `inputStyleUnderlined` - Material Design style #### Button & UI Styles + - `buttonStyleQuartz`, `buttonStyleAlpine`, `buttonStyleBalham` - `tabStyleQuartz`, `tabStyleMaterial`, `tabStyleRolodex` - `checkboxStyleDefault` @@ -202,7 +212,7 @@ const customCheckboxPart = createPart({ .ag-checkbox-input-wrapper.ag-checked { background-color: var(--ag-checkbox-selected-color); } - ` + `, }); ``` @@ -238,7 +248,7 @@ All theme parameters are implemented as CSS custom properties with `--ag-` prefi --primary-color: #2196f3; --text-color: #333; --spacing-unit: 8px; - + /* Map to AG Grid variables */ --ag-accent-color: var(--primary-color); --ag-foreground-color: var(--text-color); @@ -305,12 +315,12 @@ Target grid elements using CSS class selectors: --ag-spacing: 12px; font-size: 16px; /* Prevent zoom on iOS */ } - + /* Tablet adjustments */ @media (min-width: 769px) and (max-width: 1024px) { --ag-spacing: 10px; } - + /* Desktop optimizations */ @media (min-width: 1025px) { --ag-spacing: 8px; @@ -319,8 +329,8 @@ Target grid elements using CSS class selectors: /* Hide/show columns based on screen size */ @media (max-width: 768px) { - .ag-theme-quartz .ag-header-cell[col-id="description"], - .ag-theme-quartz .ag-cell[col-id="description"] { + .ag-theme-quartz .ag-header-cell[col-id='description'], + .ag-theme-quartz .ag-cell[col-id='description'] { display: none; } } @@ -341,16 +351,22 @@ Use `data-ag-theme-mode` attribute for dynamic theme switching: ```typescript // Custom theme modes const myTheme = themeQuartz - .withParams({ - backgroundColor: '#ffffff', - foregroundColor: '#333333', - accentColor: '#2196f3', - }, 'light') - .withParams({ - backgroundColor: '#1a1a1a', - foregroundColor: '#ffffff', - accentColor: '#64b5f6', - }, 'dark'); + .withParams( + { + backgroundColor: '#ffffff', + foregroundColor: '#333333', + accentColor: '#2196f3', + }, + 'light' + ) + .withParams( + { + backgroundColor: '#1a1a1a', + foregroundColor: '#ffffff', + accentColor: '#64b5f6', + }, + 'dark' + ); ``` ```javascript @@ -368,20 +384,20 @@ const designSystemTheme = themeQuartz.withParams({ backgroundColor: 'var(--ds-surface-primary)', foregroundColor: 'var(--ds-text-primary)', accentColor: 'var(--ds-color-primary)', - + // Semantic colors dataBackgroundColor: 'var(--ds-surface-secondary)', headerBackgroundColor: 'var(--ds-surface-elevated)', - + // Interactive states cellHoverBackgroundColor: 'var(--ds-surface-hover)', rowHoverBackgroundColor: 'var(--ds-surface-hover)', selectedBackgroundColor: 'var(--ds-surface-selected)', - + // Borders and dividers borderColor: 'var(--ds-border-default)', headerColumnBorder: 'var(--ds-border-subtle)', - + // Status colors invalidColor: 'var(--ds-color-error)', successColor: 'var(--ds-color-success)', @@ -398,18 +414,18 @@ const headerTheme = themeQuartz.withParams({ // Header dimensions headerHeight: '56px', headerVerticalPaddingScale: 1.5, - + // Header colors headerBackgroundColor: '#f5f5f5', headerTextColor: '#333', headerCellHoverBackgroundColor: 'rgba(0, 0, 0, 0.05)', - + // Header borders and separators headerColumnBorder: { width: 1, style: 'solid', color: '#e0e0e0' }, headerColumnBorderHeight: '60%', headerColumnResizeHandleColor: '#2196f3', headerColumnResizeHandleWidth: '3px', - + // Header typography headerFontWeight: '600', headerFontSize: '14px', @@ -521,23 +537,23 @@ const headerTheme = themeQuartz.withParams({ import { iconSetMaterial, iconOverrides } from 'ag-grid-community'; // Use Material Design icons -const materialTheme = themeQuartz - .withPart(iconSetMaterial) - .withParams({ - iconSize: 18, // Material icons work best at 18, 24, 36, 48px - }); +const materialTheme = themeQuartz.withPart(iconSetMaterial).withParams({ + iconSize: 18, // Material icons work best at 18, 24, 36, 48px +}); // Custom icon font integration const fontAwesomeIcons = iconOverrides({ type: 'font', family: 'Font Awesome 6 Pro', - cssImports: ['https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css'], + cssImports: [ + 'https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css', + ], weight: '900', icons: { - asc: '\uf0de', // fa-sort-up - desc: '\uf0dd', // fa-sort-down - filter: '\uf0b0', // fa-filter - menu: '\uf0c9', // fa-bars + asc: '\uf0de', // fa-sort-up + desc: '\uf0dd', // fa-sort-down + filter: '\uf0b0', // fa-filter + menu: '\uf0c9', // fa-bars }, }); ``` @@ -552,7 +568,7 @@ const svgIconOverrides = iconOverrides({ filter: { svg: ` - ` + `, }, // Add more icons as needed }, @@ -599,10 +615,10 @@ AG Grid uses DOM virtualisation to render only visible elements: ```typescript const gridOptions = { // Virtualization settings - rowBuffer: 10, // Render extra rows for smooth scrolling + rowBuffer: 10, // Render extra rows for smooth scrolling suppressMaxRenderedRowRestriction: true, // Remove 500 row limit if needed - suppressColumnVirtualisation: false, // Keep column virtualization - suppressRowVirtualisation: false, // Keep row virtualization + suppressColumnVirtualisation: false, // Keep column virtualization + suppressRowVirtualisation: false, // Keep row virtualization }; ``` @@ -643,7 +659,11 @@ const gridOptions = { ```typescript // Create minimal theme for smaller bundle size -import { createTheme, colorSchemeLight, iconSetQuartz } from 'ag-grid-community'; +import { + createTheme, + colorSchemeLight, + iconSetQuartz, +} from 'ag-grid-community'; const minimalTheme = createTheme() .withPart(colorSchemeLight) @@ -664,14 +684,19 @@ const minimalTheme = createTheme() } @keyframes flash { - 0% { background-color: rgba(76, 175, 80, 0.3); } - 100% { background-color: transparent; } + 0% { + background-color: rgba(76, 175, 80, 0.3); + } + 100% { + background-color: transparent; + } } ``` ## Enterprise vs Community Styling ### Community Features (Free) + - All core theming capabilities - Theme parameters and parts - CSS customization @@ -680,6 +705,7 @@ const minimalTheme = createTheme() - Basic cell and header styling ### Enterprise Features (Licensed) + - **Advanced Tool Panels**: Column and filter tool panel styling - **Context Menus**: Enterprise context menu theming - **Master/Detail**: Nested grid styling @@ -732,6 +758,7 @@ const minimalTheme = createTheme() ### Browser Support AG Grid themes support all modern browsers: + - Chrome 70+ - Firefox 63+ - Safari 12+ @@ -744,11 +771,11 @@ AG Grid themes support all modern browsers: .ag-theme-quartz { /* CSS Grid for layout */ display: grid; - grid-template-areas: "header header" "sidebar content"; - + grid-template-areas: 'header header' 'sidebar content'; + /* CSS Custom Properties */ --ag-spacing: 8px; - + /* Flexbox for component alignment */ } @@ -767,24 +794,24 @@ AG Grid themes support all modern browsers: --ag-row-height: 56px; --ag-header-height: 64px; } - + /* Fallback media queries */ @media (max-width: 600px) { --ag-row-height: 56px; --ag-header-height: 64px; - + /* Hide less important columns */ - .ag-header-cell[col-id="description"], - .ag-cell[col-id="description"] { + .ag-header-cell[col-id='description'], + .ag-cell[col-id='description'] { display: none; } - + /* Stack filter controls */ .ag-filter-panel { flex-direction: column; } } - + /* High DPI displays */ @media (-webkit-min-device-pixel-ratio: 2) { /* Adjust for retina displays */ @@ -802,13 +829,13 @@ AG Grid themes support all modern browsers: --ag-row-height: 48px; --ag-header-height: 56px; --ag-spacing: 12px; - + /* Larger touch targets */ .ag-checkbox-input-wrapper { width: 20px; height: 20px; } - + /* Easier scrolling */ .ag-body-viewport { -webkit-overflow-scrolling: touch; @@ -870,10 +897,10 @@ import { AgGridReact } from 'ag-grid-react'; const StyledGridWrapper = styled.div` .ag-theme-quartz { - --ag-accent-color: ${props => props.theme.colors.primary}; - --ag-background-color: ${props => props.theme.colors.surface}; - --ag-foreground-color: ${props => props.theme.colors.onSurface}; - --ag-spacing: ${props => props.theme.spacing.sm}; + --ag-accent-color: ${(props) => props.theme.colors.primary}; + --ag-background-color: ${(props) => props.theme.colors.surface}; + --ag-foreground-color: ${(props) => props.theme.colors.onSurface}; + --ag-spacing: ${(props) => props.theme.spacing.sm}; } `; @@ -904,26 +931,26 @@ interface DataGridProps { // ... other props } -export const DataGrid: React.FC = ({ +export const DataGrid: React.FC = ({ variant = 'default', colorScheme = 'light', - ...props + ...props }) => { const theme = useMemo(() => { let baseTheme = appGridTheme; - + // Apply variant if (variant === 'compact') { baseTheme = baseTheme.withParams({ spacing: 4, rowHeight: '32px' }); } else if (variant === 'comfortable') { baseTheme = baseTheme.withParams({ spacing: 12, rowHeight: '56px' }); } - + // Apply color scheme if (colorScheme === 'dark') { baseTheme = baseTheme.withPart(colorSchemeDark); } - + return baseTheme; }, [variant, colorScheme]); @@ -979,8 +1006,12 @@ themes/ } /* 4. Utility classes */ -.grid-compact { --ag-spacing: 4px; } -.grid-comfortable { --ag-spacing: 12px; } +.grid-compact { + --ag-spacing: 4px; +} +.grid-comfortable { + --ag-spacing: 12px; +} ``` ### Development Workflow @@ -1001,12 +1032,12 @@ export const themeTestUtils = { checkContrast: (backgroundColor: string, textColor: string) => { // Implementation for WCAG compliance testing }, - + // Validate responsive breakpoints testResponsiveness: (theme: Theme) => { // Test theme at different viewport sizes }, - + // Performance benchmarking measureRenderTime: (gridOptions: GridOptions) => { // Measure initial render and scroll performance @@ -1056,56 +1087,62 @@ export const themeTestUtils = { AG Grid's theming system provides comprehensive tools for design system integration through its three-pillar approach: **themes**, **parameters**, and **parts**. By leveraging CSS custom properties, modular parts system, and extensive customization options, you can create consistent, maintainable, and performant data grid experiences that align perfectly with your design system. The key to successful implementation is starting with the appropriate built-in theme, mapping your design tokens to AG Grid parameters, and progressively enhancing with custom CSS while respecting the grid's architecture and performance characteristics. + - `--mieweb-shadow-card` - Card shadow ### Tailwind Preset Mappings The `tailwind-preset.ts` maps CSS variables to Tailwind classes: -| Tailwind Class | CSS Variable | -|---------------|--------------| -| `primary-500` | `var(--mieweb-primary-500)` | +| Tailwind Class | CSS Variable | +| --------------- | ----------------------------- | +| `primary-500` | `var(--mieweb-primary-500)` | | `secondary-500` | `var(--mieweb-secondary-500)` | -| `neutral-500` | `var(--mieweb-neutral-500)` | -| `rounded-lg` | `var(--mieweb-radius-lg)` | -| `rounded-2xl` | `var(--mieweb-radius-2xl)` | -| `font-sans` | `var(--mieweb-font-sans)` | +| `neutral-500` | `var(--mieweb-neutral-500)` | +| `rounded-lg` | `var(--mieweb-radius-lg)` | +| `rounded-2xl` | `var(--mieweb-radius-2xl)` | +| `font-sans` | `var(--mieweb-font-sans)` | ## What to Flag as Issues ### ❌ Hardcoded Colors (BAD) + ```tsx // These bypass the branding system: -className="bg-violet-500" // Hardcoded violet -className="bg-purple-600" // Hardcoded purple -className="bg-blue-500" // Hardcoded blue -className="text-indigo-600" // Hardcoded indigo -className="from-violet-500 to-purple-600" // Hardcoded gradients +className = 'bg-violet-500'; // Hardcoded violet +className = 'bg-purple-600'; // Hardcoded purple +className = 'bg-blue-500'; // Hardcoded blue +className = 'text-indigo-600'; // Hardcoded indigo +className = 'from-violet-500 to-purple-600'; // Hardcoded gradients ``` ### ✅ Brand-Aware Colors (GOOD) + ```tsx // These respect the active brand: -className="bg-primary-500" // Uses brand primary -className="text-primary-600" // Uses brand primary -className="bg-secondary-500" // Uses brand secondary -className="text-neutral-700" // Uses brand neutral +className = 'bg-primary-500'; // Uses brand primary +className = 'text-primary-600'; // Uses brand primary +className = 'bg-secondary-500'; // Uses brand secondary +className = 'text-neutral-700'; // Uses brand neutral ``` ### Exceptions - Semantic Colors (OKAY) + These are intentionally hardcoded for consistent meaning across brands: + - `bg-red-*`, `text-red-*` - Error/danger states -- `bg-green-*`, `text-green-*` - Success states +- `bg-green-*`, `text-green-*` - Success states - `bg-amber-*`, `bg-yellow-*` - Warning states - `bg-neutral-*` - Only if specifically for UI chrome, not brand expression ### Border Radius Issues + ```tsx // Check if these use brand radius variables: -className="rounded-lg" // ✅ Mapped to --mieweb-radius-lg -className="rounded-2xl" // ✅ Mapped to --mieweb-radius-2xl -className="rounded-full" // ✅ OK for circular elements (avatars, pills) -className="rounded-[20px]" // ❌ Hardcoded - should use brand token +className = 'rounded-lg'; // ✅ Mapped to --mieweb-radius-lg +className = 'rounded-2xl'; // ✅ Mapped to --mieweb-radius-2xl +className = 'rounded-full'; // ✅ OK for circular elements (avatars, pills) +className = 'rounded-[20px]'; // ❌ Hardcoded - should use brand token ``` ## Audit Process @@ -1139,25 +1176,26 @@ When reporting issues, use this format: **File:** `src/components/ComponentName/ComponentName.tsx` -| Line | Issue | Current | Recommended | -|------|-------|---------|-------------| -| 45 | Hardcoded color | `bg-violet-500` | `bg-primary-500` | -| 67 | Hardcoded gradient | `from-violet-500 to-purple-600` | `bg-primary-500` | +| Line | Issue | Current | Recommended | +| ---- | ------------------ | ------------------------------- | ---------------- | +| 45 | Hardcoded color | `bg-violet-500` | `bg-primary-500` | +| 67 | Hardcoded gradient | `from-violet-500 to-purple-600` | `bg-primary-500` | **Summary:** + - ✅ Border radius: Using brand tokens correctly - ❌ Colors: 2 hardcoded colors found - ✅ Typography: Using font-sans correctly ## Brand Reference -| Brand | Primary Color | Example | -|-------|---------------|---------| -| BlueHive | Blue `#27aae1` | Healthcare/Medical | -| MIEWeb | Purple | Enterprise | -| WebChart | Blue | Clinical | -| Enterprise Health | Teal | Corporate | -| Waggleline | Orange | Consumer | +| Brand | Primary Color | Example | +| ----------------- | -------------- | ------------------ | +| BlueHive | Blue `#27aae1` | Healthcare/Medical | +| MIEWeb | Purple | Enterprise | +| WebChart | Blue | Clinical | +| Enterprise Health | Teal | Corporate | +| Waggleline | Orange | Consumer | ## Key Files to Reference diff --git a/.github/agents/style.agent.md b/.github/agents/style.agent.md index b2059b16..3aac7d27 100644 --- a/.github/agents/style.agent.md +++ b/.github/agents/style.agent.md @@ -1,7 +1,8 @@ --- description: Audit React components for mieweb/ui branding compliance - colors, border radius, fonts, and design tokens name: Style Agent -tools: ['search', 'codebase', 'editFiles', 'terminalLastCommand', 'runInTerminal'] +tools: + ['search', 'codebase', 'editFiles', 'terminalLastCommand', 'runInTerminal'] model: Claude Sonnet 4 handoffs: - label: Apply Fixes @@ -17,6 +18,7 @@ You are a specialized style auditor for the **mieweb/ui** design system. Your jo ## Your Expertise You are an expert in: + - Tailwind CSS utility classes - CSS custom properties (CSS variables) - React component patterns @@ -29,14 +31,16 @@ You are an expert in: The branding system uses CSS variables defined per brand. Each brand (BlueHive, MIEWeb, WebChart, Enterprise Health, Waggleline) defines: **Color Variables:** + - `--mieweb-primary-{50-950}` - Primary brand color scale -- `--mieweb-secondary-{50-950}` - Secondary color scale +- `--mieweb-secondary-{50-950}` - Secondary color scale - `--mieweb-neutral-{50-950}` - Neutral/gray scale - `--mieweb-success` / `--mieweb-success-foreground` - Success semantic color - `--mieweb-destructive` / `--mieweb-destructive-foreground` - Error/danger semantic color - `--mieweb-warning` / `--mieweb-warning-foreground` - Warning semantic color **Border Radius Variables:** + - `--mieweb-radius-sm` (0.25rem) - `--mieweb-radius-md` (0.5rem) - `--mieweb-radius-lg` (0.75rem) @@ -44,60 +48,67 @@ The branding system uses CSS variables defined per brand. Each brand (BlueHive, - `--mieweb-radius-2xl` (1.5rem) **Typography Variables:** + - `--mieweb-font-sans` - Primary font family - `--mieweb-font-mono` - Monospace font family **Shadow Variables:** + - `--mieweb-shadow-card` - Card shadow ### Tailwind Preset Mappings The `tailwind-preset.ts` maps CSS variables to Tailwind classes: -| Tailwind Class | CSS Variable | -|---------------|--------------| -| `primary-500` | `var(--mieweb-primary-500)` | +| Tailwind Class | CSS Variable | +| --------------- | ----------------------------- | +| `primary-500` | `var(--mieweb-primary-500)` | | `secondary-500` | `var(--mieweb-secondary-500)` | -| `neutral-500` | `var(--mieweb-neutral-500)` | -| `rounded-lg` | `var(--mieweb-radius-lg)` | -| `rounded-2xl` | `var(--mieweb-radius-2xl)` | -| `font-sans` | `var(--mieweb-font-sans)` | +| `neutral-500` | `var(--mieweb-neutral-500)` | +| `rounded-lg` | `var(--mieweb-radius-lg)` | +| `rounded-2xl` | `var(--mieweb-radius-2xl)` | +| `font-sans` | `var(--mieweb-font-sans)` | ## What to Flag as Issues ### ❌ Hardcoded Colors (BAD) + ```tsx // These bypass the branding system: -className="bg-violet-500" // Hardcoded violet -className="bg-purple-600" // Hardcoded purple -className="bg-blue-500" // Hardcoded blue -className="text-indigo-600" // Hardcoded indigo -className="from-violet-500 to-purple-600" // Hardcoded gradients +className = 'bg-violet-500'; // Hardcoded violet +className = 'bg-purple-600'; // Hardcoded purple +className = 'bg-blue-500'; // Hardcoded blue +className = 'text-indigo-600'; // Hardcoded indigo +className = 'from-violet-500 to-purple-600'; // Hardcoded gradients ``` ### ✅ Brand-Aware Colors (GOOD) + ```tsx // These respect the active brand: -className="bg-primary-500" // Uses brand primary -className="text-primary-600" // Uses brand primary -className="bg-secondary-500" // Uses brand secondary -className="text-neutral-700" // Uses brand neutral +className = 'bg-primary-500'; // Uses brand primary +className = 'text-primary-600'; // Uses brand primary +className = 'bg-secondary-500'; // Uses brand secondary +className = 'text-neutral-700'; // Uses brand neutral ``` ### Exceptions - Semantic Colors (OKAY) + These are intentionally hardcoded for consistent meaning across brands: + - `bg-red-*`, `text-red-*` - Error/danger states -- `bg-green-*`, `text-green-*` - Success states +- `bg-green-*`, `text-green-*` - Success states - `bg-amber-*`, `bg-yellow-*` - Warning states - `bg-neutral-*` - Only if specifically for UI chrome, not brand expression ### Border Radius Issues + ```tsx // Check if these use brand radius variables: -className="rounded-lg" // ✅ Mapped to --mieweb-radius-lg -className="rounded-2xl" // ✅ Mapped to --mieweb-radius-2xl -className="rounded-full" // ✅ OK for circular elements (avatars, pills) -className="rounded-[20px]" // ❌ Hardcoded - should use brand token +className = 'rounded-lg'; // ✅ Mapped to --mieweb-radius-lg +className = 'rounded-2xl'; // ✅ Mapped to --mieweb-radius-2xl +className = 'rounded-full'; // ✅ OK for circular elements (avatars, pills) +className = 'rounded-[20px]'; // ❌ Hardcoded - should use brand token ``` ## Audit Process @@ -131,25 +142,26 @@ When reporting issues, use this format: **File:** `src/components/ComponentName/ComponentName.tsx` -| Line | Issue | Current | Recommended | -|------|-------|---------|-------------| -| 45 | Hardcoded color | `bg-violet-500` | `bg-primary-500` | -| 67 | Hardcoded gradient | `from-violet-500 to-purple-600` | `bg-primary-500` | +| Line | Issue | Current | Recommended | +| ---- | ------------------ | ------------------------------- | ---------------- | +| 45 | Hardcoded color | `bg-violet-500` | `bg-primary-500` | +| 67 | Hardcoded gradient | `from-violet-500 to-purple-600` | `bg-primary-500` | **Summary:** + - ✅ Border radius: Using brand tokens correctly - ❌ Colors: 2 hardcoded colors found - ✅ Typography: Using font-sans correctly ## Brand Reference -| Brand | Primary Color | Example | -|-------|---------------|---------| -| BlueHive | Blue `#27aae1` | Healthcare/Medical | -| MIEWeb | Purple | Enterprise | -| WebChart | Blue | Clinical | -| Enterprise Health | Teal | Corporate | -| Waggleline | Orange | Consumer | +| Brand | Primary Color | Example | +| ----------------- | -------------- | ------------------ | +| BlueHive | Blue `#27aae1` | Healthcare/Medical | +| MIEWeb | Purple | Enterprise | +| WebChart | Blue | Clinical | +| Enterprise Health | Teal | Corporate | +| Waggleline | Orange | Consumer | ## Key Files to Reference diff --git a/.github/prompts/commit.prompt.md b/.github/prompts/commit.prompt.md index 09eac387..96e5fa21 100644 --- a/.github/prompts/commit.prompt.md +++ b/.github/prompts/commit.prompt.md @@ -11,21 +11,25 @@ Run the standard commit workflow: format, lint, and commit with a generated mess Execute these steps in order: 1. **Format the code** + ```bash npm run format:fix ``` 2. **Lint and fix issues** + ```bash npm run lint:fix ``` 3. **Check for any remaining errors** + ```bash npm run lint && npm run typecheck ``` 4. **Stage all changes** + ```bash git add -A ``` diff --git a/.github/prompts/fix.prompt.md b/.github/prompts/fix.prompt.md index 4806f50b..95598011 100644 --- a/.github/prompts/fix.prompt.md +++ b/.github/prompts/fix.prompt.md @@ -11,11 +11,13 @@ Auto-fix all formatting and linting issues. Execute these steps: 1. **Auto-format code** + ```bash npm run format:fix ``` 2. **Auto-fix lint issues** + ```bash npm run lint:fix ``` diff --git a/.github/prompts/validate.prompt.md b/.github/prompts/validate.prompt.md index a7c25655..2387d473 100644 --- a/.github/prompts/validate.prompt.md +++ b/.github/prompts/validate.prompt.md @@ -11,11 +11,13 @@ Run validation checks without committing. Execute these steps and report results: 1. **Format check** + ```bash npm run format ``` 2. **Lint check** + ```bash npm run lint ``` diff --git a/.storybook/manager.ts b/.storybook/manager.ts index 17bc3f81..ddaec014 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -64,7 +64,7 @@ type BrandKey = keyof typeof brandThemes; // Create a theme for a specific brand function createBrandTheme(brandKey: BrandKey, isDark = false) { const brand = brandThemes[brandKey] || brandThemes.bluehive; - + if (isDark) { return create({ base: 'dark', @@ -112,7 +112,7 @@ function createBrandTheme(brandKey: BrandKey, isDark = false) { fontCode: '"SF Mono", "Monaco", "Consolas", monospace', }); } - + return create({ base: 'light', @@ -181,13 +181,13 @@ const styleId = 'mieweb-manager-theme'; function injectBrandCSS(brandKey: BrandKey, isDark = false) { const brand = brandThemes[brandKey] || brandThemes.bluehive; - + // Remove existing style const existingStyle = document.getElementById(styleId); if (existingStyle) { existingStyle.remove(); } - + // Dark mode colors const bgColor = isDark ? brand.appBgDark : brand.appBg; const borderColor = isDark ? brand.borderColorDark : brand.borderColor; @@ -197,7 +197,7 @@ function injectBrandCSS(brandKey: BrandKey, isDark = false) { const barBg = isDark ? '#27272a' : '#ffffff'; const inputBg = isDark ? '#27272a' : '#ffffff'; const inputBorder = isDark ? '#3f3f46' : '#d1d5db'; - + // Create new style with brand colors const style = document.createElement('style'); style.id = styleId; @@ -399,9 +399,11 @@ function injectBrandCSS(brandKey: BrandKey, isDark = false) { label[for^="control-"] input[type="checkbox"] { background: transparent !important; } - ` : ''} + ` + : '' + } `; - + document.head.appendChild(style); } @@ -411,18 +413,18 @@ addons.register('mieweb-brand-sync', (api) => { const initialGlobals = api.getGlobals(); const initialBrand = (initialGlobals?.brand || 'bluehive') as BrandKey; const initialDark = initialGlobals?.theme === 'dark'; - + // Apply initial theme injectBrandCSS(initialBrand, initialDark); if (initialDark) { api.setOptions({ theme: createBrandTheme(initialBrand, true) }); } - + // Listen for global changes api.on('globalsUpdated', ({ globals }) => { const brand = (globals?.brand || 'bluehive') as BrandKey; const isDark = globals?.theme === 'dark'; - + // Update CSS and theme injectBrandCSS(brand, isDark); api.setOptions({ theme: createBrandTheme(brand, isDark) }); diff --git a/README.md b/README.md index 64d72470..b5912173 100644 --- a/README.md +++ b/README.md @@ -670,10 +670,10 @@ This package uses automated releases via GitHub Actions. There are two release c ### Release Channels -| Channel | npm Tag | Install Command | Description | -| ------- | ------- | --------------- | ----------- | -| **Stable** | `latest` | `npm install @mieweb/ui` | Production-ready releases | -| **Prerelease** | `next` | `npm install @mieweb/ui@next` | Latest from `main` branch | +| Channel | npm Tag | Install Command | Description | +| -------------- | -------- | ----------------------------- | ------------------------- | +| **Stable** | `latest` | `npm install @mieweb/ui` | Production-ready releases | +| **Prerelease** | `next` | `npm install @mieweb/ui@next` | Latest from `main` branch | ### Prerelease (Automatic) @@ -699,6 +699,7 @@ To create a stable release: 5. Click **Run workflow** The workflow will: + 1. Bump the version in `package.json` 2. Commit and push the change 3. Create a git tag (e.g., `v0.2.0`) @@ -715,6 +716,7 @@ git push origin v1.0.0 ``` The release workflow will automatically: + - Run tests and build - Publish to npm with the appropriate tag (`latest` for stable, `next` for prereleases like `v1.0.0-beta.1`) - Create a GitHub Release with auto-generated release notes diff --git a/TESTING.md b/TESTING.md index 35063726..44d5c5e2 100644 --- a/TESTING.md +++ b/TESTING.md @@ -5,17 +5,20 @@ This document outlines the comprehensive testing strategy for the MIE UI compone ## Testing Stack ### Unit & Integration Testing + - **Vitest** - Fast unit test runner with Jest compatibility - **React Testing Library** - Component testing utilities - **Jest DOM** - Additional DOM testing matchers - **User Events** - Realistic user interaction simulation ### Visual Regression Testing + - **Playwright** - Browser automation for visual testing - **Chromatic** - Visual regression testing service integrated with Storybook - **Storybook** - Component documentation and testing environment ### Code Quality + - **ESLint** - Code linting and best practices - **TypeScript** - Type checking - **Prettier** - Code formatting @@ -43,6 +46,7 @@ tests/ ## Running Tests ### Unit Tests + ```bash # Run all unit tests npm run test @@ -55,6 +59,7 @@ npm run test:coverage ``` ### Visual Regression Tests + ```bash # Install Playwright browsers (one-time setup) npm run playwright:install @@ -73,6 +78,7 @@ npx playwright test --update-snapshots ``` ### Storybook + ```bash # Start Storybook development server npm run storybook @@ -86,6 +92,7 @@ npm run build-storybook ### Unit Tests #### Basic Component Test + ```typescript import { describe, it, expect, vi } from 'vitest'; import { screen } from '@testing-library/react'; @@ -101,7 +108,7 @@ describe('Button', () => { it('handles click events', () => { const handleClick = vi.fn(); renderWithTheme(); - + fireEvent.click(screen.getByRole('button')); expect(handleClick).toHaveBeenCalledTimes(1); }); @@ -109,18 +116,19 @@ describe('Button', () => { ``` #### Testing with User Events + ```typescript import userEvent from '@testing-library/user-event'; it('handles user input', async () => { const user = userEvent.setup(); const handleChange = vi.fn(); - + renderWithTheme(); - + const input = screen.getByRole('textbox'); await user.type(input, 'Hello World'); - + expect(input).toHaveValue('Hello World'); expect(handleChange).toHaveBeenCalled(); }); @@ -129,35 +137,40 @@ it('handles user input', async () => { ### Visual Regression Tests #### Basic Visual Test + ```typescript import { test, expect } from '@playwright/test'; test('Button - Default state', async ({ page }) => { await page.goto('/iframe.html?id=button--default&viewMode=story'); await page.waitForLoadState('networkidle'); - + await expect(page).toHaveScreenshot('button-default.png'); }); ``` #### Interactive State Testing + ```typescript test('Button - Hover state', async ({ page }) => { await page.goto('/iframe.html?id=button--default&viewMode=story'); - + const button = page.getByRole('button').first(); await button.hover(); - + await expect(page).toHaveScreenshot('button-hover.png'); }); ``` #### Theme Testing + ```typescript test('Button - Dark theme', async ({ page }) => { - await page.goto('/iframe.html?id=button--default&viewMode=story&globals=theme:dark'); + await page.goto( + '/iframe.html?id=button--default&viewMode=story&globals=theme:dark' + ); await page.waitForLoadState('networkidle'); - + await expect(page).toHaveScreenshot('button-dark.png'); }); ``` @@ -165,6 +178,7 @@ test('Button - Dark theme', async ({ page }) => { ## Testing Best Practices ### Unit Tests + 1. **Test behavior, not implementation** - Focus on what the component does, not how it does it 2. **Use descriptive test names** - Make it clear what is being tested 3. **Test accessibility** - Ensure components work with screen readers and keyboard navigation @@ -172,6 +186,7 @@ test('Button - Dark theme', async ({ page }) => { 5. **Test error states** - Verify components handle errors gracefully ### Visual Tests + 1. **Wait for animations** - Use `waitForLoadState('networkidle')` or specific waits 2. **Test multiple states** - Default, hover, focus, disabled, etc. 3. **Test responsive design** - Different viewport sizes @@ -179,6 +194,7 @@ test('Button - Dark theme', async ({ page }) => { 5. **Use meaningful names** - Screenshot names should be descriptive ### Storybook Stories + 1. **Cover all variants** - Every prop combination should have a story 2. **Include interactive examples** - Show real usage patterns 3. **Document accessibility** - Use the a11y addon @@ -187,20 +203,25 @@ test('Button - Dark theme', async ({ page }) => { ## Test Configuration ### Vitest Configuration + The project uses a custom Vitest configuration with: + - JSdom environment for DOM testing - Jest DOM matchers for enhanced assertions - Coverage reporting with thresholds - Path aliases for clean imports -### Playwright Configuration +### Playwright Configuration + The visual tests are configured to: + - Run against multiple browsers (Chrome, Firefox, Safari) - Test desktop and mobile viewports - Start Storybook automatically - Generate HTML reports with screenshots ### Coverage Requirements + - **Branches**: 80% - **Functions**: 80% - **Lines**: 80% @@ -209,6 +230,7 @@ The visual tests are configured to: ## Continuous Integration The CI pipeline runs: + 1. **Linting and type checking** 2. **Unit tests with coverage** 3. **Visual regression tests** @@ -218,6 +240,7 @@ The CI pipeline runs: 7. **Security auditing** ### Visual Review Process + 1. **Automated tests** catch obvious regressions 2. **Chromatic reviews** for detailed visual changes 3. **Manual review** for complex interactions @@ -228,19 +251,23 @@ The CI pipeline runs: ### Common Issues #### Visual Tests Failing + - **Fonts not loading**: Add font loading waits - **Animations**: Add specific wait times - **Browser differences**: Check if it's browser-specific - **Timing issues**: Use `waitForLoadState('networkidle')` #### Unit Tests Failing + - **Missing mocks**: Ensure external dependencies are mocked - **Async operations**: Use proper async/await patterns - **DOM cleanup**: Tests should clean up after themselves - **Theme context**: Use `renderWithTheme` for themed components ### Updating Visual Baselines + When components intentionally change: + ```bash # Update all snapshots npx playwright test --update-snapshots @@ -255,4 +282,4 @@ npx playwright test components.spec.ts --update-snapshots - [React Testing Library](https://testing-library.com/docs/react-testing-library/intro/) - [Playwright Documentation](https://playwright.dev/) - [Storybook Testing](https://storybook.js.org/docs/react/writing-tests/introduction) -- [Jest DOM Matchers](https://github.com/testing-library/jest-dom) \ No newline at end of file +- [Jest DOM Matchers](https://github.com/testing-library/jest-dom) diff --git a/eslint.config.js b/eslint.config.js index 11f0a092..7e95c1e8 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,9 +1,9 @@ import eslint from '@eslint/js'; import tseslint from '@typescript-eslint/eslint-plugin'; import tsparser from '@typescript-eslint/parser'; +import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; import reactPlugin from 'eslint-plugin-react'; import reactHooksPlugin from 'eslint-plugin-react-hooks'; -import jsxA11yPlugin from 'eslint-plugin-jsx-a11y'; export default [ eslint.configs.recommended, diff --git a/package.json b/package.json index 98b20b10..79713613 100644 --- a/package.json +++ b/package.json @@ -184,6 +184,7 @@ "ag-grid-react": ">=32.0.0", "datavis-ace": "=4.0.0-PRE.2", "js-yaml": ">=4.0.0", + "mapbox-gl": ">=2.0.0", "mermaid": ">=10.0.0", "papaparse": ">=5.0.0", "react": ">=18.0.0", @@ -209,6 +210,9 @@ "js-yaml": { "optional": true }, + "mapbox-gl": { + "optional": true + }, "mermaid": { "optional": true }, @@ -265,6 +269,7 @@ "@types/google-libphonenumber": "^7.4.30", "@types/js-yaml": "^4.0.9", "@types/luxon": "^3.7.1", + "@types/mapbox-gl": "^3.4.1", "@types/node": "^22.19.11", "@types/papaparse": "^5.3.16", "@types/react": "^19.2.14", @@ -294,6 +299,7 @@ "eslint-plugin-storybook": "^10.2.11", "js-yaml": "^4.1.1", "jsdom": "^26.1.0", + "mapbox-gl": "^3.18.1", "mermaid": "^11.12.3", "papaparse": "^5.5.3", "postcss": "^8.5.6", diff --git a/playwright.config.ts b/playwright.config.ts index c3935b61..e835740a 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -16,12 +16,10 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: [ - ['list'], - ['html', { outputFolder: 'playwright-report' }], - ], + reporter: [['list'], ['html', { outputFolder: 'playwright-report' }]], /* Snapshot path template - use platform-agnostic names for cross-platform CI */ - snapshotPathTemplate: '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', + snapshotPathTemplate: + '{testDir}/{testFileDir}/{testFileName}-snapshots/{arg}{ext}', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { @@ -59,4 +57,4 @@ export default defineConfig({ stdout: 'ignore', stderr: 'pipe', }, -}); \ No newline at end of file +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 73c98e69..e3e50b2b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -129,6 +129,9 @@ importers: '@types/luxon': specifier: ^3.7.1 version: 3.7.1 + '@types/mapbox-gl': + specifier: ^3.4.1 + version: 3.5.0 '@types/node': specifier: ^22.19.11 version: 22.19.11 @@ -216,6 +219,9 @@ importers: jsdom: specifier: ^26.1.0 version: 26.1.0 + mapbox-gl: + specifier: ^3.18.1 + version: 3.24.0 mermaid: specifier: ^11.12.3 version: 11.15.0 @@ -266,7 +272,7 @@ importers: version: 7.12.1 ychart: specifier: file:./packages/ychart - version: file:packages/ychart + version: '@mieweb/ychart@file:packages/ychart(@popperjs/core@2.11.8)' zod: specifier: ^4.4.3 version: 4.4.3 @@ -919,6 +925,21 @@ packages: '@lezer/yaml@1.0.4': resolution: {integrity: sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==} + '@mapbox/mapbox-gl-supported@3.0.0': + resolution: {integrity: sha512-2XghOwu16ZwPJLOFVuIOaLbN0iKMn867evzXFyf0P22dqugezfJwLmdanAgU25ITvz1TvOfVP4jsDImlDJzcWg==} + + '@mapbox/point-geometry@1.1.0': + resolution: {integrity: sha512-YGcBz1cg4ATXDCM/71L9xveh4dynfGmcLDqufR+nQQy3fKwsAZsWd/x4621/6uJaeB9mwOHE6hPeDgXz9uViUQ==} + + '@mapbox/tiny-sdf@2.2.0': + resolution: {integrity: sha512-LVL4wgI9YAum5V+LNVQO6QgFBPw7/MIIY4XJPNsPDMrjEwcE+JfKk1LuIl8GnF197ejVdC9QdPaxrx5gfgdGXg==} + + '@mapbox/unitbezier@0.0.1': + resolution: {integrity: sha512-nMkuDXFv60aBr9soUG5q+GvZYL+2KZHVvsqFCzqnkGEf46U2fvmytHaEVc1/YZbiLn8X+eR3QzX1+dwDO1lxlw==} + + '@mapbox/vector-tile@2.0.5': + resolution: {integrity: sha512-pXj8m7KTsqZt+1jsE0xIpGvqTSbblfkuEJL/NJmNePMtEwxO8V3XMDo9WMSfDeqHvCtBI9Lmt4mGcGR10zecmw==} + '@marijn/find-cluster-break@1.0.2': resolution: {integrity: sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==} @@ -951,6 +972,10 @@ packages: wavesurfer.js: optional: true + '@mieweb/ychart@file:packages/ychart': + resolution: {directory: packages/ychart, type: directory} + engines: {node: '>=24.0.0'} + '@monaco-editor/loader@1.7.0': resolution: {integrity: sha512-gIwR1HrJrrx+vfyOhYmCZ0/JcWqG5kbfG7+d3f/C1LXk2EvzAbHSg3MQ5lO2sMlo9izoAZ04shohfKLVT6crVA==} @@ -1668,6 +1693,9 @@ packages: '@types/estree@1.0.8': resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + '@types/geojson-vt@3.2.5': + resolution: {integrity: sha512-qDO7wqtprzlpe8FfQ//ClPV9xiuoh2nkIgiouIptON9w5jvD/fA4szvP9GBlDVdJ5dldAl0kX/sy3URbWwLx0g==} + '@types/geojson@7946.0.16': resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} @@ -1692,6 +1720,10 @@ packages: '@types/luxon@3.7.1': resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==} + '@types/mapbox-gl@3.5.0': + resolution: {integrity: sha512-3wVAUTC6q1UKatLP9YxFBnGJWi3neJUF9OKeyRdUf/BsYjZAP35xmZkL4zogVJbO3vdExuSVYCAkzUXjpjdhOg==} + deprecated: This is a stub types definition. mapbox-gl provides its own type definitions, so you do not need this installed. + '@types/mdx@2.0.13': resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} @@ -1701,6 +1733,9 @@ packages: '@types/papaparse@5.5.2': resolution: {integrity: sha512-gFnFp/JMzLHCwRf7tQHrNnfhN4eYBVYYI897CGX4MY1tzY9l2aLkVyx2IlKZ/SAqDbB3I1AOZW5gTMGGsqWliA==} + '@types/pbf@3.0.5': + resolution: {integrity: sha512-j3pOPiEcWZ34R6a6mN07mUkM4o4Lwf6hPNt8eilOeZhTFbxFXmKhvXl9Y28jotFPaI1bpPDJsbCprUoNke6OrA==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -1715,6 +1750,9 @@ packages: '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} + '@types/supercluster@7.1.3': + resolution: {integrity: sha512-Z0pOY34GDFl3Q6hUFYf3HkTwKEE02e7QgtJppBt+beEAxnyOpJua+voGFvxINBHa06GwLFFym7gRPY2SiKIfIA==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -2224,6 +2262,9 @@ packages: resolution: {integrity: sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==} engines: {node: '>=10'} + cheap-ruler@4.0.0: + resolution: {integrity: sha512-0BJa8f4t141BYKQyn9NSQt1PguFQXMXwZiA5shfoaBYHAb2fFk2RAX+tiWMoQU+Agtzt3mdt0JtuyshAXqZ+Vw==} + check-error@2.1.3: resolution: {integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==} engines: {node: '>= 16'} @@ -2345,6 +2386,9 @@ packages: css.escape@1.5.1: resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + csscolorparser@1.0.3: + resolution: {integrity: sha512-umPSgYwZkdFoUrH5hIq5kf0wPSXiro51nPw0j2K/c83KflkPSTBGMz6NJvMB+07VlL0y7VPo6QJcDjcgKTTm3w==} + cssstyle@4.6.0: resolution: {integrity: sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==} engines: {node: '>=18'} @@ -2670,6 +2714,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + earcut@3.0.2: + resolution: {integrity: sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==} + eastasianwidth@0.2.0: resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} @@ -3028,6 +3075,9 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + geojson-vt@4.0.3: + resolution: {integrity: sha512-jR1MwkLaZGa8Zftct9ZFruyWFrdl9ZyD2OliXNy9Qq5bBPeg5wHVpBQF9p5GjnicSDQqvBVpysxTPKmWdsfWMA==} + get-caller-file@2.0.5: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} @@ -3052,6 +3102,9 @@ packages: resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==} engines: {node: '>= 0.4'} + gl-matrix@3.4.4: + resolution: {integrity: sha512-latSnyDNt/8zYUB6VIJ6PCh2jBjJX6gnDsoCZ7LyW7GkqrD51EWwa9qCoGixj8YqBtETQK/xY7OmpTF8xz1DdQ==} + glob-parent@6.0.2: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} @@ -3638,6 +3691,9 @@ packages: resolution: {integrity: sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg==} hasBin: true + kdbush@4.1.0: + resolution: {integrity: sha512-e9vurzrXJQrFX6ckpHP3bvj5l+9CnYzkxDNnNQ1h2QTqdWsUAJgXiKdGNcOa1EY85dU8KbQ+z/FdQdB7P+9yfQ==} + keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} @@ -3825,6 +3881,9 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mapbox-gl@3.24.0: + resolution: {integrity: sha512-R+FdFUB3DnoE5FYASV7lGSiRyMkSblcZ2UEy7b2pt7s5ZbCxFIUPXd0E6iAFd8OdvdA2VtbvZZVylzAZNaurjA==} + marked@14.0.0: resolution: {integrity: sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==} engines: {node: '>= 18'} @@ -3840,6 +3899,9 @@ packages: engines: {node: '>= 20'} hasBin: true + martinez-polygon-clipping@0.8.1: + resolution: {integrity: sha512-9PLLMzMPI6ihHox4Ns6LpVBLpRc7sbhULybZ/wyaY8sY3ECNe2+hxm1hA2/9bEEpRrdpjoeduBuZLg2aq1cSIQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -3905,6 +3967,9 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + murmurhash-js@1.0.0: + resolution: {integrity: sha512-TvmkNhkv8yct0SVBSy+o8wYzXjE4Zz3PCesbfs8HiCXXdcTuocApFv11UWlNFWKYsP2okqrhb7JNlSm9InBhIw==} + mz@2.7.0: resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==} @@ -4096,6 +4161,10 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pbf@4.0.2: + resolution: {integrity: sha512-J0ajxARhZfpUEebxYs1vhMGMuLSXtBe1e+fFPDrf2uA2hgo+UshKfNUWOz92HJNz6/NFEXseQPddnHkTreWRqg==} + hasBin: true + picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -4163,6 +4232,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + potpack@2.1.0: + resolution: {integrity: sha512-pcaShQc1Shq0y+E7GqJqvZj8DTthWV1KeHGdi0Z6IAin2Oi3JnLCOfwnCo84qc+HAp52wT9nK9H7FAJp5a44GQ==} + prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} engines: {node: '>= 0.8.0'} @@ -4252,6 +4324,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + protocol-buffers-schema@3.6.1: + resolution: {integrity: sha512-VG2K63Igkiv9p76tk1lilczEK1cT+kCjKtkdhw1dQZV3k3IXJbd3o6Ho8b9zJZaHSnT2hKe4I+ObmX9w6m5SmQ==} + proxy-from-env@2.1.0: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} @@ -4263,6 +4338,9 @@ packages: pure-rand@7.0.1: resolution: {integrity: sha512-oTUZM/NAZS8p7ANR3SHh30kXB+zK2r2BPcEn/awJIbOvq82WoMN4p62AWWp3Hhw50G0xMsw1mhIBLqHw64EcNQ==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + react-docgen-typescript@2.4.0: resolution: {integrity: sha512-ZtAp5XTO5HRzQctjPU0ybY0RRCQO19X/8fxn3w7y2VVTUbGHDKULPTL4ky3vB05euSgG5NpALhEhDPvQ56wvXg==} peerDependencies: @@ -4361,6 +4439,9 @@ packages: resolution: {integrity: sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==} engines: {node: '>=8'} + resolve-protobuf-schema@2.1.0: + resolution: {integrity: sha512-kI5ffTiZWmJaS/huM8wZfEMer1eRd7oJQhDuxeCLe3t7N7mX3z94CN0xPxBQxFYQTSNz9T0i+v6inKqSdK8xrQ==} + resolve@1.22.11: resolution: {integrity: sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==} engines: {node: '>= 0.4'} @@ -4376,6 +4457,9 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + robust-predicates@2.0.4: + resolution: {integrity: sha512-l4NwboJM74Ilm4VKfbAtFeGq7aEjWL+5kVFcmgFA2MrdnQWx9iE/tUGvxY5HyMI7o/WpSIUFLbC5fbeaHgSCYg==} + robust-predicates@3.0.3: resolution: {integrity: sha512-NS3levdsRIUOmiJ8FZWCP7LG3QpJyrs/TE0Zpf1yvZu8cAJJ6QMW92H1c7kWpdIHo8RvmLxN/o2JXTKHp74lUA==} @@ -4524,6 +4608,9 @@ packages: spawnd@5.0.0: resolution: {integrity: sha512-28+AJr82moMVWolQvlAIv3JcYDkjkFTEmfDc503wxrF5l2rQ3dFz6DpbXp3kD4zmgGGldfM4xM4v1sFj/ZaIOA==} + splaytree@0.1.4: + resolution: {integrity: sha512-D50hKrjZgBzqD3FT2Ek53f2dcDLAQT8SSGrzj3vidNH5ISRgceeGVJ2dQIthKOuayqFXfFjXheHNo4bbt9LhRQ==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4644,6 +4731,9 @@ packages: engines: {node: '>=16 || 14 >=14.17'} hasBin: true + supercluster@8.0.1: + resolution: {integrity: sha512-IiOea5kJ9iqzD2t7QJq/cREyLHTtSmUT6gQsweojg9WH2sYJqZK9SswTu6jrscO6D1G5v5vYZ9ru/eq85lXeZQ==} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -4713,6 +4803,9 @@ packages: resolution: {integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==} engines: {node: ^18.0.0 || >=20.0.0} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tinyrainbow@2.0.0: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} @@ -5105,9 +5198,6 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} - ychart@file:packages/ychart: - resolution: {directory: packages/ychart, type: directory} - yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -5872,6 +5962,20 @@ snapshots: '@lezer/highlight': 1.2.3 '@lezer/lr': 1.4.10 + '@mapbox/mapbox-gl-supported@3.0.0': {} + + '@mapbox/point-geometry@1.1.0': {} + + '@mapbox/tiny-sdf@2.2.0': {} + + '@mapbox/unitbezier@0.0.1': {} + + '@mapbox/vector-tile@2.0.5': + dependencies: + '@mapbox/point-geometry': 1.1.0 + '@types/geojson': 7946.0.16 + pbf: 4.0.2 + '@marijn/find-cluster-break@1.0.2': {} '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)': @@ -5917,6 +6021,28 @@ snapshots: ag-grid-react: 35.1.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4) wavesurfer.js: 7.12.1 + '@mieweb/ychart@file:packages/ychart(@popperjs/core@2.11.8)': + dependencies: + '@codemirror/lang-yaml': 6.1.3 + '@codemirror/lint': 6.9.5 + '@codemirror/state': 6.6.0 + '@codemirror/theme-one-dark': 6.1.3 + '@codemirror/view': 6.43.0 + bootstrap: 5.3.8(@popperjs/core@2.11.8) + codemirror: 6.0.2 + d3: 7.9.0 + d3-array: 3.2.4 + d3-drag: 3.0.0 + d3-flextree: 2.1.2 + d3-hierarchy: 3.1.2 + d3-org-chart: 3.1.1 + d3-selection: 3.0.0 + d3-shape: 3.2.0 + d3-zoom: 3.0.0 + js-yaml: 4.1.1 + transitivePeerDependencies: + - '@popperjs/core' + '@monaco-editor/loader@1.7.0': dependencies: state-local: 1.0.7 @@ -6580,6 +6706,10 @@ snapshots: '@types/estree@1.0.8': {} + '@types/geojson-vt@3.2.5': + dependencies: + '@types/geojson': 7946.0.16 + '@types/geojson@7946.0.16': {} '@types/google-libphonenumber@7.4.30': {} @@ -6600,6 +6730,10 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/mapbox-gl@3.5.0': + dependencies: + mapbox-gl: 3.24.0 + '@types/mdx@2.0.13': {} '@types/node@22.19.11': @@ -6610,6 +6744,8 @@ snapshots: dependencies: '@types/node': 22.19.11 + '@types/pbf@3.0.5': {} + '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -6622,6 +6758,10 @@ snapshots: '@types/stack-utils@2.0.3': {} + '@types/supercluster@7.1.3': + dependencies: + '@types/geojson': 7946.0.16 + '@types/trusted-types@2.0.7': optional: true @@ -7188,6 +7328,8 @@ snapshots: char-regex@1.0.2: {} + cheap-ruler@4.0.0: {} + check-error@2.1.3: {} chokidar@4.0.3: @@ -7294,6 +7436,8 @@ snapshots: css.escape@1.5.1: {} + csscolorparser@1.0.3: {} + cssstyle@4.6.0: dependencies: '@asamuzakjp/css-color': 3.2.0 @@ -7650,6 +7794,8 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + earcut@3.0.2: {} + eastasianwidth@0.2.0: {} electron-to-chromium@1.5.302: {} @@ -8139,6 +8285,8 @@ snapshots: gensync@1.0.0-beta.2: {} + geojson-vt@4.0.3: {} + get-caller-file@2.0.5: {} get-intrinsic@1.3.0: @@ -8169,6 +8317,8 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 + gl-matrix@3.4.4: {} + glob-parent@6.0.2: dependencies: is-glob: 4.0.3 @@ -8986,6 +9136,8 @@ snapshots: dependencies: commander: 8.3.0 + kdbush@4.1.0: {} + keyv@4.5.4: dependencies: json-buffer: 3.0.1 @@ -9128,12 +9280,43 @@ snapshots: dependencies: tmpl: 1.0.5 + mapbox-gl@3.24.0: + dependencies: + '@mapbox/mapbox-gl-supported': 3.0.0 + '@mapbox/point-geometry': 1.1.0 + '@mapbox/tiny-sdf': 2.2.0 + '@mapbox/unitbezier': 0.0.1 + '@mapbox/vector-tile': 2.0.5 + '@types/geojson': 7946.0.16 + '@types/geojson-vt': 3.2.5 + '@types/pbf': 3.0.5 + '@types/supercluster': 7.1.3 + cheap-ruler: 4.0.0 + csscolorparser: 1.0.3 + earcut: 3.0.2 + geojson-vt: 4.0.3 + gl-matrix: 3.4.4 + kdbush: 4.1.0 + martinez-polygon-clipping: 0.8.1 + murmurhash-js: 1.0.0 + pbf: 4.0.2 + potpack: 2.1.0 + quickselect: 3.0.0 + supercluster: 8.0.1 + tinyqueue: 3.0.0 + marked@14.0.0: {} marked@16.4.2: {} marked@17.0.6: {} + martinez-polygon-clipping@0.8.1: + dependencies: + robust-predicates: 2.0.4 + splaytree: 0.1.4 + tinyqueue: 3.0.0 + math-intrinsics@1.1.0: {} merge-stream@2.0.0: {} @@ -9208,6 +9391,8 @@ snapshots: ms@2.1.3: {} + murmurhash-js@1.0.0: {} + mz@2.7.0: dependencies: any-promise: 1.3.0 @@ -9425,6 +9610,10 @@ snapshots: pathval@2.0.1: {} + pbf@4.0.2: + dependencies: + resolve-protobuf-schema: 2.1.0 + picocolors@1.1.1: {} picomatch@2.3.2: {} @@ -9475,6 +9664,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + potpack@2.1.0: {} + prelude-ls@1.2.1: {} prettier-plugin-tailwindcss@0.6.14(prettier@3.8.1): @@ -9510,12 +9701,16 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + protocol-buffers-schema@3.6.1: {} + proxy-from-env@2.1.0: {} punycode@2.3.1: {} pure-rand@7.0.1: {} + quickselect@3.0.0: {} + react-docgen-typescript@2.4.0(typescript@5.9.3): dependencies: typescript: 5.9.3 @@ -9623,6 +9818,10 @@ snapshots: resolve-from@5.0.0: {} + resolve-protobuf-schema@2.1.0: + dependencies: + protocol-buffers-schema: 3.6.1 + resolve@1.22.11: dependencies: is-core-module: 2.16.1 @@ -9642,6 +9841,8 @@ snapshots: dependencies: glob: 7.2.3 + robust-predicates@2.0.4: {} + robust-predicates@3.0.3: {} rollup@4.59.0: @@ -9834,6 +10035,8 @@ snapshots: transitivePeerDependencies: - supports-color + splaytree@0.1.4: {} + sprintf-js@1.0.3: {} sprintf-js@1.1.3: {} @@ -9991,6 +10194,10 @@ snapshots: tinyglobby: 0.2.15 ts-interface-checker: 0.1.13 + supercluster@8.0.1: + dependencies: + kdbush: 4.1.0 + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -10052,6 +10259,8 @@ snapshots: tinypool@1.1.1: {} + tinyqueue@3.0.0: {} + tinyrainbow@2.0.0: {} tinyspy@4.0.4: {} @@ -10504,8 +10713,6 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 - ychart@file:packages/ychart: {} - yocto-queue@0.1.0: {} zod@4.4.3: {} diff --git a/src/brands/bluehive.css b/src/brands/bluehive.css index d35c444c..4b46ca82 100644 --- a/src/brands/bluehive.css +++ b/src/brands/bluehive.css @@ -75,6 +75,24 @@ 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); --mieweb-shadow-modal: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1); + + /* Primary RGB channels — for translucent glows, tints & focus rings */ + --mieweb-primary-rgb: 39 170 225; + + /* Brand Gradients */ + --mieweb-gradient-brand: linear-gradient(135deg, #1f98ca 0%, #0f749c 100%); + --mieweb-gradient-brand-strong: linear-gradient( + 135deg, + #27aae1 0%, + #1786b3 100% + ); + + /* Elevation & Glow Shadows */ + --mieweb-shadow-elevated: 0 10px 40px -4px rgb(0 0 0 / 0.12); + --mieweb-shadow-elevated-hover: 0 18px 50px -6px rgb(0 0 0 / 0.18); + --mieweb-shadow-glow: 0 4px 14px -2px rgb(var(--mieweb-primary-rgb) / 0.35); + --mieweb-shadow-glow-hover: 0 8px 24px -4px + rgb(var(--mieweb-primary-rgb) / 0.45); } /* Dark Mode */ @@ -99,6 +117,15 @@ --mieweb-info-foreground: #fafafa; --mieweb-secondary-foreground: #fafafa; + /* Deepen the hero gradient & strengthen ambient shadow for dark surfaces */ + --mieweb-gradient-brand-strong: linear-gradient( + 135deg, + #1786b3 0%, + #00506e 100% + ); + --mieweb-shadow-elevated: 0 10px 40px -4px rgb(0 0 0 / 0.5); + --mieweb-shadow-elevated-hover: 0 18px 50px -6px rgb(0 0 0 / 0.6); + /* Chart */ --mieweb-chart-1: #38bdf8; --mieweb-chart-2: #4ade80; diff --git a/src/brands/bluehive.ts b/src/brands/bluehive.ts index bf6ab86a..fc8f592a 100644 --- a/src/brands/bluehive.ts +++ b/src/brands/bluehive.ts @@ -93,6 +93,25 @@ export const bluehiveBrand: BrandConfig = { dropdown: '0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1)', modal: '0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -4px rgb(0 0 0 / 0.1)', + // Soft, high-blur elevation for floating surfaces (auth cards, popovers) + elevated: '0 10px 40px -4px rgb(0 0 0 / 0.12)', + elevatedHover: '0 18px 50px -6px rgb(0 0 0 / 0.18)', + // Brand-tinted glow for primary/hero actions (BlueHive Blue #27aae1) + glow: '0 4px 14px -2px rgb(39 170 225 / 0.35)', + glowHover: '0 8px 24px -4px rgb(39 170 225 / 0.45)', + // Dark mode needs deeper ambient shadow to read against #171717 + elevatedDark: '0 10px 40px -4px rgb(0 0 0 / 0.5)', + elevatedHoverDark: '0 18px 50px -6px rgb(0 0 0 / 0.6)', + }, + + gradients: { + // Primary action gradient — tuned darker (600 → 800) so bold white text + // stays legible across the whole sweep. + brand: 'linear-gradient(135deg, #1f98ca 0%, #0f749c 100%)', + // Vibrant hero gradient (500 → 700) — the signature BlueHive look. + brandStrong: 'linear-gradient(135deg, #27aae1 0%, #1786b3 100%)', + // Deepened for dark mode so it doesn't glare against a dark page. + brandStrongDark: 'linear-gradient(135deg, #1786b3 0%, #00506e 100%)', }, }; diff --git a/src/brands/index.ts b/src/brands/index.ts index 873fc2b2..099b3b83 100644 --- a/src/brands/index.ts +++ b/src/brands/index.ts @@ -6,19 +6,18 @@ // Types and utilities export type { - BrandConfig, - BrandColors, - BrandTypography, BrandBorderRadius, BrandBoxShadow, + BrandColors, + BrandConfig, + BrandTypography, ColorScale, SemanticColors, } from './types'; - export { + createBrandPreset, generateBrandCSS, generateTailwindTheme, - createBrandPreset, } from './types'; // Brand configurations diff --git a/src/brands/types.ts b/src/brands/types.ts index e192884d..db75287d 100644 --- a/src/brands/types.ts +++ b/src/brands/types.ts @@ -120,6 +120,30 @@ export interface BrandBoxShadow { card: string; dropdown: string; modal: string; + /** Soft, high-blur elevation for floating surfaces (e.g. auth cards) */ + elevated?: string; + /** Elevated shadow on hover */ + elevatedHover?: string; + /** Brand-tinted glow for primary/hero actions */ + glow?: string; + /** Brand glow on hover */ + glowHover?: string; + /** Optional dark-mode override for `elevated` */ + elevatedDark?: string; + /** Optional dark-mode override for `elevatedHover` */ + elevatedHoverDark?: string; +} + +/** + * Brand gradient definitions used for hero panels and primary actions. + */ +export interface BrandGradients { + /** Primary action gradient (e.g. brand buttons) */ + brand: string; + /** Strong hero / marketing gradient (e.g. auth side panels, dashboard heroes) */ + brandStrong: string; + /** Optional dark-mode override for `brandStrong` */ + brandStrongDark?: string; } /** @@ -140,6 +164,8 @@ export interface BrandConfig { borderRadius: BrandBorderRadius; /** Box shadow definitions */ boxShadow: BrandBoxShadow; + /** Brand gradient definitions (optional — falls back to library defaults) */ + gradients?: BrandGradients; } // ============================================================================ @@ -151,7 +177,7 @@ export interface BrandConfig { * This creates a standalone CSS file that can be imported into any project. */ export function generateBrandCSS(brand: BrandConfig): string { - const { colors, typography, borderRadius, boxShadow } = brand; + const { colors, typography, borderRadius, boxShadow, gradients } = brand; // Collect all color scales (primary + any optional scales) const scaleNames = [ @@ -235,7 +261,23 @@ ${scaleBlocks} /* Shadows */ --mieweb-shadow-card: ${boxShadow.card}; --mieweb-shadow-dropdown: ${boxShadow.dropdown}; - --mieweb-shadow-modal: ${boxShadow.modal}; + --mieweb-shadow-modal: ${boxShadow.modal};${ + boxShadow.elevated + ? `\n --mieweb-shadow-elevated: ${boxShadow.elevated};` + : '' + }${ + boxShadow.elevatedHover + ? `\n --mieweb-shadow-elevated-hover: ${boxShadow.elevatedHover};` + : '' + }${boxShadow.glow ? `\n --mieweb-shadow-glow: ${boxShadow.glow};` : ''}${ + boxShadow.glowHover + ? `\n --mieweb-shadow-glow-hover: ${boxShadow.glowHover};` + : '' + }${ + gradients + ? `\n\n /* Brand Gradients */\n --mieweb-gradient-brand: ${gradients.brand};\n --mieweb-gradient-brand-strong: ${gradients.brandStrong};` + : '' + } } /* Dark Mode */ @@ -255,7 +297,19 @@ ${scaleBlocks} --mieweb-success: ${colors.dark.success}; --mieweb-success-foreground: ${colors.dark.successForeground}; --mieweb-warning: ${colors.dark.warning}; - --mieweb-warning-foreground: ${colors.dark.warningForeground}; + --mieweb-warning-foreground: ${colors.dark.warningForeground};${ + gradients?.brandStrongDark + ? `\n --mieweb-gradient-brand-strong: ${gradients.brandStrongDark};` + : '' + }${ + boxShadow.elevatedDark + ? `\n --mieweb-shadow-elevated: ${boxShadow.elevatedDark};` + : '' + }${ + boxShadow.elevatedHoverDark + ? `\n --mieweb-shadow-elevated-hover: ${boxShadow.elevatedHoverDark};` + : '' + } } `; } @@ -314,6 +368,12 @@ export function generateTailwindTheme(brand: BrandConfig) { card: boxShadow.card, dropdown: boxShadow.dropdown, modal: boxShadow.modal, + ...(boxShadow.elevated ? { elevated: boxShadow.elevated } : {}), + ...(boxShadow.elevatedHover + ? { 'elevated-hover': boxShadow.elevatedHover } + : {}), + ...(boxShadow.glow ? { glow: boxShadow.glow } : {}), + ...(boxShadow.glowHover ? { 'glow-hover': boxShadow.glowHover } : {}), }, }; } diff --git a/src/components/AGGrid/AGGrid.enhanced.stories.tsx b/src/components/AGGrid/AGGrid.enhanced.stories.tsx index 7346694b..da289097 100644 --- a/src/components/AGGrid/AGGrid.enhanced.stories.tsx +++ b/src/components/AGGrid/AGGrid.enhanced.stories.tsx @@ -1,21 +1,22 @@ +// Import AG Grid styles +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/agGridQuartzFont.css'; +import './ag-grid-theme.css'; + import type { Meta, StoryObj } from '@storybook/react-vite'; + import { AGGrid, - EnhancedAvatarNameRenderer, - EnhancedStatusBadgeRenderer, EnhancedActionsRenderer, + EnhancedAvatarNameRenderer, EnhancedBooleanRenderer, EnhancedCurrencyRenderer, EnhancedDateRenderer, EnhancedProgressRenderer, + EnhancedStatusBadgeRenderer, EnhancedTagsRenderer, } from './index-enhanced'; -// Import AG Grid styles -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/agGridQuartzFont.css'; -import './ag-grid-theme.css'; - // ============================================================================ // Sample Data // ============================================================================ diff --git a/src/components/AGGrid/AGGrid.stories.tsx b/src/components/AGGrid/AGGrid.stories.tsx index d54456e9..7a16aaa6 100644 --- a/src/components/AGGrid/AGGrid.stories.tsx +++ b/src/components/AGGrid/AGGrid.stories.tsx @@ -1,33 +1,33 @@ -import * as React from 'react'; +// Import AG Grid styles +import 'ag-grid-community/styles/ag-grid.css'; +import 'ag-grid-community/styles/agGridQuartzFont.css'; +import './ag-grid-theme.css'; + import type { Meta, StoryObj } from '@storybook/react-vite'; -import { AGGrid, type ColDef } from './AGGrid'; +import * as React from 'react'; + import { Badge } from '../Badge'; import { Button } from '../Button'; - +import { AGGrid, type ColDef } from './AGGrid'; // Import cell renderers import { MemoizedAvatarNameRenderer, - MemoizedStatusBadgeRenderer, - MemoizedEngagementScoreRenderer, + MemoizedBooleanRenderer, + MemoizedCompanyRenderer, + MemoizedCurrencyRenderer, + MemoizedDateRenderer, + MemoizedDomainRenderer, MemoizedEmailRenderer, - MemoizedPhoneRenderer, + MemoizedEngagementScoreRenderer, MemoizedLinkedInRenderer, - MemoizedDomainRenderer, - MemoizedCurrencyRenderer, MemoizedNumberRenderer, - MemoizedDateRenderer, - MemoizedBooleanRenderer, - MemoizedCompanyRenderer, + MemoizedPhoneRenderer, MemoizedProgressRenderer, + MemoizedStatusBadgeRenderer, MemoizedTagsRenderer, statusColors, } from './CellRenderers'; -// Import AG Grid styles -import 'ag-grid-community/styles/ag-grid.css'; -import 'ag-grid-community/styles/agGridQuartzFont.css'; -import './ag-grid-theme.css'; - // ============================================================================ // Sample Data Types and Data // ============================================================================ @@ -160,7 +160,7 @@ function WithRowSelectionComponent() { setSelectedRows(event.api.getSelectedRows()); }} /> -
+
Selected: {selectedRows.length} row(s) {selectedRows.length > 0 && ( @@ -187,9 +187,9 @@ function WithRowClickComponent() { }} /> {clickedRow && ( -
+

Clicked Row:

-

+

{clickedRow.name} - {clickedRow.email} ({clickedRow.role})

@@ -226,7 +226,7 @@ function WithEditableCellsComponent() { return (
-

+

Double-click a cell in the Name, Email, or Role columns to edit.

@@ -971,7 +971,7 @@ Showcases all the built-in cell renderers available for AG Grid columns. return (
-

+

This grid demonstrates Avatar, Status, Engagement, Email, Phone, and Currency renderers.

@@ -1046,7 +1046,7 @@ export const CompanyAndLinksRenderers: Story = { return (
-

+

Click on domains or LinkedIn icons to open links. Tags overflow is handled gracefully.

@@ -1118,7 +1118,7 @@ export const DateFormatsShowcase: Story = { return (
-

+

The DateRenderer supports multiple format options for displaying dates.

@@ -1183,7 +1183,7 @@ export const ProgressAndBooleansShowcase: Story = { return (
-

+

Progress bars show completion percentage. Boolean values display as colored Yes/No badges.

@@ -1336,7 +1336,7 @@ export const StatusColorsVariations: Story = { return (
-

+

StatusBadgeRenderer can use different color configurations for different columns.

@@ -1408,7 +1408,7 @@ export const WithFloatingFilters: Story = { return (
-

+

Type in the filter inputs below each column header to filter the data.

diff --git a/src/components/AGGrid/AGGrid.tsx b/src/components/AGGrid/AGGrid.tsx index fa0d24dc..d1618030 100644 --- a/src/components/AGGrid/AGGrid.tsx +++ b/src/components/AGGrid/AGGrid.tsx @@ -1,17 +1,18 @@ -import * as React from 'react'; -import { AgGridReact, AgGridReactProps } from 'ag-grid-react'; import { - ModuleRegistry, AllCommunityModule, + type ColDef as AGColDef, type GridApi, type GridReadyEvent, - type ColDef as AGColDef, + ModuleRegistry, type RowClickedEvent, type RowSelectionOptions, } from 'ag-grid-community'; -import { cn } from '../../utils/cn'; +import { AgGridReact, AgGridReactProps } from 'ag-grid-react'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + import type { BrandConfig } from '../../brands/types'; +import { cn } from '../../utils/cn'; // Register AG Grid Community modules ModuleRegistry.registerModules([AllCommunityModule]); @@ -66,8 +67,7 @@ const agGridVariants = cva('ag-theme-custom w-full', { // ============================================================================ export interface AGGridProps - extends - Omit, 'className' | 'rowSelection'>, + extends Omit, 'className' | 'rowSelection'>, VariantProps { /** Additional CSS classes for the grid container */ className?: string; @@ -303,12 +303,12 @@ function AGGridInner( rowHeight={sizeConfig.rowHeight} headerHeight={sizeConfig.headerHeight} noRowsOverlayComponent={() => ( -
+
{noDataMessage}
)} loadingOverlayComponent={() => ( -
+
{loadingMessage}
)} @@ -338,16 +338,15 @@ export type { ColDef as AGColDef } from 'ag-grid-community'; export type ColDef = AGColDef; export type { + CellClickedEvent, + CellValueChangedEvent, + FilterChangedEvent, + FirstDataRenderedEvent, GridApi, GridReadyEvent, RowClickedEvent, - CellClickedEvent, - CellValueChangedEvent, + RowSelectedEvent, SelectionChangedEvent, - FilterChangedEvent, SortChangedEvent, - RowSelectedEvent, - FirstDataRenderedEvent, } from 'ag-grid-community'; - export { AgGridReact } from 'ag-grid-react'; diff --git a/src/components/AGGrid/EnhancedCellRenderers.tsx b/src/components/AGGrid/EnhancedCellRenderers.tsx index de400596..96a1c392 100644 --- a/src/components/AGGrid/EnhancedCellRenderers.tsx +++ b/src/components/AGGrid/EnhancedCellRenderers.tsx @@ -5,12 +5,13 @@ * They provide better integration, performance, and consistency with the overall design system. */ +import type { ICellRendererParams } from 'ag-grid-community'; import * as React from 'react'; import { memo } from 'react'; -import type { ICellRendererParams } from 'ag-grid-community'; + import { cn } from '../../utils/cn'; -import { Badge } from '../Badge'; import { Avatar } from '../Avatar'; +import { Badge } from '../Badge'; import { Button } from '../Button'; // ============================================================================= @@ -35,9 +36,9 @@ export const EnhancedAvatarNameRenderer = memo(
-
{name}
+
{name}
{email && ( -
+
{email}
)} @@ -168,7 +169,7 @@ export const EnhancedActionsRenderer = memo((params) => { variant="ghost" size="sm" onClick={() => onDelete(data)} - className="hover:bg-destructive/10 hover:text-destructive h-8 w-8 p-0" + className="hover:bg-destructive/10 h-8 w-8 p-0 hover:text-destructive" > Delete 🗑️ @@ -323,7 +324,7 @@ export const EnhancedDateRenderer = memo((params) => { formatted = date.toLocaleDateString('en-US'); } - return {formatted}; + return {formatted}; } catch { return Invalid Date; } @@ -345,7 +346,7 @@ export const EnhancedProgressRenderer = memo((params) => { return (
-
+
((params) => { style={{ width: `${progress}%` }} />
- + {Math.round(progress)}%
diff --git a/src/components/AGGrid/index-enhanced.ts b/src/components/AGGrid/index-enhanced.ts index 19f279b2..5e0315ca 100644 --- a/src/components/AGGrid/index-enhanced.ts +++ b/src/components/AGGrid/index-enhanced.ts @@ -1,100 +1,97 @@ // Main AG Grid Component with enhanced brand support -export { AGGrid, AgGridReact } from './AGGrid'; export type { + AGColDef, AGGridProps, + CellClickedEvent, + CellValueChangedEvent, ColDef, - AGColDef, + FilterChangedEvent, + FirstDataRenderedEvent, GridApi, GridReadyEvent, RowClickedEvent, - CellClickedEvent, - CellValueChangedEvent, + RowSelectedEvent, SelectionChangedEvent, - FilterChangedEvent, SortChangedEvent, - RowSelectedEvent, - FirstDataRenderedEvent, } from './AGGrid'; +export { AGGrid, AgGridReact } from './AGGrid'; // Original Cell Renderers (backward compatibility) +export type { + DateRendererProps, + ProgressRendererProps, + StatusBadgeRendererProps, + StatusConfig, +} from './CellRenderers'; export { - CellRenderers, // Individual renderers AvatarNameRenderer, - StatusBadgeRenderer, - EngagementScoreRenderer, - EmailRenderer, - PhoneRenderer, - LinkedInRenderer, - DomainRenderer, - CurrencyRenderer, - NumberRenderer, - DateRenderer, BooleanRenderer, + CellRenderers, CompanyRenderer, - ProgressRenderer, - TagsRenderer, + CurrencyRenderer, + DateRenderer, + DomainRenderer, + EmailRenderer, + EngagementScoreRenderer, + // Utilities + formatPhoneDisplay, + LinkedInRenderer, // Memoized renderers (recommended) MemoizedAvatarNameRenderer, - MemoizedStatusBadgeRenderer, - MemoizedEngagementScoreRenderer, + MemoizedBooleanRenderer, + MemoizedCompanyRenderer, + MemoizedCurrencyRenderer, + MemoizedDateRenderer, + MemoizedDomainRenderer, MemoizedEmailRenderer, - MemoizedPhoneRenderer, + MemoizedEngagementScoreRenderer, MemoizedLinkedInRenderer, - MemoizedDomainRenderer, - MemoizedCurrencyRenderer, MemoizedNumberRenderer, - MemoizedDateRenderer, - MemoizedBooleanRenderer, - MemoizedCompanyRenderer, + MemoizedPhoneRenderer, MemoizedProgressRenderer, + MemoizedStatusBadgeRenderer, MemoizedTagsRenderer, - // Utilities - formatPhoneDisplay, + NumberRenderer, + PhoneRenderer, + ProgressRenderer, + StatusBadgeRenderer, statusColors, -} from './CellRenderers'; - -export type { - StatusConfig, - StatusBadgeRendererProps, - DateRendererProps, - ProgressRendererProps, + TagsRenderer, } from './CellRenderers'; // Enhanced Cell Renderers with Design System Integration +export type { + ActionsRendererProps, + EnhancedCellRendererType, +} from './EnhancedCellRenderers'; export { - EnhancedAvatarNameRenderer, - EnhancedStatusBadgeRenderer, EnhancedActionsRenderer, + EnhancedAvatarNameRenderer, EnhancedBooleanRenderer, + enhancedCellRenderers, EnhancedCurrencyRenderer, EnhancedDateRenderer, EnhancedProgressRenderer, + EnhancedStatusBadgeRenderer, EnhancedTagsRenderer, - enhancedCellRenderers, -} from './EnhancedCellRenderers'; - -export type { - ActionsRendererProps, - EnhancedCellRendererType, } from './EnhancedCellRenderers'; // Brand Theme Utilities +export type { + AGGridBrandName, + AGGridBrandTheme, + ResponsiveColumnOptions, + UseAGGridBrandThemeOptions, +} from './brand-theme-utils'; export { agGridBrandThemes, - generateAGGridBrandCSS, - generateAGGridDarkBrandCSS, - useAGGridBrandTheme, - injectAGGridBrandStyles, - createBrandAwareColumnDef, applyBrandThemeToColumns, + createBrandAwareColumnDef, createResponsiveColumn, + generateAGGridBrandCSS, + generateAGGridDarkBrandCSS, getBrandAwareGridOptions, -} from './brand-theme-utils'; - -export type { - AGGridBrandName, - AGGridBrandTheme, - UseAGGridBrandThemeOptions, - ResponsiveColumnOptions, + injectAGGridBrandStyles, + useAGGridBrandTheme, } from './brand-theme-utils'; diff --git a/src/components/AGGrid/index.ts b/src/components/AGGrid/index.ts index c73620ec..a2e7c8d7 100644 --- a/src/components/AGGrid/index.ts +++ b/src/components/AGGrid/index.ts @@ -1,61 +1,60 @@ -export { AGGrid, AgGridReact } from './AGGrid'; export type { + AGColDef, AGGridProps, + CellClickedEvent, + CellValueChangedEvent, ColDef, - AGColDef, + FilterChangedEvent, + FirstDataRenderedEvent, GridApi, GridReadyEvent, RowClickedEvent, - CellClickedEvent, - CellValueChangedEvent, + RowSelectedEvent, SelectionChangedEvent, - FilterChangedEvent, SortChangedEvent, - RowSelectedEvent, - FirstDataRenderedEvent, } from './AGGrid'; +export { AGGrid, AgGridReact } from './AGGrid'; // Cell Renderers +export type { + DateRendererProps, + ProgressRendererProps, + StatusBadgeRendererProps, + StatusConfig, +} from './CellRenderers'; export { - CellRenderers, // Individual renderers AvatarNameRenderer, - StatusBadgeRenderer, - EngagementScoreRenderer, - EmailRenderer, - PhoneRenderer, - LinkedInRenderer, - DomainRenderer, - CurrencyRenderer, - NumberRenderer, - DateRenderer, BooleanRenderer, + CellRenderers, CompanyRenderer, - ProgressRenderer, - TagsRenderer, + CurrencyRenderer, + DateRenderer, + DomainRenderer, + EmailRenderer, + EngagementScoreRenderer, + // Utilities + formatPhoneDisplay, + LinkedInRenderer, // Memoized renderers (recommended) MemoizedAvatarNameRenderer, - MemoizedStatusBadgeRenderer, - MemoizedEngagementScoreRenderer, + MemoizedBooleanRenderer, + MemoizedCompanyRenderer, + MemoizedCurrencyRenderer, + MemoizedDateRenderer, + MemoizedDomainRenderer, MemoizedEmailRenderer, - MemoizedPhoneRenderer, + MemoizedEngagementScoreRenderer, MemoizedLinkedInRenderer, - MemoizedDomainRenderer, - MemoizedCurrencyRenderer, MemoizedNumberRenderer, - MemoizedDateRenderer, - MemoizedBooleanRenderer, - MemoizedCompanyRenderer, + MemoizedPhoneRenderer, MemoizedProgressRenderer, + MemoizedStatusBadgeRenderer, MemoizedTagsRenderer, - // Utilities - formatPhoneDisplay, + NumberRenderer, + PhoneRenderer, + ProgressRenderer, + StatusBadgeRenderer, statusColors, -} from './CellRenderers'; - -export type { - StatusConfig, - StatusBadgeRendererProps, - DateRendererProps, - ProgressRendererProps, + TagsRenderer, } from './CellRenderers'; diff --git a/src/components/AI/icons.tsx b/src/components/AI/icons.tsx index e5dcd0b7..ac39ecd0 100644 --- a/src/components/AI/icons.tsx +++ b/src/components/AI/icons.tsx @@ -5,6 +5,7 @@ */ import * as React from 'react'; + import { cn } from '../../utils/cn'; // ============================================================================ diff --git a/src/components/AI/index.ts b/src/components/AI/index.ts index c26cfd53..b43abb51 100644 --- a/src/components/AI/index.ts +++ b/src/components/AI/index.ts @@ -10,55 +10,55 @@ export * from './types'; // Icons export { - SparklesIcon, AILogoIcon, - CloseIcon, - RefreshIcon, - ChevronIcon, - SendIcon, - SpinnerIcon, - type SparklesIconProps, type AILogoIconProps, + ChevronIcon, + type ChevronIconProps, + CloseIcon, type CloseIconProps, + RefreshIcon, type RefreshIconProps, - type ChevronIconProps, + SendIcon, type SendIconProps, + SparklesIcon, + type SparklesIconProps, + SpinnerIcon, type SpinnerIconProps, } from './icons'; // MCP Tool Call Display export { - MCPToolCallDisplay, - ResourceLink, - ToolStatusIcon, getToolIcon, + MCPToolCallDisplay, type MCPToolCallDisplayProps, + ResourceLink, type ResourceLinkProps, + ToolStatusIcon, } from './MCPToolCall'; // AI Message Display export { AIMessageDisplay, - MessageAvatar, - AITypingIndicator, type AIMessageDisplayProps, + AITypingIndicator, + MessageAvatar, } from './AIMessage'; // AI Chat export { AIChat, - SuggestedActions, type AIChatProps, + SuggestedActions, type SuggestedActionsProps, } from './AIChat'; // AI Chat Modal export { AIChatModal, - AIChatTrigger, - FloatingAIChat, type AIChatModalProps, + AIChatTrigger, type AIChatTriggerProps, + FloatingAIChat, type FloatingAIChatProps, } from './AIChatModal'; diff --git a/src/components/Accordion/Accordion.tsx b/src/components/Accordion/Accordion.tsx new file mode 100644 index 00000000..4fcd3a8a --- /dev/null +++ b/src/components/Accordion/Accordion.tsx @@ -0,0 +1,482 @@ +import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + +import { cn } from '../../utils/cn'; + +// ============================================================================= +// Accordion Context +// ============================================================================= + +interface AccordionContextValue { + expandedItems: Set; + toggleItem: (id: string) => void; + allowMultiple: boolean; +} + +const AccordionContext = React.createContext( + null +); + +function useAccordionContext() { + const context = React.useContext(AccordionContext); + if (!context) { + throw new Error('Accordion components must be used within an Accordion'); + } + return context; +} + +// ============================================================================= +// Accordion Root +// ============================================================================= + +const accordionVariants = cva('w-full', { + variants: { + variant: { + default: 'divide-y divide-gray-200 dark:divide-gray-700', + bordered: + 'border border-gray-200 dark:border-gray-700 rounded-lg divide-y divide-gray-200 dark:divide-gray-700', + separated: 'space-y-2', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +export interface AccordionProps + extends React.HTMLAttributes, + VariantProps { + children: React.ReactNode; + /** Allow multiple items to be expanded at once */ + allowMultiple?: boolean; + /** Default expanded item IDs */ + defaultExpanded?: string[]; +} + +export function Accordion({ + children, + variant, + allowMultiple = false, + defaultExpanded = [], + className, + ...props +}: AccordionProps) { + const [expandedItems, setExpandedItems] = React.useState>( + new Set(defaultExpanded) + ); + + const toggleItem = React.useCallback( + (id: string) => { + setExpandedItems((prev) => { + const next = new Set(prev); + if (next.has(id)) { + next.delete(id); + } else { + if (!allowMultiple) { + next.clear(); + } + next.add(id); + } + return next; + }); + }, + [allowMultiple] + ); + + const contextValue = React.useMemo( + () => ({ expandedItems, toggleItem, allowMultiple }), + [expandedItems, toggleItem, allowMultiple] + ); + + return ( + +
+ {children} +
+
+ ); +} + +// ============================================================================= +// Accordion Item +// ============================================================================= + +interface AccordionItemContextValue { + itemId: string; + isExpanded: boolean; +} + +const AccordionItemContext = + React.createContext(null); + +function useAccordionItemContext() { + const context = React.useContext(AccordionItemContext); + if (!context) { + throw new Error( + 'AccordionItem components must be used within an AccordionItem' + ); + } + return context; +} + +const accordionItemVariants = cva('', { + variants: { + variant: { + default: '', + bordered: 'first:rounded-t-lg last:rounded-b-lg', + separated: + 'border border-gray-200 dark:border-gray-700 rounded-lg overflow-hidden', + }, + }, + defaultVariants: { + variant: 'default', + }, +}); + +export interface AccordionItemProps + extends React.HTMLAttributes, + VariantProps { + children: React.ReactNode; + /** Unique identifier for this item */ + id: string; +} + +export function AccordionItem({ + children, + id, + variant, + className, + ...props +}: AccordionItemProps) { + const { expandedItems } = useAccordionContext(); + const isExpanded = expandedItems.has(id); + + const contextValue = React.useMemo( + () => ({ itemId: id, isExpanded }), + [id, isExpanded] + ); + + return ( + +
+ {children} +
+
+ ); +} + +// ============================================================================= +// Accordion Trigger +// ============================================================================= + +const accordionTriggerVariants = cva( + 'flex w-full items-center justify-between text-left font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2', + { + variants: { + size: { + sm: 'py-2 px-3 text-sm', + md: 'py-3 px-4 text-base', + lg: 'py-4 px-5 text-lg', + }, + variant: { + default: + 'hover:bg-gray-50 dark:hover:bg-gray-800 text-gray-900 dark:text-white', + muted: + 'hover:bg-gray-100 dark:hover:bg-gray-700 text-gray-700 dark:text-gray-200', + }, + }, + defaultVariants: { + size: 'md', + variant: 'default', + }, + } +); + +export interface AccordionTriggerProps + extends React.ButtonHTMLAttributes, + VariantProps { + children: React.ReactNode; + /** Show chevron icon */ + showChevron?: boolean; +} + +export function AccordionTrigger({ + children, + size, + variant, + showChevron = true, + className, + ...props +}: AccordionTriggerProps) { + const { toggleItem } = useAccordionContext(); + const { itemId, isExpanded } = useAccordionItemContext(); + + return ( + + ); +} + +// ============================================================================= +// Accordion Content +// ============================================================================= + +const accordionContentVariants = cva( + 'overflow-hidden transition-all duration-200 ease-in-out', + { + variants: { + size: { + sm: 'px-3 text-sm', + md: 'px-4 text-base', + lg: 'px-5 text-lg', + }, + }, + defaultVariants: { + size: 'md', + }, + } +); + +export interface AccordionContentProps + extends React.HTMLAttributes, + VariantProps { + children: React.ReactNode; +} + +export function AccordionContent({ + children, + size, + className, + ...props +}: AccordionContentProps) { + const { itemId, isExpanded } = useAccordionItemContext(); + const contentRef = React.useRef(null); + const [height, setHeight] = React.useState( + isExpanded ? undefined : 0 + ); + + React.useEffect(() => { + if (!contentRef.current) return; + const resizeObserver = new ResizeObserver(() => { + if (isExpanded) { + setHeight(contentRef.current?.scrollHeight); + } + }); + resizeObserver.observe(contentRef.current); + return () => resizeObserver.disconnect(); + }, [isExpanded]); + + React.useEffect(() => { + if (isExpanded) { + setHeight(contentRef.current?.scrollHeight); + } else { + setHeight(0); + } + }, [isExpanded]); + + return ( +
+
+ {children} +
+
+ ); +} + +// ============================================================================= +// FAQ Accordion (Specialized for Provider Pages) +// ============================================================================= + +export interface FAQItem { + id: string; + question: string; + answer: string | React.ReactNode; +} + +export interface FAQAccordionProps { + items: FAQItem[]; + className?: string; +} + +export function FAQAccordion({ items, className }: FAQAccordionProps) { + return ( + + {items.map((item) => ( + + {item.question} + {item.answer} + + ))} + + ); +} + +// ============================================================================= +// Provider FAQ Generator +// ============================================================================= + +export interface ProviderFAQData { + name: string; + address: { + street1: string; + street2?: string; + city: string; + state: string; + postalCode: string; + }; + phoneNumber?: string; + services?: { name: string }[]; + website?: string; + locationType?: string; +} + +export function generateProviderFAQs(provider: ProviderFAQData): FAQItem[] { + const faqs: FAQItem[] = []; + const { name, address, phoneNumber, services, website, locationType } = + provider; + + // Location FAQ + faqs.push({ + id: 'location', + question: `Where is ${name} located?`, + answer: ( + + {name} is located at{' '} + + {address.street1}, {address.city}, {address.state}{' '} + {address.postalCode} + + . + + ), + }); + + // Phone FAQ + if (phoneNumber) { + faqs.push({ + id: 'phone', + question: `What is the phone number for ${name}?`, + answer: ( + + You can contact {name} by calling{' '} + + {phoneNumber} + + . + + ), + }); + } + + // Services FAQ + if (services && services.length > 0) { + const serviceNames = services.slice(0, 5).map((s) => s.name); + const hasMore = services.length > 5; + faqs.push({ + id: 'services', + question: `What services does ${name} offer?`, + answer: `${name} offers services including ${serviceNames.join(', ')}${hasMore ? ` and ${services.length - 5} more services` : ''}.`, + }); + } + + // Website FAQ + if (website) { + faqs.push({ + id: 'website', + question: `Does ${name} have a website?`, + answer: ( + + Yes, you can visit the {name} website at{' '} + + {new URL(website).hostname} + + . + + ), + }); + } + + // Facility Type FAQ + if (locationType) { + faqs.push({ + id: 'facility-type', + question: `What type of facility is ${name}?`, + answer: `${name} is classified as a ${locationType}.`, + }); + } + + // Booking FAQ + faqs.push({ + id: 'booking', + question: `How do I book an appointment at ${name}?`, + answer: `You can book an appointment at ${name} by clicking the "Book Appointment" button on this page, calling the provider directly${phoneNumber ? ` at ${phoneNumber}` : ''}, or visiting their website${website ? ` at ${new URL(website).hostname}` : ''}.`, + }); + + return faqs; +} + +// ============================================================================= +// Icon +// ============================================================================= + +function ChevronIcon({ className }: { className?: string }) { + return ( + + + + ); +} + +export default Accordion; diff --git a/src/components/Accordion/index.ts b/src/components/Accordion/index.ts new file mode 100644 index 00000000..edcc8680 --- /dev/null +++ b/src/components/Accordion/index.ts @@ -0,0 +1,15 @@ +export { + Accordion, + AccordionContent, + type AccordionContentProps, + AccordionItem, + type AccordionItemProps, + type AccordionProps, + AccordionTrigger, + type AccordionTriggerProps, + FAQAccordion, + type FAQAccordionProps, + type FAQItem, + generateProviderFAQs, + type ProviderFAQData, +} from './Accordion'; diff --git a/src/components/ActivityFeed/ActivityFeed.stories.tsx b/src/components/ActivityFeed/ActivityFeed.stories.tsx new file mode 100644 index 00000000..f643cf73 --- /dev/null +++ b/src/components/ActivityFeed/ActivityFeed.stories.tsx @@ -0,0 +1,70 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; +import React from 'react'; + +import { ActivityFeed, type ActivityItem } from './ActivityFeed'; + +const meta: Meta = { + title: 'Dashboard/ActivityFeed', + component: ActivityFeed, + tags: ['autodocs'], + parameters: { layout: 'padded' }, +}; + +export default meta; + +type Story = StoryObj; + +const now = Date.now(); +const minutesAgo = (m: number) => new Date(now - m * 60_000).toISOString(); + +const items: ActivityItem[] = [ + { + id: '1', + kind: 'results_ready', + title: 'Results ready for Alex Rivera', + description: 'DOT Physical — Midwest Occ Health', + timestamp: minutesAgo(3), + onClick: () => {}, + }, + { + id: '2', + kind: 'order_accepted', + title: 'Order accepted', + description: 'BH-10235 — Jamie Chen', + timestamp: minutesAgo(14), + }, + { + id: '3', + kind: 'employee_added', + title: 'New employee added', + description: 'Sam Patel', + actor: 'you', + timestamp: minutesAgo(60), + }, + { + id: '4', + kind: 'order_completed', + title: 'Order completed', + description: 'BH-10232 — Taylor Park', + timestamp: minutesAgo(60 * 6), + }, + { + id: '5', + kind: 'invoice_paid', + title: 'Invoice paid', + description: 'INV-221 — $1,240.00', + timestamp: minutesAgo(60 * 26), + }, +]; + +export const Default: Story = { + args: { items }, +}; + +export const Loading: Story = { + args: { items: [], loading: true }, +}; + +export const Empty: Story = { + args: { items: [] }, +}; diff --git a/src/components/ActivityFeed/ActivityFeed.tsx b/src/components/ActivityFeed/ActivityFeed.tsx new file mode 100644 index 00000000..0a9b669a --- /dev/null +++ b/src/components/ActivityFeed/ActivityFeed.tsx @@ -0,0 +1,281 @@ +'use client'; + +import * as React from 'react'; + +import { cn } from '../../utils/cn'; + +// ============================================================================= +// Types +// ============================================================================= + +export type ActivityKind = + | 'order_created' + | 'order_accepted' + | 'order_completed' + | 'order_refused' + | 'results_ready' + | 'employee_added' + | 'invoice_paid' + | 'message' + | 'system'; + +export interface ActivityItem { + id: string; + kind: ActivityKind; + /** Primary title (e.g. "Order accepted by Midwest Occ Health"). */ + title: string; + /** Optional secondary description (e.g. employee name, service). */ + description?: string; + /** Actor (e.g. user or system that generated the event). */ + actor?: string; + /** When the event happened. */ + timestamp?: string | Date; + /** Optional click handler to drill into the related entity. */ + onClick?: () => void; +} + +export interface ActivityFeedProps { + items: ActivityItem[]; + /** Loading state. */ + loading?: boolean; + /** Empty state node. */ + emptyState?: React.ReactNode; + /** Max items to show before scrolling. */ + maxItems?: number; + /** Additional CSS classes. */ + className?: string; +} + +// ============================================================================= +// Icon + color per kind +// ============================================================================= + +function relativeTime(input?: string | Date): string { + if (!input) return ''; + const d = input instanceof Date ? input : new Date(input); + const diff = Date.now() - d.getTime(); + if (Number.isNaN(diff)) return ''; + const s = Math.max(0, Math.floor(diff / 1000)); + if (s < 60) return 'just now'; + const m = Math.floor(s / 60); + if (m < 60) return `${m}m ago`; + const h = Math.floor(m / 60); + if (h < 24) return `${h}h ago`; + const d2 = Math.floor(h / 24); + if (d2 < 30) return `${d2}d ago`; + return d.toLocaleDateString(); +} + +const KIND_STYLE: Record< + ActivityKind, + { color: string; bg: string; icon: React.ReactNode } +> = { + order_created: { + color: 'text-sky-700 dark:text-sky-300', + bg: 'bg-sky-100 dark:bg-sky-900/30', + icon: ( + + + + ), + }, + order_accepted: { + color: 'text-violet-700 dark:text-violet-300', + bg: 'bg-violet-100 dark:bg-violet-900/30', + icon: ( + + + + ), + }, + order_completed: { + color: 'text-neutral-700 dark:text-neutral-300', + bg: 'bg-neutral-100 dark:bg-neutral-800', + icon: ( + + + + ), + }, + order_refused: { + color: 'text-red-700 dark:text-red-300', + bg: 'bg-red-100 dark:bg-red-900/30', + icon: ( + + + + ), + }, + results_ready: { + color: 'text-green-700 dark:text-green-300', + bg: 'bg-green-100 dark:bg-green-900/30', + icon: ( + + + + ), + }, + employee_added: { + color: 'text-primary-700 dark:text-primary-300', + bg: 'bg-primary-100 dark:bg-primary-900/30', + icon: ( + + + + ), + }, + invoice_paid: { + color: 'text-amber-700 dark:text-amber-300', + bg: 'bg-amber-100 dark:bg-amber-900/30', + icon: ( + + + + ), + }, + message: { + color: 'text-sky-700 dark:text-sky-300', + bg: 'bg-sky-100 dark:bg-sky-900/30', + icon: ( + + + + ), + }, + system: { + color: 'text-neutral-700 dark:text-neutral-300', + bg: 'bg-neutral-100 dark:bg-neutral-800', + icon: ( + + + + ), + }, +}; + +// ============================================================================= +// Component +// ============================================================================= + +/** + * ActivityFeed — compact vertical event list. Intended for dashboards to + * surface recent order/result/invoice/message activity. + */ +export function ActivityFeed({ + items, + loading = false, + emptyState, + maxItems, + className, +}: ActivityFeedProps): React.JSX.Element { + const visible = maxItems ? items.slice(0, maxItems) : items; + + if (loading) { + return ( +
+ {[0, 1, 2, 3].map((i) => ( +
+
+
+
+
+
+
+ ))} +
+ ); + } + + if (!items.length) { + return ( +
+ {emptyState ?? ( +

+ No recent activity +

+ )} +
+ ); + } + + return ( +
    + {visible.map((item, idx) => { + const style = KIND_STYLE[item.kind] ?? KIND_STYLE.system; + const isLast = idx === visible.length - 1; + const interactive = Boolean(item.onClick); + return ( +
  1. + {/* Timeline rail */} + {!isLast && ( +
  2. + ); + })} +
+ ); +} diff --git a/src/components/ActivityFeed/index.ts b/src/components/ActivityFeed/index.ts new file mode 100644 index 00000000..a87c71a9 --- /dev/null +++ b/src/components/ActivityFeed/index.ts @@ -0,0 +1,6 @@ +export { + ActivityFeed, + type ActivityFeedProps, + type ActivityItem, + type ActivityKind, +} from './ActivityFeed'; diff --git a/src/components/AddContactModal/AddContactModal.stories.tsx b/src/components/AddContactModal/AddContactModal.stories.tsx index bc67239c..4ea6877c 100644 --- a/src/components/AddContactModal/AddContactModal.stories.tsx +++ b/src/components/AddContactModal/AddContactModal.stories.tsx @@ -1,7 +1,8 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useEffect, useState } from 'react'; -import { AddContactModal, ContactFormData } from './AddContactModal'; + import { Button } from '../Button/Button'; +import { AddContactModal, ContactFormData } from './AddContactModal'; const meta: Meta = { title: 'Components/Forms & Inputs/AddContactModal', diff --git a/src/components/AddContactModal/AddContactModal.tsx b/src/components/AddContactModal/AddContactModal.tsx index 9f0f19f8..0dcc7ed1 100644 --- a/src/components/AddContactModal/AddContactModal.tsx +++ b/src/components/AddContactModal/AddContactModal.tsx @@ -1,18 +1,19 @@ 'use client'; import * as React from 'react'; -import { useState, useEffect } from 'react'; +import { useEffect, useState } from 'react'; + +import { cn } from '../../utils/cn'; +import { Button } from '../Button/Button'; +import { Input } from '../Input/Input'; import { Modal, - ModalHeader, - ModalTitle, ModalBody, ModalFooter, + ModalHeader, + ModalTitle, } from '../Modal/Modal'; -import { Button } from '../Button/Button'; -import { Input } from '../Input/Input'; import { Select } from '../Select/Select'; -import { cn } from '../../utils/cn'; // ============================================================================ // Constants diff --git a/src/components/AddContactModal/index.ts b/src/components/AddContactModal/index.ts index 8b6c2486..234244a3 100644 --- a/src/components/AddContactModal/index.ts +++ b/src/components/AddContactModal/index.ts @@ -1,7 +1,7 @@ -export { AddContactModal } from './AddContactModal'; export type { AddContactModalProps, - ContactFormData, ContactAddress, + ContactFormData, CustomField, } from './AddContactModal'; +export { AddContactModal } from './AddContactModal'; diff --git a/src/components/AdditionalFields/AdditionalFields.tsx b/src/components/AdditionalFields/AdditionalFields.tsx index 0f2b8192..46c4d526 100644 --- a/src/components/AdditionalFields/AdditionalFields.tsx +++ b/src/components/AdditionalFields/AdditionalFields.tsx @@ -1,4 +1,5 @@ import * as React from 'react'; + import { cn } from '../../utils/cn'; import { Button } from '../Button'; import { ChevronDownIcon, PlusIcon, TrashIcon } from '../Icons'; diff --git a/src/components/AdditionalFields/index.ts b/src/components/AdditionalFields/index.ts index d6db0435..17d42536 100644 --- a/src/components/AdditionalFields/index.ts +++ b/src/components/AdditionalFields/index.ts @@ -1,6 +1,6 @@ export { AdditionalFields, - generateId, type AdditionalFieldsProps, + generateId, type KeyValueEntry, } from './AdditionalFields'; diff --git a/src/components/Address/Address.stories.tsx b/src/components/Address/Address.stories.tsx index ea4f9159..4f57cba1 100644 --- a/src/components/Address/Address.stories.tsx +++ b/src/components/Address/Address.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react'; + import { Address, AddressCard, - AddressInline, AddressCompact, type AddressData, + AddressInline, } from './Address'; const meta: Meta = { diff --git a/src/components/Address/index.ts b/src/components/Address/index.ts index 12a925f9..f3db7385 100644 --- a/src/components/Address/index.ts +++ b/src/components/Address/index.ts @@ -1,25 +1,24 @@ export { Address, AddressCard, - AddressInline, + type AddressCardProps, AddressCompact, + type AddressCompactProps, + // Types + type AddressData, + AddressInline, + type AddressInlineProps, + type AddressProps, + formatAddressLines, // Utility functions formatAddressSingleLine, - formatAddressLines, - formatCityStateZip, formatCityState, - getGoogleMapsUrl, + formatCityStateZip, getGoogleMapsSearchUrl, - // Types - type AddressData, - type AddressProps, - type AddressCardProps, - type AddressInlineProps, - type AddressCompactProps, + getGoogleMapsUrl, } from './Address'; - export { AddressForm, - type AddressFormProps, type AddressFormData, + type AddressFormProps, } from './AddressForm'; diff --git a/src/components/Alert/index.ts b/src/components/Alert/index.ts index 9b6803ea..4ac7230c 100644 --- a/src/components/Alert/index.ts +++ b/src/components/Alert/index.ts @@ -1,7 +1,7 @@ export { Alert, - AlertTitle, AlertDescription, - alertVariants, type AlertProps, + AlertTitle, + alertVariants, } from './Alert'; diff --git a/src/components/AudioRecorder/index.ts b/src/components/AudioRecorder/index.ts index 21064a76..988a1a00 100644 --- a/src/components/AudioRecorder/index.ts +++ b/src/components/AudioRecorder/index.ts @@ -1,10 +1,10 @@ export { AudioRecorder, + type AudioRecorderControlsRenderProps, + type AudioRecorderProps, + type AudioRecorderState, audioRecorderVariants, - waveformContainerVariants, controlButtonVariants, formatTime, - type AudioRecorderProps, - type AudioRecorderState, - type AudioRecorderControlsRenderProps, + waveformContainerVariants, } from './AudioRecorder'; diff --git a/src/components/AuthDialog/index.ts b/src/components/AuthDialog/index.ts index 2e295e9f..c99ad168 100644 --- a/src/components/AuthDialog/index.ts +++ b/src/components/AuthDialog/index.ts @@ -1,8 +1,8 @@ export { AuthDialog, - DEFAULT_SOCIAL_PROVIDERS, type AuthDialogProps, type AuthMode, - type SocialProvider, + DEFAULT_SOCIAL_PROVIDERS, type SignupData, + type SocialProvider, } from './AuthDialog'; diff --git a/src/components/Avatar/Avatar.stories.tsx b/src/components/Avatar/Avatar.stories.tsx index bafbc165..54796bff 100644 --- a/src/components/Avatar/Avatar.stories.tsx +++ b/src/components/Avatar/Avatar.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; + import { Avatar, AvatarGroup } from './Avatar'; const meta: Meta = { diff --git a/src/components/Avatar/Avatar.tsx b/src/components/Avatar/Avatar.tsx index a49edab2..24df7c54 100644 --- a/src/components/Avatar/Avatar.tsx +++ b/src/components/Avatar/Avatar.tsx @@ -1,5 +1,6 @@ -import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + import { cn } from '../../utils/cn'; // ============================================================================ @@ -34,8 +35,7 @@ const avatarVariants = cva( ); export interface AvatarProps - extends - React.HTMLAttributes, + extends React.HTMLAttributes, VariantProps { /** Image URL for the avatar */ src?: string | null; diff --git a/src/components/Badge/Badge.stories.tsx b/src/components/Badge/Badge.stories.tsx index ef3e6f92..c33f5880 100644 --- a/src/components/Badge/Badge.stories.tsx +++ b/src/components/Badge/Badge.stories.tsx @@ -1,23 +1,24 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; +import type { LucideIcon } from 'lucide-react'; import React from 'react'; -import { Badge } from './Badge'; + import { - CheckIcon, AlertCircleIcon, - InfoIcon, - StarIcon, - HeartIcon, BellIcon, - TagIcon, - ZapIcon, - ShieldIcon, + CheckIcon, ClockIcon, - UserIcon, + HeartIcon, + InfoIcon, MailIcon, PlusIcon, + ShieldIcon, SparklesIcon, + StarIcon, + TagIcon, + UserIcon, + ZapIcon, } from '../Icons'; -import type { LucideIcon } from 'lucide-react'; +import { Badge } from './Badge'; // Map of available icons for the dropdown const iconMap: Record = { diff --git a/src/components/Badge/Badge.tsx b/src/components/Badge/Badge.tsx index 2a21c065..1cb7e49e 100644 --- a/src/components/Badge/Badge.tsx +++ b/src/components/Badge/Badge.tsx @@ -1,5 +1,6 @@ -import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + import { cn } from '../../utils/cn'; const badgeVariants = cva( @@ -37,8 +38,7 @@ const badgeVariants = cva( ); export interface BadgeProps - extends - React.HTMLAttributes, + extends React.HTMLAttributes, VariantProps { /** Optional icon before the text */ icon?: React.ReactNode; diff --git a/src/components/Badge/index.ts b/src/components/Badge/index.ts index 7385ca71..72c30950 100644 --- a/src/components/Badge/index.ts +++ b/src/components/Badge/index.ts @@ -1 +1 @@ -export { Badge, badgeVariants, type BadgeProps } from './Badge'; +export { Badge, type BadgeProps, badgeVariants } from './Badge'; diff --git a/src/components/BookingDialog/index.ts b/src/components/BookingDialog/index.ts index 0eb4ec5a..0a4fcb70 100644 --- a/src/components/BookingDialog/index.ts +++ b/src/components/BookingDialog/index.ts @@ -1,21 +1,20 @@ export { BookingDialog, - FloatingInput, - ServiceSelect, - ConsentSwitch, - DialogOverlay, - InlineBookingForm, - QuickBookCard, - type BookingService, - type BookingProvider, - type BookingFormData, type BookingDialogProps, - type FloatingInputProps, - type ServiceSelectProps, + type BookingFormData, + type BookingProvider, + type BookingService, + ConsentSwitch, type ConsentSwitchProps, + DialogOverlay, type DialogOverlayProps, + FloatingInput, + type FloatingInputProps, + InlineBookingForm, type InlineBookingFormProps, + QuickBookCard, type QuickBookCardProps, + ServiceSelect, + type ServiceSelectProps, } from './BookingDialog'; - export { default } from './BookingDialog'; diff --git a/src/components/Breadcrumb/index.ts b/src/components/Breadcrumb/index.ts index d4cb6f6c..f8534f1d 100644 --- a/src/components/Breadcrumb/index.ts +++ b/src/components/Breadcrumb/index.ts @@ -1,6 +1,6 @@ export { Breadcrumb, - BreadcrumbSlash, - type BreadcrumbProps, type BreadcrumbItem, + type BreadcrumbProps, + BreadcrumbSlash, } from './Breadcrumb'; diff --git a/src/components/BusinessHours/BusinessHours.stories.tsx b/src/components/BusinessHours/BusinessHours.stories.tsx index 2dd4bf8c..66641827 100644 --- a/src/components/BusinessHours/BusinessHours.stories.tsx +++ b/src/components/BusinessHours/BusinessHours.stories.tsx @@ -1,10 +1,11 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; + import { BusinessHours, + type BusinessHoursSchedule, CompactHours, HoursSummary, OpenStatusBadge, - type BusinessHoursSchedule, } from './BusinessHours'; // Sample data diff --git a/src/components/BusinessHours/index.ts b/src/components/BusinessHours/index.ts index 883951d2..768a8625 100644 --- a/src/components/BusinessHours/index.ts +++ b/src/components/BusinessHours/index.ts @@ -1,12 +1,12 @@ export { BusinessHours, - CompactHours, - HoursSummary, - OpenStatusBadge, type BusinessHoursProps, - type CompactHoursProps, - type HoursSummaryProps, type BusinessHoursSchedule, + CompactHours, + type CompactHoursProps, type DayHours, + HoursSummary, + type HoursSummaryProps, + OpenStatusBadge, type TimeRange, } from './BusinessHours'; diff --git a/src/components/BusinessHoursEditor/BusinessHoursEditor.stories.tsx b/src/components/BusinessHoursEditor/BusinessHoursEditor.stories.tsx index a329d52f..6bd3a76e 100644 --- a/src/components/BusinessHoursEditor/BusinessHoursEditor.stories.tsx +++ b/src/components/BusinessHoursEditor/BusinessHoursEditor.stories.tsx @@ -1,11 +1,12 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useEffect, useState } from 'react'; + import { BusinessHoursEditor, - DaySchedule, - createDefaultSchedule, create24HourSchedule, + createDefaultSchedule, createWeekdaySchedule, + DaySchedule, } from './BusinessHoursEditor'; const meta: Meta = { diff --git a/src/components/BusinessHoursEditor/index.ts b/src/components/BusinessHoursEditor/index.ts index 1a8fa246..931e6e28 100644 --- a/src/components/BusinessHoursEditor/index.ts +++ b/src/components/BusinessHoursEditor/index.ts @@ -1,11 +1,11 @@ -export { - BusinessHoursEditor, - createDefaultSchedule, - create24HourSchedule, - createWeekdaySchedule, -} from './BusinessHoursEditor'; export type { BusinessHoursEditorProps, DaySchedule, TimeSlot, } from './BusinessHoursEditor'; +export { + BusinessHoursEditor, + create24HourSchedule, + createDefaultSchedule, + createWeekdaySchedule, +} from './BusinessHoursEditor'; diff --git a/src/components/Button/Button.stories.tsx b/src/components/Button/Button.stories.tsx index 3a29cf3c..038aa310 100644 --- a/src/components/Button/Button.stories.tsx +++ b/src/components/Button/Button.stories.tsx @@ -1,55 +1,56 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; -import { Button } from './Button'; import { - Plus, - Minus, + ArrowLeft, + ArrowRight, + Bell, + BellOff, + Calendar, Check, - X, - ChevronRight, - ChevronLeft, ChevronDown, + ChevronLeft, + ChevronRight, ChevronUp, - ArrowRight, - ArrowLeft, - Search, - Settings, - User, - Users, - Mail, - Phone, - Calendar, Clock, - Heart, - Star, - Trash2, - Edit, Copy, Download, - Upload, - Share, - Send, - Save, - Loader2, - RefreshCw, + Edit, ExternalLink, - Link as LinkIcon, Eye, EyeOff, - Lock, - Unlock, - Bell, - BellOff, + Filter, + Heart, Home, + Link as LinkIcon, + Loader2, + Lock, + type LucideIcon, + Mail, Menu, + Minus, MoreHorizontal, MoreVertical, - Filter, + Phone, + Plus, + RefreshCw, + Save, + Search, + Send, + Settings, + Share, SortAsc, SortDesc, + Star, + Trash2, + Unlock, + Upload, + User, + Users, + X, Zap, - type LucideIcon, } from 'lucide-react'; +import { Button } from './Button'; + // Icon registry for Storybook controls const iconRegistry: Record = { None: undefined, @@ -120,7 +121,15 @@ const meta: Meta = { argTypes: { variant: { control: 'select', - options: ['primary', 'secondary', 'ghost', 'outline', 'danger', 'link'], + options: [ + 'primary', + 'brand', + 'secondary', + 'ghost', + 'outline', + 'danger', + 'link', + ], }, size: { control: 'select', @@ -164,6 +173,22 @@ export const Primary: Story = { }, }; +export const Brand: Story = { + args: { + children: 'Get started', + variant: 'brand', + size: 'lg', + }, + parameters: { + docs: { + description: { + story: + 'High-emphasis brand action with a gradient fill, brand-tinted glow, and a subtle lift on hover. Use for hero CTAs and auth flows — not for routine actions.', + }, + }, + }, +}; + export const Secondary: Story = { args: { children: 'Secondary Button', diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index a561c385..9ea26ceb 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -1,6 +1,7 @@ -import { describe, it, expect, vi } from 'vitest'; -import { screen, fireEvent } from '@testing-library/react'; +import { fireEvent, screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + import { renderWithTheme } from '../../test/test-utils'; import { Button } from './Button'; diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index 1fedec40..899c4700 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -1,5 +1,6 @@ -import * as React from 'react'; import { cva, type VariantProps } from 'class-variance-authority'; +import * as React from 'react'; + import { cn } from '../../utils/cn'; const buttonVariants = cva( @@ -19,6 +20,11 @@ const buttonVariants = cva( 'hover:bg-primary-900', 'active:bg-primary-950', ], + brand: [ + 'bg-gradient-brand text-white shadow-glow', + 'hover:-translate-y-0.5 hover:shadow-glow-hover', + 'active:translate-y-0 active:shadow-glow', + ], secondary: [ 'bg-neutral-200 text-neutral-900', 'hover:bg-neutral-300', @@ -94,6 +100,7 @@ export interface ButtonProps * @example * ```tsx * + * * * * ``` diff --git a/src/components/Button/index.ts b/src/components/Button/index.ts index ce295122..4116a61b 100644 --- a/src/components/Button/index.ts +++ b/src/components/Button/index.ts @@ -1 +1 @@ -export { Button, buttonVariants, type ButtonProps } from './Button'; +export { Button, type ButtonProps, buttonVariants } from './Button'; diff --git a/src/components/CSVColumnMapper/CSVColumnMapper.stories.tsx b/src/components/CSVColumnMapper/CSVColumnMapper.stories.tsx index 549671c6..e45175d0 100644 --- a/src/components/CSVColumnMapper/CSVColumnMapper.stories.tsx +++ b/src/components/CSVColumnMapper/CSVColumnMapper.stories.tsx @@ -1,9 +1,10 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; + import { + type CSVColumn, CSVColumnMapper, CSVFileUpload, - type CSVColumn, } from './CSVColumnMapper'; const meta: Meta = { @@ -155,7 +156,7 @@ function FileUploadWrapper() { }} /> {file && ( -

+

Selected file: {file.name}

)} diff --git a/src/components/CSVColumnMapper/index.ts b/src/components/CSVColumnMapper/index.ts index 24c07fad..dcf56182 100644 --- a/src/components/CSVColumnMapper/index.ts +++ b/src/components/CSVColumnMapper/index.ts @@ -1,8 +1,8 @@ export { + type CSVColumn, CSVColumnMapper, - CSVFileUpload, type CSVColumnMapperProps, - type CSVColumn, - type FieldOption, + CSVFileUpload, type CSVFileUploadProps, + type FieldOption, } from './CSVColumnMapper'; diff --git a/src/components/Card/Card.stories.tsx b/src/components/Card/Card.stories.tsx index 0dbe4ae2..7e59cb93 100644 --- a/src/components/Card/Card.stories.tsx +++ b/src/components/Card/Card.stories.tsx @@ -1,20 +1,21 @@ import type { Meta, StoryObj } from '@storybook/react-vite'; import { useState } from 'react'; + +import { Button } from '../Button'; import { Card, - CardHeader, - CardTitle, - CardDescription, + CardActions, + CardBadge, + CardCollapsible, CardContent, + CardDescription, + CardDivider, CardFooter, + CardHeader, CardMedia, - CardBadge, - CardActions, - CardDivider, - CardCollapsible, CardStat, + CardTitle, } from './Card'; -import { Button } from '../Button'; const meta: Meta = { title: 'Components/Layout & Structure/Card', @@ -149,7 +150,7 @@ export const NoPadding: Story = { />

Image Card

-

+

Card with no padding and an image.

@@ -180,31 +181,31 @@ export const Variants: Story = {

Default

-

With shadow

+

With shadow

Elevated

-

Larger shadow

+

Larger shadow

Outlined

-

Thicker border

+

Thicker border

Ghost

-

No background

+

No background

Filled

-

Muted background

+

Muted background

@@ -218,31 +219,31 @@ export const WithAccent: Story = {

Primary

-

Brand color accent

+

Brand color accent

Success

-

Positive status

+

Positive status

Warning

-

Caution needed

+

Caution needed

Destructive

-

Critical alert

+

Critical alert

Info

-

Informational

+

Informational

@@ -295,7 +296,7 @@ export const WithMediaOverlay: Story = { } />
-

+

Join our guided night tour and witness the beauty of the stars.

@@ -317,7 +318,7 @@ export const WithBadges: Story = {

Product Card

-

With success badge

+

With success badge

@@ -326,7 +327,7 @@ export const WithBadges: Story = {

Product Card

-

+

With destructive badge

@@ -337,7 +338,7 @@ export const WithBadges: Story = {

Product Card

-

With warning badge

+

With warning badge

@@ -363,7 +364,7 @@ export const Selectable: Story = { >

Option {id}

-

+

{selected === id ? 'Selected' : 'Click to select'}

@@ -580,7 +581,7 @@ export const ComplexCard: Story = { Master modern React development -
+
-
    +
    • • Introduction to Advanced Patterns
    • • Compound Components
    • • Render Props & HOCs
    • @@ -634,7 +635,7 @@ export const ComplexCard: Story = {
      $79 - + $129
      diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx index 70189d61..3d088dd6 100644 --- a/src/components/Checkbox/Checkbox.stories.tsx +++ b/src/components/Checkbox/Checkbox.stories.tsx @@ -1,5 +1,6 @@ -import * as React from 'react'; import type { Meta, StoryObj } from '@storybook/react-vite'; +import * as React from 'react'; + import { Checkbox, CheckboxGroup } from './Checkbox'; const meta: Meta = { diff --git a/src/components/Checkbox/index.ts b/src/components/Checkbox/index.ts index 305b4baf..ed502654 100644 --- a/src/components/Checkbox/index.ts +++ b/src/components/Checkbox/index.ts @@ -1,7 +1,7 @@ export { Checkbox, CheckboxGroup, - checkboxVariants, - type CheckboxProps, type CheckboxGroupProps, + type CheckboxProps, + checkboxVariants, } from './Checkbox'; diff --git a/src/components/CheckrIntegration/CheckrIntegration.stories.tsx b/src/components/CheckrIntegration/CheckrIntegration.stories.tsx index bdb6eddc..e446977a 100644 --- a/src/components/CheckrIntegration/CheckrIntegration.stories.tsx +++ b/src/components/CheckrIntegration/CheckrIntegration.stories.tsx @@ -1,8 +1,9 @@ import type { Meta, StoryObj } from '@storybook/react'; import { useState } from 'react'; + import { - CheckrIntegration, type BackgroundCheckReport, + CheckrIntegration, } from './CheckrIntegration'; const samplePackages = [ diff --git a/src/components/CheckrIntegration/index.ts b/src/components/CheckrIntegration/index.ts index 0f4a8ff6..56291d80 100644 --- a/src/components/CheckrIntegration/index.ts +++ b/src/components/CheckrIntegration/index.ts @@ -1,6 +1,6 @@ export { - CheckrIntegration, - type CheckrIntegrationProps, type BackgroundCheckCandidate, type BackgroundCheckReport, + CheckrIntegration, + type CheckrIntegrationProps, } from './CheckrIntegration'; diff --git a/src/components/ClaimProviderForm/ClaimProviderForm.stories.tsx b/src/components/ClaimProviderForm/ClaimProviderForm.stories.tsx index 48346d6b..df4a1ee1 100644 --- a/src/components/ClaimProviderForm/ClaimProviderForm.stories.tsx +++ b/src/components/ClaimProviderForm/ClaimProviderForm.stories.tsx @@ -1,4 +1,5 @@ import type { Meta, StoryObj } from '@storybook/react'; + import { ClaimProviderForm } from './ClaimProviderForm'; const meta: Meta = { diff --git a/src/components/ClaimProviderForm/index.ts b/src/components/ClaimProviderForm/index.ts index d41e8e9a..9c58428c 100644 --- a/src/components/ClaimProviderForm/index.ts +++ b/src/components/ClaimProviderForm/index.ts @@ -1,5 +1,5 @@ -export { ClaimProviderForm } from './ClaimProviderForm'; export type { - ClaimProviderFormProps, ClaimFormData, + ClaimProviderFormProps, } from './ClaimProviderForm'; +export { ClaimProviderForm } from './ClaimProviderForm'; diff --git a/src/components/CommandPalette/CommandPaletteProvider.tsx b/src/components/CommandPalette/CommandPaletteProvider.tsx index 3147080c..b953efc8 100644 --- a/src/components/CommandPalette/CommandPaletteProvider.tsx +++ b/src/components/CommandPalette/CommandPaletteProvider.tsx @@ -1,11 +1,12 @@ import React, { createContext, - useContext, + type ReactNode, useCallback, - useState, + useContext, useMemo, - type ReactNode, + useState, } from 'react'; + import { useCommandK } from '../../hooks/useKeyboardShortcut'; // ============================================================================= diff --git a/src/components/CommandPalette/index.ts b/src/components/CommandPalette/index.ts index 7eff38f3..5ce9288f 100644 --- a/src/components/CommandPalette/index.ts +++ b/src/components/CommandPalette/index.ts @@ -1,14 +1,14 @@ export { CommandPalette, - CommandPaletteTrigger, type CommandPaletteProps, + CommandPaletteTrigger, type CommandPaletteTriggerProps, } from './CommandPalette'; export { - CommandPaletteProvider, - useCommandPalette, - type CommandPaletteItem, type CommandPaletteCategory, type CommandPaletteContextValue, + type CommandPaletteItem, + CommandPaletteProvider, type CommandPaletteProviderProps, + useCommandPalette, } from './CommandPaletteProvider'; diff --git a/src/components/ConfirmDialog/ConfirmDialog.stories.tsx b/src/components/ConfirmDialog/ConfirmDialog.stories.tsx new file mode 100644 index 00000000..04afc9e3 --- /dev/null +++ b/src/components/ConfirmDialog/ConfirmDialog.stories.tsx @@ -0,0 +1,107 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { Button } from '../Button/Button'; +import { ConfirmDialog } from './ConfirmDialog'; + +const meta: Meta = { + title: 'Components/ConfirmDialog', + component: ConfirmDialog, + tags: ['autodocs'], + parameters: { + docs: { + description: { + component: + 'A confirmation dialog built on top of `Modal`. Supports an optional custom-message textarea — used for invites, enrollment emails, and other actions where the sender may include a personal note.', + }, + }, + }, +}; + +export default meta; + +type Story = StoryObj; + +function Harness(props: React.ComponentProps) { + const [open, setOpen] = React.useState(false); + const [submitting, setSubmitting] = React.useState(false); + const [lastMessage, setLastMessage] = React.useState(); + + return ( +
      + + {lastMessage !== undefined && ( +
      + Last confirmed message:{' '} + {lastMessage || (none)} +
      + )} + { + setSubmitting(true); + await new Promise((r) => setTimeout(r, 600)); + setSubmitting(false); + setLastMessage(message ?? ''); + setOpen(false); + }} + /> +
      + ); +} + +export const Default: Story = { + render: (args) => , + args: { + title: 'Remove user?', + description: + 'They will lose access to this organization immediately. You can re-invite them later.', + confirmLabel: 'Remove', + variant: 'danger', + }, +}; + +export const SendEnrollmentEmail: Story = { + render: (args) => , + args: { + title: 'Send enrollment email?', + description: + 'An enrollment invitation will be sent to jane.doe@example.com. You can optionally include a personal note below.', + confirmLabel: 'Send Email', + messageField: { + placeholder: + "Add a personal note — e.g. 'Welcome to the team! Let us know if you have questions.'", + helperText: 'Included in the invitation email.', + }, + }, +}; + +export const RequiredMessage: Story = { + render: (args) => , + args: { + title: 'Reject claim?', + description: + 'Please provide a reason — it will be shared with the submitter.', + confirmLabel: 'Reject', + variant: 'danger', + messageField: { + label: 'Rejection reason', + placeholder: 'Explain why this claim is being rejected…', + required: true, + minLength: 10, + }, + }, +}; + +export const InfoOnly: Story = { + render: (args) => , + args: { + title: 'Publish changes?', + description: + 'Your changes will be visible to all employees in this organization.', + confirmLabel: 'Publish', + }, +}; diff --git a/src/components/ConfirmDialog/ConfirmDialog.tsx b/src/components/ConfirmDialog/ConfirmDialog.tsx new file mode 100644 index 00000000..916bda6a --- /dev/null +++ b/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,248 @@ +'use client'; + +import * as React from 'react'; + +import { Button } from '../Button/Button'; +import { + Modal, + ModalBody, + ModalFooter, + ModalHeader, + ModalTitle, +} from '../Modal/Modal'; +import { Textarea } from '../Textarea/Textarea'; + +export type ConfirmDialogVariant = 'default' | 'danger' | 'warning' | 'info'; + +export interface ConfirmDialogMessageFieldOptions { + /** Label shown above the textarea. Defaults to "Personal Message (optional)". */ + label?: string; + /** Placeholder inside the textarea. */ + placeholder?: string; + /** Helper text shown under the textarea. */ + helperText?: string; + /** Whether a message is required to confirm. Default false. */ + required?: boolean; + /** Minimum character length if provided (enforced on submit). */ + minLength?: number; + /** Maximum character length. Defaults to 1500. */ + maxLength?: number; + /** Number of visible rows. Defaults to 4. */ + rows?: number; + /** Initial value. */ + defaultValue?: string; +} + +export interface ConfirmDialogProps { + /** Whether the dialog is open. */ + open: boolean; + /** Called when the dialog requests to close (cancel, overlay click, escape). */ + onOpenChange: (open: boolean) => void; + /** Dialog title. */ + title: React.ReactNode; + /** Body content — a description of what is about to happen. */ + description?: React.ReactNode; + /** Confirm button label. Defaults to "Confirm". */ + confirmLabel?: string; + /** Cancel button label. Defaults to "Cancel". */ + cancelLabel?: string; + /** + * Visual intent. `danger` styles the confirm button as destructive, + * `warning` uses a warning tone, `default` uses the primary action style. + */ + variant?: ConfirmDialogVariant; + /** + * If provided, renders a textarea for an optional (or required) message + * and passes its value to `onConfirm`. Pass `true` to enable with defaults, + * or an options object to customize. + */ + messageField?: boolean | ConfirmDialogMessageFieldOptions; + /** Whether the confirm action is in-flight. Disables buttons and shows "Sending…". */ + isSubmitting?: boolean; + /** Optional error text shown inside the dialog (e.g. after a failed submit). */ + errorMessage?: string; + /** + * Called when the user confirms. Receives the message string when + * `messageField` is enabled, otherwise `undefined`. + */ + onConfirm: (message: string | undefined) => void | Promise; + /** Called when the user cancels. Defaults to `onOpenChange(false)`. */ + onCancel?: () => void; + /** Modal size. Defaults to `sm` (or `md` when messageField is enabled). */ + size?: 'sm' | 'md' | 'lg'; + /** Extra content rendered between the description and the message field. */ + children?: React.ReactNode; +} + +function resolveMessageFieldOptions( + field: ConfirmDialogProps['messageField'] +): ConfirmDialogMessageFieldOptions | null { + if (!field) return null; + if (field === true) return {}; + return field; +} + +/** + * A confirmation dialog built on top of `Modal`. Supports an optional + * "custom message" textarea — useful for invites / enrollment emails + * where the sender can include a personal note. + * + * @example + * ```tsx + * const [open, setOpen] = React.useState(false); + * + * { + * await api.sendEnrollmentEmail(id, employerId, { optionalMessage: message }); + * setOpen(false); + * }} + * /> + * ``` + */ +export function ConfirmDialog({ + open, + onOpenChange, + title, + description, + confirmLabel = 'Confirm', + cancelLabel = 'Cancel', + variant = 'default', + messageField, + isSubmitting = false, + errorMessage, + onConfirm, + onCancel, + size, + children, +}: ConfirmDialogProps) { + const fieldOpts = resolveMessageFieldOptions(messageField); + const [message, setMessage] = React.useState(fieldOpts?.defaultValue ?? ''); + const [validationError, setValidationError] = React.useState( + null + ); + + // Reset state whenever the dialog is reopened or the default changes. + React.useEffect(() => { + if (open) { + setMessage(fieldOpts?.defaultValue ?? ''); + setValidationError(null); + } + }, [open]); + + const handleCancel = React.useCallback(() => { + if (isSubmitting) return; + if (onCancel) onCancel(); + else onOpenChange(false); + }, [isSubmitting, onCancel, onOpenChange]); + + const handleConfirm = React.useCallback(async () => { + if (isSubmitting) return; + + let messageToSend: string | undefined; + if (fieldOpts) { + const trimmed = message.trim(); + if (fieldOpts.required && trimmed.length === 0) { + setValidationError('A message is required.'); + return; + } + if ( + fieldOpts.minLength && + trimmed.length > 0 && + trimmed.length < fieldOpts.minLength + ) { + setValidationError( + `Message must be at least ${fieldOpts.minLength} characters.` + ); + return; + } + setValidationError(null); + messageToSend = trimmed.length > 0 ? trimmed : undefined; + } + + await onConfirm(messageToSend); + }, [fieldOpts, isSubmitting, message, onConfirm]); + + const confirmVariant: 'danger' | 'primary' = + variant === 'danger' ? 'danger' : 'primary'; + + const resolvedSize = size ?? (fieldOpts ? 'md' : 'sm'); + const maxLength = fieldOpts?.maxLength ?? 1500; + const rows = fieldOpts?.rows ?? 4; + + return ( + { + if (!next && isSubmitting) return; // prevent close mid-submit + onOpenChange(next); + }} + size={resolvedSize} + closeOnEscape={!isSubmitting} + closeOnOverlayClick={!isSubmitting} + > + + {title} + + + {description && ( +
      {description}
      + )} + + {children} + + {fieldOpts && ( +