@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,456 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { Fragment, type ReactNode } from 'react'
3
+ import { View, Text, type ViewProps, type ViewStyle } from 'react-native'
4
+ import { Card } from '../../ui/card'
5
+ import { StatusDot } from './StatusDot'
6
+
7
+ const BRAND_PRIMARY = '#FF7900'
8
+ const BRAND_PRIMARY_DARK = '#C45E00'
9
+ const BRAND_PRIMARY_LIGHT = '#FFB066'
10
+ const TEXT_PRIMARY = 'var(--color-text-primary)'
11
+ const TEXT_SECONDARY = 'var(--color-text-secondary)'
12
+ const TEXT_TERTIARY = 'var(--color-text-tertiary)'
13
+
14
+ const SUCCESS = '#14B8A6'
15
+ const WARNING = '#FFB020'
16
+ const ERROR = '#D14343'
17
+
18
+ /** Gradient stops for the 3px top accent: dark -> primary -> light (matches MesoCard). */
19
+ const ACCENT_STOPS = [BRAND_PRIMARY_DARK, BRAND_PRIMARY, BRAND_PRIMARY_LIGHT]
20
+
21
+ /** Card surface gradient: elevated -> raised at 135deg (theme-aware). */
22
+ const CARD_GRADIENT =
23
+ 'linear-gradient(135deg, var(--color-surface-elevated) 0%, var(--color-surface-raised) 100%)'
24
+
25
+ /** Gauge track gradient (teal -> amber -> red) at 0.25 alpha. */
26
+ const GAUGE_GRADIENT =
27
+ 'linear-gradient(90deg, rgba(20,184,166,0.25) 0%, rgba(255,176,32,0.25) 50%, rgba(209,67,67,0.25) 100%)'
28
+
29
+ export type MesoStatusBadgeVariant = 'success' | 'warning' | 'error'
30
+
31
+ const STATUS_VARIANTS: Record<
32
+ MesoStatusBadgeVariant,
33
+ { bg: string; border: string; text: string }
34
+ > = {
35
+ success: { bg: 'rgba(20,184,166,0.15)', border: 'rgba(20,184,166,0.3)', text: SUCCESS },
36
+ warning: { bg: 'rgba(255,176,32,0.15)', border: 'rgba(255,176,32,0.3)', text: WARNING },
37
+ error: { bg: 'rgba(209,67,67,0.15)', border: 'rgba(209,67,67,0.25)', text: ERROR },
38
+ }
39
+
40
+ export interface MesoStatusBadge {
41
+ /** Short status label, e.g. "On Track". */
42
+ label: string
43
+ /** Color treatment for the badge. */
44
+ variant: MesoStatusBadgeVariant
45
+ }
46
+
47
+ export interface MesoStatusMetric {
48
+ /** Cell label, e.g. "Prescribed". */
49
+ label: string
50
+ /** Cell value, e.g. "3×8 @ RPE 8". */
51
+ value: string
52
+ }
53
+
54
+ export interface MesoStatusGauge {
55
+ /** Gauge label, e.g. "Intensity". */
56
+ label: string
57
+ /** Fill position 0-1; the 14px marker sits at this level. */
58
+ level: number
59
+ /** Optional right-aligned sublabel, e.g. "Prescribed". */
60
+ sublabel?: string
61
+ }
62
+
63
+ export interface MesoStatusCoaching {
64
+ /** Coaching recommendation copy. */
65
+ text: string
66
+ /** Substrings within `text` to render in bold. */
67
+ highlights?: string[]
68
+ }
69
+
70
+ export interface MesoStatusNextTarget {
71
+ /** Leading glyph/emoji, e.g. "→" or "🎯". */
72
+ icon: string
73
+ /** Next-session target copy. */
74
+ text: string
75
+ }
76
+
77
+ export interface MesoStatusCardProps extends ViewProps {
78
+ /** Mesocycle name, e.g. "Hypertrophy Block". */
79
+ mesoName: string
80
+ /** Context line, e.g. "Week 6 of 8 · Upper/Lower". */
81
+ mesoSubtitle: string
82
+ /** Status pill in the header. */
83
+ statusBadge: MesoStatusBadge
84
+ /** Prescription-vs-actual metrics rendered as a 2x2 grid. */
85
+ metrics: MesoStatusMetric[]
86
+ /** Intensity / volume gauges stacked vertically. */
87
+ gauges: MesoStatusGauge[]
88
+ /** Optional coaching recommendation box. */
89
+ coaching?: MesoStatusCoaching
90
+ /** Optional next-session target box. */
91
+ nextTarget?: MesoStatusNextTarget
92
+ className?: string
93
+ }
94
+
95
+ function clamp01(value: number): number {
96
+ if (!Number.isFinite(value)) return 0
97
+ if (value < 0) return 0
98
+ if (value > 1) return 1
99
+ return value
100
+ }
101
+
102
+ /** Marker fill color by gauge zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
103
+ function getGaugeZoneColor(level: number): string {
104
+ if (level >= 0.7) return ERROR
105
+ if (level >= 0.4) return WARNING
106
+ return SUCCESS
107
+ }
108
+
109
+ /** Splits `text` into nodes, bolding any segment that exactly matches a highlight. */
110
+ function renderCoachingText(text: string, highlights?: string[]): ReactNode {
111
+ if (!highlights || highlights.length === 0) return text
112
+ const escaped = highlights
113
+ .filter(Boolean)
114
+ .map((h) => h.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
115
+ if (escaped.length === 0) return text
116
+ const regex = new RegExp(`(${escaped.join('|')})`, 'g')
117
+ return text.split(regex).map((part, index) =>
118
+ highlights.includes(part) ? (
119
+ <Text
120
+ key={`${part}-${index}`}
121
+ style={{ fontWeight: '700', color: TEXT_PRIMARY }}
122
+ >
123
+ {part}
124
+ </Text>
125
+ ) : (
126
+ <Fragment key={`t-${index}`}>{part}</Fragment>
127
+ ),
128
+ )
129
+ }
130
+
131
+ function StatusPill({ badge }: { badge: MesoStatusBadge }) {
132
+ const colors = STATUS_VARIANTS[badge.variant]
133
+ return (
134
+ <View
135
+ className="flex-row items-center"
136
+ style={{
137
+ gap: 5,
138
+ paddingVertical: 3,
139
+ paddingHorizontal: 8,
140
+ borderRadius: 4,
141
+ borderWidth: 1,
142
+ backgroundColor: colors.bg,
143
+ borderColor: colors.border,
144
+ }}
145
+ testID="meso-status-card-badge"
146
+ >
147
+ <View accessibilityElementsHidden>
148
+ <StatusDot variant={badge.variant} size="sm" />
149
+ </View>
150
+ <Text
151
+ style={{
152
+ fontSize: 10,
153
+ fontFamily: 'Inter, sans-serif',
154
+ fontWeight: '700',
155
+ letterSpacing: 0.3,
156
+ color: colors.text,
157
+ }}
158
+ >
159
+ {badge.label}
160
+ </Text>
161
+ </View>
162
+ )
163
+ }
164
+
165
+ function MetricCell({ metric }: { metric: MesoStatusMetric }) {
166
+ return (
167
+ <View style={{ width: '48%' }} testID="meso-status-card-metric">
168
+ <Text
169
+ style={{
170
+ fontSize: 10,
171
+ fontFamily: 'Inter, sans-serif',
172
+ fontWeight: '600',
173
+ letterSpacing: 0.5,
174
+ textTransform: 'uppercase',
175
+ color: TEXT_TERTIARY,
176
+ }}
177
+ >
178
+ {metric.label}
179
+ </Text>
180
+ <Text
181
+ style={{
182
+ marginTop: 2,
183
+ fontSize: 13,
184
+ fontFamily: 'Inter, sans-serif',
185
+ fontWeight: '600',
186
+ color: TEXT_PRIMARY,
187
+ }}
188
+ >
189
+ {metric.value}
190
+ </Text>
191
+ </View>
192
+ )
193
+ }
194
+
195
+ function Gauge({ gauge }: { gauge: MesoStatusGauge }) {
196
+ const level = clamp01(gauge.level)
197
+ const percentage = Math.round(level * 100)
198
+ const markerColor = getGaugeZoneColor(level)
199
+ return (
200
+ <View
201
+ style={{ gap: 6 }}
202
+ accessibilityRole="progressbar"
203
+ accessibilityValue={{ min: 0, max: 100, now: percentage }}
204
+ accessibilityLabel={`${gauge.label}: ${percentage}%`}
205
+ aria-valuenow={percentage}
206
+ aria-valuemin={0}
207
+ aria-valuemax={100}
208
+ testID="meso-status-card-gauge"
209
+ >
210
+ <View
211
+ className="flex-row items-center justify-between"
212
+ accessibilityElementsHidden
213
+ >
214
+ <Text
215
+ style={{
216
+ fontSize: 10,
217
+ fontFamily: 'Inter, sans-serif',
218
+ fontWeight: '500',
219
+ letterSpacing: 0.5,
220
+ textTransform: 'uppercase',
221
+ color: TEXT_SECONDARY,
222
+ }}
223
+ >
224
+ {gauge.label}
225
+ </Text>
226
+ {gauge.sublabel != null && (
227
+ <Text
228
+ style={{
229
+ fontSize: 10,
230
+ fontFamily: 'Inter, sans-serif',
231
+ fontWeight: '500',
232
+ color: TEXT_TERTIARY,
233
+ }}
234
+ >
235
+ {gauge.sublabel}
236
+ </Text>
237
+ )}
238
+ </View>
239
+
240
+ <View
241
+ style={
242
+ {
243
+ height: 8,
244
+ borderRadius: 4,
245
+ position: 'relative',
246
+ backgroundImage: GAUGE_GRADIENT,
247
+ } as ViewStyle
248
+ }
249
+ accessibilityElementsHidden
250
+ >
251
+ <View
252
+ style={{
253
+ position: 'absolute',
254
+ left: '50%',
255
+ top: 0,
256
+ bottom: 0,
257
+ width: 1,
258
+ marginLeft: -0.5,
259
+ backgroundColor: 'rgba(255,255,255,0.3)',
260
+ }}
261
+ testID="meso-status-card-gauge-center"
262
+ />
263
+ <View
264
+ style={{
265
+ position: 'absolute',
266
+ left: `${percentage}%`,
267
+ top: -3,
268
+ marginLeft: -7,
269
+ width: 14,
270
+ height: 14,
271
+ borderRadius: 9999,
272
+ borderWidth: 2,
273
+ borderColor: TEXT_PRIMARY,
274
+ backgroundColor: markerColor,
275
+ boxShadow: '0 0 4px rgba(0,0,0,0.5)',
276
+ }}
277
+ testID="meso-status-card-gauge-marker"
278
+ />
279
+ </View>
280
+ </View>
281
+ )
282
+ }
283
+
284
+ /**
285
+ * Mesocycle context card for a specific exercise: prescription vs actual
286
+ * metrics, intensity/volume gauges, and coaching guidance. Composes the titan
287
+ * `Card` plus the Workout `StatusDot`, and renders the §25 gauge (8px track,
288
+ * 50% center line, 14px marker) inline since that visual differs from the
289
+ * IntensityBar/DeviationBar primitives.
290
+ *
291
+ * @example
292
+ * <MesoStatusCard
293
+ * mesoName="Hypertrophy Block"
294
+ * mesoSubtitle="Week 6 of 8 · Upper/Lower"
295
+ * statusBadge={{ label: 'On Track', variant: 'success' }}
296
+ * metrics={[
297
+ * { label: 'Prescribed', value: '3×8 @ RPE 8' },
298
+ * { label: 'Actual', value: '3×8 @ 185 lbs' },
299
+ * ]}
300
+ * gauges={[{ label: 'Intensity', level: 0.7, sublabel: 'Actual' }]}
301
+ * coaching={{ text: 'Add load next session.', highlights: ['Add load'] }}
302
+ * nextTarget={{ icon: '→', text: '190 lbs × 8' }}
303
+ * />
304
+ */
305
+ export function MesoStatusCard({
306
+ mesoName,
307
+ mesoSubtitle,
308
+ statusBadge,
309
+ metrics,
310
+ gauges,
311
+ coaching,
312
+ nextTarget,
313
+ className,
314
+ ...props
315
+ }: MesoStatusCardProps) {
316
+ return (
317
+ <Card
318
+ variant="outline"
319
+ elevation={1}
320
+ borderColor={BRAND_PRIMARY_DARK}
321
+ className={className}
322
+ style={
323
+ {
324
+ borderColor: BRAND_PRIMARY_DARK,
325
+ borderWidth: 1,
326
+ backgroundImage: CARD_GRADIENT,
327
+ } as ViewStyle
328
+ }
329
+ role="article"
330
+ aria-label={`Mesocycle status for ${mesoName}`}
331
+ testID="meso-status-card"
332
+ {...props}
333
+ >
334
+ <View
335
+ className="flex-row"
336
+ style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 3, zIndex: 1 }}
337
+ accessibilityElementsHidden
338
+ testID="meso-status-card-accent"
339
+ >
340
+ {ACCENT_STOPS.map((color) => (
341
+ <View key={color} style={{ flex: 1, backgroundColor: color }} />
342
+ ))}
343
+ </View>
344
+
345
+ <View style={{ padding: 14, gap: 14 }} testID="meso-status-card-body">
346
+ <View testID="meso-status-card-header">
347
+ <View
348
+ className="flex-row items-center justify-between"
349
+ style={{ gap: 8 }}
350
+ >
351
+ <Text
352
+ style={{
353
+ flexShrink: 1,
354
+ fontSize: 15,
355
+ fontFamily: '"Space Grotesk", sans-serif',
356
+ fontWeight: '700',
357
+ color: TEXT_PRIMARY,
358
+ }}
359
+ testID="meso-status-card-name"
360
+ >
361
+ {mesoName}
362
+ </Text>
363
+ <StatusPill badge={statusBadge} />
364
+ </View>
365
+ <Text
366
+ style={{
367
+ marginTop: 4,
368
+ fontSize: 12,
369
+ fontFamily: 'Inter, sans-serif',
370
+ color: TEXT_SECONDARY,
371
+ }}
372
+ testID="meso-status-card-subtitle"
373
+ >
374
+ {mesoSubtitle}
375
+ </Text>
376
+ </View>
377
+
378
+ {metrics.length > 0 && (
379
+ <View
380
+ className="flex-row flex-wrap"
381
+ style={{ gap: 8 }}
382
+ testID="meso-status-card-metrics"
383
+ >
384
+ {metrics.map((metric, index) => (
385
+ <MetricCell key={`${metric.label}-${index}`} metric={metric} />
386
+ ))}
387
+ </View>
388
+ )}
389
+
390
+ {gauges.length > 0 && (
391
+ <View style={{ gap: 12 }} testID="meso-status-card-gauges">
392
+ {gauges.map((gauge, index) => (
393
+ <Gauge key={`${gauge.label}-${index}`} gauge={gauge} />
394
+ ))}
395
+ </View>
396
+ )}
397
+
398
+ {coaching != null && (
399
+ <View
400
+ style={{
401
+ backgroundColor: 'rgba(255,176,32,0.06)',
402
+ borderWidth: 1,
403
+ borderColor: 'rgba(255,176,32,0.15)',
404
+ borderRadius: 8,
405
+ paddingVertical: 10,
406
+ paddingHorizontal: 12,
407
+ }}
408
+ testID="meso-status-card-coaching"
409
+ >
410
+ <Text
411
+ style={{
412
+ fontSize: 12,
413
+ lineHeight: 17,
414
+ fontFamily: 'Inter, sans-serif',
415
+ color: TEXT_SECONDARY,
416
+ }}
417
+ >
418
+ {renderCoachingText(coaching.text, coaching.highlights)}
419
+ </Text>
420
+ </View>
421
+ )}
422
+
423
+ {nextTarget != null && (
424
+ <View
425
+ className="flex-row items-center"
426
+ style={{
427
+ gap: 8,
428
+ backgroundColor: 'rgba(20,184,166,0.06)',
429
+ borderWidth: 1,
430
+ borderColor: 'rgba(20,184,166,0.2)',
431
+ borderRadius: 8,
432
+ paddingVertical: 10,
433
+ paddingHorizontal: 12,
434
+ }}
435
+ testID="meso-status-card-next-target"
436
+ >
437
+ <Text style={{ fontSize: 14, color: SUCCESS }} accessibilityElementsHidden>
438
+ {nextTarget.icon}
439
+ </Text>
440
+ <Text
441
+ style={{
442
+ flexShrink: 1,
443
+ fontSize: 12,
444
+ fontFamily: 'Inter, sans-serif',
445
+ fontWeight: '600',
446
+ color: SUCCESS,
447
+ }}
448
+ >
449
+ {nextTarget.text}
450
+ </Text>
451
+ </View>
452
+ )}
453
+ </View>
454
+ </Card>
455
+ )
456
+ }
@@ -0,0 +1,118 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { MuscleGroupChip, type VolumeStatus } from './MuscleGroupChip'
4
+
5
+ const meta: Meta<typeof MuscleGroupChip> = {
6
+ title: 'Custom/Workout/MuscleGroupChip',
7
+ component: MuscleGroupChip,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ name: {
11
+ control: 'text',
12
+ description: 'Muscle group name',
13
+ },
14
+ volumeStatus: {
15
+ control: 'select',
16
+ options: [undefined, 'untrained', 'behind', 'ontrack', 'target', 'over'],
17
+ description: 'Volume status determines dot color',
18
+ },
19
+ onPress: {
20
+ action: 'pressed',
21
+ description: 'Callback when chip is tapped',
22
+ },
23
+ },
24
+ }
25
+
26
+ export default meta
27
+ type Story = StoryObj<typeof MuscleGroupChip>
28
+
29
+ export const Default: Story = {
30
+ args: {
31
+ name: 'Chest',
32
+ volumeStatus: 'ontrack',
33
+ },
34
+ }
35
+
36
+ export const Untrained: Story = {
37
+ args: {
38
+ name: 'Rear Delts',
39
+ volumeStatus: 'untrained',
40
+ },
41
+ }
42
+
43
+ export const Behind: Story = {
44
+ args: {
45
+ name: 'Quads',
46
+ volumeStatus: 'behind',
47
+ },
48
+ }
49
+
50
+ export const OnTrack: Story = {
51
+ args: {
52
+ name: 'Back',
53
+ volumeStatus: 'ontrack',
54
+ },
55
+ }
56
+
57
+ export const Target: Story = {
58
+ args: {
59
+ name: 'Biceps',
60
+ volumeStatus: 'target',
61
+ },
62
+ }
63
+
64
+ export const Over: Story = {
65
+ args: {
66
+ name: 'Front Delts',
67
+ volumeStatus: 'over',
68
+ },
69
+ }
70
+
71
+ export const WithoutStatus: Story = {
72
+ args: {
73
+ name: 'Hamstrings',
74
+ },
75
+ }
76
+
77
+ export const Tappable: Story = {
78
+ args: {
79
+ name: 'Glutes',
80
+ volumeStatus: 'ontrack',
81
+ onPress: () => {},
82
+ },
83
+ }
84
+
85
+ const allStatuses: VolumeStatus[] = [
86
+ 'untrained',
87
+ 'behind',
88
+ 'ontrack',
89
+ 'target',
90
+ 'over',
91
+ ]
92
+
93
+ export const AllStatuses: Story = {
94
+ render: () => (
95
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
96
+ {allStatuses.map((status) => (
97
+ <MuscleGroupChip
98
+ key={status}
99
+ name={status.charAt(0).toUpperCase() + status.slice(1)}
100
+ volumeStatus={status}
101
+ />
102
+ ))}
103
+ <MuscleGroupChip name="No Status" />
104
+ </View>
105
+ ),
106
+ }
107
+
108
+ export const MuscleGroups: Story = {
109
+ render: () => (
110
+ <View style={{ flexDirection: 'row', gap: 8, flexWrap: 'wrap' }}>
111
+ <MuscleGroupChip name="Chest" volumeStatus="ontrack" />
112
+ <MuscleGroupChip name="Triceps" volumeStatus="target" />
113
+ <MuscleGroupChip name="Front Delts" volumeStatus="over" />
114
+ <MuscleGroupChip name="Side Delts" volumeStatus="behind" />
115
+ <MuscleGroupChip name="Abs" volumeStatus="untrained" />
116
+ </View>
117
+ ),
118
+ }
@@ -0,0 +1,91 @@
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 { MuscleGroupChip } from './MuscleGroupChip'
5
+
6
+ describe('MuscleGroupChip', () => {
7
+ it('renders muscle group name', () => {
8
+ render(<MuscleGroupChip name="Chest" />)
9
+ expect(screen.getByText('Chest')).toBeInTheDocument()
10
+ })
11
+
12
+ it('renders the colored dot', () => {
13
+ render(<MuscleGroupChip name="Quads" volumeStatus="ontrack" />)
14
+ expect(screen.getByTestId('muscle-group-chip-dot')).toBeInTheDocument()
15
+ })
16
+
17
+ it('sets accessibility label with name and status', () => {
18
+ render(<MuscleGroupChip name="Back" volumeStatus="behind" />)
19
+ expect(
20
+ screen.getByLabelText('Back, volume status: behind'),
21
+ ).toBeInTheDocument()
22
+ })
23
+
24
+ it('sets accessibility label with no status when omitted', () => {
25
+ render(<MuscleGroupChip name="Biceps" />)
26
+ expect(
27
+ screen.getByLabelText('Biceps, volume status: no status'),
28
+ ).toBeInTheDocument()
29
+ })
30
+
31
+ it('wraps in Pressable when onPress is provided', () => {
32
+ const onPress = vi.fn()
33
+ render(<MuscleGroupChip name="Chest" onPress={onPress} />)
34
+ const pressable = screen.getByTestId('muscle-group-chip-pressable')
35
+ fireEvent.click(pressable)
36
+ expect(onPress).toHaveBeenCalledOnce()
37
+ })
38
+
39
+ it('does not render Pressable when onPress is omitted', () => {
40
+ render(<MuscleGroupChip name="Chest" />)
41
+ expect(
42
+ screen.queryByTestId('muscle-group-chip-pressable'),
43
+ ).not.toBeInTheDocument()
44
+ })
45
+
46
+ it('renders all volume status variants without error', () => {
47
+ const statuses = [
48
+ 'untrained',
49
+ 'behind',
50
+ 'ontrack',
51
+ 'target',
52
+ 'over',
53
+ ] as const
54
+ const { rerender } = render(
55
+ <MuscleGroupChip name="Test" volumeStatus="untrained" />,
56
+ )
57
+ for (const status of statuses) {
58
+ rerender(<MuscleGroupChip name="Test" volumeStatus={status} />)
59
+ expect(screen.getByText('Test')).toBeInTheDocument()
60
+ }
61
+ })
62
+
63
+ describe('accessibility', () => {
64
+ it('has no accessibility violations', async () => {
65
+ const { container } = render(
66
+ <MuscleGroupChip
67
+ name="Chest"
68
+ volumeStatus="ontrack"
69
+ onPress={() => {}}
70
+ />,
71
+ )
72
+ const results = await axe(container)
73
+ expect(results).toHaveNoViolations()
74
+ })
75
+
76
+ it('has no accessibility violations as static chip', async () => {
77
+ const { container } = render(
78
+ <MuscleGroupChip name="Quads" volumeStatus="behind" />,
79
+ )
80
+ const results = await axe(container)
81
+ expect(results).toHaveNoViolations()
82
+ })
83
+
84
+ it('pressable has correct accessibility label', () => {
85
+ render(
86
+ <MuscleGroupChip name="Back" volumeStatus="target" onPress={() => {}} />,
87
+ )
88
+ expect(screen.getByLabelText('Back, volume status: target')).toBeInTheDocument()
89
+ })
90
+ })
91
+ })