react-native-terra-ui 0.7.2 → 0.8.1

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 (252) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/README.md +1 -1
  3. package/lib/module/components/avatar/Avatar.js +20 -20
  4. package/lib/module/components/avatar/Avatar.js.map +1 -1
  5. package/lib/module/components/button/Button.js +11 -11
  6. package/lib/module/components/button/Button.js.map +1 -1
  7. package/lib/module/components/chip/Chip.js +32 -32
  8. package/lib/module/components/chip/Chip.js.map +1 -1
  9. package/lib/module/components/empty-state/EmptyState.js +3 -3
  10. package/lib/module/components/empty-state/EmptyState.js.map +1 -1
  11. package/lib/module/components/header/variants/LargeTitleHeader/index.js +30 -32
  12. package/lib/module/components/header/variants/LargeTitleHeader/index.js.map +1 -1
  13. package/lib/module/components/header/variants/TitleHeader/index.js +26 -18
  14. package/lib/module/components/header/variants/TitleHeader/index.js.map +1 -1
  15. package/lib/module/components/icon/Icon.js +3 -3
  16. package/lib/module/components/page-indicator/hooks/use-page-indicator-colors.js +2 -2
  17. package/lib/module/components/screen/Screen.js +63 -34
  18. package/lib/module/components/screen/Screen.js.map +1 -1
  19. package/lib/module/components/screen/ScreenContext.js +28 -9
  20. package/lib/module/components/screen/ScreenContext.js.map +1 -1
  21. package/lib/module/components/screen/ScreenFlatList.js +4 -162
  22. package/lib/module/components/screen/ScreenFlatList.js.map +1 -1
  23. package/lib/module/components/screen/ScreenList.js +81 -97
  24. package/lib/module/components/screen/ScreenList.js.map +1 -1
  25. package/lib/module/components/screen/ScreenScrollView.js +64 -115
  26. package/lib/module/components/screen/ScreenScrollView.js.map +1 -1
  27. package/lib/module/components/screen/hooks/use-screen-ref.js +18 -0
  28. package/lib/module/components/screen/hooks/use-screen-ref.js.map +1 -0
  29. package/lib/module/components/screen/hooks/use-screen-scroll-handler.js +19 -0
  30. package/lib/module/components/screen/hooks/use-screen-scroll-handler.js.map +1 -0
  31. package/lib/module/components/screen/index.js.map +1 -1
  32. package/lib/module/components/screen/parts/ScreenFrame.js +47 -0
  33. package/lib/module/components/screen/parts/ScreenFrame.js.map +1 -0
  34. package/lib/module/components/screen/parts/ScreenHeaderAppearance.js +43 -0
  35. package/lib/module/components/screen/parts/ScreenHeaderAppearance.js.map +1 -0
  36. package/lib/module/components/screen/parts/ScreenPlaceholder.js +11 -13
  37. package/lib/module/components/screen/parts/ScreenPlaceholder.js.map +1 -1
  38. package/lib/module/components/screen/parts/ScreenSafeArea.android.js +27 -0
  39. package/lib/module/components/screen/parts/ScreenSafeArea.android.js.map +1 -0
  40. package/lib/module/components/screen/parts/ScreenSafeArea.js +26 -0
  41. package/lib/module/components/screen/parts/ScreenSafeArea.js.map +1 -0
  42. package/lib/module/components/screen/utils.js +18 -9
  43. package/lib/module/components/screen/utils.js.map +1 -1
  44. package/lib/module/components/spinner/Spinner.js +2 -2
  45. package/lib/module/components/text/Text.js +1 -1
  46. package/lib/module/components/toast/Toast.js +13 -13
  47. package/lib/module/components/toast/Toast.js.map +1 -1
  48. package/lib/module/components/toolbar/Toolbar.js +2 -2
  49. package/lib/module/context/ThemeProvider.js +22 -39
  50. package/lib/module/context/ThemeProvider.js.map +1 -1
  51. package/lib/module/context/index.js +1 -1
  52. package/lib/module/context/index.js.map +1 -1
  53. package/lib/module/theme/breakpoints.js +1 -1
  54. package/lib/module/theme/define-config.js +21 -0
  55. package/lib/module/theme/define-config.js.map +1 -0
  56. package/lib/module/theme/diagnostics.js +13 -0
  57. package/lib/module/theme/diagnostics.js.map +1 -0
  58. package/lib/module/theme/errors.js +15 -0
  59. package/lib/module/theme/errors.js.map +1 -0
  60. package/lib/module/theme/index.js +7 -1
  61. package/lib/module/theme/index.js.map +1 -1
  62. package/lib/module/theme/legacy-tokens.js +119 -0
  63. package/lib/module/theme/legacy-tokens.js.map +1 -0
  64. package/lib/module/theme/registry.js +122 -130
  65. package/lib/module/theme/registry.js.map +1 -1
  66. package/lib/module/theme/resolve-config.js +346 -0
  67. package/lib/module/theme/resolve-config.js.map +1 -0
  68. package/lib/module/theme/runtime.js +57 -12
  69. package/lib/module/theme/runtime.js.map +1 -1
  70. package/lib/module/theme/screen-margin.js +6 -14
  71. package/lib/module/theme/screen-margin.js.map +1 -1
  72. package/lib/module/theme/screen-padding.js +17 -0
  73. package/lib/module/theme/screen-padding.js.map +1 -0
  74. package/lib/module/theme/selection-store.js +75 -0
  75. package/lib/module/theme/selection-store.js.map +1 -0
  76. package/lib/module/theme/theme.js +81 -36
  77. package/lib/module/theme/theme.js.map +1 -1
  78. package/lib/module/theme/tokens/canonical-color-keys.js +102 -0
  79. package/lib/module/theme/tokens/canonical-color-keys.js.map +1 -0
  80. package/lib/module/theme/tokens/dark.js +72 -88
  81. package/lib/module/theme/tokens/dark.js.map +1 -1
  82. package/lib/module/theme/tokens/light.js +69 -83
  83. package/lib/module/theme/tokens/light.js.map +1 -1
  84. package/lib/module/theme/tokens/primitives.js +14 -14
  85. package/lib/module/theme/tokens/primitives.js.map +1 -1
  86. package/lib/module/theme/tokens/validate.js +238 -0
  87. package/lib/module/theme/tokens/validate.js.map +1 -0
  88. package/lib/module/theme/unistyles-adapter.js +77 -0
  89. package/lib/module/theme/unistyles-adapter.js.map +1 -0
  90. package/lib/module/utils/accent-generator.js +476 -0
  91. package/lib/module/utils/accent-generator.js.map +1 -0
  92. package/lib/module/utils/accent-utils.js +52 -35
  93. package/lib/module/utils/accent-utils.js.map +1 -1
  94. package/lib/module/utils/color-utils.js +224 -7
  95. package/lib/module/utils/color-utils.js.map +1 -1
  96. package/lib/module/utils/deep-merge.js +1 -1
  97. package/lib/module/utils/deep-merge.js.map +1 -1
  98. package/lib/module/utils/resolve-theme-color.js +2 -2
  99. package/lib/typescript/src/components/chip/types.d.ts +1 -1
  100. package/lib/typescript/src/components/empty-state/EmptyState.d.ts +1 -1
  101. package/lib/typescript/src/components/empty-state/EmptyState.d.ts.map +1 -1
  102. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts +6 -5
  103. package/lib/typescript/src/components/header/variants/LargeTitleHeader/index.d.ts.map +1 -1
  104. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts +11 -5
  105. package/lib/typescript/src/components/header/variants/TitleHeader/index.d.ts.map +1 -1
  106. package/lib/typescript/src/components/icon/Icon.d.ts +2 -2
  107. package/lib/typescript/src/components/screen/Screen.d.ts +45 -16
  108. package/lib/typescript/src/components/screen/Screen.d.ts.map +1 -1
  109. package/lib/typescript/src/components/screen/ScreenContext.d.ts +42 -26
  110. package/lib/typescript/src/components/screen/ScreenContext.d.ts.map +1 -1
  111. package/lib/typescript/src/components/screen/ScreenFlatList.d.ts +5 -46
  112. package/lib/typescript/src/components/screen/ScreenFlatList.d.ts.map +1 -1
  113. package/lib/typescript/src/components/screen/ScreenList.d.ts +62 -50
  114. package/lib/typescript/src/components/screen/ScreenList.d.ts.map +1 -1
  115. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts +18 -58
  116. package/lib/typescript/src/components/screen/ScreenScrollView.d.ts.map +1 -1
  117. package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts +5 -0
  118. package/lib/typescript/src/components/screen/hooks/use-screen-ref.d.ts.map +1 -0
  119. package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts +53 -0
  120. package/lib/typescript/src/components/screen/hooks/use-screen-scroll-handler.d.ts.map +1 -0
  121. package/lib/typescript/src/components/screen/index.d.ts +2 -2
  122. package/lib/typescript/src/components/screen/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts +6 -0
  124. package/lib/typescript/src/components/screen/parts/ScreenFrame.d.ts.map +1 -0
  125. package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts +21 -0
  126. package/lib/typescript/src/components/screen/parts/ScreenHeaderAppearance.d.ts.map +1 -0
  127. package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts +6 -8
  128. package/lib/typescript/src/components/screen/parts/ScreenPlaceholder.d.ts.map +1 -1
  129. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts +7 -0
  130. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.android.d.ts.map +1 -0
  131. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts +6 -0
  132. package/lib/typescript/src/components/screen/parts/ScreenSafeArea.d.ts.map +1 -0
  133. package/lib/typescript/src/components/screen/types.d.ts +27 -32
  134. package/lib/typescript/src/components/screen/types.d.ts.map +1 -1
  135. package/lib/typescript/src/components/screen/utils.d.ts +9 -8
  136. package/lib/typescript/src/components/screen/utils.d.ts.map +1 -1
  137. package/lib/typescript/src/components/text/Text.d.ts +1 -1
  138. package/lib/typescript/src/components/text/Text.d.ts.map +1 -1
  139. package/lib/typescript/src/components/toolbar/Toolbar.d.ts +2 -2
  140. package/lib/typescript/src/context/ThemeProvider.d.ts +29 -12
  141. package/lib/typescript/src/context/ThemeProvider.d.ts.map +1 -1
  142. package/lib/typescript/src/context/index.d.ts +1 -1
  143. package/lib/typescript/src/context/index.d.ts.map +1 -1
  144. package/lib/typescript/src/theme/breakpoints.d.ts +1 -1
  145. package/lib/typescript/src/theme/define-config.d.ts +23 -0
  146. package/lib/typescript/src/theme/define-config.d.ts.map +1 -0
  147. package/lib/typescript/src/theme/diagnostics.d.ts +4 -0
  148. package/lib/typescript/src/theme/diagnostics.d.ts.map +1 -0
  149. package/lib/typescript/src/theme/errors.d.ts +10 -0
  150. package/lib/typescript/src/theme/errors.d.ts.map +1 -0
  151. package/lib/typescript/src/theme/index.d.ts +10 -2
  152. package/lib/typescript/src/theme/index.d.ts.map +1 -1
  153. package/lib/typescript/src/theme/legacy-tokens.d.ts +187 -0
  154. package/lib/typescript/src/theme/legacy-tokens.d.ts.map +1 -0
  155. package/lib/typescript/src/theme/registry.d.ts +12 -17
  156. package/lib/typescript/src/theme/registry.d.ts.map +1 -1
  157. package/lib/typescript/src/theme/resolve-config.d.ts +99 -0
  158. package/lib/typescript/src/theme/resolve-config.d.ts.map +1 -0
  159. package/lib/typescript/src/theme/runtime.d.ts +25 -4
  160. package/lib/typescript/src/theme/runtime.d.ts.map +1 -1
  161. package/lib/typescript/src/theme/screen-margin.d.ts +6 -14
  162. package/lib/typescript/src/theme/screen-margin.d.ts.map +1 -1
  163. package/lib/typescript/src/theme/screen-padding.d.ts +15 -0
  164. package/lib/typescript/src/theme/screen-padding.d.ts.map +1 -0
  165. package/lib/typescript/src/theme/selection-store.d.ts +41 -0
  166. package/lib/typescript/src/theme/selection-store.d.ts.map +1 -0
  167. package/lib/typescript/src/theme/theme.d.ts +54 -1
  168. package/lib/typescript/src/theme/theme.d.ts.map +1 -1
  169. package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts +95 -0
  170. package/lib/typescript/src/theme/tokens/canonical-color-keys.d.ts.map +1 -0
  171. package/lib/typescript/src/theme/tokens/dark.d.ts +56 -63
  172. package/lib/typescript/src/theme/tokens/dark.d.ts.map +1 -1
  173. package/lib/typescript/src/theme/tokens/light.d.ts +56 -71
  174. package/lib/typescript/src/theme/tokens/light.d.ts.map +1 -1
  175. package/lib/typescript/src/theme/tokens/primitives.d.ts +11 -10
  176. package/lib/typescript/src/theme/tokens/primitives.d.ts.map +1 -1
  177. package/lib/typescript/src/theme/tokens/validate.d.ts +34 -0
  178. package/lib/typescript/src/theme/tokens/validate.d.ts.map +1 -0
  179. package/lib/typescript/src/theme/types.d.ts +166 -86
  180. package/lib/typescript/src/theme/types.d.ts.map +1 -1
  181. package/lib/typescript/src/theme/unistyles-adapter.d.ts +33 -0
  182. package/lib/typescript/src/theme/unistyles-adapter.d.ts.map +1 -0
  183. package/lib/typescript/src/utils/accent-generator.d.ts +103 -0
  184. package/lib/typescript/src/utils/accent-generator.d.ts.map +1 -0
  185. package/lib/typescript/src/utils/accent-utils.d.ts +15 -2
  186. package/lib/typescript/src/utils/accent-utils.d.ts.map +1 -1
  187. package/lib/typescript/src/utils/color-utils.d.ts +59 -2
  188. package/lib/typescript/src/utils/color-utils.d.ts.map +1 -1
  189. package/lib/typescript/src/utils/deep-merge.d.ts +1 -0
  190. package/lib/typescript/src/utils/deep-merge.d.ts.map +1 -1
  191. package/lib/typescript/src/utils/resolve-theme-color.d.ts +2 -2
  192. package/package.json +1 -1
  193. package/src/components/avatar/Avatar.tsx +20 -20
  194. package/src/components/button/Button.tsx +11 -11
  195. package/src/components/chip/Chip.tsx +32 -32
  196. package/src/components/chip/types.ts +1 -1
  197. package/src/components/empty-state/EmptyState.tsx +4 -4
  198. package/src/components/header/variants/LargeTitleHeader/index.tsx +31 -30
  199. package/src/components/header/variants/TitleHeader/index.tsx +33 -22
  200. package/src/components/icon/Icon.tsx +4 -4
  201. package/src/components/page-indicator/hooks/use-page-indicator-colors.ts +2 -2
  202. package/src/components/screen/Screen.tsx +89 -43
  203. package/src/components/screen/ScreenContext.tsx +48 -28
  204. package/src/components/screen/ScreenFlatList.tsx +5 -191
  205. package/src/components/screen/ScreenList.tsx +197 -175
  206. package/src/components/screen/ScreenScrollView.tsx +92 -167
  207. package/src/components/screen/hooks/use-screen-ref.ts +24 -0
  208. package/src/components/screen/hooks/use-screen-scroll-handler.ts +31 -0
  209. package/src/components/screen/index.ts +8 -2
  210. package/src/components/screen/parts/ScreenFrame.tsx +49 -0
  211. package/src/components/screen/parts/ScreenHeaderAppearance.tsx +52 -0
  212. package/src/components/screen/parts/ScreenPlaceholder.tsx +16 -16
  213. package/src/components/screen/parts/ScreenSafeArea.android.tsx +22 -0
  214. package/src/components/screen/parts/ScreenSafeArea.tsx +20 -0
  215. package/src/components/screen/types.ts +29 -32
  216. package/src/components/screen/utils.ts +24 -11
  217. package/src/components/spinner/Spinner.tsx +2 -2
  218. package/src/components/text/Text.tsx +2 -2
  219. package/src/components/toast/Toast.tsx +13 -13
  220. package/src/components/toolbar/Toolbar.tsx +4 -4
  221. package/src/context/ThemeProvider.tsx +47 -51
  222. package/src/context/index.ts +1 -1
  223. package/src/theme/breakpoints.ts +1 -1
  224. package/src/theme/define-config.ts +27 -0
  225. package/src/theme/diagnostics.ts +11 -0
  226. package/src/theme/errors.ts +14 -0
  227. package/src/theme/index.ts +40 -1
  228. package/src/theme/legacy-tokens.ts +227 -0
  229. package/src/theme/registry.ts +119 -137
  230. package/src/theme/resolve-config.ts +406 -0
  231. package/src/theme/runtime.ts +52 -14
  232. package/src/theme/screen-margin.ts +6 -25
  233. package/src/theme/screen-padding.ts +26 -0
  234. package/src/theme/selection-store.ts +76 -0
  235. package/src/theme/theme.ts +104 -39
  236. package/src/theme/tokens/canonical-color-keys.ts +107 -0
  237. package/src/theme/tokens/dark.ts +91 -98
  238. package/src/theme/tokens/light.ts +88 -93
  239. package/src/theme/tokens/primitives.ts +14 -14
  240. package/src/theme/tokens/validate.ts +271 -0
  241. package/src/theme/types.ts +184 -95
  242. package/src/theme/unistyles-adapter.ts +75 -0
  243. package/src/utils/accent-generator.ts +530 -0
  244. package/src/utils/accent-utils.ts +66 -39
  245. package/src/utils/color-utils.ts +225 -8
  246. package/src/utils/deep-merge.ts +1 -1
  247. package/src/utils/resolve-theme-color.ts +2 -2
  248. package/lib/module/components/screen/parts/BottomSafeArea.js +0 -62
  249. package/lib/module/components/screen/parts/BottomSafeArea.js.map +0 -1
  250. package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts +0 -30
  251. package/lib/typescript/src/components/screen/parts/BottomSafeArea.d.ts.map +0 -1
  252. package/src/components/screen/parts/BottomSafeArea.tsx +0 -59
