@titan-design/react-ui 0.1.0 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/README.md +47 -1
  2. package/dist/{chunk-MGW37MPO.mjs → chunk-UXVRPOME.mjs} +173 -3
  3. package/dist/chunk-UXVRPOME.mjs.map +1 -0
  4. package/dist/index.d.mts +101 -12
  5. package/dist/index.d.ts +101 -12
  6. package/dist/index.js +1267 -468
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +734 -112
  9. package/dist/index.mjs.map +1 -1
  10. package/dist/theme/index.d.mts +86 -1
  11. package/dist/theme/index.d.ts +86 -1
  12. package/dist/theme/index.js +166 -0
  13. package/dist/theme/index.js.map +1 -1
  14. package/dist/theme/index.mjs +1 -1
  15. package/package.json +22 -6
  16. package/src/components/custom/DateTime/DateTime.stories.tsx +180 -0
  17. package/src/components/custom/DateTime/DateTime.test.tsx +152 -0
  18. package/src/components/custom/DateTime/DateTime.tsx +235 -0
  19. package/src/components/custom/DateTime/index.ts +2 -0
  20. package/src/components/custom/EmptyState/EmptyState.stories.tsx +99 -0
  21. package/src/components/custom/EmptyState/EmptyState.test.tsx +106 -0
  22. package/src/components/custom/EmptyState/EmptyState.tsx +68 -0
  23. package/src/components/custom/EmptyState/index.ts +2 -0
  24. package/src/components/custom/Metric/Metric.stories.tsx +133 -0
  25. package/src/components/custom/Metric/Metric.test.tsx +120 -0
  26. package/src/components/custom/Metric/Metric.tsx +92 -0
  27. package/src/components/custom/Metric/index.ts +2 -0
  28. package/src/components/custom/Sidebar/Sidebar.stories.tsx +227 -0
  29. package/src/components/custom/Sidebar/Sidebar.test.tsx +185 -0
  30. package/src/components/custom/Sidebar/Sidebar.tsx +257 -0
  31. package/src/components/custom/Sidebar/index.ts +18 -0
  32. package/src/components/custom/Table/Table.stories.tsx +481 -0
  33. package/src/components/custom/Table/Table.test.tsx +531 -0
  34. package/src/components/custom/Table/Table.tsx +696 -0
  35. package/src/components/custom/Table/index.ts +22 -0
  36. package/src/components/custom/Typography/Typography.stories.tsx +153 -0
  37. package/src/components/custom/Typography/Typography.test.tsx +107 -0
  38. package/src/components/custom/Typography/Typography.tsx +218 -0
  39. package/src/components/custom/Typography/index.ts +19 -0
  40. package/src/components/custom/Workout/RestTimer.stories.tsx +71 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +145 -0
  42. package/src/components/custom/Workout/RestTimer.tsx +172 -0
  43. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +144 -0
  44. package/src/components/custom/Workout/SupersetWrapper.test.tsx +130 -0
  45. package/src/components/custom/Workout/SupersetWrapper.tsx +63 -0
  46. package/src/components/custom/Workout/index.ts +2 -0
  47. package/src/components/custom/index.ts +9 -0
  48. package/src/components/custom/stepper/Stepper.stories.tsx +245 -0
  49. package/src/components/custom/stepper/Stepper.test.tsx +230 -0
  50. package/src/components/custom/stepper/Stepper.tsx +250 -0
  51. package/src/components/custom/stepper/index.ts +10 -0
  52. package/src/components/index.ts +5 -0
  53. package/src/components/ui/alert/Alert.stories.tsx +154 -0
  54. package/src/components/ui/alert/Alert.test.tsx +202 -0
  55. package/src/components/ui/alert/Alert.tsx +170 -0
  56. package/src/components/ui/alert/index.ts +8 -0
  57. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +299 -0
  58. package/src/components/ui/autocomplete/Autocomplete.test.tsx +247 -0
  59. package/src/components/ui/autocomplete/Autocomplete.tsx +332 -0
  60. package/src/components/ui/autocomplete/index.ts +2 -0
  61. package/src/components/ui/avatar/Avatar.stories.tsx +105 -0
  62. package/src/components/ui/avatar/Avatar.test.tsx +152 -0
  63. package/src/components/ui/avatar/Avatar.tsx +176 -0
  64. package/src/components/ui/avatar/index.ts +2 -0
  65. package/src/components/ui/badge/Badge.stories.tsx +100 -0
  66. package/src/components/ui/badge/Badge.test.tsx +103 -0
  67. package/src/components/ui/badge/Badge.tsx +119 -0
  68. package/src/components/ui/badge/index.ts +3 -0
  69. package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +70 -0
  70. package/src/components/ui/breadcrumbs/Breadcrumbs.test.tsx +156 -0
  71. package/src/components/ui/breadcrumbs/Breadcrumbs.tsx +127 -0
  72. package/src/components/ui/breadcrumbs/index.ts +2 -0
  73. package/src/components/ui/button/Button.stories.tsx +216 -0
  74. package/src/components/ui/button/Button.test.tsx +132 -0
  75. package/src/components/ui/button/Button.tsx +297 -0
  76. package/src/components/ui/button/index.ts +10 -0
  77. package/src/components/ui/card/Card.stories.tsx +443 -0
  78. package/src/components/ui/card/Card.test.tsx +294 -0
  79. package/src/components/ui/card/Card.tsx +401 -0
  80. package/src/components/ui/card/index.ts +20 -0
  81. package/src/components/ui/checkbox/Checkbox.stories.tsx +126 -0
  82. package/src/components/ui/checkbox/Checkbox.test.tsx +152 -0
  83. package/src/components/ui/checkbox/Checkbox.tsx +163 -0
  84. package/src/components/ui/checkbox/index.ts +2 -0
  85. package/src/components/ui/chip/Chip.stories.tsx +112 -0
  86. package/src/components/ui/chip/Chip.test.tsx +119 -0
  87. package/src/components/ui/chip/Chip.tsx +151 -0
  88. package/src/components/ui/chip/index.ts +2 -0
  89. package/src/components/ui/collapse/Collapse.stories.tsx +313 -0
  90. package/src/components/ui/collapse/Collapse.test.tsx +298 -0
  91. package/src/components/ui/collapse/Collapse.tsx +275 -0
  92. package/src/components/ui/collapse/index.ts +18 -0
  93. package/src/components/ui/data-row/DataRow.stories.tsx +78 -0
  94. package/src/components/ui/data-row/DataRow.test.tsx +78 -0
  95. package/src/components/ui/data-row/DataRow.tsx +29 -0
  96. package/src/components/ui/data-row/index.ts +2 -0
  97. package/src/components/ui/divider/Divider.stories.tsx +71 -0
  98. package/src/components/ui/divider/Divider.test.tsx +55 -0
  99. package/src/components/ui/divider/Divider.tsx +37 -0
  100. package/src/components/ui/divider/index.ts +2 -0
  101. package/src/components/ui/drawer/Drawer.stories.tsx +228 -0
  102. package/src/components/ui/drawer/Drawer.test.tsx +142 -0
  103. package/src/components/ui/drawer/Drawer.tsx +228 -0
  104. package/src/components/ui/drawer/index.ts +9 -0
  105. package/src/components/ui/form-field/FormField.stories.tsx +260 -0
  106. package/src/components/ui/form-field/FormField.test.tsx +280 -0
  107. package/src/components/ui/form-field/FormField.tsx +308 -0
  108. package/src/components/ui/form-field/index.ts +2 -0
  109. package/src/components/ui/help-tip/HelpTip.stories.tsx +250 -0
  110. package/src/components/ui/help-tip/HelpTip.test.tsx +198 -0
  111. package/src/components/ui/help-tip/HelpTip.tsx +229 -0
  112. package/src/components/ui/help-tip/index.ts +2 -0
  113. package/src/components/ui/icon-box/IconBox.stories.tsx +139 -0
  114. package/src/components/ui/icon-box/IconBox.test.tsx +120 -0
  115. package/src/components/ui/icon-box/IconBox.tsx +57 -0
  116. package/src/components/ui/icon-box/index.ts +1 -0
  117. package/src/components/ui/index.ts +38 -0
  118. package/src/components/ui/indicator/Indicator.stories.tsx +74 -0
  119. package/src/components/ui/indicator/Indicator.test.tsx +55 -0
  120. package/src/components/ui/indicator/Indicator.tsx +73 -0
  121. package/src/components/ui/indicator/index.ts +2 -0
  122. package/src/components/ui/input/Input.stories.tsx +192 -0
  123. package/src/components/ui/input/Input.test.tsx +86 -0
  124. package/src/components/ui/input/Input.tsx +273 -0
  125. package/src/components/ui/input/index.ts +2 -0
  126. package/src/components/ui/link/Link.stories.tsx +78 -0
  127. package/src/components/ui/link/Link.test.tsx +94 -0
  128. package/src/components/ui/link/Link.tsx +86 -0
  129. package/src/components/ui/link/index.ts +2 -0
  130. package/src/components/ui/list-item/ListItem.stories.tsx +146 -0
  131. package/src/components/ui/list-item/ListItem.test.tsx +128 -0
  132. package/src/components/ui/list-item/ListItem.tsx +89 -0
  133. package/src/components/ui/list-item/index.ts +14 -0
  134. package/src/components/ui/menu/Menu.stories.tsx +180 -0
  135. package/src/components/ui/menu/Menu.test.tsx +353 -0
  136. package/src/components/ui/menu/Menu.tsx +222 -0
  137. package/src/components/ui/menu/index.ts +2 -0
  138. package/src/components/ui/modal/Modal.stories.tsx +350 -0
  139. package/src/components/ui/modal/Modal.test.tsx +159 -0
  140. package/src/components/ui/modal/Modal.tsx +254 -0
  141. package/src/components/ui/modal/index.ts +19 -0
  142. package/src/components/ui/pill/Pill.stories.tsx +71 -0
  143. package/src/components/ui/pill/Pill.test.tsx +69 -0
  144. package/src/components/ui/pill/Pill.tsx +104 -0
  145. package/src/components/ui/pill/index.ts +2 -0
  146. package/src/components/ui/popover/Popover.stories.tsx +190 -0
  147. package/src/components/ui/popover/Popover.test.tsx +419 -0
  148. package/src/components/ui/popover/Popover.tsx +237 -0
  149. package/src/components/ui/popover/index.ts +2 -0
  150. package/src/components/ui/progress/Progress.stories.tsx +190 -0
  151. package/src/components/ui/progress/Progress.test.tsx +210 -0
  152. package/src/components/ui/progress/Progress.tsx +339 -0
  153. package/src/components/ui/progress/index.ts +9 -0
  154. package/src/components/ui/radio/Radio.stories.tsx +199 -0
  155. package/src/components/ui/radio/Radio.test.tsx +166 -0
  156. package/src/components/ui/radio/Radio.tsx +200 -0
  157. package/src/components/ui/radio/index.ts +2 -0
  158. package/src/components/ui/section/Section.stories.tsx +111 -0
  159. package/src/components/ui/section/Section.test.tsx +121 -0
  160. package/src/components/ui/section/Section.tsx +52 -0
  161. package/src/components/ui/section/index.ts +6 -0
  162. package/src/components/ui/select/Select.stories.tsx +286 -0
  163. package/src/components/ui/select/Select.test.tsx +200 -0
  164. package/src/components/ui/select/Select.tsx +243 -0
  165. package/src/components/ui/select/index.ts +2 -0
  166. package/src/components/ui/skeleton/Skeleton.stories.tsx +153 -0
  167. package/src/components/ui/skeleton/Skeleton.test.tsx +162 -0
  168. package/src/components/ui/skeleton/Skeleton.tsx +229 -0
  169. package/src/components/ui/skeleton/index.ts +16 -0
  170. package/src/components/ui/spinner/Spinner.stories.tsx +67 -0
  171. package/src/components/ui/spinner/Spinner.test.tsx +67 -0
  172. package/src/components/ui/spinner/Spinner.tsx +72 -0
  173. package/src/components/ui/spinner/index.ts +2 -0
  174. package/src/components/ui/stack/Stack.stories.tsx +127 -0
  175. package/src/components/ui/stack/Stack.test.tsx +184 -0
  176. package/src/components/ui/stack/Stack.tsx +76 -0
  177. package/src/components/ui/stack/index.ts +2 -0
  178. package/src/components/ui/surface/Surface.stories.tsx +91 -0
  179. package/src/components/ui/surface/Surface.test.tsx +63 -0
  180. package/src/components/ui/surface/Surface.tsx +51 -0
  181. package/src/components/ui/surface/index.ts +1 -0
  182. package/src/components/ui/switch/Switch.stories.tsx +111 -0
  183. package/src/components/ui/switch/Switch.test.tsx +112 -0
  184. package/src/components/ui/switch/Switch.tsx +103 -0
  185. package/src/components/ui/switch/index.ts +2 -0
  186. package/src/components/ui/tabs/Tabs.stories.tsx +213 -0
  187. package/src/components/ui/tabs/Tabs.test.tsx +192 -0
  188. package/src/components/ui/tabs/Tabs.tsx +253 -0
  189. package/src/components/ui/tabs/index.ts +16 -0
  190. package/src/components/ui/toast/Toast.stories.tsx +255 -0
  191. package/src/components/ui/toast/Toast.test.tsx +207 -0
  192. package/src/components/ui/toast/Toast.tsx +327 -0
  193. package/src/components/ui/toast/index.ts +9 -0
  194. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +415 -0
  195. package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +224 -0
  196. package/src/components/ui/toolbar-button/ToolbarButton.tsx +307 -0
  197. package/src/components/ui/toolbar-button/index.ts +7 -0
  198. package/src/components/ui/tooltip/Tooltip.stories.tsx +126 -0
  199. package/src/components/ui/tooltip/Tooltip.test.tsx +262 -0
  200. package/src/components/ui/tooltip/Tooltip.tsx +234 -0
  201. package/src/components/ui/tooltip/index.ts +2 -0
  202. package/src/index.ts +10 -0
  203. package/src/stories/PresetShowcase.stories.tsx +462 -0
  204. package/src/stories/ThemePresets.stories.tsx +85 -0
  205. package/src/test/setup.ts +19 -0
  206. package/src/theme/Colors.stories.tsx +258 -0
  207. package/src/theme/config.ts +193 -0
  208. package/src/theme/elevation.stories.tsx +346 -0
  209. package/src/theme/elevation.test.ts +27 -0
  210. package/src/theme/elevation.ts +567 -0
  211. package/src/theme/global.css +129 -1
  212. package/src/theme/index.ts +40 -0
  213. package/src/theme/presets/apply.ts +97 -0
  214. package/src/theme/presets/audiobook.ts +93 -0
  215. package/src/theme/presets/default.ts +6 -0
  216. package/src/theme/presets/index.ts +4 -0
  217. package/src/theme/presets/presets.test.ts +51 -0
  218. package/src/theme/presets/types.ts +76 -0
  219. package/src/theme/shadows.stories.tsx +523 -0
  220. package/src/theme/shadows.ts +439 -0
  221. package/src/theme/tokens/index.ts +2 -0
  222. package/src/theme/tokens/primitives.ts +354 -0
  223. package/src/theme/tokens/semantic.ts +381 -0
  224. package/src/types/jest-axe.d.ts +16 -0
  225. package/src/types/nativewind.d.ts +54 -0
  226. package/src/utils/avatar-color.test.ts +30 -0
  227. package/src/utils/avatar-color.ts +20 -0
  228. package/src/utils/cn.ts +14 -0
  229. package/src/utils/colors.ts +140 -0
  230. package/src/utils/form.ts +188 -0
  231. package/src/utils/index.ts +29 -0
  232. package/src/utils/useTheme.ts +72 -0
  233. package/src/web-jsx/jsx-dev-runtime.ts +28 -0
  234. package/src/web-jsx/jsx-runtime.ts +41 -0
  235. package/tailwind.config.js +53 -3
  236. package/dist/chunk-MGW37MPO.mjs.map +0 -1
