@titan-design/react-ui 0.3.0 → 0.5.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 (90) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +441 -97
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1958 -66
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +71 -738
  10. package/dist/index.d.ts +71 -738
  11. package/dist/index.js +888 -1583
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4515 -2348
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6646 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6626 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +28 -2
  24. package/dist/theme/index.d.ts +28 -2
  25. package/dist/theme/index.js +37 -0
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +1880 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.mjs +573 -1
  30. package/dist/theme/tokens-css.mjs.map +1 -1
  31. package/package.json +9 -7
  32. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
  33. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  34. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  35. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  36. package/src/components/custom/Workout/BodyMap.tsx +7 -8
  37. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
  38. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  39. package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
  40. package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
  41. package/src/components/custom/Workout/InputBar.tsx +10 -11
  42. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  43. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  44. package/src/components/custom/Workout/MesoCard.tsx +2 -6
  45. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  46. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  47. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
  48. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  49. package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
  50. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
  51. package/src/components/custom/Workout/README.md +50 -0
  52. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
  53. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  54. package/src/components/custom/Workout/RestTimer.tsx +55 -49
  55. package/src/components/custom/Workout/SetRow.tsx +20 -8
  56. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  57. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  58. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  59. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  60. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  61. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  62. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  63. package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
  64. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  65. package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
  66. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  67. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  68. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  69. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  70. package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
  71. package/src/components/custom/Workout/icons.tsx +68 -0
  72. package/src/components/custom/Workout/index.ts +47 -47
  73. package/src/index.ts +3 -2
  74. package/src/pages.ts +61 -0
  75. package/src/test/nativewind-stub.ts +13 -0
  76. package/src/test/stories-smoke.test.tsx +50 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/barrel.ts +53 -0
  80. package/src/theme/index.ts +8 -41
  81. package/src/theme/native-theming-guard.test.ts +117 -0
  82. package/src/theme/resolve-color.ts +19 -0
  83. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  84. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  85. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  86. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  87. package/dist/chunk-P7TSQUN6.mjs +0 -846
  88. package/dist/chunk-P7TSQUN6.mjs.map +0 -1
  89. package/dist/chunk-TOD2WQBG.mjs +0 -1048
  90. package/dist/chunk-TOD2WQBG.mjs.map +0 -1
@@ -1,5 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, Pressable } from 'react-native'
3
+ import { resolveColor } from '../../../theme/resolve-color'
3
4
 
4
5
  const BRAND_PRIMARY = '#FF7900'
5
6
 
@@ -10,6 +11,8 @@ export interface RestTimerProps {
10
11
  onAddTime: () => void
11
12
  nextSetInfo?: string
12
13
  visible: boolean
14
+ /** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
15
+ displayOnly?: boolean
13
16
  }
14
17
 
15
18
  export function RestTimer({
@@ -19,6 +22,7 @@ export function RestTimer({
19
22
  onAddTime,
20
23
  nextSetInfo,
21
24
  visible,
25
+ displayOnly = false,
22
26
  }: RestTimerProps) {
23
27
  if (!visible) return null
24
28
 
@@ -34,11 +38,11 @@ export function RestTimer({
34
38
 
35
39
  return (
36
40
  <View
41
+ className="bg-surface-raised"
37
42
  style={{
38
43
  width: '100%',
39
- backgroundColor: 'var(--color-surface-raised)',
40
44
  borderTopWidth: 1,
41
- borderTopColor: 'var(--color-border-default)',
45
+ borderTopColor: resolveColor('border-default'),
42
46
  paddingVertical: 12,
43
47
  paddingHorizontal: 16,
44
48
  }}
@@ -58,13 +62,13 @@ export function RestTimer({
58
62
  {/* Left side */}
59
63
  <View style={{ flexDirection: 'column' }}>
60
64
  <Text
65
+ className="text-text-secondary"
61
66
  style={{
62
67
  fontSize: 11,
63
68
  fontFamily: 'Inter, sans-serif',
64
69
  fontWeight: '600',
65
70
  textTransform: 'uppercase',
66
71
  letterSpacing: 1,
67
- color: 'var(--color-text-secondary)',
68
72
  }}
69
73
  testID="rest-timer-label"
70
74
  >