@@ -0,0 +1,406 @@
1
+ /**
2
+ * Pure theme resolution — normalization, layer processing, derived values and
3
+ * theme creation. Shared by startup configuration, runtime accent changes and
4
+ * previews so every consumer sees the same colors for the same input.
5
+ *
6
+ * Precedence, identical for both schemes:
7
+ *
8
+ * default tokens → shared → schemes[scheme]
9
+ * → generated selected accent → selected accent explicit overrides
10
+ *
11
+ * This module must stay free of registry, provider, Unistyles and renderer
12
+ * imports so `createTerraThemes` works without any native configuration.
13
+ */
14
+ import {
15
+ type AccentGenerationContext,
16
+ type AccentSurfaceRole,
17
+ type ContrastDiagnostic,
18
+ checkAccentContrast,
19
+ GENERATED_ACCENT_ROLES,
20
+ type GeneratedAccentRole,
21
+ generateAccentColors,
22
+ isSupportedAccentSeed,
23
+ } from "#utils/accent-generator";
24
+ import { deepMerge, isPlainObject } from "#utils/deep-merge";
25
+ import { warnContrastDiagnostics } from "./diagnostics";
26
+ import { TerraConfigError } from "./errors";
27
+ import { normalizeTokenOverride } from "./legacy-tokens";
28
+ import {
29
+ buildRadiusScale,
30
+ buildSpacingScale,
31
+ createDefaultThemeSource,
32
+ finalizeThemeSource,
33
+ type ThemeSource,
34
+ } from "./theme";
35
+ import type {
36
+ Accent,
37
+ AccentSchemeInput,
38
+ CreateTerraThemesOptions,
39
+ ElevationKey,
40
+ Scheme,
41
+ TerraTheme,
42
+ TerraThemeInput,
43
+ TerraThemeOverride,
44
+ ThemeColor,
45
+ } from "./types";
46
+
47
+ const SCHEMES: readonly Scheme[] = ["light", "dark"];
48
+
49
+ const SURFACE_ROLES: readonly AccentSurfaceRole[] = [
50
+ "surface.canvas",
51
+ "surface.default",
52
+ "surface.raised",
53
+ "surface.sunken",
54
+ ];
55
+
56
+ // ─── Layer state ─────────────────────────────────────────────────────────────
57
+
58
+ /**
59
+ * A theme mid-resolution: its source form plus provenance for every elevation
60
+ * level whose `shadowColor` a layer pinned explicitly. Default shadow colors are
61
+ * derived, never pinned, so a later `color.shadow` override still moves them.
62
+ */
63
+ export interface ThemeLayerState {
64
+ readonly source: ThemeSource;
65
+ readonly pinnedShadowColors: Readonly<Partial<Record<ElevationKey, string>>>;
66
+ }
67
+
68
+ /**
69
+ * Deep-copies an override, dropping `undefined` and `null` leaves (neither is
70
+ * an override; `null` is not a deletion mechanism). The copy means later
71
+ * mutation of caller data can never reach a normalized config or a theme.
72
+ */
73
+ function sanitizeOverride<T>(value: T): T {
74
+ if (Array.isArray(value)) {
75
+ return value.map((item) => sanitizeOverride(item)) as unknown as T;
76
+ }
77
+ if (isPlainObject(value)) {
78
+ const out: Record<string, unknown> = {};
79
+ for (const [key, item] of Object.entries(value)) {
80
+ if (item === undefined || item === null) continue;
81
+ out[key] = sanitizeOverride(item);
82
+ }
83
+ return out as T;
84
+ }
85
+ return value;
86
+ }
87
+
88
+ /**
89
+ * Applies one override layer onto `state` and returns the next state. Every
90
+ * layer — shared, scheme, generated accent, accent overrides, or a full accent
91
+ * patch — goes through here, so they all follow the same rules:
92
+ * - deprecated names are normalized within the layer (canonical wins);
93
+ * - `undefined`/`null` leaves are no override; arrays replace; objects merge;
94
+ * - `spacing.base` / `radius.base` regenerate their scale, then that layer's
95
+ * explicit steps win over the regenerated values;
96
+ * - `elevation.<level>.shadowColor` pins that level (a later pin replaces an
97
+ * earlier one); other elevation fields merge normally.
98
+ * Inputs are never mutated.
99
+ */
100
+ export function applyThemeLayer(state: ThemeLayerState, override?: TerraThemeOverride): ThemeLayerState {
101
+ if (!override) return state;
102
+ const normalized = normalizeTokenOverride(sanitizeOverride(override));
103
+ if (!normalized) return state;
104
+
105
+ const { radius, spacing, elevation, ...rest } = normalized;
106
+ let source = deepMerge(state.source, rest as Partial<ThemeSource>);
107
+ const pinnedShadowColors = { ...state.pinnedShadowColors };
108
+
109
+ if (radius) {
110
+ const { base, ...steps } = radius;
111
+ if (base !== undefined) {
112
+ source = { ...source, radius: buildRadiusScale(base) };
113
+ }
114
+ source = deepMerge(source, { radius: steps });
115
+ }
116
+
117
+ if (spacing) {
118
+ const { base, ...steps } = spacing;
119
+ if (base !== undefined) {
120
+ source = { ...source, spacing: buildSpacingScale(base) };
121
+ }
122
+ source = deepMerge(source, { spacing: steps });
123
+ }
124
+
125
+ if (elevation) {
126
+ const levels: Partial<ThemeSource["elevation"]> = {};
127
+ for (const [key, level] of Object.entries(elevation)) {
128
+ if (!level) continue;
129
+ const { shadowColor, ...fields } = level;
130
+ if (shadowColor !== undefined) {
131
+ pinnedShadowColors[key as ElevationKey] = shadowColor;
132
+ }
133
+ levels[key as ElevationKey] = fields as ThemeSource["elevation"][ElevationKey];
134
+ }
135
+ source = deepMerge(source, { elevation: levels });
136
+ }
137
+
138
+ return { source, pinnedShadowColors };
139
+ }
140
+
141
+ // ─── Normalized configuration ────────────────────────────────────────────────
142
+
143
+ export type NormalizedAccentScheme =
144
+ | { readonly kind: "none" }
145
+ | { readonly kind: "seed"; readonly seed: string; readonly overrides?: Partial<ThemeColor> }
146
+ | { readonly kind: "patch"; readonly patch: TerraThemeOverride };
147
+
148
+ interface NormalizedAccentEntry {
149
+ readonly light: NormalizedAccentScheme;
150
+ readonly dark: NormalizedAccentScheme;
151
+ }
152
+
153
+ /**
154
+ * A validated, immutable theme configuration. `base` is the fully layered
155
+ * `defaults → shared → scheme` state per scheme; every accent selection is
156
+ * re-resolved from it rather than from a previously resolved accent.
157
+ */
158
+ export interface NormalizedThemeConfig {
159
+ readonly accentNames: readonly string[];
160
+ readonly defaultAccent: string | null;
161
+ /** @internal Immutable `defaults → shared → scheme` state per scheme. */
162
+ readonly base: Readonly<Record<Scheme, ThemeLayerState>>;
163
+ /** @internal Per-accent, per-scheme normalized inputs. */
164
+ readonly accents: Readonly<Record<string, NormalizedAccentEntry>>;
165
+ }
166
+
167
+ export function normalizeAccentScheme(
168
+ input: AccentSchemeInput | undefined,
169
+ path: string
170
+ ): NormalizedAccentScheme {
171
+ if (input === undefined || input === null) return { kind: "none" };
172
+
173
+ if (typeof input === "string") {
174
+ if (!isSupportedAccentSeed(input)) {
175
+ throw new TerraConfigError(
176
+ `${path}.seed`,
177
+ `Unsupported accent seed ${JSON.stringify(input)}; expected an opaque six-digit hex color (#rrggbb).`
178
+ );
179
+ }
180
+ return { kind: "seed", seed: input.trim() };
181
+ }
182
+
183
+ if (!isPlainObject(input)) {
184
+ throw new TerraConfigError(
185
+ path,
186
+ "Expected a color string, `{ seed, overrides }`, or a theme override object."
187
+ );
188
+ }
189
+
190
+ if (!("seed" in input)) {
191
+ return { kind: "patch", patch: sanitizeOverride(input as TerraThemeOverride) };
192
+ }
193
+
194
+ const extra = Object.keys(input).filter((key) => key !== "seed" && key !== "overrides");
195
+ if (extra.length > 0) {
196
+ throw new TerraConfigError(
197
+ path,
198
+ `A seeded accent accepts only \`seed\` and \`overrides\`; found ${extra
199
+ .map((key) => `\`${key}\``)
200
+ .join(
201
+ ", "
202
+ )}. Use \`{ seed, overrides: { ... } }\` for color exceptions, or drop \`seed\` for a full theme override.`
203
+ );
204
+ }
205
+
206
+ const { seed, overrides } = input as { seed: unknown; overrides?: unknown };
207
+ if (!isSupportedAccentSeed(seed)) {
208
+ throw new TerraConfigError(
209
+ `${path}.seed`,
210
+ `Unsupported accent seed ${JSON.stringify(seed)}; expected an opaque six-digit hex color (#rrggbb).`
211
+ );
212
+ }
213
+ if (overrides !== undefined && !isPlainObject(overrides)) {
214
+ throw new TerraConfigError(`${path}.overrides`, "Expected a flat semantic color patch object.");
215
+ }
216
+
217
+ return {
218
+ kind: "seed",
219
+ seed: seed.trim(),
220
+ ...(overrides ? { overrides: sanitizeOverride(overrides as Partial<ThemeColor>) } : {}),
221
+ };
222
+ }
223
+
224
+ function normalizeAccentEntry(name: string, accent: Accent): NormalizedAccentEntry {
225
+ const path = `accents.${name}`;
226
+ if (!isPlainObject(accent)) {
227
+ throw new TerraConfigError(path, "Expected an object with `light` and/or `dark` scheme inputs.");
228
+ }
229
+ return {
230
+ light: normalizeAccentScheme(accent.light, `${path}.light`),
231
+ dark: normalizeAccentScheme(accent.dark, `${path}.dark`),
232
+ };
233
+ }
234
+
235
+ function assertOverrideObject(value: unknown, path: string): void {
236
+ if (value !== undefined && !isPlainObject(value)) {
237
+ throw new TerraConfigError(path, "Expected a theme override object.");
238
+ }
239
+ }
240
+
241
+ /**
242
+ * Validates a theme input and pre-computes the immutable per-scheme base
243
+ * (`defaults → shared → scheme`). Throws {@link TerraConfigError} for invalid
244
+ * input — before any state exists to mutate. Never mutates `input` or the
245
+ * exported defaults.
246
+ */
247
+ export function normalizeThemeInput(input: TerraThemeInput = {}): NormalizedThemeConfig {
248
+ if (!isPlainObject(input)) {
249
+ throw new TerraConfigError("", "Expected a configuration object.");
250
+ }
251
+
252
+ assertOverrideObject(input.shared, "shared");
253
+ assertOverrideObject(input.schemes, "schemes");
254
+ assertOverrideObject(input.schemes?.light, "schemes.light");
255
+ assertOverrideObject(input.schemes?.dark, "schemes.dark");
256
+ if (input.accents !== undefined && !isPlainObject(input.accents)) {
257
+ throw new TerraConfigError("accents", "Expected a record of named accents.");
258
+ }
259
+
260
+ const accents: Record<string, NormalizedAccentEntry> = {};
261
+ for (const [name, accent] of Object.entries(input.accents ?? {})) {
262
+ accents[name] = normalizeAccentEntry(name, accent);
263
+ }
264
+ const accentNames = Object.keys(accents);
265
+
266
+ let defaultAccent: string | null = null;
267
+ if (input.defaultAccent !== undefined && input.defaultAccent !== null) {
268
+ if (typeof input.defaultAccent !== "string" || !Object.hasOwn(accents, input.defaultAccent)) {
269
+ throw new TerraConfigError(
270
+ "defaultAccent",
271
+ `Unknown accent ${JSON.stringify(input.defaultAccent)}; declared accents: ${
272
+ accentNames.length > 0 ? accentNames.map((n) => `"${n}"`).join(", ") : "(none)"
273
+ }.`
274
+ );
275
+ }
276
+ defaultAccent = input.defaultAccent;
277
+ }
278
+
279
+ const base = {} as Record<Scheme, ThemeLayerState>;
280
+ for (const scheme of SCHEMES) {
281
+ let state: ThemeLayerState = { source: createDefaultThemeSource(scheme), pinnedShadowColors: {} };
282
+ state = applyThemeLayer(state, input.shared);
283
+ state = applyThemeLayer(state, input.schemes?.[scheme]);
284
+ base[scheme] = state;
285
+ }
286
+
287
+ return { accentNames, defaultAccent, base, accents };
288
+ }
289
+
290
+ // ─── Resolution ──────────────────────────────────────────────────────────────
291
+
292
+ export interface ResolvedThemes {
293
+ themes: Record<Scheme, TerraTheme>;
294
+ /** Final-colour contrast pairs that fail or cannot be evaluated, across both schemes. */
295
+ diagnostics: ContrastDiagnostic[];
296
+ }
297
+
298
+ function getAccentEntry(
299
+ config: NormalizedThemeConfig,
300
+ accent: string | null
301
+ ): NormalizedAccentEntry | undefined {
302
+ if (accent === null) return undefined;
303
+
304
+ const entry = config.accents[accent];
305
+ if (entry) return entry;
306
+
307
+ throw new TerraConfigError(
308
+ "accent",
309
+ `Unknown accent ${JSON.stringify(accent)}; declared accents: ${
310
+ config.accentNames.length > 0 ? config.accentNames.map((name) => `"${name}"`).join(", ") : "(none)"
311
+ }.`
312
+ );
313
+ }
314
+
315
+ function accentContext(scheme: Scheme, source: ThemeSource): AccentGenerationContext {
316
+ const surfaces = {} as Record<AccentSurfaceRole, string>;
317
+ for (const role of SURFACE_ROLES) {
318
+ surfaces[role] = source.color[role];
319
+ }
320
+ return { scheme, surfaces, pressedOpacity: source.opacity.pressed };
321
+ }
322
+
323
+ function pickGeneratedRoles(color: ThemeSource["color"]): Record<GeneratedAccentRole, string> {
324
+ const out = {} as Record<GeneratedAccentRole, string>;
325
+ for (const role of GENERATED_ACCENT_ROLES) {
326
+ out[role] = color[role];
327
+ }
328
+ return out;
329
+ }
330
+
331
+ function resolveScheme(
332
+ scheme: Scheme,
333
+ base: ThemeLayerState,
334
+ accent: NormalizedAccentScheme
335
+ ): { theme: TerraTheme; diagnostics: ContrastDiagnostic[] } {
336
+ let state = base;
337
+ let diagnostics: ContrastDiagnostic[] = [];
338
+
339
+ if (accent.kind === "patch") {
340
+ state = applyThemeLayer(state, accent.patch);
341
+ } else if (accent.kind === "seed") {
342
+ const context = accentContext(scheme, state.source);
343
+ const generated = generateAccentColors(accent.seed, context);
344
+ state = applyThemeLayer(state, { color: generated.colors } as TerraThemeOverride);
345
+ if (accent.overrides) {
346
+ state = applyThemeLayer(state, { color: accent.overrides });
347
+ }
348
+ // The post-override check is authoritative: it evaluates the colours that
349
+ // actually ship, so an override that fixes a pair leaves no stale report.
350
+ diagnostics = checkAccentContrast(
351
+ pickGeneratedRoles(state.source.color),
352
+ accentContext(scheme, state.source)
353
+ );
354
+ }
355
+
356
+ return { theme: finalizeThemeSource(state.source, state.pinnedShadowColors), diagnostics };
357
+ }
358
+
359
+ /**
360
+ * Resolves both schemes for `accent` from the config's immutable base. `null`
361
+ * selects the base themes; an unknown name throws {@link TerraConfigError}.
362
+ * Returned themes are freshly built and independently owned.
363
+ */
364
+ export function resolveThemes(config: NormalizedThemeConfig, accent: string | null): ResolvedThemes {
365
+ const entry = getAccentEntry(config, accent);
366
+
367
+ const themes = {} as Record<Scheme, TerraTheme>;
368
+ const diagnostics: ContrastDiagnostic[][] = [];
369
+ for (const scheme of SCHEMES) {
370
+ const resolved = resolveScheme(scheme, config.base[scheme], entry?.[scheme] ?? { kind: "none" });
371
+ themes[scheme] = resolved.theme;
372
+ diagnostics.push(resolved.diagnostics);
373
+ }
374
+
375
+ return { themes, diagnostics: diagnostics.flat() };
376
+ }
377
+
378
+ /** Resolves one scheme without generating and finalizing the other. */
379
+ export function resolveThemeScheme(
380
+ config: NormalizedThemeConfig,
381
+ scheme: Scheme,
382
+ accent: string | null
383
+ ): { theme: TerraTheme; diagnostics: ContrastDiagnostic[] } {
384
+ const entry = getAccentEntry(config, accent);
385
+ return resolveScheme(scheme, config.base[scheme], entry?.[scheme] ?? { kind: "none" });
386
+ }
387
+
388
+ /**
389
+ * Pure theme creation — no Unistyles configuration or registry involved.
390
+ * `options.accent` omitted → the input's `defaultAccent`; `null` → the base
391
+ * themes. Invalid input and unknown accents throw {@link TerraConfigError}.
392
+ * In development, contrast diagnostics are reported once each via
393
+ * `console.warn`; requested colors are never altered.
394
+ */
395
+ export function createTerraThemes(
396
+ input?: TerraThemeInput,
397
+ options?: CreateTerraThemesOptions
398
+ ): Record<Scheme, TerraTheme> {
399
+ const config = normalizeThemeInput(input);
400
+ const accent = options?.accent === undefined ? config.defaultAccent : options.accent;
401
+ const { themes, diagnostics } = resolveThemes(config, accent);
402
+
403
+ warnContrastDiagnostics(diagnostics);
404
+
405
+ return themes;
406
+ }
@@ -1,26 +1,64 @@
1
- import { UnistylesRuntime } from "react-native-unistyles";
1
+ /**
2
+ * Runtime selection commands — accent and theme mode — plus the subscribed
3
+ * selection snapshot consumed by `useTheme()`.
4
+ *
5
+ * Every accent change re-resolves both schemes from the installed, immutable
6
+ * configuration (never from the previously resolved accent), hands them to
7
+ * Unistyles, and only then publishes the new selection. A rejected or failed
8
+ * change leaves both the native themes and the published selection untouched.
9
+ */
10
+ import { warnContrastDiagnostics } from "./diagnostics";
11
+ import { resolveThemes } from "./resolve-config";
12
+ import { getInstalledConfig, getThemeSelection, publishThemeSelection } from "./selection-store";
13
+ import type { Scheme, ThemeMode } from "./types";
14
+ import { applyMode, updateThemes } from "./unistyles-adapter";
2
15
 
