@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,171 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import {
4
+ ActiveWorkoutPage,
5
+ countCompletedSets,
6
+ deriveWorkoutProgress,
7
+ findActiveExercise,
8
+ statusToCardState,
9
+ groupExercises,
10
+ type ActiveWorkoutExercise,
11
+ type ActiveWorkoutPageProps,
12
+ } from './ActiveWorkoutPage'
13
+
14
+ const exercises: ActiveWorkoutExercise[] = [
15
+ {
16
+ id: 'warmup',
17
+ name: 'Band Pull-Apart',
18
+ status: 'completed',
19
+ unit: 'lbs',
20
+ totalPlannedSets: 2,
21
+ summary: { sets: 2, reps: 15, weight: 0, unit: 'lbs' },
22
+ setVelocities: [[0.9], [0.88]],
23
+ },
24
+ {
25
+ id: 'bench',
26
+ name: 'Barbell Bench Press',
27
+ status: 'active',
28
+ unit: 'lbs',
29
+ totalPlannedSets: 4,
30
+ setVelocities: [[0.72], [0.68]],
31
+ sets: [
32
+ { mode: 'completed', setNumber: 1, reps: 8, weight: 195, rpe: 8, unit: 'lbs' },
33
+ { mode: 'active', setNumber: 3, reps: null, weight: null, unit: 'lbs', isNextSet: true },
34
+ ],
35
+ },
36
+ {
37
+ id: 'incline',
38
+ name: 'Incline DB Press',
39
+ status: 'upcoming',
40
+ unit: 'lbs',
41
+ totalPlannedSets: 3,
42
+ prescription: '3 × 10',
43
+ supersetId: 'ss1',
44
+ },
45
+ {
46
+ id: 'lateral',
47
+ name: 'Cable Lateral Raise',
48
+ status: 'upcoming',
49
+ unit: 'lbs',
50
+ totalPlannedSets: 3,
51
+ prescription: '3 × 15',
52
+ supersetId: 'ss1',
53
+ },
54
+ ]
55
+
56
+ const baseProps: ActiveWorkoutPageProps = {
57
+ title: 'Push Day A',
58
+ subtitle: 'Week 6 of 8',
59
+ exercises,
60
+ supersets: [{ id: 'ss1', label: 'SS1', color: '#22D3EE' }],
61
+ input: { reps: '8', weight: '195' },
62
+ rest: { totalSeconds: 120, elapsedMs: 30_000, nextSetInfo: 'Set 3' },
63
+ }
64
+
65
+ describe('countCompletedSets', () => {
66
+ it('uses logged velocities when present', () => {
67
+ expect(countCompletedSets(exercises[1])).toBe(2)
68
+ })
69
+
70
+ it('falls back to the full plan for a completed exercise without velocities', () => {
71
+ expect(countCompletedSets({ ...exercises[0], setVelocities: undefined })).toBe(2)
72
+ })
73
+
74
+ it('counts zero for an upcoming exercise', () => {
75
+ expect(countCompletedSets(exercises[2])).toBe(0)
76
+ })
77
+ })
78
+
79
+ describe('deriveWorkoutProgress', () => {
80
+ it('rolls up completed exercises, sets, and the fraction', () => {
81
+ const progress = deriveWorkoutProgress(exercises)
82
+ expect(progress.completedExercises).toBe(1)
83
+ expect(progress.totalExercises).toBe(4)
84
+ expect(progress.completedSets).toBe(4)
85
+ expect(progress.totalSets).toBe(12)
86
+ expect(progress.fraction).toBeCloseTo(1 / 3)
87
+ })
88
+
89
+ it('is zeroed for an empty workout', () => {
90
+ expect(deriveWorkoutProgress([])).toEqual({
91
+ completedExercises: 0,
92
+ totalExercises: 0,
93
+ completedSets: 0,
94
+ totalSets: 0,
95
+ fraction: 0,
96
+ })
97
+ })
98
+ })
99
+
100
+ describe('findActiveExercise', () => {
101
+ it('returns the active exercise', () => {
102
+ expect(findActiveExercise(exercises)?.id).toBe('bench')
103
+ })
104
+
105
+ it('returns null when none are active', () => {
106
+ expect(findActiveExercise([exercises[0]])).toBeNull()
107
+ })
108
+ })
109
+
110
+ describe('statusToCardState', () => {
111
+ it('keeps upcoming exercises upcoming regardless of focus', () => {
112
+ expect(statusToCardState('upcoming', true)).toBe('upcoming')
113
+ expect(statusToCardState('upcoming', false)).toBe('upcoming')
114
+ })
115
+
116
+ it('expands only the focused active/completed exercise', () => {
117
+ expect(statusToCardState('active', true)).toBe('expanded')
118
+ expect(statusToCardState('active', false)).toBe('collapsed')
119
+ expect(statusToCardState('completed', true)).toBe('expanded')
120
+ })
121
+ })
122
+
123
+ describe('groupExercises', () => {
124
+ it('folds consecutive same-superset exercises into one group', () => {
125
+ const groups = groupExercises(exercises, baseProps.supersets ?? [])
126
+ expect(groups).toHaveLength(3)
127
+ expect(groups[0]).toEqual({ type: 'single', exercise: exercises[0] })
128
+ expect(groups[2].type).toBe('superset')
129
+ if (groups[2].type === 'superset') {
130
+ expect(groups[2].exercises).toHaveLength(2)
131
+ expect(groups[2].superset.label).toBe('SS1')
132
+ }
133
+ })
134
+ })
135
+
136
+ describe('ActiveWorkoutPage', () => {
137
+ it('renders the header, progress, and the focused active exercise expanded', () => {
138
+ render(<ActiveWorkoutPage {...baseProps} />)
139
+ expect(screen.getByTestId('active-workout-page')).toBeInTheDocument()
140
+ expect(screen.getByTestId('active-workout-page-title')).toHaveTextContent('Push Day A')
141
+ expect(screen.getByTestId('active-workout-page-progress-count')).toHaveTextContent('1/4')
142
+ expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
143
+ expect(screen.getByTestId('superset-wrapper')).toBeInTheDocument()
144
+ })
145
+
146
+ it('shows the input bar and hides it once a set is recorded (starts rest)', () => {
147
+ render(<ActiveWorkoutPage {...baseProps} />)
148
+ expect(screen.getByTestId('input-bar')).toBeInTheDocument()
149
+ expect(screen.getByTestId('input-bar-set-info')).toHaveTextContent('Set 3/4')
150
+
151
+ fireEvent.click(screen.getByTestId('input-bar-record'))
152
+ expect(screen.queryByTestId('input-bar')).not.toBeInTheDocument()
153
+ expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
154
+ })
155
+
156
+ it('zoom-toggles focus: collapsing the active card hides its sets', () => {
157
+ render(<ActiveWorkoutPage {...baseProps} />)
158
+ expect(screen.getByTestId('exercise-card-sets')).toBeInTheDocument()
159
+
160
+ fireEvent.click(screen.getAllByTestId('exercise-card-header')[0])
161
+ expect(screen.queryByTestId('exercise-card-sets')).not.toBeInTheDocument()
162
+ })
163
+
164
+ it('skipping rest restores the input bar', () => {
165
+ render(<ActiveWorkoutPage {...baseProps} initialResting />)
166
+ expect(screen.getByTestId('rest-timer')).toBeInTheDocument()
167
+
168
+ fireEvent.click(screen.getByTestId('rest-timer-skip'))
169
+ expect(screen.getByTestId('input-bar')).toBeInTheDocument()
170
+ })
171
+ })
@@ -0,0 +1,442 @@
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, type ViewProps } from 'react-native'
4
+ import { ExerciseCard, type ExerciseCardProps, type ExerciseCardState } from './ExerciseCard'
5
+ import { InputBar } from './InputBar'
6
+ import { RestTimer } from './RestTimer'
7
+ import { SupersetWrapper } from './SupersetWrapper'
8
+ import { type SetRowProps } from './SetRow'
9
+ import { resolveColor } from '../../../theme/resolve-color'
10
+
11
+ const BRAND_PRIMARY = '#FF7900'
12
+
13
+ /** Where an exercise sits in the during-workout flow. */
14
+ export type ActiveExerciseStatus = 'completed' | 'active' | 'upcoming'
15
+
16
+ /**
17
+ * One exercise in the running workout. Projected onto an `ExerciseCard`, whose
18
+ * visual state is driven by `status` and the page's zoom focus: completed and
19
+ * active exercises collapse/expand, upcoming ones stay dimmed.
20
+ */
21
+ export interface ActiveWorkoutExercise {
22
+ /** Stable id for zoom-focus selection. */
23
+ id: string
24
+ /** Exercise name, e.g. "Barbell Bench Press". */
25
+ name: string
26
+ /** Position in the workout flow. */
27
+ status: ActiveExerciseStatus
28
+ /** Unit used for this exercise's loads. */
29
+ unit: 'lbs' | 'kg'
30
+ /** Total sets prescribed for this exercise. */
31
+ totalPlannedSets: number
32
+ /** Collapsed summary (sets × reps @ weight) for a completed exercise. */
33
+ summary?: ExerciseCardProps['summary']
34
+ /** Estimated one-rep max badge. */
35
+ e1rm?: ExerciseCardProps['e1rm']
36
+ /** Flags a personal-record exercise. */
37
+ isPR?: boolean
38
+ /** Prescribed tempo, revealed when expanded. */
39
+ tempo?: ExerciseCardProps['tempo']
40
+ /** Per-rep velocities for each logged set (drives collapsed strips + set count). */
41
+ setVelocities?: number[][]
42
+ /** Per-set breakdown revealed when expanded. */
43
+ sets?: SetRowProps[]
44
+ /** One-line prescription for an upcoming exercise. */
45
+ prescription?: string
46
+ /** Previous-best hint for an upcoming exercise. */
47
+ previousBest?: string
48
+ /** Groups consecutive exercises under a shared `SupersetWrapper`. */
49
+ supersetId?: string
50
+ }
51
+
52
+ /** A superset grouping consecutive exercises that share its id. */
53
+ export interface ActiveWorkoutSuperset {
54
+ /** Matches `ActiveWorkoutExercise.supersetId`. */
55
+ id: string
56
+ /** Corner badge label, e.g. "SS1". */
57
+ label: string
58
+ /** Accent color for the wrapper rail + label. */
59
+ color?: string
60
+ }
61
+
62
+ /** Live-set inputs shown in the bottom `InputBar`. */
63
+ export interface ActiveWorkoutInput {
64
+ /** Current reps field value. */
65
+ reps: string
66
+ /** Current weight field value. */
67
+ weight: string
68
+ }
69
+
70
+ /** Rest-timer inputs shown in the bottom `RestTimer`. */
71
+ export interface ActiveWorkoutRest {
72
+ /** Prescribed rest length, seconds. */
73
+ totalSeconds: number
74
+ /** Elapsed rest, milliseconds. */
75
+ elapsedMs: number
76
+ /** Next-set hint, e.g. "Set 3 · 8 × 195 lbs". */
77
+ nextSetInfo?: string
78
+ }
79
+
80
+ /** Derived, page-level roll-up of workout progress. */
81
+ export interface WorkoutProgress {
82
+ /** Number of exercises marked completed. */
83
+ completedExercises: number
84
+ /** Total exercises in the workout. */
85
+ totalExercises: number
86
+ /** Sets logged so far across all exercises. */
87
+ completedSets: number
88
+ /** Total sets prescribed across all exercises. */
89
+ totalSets: number
90
+ /** Fraction of prescribed sets logged, 0-1. */
91
+ fraction: number
92
+ }
93
+
94
+ /** An ordered render group: a lone exercise or a superset of exercises. */
95
+ export type WorkoutGroup =
96
+ | { type: 'single'; exercise: ActiveWorkoutExercise }
97
+ | { type: 'superset'; superset: ActiveWorkoutSuperset; exercises: ActiveWorkoutExercise[] }
98
+
99
+ export interface ActiveWorkoutPageProps extends ViewProps {
100
+ /** Workout name shown in the header. */
101
+ title?: string
102
+ /** Context line under the title, e.g. "Push Day A · Week 6". */
103
+ subtitle?: string
104
+ /** Exercises in flow order. */
105
+ exercises: ActiveWorkoutExercise[]
106
+ /** Superset groupings referenced by `exercise.supersetId`. */
107
+ supersets?: ActiveWorkoutSuperset[]
108
+ /** Seed reps/weight for the live `InputBar`. */
109
+ input?: ActiveWorkoutInput
110
+ /** Rest-timer state; when resting, the timer replaces the input bar. */
111
+ rest?: ActiveWorkoutRest
112
+ /** Start the page in the resting state (timer showing). */
113
+ initialResting?: boolean
114
+ className?: string
115
+ }
116
+
117
+ /** Count the sets logged for an exercise (pure). */
118
+ export function countCompletedSets(exercise: ActiveWorkoutExercise): number {
119
+ if (exercise.setVelocities != null) return exercise.setVelocities.length
120
+ if (exercise.status === 'completed') return exercise.totalPlannedSets
121
+ return 0
122
+ }
123
+
124
+ /** Roll up the exercises into the header progress readout (pure, testable). */
125
+ export function deriveWorkoutProgress(exercises: ActiveWorkoutExercise[]): WorkoutProgress {
126
+ let completedExercises = 0
127
+ let completedSets = 0
128
+ let totalSets = 0
129
+ for (const exercise of exercises) {
130
+ if (exercise.status === 'completed') completedExercises += 1
131
+ completedSets += countCompletedSets(exercise)
132
+ totalSets += exercise.totalPlannedSets
133
+ }
134
+ return {
135
+ completedExercises,
136
+ totalExercises: exercises.length,
137
+ completedSets,
138
+ totalSets,
139
+ fraction: totalSets === 0 ? 0 : completedSets / totalSets,
140
+ }
141
+ }
142
+
143
+ /** The active exercise anchors the bottom bar; find it, or null (pure). */
144
+ export function findActiveExercise(
145
+ exercises: ActiveWorkoutExercise[]
146
+ ): ActiveWorkoutExercise | null {
147
+ return exercises.find((exercise) => exercise.status === 'active') ?? null
148
+ }
149
+
150
+ /** Map an exercise's status + focus onto an `ExerciseCard` visual state (pure). */
151
+ export function statusToCardState(
152
+ status: ActiveExerciseStatus,
153
+ focused: boolean
154
+ ): ExerciseCardState {
155
+ if (status === 'upcoming') return 'upcoming'
156
+ return focused ? 'expanded' : 'collapsed'
157
+ }
158
+
159
+ /** Project an exercise onto ExerciseCard props, wiring zoom focus (pure). */
160
+ export function toActiveCardProps(
161
+ exercise: ActiveWorkoutExercise,
162
+ focused: boolean,
163
+ onToggle: () => void,
164
+ supersetPosition: ExerciseCardProps['supersetPosition'] = null,
165
+ supersetColor?: string
166
+ ): ExerciseCardProps {
167
+ const state = statusToCardState(exercise.status, focused)
168
+ return {
169
+ name: exercise.name,
170
+ state,
171
+ onToggle,
172
+ summary: exercise.summary,
173
+ e1rm: exercise.e1rm,
174
+ isPR: exercise.isPR,
175
+ tempo: exercise.tempo,
176
+ setVelocities: exercise.setVelocities,
177
+ totalPlannedSets: exercise.totalPlannedSets,
178
+ sets: state === 'expanded' ? exercise.sets : undefined,
179
+ prescription: exercise.prescription,
180
+ previousBest: exercise.previousBest,
181
+ supersetPosition,
182
+ supersetColor,
183
+ }
184
+ }
185
+
186
+ /** Fold consecutive same-superset exercises into render groups (pure). */
187
+ export function groupExercises(
188
+ exercises: ActiveWorkoutExercise[],
189
+ supersets: ActiveWorkoutSuperset[]
190
+ ): WorkoutGroup[] {
191
+ const byId = new Map(supersets.map((superset) => [superset.id, superset]))
192
+ const groups: WorkoutGroup[] = []
193
+ for (const exercise of exercises) {
194
+ const superset = exercise.supersetId != null ? byId.get(exercise.supersetId) : undefined
195
+ const last = groups[groups.length - 1]
196
+ if (superset != null && last?.type === 'superset' && last.superset.id === superset.id) {
197
+ last.exercises.push(exercise)
198
+ } else if (superset != null) {
199
+ groups.push({ type: 'superset', superset, exercises: [exercise] })
200
+ } else {
201
+ groups.push({ type: 'single', exercise })
202
+ }
203
+ }
204
+ return groups
205
+ }
206
+
207
+ /** Position of an exercise within its superset, for corner-radius stitching. */
208
+ function supersetPositionAt(index: number, length: number): ExerciseCardProps['supersetPosition'] {
209
+ if (length === 1) return null
210
+ if (index === 0) return 'first'
211
+ if (index === length - 1) return 'last'
212
+ return 'middle'
213
+ }
214
+
215
+ function WorkoutHeader({
216
+ title,
217
+ subtitle,
218
+ progress,
219
+ }: {
220
+ title: string
221
+ subtitle?: string
222
+ progress: WorkoutProgress
223
+ }) {
224
+ return (
225
+ <View style={{ gap: 10 }} testID="active-workout-page-header">
226
+ <View className="flex-row items-start justify-between" style={{ gap: 8 }}>
227
+ <View style={{ flexShrink: 1 }}>
228
+ <Text
229
+ accessibilityRole="header"
230
+ className="text-text-primary"
231
+ style={{
232
+ fontSize: 20,
233
+ fontFamily: '"Space Grotesk", sans-serif',
234
+ fontWeight: '700',
235
+ }}
236
+ testID="active-workout-page-title"
237
+ >
238
+ {title}
239
+ </Text>
240
+ {subtitle != null && (
241
+ <Text
242
+ className="text-text-secondary"
243
+ style={{
244
+ marginTop: 2,
245
+ fontSize: 12,
246
+ fontFamily: 'Inter, sans-serif',
247
+ }}
248
+ testID="active-workout-page-subtitle"
249
+ >
250
+ {subtitle}
251
+ </Text>
252
+ )}
253
+ </View>
254
+ <View style={{ alignItems: 'flex-end' }} testID="active-workout-page-progress-count">
255
+ <Text
256
+ style={{
257
+ fontSize: 15,
258
+ fontFamily: '"Space Grotesk", sans-serif',
259
+ fontWeight: '700',
260
+ color: BRAND_PRIMARY,
261
+ fontVariant: ['tabular-nums'],
262
+ }}
263
+ >
264
+ {`${progress.completedExercises}/${progress.totalExercises}`}
265
+ </Text>
266
+ <Text
267
+ className="text-text-tertiary"
268
+ style={{
269
+ fontSize: 10,
270
+ fontFamily: 'Inter, sans-serif',
271
+ fontWeight: '600',
272
+ letterSpacing: 0.4,
273
+ textTransform: 'uppercase',
274
+ }}
275
+ >
276
+ {`${progress.completedSets}/${progress.totalSets} sets`}
277
+ </Text>
278
+ </View>
279
+ </View>
280
+ <View
281
+ className="bg-border"
282
+ style={{ height: 4, borderRadius: 2 }}
283
+ testID="active-workout-page-progress-track"
284
+ >
285
+ <View
286
+ style={{
287
+ height: '100%',
288
+ width: `${Math.round(progress.fraction * 100)}%`,
289
+ backgroundColor: BRAND_PRIMARY,
290
+ borderRadius: 2,
291
+ }}
292
+ testID="active-workout-page-progress-fill"
293
+ />
294
+ </View>
295
+ </View>
296
+ )
297
+ }
298
+
299
+ interface ExerciseListProps {
300
+ groups: WorkoutGroup[]
301
+ focusedId: string | null
302
+ onToggle: (id: string) => void
303
+ }
304
+
305
+ function ExerciseList({ groups, focusedId, onToggle }: ExerciseListProps) {
306
+ return (
307
+ <View style={{ gap: 10 }} testID="active-workout-page-exercises">
308
+ {groups.map((group) => {
309
+ if (group.type === 'single') {
310
+ return (
311
+ <View
312
+ key={group.exercise.id}
313
+ className="bg-surface-elevated border-border"
314
+ style={{
315
+ borderWidth: 1,
316
+ borderRadius: 12,
317
+ }}
318
+ >
319
+ <ExerciseCard
320
+ {...toActiveCardProps(group.exercise, group.exercise.id === focusedId, () =>
321
+ onToggle(group.exercise.id)
322
+ )}
323
+ />
324
+ </View>
325
+ )
326
+ }
327
+ return (
328
+ <SupersetWrapper
329
+ key={group.superset.id}
330
+ label={group.superset.label}
331
+ color={group.superset.color}
332
+ >
333
+ {group.exercises.map((exercise, index) => (
334
+ <ExerciseCard
335
+ key={exercise.id}
336
+ {...toActiveCardProps(
337
+ exercise,
338
+ exercise.id === focusedId,
339
+ () => onToggle(exercise.id),
340
+ supersetPositionAt(index, group.exercises.length),
341
+ group.superset.color
342
+ )}
343
+ />
344
+ ))}
345
+ </SupersetWrapper>
346
+ )
347
+ })}
348
+ </View>
349
+ )
350
+ }
351
+
352
+ /**
353
+ * ActiveWorkoutPage — the during-workout screen, built as a zoom hierarchy over
354
+ * `ExerciseCard`s. A header progress bar rolls up completed sets; the flow list
355
+ * dims upcoming exercises, collapses finished ones to their velocity strips, and
356
+ * expands exactly one focused exercise to its `SetRow` breakdown. Consecutive
357
+ * superset exercises stitch together inside a `SupersetWrapper`. A pinned bottom
358
+ * bar toggles between the live `InputBar` (log the next set) and the `RestTimer`.
359
+ * Focus and rest are page-level state; children keep their own styles.
360
+ *
361
+ * @example
362
+ * <ActiveWorkoutPage title="Push Day A" exercises={exercises} />
363
+ */
364
+ export function ActiveWorkoutPage({
365
+ title = 'Active Workout',
366
+ subtitle,
367
+ exercises,
368
+ supersets = [],
369
+ input,
370
+ rest,
371
+ initialResting = false,
372
+ className,
373
+ ...props
374
+ }: ActiveWorkoutPageProps) {
375
+ const active = findActiveExercise(exercises)
376
+ const [focusedId, setFocusedId] = useState<string | null>(active?.id ?? null)
377
+ const [resting, setResting] = useState(initialResting)
378
+ const [reps, setReps] = useState(input?.reps ?? '')
379
+ const [weight, setWeight] = useState(input?.weight ?? '')
380
+
381
+ const progress = useMemo(() => deriveWorkoutProgress(exercises), [exercises])
382
+ const groups = useMemo(() => groupExercises(exercises, supersets), [exercises, supersets])
383
+
384
+ const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id))
385
+
386
+ const nextSetNumber = active != null ? countCompletedSets(active) + 1 : 1
387
+ const canRecord = reps.trim() !== '' && weight.trim() !== ''
388
+ const showRest = resting && rest != null
389
+ const showInput = !showRest && active != null
390
+
391
+ return (
392
+ <View
393
+ className={['bg-background-base', className].filter(Boolean).join(' ')}
394
+ style={{ position: 'relative', width: 390 }}
395
+ accessibilityRole={'main' as ViewProps['accessibilityRole']}
396
+ aria-label={title}
397
+ testID="active-workout-page"
398
+ {...props}
399
+ >
400
+ <View style={{ padding: 16, gap: 14 }} testID="active-workout-page-content">
401
+ <WorkoutHeader title={title} subtitle={subtitle} progress={progress} />
402
+ <ExerciseList groups={groups} focusedId={focusedId} onToggle={toggleFocus} />
403
+ </View>
404
+
405
+ {(showRest || showInput) && (
406
+ <View
407
+ style={{ borderTopWidth: 1, borderTopColor: resolveColor('border-default') }}
408
+ testID="active-workout-page-bottom-bar"
409
+ >
410
+ {showRest && rest != null ? (
411
+ <RestTimer
412
+ totalSeconds={rest.totalSeconds}
413
+ elapsedMs={rest.elapsedMs}
414
+ nextSetInfo={rest.nextSetInfo}
415
+ onSkip={() => setResting(false)}
416
+ onAddTime={() => undefined}
417
+ visible
418
+ />
419
+ ) : (
420
+ active != null && (
421
+ <InputBar
422
+ exerciseName={active.name}
423
+ setNumber={nextSetNumber}
424
+ totalSets={active.totalPlannedSets}
425
+ reps={reps}
426
+ weight={weight}
427
+ unit={active.unit}
428
+ onRepsChange={setReps}
429
+ onWeightChange={setWeight}
430
+ onRecord={() => {
431
+ if (rest != null) setResting(true)
432
+ }}
433
+ canRecord={canRecord}
434
+ visible
435
+ />
436
+ )
437
+ )}
438
+ </View>
439
+ )}
440
+ </View>
441
+ )
442
+ }
@@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
4
  import BodyHighlighter, { type ExtendedBodyPart, type Slug } from 'react-native-body-highlighter'