@@ -72,10 +76,10 @@ export function RestTimer({
72
76
  </Text>
73
77
  {nextSetInfo != null && (
74
78
  <Text
79
+ className="text-text-tertiary"
75
80
  style={{
76
81
  fontSize: 11,
77
82
  fontFamily: 'Inter, sans-serif',
78
- color: 'var(--color-text-tertiary)',
79
83
  marginTop: 2,
80
84
  }}
81
85
  testID="rest-timer-next-set"
@@ -87,11 +91,11 @@ export function RestTimer({
87
91
 
88
92
  {/* Right side - time display */}
89
93
  <Text
94
+ className="text-text-primary"
90
95
  style={{
91
96
  fontSize: 28,
92
97
  fontFamily: '"Space Grotesk", sans-serif',
93
98
  fontWeight: '700',
94
- color: 'var(--color-text-primary)',
95
99
  fontVariant: ['tabular-nums'],
96
100
  letterSpacing: -0.5,
97
101
  }}
@@ -103,9 +107,9 @@ export function RestTimer({
103
107
 
104
108
  {/* Progress bar */}
105
109
  <View
110
+ className="bg-border"
106
111
  style={{
107
112
  height: 3,
108
- backgroundColor: 'var(--color-border-default)',
109
113
  borderRadius: 2,
110
114
  marginBottom: 12,
111
115
  }}
@@ -122,55 +126,57 @@ export function RestTimer({
122
126
  />
123
127
  </View>
124
128
 
125
- {/* Actions row */}
126
- <View style={{ flexDirection: 'row', gap: 8 }}>
127
- <Pressable
128
- onPress={onAddTime}
129
- style={{
130
- backgroundColor: 'rgba(255,255,255,0.06)',
131
- paddingVertical: 8,
132
- paddingHorizontal: 20,
133
- borderRadius: 8,
134
- }}
135
- accessibilityRole="button"
136
- accessibilityLabel="Add 30 seconds"
137
- testID="rest-timer-add-time"
138
- >
139
- <Text
129
+ {/* Actions row — hidden in display-only (poll-only) mode */}
130
+ {!displayOnly && (
131
+ <View style={{ flexDirection: 'row', gap: 8 }}>
132
+ <Pressable
133
+ onPress={onAddTime}
140
134
  style={{
141
- fontSize: 11,
142
- fontFamily: 'Inter, sans-serif',
143
- fontWeight: '600',
144
- color: 'var(--color-text-secondary)',
135
+ backgroundColor: 'rgba(255,255,255,0.06)',
136
+ paddingVertical: 8,
137
+ paddingHorizontal: 20,
138
+ borderRadius: 8,
145
139
  }}
140
+ accessibilityRole="button"
141
+ accessibilityLabel="Add 30 seconds"
142
+ testID="rest-timer-add-time"
146
143
  >
147
- +30s
148
- </Text>
149
- </Pressable>
150
- <Pressable
151
- onPress={onSkip}
152
- style={{
153
- backgroundColor: 'rgba(255,121,0,0.12)',
154
- paddingVertical: 8,
155
- paddingHorizontal: 20,
156
- borderRadius: 8,
157
- }}
158
- accessibilityRole="button"
159
- accessibilityLabel="Skip rest"
160
- testID="rest-timer-skip"
161
- >
162
- <Text
144
+ <Text
145
+ className="text-text-secondary"
146
+ style={{
147
+ fontSize: 11,
148
+ fontFamily: 'Inter, sans-serif',
149
+ fontWeight: '600',
150
+ }}
151
+ >
152
+ +30s
153
+ </Text>
154
+ </Pressable>
155
+ <Pressable
156
+ onPress={onSkip}
163
157
  style={{
164
- fontSize: 11,
165
- fontFamily: 'Inter, sans-serif',
166
- fontWeight: '600',
167
- color: BRAND_PRIMARY,
158
+ backgroundColor: 'rgba(255,121,0,0.12)',
159
+ paddingVertical: 8,
160
+ paddingHorizontal: 20,
161
+ borderRadius: 8,
168
162
  }}
163
+ accessibilityRole="button"
164
+ accessibilityLabel="Skip rest"
165
+ testID="rest-timer-skip"
169
166
  >
170
- Skip
171
- </Text>
172
- </Pressable>
173
- </View>
167
+ <Text
168
+ style={{
169
+ fontSize: 11,
170
+ fontFamily: 'Inter, sans-serif',
171
+ fontWeight: '600',
172
+ color: BRAND_PRIMARY,
173
+ }}
174
+ >
175
+ Skip
176
+ </Text>
177
+ </Pressable>
178
+ </View>
179
+ )}
174
180
  </View>
175
181
  )
176
182
  }
@@ -1,6 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text } from 'react-native'
3
- import { VelocityStrip } from './VelocityStrip'
3
+ import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
4
4
  import { PrBadge, type PRType } from './PrBadge'
5
5
  import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
6
6
 
@@ -14,7 +14,12 @@ export interface SetRowProps {
14
14
  weight: number | null
15
15
  rpe?: number | null
16
16
  unit: 'lbs' | 'kg'
17
+ /** Per-rep MEAN concentric velocities (m/s). */
17
18
  velocities?: number[]
19
+ /** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
20
+ velocityZones?: readonly VelocityZoneBandProp[]
21
+ /** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
22
+ velocityLiveRepIndex?: number
18
23
  velocityExpanded?: boolean
19
24
  onVelocityToggle?: () => void
20
25
  setType?: string
@@ -43,6 +48,8 @@ export function SetRow({
43
48
  rpe,
44
49
  unit,
45
50
  velocities,
51
+ velocityZones,
52
+ velocityLiveRepIndex,
46
53
  velocityExpanded,
47
54
  onVelocityToggle,
48
55
  setType,
@@ -106,11 +113,11 @@ export function SetRow({
106
113
  </Text>
107
114
  ) : (
108
115
  <Text
116
+ className="text-text-secondary"
109
117
  style={{
110
118
  fontSize: 13,
111
119
  fontWeight: '600',
112
120
  fontFamily: 'Inter, sans-serif',
113
- color: 'var(--color-text-secondary)',
114
121
  }}
115
122
  >
116
123
  {setNumber}
@@ -123,10 +130,10 @@ export function SetRow({
123
130
  testID="set-row-previous"
124
131
  >
125
132
  <Text
133
+ className="text-text-secondary"
126
134
  style={{
127
135
  fontSize: 12,
128
136
  fontFamily: 'Inter, sans-serif',
129
- color: 'var(--color-text-secondary)',
130
137
  }}
131
138
  >
132
139
  {previous
@@ -141,11 +148,11 @@ export function SetRow({
141
148
  >
142
149
  {isActive && reps == null && targets ? (
143
150
  <Text
151
+ className="text-text-tertiary"
144
152
  style={{
145
153
  fontSize: 13,
146
154
  fontStyle: 'italic',
147
155
  fontFamily: 'Inter, sans-serif',
148
- color: 'var(--color-text-tertiary)',
149
156
  }}
150
157
  testID="set-row-target-reps"
151
158
  >
@@ -153,11 +160,11 @@ export function SetRow({
153
160
  </Text>
154
161
  ) : (
155
162
  <Text
163
+ className={reps != null ? 'text-text-primary' : 'text-text-tertiary'}
156
164
  style={{
157
165
  fontSize: 14,
158
166
  fontWeight: '600',
159
167
  fontFamily: 'Inter, sans-serif',
160
- color: reps != null ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
161
168
  }}
162
169
  >
163
170
  {reps != null ? reps : '\u2014'}
@@ -171,11 +178,11 @@ export function SetRow({
171
178
  >
172
179
  {isActive && weight == null && targets ? (
173
180
  <Text
181
+ className="text-text-tertiary"
174
182
  style={{
175
183
  fontSize: 13,
176
184
  fontStyle: 'italic',
177
185
  fontFamily: 'Inter, sans-serif',
178
- color: 'var(--color-text-tertiary)',
179
186
  }}
180
187
  testID="set-row-target-weight"
181
188
  >
@@ -183,11 +190,11 @@ export function SetRow({
183
190
  </Text>
184
191
  ) : (
185
192
  <Text
193
+ className={weight != null ? 'text-text-primary' : 'text-text-tertiary'}
186
194
  style={{
187
195
  fontSize: 14,
188
196
  fontWeight: '600',
189
197
  fontFamily: 'Inter, sans-serif',
190
- color: weight != null ? 'var(--color-text-primary)' : 'var(--color-text-tertiary)',
191
198
  }}
192
199
  >
193
200
  {weight != null ? roundWeight(weight) : '\u2014'}
@@ -200,11 +207,14 @@ export function SetRow({
200
207
  testID="set-row-rpe"
201
208
  >
202
209
  <Text
210
+ className={rpe != null ? undefined : 'text-text-tertiary'}
203
211
  style={{
204
212
  fontSize: 13,
205
213
  fontWeight: '600',
206
214
  fontFamily: 'Inter, sans-serif',
207
- color: rpe != null ? rpeColor(rpe) : 'var(--color-text-tertiary)',
215
+ // rpeColor returns a concrete hex (native-safe); only the tertiary
216
+ // fallback needs the className token.
217
+ color: rpe != null ? rpeColor(rpe) : undefined,
208
218
  }}
209
219
  >
210
220
  {rpe != null ? roundRpe(rpe) : '\u2014'}
@@ -236,6 +246,8 @@ export function SetRow({
236
246
  >
237
247
  <VelocityStrip
238
248
  velocities={velocities}
249
+ zones={velocityZones}
250
+ liveRepIndex={velocityLiveRepIndex}
239
251
  expanded={velocityExpanded}
240
252
  onToggle={onVelocityToggle}
241
253
  variant={onVelocityToggle ? 'full' : 'mini'}
@@ -2,17 +2,16 @@
2
2
  import { useEffect, useMemo, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
4
  import { cn } from '../../../utils/cn'
5
+ import { resolveColor } from '../../../theme/resolve-color'
5
6
 
6
7
  const BRAND_PRIMARY = '#FF7900'
7
- const TEXT_PRIMARY = 'var(--color-text-primary)'
8
- const TEXT_SECONDARY = 'var(--color-text-secondary)'
9
- const TEXT_TERTIARY = 'var(--color-text-tertiary)'
10
- const BORDER_STRONG = 'var(--color-border-strong)'
8
+ // Line/border color for the dashed projection, used where a className cannot
9
+ // apply (passed as a prop into an inline style, and a per-edge borderTopColor).
10
+ const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
11
11
  const STATUS_SUCCESS = '#14B8A6'
12
12
  const STATUS_ERROR = '#D14343'
13
13
  const STATUS_WARNING = '#FFB020'
14
14
  const GRID_LINE = 'rgba(255,255,255,0.06)'
15
- const TOOLTIP_BG = 'var(--color-surface-elevated)'
16
15
  const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
17
16
  const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
18
17
  const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
@@ -312,7 +311,7 @@ export function StrengthTrendChart({
312
311
  justifyContent: 'center',
313
312
  }}
314
313
  >
315
- <Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}>
314
+ <Text className="text-text-tertiary" style={{ fontSize: 12, fontFamily: 'Inter, sans-serif' }}>
316
315
  No strength data yet
317
316
  </Text>
318
317
  </View>
@@ -358,6 +357,7 @@ export function StrengthTrendChart({
358
357
  }}
359
358
  >
360
359
  <Text
360
+ className="text-text-tertiary"
361
361
  style={{
362
362
  position: 'absolute',
363
363
  left: -PLOT_LEFT,
@@ -366,7 +366,6 @@ export function StrengthTrendChart({
366
366
  textAlign: 'right',
367
367
  fontSize: 9,
368
368
  fontFamily: 'Inter, sans-serif',
369
- color: TEXT_TERTIARY,
370
369
  }}
371
370
  >
372
371
  {line.label}
@@ -406,7 +405,7 @@ export function StrengthTrendChart({
406
405
  <View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
407
406
  <ChartLine
408
407
  coords={geometry.projection}
409
- color={TEXT_TERTIARY}
408
+ color={PROJECTION_LINE_COLOR}
410
409
  strokeWidth={2}
411
410
  dashed
412
411
  testID="strength-trend-chart-projection-segment"
@@ -475,6 +474,7 @@ export function StrengthTrendChart({
475
474
  <View
476
475
  testID="strength-trend-chart-tooltip"
477
476
  accessibilityElementsHidden
477
+ className="bg-surface-elevated border-border-strong"
478
478
  style={{
479
479
  position: 'absolute',
480
480
  left: Math.max(
@@ -483,30 +483,28 @@ export function StrengthTrendChart({
483
483
  ),
484
484
  top: Math.max(0, selectedCoord.y - 52),
485
485
  maxWidth: TOOLTIP_WIDTH,
486
- backgroundColor: TOOLTIP_BG,
487
486
  borderWidth: 1,
488
- borderColor: BORDER_STRONG,
489
487
  borderRadius: 8,
490
488
  paddingVertical: 8,
491
489
  paddingHorizontal: 10,
492
490
  }}
493
491
  >
494
492
  <Text
493
+ className="text-text-tertiary"
495
494
  style={{
496
495
  fontSize: 10,
497
496
  fontFamily: 'Inter, sans-serif',
498
- color: TEXT_TERTIARY,
499
497
  }}
500
498
  >
501
499
  {selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
502
500
  </Text>
503
501
  <Text
502
+ className="text-text-primary"
504
503
  style={{
505
504
  marginTop: 2,
506
505
  fontSize: 13,
507
506
  fontFamily: 'Inter, sans-serif',
508
507
  fontWeight: '700',
509
- color: TEXT_PRIMARY,
510
508
  }}
511
509
  >
512
510
  {`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
@@ -547,6 +545,7 @@ export function StrengthTrendChart({
547
545
  <Text
548
546
  key={`axis-${i}`}
549
547
  testID="strength-trend-chart-axis-label"
548
+ className="text-text-tertiary"
550
549
  style={{
551
550
  position: 'absolute',
552
551
  left: boundary.x - 24,
@@ -554,7 +553,6 @@ export function StrengthTrendChart({
554
553
  textAlign: 'center',
555
554
  fontSize: 10,
556
555
  fontFamily: 'Inter, sans-serif',
557
- color: TEXT_TERTIARY,
558
556
  }}
559
557
  >
560
558
  {boundary.label}
@@ -598,7 +596,7 @@ export function StrengthTrendChart({
598
596
  >
599
597
  <View className="flex-row items-center" style={{ gap: 5 }}>
600
598
  <View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
601
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
599
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
602
600
  Actual
603
601
  </Text>
604
602
  </View>
@@ -609,16 +607,16 @@ export function StrengthTrendChart({
609
607
  height: 0,
610
608
  borderTopWidth: 2,
611
609
  borderStyle: 'dashed',
612
- borderTopColor: TEXT_TERTIARY,
610
+ borderTopColor: PROJECTION_LINE_COLOR,
613
611
  }}
614
612
  />
615
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
613
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
616
614
  Projected
617
615
  </Text>
618
616
  </View>
619
617
  <View className="flex-row items-center" style={{ gap: 5 }}>
620
618
  <Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
621
- <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
619
+ <Text className="text-text-secondary" style={{ fontSize: 10, fontFamily: 'Inter, sans-serif' }}>
622
620
  PR
623
621
  </Text>
624
622
  </View>
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { TempoBar } from './TempoBar'
4
+
5
+ const meta: Meta<typeof TempoBar> = {
6
+ title: 'Custom/Workout/TempoBar',
7
+ component: TempoBar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ activePhase: {
11
+ control: 'select',
12
+ options: [null, 'concentric', 'hold', 'eccentric'],
13
+ description: 'Phase currently in progress (null = idle)',
14
+ },
15
+ phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
16
+ completed: { control: 'object', description: 'Completed phase durations (ms)' },
17
+ target: { control: 'object', description: 'Per-phase target durations (seconds)' },
18
+ },
19
+ decorators: [
20
+ (Story) => (
21
+ <View style={{ width: 240 }}>
22
+ <Story />
23
+ </View>
24
+ ),
25
+ ],
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof TempoBar>
30
+
31
+ // tempo target: 1s concentric, 1s hold-top, 3s eccentric
32
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
33
+
34
+ export const Idle: Story = {
35
+ args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
36
+ }
37
+
38
+ export const ConcentricActive: Story = {
39
+ args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
40
+ }
41
+
42
+ export const EccentricMidRep: Story = {
43
+ args: {
44
+ activePhase: 'eccentric',
45
+ phaseElapsedMs: 1500,
46
+ completed: { concentric: 900, hold: 1000 },
47
+ target: TARGET,
48
+ },
49
+ }
50
+
51
+ export const BehindPace: Story = {
52
+ args: {
53
+ activePhase: 'eccentric',
54
+ phaseElapsedMs: 4200,
55
+ completed: { concentric: 1600, hold: 1000 },
56
+ target: TARGET,
57
+ },
58
+ }
59
+
60
+ export const RepComplete: Story = {
61
+ args: {
62
+ activePhase: null,
63
+ phaseElapsedMs: 0,
64
+ completed: { concentric: 950, hold: 1050, eccentric: 3100 },
65
+ target: TARGET,
66
+ },
67
+ }
68
+
69
+ export const NoTarget: Story = {
70
+ args: {
71
+ activePhase: 'concentric',
72
+ phaseElapsedMs: 800,
73
+ },
74
+ }
75
+
76
+ export const AllStates: Story = {
77
+ render: () => (
78
+ <View style={{ gap: 20 }}>
79
+ <TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
80
+ <TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
81
+ <TempoBar
82
+ activePhase="eccentric"
83
+ phaseElapsedMs={1500}
84
+ completed={{ concentric: 900, hold: 1000 }}
85
+ target={TARGET}
86
+ />
87
+ <TempoBar
88
+ activePhase={null}
89
+ phaseElapsedMs={0}
90
+ completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
91
+ target={TARGET}
92
+ />
93
+ </View>
94
+ ),
95
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import {
5
+ TempoBar,
6
+ getTempoPacingState,
7
+ getTempoFillPct,
8
+ TEMPO_PACING,
9
+ } from './TempoBar'
10
+
11
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
12
+
13
+ describe('getTempoPacingState', () => {
14
+ it('returns none when there is no target', () => {
15
+ expect(getTempoPacingState(1000, null)).toBe('none')
16
+ })
17
+
18
+ it('returns none for a sub-threshold target', () => {
19
+ expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
20
+ })
21
+
22
+ it('returns on-pace when within the behind threshold', () => {
23
+ expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
24
+ expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
25
+ })
26
+
27
+ it('returns behind once elapsed exceeds the behind threshold', () => {
28
+ expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
29
+ })
30
+ })
31
+
32
+ describe('getTempoFillPct', () => {
33
+ it('fills fully when there is no target', () => {
34
+ expect(getTempoFillPct(500, null)).toBe(100)
35
+ })
36
+
37
+ it('is proportional to elapsed vs target', () => {
38
+ expect(getTempoFillPct(500, 1000)).toBe(50)
39
+ })
40
+
41
+ it('clamps at 100 when past target', () => {
42
+ expect(getTempoFillPct(2000, 1000)).toBe(100)
43
+ })
44
+ })
45
+
46
+ describe('TempoBar', () => {
47
+ it('renders the bar with phase labels', () => {
48
+ render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
49
+ expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
50
+ expect(screen.getByText('Con')).toBeInTheDocument()
51
+ expect(screen.getByText('Hold')).toBeInTheDocument()
52
+ expect(screen.getByText('Ecc')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders the active segment with an elapsed/target label', () => {
56
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
57
+ expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
58
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
59
+ })
60
+
61
+ it('shows only elapsed when the active phase has no target', () => {
62
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
63
+ expect(screen.getByText('0.8')).toBeInTheDocument()
64
+ })
65
+
66
+ it('marks a completed phase that hit its target with a check', () => {
67
+ render(
68
+ <TempoBar
69
+ activePhase="hold"
70
+ phaseElapsedMs={100}
71
+ completed={{ concentric: 950 }}
72
+ target={TARGET}
73
+ />,
74
+ )
75
+ expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
76
+ expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
77
+ })
78
+
79
+ it('marks a completed phase that missed its target with a cross', () => {
80
+ render(
81
+ <TempoBar
82
+ activePhase="hold"
83
+ phaseElapsedMs={100}
84
+ completed={{ eccentric: 4000 }}
85
+ target={TARGET}
86
+ />,
87
+ )
88
+ // eccentric target 3s, 4s elapsed => behind => cross
89
+ expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
90
+ })
91
+
92
+ it('has no accessibility violations', async () => {
93
+ const { container } = render(
94
+ <TempoBar
95
+ activePhase="eccentric"
96
+ phaseElapsedMs={1500}
97
+ completed={{ concentric: 900, hold: 1000 }}
98
+ target={TARGET}
99
+ />,
100
+ )
101
+ expect(await axe(container)).toHaveNoViolations()
102
+ })
103
+ })