@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.
@@ -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
+ }));