@titan-design/react-ui 0.4.0 → 0.6.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 (150) 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 +232 -185
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1963 -11
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +193 -744
  10. package/dist/index.d.ts +193 -744
  11. package/dist/index.js +1551 -1856
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +10770 -8213
  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 +6694 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6674 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +473 -211
  24. package/dist/theme/index.d.ts +473 -211
  25. package/dist/theme/index.js +341 -127
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +2085 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.js +148 -139
  30. package/dist/theme/tokens-css.js.map +1 -1
  31. package/dist/theme/tokens-css.mjs +582 -1
  32. package/dist/theme/tokens-css.mjs.map +1 -1
  33. package/package.json +11 -7
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
  35. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  36. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  37. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  38. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  39. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  40. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  41. package/src/components/custom/Typography/Typography.stories.tsx +45 -27
  42. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  43. package/src/components/custom/Typography/Typography.tsx +5 -0
  44. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
  45. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  46. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  47. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  48. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  49. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  50. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  51. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  52. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
  56. package/src/components/custom/Workout/InputBar.tsx +2 -1
  57. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  58. package/src/components/custom/Workout/IntensityBar.tsx +113 -58
  59. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  60. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  61. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  62. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  63. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  64. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  65. package/src/components/custom/Workout/PrBadge.tsx +7 -4
  66. package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
  67. package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
  68. package/src/components/custom/Workout/README.md +50 -0
  69. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  70. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  71. package/src/components/custom/Workout/RestTimer.tsx +50 -44
  72. package/src/components/custom/Workout/SetRow.tsx +12 -2
  73. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  74. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  75. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  76. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  77. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  78. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  79. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  80. package/src/components/custom/Workout/TempoDisplay.tsx +109 -3
  81. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  82. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  83. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  84. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  85. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  86. package/src/components/custom/Workout/WeightBadge.tsx +8 -4
  87. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  88. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  89. package/src/components/custom/Workout/icons.tsx +7 -0
  90. package/src/components/custom/Workout/index.ts +38 -47
  91. package/src/components/icons/SvgIcon.tsx +50 -0
  92. package/src/components/icons/icons.stories.tsx +57 -0
  93. package/src/components/icons/icons.test.tsx +43 -0
  94. package/src/components/icons/icons.tsx +45 -0
  95. package/src/components/icons/index.ts +4 -0
  96. package/src/components/index.ts +6 -0
  97. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  98. package/src/components/shell/BrandLockup.test.tsx +23 -0
  99. package/src/components/shell/BrandLockup.tsx +47 -0
  100. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  101. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  102. package/src/components/shell/DeviceIndicator.tsx +52 -0
  103. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  104. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  105. package/src/components/shell/DeviceMenu.tsx +65 -0
  106. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  107. package/src/components/shell/DeviceRow.test.tsx +37 -0
  108. package/src/components/shell/DeviceRow.tsx +58 -0
  109. package/src/components/shell/README.md +86 -0
  110. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  111. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  112. package/src/components/shell/SessionStatePill.tsx +46 -0
  113. package/src/components/shell/TopBar.stories.tsx +49 -0
  114. package/src/components/shell/TopBar.test.tsx +36 -0
  115. package/src/components/shell/TopBar.tsx +89 -0
  116. package/src/components/shell/index.ts +8 -0
  117. package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
  118. package/src/components/ui/indicator/Indicator.test.tsx +19 -1
  119. package/src/components/ui/indicator/Indicator.tsx +45 -6
  120. package/src/components/ui/indicator/index.ts +1 -1
  121. package/src/components/ui/spinner/Spinner.tsx +3 -2
  122. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  123. package/src/index.ts +3 -2
  124. package/src/pages.ts +61 -0
  125. package/src/test/stories-smoke.test.tsx +50 -0
  126. package/src/theme/ColorSystem.stories.tsx +323 -0
  127. package/src/theme/Colors.stories.tsx +69 -1
  128. package/src/theme/barrel.ts +54 -0
  129. package/src/theme/config.ts +16 -3
  130. package/src/theme/extracted-colors-dataviz.ts +21 -0
  131. package/src/theme/extracted-colors-ui.ts +20 -0
  132. package/src/theme/global.css +64 -52
  133. package/src/theme/gradients.test.ts +25 -0
  134. package/src/theme/gradients.ts +36 -0
  135. package/src/theme/index.ts +8 -43
  136. package/src/theme/native-theming-guard.test.ts +117 -0
  137. package/src/theme/tokens/primitives.test.ts +232 -0
  138. package/src/theme/tokens/primitives.ts +247 -0
  139. package/src/theme/tokens/semantic.ts +93 -81
  140. package/src/theme/workout-tokens.ts +22 -21
  141. package/src/utils/avatar-color.ts +3 -3
  142. package/tailwind.config.js +10 -3
  143. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  144. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  145. package/dist/chunk-2SISKTWM.mjs +0 -1027
  146. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  147. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  148. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  149. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  150. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -2,6 +2,26 @@
