@titan-design/react-ui 0.2.7 → 0.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 (92) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +3014 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1088 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/chunk-2SISKTWM.mjs +1027 -0
  10. package/dist/chunk-2SISKTWM.mjs.map +1 -0
  11. package/dist/{chunk-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
  12. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  13. package/dist/{chunk-3VLOBS6O.mjs → chunk-SNVKL5WZ.mjs} +42 -5
  14. package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
  15. package/dist/index.d.mts +416 -250
  16. package/dist/index.d.ts +416 -250
  17. package/dist/index.js +1567 -1123
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +1491 -2201
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +288 -2
  22. package/dist/theme/index.d.ts +288 -2
  23. package/dist/theme/index.js +159 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -2
  26. package/dist/theme/tokens-css.d.mts +5 -3
  27. package/dist/theme/tokens-css.d.ts +5 -3
  28. package/dist/theme/tokens-css.js +318 -9
  29. package/dist/theme/tokens-css.js.map +1 -1
  30. package/dist/theme/tokens-css.mjs +1 -1
  31. package/dist/theme/tokens-css.mjs.map +1 -1
  32. package/package.json +10 -1
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/Table/Table.test.tsx +27 -0
  35. package/src/components/custom/Table/Table.tsx +33 -17
  36. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  37. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  38. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +442 -0
  39. package/src/components/custom/Workout/BodyMap.tsx +7 -9
  40. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -29
  41. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  42. package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
  43. package/src/components/custom/Workout/ExerciseCard.tsx +24 -26
  44. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  45. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  46. package/src/components/custom/Workout/ExerciseDetailPage.tsx +624 -0
  47. package/src/components/custom/Workout/InputBar.tsx +10 -12
  48. package/src/components/custom/Workout/MesoCard.tsx +2 -4
  49. package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
  50. package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
  51. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  52. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  53. package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -4
  54. package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
  55. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  56. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  57. package/src/components/custom/Workout/ProgramPlanningPage.tsx +418 -0
  58. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -12
  59. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  60. package/src/components/custom/Workout/RestTimer.tsx +12 -9
  61. package/src/components/custom/Workout/SetRow.tsx +16 -19
  62. package/src/components/custom/Workout/StatusDot.tsx +1 -0
  63. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  64. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  65. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  66. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  67. package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
  68. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  69. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  70. package/src/components/custom/Workout/TrainingStatusPage.tsx +312 -0
  71. package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
  72. package/src/components/custom/Workout/VelocityStrip.tsx +19 -16
  73. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  74. package/src/components/custom/Workout/WorkoutCard.tsx +3 -6
  75. package/src/components/custom/Workout/index.ts +80 -32
  76. package/src/test/nativewind-stub.ts +13 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/config.completeness.test.ts +77 -0
  80. package/src/theme/config.ts +151 -0
  81. package/src/theme/index.ts +2 -0
  82. package/src/theme/manifest/design-freeze.test.ts +155 -0
  83. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  84. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  85. package/src/theme/resolve-color.ts +19 -0
  86. package/src/theme/tokens-css.ts +5 -3
  87. package/src/theme/workout-tokens.ts +12 -14
  88. package/src/utils/index.ts +10 -0
  89. package/src/utils/workout-format.test.ts +81 -0
  90. package/src/utils/workout-format.ts +83 -0
  91. package/dist/chunk-3VLOBS6O.mjs.map +0 -1
  92. package/dist/chunk-5SSKGS6E.mjs.map +0 -1
@@ -1,6 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+ import { formatVelocity } from '../../../utils/workout-format'
4
6
 
5
7
  export interface VelocityStripProps extends ViewProps {
6
8
  velocities: number[]
@@ -12,12 +14,8 @@ export interface VelocityStripProps extends ViewProps {
12
14
  className?: string
13
15
  }
14
16
 
15
- const VEL_COLORS = {
16
- green: '#2ed573',
17
- yellow: '#ffd43b',
18
- orange: '#ffa502',
19
- red: '#ff4757',
20
- }
17
+ // Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
18
+ const VEL_COLORS = WORKOUT_TOKENS.scale
21
19
 
22
20
  export function getVelocityZoneColor(velocity: number): string {
23
21
  if (velocity >= 1.0) return 'vel-green'
@@ -153,7 +151,9 @@ export function VelocityStrip({
153
151
 
154
152
  const stripContent = (
155
153
  <Animated.View
156
- className={className}
154
+ className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
155
+ .filter(Boolean)
156
+ .join(' ')}
157
157
  style={[
158
158
  {
159
159
  height: heightAnim,
@@ -164,7 +164,6 @@ export function VelocityStrip({
164
164
  paddingBottom: expanded ? 24 : 0,
165
165
  paddingHorizontal: expanded ? 6 : 0,
166
166
  paddingVertical: expanded ? undefined : 8,
167
- backgroundColor: expanded ? '#1C1C1C' : 'transparent',
168
167
  overflow: expanded ? 'visible' : 'hidden',
169
168
  },
170
169
  ]}
@@ -178,7 +177,10 @@ export function VelocityStrip({
178
177
  >
179
178
  {velocities.map((v, i) => {
180
179
  const zone = getVelocityZoneColor(v)
181
- const barHeightPct = Math.round((v / (maxVelocity * 1.15)) * 100)
180
+ // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
181
+ // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
182
+ const barHeightPct =
183
+ maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
182
184
 
183
185
  const bar = (
184
186
  <View
@@ -188,10 +190,11 @@ export function VelocityStrip({
188
190
  {expanded && (
189
191
  <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
190
192
  <Text
191
- style={{ fontSize: 8, fontWeight: '600', color: '#9CA3AF' }}
193
+ className="text-text-secondary"
194
+ style={{ fontSize: 8, fontWeight: '600' }}
192
195
  testID={`velocity-label-${i}`}
193
196
  >
194
- {v.toFixed(2)}
197
+ {formatVelocity(v)}
195
198
  </Text>
196
199
  </Animated.View>
197
200
  )}
@@ -210,7 +213,7 @@ export function VelocityStrip({
210
213
  }
211
214
  testID={`velocity-bar-${i}`}
212
215
  accessibilityRole="image"
213
- accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second`}
216
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
214
217
  />
215
218
  </View>
216
219
  )
@@ -222,7 +225,7 @@ export function VelocityStrip({
222
225
  style={{ flex: 1, height: '100%' }}
223
226
  onPress={() => onRepPress(i, v)}
224
227
  accessibilityRole="button"
225
- accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second, tap for details`}
228
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
226
229
  testID={`velocity-bar-pressable-${i}`}
227
230
  >
228
231
  {bar}
@@ -247,18 +250,18 @@ export function VelocityStrip({
247
250
  testID="velocity-info-row"
248
251
  >
249
252
  <Text
253
+ className="text-text-secondary"
250
254
  style={{
251
255
  fontSize: 10,
252
- color: '#9CA3AF',
253
256
  fontFamily: 'Inter, sans-serif',
254
257
  }}
255
258
  >
256
- {meanZone} {'\u00B7'} {meanVelocity.toFixed(2)} m/s
259
+ {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
257
260
  </Text>
258
261
  <Text
262
+ className="text-text-secondary"
259
263
  style={{
260
264
  fontSize: 10,
261
- color: '#9CA3AF',
262
265
  fontFamily: 'Inter, sans-serif',
263
266
  ...getLossStyle(loss),
264
267
  }}
@@ -100,11 +100,12 @@ export function WeekRow({
100
100
  />
101
101
  )}
102
102
  <Text
103
+ className={isCurrent ? undefined : 'text-text-primary'}
103
104
  style={{
104
105
  fontSize: 13,
105
106
  fontWeight: '700',
106
107
  fontFamily: 'Inter, sans-serif',
107
- color: isCurrent ? BRAND_PRIMARY : '#F3F4F6',
108
+ color: isCurrent ? BRAND_PRIMARY : undefined,
108
109
  }}
109
110
  accessibilityElementsHidden
110
111
  >
@@ -43,9 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
43
43
  }
44
44
 
45
45
  const COLORS = {
46
- textPrimary: '#F3F4F6',
47
- textSecondary: '#9CA3AF',
48
- textTertiary: '#6B7280',
49
46
  statusSuccess: '#14B8A6',
50
47
  brandPrimary: '#FF7900',
51
48
  borderDefault: '#1F1F1F',
@@ -122,11 +119,11 @@ export function WorkoutCard({
122
119
  <View style={{ padding: 14 }} testID="workout-card-body">
123
120
  <View className="flex-row items-center" testID="workout-card-header">
124
121
  <Text
122
+ className="text-text-primary"
125
123
  style={{
126
124
  fontSize: 15,
127
125
  fontFamily: '"Space Grotesk", sans-serif',
128
126
  fontWeight: '700',
129
- color: COLORS.textPrimary,
130
127
  }}
131
128
  testID="workout-card-name"
132
129
  >
@@ -134,10 +131,10 @@ export function WorkoutCard({
134
131
  </Text>
135
132
  <View className="flex-1" />
136
133
  <Text
134
+ className="text-text-tertiary"
137
135
  style={{
138
136
  fontSize: 11,
139
137
  fontFamily: 'Inter, sans-serif',
140
- color: COLORS.textTertiary,
141
138
  }}
142
139
  testID="workout-card-date"
143
140
  >
@@ -146,11 +143,11 @@ export function WorkoutCard({
146
143
  </View>
147
144
 
148
145
  <Text
146
+ className="text-text-secondary"
149
147
  style={{
150
148
  marginTop: 4,
151
149
  fontSize: 12,
152
150
  fontFamily: 'Inter, sans-serif',
153
- color: COLORS.textSecondary,
154
151
  }}
155
152
  testID="workout-card-stats"
156
153
  >
@@ -4,15 +4,20 @@ export {
4
4
  type BaseBadgeVariant,
5
5
  type BaseBadgeSize,
6
6
  } from './BaseBadge'
7
- export {
8
- WeightBadge,
9
- type WeightBadgeProps,
10
- type WeightBadgeSize,
11
- } from './WeightBadge'
7
+ export { WeightBadge, type WeightBadgeProps, type WeightBadgeSize } from './WeightBadge'
12
8
  export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
13
9
  export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
14
10
  export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
15
11
  export { TempoDisplay, type TempoDisplayProps } from './TempoDisplay'
12
+ export {
13
+ TempoBar,
14
+ type TempoBarProps,
15
+ type TempoPhaseKey,
16
+ type TempoPacingState,
17
+ TEMPO_PACING,
18
+ getTempoPacingState,
19
+ getTempoFillPct,
20
+ } from './TempoBar'
16
21
  export { DeviationBar, type DeviationBarProps } from './DeviationBar'
17
22
  export { IntensityBar, type IntensityBarProps } from './IntensityBar'
18
23
  export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
@@ -24,11 +29,7 @@ export {
24
29
  calculateVelocityLoss,
25
30
  calculateMeanVelocity,
26
31
  } from './VelocityStrip'
27
- export {
28
- MuscleGroupChip,
29
- type MuscleGroupChipProps,
30
- type VolumeStatus,
31
- } from './MuscleGroupChip'
32
+ export { MuscleGroupChip, type MuscleGroupChipProps, type VolumeStatus } from './MuscleGroupChip'
32
33
  export { Sparkline, type SparklineProps } from './Sparkline'
33
34
  export { SetRow, type SetRowProps, type SetRowMode } from './SetRow'
34
35
  export { InputBar, type InputBarProps } from './InputBar'
@@ -49,11 +50,7 @@ export {
49
50
  type WorkoutMuscleGroup,
50
51
  type WorkoutMuscleVolumeStatus,
51
52
  } from './WorkoutCard'
52
- export {
53
- MesoCard,
54
- type MesoCardProps,
55
- type MesoVolumeHeatmapEntry,
56
- } from './MesoCard'
53
+ export { MesoCard, type MesoCardProps, type MesoVolumeHeatmapEntry } from './MesoCard'
57
54
  export {
58
55
  PrHistoryModal,
59
56
  type PrHistoryModalProps,
@@ -91,25 +88,76 @@ export {
91
88
  type WorkoutDot,
92
89
  type WorkoutDotStatus,
93
90
  } from './CapacityBandChart'
94
- export { BodyMap, type BodyMapProps, type BodyMapData } from './BodyMap'
95
- export {
96
- BodyMapDetailPanel,
97
- type BodyMapDetailPanelProps,
98
- type ContributingExercise,
99
- type UpcomingExercise,
91
+ // BodyMap / BodyMapDetailPanel / TrainingStatusPage depend on
92
+ // `react-native-body-highlighter` at runtime. Their VALUE exports live behind the
93
+ // `@titan-design/react-ui/bodymap` subpath to keep this barrel body-highlighter-free.
94
+ // Type-only re-exports stay here (erased at build, so no runtime pull).
95
+ export type { BodyMapProps, BodyMapData } from './BodyMap'
96
+ export type {
97
+ BodyMapDetailPanelProps,
98
+ ContributingExercise,
99
+ UpcomingExercise,
100
100
  } from './BodyMapDetailPanel'
101
+ export type {
102
+ TrainingStatusPageProps,
103
+ TrainingStatusMuscle,
104
+ TrainingStatusSummary,
105
+ } from './TrainingStatusPage'
106
+ export {
107
+ ProgramPlanningPage,
108
+ deriveNavLevel,
109
+ toProgressBarMesos,
110
+ findMeso,
111
+ findWeek,
112
+ findWorkout,
113
+ buildBreadcrumbs,
114
+ type ProgramPlanningPageProps,
115
+ type PlanMeso,
116
+ type PlanWeek,
117
+ type PlanWorkout,
118
+ type ProgramNavLevel,
119
+ type ProgramSelection,
120
+ type ProgramBreadcrumb,
121
+ } from './ProgramPlanningPage'
101
122
  export {
123
+ ExerciseDetailPage,
124
+ deriveExerciseStats,
125
+ toExerciseCardProps,
126
+ summarizeVbt,
127
+ EXERCISE_DETAIL_TABS,
128
+ type ExerciseDetailPageProps,
129
+ type ExerciseDetailTab,
130
+ type ExerciseDetailHeader,
131
+ type ExerciseDetailEntry,
132
+ type ExerciseTrend,
133
+ type ExerciseVbtSet,
134
+ type ExerciseVbt,
135
+ type ExerciseDetailStats,
136
+ type VbtSummary,
137
+ } from './ExerciseDetailPage'
138
+ // muscleTaxonomy imports `react-native-body-highlighter` at runtime. Its value
139
+ // exports (incl. the MuscleGroup / SimpleMuscleGroup enums) live behind the
140
+ // `@titan-design/react-ui/bodymap` subpath. Only type-only re-exports stay here.
141
+ export type {
102
142
  MuscleGroup,
103
143
  SimpleMuscleGroup,
104
- SIMPLE_TO_DETAILED,
105
- DETAILED_TO_SIMPLE,
106
- MUSCLE_TO_SVG_SLUGS,
107
- MUSCLE_TO_CATEGORY,
108
- MUSCLE_DISPLAY_NAMES,
109
- SIMPLE_DISPLAY_NAMES,
110
- DEFAULT_VOLUME_LANDMARKS,
111
- VOLUME_STATUS_LABELS,
112
- getHeatmapColor,
113
- type MovementCategory,
114
- type VolumeLandmarks,
144
+ MovementCategory,
145
+ VolumeLandmarks,
115
146
  } from './muscleTaxonomy'
147
+ export {
148
+ ActiveWorkoutPage,
149
+ countCompletedSets,
150
+ deriveWorkoutProgress,
151
+ findActiveExercise,
152
+ statusToCardState,
153
+ toActiveCardProps,
154
+ groupExercises,
155
+ type ActiveWorkoutPageProps,
156
+ type ActiveWorkoutExercise,
157
+ type ActiveWorkoutSuperset,
158
+ type ActiveWorkoutInput,
159
+ type ActiveWorkoutRest,
160
+ type ActiveExerciseStatus,
161
+ type WorkoutProgress,
162
+ type WorkoutGroup,
163
+ } from './ActiveWorkoutPage'
@@ -0,0 +1,13 @@
1
+ // Web test stub for `nativewind`.
2
+ //
3
+ // The real `nativewind` pulls react-native-css-interop → RN's Flow-typed
4
+ // sources, which the jsdom/react-native-web test environment can't parse
5
+ // ("Unexpected token 'typeof'"). Only `ThemeProvider` imports nativewind (for
6
+ // `vars()`), and only for its NATIVE behavior — on web the color tokens resolve
7
+ // from `global.css`, so an identity `vars()` is a faithful web stand-in.
8
+ // Aliased in `vitest.config.ts`; the real package is used at build/native.
9
+
10
+ /** Identity stand-in for nativewind's `vars()` — returns the token map as-is. */
11
+ export function vars(values: Record<string, string>): Record<string, string> {
12
+ return values
13
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { Text } from 'react-native'
4
+ import { ThemeProvider } from './ThemeProvider'
5
+
6
+ describe('ThemeProvider', () => {
7
+ it('renders its children', () => {
8
+ render(
9
+ <ThemeProvider>
10
+ <Text>inside</Text>
11
+ </ThemeProvider>,
12
+ )
13
+ expect(screen.getByText('inside')).toBeInTheDocument()
14
+ })
15
+
16
+ it('accepts a light mode without error', () => {
17
+ render(
18
+ <ThemeProvider mode="light">
19
+ <Text>light</Text>
20
+ </ThemeProvider>,
21
+ )
22
+ expect(screen.getByText('light')).toBeInTheDocument()
23
+ })
24
+ })
@@ -0,0 +1,50 @@
1
+ // Cross-platform theme provider (TD native-theming).
2
+ //
3
+ // Registers titan's semantic color tokens as runtime CSS variables via
4
+ // nativewind's `vars()` so that className color tokens (`text-text-secondary`,
5
+ // `bg-surface-raised`, `border-border`, …) resolve on NATIVE React Native — the
6
+ // native equivalent of the `:root {}` block `global.css` provides on web.
7
+ //
8
+ // Why this exists: titan's Workout organisms reference semantic colors as CSS
9
+ // variables. On web (the dashboard, react-native-web) those come from
10
+ // `global.css`. On native (the mobile app) nothing defined them, so any titan
11
+ // component using a `--color-*` token rendered with an unresolved (black) color.
12
+ // Wrap the mobile app root in `<ThemeProvider>` and the tokens resolve on-device.
13
+ //
14
+ // The var maps are the SAME canonical `darkThemeCSSVars` / `lightThemeCSSVars`
15
+ // that generate `global.css`, so native and web resolve to identical values.
16
+ import { vars } from 'nativewind'
17
+ import { View, type ViewProps } from 'react-native'
18
+ import { darkThemeCSSVars, lightThemeCSSVars } from './config'
19
+
20
+ export type ThemeProviderMode = 'dark' | 'light'
21
+
22
+ // Precomputed once — `vars()` is pure, so the same style object is reused across
23
+ // renders. Native == web because these are the maps `tokens-css` emits.
24
+ const MODE_VARS = {
25
+ dark: vars(darkThemeCSSVars),
26
+ light: vars(lightThemeCSSVars),
27
+ }
28
+
29
+ export interface ThemeProviderProps extends ViewProps {
30
+ /** Theme mode to register. Mobile is dark-only today; defaults to `dark`. */
31
+ mode?: ThemeProviderMode
32
+ }
33
+
34
+ /**
35
+ * Wrap the app root so descendant titan components' `--color-*` className tokens
36
+ * resolve on native. Fills its parent (`flex: 1`) by default; pass `style` to
37
+ * override. On web it is harmless (the dashboard already loads `global.css`).
38
+ */
39
+ export function ThemeProvider({
40
+ mode = 'dark',
41
+ style,
42
+ children,
43
+ ...props
44
+ }: ThemeProviderProps): React.JSX.Element {
45
+ return (
46
+ <View style={[MODE_VARS[mode], { flex: 1 }, style]} {...props}>
47
+ {children}
48
+ </View>
49
+ )
50
+ }
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { darkThemeCSSVars, lightThemeCSSVars } from './config'
6
+
7
+ /**
8
+ * Drift guard for the token codegen source (TD-05.08).
9
+ *
10
+ * `config.ts` (darkThemeCSSVars / lightThemeCSSVars) is the source that
11
+ * `tokens-css.ts` renders into `dist/tokens.css`. It must stay a complete,
12
+ * value-accurate mirror of the `:root` / `.light` custom properties in
13
+ * `global.css`; otherwise the generated stylesheet silently drifts from the
14
+ * design system (the exact gap this ticket closed). This test fails if
15
+ * global.css gains a `:root` var that config.ts does not cover, or if any
16
+ * shared var's value diverges.
17
+ */
18
+
19
+ const themeDir = path.dirname(fileURLToPath(import.meta.url))
20
+ const css = readFileSync(path.join(themeDir, 'global.css'), 'utf8')
21
+
22
+ /**
23
+ * global.css :root vars intentionally excluded from the codegen source.
24
+ * Empty today — every :root var is mirrored. Add a var here (with a reason)
25
+ * only if it is genuinely prototype-irrelevant, rather than silently skipping.
26
+ */
27
+ const CODEGEN_ALLOWLIST = new Set<string>([])
28
+
29
+ function parseVarBlock(header: RegExp): Map<string, string> {
30
+ const match = css.match(header)
31
+ if (!match) throw new Error(`could not locate CSS block for ${header}`)
32
+ const vars = new Map<string, string>()
33
+ for (const line of match[1].split('\n')) {
34
+ const declaration = line.match(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/i)
35
+ if (declaration) vars.set(declaration[1], declaration[2].trim())
36
+ }
37
+ return vars
38
+ }
39
+
40
+ const globalDark = parseVarBlock(/:root\s*\{([\s\S]*?)\n\s*\}/)
41
+ const globalLight = parseVarBlock(/\.light,\s*\n?\s*:root\.light\s*\{([\s\S]*?)\n\s*\}/)
42
+
43
+ const cases = [
44
+ ['dark', globalDark, darkThemeCSSVars as Record<string, string>],
45
+ ['light', globalLight, lightThemeCSSVars as Record<string, string>],
46
+ ] as const
47
+
48
+ describe('token codegen completeness (config.ts vs global.css)', () => {
49
+ it('parses both global.css theme blocks', () => {
50
+ expect(globalDark.size).toBeGreaterThan(90)
51
+ expect(globalLight.size).toBe(globalDark.size)
52
+ })
53
+
54
+ for (const [mode, globalVars, cfg] of cases) {
55
+ it(`${mode}: config covers every global.css :root var with the exact value`, () => {
56
+ const missing: string[] = []
57
+ const mismatched: string[] = []
58
+ for (const [name, value] of globalVars) {
59
+ if (CODEGEN_ALLOWLIST.has(name)) continue
60
+ if (!(name in cfg)) {
61
+ missing.push(name)
62
+ } else if (cfg[name] !== value) {
63
+ mismatched.push(`${name}: global='${value}' config='${cfg[name]}'`)
64
+ }
65
+ }
66
+ expect(missing, 'global.css vars missing from config.ts codegen source').toEqual([])
67
+ expect(mismatched, 'value drift between config.ts and global.css').toEqual([])
68
+ })
69
+
70
+ it(`${mode}: config emits no var absent from global.css`, () => {
71
+ const extra = Object.keys(cfg).filter(
72
+ (name) => !globalVars.has(name) && !CODEGEN_ALLOWLIST.has(name),
73
+ )
74
+ expect(extra, 'config.ts vars not present in global.css :root').toEqual([])
75
+ })
76
+ }
77
+ })
@@ -7,6 +7,35 @@
7
7
 
8
8
  import { semanticColorsLight, semanticColorsDark, type ThemeMode } from './tokens/semantic'
9
9
 
10
+ // Theme-independent custom properties: identical in light and dark in
11
+ // global.css (RGB glow decompositions, font families, animation tokens). Kept
12
+ // as literals since they are not part of the semantic COLOR maps, and spread
13
+ // into both theme maps so codegen mirrors global.css exactly.
14
+ const themeIndependentCSSVars = {
15
+ // RGB decomposed values for glow shadows
16
+ '--color-brand-primary-rgb': '255, 121, 0',
17
+ '--color-brand-secondary-rgb': '64, 109, 135',
18
+ '--color-status-success-rgb': '20, 184, 166',
19
+ '--color-status-error-rgb': '209, 67, 67',
20
+ '--color-status-warning-rgb': '255, 176, 32',
21
+ '--color-status-info-rgb': '33, 150, 243',
22
+ '--color-background-base-rgb': '16, 16, 16',
23
+
24
+ // Font families
25
+ '--font-family-sans': "'Inter'",
26
+ '--font-family-body': "'Nunito Sans'",
27
+ '--font-family-heading': "'Space Grotesk'",
28
+ '--font-family-mono': "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace",
29
+
30
+ // Animation tokens
31
+ '--ease-out': 'cubic-bezier(0.22, 1, 0.36, 1)',
32
+ '--ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',
33
+ '--ease-spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
34
+ '--duration-fast': '150ms',
35
+ '--duration-normal': '250ms',
36
+ '--duration-slow': '400ms',
37
+ } as const
38
+
10
39
  // CSS custom properties for light mode
11
40
  export const lightThemeCSSVars = {
12
41
  '--color-brand-primary': semanticColorsLight['brand-primary'],
@@ -57,6 +86,67 @@ export const lightThemeCSSVars = {
57
86
  '--color-interactive-disabled': semanticColorsLight['interactive-disabled'],
58
87
 
59
88
  '--color-divider': semanticColorsLight['divider'],
89
+
90
+ '--color-brand-primary-hover': semanticColorsLight['brand-primary-hover'],
91
+ '--color-brand-primary-active': semanticColorsLight['brand-primary-active'],
92
+ '--color-brand-secondary-hover': semanticColorsLight['brand-secondary-hover'],
93
+ '--color-brand-secondary-active': semanticColorsLight['brand-secondary-active'],
94
+
95
+ '--color-status-success-light': semanticColorsLight['status-success-light'],
96
+ '--color-status-success-dark': semanticColorsLight['status-success-dark'],
97
+ '--color-status-error-light': semanticColorsLight['status-error-light'],
98
+ '--color-status-error-dark': semanticColorsLight['status-error-dark'],
99
+ '--color-status-warning-light': semanticColorsLight['status-warning-light'],
100
+ '--color-status-warning-dark': semanticColorsLight['status-warning-dark'],
101
+ '--color-status-info-light': semanticColorsLight['status-info-light'],
102
+ '--color-status-info-dark': semanticColorsLight['status-info-dark'],
103
+
104
+ '--color-on-status-success': semanticColorsLight['on-status-success'],
105
+ '--color-on-status-error': semanticColorsLight['on-status-error'],
106
+ '--color-on-status-warning': semanticColorsLight['on-status-warning'],
107
+ '--color-on-status-info': semanticColorsLight['on-status-info'],
108
+
109
+ '--color-result-improve': semanticColorsLight['result-improve'],
110
+ '--color-result-improve-light': semanticColorsLight['result-improve-light'],
111
+ '--color-result-improve-dark': semanticColorsLight['result-improve-dark'],
112
+ '--color-result-degrade': semanticColorsLight['result-degrade'],
113
+ '--color-result-degrade-light': semanticColorsLight['result-degrade-light'],
114
+ '--color-result-degrade-dark': semanticColorsLight['result-degrade-dark'],
115
+ '--color-result-inconclusive': semanticColorsLight['result-inconclusive'],
116
+ '--color-result-inconclusive-light': semanticColorsLight['result-inconclusive-light'],
117
+ '--color-result-neutral': semanticColorsLight['result-neutral'],
118
+
119
+ '--color-on-result-improve': semanticColorsLight['on-result-improve'],
120
+ '--color-on-result-degrade': semanticColorsLight['on-result-degrade'],
121
+ '--color-on-result-inconclusive': semanticColorsLight['on-result-inconclusive'],
122
+
123
+ '--color-data-1': semanticColorsLight['data-1'],
124
+ '--color-data-2': semanticColorsLight['data-2'],
125
+ '--color-data-3': semanticColorsLight['data-3'],
126
+ '--color-data-4': semanticColorsLight['data-4'],
127
+ '--color-data-5': semanticColorsLight['data-5'],
128
+ '--color-data-6': semanticColorsLight['data-6'],
129
+ '--color-data-7': semanticColorsLight['data-7'],
130
+ '--color-data-8': semanticColorsLight['data-8'],
131
+ '--color-data-9': semanticColorsLight['data-9'],
132
+ '--color-data-10': semanticColorsLight['data-10'],
133
+
134
+ '--color-text-link-hover': semanticColorsLight['text-link-hover'],
135
+
136
+ '--color-surface-overlay': semanticColorsLight['surface-overlay'],
137
+ '--color-surface-input': semanticColorsLight['surface-input'],
138
+
139
+ '--color-border-input': semanticColorsLight['border-input'],
140
+ '--color-border-input-hover': semanticColorsLight['border-input-hover'],
141
+ '--color-border-input-focus': semanticColorsLight['border-input-focus'],
142
+ '--color-border-input-error': semanticColorsLight['border-input-error'],
143
+
144
+ '--color-interactive-disabled-text': semanticColorsLight['interactive-disabled-text'],
145
+
146
+ '--color-avatar-background': semanticColorsLight['avatar-background'],
147
+ '--color-avatar-text': semanticColorsLight['avatar-text'],
148
+
149
+ ...themeIndependentCSSVars,
60
150
  } as const
61
151
 
62
152
  // CSS custom properties for dark mode (default)
@@ -109,6 +199,67 @@ export const darkThemeCSSVars = {
109
199
  '--color-interactive-disabled': semanticColorsDark['interactive-disabled'],
110
200
 
111
201
  '--color-divider': semanticColorsDark['divider'],
202
+
203
+ '--color-brand-primary-hover': semanticColorsDark['brand-primary-hover'],
204
+ '--color-brand-primary-active': semanticColorsDark['brand-primary-active'],
205
+ '--color-brand-secondary-hover': semanticColorsDark['brand-secondary-hover'],
206
+ '--color-brand-secondary-active': semanticColorsDark['brand-secondary-active'],
207
+
208
+ '--color-status-success-light': semanticColorsDark['status-success-light'],
209
+ '--color-status-success-dark': semanticColorsDark['status-success-dark'],
210
+ '--color-status-error-light': semanticColorsDark['status-error-light'],
211
+ '--color-status-error-dark': semanticColorsDark['status-error-dark'],
212
+ '--color-status-warning-light': semanticColorsDark['status-warning-light'],
213
+ '--color-status-warning-dark': semanticColorsDark['status-warning-dark'],
214
+ '--color-status-info-light': semanticColorsDark['status-info-light'],
215
+ '--color-status-info-dark': semanticColorsDark['status-info-dark'],
216
+
217
+ '--color-on-status-success': semanticColorsDark['on-status-success'],
218
+ '--color-on-status-error': semanticColorsDark['on-status-error'],
219
+ '--color-on-status-warning': semanticColorsDark['on-status-warning'],
220
+ '--color-on-status-info': semanticColorsDark['on-status-info'],
221
+
222
+ '--color-result-improve': semanticColorsDark['result-improve'],
223
+ '--color-result-improve-light': semanticColorsDark['result-improve-light'],
224
+ '--color-result-improve-dark': semanticColorsDark['result-improve-dark'],
225
+ '--color-result-degrade': semanticColorsDark['result-degrade'],
226
+ '--color-result-degrade-light': semanticColorsDark['result-degrade-light'],
227
+ '--color-result-degrade-dark': semanticColorsDark['result-degrade-dark'],
228
+ '--color-result-inconclusive': semanticColorsDark['result-inconclusive'],
229
+ '--color-result-inconclusive-light': semanticColorsDark['result-inconclusive-light'],
230
+ '--color-result-neutral': semanticColorsDark['result-neutral'],
231
+
232
+ '--color-on-result-improve': semanticColorsDark['on-result-improve'],
233
+ '--color-on-result-degrade': semanticColorsDark['on-result-degrade'],
234
+ '--color-on-result-inconclusive': semanticColorsDark['on-result-inconclusive'],
235
+
236
+ '--color-data-1': semanticColorsDark['data-1'],
237
+ '--color-data-2': semanticColorsDark['data-2'],
238
+ '--color-data-3': semanticColorsDark['data-3'],
239
+ '--color-data-4': semanticColorsDark['data-4'],
240
+ '--color-data-5': semanticColorsDark['data-5'],
241
+ '--color-data-6': semanticColorsDark['data-6'],
242
+ '--color-data-7': semanticColorsDark['data-7'],
243
+ '--color-data-8': semanticColorsDark['data-8'],
244
+ '--color-data-9': semanticColorsDark['data-9'],
245
+ '--color-data-10': semanticColorsDark['data-10'],
246
+
247
+ '--color-text-link-hover': semanticColorsDark['text-link-hover'],
248
+
249
+ '--color-surface-overlay': semanticColorsDark['surface-overlay'],
250
+ '--color-surface-input': semanticColorsDark['surface-input'],
251
+
252
+ '--color-border-input': semanticColorsDark['border-input'],
253
+ '--color-border-input-hover': semanticColorsDark['border-input-hover'],
254
+ '--color-border-input-focus': semanticColorsDark['border-input-focus'],
255
+ '--color-border-input-error': semanticColorsDark['border-input-error'],
256
+
257
+ '--color-interactive-disabled-text': semanticColorsDark['interactive-disabled-text'],
258
+
259
+ '--color-avatar-background': semanticColorsDark['avatar-background'],
260
+ '--color-avatar-text': semanticColorsDark['avatar-text'],
261
+
262
+ ...themeIndependentCSSVars,
112
263
  } as const
113
264
 
114
265
  // Helper to get CSS vars for a theme mode
@@ -4,6 +4,8 @@ export * from './config'
4
4
  export * from './shadows'
5
5
  export * from './elevation'
6
6
  export * from './manifest'
7
+ export { ThemeProvider, type ThemeProviderProps, type ThemeProviderMode } from './ThemeProvider'
8
+ export { resolveColor } from './resolve-color'
7
9
 
8
10
  // Re-export commonly used items
9
11
  export { getSemanticColors, type ThemeMode } from './tokens/semantic'