@titan-design/react-ui 0.2.5 → 0.2.7

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 (158) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/docs/WEB_SETUP.md +217 -0
  23. package/package.json +17 -5
  24. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  25. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  26. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  27. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  28. package/src/components/custom/Gauge/index.ts +2 -0
  29. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  30. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  31. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  32. package/src/components/custom/Scatter/index.ts +2 -0
  33. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  34. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  35. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  36. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  37. package/src/components/custom/Treemap/index.ts +2 -0
  38. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  39. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  40. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  41. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  42. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  43. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  46. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  47. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  48. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  49. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  50. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  51. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  52. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  53. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  54. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  55. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  56. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  57. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  58. package/src/components/custom/Workout/InputBar.tsx +163 -0
  59. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  60. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  61. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  62. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  63. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  64. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  65. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  66. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  67. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  68. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  69. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  70. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  73. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  76. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  77. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  78. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  79. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  80. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  81. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  82. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  83. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  84. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  85. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  86. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  87. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  88. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  89. package/src/components/custom/Workout/SetRow.tsx +253 -0
  90. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  91. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  92. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  93. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  94. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  95. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  98. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  99. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  100. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  101. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  102. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  103. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  104. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  105. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  106. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  107. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  108. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  109. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  110. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  111. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  112. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  113. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  114. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  115. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  116. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  117. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  118. package/src/components/custom/Workout/index.ts +113 -0
  119. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  120. package/src/components/custom/index.ts +3 -0
  121. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  122. package/src/components/custom/stepper/Stepper.tsx +2 -2
  123. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  124. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  125. package/src/components/ui/card/Card.tsx +1 -1
  126. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  127. package/src/components/ui/collapse/Collapse.tsx +1 -1
  128. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  129. package/src/components/ui/drawer/Drawer.tsx +3 -3
  130. package/src/components/ui/form-field/FormField.tsx +1 -1
  131. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  132. package/src/components/ui/menu/Menu.tsx +2 -2
  133. package/src/components/ui/pill/Pill.tsx +1 -1
  134. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  135. package/src/components/ui/popover/Popover.tsx +1 -1
  136. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  137. package/src/components/ui/section/Section.stories.tsx +4 -4
  138. package/src/components/ui/select/Select.tsx +1 -1
  139. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  140. package/src/components/ui/tabs/Tabs.tsx +2 -2
  141. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  142. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  143. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  144. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  145. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  146. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  147. package/src/stories/PresetShowcase.stories.tsx +15 -15
  148. package/src/theme/index.ts +2 -1
  149. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  150. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  151. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  152. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  153. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  154. package/src/theme/manifest/index.ts +11 -0
  155. package/src/theme/tokens-css.test.ts +28 -0
  156. package/src/theme/tokens-css.ts +34 -0
  157. package/src/theme/workout-tokens.ts +61 -0
  158. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,628 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { useEffect, useMemo, useState } from 'react'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { cn } from '../../../utils/cn'
