@halogen-ui/tokens 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +102 -0
- package/README.md +43 -0
- package/dist/CONTRAST-REPORT.md +511 -0
- package/dist/figma.json +1932 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/fonts/LICENSE-Geist-OFL-1.1.txt +92 -0
- package/dist/fonts.css +24 -0
- package/dist/halogen.tokens.json +2061 -0
- package/dist/theme.css +859 -0
- package/dist/token-review.html +902 -0
- package/dist/tokens.css +782 -0
- package/dist/tokens.d.ts +535 -0
- package/dist/tokens.js +532 -0
- package/dist/tokens.ts +540 -0
- package/dist/tw-classgroups.json +351 -0
- package/fonts/Geist-Variable.woff2 +0 -0
- package/fonts/GeistMono-Variable.woff2 +0 -0
- package/fonts/LICENSE-Geist-OFL-1.1.txt +92 -0
- package/package.json +66 -0
- package/src/color/oklch.ts +306 -0
- package/src/contrast-pairs.ts +473 -0
- package/src/namespaces.ts +59 -0
- package/src/primitives/color.ts +404 -0
- package/src/primitives/elevation.ts +38 -0
- package/src/primitives/motion.ts +129 -0
- package/src/primitives/scale.ts +163 -0
- package/src/primitives/typography.ts +184 -0
- package/src/semantic/index.ts +415 -0
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — color engine.
|
|
3
|
+
*
|
|
4
|
+
* Self-contained OKLCH <-> sRGB conversion, sRGB gamut mapping, and WCAG 2.x
|
|
5
|
+
* contrast. No runtime dependency: this file is the single source of color
|
|
6
|
+
* truth for the token build, and it is unit-tested against known values.
|
|
7
|
+
*
|
|
8
|
+
* References:
|
|
9
|
+
* Oklab — Björn Ottosson, https://bottosson.github.io/posts/oklab/
|
|
10
|
+
* sRGB — IEC 61966-2-1
|
|
11
|
+
* Contrast — WCAG 2.2 SC 1.4.3 / 1.4.11 relative-luminance formula
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export interface Oklch {
|
|
15
|
+
/** Perceptual lightness, 0..1 */
|
|
16
|
+
l: number;
|
|
17
|
+
/** Chroma, 0..~0.37 in sRGB */
|
|
18
|
+
c: number;
|
|
19
|
+
/** Hue angle in degrees, 0..360 */
|
|
20
|
+
h: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface Rgb {
|
|
24
|
+
/** 0..1 */ r: number;
|
|
25
|
+
/** 0..1 */ g: number;
|
|
26
|
+
/** 0..1 */ b: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const clamp01 = (x: number): number => (x < 0 ? 0 : x > 1 ? 1 : x);
|
|
30
|
+
|
|
31
|
+
/* -------------------------------------------------------------------------- */
|
|
32
|
+
/* sRGB transfer function */
|
|
33
|
+
/* -------------------------------------------------------------------------- */
|
|
34
|
+
|
|
35
|
+
/** sRGB electro-optical transfer: gamma-encoded channel -> linear-light. */
|
|
36
|
+
export function srgbToLinear(x: number): number {
|
|
37
|
+
return x <= 0.04045 ? x / 12.92 : Math.pow((x + 0.055) / 1.055, 2.4);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** sRGB opto-electronic transfer: linear-light channel -> gamma-encoded. */
|
|
41
|
+
export function linearToSrgb(x: number): number {
|
|
42
|
+
return x <= 0.0031308 ? 12.92 * x : 1.055 * Math.pow(x, 1 / 2.4) - 0.055;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* -------------------------------------------------------------------------- */
|
|
46
|
+
/* Oklab <-> linear sRGB */
|
|
47
|
+
/* -------------------------------------------------------------------------- */
|
|
48
|
+
|
|
49
|
+
function linearRgbToOklab(r: number, g: number, b: number): [number, number, number] {
|
|
50
|
+
const l = 0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b;
|
|
51
|
+
const m = 0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b;
|
|
52
|
+
const s = 0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b;
|
|
53
|
+
|
|
54
|
+
const l_ = Math.cbrt(l);
|
|
55
|
+
const m_ = Math.cbrt(m);
|
|
56
|
+
const s_ = Math.cbrt(s);
|
|
57
|
+
|
|
58
|
+
return [
|
|
59
|
+
0.2104542553 * l_ + 0.793617785 * m_ - 0.0040720468 * s_,
|
|
60
|
+
1.9779984951 * l_ - 2.428592205 * m_ + 0.4505937099 * s_,
|
|
61
|
+
0.0259040371 * l_ + 0.7827717662 * m_ - 0.808675766 * s_,
|
|
62
|
+
];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function oklabToLinearRgb(L: number, a: number, b: number): [number, number, number] {
|
|
66
|
+
const l_ = L + 0.3963377774 * a + 0.2158037573 * b;
|
|
67
|
+
const m_ = L - 0.1055613458 * a - 0.0638541728 * b;
|
|
68
|
+
const s_ = L - 0.0894841775 * a - 1.291485548 * b;
|
|
69
|
+
|
|
70
|
+
const l = l_ * l_ * l_;
|
|
71
|
+
const m = m_ * m_ * m_;
|
|
72
|
+
const s = s_ * s_ * s_;
|
|
73
|
+
|
|
74
|
+
return [
|
|
75
|
+
4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
|
|
76
|
+
-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
|
|
77
|
+
-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s,
|
|
78
|
+
];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* -------------------------------------------------------------------------- */
|
|
82
|
+
/* OKLCH <-> sRGB */
|
|
83
|
+
/* -------------------------------------------------------------------------- */
|
|
84
|
+
|
|
85
|
+
const DEG = 180 / Math.PI;
|
|
86
|
+
const RAD = Math.PI / 180;
|
|
87
|
+
|
|
88
|
+
/** OKLCH -> unclamped linear-light sRGB. May fall outside 0..1 (out of gamut). */
|
|
89
|
+
function oklchToLinearRgb({ l, c, h }: Oklch): [number, number, number] {
|
|
90
|
+
return oklabToLinearRgb(l, c * Math.cos(h * RAD), c * Math.sin(h * RAD));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** True when the OKLCH triple is representable in sRGB (within `eps`). */
|
|
94
|
+
export function inGamut(color: Oklch, eps = 1e-6): boolean {
|
|
95
|
+
const [r, g, b] = oklchToLinearRgb(color);
|
|
96
|
+
const lo = -eps;
|
|
97
|
+
const hi = 1 + eps;
|
|
98
|
+
return r >= lo && r <= hi && g >= lo && g <= hi && b >= lo && b <= hi;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Largest chroma representable in sRGB at this lightness and hue.
|
|
103
|
+
* Bisection to 1e-4 — well below a perceptible step.
|
|
104
|
+
*/
|
|
105
|
+
export function maxChroma(l: number, h: number): number {
|
|
106
|
+
if (l <= 0 || l >= 1) return 0;
|
|
107
|
+
let lo = 0;
|
|
108
|
+
let hi = 0.5;
|
|
109
|
+
for (let i = 0; i < 40; i++) {
|
|
110
|
+
const mid = (lo + hi) / 2;
|
|
111
|
+
if (inGamut({ l, c: mid, h })) lo = mid;
|
|
112
|
+
else hi = mid;
|
|
113
|
+
}
|
|
114
|
+
return lo;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Map an out-of-gamut OKLCH color into sRGB by reducing chroma while holding
|
|
119
|
+
* lightness and hue — the CSS Color 4 approach. In-gamut colors pass through.
|
|
120
|
+
*/
|
|
121
|
+
export function toGamut(color: Oklch): Oklch {
|
|
122
|
+
if (inGamut(color)) return color;
|
|
123
|
+
return { ...color, c: Math.min(color.c, maxChroma(color.l, color.h)) };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** OKLCH -> sRGB in 0..1. Gamut-maps first, then clamps residual float error. */
|
|
127
|
+
export function oklchToRgb(color: Oklch): Rgb {
|
|
128
|
+
const [r, g, b] = oklchToLinearRgb(toGamut(color));
|
|
129
|
+
return {
|
|
130
|
+
r: clamp01(linearToSrgb(r)),
|
|
131
|
+
g: clamp01(linearToSrgb(g)),
|
|
132
|
+
b: clamp01(linearToSrgb(b)),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** sRGB in 0..1 -> OKLCH. Hue is normalized to 0..360; achromatic hue is 0. */
|
|
137
|
+
export function rgbToOklch({ r, g, b }: Rgb): Oklch {
|
|
138
|
+
const [L, a, bb] = linearRgbToOklab(srgbToLinear(r), srgbToLinear(g), srgbToLinear(b));
|
|
139
|
+
const c = Math.sqrt(a * a + bb * bb);
|
|
140
|
+
let h = c < 1e-7 ? 0 : Math.atan2(bb, a) * DEG;
|
|
141
|
+
if (h < 0) h += 360;
|
|
142
|
+
return { l: L, c, h };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* -------------------------------------------------------------------------- */
|
|
146
|
+
/* Hex */
|
|
147
|
+
/* -------------------------------------------------------------------------- */
|
|
148
|
+
|
|
149
|
+
const hex2 = (x: number): string =>
|
|
150
|
+
Math.round(clamp01(x) * 255)
|
|
151
|
+
.toString(16)
|
|
152
|
+
.padStart(2, '0');
|
|
153
|
+
|
|
154
|
+
export function rgbToHex({ r, g, b }: Rgb): string {
|
|
155
|
+
return `#${hex2(r)}${hex2(g)}${hex2(b)}`.toUpperCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function hexToRgb(hex: string): Rgb {
|
|
159
|
+
const s = hex.replace('#', '').trim();
|
|
160
|
+
const full =
|
|
161
|
+
s.length === 3
|
|
162
|
+
? s
|
|
163
|
+
.split('')
|
|
164
|
+
.map((ch) => ch + ch)
|
|
165
|
+
.join('')
|
|
166
|
+
: s;
|
|
167
|
+
if (!/^[0-9a-fA-F]{6}$/.test(full)) {
|
|
168
|
+
throw new Error(`Not a 6-digit hex color: "${hex}"`);
|
|
169
|
+
}
|
|
170
|
+
return {
|
|
171
|
+
r: parseInt(full.slice(0, 2), 16) / 255,
|
|
172
|
+
g: parseInt(full.slice(2, 4), 16) / 255,
|
|
173
|
+
b: parseInt(full.slice(4, 6), 16) / 255,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export const oklchToHex = (c: Oklch): string => rgbToHex(oklchToRgb(c));
|
|
178
|
+
export const hexToOklch = (h: string): Oklch => rgbToOklch(hexToRgb(h));
|
|
179
|
+
|
|
180
|
+
/** CSS `oklch()` string, rounded for readable output. */
|
|
181
|
+
export function formatOklch({ l, c, h }: Oklch): string {
|
|
182
|
+
const L = (l * 100).toFixed(2).replace(/\.?0+$/, '');
|
|
183
|
+
const C = c.toFixed(4).replace(/\.?0+$/, '');
|
|
184
|
+
const H = h.toFixed(2).replace(/\.?0+$/, '');
|
|
185
|
+
return `oklch(${L}% ${C || '0'} ${H || '0'})`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/* -------------------------------------------------------------------------- */
|
|
189
|
+
/* WCAG contrast */
|
|
190
|
+
/* -------------------------------------------------------------------------- */
|
|
191
|
+
|
|
192
|
+
/** WCAG 2.x relative luminance of an sRGB color. */
|
|
193
|
+
export function relativeLuminance({ r, g, b }: Rgb): number {
|
|
194
|
+
return (
|
|
195
|
+
0.2126 * srgbToLinear(r) + 0.7152 * srgbToLinear(g) + 0.0722 * srgbToLinear(b)
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** WCAG 2.x contrast ratio, 1..21. Order-independent. */
|
|
200
|
+
export function contrastRatio(a: Rgb, b: Rgb): number {
|
|
201
|
+
const la = relativeLuminance(a);
|
|
202
|
+
const lb = relativeLuminance(b);
|
|
203
|
+
const lighter = Math.max(la, lb);
|
|
204
|
+
const darker = Math.min(la, lb);
|
|
205
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export const contrastHex = (a: string, b: string): number =>
|
|
209
|
+
contrastRatio(hexToRgb(a), hexToRgb(b));
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Round an sRGB triple to the 8-bit grid.
|
|
213
|
+
*
|
|
214
|
+
* Tokens ship as 6-digit hex, so every contrast figure the system publishes or
|
|
215
|
+
* asserts must be measured on the *quantised* color. Measuring at full float
|
|
216
|
+
* precision silently overstates contrast by up to ~0.03:1 — enough to let a
|
|
217
|
+
* value that reads 4.50 in the solver ship as 4.48 in the browser.
|
|
218
|
+
*/
|
|
219
|
+
export const quantize = (c: Rgb): Rgb => hexToRgb(rgbToHex(c));
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Composite a translucent foreground over an opaque background (simple alpha
|
|
223
|
+
* compositing in gamma-encoded sRGB, which is what browsers do for `rgba()`).
|
|
224
|
+
* Needed because several Halogen tokens — scrims, hairline borders — are
|
|
225
|
+
* alpha values whose contrast must be measured against the *composited*
|
|
226
|
+
* result, not the nominal color.
|
|
227
|
+
*/
|
|
228
|
+
export function compositeOver(fg: Rgb, alpha: number, bg: Rgb): Rgb {
|
|
229
|
+
return {
|
|
230
|
+
r: fg.r * alpha + bg.r * (1 - alpha),
|
|
231
|
+
g: fg.g * alpha + bg.g * (1 - alpha),
|
|
232
|
+
b: fg.b * alpha + bg.b * (1 - alpha),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/* -------------------------------------------------------------------------- */
|
|
237
|
+
/* Contrast solving */
|
|
238
|
+
/* -------------------------------------------------------------------------- */
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Find the *lightest* OKLCH lightness at the given hue that still reaches
|
|
242
|
+
* `target` contrast against `bg` — i.e. the most vivid accessible step.
|
|
243
|
+
* Chroma is held at `chromaFraction` of the in-gamut maximum for each candidate
|
|
244
|
+
* lightness, so the result stays as saturated as sRGB allows.
|
|
245
|
+
*
|
|
246
|
+
* Returns null when no lightness in [lo, hi] reaches the target, which is a
|
|
247
|
+
* real answer — some hues simply cannot carry text on some backgrounds.
|
|
248
|
+
*/
|
|
249
|
+
export function solveLightnessForContrast(opts: {
|
|
250
|
+
hue: number;
|
|
251
|
+
bg: Rgb;
|
|
252
|
+
target: number;
|
|
253
|
+
chromaFraction: number;
|
|
254
|
+
/** Search bounds. Defaults span the usable range. */
|
|
255
|
+
lo?: number;
|
|
256
|
+
hi?: number;
|
|
257
|
+
/** 'darker' walks down from hi (light bg); 'lighter' walks up from lo. */
|
|
258
|
+
direction: 'darker' | 'lighter';
|
|
259
|
+
}): Oklch | null {
|
|
260
|
+
const { hue, bg, target, chromaFraction, direction } = opts;
|
|
261
|
+
const lo = opts.lo ?? 0.05;
|
|
262
|
+
const hi = opts.hi ?? 0.99;
|
|
263
|
+
|
|
264
|
+
const at = (l: number): Oklch => ({ l, c: maxChroma(l, hue) * chromaFraction, h: hue });
|
|
265
|
+
// Measured on the quantised color: the solver must optimize the value that
|
|
266
|
+
// actually ships as hex, not an idealised float that rounds the wrong way.
|
|
267
|
+
const ratioAt = (l: number): number => contrastRatio(quantize(oklchToRgb(at(l))), bg);
|
|
268
|
+
|
|
269
|
+
// Along the sRGB gamut boundary chroma varies with lightness, so contrast is
|
|
270
|
+
// not perfectly monotonic in L. Bisection converges on a passing value, and
|
|
271
|
+
// the invariant below re-checks it rather than trusting the search.
|
|
272
|
+
let a = direction === 'darker' ? lo : hi;
|
|
273
|
+
let b = direction === 'darker' ? hi : lo;
|
|
274
|
+
|
|
275
|
+
if (ratioAt(a) < target) return null; // even the extreme fails
|
|
276
|
+
|
|
277
|
+
for (let i = 0; i < 60; i++) {
|
|
278
|
+
const mid = (a + b) / 2;
|
|
279
|
+
if (ratioAt(mid) >= target) a = mid;
|
|
280
|
+
else b = mid;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// Guard the non-monotonic case: never hand back a value that misses the
|
|
284
|
+
// target. Step back toward the passing end until it genuinely clears.
|
|
285
|
+
let result = a;
|
|
286
|
+
for (let i = 0; i < 64 && ratioAt(result) < target; i++) {
|
|
287
|
+
result += direction === 'darker' ? -0.002 : 0.002;
|
|
288
|
+
if (result <= 0 || result >= 1) return null;
|
|
289
|
+
}
|
|
290
|
+
return ratioAt(result) >= target ? at(result) : null;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/** Perceptual distance in Oklab — a defensible "are these two colors confusable" metric. */
|
|
294
|
+
export function deltaEOk(a: Oklch, b: Oklch): number {
|
|
295
|
+
const ax = a.c * Math.cos(a.h * RAD);
|
|
296
|
+
const ay = a.c * Math.sin(a.h * RAD);
|
|
297
|
+
const bx = b.c * Math.cos(b.h * RAD);
|
|
298
|
+
const by = b.c * Math.sin(b.h * RAD);
|
|
299
|
+
return Math.hypot(a.l - b.l, ax - bx, ay - by);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** Smallest signed angular difference between two hues, in degrees (0..180). */
|
|
303
|
+
export function hueDistance(h1: number, h2: number): number {
|
|
304
|
+
const d = Math.abs(((h1 - h2) % 360) + 360) % 360;
|
|
305
|
+
return d > 180 ? 360 - d : d;
|
|
306
|
+
}
|