@titan-design/react-ui 0.3.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +441 -97
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1958 -66
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +71 -738
  10. package/dist/index.d.ts +71 -738
  11. package/dist/index.js +888 -1583
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +4515 -2348
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6646 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6626 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +28 -2
  24. package/dist/theme/index.d.ts +28 -2
  25. package/dist/theme/index.js +37 -0
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +1880 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.mjs +573 -1
  30. package/dist/theme/tokens-css.mjs.map +1 -1
  31. package/package.json +9 -7
  32. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
  33. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  34. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  35. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  36. package/src/components/custom/Workout/BodyMap.tsx +7 -8
  37. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
  38. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  39. package/src/components/custom/Workout/ExerciseCard.tsx +17 -28
  40. package/src/components/custom/Workout/ExerciseDetailPage.tsx +38 -31
  41. package/src/components/custom/Workout/InputBar.tsx +10 -11
  42. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  43. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  44. package/src/components/custom/Workout/MesoCard.tsx +2 -6
  45. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  46. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  47. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -10
  48. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  49. package/src/components/custom/Workout/PrHistoryModal.tsx +14 -16
  50. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
  51. package/src/components/custom/Workout/README.md +50 -0
  52. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
  53. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  54. package/src/components/custom/Workout/RestTimer.tsx +55 -49
  55. package/src/components/custom/Workout/SetRow.tsx +20 -8
  56. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  57. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  58. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  59. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  60. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  61. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  62. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  63. package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
  64. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  65. package/src/components/custom/Workout/VelocityStrip.tsx +210 -43
  66. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  67. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  68. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  69. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  70. package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
  71. package/src/components/custom/Workout/icons.tsx +68 -0
  72. package/src/components/custom/Workout/index.ts +47 -47
  73. package/src/index.ts +3 -2
  74. package/src/pages.ts +61 -0
  75. package/src/test/nativewind-stub.ts +13 -0
  76. package/src/test/stories-smoke.test.tsx +50 -0
  77. package/src/theme/ThemeProvider.test.tsx +24 -0
  78. package/src/theme/ThemeProvider.tsx +50 -0
  79. package/src/theme/barrel.ts +53 -0
  80. package/src/theme/index.ts +8 -41
  81. package/src/theme/native-theming-guard.test.ts +117 -0
  82. package/src/theme/resolve-color.ts +19 -0
  83. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  84. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  85. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  86. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  87. package/dist/chunk-P7TSQUN6.mjs +0 -846
  88. package/dist/chunk-P7TSQUN6.mjs.map +0 -1
  89. package/dist/chunk-TOD2WQBG.mjs +0 -1048
  90. package/dist/chunk-TOD2WQBG.mjs.map +0 -1