5
+
6
+ const BRAND_PRIMARY = '#FF7900'
7
+ const TEXT_PRIMARY = '#F3F4F6'
8
+ const TEXT_SECONDARY = '#9CA3AF'
9
+ const TEXT_TERTIARY = '#6B7280'
10
+ const BORDER_STRONG = '#2C2C2C'
11
+ const STATUS_SUCCESS = '#14B8A6'
12
+ const STATUS_ERROR = '#D14343'
13
+ const STATUS_WARNING = '#FFB020'
14
+ const GRID_LINE = 'rgba(255,255,255,0.06)'
15
+ const TOOLTIP_BG = 'rgba(28,28,28,0.95)'
16
+ const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
17
+ const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
18
+ const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
19
+ const ERROR_PILL_BORDER = 'rgba(209,67,67,0.20)'
20
+
21
+ /** Left gutter reserved for y-axis value labels. */
22
+ const PLOT_LEFT = 26
23
+ /** Approximate tooltip width used for edge clamping. */
24
+ const TOOLTIP_WIDTH = 124
25
+
26
+ export interface StrengthTrendDataPoint {
27
+ /** ISO date string for the session. */
28
+ date: string
29
+ /** Estimated one-rep max in the chart's unit. */
30
+ e1rm: number
31
+ /** Marks a personal-record session (rendered with a star). */
32
+ isPR?: boolean
33
+ /** Optional human label shown in the tooltip instead of the raw date. */
34
+ sessionLabel?: string
35
+ }
36
+
37
+ export interface StrengthTrendChartMesoBoundary {
38
+ /** ISO date where the mesocycle boundary falls. */
39
+ date: string
40
+ /** Short label, e.g. "Hypertrophy". */
41
+ label: string
42
+ }
43
+
44
+ export interface StrengthTrendChartProps extends ViewProps {
45
+ /** Actual e1RM data points, chronological. */
46
+ data: StrengthTrendDataPoint[]
47
+ /** Projected/planned e1RM trajectory, drawn as a dashed line. */
48
+ projection?: StrengthTrendDataPoint[]
49
+ /** Chart plot width in pixels. */
50
+ width: number
51
+ /** Chart plot height in pixels (120-160 for the compact variant). */
52
+ height: number
53
+ /** Mesocycle boundary markers (vertical guides + labels). */
54
+ mesoBoundaries?: StrengthTrendChartMesoBoundary[]
55
+ /** Called when an actual data point is tapped. */
56
+ onPointPress?: (point: StrengthTrendDataPoint) => void
57
+ /** Unit for display and labels. */
58
+ unit: 'lbs' | 'kg'
59
+ /** Animate the left-to-right line draw on mount (default true). */
60
+ animateOnMount?: boolean
61
+ className?: string
62
+ }
63
+
64
+ interface Coord {
65
+ x: number
66
+ y: number
67
+ point: StrengthTrendDataPoint
68
+ index: number
69
+ }
70
+
71
+ interface Geometry {
72
+ hasData: boolean
73
+ toX: (time: number) => number
74
+ toY: (value: number) => number
75
+ actual: Coord[]
76
+ projection: Coord[]
77
+ gridLines: Array<{ y: number; label: string }>
78
+ boundaries: Array<{ x: number; label: string }>
79
+ }
80
+
81
+ function timeOf(dateStr: string): number {
82
+ const t = Date.parse(dateStr)
83
+ return Number.isNaN(t) ? 0 : t
84
+ }
85
+
86
+ function formatValue(value: number): string {
87
+ return `${Math.round(value)}`
88
+ }
89
+
90
+ function buildGeometry(
91
+ data: StrengthTrendDataPoint[],
92
+ projection: StrengthTrendDataPoint[],
93
+ boundaries: StrengthTrendChartMesoBoundary[],
94
+ width: number,
95
+ height: number,
96
+ ): Geometry {
97
+ const values = [...data, ...projection].map((p) => p.e1rm)
98
+ // Empty state keys off actual data only: a projection with no measured
99
+ // sessions still shows the placeholder rather than a "Current: 0" chart.
100
+ if (data.length === 0) {
101
+ return {
102
+ hasData: false,
103
+ toX: () => 0,
104
+ toY: () => 0,
105
+ actual: [],
106
+ projection: [],
107
+ gridLines: [],
108
+ boundaries: [],
109
+ }
110
+ }
111
+
112
+ const times = [
113
+ ...data.map((p) => timeOf(p.date)),
114
+ ...projection.map((p) => timeOf(p.date)),
115
+ ...boundaries.map((b) => timeOf(b.date)),
116
+ ]
117
+ const tMin = Math.min(...times)
118
+ const tMaxRaw = Math.max(...times)
119
+ const tMax = tMaxRaw === tMin ? tMin + 1 : tMaxRaw
120
+
121
+ const yMin = Math.min(...values)
122
+ const yMax = Math.max(...values)
123
+ const range = yMax - yMin || Math.max(1, yMax * 0.1)
124
+ const padV = range * 0.15
125
+ const domainMin = yMin - padV
126
+ const domainMax = yMax + padV
127
+
128
+ const innerW = Math.max(1, width - PLOT_LEFT)
129
+ const toX = (time: number) =>
130
+ PLOT_LEFT + ((time - tMin) / (tMax - tMin)) * innerW
131
+ const toY = (value: number) =>
132
+ height - ((value - domainMin) / (domainMax - domainMin)) * height
133
+
134
+ const project = (points: StrengthTrendDataPoint[]): Coord[] =>
135
+ points.map((point, index) => ({
136
+ x: toX(timeOf(point.date)),
137
+ y: toY(point.e1rm),
138
+ point,
139
+ index,
140
+ }))
141
+
142
+ const tickValues = Array.from(
143
+ new Set([yMax, (yMin + yMax) / 2, yMin].map((v) => Math.round(v))),
144
+ )
145
+ const gridLines = tickValues.map((v) => ({ y: toY(v), label: formatValue(v) }))
146
+
147
+ return {
148
+ hasData: true,
149
+ toX,
150
+ toY,
151
+ actual: project(data),
152
+ projection: project(projection),
153
+ gridLines,
154
+ boundaries: boundaries.map((b) => ({
155
+ x: toX(timeOf(b.date)),
156
+ label: b.label,
157
+ })),
158
+ }
159
+ }
160
+
161
+ function computeTrend(
162
+ data: StrengthTrendDataPoint[],
163
+ boundaries: StrengthTrendChartMesoBoundary[],
164
+ ): { percent: number; positive: boolean } {
165
+ if (data.length < 2) return { percent: 0, positive: true }
166
+ let series = data
167
+ if (boundaries.length > 0) {
168
+ const lastBoundary = Math.max(...boundaries.map((b) => timeOf(b.date)))
169
+ const filtered = data.filter((p) => timeOf(p.date) >= lastBoundary)
170
+ // The pill is labelled "this meso"; if the current meso has <2 points
171
+ // there is no meso trend to show — stay neutral rather than silently
172
+ // reporting the whole-series change under a "this meso" label.
173
+ if (filtered.length < 2) return { percent: 0, positive: true }
174
+ series = filtered
175
+ }
176
+ const first = series[0].e1rm
177
+ const last = series[series.length - 1].e1rm
178
+ const percent = first ? ((last - first) / first) * 100 : 0
179
+ return { percent, positive: percent >= 0 }
180
+ }
181
+
182
+ function ChartLine({
183
+ coords,
184
+ color,
185
+ strokeWidth,
186
+ dashed,
187
+ testID,
188
+ }: {
189
+ coords: Coord[]
190
+ color: string
191
+ strokeWidth: number
192
+ dashed?: boolean
193
+ testID: string
194
+ }) {
195
+ return (
196
+ <>
197
+ {coords.map((c, i) => {
198
+ if (i === 0) return null
199
+ const prev = coords[i - 1]
200
+ const dx = c.x - prev.x
201
+ const dy = c.y - prev.y
202
+ const length = Math.sqrt(dx * dx + dy * dy)
203
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI)
204
+ return (
205
+ <View
206
+ key={`${testID}-${i}`}
207
+ testID={testID}
208
+ accessibilityElementsHidden
209
+ style={[
210
+ {
211
+ position: 'absolute',
212
+ left: prev.x,
213
+ top: prev.y,
214
+ width: length,
215
+ transformOrigin: '0 0',
216
+ transform: [{ rotate: `${angle}deg` }],
217
+ },
218
+ dashed
219
+ ? {
220
+ height: 0,
221
+ borderTopWidth: strokeWidth,
222
+ borderStyle: 'dashed',
223
+ borderTopColor: color,
224
+ }
225
+ : {
226
+ height: strokeWidth,
227
+ borderRadius: strokeWidth / 2,
228
+ backgroundColor: color,
229
+ },
230
+ ]}
231
+ />
232
+ )
233
+ })}
234
+ </>
235
+ )
236
+ }
237
+
238
+ /**
239
+ * Estimated-1RM line chart over time with an optional dashed plan-projection
240
+ * line and PR star markers. Rendered entirely with absolutely-positioned
241
+ * Views (no SVG), following the Sparkline approach. Tapping an actual point
242
+ * opens a tooltip and fires `onPointPress`. A trend pill below the chart
243
+ * summarizes the percentage change for the current mesocycle.
244
+ *
245
+ * @example
246
+ * <StrengthTrendChart
247
+ * data={[{ date: '2026-01-01', e1rm: 225 }, { date: '2026-02-01', e1rm: 245, isPR: true }]}
248
+ * projection={[{ date: '2026-02-01', e1rm: 245 }, { date: '2026-03-01', e1rm: 260 }]}
249
+ * width={320}
250
+ * height={150}
251
+ * unit="lbs"
252
+ * />
253
+ */
254
+ export function StrengthTrendChart({
255
+ data,
256
+ projection = [],
257
+ width,
258
+ height,
259
+ mesoBoundaries = [],
260
+ onPointPress,
261
+ unit,
262
+ animateOnMount = true,
263
+ className,
264
+ ...props
265
+ }: StrengthTrendChartProps) {
266
+ const geometry = useMemo(
267
+ () => buildGeometry(data, projection, mesoBoundaries, width, height),
268
+ [data, projection, mesoBoundaries, width, height],
269
+ )
270
+ const trend = useMemo(
271
+ () => computeTrend(data, mesoBoundaries),
272
+ [data, mesoBoundaries],
273
+ )
274
+
275
+ const [reveal] = useState(() => new Animated.Value(animateOnMount ? 0 : 1))
276
+ const [selected, setSelected] = useState<number | null>(null)
277
+
278
+ useEffect(() => {
279
+ if (!animateOnMount) return
280
+ const animation = Animated.timing(reveal, {
281
+ toValue: 1,
282
+ duration: 600,
283
+ easing: Easing.out(Easing.ease),
284
+ useNativeDriver: false,
285
+ })
286
+ animation.start()
287
+ return () => animation.stop()
288
+ }, [animateOnMount, reveal])
289
+
290
+ const current = data.length > 0 ? data[data.length - 1].e1rm : 0
291
+ const trendSign = trend.percent >= 0 ? '+' : '-'
292
+ const trendText = `${trendSign}${Math.abs(trend.percent).toFixed(1)}% this meso`
293
+ const ariaLabel = geometry.hasData
294
+ ? `Strength trend chart showing estimated one rep max over time. Current: ${formatValue(current)} ${unit}. Trend: ${trendSign}${Math.abs(trend.percent).toFixed(1)}%`
295
+ : 'Strength trend chart, no data'
296
+
297
+ if (!geometry.hasData) {
298
+ return (
299
+ <View
300
+ style={{ width }}
301
+ className={cn(className)}
302
+ accessibilityRole="image"
303
+ accessibilityLabel={ariaLabel}
304
+ testID="strength-trend-chart-empty"
305
+ {...props}
306
+ >
307
+ <View
308
+ style={{
309
+ width,
310
+ height,
311
+ alignItems: 'center',
312
+ justifyContent: 'center',
313
+ }}
314
+ >
315
+ <Text style={{ fontSize: 12, fontFamily: 'Inter, sans-serif', color: TEXT_TERTIARY }}>
316
+ No strength data yet
317
+ </Text>
318
+ </View>
319
+ </View>
320
+ )
321
+ }
322
+
323
+ const revealWidth = reveal.interpolate({
324
+ inputRange: [0, 1],
325
+ outputRange: [0, width],
326
+ })
327
+
328
+ const selectedCoord =
329
+ selected != null ? geometry.actual[selected] : undefined
330
+
331
+ return (
332
+ <View
333
+ style={{ width }}
334
+ className={cn(className)}
335
+ testID="strength-trend-chart"
336
+ {...props}
337
+ >
338
+ <View style={{ width, height, position: 'relative' }}>
339
+ {/* Labelled, non-interactive chart canvas (grid + lines + markers). */}
340
+ <View
341
+ style={{ position: 'absolute', top: 0, left: 0, width, height }}
342
+ accessibilityRole="image"
343
+ accessibilityLabel={ariaLabel}
344
+ testID="strength-trend-chart-canvas"
345
+ >
346
+ {geometry.gridLines.map((line, i) => (
347
+ <View
348
+ key={`grid-${i}`}
349
+ accessibilityElementsHidden
350
+ testID="strength-trend-chart-gridline"
351
+ style={{
352
+ position: 'absolute',
353
+ left: PLOT_LEFT,
354
+ right: 0,
355
+ top: line.y,
356
+ height: 1,
357
+ backgroundColor: GRID_LINE,
358
+ }}
359
+ >
360
+ <Text
361
+ style={{
362
+ position: 'absolute',
363
+ left: -PLOT_LEFT,
364
+ top: -6,
365
+ width: PLOT_LEFT - 4,
366
+ textAlign: 'right',
367
+ fontSize: 9,
368
+ fontFamily: 'Inter, sans-serif',
369
+ color: TEXT_TERTIARY,
370
+ }}
371
+ >
372
+ {line.label}
373
+ </Text>
374
+ </View>
375
+ ))}
376
+
377
+ {geometry.boundaries.map((boundary, i) => (
378
+ <View
379
+ key={`boundary-${i}`}
380
+ accessibilityElementsHidden
381
+ testID="strength-trend-chart-meso-boundary"
382
+ style={{
383
+ position: 'absolute',
384
+ left: boundary.x,
385
+ top: 0,
386
+ width: 0,
387
+ height,
388
+ borderLeftWidth: 1,
389
+ borderStyle: 'dashed',
390
+ borderLeftColor: 'rgba(255,255,255,0.10)',
391
+ }}
392
+ />
393
+ ))}
394
+
395
+ <Animated.View
396
+ style={{
397
+ position: 'absolute',
398
+ top: 0,
399
+ left: 0,
400
+ width: revealWidth,
401
+ height,
402
+ overflow: 'hidden',
403
+ }}
404
+ testID="strength-trend-chart-reveal"
405
+ >
406
+ <View style={{ width, height, position: 'absolute', top: 0, left: 0 }}>
407
+ <ChartLine
408
+ coords={geometry.projection}
409
+ color={TEXT_TERTIARY}
410
+ strokeWidth={2}
411
+ dashed
412
+ testID="strength-trend-chart-projection-segment"
413
+ />
414
+ <ChartLine
415
+ coords={geometry.actual}
416
+ color={BRAND_PRIMARY}
417
+ strokeWidth={2.5}
418
+ testID="strength-trend-chart-actual-segment"
419
+ />
420
+ </View>
421
+ </Animated.View>
422
+
423
+ {geometry.actual.map((c) =>
424
+ c.point.isPR ? (
425
+ <Text
426
+ key={`star-${c.index}`}
427
+ accessibilityElementsHidden
428
+ testID="strength-trend-chart-pr-star"
429
+ style={{
430
+ position: 'absolute',
431
+ left: c.x - 7,
432
+ top: c.y - 20,
433
+ fontSize: 12,
434
+ color: STATUS_WARNING,
435
+ }}
436
+ >
437
+
438
+ </Text>
439
+ ) : null,
440
+ )}
441
+ </View>
442
+
443
+ {/* Interactive point overlay (kept out of the image-role canvas). */}
444
+ <View
445
+ style={{ position: 'absolute', top: 0, left: 0, width, height }}
446
+ testID="strength-trend-chart-points"
447
+ >
448
+ {geometry.actual.map((c) => {
449
+ const label = c.point.sessionLabel ?? c.point.date
450
+ return (
451
+ <Pressable
452
+ key={`point-${c.index}`}
453
+ testID="strength-trend-chart-point"
454
+ accessibilityRole="button"
455
+ accessibilityLabel={`${label}: ${formatValue(c.point.e1rm)} ${unit}${c.point.isPR ? ', personal record' : ''}`}
456
+ onPress={() => {
457
+ setSelected(c.index)
458
+ onPointPress?.(c.point)
459
+ }}
460
+ style={{
461
+ position: 'absolute',
462
+ left: c.x - 4,
463
+ top: c.y - 4,
464
+ width: 8,
465
+ height: 8,
466
+ borderRadius: 1,
467
+ backgroundColor: BRAND_PRIMARY,
468
+ }}
469
+ />
470
+ )
471
+ })}
472
+ </View>
473
+
474
+ {selectedCoord && (
475
+ <View
476
+ testID="strength-trend-chart-tooltip"
477
+ accessibilityElementsHidden
478
+ style={{
479
+ position: 'absolute',
480
+ left: Math.max(
481
+ 0,
482
+ Math.min(width - TOOLTIP_WIDTH, selectedCoord.x - TOOLTIP_WIDTH / 2),
483
+ ),
484
+ top: Math.max(0, selectedCoord.y - 52),
485
+ maxWidth: TOOLTIP_WIDTH,
486
+ backgroundColor: TOOLTIP_BG,
487
+ borderWidth: 1,
488
+ borderColor: BORDER_STRONG,
489
+ borderRadius: 8,
490
+ paddingVertical: 8,
491
+ paddingHorizontal: 10,
492
+ }}
493
+ >
494
+ <Text
495
+ style={{
496
+ fontSize: 10,
497
+ fontFamily: 'Inter, sans-serif',
498
+ color: TEXT_TERTIARY,
499
+ }}
500
+ >
501
+ {selectedCoord.point.sessionLabel ?? selectedCoord.point.date}
502
+ </Text>
503
+ <Text
504
+ style={{
505
+ marginTop: 2,
506
+ fontSize: 13,
507
+ fontFamily: 'Inter, sans-serif',
508
+ fontWeight: '700',
509
+ color: TEXT_PRIMARY,
510
+ }}
511
+ >
512
+ {`${formatValue(selectedCoord.point.e1rm)} ${unit}`}
513
+ </Text>
514
+ {selected != null && selected > 0 && (
515
+ <Text
516
+ style={{
517
+ marginTop: 1,
518
+ fontSize: 10,
519
+ fontFamily: 'Inter, sans-serif',
520
+ color:
521
+ selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm >= 0
522
+ ? STATUS_SUCCESS
523
+ : STATUS_ERROR,
524
+ }}
525
+ >
526
+ {`${
527
+ selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm >= 0
528
+ ? '+'
529
+ : '-'
530
+ }${Math.abs(
531
+ selectedCoord.point.e1rm - geometry.actual[selected - 1].point.e1rm,
532
+ ).toFixed(1)} ${unit}`}
533
+ </Text>
534
+ )}
535
+ </View>
536
+ )}
537
+ </View>
538
+
539
+ {/* Meso boundary x-axis labels. */}
540
+ {geometry.boundaries.length > 0 && (
541
+ <View
542
+ style={{ width, height: 14, position: 'relative', marginTop: 2 }}
543
+ accessibilityElementsHidden
544
+ testID="strength-trend-chart-axis"
545
+ >
546
+ {geometry.boundaries.map((boundary, i) => (
547
+ <Text
548
+ key={`axis-${i}`}
549
+ testID="strength-trend-chart-axis-label"
550
+ style={{
551
+ position: 'absolute',
552
+ left: boundary.x - 24,
553
+ width: 48,
554
+ textAlign: 'center',
555
+ fontSize: 10,
556
+ fontFamily: 'Inter, sans-serif',
557
+ color: TEXT_TERTIARY,
558
+ }}
559
+ >
560
+ {boundary.label}
561
+ </Text>
562
+ ))}
563
+ </View>
564
+ )}
565
+
566
+ {/* Trend pill. */}
567
+ <View className="flex-row" style={{ marginTop: 8 }}>
568
+ <View
569
+ testID="strength-trend-chart-trend-pill"
570
+ accessibilityLabel={`Strength trend: ${trendText}`}
571
+ style={{
572
+ paddingVertical: 3,
573
+ paddingHorizontal: 8,
574
+ borderRadius: 4,
575
+ borderWidth: 1,
576
+ backgroundColor: trend.positive ? SUCCESS_PILL_BG : ERROR_PILL_BG,
577
+ borderColor: trend.positive ? SUCCESS_PILL_BORDER : ERROR_PILL_BORDER,
578
+ }}
579
+ >
580
+ <Text
581
+ style={{
582
+ fontSize: 11,
583
+ fontFamily: 'Inter, sans-serif',
584
+ fontWeight: '600',
585
+ color: trend.positive ? STATUS_SUCCESS : STATUS_ERROR,
586
+ }}
587
+ >
588
+ {trendText}
589
+ </Text>
590
+ </View>
591
+ </View>
592
+
593
+ {/* Legend. */}
594
+ <View
595
+ className="flex-row items-center"
596
+ style={{ marginTop: 8, gap: 14 }}
597
+ testID="strength-trend-chart-legend"
598
+ >
599
+ <View className="flex-row items-center" style={{ gap: 5 }}>
600
+ <View style={{ width: 14, height: 2.5, borderRadius: 1.5, backgroundColor: BRAND_PRIMARY }} />
601
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
602
+ Actual
603
+ </Text>
604
+ </View>
605
+ <View className="flex-row items-center" style={{ gap: 5 }}>
606
+ <View
607
+ style={{
608
+ width: 14,
609
+ height: 0,
610
+ borderTopWidth: 2,
611
+ borderStyle: 'dashed',
612
+ borderTopColor: TEXT_TERTIARY,
613
+ }}
614
+ />
615
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
616
+ Projected
617
+ </Text>
618
+ </View>
619
+ <View className="flex-row items-center" style={{ gap: 5 }}>
620
+ <Text style={{ fontSize: 12, color: STATUS_WARNING }}>★</Text>
621
+ <Text style={{ fontSize: 10, fontFamily: 'Inter, sans-serif', color: TEXT_SECONDARY }}>
622
+ PR
623
+ </Text>
624
+ </View>
625
+ </View>
626
+ </View>
627
+ )
628
+ }
@@ -6,7 +6,6 @@ export interface SupersetWrapperProps {
6
6
  label?: string
7
7
  color?: string
8
8
  children: React.ReactNode
9
- syncExpansion?: boolean
10
9
  }
