@@ -15,6 +15,8 @@ export type AccentStrength = "vibrant" | "muted" | "grayscale";
1515export type ColorSignature = {
1616 dominantHue : HueBucket ;
1717 dominantHueAngle : number ;
18+ accentHue ?: HueBucket ;
19+ accentHueAngle ?: number ;
1820 brightness : Brightness ;
1921 accentStrength : AccentStrength ;
2022 dominantColor : string ;
@@ -124,6 +126,16 @@ const STEP_BY_BRIGHTNESS: Record<Brightness, StepMap> = {
124126} ;
125127
126128const WARM_BUCKETS = new Set < HueBucket > ( [ "red" , "orange" , "yellow" , "pink" ] ) ;
129+ const HUE_BUCKETS : HueBucket [ ] = [
130+ "red" ,
131+ "orange" ,
132+ "yellow" ,
133+ "green" ,
134+ "teal" ,
135+ "blue" ,
136+ "purple" ,
137+ "pink" ,
138+ ] ;
127139
128140export type GradientPalette = {
129141 id : string ;
@@ -144,6 +156,40 @@ function hueDistance(a: number, b: number): number {
144156 return diff > 180 ? 360 - diff : diff ;
145157}
146158
159+ function normalizeHue ( angle : number ) : number {
160+ if ( ! Number . isFinite ( angle ) ) return 0 ;
161+ const normalized = angle % 360 ;
162+ return normalized < 0 ? normalized + 360 : normalized ;
163+ }
164+
165+ function hueToBucket ( angle : number ) : HueBucket {
166+ const hue = normalizeHue ( angle ) ;
167+ if ( hue >= 345 || hue < 15 ) return "red" ;
168+ if ( hue < 45 ) return "orange" ;
169+ if ( hue < 75 ) return "yellow" ;
170+ if ( hue < 165 ) return "green" ;
171+ if ( hue < 195 ) return "teal" ;
172+ if ( hue < 255 ) return "blue" ;
173+ if ( hue < 285 ) return "purple" ;
174+ if ( hue < 345 ) return "pink" ;
175+ return "neutral" ;
176+ }
177+
178+ function hashString ( value : string ) : number {
179+ let hash = 0 ;
180+ for ( let i = 0 ; i < value . length ; i += 1 ) {
181+ hash = ( hash * 31 + value . charCodeAt ( i ) ) % 1_000_000 ;
182+ }
183+ return hash ;
184+ }
185+
186+ function fallbackAccentBucket ( signature : ColorSignature ) : HueBucket {
187+ const seed = `${ signature . dominantColor } :${ signature . brightness } :${ signature . accentStrength } ` ;
188+ const hash = hashString ( seed ) ;
189+ const index = hash % HUE_BUCKETS . length ;
190+ return HUE_BUCKETS [ index ] ?? "blue" ;
191+ }
192+
147193function resolveVariant ( signature : ColorSignature ) : HueVariant {
148194 const variants = HUE_VARIANTS [ signature . dominantHue ] ?? HUE_VARIANTS . neutral ;
149195 if ( ! Number . isFinite ( signature . dominantHueAngle ) ) {
@@ -166,40 +212,107 @@ function pickScale(scale: ScaleName, step: ScaleKey): string {
166212 return COLOR_SCALES [ scale ] [ step ] ;
167213}
168214
215+ function resolveSecondaryHue ( signature : ColorSignature ) : { hue : HueBucket ; angle : number } {
216+ const baseAngle = normalizeHue ( signature . dominantHueAngle ) ;
217+ const accentAngle = signature . accentHueAngle ;
218+ if (
219+ signature . accentHue &&
220+ typeof accentAngle === "number" &&
221+ ( signature . dominantHue === "neutral" || hueDistance ( baseAngle , accentAngle ) >= 35 )
222+ ) {
223+ return { hue : signature . accentHue , angle : accentAngle } ;
224+ }
225+
226+ if ( signature . dominantHue === "neutral" ) {
227+ const fallbackHue = fallbackAccentBucket ( signature ) ;
228+ const anchor = HUE_VARIANTS [ fallbackHue ] ?. [ 0 ] ?. anchor ?? 0 ;
229+ return { hue : fallbackHue , angle : anchor } ;
230+ }
231+
232+ const complementAngle = normalizeHue ( baseAngle + 180 ) ;
233+ return { hue : hueToBucket ( complementAngle ) , angle : complementAngle } ;
234+ }
235+
236+ function resolveAccentVariant ( signature : ColorSignature ) : HueVariant | null {
237+ if ( signature . accentHue ) {
238+ return resolveVariant ( {
239+ ...signature ,
240+ dominantHue : signature . accentHue ,
241+ dominantHueAngle :
242+ signature . accentHueAngle ??
243+ HUE_VARIANTS [ signature . accentHue ] ?. [ 0 ] ?. anchor ??
244+ 0 ,
245+ } ) ;
246+ }
247+
248+ const fallback = fallbackAccentBucket ( signature ) ;
249+ return resolveVariant ( {
250+ ...signature ,
251+ dominantHue : fallback ,
252+ dominantHueAngle : HUE_VARIANTS [ fallback ] ?. [ 0 ] ?. anchor ?? 0 ,
253+ } ) ;
254+ }
255+
256+ export function createComplementSignature ( signature : ColorSignature ) : ColorSignature {
257+ const baseAngle = signature . dominantHue !== "neutral" || ! signature . accentHueAngle
258+ ? signature . dominantHueAngle
259+ : signature . accentHueAngle ;
260+ const complementAngle = normalizeHue ( baseAngle + 180 ) ;
261+ const complementHue = hueToBucket ( complementAngle ) ;
262+
263+ return {
264+ ...signature ,
265+ dominantHue : complementHue ,
266+ dominantHueAngle : complementAngle ,
267+ accentHue : signature . dominantHue ,
268+ accentHueAngle : signature . dominantHueAngle ,
269+ accentStrength : signature . accentStrength === "vibrant" ? "muted" : signature . accentStrength ,
270+ } ;
271+ }
272+
169273export function buildGradientPalette ( signature : ColorSignature ) : GradientPalette {
170274 const steps = STEP_BY_BRIGHTNESS [ signature . brightness ] ;
171275 const variant = resolveVariant ( signature ) ;
276+ const secondaryHue = resolveSecondaryHue ( signature ) ;
277+ const secondaryVariant = resolveVariant ( {
278+ ...signature ,
279+ dominantHue : secondaryHue . hue ,
280+ dominantHueAngle : secondaryHue . angle ,
281+ } ) ;
172282 const neutralScale : ScaleName = WARM_BUCKETS . has ( signature . dominantHue ) ? "stone" : "slate" ;
173283
174284 if ( signature . accentStrength === "grayscale" ) {
285+ const accentVariant = resolveAccentVariant ( signature ) ?? secondaryVariant ;
175286 return {
176287 id : `neutral-${ signature . brightness } ` ,
177288 hue : "neutral" ,
178289 brightness : signature . brightness ,
179290 strength : signature . accentStrength ,
180291 colors : {
181292 primary : pickScale ( "slate" , steps . primary ) ,
182- secondary : pickScale ( "zinc" , steps . secondary ) ,
183- tertiary : pickScale ( "slate" , steps . tertiary ) ,
184- glow : pickScale ( "slate" , steps . glow ) ,
293+ secondary : pickScale ( accentVariant . primary , "base" ) ,
294+ tertiary : pickScale ( accentVariant . secondary , "soft" ) ,
295+ glow : pickScale ( accentVariant . accent , "base" ) ,
185296 neutral : pickScale ( "slate" , steps . neutral ) ,
186297 } ,
187298 } ;
188299 }
189300
190- const secondaryScale = signature . accentStrength === "muted" ? neutralScale : variant . secondary ;
191- const accentScale = signature . accentStrength === "muted" ? variant . primary : variant . accent ;
301+ const accentIsMuted = signature . accentStrength !== "vibrant" ;
302+ const accentSecondaryStep = accentIsMuted ? "soft" : ( signature . brightness === "dark" ? "rich" : steps . secondary ) ;
303+ const accentTertiaryStep = accentIsMuted ? "soft" : ( signature . brightness === "dark" ? "base" : steps . tertiary ) ;
304+ const accentGlowStep = accentIsMuted ? "base" : ( signature . brightness === "dark" ? "base" : steps . glow ) ;
192305
193306 return {
194- id : `${ variant . primary } -${ variant . secondary } -${ signature . brightness } -${ signature . accentStrength } ` ,
307+ id : `${ variant . primary } -${ secondaryVariant . primary } -${ signature . brightness } -${ signature . accentStrength } ` ,
195308 hue : signature . dominantHue ,
196309 brightness : signature . brightness ,
197310 strength : signature . accentStrength ,
198311 colors : {
199312 primary : pickScale ( variant . primary , steps . primary ) ,
200- secondary : pickScale ( secondaryScale , steps . secondary ) ,
201- tertiary : pickScale ( accentScale , steps . tertiary ) ,
202- glow : pickScale ( accentScale , steps . glow ) ,
313+ secondary : pickScale ( secondaryVariant . primary , accentSecondaryStep ) ,
314+ tertiary : pickScale ( secondaryVariant . secondary , accentTertiaryStep ) ,
315+ glow : pickScale ( secondaryVariant . accent , accentGlowStep ) ,
203316 neutral : pickScale ( neutralScale , steps . neutral ) ,
204317 } ,
205318 } ;
0 commit comments