diff --git a/.github/agents/ag-grid-styling.agent.md b/.github/agents/ag-grid-styling.agent.md index e234e4e3c..2108696d5 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,111 +1087,5 @@ 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)` | -| `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)` | - -## 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 -``` - -### ✅ 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 -``` - -### 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-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 -``` - -## Audit Process - -When auditing a component or directory: - -1. **Search for hardcoded color patterns:** - - `bg-violet-`, `bg-purple-`, `bg-blue-`, `bg-indigo-` - - `text-violet-`, `text-purple-`, `text-blue-`, `text-indigo-` - - `from-violet-`, `from-purple-`, `to-violet-`, `to-purple-` - - `border-violet-`, `border-purple-`, `ring-violet-`, `ring-purple-` - -2. **Verify brand color usage:** - - Primary actions should use `primary-*` - - Secondary actions should use `secondary-*` - - Text should use `neutral-*` for body text - -3. **Check border radius consistency:** - - Look for hardcoded pixel values like `rounded-[16px]` - - Verify modal/card containers use `rounded-2xl` or `rounded-xl` - -4. **Review gradients:** - - Gradients with hardcoded colors should be converted to solid `primary-*` colors - - Or use CSS variables directly - -## Output Format - -When reporting issues, use this format: - -### 🔍 Audit Results for `ComponentName` - -**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` | - -**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 | - -## Key Files to Reference -- `src/tailwind-preset.ts` - Tailwind class mappings -- `src/brands/*.css` - Brand-specific CSS variables -- `src/brands/types.ts` - TypeScript brand definitions +For general brand tokens and Tailwind guidance, see [the style agent](style.agent.md). diff --git a/.github/agents/style.agent.md b/.github/agents/style.agent.md index b2059b162..be3f46159 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 +
// Hardcoded violet +
// Hardcoded purple +
// Hardcoded blue +
// Hardcoded indigo +
// 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 +
// Uses brand primary +
// Uses brand primary +
// Uses brand secondary +
// 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 +
// ✅ Mapped to --mieweb-radius-lg +
// ✅ Mapped to --mieweb-radius-2xl +
// ✅ OK for circular elements (avatars, pills) +
// ❌ 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 09eac3871..96e5fa21d 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 4806f50b9..955980113 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 a7c256559..2387d473c 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 f6cfbf442..973068a12 100644 --- a/.storybook/manager.ts +++ b/.storybook/manager.ts @@ -67,7 +67,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', @@ -115,7 +115,7 @@ function createBrandTheme(brandKey: BrandKey, isDark = false) { fontCode: '"SF Mono", "Monaco", "Consolas", monospace', }); } - + return create({ base: 'light', @@ -232,13 +232,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; @@ -248,7 +248,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; @@ -468,9 +468,11 @@ function injectBrandCSS(brandKey: BrandKey, isDark = false) { label[for^="control-"]:has(input[role="switch"]) input[type="checkbox"] { background: transparent !important; } - ` : ''} + ` + : '' + } `; - + document.head.appendChild(style); } @@ -480,18 +482,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/.storybook/preview.tsx b/.storybook/preview.tsx index edec12e59..ac8d34aa0 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -488,6 +488,8 @@ const preview: Preview = { [ 'Data display', ['Overview', '*'], + 'Identity', + ['Overview', '*'], 'Grids', ['Overview', '*'], 'Feedback', @@ -505,6 +507,8 @@ const preview: Preview = { [ 'Dashboards', ['Overview', '*'], + 'Portals', + ['Overview', '*'], 'Media', ['Overview', '*'], 'Editors', @@ -535,6 +539,8 @@ const preview: Preview = { ['Overview', '*'], 'Providers', ['Overview', '*'], + 'Provider discovery', + ['Overview', '*'], 'Services', ['Overview', '*'], 'Users & integrations', diff --git a/.storybook/taxonomy.json b/.storybook/taxonomy.json index e76a0cf8f..563a0b5ef 100644 --- a/.storybook/taxonomy.json +++ b/.storybook/taxonomy.json @@ -51,6 +51,7 @@ "scope": "scope:general-purpose", "families": [ "Data display", + "Identity", "Grids", "Feedback", "Loading", @@ -64,6 +65,7 @@ "scope": "scope:general-purpose", "families": [ "Dashboards", + "Portals", "Media", "Editors", "Chat", @@ -86,6 +88,7 @@ "Employers", "Billing", "Providers", + "Provider discovery", "Services", "Users & integrations", "Operations" diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5af9a06b5..1f82f96ec 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -336,6 +336,8 @@ Current notes: | Module | Why it has notes | | ---------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| [PortalShell](src/components/PortalShell/MAINTAINERS.md) | SidebarProvider coupling, recursive navigation, persistence, responsive focus and inert content | +| [ProviderMap](src/components/ProviderMap/MAINTAINERS.md) | Optional Mapbox peer/CDN loading, text-safe popups, shared assets and map lifecycle | | [AI](src/components/AI/MAINTAINERS.md) | `renderTextContent` extension point; host owns sanitization; reuses the Messaging composer | | [AGGrid (deprecated)](src/components/AGGrid/MAINTAINERS.md) | Legacy maintenance only; retained for existing consumers. Use [DataVis NITRO](src/components/DataVisNITRO/MAINTAINERS.md) for new work. | | [CustomizableDashboard](src/components/CustomizableDashboard/MAINTAINERS.md) | Ported portlet grid; `@dnd-kit` is a regular dependency; layout persistence and widget registry coupling | diff --git a/TESTING.md b/TESTING.md index 35063726a..44d5c5e29 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 61304efad..11aa57ad1 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, @@ -67,6 +67,9 @@ export default [ process: 'readonly', // Browser APIs confirm: 'readonly', + alert: 'readonly', + ImageData: 'readonly', + ResizeObserver: 'readonly', // Google Maps API (loaded externally) google: 'readonly', // Audio/Media APIs diff --git a/native/.gitignore b/native/.gitignore new file mode 100644 index 000000000..f3d6549d8 --- /dev/null +++ b/native/.gitignore @@ -0,0 +1 @@ +/build/ \ No newline at end of file diff --git a/native/README.md b/native/README.md new file mode 100644 index 000000000..50abaa154 --- /dev/null +++ b/native/README.md @@ -0,0 +1,69 @@ +# Native Compose Companion + +This module brings the existing `mieweb/ui` brand vocabulary to Kotlin/Compose. +It is consumed by BlueHive's native mobile workspace as `:miewebUiNative`. +It does not replace the React package or change its npm exports. + +## Why a Native Module + +The existing Button, Input, Badge, and ThemeProvider implementations rely on +React/DOM/Tailwind and cannot execute in Compose. Embedding them in a WebView +would defeat the native UI requirement. This module instead supplies a small +theme adapter and Compose equivalents, keeping application data, authentication, +navigation, and offline behavior in the consuming app. + +`MieTheme` accepts semantic `MieColors` and a font family. `MieButton`, `MieField`, +and `MieStatus` use that theme and take labels from callers. `MieField` exposes +native keyboard options/actions; callers must use the focus context belonging to +the containing modal or screen. Material components provide semantics and touch +targets rather than a separate hand-drawn control implementation. + +Use `MieSearchField` for placeholder-led search with a leading icon and native +keyboard actions; keep `MieField` for persistently labeled form inputs. +`MieAvatar` displays caller-supplied initials as decoration alongside a full name. +`MieIconButton` supplies a bordered brand action with a 48dp touch target; callers +must give its icon a localized content description. `MieStatus` accepts an optional +decorative icon so status labels remain understandable without color alone. + +`MieListRow` provides an unframed leading/headline/supporting/trailing layout. +It groups descriptive accessibility semantics; callers own row actions and +must retain accessible labels on any independently interactive trailing control. + +The companion is an initial implementation, not feature parity with the web +component catalog. Use the React library for web applications. There is no +independent Maven publication or native Storybook target yet; the consuming +mobile app and native XCTest screenshots are the current integration surface. + +## Tokens + +`generate-tokens.mjs` imports the canonical typed `bluehiveBrand` definition. +Do not hand-edit `BlueHiveTokens.kt` or duplicate its hex colors in app screens. + +From this module on Node 22: + +```sh +node --experimental-strip-types generate-tokens.mjs +node --experimental-strip-types generate-tokens.mjs --check +``` + +The generator produces deterministic light/dark snapshots. `MieTheme` can accept +other palettes without changes to individual controls, but only BlueHive has a +generated palette in this first slice. Other brands still need generated palettes +and visual validation. Fonts/assets are bundled by the consuming application. + +## Verification + +From the BlueHive mobile Gradle workspace: + +```sh +./gradlew :shared:compileAndroidMain :sync:jvmTest +``` + +The employer XCTest workflow covers field entry, native Next/Done actions, +pending-change rows, and app relaunch. Light and dark screenshots were checked +on a regular iPhone and a smaller iPhone SE simulator. Desktop, RTL, every brand, +and a complete VoiceOver/TalkBack/large-text audit are not yet verified. + +Follow the parent repository's contribution and review requirements. Native-only +changes do not require adding React wrappers or new web catalog entries, but any +changes to shared brand definitions must retain their existing web tests/stories. \ No newline at end of file diff --git a/native/build.gradle.kts b/native/build.gradle.kts new file mode 100644 index 000000000..09be4c82e --- /dev/null +++ b/native/build.gradle.kts @@ -0,0 +1,28 @@ +plugins { + kotlin("multiplatform") + kotlin("plugin.compose") + id("org.jetbrains.compose") + id("com.android.kotlin.multiplatform.library") +} + +kotlin { + androidLibrary { + namespace = "com.mieweb.ui.compose" + compileSdk = 36 + minSdk = 28 + androidResources.enable = true + } + iosArm64() + iosSimulatorArm64() + jvmToolchain(17) + sourceSets { + commonMain.dependencies { + api(compose.material3) + implementation(compose.components.resources) + } + } +} + +compose.resources { + packageOfResClass = "com.mieweb.ui.native.resources" +} \ No newline at end of file diff --git a/native/generate-tokens.mjs b/native/generate-tokens.mjs new file mode 100644 index 000000000..ff5fbf6d4 --- /dev/null +++ b/native/generate-tokens.mjs @@ -0,0 +1,38 @@ +import { readFileSync, writeFileSync } from 'node:fs'; +import { bluehiveBrand } from '../src/brands/bluehive.ts'; + +const output = new URL('./src/commonMain/kotlin/com/mieweb/ui/native/BlueHiveTokens.kt', import.meta.url); +const color = (hex) => `Color(0xFF${hex.slice(1).toUpperCase()})`; +function palette(mode) { + const source = bluehiveBrand.colors[mode]; + return ` val ${mode} = MieColors( + background = ${color(source.background)}, + foreground = ${color(source.foreground)}, + surface = ${color(source.card)}, + muted = ${color(source.muted)}, + mutedForeground = ${color(source.mutedForeground)}, + border = ${color(source.border)}, + primary = ${color(bluehiveBrand.colors.primary[mode === 'light' ? 800 : 300])}, + onPrimary = ${color(mode === 'light' ? source.card : source.background)}, + brand = ${color(bluehiveBrand.colors.primary[500])}, + danger = ${color(source.destructive)}, + )`; +} +const content = `package com.mieweb.ui.native + +import androidx.compose.ui.graphics.Color + +object BlueHiveTokens { +${palette('light')} +${palette('dark')} +} +`; +if (process.argv.includes('--check')) { + if (readFileSync(output, 'utf8') !== content) { + throw new Error('Native tokens are stale. Run node --experimental-strip-types native/generate-tokens.mjs.'); + } + console.log('Native BlueHive tokens match the canonical brand.'); +} else { + writeFileSync(output, content); + console.log('Generated native BlueHive tokens.'); +} \ No newline at end of file diff --git a/native/src/commonMain/kotlin/com/mieweb/ui/native/BlueHiveTokens.kt b/native/src/commonMain/kotlin/com/mieweb/ui/native/BlueHiveTokens.kt new file mode 100644 index 000000000..18920b732 --- /dev/null +++ b/native/src/commonMain/kotlin/com/mieweb/ui/native/BlueHiveTokens.kt @@ -0,0 +1,30 @@ +package com.mieweb.ui.native + +import androidx.compose.ui.graphics.Color + +object BlueHiveTokens { + val light = MieColors( + background = Color(0xFFFFFFFF), + foreground = Color(0xFF171717), + surface = Color(0xFFFFFFFF), + muted = Color(0xFFF5F5F5), + mutedForeground = Color(0xFF494949), + border = Color(0xFFE5E7EB), + primary = Color(0xFF0F749C), + onPrimary = Color(0xFFFFFFFF), + brand = Color(0xFF27AAE1), + danger = Color(0xFFDC2626), + ) + val dark = MieColors( + background = Color(0xFF171717), + foreground = Color(0xFFFAFAFA), + surface = Color(0xFF262626), + muted = Color(0xFF404040), + mutedForeground = Color(0xFFA1A1AA), + border = Color(0xFF404040), + primary = Color(0xFF4DC4EA), + onPrimary = Color(0xFF171717), + brand = Color(0xFF27AAE1), + danger = Color(0xFFDC2626), + ) +} diff --git a/native/src/commonMain/kotlin/com/mieweb/ui/native/MieComponents.kt b/native/src/commonMain/kotlin/com/mieweb/ui/native/MieComponents.kt new file mode 100644 index 000000000..a98a3f9ff --- /dev/null +++ b/native/src/commonMain/kotlin/com/mieweb/ui/native/MieComponents.kt @@ -0,0 +1,186 @@ +package com.mieweb.ui.native + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedIconButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextField +import androidx.compose.material3.TextFieldDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.Alignment +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.KeyboardActions + +@Composable +fun MieButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + icon: @Composable (() -> Unit)? = null, +) { + Button(onClick = onClick, modifier = modifier.heightIn(min = 52.dp), enabled = enabled, shape = MaterialTheme.shapes.medium) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + icon?.invoke() + Text(text, style = MaterialTheme.typography.labelLarge) + } + } +} + +@Composable +fun MieField( + value: String, + label: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + error: String? = null, +) { + OutlinedTextField( + value = value, onValueChange = onValueChange, label = { Text(label) }, modifier = modifier.heightIn(min = 52.dp), + textStyle = MaterialTheme.typography.bodyLarge, + singleLine = true, keyboardOptions = keyboardOptions, isError = error != null, + keyboardActions = keyboardActions, + shape = MaterialTheme.shapes.medium, + colors = OutlinedTextFieldDefaults.colors( + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedBorderColor = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f), + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedLabelColor = MaterialTheme.colorScheme.onSurfaceVariant, + errorContainerColor = MaterialTheme.colorScheme.surface, + ), + supportingText = if (error != null) ({ Text(error) }) else null, + ) +} + +@Composable +fun MieSearchField( + value: String, + label: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + leadingIcon: @Composable (() -> Unit)? = null, +) { + val interactionSource = remember { MutableInteractionSource() } + val focused by interactionSource.collectIsFocusedAsState() + TextField( + value = value, onValueChange = onValueChange, + placeholder = { Text(label) }, + textStyle = MaterialTheme.typography.bodyLarge, + interactionSource = interactionSource, + modifier = modifier.heightIn(min = 48.dp) + .border( + if (focused) 2.dp else 1.dp, + if (focused) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.65f), + MaterialTheme.shapes.medium, + ) + .semantics { contentDescription = label }, + singleLine = true, keyboardOptions = keyboardOptions, keyboardActions = keyboardActions, + leadingIcon = leadingIcon, shape = MaterialTheme.shapes.medium, + colors = TextFieldDefaults.colors( + focusedContainerColor = MaterialTheme.colorScheme.surface, + unfocusedContainerColor = MaterialTheme.colorScheme.surface, + focusedIndicatorColor = Color.Transparent, + unfocusedIndicatorColor = Color.Transparent, + focusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + unfocusedPlaceholderColor = MaterialTheme.colorScheme.onSurfaceVariant, + focusedLeadingIconColor = MaterialTheme.colorScheme.primary, + unfocusedLeadingIconColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) +} + +@Composable +fun MieAvatar(initials: String, modifier: Modifier = Modifier) { + Box( + modifier.size(48.dp).background(MaterialTheme.colorScheme.primaryContainer, CircleShape).clearAndSetSemantics {}, + contentAlignment = Alignment.Center, + ) { + Text(initials.take(2), style = MaterialTheme.typography.titleLarge, color = MaterialTheme.colorScheme.onPrimaryContainer) + } +} + +@Composable +fun MieListRow( + headline: @Composable () -> Unit, + modifier: Modifier = Modifier, + leading: @Composable (() -> Unit)? = null, + supporting: @Composable (() -> Unit)? = null, + trailing: @Composable (() -> Unit)? = null, +) { + Row( + modifier.fillMaxWidth().semantics(mergeDescendants = true) {}.padding(horizontal = 16.dp, vertical = 14.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + verticalAlignment = Alignment.Top, + ) { + leading?.invoke() + Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) { + headline() + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { + supporting?.invoke() + } + } + if (trailing != null) { + Box(Modifier.align(Alignment.CenterVertically)) { + CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurfaceVariant) { + trailing() + } + } + } + } +} + +@Composable +fun MieIconButton(onClick: () -> Unit, modifier: Modifier = Modifier, icon: @Composable () -> Unit) { + OutlinedIconButton( + onClick = onClick, modifier = modifier.size(48.dp), shape = MaterialTheme.shapes.medium, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.primary), + colors = IconButtonDefaults.outlinedIconButtonColors( + containerColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.04f), + contentColor = MaterialTheme.colorScheme.primary, + ), + content = icon, + ) +} + +@Composable +fun MieStatus(label: String, attention: Boolean = false, icon: @Composable (() -> Unit)? = null) { + val color = if (attention) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary + CompositionLocalProvider(LocalContentColor provides color) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically) { + icon?.invoke() + Text(label, style = MaterialTheme.typography.labelMedium, color = color) + } + } +} \ No newline at end of file diff --git a/native/src/commonMain/kotlin/com/mieweb/ui/native/MieTheme.kt b/native/src/commonMain/kotlin/com/mieweb/ui/native/MieTheme.kt new file mode 100644 index 000000000..3caa1ee9c --- /dev/null +++ b/native/src/commonMain/kotlin/com/mieweb/ui/native/MieTheme.kt @@ -0,0 +1,79 @@ +package com.mieweb.ui.native + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.Typography +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.compositeOver +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +data class MieColors( + val background: Color, + val foreground: Color, + val surface: Color, + val muted: Color, + val mutedForeground: Color, + val border: Color, + val primary: Color, + val onPrimary: Color, + val brand: Color, + val danger: Color, + val primaryContainer: Color = brand.copy(alpha = 0.42f).compositeOver(background), + val onPrimaryContainer: Color = foreground, +) + +@Composable +fun MieTheme( + dark: Boolean = isSystemInDarkTheme(), + colors: MieColors = if (dark) BlueHiveTokens.dark else BlueHiveTokens.light, + fontFamily: FontFamily = FontFamily.Default, + content: @Composable () -> Unit, +) { + val base = if (dark) darkColorScheme() else lightColorScheme() + val scheme = base.copy( + primary = colors.primary, onPrimary = colors.onPrimary, + primaryContainer = colors.primaryContainer, onPrimaryContainer = colors.onPrimaryContainer, + secondary = colors.primary, onSecondary = colors.onPrimary, + secondaryContainer = colors.muted, onSecondaryContainer = colors.foreground, + background = colors.background, onBackground = colors.foreground, + surface = colors.surface, onSurface = colors.foreground, + surfaceVariant = colors.muted, onSurfaceVariant = colors.mutedForeground, + surfaceContainer = colors.surface, surfaceContainerHigh = colors.muted, + surfaceContainerHighest = colors.muted, surfaceContainerLow = colors.surface, + surfaceContainerLowest = colors.background, + outline = colors.border, outlineVariant = colors.border, + error = colors.danger, onError = colors.onPrimary, + errorContainer = colors.danger.copy(alpha = 0.12f), onErrorContainer = colors.danger, + surfaceTint = colors.primary, + ) + val typography = Typography() + MaterialTheme( + colorScheme = scheme, + typography = typography.copy( + headlineMedium = typography.headlineMedium.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold, letterSpacing = 0.sp), + headlineSmall = typography.headlineSmall.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold, letterSpacing = 0.sp), + titleLarge = typography.titleLarge.copy(fontFamily = fontFamily, fontWeight = FontWeight.Bold, fontSize = 20.sp, lineHeight = 26.sp, letterSpacing = 0.sp), + titleMedium = typography.titleMedium.copy(fontFamily = fontFamily, fontWeight = FontWeight.Normal, fontSize = 16.sp, lineHeight = 22.sp, letterSpacing = 0.sp), + titleSmall = typography.titleSmall.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + bodyLarge = typography.bodyLarge.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + bodyMedium = typography.bodyMedium.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + bodySmall = typography.bodySmall.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + labelLarge = typography.labelLarge.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + labelMedium = typography.labelMedium.copy(fontFamily = fontFamily, fontSize = 14.sp, lineHeight = 20.sp, letterSpacing = 0.sp), + labelSmall = typography.labelSmall.copy(fontFamily = fontFamily, letterSpacing = 0.sp), + ), + shapes = Shapes( + small = RoundedCornerShape(8.dp), medium = RoundedCornerShape(8.dp), + large = RoundedCornerShape(8.dp), extraLarge = RoundedCornerShape(16.dp), + ), + content = content, + ) +} \ No newline at end of file diff --git a/package.json b/package.json index 48a59900a..0d12de2d3 100644 --- a/package.json +++ b/package.json @@ -263,6 +263,7 @@ "datavis-ace": "=4.1.0", "js-yaml": ">=4.0.0", "katex": ">=0.16.0", + "mapbox-gl": ">=2.0.0", "mermaid": ">=11.0.0", "papaparse": ">=5.0.0", "react": ">=18.0.0", @@ -314,6 +315,9 @@ "js-yaml": { "optional": true }, + "mapbox-gl": { + "optional": true + }, "mermaid": { "optional": true }, @@ -402,6 +406,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", @@ -434,6 +439,7 @@ "js-yaml": "^4.1.1", "jsdom": "^26.1.0", "katex": "^0.17.0", + "mapbox-gl": "^3.18.1", "mermaid": "^11.15.0", "papaparse": "^5.5.3", "postcss": "^8.5.10", diff --git a/playwright.config.ts b/playwright.config.ts index 2b826b90f..cdae586d2 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: { @@ -62,4 +60,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 7278b9c80..3c0902f0d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -172,6 +172,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 @@ -268,6 +271,9 @@ importers: katex: specifier: ^0.17.0 version: 0.17.0 + mapbox-gl: + specifier: ^3.18.1 + version: 3.30.0 mermaid: specifier: ^11.15.0 version: 11.15.0 @@ -345,7 +351,7 @@ importers: version: 3.0.0(yjs@13.6.30) ychart: specifier: file:./packages/ychart - version: '@mieweb/ychart@file:packages/ychart(@popperjs/core@2.11.8)' + version: file:packages/ychart yjs: specifier: ^13.6.30 version: 13.6.30 @@ -1299,10 +1305,6 @@ packages: yjs: 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==} @@ -2117,6 +2119,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/mdast@4.0.4': resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} @@ -4534,6 +4540,9 @@ packages: makeerror@1.0.12: resolution: {integrity: sha512-JmqCvUhmt43madlpFzG4BQzG2Z3m6tvQDNKdClZnO3VbIudJYmxsT0FNJMeiB2+JTSlTQTSbU8QdesVmwJcmLg==} + mapbox-gl@3.30.0: + resolution: {integrity: sha512-26m0CSBszLkvbdVWf2oIZ3SjqCuResxJKEbupwwSyNwbwJAcgnMhDy2Yl5cSgtUxeamUJ1UeXRqpBmGEUVaAeQ==} + markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} @@ -6206,6 +6215,9 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + ychart@file:packages/ychart: + resolution: {directory: packages/ychart, type: directory} + yjs@13.6.30: resolution: {integrity: sha512-vv/9h42eCMC81ZHDFswuu/MKzkl/vyq1BhaNGfHyOonwlG4CJbQF4oiBBJPvfdeCt/PlVDWh7Nov9D34YY09uQ==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -7445,28 +7457,6 @@ snapshots: y-protocols: 1.0.6(yjs@13.6.30) yjs: 13.6.30 - '@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 @@ -8214,6 +8204,10 @@ snapshots: '@types/luxon@3.7.1': {} + '@types/mapbox-gl@3.5.0': + dependencies: + mapbox-gl: 3.30.0 + '@types/mdast@4.0.4': dependencies: '@types/unist': 3.0.3 @@ -11089,6 +11083,8 @@ snapshots: dependencies: tmpl: 1.0.5 + mapbox-gl@3.30.0: {} + markdown-table@3.0.4: {} marked@14.0.0: {} @@ -13244,6 +13240,8 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + ychart@file:packages/ychart: {} + yjs@13.6.30: dependencies: lib0: 0.2.117 diff --git a/src/brands/bluehive.css b/src/brands/bluehive.css index d35c444c7..4b46ca826 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 bf6ab86af..fc8f592a4 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 873fc2b2f..3a64a355a 100644 --- a/src/brands/index.ts +++ b/src/brands/index.ts @@ -6,19 +6,19 @@ // Types and utilities export type { - BrandConfig, - BrandColors, - BrandTypography, BrandBorderRadius, BrandBoxShadow, + BrandColors, + BrandConfig, + BrandGradients, + 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 e192884dc..c6a542811 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};` + : '' + } } `; } @@ -295,6 +349,10 @@ export function generateTailwindTheme(brand: BrandConfig) { return { colors: colorConfig, + ...(brand.gradients ? { backgroundImage: { + 'gradient-brand': `var(--mieweb-gradient-brand, ${brand.gradients.brand})`, + 'gradient-brand-strong': `var(--mieweb-gradient-brand-strong, ${brand.gradients.brandStrong})`, + } } : {}), fontFamily: { sans: typography.fontFamily.sans, ...(typography.fontFamily.mono @@ -314,6 +372,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/catalog/Identity.mdx b/src/catalog/Identity.mdx new file mode 100644 index 000000000..812b7d2ca --- /dev/null +++ b/src/catalog/Identity.mdx @@ -0,0 +1,14 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Identity + +Use an avatar for compact recognition and a badge when a person's name and profile context must be visible. Keep important actions available without hover. + +| Component | Choose it when | Caller owns | +| --- | --- | --- | +| [UserAvatar](?path=/docs/components-useravatar--docs) | An image or initials needs an optional presence dot | Identity data and a localized presence label | +| [UserBadge](?path=/docs/components-userbadge--docs) | A row needs a name, profile link and quick preview | Authorized personal data, routing and visible labels | + +Use the base [Avatar](?path=/docs/data-display-avatar--docs) for an image without user-presence semantics. diff --git a/src/catalog/Portals.mdx b/src/catalog/Portals.mdx new file mode 100644 index 000000000..607ed62d5 --- /dev/null +++ b/src/catalog/Portals.mdx @@ -0,0 +1,14 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Portals + +Use PortalShell for workspace structure and HeroActionCard for a prominent task inside that structure. Neither component authorizes access or fetches application data. + +| Component | Choose it when | Caller owns | +| --- | --- | --- | +| [PortalShell](?path=/docs/layout-portalshell--docs) | Desktop sidebar and mobile navigation need a shared frame | Route state, permitted navigation and page content | +| [HeroActionCard](?path=/docs/dashboard-heroactioncard--docs) | A primary workspace task needs an introduction and action | Translated copy, callbacks and permission checks | + +Use [AppHeader](?path=/docs/layout-appheader--docs) alone for surfaces without persistent navigation. Use [Card](?path=/docs/layout-card--docs) for neutral information. diff --git a/src/catalog/ProviderDiscovery.mdx b/src/catalog/ProviderDiscovery.mdx new file mode 100644 index 000000000..6b5dc70cb --- /dev/null +++ b/src/catalog/ProviderDiscovery.mdx @@ -0,0 +1,12 @@ +import { Meta } from '@storybook/addon-docs/blocks'; + + + +# Provider discovery + +Choose a card to compare clinics and a map to locate a selected clinic. Keep the address and contact details available outside the map. + +| Component | Choose it for | Caller owns | +| --- | --- | --- | +| [NearbyProviderCard](?path=/docs/providers-nearbyprovidercard--docs) | Comparing name, distance and contact details | Search results, route and distance | +| [ProviderMap](?path=/docs/providers-providermap--docs) | Finding a selected location or opening directions | Coordinates, directions URL and optional public Mapbox token | diff --git a/src/components/AGGrid/AGGrid.tsx b/src/components/AGGrid/AGGrid.tsx index 618f13733..e42f8f97c 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; @@ -322,12 +322,12 @@ function AGGridInner( rowHeight={sizeConfig.rowHeight} headerHeight={sizeConfig.headerHeight} noRowsOverlayComponent={() => ( -
+
{noDataMessage}
)} loadingOverlayComponent={() => ( -
+
{loadingMessage}
)} @@ -361,16 +361,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 b6812ecfa..7f7a80f7b 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 19f279b2f..5e0315cad 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/AI/icons.tsx b/src/components/AI/icons.tsx index e5dcd0b75..ac39ecd0a 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/ActivityFeed/ActivityFeed.stories.tsx b/src/components/ActivityFeed/ActivityFeed.stories.tsx new file mode 100644 index 000000000..6a48113c0 --- /dev/null +++ b/src/components/ActivityFeed/ActivityFeed.stories.tsx @@ -0,0 +1,71 @@ +import type { Meta, StoryObj } from '@storybook/react-vite'; + +import { ActivityFeed, type ActivityItem } from './ActivityFeed'; + +const meta: Meta = { + id: 'dashboard-activityfeed', + title: 'Modules/Dashboards/ActivityFeed', + component: ActivityFeed, + tags: ['autodocs', 'scope:general-purpose', 'maturity:beta'], + parameters: { + catalog: {"entry": "@mieweb/ui", "relationships": []}, docs: { description: { component: "### What it's for\n\nRecent events with timestamps, event icons, empty states and optional actions in ActivityFeed.\n\n### Use it when\n\nA dashboard needs a short, chronological summary linked to full records.\n\n### Don't use it when\n\nUse Timeline for a general process chronology or a table for searching an audit history.\n\n### Example\n\nFetch events in the page, pass items and loading, and use each item onClick to open its record.\n\n### Limitations\n\nIt does not fetch, paginate or authorize events. Supply localized titles. Relative timestamps are display text; provide a full audit screen for precise historical review." } }, 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 000000000..9190e814a --- /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 000000000..a87c71a9e --- /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 80ac83652..343c02183 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 = { id: 'users-integrations-addcontactmodal', diff --git a/src/components/AddContactModal/AddContactModal.tsx b/src/components/AddContactModal/AddContactModal.tsx index 96f55a1e1..e7a6774d0 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 8b6c2486a..234244a3b 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 c9322785d..94d8e747c 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 d6db0435f..17d425361 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 c41e8e070..38dd8235c 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/AddressForm.tsx b/src/components/Address/AddressForm.tsx index 8f2b9bf83..5e1fdccec 100644 --- a/src/components/Address/AddressForm.tsx +++ b/src/components/Address/AddressForm.tsx @@ -106,6 +106,21 @@ export interface AddressFormProps { }; /** Custom className for the container */ className?: string; + /** + * Replace the Address Line 1 input, e.g. with an app-level address + * autocomplete. Call `onAddressSelect` to fill the remaining fields. + */ + renderStreet1?: (field: { + id: string; + label: string; + placeholder: string; + value: string; + onChange: (value: string) => void; + onAddressSelect: (address: Partial) => void; + disabled: boolean; + required: boolean; + error?: string; + }) => React.ReactNode; /** Google Places autocomplete options (requires Google Maps API) */ googlePlaces?: { /** Whether to enable autocomplete on street1 field */ @@ -199,6 +214,7 @@ export function AddressForm({ placeholders = {}, className, googlePlaces, + renderStreet1, }: AddressFormProps) { const generatedId = React.useId(); const idPrefix = id || generatedId; @@ -313,20 +329,40 @@ export function AddressForm({ return (
{/* Street Address Line 1 */} - handleChange('street1', e.target.value)} - disabled={disabled} - required={required} - hasError={!!errors.street1} - error={errors.street1} - autoComplete="address-line1" - data-cy="input-address-line-1" - /> + {renderStreet1 ? ( + renderStreet1({ + id: `${idPrefix}-street1`, + label: mergedLabels.street1, + placeholder: mergedPlaceholders.street1, + value: value.street1 || '', + onChange: (street1) => handleChange('street1', street1), + onAddressSelect: (selected) => + onChange({ + ...value, + ...Object.fromEntries( + Object.entries(selected).filter(([, v]) => Boolean(v)) + ), + }), + disabled, + required, + error: errors.street1, + }) + ) : ( + handleChange('street1', e.target.value)} + disabled={disabled} + required={required} + hasError={!!errors.street1} + error={errors.street1} + autoComplete="address-line1" + data-cy="input-address-line-1" + /> + )} {/* Street Address Line 2 */} {logo}
} {children} @@ -135,7 +135,7 @@ export function AppHeaderTitle({ {children} {subtitle && ( -

{subtitle}

+

{subtitle}

)}
); @@ -190,7 +190,7 @@ export function AppHeaderDivider({ // AppHeaderIconButton Component // ============================================================================= -export interface AppHeaderIconButtonProps { +export interface AppHeaderIconButtonProps extends React.AriaAttributes { /** Button icon */ icon: ReactNode; /** Accessible label */ @@ -215,6 +215,7 @@ export function AppHeaderIconButton({ isActive = false, className, 'data-testid': testId, + ...ariaProps }: AppHeaderIconButtonProps): React.JSX.Element { return (
{/* Name (hidden on small screens) */} -
+
{email}
@@ -425,7 +430,7 @@ export function AppHeaderUserMenu({ + Order Testing + + ), + }, +}; + export const WithRightIcon: Story = { args: { children: 'Continue', diff --git a/src/components/Button/Button.test.tsx b/src/components/Button/Button.test.tsx index a561c3859..cbfad0912 100644 --- a/src/components/Button/Button.test.tsx +++ b/src/components/Button/Button.test.tsx @@ -1,6 +1,8 @@ -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 { Plus } from 'lucide-react'; +import { describe, expect, it, vi } from 'vitest'; + import { renderWithTheme } from '../../test/test-utils'; import { Button } from './Button'; @@ -101,6 +103,50 @@ describe('Button', () => { expect(screen.getByText('Button Text')).toBeInTheDocument(); }); + it('keeps inline icon children in the button flex layout', () => { + const handleClick = vi.fn(); + renderWithTheme( + + ); + + const button = screen.getByRole('button', { name: 'Add Employee' }); + const icon = screen.getByTestId('inline-icon'); + expect(icon.parentElement).toBe(button); + fireEvent.click(icon); + expect(handleClick).toHaveBeenCalledTimes(1); + }); + + it('preserves wrapped icons and text supplied through a fragment', () => { + renderWithTheme( + + ); + + const button = screen.getByRole('button', { name: 'Order Testing' }); + expect(screen.getByTestId('icon-slot').parentElement).toBe(button); + }); + + it('retains truncation for text labels with explicit icon props', () => { + renderWithTheme( + + ); + + const button = screen.getByRole('button', { name: 'A long label' }); + const label = button.querySelector('[data-slot="button-label"]'); + expect(label).toHaveClass('truncate'); + expect(label).toHaveTextContent('A long label'); + expect(label?.querySelector('svg')).toBeNull(); + }); + describe('accessibility', () => { it('has proper ARIA attributes', () => { renderWithTheme(); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index c18f2f516..cad362856 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( @@ -20,6 +21,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', @@ -95,6 +101,7 @@ export interface ButtonProps * @example * ```tsx * + * * * * ``` @@ -127,7 +134,11 @@ const Button = React.forwardRef( React.useLayoutEffect(() => { const label = labelRef.current; const button = innerRef.current; - if (!label || !button || title !== undefined) return; + if (!button || title !== undefined) return; + if (!label) { + button.removeAttribute('title'); + return; + } const update = () => { if (label.scrollWidth > label.clientWidth) { @@ -144,6 +155,18 @@ const Button = React.forwardRef( return () => observer.disconnect(); }, [title, children, isLoading, loadingText]); + const content = isLoading ? loadingText || children : children; + const textOnly = React.Children.toArray(content).every( + (child) => typeof child === 'string' || typeof child === 'number' + ); + const labelContent = textOnly ? ( + + {content} + + ) : ( + content + ); + return (
@@ -304,31 +305,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

@@ -381,7 +382,7 @@ export const WithMediaOverlay: Story = { } />
-

+

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

@@ -403,7 +404,7 @@ export const WithBadges: Story = {

Product Card

-

With success badge

+

With success badge

@@ -412,7 +413,7 @@ export const WithBadges: Story = {

Product Card

-

+

With destructive badge

@@ -423,7 +424,7 @@ export const WithBadges: Story = {

Product Card

-

With warning badge

+

With warning badge

@@ -449,7 +450,7 @@ export const Selectable: Story = { >

Option {id}

-

+

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

@@ -666,7 +667,7 @@ export const ComplexCard: Story = { Master modern React development -
+
-
    +
    • • Introduction to Advanced Patterns
    • • Compound Components
    • • Render Props & HOCs
    • @@ -720,7 +721,7 @@ export const ComplexCard: Story = {
      $79 - + $129
      diff --git a/src/components/Checkbox/Checkbox.stories.tsx b/src/components/Checkbox/Checkbox.stories.tsx index bcbc51fb4..7e4b4538e 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 305b4baf3..ed5026547 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 c4b3ec7b3..aaabb9355 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 0f4a8ff6f..56291d801 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 8d7b49244..fa8b0fb02 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 d41e8e9aa..9c58428c2 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 3147080c7..b953efc83 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 7eff38f37..5ce9288f2 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 000000000..8dae5819e --- /dev/null +++ b/src/components/ConfirmDialog/ConfirmDialog.stories.tsx @@ -0,0 +1,108 @@ +import type { Meta, StoryObj } from '@storybook/react'; +import * as React from 'react'; + +import { Button } from '../Button/Button'; +import { ConfirmDialog } from './ConfirmDialog'; + +const meta: Meta = { + id: 'components-confirmdialog', + title: 'Components/Feedback/ConfirmDialog', + component: ConfirmDialog, + tags: ['autodocs', 'scope:general-purpose', 'maturity:beta'], + parameters: { + catalog: {"entry": "@mieweb/ui", "relationships": [{"type": "uses", "target": "overlays-modal", "why": "Modal supplies the dialog, focus management and overlay."}]}, + docs: { + description: { + component: "### What it's for\n\nA focused confirmation step, composed from Modal and Buttons, with an optional message field.\n\n### Use it when\n\nThe user must confirm one consequential action or add a note before sending an invitation.\n\n### Don't use it when\n\nUse Modal for a multi-step form, or Button directly for a reversible routine action.\n\n### Example\n\nThe parent owns open and isSubmitting; onConfirm performs the request and closes only after success.\n\n### Limitations\n\nModal manages focus and Escape. The caller owns request errors, translated labels and permission checks. Keep the body short enough for mobile.", + }, + }, + }, +}; + +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 000000000..22eff56c5 --- /dev/null +++ b/src/components/ConfirmDialog/ConfirmDialog.tsx @@ -0,0 +1,249 @@ +'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. + const defaultMessage = fieldOpts?.defaultValue ?? ''; + React.useEffect(() => { + if (open) { + setMessage(defaultMessage); + setValidationError(null); + } + }, [open, defaultMessage]); + + 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 && ( +