3
- import { getRegistry, resolveTheme } from "./registry";
4
- import type { Scheme } from "./types";
16
+ export type { ThemeSelection } from "./selection-store";
17
+ export { getThemeSelection, subscribeThemeSelection } from "./selection-store";
5
18
 
6
- /** Apply a named accent at runtime by updating both registered themes. */
7
- export function applyAccent(name: string): void {
8
- const registry = getRegistry();
9
- if (!registry.accents[name]) {
19
+ /**
20
+ * Apply a registered accent (`null` restores the base themes) by updating both
21
+ * registered themes. An unknown name warns in development and changes nothing.
22
+ *
23
+ * If the native update fails part-way (see `updateThemes` in the adapter), the
24
+ * error propagates and the previous selection stays published; calling
25
+ * `applyAccent(getCurrentAccent() ?? null)` rewrites both themes.
26
+ */
27
+ export function applyAccent(name: string | null): void {
28
+ const config = getInstalledConfig();
29
+ if (name !== null && !config.accentNames.includes(name)) {
10
30
  if (__DEV__) {
11
31
  console.warn(`[react-native-terra-ui] Unknown accent "${name}".`);
12
32
  }
13
33
  return;
14
34
  }
15
- registry.currentAccent = name;
16
- UnistylesRuntime.updateTheme("light", () => resolveTheme("light", name));
17
- UnistylesRuntime.updateTheme("dark", () => resolveTheme("dark", name));
35
+
36
+ const { themes, diagnostics } = resolveThemes(config, name);
37
+ updateThemes(themes);
38
+ publishThemeSelection({ accent: name ?? undefined });
39
+ warnContrastDiagnostics(diagnostics);
40
+ }
41
+
42
+ /** The applied accent name, or `undefined` for the base themes. */
43
+ export const getCurrentAccent = (): string | undefined => getThemeSelection().accent;
44
+
45
+ /**
46
+ * Choose how the active scheme is selected: `system` follows the OS appearance;
47
+ * `light` / `dark` pin a scheme until the next call.
48
+ */
49
+ export function setThemeMode(mode: ThemeMode): void {
50
+ applyMode(mode);
51
+ publishThemeSelection({ mode });
18
52
  }
19
53
 
20
- export const getCurrentAccent = (): string | undefined => getRegistry().currentAccent;
54
+ /** The current theme mode. A system appearance change does not change it. */
55
+ export const getThemeMode = (): ThemeMode => getThemeSelection().mode;
21
56
 
22
- /** Switch the active color scheme, disabling adaptive (system) theming. */
57
+ /**
58
+ * Pin the active color scheme, disabling adaptive (system) theming.
59
+ * Compatibility wrapper for `setThemeMode(scheme)`; use `setThemeMode("system")`
60
+ * to return to the OS preference.
61
+ */
23
62
  export function applyScheme(scheme: Scheme): void {
24
- UnistylesRuntime.setAdaptiveThemes(false);
25
- UnistylesRuntime.setTheme(scheme);
63
+ setThemeMode(scheme);
26
64
  }
@@ -1,26 +1,7 @@
1
- import type { UnistylesBreakpoints } from "react-native-unistyles";
1
+ import type { ResolvedScreenPadding } from "./screen-padding";
2
+ import { resolveScreenPadding } from "./screen-padding";
2
3
 
3
- import { resolveBreakpointValue } from "./breakpoints";
4
- import type { TerraTheme } from "./types";
5
-
6
- export interface ResolvedScreenMargin {
7
- x: number;
8
- y: number;
9
- }
10
-
11
- /**
12
- * Resolves both axes of `theme.layout.screen.margin` for the current runtime
13
- * breakpoint in one call — nearly every consumer needs both axes, so this
14
- * saves calling `resolveBreakpointValue` twice. Call inside a
15
- * `StyleSheet.create` factory (passing `rt.breakpoint`) so the result stays
16
- * reactive to breakpoint changes; outside one, use {@link useScreenMargin}.
17
- */
18
- export function resolveScreenMargin(
19
- theme: TerraTheme,
20
- breakpoint: keyof UnistylesBreakpoints | undefined
21
- ): ResolvedScreenMargin {
22
- return {
23
- x: resolveBreakpointValue(theme.layout.screen.margin.x, breakpoint),
24
- y: resolveBreakpointValue(theme.layout.screen.margin.y, breakpoint),
25
- };
26
- }
4
+ /** @deprecated Use ResolvedScreenPadding. */
5
+ export type ResolvedScreenMargin = ResolvedScreenPadding;
6
+ /** @deprecated Use resolveScreenPadding. */
7
+ export const resolveScreenMargin = resolveScreenPadding;
@@ -0,0 +1,26 @@
1
+ import type { UnistylesBreakpoints } from "react-native-unistyles";
2
+
3
+ import { resolveBreakpointValue } from "./breakpoints";
4
+ import type { TerraTheme } from "./types";
5
+
6
+ export interface ResolvedScreenPadding {
7
+ x: number;
8
+ y: number;
9
+ }
10
+
11
+ /**
12
+ * Resolves both axes of `theme.layout.screen.padding` for the current runtime
13
+ * breakpoint in one call — nearly every consumer needs both axes, so this
14
+ * saves calling `resolveBreakpointValue` twice. Call inside a
15
+ * `StyleSheet.create` factory (passing `rt.breakpoint`) so the result stays
16
+ * reactive to breakpoint changes; outside one, use {@link useScreenPadding}.
17
+ */
18
+ export function resolveScreenPadding(
19
+ theme: TerraTheme,
20
+ breakpoint: keyof UnistylesBreakpoints | undefined
21
+ ): ResolvedScreenPadding {
22
+ return {
23
+ x: resolveBreakpointValue(theme.layout.screen.padding.x, breakpoint),
24
+ y: resolveBreakpointValue(theme.layout.screen.padding.y, breakpoint),
25
+ };
26
+ }
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Internal runtime state shared by `registry.ts` (installation) and
3
+ * `runtime.ts` (selection commands). Lives in its own module so neither of
4
+ * those has to import the other.
5
+ *
6
+ * Holds two things, both private to `theme/`:
7
+ * - the installed {@link NormalizedThemeConfig} — immutable; every accent
8
+ * selection is re-resolved from it, never layered on a previous result;
9
+ * - the selection snapshot (`accent`, `mode`) behind a `useSyncExternalStore`
10
+ * compatible subscribe/get pair. The snapshot object keeps its identity until
11
+ * a value actually changes, so subscribers only re-render on real changes.
12
+ *
13
+ * Nothing here touches Unistyles. Callers publish *after* native work succeeds.
14
+ */
15
+ import { type NormalizedThemeConfig, normalizeThemeInput } from "./resolve-config";
16
+ import type { ThemeMode } from "./types";
17
+
18
+ // ─── Installed configuration ─────────────────────────────────────────────────
19
+
20
+ /**
21
+ * Before any configure call the built-in defaults are "installed", matching
22
+ * the previous behaviour where `resolveTheme` worked pre-configuration.
23
+ */
24
+ let installedConfig: NormalizedThemeConfig = normalizeThemeInput({});
25
+
26
+ /** @internal Read-only handle on the installed config. */
27
+ export const getInstalledConfig = (): NormalizedThemeConfig => installedConfig;
28
+
29
+ /** @internal Replaces the installed config; only `registry.ts` should call this. */
30
+ export function setInstalledConfig(config: NormalizedThemeConfig): void {
31
+ installedConfig = config;
32
+ }
33
+
34
+ // ─── Selection snapshot ──────────────────────────────────────────────────────
35
+
36
+ /** The current runtime selection: which accent is applied and how the scheme is chosen. */
37
+ export interface ThemeSelection {
38
+ /** Applied accent name, or `undefined` for the base themes. */
39
+ readonly accent: string | undefined;
40
+ /** `system` follows the OS appearance; `light` / `dark` pin a scheme. */
41
+ readonly mode: ThemeMode;
42
+ }
43
+
44
+ let selection: ThemeSelection = Object.freeze({ accent: undefined, mode: "system" as ThemeMode });
45
+
46
+ const listeners = new Set<() => void>();
47
+
48
+ /**
49
+ * Current selection snapshot. Stable identity: the same object is returned
50
+ * until {@link publishThemeSelection} commits a real change, which makes it a
51
+ * valid `getSnapshot` for `useSyncExternalStore`.
52
+ */
53
+ export const getThemeSelection = (): ThemeSelection => selection;
54
+
55
+ /** Subscribe to selection changes. Returns the unsubscribe function. */
56
+ export function subscribeThemeSelection(listener: () => void): () => void {
57
+ listeners.add(listener);
58
+ return () => {
59
+ listeners.delete(listener);
60
+ };
61
+ }
62
+
63
+ /**
64
+ * @internal Commits a (partial) selection change and notifies subscribers.
65
+ * A no-op — same snapshot identity, no notification — when nothing changed.
66
+ */
67
+ export function publishThemeSelection(next: Partial<ThemeSelection>): void {
68
+ const accent = "accent" in next ? next.accent : selection.accent;
69
+ const mode = next.mode ?? selection.mode;
70
+ if (accent === selection.accent && mode === selection.mode) return;
71
+
72
+ selection = Object.freeze({ accent, mode });
73
+ for (const listener of listeners) {
74
+ listener();
75
+ }
76
+ }