@titan-design/react-ui 0.3.0 → 0.4.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 (52) hide show
  1. package/dist/bodymap.js +441 -97
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +49 -62
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/{chunk-TOD2WQBG.mjs → chunk-2SISKTWM.mjs} +22 -43
  6. package/dist/chunk-2SISKTWM.mjs.map +1 -0
  7. package/dist/{chunk-P7TSQUN6.mjs → chunk-SNVKL5WZ.mjs} +42 -5
  8. package/dist/chunk-SNVKL5WZ.mjs.map +1 -0
  9. package/dist/index.d.mts +42 -2
  10. package/dist/index.d.ts +42 -2
  11. package/dist/index.js +431 -328
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +397 -314
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/theme/index.d.mts +28 -2
  16. package/dist/theme/index.d.ts +28 -2
  17. package/dist/theme/index.js +37 -0
  18. package/dist/theme/index.js.map +1 -1
  19. package/dist/theme/index.mjs +1 -1
  20. package/package.json +1 -1
  21. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +10 -15
  22. package/src/components/custom/Workout/BodyMap.tsx +7 -8
  23. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +25 -28
  24. package/src/components/custom/Workout/CapacityBandChart.tsx +2 -3
  25. package/src/components/custom/Workout/ExerciseCard.tsx +12 -27
  26. package/src/components/custom/Workout/ExerciseDetailPage.tsx +21 -27
  27. package/src/components/custom/Workout/InputBar.tsx +10 -11
  28. package/src/components/custom/Workout/MesoCard.tsx +2 -6
  29. package/src/components/custom/Workout/MesoStatusCard.tsx +17 -15
  30. package/src/components/custom/Workout/MuscleGroupChip.tsx +2 -4
  31. package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
  32. package/src/components/custom/Workout/PrHistoryModal.tsx +12 -14
  33. package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -12
  34. package/src/components/custom/Workout/ReadinessCheck.tsx +13 -11
  35. package/src/components/custom/Workout/RestTimer.tsx +8 -7
  36. package/src/components/custom/Workout/SetRow.tsx +10 -7
  37. package/src/components/custom/Workout/StrengthTrendChart.tsx +15 -17
  38. package/src/components/custom/Workout/TempoBar.stories.tsx +95 -0
  39. package/src/components/custom/Workout/TempoBar.test.tsx +103 -0
  40. package/src/components/custom/Workout/TempoBar.tsx +215 -0
  41. package/src/components/custom/Workout/TrainingStatusPage.tsx +8 -17
  42. package/src/components/custom/Workout/VelocityStrip.tsx +7 -5
  43. package/src/components/custom/Workout/WeekRow.tsx +2 -1
  44. package/src/components/custom/Workout/WorkoutCard.tsx +3 -8
  45. package/src/components/custom/Workout/index.ts +9 -0
  46. package/src/test/nativewind-stub.ts +13 -0
  47. package/src/theme/ThemeProvider.test.tsx +24 -0
  48. package/src/theme/ThemeProvider.tsx +50 -0
  49. package/src/theme/index.ts +2 -0
  50. package/src/theme/resolve-color.ts +19 -0
  51. package/dist/chunk-P7TSQUN6.mjs.map +0 -1
  52. package/dist/chunk-TOD2WQBG.mjs.map +0 -1
