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