@multiplatform.one/theme 6.1.0 → 6.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (187) hide show
  1. package/README.md +73 -0
  2. package/package.json +22 -9
  3. package/src/dates.spec.ts +75 -0
  4. package/src/dates.ts +130 -0
  5. package/src/devWarn.spec.ts +144 -0
  6. package/src/devWarn.ts +235 -0
  7. package/src/figma/figmaTokens.spec.ts +244 -0
  8. package/src/figma/figmaTokens.ts +503 -0
  9. package/src/font.ts +6 -8
  10. package/src/fonts/createFontLoader.web.ts +15 -4
  11. package/src/index.ts +7 -0
  12. package/src/keyboardFocusRing.ts +45 -0
  13. package/src/menuRow.spec.ts +32 -0
  14. package/src/menuRow.ts +31 -0
  15. package/src/theme/FontKnobStyles.native.tsx +24 -0
  16. package/src/theme/FontKnobStyles.tsx +256 -0
  17. package/src/theme/animations/reactNative.ts +6 -1
  18. package/src/theme/chartPalette.spec.ts +159 -0
  19. package/src/theme/chartPalette.ts +170 -0
  20. package/src/theme/colorRules.ts +33 -1
  21. package/src/theme/cookies.spec.ts +32 -0
  22. package/src/theme/cookiesPersistence.spec.ts +86 -0
  23. package/src/theme/cornerSmoothing.native.ts +16 -0
  24. package/src/theme/cornerSmoothing.spec.ts +27 -0
  25. package/src/theme/cornerSmoothing.ts +46 -0
  26. package/src/theme/createDefaultThemeConfig.ts +32 -8
  27. package/src/theme/createThemeConfig.spec.ts +137 -0
  28. package/src/theme/createThemes.ts +77 -2
  29. package/src/theme/defaults/accent.ts +39 -0
  30. package/src/theme/defaults/base.ts +63 -0
  31. package/src/theme/defaults/builderOptions.ts +174 -0
  32. package/src/theme/defaults/fonts.spec.ts +87 -0
  33. package/src/theme/defaults/fonts.ts +89 -0
  34. package/src/theme/defaults/index.ts +4 -0
  35. package/src/theme/devtools/ThemeDevtoolsPanel.tsx +196 -2
  36. package/src/theme/devtools/buildSnapshot.ts +11 -0
  37. package/src/theme/devtools/knobIcons.tsx +141 -0
  38. package/src/theme/devtools/vizConstants.ts +1 -1
  39. package/src/theme/focusState.spec.ts +174 -0
  40. package/src/theme/focusState.ts +112 -0
  41. package/src/theme/fontKnobStyles.spec.ts +98 -0
  42. package/src/theme/hairline.native.ts +43 -0
  43. package/src/theme/hairline.spec.ts +63 -0
  44. package/src/theme/hairline.ts +101 -0
  45. package/src/theme/index.ts +8 -0
  46. package/src/theme/intent.spec.tsx +2 -2
  47. package/src/theme/intents.ts +6 -2
  48. package/src/theme/knobs.ts +134 -2
  49. package/src/theme/layoutTokens.spec.ts +75 -0
  50. package/src/theme/layoutTokens.ts +213 -0
  51. package/src/theme/layoutTokensHooks.spec.tsx +157 -0
  52. package/src/theme/presets.spec.ts +17 -0
  53. package/src/theme/presets.ts +22 -2
  54. package/src/theme/readableColor.ts +101 -0
  55. package/src/theme/recipes.ts +130 -7
  56. package/src/theme/resolveKnobs.spec.ts +560 -21
  57. package/src/theme/resolveKnobs.ts +267 -23
  58. package/src/theme/shared.tsx +26 -2
  59. package/src/theme/useResolvedKnobs.ts +67 -26
  60. package/src/theme/useResolvedKnobsBehavior.spec.tsx +166 -0
  61. package/types/audit/constraintAudit.d.ts +83 -0
  62. package/types/audit/constraintAudit.d.ts.map +1 -0
  63. package/types/audit/diffReport.d.ts +45 -0
  64. package/types/audit/diffReport.d.ts.map +1 -0
  65. package/types/audit/index.d.ts +5 -0
  66. package/types/audit/index.d.ts.map +1 -0
  67. package/types/dates.d.ts +52 -0
  68. package/types/dates.d.ts.map +1 -0
  69. package/types/devWarn.d.ts +104 -0
  70. package/types/devWarn.d.ts.map +1 -0
  71. package/types/figma/figmaTokens.d.ts +168 -0
  72. package/types/figma/figmaTokens.d.ts.map +1 -0
  73. package/types/font.d.ts +5 -0
  74. package/types/font.d.ts.map +1 -0
  75. package/types/fonts/createFontLoader.d.ts +3 -0
  76. package/types/fonts/createFontLoader.d.ts.map +1 -0
  77. package/types/fonts/createFontLoader.web.d.ts +3 -0
  78. package/types/fonts/createFontLoader.web.d.ts.map +1 -0
  79. package/types/fonts/index.d.ts +3 -0
  80. package/types/fonts/index.d.ts.map +1 -0
  81. package/types/fonts/types.d.ts +12 -0
  82. package/types/fonts/types.d.ts.map +1 -0
  83. package/types/index.d.ts +9 -0
  84. package/types/index.d.ts.map +1 -0
  85. package/types/keyboardFocusRing.d.ts +32 -0
  86. package/types/keyboardFocusRing.d.ts.map +1 -0
  87. package/types/menuRow.d.ts +32 -0
  88. package/types/menuRow.d.ts.map +1 -0
  89. package/types/theme/FontKnobStyles.d.ts +49 -0
  90. package/types/theme/FontKnobStyles.d.ts.map +1 -0
  91. package/types/theme/FontKnobStyles.native.d.ts +12 -0
  92. package/types/theme/FontKnobStyles.native.d.ts.map +1 -0
  93. package/types/theme/Intent.d.ts +20 -0
  94. package/types/theme/Intent.d.ts.map +1 -0
  95. package/types/theme/Preset.d.ts +15 -0
  96. package/types/theme/Preset.d.ts.map +1 -0
  97. package/types/theme/PresetContext.d.ts +18 -0
  98. package/types/theme/PresetContext.d.ts.map +1 -0
  99. package/types/theme/Tint.d.ts +19 -0
  100. package/types/theme/Tint.d.ts.map +1 -0
  101. package/types/theme/animations/css.d.ts +24 -0
  102. package/types/theme/animations/css.d.ts.map +1 -0
  103. package/types/theme/animations/index.d.ts +2 -0
  104. package/types/theme/animations/index.d.ts.map +1 -0
  105. package/types/theme/animations/index.web.d.ts +2 -0
  106. package/types/theme/animations/index.web.d.ts.map +1 -0
  107. package/types/theme/animations/reactNative.d.ts +104 -0
  108. package/types/theme/animations/reactNative.d.ts.map +1 -0
  109. package/types/theme/chartPalette.d.ts +62 -0
  110. package/types/theme/chartPalette.d.ts.map +1 -0
  111. package/types/theme/colorRules.d.ts +69 -0
  112. package/types/theme/colorRules.d.ts.map +1 -0
  113. package/types/theme/cookies.d.ts +76 -0
  114. package/types/theme/cookies.d.ts.map +1 -0
  115. package/types/theme/cornerSmoothing.d.ts +31 -0
  116. package/types/theme/cornerSmoothing.d.ts.map +1 -0
  117. package/types/theme/cornerSmoothing.native.d.ts +14 -0
  118. package/types/theme/cornerSmoothing.native.d.ts.map +1 -0
  119. package/types/theme/createDefaultThemeConfig.d.ts +37 -0
  120. package/types/theme/createDefaultThemeConfig.d.ts.map +1 -0
  121. package/types/theme/createThemes.d.ts +38 -0
  122. package/types/theme/createThemes.d.ts.map +1 -0
  123. package/types/theme/defaults/accent.d.ts +3 -0
  124. package/types/theme/defaults/accent.d.ts.map +1 -0
  125. package/types/theme/defaults/base.d.ts +3 -0
  126. package/types/theme/defaults/base.d.ts.map +1 -0
  127. package/types/theme/defaults/builderOptions.d.ts +3 -0
  128. package/types/theme/defaults/builderOptions.d.ts.map +1 -0
  129. package/types/theme/defaults/fonts.d.ts +7 -0
  130. package/types/theme/defaults/fonts.d.ts.map +1 -0
  131. package/types/theme/defaults/index.d.ts +5 -0
  132. package/types/theme/defaults/index.d.ts.map +1 -0
  133. package/types/theme/devtools/ColorLineVisualizer.d.ts +17 -0
  134. package/types/theme/devtools/ColorLineVisualizer.d.ts.map +1 -0
  135. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts +87 -0
  136. package/types/theme/devtools/ThemeDevtoolsPanel.d.ts.map +1 -0
  137. package/types/theme/devtools/buildSnapshot.d.ts +12 -0
  138. package/types/theme/devtools/buildSnapshot.d.ts.map +1 -0
  139. package/types/theme/devtools/knobIcons.d.ts +33 -0
  140. package/types/theme/devtools/knobIcons.d.ts.map +1 -0
  141. package/types/theme/devtools/vizConstants.d.ts +5 -0
  142. package/types/theme/devtools/vizConstants.d.ts.map +1 -0
  143. package/types/theme/focusState.d.ts +42 -0
  144. package/types/theme/focusState.d.ts.map +1 -0
  145. package/types/theme/hairline.d.ts +88 -0
  146. package/types/theme/hairline.d.ts.map +1 -0
  147. package/types/theme/hairline.native.d.ts +42 -0
  148. package/types/theme/hairline.native.d.ts.map +1 -0
  149. package/types/theme/index.d.ts +29 -0
  150. package/types/theme/index.d.ts.map +1 -0
  151. package/types/theme/intents.d.ts +3 -0
  152. package/types/theme/intents.d.ts.map +1 -0
  153. package/types/theme/knobs.d.ts +236 -0
  154. package/types/theme/knobs.d.ts.map +1 -0
  155. package/types/theme/layoutTokens.d.ts +104 -0
  156. package/types/theme/layoutTokens.d.ts.map +1 -0
  157. package/types/theme/preset.types.d.ts +21 -0
  158. package/types/theme/preset.types.d.ts.map +1 -0
  159. package/types/theme/presets.d.ts +4 -0
  160. package/types/theme/presets.d.ts.map +1 -0
  161. package/types/theme/readableColor.d.ts +17 -0
  162. package/types/theme/readableColor.d.ts.map +1 -0
  163. package/types/theme/recipes.d.ts +200 -0
  164. package/types/theme/recipes.d.ts.map +1 -0
  165. package/types/theme/resolveKnobs.d.ts +22 -0
  166. package/types/theme/resolveKnobs.d.ts.map +1 -0
  167. package/types/theme/shared.d.ts +40 -0
  168. package/types/theme/shared.d.ts.map +1 -0
  169. package/types/theme/theme.d.ts +19 -0
  170. package/types/theme/theme.d.ts.map +1 -0
  171. package/types/theme/theme.native.d.ts +10 -0
  172. package/types/theme/theme.native.d.ts.map +1 -0
  173. package/types/theme/useColorScale.d.ts +7 -0
  174. package/types/theme/useColorScale.d.ts.map +1 -0
  175. package/types/theme/useColorScale.native.d.ts +6 -0
  176. package/types/theme/useColorScale.native.d.ts.map +1 -0
  177. package/types/theme/useResolvedKnobs.d.ts +32 -0
  178. package/types/theme/useResolvedKnobs.d.ts.map +1 -0
  179. package/types/theme/useTheme.d.ts +31 -0
  180. package/types/theme/useTheme.d.ts.map +1 -0
  181. package/types/theme/useTheme.native.d.ts +10 -0
  182. package/types/theme/useTheme.native.d.ts.map +1 -0
  183. package/CHANGELOG.md +0 -53
  184. package/src/theme/__snapshots__/resolveKnobs.spec.ts.snap +0 -69
  185. package/src/theme/pokemonScreen.spec.ts +0 -109
  186. package/tsconfig.json +0 -11
  187. package/vitest.config.mjs +0 -9
