Skip to content

Commit 20c8512

Browse files
committed
feat(theme): add light/dark mode support for brand colors
- Add BrandColorValue type supporting string or { light, dark } object - Update ThemeProvider to generate separate CSS for light/dark themes - Add validation caching to prevent duplicate warnings for string colors - Maintain backward compatibility with existing string color values
1 parent 6e32869 commit 20c8512

File tree

3 files changed

+136
-63
lines changed

3 files changed

+136
-63
lines changed

packages/ui/src/providers/ThemeProvider.tsx

Lines changed: 117 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import React, {
99
} from 'react';
1010
import { useOpenAiGlobal } from '../hooks/openai/useOpenAiGlobal';
1111
import type { Theme } from '../hooks/openai/types';
12-
import type { BrandColorConfig } from '../tokens/colors';
12+
import type { BrandColorConfig, BrandColorValue } from '../tokens/colors';
1313
import {
1414
getContrastColor,
1515
validateContrast,
@@ -92,86 +92,140 @@ export interface ThemeProviderProps {
9292

9393
/**
9494
* Generate CSS custom properties from brand color configuration
95+
* Uses data-attribute selectors for explicit, predictable specificity
9596
*/
96-
function generateBrandColorCSS(brandColors: BrandColorConfig, theme: Theme): string {
97-
const isDark = theme === 'dark';
98-
const mixColor = isDark ? 'white' : 'black';
99-
const hoverPercent = isDark ? '85%' : '85%';
100-
const activePercent = isDark ? '90%' : '75%';
101-
102-
const styles: string[] = [];
103-
104-
// Helper to validate and add color with warnings
105-
const addColorVariable = (colorKey: keyof BrandColorConfig, varName: string, value: string) => {
106-
// Validate hex color format first
107-
if (!isValidHexColor(value)) {
108-
console.warn(
109-
`[ThemeProvider] Invalid hex color format for brand ${colorKey}: "${value}". ` +
110-
`Expected format: #RGB, #RRGGBB, or #RRGGBBAA. This color will be skipped.`
111-
);
112-
return; // Skip invalid colors
113-
}
97+
function generateBrandColorCSS(brandColors: BrandColorConfig, _theme: Theme): string {
98+
const lightStyles: string[] = [];
99+
const darkStyles: string[] = [];
114100

115-
// Normalize the color to standard format with #
116-
const normalizedValue = normalizeHexColor(value);
117-
if (!normalizedValue) {
118-
console.warn(
119-
`[ThemeProvider] Failed to normalize color "${value}" for brand ${colorKey}. This color will be skipped.`
120-
);
121-
return;
122-
}
101+
// Track validated colors to avoid duplicate warnings for string values
102+
const validatedColors = new Map<string, { normalized: string; onColor: string }>();
123103

124-
// Check if color has alpha channel (8-digit hex)
125-
const hasAlpha = normalizedValue.length === 9;
126-
if (hasAlpha) {
127-
console.warn(
128-
`[ThemeProvider] Brand ${colorKey} color "${normalizedValue}" contains an alpha channel. ` +
129-
`Contrast validation will be performed on the opaque base color (alpha channel stripped). ` +
130-
`Actual contrast may vary depending on background opacity.`
131-
);
104+
// Helper to resolve color value for a specific theme
105+
const resolveColorValue = (value: BrandColorValue, forTheme: 'light' | 'dark'): string => {
106+
if (typeof value === 'string') {
107+
return value;
132108
}
109+
return forTheme === 'dark' ? value.dark : value.light;
110+
};
111+
112+
// Helper to validate and add color with warnings for a specific theme
113+
const addColorVariable = (
114+
colorKey: keyof BrandColorConfig,
115+
varName: string,
116+
value: string,
117+
targetStyles: string[],
118+
themeName: 'light' | 'dark'
119+
) => {
120+
const cacheKey = `${colorKey}:${value}`;
121+
let normalizedValue: string;
122+
let onColor: string;
123+
124+
// Check if we've already validated this exact color value
125+
const cached = validatedColors.get(cacheKey);
126+
if (cached) {
127+
// Use cached validation results (avoids duplicate warnings)
128+
normalizedValue = cached.normalized;
129+
onColor = cached.onColor;
130+
} else {
131+
// Validate hex color format first
132+
if (!isValidHexColor(value)) {
133+
console.warn(
134+
`[ThemeProvider] Invalid hex color format for brand ${colorKey}: "${value}". ` +
135+
`Expected format: #RGB, #RRGGBB, or #RRGGBBAA. This color will be skipped.`
136+
);
137+
return; // Skip invalid colors
138+
}
139+
140+
// Normalize the color to standard format with #
141+
const normalized = normalizeHexColor(value);
142+
if (!normalized) {
143+
console.warn(
144+
`[ThemeProvider] Failed to normalize color "${value}" for brand ${colorKey}. This color will be skipped.`
145+
);
146+
return;
147+
}
148+
normalizedValue = normalized;
149+
150+
// Check if color has alpha channel (8-digit hex)
151+
const hasAlpha = normalizedValue.length === 9;
152+
if (hasAlpha) {
153+
console.warn(
154+
`[ThemeProvider] Brand ${colorKey} color "${normalizedValue}" contains an alpha channel. ` +
155+
`Contrast validation will be performed on the opaque base color (alpha channel stripped). ` +
156+
`Actual contrast may vary depending on background opacity.`
157+
);
158+
}
133159

134-
// Validate contrast for on-color (text on brand color)
135-
// Note: For 8-digit colors, we validate against the opaque RGB values
136-
const onColor = getContrastColor(normalizedValue);
137-
const contrastResult = validateContrast(onColor, normalizedValue);
160+
// Validate contrast for on-color (text on brand color)
161+
// Note: For 8-digit colors, we validate against the opaque RGB values
162+
onColor = getContrastColor(normalizedValue);
163+
const contrastResult = validateContrast(onColor, normalizedValue);
138164

139-
if (!contrastResult.valid) {
140-
console.warn(
141-
`[ThemeProvider] Brand ${colorKey} color "${normalizedValue}" may not meet WCAG AA contrast requirements (ratio: ${contrastResult.ratio?.toFixed(2)}). Consider using a different color.`
142-
);
165+
if (!contrastResult.valid) {
166+
console.warn(
167+
`[ThemeProvider] Brand ${colorKey} color "${normalizedValue}" may not meet WCAG AA contrast requirements (ratio: ${contrastResult.ratio?.toFixed(2)}). Consider using a different color.`
168+
);
169+
}
170+
171+
// Cache the validation results
172+
validatedColors.set(cacheKey, { normalized: normalizedValue, onColor });
143173
}
144174

145-
styles.push(`--ai-color-${varName}: ${normalizedValue};`);
146-
styles.push(
175+
const isDark = themeName === 'dark';
176+
const mixColor = isDark ? 'white' : 'black';
177+
const hoverPercent = isDark ? '85%' : '85%';
178+
const activePercent = isDark ? '90%' : '75%';
179+
180+
targetStyles.push(`--ai-color-${varName}: ${normalizedValue};`);
181+
targetStyles.push(
147182
`--ai-color-${varName}-hover: color-mix(in srgb, var(--ai-color-${varName}) ${hoverPercent}, ${mixColor});`
148183
);
149-
styles.push(
184+
targetStyles.push(
150185
`--ai-color-${varName}-active: color-mix(in srgb, var(--ai-color-${varName}) ${activePercent}, ${mixColor});`
151186
);
152-
styles.push(`--ai-color-brand-on-${colorKey}: ${onColor};`);
187+
targetStyles.push(`--ai-color-brand-on-${colorKey}: ${onColor};`);
153188
};
154189

155-
// Generate CSS for each provided brand color
190+
// Generate CSS for each provided brand color for both themes
156191
if (brandColors.primary) {
157-
addColorVariable('primary', 'brand-primary', brandColors.primary);
192+
addColorVariable('primary', 'brand-primary', resolveColorValue(brandColors.primary, 'light'), lightStyles, 'light');
193+
addColorVariable('primary', 'brand-primary', resolveColorValue(brandColors.primary, 'dark'), darkStyles, 'dark');
158194
}
159195
if (brandColors.success) {
160-
addColorVariable('success', 'brand-success', brandColors.success);
196+
addColorVariable('success', 'brand-success', resolveColorValue(brandColors.success, 'light'), lightStyles, 'light');
197+
addColorVariable('success', 'brand-success', resolveColorValue(brandColors.success, 'dark'), darkStyles, 'dark');
161198
}
162199
if (brandColors.warning) {
163-
addColorVariable('warning', 'brand-warning', brandColors.warning);
200+
addColorVariable('warning', 'brand-warning', resolveColorValue(brandColors.warning, 'light'), lightStyles, 'light');
201+
addColorVariable('warning', 'brand-warning', resolveColorValue(brandColors.warning, 'dark'), darkStyles, 'dark');
164202
}
165203
if (brandColors.error) {
166-
addColorVariable('error', 'brand-error', brandColors.error);
204+
addColorVariable('error', 'brand-error', resolveColorValue(brandColors.error, 'light'), lightStyles, 'light');
205+
addColorVariable('error', 'brand-error', resolveColorValue(brandColors.error, 'dark'), darkStyles, 'dark');
167206
}
168207

169208
// Return empty if no custom colors provided
170-
if (styles.length === 0) return '';
209+
if (lightStyles.length === 0 && darkStyles.length === 0) return '';
210+
211+
// Generate CSS using data-attribute selectors for explicit specificity
212+
// This approach is cleaner than :root:root hacks and more maintainable
213+
const cssBlocks: string[] = [];
214+
215+
if (lightStyles.length > 0) {
216+
// Light mode: applies when brand colors are active (attribute on html)
217+
cssBlocks.push(`html[data-ainativekit-brand] {\n ${lightStyles.join('\n ')}\n}`);
218+
}
171219

172-
// Wrap in appropriate selector
173-
const selector = isDark ? '[data-theme="dark"]' : ':root';
174-
return `${selector} {\n ${styles.join('\n ')}\n}`;
220+
if (darkStyles.length > 0) {
221+
// Dark mode: applies when both brand colors and dark theme are active
222+
// Handles both global (html) and scoped (container) theme attributes
223+
cssBlocks.push(
224+
`html[data-ainativekit-brand][data-theme="dark"],\nhtml[data-ainativekit-brand] [data-theme="dark"] {\n ${darkStyles.join('\n ')}\n}`
225+
);
226+
}
227+
228+
return cssBlocks.join('\n\n');
175229
}
176230

177231
/**
@@ -344,19 +398,23 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
344398
}
345399
}, [chatGPTTheme, enableSystemTheme, storageKey]);
346400

347-
// Inject brand color CSS
401+
// Inject brand color CSS and set data attribute
348402
useEffect(() => {
349403
if (typeof window === 'undefined') return;
350404

351405
if (!brandColorCSS) {
352-
// No custom colors, remove style element if it exists
406+
// No custom colors, remove style element and data attribute if they exist
353407
if (styleElementRef.current) {
354408
styleElementRef.current.remove();
355409
styleElementRef.current = null;
356410
}
411+
document.documentElement.removeAttribute('data-ainativekit-brand');
357412
return;
358413
}
359414

415+
// Set data attribute on html element to activate brand color selectors
416+
document.documentElement.setAttribute('data-ainativekit-brand', 'true');
417+
360418
// Create or update style element
361419
if (!styleElementRef.current) {
362420
styleElementRef.current = document.createElement('style');
@@ -375,6 +433,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({
375433
styleElementRef.current.remove();
376434
styleElementRef.current = null;
377435
}
436+
document.documentElement.removeAttribute('data-ainativekit-brand');
378437
};
379438
}, [brandColorCSS]);
380439

packages/ui/src/tokens/colors.ts

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -170,19 +170,33 @@ export const colors = {
170170
export type ColorTheme = typeof colors.light;
171171
export type ThemeMode = 'light' | 'dark';
172172

173+
/**
174+
* Brand color value that supports both simple strings and light/dark mode variants
175+
* - String: Same color for both light and dark modes
176+
* - Object: Different colors for light and dark modes (following ConstructKit pattern)
177+
*
178+
* @example
179+
* // Simple (same color for both modes)
180+
* primary: '#6366F1'
181+
*
182+
* // Light/dark variants (different colors per mode)
183+
* success: { light: '#059669', dark: '#34D399' }
184+
*/
185+
export type BrandColorValue = string | { light: string; dark: string };
186+
173187
/**
174188
* Brand color configuration for customizing theme colors
175189
* Used by ThemeProvider to override default brand colors
176190
*/
177191
export interface BrandColorConfig {
178192
/** Primary brand color (used for main actions, links, etc.) */
179-
primary?: string;
193+
primary?: BrandColorValue;
180194
/** Success color (used for positive actions, success states) */
181-
success?: string;
195+
success?: BrandColorValue;
182196
/** Warning color (used for warning states, caution messages) */
183-
warning?: string;
197+
warning?: BrandColorValue;
184198
/** Error color (used for error states, destructive actions) */
185-
error?: string;
199+
error?: BrandColorValue;
186200
}
187201

188202
/**

packages/ui/src/tokens/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ export * from './icon-utils';
1616
export * from './utility-classes';
1717

1818
// Export types
19-
export type { ColorTheme, ThemeMode } from './colors';
19+
export type { ColorTheme, ThemeMode, BrandColorConfig, BrandColorValue } from './colors';
2020
export type { ElevationLevel, ElevationDefinition } from './elevation';
2121
export type { SpacingScale } from './spacing';
2222
export type { ColorPath, RadiusScale, TypographyStyle, OpacityPreset } from './token-helpers';

0 commit comments

Comments
 (0)