@cyclone-ui/theme-builder 0.0.0-stage → 0.0.5
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/CHANGELOG.md +26 -0
- package/LICENSE +187 -0
- package/README.md +305 -2
- package/dist/generate.cjs +1 -0
- package/dist/generate.d.cts +5 -0
- package/dist/generate.d.cts.map +1 -0
- package/dist/generate.d.mts +6 -0
- package/dist/generate.d.mts.map +1 -0
- package/dist/generate.mjs +2 -0
- package/dist/generate.mjs.map +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.cts +3 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +1 -0
- package/dist/preprocessor.cjs +1 -0
- package/dist/preprocessor.d.cts +26 -0
- package/dist/preprocessor.d.cts.map +1 -0
- package/dist/preprocessor.d.mts +26 -0
- package/dist/preprocessor.d.mts.map +1 -0
- package/dist/preprocessor.mjs +2 -0
- package/dist/preprocessor.mjs.map +1 -0
- package/package.json +70 -3
- package/powerlines.config.ts +28 -0
- package/project.json +8 -0
- package/src/generate.ts +66 -0
- package/src/index.ts +22 -0
- package/src/preprocessor.ts +1507 -0
- package/test/preprocessor.spec.ts +497 -0
- package/test/shiki-theme-mapping.spec.ts +157 -0
- package/test/storybook-theme-mapping.spec.ts +118 -0
- package/tsconfig.json +13 -0
- package/tsconfig.spec.json +16 -0
- package/vitest.config.mts +24 -0
|
@@ -0,0 +1,1507 @@
|
|
|
1
|
+
/* -------------------------------------------------------------------
|
|
2
|
+
|
|
3
|
+
🗲 Storm Software - Cyclone UI
|
|
4
|
+
|
|
5
|
+
This code was released as part of the Cyclone UI project. Cyclone UI
|
|
6
|
+
is maintained by Storm Software under the Apache-2.0 license, and is
|
|
7
|
+
free for commercial and private use. For more information, please visit
|
|
8
|
+
our licensing page at https://stormsoftware.com/licenses/projects/cyclone-ui.
|
|
9
|
+
|
|
10
|
+
Website: https://stormsoftware.com
|
|
11
|
+
Repository: https://github.com/storm-software/cyclone-ui
|
|
12
|
+
Documentation: https://docs.stormsoftware.com/projects/cyclone-ui
|
|
13
|
+
Contact: https://stormsoftware.com/contact
|
|
14
|
+
|
|
15
|
+
SPDX-License-Identifier: Apache-2.0
|
|
16
|
+
|
|
17
|
+
------------------------------------------------------------------- */
|
|
18
|
+
|
|
19
|
+
import { definePlugin } from "@razorwind/core/plugin";
|
|
20
|
+
import type { PreprocessedTokens } from "style-dictionary/types";
|
|
21
|
+
|
|
22
|
+
const HEX_RE = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
|
|
23
|
+
const OKLCH_RE =
|
|
24
|
+
/^oklch\(\s*([0-9.]+%?|none)\s+([0-9.]+|none)\s+([0-9.]+|none)(?:\s*\/\s*([0-9.%]+))?\s*\)$/i;
|
|
25
|
+
|
|
26
|
+
const RESERVED_KEYS = new Set([
|
|
27
|
+
"$schema",
|
|
28
|
+
"$type",
|
|
29
|
+
"$description",
|
|
30
|
+
"$extensions",
|
|
31
|
+
"$extends",
|
|
32
|
+
"$deprecated",
|
|
33
|
+
"$root",
|
|
34
|
+
"$value",
|
|
35
|
+
"$ref",
|
|
36
|
+
"theme",
|
|
37
|
+
"palette",
|
|
38
|
+
"primitive"
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function isPlainObject(value: unknown): value is Record<string, unknown> {
|
|
42
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isTokenNode(value: unknown): value is Record<string, unknown> {
|
|
46
|
+
return isPlainObject(value) && ("$value" in value || "$ref" in value);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isAlias(value: string): boolean {
|
|
50
|
+
const trimmed = value.trim();
|
|
51
|
+
|
|
52
|
+
return trimmed.startsWith("{") || trimmed.startsWith("var(");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function clamp01(value: number): number {
|
|
56
|
+
return Math.min(1, Math.max(0, value));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function toSrgb(channel: number): number {
|
|
60
|
+
const clipped = clamp01(channel);
|
|
61
|
+
|
|
62
|
+
return clipped <= 0.003_130_8
|
|
63
|
+
? 12.92 * clipped
|
|
64
|
+
: 1.055 * clipped ** (1 / 2.4) - 0.055;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function fromSrgb(channel: number): number {
|
|
68
|
+
const clipped = clamp01(channel);
|
|
69
|
+
|
|
70
|
+
return clipped <= 0.040_45
|
|
71
|
+
? clipped / 12.92
|
|
72
|
+
: ((clipped + 0.055) / 1.055) ** 2.4;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function hexChannel(channel: number): string {
|
|
76
|
+
return Math.round(toSrgb(channel) * 255)
|
|
77
|
+
.toString(16)
|
|
78
|
+
.padStart(2, "0");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function srgbHexChannel(channel: number): string {
|
|
82
|
+
return Math.round(clamp01(channel) * 255)
|
|
83
|
+
.toString(16)
|
|
84
|
+
.padStart(2, "0");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function alphaHex(alpha: number): string {
|
|
88
|
+
return Math.round(clamp01(alpha) * 255)
|
|
89
|
+
.toString(16)
|
|
90
|
+
.padStart(2, "0");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function expandHex(value: string): string {
|
|
94
|
+
const hex = value.startsWith("#") ? value.slice(1) : value;
|
|
95
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
96
|
+
return `#${[...hex].map(digit => digit + digit).join("")}`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return `#${hex}`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Normalize a hex string or sRGB DTCG color object to `#rrggbb` / `#rrggbbaa`.
|
|
104
|
+
*/
|
|
105
|
+
function colorValueToHex(value: unknown): string | undefined {
|
|
106
|
+
if (typeof value === "string") {
|
|
107
|
+
const trimmed = value.trim();
|
|
108
|
+
if (!HEX_RE.test(trimmed)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const expanded = expandHex(trimmed).toLowerCase();
|
|
113
|
+
if (expanded.length === 9 && expanded.endsWith("ff")) {
|
|
114
|
+
return expanded.slice(0, 7);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return expanded;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!isPlainObject(value)) {
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (typeof value.hex === "string" && HEX_RE.test(value.hex)) {
|
|
125
|
+
const hex = expandHex(value.hex).toLowerCase().slice(0, 7);
|
|
126
|
+
const alpha = parseAlpha(value.alpha);
|
|
127
|
+
if (alpha !== undefined && alpha < 1) {
|
|
128
|
+
return `${hex}${alphaHex(alpha)}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return hex;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (
|
|
135
|
+
value.colorSpace === "srgb" &&
|
|
136
|
+
Array.isArray(value.components) &&
|
|
137
|
+
value.components.length >= 3
|
|
138
|
+
) {
|
|
139
|
+
const red = parseComponent(value.components[0]);
|
|
140
|
+
const green = parseComponent(value.components[1]);
|
|
141
|
+
const blue = parseComponent(value.components[2]);
|
|
142
|
+
if (red === undefined || green === undefined || blue === undefined) {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const hex = `#${srgbHexChannel(red)}${srgbHexChannel(green)}${srgbHexChannel(blue)}`;
|
|
147
|
+
const alpha = parseAlpha(value.alpha);
|
|
148
|
+
if (alpha !== undefined && alpha < 1) {
|
|
149
|
+
return `${hex}${alphaHex(alpha)}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return hex;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Convert OKLCH (L C H) to unclipped linear sRGB channels. */
|
|
159
|
+
function oklchToLinearSrgb(
|
|
160
|
+
lightness: number,
|
|
161
|
+
chroma: number,
|
|
162
|
+
hue: number
|
|
163
|
+
): { red: number; green: number; blue: number } {
|
|
164
|
+
const hueRad = (hue * Math.PI) / 180;
|
|
165
|
+
const a = chroma * Math.cos(hueRad);
|
|
166
|
+
const b = chroma * Math.sin(hueRad);
|
|
167
|
+
|
|
168
|
+
const l_ = lightness + 0.396_337_777_4 * a + 0.215_803_757_3 * b;
|
|
169
|
+
const m_ = lightness - 0.105_561_345_8 * a - 0.063_854_172_8 * b;
|
|
170
|
+
const s_ = lightness - 0.089_484_177_5 * a - 1.291_485_548 * b;
|
|
171
|
+
|
|
172
|
+
const l3 = l_ ** 3;
|
|
173
|
+
const m3 = m_ ** 3;
|
|
174
|
+
const s3 = s_ ** 3;
|
|
175
|
+
|
|
176
|
+
return {
|
|
177
|
+
red: 4.076_741_662_1 * l3 - 3.307_711_591_3 * m3 + 0.230_969_929_2 * s3,
|
|
178
|
+
green: -1.268_438_004_6 * l3 + 2.609_757_401_1 * m3 - 0.341_319_396_5 * s3,
|
|
179
|
+
blue: -0.004_196_086_3 * l3 - 0.703_418_614_7 * m3 + 1.707_614_701 * s3
|
|
180
|
+
};
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function oklchToHex(
|
|
184
|
+
lightness: number,
|
|
185
|
+
chroma: number,
|
|
186
|
+
hue: number,
|
|
187
|
+
alpha?: number
|
|
188
|
+
): string {
|
|
189
|
+
const { red, green, blue } = oklchToLinearSrgb(lightness, chroma, hue);
|
|
190
|
+
|
|
191
|
+
const hex = `#${hexChannel(red)}${hexChannel(green)}${hexChannel(blue)}`;
|
|
192
|
+
if (alpha === undefined || alpha >= 1) {
|
|
193
|
+
return hex;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return `${hex}${alphaHex(alpha)}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function parseComponent(value: unknown): number | undefined {
|
|
200
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
201
|
+
return value;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (typeof value !== "string" || value === "none") {
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const parsed = Number.parseFloat(value);
|
|
209
|
+
|
|
210
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function parseAlpha(value: unknown): number | undefined {
|
|
214
|
+
if (typeof value === "number" && Number.isFinite(value)) {
|
|
215
|
+
return value > 1 ? value / 100 : value;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (typeof value !== "string") {
|
|
219
|
+
return undefined;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const parsed = Number.parseFloat(value);
|
|
223
|
+
if (!Number.isFinite(parsed)) {
|
|
224
|
+
return undefined;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return value.includes("%")
|
|
228
|
+
? parsed / 100
|
|
229
|
+
: parsed > 1
|
|
230
|
+
? parsed / 100
|
|
231
|
+
: parsed;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function oklchStringToHex(value: string): string | undefined {
|
|
235
|
+
const match = OKLCH_RE.exec(value.trim());
|
|
236
|
+
if (!match) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
let lightness = parseComponent(match[1]);
|
|
241
|
+
const chroma = parseComponent(match[2]);
|
|
242
|
+
const hue = parseComponent(match[3]);
|
|
243
|
+
if (lightness === undefined || chroma === undefined || hue === undefined) {
|
|
244
|
+
return undefined;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (match[1]?.includes("%")) {
|
|
248
|
+
lightness /= 100;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return oklchToHex(lightness, chroma, hue, parseAlpha(match[4]));
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function oklchObjectToHex(value: Record<string, unknown>): string | undefined {
|
|
255
|
+
if (!Array.isArray(value.components) || value.components.length < 3) {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
let lightness = parseComponent(value.components[0]);
|
|
260
|
+
const chroma = parseComponent(value.components[1]);
|
|
261
|
+
const hue = parseComponent(value.components[2]);
|
|
262
|
+
if (lightness === undefined || chroma === undefined || hue === undefined) {
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
if (lightness > 1) {
|
|
267
|
+
lightness /= 100;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return oklchToHex(lightness, chroma, hue, parseAlpha(value.alpha));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function convertColor(value: unknown): string | undefined {
|
|
274
|
+
if (typeof value === "string") {
|
|
275
|
+
if (isAlias(value)) {
|
|
276
|
+
return undefined;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
return oklchStringToHex(value) ?? colorValueToHex(value) ?? undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (!isPlainObject(value)) {
|
|
283
|
+
return undefined;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (
|
|
287
|
+
typeof value.colorSpace === "string" &&
|
|
288
|
+
value.colorSpace.toLowerCase() === "oklch"
|
|
289
|
+
) {
|
|
290
|
+
return oklchObjectToHex(value);
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
return colorValueToHex(value) ?? undefined;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function convertShadow(value: unknown): unknown {
|
|
297
|
+
if (Array.isArray(value)) {
|
|
298
|
+
return value.map(layer => convertShadow(layer));
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (!isPlainObject(value) || !("color" in value)) {
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const color = convertColor(value.color);
|
|
306
|
+
if (!color) {
|
|
307
|
+
return value;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...value, color };
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function convertValue(value: unknown, type?: string): unknown {
|
|
314
|
+
if (type === "shadow") {
|
|
315
|
+
return convertShadow(value);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
if (type === "border" && isPlainObject(value) && "color" in value) {
|
|
319
|
+
const color = convertColor(value.color);
|
|
320
|
+
|
|
321
|
+
return color ? { ...value, color } : value;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (type === "color" || type === undefined) {
|
|
325
|
+
return convertColor(value) ?? value;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function resolveType(
|
|
332
|
+
token: Record<string, unknown>,
|
|
333
|
+
inheritedType?: string
|
|
334
|
+
): string | undefined {
|
|
335
|
+
if (typeof token.$type === "string") {
|
|
336
|
+
return token.$type;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (typeof token.type === "string") {
|
|
340
|
+
return token.type;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
return inheritedType;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function mapTokenNode(
|
|
347
|
+
token: Record<string, unknown>,
|
|
348
|
+
inheritedType?: string
|
|
349
|
+
): Record<string, unknown> {
|
|
350
|
+
if ("$ref" in token && !("$value" in token)) {
|
|
351
|
+
return { ...token };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const type = resolveType(token, inheritedType);
|
|
355
|
+
if (!("$value" in token)) {
|
|
356
|
+
return { ...token };
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
...token,
|
|
361
|
+
$value: convertValue(token.$value, type)
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function walk(node: any, inheritedType?: string): unknown {
|
|
366
|
+
if (Array.isArray(node)) {
|
|
367
|
+
return node.map(item => walk(item, inheritedType));
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
if (!isPlainObject(node)) {
|
|
371
|
+
return node;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
if (isTokenNode(node)) {
|
|
375
|
+
return mapTokenNode(node, inheritedType);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
const groupType =
|
|
379
|
+
typeof (node as any).$type === "string"
|
|
380
|
+
? (node as any).$type
|
|
381
|
+
: inheritedType;
|
|
382
|
+
|
|
383
|
+
const result: Record<string, unknown> = {};
|
|
384
|
+
for (const [key, value] of Object.entries(node)) {
|
|
385
|
+
if (RESERVED_KEYS.has(key) || key.startsWith("$")) {
|
|
386
|
+
result[key] = value;
|
|
387
|
+
continue;
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
result[key] = walk(value, groupType);
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
interface ColorStateVariant {
|
|
397
|
+
name: string;
|
|
398
|
+
opacity?: number;
|
|
399
|
+
/** Multiplier applied to OKLCH chroma for non-greyscale colors. */
|
|
400
|
+
saturation?: number;
|
|
401
|
+
/**
|
|
402
|
+
* Multiplier applied to OKLCH lightness (e.g. 1.1 = 10%). State direction
|
|
403
|
+
* is determined by the source color and its parent theme.
|
|
404
|
+
*/
|
|
405
|
+
brightness?: number;
|
|
406
|
+
/** Resolve state colors from the parent theme's contrast primitive. */
|
|
407
|
+
useContrastPrimitive?: boolean;
|
|
408
|
+
/** Composite the color at `opacity` over this opaque color. */
|
|
409
|
+
fadeOver?: string;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
type ParentTheme = "dark" | "light";
|
|
413
|
+
|
|
414
|
+
const CONTRAST_PRIMITIVES: Record<ParentTheme, string> = {
|
|
415
|
+
dark: "#ffffff",
|
|
416
|
+
light: "#0c0c0d"
|
|
417
|
+
};
|
|
418
|
+
|
|
419
|
+
/** Active colors already near the contrast primitive jump to these instead. */
|
|
420
|
+
const PURE_CONTRAST_COLORS: Record<ParentTheme, string> = {
|
|
421
|
+
dark: "#ffffff",
|
|
422
|
+
light: "#000000"
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
function parentThemeFromDescription(
|
|
426
|
+
description: unknown
|
|
427
|
+
): ParentTheme | undefined {
|
|
428
|
+
if (typeof description !== "string") {
|
|
429
|
+
return undefined;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const theme = /\b(dark|light)[\s-]+theme\b/i.exec(description)?.[1];
|
|
433
|
+
|
|
434
|
+
return theme?.toLowerCase() === "dark"
|
|
435
|
+
? "dark"
|
|
436
|
+
: theme?.toLowerCase() === "light"
|
|
437
|
+
? "light"
|
|
438
|
+
: undefined;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function parentThemeFromDictionary(
|
|
442
|
+
dictionary: unknown
|
|
443
|
+
): ParentTheme | undefined {
|
|
444
|
+
if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
|
|
445
|
+
return undefined;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
return parentThemeFromDescription(dictionary.color.$description);
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
452
|
+
name: "active",
|
|
453
|
+
brightness: 1.45,
|
|
454
|
+
useContrastPrimitive: true
|
|
455
|
+
};
|
|
456
|
+
|
|
457
|
+
const COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
458
|
+
name: "active",
|
|
459
|
+
brightness: 1.25
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
const SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
463
|
+
name: "active",
|
|
464
|
+
brightness: 1.125
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
const HAIRLINE_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
468
|
+
name: "active",
|
|
469
|
+
brightness: 1.4
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
const INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
473
|
+
name: "active",
|
|
474
|
+
brightness: 1.8,
|
|
475
|
+
useContrastPrimitive: true
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const COLOR_STATE_INACTIVE: ColorStateVariant = {
|
|
479
|
+
name: "inactive",
|
|
480
|
+
brightness: 0.8
|
|
481
|
+
};
|
|
482
|
+
|
|
483
|
+
const LOW_SATURATION_COLOR_STATE_INACTIVE: ColorStateVariant = {
|
|
484
|
+
name: "inactive",
|
|
485
|
+
brightness: 0.6,
|
|
486
|
+
useContrastPrimitive: true
|
|
487
|
+
};
|
|
488
|
+
|
|
489
|
+
const COLOR_STATE_DISABLED: ColorStateVariant = {
|
|
490
|
+
name: "disabled",
|
|
491
|
+
opacity: 0.35,
|
|
492
|
+
saturation: 0.85,
|
|
493
|
+
fadeOver: "oklch(0.3839 0.0045 286.26)"
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
/** State variants for low-saturation (neutral) and saturated colors. */
|
|
497
|
+
interface ColorStateVariants {
|
|
498
|
+
lowSaturation: readonly ColorStateVariant[];
|
|
499
|
+
saturated: readonly ColorStateVariant[];
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
const DEFAULT_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
503
|
+
lowSaturation: [
|
|
504
|
+
LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
505
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
506
|
+
COLOR_STATE_DISABLED
|
|
507
|
+
],
|
|
508
|
+
saturated: [COLOR_STATE_ACTIVE, COLOR_STATE_INACTIVE, COLOR_STATE_DISABLED]
|
|
509
|
+
};
|
|
510
|
+
|
|
511
|
+
const SUBTLE_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
512
|
+
lowSaturation: [
|
|
513
|
+
SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
514
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
515
|
+
COLOR_STATE_DISABLED
|
|
516
|
+
],
|
|
517
|
+
saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
|
|
518
|
+
};
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* Backgrounds shift their own lightness instead of jumping to the contrast
|
|
522
|
+
* primitive, at about half the change of other colors' states.
|
|
523
|
+
*/
|
|
524
|
+
const BACKGROUND_COLOR_STATE_INACTIVE: ColorStateVariant = {
|
|
525
|
+
name: "inactive",
|
|
526
|
+
brightness: 0.9
|
|
527
|
+
};
|
|
528
|
+
|
|
529
|
+
const BACKGROUND_COLOR_STATE_DISABLED: ColorStateVariant = {
|
|
530
|
+
...COLOR_STATE_DISABLED,
|
|
531
|
+
opacity: 0.675,
|
|
532
|
+
saturation: 0.925
|
|
533
|
+
};
|
|
534
|
+
|
|
535
|
+
const BACKGROUND_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
536
|
+
lowSaturation: [
|
|
537
|
+
{ name: "active", brightness: 1.0375 },
|
|
538
|
+
BACKGROUND_COLOR_STATE_INACTIVE,
|
|
539
|
+
BACKGROUND_COLOR_STATE_DISABLED
|
|
540
|
+
],
|
|
541
|
+
saturated: [
|
|
542
|
+
{ name: "active", brightness: 1.125 },
|
|
543
|
+
BACKGROUND_COLOR_STATE_INACTIVE,
|
|
544
|
+
BACKGROUND_COLOR_STATE_DISABLED
|
|
545
|
+
]
|
|
546
|
+
};
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Color groups (`color.base`, `color.danger`, …) that get their own Tamagui
|
|
550
|
+
* child theme. Each defines a `primary` color and optionally a `secondary`
|
|
551
|
+
* (low-emphasis background); missing `secondary`, `on-primary`, and
|
|
552
|
+
* `on-secondary` companions are generated, along with state variants and
|
|
553
|
+
* focus rings.
|
|
554
|
+
*/
|
|
555
|
+
const THEME_COLOR_KEYS = [
|
|
556
|
+
"base",
|
|
557
|
+
"danger",
|
|
558
|
+
"warning",
|
|
559
|
+
"info",
|
|
560
|
+
"discovery",
|
|
561
|
+
"success"
|
|
562
|
+
] as const;
|
|
563
|
+
|
|
564
|
+
/** State variants for the tokens inside each theme color group. */
|
|
565
|
+
const THEME_COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
|
|
566
|
+
primary: DEFAULT_COLOR_STATE_VARIANTS,
|
|
567
|
+
secondary: SUBTLE_COLOR_STATE_VARIANTS
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
/** Color tokens with these `group`s receive state variants. */
|
|
571
|
+
const COLOR_STATE_TOKEN_GROUPS: Record<string, ColorStateVariants> = {
|
|
572
|
+
foreground: DEFAULT_COLOR_STATE_VARIANTS,
|
|
573
|
+
background: BACKGROUND_COLOR_STATE_VARIANTS
|
|
574
|
+
};
|
|
575
|
+
|
|
576
|
+
/** Standalone color tokens that receive state variants. */
|
|
577
|
+
const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
|
|
578
|
+
hairline: {
|
|
579
|
+
lowSaturation: [
|
|
580
|
+
INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
581
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
582
|
+
COLOR_STATE_DISABLED
|
|
583
|
+
],
|
|
584
|
+
saturated: [
|
|
585
|
+
HAIRLINE_COLOR_STATE_ACTIVE,
|
|
586
|
+
COLOR_STATE_INACTIVE,
|
|
587
|
+
COLOR_STATE_DISABLED
|
|
588
|
+
]
|
|
589
|
+
}
|
|
590
|
+
};
|
|
591
|
+
|
|
592
|
+
/** OKLCH chroma below which a color uses the low-saturation state variants. */
|
|
593
|
+
const LOW_SATURATION_CHROMA = 0.02;
|
|
594
|
+
|
|
595
|
+
const LOW_SATURATION_RING_OPACITY = 0.1;
|
|
596
|
+
const SATURATED_RING_OPACITY = 0.15;
|
|
597
|
+
const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.25;
|
|
598
|
+
const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
|
|
599
|
+
const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
|
|
600
|
+
const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
|
|
601
|
+
const SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS = 32;
|
|
602
|
+
/** OKLCH lightness shift for hovered colors already near the contrast primitive. */
|
|
603
|
+
const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.25;
|
|
604
|
+
|
|
605
|
+
function isStateVariantKey(key: string): boolean {
|
|
606
|
+
return (
|
|
607
|
+
key.toLowerCase().endsWith("-hovered") ||
|
|
608
|
+
key.toLowerCase().endsWith("-active") ||
|
|
609
|
+
key.toLowerCase().endsWith("-inactive") ||
|
|
610
|
+
key.toLowerCase().endsWith("-disabled")
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function getTokenAtPath(
|
|
615
|
+
tree: unknown,
|
|
616
|
+
path: string
|
|
617
|
+
): Record<string, unknown> | undefined {
|
|
618
|
+
let node: unknown = tree;
|
|
619
|
+
|
|
620
|
+
for (const part of path.split(".").filter(Boolean)) {
|
|
621
|
+
if (!isPlainObject(node) || !(part in node)) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
node = node[part];
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
return isTokenNode(node) ? node : undefined;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function resolveColorHex(
|
|
632
|
+
value: unknown,
|
|
633
|
+
tree: unknown,
|
|
634
|
+
seen: Set<string> = new Set()
|
|
635
|
+
): string | undefined {
|
|
636
|
+
const direct = convertColor(value);
|
|
637
|
+
if (direct) {
|
|
638
|
+
return direct;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
if (typeof value !== "string") {
|
|
642
|
+
return undefined;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
const ref = /^\{([^{}]+)\}$/.exec(value.trim());
|
|
646
|
+
if (!ref?.[1] || seen.has(ref[1])) {
|
|
647
|
+
return undefined;
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
seen.add(ref[1]);
|
|
651
|
+
const token = getTokenAtPath(tree, ref[1]);
|
|
652
|
+
if (!token || !("$value" in token)) {
|
|
653
|
+
return undefined;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return resolveColorHex(token.$value, tree, seen);
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
function applyOpacity(hex: string, opacity: number): string {
|
|
660
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
661
|
+
const rgb = expanded.slice(0, 7);
|
|
662
|
+
const existingAlpha =
|
|
663
|
+
expanded.length === 9 ? Number.parseInt(expanded.slice(7, 9), 16) / 255 : 1;
|
|
664
|
+
const alpha = clamp01(existingAlpha * opacity);
|
|
665
|
+
|
|
666
|
+
if (alpha >= 1) {
|
|
667
|
+
return rgb;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
return `${rgb}${alphaHex(alpha)}`;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/** Alpha-composite `hex` at `opacity` over an opaque `background` (sRGB). */
|
|
674
|
+
function applyFadeOver(
|
|
675
|
+
hex: string,
|
|
676
|
+
background: string,
|
|
677
|
+
opacity: number
|
|
678
|
+
): string {
|
|
679
|
+
const foreground = expandHex(hex).toLowerCase();
|
|
680
|
+
const base = expandHex(convertColor(background) ?? background).toLowerCase();
|
|
681
|
+
const existingAlpha =
|
|
682
|
+
foreground.length === 9
|
|
683
|
+
? Number.parseInt(foreground.slice(7, 9), 16) / 255
|
|
684
|
+
: 1;
|
|
685
|
+
const alpha = clamp01(existingAlpha * opacity);
|
|
686
|
+
const channel = (offset: number) =>
|
|
687
|
+
srgbHexChannel(
|
|
688
|
+
(Number.parseInt(foreground.slice(offset, offset + 2), 16) * alpha +
|
|
689
|
+
Number.parseInt(base.slice(offset, offset + 2), 16) * (1 - alpha)) /
|
|
690
|
+
255
|
|
691
|
+
);
|
|
692
|
+
|
|
693
|
+
return `#${channel(1)}${channel(3)}${channel(5)}`;
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
function applyRingOpacity(
|
|
697
|
+
node: unknown,
|
|
698
|
+
tree: unknown,
|
|
699
|
+
inRingGroup = false
|
|
700
|
+
): unknown {
|
|
701
|
+
if (Array.isArray(node)) {
|
|
702
|
+
return node.map(item => applyRingOpacity(item, tree, inRingGroup));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
if (!isPlainObject(node)) {
|
|
706
|
+
return node;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
if (inRingGroup && isTokenNode(node) && resolveType(node) === "shadow") {
|
|
710
|
+
const value = node.$value;
|
|
711
|
+
const applyToLayer = (layer: unknown): unknown => {
|
|
712
|
+
if (!isPlainObject(layer) || !("color" in layer)) {
|
|
713
|
+
return layer;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
const color = resolveColorHex(layer.color, tree);
|
|
717
|
+
|
|
718
|
+
return color
|
|
719
|
+
? {
|
|
720
|
+
...layer,
|
|
721
|
+
color: applyOpacity(
|
|
722
|
+
color,
|
|
723
|
+
isLowSaturation(color)
|
|
724
|
+
? LOW_SATURATION_RING_OPACITY
|
|
725
|
+
: SATURATED_RING_OPACITY
|
|
726
|
+
)
|
|
727
|
+
}
|
|
728
|
+
: layer;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
return {
|
|
732
|
+
...node,
|
|
733
|
+
// CSS paints the first shadow layer on top. Ring tokens may use those
|
|
734
|
+
// leading layers as opaque offset masks, so only tint the final,
|
|
735
|
+
// visible ring layer.
|
|
736
|
+
$value: Array.isArray(value)
|
|
737
|
+
? value.map((layer, index) =>
|
|
738
|
+
index === value.length - 1 ? applyToLayer(layer) : layer
|
|
739
|
+
)
|
|
740
|
+
: applyToLayer(value)
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
const result: Record<string, unknown> = {};
|
|
745
|
+
for (const [key, value] of Object.entries(node)) {
|
|
746
|
+
result[key] = applyRingOpacity(
|
|
747
|
+
value,
|
|
748
|
+
tree,
|
|
749
|
+
inRingGroup || key.toLowerCase() === "ring"
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
return result;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function ringLayer(color: string, spread: number): Record<string, unknown> {
|
|
757
|
+
const zero = { value: 0, unit: "px" };
|
|
758
|
+
|
|
759
|
+
return {
|
|
760
|
+
color,
|
|
761
|
+
offsetX: zero,
|
|
762
|
+
offsetY: zero,
|
|
763
|
+
blur: zero,
|
|
764
|
+
spread: { value: spread, unit: "px" }
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* Add the default, subtle, offset, and subtle-offset ring tokens for each
|
|
770
|
+
* ring color (e.g. `ring.danger`, `ring.danger-subtle`, `ring.danger-offset`,
|
|
771
|
+
* `ring.danger-subtle-offset`) unless they are already defined.
|
|
772
|
+
*/
|
|
773
|
+
function addMissingRingTokens(dictionary: unknown): unknown {
|
|
774
|
+
if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
|
|
775
|
+
return dictionary;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const color = dictionary.color;
|
|
779
|
+
const ring = isPlainObject(dictionary.ring) ? { ...dictionary.ring } : {};
|
|
780
|
+
let changed = false;
|
|
781
|
+
|
|
782
|
+
for (const name of THEME_COLOR_KEYS) {
|
|
783
|
+
const group = color[name];
|
|
784
|
+
if (!isPlainObject(group) || !isTokenNode(group.primary)) {
|
|
785
|
+
continue;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
const ringColor = `{color.${name}.primary}`;
|
|
789
|
+
const gap = ringLayer("{color.elevated}", 3);
|
|
790
|
+
const variants: Record<string, [string, unknown[]]> = {
|
|
791
|
+
[name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
|
|
792
|
+
[`${name}-subtle`]: [
|
|
793
|
+
`${name} 1px hairline focus ring with no gap`,
|
|
794
|
+
[ringLayer(ringColor, 1)]
|
|
795
|
+
],
|
|
796
|
+
[`${name}-offset`]: [
|
|
797
|
+
`${name} 3px focus ring with a 3px gap`,
|
|
798
|
+
[gap, ringLayer(ringColor, 6)]
|
|
799
|
+
],
|
|
800
|
+
[`${name}-subtle-offset`]: [
|
|
801
|
+
`${name} 1px focus ring with a 3px gap`,
|
|
802
|
+
[gap, ringLayer(ringColor, 4)]
|
|
803
|
+
]
|
|
804
|
+
};
|
|
805
|
+
|
|
806
|
+
for (const [key, [description, value]] of Object.entries(variants)) {
|
|
807
|
+
if (key in ring) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
ring[key] = {
|
|
812
|
+
$description: `Generated ${description}`,
|
|
813
|
+
$type: "shadow",
|
|
814
|
+
$value: value,
|
|
815
|
+
theme: name
|
|
816
|
+
};
|
|
817
|
+
changed = true;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
return changed ? { ...dictionary, ring } : dictionary;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function isLowSaturation(hex: string): boolean {
|
|
825
|
+
return hexToOklch(hex).chroma < LOW_SATURATION_CHROMA;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function isGreyscale(hex: string): boolean {
|
|
829
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
830
|
+
|
|
831
|
+
return (
|
|
832
|
+
expanded.slice(1, 3) === expanded.slice(3, 5) &&
|
|
833
|
+
expanded.slice(3, 5) === expanded.slice(5, 7)
|
|
834
|
+
);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* Convert `#rrggbb` / `#rrggbbaa` to OKLCH so lightness can be scaled.
|
|
839
|
+
*
|
|
840
|
+
* @see https://bottosson.github.io/posts/oklab/
|
|
841
|
+
*/
|
|
842
|
+
function hexToOklch(hex: string): {
|
|
843
|
+
lightness: number;
|
|
844
|
+
chroma: number;
|
|
845
|
+
hue: number;
|
|
846
|
+
alpha?: number;
|
|
847
|
+
} {
|
|
848
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
849
|
+
const red = fromSrgb(Number.parseInt(expanded.slice(1, 3), 16) / 255);
|
|
850
|
+
const green = fromSrgb(Number.parseInt(expanded.slice(3, 5), 16) / 255);
|
|
851
|
+
const blue = fromSrgb(Number.parseInt(expanded.slice(5, 7), 16) / 255);
|
|
852
|
+
const alpha =
|
|
853
|
+
expanded.length === 9
|
|
854
|
+
? Number.parseInt(expanded.slice(7, 9), 16) / 255
|
|
855
|
+
: undefined;
|
|
856
|
+
|
|
857
|
+
const l_ =
|
|
858
|
+
0.412_221_470_8 * red + 0.536_332_536_3 * green + 0.051_445_992_9 * blue;
|
|
859
|
+
const m_ =
|
|
860
|
+
0.211_903_498_2 * red + 0.680_699_545_1 * green + 0.107_396_956_6 * blue;
|
|
861
|
+
const s_ =
|
|
862
|
+
0.088_302_461_9 * red + 0.281_718_837_6 * green + 0.629_978_700_5 * blue;
|
|
863
|
+
|
|
864
|
+
const l = Math.cbrt(l_);
|
|
865
|
+
const m = Math.cbrt(m_);
|
|
866
|
+
const s = Math.cbrt(s_);
|
|
867
|
+
|
|
868
|
+
const lightness =
|
|
869
|
+
0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s;
|
|
870
|
+
const a = 1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s;
|
|
871
|
+
const b = 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s;
|
|
872
|
+
|
|
873
|
+
const chroma = Math.hypot(a, b);
|
|
874
|
+
let hue = (Math.atan2(b, a) * 180) / Math.PI;
|
|
875
|
+
if (hue < 0) {
|
|
876
|
+
hue += 360;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
return { lightness, chroma, hue, alpha };
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function isInSrgbGamut({
|
|
883
|
+
red,
|
|
884
|
+
green,
|
|
885
|
+
blue
|
|
886
|
+
}: {
|
|
887
|
+
red: number;
|
|
888
|
+
green: number;
|
|
889
|
+
blue: number;
|
|
890
|
+
}): boolean {
|
|
891
|
+
return (
|
|
892
|
+
red >= 0 && red <= 1 && green >= 0 && green <= 1 && blue >= 0 && blue <= 1
|
|
893
|
+
);
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function oklchToGamutMappedHex(
|
|
897
|
+
lightness: number,
|
|
898
|
+
chroma: number,
|
|
899
|
+
hue: number,
|
|
900
|
+
alpha?: number
|
|
901
|
+
): string {
|
|
902
|
+
if (isInSrgbGamut(oklchToLinearSrgb(lightness, chroma, hue))) {
|
|
903
|
+
return oklchToHex(lightness, chroma, hue, alpha);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
let minimumChroma = 0;
|
|
907
|
+
let maximumChroma = chroma;
|
|
908
|
+
|
|
909
|
+
// Find the highest in-gamut chroma while holding lightness and hue fixed.
|
|
910
|
+
for (
|
|
911
|
+
let index = 0;
|
|
912
|
+
index < SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS;
|
|
913
|
+
index += 1
|
|
914
|
+
) {
|
|
915
|
+
const candidateChroma = (minimumChroma + maximumChroma) / 2;
|
|
916
|
+
if (isInSrgbGamut(oklchToLinearSrgb(lightness, candidateChroma, hue))) {
|
|
917
|
+
minimumChroma = candidateChroma;
|
|
918
|
+
} else {
|
|
919
|
+
maximumChroma = candidateChroma;
|
|
920
|
+
}
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
return oklchToHex(lightness, minimumChroma, hue, alpha);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
function applyBrightness(hex: string, factor: number): string {
|
|
927
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
928
|
+
|
|
929
|
+
return oklchToHex(clamp01(lightness * factor), chroma, hue, alpha);
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function applyLightnessShift(hex: string, shift: number): string {
|
|
933
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
934
|
+
|
|
935
|
+
return oklchToHex(clamp01(lightness + shift), chroma, hue, alpha);
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
function applySaturation(hex: string, factor: number): string {
|
|
939
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
940
|
+
|
|
941
|
+
return oklchToHex(lightness, chroma * clamp01(factor), hue, alpha);
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
function relativeLuminance(hex: string): number {
|
|
945
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
946
|
+
const channel = (offset: number) => {
|
|
947
|
+
const value = Number.parseInt(expanded.slice(offset, offset + 2), 16) / 255;
|
|
948
|
+
|
|
949
|
+
return value <= 0.040_45 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
950
|
+
};
|
|
951
|
+
|
|
952
|
+
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
function contrastRatio(first: string, second: string): number {
|
|
956
|
+
const firstLuminance = relativeLuminance(first);
|
|
957
|
+
const secondLuminance = relativeLuminance(second);
|
|
958
|
+
const lighter = Math.max(firstLuminance, secondLuminance);
|
|
959
|
+
const darker = Math.min(firstLuminance, secondLuminance);
|
|
960
|
+
|
|
961
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function createOnColor(hex: string): "#FAFAFA" | "#151518" {
|
|
965
|
+
const whiteContrast = contrastRatio(hex, "#FAFAFA");
|
|
966
|
+
if (whiteContrast >= MINIMUM_INVERSE_CONTRAST_RATIO) {
|
|
967
|
+
return "#FAFAFA";
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
const blackContrast = contrastRatio(hex, "#151518");
|
|
971
|
+
if (blackContrast * 0.5 > whiteContrast) {
|
|
972
|
+
return "#151518";
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
return "#FAFAFA";
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function createMutedColor(
|
|
979
|
+
hex: string,
|
|
980
|
+
parentTheme: ParentTheme
|
|
981
|
+
): { secondary: string; onMuted?: string } {
|
|
982
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
983
|
+
const secondaryDirection = parentTheme === "light" ? 1 : -1;
|
|
984
|
+
const onMutedDirection = -secondaryDirection;
|
|
985
|
+
let secondaryLightness = lightness;
|
|
986
|
+
let onMutedLightness = lightness;
|
|
987
|
+
let secondary = hex;
|
|
988
|
+
let onMuted = hex;
|
|
989
|
+
|
|
990
|
+
for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
|
|
991
|
+
secondaryLightness = clamp01(
|
|
992
|
+
secondaryLightness + secondaryDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
993
|
+
);
|
|
994
|
+
secondary = oklchToGamutMappedHex(secondaryLightness, chroma, hue, alpha);
|
|
995
|
+
if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
996
|
+
return onMuted === hex ? { secondary } : { secondary, onMuted };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
onMutedLightness = clamp01(
|
|
1000
|
+
onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
1001
|
+
);
|
|
1002
|
+
onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
|
|
1003
|
+
if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
1004
|
+
return { secondary, onMuted };
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
if (
|
|
1008
|
+
(secondaryLightness === 0 || secondaryLightness === 1) &&
|
|
1009
|
+
(onMutedLightness === 0 || onMutedLightness === 1)
|
|
1010
|
+
) {
|
|
1011
|
+
break;
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
throw new Error(`Unable to generate an accessible secondary pair for ${hex}`);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
|
|
1019
|
+
if (parentTheme === "dark") {
|
|
1020
|
+
return isWhiteColor(hex);
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
if (parentTheme === "light") {
|
|
1024
|
+
return !isBlackColor(hex);
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
return hexToOklch(hex).lightness >= 0.5;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function isDarkColor(hex: string, parentTheme?: ParentTheme): boolean {
|
|
1031
|
+
return !isLightColor(hex, parentTheme);
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function isWhiteColor(hex: string): boolean {
|
|
1035
|
+
return hexToOklch(hex).lightness >= 0.75;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
function isBlackColor(hex: string): boolean {
|
|
1039
|
+
return hexToOklch(hex).lightness <= 0.25;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
/** Hovered and active states share the contrast primitive behavior. */
|
|
1043
|
+
function isInteractionState(variant: ColorStateVariant): boolean {
|
|
1044
|
+
return variant.name === "hovered" || variant.name === "active";
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
function isNearContrastPrimitive(
|
|
1048
|
+
hex: string,
|
|
1049
|
+
parentTheme?: ParentTheme
|
|
1050
|
+
): boolean {
|
|
1051
|
+
return parentTheme === "dark"
|
|
1052
|
+
? isWhiteColor(hex)
|
|
1053
|
+
: parentTheme === "light" && isBlackColor(hex);
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
/** Resolve a state multiplier from the source color and parent theme. */
|
|
1057
|
+
function directedBrightnessFactor(
|
|
1058
|
+
hex: string,
|
|
1059
|
+
factor: number,
|
|
1060
|
+
parentTheme?: ParentTheme
|
|
1061
|
+
): number {
|
|
1062
|
+
return isDarkColor(hex, parentTheme) ? factor : 2 - factor;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
function contrastPrimitive(
|
|
1066
|
+
variant: ColorStateVariant,
|
|
1067
|
+
parentTheme?: ParentTheme
|
|
1068
|
+
): string | undefined {
|
|
1069
|
+
return variant.useContrastPrimitive && parentTheme
|
|
1070
|
+
? CONTRAST_PRIMITIVES[parentTheme]
|
|
1071
|
+
: undefined;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
/**
|
|
1075
|
+
* Hovered colors that already sit near the parent theme's contrast primitive
|
|
1076
|
+
* (near-white in dark themes, near-black in light themes) would barely change
|
|
1077
|
+
* by jumping to it, so their lightness shifts toward the middle instead.
|
|
1078
|
+
*/
|
|
1079
|
+
function contrastSideLightnessShift(
|
|
1080
|
+
hex: string,
|
|
1081
|
+
variant: ColorStateVariant,
|
|
1082
|
+
parentTheme?: ParentTheme
|
|
1083
|
+
): number | undefined {
|
|
1084
|
+
if (
|
|
1085
|
+
variant.name !== "hovered" ||
|
|
1086
|
+
!contrastPrimitive(variant, parentTheme) ||
|
|
1087
|
+
!isNearContrastPrimitive(hex, parentTheme)
|
|
1088
|
+
) {
|
|
1089
|
+
return undefined;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
return parentTheme === "dark"
|
|
1093
|
+
? -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT
|
|
1094
|
+
: CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
/**
|
|
1098
|
+
* The color an interaction state jumps to. Active colors already near the
|
|
1099
|
+
* contrast primitive go to pure white (dark) or pure black (light) so they
|
|
1100
|
+
* never lose contrast.
|
|
1101
|
+
*/
|
|
1102
|
+
function stateContrastColor(
|
|
1103
|
+
hex: string,
|
|
1104
|
+
variant: ColorStateVariant,
|
|
1105
|
+
parentTheme?: ParentTheme
|
|
1106
|
+
): string | undefined {
|
|
1107
|
+
const primitive = contrastPrimitive(variant, parentTheme);
|
|
1108
|
+
|
|
1109
|
+
return primitive &&
|
|
1110
|
+
parentTheme &&
|
|
1111
|
+
variant.name === "active" &&
|
|
1112
|
+
isNearContrastPrimitive(hex, parentTheme)
|
|
1113
|
+
? PURE_CONTRAST_COLORS[parentTheme]
|
|
1114
|
+
: primitive;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function stateBrightnessFactor(
|
|
1118
|
+
hex: string,
|
|
1119
|
+
variant: ColorStateVariant,
|
|
1120
|
+
parentTheme?: ParentTheme
|
|
1121
|
+
): number {
|
|
1122
|
+
if (variant.brightness === undefined) {
|
|
1123
|
+
return 1;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (contrastPrimitive(variant, parentTheme)) {
|
|
1127
|
+
return isInteractionState(variant) ? 1 : variant.brightness;
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return directedBrightnessFactor(hex, variant.brightness, parentTheme);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function applyStateTransform(
|
|
1134
|
+
hex: string,
|
|
1135
|
+
variant: ColorStateVariant,
|
|
1136
|
+
parentTheme?: ParentTheme
|
|
1137
|
+
): string {
|
|
1138
|
+
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1139
|
+
const primitive =
|
|
1140
|
+
shift === undefined
|
|
1141
|
+
? stateContrastColor(hex, variant, parentTheme)
|
|
1142
|
+
: undefined;
|
|
1143
|
+
let transformed = primitive ?? hex;
|
|
1144
|
+
|
|
1145
|
+
if (shift !== undefined) {
|
|
1146
|
+
transformed = applyLightnessShift(hex, shift);
|
|
1147
|
+
} else if (
|
|
1148
|
+
variant.brightness !== undefined &&
|
|
1149
|
+
!(primitive && isInteractionState(variant))
|
|
1150
|
+
) {
|
|
1151
|
+
transformed = applyBrightness(
|
|
1152
|
+
transformed,
|
|
1153
|
+
stateBrightnessFactor(transformed, variant, parentTheme)
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (variant.saturation !== undefined && !isGreyscale(hex)) {
|
|
1158
|
+
transformed = applySaturation(transformed, variant.saturation);
|
|
1159
|
+
if (variant.fadeOver === undefined) {
|
|
1160
|
+
return transformed;
|
|
1161
|
+
}
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (variant.fadeOver !== undefined) {
|
|
1165
|
+
return applyFadeOver(transformed, variant.fadeOver, variant.opacity ?? 1);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
if (variant.opacity !== undefined) {
|
|
1169
|
+
return applyOpacity(transformed, variant.opacity);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return transformed;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function variantDetail(
|
|
1176
|
+
variant: ColorStateVariant,
|
|
1177
|
+
hex: string,
|
|
1178
|
+
parentTheme?: ParentTheme
|
|
1179
|
+
): string {
|
|
1180
|
+
const details: string[] = [];
|
|
1181
|
+
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1182
|
+
const primitive = stateContrastColor(hex, variant, parentTheme);
|
|
1183
|
+
|
|
1184
|
+
if (shift !== undefined) {
|
|
1185
|
+
details.push(
|
|
1186
|
+
`${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
|
|
1187
|
+
);
|
|
1188
|
+
} else if (primitive && isInteractionState(variant)) {
|
|
1189
|
+
details.push(
|
|
1190
|
+
parentTheme && primitive !== CONTRAST_PRIMITIVES[parentTheme]
|
|
1191
|
+
? `pure ${parentTheme === "dark" ? "white" : "black"}`
|
|
1192
|
+
: `${parentTheme} contrast primitive`
|
|
1193
|
+
);
|
|
1194
|
+
} else if (variant.brightness !== undefined) {
|
|
1195
|
+
const factor = stateBrightnessFactor(hex, variant, parentTheme);
|
|
1196
|
+
const percent = Math.round((factor - 1) * 100);
|
|
1197
|
+
|
|
1198
|
+
details.push(
|
|
1199
|
+
percent >= 0 ? `${percent}% brighter` : `${Math.abs(percent)}% darker`
|
|
1200
|
+
);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
if (variant.saturation !== undefined && !isGreyscale(hex)) {
|
|
1204
|
+
details.push(`${Math.round(variant.saturation * 100)}% saturation`);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
if (variant.fadeOver !== undefined) {
|
|
1208
|
+
details.push(
|
|
1209
|
+
`${Math.round((variant.opacity ?? 1) * 100)}% over ${variant.fadeOver}`
|
|
1210
|
+
);
|
|
1211
|
+
} else if (
|
|
1212
|
+
variant.opacity !== undefined &&
|
|
1213
|
+
(variant.saturation === undefined || isGreyscale(hex))
|
|
1214
|
+
) {
|
|
1215
|
+
details.push(`${Math.round(variant.opacity * 100)}% opacity`);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
return details.join(", ") || variant.name;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function createStateToken(
|
|
1222
|
+
source: Record<string, unknown>,
|
|
1223
|
+
hex: string,
|
|
1224
|
+
variant: ColorStateVariant,
|
|
1225
|
+
parentTheme?: ParentTheme
|
|
1226
|
+
): Record<string, unknown> {
|
|
1227
|
+
const detail = variantDetail(variant, hex, parentTheme);
|
|
1228
|
+
const transformed = applyStateTransform(hex, variant, parentTheme);
|
|
1229
|
+
|
|
1230
|
+
return {
|
|
1231
|
+
...source,
|
|
1232
|
+
$type: "color",
|
|
1233
|
+
$value: transformed,
|
|
1234
|
+
$description:
|
|
1235
|
+
typeof source.$description === "string"
|
|
1236
|
+
? `${source.$description} (${variant.name}, ${detail})`
|
|
1237
|
+
: `${variant.name} state at ${detail}`
|
|
1238
|
+
};
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
function addColorStateTokens(
|
|
1242
|
+
group: Record<string, unknown>,
|
|
1243
|
+
tree: unknown,
|
|
1244
|
+
variants: ColorStateVariants,
|
|
1245
|
+
onlyKey?: string,
|
|
1246
|
+
parentTheme?: ParentTheme
|
|
1247
|
+
): Record<string, unknown> {
|
|
1248
|
+
const result = { ...group } as Record<string, any>;
|
|
1249
|
+
const names = onlyKey ? [onlyKey] : Object.keys(group);
|
|
1250
|
+
|
|
1251
|
+
for (const name of names) {
|
|
1252
|
+
if (name.startsWith("$") || isStateVariantKey(name)) {
|
|
1253
|
+
continue;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const token = group[name];
|
|
1257
|
+
if (!isTokenNode(token)) {
|
|
1258
|
+
continue;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const type = resolveType(token);
|
|
1262
|
+
if (type && type !== "color") {
|
|
1263
|
+
continue;
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
const hex = resolveColorHex(token.$value, tree);
|
|
1267
|
+
if (!hex) {
|
|
1268
|
+
continue;
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
// Hovered states are generated from the matching active variant.
|
|
1272
|
+
const tokenVariants = (
|
|
1273
|
+
isLowSaturation(hex) ? variants.lowSaturation : variants.saturated
|
|
1274
|
+
).flatMap(variant =>
|
|
1275
|
+
variant.name === "active"
|
|
1276
|
+
? [{ ...variant, name: "hovered" }, variant]
|
|
1277
|
+
: [variant]
|
|
1278
|
+
);
|
|
1279
|
+
const tokenParentTheme =
|
|
1280
|
+
parentThemeFromDescription(token.$description) ?? parentTheme;
|
|
1281
|
+
|
|
1282
|
+
for (const variant of tokenVariants) {
|
|
1283
|
+
const variantKey = `${name}-${variant.name}`;
|
|
1284
|
+
if (variantKey in result) {
|
|
1285
|
+
continue;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
result[variantKey] = createStateToken(
|
|
1289
|
+
token,
|
|
1290
|
+
hex,
|
|
1291
|
+
variant,
|
|
1292
|
+
tokenParentTheme
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
return result;
|
|
1298
|
+
}
|
|
1299
|
+
|
|
1300
|
+
/**
|
|
1301
|
+
* Fill in a theme color group (`color.danger`, …): generate a missing
|
|
1302
|
+
* `secondary` low-emphasis background from `primary` and the parent theme,
|
|
1303
|
+
* `on-primary` / `on-secondary` text colors, and state variants for
|
|
1304
|
+
* `primary` and `secondary`. Every token in the group gets the group name as
|
|
1305
|
+
* its `theme` and `group`, so Tamagui places it in that child theme (e.g.
|
|
1306
|
+
* `danger.on-primary` → `onPrimary` in the `danger` theme).
|
|
1307
|
+
*/
|
|
1308
|
+
function addThemeColorGroupTokens(
|
|
1309
|
+
name: string,
|
|
1310
|
+
group: Record<string, unknown>,
|
|
1311
|
+
tree: unknown,
|
|
1312
|
+
parentTheme?: ParentTheme
|
|
1313
|
+
): Record<string, unknown> {
|
|
1314
|
+
const token = group.primary;
|
|
1315
|
+
const hex = isTokenNode(token) && resolveColorHex(token.$value, tree);
|
|
1316
|
+
const type = isTokenNode(token) && resolveType(token);
|
|
1317
|
+
if (!isTokenNode(token) || !hex || (type && type !== "color")) {
|
|
1318
|
+
return group;
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
let result: Record<string, unknown> = { ...group };
|
|
1322
|
+
result["on-primary"] ??= {
|
|
1323
|
+
...token,
|
|
1324
|
+
$type: "color",
|
|
1325
|
+
$value: createOnColor(hex),
|
|
1326
|
+
$description: `Generated text and icon color on ${name} primary backgrounds`
|
|
1327
|
+
};
|
|
1328
|
+
|
|
1329
|
+
let onSecondary: string | undefined;
|
|
1330
|
+
if (parentTheme && !("secondary" in group)) {
|
|
1331
|
+
const pair = createMutedColor(hex, parentTheme);
|
|
1332
|
+
result.secondary = {
|
|
1333
|
+
...token,
|
|
1334
|
+
$type: "color",
|
|
1335
|
+
$value: pair.secondary,
|
|
1336
|
+
$description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
|
|
1337
|
+
};
|
|
1338
|
+
onSecondary = pair.onMuted ?? hex;
|
|
1339
|
+
} else if (isTokenNode(group.secondary)) {
|
|
1340
|
+
const secondaryHex = resolveColorHex(group.secondary.$value, tree);
|
|
1341
|
+
onSecondary = secondaryHex && createOnColor(secondaryHex);
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
if (onSecondary && !("on-secondary" in group)) {
|
|
1345
|
+
result["on-secondary"] = {
|
|
1346
|
+
...token,
|
|
1347
|
+
$type: "color",
|
|
1348
|
+
$value: onSecondary,
|
|
1349
|
+
$description: `Generated text and icon color on ${name} secondary backgrounds`
|
|
1350
|
+
};
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
for (const [key, variants] of Object.entries(THEME_COLOR_STATE_VARIANTS)) {
|
|
1354
|
+
result = addColorStateTokens(result, tree, variants, key, parentTheme);
|
|
1355
|
+
}
|
|
1356
|
+
|
|
1357
|
+
for (const [key, value] of Object.entries(result)) {
|
|
1358
|
+
if (isTokenNode(value)) {
|
|
1359
|
+
result[key] = { theme: name, group: name, ...value };
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
return result;
|
|
1364
|
+
}
|
|
1365
|
+
|
|
1366
|
+
/**
|
|
1367
|
+
* Add a `group` (the main token name) to each state and `on-*` color token
|
|
1368
|
+
* that does not define one, e.g. `hairline-active` → `hairline`.
|
|
1369
|
+
*/
|
|
1370
|
+
function addColorTokenGroups(
|
|
1371
|
+
group: Record<string, unknown>
|
|
1372
|
+
): Record<string, unknown> {
|
|
1373
|
+
const result = { ...group };
|
|
1374
|
+
for (const [name, token] of Object.entries(group)) {
|
|
1375
|
+
const root = name
|
|
1376
|
+
.replace(/^on-/, "")
|
|
1377
|
+
.replace(/-(?:hovered|active|inactive|disabled)$/i, "");
|
|
1378
|
+
if (root !== name && isTokenNode(token) && !("group" in token)) {
|
|
1379
|
+
result[name] = { ...token, group: root };
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
return result;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
function injectColorStateVariants(
|
|
1387
|
+
node: unknown,
|
|
1388
|
+
tree: unknown,
|
|
1389
|
+
key?: string,
|
|
1390
|
+
parentTheme?: ParentTheme
|
|
1391
|
+
): unknown {
|
|
1392
|
+
if (Array.isArray(node)) {
|
|
1393
|
+
return node.map(item =>
|
|
1394
|
+
injectColorStateVariants(item, tree, undefined, parentTheme)
|
|
1395
|
+
);
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
if (!isPlainObject(node) || isTokenNode(node)) {
|
|
1399
|
+
return node;
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
const result: Record<string, unknown> = {};
|
|
1403
|
+
for (const [childKey, value] of Object.entries(node)) {
|
|
1404
|
+
if (RESERVED_KEYS.has(childKey) || childKey.startsWith("$")) {
|
|
1405
|
+
result[childKey] = value;
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
const childTheme =
|
|
1410
|
+
childKey === "dark" || childKey === "light" ? childKey : parentTheme;
|
|
1411
|
+
result[childKey] = injectColorStateVariants(
|
|
1412
|
+
value,
|
|
1413
|
+
tree,
|
|
1414
|
+
childKey,
|
|
1415
|
+
childTheme
|
|
1416
|
+
);
|
|
1417
|
+
}
|
|
1418
|
+
|
|
1419
|
+
let withStates = result;
|
|
1420
|
+
if (key === "color") {
|
|
1421
|
+
for (const name of THEME_COLOR_KEYS) {
|
|
1422
|
+
const group = withStates[name];
|
|
1423
|
+
if (isPlainObject(group) && !isTokenNode(group)) {
|
|
1424
|
+
withStates[name] = addThemeColorGroupTokens(
|
|
1425
|
+
name,
|
|
1426
|
+
group,
|
|
1427
|
+
tree,
|
|
1428
|
+
parentTheme
|
|
1429
|
+
);
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
for (const [tokenKey, token] of Object.entries(withStates)) {
|
|
1434
|
+
const variants =
|
|
1435
|
+
isTokenNode(token) && typeof token.group === "string"
|
|
1436
|
+
? COLOR_STATE_TOKEN_GROUPS[token.group]
|
|
1437
|
+
: undefined;
|
|
1438
|
+
if (variants) {
|
|
1439
|
+
withStates = addColorStateTokens(
|
|
1440
|
+
withStates,
|
|
1441
|
+
tree,
|
|
1442
|
+
variants,
|
|
1443
|
+
tokenKey,
|
|
1444
|
+
parentTheme
|
|
1445
|
+
);
|
|
1446
|
+
}
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
|
|
1451
|
+
if (isTokenNode(withStates[tokenKey])) {
|
|
1452
|
+
withStates = addColorStateTokens(
|
|
1453
|
+
withStates,
|
|
1454
|
+
tree,
|
|
1455
|
+
variants,
|
|
1456
|
+
tokenKey,
|
|
1457
|
+
parentTheme
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
return key === "color" ? addColorTokenGroups(withStates) : withStates;
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/**
|
|
1466
|
+
* Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
|
|
1467
|
+
* colors) to hex so generators emit `#rrggbb` / `#rrggbbaa` instead of
|
|
1468
|
+
* `oklch()`. Root colors (emphasis, ink, subtle, subtlest, hairline, backgrounds)
|
|
1469
|
+
* stay in the root light and dark themes without rings. For the `base` group and each
|
|
1470
|
+
* status group, a missing `secondary` background and `on-primary` /
|
|
1471
|
+
* `on-secondary` text colors are generated, then hovered, active, inactive,
|
|
1472
|
+
* and disabled variants are added to the hairline, `foreground` and
|
|
1473
|
+
* `background` group colors, and each theme color group's `primary` and `secondary`.
|
|
1474
|
+
* Low-saturation colors derive their states from the parent theme's contrast
|
|
1475
|
+
* primitive (backgrounds use a half-strength shift of their own lightness; hovered
|
|
1476
|
+
* colors already near that primitive shift toward the middle, while active
|
|
1477
|
+
* ones become pure white or black); saturated
|
|
1478
|
+
* colors scale their own lightness. Tokens in a theme color group get the
|
|
1479
|
+
* group name as `theme` and `group`, unless already set; root state and `on-*`
|
|
1480
|
+
* tokens get their main token name as `group` (e.g. `hairline-active` →
|
|
1481
|
+
* `hairline`).
|
|
1482
|
+
* Finally, focus rings are generated for `base` and each status color.
|
|
1483
|
+
*/
|
|
1484
|
+
export function tamaguiPreprocessor(
|
|
1485
|
+
dictionary: PreprocessedTokens
|
|
1486
|
+
): PreprocessedTokens {
|
|
1487
|
+
const converted = walk(dictionary) as PreprocessedTokens;
|
|
1488
|
+
const withStateVariants = injectColorStateVariants(
|
|
1489
|
+
converted,
|
|
1490
|
+
converted,
|
|
1491
|
+
undefined,
|
|
1492
|
+
parentThemeFromDictionary(converted)
|
|
1493
|
+
) as PreprocessedTokens;
|
|
1494
|
+
const withRings = addMissingRingTokens(withStateVariants);
|
|
1495
|
+
|
|
1496
|
+
return applyRingOpacity(withRings, withRings) as PreprocessedTokens;
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
export default definePlugin(() => ({
|
|
1500
|
+
name: "cyclone-ui:tamagui-preprocessor",
|
|
1501
|
+
preprocessors: [
|
|
1502
|
+
{
|
|
1503
|
+
name: "tamagui-preprocessor",
|
|
1504
|
+
preprocessor: tamaguiPreprocessor
|
|
1505
|
+
}
|
|
1506
|
+
]
|
|
1507
|
+
}));
|