@cyclone-ui/theme-builder 0.0.4 → 0.0.6
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 +18 -0
- package/dist/index.cjs +1 -1
- package/dist/preprocessor.cjs +1 -1
- package/dist/preprocessor.d.cts +25 -1
- package/dist/preprocessor.d.cts.map +1 -1
- package/dist/preprocessor.d.mts +24 -1
- package/dist/preprocessor.mjs +1 -1
- package/dist/preprocessor.mjs.map +1 -1
- package/package.json +1 -1
- package/src/preprocessor.ts +258 -238
- package/test/preprocessor.spec.ts +170 -191
- package/test/shiki-theme-mapping.spec.ts +26 -27
- package/test/storybook-theme-mapping.spec.ts +31 -29
package/src/preprocessor.ts
CHANGED
|
@@ -416,6 +416,12 @@ const CONTRAST_PRIMITIVES: Record<ParentTheme, string> = {
|
|
|
416
416
|
light: "#0c0c0d"
|
|
417
417
|
};
|
|
418
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
|
+
|
|
419
425
|
function parentThemeFromDescription(
|
|
420
426
|
description: unknown
|
|
421
427
|
): ParentTheme | undefined {
|
|
@@ -453,17 +459,12 @@ const COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
|
453
459
|
brightness: 1.25
|
|
454
460
|
};
|
|
455
461
|
|
|
456
|
-
const SURFACE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
457
|
-
name: "active",
|
|
458
|
-
brightness: 1.075
|
|
459
|
-
};
|
|
460
|
-
|
|
461
462
|
const SUBTLE_LOW_SATURATION_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
462
463
|
name: "active",
|
|
463
464
|
brightness: 1.125
|
|
464
465
|
};
|
|
465
466
|
|
|
466
|
-
const
|
|
467
|
+
const HAIRLINE_COLOR_STATE_ACTIVE: ColorStateVariant = {
|
|
467
468
|
name: "active",
|
|
468
469
|
brightness: 1.4
|
|
469
470
|
};
|
|
@@ -517,23 +518,42 @@ const SUBTLE_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
|
517
518
|
};
|
|
518
519
|
|
|
519
520
|
/**
|
|
520
|
-
*
|
|
521
|
-
* primitive,
|
|
521
|
+
* Backgrounds shift their own lightness instead of jumping to the contrast
|
|
522
|
+
* primitive, at about half the change of other colors' states.
|
|
522
523
|
*/
|
|
523
|
-
const
|
|
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 = {
|
|
524
536
|
lowSaturation: [
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
537
|
+
{ name: "active", brightness: 1.15 },
|
|
538
|
+
BACKGROUND_COLOR_STATE_INACTIVE,
|
|
539
|
+
BACKGROUND_COLOR_STATE_DISABLED
|
|
528
540
|
],
|
|
529
|
-
saturated:
|
|
541
|
+
saturated: [
|
|
542
|
+
{ name: "active", brightness: 1.25 },
|
|
543
|
+
BACKGROUND_COLOR_STATE_INACTIVE,
|
|
544
|
+
BACKGROUND_COLOR_STATE_DISABLED
|
|
545
|
+
]
|
|
530
546
|
};
|
|
531
547
|
|
|
532
548
|
/**
|
|
533
|
-
*
|
|
534
|
-
*
|
|
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.
|
|
535
554
|
*/
|
|
536
|
-
const
|
|
555
|
+
const THEME_COLOR_KEYS = [
|
|
556
|
+
"base",
|
|
537
557
|
"danger",
|
|
538
558
|
"warning",
|
|
539
559
|
"info",
|
|
@@ -541,63 +561,77 @@ const STATUS_COLOR_KEYS = [
|
|
|
541
561
|
"success"
|
|
542
562
|
] as const;
|
|
543
563
|
|
|
544
|
-
/**
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
*/
|
|
550
|
-
const BASE_THEME_COLOR_NAMES: Record<string, string> = {
|
|
551
|
-
primary: "base",
|
|
552
|
-
tertiary: "base-subtle"
|
|
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
|
+
link: DEFAULT_COLOR_STATE_VARIANTS
|
|
553
569
|
};
|
|
554
570
|
|
|
555
571
|
/**
|
|
556
|
-
*
|
|
557
|
-
*
|
|
572
|
+
* Foreground and background hovered states are a smaller step than active
|
|
573
|
+
* (e.g. in dark themes, active is lighter than hovered).
|
|
558
574
|
*/
|
|
559
|
-
const
|
|
575
|
+
const FOREGROUND_COLOR_STATE_VARIANTS: ColorStateVariants = {
|
|
576
|
+
lowSaturation: [
|
|
577
|
+
{ name: "hovered", brightness: 1.3 },
|
|
578
|
+
...DEFAULT_COLOR_STATE_VARIANTS.lowSaturation
|
|
579
|
+
],
|
|
580
|
+
saturated: [
|
|
581
|
+
{ name: "hovered", brightness: 1.15 },
|
|
582
|
+
...DEFAULT_COLOR_STATE_VARIANTS.saturated
|
|
583
|
+
]
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
const BACKGROUND_COLOR_STATE_HOVERED_VARIANTS: ColorStateVariants = {
|
|
587
|
+
lowSaturation: [
|
|
588
|
+
{ name: "hovered", brightness: 1.1 },
|
|
589
|
+
...BACKGROUND_COLOR_STATE_VARIANTS.lowSaturation
|
|
590
|
+
],
|
|
591
|
+
saturated: [
|
|
592
|
+
{ name: "hovered", brightness: 1.15 },
|
|
593
|
+
...BACKGROUND_COLOR_STATE_VARIANTS.saturated
|
|
594
|
+
]
|
|
595
|
+
};
|
|
560
596
|
|
|
561
|
-
/**
|
|
562
|
-
const
|
|
597
|
+
/** Color tokens with these `group`s receive state variants. */
|
|
598
|
+
const COLOR_STATE_TOKEN_GROUPS: Record<string, ColorStateVariants> = {
|
|
599
|
+
foreground: FOREGROUND_COLOR_STATE_VARIANTS,
|
|
600
|
+
background: BACKGROUND_COLOR_STATE_HOVERED_VARIANTS
|
|
601
|
+
};
|
|
563
602
|
|
|
564
603
|
/** Standalone color tokens that receive state variants. */
|
|
565
604
|
const COLOR_STATE_VARIANTS: Record<string, ColorStateVariants> = {
|
|
566
|
-
|
|
605
|
+
outline: {
|
|
567
606
|
lowSaturation: [
|
|
568
607
|
INCREASED_LOW_SATURATION_COLOR_STATE_ACTIVE,
|
|
569
608
|
LOW_SATURATION_COLOR_STATE_INACTIVE,
|
|
570
609
|
COLOR_STATE_DISABLED
|
|
571
610
|
],
|
|
572
611
|
saturated: [
|
|
573
|
-
|
|
612
|
+
HAIRLINE_COLOR_STATE_ACTIVE,
|
|
574
613
|
COLOR_STATE_INACTIVE,
|
|
575
614
|
COLOR_STATE_DISABLED
|
|
576
615
|
]
|
|
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
|
-
)
|
|
616
|
+
}
|
|
584
617
|
};
|
|
585
618
|
|
|
586
619
|
/** OKLCH chroma below which a color uses the low-saturation state variants. */
|
|
587
620
|
const LOW_SATURATION_CHROMA = 0.02;
|
|
588
621
|
|
|
589
|
-
const LOW_SATURATION_RING_OPACITY = 0.
|
|
622
|
+
const LOW_SATURATION_RING_OPACITY = 0.1;
|
|
590
623
|
const SATURATED_RING_OPACITY = 0.15;
|
|
591
|
-
const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.
|
|
624
|
+
const MINIMUM_SUBTLE_CONTRAST_RATIO = 7.5;
|
|
592
625
|
const MINIMUM_INVERSE_CONTRAST_RATIO = 2;
|
|
593
626
|
const SUBTLE_COLOR_LIGHTNESS_STEP = 0.01;
|
|
594
627
|
const MAX_SUBTLE_COLOR_ADJUSTMENTS = 100;
|
|
595
628
|
const SUBTLE_COLOR_GAMUT_SEARCH_ITERATIONS = 32;
|
|
596
|
-
/** OKLCH lightness shift for
|
|
597
|
-
const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.
|
|
629
|
+
/** OKLCH lightness shift for hovered colors already near the contrast primitive. */
|
|
630
|
+
const CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT = 0.25;
|
|
598
631
|
|
|
599
632
|
function isStateVariantKey(key: string): boolean {
|
|
600
633
|
return (
|
|
634
|
+
key.toLowerCase().endsWith("-hovered") ||
|
|
601
635
|
key.toLowerCase().endsWith("-active") ||
|
|
602
636
|
key.toLowerCase().endsWith("-inactive") ||
|
|
603
637
|
key.toLowerCase().endsWith("-disabled")
|
|
@@ -773,16 +807,17 @@ function addMissingRingTokens(dictionary: unknown): unknown {
|
|
|
773
807
|
let changed = false;
|
|
774
808
|
|
|
775
809
|
for (const name of THEME_COLOR_KEYS) {
|
|
776
|
-
|
|
810
|
+
const group = color[name];
|
|
811
|
+
if (!isPlainObject(group) || !isTokenNode(group.primary)) {
|
|
777
812
|
continue;
|
|
778
813
|
}
|
|
779
814
|
|
|
780
|
-
const ringColor = `{color.${name}}`;
|
|
781
|
-
const gap = ringLayer("{color.
|
|
815
|
+
const ringColor = `{color.${name}.primary}`;
|
|
816
|
+
const gap = ringLayer("{color.elevated}", 3);
|
|
782
817
|
const variants: Record<string, [string, unknown[]]> = {
|
|
783
818
|
[name]: [`${name} 3px focus ring with no gap`, [ringLayer(ringColor, 3)]],
|
|
784
819
|
[`${name}-subtle`]: [
|
|
785
|
-
`${name} 1px
|
|
820
|
+
`${name} 1px outline focus ring with no gap`,
|
|
786
821
|
[ringLayer(ringColor, 1)]
|
|
787
822
|
],
|
|
788
823
|
[`${name}-offset`]: [
|
|
@@ -804,7 +839,7 @@ function addMissingRingTokens(dictionary: unknown): unknown {
|
|
|
804
839
|
$description: `Generated ${description}`,
|
|
805
840
|
$type: "shadow",
|
|
806
841
|
$value: value,
|
|
807
|
-
theme:
|
|
842
|
+
theme: name
|
|
808
843
|
};
|
|
809
844
|
changed = true;
|
|
810
845
|
}
|
|
@@ -970,41 +1005,41 @@ function createOnColor(hex: string): "#FAFAFA" | "#151518" {
|
|
|
970
1005
|
function createMutedColor(
|
|
971
1006
|
hex: string,
|
|
972
1007
|
parentTheme: ParentTheme
|
|
973
|
-
): {
|
|
1008
|
+
): { secondary: string; onMuted?: string } {
|
|
974
1009
|
const { lightness, chroma, hue, alpha } = hexToOklch(hex);
|
|
975
|
-
const
|
|
976
|
-
const onMutedDirection = -
|
|
977
|
-
let
|
|
1010
|
+
const secondaryDirection = parentTheme === "light" ? 1 : -1;
|
|
1011
|
+
const onMutedDirection = -secondaryDirection;
|
|
1012
|
+
let secondaryLightness = lightness;
|
|
978
1013
|
let onMutedLightness = lightness;
|
|
979
|
-
let
|
|
1014
|
+
let secondary = hex;
|
|
980
1015
|
let onMuted = hex;
|
|
981
1016
|
|
|
982
1017
|
for (let index = 0; index < MAX_SUBTLE_COLOR_ADJUSTMENTS; index += 1) {
|
|
983
|
-
|
|
984
|
-
|
|
1018
|
+
secondaryLightness = clamp01(
|
|
1019
|
+
secondaryLightness + secondaryDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
985
1020
|
);
|
|
986
|
-
|
|
987
|
-
if (contrastRatio(
|
|
988
|
-
return onMuted === hex ? {
|
|
1021
|
+
secondary = oklchToGamutMappedHex(secondaryLightness, chroma, hue, alpha);
|
|
1022
|
+
if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
1023
|
+
return onMuted === hex ? { secondary } : { secondary, onMuted };
|
|
989
1024
|
}
|
|
990
1025
|
|
|
991
1026
|
onMutedLightness = clamp01(
|
|
992
1027
|
onMutedLightness + onMutedDirection * SUBTLE_COLOR_LIGHTNESS_STEP
|
|
993
1028
|
);
|
|
994
1029
|
onMuted = oklchToGamutMappedHex(onMutedLightness, chroma, hue, alpha);
|
|
995
|
-
if (contrastRatio(
|
|
996
|
-
return {
|
|
1030
|
+
if (contrastRatio(secondary, onMuted) >= MINIMUM_SUBTLE_CONTRAST_RATIO) {
|
|
1031
|
+
return { secondary, onMuted };
|
|
997
1032
|
}
|
|
998
1033
|
|
|
999
1034
|
if (
|
|
1000
|
-
(
|
|
1035
|
+
(secondaryLightness === 0 || secondaryLightness === 1) &&
|
|
1001
1036
|
(onMutedLightness === 0 || onMutedLightness === 1)
|
|
1002
1037
|
) {
|
|
1003
1038
|
break;
|
|
1004
1039
|
}
|
|
1005
1040
|
}
|
|
1006
1041
|
|
|
1007
|
-
throw new Error(`Unable to generate an accessible
|
|
1042
|
+
throw new Error(`Unable to generate an accessible secondary pair for ${hex}`);
|
|
1008
1043
|
}
|
|
1009
1044
|
|
|
1010
1045
|
function isLightColor(hex: string, parentTheme?: ParentTheme): boolean {
|
|
@@ -1031,6 +1066,20 @@ function isBlackColor(hex: string): boolean {
|
|
|
1031
1066
|
return hexToOklch(hex).lightness <= 0.25;
|
|
1032
1067
|
}
|
|
1033
1068
|
|
|
1069
|
+
/** Hovered and active states share the contrast primitive behavior. */
|
|
1070
|
+
function isInteractionState(variant: ColorStateVariant): boolean {
|
|
1071
|
+
return variant.name === "hovered" || variant.name === "active";
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function isNearContrastPrimitive(
|
|
1075
|
+
hex: string,
|
|
1076
|
+
parentTheme?: ParentTheme
|
|
1077
|
+
): boolean {
|
|
1078
|
+
return parentTheme === "dark"
|
|
1079
|
+
? isWhiteColor(hex)
|
|
1080
|
+
: parentTheme === "light" && isBlackColor(hex);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1034
1083
|
/** Resolve a state multiplier from the source color and parent theme. */
|
|
1035
1084
|
function directedBrightnessFactor(
|
|
1036
1085
|
hex: string,
|
|
@@ -1050,7 +1099,7 @@ function contrastPrimitive(
|
|
|
1050
1099
|
}
|
|
1051
1100
|
|
|
1052
1101
|
/**
|
|
1053
|
-
*
|
|
1102
|
+
* Hovered colors that already sit near the parent theme's contrast primitive
|
|
1054
1103
|
* (near-white in dark themes, near-black in light themes) would barely change
|
|
1055
1104
|
* by jumping to it, so their lightness shifts toward the middle instead.
|
|
1056
1105
|
*/
|
|
@@ -1059,19 +1108,37 @@ function contrastSideLightnessShift(
|
|
|
1059
1108
|
variant: ColorStateVariant,
|
|
1060
1109
|
parentTheme?: ParentTheme
|
|
1061
1110
|
): number | undefined {
|
|
1062
|
-
if (
|
|
1111
|
+
if (
|
|
1112
|
+
variant.name !== "hovered" ||
|
|
1113
|
+
!contrastPrimitive(variant, parentTheme) ||
|
|
1114
|
+
!isNearContrastPrimitive(hex, parentTheme)
|
|
1115
|
+
) {
|
|
1063
1116
|
return undefined;
|
|
1064
1117
|
}
|
|
1065
1118
|
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1119
|
+
return parentTheme === "dark"
|
|
1120
|
+
? -CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT
|
|
1121
|
+
: CONTRAST_SIDE_ACTIVE_LIGHTNESS_SHIFT;
|
|
1122
|
+
}
|
|
1069
1123
|
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1124
|
+
/**
|
|
1125
|
+
* The color an interaction state jumps to. Active colors already near the
|
|
1126
|
+
* contrast primitive go to pure white (dark) or pure black (light) so they
|
|
1127
|
+
* never lose contrast.
|
|
1128
|
+
*/
|
|
1129
|
+
function stateContrastColor(
|
|
1130
|
+
hex: string,
|
|
1131
|
+
variant: ColorStateVariant,
|
|
1132
|
+
parentTheme?: ParentTheme
|
|
1133
|
+
): string | undefined {
|
|
1134
|
+
const primitive = contrastPrimitive(variant, parentTheme);
|
|
1073
1135
|
|
|
1074
|
-
return
|
|
1136
|
+
return primitive &&
|
|
1137
|
+
parentTheme &&
|
|
1138
|
+
variant.name === "active" &&
|
|
1139
|
+
isNearContrastPrimitive(hex, parentTheme)
|
|
1140
|
+
? PURE_CONTRAST_COLORS[parentTheme]
|
|
1141
|
+
: primitive;
|
|
1075
1142
|
}
|
|
1076
1143
|
|
|
1077
1144
|
function stateBrightnessFactor(
|
|
@@ -1084,7 +1151,7 @@ function stateBrightnessFactor(
|
|
|
1084
1151
|
}
|
|
1085
1152
|
|
|
1086
1153
|
if (contrastPrimitive(variant, parentTheme)) {
|
|
1087
|
-
return variant
|
|
1154
|
+
return isInteractionState(variant) ? 1 : variant.brightness;
|
|
1088
1155
|
}
|
|
1089
1156
|
|
|
1090
1157
|
return directedBrightnessFactor(hex, variant.brightness, parentTheme);
|
|
@@ -1097,14 +1164,16 @@ function applyStateTransform(
|
|
|
1097
1164
|
): string {
|
|
1098
1165
|
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1099
1166
|
const primitive =
|
|
1100
|
-
shift === undefined
|
|
1167
|
+
shift === undefined
|
|
1168
|
+
? stateContrastColor(hex, variant, parentTheme)
|
|
1169
|
+
: undefined;
|
|
1101
1170
|
let transformed = primitive ?? hex;
|
|
1102
1171
|
|
|
1103
1172
|
if (shift !== undefined) {
|
|
1104
1173
|
transformed = applyLightnessShift(hex, shift);
|
|
1105
1174
|
} else if (
|
|
1106
1175
|
variant.brightness !== undefined &&
|
|
1107
|
-
!(primitive && variant
|
|
1176
|
+
!(primitive && isInteractionState(variant))
|
|
1108
1177
|
) {
|
|
1109
1178
|
transformed = applyBrightness(
|
|
1110
1179
|
transformed,
|
|
@@ -1137,14 +1206,18 @@ function variantDetail(
|
|
|
1137
1206
|
): string {
|
|
1138
1207
|
const details: string[] = [];
|
|
1139
1208
|
const shift = contrastSideLightnessShift(hex, variant, parentTheme);
|
|
1140
|
-
const primitive =
|
|
1209
|
+
const primitive = stateContrastColor(hex, variant, parentTheme);
|
|
1141
1210
|
|
|
1142
1211
|
if (shift !== undefined) {
|
|
1143
1212
|
details.push(
|
|
1144
1213
|
`${Math.round(Math.abs(shift) * 100)}% ${shift < 0 ? "darker" : "lighter"}`
|
|
1145
1214
|
);
|
|
1146
|
-
} else if (primitive && variant
|
|
1147
|
-
details.push(
|
|
1215
|
+
} else if (primitive && isInteractionState(variant)) {
|
|
1216
|
+
details.push(
|
|
1217
|
+
parentTheme && primitive !== CONTRAST_PRIMITIVES[parentTheme]
|
|
1218
|
+
? `pure ${parentTheme === "dark" ? "white" : "black"}`
|
|
1219
|
+
: `${parentTheme} contrast primitive`
|
|
1220
|
+
);
|
|
1148
1221
|
} else if (variant.brightness !== undefined) {
|
|
1149
1222
|
const factor = stateBrightnessFactor(hex, variant, parentTheme);
|
|
1150
1223
|
const percent = Math.round((factor - 1) * 100);
|
|
@@ -1222,9 +1295,16 @@ function addColorStateTokens(
|
|
|
1222
1295
|
continue;
|
|
1223
1296
|
}
|
|
1224
1297
|
|
|
1225
|
-
|
|
1298
|
+
// Hovered states default to the matching active variant.
|
|
1299
|
+
const baseVariants = isLowSaturation(hex)
|
|
1226
1300
|
? variants.lowSaturation
|
|
1227
1301
|
: variants.saturated;
|
|
1302
|
+
const hasHovered = baseVariants.some(variant => variant.name === "hovered");
|
|
1303
|
+
const tokenVariants = baseVariants.flatMap(variant =>
|
|
1304
|
+
variant.name === "active" && !hasHovered
|
|
1305
|
+
? [{ ...variant, name: "hovered" }, variant]
|
|
1306
|
+
: [variant]
|
|
1307
|
+
);
|
|
1228
1308
|
const tokenParentTheme =
|
|
1229
1309
|
parentThemeFromDescription(token.$description) ?? parentTheme;
|
|
1230
1310
|
|
|
@@ -1247,144 +1327,71 @@ function addColorStateTokens(
|
|
|
1247
1327
|
}
|
|
1248
1328
|
|
|
1249
1329
|
/**
|
|
1250
|
-
*
|
|
1251
|
-
*
|
|
1252
|
-
*
|
|
1253
|
-
*
|
|
1254
|
-
*
|
|
1330
|
+
* Fill in a theme color group (`color.danger`, …): generate a missing
|
|
1331
|
+
* `secondary` low-emphasis background from `primary` and the parent theme,
|
|
1332
|
+
* a missing `link` color (aliasing `primary`), `on-primary` / `on-secondary`
|
|
1333
|
+
* text colors, and state variants for `primary`, `secondary` and `link`. Every token in the group gets the group name as
|
|
1334
|
+
* its `theme` and `group`, so Tamagui places it in that child theme (e.g.
|
|
1335
|
+
* `danger.on-primary` → `onPrimary` in the `danger` theme).
|
|
1255
1336
|
*/
|
|
1256
|
-
function
|
|
1257
|
-
|
|
1337
|
+
function addThemeColorGroupTokens(
|
|
1338
|
+
name: string,
|
|
1339
|
+
group: Record<string, unknown>,
|
|
1258
1340
|
tree: unknown,
|
|
1259
1341
|
parentTheme?: ParentTheme
|
|
1260
1342
|
): Record<string, unknown> {
|
|
1261
|
-
const
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1343
|
+
const token = group.primary;
|
|
1344
|
+
const hex = isTokenNode(token) && resolveColorHex(token.$value, tree);
|
|
1345
|
+
const type = isTokenNode(token) && resolveType(token);
|
|
1346
|
+
if (!isTokenNode(token) || !hex || (type && type !== "color")) {
|
|
1347
|
+
return group;
|
|
1348
|
+
}
|
|
1267
1349
|
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
}
|
|
1350
|
+
let result: Record<string, unknown> = { ...group };
|
|
1351
|
+
result.link ??= {
|
|
1352
|
+
...token,
|
|
1353
|
+
$type: "color",
|
|
1354
|
+
$value: `{color.${name}.primary}`,
|
|
1355
|
+
$description: `Generated ${name} hyperlink text color (matches primary)`
|
|
1356
|
+
};
|
|
1357
|
+
result["on-primary"] ??= {
|
|
1358
|
+
...token,
|
|
1359
|
+
$type: "color",
|
|
1360
|
+
$value: createOnColor(hex),
|
|
1361
|
+
$description: `Generated text and icon color on ${name} primary backgrounds`
|
|
1362
|
+
};
|
|
1273
1363
|
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
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`] ??= {
|
|
1364
|
+
let onSecondary: string | undefined;
|
|
1365
|
+
if (parentTheme && !("secondary" in group)) {
|
|
1366
|
+
const pair = createMutedColor(hex, parentTheme);
|
|
1367
|
+
result.secondary = {
|
|
1368
|
+
...token,
|
|
1292
1369
|
$type: "color",
|
|
1293
|
-
$value:
|
|
1294
|
-
$description: `
|
|
1295
|
-
theme: name,
|
|
1296
|
-
group: name
|
|
1370
|
+
$value: pair.secondary,
|
|
1371
|
+
$description: `Generated low-emphasis ${name} background for the ${parentTheme} theme`
|
|
1297
1372
|
};
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
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
|
-
}
|
|
1373
|
+
onSecondary = pair.onMuted ?? hex;
|
|
1374
|
+
} else if (isTokenNode(group.secondary)) {
|
|
1375
|
+
const secondaryHex = resolveColorHex(group.secondary.$value, tree);
|
|
1376
|
+
onSecondary = secondaryHex && createOnColor(secondaryHex);
|
|
1325
1377
|
}
|
|
1326
1378
|
|
|
1327
|
-
|
|
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)] ??= {
|
|
1379
|
+
if (onSecondary && !("on-secondary" in group)) {
|
|
1380
|
+
result["on-secondary"] = {
|
|
1370
1381
|
...token,
|
|
1371
|
-
|
|
1372
|
-
|
|
1382
|
+
$type: "color",
|
|
1383
|
+
$value: onSecondary,
|
|
1384
|
+
$description: `Generated text and icon color on ${name} secondary backgrounds`
|
|
1373
1385
|
};
|
|
1374
1386
|
}
|
|
1375
1387
|
|
|
1376
|
-
|
|
1377
|
-
|
|
1388
|
+
for (const [key, variants] of Object.entries(THEME_COLOR_STATE_VARIANTS)) {
|
|
1389
|
+
result = addColorStateTokens(result, tree, variants, key, parentTheme);
|
|
1390
|
+
}
|
|
1378
1391
|
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
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 };
|
|
1392
|
+
for (const [key, value] of Object.entries(result)) {
|
|
1393
|
+
if (isTokenNode(value)) {
|
|
1394
|
+
result[key] = { theme: name, group: name, ...value };
|
|
1388
1395
|
}
|
|
1389
1396
|
}
|
|
1390
1397
|
|
|
@@ -1392,15 +1399,17 @@ function addColorTokenThemes(
|
|
|
1392
1399
|
}
|
|
1393
1400
|
|
|
1394
1401
|
/**
|
|
1395
|
-
* Add a `group` (the main token name) to each state
|
|
1396
|
-
*
|
|
1402
|
+
* Add a `group` (the main token name) to each state and `on-*` color token
|
|
1403
|
+
* that does not define one, e.g. `outline-active` → `outline`.
|
|
1397
1404
|
*/
|
|
1398
1405
|
function addColorTokenGroups(
|
|
1399
1406
|
group: Record<string, unknown>
|
|
1400
1407
|
): Record<string, unknown> {
|
|
1401
1408
|
const result = { ...group };
|
|
1402
1409
|
for (const [name, token] of Object.entries(group)) {
|
|
1403
|
-
const root =
|
|
1410
|
+
const root = name
|
|
1411
|
+
.replace(/^on-/, "")
|
|
1412
|
+
.replace(/-(?:hovered|active|inactive|disabled)$/i, "");
|
|
1404
1413
|
if (root !== name && isTokenNode(token) && !("group" in token)) {
|
|
1405
1414
|
result[name] = { ...token, group: root };
|
|
1406
1415
|
}
|
|
@@ -1442,26 +1451,37 @@ function injectColorStateVariants(
|
|
|
1442
1451
|
);
|
|
1443
1452
|
}
|
|
1444
1453
|
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1454
|
+
let withStates = result;
|
|
1455
|
+
if (key === "color") {
|
|
1456
|
+
for (const name of THEME_COLOR_KEYS) {
|
|
1457
|
+
const group = withStates[name];
|
|
1458
|
+
if (isPlainObject(group) && !isTokenNode(group)) {
|
|
1459
|
+
withStates[name] = addThemeColorGroupTokens(
|
|
1460
|
+
name,
|
|
1461
|
+
group,
|
|
1462
|
+
tree,
|
|
1463
|
+
parentTheme
|
|
1464
|
+
);
|
|
1465
|
+
}
|
|
1466
|
+
}
|
|
1456
1467
|
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1468
|
+
for (const [tokenKey, token] of Object.entries(withStates)) {
|
|
1469
|
+
const variants =
|
|
1470
|
+
isTokenNode(token) && typeof token.group === "string"
|
|
1471
|
+
? COLOR_STATE_TOKEN_GROUPS[token.group]
|
|
1472
|
+
: undefined;
|
|
1473
|
+
if (variants) {
|
|
1474
|
+
withStates = addColorStateTokens(
|
|
1475
|
+
withStates,
|
|
1461
1476
|
tree,
|
|
1477
|
+
variants,
|
|
1478
|
+
tokenKey,
|
|
1462
1479
|
parentTheme
|
|
1463
|
-
)
|
|
1464
|
-
|
|
1480
|
+
);
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1465
1485
|
for (const [tokenKey, variants] of Object.entries(COLOR_STATE_VARIANTS)) {
|
|
1466
1486
|
if (isTokenNode(withStates[tokenKey])) {
|
|
1467
1487
|
withStates = addColorStateTokens(
|
|
@@ -1474,31 +1494,31 @@ function injectColorStateVariants(
|
|
|
1474
1494
|
}
|
|
1475
1495
|
}
|
|
1476
1496
|
|
|
1477
|
-
return key === "color"
|
|
1478
|
-
? addColorTokenGroups(addColorTokenThemes(withStates))
|
|
1479
|
-
: withStates;
|
|
1497
|
+
return key === "color" ? addColorTokenGroups(withStates) : withStates;
|
|
1480
1498
|
}
|
|
1481
1499
|
|
|
1482
1500
|
/**
|
|
1483
1501
|
* Style Dictionary preprocessor: rewrite color `$value`s (and nested shadow
|
|
1484
1502
|
* colors) to hex so generators emit `#rrggbb` / `#rrggbbaa` instead of
|
|
1485
|
-
* `oklch()`.
|
|
1486
|
-
*
|
|
1487
|
-
*
|
|
1488
|
-
*
|
|
1489
|
-
*
|
|
1490
|
-
*
|
|
1491
|
-
* `
|
|
1492
|
-
* surface colors, border, and each theme color and its `*-subtle` color.
|
|
1503
|
+
* `oklch()`. Root colors (emphasis, ink, subtle, subtlest, outline, backgrounds)
|
|
1504
|
+
* stay in the root light and dark themes without rings. For the `base` group and each
|
|
1505
|
+
* status group, a missing `secondary` background and `on-primary` /
|
|
1506
|
+
* `on-secondary` text colors are generated, then hovered, active, inactive,
|
|
1507
|
+
* and disabled variants are added to the outline, `foreground` and
|
|
1508
|
+
* `background` group colors, and each theme color group's `primary`, `secondary`
|
|
1509
|
+
* and `link` (which defaults to `primary` when missing).
|
|
1493
1510
|
* Low-saturation colors derive their states from the parent theme's contrast
|
|
1494
|
-
* primitive (
|
|
1495
|
-
* colors already near that primitive shift toward the middle
|
|
1496
|
-
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1511
|
+
* primitive (backgrounds use a half-strength shift of their own lightness; hovered
|
|
1512
|
+
* colors already near that primitive shift toward the middle, while active
|
|
1513
|
+
* ones become pure white or black); saturated
|
|
1514
|
+
* colors scale their own lightness. Foreground and background hovered states
|
|
1515
|
+
* take a smaller step than their active states. Tokens in a theme color group get the
|
|
1516
|
+
* group name as `theme` and `group`, unless already set; root state and `on-*`
|
|
1517
|
+
* tokens get their main token name as `group` (e.g. `outline-active` →
|
|
1518
|
+
* `outline`).
|
|
1499
1519
|
* Finally, focus rings are generated for `base` and each status color.
|
|
1500
1520
|
*/
|
|
1501
|
-
function tamaguiPreprocessor(
|
|
1521
|
+
export function tamaguiPreprocessor(
|
|
1502
1522
|
dictionary: PreprocessedTokens
|
|
1503
1523
|
): PreprocessedTokens {
|
|
1504
1524
|
const converted = walk(dictionary) as PreprocessedTokens;
|