2
2
  import { useState, useCallback } from 'react'
3
3
  import { View, Text, Pressable, type ViewProps } from 'react-native'
4
4
  import { roundTempo } from '../../../utils/workout-format'
5
+ import { alpha } from '../../../utils/colors'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+ import { getTempoFillPct, getTempoPacingState } from './TempoBar'
8
+
9
+ const t = getSemanticColors('dark')
10
+
11
+ /** The four tempo phases, in the order the display renders them. */
12
+ export type TempoLivePhase = 'eccentric' | 'pauseBottom' | 'concentric' | 'pauseTop'
13
+
14
+ /**
15
+ * Live rep state driving the phase-fill overlay. Controlled by the consumer
16
+ * (matching titan's `elapsedMs`-in convention) so the same real timer feeds
17
+ * both web dashboard and native app — TempoDisplay owns rendering only.
18
+ */
19
+ export interface TempoLiveState {
20
+ /** Phase currently in progress, or null when idle/at rest. */
21
+ activePhase: TempoLivePhase | null
22
+ /** Elapsed time (ms) within the active phase. */
23
+ phaseElapsedMs: number
24
+ }
5
25
 
6
26
  export interface TempoDisplayProps extends ViewProps {
7
27
  /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
@@ -11,18 +31,25 @@ export interface TempoDisplayProps extends ViewProps {
11
31
  colored?: boolean
12
32
  /** Show info tooltip on press */
13
33
  showInfo?: boolean
34
+ /**
35
+ * When set, renders a live phase-fill: the active phase digit fills against
36
+ * its prescribed duration and inactive phases dim. Omit for the static
37
+ * prescription string (default, backward compatible).
38
+ */
39
+ live?: TempoLiveState
14
40
  onPress?: () => void
15
41
  className?: string
16
42
  }
17
43
 
18
44
  const INTER = 'Inter, sans-serif'
19
45
  const TEXT_TERTIARY = '#6B7280'
46
+ const STATUS_ERROR = t['status-error'] // shown when a phase runs behind pace
20
47
 
21
48
  // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
22
49
  const phaseColors = {
23
- eccentric: '#FFB020', // status-warning (amber)
50
+ eccentric: t['status-warning'],
24
51
  pauseBottom: TEXT_TERTIARY,
25
- concentric: '#14B8A6', // status-success (teal)
52
+ concentric: t['status-success'],
26
53
  pauseTop: TEXT_TERTIARY,
27
54
  dash: TEXT_TERTIARY,
28
55
  }
@@ -67,11 +94,84 @@ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }
67
94
  )
68
95
  }
69
96
 