package/src/devWarn.ts ADDED
@@ -0,0 +1,235 @@
1
+ /**
2
+ * Tree-shaken DEV guardrails (blueprint §5).
3
+ * No-ops when `NODE_ENV === "production"`.
4
+ *
5
+ * W12 owns `outline-none` + `positive-tabindex`. Other codes are catalogued
6
+ * here so consumers share one tree-shaken entry point.
7
+ */
8
+
9
+ export type DevWarnCode =
10
+ | "placeholder-as-label"
11
+ | "two-primaries"
12
+ | "banned-error-word"
13
+ | "bare-disabled"
14
+ | "cancel-disabled"
15
+ | "dialog-too-many-actions"
16
+ | "select-too-few-options"
17
+ | "positive-tabindex"
18
+ | "tooltip-on-disabled"
19
+ | "outline-none"
20
+ // DG-A11Y-02 — a second level-1 heading mounted under one page scope.
21
+ // Emitted by PageHeadingScope in @multiplatform.one/components.
22
+ | "multiple-h1";
23
+
24
+ export type DevWarnDetail = {
25
+ component?: string;
26
+ id?: string;
27
+ value?: unknown;
28
+ word?: string;
29
+ count?: number;
30
+ suggest?: string;
31
+ region?: string;
32
+ [key: string]: unknown;
33
+ };
34
+
35
+ const seen = new Set<string>();
36
+
37
+ function isDev(): boolean {
38
+ return process.env.NODE_ENV !== "production";
39
+ }
40
+
41
+ /**
42
+ * Emit a once-per-key console warning in non-production builds.
43
+ */
44
+ export function devWarn(code: DevWarnCode, detail: DevWarnDetail = {}): void {
45
+ if (!isDev()) return;
46
+ const key = [
47
+ code,
48
+ detail.component ?? "",
49
+ detail.id ?? "",
50
+ detail.region ?? "",
51
+ String(detail.value ?? ""),
52
+ detail.word ?? "",
53
+ detail.count ?? "",
54
+ ].join(":");
55
+ if (seen.has(key)) return;
56
+ seen.add(key);
57
+ const bits = [
58
+ detail.component ? `component=${detail.component}` : undefined,
59
+ detail.id ? `id=${detail.id}` : undefined,
60
+ detail.region ? `region=${detail.region}` : undefined,
61
+ detail.value !== undefined ? `value=${String(detail.value)}` : undefined,
62
+ detail.word ? `word=${detail.word}` : undefined,
63
+ detail.count !== undefined ? `count=${detail.count}` : undefined,
64
+ detail.suggest ? `suggest=${detail.suggest}` : undefined,
65
+ ].filter(Boolean);
66
+ const suffix = bits.length ? ` (${bits.join(", ")})` : "";
67
+ console.warn(`[theme] ${code}${suffix}`);
68
+ }
69
+
70
+ /** @internal test helper */
71
+ export function __resetDevWarnSeen(): void {
72
+ seen.clear();
73
+ }
74
+
75
+ /** Alias kept for callers that imported the W12-era name. */
76
+ export const themeDevWarn = devWarn;
77
+ export const __resetThemeDevWarnSeen = __resetDevWarnSeen;
78
+
79
+ /**
80
+ * DG-FORM-02 / DG-FORM-11: placeholder must not be the only label.
81
+ */
82
+ export function warnPlaceholderAsLabel(args: {
83
+ placeholder?: string | null;
84
+ label?: unknown;
85
+ ariaLabel?: string | null;
86
+ id?: string;
87
+ component?: string;
88
+ }): void {
89
+ if (!isDev()) return;
90
+ const placeholder = typeof args.placeholder === "string" ? args.placeholder.trim() : "";
91
+ if (!placeholder) return;
92
+ if (args.label != null && args.label !== false && args.label !== "") return;
93
+ const ariaLabel = typeof args.ariaLabel === "string" ? args.ariaLabel.trim() : "";
94
+ if (ariaLabel) return;
95
+ devWarn("placeholder-as-label", {
96
+ component: args.component ?? "FieldLayout",
97
+ id: args.id,
98
+ });
99
+ }
100
+
101
+ const BANNED_ERROR_WORDS = ["invalid", "error", "please", "sorry", "oops"] as const;
102
+
103
+ /** DG-COPY-02 / DG-VAL-03 — banned words in error copy. */
104
+ export function warnBannedErrorWords(
105
+ message: string | null | undefined,
106
+ detail: DevWarnDetail = {},
107
+ ): void {
108
+ if (!isDev() || !message) return;
109
+ const lower = message.toLowerCase();
110
+ for (const word of BANNED_ERROR_WORDS) {
111
+ if (new RegExp(`\\b${word}\\b`, "i").test(lower)) {
112
+ devWarn("banned-error-word", { ...detail, word });
113
+ return;
114
+ }
115
+ }
116
+ }
117
+
118
+ /** DG-ST-03 — bare `disabled` without reason / explain. */
119
+ export function warnBareDisabled(args: {
120
+ disabled?: boolean;
121
+ disabledReason?: string | null;
122
+ skip?: boolean;
123
+ component?: string;
124
+ id?: string;
125
+ }): void {
126
+ if (!isDev() || !args.disabled || args.skip) return;
127
+ if (typeof args.disabledReason === "string" && args.disabledReason.trim()) return;
128
+ devWarn("bare-disabled", { component: args.component, id: args.id });
129
+ }
130
+
131
+ /**
132
+ * DG-ACT-05 — the cancel/dismiss action never renders disabled (while confirm
133
+ * may be). Enforcing callers strip the prop and report the attempt here.
134
+ */
135
+ export function warnCancelDisabled(args: {
136
+ disabled?: boolean;
137
+ component?: string;
138
+ id?: string;
139
+ }): void {
140
+ if (!isDev() || !args.disabled) return;
141
+ devWarn("cancel-disabled", { component: args.component, id: args.id });
142
+ }
143
+
144
+ /** DG-OVL-04 — dialog action rows cap at this many actions by default. */
145
+ export const DIALOG_MAX_ACTIONS = 2;
146
+
147
+ /**
148
+ * DG-OVL-04 — max 2 actions in a dialog by default. Warn-only (never a hard
149
+ * block); `allowManyActions` is the explicit eject acknowledging the extra
150
+ * actions.
151
+ */
152
+ export function warnDialogTooManyActions(args: {
153
+ count: number;
154
+ allowManyActions?: boolean;
155
+ component?: string;
156
+ id?: string;
157
+ }): void {
158
+ if (!isDev() || args.allowManyActions || args.count <= DIALOG_MAX_ACTIONS) return;
159
+ devWarn("dialog-too-many-actions", {
160
+ component: args.component,
161
+ id: args.id,
162
+ count: args.count,
163
+ });
164
+ }
165
+
166
+ /**
167
+ * DG-CTRL — Select with too few options; suggest RadioGroup / ToggleGroup.
168
+ */
169
+ export function warnSelectTooFewOptions(args: {
170
+ count: number;
171
+ multiple?: boolean;
172
+ component?: string;
173
+ id?: string;
174
+ }): void {
175
+ if (!isDev() || args.multiple || args.count > 5) return;
176
+ const suggest = args.count <= 2 ? "ToggleGroup" : "RadioGroup";
177
+ devWarn("select-too-few-options", {
178
+ component: args.component,
179
+ id: args.id,
180
+ count: args.count,
181
+ suggest,
182
+ });
183
+ }
184
+
185
+ /** DG-A11Y-03 — never `tabIndex` > 0. */
186
+ export function warnPositiveTabIndex(args: {
187
+ value?: number | string | null;
188
+ component?: string;
189
+ id?: string;
190
+ }): void {
191
+ if (!isDev()) return;
192
+ const n = typeof args.value === "string" ? Number(args.value) : args.value;
193
+ if (typeof n !== "number" || Number.isNaN(n) || n <= 0) return;
194
+ devWarn("positive-tabindex", {
195
+ component: args.component,
196
+ id: args.id,
197
+ value: n,
198
+ });
199
+ }
200
+
201
+ /** DG-OVL-05 — no hover tooltip on disabled controls. */
202
+ export function warnTooltipOnDisabled(args: {
203
+ childDisabled?: boolean;
204
+ component?: string;
205
+ id?: string;
206
+ }): void {
207
+ if (!isDev() || !args.childDisabled) return;
208
+ devWarn("tooltip-on-disabled", {
209
+ component: args.component ?? "Tooltip",
210
+ id: args.id,
211
+ });
212
+ }
213
+
214
+ type OutlineLike =
215
+ | {
216
+ outline?: string | number;
217
+ outlineStyle?: string;
218
+ outlineWidth?: number;
219
+ outlineColor?: string;
220
+ }
221
+ | null
222
+ | undefined;
223
+
224
+ /**
225
+ * DG-A11Y-01 / W12 — warn when a focus style strips the outline.
226
+ * Call on `focusVisibleStyle` / `focusStyle` only — not resting
227
+ * `outlineWidth: 0` + transparent (border-flicker rest).
228
+ */
229
+ export function warnOutlineNone(style: OutlineLike, detail: DevWarnDetail = {}): void {
230
+ if (!isDev() || !style) return;
231
+ const stripped =
232
+ style.outline === "none" || style.outlineStyle === "none" || style.outlineWidth === 0;
233
+ if (!stripped) return;
234
+ devWarn("outline-none", detail);
235
+ }
@@ -0,0 +1,244 @@
1
+ import * as Colors from "@tamagui/colors";
2
+ import { tokens } from "@tamagui/themes";
3
+ import { describe, expect, it } from "vitest";
4
+ import { normalizeToHex } from "../theme/colorRules";
5
+ import { defaultAccentTheme } from "../theme/defaults/accent";
6
+ import { defaultBaseTheme } from "../theme/defaults/base";
7
+ import { interTrackingPx } from "../theme/defaults/fonts";
8
+ import { FIGMA_THEME_MODES, buildFigmaTokens, figmaTokenName } from "./figmaTokens";
9
+
10
+ const fontFamilies = {
11
+ "sans-serif": "Inter",
12
+ pixel: "'Silkscreen', 'Press Start 2P', monospace",
13
+ };
14
+
15
+ /**
16
+ * The Tamagui theme-builder normalizes palette colors (hex→hsla) and may
17
+ * round interior steps, so source↔built comparisons must be colorimetric.
18
+ * True when the two CSS colors are within `tolerance` per RGB channel.
19
+ */
20
+ function sameColor(a: string, b: string, tolerance = 3): boolean {
21
+ const hexA = normalizeToHex(a);
22
+ const hexB = normalizeToHex(b);
23
+ if (!hexA || !hexB) return false;
24
+ for (let i = 1; i < 7; i += 2) {
25
+ const channelA = Number.parseInt(hexA.slice(i, i + 2), 16);
26
+ const channelB = Number.parseInt(hexB.slice(i, i + 2), 16);
27
+ if (Math.abs(channelA - channelB) > tolerance) return false;
28
+ }
29
+ return true;
30
+ }
31
+
32
+ describe("buildFigmaTokens", () => {
33
+ const result = buildFigmaTokens({ fontFamilies });
34
+
35
+ it("is deterministic (pure transform, golden-comparable)", () => {
36
+ expect(JSON.stringify(result)).toBe(JSON.stringify(buildFigmaTokens({ fontFamilies })));
37
+ });
38
+
39
+ describe("palette", () => {
40
+ it("exports the 9 spec theme modes with 12-step light+dark ramps", () => {
41
+ expect(Object.keys(result.palette.themes)).toEqual([...FIGMA_THEME_MODES]);
42
+ for (const theme of Object.values(result.palette.themes)) {
43
+ expect(theme.light).toHaveLength(12);
44
+ expect(theme.dark).toHaveLength(12);
45
+ }
46
+ });
47
+
48
+ it("derives base/accent ramps from the canonical default palettes", () => {
49
+ // The theme-builder rounds/normalizes palette values, so the export
50
+ // reflects the BUILT runtime themes — every step must still be
51
+ // colorimetrically the canonical source step.
52
+ for (const scheme of ["light", "dark"] as const) {
53
+ const built = result.palette.themes.base[scheme];
54
+ const source =
55
+ scheme === "light" ? defaultBaseTheme.lightPalette : defaultBaseTheme.darkPalette;
56
+ for (let i = 0; i < 12; i++) {
57
+ expect(sameColor(built[i], source[i])).toBe(true);
58
+ }
59
+ }
60
+ expect(defaultAccentTheme.lightPalette).toContain(result.palette.themes.accent.light[0]);
61
+ expect(defaultAccentTheme.darkPalette).toContain(result.palette.themes.accent.dark[0]);
62
+ });
63
+
64
+ it("derives intent ramps from @tamagui/colors (error=red, success=green, warning=yellow)", () => {
65
+ // Built themes carry hsla-normalized values; compare colorimetrically.
66
+ const includesColor = (ramp: string[], value: string) =>
67
+ ramp.some((step) => sameColor(step, value));
68
+ expect(includesColor(result.palette.themes.error.light, Colors.red.red9)).toBe(true);
69
+ expect(includesColor(result.palette.themes.error.dark, Colors.redDark.red9)).toBe(true);
70
+ expect(includesColor(result.palette.themes.success.light, Colors.green.green9)).toBe(true);
71
+ expect(includesColor(result.palette.themes.warning.light, Colors.yellow.yellow9)).toBe(true);
72
+ });
73
+
74
+ it("exports tint ramps with neutralized text tiers (runtime reality, not stock Radix)", () => {
75
+ // neutralizeTintText re-anchors color11/color12 of tint sub-themes to
76
+ // the neutral base scheme.
77
+ expect(result.palette.themes.orange.light[11]).toBe(result.palette.themes.base.light[11]);
78
+ expect(result.palette.themes.purple.dark[10]).toBe(result.palette.themes.base.dark[10]);
79
+ // Non-text steps keep the hue.
80
+ expect(result.palette.themes.orange.light[8]).not.toBe(result.palette.themes.base.light[8]);
81
+ });
82
+
83
+ it("exports 6 shadow steps per scheme from the base theme", () => {
84
+ expect(Object.keys(result.palette.shadows.light)).toHaveLength(6);
85
+ expect(result.palette.shadows.light.shadow1).toBe(defaultBaseTheme.lightShadows?.shadow1);
86
+ expect(result.palette.shadows.dark.shadow6).toBe(defaultBaseTheme.darkShadows?.shadow6);
87
+ });
88
+ });
89
+
90
+ describe("semantic", () => {
91
+ it("derives the current interaction-ramp aliases (post-knobs rebuild)", () => {
92
+ // These pin the drift the May 2026 spec missed: borderColor moved from
93
+ // color7 to color5, textMuted from color10 to color11.
94
+ expect(result.semantic.borderColor?.alias).toBe("color5");
95
+ expect(result.semantic.borderColorHover?.alias).toBe("color6");
96
+ expect(result.semantic.textMuted?.alias).toBe("color11");
97
+ expect(result.semantic.backgroundPress?.alias).toBe("color5");
98
+ expect(result.semantic.backgroundActive?.alias).toBe("color4");
99
+ expect(result.semantic.placeholderColor?.alias).toBe("color10");
100
+ expect(result.semantic.surfaceMuted?.alias).toBe("color10");
101
+ expect(result.semantic.borderStrong?.alias).toBe("color7");
102
+ });
103
+
104
+ it("exports the computed focus ring color (outlineColor) with per-scheme values", () => {
105
+ const outline = result.semantic.outlineColor;
106
+ expect(outline).toBeDefined();
107
+ expect(outline?.light).toBeTruthy();
108
+ expect(outline?.dark).toBeTruthy();
109
+ });
110
+
111
+ it("derives textAccent knob tiers as text-low/medium/high", () => {
112
+ expect(result.semantic["text-low"]?.alias).toBe("color11");
113
+ expect(result.semantic["text-medium"]?.alias).toBe("color11");
114
+ expect(result.semantic["text-high"]?.alias).toBe("color12");
115
+ });
116
+
117
+ it("excludes raw Radix hue steps (amber1, blueA12, ...) from semantic", () => {
118
+ const keys = Object.keys(result.semantic);
119
+ expect(keys.some((key) => /^(amber|blue|red|mauve)A?\d+$/.test(key))).toBe(false);
120
+ // Semantic surface is role tokens only — small, not the 600+ hue dump.
121
+ expect(keys.length).toBeGreaterThan(20);
122
+ expect(keys.length).toBeLessThan(100);
123
+ });
124
+
125
+ it("only aliases when light and dark agree; raw entries keep both values", () => {
126
+ for (const entry of Object.values(result.semantic)) {
127
+ if (entry.alias === null) {
128
+ expect(typeof entry.light).toBe("string");
129
+ expect(typeof entry.dark).toBe("string");
130
+ } else {
131
+ expect(entry.alias).toMatch(/^color([1-9]|1[0-2])$/);
132
+ }
133
+ }
134
+ });
135
+ });
136
+
137
+ describe("sizing", () => {
138
+ it("mirrors @tamagui/themes tokens with figma-safe names", () => {
139
+ const sizeTrue = (tokens.size as Record<string, { val: number }>).true.val;
140
+ expect(result.sizing["size-true"]).toBe(sizeTrue);
141
+ const radius4 = (tokens.radius as Record<string, { val: number }>)["4"].val;
142
+ expect(result.sizing["radius-4"]).toBe(radius4);
143
+ expect(result.sizing["space-1-5"]).toBe(
144
+ (tokens.space as Record<string, { val: number }>)["1.5"].val,
145
+ );
146
+ });
147
+
148
+ it("exports knob-shaped borderWidth values including the 0.5 hairline", () => {
149
+ expect(result.sizing["borderWidth-none"]).toBe(0);
150
+ expect(result.sizing["borderWidth-small"]).toBe(0.5);
151
+ expect(result.sizing["borderWidth-medium"]).toBe(1);
152
+ expect(result.sizing["borderWidth-large"]).toBe(2);
153
+ });
154
+ });
155
+
156
+ describe("knobs (resolved via resolveKnobs — no copied maps)", () => {
157
+ it("exports current borderRadius modes (medium=$4, not the May-era $3)", () => {
158
+ const { modes, default: def } = result.knobs.borderRadius;
159
+ expect(def).toBe("medium");
160
+ expect(modes.none.token).toBe("$0");
161
+ expect(modes.small.token).toBe("$2");
162
+ expect(modes.medium.token).toBe("$4");
163
+ expect(modes.large.token).toBe("$6");
164
+ expect(modes.full.token).toBe("$12");
165
+ expect(modes.medium.figmaName).toBe("radius-4");
166
+ expect(modes.medium.px).toBe(result.sizing["radius-4"]);
167
+ // Outer/nested radius fragment landed with the knobs rebuild.
168
+ expect(modes.full.outerToken).toBe("$5");
169
+ });
170
+
171
+ it("exports borderWidth knob modes", () => {
172
+ expect(result.knobs.borderWidth.modes).toEqual({
173
+ none: 0,
174
+ small: 0.5,
175
+ medium: 1,
176
+ large: 2,
177
+ });
178
+ });
179
+
180
+ it("exports platform-normalized elevation modes", () => {
181
+ const { modes } = result.knobs.elevation;
182
+ expect(modes.none).toEqual({ sizeToken: null, px: null });
183
+ expect(modes.small.sizeToken).toBe("$1");
184
+ expect(modes.small.px).toBe((tokens.size as Record<string, { val: number }>)["1"].val);
185
+ expect(modes.medium.sizeToken).toBe("$2");
186
+ expect(modes.large.sizeToken).toBe("$4");
187
+ });
188
+
189
+ it("exports fontWeight, space, size and cornerSmoothing modes", () => {
190
+ expect(result.knobs.fontWeight.modes).toEqual({ regular: "400", bold: "700" });
191
+ expect(result.knobs.space.modes.medium.paddingFigmaName).toBe("space-4");
192
+ expect(result.knobs.space.modes.medium.gapFigmaName).toBe("space-4");
193
+ expect(result.knobs.space.modes.medium.gapLgFigmaName).toBe("space-5");
194
+ expect(result.knobs.size.modes).toEqual({
195
+ small: { token: "$3", figmaName: "size-3" },
196
+ medium: { token: "$4", figmaName: "size-4" },
197
+ large: { token: "$5", figmaName: "size-5" },
198
+ });
199
+ expect(result.knobs.cornerSmoothing.modes).toEqual({ round: 0, smooth: 0.6 });
200
+ });
201
+
202
+ it("exports density compact stepping", () => {
203
+ expect(result.knobs.density.default).toBe("comfortable");
204
+ expect(result.knobs.density.compact.size).toEqual({
205
+ small: "small",
206
+ medium: "small",
207
+ large: "medium",
208
+ });
209
+ expect(result.knobs.density.compact.space).toEqual({
210
+ small: "small",
211
+ medium: "small",
212
+ large: "medium",
213
+ });
214
+ });
215
+ });
216
+
217
+ describe("typography (closes deferred task 10.0)", () => {
218
+ it("exports heading/body size + lineHeight tables from the built default fonts", () => {
219
+ // Heading scale is 1.4x Inter base: H1 $10 = round(46 * 1.4) = 64.
220
+ expect(result.typography.heading.size["10"]).toBe(64);
221
+ expect(result.typography.body.size.true).toBe(14);
222
+ // lineHeight = size + 10 (defaults/fonts.ts).
223
+ expect(result.typography.body.lineHeight.true).toBe(24);
224
+ expect(result.typography.heading.lineHeight["10"]).toBe(74);
225
+ });
226
+
227
+ it("exports Inter dynamic tracking per size", () => {
228
+ const body = result.typography.body;
229
+ expect(body.letterSpacing.true).toBe(interTrackingPx(14));
230
+ expect(Object.keys(body.letterSpacing).length).toBeGreaterThanOrEqual(16);
231
+ });
232
+
233
+ it("exports category stacks with a Figma-usable first family", () => {
234
+ expect(result.typography.categories.pixel.figmaFamily).toBe("Silkscreen");
235
+ expect(result.typography.categories["sans-serif"].figmaFamily).toBe("Inter");
236
+ });
237
+ });
238
+
239
+ it("figmaTokenName converts Tamagui token keys to Figma-safe names", () => {
240
+ expect(figmaTokenName("size", "1.5")).toBe("size-1-5");
241
+ expect(figmaTokenName("space", "true")).toBe("space-true");
242
+ expect(figmaTokenName("space", "-0.25")).toBe("space--0-25");
243
+ });
244
+ });