@@ -0,0 +1,215 @@
1
+ // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
+ import { View, Text, type ViewProps } from 'react-native'
3
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
4
+ import { alpha } from '../../../utils/colors'
5
+
6
+ const t = getSemanticColors('dark')
7
+
8
+ /**
9
+ * Titan-local phase key. Consumers map their own movement-phase enum onto this
10
+ * presentational key at the call site — TempoBar owns rendering only.
11
+ */
12
+ export type TempoPhaseKey = 'concentric' | 'hold' | 'eccentric'
13
+
14
+ /** Pacing tuning shared with the fill/pacing helpers below. */
15
+ export const TEMPO_PACING = {
16
+ behindThresholdPct: 0.15,
17
+ minPhaseDurationMs: 500,
18
+ } as const
19
+
20
+ export type TempoPacingState = 'none' | 'on-pace' | 'behind'
21
+
22
+ /**
23
+ * Classify how a phase's elapsed time tracks against its target duration.
24
+ * `none` when there is no meaningful target; `behind` once elapsed exceeds the
25
+ * target by more than the behind threshold; `on-pace` otherwise.
26
+ */
27
+ export function getTempoPacingState(
28
+ elapsedMs: number,
29
+ targetMs: number | null,
30
+ ): TempoPacingState {
31
+ if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
32
+ const ratio = elapsedMs / targetMs
33
+ if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
34
+ return 'on-pace'
35
+ }
36
+
37
+ /** Fill percentage (0–100) for the active phase; full when no target is set. */
38
+ export function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
39
+ if (!targetMs) return 100
40
+ return Math.min(100, (elapsedMs / targetMs) * 100)
41
+ }
42
+
43
+ interface PhaseConfig {
44
+ label: string
45
+ color: string
46
+ flex: number
47
+ }
48
+
49
+ const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
50
+
51
+ const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
52
+ concentric: { label: 'Con', color: t['status-success'], flex: 2 },
53
+ hold: { label: 'Hold', color: t['brand-primary'], flex: 1 },
54
+ eccentric: { label: 'Ecc', color: t['status-warning'], flex: 3 },
55
+ }
56
+
57
+ export interface TempoBarProps extends ViewProps {
58
+ /** Phase currently in progress, or null when idle/at rest. */
59
+ activePhase: TempoPhaseKey | null
60
+ /** Elapsed time (ms) within the active phase. */
61
+ phaseElapsedMs: number
62
+ /** Completed phase durations (ms) for the current rep, keyed by phase. */
63
+ completed?: Partial<Record<TempoPhaseKey, number>>
64
+ /** Optional per-phase target durations (seconds) for pacing feedback. */
65
+ target?: Partial<Record<TempoPhaseKey, number>>
66
+ className?: string
67
+ }
68
+
69
+ /**
70
+ * TempoBar — live rep phase progression indicator.
71
+ *
72
+ * Renders a segmented bar (Con → Hold → Ecc) where the active phase fills in
73
+ * real time and completed phases show their duration with a ✓/✗ pacing mark.
74
+ * Presentational only: the consumer derives which phase is active, its elapsed
75
+ * time, and completed durations, then feeds them in.
76
+ */
77
+ export function TempoBar({
78
+ activePhase,
79
+ phaseElapsedMs,
80
+ completed,
81
+ target,
82
+ className,
83
+ ...props
84
+ }: TempoBarProps) {
85
+ return (
86
+ <View className={className} testID="tempo-bar" {...props}>
87
+ {/* Phase labels */}
88
+ <View style={{ flexDirection: 'row', marginBottom: 4 }}>
89
+ {PHASE_ORDER.map((phase) => {
90
+ const config = PHASE_CONFIG[phase]
91
+ return (
92
+ <View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
93
+ <Text style={{ fontSize: 10, color: t['text-disabled'] }}>{config.label}</Text>
94
+ </View>
95
+ )
96
+ })}
97
+ </View>
98
+
99
+ {/* Segmented bar */}
100
+ <View style={{ flexDirection: 'row', gap: 2, height: 20 }}>
101
+ {PHASE_ORDER.map((phase) => {
102
+ const config = PHASE_CONFIG[phase]
103
+ const isActive = activePhase === phase
104
+ const completedMs = completed?.[phase]
105
+ const targetSec = target?.[phase]
106
+ const targetMs = targetSec != null ? targetSec * 1000 : null
107
+
108
+ return (
109
+ <View
110
+ key={phase}
111
+ style={{
112
+ flex: config.flex,
113
+ backgroundColor: alpha('#ffffff', 0.06),
114
+ borderRadius: 4,
115
+ overflow: 'hidden',
116
+ }}
117
+ >
118
+ {isActive ? (
119
+ <ActiveSegment
120
+ config={config}
121
+ phaseElapsedMs={phaseElapsedMs}
122
+ targetMs={targetMs}
123
+ />
124
+ ) : completedMs != null ? (
125
+ <CompletedSegment config={config} completedMs={completedMs} targetMs={targetMs} />
126
+ ) : null}
127
+ </View>
128
+ )
129
+ })}
130
+ </View>
131
+ </View>
132
+ )
133
+ }
134
+
135
+ function ActiveSegment({
136
+ config,
137
+ phaseElapsedMs,
138
+ targetMs,
139
+ }: {
140
+ config: PhaseConfig
141
+ phaseElapsedMs: number
142
+ targetMs: number | null
143
+ }) {
144
+ const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
145
+ const barColor = pacing === 'behind' ? t['status-error'] : config.color
146
+ const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
147
+
148
+ const label = targetMs
149
+ ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
150
+ : formatDuration(phaseElapsedMs)
151
+
152
+ return (
153
+ <View style={{ height: '100%', position: 'relative' }} testID={`tempo-segment-active-${config.label}`}>
154
+ <View
155
+ style={{
156
+ position: 'absolute',
157
+ left: 0,
158
+ top: 0,
159
+ bottom: 0,
160
+ width: `${fillPct}%`,
161
+ backgroundColor: alpha(barColor, 0.3),
162
+ borderRadius: 4,
163
+ }}
164
+ />
165
+ <View
166
+ style={{
167
+ height: '100%',
168
+ flexDirection: 'row',
169
+ alignItems: 'center',
170
+ justifyContent: 'center',
171
+ }}
172
+ >
173
+ <Text style={{ fontSize: 12, fontWeight: '700', color: barColor }}>{label}</Text>
174
+ </View>
175
+ </View>
176
+ )
177
+ }
178
+
179
+ function CompletedSegment({
180
+ config,
181
+ completedMs,
182
+ targetMs,
183
+ }: {
184
+ config: PhaseConfig
185
+ completedMs: number
186
+ targetMs: number | null
187
+ }) {
188
+ const pacing = getTempoPacingState(completedMs, targetMs)
189
+ const hitTarget = pacing !== 'behind'
190
+ const indicator =
191
+ targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
192
+
193
+ return (
194
+ <View
195
+ style={{
196
+ height: '100%',
197
+ flexDirection: 'row',
198
+ alignItems: 'center',
199
+ justifyContent: 'center',
200
+ backgroundColor: alpha(config.color, 0.15),
201
+ }}
202
+ testID={`tempo-segment-completed-${config.label}`}
203
+ >
204
+ <Text style={{ fontSize: 12, fontWeight: '500', color: alpha(config.color, 0.7) }}>
205
+ {formatDuration(completedMs)}
206
+ {indicator}
207
+ </Text>
208
+ </View>
209
+ )
210
+ }
211
+
212
+ function formatDuration(ms: number): string {
213
+ const s = ms / 1000
214
+ return s < 10 ? s.toFixed(1) : Math.round(s).toString()
215
+ }
@@ -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]} />)
@@ -2,6 +2,23 @@
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 { getTempoFillPct, getTempoPacingState } from './TempoBar'
7
+
8
+ /** The four tempo phases, in the order the display renders them. */
9
+ export type TempoLivePhase = 'eccentric' | 'pauseBottom' | 'concentric' | 'pauseTop'
10
+
11
+ /**
12
+ * Live rep state driving the phase-fill overlay. Controlled by the consumer
13
+ * (matching titan's `elapsedMs`-in convention) so the same real timer feeds
14
+ * both web dashboard and native app — TempoDisplay owns rendering only.
15
+ */
16
+ export interface TempoLiveState {
17
+ /** Phase currently in progress, or null when idle/at rest. */
18
+ activePhase: TempoLivePhase | null
19
+ /** Elapsed time (ms) within the active phase. */
20
+ phaseElapsedMs: number
21
+ }
5
22
 