@@ -0,0 +1,567 @@
1
+ /**
2
+ * Elevation System
3
+ *
4
+ * A unified depth system where each elevation level corresponds to:
5
+ * - Calculated surface color (derived from base using lighten)
6
+ * - Shadow intensity and style (raised/pressed)
7
+ * - Dynamically calculated shadow colors (derived from surface color)
8
+ * - Semantic name for reference
9
+ *
10
+ * Elevation range: -2 (deep inset) to +5 (floating overlay)
11
+ *
12
+ * Negative elevations: Inset/pressed elements (sunken into surface)
13
+ * Zero elevation: Base surface (background/page level)
14
+ * Positive elevations: Raised elements (floating above surface)
15
+ *
16
+ * All colors are calculated dynamically from a base surface color,
17
+ * allowing for custom surfaces and consistent color relationships.
18
+ *
19
+ * Lighting Model: Light source from upper-right corner
20
+ * - Raised elements are always lighter than base (consistent across themes)
21
+ * - Base colors must allow sufficient lightening in both themes
22
+ */
23
+
24
+ import { Platform, type ViewStyle } from 'react-native'
25
+ import { lighten, darken, hexToRgb, rgbToHsv, hsvToRgb, rgbToHex } from './shadows'
26
+ import { semanticColorsLight, semanticColorsDark } from './tokens/semantic'
27
+
28
+ export type ElevationLevel = -2 | -1 | 0 | 1 | 2 | 3 | 4 | 5
29
+
30
+ export interface ElevationConfig {
31
+ /** Semantic name for this elevation level */
32
+ name: string
33
+ /** Amount to lighten base color (0-1) */
34
+ colorAdjustment: number
35
+ /** Shadow intensity */
36
+ shadowIntensity: 'subtle' | 'medium' | 'strong'
37
+ /** Shadow style: raised (floating) or pressed (inset) */
38
+ shadowStyle: 'raised' | 'pressed'
39
+ /** Description of use case */
40
+ description: string
41
+ }
42
+
43
+ export const elevationSystem: Record<ElevationLevel, ElevationConfig> = {
44
+ // Deep inset (recessed input fields, deeply pressed buttons)
45
+ [-2]: {
46
+ name: 'deep-inset',
47
+ colorAdjustment: 0,
48
+ shadowIntensity: 'strong',
49
+ shadowStyle: 'pressed',
50
+ description: 'Deeply recessed elements (e.g., input fields, deeply pressed buttons)',
51
+ },
52
+
53
+ // Shallow inset (pressed buttons, sunken panels)
54
+ [-1]: {
55
+ name: 'inset',
56
+ colorAdjustment: 0,
57
+ shadowIntensity: 'medium',
58
+ shadowStyle: 'pressed',
59
+ description: 'Shallow inset elements (e.g., pressed buttons, sunken panels within cards)',
60
+ },
61
+
62
+ // Base level (page background, default surface)
63
+ [0]: {
64
+ name: 'base',
65
+ colorAdjustment: 0,
66
+ shadowIntensity: 'subtle',
67
+ shadowStyle: 'raised',
68
+ description: 'Base surface level (page background, default containers)',
69
+ },
70
+
71
+ // Level 1: Subtle elevation (outline cards, subtle containers)
72
+ [1]: {
73
+ name: 'subtle',
74
+ colorAdjustment: 0.01,
75
+ shadowIntensity: 'subtle',
76
+ shadowStyle: 'raised',
77
+ description: 'Subtle elevation (outline cards, subtle containers)',
78
+ },
79
+
80
+ // Level 2: Standard elevation (default cards, buttons)
81
+ [2]: {
82
+ name: 'standard',
83
+ colorAdjustment: 0.02,
84
+ shadowIntensity: 'medium',
85
+ shadowStyle: 'raised',
86
+ description: 'Standard elevation (default cards, raised buttons)',
87
+ },
88
+
89
+ // Level 3: Prominent elevation (important cards, modals)
90
+ [3]: {
91
+ name: 'prominent',
92
+ colorAdjustment: 0.04,
93
+ shadowIntensity: 'strong',
94
+ shadowStyle: 'raised',
95
+ description: 'Prominent elevation (important cards, modals, dropdowns)',
96
+ },
97
+
98
+ // Level 4: High elevation (overlays, popovers)
99
+ [4]: {
100
+ name: 'high',
101
+ colorAdjustment: 0.06,
102
+ shadowIntensity: 'strong',
103
+ shadowStyle: 'raised',
104
+ description: 'High elevation (overlays, popovers, tooltips)',
105
+ },
106
+
107
+ // Level 5: Floating (toasts, floating action buttons)
108
+ [5]: {
109
+ name: 'floating',
110
+ colorAdjustment: 0.08,
111
+ shadowIntensity: 'strong',
112
+ shadowStyle: 'raised',
113
+ description: 'Floating elevation (toasts, floating action buttons, highest z-index)',
114
+ },
115
+ }
116
+
117
+ /**
118
+ * Calculate rim light color from the BASE color.
119
+ * Creates a consistent bright highlight with opacity that scales with elevation.
120
+ *
121
+ * The rim light represents light hitting the edge of raised elements from a
122
+ * consistent light source. Uses ABSOLUTE HSV value (not relative lightening)
123
+ * for consistent intensity across all surface colors.
124
+ *
125
+ * For colored surfaces: preserves hue/saturation, sets value to fixed bright level
126
+ * For gray surfaces: uses white with appropriate opacity
127
+ *
128
+ * @param baseColor - The base surface color (hex)
129
+ * @param level - Elevation level (used to scale opacity)
130
+ */
131
+ function calculateRimLightColor(baseColor: string, level: ElevationLevel): string {
132
+ const rgb = hexToRgb(baseColor)
133
+ if (!rgb) {
134
+ return 'rgba(255, 255, 255, 0.5)'
135
+ }
136
+
137
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
138
+ const isLight = hsv.v > 0.6
139
+ const isSaturated = hsv.s > 0.3 // Colored (not gray)
140
+
141
+ // Opacity scales with elevation to compensate for lighter surfaces at higher levels
142
+ // Range: +0% at level 1 to +20% at level 5
143
+ const absLevel = Math.abs(level)
144
+ const opacityBoost = Math.max(0, (absLevel - 1) * 0.05) // +5% per level above 1 (0% to 20%)
145
+
146
+ if (isSaturated) {
147
+ // For colored surfaces, preserve hue and saturation but set value to fixed bright level
148
+ // This ensures consistent rim light intensity across all colored surfaces
149
+ const rimLightValue = 0.95 // Fixed bright value for rim light
150
+ const rimLightRgb = hsvToRgb(hsv.h, hsv.s * 0.3, rimLightValue) // Reduce saturation for brighter appearance
151
+
152
+ // Base opacity + elevation boost, capped at 1.0
153
+ const baseOpacity = isLight ? 0.9 : 0.5
154
+ const finalOpacity = Math.min(1, baseOpacity + opacityBoost)
155
+ return `rgba(${rimLightRgb.r}, ${rimLightRgb.g}, ${rimLightRgb.b}, ${finalOpacity.toFixed(2)})`
156
+ } else {
157
+ // For gray/neutral surfaces, use white with appropriate opacity
158
+ const baseOpacity = isLight ? 0.9 : 0.12
159
+ const finalOpacity = Math.min(1, baseOpacity + opacityBoost)
160
+ return `rgba(255, 255, 255, ${finalOpacity.toFixed(2)})`
161
+ }
162
+ }
163
+
164
+ /**
165
+ * Calculate dark shadow colors from the surface color.
166
+ * The dark shadow is cast BY the element, so it should match the surface color.
167
+ *
168
+ * Uses ABSOLUTE HSV value (not relative darkening) for consistent shadow intensity
169
+ * across all surface colors - simulating a consistent light source.
170
+ *
171
+ * For colored surfaces: preserves hue/saturation, sets value to fixed dark level
172
+ * For gray surfaces: uses black with appropriate opacity
173
+ */
174
+ function calculateDarkShadowColors(surfaceColor: string): {
175
+ dark: string
176
+ darker: string
177
+ } {
178
+ const rgb = hexToRgb(surfaceColor)
179
+ if (!rgb) {
180
+ return {
181
+ dark: 'rgba(0, 0, 0, 0.3)',
182
+ darker: 'rgba(0, 0, 0, 0.4)',
183
+ }
184
+ }
185
+
186
+ const hsv = rgbToHsv(rgb.r, rgb.g, rgb.b)
187
+ const isLight = hsv.v > 0.6
188
+ const isSaturated = hsv.s > 0.3
189
+
190
+ if (isSaturated) {
191
+ // For colored surfaces, preserve hue and saturation but set value to fixed dark level
192
+ // This ensures consistent shadow intensity across all colored surfaces
193
+ const darkShadowValue = 0.15 // Fixed dark value for shadows
194
+ const darkShadowRgb = hsvToRgb(hsv.h, hsv.s, darkShadowValue)
195
+
196
+ if (isLight) {
197
+ return {
198
+ dark: `rgba(${darkShadowRgb.r}, ${darkShadowRgb.g}, ${darkShadowRgb.b}, 0.3)`,
199
+ darker: `rgba(${darkShadowRgb.r}, ${darkShadowRgb.g}, ${darkShadowRgb.b}, 0.4)`,
200
+ }
201
+ } else {
202
+ return {
203
+ dark: `rgba(${darkShadowRgb.r}, ${darkShadowRgb.g}, ${darkShadowRgb.b}, 0.5)`,
204
+ darker: `rgba(${darkShadowRgb.r}, ${darkShadowRgb.g}, ${darkShadowRgb.b}, 0.6)`,
205
+ }
206
+ }
207
+ } else {
208
+ // For gray surfaces, use standard black shadows
209
+ if (isLight) {
210
+ return {
211
+ dark: 'rgba(0, 0, 0, 0.2)',
212
+ darker: 'rgba(0, 0, 0, 0.3)',
213
+ }
214
+ } else {
215
+ return {
216
+ dark: 'rgba(0, 0, 0, 0.5)',
217
+ darker: 'rgba(0, 0, 0, 0.6)',
218
+ }
219
+ }
220
+ }
221
+ }
222
+
223
+ /**
224
+ * Get rim light size config.
225
+ * Fixed 1px offset and blur for a consistent, crisp rim highlight.
226
+ * Opacity scaling (in calculateRimLightColor) handles elevation differences.
227
+ */
228
+ function getRimLightConfig(): { offset: number; blur: number } {
229
+ return {
230
+ offset: 1, // Fixed 1px for crisp rim
231
+ blur: 1, // Tight blur for crisp edge
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get elevation configuration for a given level
237
+ */
238
+ export function getElevationConfig(level: ElevationLevel): ElevationConfig {
239
+ return elevationSystem[level]
240
+ }
241
+
242
+ // Simple cache for elevation surface colors
243
+ const elevationSurfaceCache = new Map<string, string>()
244
+ const MAX_CACHE_SIZE = 100
245
+
246
+ function getCacheKey(baseColor: string, level: ElevationLevel, theme: 'light' | 'dark'): string {
247
+ return `${baseColor}-${level}-${theme}`
248
+ }
249
+
250
+ function calculateElevationSurface(
251
+ baseColor: string,
252
+ level: ElevationLevel,
253
+ theme: 'light' | 'dark'
254
+ ): string {
255
+ const config = elevationSystem[level]
256
+
257
+ if (config.colorAdjustment === 0) {
258
+ return baseColor
259
+ }
260
+
261
+ // For raised elevations (positive), always lighten (consistent lighting model)
262
+ // Light source is from upper-right, so raised elements are always lighter
263
+ // For inset elevations (negative), we keep base color (shadow creates depth)
264
+ if (level < 0) {
265
+ return baseColor // Inset elements use base color
266
+ }
267
+
268
+ // Always lighten for raised elements (consistent across themes)
269
+ // Base colors must be chosen to allow sufficient lightening in dark mode
270
+ return lighten(baseColor, config.colorAdjustment)
271
+ }
272
+
273
+ /**
274
+ * Calculate surface color for an elevation level from a base color.
275
+ * Results are cached to avoid redundant calculations.
276
+ *
277
+ * @param baseColor - Base surface color (hex)
278
+ * @param level - Elevation level
279
+ * @param theme - Current theme ('light' or 'dark')
280
+ * @returns Calculated surface color (hex)
281
+ */
282
+ export function getElevationSurface(
283
+ baseColor: string,
284
+ level: ElevationLevel,
285
+ theme: 'light' | 'dark'
286
+ ): string {
287
+ const cacheKey = getCacheKey(baseColor, level, theme)
288
+
289
+ // Check cache
290
+ if (elevationSurfaceCache.has(cacheKey)) {
291
+ return elevationSurfaceCache.get(cacheKey)!
292
+ }
293
+
294
+ // Calculate
295
+ const result = calculateElevationSurface(baseColor, level, theme)
296
+
297
+ // Cache result (with size limit to prevent memory leaks)
298
+ if (elevationSurfaceCache.size >= MAX_CACHE_SIZE) {
299
+ // Remove oldest entry (simple FIFO)
300
+ const firstKey = elevationSurfaceCache.keys().next().value!
301
+ elevationSurfaceCache.delete(firstKey)
302
+ }
303
+ elevationSurfaceCache.set(cacheKey, result)
304
+
305
+ return result
306
+ }
307
+
308
+ // Cache for shadow styles (less frequently used, smaller cache)
309
+ const elevationShadowCache = new Map<string, ViewStyle>()
310
+ const MAX_SHADOW_CACHE_SIZE = 50
311
+
312
+ function getShadowCacheKey(baseColor: string, level: ElevationLevel, theme: 'light' | 'dark', isHovered: boolean): string {
313
+ return `${baseColor}-${level}-${theme}-${isHovered}`
314
+ }
315
+
316
+ function calculateElevationShadow(
317
+ baseColor: string,
318
+ level: ElevationLevel,
319
+ theme: 'light' | 'dark',
320
+ isHovered: boolean = false
321
+ ): ViewStyle {
322
+ const config = elevationSystem[level]
323
+
324
+ // Base level has no shadow
325
+ if (level === 0) {
326
+ return {}
327
+ }
328
+
329
+ // Calculate the actual elevated surface color
330
+ const elevatedSurfaceColor = calculateElevationSurface(baseColor, level, theme)
331
+
332
+ // Rim light: calculated from BASE color (consistent across all elevations)
333
+ // This represents a consistent light source - only size changes with elevation
334
+ const rimLightColor = calculateRimLightColor(baseColor, level)
335
+
336
+ // Dark shadow: calculated from ELEVATED surface color
337
+ // The shadow is cast by the element, so it should match the surface
338
+ const darkShadowColors = calculateDarkShadowColors(elevatedSurfaceColor)
339
+
340
+ // Get shadow intensity config (for dark shadow)
341
+ const intensityConfig = {
342
+ subtle: { offset: 1, blur: 2 },
343
+ medium: { offset: 2, blur: 4 },
344
+ strong: { offset: 2, blur: 5 },
345
+ }
346
+
347
+ const { offset, blur } = intensityConfig[config.shadowIntensity]
348
+
349
+ // Get rim light config (scales with elevation level)
350
+ const rimConfig = getRimLightConfig()
351
+
352
+ if (config.shadowStyle === 'pressed') {
353
+ // Inset shadow (pressed elements)
354
+ if (isHovered) {
355
+ // Reduce shadow depth for hover
356
+ const hoverOffset = Math.max(1, Math.round(offset / 2))
357
+ const hoverBlur = Math.max(1, Math.round(blur / 2))
358
+ return Platform.select({
359
+ web: {
360
+ boxShadow: `inset ${hoverOffset}px ${hoverOffset}px ${hoverBlur}px ${darkShadowColors.darker}, inset -${rimConfig.offset}px -${rimConfig.offset}px ${rimConfig.blur}px ${rimLightColor}`,
361
+ } as any,
362
+ default: {
363
+ shadowOpacity: 0,
364
+ elevation: 0,
365
+ },
366
+ }) as ViewStyle
367
+ }
368
+
369
+ return Platform.select({
370
+ web: {
371
+ boxShadow: `inset ${offset}px ${offset}px ${blur}px ${darkShadowColors.darker}, inset -${rimConfig.offset}px -${rimConfig.offset}px ${rimConfig.blur}px ${rimLightColor}`,
372
+ } as any,
373
+ default: {
374
+ shadowOpacity: 0,
375
+ elevation: 0,
376
+ },
377
+ }) as ViewStyle
378
+ } else {
379
+ // Raised shadow (floating elements)
380
+ if (isHovered) {
381
+ // Reduce shadow depth for hover
382
+ const hoverOffset = Math.max(1, Math.round(offset / 2))
383
+ const hoverBlur = Math.max(1, Math.round(blur / 2))
384
+ return Platform.select({
385
+ web: {
386
+ boxShadow: `${hoverOffset}px ${hoverOffset}px ${hoverBlur}px ${darkShadowColors.dark}, -${rimConfig.offset}px -${rimConfig.offset}px ${rimConfig.blur}px ${rimLightColor}`,
387
+ } as any,
388
+ default: {
389
+ shadowColor: '#000',
390
+ shadowOffset: { width: hoverOffset, height: hoverOffset },
391
+ shadowOpacity: 0.2,
392
+ shadowRadius: hoverBlur / 2,
393
+ elevation: hoverOffset,
394
+ },
395
+ }) as ViewStyle
396
+ }
397
+
398
+ return Platform.select({
399
+ web: {
400
+ boxShadow: `${offset}px ${offset}px ${blur}px ${darkShadowColors.dark}, -${rimConfig.offset}px -${rimConfig.offset}px ${rimConfig.blur}px ${rimLightColor}`,
401
+ } as any,
402
+ default: {
403
+ shadowColor: '#000',
404
+ shadowOffset: { width: offset, height: offset },
405
+ shadowOpacity: 0.3,
406
+ shadowRadius: blur / 2,
407
+ elevation: offset,
408
+ },
409
+ }) as ViewStyle
410
+ }
411
+ }
412
+
413
+ /**
414
+ * Get shadow style for an elevation level, calculated dynamically from surface color.
415
+ * Results are cached to avoid redundant calculations.
416
+ *
417
+ * @param baseColor - Base surface color (hex)
418
+ * @param level - Elevation level
419
+ * @param theme - Current theme ('light' or 'dark')
420
+ * @param isHovered - Whether element is hovered
421
+ * @returns ViewStyle with shadow properties
422
+ */
423
+ export function getElevationShadow(
424
+ baseColor: string,
425
+ level: ElevationLevel,
426
+ theme: 'light' | 'dark',
427
+ isHovered: boolean = false
428
+ ): ViewStyle {
429
+ const cacheKey = getShadowCacheKey(baseColor, level, theme, isHovered)
430
+
431
+ // Check cache
432
+ if (elevationShadowCache.has(cacheKey)) {
433
+ return elevationShadowCache.get(cacheKey)!
434
+ }
435
+
436
+ // Calculate shadow
437
+ const result = calculateElevationShadow(baseColor, level, theme, isHovered)
438
+
439
+ // Cache result
440
+ if (elevationShadowCache.size >= MAX_SHADOW_CACHE_SIZE) {
441
+ const firstKey = elevationShadowCache.keys().next().value!
442
+ elevationShadowCache.delete(firstKey)
443
+ }
444
+ elevationShadowCache.set(cacheKey, result)
445
+
446
+ return result
447
+ }
448
+
449
+ /**
450
+ * Get base surface color from theme.
451
+ * This is the foundation color that all elevations are calculated from.
452
+ *
453
+ * IMPORTANT: Base colors must be chosen to allow sufficient lightening.
454
+ *
455
+ * **Light Mode**: Cannot use `#FFFFFF` (white) - cannot lighten white!
456
+ * - Use `#F3F4F6` (neutral[100]) - can lighten to `#FAFAFA` → `#FFFFFF`
457
+ * - This allows raised elevations to be lighter (closer to white)
458
+ *
459
+ * **Dark Mode**: Need lighter base to allow sufficient lightening
460
+ * - Use `#191919` (charcoal[600]) - allows lightening to `#2A2A2A` at max elevation
461
+ *
462
+ * Can be overridden to use custom base colors for special cases.
463
+ */
464
+ export function getBaseSurfaceColor(theme: 'light' | 'dark'): string {
465
+ // Use semantic token values so elevation colors stay in sync with the token pipeline.
466
+ // Light mode: surface-raised (#F3F4F6 / neutral[100]) - NOT white, so we can lighten it
467
+ // Dark mode: surface-elevated (#191919 / charcoal[600]) - allows sufficient lightening
468
+
469
+ return theme === 'light'
470
+ ? semanticColorsLight['surface-raised']
471
+ : semanticColorsDark['surface-elevated']
472
+ }
473
+
474
+ /**
475
+ * Component-specific elevation ranges
476
+ * Defines which elevation levels each component type can use
477
+ */
478
+ export const componentElevationRanges = {
479
+ button: {
480
+ allowed: [2] as const,
481
+ default: 2 as const,
482
+ hover: 3 as const,
483
+ },
484
+ card: {
485
+ allowed: [1, 2, 3] as const,
486
+ default: 2 as const,
487
+ hover: 3 as const,
488
+ },
489
+ input: {
490
+ allowed: [-2, -1, 0] as const,
491
+ default: -1 as const,
492
+ },
493
+ overlay: {
494
+ allowed: [4, 5] as const,
495
+ default: 4 as const,
496
+ },
497
+ } as const
498
+
499
+ export type ComponentType = keyof typeof componentElevationRanges
500
+
501
+ /**
502
+ * Validate and clamp elevation to component's allowed range
503
+ */
504
+ export function getValidatedElevation(
505
+ component: ComponentType,
506
+ requested: ElevationLevel
507
+ ): ElevationLevel {
508
+ const range = componentElevationRanges[component]
509
+ if ((range.allowed as readonly ElevationLevel[]).includes(requested)) {
510
+ return requested
511
+ }
512
+ // Clamp to nearest allowed elevation
513
+ const sorted = [...range.allowed].sort((a, b) => a - b)
514
+ if (requested < sorted[0]) return sorted[0]
515
+ if (requested > sorted[sorted.length - 1]) return sorted[sorted.length - 1]
516
+ // Find closest
517
+ return sorted.reduce((prev, curr) =>
518
+ Math.abs(curr - requested) < Math.abs(prev - requested) ? curr : prev
519
+ ) as ElevationLevel
520
+ }
521
+
522
+ // =============================================================================
523
+ // Glow Shadow Support
524
+ // =============================================================================
525
+
526
+ export type GlowIntensity = 'subtle' | 'medium' | 'strong'
527
+
528
+ const glowConfig: Record<GlowIntensity, { blur: number; spread: number; opacity: number }> = {
529
+ subtle: { blur: 12, spread: 0, opacity: 0.25 },
530
+ medium: { blur: 20, spread: 2, opacity: 0.4 },
531
+ strong: { blur: 30, spread: 4, opacity: 0.55 },
532
+ }
533
+
534
+ /**
535
+ * Get a glow shadow style for emphasis/active states.
536
+ *
537
+ * Creates a colored radial glow around an element, useful for:
538
+ * - Active recording indicators (orange glow)
539
+ * - Success states (green glow)
540
+ * - Error/danger indicators (red glow)
541
+ * - Focus rings and attention-drawing elements
542
+ *
543
+ * @param color - Glow color (hex string, e.g. '#FF7900')
544
+ * @param intensity - Glow intensity level
545
+ * @returns ViewStyle with platform-specific shadow properties
546
+ */
547
+ export function getGlowShadow(
548
+ color: string,
549
+ intensity: GlowIntensity = 'medium'
550
+ ): ViewStyle {
551
+ const config = glowConfig[intensity]
552
+ const rgb = hexToRgb(color)
553
+ if (!rgb) return {}
554
+
555
+ return Platform.select({
556
+ web: {
557
+ boxShadow: `0 0 ${config.blur}px ${config.spread}px rgba(${rgb.r}, ${rgb.g}, ${rgb.b}, ${config.opacity})`,
558
+ } as any,
559
+ default: {
560
+ shadowColor: color,
561
+ shadowOffset: { width: 0, height: 0 },
562
+ shadowOpacity: config.opacity,
563
+ shadowRadius: config.blur / 2,
564
+ elevation: 0,
565
+ },
566
+ }) as ViewStyle
567
+ }