@@ -7,6 +7,14 @@ type OklchColor = {
77 c : number ;
88 h : number ;
99} ;
10+ export type ShadowValue = {
11+ color : string ;
12+ opacity : number ;
13+ blur : number ;
14+ spread : number ;
15+ offsetX : number ;
16+ offsetY : number ;
17+ } ;
1018
1119function normalizeAlpha ( value : number ) : number {
1220 if ( ! Number . isFinite ( value ) ) return 1 ;
@@ -72,42 +80,66 @@ export function formatOklchColor(color: OklchColor): string {
7280 return `oklch(${ color . l . toFixed ( 3 ) } ${ color . c . toFixed ( 3 ) } ${ color . h . toFixed ( 3 ) } )` ;
7381}
7482
75- export function deriveStatusPalette ( styles : RuntimeStyles ) : Pick <
83+ export function deriveStatusPalette (
84+ styles : RuntimeStyles
85+ ) : Pick <
7686 ThemeEditorFields ,
77- 'success' | 'successForeground' | 'info' | 'infoForeground' | 'warning' | 'warningForeground' | 'danger' | 'dangerForeground'
87+ | 'success'
88+ | 'successForeground'
89+ | 'info'
90+ | 'infoForeground'
91+ | 'warning'
92+ | 'warningForeground'
93+ | 'danger'
94+ | 'dangerForeground'
7895> {
79- const mode : ThemeMode = parseCssScalar ( styles . background ?? '' ) != null
80- ? ( ( parseOklchColor ( styles . background ) ?. l ?? 1 ) < 0.45 ? 'dark' : 'light' )
81- : 'light' ;
82- const seed = parseOklchColor ( styles . primary ) ?? parseOklchColor ( styles . accent ) ?? parseOklchColor ( styles . ring ) ;
96+ const mode : ThemeMode =
97+ parseCssScalar ( styles . background ?? '' ) != null
98+ ? ( parseOklchColor ( styles . background ) ?. l ?? 1 ) < 0.45
99+ ? 'dark'
100+ : 'light'
101+ : 'light' ;
102+ const seed =
103+ parseOklchColor ( styles . primary ) ??
104+ parseOklchColor ( styles . accent ) ??
105+ parseOklchColor ( styles . ring ) ;
83106 const chromaBase = clamp ( seed ?. c ?? ( mode === 'dark' ? 0.17 : 0.19 ) , 0.1 , 0.24 ) ;
84107 const lightnessShift = seed ? ( seed . l - ( mode === 'dark' ? 0.78 : 0.58 ) ) * 0.08 : 0 ;
85108 const hueShift = seed ? ( ( seed . h - 220 ) / 220 ) * 6 : 0 ;
86109 const statusForeground = mode === 'dark' ? 'oklch(0.145 0 0)' : 'oklch(0.985 0 0)' ;
87110
88- const createStatus = ( hue : number , lightness : number , chromaScale : number ) => formatOklchColor ( {
89- l : clamp ( lightness + lightnessShift , mode === 'dark' ? 0.68 : 0.54 , mode === 'dark' ? 0.84 : 0.74 ) ,
90- c : clamp ( chromaBase * chromaScale , 0.12 , 0.26 ) ,
91- h : normalizeHue ( hue + hueShift ) ,
92- } ) ;
111+ const createStatus = ( hue : number , lightness : number , chromaScale : number ) =>
112+ formatOklchColor ( {
113+ l : clamp (
114+ lightness + lightnessShift ,
115+ mode === 'dark' ? 0.68 : 0.54 ,
116+ mode === 'dark' ? 0.84 : 0.74
117+ ) ,
118+ c : clamp ( chromaBase * chromaScale , 0.12 , 0.26 ) ,
119+ h : normalizeHue ( hue + hueShift ) ,
120+ } ) ;
93121
94122 return {
95123 success : createStatus ( 148 , mode === 'dark' ? 0.76 : 0.62 , 0.92 ) ,
96124 successForeground : statusForeground ,
97- info : createStatus ( 238 , mode === 'dark' ? 0.74 : 0.60 , 0.96 ) ,
125+ info : createStatus ( 238 , mode === 'dark' ? 0.74 : 0.6 , 0.96 ) ,
98126 infoForeground : statusForeground ,
99- warning : createStatus ( 72 , mode === 'dark' ? 0.80 : 0.69 , 0.94 ) ,
127+ warning : createStatus ( 72 , mode === 'dark' ? 0.8 : 0.69 , 0.94 ) ,
100128 warningForeground : mode === 'dark' ? 'oklch(0.145 0 0)' : 'oklch(0.205 0 0)' ,
101- danger : createStatus ( 28 , mode === 'dark' ? 0.72 : 0.60 , 1 ) ,
129+ danger : createStatus ( 28 , mode === 'dark' ? 0.72 : 0.6 , 1 ) ,
102130 dangerForeground : statusForeground ,
103131 } ;
104132}
105133
106134function parseHexColor ( color : string ) : [ number , number , number ] | null {
107135 const normalized = color . trim ( ) . replace ( '#' , '' ) ;
108- const value = normalized . length === 3
109- ? normalized . split ( '' ) . map ( ( char ) => `${ char } ${ char } ` ) . join ( '' )
110- : normalized ;
136+ const value =
137+ normalized . length === 3
138+ ? normalized
139+ . split ( '' )
140+ . map ( ( char ) => `${ char } ${ char } ` )
141+ . join ( '' )
142+ : normalized ;
111143
112144 if ( ! / ^ [ 0 - 9 a - f ] { 6 } $ / i. test ( value ) ) return null ;
113145
@@ -155,9 +187,138 @@ export function softenSurface(color: string, mode: ThemeMode, amount: number): s
155187 return mode === 'dark' ? tintColor ( color , amount ) : shadeColor ( color , amount ) ;
156188}
157189
158- export function buildShadow ( styles : RuntimeStyles ) : string {
190+ function splitTopLevelTokens ( value : string ) : string [ ] {
191+ const tokens : string [ ] = [ ] ;
192+ let current = '' ;
193+ let depth = 0 ;
194+
195+ for ( const char of value ) {
196+ if ( char === '(' ) depth += 1 ;
197+ if ( char === ')' ) depth = Math . max ( 0 , depth - 1 ) ;
198+
199+ if ( / \s / . test ( char ) && depth === 0 ) {
200+ if ( current ) {
201+ tokens . push ( current ) ;
202+ current = '' ;
203+ }
204+ continue ;
205+ }
206+
207+ current += char ;
208+ }
209+
210+ if ( current ) tokens . push ( current ) ;
211+ return tokens ;
212+ }
213+
214+ function parsePxValue ( token : string ) : number | null {
215+ const trimmed = token . trim ( ) ;
216+ if ( / ^ - ? 0 (?: \. 0 + ) ? (?: p x ) ? $ / i. test ( trimmed ) ) return 0 ;
217+
218+ const match = / ^ ( - ? \d + (?: \. \d + ) ? ) p x $ / i. exec ( trimmed ) ;
219+ if ( ! match ) return null ;
220+
221+ const parsed = Number . parseFloat ( match [ 1 ] ) ;
222+ return Number . isFinite ( parsed ) ? parsed : null ;
223+ }
224+
225+ function rgbChannelToHex ( value : string ) : string | null {
226+ const parsed = Number . parseInt ( value . trim ( ) , 10 ) ;
227+ if ( ! Number . isFinite ( parsed ) || parsed < 0 || parsed > 255 ) return null ;
228+ return parsed . toString ( 16 ) . padStart ( 2 , '0' ) ;
229+ }
230+
231+ function parseShadowColor ( value : string ) : Pick < ShadowValue , 'color' | 'opacity' > | null {
232+ const colorMixMatch =
233+ / ^ c o l o r - m i x \( i n s r g b , \s * ( .+ ?) \s + ( - ? \d + (?: \. \d + ) ? ) % , \s * t r a n s p a r e n t \s * \) $ / i. exec ( value . trim ( ) ) ;
234+ if ( colorMixMatch ) {
235+ const percentage = Number . parseFloat ( colorMixMatch [ 2 ] ) ;
236+ if ( Number . isFinite ( percentage ) ) {
237+ return {
238+ color : colorMixMatch [ 1 ] . trim ( ) ,
239+ opacity : clamp ( percentage / 100 , 0 , 1 ) ,
240+ } ;
241+ }
242+ }
243+
244+ const rgbaMatch = / ^ r g b a \( \s * ( [ ^ , ] + ) \s * , \s * ( [ ^ , ] + ) \s * , \s * ( [ ^ , ] + ) \s * , \s * ( [ ^ ) ] + ) \) $ / i. exec (
245+ value . trim ( )
246+ ) ;
247+ if ( rgbaMatch ) {
248+ const red = rgbChannelToHex ( rgbaMatch [ 1 ] ) ;
249+ const green = rgbChannelToHex ( rgbaMatch [ 2 ] ) ;
250+ const blue = rgbChannelToHex ( rgbaMatch [ 3 ] ) ;
251+ const alpha = Number . parseFloat ( rgbaMatch [ 4 ] ) ;
252+
253+ if ( red && green && blue && Number . isFinite ( alpha ) ) {
254+ return {
255+ color : `#${ red } ${ green } ${ blue } ` ,
256+ opacity : clamp ( alpha , 0 , 1 ) ,
257+ } ;
258+ }
259+ }
260+
261+ const rgbMatch = / ^ r g b \( \s * ( [ ^ , ] + ) \s * , \s * ( [ ^ , ] + ) \s * , \s * ( [ ^ , ] + ) \s * \) $ / i. exec ( value . trim ( ) ) ;
262+ if ( rgbMatch ) {
263+ const red = rgbChannelToHex ( rgbMatch [ 1 ] ) ;
264+ const green = rgbChannelToHex ( rgbMatch [ 2 ] ) ;
265+ const blue = rgbChannelToHex ( rgbMatch [ 3 ] ) ;
266+
267+ if ( red && green && blue ) {
268+ return {
269+ color : `#${ red } ${ green } ${ blue } ` ,
270+ opacity : 1 ,
271+ } ;
272+ }
273+ }
274+
275+ return value . trim ( )
276+ ? {
277+ color : value . trim ( ) ,
278+ opacity : 1 ,
279+ }
280+ : null ;
281+ }
282+
283+ export function formatShadowValue ( shadow : ShadowValue ) : string {
284+ const offsetX = `${ shadow . offsetX } px` ;
285+ const offsetY = `${ shadow . offsetY } px` ;
286+ const blur = `${ shadow . blur } px` ;
287+ const spread = `${ shadow . spread } px` ;
288+
289+ return `${ offsetX } ${ offsetY } ${ blur } ${ spread } ${ toRgba ( shadow . color , shadow . opacity ) } ` ;
290+ }
291+
292+ export function parseShadowValue ( value : string , fallback : ShadowValue ) : ShadowValue {
293+ const trimmed = value . trim ( ) ;
294+ if ( ! trimmed || trimmed . toLowerCase ( ) === 'none' ) return fallback ;
295+
296+ const tokens = splitTopLevelTokens ( trimmed ) ;
297+ if ( tokens . length < 5 ) return fallback ;
298+
299+ const offsetX = parsePxValue ( tokens [ 0 ] ) ;
300+ const offsetY = parsePxValue ( tokens [ 1 ] ) ;
301+ const blur = parsePxValue ( tokens [ 2 ] ) ;
302+ const spread = parsePxValue ( tokens [ 3 ] ) ;
303+ const color = parseShadowColor ( tokens . slice ( 4 ) . join ( ' ' ) ) ;
304+
305+ if ( offsetX == null || offsetY == null || blur == null || spread == null || ! color ) {
306+ return fallback ;
307+ }
308+
309+ return {
310+ color : color . color ,
311+ opacity : color . opacity ,
312+ blur,
313+ spread,
314+ offsetX,
315+ offsetY,
316+ } ;
317+ }
318+
319+ export function buildShadow ( styles : RuntimeStyles , fallback = DEFAULT_FIELDS . shadowCard ) : string {
159320 const color = styles [ 'shadow-color' ] ;
160- if ( ! color ) return DEFAULT_FIELDS . shadowCard ;
321+ if ( ! color ) return fallback ;
161322
162323 const opacity = Number . parseFloat ( styles [ 'shadow-opacity' ] ?? '0.1' ) ;
163324 const blur = styles [ 'shadow-blur' ] ?? '0px' ;
0 commit comments