@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
@@ -0,0 +1,418 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useMemo, useState } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
+ import { MesoProgressBar, type Meso, type MesoStatus } from './MesoProgressBar'
5
+ import { MesoCard, type MesoVolumeHeatmapEntry } from './MesoCard'
6
+ import { type WeekRowProps, type WeekRowWorkout } from './WeekRow'
7
+ import { WorkoutCard, type WorkoutStatus, type WorkoutMuscleGroup } from './WorkoutCard'
8
+ import { type WorkoutPillStatus } from './WorkoutPill'
9
+ import { type ExerciseCardProps } from './ExerciseCard'
10
+ import { cn } from '../../../utils/cn'
11
+
12
+ const BRAND_PRIMARY = '#FF7900'
13
+
14
+ /** A single workout within a planned week, plus its exercise breakdown. */
15
+ export interface PlanWorkout {
16
+ /** Stable identifier used for drill-down selection. */
17
+ id: string
18
+ name: string
19
+ date: string
20
+ duration?: string
21
+ status: WorkoutStatus
22
+ muscleGroups: WorkoutMuscleGroup[]
23
+ totalSets: number
24
+ totalVolume?: number
25
+ unit: 'lbs' | 'kg'
26
+ /** Exercises revealed when the workout is opened. */
27
+ exercises: ExerciseCardProps[]
28
+ }
29
+
30
+ /** A training week inside a mesocycle. */
31
+ export interface PlanWeek {
32
+ /** 1-based week index within the mesocycle. */
33
+ weekNumber: number
34
+ /** Planned intensity for the week, 0-1. */
35
+ intensityLevel: number
36
+ /** Optional MRV/overexertion threshold, 0-1. */
37
+ intensityThreshold?: number
38
+ isDeload?: boolean
39
+ isCurrent?: boolean
40
+ workouts: PlanWorkout[]
41
+ }
42
+
43
+ /** A mesocycle: the top level of the drill-down. */
44
+ export interface PlanMeso {
45
+ id: string
46
+ name: string
47
+ goal: string
48
+ split: string
49
+ weekRange: string
50
+ weekCount: number
51
+ currentWeek?: number
52
+ status: MesoStatus
53
+ volumeHeatmap?: MesoVolumeHeatmapEntry[]
54
+ weeks: PlanWeek[]
55
+ }
56
+
57
+ /** Current depth of the meso -> week -> workout drill-down. */
58
+ export type ProgramNavLevel = 'meso' | 'week' | 'workout'
59
+
60
+ /** The navigation cursor within the active mesocycle. */
61
+ export interface ProgramSelection {
62
+ weekNumber: number | null
63
+ workoutId: string | null
64
+ }
65
+
66
+ /** One tappable step in the breadcrumb trail. */
67
+ export interface ProgramBreadcrumb {
68
+ key: string
69
+ label: string
70
+ level: ProgramNavLevel
71
+ }
72
+
73
+ export interface ProgramPlanningPageProps extends ViewProps {
74
+ /** Page heading. */
75
+ title?: string
76
+ /** Mesocycles that make up the program. */
77
+ mesos: PlanMeso[]
78
+ className?: string
79
+ }
80
+
81
+ /** Map a workout's lifecycle status onto a WorkoutPill status. */
82
+ const WORKOUT_PILL_STATUS: Record<WorkoutStatus, WorkoutPillStatus> = {
83
+ completed: 'completed',
84
+ today: 'current',
85
+ upcoming: 'upcoming',
86
+ }
87
+
88
+ /** Derive the current drill-down level from the selection cursor (pure). */
89
+ export function deriveNavLevel(selection: ProgramSelection): ProgramNavLevel {
90
+ if (selection.workoutId != null) return 'workout'
91
+ if (selection.weekNumber != null) return 'week'
92
+ return 'meso'
93
+ }
94
+
95
+ /** Project the mesocycles onto MesoProgressBar segment data (pure). */
96
+ export function toProgressBarMesos(mesos: PlanMeso[]): Meso[] {
97
+ return mesos.map(({ id, name, weekCount, status, currentWeek }) => ({
98
+ id,
99
+ name,
100
+ weekCount,
101
+ status,
102
+ currentWeek,
103
+ }))
104
+ }
105
+
106
+ /** Look up a meso by id, or null. */
107
+ export function findMeso(mesos: PlanMeso[], id: string | null): PlanMeso | null {
108
+ if (id == null) return null
109
+ return mesos.find((meso) => meso.id === id) ?? null
110
+ }
111
+
112
+ /** Look up a week within a meso, or null. */
113
+ export function findWeek(meso: PlanMeso | null, weekNumber: number | null): PlanWeek | null {
114
+ if (meso == null || weekNumber == null) return null
115
+ return meso.weeks.find((week) => week.weekNumber === weekNumber) ?? null
116
+ }
117
+
118
+ /** Look up a workout within a week, or null. */
119
+ export function findWorkout(week: PlanWeek | null, workoutId: string | null): PlanWorkout | null {
120
+ if (week == null || workoutId == null) return null
121
+ return week.workouts.find((workout) => workout.id === workoutId) ?? null
122
+ }
123
+
124
+ /** Build the breadcrumb trail for the active drill path (pure). */
125
+ export function buildBreadcrumbs(
126
+ meso: PlanMeso,
127
+ weekNumber: number | null,
128
+ workout: PlanWorkout | null
129
+ ): ProgramBreadcrumb[] {
130
+ const crumbs: ProgramBreadcrumb[] = [{ key: 'meso', label: meso.name, level: 'meso' }]
131
+ if (weekNumber != null) {
132
+ crumbs.push({ key: 'week', label: `Week ${weekNumber}`, level: 'week' })
133
+ }
134
+ if (workout != null) {
135
+ crumbs.push({ key: 'workout', label: workout.name, level: 'workout' })
136
+ }
137
+ return crumbs
138
+ }
139
+
140
+ interface BreadcrumbsProps {
141
+ crumbs: ProgramBreadcrumb[]
142
+ onNavigate: (level: ProgramNavLevel) => void
143
+ }
144
+
145
+ function Breadcrumbs({ crumbs, onNavigate }: BreadcrumbsProps) {
146
+ return (
147
+ <View
148
+ className="flex-row items-center flex-wrap"
149
+ style={{ gap: 4 }}
150
+ testID="program-planning-page-breadcrumbs"
151
+ >
152
+ {crumbs.map((crumb, index) => {
153
+ const isLast = index === crumbs.length - 1
154
+ return (
155
+ <View key={crumb.key} className="flex-row items-center" style={{ gap: 4 }}>
156
+ {index > 0 && <Text className="text-text-tertiary" style={{ fontSize: 12 }}>{'›'}</Text>}
157
+ <Pressable
158
+ onPress={() => onNavigate(crumb.level)}
159
+ disabled={isLast}
160
+ accessibilityRole="button"
161
+ testID={`program-planning-page-crumb-${crumb.key}`}
162
+ >
163
+ <Text
164
+ className={isLast ? 'text-text-primary' : undefined}
165
+ style={{
166
+ fontSize: 12,
167
+ fontFamily: 'Inter, sans-serif',
168
+ fontWeight: isLast ? '700' : '500',
169
+ color: isLast ? undefined : BRAND_PRIMARY,
170
+ }}
171
+ >
172
+ {crumb.label}
173
+ </Text>
174
+ </Pressable>
175
+ </View>
176
+ )
177
+ })}
178
+ </View>
179
+ )
180
+ }
181
+
182
+ /** Project a plan week onto WeekRow props, wiring pill taps to week selection. */
183
+ function toWeekRow(
184
+ week: PlanWeek,
185
+ meso: PlanMeso,
186
+ onSelectWeek: (weekNumber: number) => void
187
+ ): WeekRowProps {
188
+ const workouts: WeekRowWorkout[] = week.workouts.map((workout) => ({
189
+ name: workout.name,
190
+ status: WORKOUT_PILL_STATUS[workout.status],
191
+ onPress: () => onSelectWeek(week.weekNumber),
192
+ }))
193
+ return {
194
+ weekNumber: week.weekNumber,
195
+ totalWeeks: meso.weekCount,
196
+ workouts,
197
+ intensityLevel: week.intensityLevel,
198
+ intensityThreshold: week.intensityThreshold,
199
+ isCurrent: week.isCurrent ?? week.weekNumber === meso.currentWeek,
200
+ isDeload: week.isDeload,
201
+ }
202
+ }
203
+
204
+ interface MesoLevelProps {
205
+ mesos: PlanMeso[]
206
+ activeMesoId: string | null
207
+ onSelectMeso: (id: string) => void
208
+ onSelectWeek: (weekNumber: number) => void
209
+ }
210
+
211
+ function MesoLevel({ mesos, activeMesoId, onSelectMeso, onSelectWeek }: MesoLevelProps) {
212
+ return (
213
+ <View style={{ gap: 12 }} testID="program-planning-page-meso-level">
214
+ {mesos.map((meso) => {
215
+ const expanded = meso.id === activeMesoId
216
+ return (
217
+ <MesoCard
218
+ key={meso.id}
219
+ name={meso.name}
220
+ goal={meso.goal}
221
+ split={meso.split}
222
+ weekRange={meso.weekRange}
223
+ currentWeek={meso.currentWeek}
224
+ totalWeeks={meso.weekCount}
225
+ weeks={expanded ? meso.weeks.map((week) => toWeekRow(week, meso, onSelectWeek)) : []}
226
+ volumeHeatmap={meso.volumeHeatmap}
227
+ expanded={expanded}
228
+ onToggle={() => onSelectMeso(meso.id)}
229
+ highlighted={expanded}
230
+ />
231
+ )
232
+ })}
233
+ </View>
234
+ )
235
+ }
236
+
237
+ interface WeekLevelProps {
238
+ week: PlanWeek
239
+ onSelectWorkout: (id: string) => void
240
+ }
241
+
242
+ function WeekLevel({ week, onSelectWorkout }: WeekLevelProps) {
243
+ return (
244
+ <View style={{ gap: 10 }} testID="program-planning-page-week-level">
245
+ {week.workouts.map((workout) => (
246
+ <WorkoutCard
247
+ key={workout.id}
248
+ name={workout.name}
249
+ date={workout.date}
250
+ duration={workout.duration}
251
+ status={workout.status}
252
+ muscleGroups={workout.muscleGroups}
253
+ totalSets={workout.totalSets}
254
+ totalVolume={workout.totalVolume}
255
+ unit={workout.unit}
256
+ exercises={workout.exercises}
257
+ expanded={false}
258
+ onToggle={() => onSelectWorkout(workout.id)}
259
+ />
260
+ ))}
261
+ </View>
262
+ )
263
+ }
264
+
265
+ interface WorkoutLevelProps {
266
+ workout: PlanWorkout
267
+ expandedExercise: number | null
268
+ onToggleExercise: (index: number) => void
269
+ }
270
+
271
+ function WorkoutLevel({ workout, expandedExercise, onToggleExercise }: WorkoutLevelProps) {
272
+ const exercises = workout.exercises.map((exercise, index) => ({
273
+ ...exercise,
274
+ state:
275
+ exercise.state === 'upcoming'
276
+ ? exercise.state
277
+ : ((index === expandedExercise ? 'expanded' : 'collapsed') as ExerciseCardProps['state']),
278
+ onToggle: () => onToggleExercise(index),
279
+ }))
280
+ return (
281
+ <View testID="program-planning-page-workout-level">
282
+ <WorkoutCard
283
+ name={workout.name}
284
+ date={workout.date}
285
+ duration={workout.duration}
286
+ status={workout.status}
287
+ muscleGroups={workout.muscleGroups}
288
+ totalSets={workout.totalSets}
289
+ totalVolume={workout.totalVolume}
290
+ unit={workout.unit}
291
+ exercises={exercises}
292
+ expanded
293
+ onToggle={() => undefined}
294
+ />
295
+ </View>
296
+ )
297
+ }
298
+
299
+ /**
300
+ * ProgramPlanningPage — a meso -> week -> workout drill-down over an entire
301
+ * training program. A `MesoProgressBar` pins the mesocycle timeline to the top;
302
+ * the body swaps between three levels: expandable `MesoCard`s (with inline
303
+ * `WeekRow`s), a week's `WorkoutCard` list, and a single opened `WorkoutCard`
304
+ * with inline `ExerciseCard` expansion. A breadcrumb trail tracks and rewinds
305
+ * the drill path. Selection is page-level state; children keep their own styles.
306
+ *
307
+ * @example
308
+ * <ProgramPlanningPage mesos={mesos} />
309
+ */
310
+ export function ProgramPlanningPage({
311
+ title = 'Program Plan',
312
+ mesos,
313
+ className,
314
+ ...props
315
+ }: ProgramPlanningPageProps) {
316
+ const [activeMesoId, setActiveMesoId] = useState<string | null>(mesos[0]?.id ?? null)
317
+ const [selectedWeek, setSelectedWeek] = useState<number | null>(null)
318
+ const [selectedWorkoutId, setSelectedWorkoutId] = useState<string | null>(null)
319
+ const [expandedExercise, setExpandedExercise] = useState<number | null>(null)
320
+
321
+ const progressMesos = useMemo(() => toProgressBarMesos(mesos), [mesos])
322
+ const activeMeso = findMeso(mesos, activeMesoId)
323
+ const level = deriveNavLevel({ weekNumber: selectedWeek, workoutId: selectedWorkoutId })
324
+ const activeWeek = findWeek(activeMeso, selectedWeek)
325
+ const activeWorkout = findWorkout(activeWeek, selectedWorkoutId)
326
+
327
+ const selectMeso = (id: string) => {
328
+ setActiveMesoId(id)
329
+ setSelectedWeek(null)
330
+ setSelectedWorkoutId(null)
331
+ }
332
+ const selectWeek = (weekNumber: number) => {
333
+ setSelectedWeek(weekNumber)
334
+ setSelectedWorkoutId(null)
335
+ setExpandedExercise(null)
336
+ }
337
+ const selectWorkout = (id: string) => {
338
+ setSelectedWorkoutId(id)
339
+ setExpandedExercise(null)
340
+ }
341
+ const toggleExercise = (index: number) =>
342
+ setExpandedExercise((current) => (current === index ? null : index))
343
+
344
+ const navigateTo = (target: ProgramNavLevel) => {
345
+ if (target === 'meso') selectMeso(activeMesoId ?? mesos[0]?.id)
346
+ else if (target === 'week' && selectedWeek != null) selectWeek(selectedWeek)
347
+ }
348
+
349
+ return (
350
+ <View
351
+ className={cn(className, 'bg-background-base')}
352
+ style={{ width: 390 }}
353
+ accessibilityRole={'main' as ViewProps['accessibilityRole']}
354
+ aria-label={title}
355
+ testID="program-planning-page"
356
+ {...props}
357
+ >
358
+ <View style={{ padding: 16, gap: 14 }} testID="program-planning-page-content">
359
+ <Text
360
+ accessibilityRole="header"
361
+ className="text-text-primary"
362
+ style={{
363
+ fontSize: 20,
364
+ fontFamily: '"Space Grotesk", sans-serif',
365
+ fontWeight: '700',
366
+ }}
367
+ testID="program-planning-page-title"
368
+ >
369
+ {title}
370
+ </Text>
371
+
372
+ <View style={{ marginHorizontal: -16 }}>
373
+ <MesoProgressBar
374
+ mesos={progressMesos}
375
+ activeMesoId={activeMesoId}
376
+ onMesoPress={selectMeso}
377
+ />
378
+ </View>
379
+
380
+ {activeMeso != null && (
381
+ <Breadcrumbs
382
+ crumbs={buildBreadcrumbs(activeMeso, selectedWeek, activeWorkout)}
383
+ onNavigate={navigateTo}
384
+ />
385
+ )}
386
+
387
+ <View
388
+ className="bg-surface-elevated border-border"
389
+ style={{
390
+ borderWidth: 1,
391
+ borderRadius: 12,
392
+ padding: 12,
393
+ }}
394
+ testID="program-planning-page-body"
395
+ >
396
+ {level === 'meso' && (
397
+ <MesoLevel
398
+ mesos={mesos}
399
+ activeMesoId={activeMesoId}
400
+ onSelectMeso={selectMeso}
401
+ onSelectWeek={selectWeek}
402
+ />
403
+ )}
404
+ {level === 'week' && activeWeek != null && (
405
+ <WeekLevel week={activeWeek} onSelectWorkout={selectWorkout} />
406
+ )}
407
+ {level === 'workout' && activeWorkout != null && (
408
+ <WorkoutLevel
409
+ workout={activeWorkout}
410
+ expandedExercise={expandedExercise}
411
+ onToggleExercise={toggleExercise}
412
+ />
413
+ )}
414
+ </View>
415
+ </View>
416
+ </View>
417
+ )
418
+ }
@@ -2,15 +2,12 @@
2
2
  import { View, Text, Pressable } from 'react-native'
