feat: add advanced analytics and territory management system

- Add comprehensive analytics components with export functionality
- Implement territory management with manager performance tracking
- Add seatmap components for venue layout management
- Create customer management features with modal interface
- Add advanced hooks for dashboard flags and territory data
- Implement seat selection and venue management utilities
- Add type definitions for ticketing and seatmap systems

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2025-08-26 09:25:10 -06:00
parent d5c3953888
commit aa81eb5adb
438 changed files with 90509 additions and 2787 deletions

View File

@@ -0,0 +1,482 @@
/**
* Branding Application Utilities
*
* Comprehensive utilities for applying organization-specific branding
* with zero FOUC (Flash of Unstyled Content) support
*/
import type { BrandingTheme, BrandingCache } from '../types/organization';
import { DEFAULT_BRANDING_THEME, createThemeChecksum, isValidCssColor } from '../types/organization';
// CSS custom property mappings for organization branding
const CSS_VARIABLE_MAP = {
// Core brand colors
accent: '--color-accent',
bgCanvas: '--color-bg-canvas',
bgSurface: '--color-bg-surface',
textPrimary: '--color-text-primary',
textSecondary: '--color-text-secondary',
border: '--color-border-default',
ring: '--color-focus-ring',
// Extended colors (optional)
accent600: '--color-accent-600',
surfaceRaised: '--color-surface-raised',
success: '--color-success',
warning: '--color-warning',
error: '--color-error',
} as const;
// Additional derived CSS variables that are computed from theme colors
const DERIVED_VARIABLES = {
accentHover: '--color-accent-hover',
accentLight: '--color-accent-light',
accentBg: '--color-accent-bg',
accentBorder: '--color-accent-border',
glassBg: '--color-glass-bg',
glassBorder: '--color-glass-border',
} as const;
/**
* Apply organization branding theme to CSS custom properties
* This function updates CSS variables that are consumed by Tailwind utilities
*/
export function applyBranding(
theme: BrandingTheme,
targetElement: HTMLElement = document.documentElement
): void {
try {
// Validate theme colors before applying
const validation = validateBrandingTheme(theme);
if (!validation.valid) {
console.warn('Invalid theme colors detected:', validation.errors);
// Apply default theme as fallback
theme = { ...DEFAULT_BRANDING_THEME };
}
// Apply core theme colors to CSS custom properties
Object.entries(CSS_VARIABLE_MAP).forEach(([themeKey, cssVar]) => {
const themeValue = theme[themeKey as keyof BrandingTheme];
if (themeValue) {
targetElement.style.setProperty(cssVar, themeValue);
}
});
// Apply derived variables for enhanced theming
applyDerivedVariables(theme, targetElement);
// Store applied theme for reference and cache validation
targetElement.setAttribute('data-org-theme', JSON.stringify(theme));
targetElement.setAttribute('data-org-theme-checksum', createThemeChecksum(theme));
// Dispatch custom event for components that need to react to theme changes
if (typeof window !== 'undefined') {
window.dispatchEvent(new CustomEvent('org-theme-applied', {
detail: { theme }
}));
}
console.log('Applied organization branding theme:', theme);
} catch (error) {
console.error('Failed to apply branding theme:', error);
// Apply default theme as emergency fallback
applyBranding(DEFAULT_BRANDING_THEME, targetElement);
}
}
/**
* Apply derived CSS variables computed from the theme
*/
function applyDerivedVariables(
theme: BrandingTheme,
targetElement: HTMLElement
): void {
// Generate hover variants (slightly darker/lighter)
const accentHover = adjustColorBrightness(theme.accent, -0.1);
const accentLight = adjustColorBrightness(theme.accent, 0.2);
// Generate alpha variants for backgrounds and borders
const accentBg = addAlphaToColor(theme.accent, 0.1);
const accentBorder = addAlphaToColor(theme.accent, 0.3);
// Generate glass effect colors based on surface color
const glassBg = addAlphaToColor(theme.bgSurface, 0.7);
const glassBorder = addAlphaToColor(theme.textSecondary, 0.15);
// Apply derived variables
const derivedValues = {
[DERIVED_VARIABLES.accentHover]: accentHover,
[DERIVED_VARIABLES.accentLight]: accentLight,
[DERIVED_VARIABLES.accentBg]: accentBg,
[DERIVED_VARIABLES.accentBorder]: accentBorder,
[DERIVED_VARIABLES.glassBg]: glassBg,
[DERIVED_VARIABLES.glassBorder]: glassBorder,
};
Object.entries(derivedValues).forEach(([cssVar, value]) => {
if (value) {
targetElement.style.setProperty(cssVar, value);
}
});
}
/**
* Serialize theme for caching with checksums
*/
export function serializeTheme(theme: BrandingTheme): string {
const serialized = {
theme,
checksum: createThemeChecksum(theme),
timestamp: Date.now(),
};
return JSON.stringify(serialized);
}
/**
* Deserialize cached theme with validation
*/
export function deserializeTheme(serialized: string): BrandingTheme | null {
try {
const parsed = JSON.parse(serialized);
if (parsed.theme && parsed.checksum) {
// Validate checksum to ensure theme integrity
const currentChecksum = createThemeChecksum(parsed.theme);
if (currentChecksum === parsed.checksum) {
return parsed.theme;
}
}
return null;
} catch (error) {
console.warn('Failed to deserialize theme:', error);
return null;
}
}
/**
* Update favicon dynamically with error handling
*/
export function updateFavicon(faviconUrl: string): void {
try {
// Validate URL format
if (!faviconUrl || !isValidUrl(faviconUrl)) {
console.warn('Invalid favicon URL:', faviconUrl);
return;
}
// Remove existing favicon links
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
existingFavicons.forEach(link => link.remove());
// Add new favicon with multiple sizes for better compatibility
const sizes = [16, 32, 48, 64];
sizes.forEach(size => {
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/png';
link.sizes = `${size}x${size}`;
link.href = faviconUrl;
document.head.appendChild(link);
});
// Add fallback ICO favicon
const icoLink = document.createElement('link');
icoLink.rel = 'shortcut icon';
icoLink.type = 'image/x-icon';
icoLink.href = faviconUrl;
document.head.appendChild(icoLink);
console.log('Updated favicon:', faviconUrl);
} catch (error) {
console.warn('Failed to update favicon:', error);
}
}
/**
* Get currently applied organization theme from DOM
*/
export function getCurrentOrgTheme(
targetElement: HTMLElement = document.documentElement
): BrandingTheme | null {
const themeData = targetElement.getAttribute('data-org-theme');
if (!themeData) return null;
try {
const theme = JSON.parse(themeData) as BrandingTheme;
// Validate the theme structure
const validation = validateBrandingTheme(theme);
return validation.valid ? theme : null;
} catch (error) {
console.warn('Failed to parse current org theme:', error);
return null;
}
}
/**
* Check if current theme differs from applied theme
*/
export function isThemeApplied(
theme: BrandingTheme,
targetElement: HTMLElement = document.documentElement
): boolean {
const appliedTheme = getCurrentOrgTheme(targetElement);
if (!appliedTheme) return false;
const appliedChecksum = targetElement.getAttribute('data-org-theme-checksum');
const newChecksum = createThemeChecksum(theme);
return appliedChecksum === newChecksum;
}
/**
* Reset to default theme values
*/
export function resetToDefaultTheme(
targetElement: HTMLElement = document.documentElement
): void {
applyBranding(DEFAULT_BRANDING_THEME, targetElement);
console.log('Reset to default theme');
}
/**
* Generate CSS string for a given theme (useful for style injection)
*/
export function generateThemeCSS(theme: BrandingTheme): string {
const cssRules = Object.entries(CSS_VARIABLE_MAP)
.map(([themeKey, cssVar]) => {
const value = theme[themeKey as keyof BrandingTheme];
return value ? ` ${cssVar}: ${value};` : null;
})
.filter(Boolean)
.join('\n');
return `:root {\n${cssRules}\n}`;
}
/**
* Generate inline style object for React components
*/
export function generateThemeStyle(theme: BrandingTheme): React.CSSProperties {
const style: React.CSSProperties = {};
Object.entries(CSS_VARIABLE_MAP).forEach(([themeKey, cssVar]) => {
const value = theme[themeKey as keyof BrandingTheme];
if (value) {
(style as any)[cssVar] = value;
}
});
return style;
}
/**
* Validate branding theme colors and structure
*/
export function validateBrandingTheme(theme: Partial<BrandingTheme>): {
valid: boolean;
errors: string[]
} {
const errors: string[] = [];
// Check required properties
const requiredProps: Array<keyof BrandingTheme> = [
'accent', 'bgCanvas', 'bgSurface', 'textPrimary', 'textSecondary', 'border', 'ring'
];
requiredProps.forEach(prop => {
if (!theme[prop]) {
errors.push(`Missing required property: ${prop}`);
} else if (!isValidCssColor(theme[prop]!)) {
errors.push(`Invalid color format for ${prop}: ${theme[prop]}`);
}
});
// Check optional properties if present
const optionalProps: Array<keyof BrandingTheme> = [
'accent600', 'surfaceRaised', 'success', 'warning', 'error'
];
optionalProps.forEach(prop => {
if (theme[prop] && !isValidCssColor(theme[prop]!)) {
errors.push(`Invalid color format for ${prop}: ${theme[prop]}`);
}
});
return {
valid: errors.length === 0,
errors,
};
}
/**
* Calculate contrast ratio between two colors for accessibility validation
*/
export function calculateContrastRatio(color1: string, color2: string): number {
const getLuminance = (color: string): number => {
// Remove # and convert to RGB
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16) / 255;
const g = parseInt(hex.substr(2, 2), 16) / 255;
const b = parseInt(hex.substr(4, 2), 16) / 255;
// Apply gamma correction
const gamma = (component: number) =>
component <= 0.03928 ? component / 12.92 : Math.pow((component + 0.055) / 1.055, 2.4);
const rLin = gamma(r);
const gLin = gamma(g);
const bLin = gamma(b);
// Calculate relative luminance
return 0.2126 * rLin + 0.7152 * gLin + 0.0722 * bLin;
};
try {
const lum1 = getLuminance(color1);
const lum2 = getLuminance(color2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
} catch (error) {
console.warn('Failed to calculate contrast ratio:', error);
return 1; // Return minimum contrast on error
}
}
/**
* Validate theme accessibility (WCAG AA compliance)
*/
export function validateThemeAccessibility(theme: BrandingTheme): {
valid: boolean;
warnings: string[]
} {
const warnings: string[] = [];
try {
// Check contrast ratios (WCAG AA requires 4.5:1 for normal text, 3:1 for large text)
const textCanvasContrast = calculateContrastRatio(theme.textPrimary, theme.bgCanvas);
if (textCanvasContrast < 4.5) {
warnings.push(`Low contrast between primary text and canvas: ${textCanvasContrast.toFixed(2)}:1 (minimum 4.5:1)`);
}
const textSurfaceContrast = calculateContrastRatio(theme.textPrimary, theme.bgSurface);
if (textSurfaceContrast < 4.5) {
warnings.push(`Low contrast between primary text and surface: ${textSurfaceContrast.toFixed(2)}:1 (minimum 4.5:1)`);
}
const secondaryTextCanvasContrast = calculateContrastRatio(theme.textSecondary, theme.bgCanvas);
if (secondaryTextCanvasContrast < 4.5) {
warnings.push(`Low contrast between secondary text and canvas: ${secondaryTextCanvasContrast.toFixed(2)}:1 (minimum 4.5:1)`);
}
const accentCanvasContrast = calculateContrastRatio(theme.accent, theme.bgCanvas);
if (accentCanvasContrast < 3) {
warnings.push(`Low contrast between accent and canvas: ${accentCanvasContrast.toFixed(2)}:1 (minimum 3:1)`);
}
} catch (error) {
console.warn('Failed to validate theme accessibility:', error);
warnings.push('Unable to validate accessibility due to color parsing error');
}
return {
valid: warnings.length === 0,
warnings,
};
}
// Utility functions
/**
* Adjust color brightness by a percentage (-1 to 1)
*/
function adjustColorBrightness(color: string, adjustment: number): string {
try {
const hex = color.replace('#', '');
const r = Math.max(0, Math.min(255, parseInt(hex.substr(0, 2), 16) + Math.round(255 * adjustment)));
const g = Math.max(0, Math.min(255, parseInt(hex.substr(2, 2), 16) + Math.round(255 * adjustment)));
const b = Math.max(0, Math.min(255, parseInt(hex.substr(4, 2), 16) + Math.round(255 * adjustment)));
return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${b.toString(16).padStart(2, '0')}`;
} catch (error) {
console.warn('Failed to adjust color brightness:', error);
return color; // Return original color on error
}
}
/**
* Add alpha channel to a hex color
*/
function addAlphaToColor(color: string, alpha: number): string {
try {
const hex = color.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
} catch (error) {
console.warn('Failed to add alpha to color:', error);
return color; // Return original color on error
}
}
/**
* Basic URL validation
*/
function isValidUrl(string: string): boolean {
try {
new URL(string);
return true;
} catch {
return false;
}
}
/**
* Apply cached branding immediately (for FOUC prevention)
* This function is designed to run before React hydration
*/
export function applyCachedBranding(): boolean {
try {
const cached = localStorage.getItem('bct_branding');
if (!cached) return false;
const brandingCache: BrandingCache = JSON.parse(cached);
// Validate cache timestamp (expire after 24 hours)
const cacheAge = Date.now() - brandingCache.timestamp;
const maxCacheAge = 24 * 60 * 60 * 1000; // 24 hours
if (cacheAge > maxCacheAge) {
console.log('Branding cache expired, will fetch fresh data');
localStorage.removeItem('bct_branding');
return false;
}
// Apply cached theme immediately
applyBranding(brandingCache.theme);
// Apply cached assets
if (brandingCache.faviconUrl) {
updateFavicon(brandingCache.faviconUrl);
}
if (brandingCache.logoUrl) {
document.documentElement.setAttribute('data-org-logo', brandingCache.logoUrl);
}
console.log('Applied cached branding for organization:', brandingCache.orgId);
return true;
} catch (error) {
console.warn('Failed to apply cached branding:', error);
// Clear corrupted cache
try {
localStorage.removeItem('bct_branding');
} catch {
// Ignore localStorage errors
}
return false;
}
}

View File

@@ -0,0 +1,89 @@
/**
* CSS Variables Generator
* Converts TypeScript tokens to CSS custom properties
*/
import { lightTokens, darkTokens, type ThemeTokens } from './tokens';
// Flatten nested token object to CSS variable name-value pairs
function flattenTokens(tokens: ThemeTokens, prefix = 'color'): Record<string, string> {
const flattened: Record<string, string> = {};
// Background colors
Object.entries(tokens.background).forEach(([key, value]) => {
flattened[`--${prefix}-background-${key}`] = value;
});
// Text colors
Object.entries(tokens.text).forEach(([key, value]) => {
flattened[`--${prefix}-text-${key}`] = value;
});
// Glass colors
Object.entries(tokens.glass).forEach(([key, value]) => {
flattened[`--${prefix}-glass-${key}`] = value;
});
// Accent colors (nested structure)
Object.entries(tokens.accent).forEach(([accentType, accentValues]) => {
Object.entries(accentValues).forEach(([key, value]) => {
flattened[`--${prefix}-accent-${accentType}-${key}`] = value;
});
});
// Semantic colors (nested structure)
Object.entries(tokens.semantic).forEach(([semanticType, semanticValues]) => {
Object.entries(semanticValues).forEach(([key, value]) => {
flattened[`--${prefix}-semantic-${semanticType}-${key}`] = value;
});
});
// Border colors
Object.entries(tokens.border).forEach(([key, value]) => {
flattened[`--${prefix}-border-${key}`] = value;
});
// Focus colors
Object.entries(tokens.focus).forEach(([key, value]) => {
flattened[`--${prefix}-focus-${key}`] = value;
});
return flattened;
}
// Convert flattened tokens to CSS string
function generateCSSRules(variables: Record<string, string>, selector = ':root'): string {
const rules = Object.entries(variables)
.map(([name, value]) => ` ${name}: ${value};`)
.join('\n');
return `${selector} {\n${rules}\n}`;
}
// Generate complete CSS with both light and dark themes
export function generateThemeCSS(): string {
const lightVariables = flattenTokens(lightTokens);
const darkVariables = flattenTokens(darkTokens);
const lightCSS = generateCSSRules(lightVariables, ':root');
const darkCSS = generateCSSRules(darkVariables, '.dark, [data-theme="dark"]');
return `/* Auto-generated theme CSS variables */\n/* DO NOT EDIT - Generated from src/theme/tokens.ts */\n\n${lightCSS}\n\n${darkCSS}`;
}
// Get CSS variable name for a token path
export function getCSSVariableName(path: string): string {
return `var(--color-${path.replace(/\./g, '-')})`;
}
// Apply theme variables to document (for runtime theme switching)
export function applyThemeVariables(theme: 'light' | 'dark'): void {
if (typeof document === 'undefined') {return;}
const tokens = theme === 'light' ? lightTokens : darkTokens;
const variables = flattenTokens(tokens);
Object.entries(variables).forEach(([name, value]) => {
document.documentElement.style.setProperty(name, value);
});
}

View File

@@ -0,0 +1,243 @@
/**
* Organization Bootstrap Script
*
* This script runs before React mounts to:
* 1. Resolve organization from current host
* 2. Apply organization theme to prevent FOUC
* 3. Set up organization data for the app
*/
import { applyOrgTheme, DEFAULT_ORG_THEME } from './orgTheme';
import type { DomainResolutionResponse, Organization } from '../stores/organizationStore';
// Configuration
const FUNCTIONS_BASE_URL = import.meta.env.DEV
? 'http://localhost:5001/bct-whitelabel-0825/us-central1'
: 'https://us-central1-bct-whitelabel-0825.cloudfunctions.net';
// Bootstrap state
let bootstrapPromise: Promise<Organization | null> | null = null;
let resolvedOrg: Organization | null = null;
/**
* Bootstrap organization resolution and theming
* This function should be called as early as possible
*/
export async function bootstrapOrganization(): Promise<Organization | null> {
// Return cached promise if already running
if (bootstrapPromise) {
return bootstrapPromise;
}
bootstrapPromise = performBootstrap();
return bootstrapPromise;
}
/**
* Get the resolved organization (if available)
*/
export function getBootstrappedOrg(): Organization | null {
return resolvedOrg;
}
/**
* Check if bootstrap has completed
*/
export function isBootstrapComplete(): boolean {
return resolvedOrg !== null || bootstrapPromise !== null;
}
/**
* Internal bootstrap implementation
*/
async function performBootstrap(): Promise<Organization | null> {
// Get current host outside try block so it's available in catch
let host: string = 'unknown-host';
try {
host = window.location.host || 'unknown-host';
console.log('Bootstrapping organization for host:', host);
// Skip resolution for localhost and Firebase hosting - be more aggressive about detection
if (host === 'localhost:5173' || host === 'localhost:3000' || host === '127.0.0.1:5173' ||
host.includes('firebaseapp.com') || host.includes('web.app') ||
host.includes('dev-racer') || host.includes('firebase') ||
// Always skip if not in production or if functions aren't available
import.meta.env.DEV || !import.meta.env.VITE_FUNCTIONS_AVAILABLE) {
console.log('Development/Firebase hosting detected, using default theme for host:', host);
applyOrgTheme(DEFAULT_ORG_THEME);
// Return mock organization for development
const mockOrg: Organization = {
id: 'dev-org',
name: 'Development Organization',
slug: 'dev',
branding: {
theme: DEFAULT_ORG_THEME,
},
domains: [],
};
resolvedOrg = mockOrg;
return mockOrg;
}
// Resolve organization by host with aggressive timeout
console.log('Attempting to resolve organization from functions:', FUNCTIONS_BASE_URL);
const controller = new AbortController();
const timeoutId = setTimeout(() => {
console.warn('Organization resolution timeout after 2 seconds');
controller.abort();
}, 2000); // Reduced to 2 second timeout to fail fast
const response = await fetch(
`${FUNCTIONS_BASE_URL}/resolveDomain?host=${encodeURIComponent(host)}`,
{
method: 'GET',
headers: {
'Content-Type': 'application/json',
},
signal: controller.signal,
}
);
clearTimeout(timeoutId);
if (!response.ok) {
if (response.status === 404) {
console.warn(`No organization found for host: ${host}`);
// Apply default theme and continue
applyOrgTheme(DEFAULT_ORG_THEME);
return null;
}
throw new Error(`Failed to resolve organization: ${response.status}`);
}
const data: DomainResolutionResponse = await response.json();
// Construct organization object
const org: Organization = {
id: data.orgId,
name: data.name,
slug: data.orgId, // Use orgId as fallback slug
branding: data.branding,
domains: data.domains,
};
// Apply organization theme immediately
applyOrgTheme(org.branding.theme);
// Apply branding (logo, favicon)
if (org.branding.logoUrl) {
// Store logo URL for header component to use
document.documentElement.setAttribute('data-org-logo', org.branding.logoUrl);
}
if (org.branding.faviconUrl) {
updateFavicon(org.branding.faviconUrl);
}
// Update page title if organization name is available
if (org.name && !document.title.includes(org.name)) {
document.title = `${org.name} - Black Canyon Tickets`;
}
console.log('Successfully resolved organization:', org.name);
resolvedOrg = org;
return org;
} catch (error) {
// Handle specific timeout/abort errors
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.warn('Organization resolution timed out after 2 seconds, using default theme for host:', host);
} else if (error.message.includes('fetch') || error.message.includes('network')) {
console.warn('Network error during organization resolution:', error.message, 'for host:', host);
} else {
console.error('Bootstrap failed:', error, 'for host:', host);
}
} else {
console.error('Bootstrap failed with unknown error:', error, 'for host:', host);
}
// Always apply default theme as fallback and create mock org
console.log('Applying default theme fallback for host:', host);
applyOrgTheme(DEFAULT_ORG_THEME);
// Return mock org instead of null to ensure app continues
const fallbackOrg: Organization = {
id: 'fallback-org',
name: 'Black Canyon Tickets',
slug: 'fallback',
branding: {
theme: DEFAULT_ORG_THEME,
},
domains: [],
};
resolvedOrg = fallbackOrg;
return fallbackOrg;
}
}
/**
* Update favicon dynamically
*/
function updateFavicon(faviconUrl: string): void {
try {
// Remove existing favicon links
const existingFavicons = document.querySelectorAll('link[rel*="icon"]');
existingFavicons.forEach(link => link.remove());
// Add new favicon
const link = document.createElement('link');
link.rel = 'icon';
link.type = 'image/x-icon';
link.href = faviconUrl;
document.head.appendChild(link);
console.log('Updated favicon:', faviconUrl);
} catch (error) {
console.warn('Failed to update favicon:', error);
}
}
/**
* Manual refresh of organization data
* Useful for testing or admin interfaces
*/
export async function refreshOrganization(): Promise<Organization | null> {
// Reset state
bootstrapPromise = null;
resolvedOrg = null;
// Remove cached attributes
document.documentElement.removeAttribute('data-org-theme');
document.documentElement.removeAttribute('data-org-logo');
// Re-bootstrap
return bootstrapOrganization();
}
/**
* Apply organization to existing React store
* This function bridges the bootstrap data to the Zustand store
*/
export function applyBootstrapToStore(): void {
if (typeof window === 'undefined') {return;}
// This will be called from React components after store is available
const org = getBootstrappedOrg();
if (org) {
// Import dynamically to avoid circular dependencies
import('../stores/organizationStore').then(({ useOrganizationStore }) => {
useOrganizationStore.getState().setCurrentOrg(org);
});
}
}
// Auto-bootstrap disabled - bootstrap is called explicitly from index.html
// This prevents multiple bootstrap calls that can cause conflicts
console.log('orgBootstrap.ts loaded - auto-bootstrap disabled (called from HTML)');

View File

@@ -0,0 +1,199 @@
import type { OrgTheme } from '../stores/organizationStore';
/**
* Organization Theme Manager
*
* Handles applying organization-specific themes to CSS custom properties
* This runs before React mounts to prevent FOUC (Flash of Unstyled Content)
*/
// CSS custom property mappings for organization themes
const CSS_VARIABLE_MAP = {
// Accent colors
accent: '--color-accent-500',
// Background colors
bgCanvas: '--color-bg-canvas',
bgSurface: '--color-bg-surface',
// Text colors
textPrimary: '--color-text-primary',
textSecondary: '--color-text-secondary',
} as const;
// Default fallback theme values
export const DEFAULT_ORG_THEME: OrgTheme = {
accent: '#F0C457',
bgCanvas: '#2B2D2F',
bgSurface: '#34373A',
textPrimary: '#F1F3F5',
textSecondary: '#C9D0D4',
};
/**
* Apply organization theme to CSS custom properties
* This function updates CSS variables that are consumed by Tailwind utilities
*/
export function applyOrgTheme(theme: OrgTheme, targetElement: HTMLElement = document.documentElement): void {
// Apply theme colors to CSS custom properties
Object.entries(CSS_VARIABLE_MAP).forEach(([themeKey, cssVar]) => {
const themeValue = theme[themeKey as keyof OrgTheme];
if (themeValue) {
targetElement.style.setProperty(cssVar, themeValue);
}
});
// Note: We apply to the root element only to avoid recursion
// Theme variables cascade naturally through CSS custom properties
// Store applied theme for reference
targetElement.setAttribute('data-org-theme', JSON.stringify(theme));
console.log('Applied organization theme:', theme);
}
/**
* Get currently applied organization theme from DOM
*/
export function getCurrentOrgTheme(targetElement: HTMLElement = document.documentElement): OrgTheme | null {
const themeData = targetElement.getAttribute('data-org-theme');
if (!themeData) {return null;}
try {
return JSON.parse(themeData) as OrgTheme;
} catch {
return null;
}
}
/**
* Reset to default theme values
*/
export function resetToDefaultTheme(targetElement: HTMLElement = document.documentElement): void {
applyOrgTheme(DEFAULT_ORG_THEME, targetElement);
}
/**
* Check if current theme differs from applied theme
*/
export function isThemeApplied(theme: OrgTheme, targetElement: HTMLElement = document.documentElement): boolean {
const appliedTheme = getCurrentOrgTheme(targetElement);
if (!appliedTheme) {return false;}
return Object.keys(theme).every(key => {
const themeKey = key as keyof OrgTheme;
return theme[themeKey] === appliedTheme[themeKey];
});
}
/**
* Generate CSS variable values for a given theme
* Useful for preview mode or dynamic style generation
*/
export function generateThemeCSS(theme: OrgTheme): string {
const cssRules = Object.entries(CSS_VARIABLE_MAP)
.map(([themeKey, cssVar]) => {
const value = theme[themeKey as keyof OrgTheme];
return ` ${cssVar}: ${value};`;
})
.join('\n');
return `:root {\n${cssRules}\n}`;
}
/**
* Generate inline style object for React components
*/
export function generateThemeStyle(theme: OrgTheme): React.CSSProperties {
const style: React.CSSProperties = {};
Object.entries(CSS_VARIABLE_MAP).forEach(([themeKey, cssVar]) => {
const value = theme[themeKey as keyof OrgTheme];
if (value) {
(style as any)[cssVar] = value;
}
});
return style;
}
/**
* Validate theme color values
*/
export function validateThemeColors(theme: Partial<OrgTheme>): { valid: boolean; errors: string[] } {
const errors: string[] = [];
// Helper to validate hex color
const isValidHex = (color: string): boolean => /^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})$/.test(color);
// Validate each color property
Object.entries(theme).forEach(([key, value]) => {
if (value && !isValidHex(value)) {
errors.push(`Invalid hex color for ${key}: ${value}`);
}
});
return {
valid: errors.length === 0,
errors,
};
}
/**
* Calculate contrast ratio between two colors
* Used for accessibility validation
*/
export function calculateContrastRatio(color1: string, color2: string): number {
// This is a simplified contrast calculation
// In production, you might want to use a more robust library
const getLuminance = (hex: string): number => {
const rgb = parseInt(hex.slice(1), 16);
const r = (rgb >> 16) & 0xff;
const g = (rgb >> 8) & 0xff;
const b = rgb & 0xff;
const [rs, gs, bs] = [r, g, b].map(c => {
c = c / 255;
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
});
return 0.2126 * rs + 0.7152 * gs + 0.0722 * bs;
};
const lum1 = getLuminance(color1);
const lum2 = getLuminance(color2);
const brightest = Math.max(lum1, lum2);
const darkest = Math.min(lum1, lum2);
return (brightest + 0.05) / (darkest + 0.05);
}
/**
* Validate theme accessibility
*/
export function validateThemeAccessibility(theme: OrgTheme): { valid: boolean; warnings: string[] } {
const warnings: string[] = [];
// Check contrast ratios (WCAG AA requires 4.5:1 for normal text)
const textBgContrast = calculateContrastRatio(theme.textPrimary, theme.bgCanvas);
if (textBgContrast < 4.5) {
warnings.push(`Low contrast between primary text and canvas background: ${textBgContrast.toFixed(2)}:1`);
}
const textSurfaceContrast = calculateContrastRatio(theme.textPrimary, theme.bgSurface);
if (textSurfaceContrast < 4.5) {
warnings.push(`Low contrast between primary text and surface background: ${textSurfaceContrast.toFixed(2)}:1`);
}
const accentBgContrast = calculateContrastRatio(theme.accent, theme.bgCanvas);
if (accentBgContrast < 3) {
warnings.push(`Low contrast between accent and canvas background: ${accentBgContrast.toFixed(2)}:1`);
}
return {
valid: warnings.length === 0,
warnings,
};
}

View File

@@ -0,0 +1,375 @@
/**
* Design Tokens - Single Source of Truth
* All colors defined here, consumed everywhere else
* Change brand colors here and see them propagate throughout the app
*/
// Base color palettes - these are the foundation
export const baseColors = {
// Brand gold - core identity color
gold: {
50: '#fefcf0',
100: '#fdf7dc',
200: '#fbecb8',
300: '#f7dc8a',
400: '#f2c55a',
500: '#d99e34', // Primary gold
600: '#c8852d',
700: '#a66b26',
800: '#855424',
900: '#6d4520',
},
// Warm gray - primary neutral palette
warmGray: {
50: '#f5f4f1',
100: '#e8e5df',
200: '#d1cbbf',
300: '#b0a990',
400: '#8a7e6b',
500: '#7d7461',
600: '#635c51',
700: '#4e453d',
800: '#39304a',
900: '#202030',
},
// Purple - secondary accent
purple: {
50: '#faf5ff',
100: '#f3e8ff',
200: '#e9d5ff',
300: '#d8b4fe',
400: '#c084fc',
500: '#a855f7',
600: '#9333ea',
700: '#7c3aed',
800: '#6b21a8',
900: '#581c87',
},
// Semantic colors - consistent across themes
semantic: {
success: '#10b981',
warning: '#f59e0b',
error: '#ef4444',
info: '#3b82f6',
},
// Pure values for special cases
pure: {
white: '#ffffff',
black: '#000000',
transparent: 'transparent',
},
} as const;
// Theme token structure - semantic naming only
export interface ThemeTokens {
background: {
primary: string;
secondary: string;
tertiary: string;
elevated: string;
overlay: string;
};
brand: {
shell: string;
shellContrast: string;
};
text: {
primary: string;
secondary: string;
muted: string;
inverse: string;
disabled: string;
};
surface: {
subtle: string;
card: string;
raised: string;
};
glass: {
bg: string;
border: string;
shadow: string;
subtle: string;
hover: string;
raised: string;
};
accent: {
primary: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
text: string;
};
secondary: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
text: string;
};
gold: {
50: string;
100: string;
200: string;
300: string;
400: string;
500: string;
600: string;
700: string;
800: string;
900: string;
text: string;
};
};
semantic: {
success: {
bg: string;
border: string;
text: string;
accent: string;
};
warning: {
bg: string;
border: string;
text: string;
accent: string;
};
error: {
bg: string;
border: string;
text: string;
accent: string;
};
info: {
bg: string;
border: string;
text: string;
accent: string;
};
};
border: {
default: string;
muted: string;
strong: string;
};
shadow: {
sm: string;
md: string;
lg: string;
};
focus: {
ring: string;
offset: string;
width: string;
};
}
// Light theme tokens
export const lightTokens: ThemeTokens = {
background: {
primary: baseColors.pure.white,
secondary: '#f8fafc',
tertiary: '#f1f5f9',
elevated: baseColors.pure.white,
overlay: 'rgba(0, 0, 0, 0.5)',
},
brand: {
shell: '#f8fafc',
shellContrast: baseColors.warmGray[900],
},
text: {
primary: baseColors.warmGray[900],
secondary: baseColors.warmGray[800],
muted: baseColors.warmGray[600],
inverse: baseColors.pure.white,
disabled: baseColors.warmGray[500],
},
surface: {
subtle: 'rgba(255, 255, 255, 0.6)',
card: 'rgba(255, 255, 255, 0.8)',
raised: 'rgba(255, 255, 255, 0.95)',
},
glass: {
bg: 'rgba(255, 255, 255, 0.8)',
border: 'rgba(203, 213, 225, 0.3)',
shadow: 'rgba(0, 0, 0, 0.1)',
subtle: 'rgba(255, 255, 255, 0.6)',
hover: 'rgba(255, 255, 255, 0.9)',
raised: 'rgba(255, 255, 255, 0.95)',
},
accent: {
primary: {
...baseColors.warmGray,
text: baseColors.warmGray[800],
},
secondary: {
...baseColors.purple,
text: baseColors.purple[600],
},
gold: {
...baseColors.gold,
text: baseColors.gold[700], // Darker for better contrast
},
},
semantic: {
success: {
bg: '#ecfdf5',
border: '#bbf7d0',
text: '#065f46',
accent: baseColors.semantic.success,
},
warning: {
bg: '#fffbeb',
border: '#fed7aa',
text: '#92400e',
accent: baseColors.semantic.warning,
},
error: {
bg: '#fef2f2',
border: '#fecaca',
text: '#991b1b',
accent: baseColors.semantic.error,
},
info: {
bg: '#eff6ff',
border: '#bfdbfe',
text: '#1e40af',
accent: baseColors.semantic.info,
},
},
border: {
default: '#e2e8f0',
muted: '#f1f5f9',
strong: '#cbd5e1',
},
shadow: {
sm: '0 2px 8px rgba(0, 0, 0, 0.08)',
md: '0 4px 16px rgba(0, 0, 0, 0.12)',
lg: '0 8px 32px rgba(0, 0, 0, 0.16)',
},
focus: {
ring: baseColors.gold[500],
offset: baseColors.pure.white,
width: '2px',
},
};
// Dark theme tokens
export const darkTokens: ThemeTokens = {
background: {
primary: '#2B2D2F', // Charcoal canvas
secondary: '#34373A', // Lighter surface
tertiary: '#3C4043', // Elevated surface
elevated: '#34373A', // Card/panel surfaces
overlay: 'rgba(43, 45, 47, 0.8)', // Charcoal overlay
},
brand: {
shell: '#686A6C', // Nardo Grey for header/sidebar
shellContrast: '#F2F3F4', // High contrast text on Nardo
},
text: {
primary: '#F1F3F5', // High contrast primary text
secondary: '#C9D0D4', // Secondary text
muted: '#A6AEB3', // Muted text
inverse: '#1B1C1D', // Dark text for filled components
disabled: '#7A8084', // Disabled state
},
surface: {
subtle: '#34373A', // Subtle card surfaces
card: '#34373A', // Main card surfaces
raised: '#3C4043', // Elevated surfaces
},
glass: {
bg: 'rgba(176, 169, 144, 0.1)',
border: 'rgba(176, 169, 144, 0.2)',
shadow: 'rgba(32, 32, 48, 0.3)',
subtle: 'rgba(176, 169, 144, 0.08)',
hover: 'rgba(176, 169, 144, 0.15)',
raised: 'rgba(176, 169, 144, 0.18)',
},
accent: {
primary: {
...baseColors.warmGray,
text: baseColors.warmGray[300],
},
secondary: {
...baseColors.purple,
text: baseColors.purple[300],
},
gold: {
...baseColors.gold,
500: '#F0C457', // Bright gold for dark mode
text: '#1B1C1D', // Dark text for filled components
},
},
semantic: {
success: {
bg: 'rgba(16, 185, 129, 0.1)',
border: 'rgba(16, 185, 129, 0.3)',
text: '#6ee7b7',
accent: baseColors.semantic.success,
},
warning: {
bg: 'rgba(245, 158, 11, 0.1)',
border: 'rgba(245, 158, 11, 0.3)',
text: '#fcd34d',
accent: baseColors.semantic.warning,
},
error: {
bg: 'rgba(239, 68, 68, 0.1)',
border: 'rgba(239, 68, 68, 0.3)',
text: '#fca5a5',
accent: baseColors.semantic.error,
},
info: {
bg: 'rgba(59, 130, 246, 0.1)',
border: 'rgba(59, 130, 246, 0.3)',
text: '#93c5fd',
accent: baseColors.semantic.info,
},
},
border: {
default: 'rgba(255, 255, 255, 0.10)', // Subtle white borders
muted: 'rgba(255, 255, 255, 0.05)', // Very subtle borders
strong: 'rgba(255, 255, 255, 0.15)', // More prominent borders
},
shadow: {
sm: '0 1px 0 rgba(0, 0, 0, 0.35)', // Crisp shadow for cards
md: '0 6px 20px rgba(0, 0, 0, 0.35)', // Medium depth
lg: '0 8px 32px rgba(0, 0, 0, 0.4)', // Deep shadows
},
focus: {
ring: '#F0C457', // Bright gold for focus
offset: '#2B2D2F', // Charcoal background
width: '2px',
},
};
// Helper function to get tokens for a theme
export function getTokens(theme: 'light' | 'dark'): ThemeTokens {
return theme === 'light' ? lightTokens : darkTokens;
}
// Type guard for theme values
export function isValidTheme(theme: string): theme is 'light' | 'dark' {
return theme === 'light' || theme === 'dark';
}
export type Theme = 'light' | 'dark';