97
+ // Phase order + color matching the tempo tuple, for the live phase-fill row.
98
+ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
99
+ { key: 'eccentric', color: phaseColors.eccentric },
100
+ { key: 'pauseBottom', color: phaseColors.pauseBottom },
101
+ { key: 'concentric', color: phaseColors.concentric },
102
+ { key: 'pauseTop', color: phaseColors.pauseTop },
103
+ ]
104
+
105
+ function LiveTempoCell({
106
+ value,
107
+ color,
108
+ isActive,
109
+ phaseElapsedMs,
110
+ fontSize,
111
+ }: {
112
+ value: number
113
+ color: string
114
+ isActive: boolean
115
+ phaseElapsedMs: number
116
+ fontSize: number
117
+ }) {
118
+ if (!isActive) {
119
+ return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
120
+ }
121
+ const targetMs = value > 0 ? value * 1000 : null
122
+ const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
123
+ const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
124
+ return (
125
+ <View style={{ position: 'relative' }} testID="tempo-live-active">
126
+ <View
127
+ style={{
128
+ position: 'absolute',
129
+ left: 0,
130
+ top: 0,
131
+ bottom: 0,
132
+ width: `${fillPct}%`,
133
+ backgroundColor: alpha(barColor, 0.3),
134
+ borderRadius: 2,
135
+ }}
136
+ />
137
+ <TempoValue value={value} color={barColor} fontSize={fontSize} />
138
+ </View>
139
+ )
140
+ }
141
+
142
+ function LiveTempoRow({
143
+ values,
144
+ live,
145
+ fontSize,
146
+ }: {
147
+ values: [number, number, number, number]
148
+ live: TempoLiveState
149
+ fontSize: number
150
+ }) {
151
+ return (
152
+ <>
153
+ {LIVE_PHASES.map((phase, i) => (
154
+ <View key={phase.key} style={{ flexDirection: 'row' }}>
155
+ {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
156
+ <LiveTempoCell
157
+ value={values[i]}
158
+ color={phase.color}
159
+ isActive={live.activePhase === phase.key}
160
+ phaseElapsedMs={live.phaseElapsedMs}
161
+ fontSize={fontSize}
162
+ />
163
+ </View>
164
+ ))}
165
+ </>
166
+ )
167
+ }
168
+
70
169
  export function TempoDisplay({
71
170
  tempo,
72
171
  size = 'md',
73
172
  colored = true,
74
173
  showInfo = true,
174
+ live,
75
175
  onPress,
76
176
  className,
77
177
  ...props
@@ -119,7 +219,13 @@ export function TempoDisplay({
119
219
  TEMPO
120
220
  </Text>
121
221
  <View style={{ flexDirection: 'row' }} testID="tempo-value">
122
- {colored ? (
222
+ {live ? (
223
+ <LiveTempoRow
224
+ values={[eccentric, pauseBottom, concentric, pauseTop]}
225
+ live={live}
226
+ fontSize={fontSize}
227
+ />
228
+ ) : colored ? (
123
229
  <>
124
230
  <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
125
231
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
@@ -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: '#D14343' })
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,
@@ -176,11 +324,44 @@ export function VelocityStrip({
176
324
  style={{ flexDirection: 'row', flex: 1, gap: 2, alignItems: 'flex-end' }}
177
325
  >
178
326
  {velocities.map((v, i) => {
179
- const zone = getVelocityZoneColor(v)
327
+ const barBackground = barColorFor(v)
180
328
  // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
181
329
  // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
182
330
  const barHeightPct =
183
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
+ )
184
365
 
185
366
  const bar = (
186
367
  <View
@@ -198,23 +379,7 @@ export function VelocityStrip({
198
379
  </Text>
199
380
  </Animated.View>
200
381
  )}
201
- <View
202
- style={
203
- expanded
204
- ? { height: `${barHeightPct}%`, minHeight: 2, borderTopLeftRadius: 2, borderTopRightRadius: 2, backgroundColor: zoneHexMap[zone] }
205
- : {
206
- minHeight: '100%' as unknown as number,
207
- borderTopLeftRadius: 2,
208
- borderTopRightRadius: 2,
209
- borderBottomLeftRadius: 0,
210
- borderBottomRightRadius: 0,
211
- backgroundColor: zoneHexMap[zone],
212
- }
213
- }
214
- testID={`velocity-bar-${i}`}
215
- accessibilityRole="image"
216
- accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
217
- />
382
+ {barInner}
218
383
  </View>
219
384
  )
220
385
 
@@ -256,7 +421,7 @@ export function VelocityStrip({
256
421
  fontFamily: 'Inter, sans-serif',
257
422
  }}
258
423
  >
259
- {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
424
+ {meanZone} {'·'} {formatVelocity(meanVelocity)} m/s
260
425
  </Text>
261
426
  <Text
262
427
  className="text-text-secondary"
@@ -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
 
@@ -2,8 +2,9 @@
2
2
  import { View, Text, type ViewProps } from 'react-native'
3
3
  import { WorkoutPill, type WorkoutPillStatus } from './WorkoutPill'
4
4
  import { IntensityBar } from './IntensityBar'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
6
 
6
- const BRAND_PRIMARY = '#FF7900'
7
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
7
8
 
8
9
  export interface WeekRowWorkout {
9
10
  name: string
@@ -66,7 +67,7 @@ export function WeekRow({
66
67
  }
67
68
 
68
69
  if (isDeload) {
69
- rowStyle.backgroundColor = 'rgba(147,51,234,0.06)'
70
+ rowStyle.backgroundColor = 'rgba(186,41,150,0.06)'
70
71
  }
71
72
 
72
73
  if (isCurrent) {
@@ -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
  })