@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
@@ -1,8 +1,11 @@
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
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
7
+
8
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
6
9
 
7
10
  export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
8
11
 
@@ -66,7 +69,7 @@ export function PrBadge({
66
69
  testID="pr-badge-star"
67
70
  {...props}
68
71
  >
69
- <Star size={14} color="#FF7900" fill="#FF7900" strokeWidth={2} />
72
+ <StarIcon size={14} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
70
73
  </View>
71
74
  ) : (
72
75
  <BaseBadge
@@ -77,8 +80,8 @@ export function PrBadge({
77
80
  >
78
81
  <Text
79
82
  style={{
80
- fontSize: 10,
81
- color: '#FF7900',
83
+ fontSize: 12,
84
+ color: BRAND_PRIMARY,
82
85
  fontWeight: '700',
83
86
  fontFamily: 'Inter, sans-serif',
84
87
  }}
@@ -1,12 +1,13 @@
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
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
7
 
7
- const BRAND_PRIMARY = '#FF7900'
8
- const RECENT_BORDER = 'rgba(255,176,32,0.3)'
9
- const RECENT_GLOW = '0 0 12px rgba(255,176,32,0.06)'
8
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
9
+ const RECENT_BORDER = 'rgba(249,180,21,0.3)'
10
+ const RECENT_GLOW = '0 0 12px rgba(249,180,21,0.06)'
10
11
  // Native-safe fallback for the conditional row border (recent uses a computed
11
12
  // rgba, so className can't express both branches).
12
13
  const BORDER_DEFAULT = resolveColor('border-default')
@@ -84,7 +85,7 @@ function PrRecordRow({ record, index }: { record: PrRecord; index: number }) {
84
85
  }}
85
86
  testID={`pr-history-modal-record-${index}`}
86
87
  >
87
- <Star size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
88
+ <StarIcon size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
88
89
  <Text
89
90
  className="text-text-secondary"
90
91
  style={{
@@ -8,8 +8,9 @@ import { WorkoutCard, type WorkoutStatus, type WorkoutMuscleGroup } from './Work
8
8
  import { type WorkoutPillStatus } from './WorkoutPill'
9
9
  import { type ExerciseCardProps } from './ExerciseCard'
10
10
  import { cn } from '../../../utils/cn'
11
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
11
12
 
12
- const BRAND_PRIMARY = '#FF7900'
13
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
13
14
 
14
15
  /** A single workout within a planned week, plus its exercise breakdown. */
15
16
  export interface PlanWorkout {
@@ -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.
@@ -2,12 +2,15 @@
2
2
  import { View, Text, Pressable } from 'react-native'
3
3
  import { Card } from '../../ui/card'
4
4
  import { Badge, type BadgeColor } from '../../ui/badge'
5
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
5
6
 
6
- const BRAND_PRIMARY = '#FF7900'
7
+ const t = getSemanticColors('dark')
8
+
9
+ const BRAND_PRIMARY = t['brand-primary']
7
10
  const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
8
- const STATUS_SUCCESS = '#14B8A6'
9
- const STATUS_WARNING = '#FFB020'
10
- const STATUS_ERROR = '#D14343'
11
+ const STATUS_SUCCESS = t['status-success']
12
+ const STATUS_WARNING = t['status-warning']
13
+ const STATUS_ERROR = t['status-error']
11
14
 
12
15
  /** Emoji option sets per known factor, keyed by lowercase id/label. */
13
16
  const EMOJI_SETS: Record<string, readonly string[]> = {
@@ -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
  })
@@ -1,8 +1,9 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, Pressable } from 'react-native'
3
3
  import { resolveColor } from '../../../theme/resolve-color'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
 
5
- const BRAND_PRIMARY = '#FF7900'
6
+ const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
6
7
 
7
8
  export interface RestTimerProps {
8
9
  totalSeconds: number
@@ -11,6 +12,8 @@ export interface RestTimerProps {
11
12
  onAddTime: () => void
12
13
  nextSetInfo?: string
13
14
  visible: boolean
15
+ /** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
16
+ displayOnly?: boolean
14
17
  }
15
18
 
16
19
  export function RestTimer({
@@ -20,6 +23,7 @@ export function RestTimer({
20
23
  onAddTime,
21
24
  nextSetInfo,
22
25
  visible,
26
+ displayOnly = false,
23
27
  }: RestTimerProps) {
24
28
  if (!visible) return null
25
29
 
@@ -123,55 +127,57 @@ export function RestTimer({
123
127
  />
124
128
  </View>
125
129
 
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"
130
+ {/* Actions row — hidden in display-only (poll-only) mode */}
131
+ {!displayOnly && (
132
+ <View style={{ flexDirection: 'row', gap: 8 }}>
133
+ <Pressable
134
+ onPress={onAddTime}
142
135
  style={{
143
- fontSize: 11,
144
- fontFamily: 'Inter, sans-serif',
145
- fontWeight: '600',
136
+ backgroundColor: 'rgba(255,255,255,0.06)',
137
+ paddingVertical: 8,
138
+ paddingHorizontal: 20,
139
+ borderRadius: 8,
146
140
  }}
141
+ accessibilityRole="button"
142
+ accessibilityLabel="Add 30 seconds"
143
+ testID="rest-timer-add-time"
147
144
  >
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
145
+ <Text
146
+ className="text-text-secondary"
147
+ style={{
148
+ fontSize: 11,
149
+ fontFamily: 'Inter, sans-serif',
150
+ fontWeight: '600',
151
+ }}
152
+ >
153
+ +30s
154
+ </Text>
155
+ </Pressable>
156
+ <Pressable
157
+ onPress={onSkip}
164
158
  style={{
165
- fontSize: 11,
166
- fontFamily: 'Inter, sans-serif',
167
- fontWeight: '600',
168
- color: BRAND_PRIMARY,
159
+ backgroundColor: 'rgba(255,121,0,0.12)',
160
+ paddingVertical: 8,
161
+ paddingHorizontal: 20,
162
+ borderRadius: 8,
169
163
  }}
164
+ accessibilityRole="button"
165
+ accessibilityLabel="Skip rest"
166
+ testID="rest-timer-skip"
170
167
  >
171
- Skip
172
- </Text>
173
- </Pressable>
174
- </View>
168
+ <Text
169
+ style={{
170
+ fontSize: 11,
171
+ fontFamily: 'Inter, sans-serif',
172
+ fontWeight: '600',
173
+ color: BRAND_PRIMARY,
174
+ }}
175
+ >
176
+ Skip
177
+ </Text>
178
+ </Pressable>
179
+ </View>
180
+ )}
175
181
  </View>
176
182
  )
177
183
  }
@@ -1,8 +1,9 @@
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
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
6
7
 
7
8
  export type SetRowMode = 'active' | 'completed' | 'history'
8
9
 
@@ -14,7 +15,12 @@ export interface SetRowProps {
14
15
  weight: number | null
15
16
  rpe?: number | null
16
17
  unit: 'lbs' | 'kg'
18
+ /** Per-rep MEAN concentric velocities (m/s). */
17
19
  velocities?: number[]
20
+ /** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
21
+ velocityZones?: readonly VelocityZoneBandProp[]
22
+ /** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
23
+ velocityLiveRepIndex?: number
18
24
  velocityExpanded?: boolean
19
25
  onVelocityToggle?: () => void
20
26
  setType?: string
@@ -43,6 +49,8 @@ export function SetRow({
43
49
  rpe,
44
50
  unit,
45
51
  velocities,
52
+ velocityZones,
53
+ velocityLiveRepIndex,
46
54
  velocityExpanded,
47
55
  onVelocityToggle,
48
56
  setType,
@@ -93,7 +101,7 @@ export function SetRow({
93
101
  fontSize: 10,
94
102
  fontWeight: '700',
95
103
  fontFamily: 'Inter, sans-serif',
96
- color: '#ffa502',
104
+ color: WORKOUT_TOKENS.scale.orange,
97
105
  backgroundColor: 'rgba(255,165,2,0.12)',
98
106
  paddingVertical: 1,
99
107
  paddingHorizontal: 5,
@@ -239,6 +247,8 @@ export function SetRow({
239
247
  >
240
248
  <VelocityStrip
241
249
  velocities={velocities}
250
+ zones={velocityZones}
251
+ liveRepIndex={velocityLiveRepIndex}
242
252
  expanded={velocityExpanded}
243
253
  onToggle={onVelocityToggle}
244
254
  variant={onVelocityToggle ? 'full' : 'mini'}
@@ -2,8 +2,9 @@
2
2
  import React from 'react'
3
3
  import { View, Text, type ViewProps } from 'react-native'
4
4
  import { cn } from '../../../utils/cn'
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 SparklineProps extends ViewProps {
9
10
  data: number[]
@@ -103,13 +103,13 @@ describe('StatusDot', () => {
103
103
  it('applies a box-shadow when glow is set on a solid variant', () => {
104
104
  render(<StatusDot variant="success" glow />)
105
105
  const dot = screen.getByTestId('status-dot')
106
- expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
106
+ expect(dot.style.boxShadow).toContain('rgba(46,213,115,0.4)')
107
107
  })
108
108
 
109
109
  it('applies a box-shadow when glow is set on a ring variant', () => {
110
110
  render(<StatusDot variant="on-track" glow />)
111
111
  const dot = screen.getByTestId('status-dot')
112
- expect(dot.style.boxShadow).toContain('rgba(20,184,166,0.4)')
112
+ expect(dot.style.boxShadow).toContain('rgba(46,213,115,0.4)')
113
113
  })
114
114
 
115
115
  it('applies a gray box-shadow when glow is set on the future variant', () => {
@@ -1,5 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { View, Text, type ViewProps } from 'react-native'
3
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+
5
+ const t = getSemanticColors('dark')
3
6
 
4
7
  export type StatusDotVariant =
5
8
  | 'success'
@@ -22,17 +25,17 @@ export interface StatusDotProps extends ViewProps {
22
25
  }
23
26
 
24
27
  const solidVariantColors: Record<string, string> = {
25
- success: '#14B8A6',
26
- warning: '#FFB020',
27
- error: '#D14343',
28
+ success: t['status-success'],
29
+ warning: t['status-warning'],
30
+ error: t['status-error'],
28
31
  neutral: '#6B7280',
29
32
  }
30
33
 
31
34
  const ringVariantStyles: Record<string, Record<string, unknown>> = {
32
35
  'on-track': {
33
- backgroundColor: 'rgba(20,184,166,0.15)',
36
+ backgroundColor: 'rgba(46,213,115,0.15)',
34
37
  borderWidth: 1,
35
- borderColor: 'rgba(20,184,166,0.3)',
38
+ borderColor: 'rgba(46,213,115,0.3)',
36
39
  },
37
40
  deviation: {
38
41
  backgroundColor: 'rgba(245,158,11,0.15)',
@@ -48,11 +51,11 @@ const ringVariantStyles: Record<string, Record<string, unknown>> = {
48
51
  }
49
52
 
50
53
  const glowStyles: Record<StatusDotVariant, Record<string, unknown>> = {
51
- success: { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
54
+ success: { boxShadow: '0 0 4px rgba(46,213,115,0.4)' },
52
55
  warning: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
53
56
  error: { boxShadow: '0 0 4px rgba(239,68,68,0.4)' },
54
57
  neutral: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
55
- 'on-track': { boxShadow: '0 0 4px rgba(20,184,166,0.4)' },
58
+ 'on-track': { boxShadow: '0 0 4px rgba(46,213,115,0.4)' },
56
59
  deviation: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
57
60
  future: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
58
61
  }
@@ -69,8 +72,8 @@ const iconColors: Record<StatusDotVariant, string> = {
69
72
  warning: '#6B4000',
70
73
  error: '#5C1A1A',
71
74
  neutral: '#D1D5DB',
72
- 'on-track': '#14B8A6',
73
- deviation: '#FFB020',
75
+ 'on-track': t['status-success'],
76
+ deviation: t['status-warning'],
74
77
  future: '#6B7280',
75
78
  }
76
79
 
@@ -3,17 +3,20 @@ import { useEffect, useMemo, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
4
  import { cn } from '../../../utils/cn'
5
5
  import { resolveColor } from '../../../theme/resolve-color'
6
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
6
7
 
7
- const BRAND_PRIMARY = '#FF7900'
8
+ const sem = getSemanticColors('dark')
9
+
10
+ const BRAND_PRIMARY = sem['brand-primary']
8
11
  // Line/border color for the dashed projection, used where a className cannot
9
12
  // apply (passed as a prop into an inline style, and a per-edge borderTopColor).
10
13
  const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
11
- const STATUS_SUCCESS = '#14B8A6'
12
- const STATUS_ERROR = '#D14343'
13
- const STATUS_WARNING = '#FFB020'
14
+ const STATUS_SUCCESS = sem['status-success']
15
+ const STATUS_ERROR = sem['status-error']
16
+ const STATUS_WARNING = sem['status-warning']
14
17
  const GRID_LINE = 'rgba(255,255,255,0.06)'
15
- const SUCCESS_PILL_BG = 'rgba(20,184,166,0.10)'
16
- const SUCCESS_PILL_BORDER = 'rgba(20,184,166,0.20)'
18
+ const SUCCESS_PILL_BG = 'rgba(46,213,115,0.10)'
19
+ const SUCCESS_PILL_BORDER = 'rgba(46,213,115,0.20)'
17
20
  const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
18
21
  const ERROR_PILL_BORDER = 'rgba(209,67,67,0.20)'
19
22
 
@@ -1,6 +1,7 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import React from 'react'
3
3
  import { View, Text } from 'react-native'
4
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
 
5
6
  export interface SupersetWrapperProps {
6
7
  label?: string
@@ -10,7 +11,7 @@ export interface SupersetWrapperProps {
10
11
 
11
12
  const DEFAULTS = {
12
13
  label: 'SS',
13
- color: '#FF7900',
14
+ color: getSemanticColors('dark')['brand-primary'],
14
15
  bgBase: '#101010',
15
16
  } as const
16
17
 
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useEffect, useState } from 'react'
2
3
  import { View } from 'react-native'
3
- import { TempoDisplay } from './TempoDisplay'
4
+ import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
4
5
 
5
6
  // tempo = [eccentric, pauseBottom, concentric, pauseTop]
6
7
  const meta: Meta<typeof TempoDisplay> = {
@@ -8,10 +9,17 @@ const meta: Meta<typeof TempoDisplay> = {
8
9
  component: TempoDisplay,
9
10
  tags: ['autodocs'],
10
11
  argTypes: {
11
- tempo: { control: 'object', description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds' },
12
+ tempo: {
13
+ control: 'object',
14
+ description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
15
+ },
12
16
  size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
13
17
  colored: { control: 'boolean', description: 'Colored vs mono display' },
14
18
  showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
19
+ live: {
20
+ control: 'object',
21
+ description: 'Live phase-fill state (opt-in); omit for static prescription',
22
+ },
15
23
  },
16
24
  }
17
25
 
@@ -64,3 +72,62 @@ export const AllVariants: Story = {
64
72
  </View>
65
73
  ),
66
74
  }
75
+
76
+ // --- Live phase-fill (opt-in) ----------------------------------------------
77
+
78
+ // Deterministic mid-rep frames — the active phase digit fills against its target.
79
+ export const LiveEccentricInProgress: Story = {
80
+ args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
81
+ }
82
+
83
+ // Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
84
+ export const LiveBehindPace: Story = {
85
+ args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
86
+ }
87
+
88
+ const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
89
+ { phase: 'eccentric', ms: 3000 },
90
+ { phase: 'pauseBottom', ms: 1000 },
91
+ { phase: 'concentric', ms: 1000 },
92
+ ]
93
+
94
+ function LiveTempoDemo() {
95
+ const [live, setLive] = useState<TempoLiveState>({ activePhase: 'eccentric', phaseElapsedMs: 0 })
96
+ useEffect(() => {
97
+ let idx = 0
98
+ let start = Date.now()
99
+ const id = setInterval(() => {
100
+ const step = LIVE_CYCLE[idx]
101
+ const elapsed = Date.now() - start
102
+ if (elapsed >= step.ms + 400) {
103
+ idx = (idx + 1) % LIVE_CYCLE.length
104
+ start = Date.now()
105
+ }
106
+ setLive({
107
+ activePhase: LIVE_CYCLE[idx].phase,
108
+ phaseElapsedMs: Math.min(elapsed, step.ms + 400),
109
+ })
110
+ }, 50)
111
+ return () => clearInterval(id)
112
+ }, [])
113
+ return <TempoDisplay tempo={[3, 1, 1, 0]} live={live} />
114
+ }
115
+
116
+ // Self-driving demo cycling Ecc → Pause → Con to show the fill animating.
117
+ export const LiveAnimated: Story = {
118
+ render: () => <LiveTempoDemo />,
119
+ }
120
+
121
+ // Static and live side by side — the same prescription, two modes.
122
+ export const StaticVsLive: Story = {
123
+ render: () => (
124
+ <View style={{ gap: 12 }}>
125
+ <TempoDisplay tempo={[3, 1, 1, 0]} />
126
+ <TempoDisplay
127
+ tempo={[3, 1, 1, 0]}
128
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
129
+ />
130
+ <LiveTempoDemo />
131
+ </View>
132
+ ),
133
+ }
@@ -80,6 +80,41 @@ describe('TempoDisplay', () => {
80
80
  })
81
81
  })
82
82
 
83
+ describe('live phase-fill', () => {
84
+ it('is inert by default (no active fill) for backward-compatible static use', () => {
85
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
86
+ expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
87
+ })
88
+
89
+ it('renders an active fill overlay for the in-progress phase when live', () => {
90
+ render(
91
+ <TempoDisplay
92
+ tempo={[3, 1, 1, 0]}
93
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
94
+ />
95
+ )
96
+ expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
97
+ expect(screen.getByText('3')).toBeInTheDocument()
98
+ })
99
+
100
+ it('marks no phase active when idle (activePhase null)', () => {
101
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: null, phaseElapsedMs: 0 }} />)
102
+ expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
103
+ })
104
+
105
+ it('still renders all four phase digits in live mode', () => {
106
+ render(
107
+ <TempoDisplay
108
+ tempo={[4, 3, 2, 1]}
109
+ live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
110
+ />
111
+ )
112
+ for (const digit of ['4', '3', '2', '1']) {
113
+ expect(screen.getByText(digit)).toBeInTheDocument()
114
+ }
115
+ })
116
+ })
117
+
83
118
  describe('accessibility', () => {
84
119
  it('has no accessibility violations', async () => {
85
120
  const { container } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)