5
5
  import { cn } from '../../../utils/cn'
6
- import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
+ import { resolveColor } from '../../../theme/resolve-color'
7
7
  import {
8
8
  MuscleGroup,
9
9
  SimpleMuscleGroup,
@@ -28,8 +28,6 @@ const Body = ((BodyHighlighter as unknown as { default?: typeof BodyHighlighter
28
28
  const OUTLINE_FILL = 'rgba(255,255,255,0.08)'
29
29
  const OUTLINE_BORDER = 'rgba(255,255,255,0.12)'
30
30
  const BODY_SCALE = 0.8 // 200x400 intrinsic -> ~160x320 px
31
- const TEXT_PRIMARY = '#F3F4F6'
32
- const TEXT_SECONDARY = '#9CA3AF'
33
31
 
34
32
  export interface BodyMapData {
35
33
  muscleGroup: MuscleGroup
@@ -218,10 +216,10 @@ export function BodyMap({
218
216
 
219
217
  {legend.length === 0 ? (
220
218
  <Text
219
+ className="text-text-secondary"
221
220
  style={{
222
221
  marginTop: 8,
223
222
  fontSize: 12,
224
- color: TEXT_SECONDARY,
225
223
  textAlign: 'center',
226
224
  fontFamily: 'Inter, sans-serif',
227
225
  }}
@@ -277,7 +275,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
277
275
  borderRadius: 9999,
278
276
  backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
279
277
  borderWidth: 1,
280
- borderColor: active ? '#FF7900' : WORKOUT_TOKENS.border.strong,
278
+ borderColor: active ? '#FF7900' : resolveColor('border-strong'),
281
279
  }}
282
280
  testID={`body-map-toggle-${side}`}
283
281
  >
@@ -286,7 +284,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
286
284
  fontSize: 12,
287
285
  fontFamily: 'Inter, sans-serif',
288
286
  fontWeight: active ? '700' : '500',
289
- color: active ? '#FF7900' : TEXT_SECONDARY,
287
+ color: active ? '#FF7900' : resolveColor('text-secondary'),
290
288
  textTransform: 'capitalize',
291
289
  }}
292
290
  >
@@ -314,6 +312,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
314
312
  accessibilityRole="button"
315
313
  accessibilityLabel={label}
316
314
  aria-pressed={highlighted}
315
+ className="bg-surface-raised"
317
316
  style={{
318
317
  flexDirection: 'row',
319
318
  alignItems: 'center',
@@ -322,8 +321,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
322
321
  paddingVertical: 3,
323
322
  borderRadius: 9999,
324
323
  borderWidth: 1,
325
- borderColor: highlighted ? '#FF7900' : WORKOUT_TOKENS.border.default,
326
- backgroundColor: WORKOUT_TOKENS.surface.raised,
324
+ borderColor: highlighted ? '#FF7900' : resolveColor('border-default'),
327
325
  }}
328
326
  testID={`body-map-muscle-${entry.key}`}
329
327
  >
@@ -333,11 +331,11 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
333
331
  testID={`body-map-muscle-dot-${entry.key}`}
334
332
  />
335
333
  <Text
334
+ className="text-text-primary"
336
335
  style={{
337
336
  fontSize: 11,
338
337
  fontFamily: 'Inter, sans-serif',
339
338
  fontWeight: '500',
340
- color: TEXT_PRIMARY,
341
339
  }}
342
340
  accessibilityElementsHidden
343
341
  >