@titan-design/react-ui 0.3.0 → 0.5.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 (90) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +441 -97
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1958 -66
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +71 -738
  10. package/dist/index.d.ts +71 -738
  11. package/dist/index.js +888 -1583
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4515 -2348
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6646 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6626 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +28 -2
  24. package/dist/theme/index.d.ts +28 -2
  25. package/dist/theme/index.js +37 -0
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +1880 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.mjs +573 -1
  30. package/dist/theme/tokens-css.mjs.map +1 -1
  31. package/package.json +9 -7
  32. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
  33. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  34. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  35. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  36. package/src/components/custom/Workout/BodyMap.tsx +7 -8
  37. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
  38. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  39. package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
  40. package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
  41. package/src/components/custom/Workout/InputBar.tsx +10 -11
  42. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  43. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  44. package/src/components/custom/Workout/MesoCard.tsx +2 -6
  45. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  46. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  47. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
  48. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  49. package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
  50. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
  51. package/src/components/custom/Workout/README.md +50 -0
  52. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
  53. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  54. package/src/components/custom/Workout/RestTimer.tsx +55 -49
  55. package/src/components/custom/Workout/SetRow.tsx +20 -8
  56. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  57. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  58. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  59. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  60. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  61. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  62. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  63. package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
  64. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  65. package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
  66. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  67. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  68. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  69. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  70. package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
  71. package/src/components/custom/Workout/icons.tsx +68 -0
  72. package/src/components/custom/Workout/index.ts +47 -47
  73. package/src/index.ts +3 -2
  74. package/src/pages.ts +61 -0
  75. package/src/test/nativewind-stub.ts +13 -0
  76. package/src/test/stories-smoke.test.tsx +50 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/barrel.ts +53 -0
  80. package/src/theme/index.ts +8 -41
  81. package/src/theme/native-theming-guard.test.ts +117 -0
  82. package/src/theme/resolve-color.ts +19 -0
  83. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  84. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  85. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  86. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  87. package/dist/chunk-P7TSQUN6.mjs +0 -846
  88. package/dist/chunk-P7TSQUN6.mjs.map +0 -1
  89. package/dist/chunk-TOD2WQBG.mjs +0 -1048
  90. package/dist/chunk-TOD2WQBG.mjs.map +0 -1
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest'
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import {
@@ -210,10 +210,26 @@ describe('getVelocityZoneName', () => {
210
210
  })
211
211
 
212
212
  describe('calculateVelocityLoss', () => {
213
- it('calculates percentage loss from first to last rep', () => {
213
+ it('calculates percentage loss from running-best to last rep', () => {
214
+ // Best is the first rep here, so best->last matches the legacy result.
214
215
  expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
215
216
  })
216
217
 
218
+ it('uses the best rep (not the first) as the reference for non-monotonic sets', () => {
219
+ // best = 1.0 (rep 2), last = 0.6 -> (1.0 - 0.6) / 1.0 = 40%.
220
+ // Legacy first->last would have been (0.8 - 0.6) / 0.8 = 25%.
221
+ expect(calculateVelocityLoss([0.8, 1.0, 0.6])).toBe(40)
222
+ })
223
+
224
+ it('clamps to 0 when the set ends on its best rep', () => {
225
+ expect(calculateVelocityLoss([0.6, 0.8, 1.0])).toBe(0)
226
+ })
227
+
228
+ it('clamps to 0 when a later rep exceeds the first rep', () => {
229
+ // best = 1.2 (last), so loss is negative pre-clamp -> 0.
230
+ expect(calculateVelocityLoss([1.0, 0.9, 1.2])).toBe(0)
231
+ })
232
+
217
233
  it('returns 0 for single rep', () => {
218
234
  expect(calculateVelocityLoss([1.0])).toBe(0)
219
235
  })
@@ -221,6 +237,10 @@ describe('calculateVelocityLoss', () => {
221
237
  it('returns 0 for empty array', () => {
222
238
  expect(calculateVelocityLoss([])).toBe(0)
223
239
  })
240
+
241
+ it('returns 0 when the best rep is non-positive', () => {
242
+ expect(calculateVelocityLoss([0, 0])).toBe(0)
243
+ })
224
244
  })
225
245
 
226
246
  describe('calculateMeanVelocity', () => {
@@ -233,6 +253,73 @@ describe('calculateMeanVelocity', () => {
233
253
  })
234
254
  })
235
255
 
256
+ // WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
257
+ const compoundBands = [
258
+ { id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
259
+ { id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
260
+ { id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
261
+ { id: 'power', label: 'Power', min: 0.75, max: 1.0 },
262
+ { id: 'speed', label: 'Speed', min: 1.0, max: null },
263
+ ] as const
264
+
265
+ describe('VelocityStrip zones prop', () => {
266
+ it('colors bars from the supplied bands (mini)', () => {
267
+ render(<VelocityStrip velocities={[1.1, 0.45]} zones={compoundBands} variant="mini" />)
268
+ // 1.1 -> speed -> green; 0.45 -> maximalStrength -> red (shared with grinding).
269
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
270
+ expect(screen.getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: '#ff4757' })
271
+ })
272
+
273
+ it('labels the summary row with the band containing the mean velocity', () => {
274
+ // mean of the sample = 0.80 -> power band.
275
+ render(<VelocityStrip velocities={sampleVelocities} zones={compoundBands} expanded />)
276
+ expect(screen.getByTestId('velocity-info-row')).toHaveTextContent('Power')
277
+ })
278
+
279
+ it('falls back to the default scale when zones is absent', () => {
280
+ render(<VelocityStrip velocities={[1.1]} variant="mini" />)
281
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
282
+ })
283
+ })
284
+
285
+ describe('VelocityStrip live mode', () => {
286
+ const originalMatchMedia = window.matchMedia
287
+
288
+ afterEach(() => {
289
+ if (originalMatchMedia) window.matchMedia = originalMatchMedia
290
+ else delete (window as { matchMedia?: unknown }).matchMedia
291
+ })
292
+
293
+ it('marks the latest rep bar in its accessibility label', () => {
294
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={3} expanded />)
295
+ expect(
296
+ screen.getByLabelText(/Rep 4: 0\.68 meters per second, latest rep$/),
297
+ ).toBeInTheDocument()
298
+ })
299
+
300
+ it('flags a new set peak on the latest bar', () => {
301
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
302
+ expect(
303
+ screen.getByLabelText(/Rep 1: 1\.10 meters per second, latest rep, new set peak$/),
304
+ ).toBeInTheDocument()
305
+ })
306
+
307
+ it('renders without animation when prefers-reduced-motion is set', () => {
308
+ window.matchMedia = vi.fn().mockReturnValue({
309
+ matches: true,
310
+ addEventListener: vi.fn(),
311
+ removeEventListener: vi.fn(),
312
+ }) as unknown as typeof window.matchMedia
313
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
314
+ expect(screen.getByTestId('velocity-bar-0')).toBeInTheDocument()
315
+ })
316
+
317
+ it('does not apply live marking in the mini variant', () => {
318
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} variant="mini" />)
319
+ expect(screen.queryByLabelText(/latest rep/)).not.toBeInTheDocument()
320
+ })
321
+ })
322
+
236
323
  describe('VelocityStrip all-zero velocities (NaN guard)', () => {
237
324
  it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
238
325
  render(<VelocityStrip velocities={[0, 0, 0]} expanded />)
@@ -1,11 +1,54 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
- import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
3
+ import { View, Text, Pressable, Animated, Easing, type ViewProps, type ViewStyle } from 'react-native'
4
4
  import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
5
  import { formatVelocity } from '../../../utils/workout-format'
6
6
 
7
+ /**
8
+ * Structural velocity-zone band accepted from an upstream analytics source
9
+ * (e.g. workout-analytics' `VelocityZones.bands`).
10
+ *
11
+ * Deliberately a plain structural shape — titan never imports the analytics
12
+ * package (same policy as TempoBar's presentational phase key). Any object with
13
+ * this shape can be passed, so `zones={waVelocityZones.bands}` works directly.
14
+ * Bands are ordered slow → fast, contiguous, and cover `[0, ∞)` with the top
15
+ * band's `max === null`. Bands carry NO color — color is a UI concern resolved
16
+ * here via {@link zoneIdToScaleToken}.
17
+ */
18
+ export interface VelocityZoneBandProp {
19
+ /** Stable zone identity (e.g. WA's `VelocityZoneId`). Drives color mapping. */
20
+ id: string
21
+ /** Human-readable label shown in the summary row. */
22
+ label: string
23
+ /** Inclusive lower bound (m/s mean concentric velocity). */
24
+ min: number
25
+ /** Exclusive upper bound (m/s); `null` marks the open top band. */
26
+ max: number | null
27
+ }
28
+
7
29
  export interface VelocityStripProps extends ViewProps {
30
+ /**
31
+ * Per-rep MEAN concentric velocity (m/s), one entry per rep (brain WA-D02).
32
+ * Drives both bar height and — in the default path — zone color. Callers must
33
+ * feed mean (not peak) concentric velocity so bar color, height, and the
34
+ * summary label all describe one metric.
35
+ */
8
36
  velocities: number[]
37
+ /**
38
+ * Optional velocity-zone bands (shape-compatible with WA's
39
+ * `VelocityZones.bands`). When provided, bar color and the summary zone label
40
+ * come from these bands via {@link zoneIdToScaleToken}. When omitted, the
41
+ * built-in default scale (≥1.0 / ≥0.75 / ≥0.5) is used — identical to prior
42
+ * releases. Colors NEVER come from the bands themselves.
43
+ */
44
+ zones?: readonly VelocityZoneBandProp[]
45
+ /**
46
+ * Live mode: index of the most-recently-completed rep. That bar animates in
47
+ * with a "pop"; if it is also the current set peak (a new best), it "bounces"
48
+ * instead. Only the latest bar animates. Honors `prefers-reduced-motion`.
49
+ * Full variant only.
50
+ */
51
+ liveRepIndex?: number
9
52
  expanded?: boolean
10
53
  onToggle?: () => void
11
54
  onRepPress?: (index: number, velocity: number) => void
@@ -14,9 +57,36 @@ export interface VelocityStripProps extends ViewProps {
14
57
  className?: string
15
58
  }
16
59
 
17
- // Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
60
+ // Canonical 4-color performance scale shared with SetRow's RPE color
61
+ // (see theme/workout-tokens.ts). green = fastest, red = slowest/grinding.
18
62
  const VEL_COLORS = WORKOUT_TOKENS.scale
19
63
 
64
+ /**
65
+ * Map a velocity-zone id (WA's 5-band taxonomy) onto the canonical 4-color
66
+ * scale. The scale has 4 hues but the taxonomy has 5 bands, so the two slowest
67
+ * bands — `maximalStrength` and `grinding` — intentionally share `red`: both
68
+ * read as "heavy / effortful" and there is no distinct 5th data-viz hue. The
69
+ * top three bands align 1:1 with the legacy default scale so an exercise moving
70
+ * from default to profile-derived zones never shifts its fast-end colors.
71
+ *
72
+ * Legacy default parity: speed=green, power=yellow, strengthSpeed=orange, and
73
+ * the old sub-0.5 "Strength" band (now split into maximalStrength + grinding)
74
+ * stays red.
75
+ *
76
+ * Unknown ids fall back to `green` (matching the historical default-path
77
+ * fallback), so a forward-compatible band id never renders an empty bar.
78
+ */
79
+ const zoneIdToScaleToken: Record<string, keyof typeof VEL_COLORS> = {
80
+ speed: 'green',
81
+ power: 'yellow',
82
+ strengthSpeed: 'orange',
83
+ maximalStrength: 'red',
84
+ grinding: 'red',
85
+ }
86
+
87
+ // --- Default (no-zones) scale ------------------------------------------------
88
+ // Retained as named exports for back-compat; they define the default path.
89
+
20
90
  export function getVelocityZoneColor(velocity: number): string {
21
91
  if (velocity >= 1.0) return 'vel-green'
22
92
  if (velocity >= 0.75) return 'vel-yellow'
@@ -31,35 +101,47 @@ export function getVelocityZoneName(velocity: number): string {
31
101
  return 'Strength'
32
102
  }
33
103
 
104
+ const zoneHexMap: Record<string, string> = {
105
+ 'vel-green': VEL_COLORS.green,
106
+ 'vel-yellow': VEL_COLORS.yellow,
107
+ 'vel-orange': VEL_COLORS.orange,
108
+ 'vel-red': VEL_COLORS.red,
109
+ }
110
+
111
+ /**
112
+ * Velocity loss for a set, as a whole percentage. Uses the running-best rep as
113
+ * the reference (matching WA-02.05 / brain WA-D01): `(vBest − vLast) / vBest`,
114
+ * clamped to ≥ 0 so a set that ends on its best rep reports 0 loss.
115
+ */
34
116
  export function calculateVelocityLoss(velocities: number[]): number {
35
117
  if (velocities.length < 2) return 0
36
- const first = velocities[0]
118
+ const best = Math.max(...velocities)
119
+ if (best <= 0) return 0
37
120
  const last = velocities[velocities.length - 1]
38
- if (first === 0) return 0
39
- return Math.round(((first - last) / first) * 100)
121
+ return Math.max(0, Math.round(((best - last) / best) * 100))
40
122
  }
41
123
 
124
+ /** Arithmetic mean of the per-rep mean-concentric velocities. */
42
125
  export function calculateMeanVelocity(velocities: number[]): number {
43
126
  if (velocities.length === 0) return 0
44
127
  const sum = velocities.reduce((acc, v) => acc + v, 0)
45
128
  return sum / velocities.length
46
129
  }
47
130
 
48
- function getBarColor(zone: string): string {
49
- switch (zone) {
50
- case 'vel-green': return VEL_COLORS.green
51
- case 'vel-yellow': return VEL_COLORS.yellow
52
- case 'vel-orange': return VEL_COLORS.orange
53
- case 'vel-red': return VEL_COLORS.red
54
- default: return VEL_COLORS.green
131
+ /** Classify a velocity into its band (slow → fast, min inclusive / max exclusive). */
132
+ function classifyBand(
133
+ velocity: number,
134
+ bands: readonly VelocityZoneBandProp[],
135
+ ): VelocityZoneBandProp | undefined {
136
+ for (const band of bands) {
137
+ if (band.max === null || velocity < band.max) return band
55
138
  }
139
+ return bands[bands.length - 1]
56
140
  }
57
141
 
58
- const zoneHexMap: Record<string, string> = {
59
- 'vel-green': VEL_COLORS.green,
60
- 'vel-yellow': VEL_COLORS.yellow,
61
- 'vel-orange': VEL_COLORS.orange,
62
- 'vel-red': VEL_COLORS.red,
142
+ function bandColor(band: VelocityZoneBandProp): string {
143
+ const token = zoneIdToScaleToken[band.id]
144
+ return token ? VEL_COLORS[token] : VEL_COLORS.green
63
145
  }
64
146
 
65
147
  function getLossStyle(loss: number): Record<string, string> | null {
@@ -68,11 +150,33 @@ function getLossStyle(loss: number): Record<string, string> | null {
68
150
  return null
69
151
  }
70
152
 
153
+ function getReducedMotionPreference(): boolean {
154
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false
155
+ return window.matchMedia('(prefers-reduced-motion: reduce)').matches
156
+ }
157
+
158
+ /** Track the OS "reduce motion" preference; falls back to `false` (jsdom/SSR). */
159
+ function usePrefersReducedMotion(): boolean {
160
+ const [reduced, setReduced] = useState(getReducedMotionPreference)
161
+ useEffect(() => {
162
+ if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return
163
+ const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
164
+ const handler = () => setReduced(mq.matches)
165
+ handler()
166
+ mq.addEventListener?.('change', handler)
167
+ return () => mq.removeEventListener?.('change', handler)
168
+ }, [])
169
+ return reduced
170
+ }
171
+
71
172
  const ANIMATION_DURATION = 400
72
173
  const ANIMATION_EASING = Easing.bezier(0.22, 1, 0.36, 1)
174
+ const POP_EASING = Easing.bezier(0.34, 1.56, 0.64, 1)
73
175
 
74
176
  export function VelocityStrip({
75
177
  velocities,
178
+ zones,
179
+ liveRepIndex,
76
180
  expanded = false,
77
181
  onToggle,
78
182
  onRepPress,
@@ -84,11 +188,55 @@ export function VelocityStrip({
84
188
  const maxVelocity = Math.max(...velocities, 0)
85
189
  const meanVelocity = calculateMeanVelocity(velocities)
86
190
  const loss = calculateVelocityLoss(velocities)
87
- const meanZone = getVelocityZoneName(meanVelocity)
88
191
 
192
+ const hasZones = zones != null && zones.length > 0
193
+ const barColorFor = (v: number): string =>
194
+ hasZones ? bandColor(classifyBand(v, zones)!) : zoneHexMap[getVelocityZoneColor(v)]
195
+ const meanZone = hasZones
196
+ ? (classifyBand(meanVelocity, zones)?.label ?? '')
197
+ : getVelocityZoneName(meanVelocity)
198
+
199
+ const prefersReducedMotion = usePrefersReducedMotion()
89
200
  const [heightAnim] = useState(() => new Animated.Value(expanded ? 60 : 3))
90
201
  const [labelOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
91
202
  const [infoOpacity] = useState(() => new Animated.Value(expanded ? 1 : 0))
203
+ const [liveScale] = useState(() => new Animated.Value(1))
204
+
205
+ // Newest-rep animation: pop for a normal rep, bounce when it sets a new peak.
206
+ const liveVelocity = liveRepIndex != null ? velocities[liveRepIndex] : undefined
207
+ const isNewPeak = liveVelocity != null && maxVelocity > 0 && liveVelocity === maxVelocity
208
+ useEffect(() => {
209
+ if (variant === 'mini' || liveRepIndex == null || liveVelocity == null) return
210
+ if (prefersReducedMotion) {
211
+ liveScale.setValue(1)
212
+ return
213
+ }
214
+ if (isNewPeak) {
215
+ liveScale.setValue(1)
216
+ Animated.sequence([
217
+ Animated.timing(liveScale, {
218
+ toValue: 1.25,
219
+ duration: 150,
220
+ easing: Easing.out(Easing.quad),
221
+ useNativeDriver: true,
222
+ }),
223
+ Animated.spring(liveScale, {
224
+ toValue: 1,
225
+ friction: 3,
226
+ tension: 140,
227
+ useNativeDriver: true,
228
+ }),
229
+ ]).start()
230
+ } else {
231
+ liveScale.setValue(0.8)
232
+ Animated.timing(liveScale, {
233
+ toValue: 1,
234
+ duration: 300,
235
+ easing: POP_EASING,
236
+ useNativeDriver: true,
237
+ }).start()
238
+ }
239
+ }, [variant, liveRepIndex, liveVelocity, isNewPeak, prefersReducedMotion, liveScale])
92
240
 
93
241
  useEffect(() => {
94
242
  if (variant === 'mini') return
@@ -131,7 +279,7 @@ export function VelocityStrip({
131
279
  key={i}
132
280
  style={{
133
281
  flex: 1,
134
- backgroundColor: getBarColor(getVelocityZoneColor(v)),
282
+ backgroundColor: barColorFor(v),
135
283
  borderRadius: 1,
136
284
  minWidth: 4,
137
285
  height: '100%' as unknown as number,
@@ -151,7 +299,9 @@ export function VelocityStrip({
151
299
 
152
300
  const stripContent = (
153
301
  <Animated.View
154
- className={className}
302
+ className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
303
+ .filter(Boolean)
304
+ .join(' ')}
155
305
  style={[
156
306
  {
157
307
  height: heightAnim,
@@ -162,7 +312,6 @@ export function VelocityStrip({
162
312
  paddingBottom: expanded ? 24 : 0,
163
313
  paddingHorizontal: expanded ? 6 : 0,
164
314
  paddingVertical: expanded ? undefined : 8,
165
- backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
166
315
  overflow: expanded ? 'visible' : 'hidden',
167
316
  },
168
317
  ]}
@@ -175,11 +324,44 @@ export function VelocityStrip({
175
324
  style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
176
325
  >
177
326
  {velocities.map((v, i) => {
178
- const zone = getVelocityZoneColor(v)
327
+ const barBackground = barColorFor(v)
179
328
  // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
180
329
  // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
181
330
  const barHeightPct =
182
331
  maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
332
+ const isLive = liveRepIndex === i
333
+ const liveLabelSuffix = isLive
334
+ ? isNewPeak
335
+ ? ', latest rep, new set peak'
336
+ : ', latest rep'
337
+ : ''
338
+
339
+ const barStyle: ViewStyle = expanded
340
+ ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: barBackground }
341
+ : {
342
+ minHeight: '100%' as unknown as number,
343
+ borderTopLeftRadius: 2,
344
+ borderTopRightRadius: 2,
345
+ borderBottomLeftRadius: 0,
346
+ borderBottomRightRadius: 0,
347
+ backgroundColor: barBackground,
348
+ }
349
+
350
+ const barInner = isLive ? (
351
+ <Animated.View
352
+ style={[barStyle, { transform: [{ scale: liveScale }] }]}
353
+ testID={`velocity-bar-${i}`}
354
+ accessibilityRole="image"
355
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second${liveLabelSuffix}`}
356
+ />
357
+ ) : (
358
+ <View
359
+ style={barStyle}
360
+ testID={`velocity-bar-${i}`}
361
+ accessibilityRole="image"
362
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
363
+ />
364
+ )
183
365
 
184
366
  const bar = (
185
367
  <View
@@ -189,30 +371,15 @@ export function VelocityStrip({
189
371
  {expanded && (
190
372
  <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
191
373
  <Text
192
- style={{ fontSize: 8, fontWeight: '600', color: 'var(--color-text-secondary)' }}
374
+ className="text-text-secondary"
375
+ style={{ fontSize: 8, fontWeight: '600' }}
193
376
  testID={`velocity-label-${i}`}
194
377
  >
195
378
  {formatVelocity(v)}
196
379
  </Text>
197
380
  </Animated.View>
198
381
  )}
199
- <View
200
- style={
201
- expanded
202
- ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
203
- : {
204
- minHeight: '100%' as unknown as number,
205
- borderTopLeftRadius: 2,
206
- borderTopRightRadius: 2,
207
- borderBottomLeftRadius: 0,
208
- borderBottomRightRadius: 0,
209
- backgroundColor: zoneHexMap[zone],
210
- }
211
- }
212
- testID={`velocity-bar-${i}`}
213
- accessibilityRole="image"
214
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
215
- />
382
+ {barInner}
216
383
  </View>
217
384
  )
218
385
 
@@ -248,18 +415,18 @@ export function VelocityStrip({
248
415
  testID="velocity-info-row"
249
416
  >
250
417
  <Text
418
+ className="text-text-secondary"
251
419
  style={{
252
420
  fontSize: 10,
253
- color: 'var(--color-text-secondary)',
254
421
  fontFamily: 'Inter, sans-serif',
255
422
  }}
256
423
  >
257
- {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
424
+ {meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
258
425
  </Text>
259
426
  <Text
427
+ className="text-text-secondary"
260
428
  style={{
261
429
  fontSize: 10,
262
- color: 'var(--color-text-secondary)',
263
430
  fontFamily: 'Inter, sans-serif',
264
431
  ...getLossStyle(loss),
265
432
  }}
@@ -37,12 +37,12 @@ describe('WeekRow', () => {
37
37
 
38
38
  it('passes the threshold through to the intensity bar', () => {
39
39
  render(<WeekRow {...baseProps} intensityThreshold={0.8} />)
40
- expect(screen.getByTestId('intensity-threshold')).toBeInTheDocument()
40
+ expect(screen.getByTestId('intensity-target')).toBeInTheDocument()
41
41
  })
42
42
 
43
43
  it('omits the threshold line when not provided', () => {
44
44
  render(<WeekRow {...baseProps} />)
45
- expect(screen.queryByTestId('intensity-threshold')).not.toBeInTheDocument()
45
+ expect(screen.queryByTestId('intensity-target')).not.toBeInTheDocument()
46
46
  })
47
47
  })
48
48
 
@@ -100,11 +100,12 @@ export function WeekRow({
100
100
  />
101
101
  )}
102
102
  <Text
103
+ className={isCurrent ? undefined : 'text-text-primary'}
103
104
  style={{
104
105
  fontSize: 13,
105
106
  fontWeight: '700',
106
107
  fontFamily: 'Inter, sans-serif',
107
- color: isCurrent ? BRAND_PRIMARY : 'var(--color-text-primary)',
108
+ color: isCurrent ? BRAND_PRIMARY : undefined,
108
109
  }}
109
110
  accessibilityElementsHidden
110
111
  >
@@ -75,10 +75,10 @@ describe('WeightBadge', () => {
75
75
  })
76
76
  })
77
77
 
78
- it('uses brand-primary-subtle border color on PR state', () => {
78
+ it('uses a brand-primary border ring on PR state', () => {
79
79
  render(<WeightBadge value={315} isPr />)
80
80
  expect(screen.getByTestId('weight-badge')).toHaveStyle({
81
- borderTopColor: 'rgba(255, 121, 0, 0.12)',
81
+ borderTopColor: 'rgba(255, 121, 0, 0.3)',
82
82
  })
83
83
  })
84
84
  })
@@ -1,6 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { Text } from 'react-native'
3
- import { Dumbbell } from 'lucide-react'
3
+ import { DumbbellIcon } from './icons'
4
4
  import {
5
5
  BaseBadge,
6
6
  baseBadgeSizeConfig,
@@ -61,7 +61,7 @@ export function WeightBadge({
61
61
  className={className}
62
62
  icon={
63
63
  showIcon ? (
64
- <Dumbbell size={config.iconSize} color={iconColor} strokeWidth={2} />
64
+ <DumbbellIcon size={config.iconSize} color={iconColor} strokeWidth={2} />
65
65
  ) : undefined
66
66
  }
67
67
  accessibilityLabel={fullLabel}
@@ -43,11 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
43
43
  }
44
44
 
45
45
  const COLORS = {
46
- // Theme-aware foregrounds: resolve to a readable color in both light and dark
47
- // (react-native-web passes the CSS var through to the inline style).
48
- textPrimary: 'var(--color-text-primary)',
49
- textSecondary: 'var(--color-text-secondary)',
50
- textTertiary: 'var(--color-text-tertiary)',
51
46
  statusSuccess: '#14B8A6',
52
47
  brandPrimary: '#FF7900',
53
48
  borderDefault: '#1F1F1F',
@@ -124,11 +119,11 @@ export function WorkoutCard({
124
119
  <View style={{ padding: 14 }} testID="workout-card-body">
125
120
  <View className="flex-row items-center" testID="workout-card-header">
126
121
  <Text
122
+ className="text-text-primary"
127
123
  style={{
128
124
  fontSize: 15,
129
125
  fontFamily: '"Space Grotesk", sans-serif',
130
126
  fontWeight: '700',
131
- color: COLORS.textPrimary,
132
127
  }}
133
128
  testID="workout-card-name"
134
129
  >
@@ -136,10 +131,10 @@ export function WorkoutCard({
136
131
  </Text>
137
132
  <View className="flex-1" />
138
133
  <Text
134
+ className="text-text-tertiary"
139
135
  style={{
140
136
  fontSize: 11,
141
137
  fontFamily: 'Inter, sans-serif',
142
- color: COLORS.textTertiary,
143
138
  }}
144
139
  testID="workout-card-date"
145
140
  >
@@ -148,11 +143,11 @@ export function WorkoutCard({
148
143
  </View>
149
144
 
150
145
  <Text
146
+ className="text-text-secondary"
151
147
  style={{
152
148
  marginTop: 4,
153
149
  fontSize: 12,
154
150
  fontFamily: 'Inter, sans-serif',
155
- color: COLORS.textSecondary,
156
151
  }}
157
152
  testID="workout-card-stats"
158
153
  >
@@ -0,0 +1,68 @@
1
+ // Inline SVG icons for the Workout badges — replaces the `lucide-react`
2
+ // dependency (dep hygiene, titan 0.5.0). The paths mirror lucide-react's
3
+ // `Dumbbell` and `Star` glyphs verbatim so rendering is pixel-identical to the
4
+ // previous `<Dumbbell>` / `<Star>` output (same viewBox, stroke, fill, and
5
+ // round line caps). Rendered as raw `<svg>` DOM elements — the same web-SVG
6
+ // mechanism `Progress.tsx` uses, and the same surface `lucide-react` produced
7
+ // (both are web-only SVG; native icon rendering was never wired here).
8
+
9
+ export interface WorkoutIconProps {
10
+ /** Rendered width/height in px (viewBox is fixed 24×24). */
11
+ size?: number
12
+ /** Stroke color. */
13
+ color?: string
14
+ /** Fill color; defaults to `none` (outline only). */
15
+ fill?: string
16
+ /** Stroke width. */
17
+ strokeWidth?: number
18
+ }
19
+
20
+ /** Dumbbell glyph (mirrors lucide-react `Dumbbell`). Outline-only by default. */
21
+ export function DumbbellIcon({
22
+ size = 24,
23
+ color = 'currentColor',
24
+ fill = 'none',
25
+ strokeWidth = 2,
26
+ }: WorkoutIconProps) {
27
+ return (
28
+ <svg
29
+ width={size}
30
+ height={size}
31
+ viewBox="0 0 24 24"
32
+ fill={fill}
33
+ stroke={color}
34
+ strokeWidth={strokeWidth}
35
+ strokeLinecap="round"
36
+ strokeLinejoin="round"
37
+ >
38
+ <path d="M14.4 14.4 9.6 9.6" />
39
+ <path d="M18.657 21.485a2 2 0 1 1-2.829-2.828l-1.767 1.768a2 2 0 1 1-2.829-2.829l6.364-6.364a2 2 0 1 1 2.829 2.829l-1.768 1.767a2 2 0 1 1 2.828 2.829z" />
40
+ <path d="m21.5 21.5-1.4-1.4" />
41
+ <path d="M3.9 3.9 2.5 2.5" />
42
+ <path d="M6.404 12.768a2 2 0 1 1-2.829-2.829l1.768-1.767a2 2 0 1 1-2.828-2.829l2.828-2.828a2 2 0 1 1 2.829 2.828l1.767-1.768a2 2 0 1 1 2.829 2.829z" />
43
+ </svg>
44
+ )
45
+ }
46
+
47
+ /** Star glyph (mirrors lucide-react `Star`). Pass `fill` for a solid star. */
48
+ export function StarIcon({
49
+ size = 24,
50
+ color = 'currentColor',
51
+ fill = 'none',
52
+ strokeWidth = 2,
53
+ }: WorkoutIconProps) {
54
+ return (
55
+ <svg
56
+ width={size}
57
+ height={size}
58
+ viewBox="0 0 24 24"
59
+ fill={fill}
60
+ stroke={color}
61
+ strokeWidth={strokeWidth}
62
+ strokeLinecap="round"
63
+ strokeLinejoin="round"
64
+ >
65
+ <path d="M11.525 2.295a.53.53 0 0 1 .95 0l2.31 4.679a2.123 2.123 0 0 0 1.595 1.16l5.166.756a.53.53 0 0 1 .294.904l-3.736 3.638a2.123 2.123 0 0 0-.611 1.878l.882 5.14a.53.53 0 0 1-.771.56l-4.618-2.428a2.122 2.122 0 0 0-1.973 0L6.396 21.01a.53.53 0 0 1-.77-.56l.881-5.139a2.122 2.122 0 0 0-.611-1.879L2.16 9.795a.53.53 0 0 1 .294-.906l5.165-.755a2.122 2.122 0 0 0 1.597-1.16z" />
66
+ </svg>
67
+ )
68
+ }