11
10
 
12
11
  const DEFAULTS = {
@@ -31,7 +30,7 @@ export function SupersetWrapper({
31
30
  marginBottom: 8,
32
31
  overflow: 'visible',
33
32
  }}
34
- accessibilityRole={"group" as any}
33
+ accessibilityRole={'group' as never}
35
34
  accessibilityLabel={`Superset: ${label}`}
36
35
  testID="superset-wrapper"
37
36
  >
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { TempoDisplay } from './TempoDisplay'
4
+
5
+ // tempo = [eccentric, pauseBottom, concentric, pauseTop]
6
+ const meta: Meta<typeof TempoDisplay> = {
7
+ title: 'Custom/Workout/TempoDisplay',
8
+ component: TempoDisplay,
9
+ tags: ['autodocs'],
10
+ argTypes: {
11
+ tempo: { control: 'object', description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds' },
12
+ size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
13
+ colored: { control: 'boolean', description: 'Colored vs mono display' },
14
+ showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
15
+ },
16
+ }
17
+
18
+ export default meta
19
+ type Story = StoryObj<typeof TempoDisplay>
20
+
21
+ export const ColoredStandard: Story = {
22
+ args: { tempo: [3, 1, 1, 0], colored: true },
23
+ }
24
+
25
+ export const MonoStandard: Story = {
26
+ args: { tempo: [3, 1, 1, 0], colored: false },
27
+ }
28
+
29
+ export const ColoredExplosive: Story = {
30
+ args: { tempo: [1, 0, 1, 0], colored: true },
31
+ }
32
+
33
+ export const MonoExplosive: Story = {
34
+ args: { tempo: [1, 0, 1, 0], colored: false },
35
+ }
36
+
37
+ export const ColoredSlowEccentric: Story = {
38
+ args: { tempo: [5, 2, 1, 1], colored: true },
39
+ }
40
+
41
+ export const SmallColored: Story = {
42
+ args: { tempo: [3, 1, 1, 0], size: 'sm', colored: true },
43
+ }
44
+
45
+ export const SmallMono: Story = {
46
+ args: { tempo: [3, 1, 1, 0], size: 'sm', colored: false },
47
+ }
48
+
49
+ export const AllVariants: Story = {
50
+ render: () => (
51
+ <View style={{ gap: 12 }}>
52
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
53
+ <TempoDisplay tempo={[3, 1, 1, 0]} colored />
54
+ <TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />
55
+ </View>
56
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
57
+ <TempoDisplay tempo={[1, 0, 1, 0]} colored />
58
+ <TempoDisplay tempo={[1, 0, 1, 0]} colored={false} />
59
+ </View>
60
+ <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
61
+ <TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored />
62
+ <TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored={false} />
63
+ </View>
64
+ </View>
65
+ ),
66
+ }