@@ -0,0 +1,95 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View } from 'react-native'
3
+ import { TempoBar } from './TempoBar'
4
+
5
+ const meta: Meta<typeof TempoBar> = {
6
+ title: 'Custom/Workout/TempoBar',
7
+ component: TempoBar,
8
+ tags: ['autodocs'],
9
+ argTypes: {
10
+ activePhase: {
11
+ control: 'select',
12
+ options: [null, 'concentric', 'hold', 'eccentric'],
13
+ description: 'Phase currently in progress (null = idle)',
14
+ },
15
+ phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
16
+ completed: { control: 'object', description: 'Completed phase durations (ms)' },
17
+ target: { control: 'object', description: 'Per-phase target durations (seconds)' },
18
+ },
19
+ decorators: [
20
+ (Story) => (
21
+ <View style={{ width: 240 }}>
22
+ <Story />
23
+ </View>
24
+ ),
25
+ ],
26
+ }
27
+
28
+ export default meta
29
+ type Story = StoryObj<typeof TempoBar>
30
+
31
+ // tempo target: 1s concentric, 1s hold-top, 3s eccentric
32
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
33
+
34
+ export const Idle: Story = {
35
+ args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
36
+ }
37
+
38
+ export const ConcentricActive: Story = {
39
+ args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
40
+ }
41
+
42
+ export const EccentricMidRep: Story = {
43
+ args: {
44
+ activePhase: 'eccentric',
45
+ phaseElapsedMs: 1500,
46
+ completed: { concentric: 900, hold: 1000 },
47
+ target: TARGET,
48
+ },
49
+ }
50
+
51
+ export const BehindPace: Story = {
52
+ args: {
53
+ activePhase: 'eccentric',
54
+ phaseElapsedMs: 4200,
55
+ completed: { concentric: 1600, hold: 1000 },
56
+ target: TARGET,
57
+ },
58
+ }
59
+
60
+ export const RepComplete: Story = {
61
+ args: {
62
+ activePhase: null,
63
+ phaseElapsedMs: 0,
64
+ completed: { concentric: 950, hold: 1050, eccentric: 3100 },
65
+ target: TARGET,
66
+ },
67
+ }
68
+
69
+ export const NoTarget: Story = {
70
+ args: {
71
+ activePhase: 'concentric',
72
+ phaseElapsedMs: 800,
73
+ },
74
+ }
75
+
76
+ export const AllStates: Story = {
77
+ render: () => (
78
+ <View style={{ gap: 20 }}>
79
+ <TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
80
+ <TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
81
+ <TempoBar
82
+ activePhase="eccentric"
83
+ phaseElapsedMs={1500}
84
+ completed={{ concentric: 900, hold: 1000 }}
85
+ target={TARGET}
86
+ />
87
+ <TempoBar
88
+ activePhase={null}
89
+ phaseElapsedMs={0}
90
+ completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
91
+ target={TARGET}
92
+ />
93
+ </View>
94
+ ),
95
+ }
@@ -0,0 +1,103 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import {
5
+ TempoBar,
6
+ getTempoPacingState,
7
+ getTempoFillPct,
8
+ TEMPO_PACING,
9
+ } from './TempoBar'
10
+
11
+ const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
12
+
13
+ describe('getTempoPacingState', () => {
14
+ it('returns none when there is no target', () => {
15
+ expect(getTempoPacingState(1000, null)).toBe('none')
16
+ })
17
+
18
+ it('returns none for a sub-threshold target', () => {
19
+ expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
20
+ })
21
+
22
+ it('returns on-pace when within the behind threshold', () => {
23
+ expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
24
+ expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
25
+ })
26
+
27
+ it('returns behind once elapsed exceeds the behind threshold', () => {
28
+ expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
29
+ })
30
+ })
31
+
32
+ describe('getTempoFillPct', () => {
33
+ it('fills fully when there is no target', () => {
34
+ expect(getTempoFillPct(500, null)).toBe(100)
35
+ })
36
+
37
+ it('is proportional to elapsed vs target', () => {
38
+ expect(getTempoFillPct(500, 1000)).toBe(50)
39
+ })
40
+
41
+ it('clamps at 100 when past target', () => {
42
+ expect(getTempoFillPct(2000, 1000)).toBe(100)
43
+ })
44
+ })
45
+
46
+ describe('TempoBar', () => {
47
+ it('renders the bar with phase labels', () => {
48
+ render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
49
+ expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
50
+ expect(screen.getByText('Con')).toBeInTheDocument()
51
+ expect(screen.getByText('Hold')).toBeInTheDocument()
52
+ expect(screen.getByText('Ecc')).toBeInTheDocument()
53
+ })
54
+
55
+ it('renders the active segment with an elapsed/target label', () => {
56
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
57
+ expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
58
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
59
+ })
60
+
61
+ it('shows only elapsed when the active phase has no target', () => {
62
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
63
+ expect(screen.getByText('0.8')).toBeInTheDocument()
64
+ })
65
+
66
+ it('marks a completed phase that hit its target with a check', () => {
67
+ render(
68
+ <TempoBar
69
+ activePhase="hold"
70
+ phaseElapsedMs={100}
71
+ completed={{ concentric: 950 }}
72
+ target={TARGET}
73
+ />,
74
+ )
75
+ expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
76
+ expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
77
+ })
78
+
79
+ it('marks a completed phase that missed its target with a cross', () => {
80
+ render(
81
+ <TempoBar
82
+ activePhase="hold"
83
+ phaseElapsedMs={100}
84
+ completed={{ eccentric: 4000 }}
85
+ target={TARGET}
86
+ />,
87
+ )
88
+ // eccentric target 3s, 4s elapsed => behind => cross
89
+ expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
90
+ })
91
+
92
+ it('has no accessibility violations', async () => {
93
+ const { container } = render(
94
+ <TempoBar
95
+ activePhase="eccentric"
96
+ phaseElapsedMs={1500}
97
+ completed={{ concentric: 900, hold: 1000 }}
98
+ target={TARGET}
99
+ />,
100
+ )
101
+ expect(await axe(container)).toHaveNoViolations()
102
+ })
103
+ })
@@ -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
+ }
@@ -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,
@@ -151,7 +151,9 @@ export function VelocityStrip({
151
151
 
152
152
  const stripContent = (
153
153
  <Animated.View
154
- className={className}
154
+ className={[className, expanded ? 'bg-surface-raised' : 'bg-transparent']
155
+ .filter(Boolean)
156
+ .join(' ')}
155
157
  style={[
156
158
  {
157
159
  height: heightAnim,
@@ -162,7 +164,6 @@ export function VelocityStrip({
162
164
  paddingBottom: expanded ? 24 : 0,
163
165
  paddingHorizontal: expanded ? 6 : 0,
164
166
  paddingVertical: expanded ? undefined : 8,
165
- backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
166
167
  overflow: expanded ? 'visible' : 'hidden',
167
168
  },
168
169
  ]}
@@ -189,7 +190,8 @@ export function VelocityStrip({
189
190
  {expanded && (
190
191
  <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
191
192
  <Text
192
- style={{ fontSize: 8, fontWeight: '600', color: 'var(--color-text-secondary)' }}
193
+ className="text-text-secondary"
194
+ style={{ fontSize: 8, fontWeight: '600' }}
193
195
  testID={`velocity-label-${i}`}
194
196
  >
195
197
  {formatVelocity(v)}
@@ -248,18 +250,18 @@ export function VelocityStrip({
248
250
  testID="velocity-info-row"
249
251
  >
250
252
  <Text
253
+ className="text-text-secondary"
251
254
  style={{
252
255
  fontSize: 10,
253
- color: 'var(--color-text-secondary)',
254
256
  fontFamily: 'Inter, sans-serif',
255
257
  }}
256
258
  >
257
259
  {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
258
260
  </Text>
259
261
  <Text
262
+ className="text-text-secondary"
260
263
  style={{
261
264
  fontSize: 10,
262
- color: 'var(--color-text-secondary)',
263
265
  fontFamily: 'Inter, sans-serif',
264
266
  ...getLossStyle(loss),
265
267
  }}
@@ -100,11 +100,12 @@ export function WeekRow({
100
100
  />
101
101
  )}
102
102
  <Text
103
+ className={isCurrent ? undefined : 'text-text-primary'}
103
104
  style={{
104
105
  fontSize: 13,
105
106
  fontWeight: '700',
106
107
  fontFamily: 'Inter, sans-serif',
107
- color: isCurrent ? BRAND_PRIMARY : 'var(--color-text-primary)',
108
+ color: isCurrent ? BRAND_PRIMARY : undefined,
108
109
  }}
109
110
  accessibilityElementsHidden
110
111
  >
@@ -43,11 +43,6 @@ export interface WorkoutCardProps extends ViewProps {
43
43
  }
44
44
 
45
45
  const COLORS = {
46
- // Theme-aware foregrounds: resolve to a readable color in both light and dark
47
- // (react-native-web passes the CSS var through to the inline style).
48
- textPrimary: 'var(--color-text-primary)',
49
- textSecondary: 'var(--color-text-secondary)',
50
- textTertiary: 'var(--color-text-tertiary)',
51
46
  statusSuccess: '#14B8A6',
52
47
  brandPrimary: '#FF7900',
53
48
  borderDefault: '#1F1F1F',
@@ -124,11 +119,11 @@ export function WorkoutCard({
124
119
  <View style={{ padding: 14 }} testID="workout-card-body">
125
120
  <View className="flex-row items-center" testID="workout-card-header">
126
121
  <Text
122
+ className="text-text-primary"
127
123
  style={{
128
124
  fontSize: 15,
129
125
  fontFamily: '"Space Grotesk", sans-serif',
130
126
  fontWeight: '700',
131
- color: COLORS.textPrimary,
132
127
  }}
133
128
  testID="workout-card-name"
134
129
  >
@@ -136,10 +131,10 @@ export function WorkoutCard({
136
131
  </Text>
137
132
  <View className="flex-1" />
138
133
  <Text
134
+ className="text-text-tertiary"
139
135
  style={{
140
136
  fontSize: 11,
141
137
  fontFamily: 'Inter, sans-serif',
142
- color: COLORS.textTertiary,
143
138
  }}
144
139
  testID="workout-card-date"
145
140
  >
@@ -148,11 +143,11 @@ export function WorkoutCard({
148
143
  </View>
149
144
 
150
145
  <Text
146
+ className="text-text-secondary"
151
147
  style={{
152
148
  marginTop: 4,
153
149
  fontSize: 12,
154
150
  fontFamily: 'Inter, sans-serif',
155
- color: COLORS.textSecondary,
156
151
  }}
157
152
  testID="workout-card-stats"
158
153
  >
@@ -9,6 +9,15 @@ export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
9
9
  export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
10
10
  export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
11
11
  export { TempoDisplay, type TempoDisplayProps } from './TempoDisplay'
12
+ export {
13
+ TempoBar,
14
+ type TempoBarProps,
15
+ type TempoPhaseKey,
16
+ type TempoPacingState,
17
+ TEMPO_PACING,
18
+ getTempoPacingState,
19
+ getTempoFillPct,
20
+ } from './TempoBar'
12
21
  export { DeviationBar, type DeviationBarProps } from './DeviationBar'
13
22
  export { IntensityBar, type IntensityBarProps } from './IntensityBar'
14
23
  export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
@@ -0,0 +1,13 @@
1
+ // Web test stub for `nativewind`.
2
+ //
3
+ // The real `nativewind` pulls react-native-css-interop → RN's Flow-typed
4
+ // sources, which the jsdom/react-native-web test environment can't parse
5
+ // ("Unexpected token 'typeof'"). Only `ThemeProvider` imports nativewind (for
6
+ // `vars()`), and only for its NATIVE behavior — on web the color tokens resolve
7
+ // from `global.css`, so an identity `vars()` is a faithful web stand-in.
8
+ // Aliased in `vitest.config.ts`; the real package is used at build/native.
9
+
10
+ /** Identity stand-in for nativewind's `vars()` — returns the token map as-is. */
11
+ export function vars(values: Record<string, string>): Record<string, string> {
12
+ return values
13
+ }
@@ -0,0 +1,24 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { Text } from 'react-native'
4
+ import { ThemeProvider } from './ThemeProvider'
5
+
6
+ describe('ThemeProvider', () => {
7
+ it('renders its children', () => {
8
+ render(
9
+ <ThemeProvider>
10
+ <Text>inside</Text>
11
+ </ThemeProvider>,
12
+ )
13
+ expect(screen.getByText('inside')).toBeInTheDocument()
14
+ })
15
+
16
+ it('accepts a light mode without error', () => {
17
+ render(
18
+ <ThemeProvider mode="light">
19
+ <Text>light</Text>
20
+ </ThemeProvider>,
21
+ )
22
+ expect(screen.getByText('light')).toBeInTheDocument()
23
+ })
24
+ })