@@ -9,7 +9,7 @@ import React, {
99} from 'react' ;
1010import { useOpenAiGlobal } from '../hooks/openai/useOpenAiGlobal' ;
1111import type { Theme } from '../hooks/openai/types' ;
12- import type { BrandColorConfig } from '../tokens/colors' ;
12+ import type { BrandColorConfig , BrandColorValue } from '../tokens/colors' ;
1313import {
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
0 commit comments