@titan-design/react-ui 0.4.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 (67) 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.mjs +1911 -6
  6. package/dist/bodymap.mjs.map +1 -1
  7. package/dist/index.d.mts +31 -738
  8. package/dist/index.d.ts +31 -738
  9. package/dist/index.js +894 -1692
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +10287 -8203
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/pages-D7Jlssvp.d.ts +791 -0
  14. package/dist/pages-MO0JCySL.d.mts +791 -0
  15. package/dist/pages.d.mts +4 -0
  16. package/dist/pages.d.ts +4 -0
  17. package/dist/pages.js +6646 -0
  18. package/dist/pages.js.map +1 -0
  19. package/dist/pages.mjs +6626 -0
  20. package/dist/pages.mjs.map +1 -0
  21. package/dist/theme/index.d.mts +12 -12
  22. package/dist/theme/index.d.ts +12 -12
  23. package/dist/theme/index.js +30 -30
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +1880 -2
  26. package/dist/theme/index.mjs.map +1 -1
  27. package/dist/theme/tokens-css.mjs +573 -1
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +9 -7
  30. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  31. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  32. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  33. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  34. package/src/components/custom/Workout/ExerciseDetailPage.tsx +17 -4
  35. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  36. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  37. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  38. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  39. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  40. package/src/components/custom/Workout/README.md +50 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  42. package/src/components/custom/Workout/RestTimer.tsx +48 -43
  43. package/src/components/custom/Workout/SetRow.tsx +10 -1
  44. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  45. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  46. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  47. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  48. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  49. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  50. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  51. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  52. package/src/components/custom/Workout/icons.tsx +68 -0
  53. package/src/components/custom/Workout/index.ts +38 -47
  54. package/src/index.ts +3 -2
  55. package/src/pages.ts +61 -0
  56. package/src/test/stories-smoke.test.tsx +50 -0
  57. package/src/theme/barrel.ts +53 -0
  58. package/src/theme/index.ts +8 -43
  59. package/src/theme/native-theming-guard.test.ts +117 -0
  60. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  61. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  62. package/dist/chunk-2SISKTWM.mjs +0 -1027
  63. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  64. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  65. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  66. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  67. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -1,13 +1,20 @@
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, Animated, type ViewProps, type ViewStyle } from 'react-native'
3
+ import {
4
+ View,
5
+ Text,
6
+ Animated,
7
+ type ViewProps,
8
+ type ViewStyle,
9
+ type DimensionValue,
10
+ } from 'react-native'
4
11
 
5
12
  export type IntensityBarOrientation = 'vertical' | 'horizontal'
6
13
 