6
23
  export interface TempoDisplayProps extends ViewProps {
7
24
  /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
@@ -11,12 +28,19 @@ export interface TempoDisplayProps extends ViewProps {
11
28
  colored?: boolean
12
29
  /** Show info tooltip on press */
13
30
  showInfo?: boolean
31
+ /**
32
+ * When set, renders a live phase-fill: the active phase digit fills against
33
+ * its prescribed duration and inactive phases dim. Omit for the static
34
+ * prescription string (default, backward compatible).
35
+ */
36
+ live?: TempoLiveState
14
37
  onPress?: () => void
15
38
  className?: string
16
39
  }
17
40
 
18
41
  const INTER = 'Inter, sans-serif'
19
42
  const TEXT_TERTIARY = '#6B7280'
43
+ const STATUS_ERROR = '#D14343' // status-error (red), shown when a phase runs behind pace
20
44
 
21
45
  // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
22
46
  const phaseColors = {
@@ -67,11 +91,84 @@ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }
67
91
  )
68
92
  }
69
93
 
94
+ // Phase order + color matching the tempo tuple, for the live phase-fill row.
95
+ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
96
+ { key: 'eccentric', color: phaseColors.eccentric },
97
+ { key: 'pauseBottom', color: phaseColors.pauseBottom },
98
+ { key: 'concentric', color: phaseColors.concentric },
99
+ { key: 'pauseTop', color: phaseColors.pauseTop },
100
+ ]
101
+
102
+ function LiveTempoCell({
103
+ value,
104
+ color,
105
+ isActive,
106
+ phaseElapsedMs,
107
+ fontSize,
108
+ }: {
109
+ value: number
110
+ color: string
111
+ isActive: boolean
112
+ phaseElapsedMs: number
113
+ fontSize: number
114
+ }) {
115
+ if (!isActive) {
116
+ return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
117
+ }
118
+ const targetMs = value > 0 ? value * 1000 : null
119
+ const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
120
+ const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
121
+ return (
122
+ <View style={{ position: 'relative' }} testID="tempo-live-active">
123
+ <View
124
+ style={{
125
+ position: 'absolute',
126
+ left: 0,
127
+ top: 0,
128
+ bottom: 0,
129
+ width: `${fillPct}%`,
130
+ backgroundColor: alpha(barColor, 0.3),
131
+ borderRadius: 2,
132
+ }}
133
+ />
134
+ <TempoValue value={value} color={barColor} fontSize={fontSize} />
135
+ </View>
136
+ )
137
+ }
138
+
139
+ function LiveTempoRow({
140
+ values,
141
+ live,
142
+ fontSize,
143
+ }: {
144
+ values: [number, number, number, number]
145
+ live: TempoLiveState
146
+ fontSize: number
147
+ }) {
148
+ return (
149
+ <>
150
+ {LIVE_PHASES.map((phase, i) => (
151
+ <View key={phase.key} style={{ flexDirection: 'row' }}>
152
+ {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
153
+ <LiveTempoCell
154
+ value={values[i]}
155
+ color={phase.color}
156
+ isActive={live.activePhase === phase.key}
157
+ phaseElapsedMs={live.phaseElapsedMs}
158
+ fontSize={fontSize}
159
+ />
160
+ </View>
161
+ ))}
162
+ </>
163
+ )
164
+ }
165
+
70
166
  export function TempoDisplay({
71
167
  tempo,
72
168
  size = 'md',
73
169
  colored = true,
74
170
  showInfo = true,
171
+ live,
75
172
  onPress,
76
173
  className,
77
174
  ...props
@@ -119,7 +216,13 @@ export function TempoDisplay({
119
216
  TEMPO
120
217
  </Text>
121
218
  <View style={{ flexDirection: 'row' }} testID="tempo-value">
122
- {colored ? (
219
+ {live ? (
220
+ <LiveTempoRow
221
+ values={[eccentric, pauseBottom, concentric, pauseTop]}
222
+ live={live}
223
+ fontSize={fontSize}
224
+ />
225
+ ) : colored ? (
123
226
  <>
124
227
  <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
125
228
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
@@ -16,13 +16,6 @@ import {
16
16
  type VolumeStatus,
17
17
  } from './muscleTaxonomy'
18
18
 
19
- const SURFACE_ELEVATED = 'var(--color-surface-elevated)'
20
- const BORDER_DEFAULT = 'var(--color-border-default)'
21
- const TEXT_PRIMARY = 'var(--color-text-primary)'
22
- const TEXT_SECONDARY = 'var(--color-text-secondary)'
23
- const TEXT_TERTIARY = 'var(--color-text-tertiary)'
24
- const PAGE_BG = 'var(--color-background-base)'
25
-
26
19
  /** Volume statuses in display order for the legend and summary cards. */
27
20
  const STATUS_ORDER: VolumeStatus[] = ['under', 'maintenance', 'productive', 'over']
28
21
 
@@ -121,28 +114,28 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
121
114
  return (
122
115
  <View
123
116
  key={key}
117
+ className="bg-surface-elevated border-border"
124
118
  style={{
125
119
  flexGrow: 1,
126
120
  flexBasis: '46%',
127
121
  padding: 12,
128
122
  borderRadius: 10,
129
- backgroundColor: SURFACE_ELEVATED,
130
123
  borderWidth: 1,
131
- borderColor: BORDER_DEFAULT,
132
124
  }}
133
125
  testID={`training-status-page-summary-${key}`}
134
126
  >
135
127
  <Text
128
+ className="text-text-primary"
136
129
  style={{
137
130
  fontSize: 22,
138
131
  fontFamily: '"Space Grotesk", sans-serif',
139
132
  fontWeight: '700',
140
- color: TEXT_PRIMARY,
141
133
  }}
142
134
  >
143
135
  {value}
144
136
  </Text>
145
137
  <Text
138
+ className="text-text-tertiary"
146
139
  style={{
147
140
  marginTop: 2,
148
141
  fontSize: 11,
@@ -150,7 +143,6 @@ function SummaryCards({ summary }: { summary: TrainingStatusSummary }) {
150
143
  fontWeight: '600',
151
144
  letterSpacing: 0.4,
152
145
  textTransform: 'uppercase',
153
- color: TEXT_TERTIARY,
154
146
  }}
155
147
  >
156
148
  {label}
@@ -186,10 +178,10 @@ function StatusLegend() {
186
178
  accessibilityElementsHidden
187
179
  />
188
180
  <Text
181
+ className="text-text-secondary"
189
182
  style={{
190
183
  fontSize: 11,
191
184
  fontFamily: 'Inter, sans-serif',
192
- color: TEXT_SECONDARY,
193
185
  textTransform: 'capitalize',
194
186
  }}
195
187
  >
@@ -247,8 +239,8 @@ export function TrainingStatusPage({
247
239
 
248
240
  return (
249
241
  <View
250
- className={className}
251
- style={{ position: 'relative', width: 390, backgroundColor: PAGE_BG }}
242
+ className={['bg-background-base', className].filter(Boolean).join(' ')}
243
+ style={{ position: 'relative', width: 390 }}
252
244
  accessibilityRole={'main' as ViewProps['accessibilityRole']}
253
245
  aria-label={title}
254
246
  testID="training-status-page"
@@ -257,11 +249,11 @@ export function TrainingStatusPage({
257
249
  <View style={{ padding: 16, gap: 16 }} testID="training-status-page-content">
258
250
  <Text
259
251
  accessibilityRole="header"
252
+ className="text-text-primary"
260
253
  style={{
261
254
  fontSize: 20,
262
255
  fontFamily: '"Space Grotesk", sans-serif',
263
256
  fontWeight: '700',
264
- color: TEXT_PRIMARY,
265
257
  }}
266
258
  testID="training-status-page-title"
267
259
  >
@@ -273,10 +265,9 @@ export function TrainingStatusPage({
273
265
  <SummaryCards summary={summary} />
274
266
 
275
267
  <View
268
+ className="bg-surface-elevated border-border"
276
269
  style={{
277
- backgroundColor: SURFACE_ELEVATED,
278
270
  borderWidth: 1,
279
- borderColor: BORDER_DEFAULT,
280
271
  borderRadius: 12,
281
272
  padding: 12,
282
273
  gap: 12,