3
3
  import { Card } from '../../ui/card'
4
4
  import { Badge, type BadgeColor } from '../../ui/badge'
5
- import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
5
 
7
6
  const BRAND_PRIMARY = '#FF7900'
8
7
  const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
9
8
  const STATUS_SUCCESS = '#14B8A6'
10
9
  const STATUS_WARNING = '#FFB020'
11
10
  const STATUS_ERROR = '#D14343'
12
- const TEXT_PRIMARY = '#F3F4F6'
13
- const TEXT_SECONDARY = '#9CA3AF'
14
11
 
15
12
  /** Emoji option sets per known factor, keyed by lowercase id/label. */
16
13
  const EMOJI_SETS: Record<string, readonly string[]> = {
@@ -92,7 +89,8 @@ function EmojiSlider({ factor }: EmojiSliderProps) {
92
89
  return (
93
90
  <View style={{ marginTop: 14 }} testID={`readiness-check-factor-${factor.id}`}>
94
91
  <Text
95
- style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
92
+ className="text-text-secondary"
93
+ style={{ fontSize: 12, fontWeight: '500', fontFamily: 'Inter, sans-serif' }}
96
94
  testID="readiness-check-factor-label"
97
95
  >
98
96
  {factor.label}
@@ -115,14 +113,15 @@ function EmojiSlider({ factor }: EmojiSliderProps) {
115
113
  accessibilityLabel={`${factor.label} level ${level} of 5`}
116
114
  aria-checked={selected}
117
115
  testID="readiness-check-emoji"
116
+ className={selected ? undefined : 'border-border bg-surface-raised'}
118
117
  style={({ pressed }) => ({
119
118
  flex: 1,
120
119
  alignItems: 'center',
121
120
  paddingVertical: 8,
122
121
  borderRadius: 8,
123
122
  borderWidth: 1,
124
- borderColor: selected ? BRAND_PRIMARY : WORKOUT_TOKENS.border.default,
125
- backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : WORKOUT_TOKENS.surface.raised,
123
+ borderColor: selected ? BRAND_PRIMARY : undefined,
124
+ backgroundColor: selected ? BRAND_PRIMARY_SUBTLE : undefined,
126
125
  opacity: selected ? 1 : pressed ? 0.8 : 0.55,
127
126
  transform: [{ scale: selected ? 1.06 : 1 }],
128
127
  })}
@@ -144,13 +143,13 @@ function ScoreGauge({ score }: { score: number }) {
144
143
  accessibilityRole="image"
145
144
  accessibilityLabel={`Readiness score: ${score} out of 100`}
146
145
  testID="readiness-check-score"
146
+ className="bg-surface-raised"
147
147
  style={{
148
148
  width: 60,
149
149
  height: 60,
150
150
  borderRadius: 30,
151
151
  borderWidth: 4,
152
152
  borderColor: color,
153
- backgroundColor: WORKOUT_TOKENS.surface.raised,
154
153
  alignItems: 'center',
155
154
  justifyContent: 'center',
156
155
  }}
@@ -170,19 +169,19 @@ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
170
169
  const badge = WARMUP_BADGE[validation.status]
171
170
  return (
172
171
  <View
172
+ className="border-border bg-surface-raised"
173
173
  style={{
174
174
  marginTop: 16,
175
175
  padding: 12,
176
176
  borderRadius: 8,
177
177
  borderWidth: 1,
178
- borderColor: WORKOUT_TOKENS.border.default,
179
- backgroundColor: WORKOUT_TOKENS.surface.raised,
180
178
  }}
181
179
  testID="readiness-check-warmup"
182
180
  >
183
181
  <View className="flex-row items-center justify-between" style={{ gap: 8 }}>
184
182
  <Text
185
- style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}
183
+ className="text-text-secondary"
184
+ style={{ fontSize: 12, fontWeight: '600', fontFamily: 'Inter, sans-serif' }}
186
185
  testID="readiness-check-warmup-deficit"
187
186
  >
188
187
  {velocityDeficitText(validation.velocityDeficit)}
@@ -192,7 +191,8 @@ function WarmUpCard({ validation }: { validation: WarmUpValidation }) {
192
191
  </Badge>
193
192
  </View>
194
193
  <Text
195
- style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif', color: TEXT_PRIMARY }}
194
+ className="text-text-primary"
195
+ style={{ marginTop: 6, fontSize: 13, fontFamily: 'Inter, sans-serif' }}
196
196
  testID="readiness-check-warmup-recommendation"
197
197
  >
198
198
  {validation.recommendation}
@@ -227,7 +227,8 @@ export function ReadinessCheck({
227
227
  <View style={{ padding: 16 }}>
228
228
  <View className="flex-row items-center justify-between" style={{ gap: 12 }}>
229
229
  <Text
230
- style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif', color: TEXT_PRIMARY }}
230
+ className="text-text-primary"
231
+ style={{ fontSize: 18, fontWeight: '700', fontFamily: '"Space Grotesk", sans-serif' }}
231
232
  testID="readiness-check-title"
232
233
  >
233
234
  How are you feeling?
@@ -143,3 +143,13 @@ describe('RestTimer', () => {
143
143
  })
144
144
  })
145
145
  })
146
+
147
+ describe('RestTimer zero-duration timer (NaN guard)', () => {
148
+ it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
149
+ const { container } = render(
150
+ <RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
151
+ )
152
+ expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
153
+ expect(container.innerHTML).not.toContain('NaN')
154
+ })
155
+ })
@@ -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, Pressable } from 'react-native'
3
- import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
3
+ import { resolveColor } from '../../../theme/resolve-color'
4
4
 
5
5
  const BRAND_PRIMARY = '#FF7900'
6
6
 
@@ -28,15 +28,18 @@ export function RestTimer({
28
28
  const minutes = Math.floor(remainingSec / 60)
29
29
  const seconds = remainingSec % 60
30
30
  const timeDisplay = `${minutes}:${String(seconds).padStart(2, '0')}`
31
- const progressPct = Math.min(100, (elapsedMs / (totalSeconds * 1000)) * 100)
31
+ // A zero-duration timer is already complete; guard the 0 / 0 === NaN case
32
+ // that would otherwise emit width:'NaN%'.
33
+ const totalMs = totalSeconds * 1000
34
+ const progressPct = totalMs > 0 ? Math.min(100, (elapsedMs / totalMs) * 100) : 100
32
35
 
33
36
  return (
34
37
  <View
38
+ className="bg-surface-raised"
35
39
  style={{
36
40
  width: '100%',
37
- backgroundColor: WORKOUT_TOKENS.surface.raised,
38
41
  borderTopWidth: 1,
39
- borderTopColor: WORKOUT_TOKENS.border.default,
42
+ borderTopColor: resolveColor('border-default'),
40
43
  paddingVertical: 12,
41
44
  paddingHorizontal: 16,
42
45
  }}
@@ -56,13 +59,13 @@ export function RestTimer({
56
59
  {/* Left side */}
57
60
  <View style={{ flexDirection: 'column' }}>
58
61
  <Text
62
+ className="text-text-secondary"
59
63
  style={{
60
64
  fontSize: 11,
61
65
  fontFamily: 'Inter, sans-serif',
62
66
  fontWeight: '600',
63
67
  textTransform: 'uppercase',
64
68
  letterSpacing: 1,
65
- color: '#9CA3AF',
66
69
  }}
67
70
  testID="rest-timer-label"
68
71
  >
@@ -70,10 +73,10 @@ export function RestTimer({
70
73
  </Text>
71
74
  {nextSetInfo != null && (
72
75
  <Text
76
+ className="text-text-tertiary"
73
77
  style={{
74
78
  fontSize: 11,
75
79
  fontFamily: 'Inter, sans-serif',
76
- color: '#6B7280',
77
80
  marginTop: 2,
78
81
  }}
79
82
  testID="rest-timer-next-set"
@@ -85,11 +88,11 @@ export function RestTimer({
85
88
 
86
89
  {/* Right side - time display */}
87
90
  <Text
91
+ className="text-text-primary"
88
92
  style={{
89
93
  fontSize: 28,
90
94
  fontFamily: '"Space Grotesk", sans-serif',
91
95
  fontWeight: '700',
92
- color: '#F3F4F6',
93
96
  fontVariant: ['tabular-nums'],
94
97
  letterSpacing: -0.5,
95
98
  }}
@@ -101,9 +104,9 @@ export function RestTimer({
101
104
 
102
105
  {/* Progress bar */}
103
106
  <View
107
+ className="bg-border"
104
108
  style={{
105
109
  height: 3,
106
- backgroundColor: WORKOUT_TOKENS.border.default,
107
110
  borderRadius: 2,
108
111
  marginBottom: 12,
109
112
  }}
@@ -135,11 +138,11 @@ export function RestTimer({
135
138
  testID="rest-timer-add-time"
136
139
  >
137
140
  <Text
141
+ className="text-text-secondary"
138
142
  style={{
139
143
  fontSize: 11,
140
144
  fontFamily: 'Inter, sans-serif',
141
145
  fontWeight: '600',
142
- color: '#9CA3AF',
143
146
  }}
144
147
  >
145
148
  +30s