7
14
  export interface IntensityBarProps extends ViewProps {
8
- /** Current intensity level 0-1 */
15
+ /** Current intensity level; 1 == 100% of target. Values >1 render as over-target. */
9
16
  level: number
10
- /** MRV/overexertion threshold position 0-1 */
17
+ /** Target position 0-1. When the level sits at this target the fill gains an at-target glow. */
11
18
  threshold?: number
12
19
  /** Orientation */
13
20
  orientation?: IntensityBarOrientation
@@ -15,28 +22,57 @@ export interface IntensityBarProps extends ViewProps {
15
22
  size?: number
16
23
  /** Width for vertical (default 6px) or height for horizontal */
17
24
  thickness?: number
18
- /** Show threshold label ("MRV") */
25
+ /** Show the "MRV" threshold label under the bar */
19
26
  showThresholdLabel?: boolean
20
27
  className?: string
21
28
  }
22
29
 
23
- const TRACK_BG = 'rgba(255,255,255,0.06)'
24
- const THRESHOLD_COLOR = '#FFFFFF'
30
+ const TRACK_BG = '#333333'
25
31
  const LABEL_COLOR = '#6B7280'
32
+ const TARGET_LINE_COLOR = 'rgba(33, 150, 243, 0.5)'
33
+ const AT_TARGET_GLOW = '0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)'
26
34
 
27
- /** Fill color by intensity zone: teal 0-0.4, amber 0.4-0.7, red 0.7-1.0. */
28
- function getZoneColor(level: number): string {
29
- if (level >= 0.7) return '#D14343'
30
- if (level >= 0.4) return '#FFB020'
31
- return '#14B8A6'
35
+ // Zone colors graded by TRUE percentage (level * 100).
36
+ const ZONE = {
37
+ building: '#14B8A6', // teal — below target ramp-up
38
+ approaching: '#FFB020', // amber — nearing target
39
+ target: '#FF7900', // brand — exactly at target
40
+ over1: '#D14343', // red — mild over-target
41
+ over2: '#A62626', // deep red — moderate over-target
42
+ over3: '#7A1C1C', // darkest red — severe over-target
43
+ } as const
44
+
45
+ type OverTier = 0 | 1 | 2 | 3
46
+
47
+ interface Zone {
48
+ color: string
49
+ over: OverTier
50
+ }
51
+
52
+ const BULGE_SIZE: Record<Exclude<OverTier, 0>, number> = { 1: 8, 2: 10, 3: 11 }
53
+
54
+ /** Map a true percentage to its fill color and over-target tier. */
55
+ function zoneForPct(pct: number): Zone {
56
+ if (pct >= 125) return { color: ZONE.over3, over: 3 }
57
+ if (pct >= 115) return { color: ZONE.over2, over: 2 }
58
+ if (pct > 100) return { color: ZONE.over1, over: 1 }
59
+ if (pct === 100) return { color: ZONE.target, over: 0 }
60
+ if (pct >= 75) return { color: ZONE.approaching, over: 0 }
61
+ return { color: ZONE.building, over: 0 }
32
62
  }
33
63
 
34
- function clamp01(value: number): number {
64
+ function clampFill(value: number): number {
35
65
  if (value < 0) return 0
36
66
  if (value > 1) return 1
37
67
  return value
38
68
  }
39
69
 
70
+ /** True when the level rests on the supplied target (within 5%). */
71
+ function isAtTarget(pct: number, threshold?: number): boolean {
72
+ if (threshold == null) return false
73
+ return Math.abs(pct - Math.round(threshold * 100)) <= 5
74
+ }
75
+
40
76
  export function IntensityBar({
41
77
  level,
42
78
  threshold,
@@ -48,21 +84,22 @@ export function IntensityBar({
48
84
  ...props
49
85
  }: IntensityBarProps) {
50
86
  const isVertical = orientation === 'vertical'
51
- const clampedLevel = clamp01(level)
52
- const percentage = Math.round(clampedLevel * 100)
53
- const zoneColor = getZoneColor(clampedLevel)
87
+ const fillLevel = clampFill(level)
88
+ const pct = Math.round(Math.max(0, level) * 100)
89
+ const zone = zoneForPct(pct)
90
+ const atTarget = isAtTarget(pct, threshold)
54
91
 
55
- const [fillAnim] = useState(() => new Animated.Value(clampedLevel))
92
+ const [fillAnim] = useState(() => new Animated.Value(fillLevel))
56
93
 
57
94
  useEffect(() => {
58
95
  const animation = Animated.timing(fillAnim, {
59
- toValue: clampedLevel,
96
+ toValue: fillLevel,
60
97
  duration: 250,
61
98
  useNativeDriver: false,
62
99
  })
63
100
  animation.start()
64
101
  return () => animation.stop()
65
- }, [clampedLevel, fillAnim])
102
+ }, [fillLevel, fillAnim])
66
103
 
67
104
  const fillSizePercent = fillAnim.interpolate({
68
105
  inputRange: [0, 1],
@@ -74,51 +111,50 @@ export function IntensityBar({
74
111
  : { width: size, height: thickness }
75
112
 
76
113
  const fillStyle = isVertical
114
+ ? { position: 'absolute' as const, bottom: 0, left: 0, right: 0, height: fillSizePercent }
115
+ : { position: 'absolute' as const, top: 0, bottom: 0, left: 0, width: fillSizePercent }
116
+
117
+ const bulgeSize = zone.over === 0 ? 0 : BULGE_SIZE[zone.over]
118
+ const bulgeStyle: ViewStyle = {
119
+ position: 'absolute',
120
+ width: bulgeSize,
121
+ height: bulgeSize,
122
+ borderRadius: bulgeSize / 2,
123
+ backgroundColor: zone.color,
124
+ zIndex: 1,
125
+ ...(isVertical
126
+ ? { top: 0, left: '50%', transform: [{ translateX: -bulgeSize / 2 }] }
127
+ : { right: 0, top: '50%', transform: [{ translateY: -bulgeSize / 2 }] }),
128
+ }
129
+
130
+ const targetPct: DimensionValue = `${clampFill(threshold ?? 0) * 100}%`
131
+ const targetLineStyle: ViewStyle = isVertical
77
132
  ? {
78
- position: 'absolute' as const,
79
- bottom: 0,
80
- left: 0,
81
- right: 0,
82
- height: fillSizePercent,
133
+ position: 'absolute',
134
+ bottom: targetPct,
135
+ left: -3,
136
+ right: -3,
137
+ height: 1.5,
138
+ backgroundColor: TARGET_LINE_COLOR,
139
+ zIndex: 4,
83
140
  }
84
141
  : {
85
- position: 'absolute' as const,
86
- top: 0,
87
- bottom: 0,
88
- left: 0,
89
- width: fillSizePercent,
142
+ position: 'absolute',
143
+ left: targetPct,
144
+ top: -3,
145
+ bottom: -3,
146
+ width: 1.5,
147
+ backgroundColor: TARGET_LINE_COLOR,
148
+ zIndex: 4,
90
149
  }
91
150
 
92
- const thresholdLineStyle: ViewStyle | undefined =
93
- threshold != null
94
- ? isVertical
95
- ? {
96
- position: 'absolute' as const,
97
- bottom: `${clamp01(threshold) * 100}%`,
98
- left: -2,
99
- right: -2,
100
- height: 1,
101
- backgroundColor: THRESHOLD_COLOR,
102
- zIndex: 4,
103
- }
104
- : {
105
- position: 'absolute' as const,
106
- left: `${clamp01(threshold) * 100}%`,
107
- top: -2,
108
- bottom: -2,
109
- width: 1,
110
- backgroundColor: THRESHOLD_COLOR,
111
- zIndex: 4,
112
- }
113
- : undefined
114
-
115
151
  return (
116
152
  <View
117
153
  className={className}
118
154
  style={{ alignItems: 'center' }}
119
155
  accessibilityRole="progressbar"
120
- accessibilityValue={{ min: 0, max: 100, now: percentage }}
121
- accessibilityLabel={`Intensity level: ${percentage}%`}
156
+ accessibilityValue={{ min: 0, max: 100, now: pct }}
157
+ accessibilityLabel={`Intensity level: ${pct}%`}
122
158
  testID="intensity-bar"
123
159
  {...props}
124
160
  >
@@ -136,16 +172,31 @@ export function IntensityBar({
136
172
  style={{
137
173
  ...fillStyle,
138
174
  borderRadius: 3,
139
- backgroundColor: zoneColor,
175
+ backgroundColor: zone.color,
140
176
  zIndex: 2,
177
+ ...(atTarget ? { boxShadow: AT_TARGET_GLOW } : null),
141
178
  }}
142
179
  testID="intensity-fill"
143
180
  />
144
181
 
145
- {threshold != null && (
146
- <View style={thresholdLineStyle} testID="intensity-threshold" />
147
- )}
182
+ {zone.over !== 0 && <View style={bulgeStyle} testID="intensity-bulge" />}
183
+
184
+ {threshold != null && <View style={targetLineStyle} testID="intensity-target" />}
148
185
  </View>
186
+
187
+ <Text
188
+ style={{
189
+ fontSize: 8,
190
+ color: LABEL_COLOR,
191
+ fontFamily: '"Nunito Sans", sans-serif',
192
+ marginTop: 6,
193
+ }}
194
+ accessibilityElementsHidden
195
+ testID="intensity-label"
196
+ >
197
+ {pct}%
198
+ </Text>
199
+
149
200
  {showThresholdLabel && threshold != null && (
150
201
  <Text
151
202
  style={{
@@ -153,7 +204,7 @@ export function IntensityBar({
153
204
  fontWeight: '500',
154
205
  color: LABEL_COLOR,
155
206
  fontFamily: 'Inter, sans-serif',
156
- marginTop: 6,
207
+ marginTop: 2,
157
208
  }}
158
209
  accessibilityElementsHidden
159
210
  testID="intensity-threshold-label"
@@ -1,6 +1,7 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, type ViewProps } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
4
5
 
5
6
  export interface PlaceholderStripProps extends ViewProps {
6
7
  width?: number | string
@@ -18,14 +19,12 @@ function SingleStrip({
18
19
  }: Omit<PlaceholderStripProps, 'mode' | 'segments'>) {
19
20
  return (
20
21
  <View
21
- className={cn('border-border-strong', className)}
22
+ className={className}
22
23
  style={[
23
24
  {
24
25
  height: 3,
25
- backgroundColor: 'transparent',
26
- borderWidth: 1,
27
- borderStyle: 'dashed',
28
- borderRadius: 1,
26
+ backgroundColor: WORKOUT_TOKENS.placeholder.fill,
27
+ borderRadius: 2,
29
28
  opacity: 0.5,
30
29
  },
31
30
  width != null ? { width: width as number } : { flex: 1 },
@@ -59,13 +58,10 @@ function SegmentedStrip({
59
58
  {Array.from({ length: segments }, (_, i) => (
60
59
  <View
61
60
  key={i}
62
- className="border-border-strong"
63
61
  style={{
64
62
  flex: 1,
65
63
  height: 3,
66
- backgroundColor: 'transparent',
67
- borderWidth: 1,
68
- borderStyle: 'dashed',
64
+ backgroundColor: WORKOUT_TOKENS.placeholder.fill,
69
65
  borderRadius: 1,
70
66
  minWidth: 4,
71
67
  }}
@@ -1,7 +1,7 @@
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
3
  import { View, Text, Animated, Easing, type ViewProps } from 'react-native'
4
- import { Star } from 'lucide-react'
4
+ import { StarIcon } from './icons'
5
5
  import { BaseBadge } from './BaseBadge'
6
6
 
7
7
  export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
@@ -66,7 +66,7 @@ export function PrBadge({
66
66
  testID="pr-badge-star"
67
67
  {...props}
68
68
  >
69
- <Star size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
69
+ <StarIcon size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
70
70
  </View>
71
71
  ) : (
72
72
  <BaseBadge
@@ -77,7 +77,7 @@ export function PrBadge({
77
77
  >
78
78
  <Text
79
79
  style={{
80
- fontSize: 10,
80
+ fontSize: 12,
81
81
  color: '#FF7900',
82
82
  fontWeight: '700',
83
83
  fontFamily: 'Inter, sans-serif',
@@ -1,6 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, Pressable, type ViewProps } from 'react-native'
3
- import { Star } from 'lucide-react'
3
+ import { StarIcon } from './icons'
4
4
  import { Drawer, DrawerBody } from '../../ui/drawer'
5
5
  import { resolveColor } from '../../../theme/resolve-color'
6
6
 
@@ -84,7 +84,7 @@ function PrRecordRow({ record, index }: { record: PrRecord; index: number }) {
84
84
  }}
85
85
  testID={`pr-history-modal-record-${index}`}
86
86
  >
87
- <Star size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
87
+ <StarIcon size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
88
88
  <Text
89
89
  className="text-text-secondary"
90
90
  style={{
@@ -0,0 +1,50 @@
1
+ # Workout component family
2
+
3
+ Training-specific components (badges, bars, timers, cards, charts, and page-level
4
+ screens) built on titan's atoms. They live flat on disk — the tiering below is a
5
+ documentation contract, not a directory layout, so promoting or composing a
6
+ component is pure import churn with no file moves.
7
+
8
+ ## Tier map
9
+
10
+ **Atoms** — single-purpose, no cross-component state:
11
+ BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip · TempoBar ·
12
+ DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
13
+ SupersetWrapper · InputBar
14
+
15
+ **Molecules** — compose atoms, own a little local state:
16
+ VelocityStrip · SetRow · TempoDisplay · RestTimer · MesoProgressBar · WeekRow ·
17
+ WorkoutCard
18
+
19
+ **Organisms** — full features, often with their own data contract:
20
+ ExerciseCard · MesoCard · MesoStatusCard · PrHistoryModal · ReadinessCheck ·
21
+ StrengthTrendChart · CapacityBandChart · BodyMap · BodyMapDetailPanel
22
+
23
+ **Pages** — phone-shaped reference screens (whole-screen compositions):
24
+ ActiveWorkoutPage · ExerciseDetailPage · ProgramPlanningPage · TrainingStatusPage
25
+
26
+ ## Subpaths
27
+
28
+ Two families are kept off the root barrel so `@titan-design/react-ui` stays free
29
+ of their heavy runtime dependencies. In both cases the value exports move to the
30
+ subpath while type-only re-exports remain on the root, so consumers can still
31
+ type props without pulling the dependency.
32
+
33
+ - **`@titan-design/react-ui/bodymap`** — isolates
34
+ `react-native-body-highlighter` (a native SVG dep). BodyMap, BodyMapDetailPanel,
35
+ TrainingStatusPage, and the muscle taxonomy live here.
36
+ - **`@titan-design/react-ui/pages`** — isolates the page-level organisms
37
+ (ActiveWorkoutPage, ExerciseDetailPage, ProgramPlanningPage). They are full app
38
+ screens orphaned by both consumers, kept as reference implementations while their
39
+ successors (the responsive level views) are built. TrainingStatusPage is the
40
+ exception — it stays under `/bodymap` because of the body-highlighter dep.
41
+
42
+ ## Notes
43
+
44
+ - **BaseBadge is an internal composition primitive** — the shared shell that
45
+ WeightBadge and PrBadge build on. It is exempt from orphan accounting; it is not
46
+ meant to be consumed directly even though it is exported for composition.
47
+ - Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
48
+ SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
49
+ to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
50
+ is unchanged.
@@ -75,16 +75,51 @@ describe('RestTimer', () => {
75
75
  })
76
76
  })
77
77
 
78
- describe('next set info', () => {
79
- it('displays nextSetInfo when provided', () => {
78
+ describe('display-only mode', () => {
79
+ it('renders the Skip and +30s actions by default', () => {
80
+ render(<RestTimer {...defaultProps} />)
81
+ expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
82
+ expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
83
+ })
84
+
85
+ it('hides the Skip and +30s actions when displayOnly', () => {
86
+ render(<RestTimer {...defaultProps} displayOnly />)
87
+ expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
88
+ expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
89
+ })
90
+
91
+ it('keeps the countdown, progress bar, and next-set line when displayOnly', () => {
80
92
  render(
81
93
  <RestTimer
82
94
  {...defaultProps}
95
+ elapsedMs={30000}
96
+ displayOnly
83
97
  nextSetInfo="Bench Press — Set 3 of 4"
84
- />,
98
+ />
99
+ )
100
+ expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:00')
101
+ expect(screen.getByTestId('rest-timer-progress-fill')).toBeInTheDocument()
102
+ expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
103
+ 'Bench Press — Set 3 of 4'
85
104
  )
105
+ })
106
+
107
+ it('does not fire callbacks in display-only mode (controls absent)', () => {
108
+ const onSkip = vi.fn()
109
+ const onAddTime = vi.fn()
110
+ render(<RestTimer {...defaultProps} onSkip={onSkip} onAddTime={onAddTime} displayOnly />)
111
+ expect(screen.queryByLabelText('Skip rest')).not.toBeInTheDocument()
112
+ expect(screen.queryByLabelText('Add 30 seconds')).not.toBeInTheDocument()
113
+ expect(onSkip).not.toHaveBeenCalled()
114
+ expect(onAddTime).not.toHaveBeenCalled()
115
+ })
116
+ })
117
+
118
+ describe('next set info', () => {
119
+ it('displays nextSetInfo when provided', () => {
120
+ render(<RestTimer {...defaultProps} nextSetInfo="Bench Press — Set 3 of 4" />)
86
121
  expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
87
- 'Bench Press — Set 3 of 4',
122
+ 'Bench Press — Set 3 of 4'
88
123
  )
89
124
  })
90
125
 
@@ -127,9 +162,7 @@ describe('RestTimer', () => {
127
162
 
128
163
  it('has timer role with remaining seconds in label', () => {
129
164
  render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={30000} />)
130
- expect(
131
- screen.getByLabelText('Rest timer, 60 seconds remaining'),
132
- ).toBeInTheDocument()
165
+ expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
133
166
  })
134
167
 
135
168
  it('skip button has accessible label', () => {
@@ -146,9 +179,7 @@ describe('RestTimer', () => {
146
179
 
147
180
  describe('RestTimer zero-duration timer (NaN guard)', () => {
148
181
  it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
149
- const { container } = render(
150
- <RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
151
- )
182
+ const { container } = render(<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />)
152
183
  expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
153
184
  expect(container.innerHTML).not.toContain('NaN')
154
185
  })
@@ -11,6 +11,8 @@ export interface RestTimerProps {
11
11
  onAddTime: () => void
12
12
  nextSetInfo?: string
13
13
  visible: boolean
14
+ /** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
15
+ displayOnly?: boolean
14
16
  }
15
17
 
16
18
  export function RestTimer({
@@ -20,6 +22,7 @@ export function RestTimer({
20
22
  onAddTime,
21
23
  nextSetInfo,
22
24
  visible,
25
+ displayOnly = false,
23
26
  }: RestTimerProps) {
24
27
  if (!visible) return null
25
28
 
@@ -123,55 +126,57 @@ export function RestTimer({
123
126
  />
124
127
  </View>
125
128
 
126
- {/* Actions row */}
127
- <View style={{ flexDirection: 'row', gap: 8 }}>
128
- <Pressable
129
- onPress={onAddTime}
130
- style={{
131
- backgroundColor: 'rgba(255,255,255,0.06)',
132
- paddingVertical: 8,
133
- paddingHorizontal: 20,
134
- borderRadius: 8,
135
- }}
136
- accessibilityRole="button"
137
- accessibilityLabel="Add 30 seconds"
138
- testID="rest-timer-add-time"
139
- >
140
- <Text
141
- className="text-text-secondary"
129
+ {/* Actions row — hidden in display-only (poll-only) mode */}
130
+ {!displayOnly && (
131
+ <View style={{ flexDirection: 'row', gap: 8 }}>
132
+ <Pressable
133
+ onPress={onAddTime}
142
134
  style={{
143
- fontSize: 11,
144
- fontFamily: 'Inter, sans-serif',
145
- fontWeight: '600',
135
+ backgroundColor: 'rgba(255,255,255,0.06)',
136
+ paddingVertical: 8,
137
+ paddingHorizontal: 20,
138
+ borderRadius: 8,
146
139
  }}
140
+ accessibilityRole="button"
141
+ accessibilityLabel="Add 30 seconds"
142
+ testID="rest-timer-add-time"
147
143
  >
148
- +30s
149
- </Text>
150
- </Pressable>
151
- <Pressable
152
- onPress={onSkip}
153
- style={{
154
- backgroundColor: 'rgba(255,121,0,0.12)',
155
- paddingVertical: 8,
156
- paddingHorizontal: 20,
157
- borderRadius: 8,
158
- }}
159
- accessibilityRole="button"
160
- accessibilityLabel="Skip rest"
161
- testID="rest-timer-skip"
162
- >
163
- <Text
144
+ <Text
145
+ className="text-text-secondary"
146
+ style={{
147
+ fontSize: 11,
148
+ fontFamily: 'Inter, sans-serif',
149
+ fontWeight: '600',
150
+ }}
151
+ >
152
+ +30s
153
+ </Text>
154
+ </Pressable>
155
+ <Pressable
156
+ onPress={onSkip}
164
157
  style={{
165
- fontSize: 11,
166
- fontFamily: 'Inter, sans-serif',
167
- fontWeight: '600',
168
- color: BRAND_PRIMARY,
158
+ backgroundColor: 'rgba(255,121,0,0.12)',
159
+ paddingVertical: 8,
160
+ paddingHorizontal: 20,
161
+ borderRadius: 8,
169
162
  }}
163
+ accessibilityRole="button"
164
+ accessibilityLabel="Skip rest"
165
+ testID="rest-timer-skip"
170
166
  >
171
- Skip
172
- </Text>
173
- </Pressable>
174
- </View>
167
+ <Text
168
+ style={{
169
+ fontSize: 11,
170
+ fontFamily: 'Inter, sans-serif',
171
+ fontWeight: '600',
172
+ color: BRAND_PRIMARY,
173
+ }}
174
+ >
175
+ Skip
176
+ </Text>
177
+ </Pressable>
178
+ </View>
179
+ )}
175
180
  </View>
176
181
  )
177
182
  }
@@ -1,6 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text } from 'react-native'
3
- import { VelocityStrip } from './VelocityStrip'
3
+ import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
4
4
  import { PrBadge, type PRType } from './PrBadge'
5
5
  import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
6
6
 
@@ -14,7 +14,12 @@ export interface SetRowProps {
14
14
  weight: number | null
15
15
  rpe?: number | null
16
16
  unit: 'lbs' | 'kg'
17
+ /** Per-rep MEAN concentric velocities (m/s). */
17
18
  velocities?: number[]
19
+ /** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
20
+ velocityZones?: readonly VelocityZoneBandProp[]
21
+ /** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
22
+ velocityLiveRepIndex?: number
18
23
  velocityExpanded?: boolean
19
24
  onVelocityToggle?: () => void
20
25
  setType?: string
@@ -43,6 +48,8 @@ export function SetRow({
43
48
  rpe,
44
49
  unit,
45
50
  velocities,
51
+ velocityZones,
52
+ velocityLiveRepIndex,
46
53
  velocityExpanded,
47
54
  onVelocityToggle,
48
55
  setType,
@@ -239,6 +246,8 @@ export function SetRow({
239
246
  >
240
247
  <VelocityStrip
241
248
  velocities={velocities}
249
+ zones={velocityZones}
250
+ liveRepIndex={velocityLiveRepIndex}
242
251
  expanded={velocityExpanded}
243
252
  onToggle={onVelocityToggle}
244
253
  variant={onVelocityToggle ? 'full' : 'mini'}