forked from
npmx.dev/npmx.dev
[READ-ONLY]
a fast, modern browser for the npm registry
1// Vue Data UI does not support CSS vars nor OKLCH for now
2
3/**
4 * Default neutral OKLCH color used as fallback when CSS variables are unavailable (e.g., during SSR).
5 * This matches the dark mode value of --fg-subtle defined in main.css.
6 */
7export const OKLCH_NEUTRAL_FALLBACK = 'oklch(0.633 0 0)'
8
9/** Converts a 0-255 RGB component to a 2-digit hex string */
10const componentToHex = (value: number): string =>
11 Math.round(Math.min(Math.max(0, value), 255))
12 .toString(16)
13 .padStart(2, '0')
14
15/** Converts a 0-1 linear value to a 2-digit hex string */
16const linearToHex = (value: number): string =>
17 Math.round(Math.min(Math.max(0, value), 1) * 255)
18 .toString(16)
19 .padStart(2, '0')
20
21/** Converts linear RGB to sRGB gamma-corrected value */
22const linearToSrgb = (value: number): number =>
23 value <= 0.0031308 ? 12.92 * value : 1.055 * Math.pow(value, 1 / 2.4) - 0.055
24
25/**
26 * Converts a hex color to RGB components
27 */
28function hexToRgb(hex: string): [number, number, number] | null {
29 const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex)
30 return result
31 ? [parseInt(result[1]!, 16), parseInt(result[2]!, 16), parseInt(result[3]!, 16)]
32 : null
33}
34
35/**
36 * Converts RGB components to hex color
37 */
38function rgbToHex(r: number, g: number, b: number): string {
39 return `#${componentToHex(r)}${componentToHex(g)}${componentToHex(b)}`
40}
41
42/**
43 * Lightens a hex color by mixing it with white.
44 * Used to create light tints of accent colors for better visibility in light mode.
45 * @param hex - The hex color to lighten (e.g., "#ff0000")
46 * @param factor - Lighten factor from 0 to 1 (0.5 = 50% lighter, mixed with white)
47 */
48export function lightenHex(hex: string, factor: number = 0.5): string {
49 const rgb = hexToRgb(hex)
50 if (!rgb) return hex
51
52 // Lighten by mixing with white (255, 255, 255)
53 const lightened = rgb.map(c => Math.round(c + (255 - c) * factor)) as [number, number, number]
54 return rgbToHex(...lightened)
55}
56
57export function oklchToHex(color: string | undefined | null): string | undefined | null {
58 if (color == null) return color
59
60 const match = color.trim().match(/^oklch\(\s*([0-9.]+)\s+([0-9.]+)\s+([0-9.]+)\s*\)$/i)
61
62 if (!match) {
63 throw new Error('Invalid OKLCH color format')
64 }
65
66 const lightness = Number(match[1])
67 const chroma = Number(match[2])
68 const hue = Number(match[3])
69
70 const hRad = (hue * Math.PI) / 180
71
72 const a = chroma * Math.cos(hRad)
73 const b = chroma * Math.sin(hRad)
74
75 let l_ = lightness + 0.3963377774 * a + 0.2158037573 * b
76 let m_ = lightness - 0.1055613458 * a - 0.0638541728 * b
77 let s_ = lightness - 0.0894841775 * a - 1.291485548 * b
78
79 l_ = l_ ** 3
80 m_ = m_ ** 3
81 s_ = s_ ** 3
82
83 const r = 4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_
84 const g = -1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_
85 const bRgb = -0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
86
87 return `#${linearToHex(linearToSrgb(r))}${linearToHex(linearToSrgb(g))}${linearToHex(linearToSrgb(bRgb))}`
88}
89
90/**
91 * Lighten an OKLCH color by a given factor.
92 *
93 * Works with strict TypeScript settings including `noUncheckedIndexedAccess`,
94 * where `match[n]` is typed as `string | undefined`.
95 *
96 * @param oklch - Color in the form "oklch(L C H)" or "oklch(L C H / A)"
97 * @param factor - Lightening force in range [0, 1]
98 * @returns Lightened OKLCH color string (0.5 = 50% lighter)
99 */
100export function lightenOklch(
101 oklch: string | null | undefined,
102 factor: number,
103): string | null | undefined {
104 if (oklch == null) {
105 return oklch
106 }
107
108 const input = oklch.trim()
109
110 const match = input.match(
111 /^oklch\(\s*([+-]?[\d.]+%?)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)(?:\s*\/\s*([+-]?[\d.]+%?))?\s*\)$/i,
112 )
113
114 if (!match) {
115 throw new Error('Invalid OKLCH color format')
116 }
117
118 const [, lightnessText, chromaText, hueText, alphaText] = match
119
120 if (lightnessText === undefined || chromaText === undefined || hueText === undefined) {
121 throw new Error('Invalid OKLCH color format')
122 }
123
124 let lightness = lightnessText.endsWith('%')
125 ? Number.parseFloat(lightnessText) / 100
126 : Number.parseFloat(lightnessText)
127 let chroma = Number.parseFloat(chromaText)
128 const hue = Number.parseFloat(hueText)
129 const alpha =
130 alphaText === undefined
131 ? null
132 : alphaText.endsWith('%')
133 ? Number.parseFloat(alphaText) / 100
134 : Number.parseFloat(alphaText)
135
136 const clampedFactor = Math.min(Math.max(factor, 0), 1)
137 lightness = lightness + (1 - lightness) * clampedFactor
138
139 // Reduce chroma slightly as lightness increases
140 chroma = chroma * (1 - clampedFactor * 0.3)
141
142 lightness = Math.min(Math.max(lightness, 0), 1)
143 chroma = Math.max(chroma, 0)
144
145 return alpha === null
146 ? `oklch(${lightness} ${chroma} ${hue})`
147 : `oklch(${lightness} ${chroma} ${hue} / ${alpha})`
148}
149
150/**
151 * Make an OKLCH color transparent by a given factor.
152 *
153 * @param oklch - Color in the form "oklch(L C H)" or "oklch(L C H / A)"
154 * @param factor - Transparency factor in range [0, 1]
155 * @returns OKLCH color string with adjusted alpha (0.5 = 50% transparency, 1 = fully transparent)
156 */
157export function transparentizeOklch(
158 oklch: string | null | undefined,
159 factor: number,
160 fallback = 'oklch(0 0 0 / 0)',
161): string {
162 if (oklch == null) return fallback
163
164 const input = oklch.trim()
165 if (!input) return fallback
166
167 const match = input.match(
168 /^oklch\(\s*([+-]?[\d.]+%?)\s+([+-]?[\d.]+)\s+([+-]?[\d.]+)(?:\s*\/\s*([+-]?[\d.]+%?))?\s*\)$/i,
169 )
170
171 if (!match) return fallback
172
173 const [, lightnessText, chromaText, hueText, alphaText] = match
174
175 if (lightnessText === undefined || chromaText === undefined || hueText === undefined) {
176 return fallback
177 }
178
179 const lightness = lightnessText.endsWith('%')
180 ? Math.min(Math.max(Number.parseFloat(lightnessText) / 100, 0), 1)
181 : Math.min(Math.max(Number.parseFloat(lightnessText), 0), 1)
182
183 const chroma = Math.max(0, Number.parseFloat(chromaText))
184 const hue = Number.parseFloat(hueText)
185
186 const originalAlpha =
187 alphaText === undefined
188 ? 1
189 : alphaText.endsWith('%')
190 ? Math.min(Math.max(Number.parseFloat(alphaText) / 100, 0), 1)
191 : Math.min(Math.max(Number.parseFloat(alphaText), 0), 1)
192
193 const clampedFactor = Math.min(Math.max(factor, 0), 1)
194 const alpha = Math.max(0, originalAlpha * (1 - clampedFactor))
195
196 return `oklch(${lightness} ${chroma} ${hue} / ${alpha})`
197}