@cyclone-ui/theme-builder 0.0.0-stage → 0.0.4
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 +15 -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 +4 -0
- package/dist/preprocessor.d.cts.map +1 -0
- package/dist/preprocessor.d.mts +5 -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 +1524 -0
- package/test/preprocessor.spec.ts +518 -0
- package/test/shiki-theme-mapping.spec.ts +160 -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,1524 @@
|
|
|
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
|
+
function parentThemeFromDescription(
|
|
420
|
+
description: unknown
|
|
421
|
+
): ParentTheme | undefined {
|
|
422
|
+
if (typeof description !== "string") {
|
|
423
|
+
return undefined;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const theme = /\b(dark|light)[\s-]+theme\b/i.exec(description)?.[1];
|
|
427
|
+
|
|
428
|
+
return theme?.toLowerCase() === "dark"
|
|
429
|
+
? "dark"
|
|
430
|
+
: theme?.toLowerCase() === "light"
|
|
431
|
+
? "light"
|
|
432
|
+
: undefined;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function parentThemeFromDictionary(
|
|
436
|
+
dictionary: unknown
|
|
437
|
+
): ParentTheme | undefined {
|
|
438
|
+
if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
return parentThemeFromDescription(dictionary.color.$description);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
const LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
446
|
+
name: "active",
|
|
447
|
+
brightness: 1.45,
|
|
448
|
+
useContrastPrimitive: true
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
const COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
452
|
+
name: "active",
|
|
453
|
+
brightness: 1.25
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
457
|
+
name: "active",
|
|
458
|
+
brightness: 1.075
|
|
459
|
+
};
|
|
460
|
+
|
|
461
|
+
const SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
462
|
+
name: "active",
|
|
463
|
+
brightness: 1.125
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const BORDER_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
467
|
+
name: "active",
|
|
468
|
+
brightness: 1.4
|
|
469
|
+
};
|
|
470
|
+
|
|
471
|
+
const INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
472
|
+
name: "active",
|
|
473
|
+
brightness: 1.8,
|
|
474
|
+
useContrastPrimitive: true
|
|
475
|
+
};
|
|
476
|
+
|
|
477
|
+
const COLOR_STATE_INACTIVE: ColorStateVariant = {
|
|
478
|
+
name: "inactive",
|
|
479
|
+
brightness: 0.8
|
|
480
|
+
};
|
|
481
|
+
|
|
482
|
+
const LOW_SATURATION_COLOR_STATE_INACTIVE: ColorStateVariant = {
|
|
483
|
+
name: "inactive",
|
|
484
|
+
brightness: 0.6,
|
|
485
|
+
useContrastPrimitive: true
|
|
486
|
+
};
|
|
487
|
+
|
|
488
|
+
const COLOR_STATE_DISABLED: ColorStateVariant = {
|
|
489
|
+
name: "disabled",
|
|
490
|
+
opacity: 0.35,
|
|
491
|
+
saturation: 0.85,
|
|
492
|
+
fadeOver: "oklch(0.3839 0.0045 286.26)"
|
|
493
|
+
};
|
|
494
|
+
|
|
495
|
+
/** State variants for low-saturation (neutral) and saturated colors. */
|
|
496
|
+
interface ColorStateVariants {
|
|
497
|
+
lowSaturation: readonly ColorStateVariant[];
|
|
498
|
+
saturated: readonly ColorStateVariant[];
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
const DEFAULT_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
502
|
+
lowSaturation: [
|
|
503
|
+
LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
504
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
505
|
+
COLOR_STATE_DISABLED
|
|
506
|
+
],
|
|
507
|
+
saturated: [COLOR_STATE_ACTIVE, COLOR_STATE_INACTIVE, COLOR_STATE_DISABLED]
|
|
508
|
+
};
|
|
509
|
+
|
|
510
|
+
const SUBTLE_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
511
|
+
lowSaturation: [
|
|
512
|
+
SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
513
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
514
|
+
COLOR_STATE_DISABLED
|
|
515
|
+
],
|
|
516
|
+
saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* Surfaces shift their own lightness instead of jumping to the contrast
|
|
521
|
+
* primitive, so their states stay subtler than other low-saturation colors.
|
|
522
|
+
*/
|
|
523
|
+
const SURFACE_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
524
|
+
lowSaturation: [
|
|
525
|
+
SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
526
|
+
COLOR_STATE_INACTIVE,
|
|
527
|
+
COLOR_STATE_DISABLED
|
|
528
|
+
],
|
|
529
|
+
saturated: DEFAULT_COLOR_STATE_VARIANTS.saturated
|
|
530
|
+
};
|
|
531
|
+
|
|
532
|
+
/**
|
|
533
|
+
* Status colors that receive generated `on-*` (text) and `*-subtle`
|
|
534
|
+
* (low-emphasis background) companions when missing.
|
|
535
|
+
*/
|
|
536
|
+
const STATUS_COLOR_KEYS = [
|
|
537
|
+
"danger",
|
|
538
|
+
"warning",
|
|
539
|
+
"info",
|
|
540
|
+
"discovery",
|
|
541
|
+
"success"
|
|
542
|
+
] as const;
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* Primary and tertiary colors are copied into the `base` theme as `base` and
|
|
546
|
+
* `base-subtle`, so it is generated like a status theme (`accent`, `active`,
|
|
547
|
+
* `subtle`, `onAccent`, `ring`, …). Rings and states are generated for these
|
|
548
|
+
* copies, not for primary, secondary, or tertiary.
|
|
549
|
+
*/
|
|
550
|
+
const BASE_THEME_COLOR_NAMES: Record<string, string> = {
|
|
551
|
+
primary: "base",
|
|
552
|
+
tertiary: "base-subtle"
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* Colors that get their own Tamagui child theme, with generated companion,
|
|
557
|
+
* state, and focus ring tokens.
|
|
558
|
+
*/
|
|
559
|
+
const THEME_COLOR_KEYS = ["base", ...STATUS_COLOR_KEYS] as const;
|
|
560
|
+
|
|
561
|
+
/** Every token in this group receives state variants. */
|
|
562
|
+
const COLOR_STATE_GROUP_KEY = "surface";
|
|
563
|
+
|
|
564
|
+
/** Standalone color tokens that receive state variants. */
|
|
565
|
+
const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
|
|
566
|
+
border: {
|
|
567
|
+
lowSaturation: [
|
|
568
|
+
INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
569
|
+
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
570
|
+
COLOR_STATE_DISABLED
|
|
571
|
+
],
|
|
572
|
+
saturated: [
|
|
573
|
+
BORDER_COLOR_STATE_ACTIVE,
|
|
574
|
+
COLOR_STATE_INACTIVE,
|
|
575
|
+
COLOR_STATE_DISABLED
|
|
576
|
+
]
|
|
577
|
+
},
|
|
578
|
+
...Object.fromEntries(
|
|
579
|
+
THEME_COLOR_KEYS.flatMap(key => [
|
|
580
|
+
[key, DEFAULT_COLOR_STATE_VARIANTS],
|
|
581
|
+
[`${key}-subtle`, SUBTLE_COLOR_STATE_VARIANTS]
|
|
582
|
+
])
|
|
583
|
+
)
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
/** OKLCH chroma below which a color uses the low-saturation state variants. */
|
|
587
|
+
const LOW_SATURATION_CHROMA = 0.02;
|
|
588
|
+
|
|
589
|
+
const LOW_SATURATION_RING_OPACITY = 0.2;
|
|
590
|
+
const SATURATED_RING_OPACITY = 0.15;
|
|
591
|
+
const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.25;
|
|
592
|
+
const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
|
|
593
|
+
const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
|
|
594
|
+
const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
|
|
595
|
+
const SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS = 32;
|
|
596
|
+
/** OKLCH lightness shift for active colors already near the contrast primitive. */
|
|
597
|
+
const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.1;
|
|
598
|
+
|
|
599
|
+
function isStateVariantKey(key: string): boolean {
|
|
600
|
+
return (
|
|
601
|
+
key.toLowerCase().endsWith("-active") ||
|
|
602
|
+
key.toLowerCase().endsWith("-inactive") ||
|
|
603
|
+
key.toLowerCase().endsWith("-disabled")
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
function getTokenAtPath(
|
|
608
|
+
tree: unknown,
|
|
609
|
+
path: string
|
|
610
|
+
): Record<string, unknown> | undefined {
|
|
611
|
+
let node: unknown = tree;
|
|
612
|
+
|
|
613
|
+
for (const part of path.split(".").filter(Boolean)) {
|
|
614
|
+
if (!isPlainObject(node) || !(part in node)) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
node = node[part];
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
return isTokenNode(node) ? node : undefined;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function resolveColorHex(
|
|
625
|
+
value: unknown,
|
|
626
|
+
tree: unknown,
|
|
627
|
+
seen: Set<string> = new Set()
|
|
628
|
+
): string | undefined {
|
|
629
|
+
const direct = convertColor(value);
|
|
630
|
+
if (direct) {
|
|
631
|
+
return direct;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
if (typeof value !== "string") {
|
|
635
|
+
return undefined;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
const ref = /^\{([^{}]+)\}$/.exec(value.trim());
|
|
639
|
+
if (!ref?.[1] || seen.has(ref[1])) {
|
|
640
|
+
return undefined;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
seen.add(ref[1]);
|
|
644
|
+
const token = getTokenAtPath(tree, ref[1]);
|
|
645
|
+
if (!token || !("$value" in token)) {
|
|
646
|
+
return undefined;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return resolveColorHex(token.$value, tree, seen);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function applyOpacity(hex: string, opacity: number): string {
|
|
653
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
654
|
+
const rgb = expanded.slice(0, 7);
|
|
655
|
+
const existingAlpha =
|
|
656
|
+
expanded.length === 9 ? Number.parseInt(expanded.slice(7, 9), 16) / 255 : 1;
|
|
657
|
+
const alpha = clamp01(existingAlpha * opacity);
|
|
658
|
+
|
|
659
|
+
if (alpha >= 1) {
|
|
660
|
+
return rgb;
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
return `${rgb}${alphaHex(alpha)}`;
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** Alpha-composite `hex` at `opacity` over an opaque `background` (sRGB). */
|
|
667
|
+
function applyFadeOver(
|
|
668
|
+
hex: string,
|
|
669
|
+
background: string,
|
|
670
|
+
opacity: number
|
|
671
|
+
): string {
|
|
672
|
+
const foreground = expandHex(hex).toLowerCase();
|
|
673
|
+
const base = expandHex(convertColor(background) ?? background).toLowerCase();
|
|
674
|
+
const existingAlpha =
|
|
675
|
+
foreground.length === 9
|
|
676
|
+
? Number.parseInt(foreground.slice(7, 9), 16) / 255
|
|
677
|
+
: 1;
|
|
678
|
+
const alpha = clamp01(existingAlpha * opacity);
|
|
679
|
+
const channel = (offset: number) =>
|
|
680
|
+
srgbHexChannel(
|
|
681
|
+
(Number.parseInt(foreground.slice(offset, offset + 2), 16) * alpha +
|
|
682
|
+
Number.parseInt(base.slice(offset, offset + 2), 16) * (1 - alpha)) /
|
|
683
|
+
255
|
|
684
|
+
);
|
|
685
|
+
|
|
686
|
+
return `#${channel(1)}${channel(3)}${channel(5)}`;
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function applyRingOpacity(
|
|
690
|
+
node: unknown,
|
|
691
|
+
tree: unknown,
|
|
692
|
+
inRingGroup = false
|
|
693
|
+
): unknown {
|
|
694
|
+
if (Array.isArray(node)) {
|
|
695
|
+
return node.map(item => applyRingOpacity(item, tree, inRingGroup));
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (!isPlainObject(node)) {
|
|
699
|
+
return node;
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
if (inRingGroup && isTokenNode(node) && resolveType(node) === "shadow") {
|
|
703
|
+
const value = node.$value;
|
|
704
|
+
const applyToLayer = (layer: unknown): unknown => {
|
|
705
|
+
if (!isPlainObject(layer) || !("color" in layer)) {
|
|
706
|
+
return layer;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
const color = resolveColorHex(layer.color, tree);
|
|
710
|
+
|
|
711
|
+
return color
|
|
712
|
+
? {
|
|
713
|
+
...layer,
|
|
714
|
+
color: applyOpacity(
|
|
715
|
+
color,
|
|
716
|
+
isLowSaturation(color)
|
|
717
|
+
? LOW_SATURATION_RING_OPACITY
|
|
718
|
+
: SATURATED_RING_OPACITY
|
|
719
|
+
)
|
|
720
|
+
}
|
|
721
|
+
: layer;
|
|
722
|
+
};
|
|
723
|
+
|
|
724
|
+
return {
|
|
725
|
+
...node,
|
|
726
|
+
// CSS paints the first shadow layer on top. Ring tokens may use those
|
|
727
|
+
// leading layers as opaque offset masks, so only tint the final,
|
|
728
|
+
// visible ring layer.
|
|
729
|
+
$value: Array.isArray(value)
|
|
730
|
+
? value.map((layer, index) =>
|
|
731
|
+
index === value.length - 1 ? applyToLayer(layer) : layer
|
|
732
|
+
)
|
|
733
|
+
: applyToLayer(value)
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
const result: Record<string, unknown> = {};
|
|
738
|
+
for (const [key, value] of Object.entries(node)) {
|
|
739
|
+
result[key] = applyRingOpacity(
|
|
740
|
+
value,
|
|
741
|
+
tree,
|
|
742
|
+
inRingGroup || key.toLowerCase() === "ring"
|
|
743
|
+
);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
return result;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function ringLayer(color: string, spread: number): Record<string, unknown> {
|
|
750
|
+
const zero = { value: 0, unit: "px" };
|
|
751
|
+
|
|
752
|
+
return {
|
|
753
|
+
color,
|
|
754
|
+
offsetX: zero,
|
|
755
|
+
offsetY: zero,
|
|
756
|
+
blur: zero,
|
|
757
|
+
spread: { value: spread, unit: "px" }
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Add the default, subtle, offset, and subtle-offset ring tokens for each
|
|
763
|
+
* ring color (e.g. `ring.danger`, `ring.danger-subtle`, `ring.danger-offset`,
|
|
764
|
+
* `ring.danger-subtle-offset`) unless they are already defined.
|
|
765
|
+
*/
|
|
766
|
+
function addMissingRingTokens(dictionary: unknown): unknown {
|
|
767
|
+
if (!isPlainObject(dictionary) || !isPlainObject(dictionary.color)) {
|
|
768
|
+
return dictionary;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
const color = dictionary.color;
|
|
772
|
+
const ring = isPlainObject(dictionary.ring) ? { ...dictionary.ring } : {};
|
|
773
|
+
let changed = false;
|
|
774
|
+
|
|
775
|
+
for (const name of THEME_COLOR_KEYS) {
|
|
776
|
+
if (!isTokenNode(color[name])) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
const ringColor = `{color.${name}}`;
|
|
781
|
+
const gap = ringLayer("{color.surface-elevated}", 3);
|
|
782
|
+
const variants: Record<string, [string, unknown[]]> = {
|
|
783
|
+
[name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
|
|
784
|
+
[`${name}-subtle`]: [
|
|
785
|
+
`${name} 1px hairline focus ring with no gap`,
|
|
786
|
+
[ringLayer(ringColor, 1)]
|
|
787
|
+
],
|
|
788
|
+
[`${name}-offset`]: [
|
|
789
|
+
`${name} 3px focus ring with a 3px gap`,
|
|
790
|
+
[gap, ringLayer(ringColor, 6)]
|
|
791
|
+
],
|
|
792
|
+
[`${name}-subtle-offset`]: [
|
|
793
|
+
`${name} 1px focus ring with a 3px gap`,
|
|
794
|
+
[gap, ringLayer(ringColor, 4)]
|
|
795
|
+
]
|
|
796
|
+
};
|
|
797
|
+
|
|
798
|
+
for (const [key, [description, value]] of Object.entries(variants)) {
|
|
799
|
+
if (key in ring) {
|
|
800
|
+
continue;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
ring[key] = {
|
|
804
|
+
$description: `Generated ${description}`,
|
|
805
|
+
$type: "shadow",
|
|
806
|
+
$value: value,
|
|
807
|
+
theme: colorTokenTheme(name)
|
|
808
|
+
};
|
|
809
|
+
changed = true;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
return changed ? { ...dictionary, ring } : dictionary;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
function isLowSaturation(hex: string): boolean {
|
|
817
|
+
return hexToOklch(hex).chroma < LOW_SATURATION_CHROMA;
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
function isGreyscale(hex: string): boolean {
|
|
821
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
822
|
+
|
|
823
|
+
return (
|
|
824
|
+
expanded.slice(1, 3) === expanded.slice(3, 5) &&
|
|
825
|
+
expanded.slice(3, 5) === expanded.slice(5, 7)
|
|
826
|
+
);
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
/**
|
|
830
|
+
* Convert `#rrggbb` / `#rrggbbaa` to OKLCH so lightness can be scaled.
|
|
831
|
+
*
|
|
832
|
+
* @see https://bottosson.github.io/posts/oklab/
|
|
833
|
+
*/
|
|
834
|
+
function hexToOklch(hex: string): {
|
|
835
|
+
lightness: number;
|
|
836
|
+
chroma: number;
|
|
837
|
+
hue: number;
|
|
838
|
+
alpha?: number;
|
|
839
|
+
} {
|
|
840
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
841
|
+
const red = fromSrgb(Number.parseInt(expanded.slice(1, 3), 16) / 255);
|
|
842
|
+
const green = fromSrgb(Number.parseInt(expanded.slice(3, 5), 16) / 255);
|
|
843
|
+
const blue = fromSrgb(Number.parseInt(expanded.slice(5, 7), 16) / 255);
|
|
844
|
+
const alpha =
|
|
845
|
+
expanded.length === 9
|
|
846
|
+
? Number.parseInt(expanded.slice(7, 9), 16) / 255
|
|
847
|
+
: undefined;
|
|
848
|
+
|
|
849
|
+
const l_ =
|
|
850
|
+
0.412_221_470_8 * red + 0.536_332_536_3 * green + 0.051_445_992_9 * blue;
|
|
851
|
+
const m_ =
|
|
852
|
+
0.211_903_498_2 * red + 0.680_699_545_1 * green + 0.107_396_956_6 * blue;
|
|
853
|
+
const s_ =
|
|
854
|
+
0.088_302_461_9 * red + 0.281_718_837_6 * green + 0.629_978_700_5 * blue;
|
|
855
|
+
|
|
856
|
+
const l = Math.cbrt(l_);
|
|
857
|
+
const m = Math.cbrt(m_);
|
|
858
|
+
const s = Math.cbrt(s_);
|
|
859
|
+
|
|
860
|
+
const lightness =
|
|
861
|
+
0.210_454_255_3 * l + 0.793_617_785 * m - 0.004_072_046_8 * s;
|
|
862
|
+
const a = 1.977_998_495_1 * l - 2.428_592_205 * m + 0.450_593_709_9 * s;
|
|
863
|
+
const b = 0.025_904_037_1 * l + 0.782_771_766_2 * m - 0.808_675_766 * s;
|
|
864
|
+
|
|
865
|
+
const chroma = Math.hypot(a, b);
|
|
866
|
+
let hue = (Math.atan2(b, a) * 180) / Math.PI;
|
|
867
|
+
if (hue < 0) {
|
|
868
|
+
hue += 360;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return { lightness, chroma, hue, alpha };
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function isInSrgbGamut({
|
|
875
|
+
red,
|
|
876
|
+
green,
|
|
877
|
+
blue
|
|
878
|
+
}: {
|
|
879
|
+
red: number;
|
|
880
|
+
green: number;
|
|
881
|
+
blue: number;
|
|
882
|
+
}): boolean {
|
|
883
|
+
return (
|
|
884
|
+
red >= 0 && red <= 1 && green >= 0 && green <= 1 && blue >= 0 && blue <= 1
|
|
885
|
+
);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function oklchToGamutMappedHex(
|
|
889
|
+
lightness: number,
|
|
890
|
+
chroma: number,
|
|
891
|
+
hue: number,
|
|
892
|
+
alpha?: number
|
|
893
|
+
): string {
|
|
894
|
+
if (isInSrgbGamut(oklchToLinearSrgb(lightness, chroma, hue))) {
|
|
895
|
+
return oklchToHex(lightness, chroma, hue, alpha);
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
let minimumChroma = 0;
|
|
899
|
+
let maximumChroma = chroma;
|
|
900
|
+
|
|
901
|
+
// Find the highest in-gamut chroma while holding lightness and hue fixed.
|
|
902
|
+
for (
|
|
903
|
+
let index = 0;
|
|
904
|
+
index < SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS;
|
|
905
|
+
index += 1
|
|
906
|
+
) {
|
|
907
|
+
const candidateChroma = (minimumChroma + maximumChroma) / 2;
|
|
908
|
+
if (isInSrgbGamut(oklchToLinearSrgb(lightness, candidateChroma, hue))) {
|
|
909
|
+
minimumChroma = candidateChroma;
|
|
910
|
+
} else {
|
|
911
|
+
maximumChroma = candidateChroma;
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
return oklchToHex(lightness, minimumChroma, hue, alpha);
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function applyBrightness(hex: string, factor: number): string {
|
|
919
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
920
|
+
|
|
921
|
+
return oklchToHex(clamp01(lightness * factor), chroma, hue, alpha);
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
function applyLightnessShift(hex: string, shift: number): string {
|
|
925
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
926
|
+
|
|
927
|
+
return oklchToHex(clamp01(lightness + shift), chroma, hue, alpha);
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
function applySaturation(hex: string, factor: number): string {
|
|
931
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
932
|
+
|
|
933
|
+
return oklchToHex(lightness, chroma * clamp01(factor), hue, alpha);
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function relativeLuminance(hex: string): number {
|
|
937
|
+
const expanded = expandHex(hex).toLowerCase();
|
|
938
|
+
const channel = (offset: number) => {
|
|
939
|
+
const value = Number.parseInt(expanded.slice(offset, offset + 2), 16) / 255;
|
|
940
|
+
|
|
941
|
+
return value <= 0.040_45 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4;
|
|
942
|
+
};
|
|
943
|
+
|
|
944
|
+
return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5);
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
function contrastRatio(first: string, second: string): number {
|
|
948
|
+
const firstLuminance = relativeLuminance(first);
|
|
949
|
+
const secondLuminance = relativeLuminance(second);
|
|
950
|
+
const lighter = Math.max(firstLuminance, secondLuminance);
|
|
951
|
+
const darker = Math.min(firstLuminance, secondLuminance);
|
|
952
|
+
|
|
953
|
+
return (lighter + 0.05) / (darker + 0.05);
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
function createOnColor(hex: string): "#FAFAFA" | "#151518" {
|
|
957
|
+
const whiteContrast = contrastRatio(hex, "#FAFAFA");
|
|
958
|
+
if (whiteContrast >= MINIMUM_INVERSE_CONTRAST_RATIO) {
|
|
959
|
+
return "#FAFAFA";
|
|
960
|
+
}
|
|
961
|
+
|
|
962
|
+
const blackContrast = contrastRatio(hex, "#151518");
|
|
963
|
+
if (blackContrast * 0.5 > whiteContrast) {
|
|
964
|
+
return "#151518";
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
return "#FAFAFA";
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function createMutedColor(
|
|
971
|
+
hex: string,
|
|
972
|
+
parentTheme: ParentTheme
|
|
973
|
+
): { subtle: string; onMuted?: string } {
|
|
974
|
+
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
975
|
+
const subtleDirection = parentTheme === "light" ? 1 : -1;
|
|
976
|
+
const onMutedDirection = -subtleDirection;
|
|
977
|
+
let subtleLightness = lightness;
|
|
978
|
+
let onMutedLightness = lightness;
|
|
979
|
+
let subtle = hex;
|
|
980
|
+
let onMuted = hex;
|
|
981
|
+
|
|
982
|
+
for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
|
|
983
|
+
subtleLightness = clamp01(
|
|
984
|
+
subtleLightness + subtleDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
985
|
+
);
|
|
986
|
+
subtle = oklchToGamutMappedHex(subtleLightness, chroma, hue, alpha);
|
|
987
|
+
if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
988
|
+
return onMuted === hex ? { subtle } : { subtle, onMuted };
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
onMutedLightness = clamp01(
|
|
992
|
+
onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
993
|
+
);
|
|
994
|
+
onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
|
|
995
|
+
if (contrastRatio(subtle, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
996
|
+
return { subtle, onMuted };
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
if (
|
|
1000
|
+
(subtleLightness === 0 || subtleLightness === 1) &&
|
|
1001
|
+
(onMutedLightness === 0 || onMutedLightness === 1)
|
|
1002
|
+
) {
|
|
1003
|
+
break;
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
throw new Error(`Unable to generate an accessible subtle pair for ${hex}`);
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
|
|
1011
|
+
if (parentTheme === "dark") {
|
|
1012
|
+
return isWhiteColor(hex);
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
if (parentTheme === "light") {
|
|
1016
|
+
return !isBlackColor(hex);
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
return hexToOklch(hex).lightness >= 0.5;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function isDarkColor(hex: string, parentTheme?: ParentTheme): boolean {
|
|
1023
|
+
return !isLightColor(hex, parentTheme);
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function isWhiteColor(hex: string): boolean {
|
|
1027
|
+
return hexToOklch(hex).lightness >= 0.75;
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
function isBlackColor(hex: string): boolean {
|
|
1031
|
+
return hexToOklch(hex).lightness <= 0.25;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Resolve a state multiplier from the source color and parent theme. */
|
|
1035
|
+
function directedBrightnessFactor(
|
|
1036
|
+
hex: string,
|
|
1037
|
+
factor: number,
|
|
1038
|
+
parentTheme?: ParentTheme
|
|
1039
|
+
): number {
|
|
1040
|
+
return isDarkColor(hex, parentTheme) ? factor : 2 - factor;
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function contrastPrimitive(
|
|
1044
|
+
variant: ColorStateVariant,
|
|
1045
|
+
parentTheme?: ParentTheme
|
|
1046
|
+
): string | undefined {
|
|
1047
|
+
return variant.useContrastPrimitive && parentTheme
|
|
1048
|
+
? CONTRAST_PRIMITIVES[parentTheme]
|
|
1049
|
+
: undefined;
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/**
|
|
1053
|
+
* Active colors that already sit near the parent theme's contrast primitive
|
|
1054
|
+
* (near-white in dark themes, near-black in light themes) would barely change
|
|
1055
|
+
* by jumping to it, so their lightness shifts toward the middle instead.
|
|
1056
|
+
*/
|
|
1057
|
+
function contrastSideLightnessShift(
|
|
1058
|
+
hex: string,
|
|
1059
|
+
variant: ColorStateVariant,
|
|
1060
|
+
parentTheme?: ParentTheme
|
|
1061
|
+
): number | undefined {
|
|
1062
|
+
if (variant.name !== "active" || !contrastPrimitive(variant, parentTheme)) {
|
|
1063
|
+
return undefined;
|
|
1064
|
+
}
|
|
1065
|
+
|
|
1066
|
+
if (parentTheme === "dark" && isWhiteColor(hex)) {
|
|
1067
|
+
return -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
if (parentTheme === "light" && isBlackColor(hex)) {
|
|
1071
|
+
return CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
return undefined;
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function stateBrightnessFactor(
|
|
1078
|
+
hex: string,
|
|
1079
|
+
variant: ColorStateVariant,
|
|
1080
|
+
parentTheme?: ParentTheme
|
|
1081
|
+
): number {
|
|
1082
|
+
if (variant.brightness === undefined) {
|
|
1083
|
+
return 1;
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
if (contrastPrimitive(variant, parentTheme)) {
|
|
1087
|
+
return variant.name === "active" ? 1 : variant.brightness;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
return directedBrightnessFactor(hex, variant.brightness, parentTheme);
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
function applyStateTransform(
|
|
1094
|
+
hex: string,
|
|
1095
|
+
variant: ColorStateVariant,
|
|
1096
|
+
parentTheme?: ParentTheme
|
|
1097
|
+
): string {
|
|
1098
|
+
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1099
|
+
const primitive =
|
|
1100
|
+
shift === undefined ? contrastPrimitive(variant, parentTheme) : undefined;
|
|
1101
|
+
let transformed = primitive ?? hex;
|
|
1102
|
+
|
|
1103
|
+
if (shift !== undefined) {
|
|
1104
|
+
transformed = applyLightnessShift(hex, shift);
|
|
1105
|
+
} else if (
|
|
1106
|
+
variant.brightness !== undefined &&
|
|
1107
|
+
!(primitive && variant.name === "active")
|
|
1108
|
+
) {
|
|
1109
|
+
transformed = applyBrightness(
|
|
1110
|
+
transformed,
|
|
1111
|
+
stateBrightnessFactor(transformed, variant, parentTheme)
|
|
1112
|
+
);
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
if (variant.saturation !== undefined && !isGreyscale(hex)) {
|
|
1116
|
+
transformed = applySaturation(transformed, variant.saturation);
|
|
1117
|
+
if (variant.fadeOver === undefined) {
|
|
1118
|
+
return transformed;
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
if (variant.fadeOver !== undefined) {
|
|
1123
|
+
return applyFadeOver(transformed, variant.fadeOver, variant.opacity ?? 1);
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
if (variant.opacity !== undefined) {
|
|
1127
|
+
return applyOpacity(transformed, variant.opacity);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
return transformed;
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
function variantDetail(
|
|
1134
|
+
variant: ColorStateVariant,
|
|
1135
|
+
hex: string,
|
|
1136
|
+
parentTheme?: ParentTheme
|
|
1137
|
+
): string {
|
|
1138
|
+
const details: string[] = [];
|
|
1139
|
+
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1140
|
+
const primitive = contrastPrimitive(variant, parentTheme);
|
|
1141
|
+
|
|
1142
|
+
if (shift !== undefined) {
|
|
1143
|
+
details.push(
|
|
1144
|
+
`${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
|
|
1145
|
+
);
|
|
1146
|
+
} else if (primitive && variant.name === "active") {
|
|
1147
|
+
details.push(`${parentTheme} contrast primitive`);
|
|
1148
|
+
} else if (variant.brightness !== undefined) {
|
|
1149
|
+
const factor = stateBrightnessFactor(hex, variant, parentTheme);
|
|
1150
|
+
const percent = Math.round((factor - 1) * 100);
|
|
1151
|
+
|
|
1152
|
+
details.push(
|
|
1153
|
+
percent >= 0 ? `${percent}% brighter` : `${Math.abs(percent)}% darker`
|
|
1154
|
+
);
|
|
1155
|
+
}
|
|
1156
|
+
|
|
1157
|
+
if (variant.saturation !== undefined && !isGreyscale(hex)) {
|
|
1158
|
+
details.push(`${Math.round(variant.saturation * 100)}% saturation`);
|
|
1159
|
+
}
|
|
1160
|
+
|
|
1161
|
+
if (variant.fadeOver !== undefined) {
|
|
1162
|
+
details.push(
|
|
1163
|
+
`${Math.round((variant.opacity ?? 1) * 100)}% over ${variant.fadeOver}`
|
|
1164
|
+
);
|
|
1165
|
+
} else if (
|
|
1166
|
+
variant.opacity !== undefined &&
|
|
1167
|
+
(variant.saturation === undefined || isGreyscale(hex))
|
|
1168
|
+
) {
|
|
1169
|
+
details.push(`${Math.round(variant.opacity * 100)}% opacity`);
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
return details.join(", ") || variant.name;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function createStateToken(
|
|
1176
|
+
source: Record<string, unknown>,
|
|
1177
|
+
hex: string,
|
|
1178
|
+
variant: ColorStateVariant,
|
|
1179
|
+
parentTheme?: ParentTheme
|
|
1180
|
+
): Record<string, unknown> {
|
|
1181
|
+
const detail = variantDetail(variant, hex, parentTheme);
|
|
1182
|
+
const transformed = applyStateTransform(hex, variant, parentTheme);
|
|
1183
|
+
|
|
1184
|
+
return {
|
|
1185
|
+
...source,
|
|
1186
|
+
$type: "color",
|
|
1187
|
+
$value: transformed,
|
|
1188
|
+
$description:
|
|
1189
|
+
typeof source.$description === "string"
|
|
1190
|
+
? `${source.$description} (${variant.name}, ${detail})`
|
|
1191
|
+
: `${variant.name} state at ${detail}`
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function addColorStateTokens(
|
|
1196
|
+
group: Record<string, unknown>,
|
|
1197
|
+
tree: unknown,
|
|
1198
|
+
variants: ColorStateVariants,
|
|
1199
|
+
onlyKey?: string,
|
|
1200
|
+
parentTheme?: ParentTheme
|
|
1201
|
+
): Record<string, unknown> {
|
|
1202
|
+
const result = { ...group } as Record<string, any>;
|
|
1203
|
+
const names = onlyKey ? [onlyKey] : Object.keys(group);
|
|
1204
|
+
|
|
1205
|
+
for (const name of names) {
|
|
1206
|
+
if (name.startsWith("$") || isStateVariantKey(name)) {
|
|
1207
|
+
continue;
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
const token = group[name];
|
|
1211
|
+
if (!isTokenNode(token)) {
|
|
1212
|
+
continue;
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
const type = resolveType(token);
|
|
1216
|
+
if (type && type !== "color") {
|
|
1217
|
+
continue;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
const hex = resolveColorHex(token.$value, tree);
|
|
1221
|
+
if (!hex) {
|
|
1222
|
+
continue;
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
const tokenVariants = isLowSaturation(hex)
|
|
1226
|
+
? variants.lowSaturation
|
|
1227
|
+
: variants.saturated;
|
|
1228
|
+
const tokenParentTheme =
|
|
1229
|
+
parentThemeFromDescription(token.$description) ?? parentTheme;
|
|
1230
|
+
|
|
1231
|
+
for (const variant of tokenVariants) {
|
|
1232
|
+
const variantKey = `${name}-${variant.name}`;
|
|
1233
|
+
if (variantKey in result) {
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
result[variantKey] = createStateToken(
|
|
1238
|
+
token,
|
|
1239
|
+
hex,
|
|
1240
|
+
variant,
|
|
1241
|
+
tokenParentTheme
|
|
1242
|
+
);
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
return result;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/**
|
|
1250
|
+
* Add an `on-*-accent` text color (renamed from `on-*` when defined), an
|
|
1251
|
+
* `*-accent` alias, a `*-subtle` low-emphasis background, and an `on-*-subtle`
|
|
1252
|
+
* text color for each theme color (`base` and the status colors) that does
|
|
1253
|
+
* not define them, so state variants can be generated for the subtle
|
|
1254
|
+
* backgrounds.
|
|
1255
|
+
*/
|
|
1256
|
+
function addMissingStatusCompanionTokens(
|
|
1257
|
+
color: Record<string, unknown>,
|
|
1258
|
+
tree: unknown,
|
|
1259
|
+
parentTheme?: ParentTheme
|
|
1260
|
+
): Record<string, unknown> {
|
|
1261
|
+
const result = { ...color };
|
|
1262
|
+
for (const name of THEME_COLOR_KEYS) {
|
|
1263
|
+
const token = color[name];
|
|
1264
|
+
if (!isTokenNode(token)) {
|
|
1265
|
+
continue;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const type = resolveType(token);
|
|
1269
|
+
const hex = resolveColorHex(token.$value, tree);
|
|
1270
|
+
if ((type && type !== "color") || !hex) {
|
|
1271
|
+
continue;
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
// Tamagui drops the theme name from child theme keys, so `danger` would be
|
|
1275
|
+
// empty and `on-danger` would be `on`. The `*-accent` names become `accent`
|
|
1276
|
+
// and `onAccent` in the `danger` theme; `on-danger` is renamed rather than
|
|
1277
|
+
// aliased so it does not leak into the root themes.
|
|
1278
|
+
const onToken = color[`on-${name}`];
|
|
1279
|
+
delete result[`on-${name}`];
|
|
1280
|
+
result[`on-${name}-accent`] ??= isTokenNode(onToken)
|
|
1281
|
+
? { ...onToken, theme: name, group: name }
|
|
1282
|
+
: {
|
|
1283
|
+
...token,
|
|
1284
|
+
$type: "color",
|
|
1285
|
+
$value: createOnColor(hex),
|
|
1286
|
+
$description: `Generated text and icon color on ${name} backgrounds`,
|
|
1287
|
+
theme: name,
|
|
1288
|
+
group: name
|
|
1289
|
+
};
|
|
1290
|
+
|
|
1291
|
+
result[`${name}-accent`] ??= {
|
|
1292
|
+
$type: "color",
|
|
1293
|
+
$value: `{color.${name}}`,
|
|
1294
|
+
$description: `Alias of ${name} for the ${name} theme`,
|
|
1295
|
+
theme: name,
|
|
1296
|
+
group: name
|
|
1297
|
+
};
|
|
1298
|
+
|
|
1299
|
+
let onMuted: string | undefined;
|
|
1300
|
+
if (parentTheme && !(`${name}-subtle` in color)) {
|
|
1301
|
+
const pair = createMutedColor(hex, parentTheme);
|
|
1302
|
+
result[`${name}-subtle`] = {
|
|
1303
|
+
...token,
|
|
1304
|
+
$type: "color",
|
|
1305
|
+
$value: pair.subtle,
|
|
1306
|
+
$description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
|
|
1307
|
+
};
|
|
1308
|
+
onMuted = pair.onMuted ?? hex;
|
|
1309
|
+
} else if (isTokenNode(color[`${name}-subtle`])) {
|
|
1310
|
+
const subtleHex = resolveColorHex(
|
|
1311
|
+
(color[`${name}-subtle`] as Record<string, unknown>).$value,
|
|
1312
|
+
tree
|
|
1313
|
+
);
|
|
1314
|
+
onMuted = subtleHex && createOnColor(subtleHex);
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
if (onMuted && !(`on-${name}-subtle` in color)) {
|
|
1318
|
+
result[`on-${name}-subtle`] = {
|
|
1319
|
+
...token,
|
|
1320
|
+
$type: "color",
|
|
1321
|
+
$value: onMuted,
|
|
1322
|
+
$description: `Generated text and icon color on ${name}-subtle backgrounds`
|
|
1323
|
+
};
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
|
|
1327
|
+
return result;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Strip the `on-` prefix and `-subtle` / state suffixes from a token name. */
|
|
1331
|
+
function colorTokenRoot(name: string): string {
|
|
1332
|
+
return name
|
|
1333
|
+
.replace(/^on-/, "")
|
|
1334
|
+
.replace(/-(?:active|inactive|disabled)$/i, "")
|
|
1335
|
+
.replace(/-subtle$/, "");
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
/**
|
|
1339
|
+
* Resolve the Tamagui theme for a color token name: the theme color name for
|
|
1340
|
+
* `base` and status colors (including their `on-*`, `*-subtle`, and state
|
|
1341
|
+
* variants). Primary, secondary, and tertiary colors stay in the root light
|
|
1342
|
+
* and dark themes so every child theme inherits them.
|
|
1343
|
+
*/
|
|
1344
|
+
function colorTokenTheme(name: string): string | undefined {
|
|
1345
|
+
const root = colorTokenRoot(name);
|
|
1346
|
+
|
|
1347
|
+
return (THEME_COLOR_KEYS as readonly string[]).includes(root)
|
|
1348
|
+
? root
|
|
1349
|
+
: undefined;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
/**
|
|
1353
|
+
* Copy each primary and secondary color (and its `on-*` color) into the
|
|
1354
|
+
* `base` theme as `base` / `base-subtle`.
|
|
1355
|
+
*/
|
|
1356
|
+
function addBaseThemeAliases(
|
|
1357
|
+
color: Record<string, unknown>
|
|
1358
|
+
): Record<string, unknown> {
|
|
1359
|
+
const result = { ...color };
|
|
1360
|
+
for (const [name, token] of Object.entries(color)) {
|
|
1361
|
+
const root = colorTokenRoot(name);
|
|
1362
|
+
const baseName = BASE_THEME_COLOR_NAMES[root];
|
|
1363
|
+
if (!baseName || !isTokenNode(token)) {
|
|
1364
|
+
continue;
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// Copied rather than referenced so states resolve inside nested
|
|
1368
|
+
// `light` / `dark` color groups.
|
|
1369
|
+
result[name.replace(root, baseName)] ??= {
|
|
1370
|
+
...token,
|
|
1371
|
+
theme: "base",
|
|
1372
|
+
group: "base"
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return result;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
/** Add a `theme` to each themed color token that does not define one. */
|
|
1380
|
+
function addColorTokenThemes(
|
|
1381
|
+
color: Record<string, unknown>
|
|
1382
|
+
): Record<string, unknown> {
|
|
1383
|
+
const result = { ...color };
|
|
1384
|
+
for (const [name, token] of Object.entries(color)) {
|
|
1385
|
+
const theme = colorTokenTheme(name);
|
|
1386
|
+
if (theme && isTokenNode(token) && !("theme" in token)) {
|
|
1387
|
+
result[name] = { ...token, theme };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
return result;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
/**
|
|
1395
|
+
* Add a `group` (the main token name) to each state, `on-*`, and `*-subtle`
|
|
1396
|
+
* color token that does not define one, e.g. `border-active` → `border`.
|
|
1397
|
+
*/
|
|
1398
|
+
function addColorTokenGroups(
|
|
1399
|
+
group: Record<string, unknown>
|
|
1400
|
+
): Record<string, unknown> {
|
|
1401
|
+
const result = { ...group };
|
|
1402
|
+
for (const [name, token] of Object.entries(group)) {
|
|
1403
|
+
const root = colorTokenRoot(name);
|
|
1404
|
+
if (root !== name && isTokenNode(token) && !("group" in token)) {
|
|
1405
|
+
result[name] = { ...token, group: root };
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
return result;
|
|
1410
|
+
}
|
|
1411
|
+
|
|
1412
|
+
function injectColorStateVariants(
|
|
1413
|
+
node: unknown,
|
|
1414
|
+
tree: unknown,
|
|
1415
|
+
key?: string,
|
|
1416
|
+
parentTheme?: ParentTheme
|
|
1417
|
+
): unknown {
|
|
1418
|
+
if (Array.isArray(node)) {
|
|
1419
|
+
return node.map(item =>
|
|
1420
|
+
injectColorStateVariants(item, tree, undefined, parentTheme)
|
|
1421
|
+
);
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
if (!isPlainObject(node) || isTokenNode(node)) {
|
|
1425
|
+
return node;
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
const result: Record<string, unknown> = {};
|
|
1429
|
+
for (const [childKey, value] of Object.entries(node)) {
|
|
1430
|
+
if (RESERVED_KEYS.has(childKey) || childKey.startsWith("$")) {
|
|
1431
|
+
result[childKey] = value;
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const childTheme =
|
|
1436
|
+
childKey === "dark" || childKey === "light" ? childKey : parentTheme;
|
|
1437
|
+
result[childKey] = injectColorStateVariants(
|
|
1438
|
+
value,
|
|
1439
|
+
tree,
|
|
1440
|
+
childKey,
|
|
1441
|
+
childTheme
|
|
1442
|
+
);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
if (key?.startsWith(`${COLOR_STATE_GROUP_KEY}-`)) {
|
|
1446
|
+
return addColorTokenGroups(
|
|
1447
|
+
addColorStateTokens(
|
|
1448
|
+
result,
|
|
1449
|
+
tree,
|
|
1450
|
+
SURFACE_COLOR_STATE_VARIANTS,
|
|
1451
|
+
undefined,
|
|
1452
|
+
parentTheme
|
|
1453
|
+
)
|
|
1454
|
+
);
|
|
1455
|
+
}
|
|
1456
|
+
|
|
1457
|
+
let withStates =
|
|
1458
|
+
key === "color"
|
|
1459
|
+
? addMissingStatusCompanionTokens(
|
|
1460
|
+
addBaseThemeAliases(result),
|
|
1461
|
+
tree,
|
|
1462
|
+
parentTheme
|
|
1463
|
+
)
|
|
1464
|
+
: result;
|
|
1465
|
+
for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
|
|
1466
|
+
if (isTokenNode(withStates[tokenKey])) {
|
|
1467
|
+
withStates = addColorStateTokens(
|
|
1468
|
+
withStates,
|
|
1469
|
+
tree,
|
|
1470
|
+
variants,
|
|
1471
|
+
tokenKey,
|
|
1472
|
+
parentTheme
|
|
1473
|
+
);
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
return key === "color"
|
|
1478
|
+
? addColorTokenGroups(addColorTokenThemes(withStates))
|
|
1479
|
+
: withStates;
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
/**
|
|
1483
|
+
* Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
|
|
1484
|
+
* colors) to hex so generators emit `#rrggbb` / `#rrggbbaa` instead of
|
|
1485
|
+
* `oklch()`. Primary and secondary colors are first copied into the `base`
|
|
1486
|
+
* theme as `base` / `base-subtle`; primary, secondary, and tertiary themselves
|
|
1487
|
+
* stay in the root light and dark themes without rings or states. For `base`
|
|
1488
|
+
* and each status color, missing `on-*-accent` text (Tamagui `onAccent`; any
|
|
1489
|
+
* `on-*` color is renamed to it), `*-subtle` background, and `on-*-subtle`
|
|
1490
|
+
* text colors are generated next, along with a `*-accent` alias (Tamagui
|
|
1491
|
+
* `accent`), then active, inactive, and disabled variants are added to the
|
|
1492
|
+
* surface colors, border, and each theme color and its `*-subtle` color.
|
|
1493
|
+
* Low-saturation colors derive their states from the parent theme's contrast
|
|
1494
|
+
* primitive (surfaces use a subtler shift of their own lightness, and active
|
|
1495
|
+
* colors already near that primitive shift toward the middle); saturated
|
|
1496
|
+
* colors scale their own lightness. Theme colors get their name as theme,
|
|
1497
|
+
* unless one is already set. State, `on-*`, and `*-subtle` color tokens also
|
|
1498
|
+
* get their main token name as `group` (e.g. `border-active` → `border`).
|
|
1499
|
+
* Finally, focus rings are generated for `base` and each status color.
|
|
1500
|
+
*/
|
|
1501
|
+
function tamaguiPreprocessor(
|
|
1502
|
+
dictionary: PreprocessedTokens
|
|
1503
|
+
): PreprocessedTokens {
|
|
1504
|
+
const converted = walk(dictionary) as PreprocessedTokens;
|
|
1505
|
+
const withStateVariants = injectColorStateVariants(
|
|
1506
|
+
converted,
|
|
1507
|
+
converted,
|
|
1508
|
+
undefined,
|
|
1509
|
+
parentThemeFromDictionary(converted)
|
|
1510
|
+
) as PreprocessedTokens;
|
|
1511
|
+
const withRings = addMissingRingTokens(withStateVariants);
|
|
1512
|
+
|
|
1513
|
+
return applyRingOpacity(withRings, withRings) as PreprocessedTokens;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
export default definePlugin(() => ({
|
|
1517
|
+
name: "cyclone-ui:tamagui-preprocessor",
|
|
1518
|
+
preprocessors: [
|
|
1519
|
+
{
|
|
1520
|
+
name: "tamagui-preprocessor",
|
|
1521
|
+
preprocessor: tamaguiPreprocessor
|
|
1522
|
+
}
|
|
1523
|
+
]
|
|
1524
|
+
}));
|