@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
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Native no-op. React Native has no CSS variables, so the web-only
3
+ * `.font_heading` / `.font_body` --f-family override cannot apply. On native,
4
+ * components consume the font knobs through `knobProps.heading` /
5
+ * `knobProps.body` from useResolvedKnobs (see e.g. public/components
6
+ * layouts/page.tsx).
7
+ */
8
+ import type { FontCategory } from "./knobs";
9
+
10
+ export function getConfiguredFontFamily(_category: FontCategory): string | undefined {
11
+ return undefined;
12
+ }
13
+
14
+ export function buildFontKnobCss(
15
+ _headingFont: FontCategory,
16
+ _bodyFont: FontCategory,
17
+ _resolveFamily?: (category: FontCategory) => string | undefined,
18
+ ): string {
19
+ return "";
20
+ }
21
+
22
+ export function FontKnobStyles() {
23
+ return null;
24
+ }
@@ -0,0 +1,256 @@
1
+ /**
2
+ * FontKnobStyles — makes the headingFont/bodyFont knobs retheme EVERY
3
+ * heading/body text on web without per-component wiring.
4
+ *
5
+ * How it works: on web Tamagui renders text with a font-scope class
6
+ * (`font_heading`, `font_body`, …) and resolves `font-family` through the
7
+ * scoped CSS variable `--f-family` (rule: `:root .font_heading { --f-family:
8
+ * Inter, … }`). Overriding that one variable per scope swaps the rendered
9
+ * family of every H1–H6 (`$heading`) and every default/body text (`$body`)
10
+ * at once, keeping the size scale intact.
11
+ *
12
+ * Density: the base lineHeight/letterSpacing tables are tuned for Inter
13
+ * (display leading ~1.16, negative tracking at large sizes). Most other
14
+ * families need looser leading — mono/serif/script glyphs fill more of the
15
+ * em box, so Inter's leading reads squished on them. Each category declares
16
+ * a minimum lineHeight:fontSize ratio in `fontCategoryMetrics`; sizes whose
17
+ * base leading falls below the floor get a scoped `--f-lineHeight-N`
18
+ * override (same var the text classes already consume), and "neutral"
19
+ * tracking categories zero out Inter's tracking-by-size letterSpacing.
20
+ *
21
+ * Components that explicitly set another font token (e.g. `fontFamily="$mono"`
22
+ * code blocks, or `knobProps.heading` consumers) carry a different font class
23
+ * and are untouched.
24
+ *
25
+ * Native has no CSS variables — there, components must consume
26
+ * `knobProps.heading` / `knobProps.body` (see FontKnobStyles.native.tsx no-op).
27
+ */
28
+ import { getConfig } from "@tamagui/web";
29
+ import { useEffect } from "react";
30
+ import type { FontCategory } from "./knobs";
31
+ import { defaultKnobs } from "./knobs";
32
+ import { usePresetContext } from "./PresetContext";
33
+
34
+ const STYLE_TAG_ID = "mp-font-knob-styles";
35
+
36
+ /**
37
+ * Per-category legibility floors, applied on top of the base (Inter-tuned)
38
+ * lineHeight tables when the heading/body knob selects that category.
39
+ * `heading`/`body` are minimum lineHeight:fontSize ratios; `letterSpacing:
40
+ * "neutral"` zeroes Inter's tracking-by-size curve (wrong for fixed-pitch
41
+ * and connected-script families). "sans-serif" is the default scope (never
42
+ * overridden), listed for completeness.
43
+ */
44
+ export const fontCategoryMetrics: Record<
45
+ FontCategory,
46
+ { heading: number; body: number; letterSpacing: "inherit" | "neutral" }
47
+ > = {
48
+ "sans-serif": { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
49
+ serif: { heading: 1.2, body: 1.4, letterSpacing: "inherit" },
50
+ mono: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
51
+ // Heading floors below are sized to each configured face's em box
52
+ // (ascent+descent) so display headings never clip (LC-11): Roboto Slab
53
+ // ≈1.32, Nunito ≈1.36, Silkscreen ≈1.3, Poppins/Futura ≈1.4.
54
+ slab: { heading: 1.35, body: 1.4, letterSpacing: "inherit" },
55
+ rounded: { heading: 1.4, body: 1.4, letterSpacing: "inherit" },
56
+ condensed: { heading: 1.15, body: 1.4, letterSpacing: "inherit" },
57
+ cursive: { heading: 1.3, body: 1.5, letterSpacing: "neutral" },
58
+ handwriting: { heading: 1.25, body: 1.45, letterSpacing: "neutral" },
59
+ pixel: { heading: 1.35, body: 1.4, letterSpacing: "neutral" },
60
+ blackletter: { heading: 1.3, body: 1.45, letterSpacing: "neutral" },
61
+ geometric: { heading: 1.45, body: 1.4, letterSpacing: "inherit" },
62
+ };
63
+
64
+ // Default leading for font-scoped text that carries no explicit lineHeight
65
+ // (e.g. `Text fontFamily="$body" fontSize=…` renders `line-height: normal`
66
+ // ≈1.2 — under every category's body floor). Emitted at ZERO specificity
67
+ // (`:where()`) so any sized text keeps its per-size table value, while
68
+ // unsized text meets the category floor (LC-11). The minimums keep the
69
+ // default scope comfortable even where a category floor is lower.
70
+ const DEFAULT_HEADING_LEADING_MIN = 1.25;
71
+ const DEFAULT_BODY_LEADING_MIN = 1.5;
72
+
73
+ /** Zero-specificity default line-height rule for one font scope. */
74
+ function defaultLeadingRule(scope: "heading" | "body", category: FontCategory): string {
75
+ const metrics = fontCategoryMetrics[category] ?? fontCategoryMetrics["sans-serif"];
76
+ const ratio = Math.max(
77
+ metrics[scope],
78
+ scope === "heading" ? DEFAULT_HEADING_LEADING_MIN : DEFAULT_BODY_LEADING_MIN,
79
+ );
80
+ return `:where(:root .font_${scope}) { line-height: ${ratio}; }`;
81
+ }
82
+
83
+ /** Numeric font tables for a purpose font, keyed WITHOUT the `$` prefix. */
84
+ export interface FontScopeTables {
85
+ size: Record<string, number>;
86
+ lineHeight: Record<string, number>;
87
+ letterSpacing?: Record<string, number>;
88
+ }
89
+
90
+ /**
91
+ * Resolve the concrete font-family stack registered in the Tamagui config for
92
+ * a font category. Returns undefined when the category font is not registered
93
+ * (the caller then leaves the default family in place).
94
+ */
95
+ export function getConfiguredFontFamily(category: FontCategory): string | undefined {
96
+ let config: ReturnType<typeof getConfig> | undefined;
97
+ try {
98
+ config = getConfig();
99
+ } catch {
100
+ return undefined;
101
+ }
102
+ const fontsParsed = (config as { fontsParsed?: Record<string, unknown> }).fontsParsed;
103
+ const font = (fontsParsed?.[`$${category}`] ?? config.fonts?.[category]) as
104
+ | { family?: string | { val?: string } }
105
+ | undefined;
106
+ const family = font?.family;
107
+ if (typeof family === "string") return family;
108
+ if (family && typeof family.val === "string") return family.val;
109
+ return undefined;
110
+ }
111
+
112
+ /**
113
+ * Read the numeric size/lineHeight/letterSpacing tables of the `$heading` /
114
+ * `$body` purpose font from the Tamagui config, keys normalized without the
115
+ * `$` prefix (matching the emitted `--f-lineHeight-<key>` CSS var names).
116
+ * Returns undefined outside a configured Tamagui runtime (unit tests, SSR
117
+ * before setup) — callers then emit family-only overrides.
118
+ */
119
+ export function getConfiguredFontTables(scope: "heading" | "body"): FontScopeTables | undefined {
120
+ let config: ReturnType<typeof getConfig> | undefined;
121
+ try {
122
+ config = getConfig();
123
+ } catch {
124
+ return undefined;
125
+ }
126
+ const fontsParsed = (config as { fontsParsed?: Record<string, unknown> }).fontsParsed;
127
+ const font = (fontsParsed?.[`$${scope}`] ?? config.fonts?.[scope]) as
128
+ | Record<string, Record<string, number | { val?: number }> | undefined>
129
+ | undefined;
130
+ if (!font) return undefined;
131
+ const unwrap = (
132
+ table: Record<string, number | { val?: number }> | undefined,
133
+ ): Record<string, number> | undefined => {
134
+ if (!table) return undefined;
135
+ const out: Record<string, number> = {};
136
+ for (const [key, value] of Object.entries(table)) {
137
+ const num =
138
+ typeof value === "number"
139
+ ? value
140
+ : value && typeof value.val === "number"
141
+ ? value.val
142
+ : undefined;
143
+ if (num !== undefined) out[key.replace(/^\$/, "")] = num;
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ };
147
+ const size = unwrap(font.size);
148
+ const lineHeight = unwrap(font.lineHeight);
149
+ if (!size || !lineHeight) return undefined;
150
+ return { size, lineHeight, letterSpacing: unwrap(font.letterSpacing) };
151
+ }
152
+
153
+ /** Build the full override rule for one font scope (heading or body). */
154
+ function buildScopeRule(
155
+ scope: "heading" | "body",
156
+ category: FontCategory,
157
+ family: string,
158
+ tables: FontScopeTables | undefined,
159
+ ): string {
160
+ const metrics = fontCategoryMetrics[category];
161
+ const decls = [`--f-family: ${family} !important`];
162
+ if (tables && metrics) {
163
+ const minRatio = metrics[scope];
164
+ for (const [key, size] of Object.entries(tables.size)) {
165
+ const baseLineHeight = tables.lineHeight[key];
166
+ if (typeof baseLineHeight !== "number") continue;
167
+ const floor = Math.round(size * minRatio);
168
+ if (baseLineHeight < floor) {
169
+ decls.push(`--f-lineHeight-${key}: ${floor}px !important`);
170
+ }
171
+ }
172
+ if (metrics.letterSpacing === "neutral" && tables.letterSpacing) {
173
+ // Inter's tracking-by-size curve (negative display tracking AND
174
+ // positive caption tracking) is Inter-specific: fixed-pitch faces must
175
+ // keep their pitch and connected scripts break when tracked apart, so
176
+ // "neutral" zeroes every non-zero entry (LC-11).
177
+ for (const [key, tracking] of Object.entries(tables.letterSpacing)) {
178
+ if (tracking !== 0) decls.push(`--f-letterSpacing-${key}: 0px !important`);
179
+ }
180
+ }
181
+ }
182
+ return `:root .font_${scope} { ${decls.join("; ")}; }`;
183
+ }
184
+
185
+ /**
186
+ * Build the CSS override for the current headingFont/bodyFont knob values.
187
+ * Always emits the zero-specificity default-leading rules (unsized scoped
188
+ * text must meet the active category's line-height floor — LC-11); family
189
+ * and per-size overrides are added for non-default categories. Exported for
190
+ * unit testing.
191
+ */
192
+ export function buildFontKnobCss(
193
+ headingFont: FontCategory,
194
+ bodyFont: FontCategory,
195
+ resolveFamily: (category: FontCategory) => string | undefined = getConfiguredFontFamily,
196
+ resolveTables: (
197
+ scope: "heading" | "body",
198
+ ) => FontScopeTables | undefined = getConfiguredFontTables,
199
+ ): string {
200
+ const rules: string[] = [
201
+ defaultLeadingRule("heading", headingFont),
202
+ defaultLeadingRule("body", bodyFont),
203
+ ];
204
+ if (headingFont !== "sans-serif") {
205
+ const family = resolveFamily(headingFont);
206
+ if (family) {
207
+ rules.push(buildScopeRule("heading", headingFont, family, resolveTables("heading")));
208
+ }
209
+ }
210
+ if (bodyFont !== "sans-serif") {
211
+ const family = resolveFamily(bodyFont);
212
+ if (family) {
213
+ rules.push(buildScopeRule("body", bodyFont, family, resolveTables("body")));
214
+ }
215
+ }
216
+ return rules.join("\n");
217
+ }
218
+
219
+ /**
220
+ * Mount ONCE near the root (inside the Preset/PresetContext provider). Reads
221
+ * the active headingFont/bodyFont knobs and syncs a `<style>` override so all
222
+ * `$heading`/`$body` text follows the knobs. Renders nothing.
223
+ */
224
+ export function FontKnobStyles() {
225
+ const presetCtx = usePresetContext();
226
+ const knobs = presetCtx ? { ...presetCtx.preset.knobs, ...presetCtx.overrides } : defaultKnobs;
227
+ const headingFont = knobs.headingFont ?? defaultKnobs.headingFont;
228
+ const bodyFont = knobs.bodyFont ?? defaultKnobs.bodyFont;
229
+
230
+ useEffect(() => {
231
+ if (typeof document === "undefined") return;
232
+ const css = buildFontKnobCss(headingFont, bodyFont);
233
+ let tag = document.getElementById(STYLE_TAG_ID) as HTMLStyleElement | null;
234
+ if (!css) {
235
+ tag?.remove();
236
+ return;
237
+ }
238
+ if (!tag) {
239
+ tag = document.createElement("style");
240
+ tag.id = STYLE_TAG_ID;
241
+ document.head.appendChild(tag);
242
+ }
243
+ if (tag.textContent !== css) tag.textContent = css;
244
+ }, [headingFont, bodyFont]);
245
+
246
+ useEffect(
247
+ () => () => {
248
+ if (typeof document !== "undefined") {
249
+ document.getElementById(STYLE_TAG_ID)?.remove();
250
+ }
251
+ },
252
+ [],
253
+ );
254
+
255
+ return null;
256
+ }
@@ -1,6 +1,11 @@
1
1
  import { createAnimations } from "@tamagui/animations-react-native";
2
2
 
3
- const animationConfig = {
3
+ /**
4
+ * Canonical spring vocabulary. Exported so native gesture code can drive
5
+ * RN `Animated.spring` (which provides completion callbacks) with the exact
6
+ * same physics as Tamagui's `transition` tokens — one closed vocabulary.
7
+ */
8
+ export const animationConfig = {
4
9
  bouncy: {
5
10
  type: "spring",
6
11
  damping: 9,
@@ -0,0 +1,159 @@
1
+ import * as Colors from "@tamagui/colors";
2
+ import { describe, expect, it } from "vitest";
3
+ import { resolveChartPalette } from "./chartPalette";
4
+ import { contrastRatio, normalizeToHex, relativeLuminance } from "./colorRules";
5
+
6
+ // The default accent solid (accentBackground) from defaults/accent.ts.
7
+ const lightAccent = "hsla(250, 50%, 54%, 1)";
8
+ const darkAccent = "hsla(250, 50%, 60%, 1)";
9
+ // Tint identity solids as the stock tint sub-themes resolve $color9.
10
+ const redTintSolid = "hsl(358, 75.0%, 59.0%)";
11
+ const purpleTintSolid = "hsl(272, 46.8%, 50.3%)";
12
+ const tomatoTintSolid = "hsl(10, 78.0%, 54.0%)";
13
+ const grayTintSolid = "hsla(270, 6%, 56%, 1)";
14
+
15
+ function hexToHsl(color: string): { h: number; s: number; l: number } {
16
+ const hex = normalizeToHex(color);
17
+ if (!hex) throw new Error(`unparseable color: ${color}`);
18
+ const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
19
+ const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
20
+ const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
21
+ const max = Math.max(r, g, b);
22
+ const min = Math.min(r, g, b);
23
+ const l = (max + min) / 2;
24
+ const d = max - min;
25
+ if (d === 0) return { h: 0, s: 0, l };
26
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
27
+ let h: number;
28
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
29
+ else if (max === g) h = ((b - r) / d + 2) * 60;
30
+ else h = ((r - g) / d + 4) * 60;
31
+ return { h, s, l };
32
+ }
33
+
34
+ function hueDistance(a: number, b: number): number {
35
+ const d = Math.abs(a - b) % 360;
36
+ return d > 180 ? 360 - d : d;
37
+ }
38
+
39
+ /**
40
+ * Distinguishability bar for ADJACENT categorical marks. A pair is tellable
41
+ * apart when at least one perceptual channel separates it: hue (≥30° between
42
+ * saturated colors), saturation (neutral vs vivid, delta ≥0.5), or luminance
43
+ * (contrast ≥1.5:1).
44
+ */
45
+ function adjacentDistinguishable(a: string, b: string): boolean {
46
+ const ha = hexToHsl(a);
47
+ const hb = hexToHsl(b);
48
+ if (ha.s >= 0.15 && hb.s >= 0.15 && hueDistance(ha.h, hb.h) >= 30) return true;
49
+ if (Math.abs(ha.s - hb.s) >= 0.5) return true;
50
+ const la = relativeLuminance(normalizeToHex(a) as string);
51
+ const lb = relativeLuminance(normalizeToHex(b) as string);
52
+ return contrastRatio(la, lb) >= 1.5;
53
+ }
54
+
55
+ describe("resolveChartPalette", () => {
56
+ it("single-series takes the theme identity solid", () => {
57
+ expect(resolveChartPalette({ scheme: "light", identitySolid: lightAccent }).single).toBe(
58
+ lightAccent,
59
+ );
60
+ expect(resolveChartPalette({ scheme: "dark", identitySolid: darkAccent }).single).toBe(
61
+ darkAccent,
62
+ );
63
+ });
64
+
65
+ it("categorical palette is identity-led", () => {
66
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: lightAccent });
67
+ expect(palette.categorical[0]).toBe(lightAccent);
68
+ });
69
+
70
+ it("drops the cycle hue confusable with the accent identity (violet)", () => {
71
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: lightAccent });
72
+ expect(palette.categorical).not.toContain(Colors.violet.violet9);
73
+ expect(palette.categorical).toContain(Colors.blue.blue9);
74
+ expect(palette.categorical).toHaveLength(8);
75
+ });
76
+
77
+ it("re-anchors under a red tint and drops the red cycle hue", () => {
78
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: redTintSolid });
79
+ expect(palette.categorical[0]).toBe(redTintSolid);
80
+ expect(palette.categorical).not.toContain(Colors.red.red9);
81
+ expect(palette.categorical).toContain(Colors.violet.violet9);
82
+ });
83
+
84
+ it("re-anchors under a purple tint and drops the violet cycle hue", () => {
85
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: purpleTintSolid });
86
+ expect(palette.categorical[0]).toBe(purpleTintSolid);
87
+ expect(palette.categorical).not.toContain(Colors.violet.violet9);
88
+ expect(palette.categorical).toContain(Colors.red.red9);
89
+ });
90
+
91
+ it("drops every cycle hue within the confusable band (tomato drops red and orange)", () => {
92
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: tomatoTintSolid });
93
+ expect(palette.categorical).not.toContain(Colors.red.red9);
94
+ expect(palette.categorical).not.toContain(Colors.orange.orange9);
95
+ expect(palette.categorical).toHaveLength(7);
96
+ });
97
+
98
+ it("keeps the full cycle behind a neutral identity (gray tint)", () => {
99
+ const palette = resolveChartPalette({ scheme: "light", identitySolid: grayTintSolid });
100
+ expect(palette.categorical[0]).toBe(grayTintSolid);
101
+ expect(palette.categorical).toHaveLength(9);
102
+ });
103
+
104
+ it("falls back to the violet ramp solid when identity is missing or unparseable", () => {
105
+ const missing = resolveChartPalette({ scheme: "light" });
106
+ expect(missing.single).toBe(Colors.violet.violet9);
107
+ const unparseable = resolveChartPalette({
108
+ scheme: "light",
109
+ identitySolid: "var(--accentBackground)",
110
+ });
111
+ expect(unparseable.single).toBe(Colors.violet.violet9);
112
+ // Fallback identity still dedupes its own hue family from the cycle.
113
+ expect(unparseable.categorical.filter((c) => c === Colors.violet.violet9)).toHaveLength(1);
114
+ });
115
+
116
+ it("semantic series come from the sanctioned semantic ramps, scheme-aware", () => {
117
+ const light = resolveChartPalette({ scheme: "light", identitySolid: lightAccent });
118
+ const dark = resolveChartPalette({ scheme: "dark", identitySolid: darkAccent });
119
+ expect(light.semantic.error).toBe(Colors.red.red9);
120
+ expect(light.semantic.success).toBe(Colors.green.green9);
121
+ expect(light.semantic.warning).toBe(Colors.yellow.yellow11);
122
+ expect(dark.semantic.error).toBe(Colors.redDark.red9);
123
+ expect(dark.semantic.success).toBe(Colors.greenDark.green9);
124
+ expect(dark.semantic.warning).toBe(Colors.yellowDark.yellow9);
125
+ // Warning adapts per scheme (yellow9 is ~1.3:1 against light surfaces).
126
+ expect(light.semantic.warning).not.toBe(dark.semantic.warning);
127
+ });
128
+
129
+ it("keeps adjacent categorical marks distinguishable in light and dark for every identity", () => {
130
+ const identities = [
131
+ lightAccent,
132
+ darkAccent,
133
+ redTintSolid,
134
+ purpleTintSolid,
135
+ tomatoTintSolid,
136
+ grayTintSolid,
137
+ undefined,
138
+ ];
139
+ for (const scheme of ["light", "dark"] as const) {
140
+ for (const identitySolid of identities) {
141
+ const palette = resolveChartPalette({ scheme, identitySolid });
142
+ for (let i = 0; i < palette.categorical.length - 1; i++) {
143
+ const a = palette.categorical[i];
144
+ const b = palette.categorical[i + 1];
145
+ expect(
146
+ adjacentDistinguishable(a, b),
147
+ `${scheme} identity=${identitySolid ?? "(none)"} pair ${i}: ${a} vs ${b}`,
148
+ ).toBe(true);
149
+ }
150
+ }
151
+ }
152
+ });
153
+
154
+ it("is pure and stable for identical input", () => {
155
+ const a = resolveChartPalette({ scheme: "dark", identitySolid: redTintSolid });
156
+ const b = resolveChartPalette({ scheme: "dark", identitySolid: redTintSolid });
157
+ expect(a).toEqual(b);
158
+ });
159
+ });
@@ -0,0 +1,170 @@
1
+ import * as Colors from "@tamagui/colors";
2
+ import { useMemo } from "react";
3
+ import { useTheme as useTamaguiTheme, useThemeName } from "tamagui";
4
+ import { normalizeToHex } from "./colorRules";
5
+ import { tintHueNames } from "./createThemes";
6
+
7
+ /**
8
+ * Chart palette contract (SB-D-07 / SB-D-25 GAP closure).
9
+ *
10
+ * Charts are chrome that renders data, so their DEFAULT colors must come from
11
+ * the one theme channel (Axiom 5 ONE SOURCE) while explicit user/data colors
12
+ * pass through untouched (Axiom 11 VALUE IS DATA):
13
+ *
14
+ * - `single` — every mark of a single-series chart takes the theme identity:
15
+ * the active accent, or the tint solid when a tint sub-theme is active
16
+ * (the palette re-anchors under tint exactly like the neutral ramp does).
17
+ * - `categorical` — identity-led list of sanctioned Radix step-9 solids for
18
+ * multi-series data. Slot 0 is the theme identity; the fixed hue cycle
19
+ * fills the rest, dropping hues indistinguishable from the identity so
20
+ * adjacent series stay tellable-apart under every tint.
21
+ * - `semantic` — error/success/warning series solids from the sanctioned
22
+ * semantic ramps ($red/$green/$yellow), tint-independent, scheme-aware.
23
+ *
24
+ * Values resolve from the same Radix scales the theme builder splats into the
25
+ * base themes, so `categorical[1]` under `light` equals `$blue9`, and they
26
+ * stay resolvable on native and under tint sub-themes (which only carry the
27
+ * re-ramped neutral family).
28
+ */
29
+ export interface ChartPalette {
30
+ /** Scheme the palette resolved for. */
31
+ scheme: "light" | "dark";
32
+ /** Theme-identity solid: paints every mark of a single-series chart. */
33
+ single: string;
34
+ /** Identity-led distinguishable solids for categorical multi-series data. */
35
+ categorical: string[];
36
+ /** Semantic series solids (trend/status series), tint-independent. */
37
+ semantic: { error: string; success: string; warning: string };
38
+ }
39
+
40
+ export interface ResolveChartPaletteInput {
41
+ scheme: "light" | "dark";
42
+ /**
43
+ * Resolved theme-identity solid — `accentBackground` in base themes, the
44
+ * re-ramped `$color9` under a tint. Falls back to the violet ramp solid
45
+ * when missing or unparseable (SSR before hydration, exotic values).
46
+ */
47
+ identitySolid?: string;
48
+ }
49
+
50
+ /**
51
+ * Fixed categorical hue cycle: Radix step-9 solids ordered so neighbouring
52
+ * entries sit far apart on the hue wheel (min adjacent distance ~78°).
53
+ * Step 9 is the Radix "solid" step, designed for filled marks on both
54
+ * schemes; hues where Radix ships scheme-specific solids follow the scheme.
55
+ */
56
+ const categoricalHueCycle = [
57
+ { family: "blue", light: Colors.blue.blue9, dark: Colors.blueDark.blue9 },
58
+ { family: "orange", light: Colors.orange.orange9, dark: Colors.orangeDark.orange9 },
59
+ { family: "green", light: Colors.green.green9, dark: Colors.greenDark.green9 },
60
+ { family: "amber", light: Colors.amber.amber9, dark: Colors.amberDark.amber9 },
61
+ { family: "pink", light: Colors.pink.pink9, dark: Colors.pinkDark.pink9 },
62
+ { family: "teal", light: Colors.teal.teal9, dark: Colors.tealDark.teal9 },
63
+ { family: "violet", light: Colors.violet.violet9, dark: Colors.violetDark.violet9 },
64
+ { family: "red", light: Colors.red.red9, dark: Colors.redDark.red9 },
65
+ ] as const;
66
+
67
+ /**
68
+ * Semantic series solids. Error/success use the step-9 solids. Warning uses
69
+ * the sanctioned $yellow ramp, but yellow9 measures ~1.3:1 against light
70
+ * card surfaces (invisible marks), so light scheme takes the deep yellow11
71
+ * step while dark keeps the bright yellow9 (13:1 on dark surfaces).
72
+ */
73
+ const semanticSeries = {
74
+ light: {
75
+ error: Colors.red.red9,
76
+ success: Colors.green.green9,
77
+ warning: Colors.yellow.yellow11,
78
+ },
79
+ dark: {
80
+ error: Colors.redDark.red9,
81
+ success: Colors.greenDark.green9,
82
+ warning: Colors.yellowDark.yellow9,
83
+ },
84
+ } as const;
85
+
86
+ /** Below this HSL saturation the identity is neutral-ish; hue is meaningless. */
87
+ const neutralSaturationFloor = 0.15;
88
+ /** Cycle hues closer than this to the identity hue are dropped as confusable. */
89
+ const hueDedupeThresholdDeg = 30;
90
+
91
+ interface Hsl {
92
+ h: number;
93
+ s: number;
94
+ l: number;
95
+ }
96
+
97
+ function colorToHsl(color: string): Hsl | null {
98
+ const hex = normalizeToHex(color);
99
+ if (!hex) return null;
100
+ const r = Number.parseInt(hex.slice(1, 3), 16) / 255;
101
+ const g = Number.parseInt(hex.slice(3, 5), 16) / 255;
102
+ const b = Number.parseInt(hex.slice(5, 7), 16) / 255;
103
+ const max = Math.max(r, g, b);
104
+ const min = Math.min(r, g, b);
105
+ const l = (max + min) / 2;
106
+ const d = max - min;
107
+ if (d === 0) return { h: 0, s: 0, l };
108
+ const s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
109
+ let h: number;
110
+ if (max === r) h = ((g - b) / d + (g < b ? 6 : 0)) * 60;
111
+ else if (max === g) h = ((b - r) / d + 2) * 60;
112
+ else h = ((r - g) / d + 4) * 60;
113
+ return { h, s, l };
114
+ }
115
+
116
+ function hueDistance(a: number, b: number): number {
117
+ const d = Math.abs(a - b) % 360;
118
+ return d > 180 ? 360 - d : d;
119
+ }
120
+
121
+ /**
122
+ * Pure palette resolution — see {@link ChartPalette} for the contract.
123
+ * Exported separately from the hook so tests and non-React callers can
124
+ * resolve palettes for any scheme/identity combination.
125
+ */
126
+ export function resolveChartPalette(input: ResolveChartPaletteInput): ChartPalette {
127
+ const { scheme } = input;
128
+ const fallbackIdentity = scheme === "dark" ? Colors.violetDark.violet9 : Colors.violet.violet9;
129
+ const identityHslRaw = input.identitySolid ? colorToHsl(input.identitySolid) : null;
130
+ const single = identityHslRaw ? (input.identitySolid as string) : fallbackIdentity;
131
+ const identityHsl = identityHslRaw ?? (colorToHsl(fallbackIdentity) as Hsl);
132
+
133
+ const cycle = categoricalHueCycle.map((entry) => (scheme === "dark" ? entry.dark : entry.light));
134
+ const deduped =
135
+ identityHsl.s < neutralSaturationFloor
136
+ ? cycle
137
+ : cycle.filter((value) => {
138
+ const hsl = colorToHsl(value);
139
+ if (!hsl) return true;
140
+ return hueDistance(hsl.h, identityHsl.h) >= hueDedupeThresholdDeg;
141
+ });
142
+
143
+ return {
144
+ scheme,
145
+ single,
146
+ categorical: [single, ...deduped],
147
+ semantic: semanticSeries[scheme],
148
+ };
149
+ }
150
+
151
+ /**
152
+ * Canonical chart palette for the active Tamagui theme.
153
+ *
154
+ * Reads scheme and tint from the resolved theme name and the identity solid
155
+ * from the active theme (`$color9` under a tint — the re-ramped neutral
156
+ * solid — otherwise `$accentBackground`). Chart chrome MUST take its default
157
+ * series colors from this hook; explicit `color`/`colors` props on chart
158
+ * data are user values and pass through untouched.
159
+ */
160
+ export function useChartPalette(): ChartPalette {
161
+ const themeName = useThemeName() as string | undefined;
162
+ const theme = useTamaguiTheme() as unknown as Record<string, { val?: string } | undefined>;
163
+ const scheme: "light" | "dark" = themeName?.startsWith("dark") ? "dark" : "light";
164
+ const nameParts = (themeName ?? "").split("_");
165
+ const isTint = nameParts.length > 1 && tintHueNames.has(nameParts[1]);
166
+ const color9 = theme.color9?.val;
167
+ const accent = theme.accentBackground?.val;
168
+ const identitySolid = isTint ? (color9 ?? accent) : (accent ?? color9);
169
+ return useMemo(() => resolveChartPalette({ scheme, identitySolid }), [scheme, identitySolid]);
170
+ }
@@ -34,8 +34,13 @@ export function normalizeToHex(raw: string): string | null {
34
34
  if (s.length === 4) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`;
35
35
  return s;
36
36
  }
37
- const hslMatch = s.match(/hsl\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*\)/);
37
+ const hslMatch = s.match(
38
+ /hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*([\d.]+)\s*)?\)/,
39
+ );
38
40
  if (hslMatch) {
41
+ // Treat only fully-opaque hsla as a plain color; alpha blends depend on
42
+ // the backdrop and cannot be reduced to a single hex.
43
+ if (hslMatch[4] !== undefined && Number(hslMatch[4]) < 0.999) return null;
39
44
  const h = Number(hslMatch[1]);
40
45
  const sat = Number(hslMatch[2]) / 100;
41
46
  const l = Number(hslMatch[3]) / 100;
@@ -69,6 +74,33 @@ export function contrastRatio(l1: number, l2: number): number {
69
74
 
70
75
  export const minContrastRatio = 3;
71
76
 
77
+ /** WCAG AA contrast floor for normal-size body/label text. */
78
+ export const aaTextContrastRatio = 4.5;
79
+
80
+ /**
81
+ * Axiom 15 OPTICS — pick whichever candidate foreground carries more WCAG
82
+ * contrast on a given `fill`. The canonical "readable foreground on a fill"
83
+ * primitive: intent Buttons (defaults/builderOptions), the Switch thumb and
84
+ * on-accent labels (useReadableTextOn) all reduce to this. Inputs/outputs are
85
+ * raw color strings so callers keep their own tokens/hsla; returns
86
+ * `candidateA` unchanged when any color fails to parse (safe default).
87
+ */
88
+ export function pickReadableForeground(
89
+ fill: string,
90
+ candidateA: string,
91
+ candidateB: string,
92
+ ): string {
93
+ const fillHex = normalizeToHex(fill);
94
+ const aHex = normalizeToHex(candidateA);
95
+ const bHex = normalizeToHex(candidateB);
96
+ if (!fillHex || !aHex || !bHex) return candidateA;
97
+ const fillLum = relativeLuminance(fillHex);
98
+ return contrastRatio(relativeLuminance(aHex), fillLum) >=
99
+ contrastRatio(relativeLuminance(bHex), fillLum)
100
+ ? candidateA
101
+ : candidateB;
102
+ }
103
+
72
104
  /**
73
105
  * Given a 12-color scale (hex strings), a background step, and a starting
74
106
  * text step, scan towards `preferredDir` for the first step whose contrast