@titan-design/react-ui 0.2.6 → 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 (191) 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-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,447 @@
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
+
10
+ const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
11
+ const BORDER_DEFAULT = 'var(--color-border-default)'
12
+ const BRAND_PRIMARY = '#FF7900'
13
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
14
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
15
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
16
+ const PAGE_BG = 'var(--color-background-base)'
17
+
18
+ /** Where an exercise sits in the during-workout flow. */
19
+ export type ActiveExerciseStatus = 'completed' | 'active' | 'upcoming'
20
+
21
+ /**
22
+ * One exercise in the running workout. Projected onto an `ExerciseCard`, whose
23
+ * visual state is driven by `status` and the page's zoom focus: completed and
24
+ * active exercises collapse/expand, upcoming ones stay dimmed.
25
+ */
26
+ export interface ActiveWorkoutExercise {
27
+ /** Stable id for zoom-focus selection. */
28
+ id: string
29
+ /** Exercise name, e.g. "Barbell Bench Press". */
30
+ name: string
31
+ /** Position in the workout flow. */
32
+ status: ActiveExerciseStatus
33
+ /** Unit used for this exercise's loads. */
34
+ unit: 'lbs' | 'kg'
35
+ /** Total sets prescribed for this exercise. */
36
+ totalPlannedSets: number
37
+ /** Collapsed summary (sets × reps @ weight) for a completed exercise. */
38
+ summary?: ExerciseCardProps['summary']
39
+ /** Estimated one-rep max badge. */
40
+ e1rm?: ExerciseCardProps['e1rm']
41
+ /** Flags a personal-record exercise. */
42
+ isPR?: boolean
43
+ /** Prescribed tempo, revealed when expanded. */
44
+ tempo?: ExerciseCardProps['tempo']
45
+ /** Per-rep velocities for each logged set (drives collapsed strips + set count). */
46
+ setVelocities?: number[][]
47
+ /** Per-set breakdown revealed when expanded. */
48
+ sets?: SetRowProps[]
49
+ /** One-line prescription for an upcoming exercise. */
50
+ prescription?: string
51
+ /** Previous-best hint for an upcoming exercise. */
52
+ previousBest?: string
53
+ /** Groups consecutive exercises under a shared `SupersetWrapper`. */
54
+ supersetId?: string
55
+ }
56
+
57
+ /** A superset grouping consecutive exercises that share its id. */
58
+ export interface ActiveWorkoutSuperset {
59
+ /** Matches `ActiveWorkoutExercise.supersetId`. */
60
+ id: string
61
+ /** Corner badge label, e.g. "SS1". */
62
+ label: string
63
+ /** Accent color for the wrapper rail + label. */
64
+ color?: string
65
+ }
66
+
67
+ /** Live-set inputs shown in the bottom `InputBar`. */
68
+ export interface ActiveWorkoutInput {
69
+ /** Current reps field value. */
70
+ reps: string
71
+ /** Current weight field value. */
72
+ weight: string
73
+ }
74
+
75
+ /** Rest-timer inputs shown in the bottom `RestTimer`. */
76
+ export interface ActiveWorkoutRest {
77
+ /** Prescribed rest length, seconds. */
78
+ totalSeconds: number
79
+ /** Elapsed rest, milliseconds. */
80
+ elapsedMs: number
81
+ /** Next-set hint, e.g. "Set 3 · 8 × 195 lbs". */
82
+ nextSetInfo?: string
83
+ }
84
+
85
+ /** Derived, page-level roll-up of workout progress. */
86
+ export interface WorkoutProgress {
87
+ /** Number of exercises marked completed. */
88
+ completedExercises: number
89
+ /** Total exercises in the workout. */
90
+ totalExercises: number
91
+ /** Sets logged so far across all exercises. */
92
+ completedSets: number
93
+ /** Total sets prescribed across all exercises. */
94
+ totalSets: number
95
+ /** Fraction of prescribed sets logged, 0-1. */
96
+ fraction: number
97
+ }
98
+
99
+ /** An ordered render group: a lone exercise or a superset of exercises. */
100
+ export type WorkoutGroup =
101
+ | { type: 'single'; exercise: ActiveWorkoutExercise }
102
+ | { type: 'superset'; superset: ActiveWorkoutSuperset; exercises: ActiveWorkoutExercise[] }
103
+
104
+ export interface ActiveWorkoutPageProps extends ViewProps {
105
+ /** Workout name shown in the header. */
106
+ title?: string
107
+ /** Context line under the title, e.g. "Push Day A · Week 6". */
108
+ subtitle?: string
109
+ /** Exercises in flow order. */
110
+ exercises: ActiveWorkoutExercise[]
111
+ /** Superset groupings referenced by `exercise.supersetId`. */
112
+ supersets?: ActiveWorkoutSuperset[]
113
+ /** Seed reps/weight for the live `InputBar`. */
114
+ input?: ActiveWorkoutInput
115
+ /** Rest-timer state; when resting, the timer replaces the input bar. */
116
+ rest?: ActiveWorkoutRest
117
+ /** Start the page in the resting state (timer showing). */
118
+ initialResting?: boolean
119
+ className?: string
120
+ }
121
+
122
+ /** Count the sets logged for an exercise (pure). */
123
+ export function countCompletedSets(exercise: ActiveWorkoutExercise): number {
124
+ if (exercise.setVelocities != null) return exercise.setVelocities.length
125
+ if (exercise.status === 'completed') return exercise.totalPlannedSets
126
+ return 0
127
+ }
128
+
129
+ /** Roll up the exercises into the header progress readout (pure, testable). */
130
+ export function deriveWorkoutProgress(exercises: ActiveWorkoutExercise[]): WorkoutProgress {
131
+ let completedExercises = 0
132
+ let completedSets = 0
133
+ let totalSets = 0
134
+ for (const exercise of exercises) {
135
+ if (exercise.status === 'completed') completedExercises += 1
136
+ completedSets += countCompletedSets(exercise)
137
+ totalSets += exercise.totalPlannedSets
138
+ }
139
+ return {
140
+ completedExercises,
141
+ totalExercises: exercises.length,
142
+ completedSets,
143
+ totalSets,
144
+ fraction: totalSets === 0 ? 0 : completedSets / totalSets,
145
+ }
146
+ }
147
+
148
+ /** The active exercise anchors the bottom bar; find it, or null (pure). */
149
+ export function findActiveExercise(
150
+ exercises: ActiveWorkoutExercise[]
151
+ ): ActiveWorkoutExercise | null {
152
+ return exercises.find((exercise) => exercise.status === 'active') ?? null
153
+ }
154
+
155
+ /** Map an exercise's status + focus onto an `ExerciseCard` visual state (pure). */
156
+ export function statusToCardState(
157
+ status: ActiveExerciseStatus,
158
+ focused: boolean
159
+ ): ExerciseCardState {
160
+ if (status === 'upcoming') return 'upcoming'
161
+ return focused ? 'expanded' : 'collapsed'
162
+ }
163
+
164
+ /** Project an exercise onto ExerciseCard props, wiring zoom focus (pure). */
165
+ export function toActiveCardProps(
166
+ exercise: ActiveWorkoutExercise,
167
+ focused: boolean,
168
+ onToggle: () => void,
169
+ supersetPosition: ExerciseCardProps['supersetPosition'] = null,
170
+ supersetColor?: string
171
+ ): ExerciseCardProps {
172
+ const state = statusToCardState(exercise.status, focused)
173
+ return {
174
+ name: exercise.name,
175
+ state,
176
+ onToggle,
177
+ summary: exercise.summary,
178
+ e1rm: exercise.e1rm,
179
+ isPR: exercise.isPR,
180
+ tempo: exercise.tempo,
181
+ setVelocities: exercise.setVelocities,
182
+ totalPlannedSets: exercise.totalPlannedSets,
183
+ sets: state === 'expanded' ? exercise.sets : undefined,
184
+ prescription: exercise.prescription,
185
+ previousBest: exercise.previousBest,
186
+ supersetPosition,
187
+ supersetColor,
188
+ }
189
+ }
190
+
191
+ /** Fold consecutive same-superset exercises into render groups (pure). */
192
+ export function groupExercises(
193
+ exercises: ActiveWorkoutExercise[],
194
+ supersets: ActiveWorkoutSuperset[]
195
+ ): WorkoutGroup[] {
196
+ const byId = new Map(supersets.map((superset) => [superset.id, superset]))
197
+ const groups: WorkoutGroup[] = []
198
+ for (const exercise of exercises) {
199
+ const superset = exercise.supersetId != null ? byId.get(exercise.supersetId) : undefined
200
+ const last = groups[groups.length - 1]
201
+ if (superset != null && last?.type === 'superset' && last.superset.id === superset.id) {
202
+ last.exercises.push(exercise)
203
+ } else if (superset != null) {
204
+ groups.push({ type: 'superset', superset, exercises: [exercise] })
205
+ } else {
206
+ groups.push({ type: 'single', exercise })
207
+ }
208
+ }
209
+ return groups
210
+ }
211
+
212
+ /** Position of an exercise within its superset, for corner-radius stitching. */
213
+ function supersetPositionAt(index: number, length: number): ExerciseCardProps['supersetPosition'] {
214
+ if (length === 1) return null
215
+ if (index === 0) return 'first'
216
+ if (index === length - 1) return 'last'
217
+ return 'middle'
218
+ }
219
+
220
+ function WorkoutHeader({
221
+ title,
222
+ subtitle,
223
+ progress,
224
+ }: {
225
+ title: string
226
+ subtitle?: string
227
+ progress: WorkoutProgress
228
+ }) {
229
+ return (
230
+ <View style={{ gap: 10 }} testID="active-workout-page-header">
231
+ <View className="flex-row items-start justify-between" style={{ gap: 8 }}>
232
+ <View style={{ flexShrink: 1 }}>
233
+ <Text
234
+ accessibilityRole="header"
235
+ style={{
236
+ fontSize: 20,
237
+ fontFamily: '"Space Grotesk", sans-serif',
238
+ fontWeight: '700',
239
+ color: TEXT_PRIMARY,
240
+ }}
241
+ testID="active-workout-page-title"
242
+ >
243
+ {title}
244
+ </Text>
245
+ {subtitle != null && (
246
+ <Text
247
+ style={{
248
+ marginTop: 2,
249
+ fontSize: 12,
250
+ fontFamily: 'Inter, sans-serif',
251
+ color: TEXT_SECONDARY,
252
+ }}
253
+ testID="active-workout-page-subtitle"
254
+ >
255
+ {subtitle}
256
+ </Text>
257
+ )}
258
+ </View>
259
+ <View style={{ alignItems: 'flex-end' }} testID="active-workout-page-progress-count">
260
+ <Text
261
+ style={{
262
+ fontSize: 15,
263
+ fontFamily: '"Space Grotesk", sans-serif',
264
+ fontWeight: '700',
265
+ color: BRAND_PRIMARY,
266
+ fontVariant: ['tabular-nums'],
267
+ }}
268
+ >
269
+ {`${progress.completedExercises}/${progress.totalExercises}`}
270
+ </Text>
271
+ <Text
272
+ style={{
273
+ fontSize: 10,
274
+ fontFamily: 'Inter, sans-serif',
275
+ fontWeight: '600',
276
+ letterSpacing: 0.4,
277
+ textTransform: 'uppercase',
278
+ color: TEXT_TERTIARY,
279
+ }}
280
+ >
281
+ {`${progress.completedSets}/${progress.totalSets} sets`}
282
+ </Text>
283
+ </View>
284
+ </View>
285
+ <View
286
+ style={{ height: 4, backgroundColor: BORDER_DEFAULT, borderRadius: 2 }}
287
+ testID="active-workout-page-progress-track"
288
+ >
289
+ <View
290
+ style={{
291
+ height: '100%',
292
+ width: `${Math.round(progress.fraction * 100)}%`,
293
+ backgroundColor: BRAND_PRIMARY,
294
+ borderRadius: 2,
295
+ }}
296
+ testID="active-workout-page-progress-fill"
297
+ />
298
+ </View>
299
+ </View>
300
+ )
301
+ }
302
+
303
+ interface ExerciseListProps {
304
+ groups: WorkoutGroup[]
305
+ focusedId: string | null
306
+ onToggle: (id: string) => void
307
+ }
308
+
309
+ function ExerciseList({ groups, focusedId, onToggle }: ExerciseListProps) {
310
+ return (
311
+ <View style={{ gap: 10 }} testID="active-workout-page-exercises">
312
+ {groups.map((group) => {
313
+ if (group.type === 'single') {
314
+ return (
315
+ <View
316
+ key={group.exercise.id}
317
+ style={{
318
+ backgroundColor: SURFACE_ELEVATED,
319
+ borderWidth: 1,
320
+ borderColor: BORDER_DEFAULT,
321
+ borderRadius: 12,
322
+ }}
323
+ >
324
+ <ExerciseCard
325
+ {...toActiveCardProps(group.exercise, group.exercise.id === focusedId, () =>
326
+ onToggle(group.exercise.id)
327
+ )}
328
+ />
329
+ </View>
330
+ )
331
+ }
332
+ return (
333
+ <SupersetWrapper
334
+ key={group.superset.id}
335
+ label={group.superset.label}
336
+ color={group.superset.color}
337
+ >
338
+ {group.exercises.map((exercise, index) => (
339
+ <ExerciseCard
340
+ key={exercise.id}
341
+ {...toActiveCardProps(
342
+ exercise,
343
+ exercise.id === focusedId,
344
+ () => onToggle(exercise.id),
345
+ supersetPositionAt(index, group.exercises.length),
346
+ group.superset.color
347
+ )}
348
+ />
349
+ ))}
350
+ </SupersetWrapper>
351
+ )
352
+ })}
353
+ </View>
354
+ )
355
+ }
356
+
357
+ /**
358
+ * ActiveWorkoutPage — the during-workout screen, built as a zoom hierarchy over
359
+ * `ExerciseCard`s. A header progress bar rolls up completed sets; the flow list
360
+ * dims upcoming exercises, collapses finished ones to their velocity strips, and
361
+ * expands exactly one focused exercise to its `SetRow` breakdown. Consecutive
362
+ * superset exercises stitch together inside a `SupersetWrapper`. A pinned bottom
363
+ * bar toggles between the live `InputBar` (log the next set) and the `RestTimer`.
364
+ * Focus and rest are page-level state; children keep their own styles.
365
+ *
366
+ * @example
367
+ * <ActiveWorkoutPage title="Push Day A" exercises={exercises} />
368
+ */
369
+ export function ActiveWorkoutPage({
370
+ title = 'Active Workout',
371
+ subtitle,
372
+ exercises,
373
+ supersets = [],
374
+ input,
375
+ rest,
376
+ initialResting = false,
377
+ className,
378
+ ...props
379
+ }: ActiveWorkoutPageProps) {
380
+ const active = findActiveExercise(exercises)
381
+ const [focusedId, setFocusedId] = useState<string | null>(active?.id ?? null)
382
+ const [resting, setResting] = useState(initialResting)
383
+ const [reps, setReps] = useState(input?.reps ?? '')
384
+ const [weight, setWeight] = useState(input?.weight ?? '')
385
+
386
+ const progress = useMemo(() => deriveWorkoutProgress(exercises), [exercises])
387
+ const groups = useMemo(() => groupExercises(exercises, supersets), [exercises, supersets])
388
+
389
+ const toggleFocus = (id: string) => setFocusedId((current) => (current === id ? null : id))
390
+
391
+ const nextSetNumber = active != null ? countCompletedSets(active) + 1 : 1
392
+ const canRecord = reps.trim() !== '' && weight.trim() !== ''
393
+ const showRest = resting && rest != null
394
+ const showInput = !showRest && active != null
395
+
396
+ return (
397
+ <View
398
+ className={className}
399
+ style={{ position: 'relative', width: 390, backgroundColor: PAGE_BG }}
400
+ accessibilityRole={'main' as ViewProps['accessibilityRole']}
401
+ aria-label={title}
402
+ testID="active-workout-page"
403
+ {...props}
404
+ >
405
+ <View style={{ padding: 16, gap: 14 }} testID="active-workout-page-content">
406
+ <WorkoutHeader title={title} subtitle={subtitle} progress={progress} />
407
+ <ExerciseList groups={groups} focusedId={focusedId} onToggle={toggleFocus} />
408
+ </View>
409
+
410
+ {(showRest || showInput) && (
411
+ <View
412
+ style={{ borderTopWidth: 1, borderTopColor: BORDER_DEFAULT }}
413
+ testID="active-workout-page-bottom-bar"
414
+ >
415
+ {showRest && rest != null ? (
416
+ <RestTimer
417
+ totalSeconds={rest.totalSeconds}
418
+ elapsedMs={rest.elapsedMs}
419
+ nextSetInfo={rest.nextSetInfo}
420
+ onSkip={() => setResting(false)}
421
+ onAddTime={() => undefined}
422
+ visible
423
+ />
424
+ ) : (
425
+ active != null && (
426
+ <InputBar
427
+ exerciseName={active.name}
428
+ setNumber={nextSetNumber}
429
+ totalSets={active.totalPlannedSets}
430
+ reps={reps}
431
+ weight={weight}
432
+ unit={active.unit}
433
+ onRepsChange={setReps}
434
+ onWeightChange={setWeight}
435
+ onRecord={() => {
436
+ if (rest != null) setResting(true)
437
+ }}
438
+ canRecord={canRecord}
439
+ visible
440
+ />
441
+ )
442
+ )}
443
+ </View>
444
+ )}
445
+ </View>
446
+ )
447
+ }
@@ -0,0 +1,75 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
+ import { Dumbbell } from 'lucide-react'
4
+ import { BaseBadge } from './BaseBadge'
5
+
6
+ const meta: Meta<typeof BaseBadge> = {
7
+ title: 'Custom/Workout/BaseBadge',
8
+ component: BaseBadge,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ variant: { control: 'select', options: ['plain', 'pr'], description: 'Visual variant' },
12
+ size: { control: 'select', options: ['sm', 'md', 'lg'], description: 'Badge size' },
13
+ onPress: { action: 'pressed', description: 'Callback when tapped' },
14
+ },
15
+ }
16
+
17
+ export default meta
18
+ type Story = StoryObj<typeof BaseBadge>
19
+
20
+ const Label = ({ children, color = '#9CA3AF' }: { children: string; color?: string }) => (
21
+ <Text
22
+ style={{
23
+ fontFamily: '"Space Grotesk", sans-serif',
24
+ fontWeight: '600',
25
+ fontSize: 10,
26
+ color,
27
+ }}
28
+ >
29
+ {children}
30
+ </Text>
31
+ )
32
+
33
+ export const Plain: Story = {
34
+ args: {
35
+ variant: 'plain',
36
+ children: <Label>225 lbs</Label>,
37
+ },
38
+ }
39
+
40
+ export const Pr: Story = {
41
+ args: {
42
+ variant: 'pr',
43
+ children: <Label color="#FF7900">PR</Label>,
44
+ },
45
+ }
46
+
47
+ export const WithIcon: Story = {
48
+ args: {
49
+ variant: 'plain',
50
+ icon: <Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />,
51
+ children: <Label>225 lbs</Label>,
52
+ },
53
+ }
54
+
55
+ export const AllVariants: Story = {
56
+ render: () => (
57
+ <View style={{ gap: 12 }}>
58
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
59
+ <BaseBadge size="sm"><Label>225 lbs</Label></BaseBadge>
60
+ <BaseBadge size="md"><Label>225 lbs</Label></BaseBadge>
61
+ <BaseBadge size="lg"><Label>225 lbs</Label></BaseBadge>
62
+ </View>
63
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
64
+ <BaseBadge variant="pr" size="sm"><Label color="#FF7900">PR</Label></BaseBadge>
65
+ <BaseBadge variant="pr" size="md"><Label color="#FF7900">PR</Label></BaseBadge>
66
+ <BaseBadge variant="pr" size="lg"><Label color="#FF7900">PR</Label></BaseBadge>
67
+ </View>
68
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
69
+ <BaseBadge icon={<Dumbbell size={12} color="#9CA3AF" strokeWidth={2} />}>
70
+ <Label>225 lbs</Label>
71
+ </BaseBadge>
72
+ </View>
73
+ </View>
74
+ ),
75
+ }
@@ -0,0 +1,102 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Text, View } from 'react-native'
5
+ import { BaseBadge } from './BaseBadge'
6
+
7
+ describe('BaseBadge', () => {
8
+ it('renders children', () => {
9
+ render(<BaseBadge testID="bb"><Text>Hello</Text></BaseBadge>)
10
+ expect(screen.getByText('Hello')).toBeInTheDocument()
11
+ })
12
+
13
+ it('defaults to the plain variant border color', () => {
14
+ render(<BaseBadge testID="bb"><Text>x</Text></BaseBadge>)
15
+ expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: '#1F1F1F' })
16
+ })
17
+
18
+ it('applies the pr variant border color', () => {
19
+ render(
20
+ <BaseBadge variant="pr" testID="bb">
21
+ <Text>x</Text>
22
+ </BaseBadge>,
23
+ )
24
+ expect(screen.getByTestId('bb')).toHaveStyle({
25
+ borderTopColor: 'rgba(255, 121, 0, 0.12)',
26
+ })
27
+ })
28
+
29
+ describe('icon', () => {
30
+ it('renders the icon when provided and hides it from the a11y tree', () => {
31
+ render(
32
+ <BaseBadge icon={<View testID="my-icon" />}>
33
+ <Text>x</Text>
34
+ </BaseBadge>,
35
+ )
36
+ expect(screen.getByTestId('base-badge-icon')).toBeInTheDocument()
37
+ expect(screen.getByTestId('my-icon')).toBeInTheDocument()
38
+ })
39
+
40
+ it('does not render an icon wrapper when no icon is provided', () => {
41
+ render(<BaseBadge><Text>x</Text></BaseBadge>)
42
+ expect(screen.queryByTestId('base-badge-icon')).not.toBeInTheDocument()
43
+ })
44
+ })
45
+
46
+ describe('onPress', () => {
47
+ it('wraps in a Pressable when onPress is provided', () => {
48
+ const onPress = vi.fn()
49
+ render(
50
+ <BaseBadge onPress={onPress} accessibilityLabel="Press me">
51
+ <Text>x</Text>
52
+ </BaseBadge>,
53
+ )
54
+ const pressable = screen.getByTestId('base-badge-pressable')
55
+ fireEvent.click(pressable)
56
+ expect(onPress).toHaveBeenCalledOnce()
57
+ })
58
+
59
+ it('does not render a Pressable when onPress is omitted', () => {
60
+ render(<BaseBadge><Text>x</Text></BaseBadge>)
61
+ expect(screen.queryByTestId('base-badge-pressable')).not.toBeInTheDocument()
62
+ })
63
+
64
+ it('exposes a button role only when pressable', () => {
65
+ render(
66
+ <BaseBadge onPress={() => {}} accessibilityLabel="Press me">
67
+ <Text>x</Text>
68
+ </BaseBadge>,
69
+ )
70
+ expect(screen.getByRole('button')).toBeInTheDocument()
71
+ })
72
+ })
73
+
74
+ describe('size', () => {
75
+ it.each(['sm', 'md', 'lg'] as const)('renders %s size', (size) => {
76
+ render(<BaseBadge size={size} testID="bb"><Text>x</Text></BaseBadge>)
77
+ expect(screen.getByTestId('bb')).toBeInTheDocument()
78
+ })
79
+ })
80
+
81
+ describe('accessibility', () => {
82
+ it('has no accessibility violations as a static badge', async () => {
83
+ const { container } = render(
84
+ <BaseBadge accessibilityLabel="Static badge">
85
+ <Text>x</Text>
86
+ </BaseBadge>,
87
+ )
88
+ const results = await axe(container)
89
+ expect(results).toHaveNoViolations()
90
+ })
91
+
92
+ it('has no accessibility violations as a pressable badge', async () => {
93
+ const { container } = render(
94
+ <BaseBadge onPress={() => {}} accessibilityLabel="Pressable badge">
95
+ <Text>x</Text>
96
+ </BaseBadge>,
97
+ )
98
+ const results = await axe(container)
99
+ expect(results).toHaveNoViolations()
100
+ })
101
+ })
102
+ })