Skip to content

Commit 952ff2f

Browse files
Add dynamic color palette generation for shimmer effects
Enhance ShimmerText component to automatically generate harmonious color palettes from a single primary color using HSL color space conversions. This eliminates the need for manual palette configuration and ensures theme-aware shimmer animations. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
1 parent 2f89226 commit 952ff2f

File tree

2 files changed

+160
-17
lines changed

2 files changed

+160
-17
lines changed

cli/src/components/shimmer-text.tsx

Lines changed: 153 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,139 @@ export const DEFAULT_SHIMMER_COLORS = [
1414
'#ff7700',
1515
]
1616

17+
const clamp = (value: number, min: number, max: number): number =>
18+
Math.min(max, Math.max(min, value))
19+
20+
const normalizeHex = (hex: string): string | null => {
21+
const trimmed = hex.trim()
22+
const withoutHash = trimmed.startsWith('#') ? trimmed.slice(1) : trimmed
23+
if (withoutHash.length === 3) {
24+
return withoutHash
25+
.split('')
26+
.map((char) => char + char)
27+
.join('')
28+
}
29+
if (withoutHash.length === 6) {
30+
return withoutHash
31+
}
32+
return null
33+
}
34+
35+
const hexToRgb = (hex: string): { r: number; g: number; b: number } | null => {
36+
const normalized = normalizeHex(hex)
37+
if (!normalized) return null
38+
const r = parseInt(normalized.slice(0, 2), 16) / 255
39+
const g = parseInt(normalized.slice(2, 4), 16) / 255
40+
const b = parseInt(normalized.slice(4, 6), 16) / 255
41+
if (Number.isNaN(r) || Number.isNaN(g) || Number.isNaN(b)) return null
42+
return { r, g, b }
43+
}
44+
45+
const rgbToHsl = (
46+
r: number,
47+
g: number,
48+
b: number,
49+
): { h: number; s: number; l: number } => {
50+
const max = Math.max(r, g, b)
51+
const min = Math.min(r, g, b)
52+
const l = (max + min) / 2
53+
54+
let h = 0
55+
let s = 0
56+
57+
if (max !== min) {
58+
const d = max - min
59+
s = l > 0.5 ? d / (2 - max - min) : d / (max + min)
60+
switch (max) {
61+
case r:
62+
h = (g - b) / d + (g < b ? 6 : 0)
63+
break
64+
case g:
65+
h = (b - r) / d + 2
66+
break
67+
default:
68+
h = (r - g) / d + 4
69+
}
70+
h /= 6
71+
}
72+
73+
return { h, s, l }
74+
}
75+
76+
const hueToRgb = (p: number, q: number, t: number): number => {
77+
let temp = t
78+
if (temp < 0) temp += 1
79+
if (temp > 1) temp -= 1
80+
if (temp < 1 / 6) return p + (q - p) * 6 * temp
81+
if (temp < 1 / 2) return q
82+
if (temp < 2 / 3) return p + (q - p) * (2 / 3 - temp) * 6
83+
return p
84+
}
85+
86+
const hslToRgb = (
87+
h: number,
88+
s: number,
89+
l: number,
90+
): { r: number; g: number; b: number } => {
91+
if (s === 0) {
92+
return { r: l, g: l, b: l }
93+
}
94+
95+
const q = l < 0.5 ? l * (1 + s) : l + s - l * s
96+
const p = 2 * l - q
97+
98+
return {
99+
r: hueToRgb(p, q, h + 1 / 3),
100+
g: hueToRgb(p, q, h),
101+
b: hueToRgb(p, q, h - 1 / 3),
102+
}
103+
}
104+
105+
const rgbToHex = (r: number, g: number, b: number): string => {
106+
const toHex = (value: number) =>
107+
Math.round(clamp(value, 0, 1) * 255)
108+
.toString(16)
109+
.padStart(2, '0')
110+
return `#${toHex(r)}${toHex(g)}${toHex(b)}`
111+
}
112+
113+
const generatePaletteFromPrimary = (
114+
primaryColor: string,
115+
size: number,
116+
): string[] => {
117+
const baseRgb = hexToRgb(primaryColor)
118+
if (!baseRgb) {
119+
return DEFAULT_SHIMMER_COLORS
120+
}
121+
122+
const { h, s, l } = rgbToHsl(baseRgb.r, baseRgb.g, baseRgb.b)
123+
const palette: string[] = []
124+
const paletteSize = Math.max(6, Math.min(24, size))
125+
const lightnessRange = 0.22
126+
127+
for (let i = 0; i < paletteSize; i++) {
128+
const ratio = paletteSize === 1 ? 0.5 : i / (paletteSize - 1)
129+
const offset = (0.5 - ratio) * 2 * lightnessRange
130+
const adjustedLightness = clamp(l + offset, 0.08, 0.92)
131+
const saturationScale = 0.88 + 0.18 * Math.cos(ratio * Math.PI)
132+
const adjustedSaturation = clamp(s * saturationScale, 0.05, 1)
133+
const { r, g, b } = hslToRgb(h, adjustedSaturation, adjustedLightness)
134+
palette.push(rgbToHex(r, g, b))
135+
}
136+
137+
return palette
138+
}
139+
17140
export const ShimmerText = ({
18141
text,
19-
interval = 250,
20-
colors = DEFAULT_SHIMMER_COLORS,
142+
interval = 180,
143+
colors,
144+
primaryColor,
21145
}: {
22146
text: string
23147
interval?: number
24148
colors?: string[]
149+
primaryColor?: string
25150
}) => {
26151
const [pulse, setPulse] = useState<number>(0)
27152
const chars = text.split('')
@@ -36,19 +161,40 @@ export const ShimmerText = ({
36161
}, [interval, numChars])
37162

38163
const generateColors = (length: number, colorPalette: string[]): string[] => {
164+
if (length === 0) return []
165+
if (colorPalette.length === 0) {
166+
return Array.from({ length }, () => '#ffffff')
167+
}
168+
if (colorPalette.length === 1) {
169+
return Array.from({ length }, () => colorPalette[0])
170+
}
39171
const generatedColors: string[] = []
40172
for (let i = 0; i < length; i++) {
41-
const ratio = i / (length - 1)
42-
const colorIndex = Math.floor(ratio * (colorPalette.length - 1))
173+
const ratio = length === 1 ? 0 : i / (length - 1)
174+
const colorIndex = Math.min(
175+
colorPalette.length - 1,
176+
Math.floor(ratio * (colorPalette.length - 1)),
177+
)
43178
generatedColors.push(colorPalette[colorIndex])
44179
}
45180
return generatedColors
46181
}
47182

183+
const palette = useMemo(() => {
184+
if (colors && colors.length > 0) {
185+
return colors
186+
}
187+
if (primaryColor) {
188+
const paletteSize = Math.max(8, Math.min(20, Math.ceil(numChars * 1.5)))
189+
return generatePaletteFromPrimary(primaryColor, paletteSize)
190+
}
191+
return DEFAULT_SHIMMER_COLORS
192+
}, [colors, primaryColor, numChars])
193+
48194
const generateAttributes = (length: number): number[] => {
49195
const attributes: number[] = []
50196
for (let i = 0; i < length; i++) {
51-
const ratio = i / (length - 1)
197+
const ratio = length <= 1 ? 0 : i / (length - 1)
52198
if (ratio < 0.23) {
53199
attributes.push(TextAttributes.BOLD)
54200
} else if (ratio < 0.69) {
@@ -61,8 +207,8 @@ export const ShimmerText = ({
61207
}
62208

63209
const generatedColors = useMemo(
64-
() => generateColors(numChars, colors),
65-
[numChars, colors],
210+
() => generateColors(numChars, palette),
211+
[numChars, palette],
66212
)
67213
const attributes = useMemo(() => generateAttributes(numChars), [numChars])
68214

cli/src/components/status-indicator.tsx

Lines changed: 7 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,6 @@ import { getCodebuffClient } from '../utils/codebuff-client'
55

66
import type { ChatTheme } from '../utils/theme-system'
77

8-
const THINKING_SHIMMER_COLORS = [
9-
'#9ca3af',
10-
'#8b92a0',
11-
'#7a8090',
12-
'#6b7280',
13-
'#5a6070',
14-
'#4a5060',
15-
]
16-
178
export const StatusIndicator = ({
189
isProcessing,
1910
theme,
@@ -63,7 +54,13 @@ export const StatusIndicator = ({
6354
}
6455

6556
if (isProcessing) {
66-
return <ShimmerText text="thinking..." colors={THINKING_SHIMMER_COLORS} />
57+
return (
58+
<ShimmerText
59+
text="thinking..."
60+
interval={160}
61+
primaryColor={theme.statusSecondary}
62+
/>
63+
)
6764
}
6865

6966
return null

0 commit comments

Comments
 (0)