@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.
- package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
- package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
- package/dist/bodymap.d.mts +246 -3
- package/dist/bodymap.d.ts +246 -3
- package/dist/bodymap.mjs +1911 -6
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +31 -738
- package/dist/index.d.ts +31 -738
- package/dist/index.js +894 -1692
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10287 -8203
- package/dist/index.mjs.map +1 -1
- package/dist/pages-D7Jlssvp.d.ts +791 -0
- package/dist/pages-MO0JCySL.d.mts +791 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.d.ts +4 -0
- package/dist/pages.js +6646 -0
- package/dist/pages.js.map +1 -0
- package/dist/pages.mjs +6626 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/theme/index.d.mts +12 -12
- package/dist/theme/index.d.ts +12 -12
- package/dist/theme/index.js +30 -30
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +1880 -2
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.mjs +573 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +9 -7
- package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +17 -4
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +109 -58
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
- package/src/components/custom/Workout/PrBadge.tsx +3 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +48 -43
- package/src/components/custom/Workout/SetRow.tsx +10 -1
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
- package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
- package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +2 -2
- package/src/components/custom/Workout/icons.tsx +68 -0
- package/src/components/custom/Workout/index.ts +38 -47
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/barrel.ts +53 -0
- package/src/theme/index.ts +8 -43
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-2SISKTWM.mjs +0 -1027
- package/dist/chunk-2SISKTWM.mjs.map +0 -1
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-SNVKL5WZ.mjs +0 -883
- package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
|
@@ -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: {
|
|
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
|
-
{
|
|
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} />
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { describe, it, expect, vi } from 'vitest'
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest'
|
|
2
2
|
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
3
|
import { axe } from 'jest-axe'
|
|
4
4
|
import {
|
|
@@ -210,10 +210,26 @@ describe('getVelocityZoneName', () => {
|
|
|
210
210
|
})
|
|
211
211
|
|
|
212
212
|
describe('calculateVelocityLoss', () => {
|
|
213
|
-
it('calculates percentage loss from
|
|
213
|
+
it('calculates percentage loss from running-best to last rep', () => {
|
|
214
|
+
// Best is the first rep here, so best->last matches the legacy result.
|
|
214
215
|
expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
|
|
215
216
|
})
|
|
216
217
|
|
|
218
|
+
it('uses the best rep (not the first) as the reference for non-monotonic sets', () => {
|
|
219
|
+
// best = 1.0 (rep 2), last = 0.6 -> (1.0 - 0.6) / 1.0 = 40%.
|
|
220
|
+
// Legacy first->last would have been (0.8 - 0.6) / 0.8 = 25%.
|
|
221
|
+
expect(calculateVelocityLoss([0.8, 1.0, 0.6])).toBe(40)
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it('clamps to 0 when the set ends on its best rep', () => {
|
|
225
|
+
expect(calculateVelocityLoss([0.6, 0.8, 1.0])).toBe(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
it('clamps to 0 when a later rep exceeds the first rep', () => {
|
|
229
|
+
// best = 1.2 (last), so loss is negative pre-clamp -> 0.
|
|
230
|
+
expect(calculateVelocityLoss([1.0, 0.9, 1.2])).toBe(0)
|
|
231
|
+
})
|
|
232
|
+
|
|
217
233
|
it('returns 0 for single rep', () => {
|
|
218
234
|
expect(calculateVelocityLoss([1.0])).toBe(0)
|
|
219
235
|
})
|
|
@@ -221,6 +237,10 @@ describe('calculateVelocityLoss', () => {
|
|
|
221
237
|
it('returns 0 for empty array', () => {
|
|
222
238
|
expect(calculateVelocityLoss([])).toBe(0)
|
|
223
239
|
})
|
|
240
|
+
|
|
241
|
+
it('returns 0 when the best rep is non-positive', () => {
|
|
242
|
+
expect(calculateVelocityLoss([0, 0])).toBe(0)
|
|
243
|
+
})
|
|
224
244
|
})
|
|
225
245
|
|
|
226
246
|
describe('calculateMeanVelocity', () => {
|
|
@@ -233,6 +253,73 @@ describe('calculateMeanVelocity', () => {
|
|
|
233
253
|
})
|
|
234
254
|
})
|
|
235
255
|
|
|
256
|
+
// WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
|
|
257
|
+
const compoundBands = [
|
|
258
|
+
{ id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
|
|
259
|
+
{ id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
|
|
260
|
+
{ id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
|
|
261
|
+
{ id: 'power', label: 'Power', min: 0.75, max: 1.0 },
|
|
262
|
+
{ id: 'speed', label: 'Speed', min: 1.0, max: null },
|
|
263
|
+
] as const
|
|
264
|
+
|
|
265
|
+
describe('VelocityStrip zones prop', () => {
|
|
266
|
+
it('colors bars from the supplied bands (mini)', () => {
|
|
267
|
+
render(<VelocityStrip velocities={[1.1, 0.45]} zones={compoundBands} variant="mini" />)
|
|
268
|
+
// 1.1 -> speed -> green; 0.45 -> maximalStrength -> red (shared with grinding).
|
|
269
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
|
|
270
|
+
expect(screen.getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: '#ff4757' })
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
it('labels the summary row with the band containing the mean velocity', () => {
|
|
274
|
+
// mean of the sample = 0.80 -> power band.
|
|
275
|
+
render(<VelocityStrip velocities={sampleVelocities} zones={compoundBands} expanded />)
|
|
276
|
+
expect(screen.getByTestId('velocity-info-row')).toHaveTextContent('Power')
|
|
277
|
+
})
|
|
278
|
+
|
|
279
|
+
it('falls back to the default scale when zones is absent', () => {
|
|
280
|
+
render(<VelocityStrip velocities={[1.1]} variant="mini" />)
|
|
281
|
+
expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
|
|
282
|
+
})
|
|
283
|
+
})
|
|
284
|
+
|
|
285
|
+
describe('VelocityStrip live mode', () => {
|
|
286
|
+
const originalMatchMedia = window.matchMedia
|
|
287
|
+
|
|
288
|
+
afterEach(() => {
|
|
289
|
+
if (originalMatchMedia) window.matchMedia = originalMatchMedia
|
|
290
|
+
else delete (window as { matchMedia?: unknown }).matchMedia
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('marks the latest rep bar in its accessibility label', () => {
|
|
294
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={3} expanded />)
|
|
295
|
+
expect(
|
|
296
|
+
screen.getByLabelText(/Rep 4: 0\.68 meters per second, latest rep$/),
|
|
297
|
+
).toBeInTheDocument()
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
it('flags a new set peak on the latest bar', () => {
|
|
301
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
|
|
302
|
+
expect(
|
|
303
|
+
screen.getByLabelText(/Rep 1: 1\.10 meters per second, latest rep, new set peak$/),
|
|
304
|
+
).toBeInTheDocument()
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
it('renders without animation when prefers-reduced-motion is set', () => {
|
|
308
|
+
window.matchMedia = vi.fn().mockReturnValue({
|
|
309
|
+
matches: true,
|
|
310
|
+
addEventListener: vi.fn(),
|
|
311
|
+
removeEventListener: vi.fn(),
|
|
312
|
+
}) as unknown as typeof window.matchMedia
|
|
313
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
|
|
314
|
+
expect(screen.getByTestId('velocity-bar-0')).toBeInTheDocument()
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
it('does not apply live marking in the mini variant', () => {
|
|
318
|
+
render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} variant="mini" />)
|
|
319
|
+
expect(screen.queryByLabelText(/latest rep/)).not.toBeInTheDocument()
|
|
320
|
+
})
|
|
321
|
+
})
|
|
322
|
+
|
|
236
323
|
describe('VelocityStrip all-zero velocities (NaN guard)', () => {
|
|
237
324
|
it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
|
|
238
325
|
render(<VelocityStrip velocities={[0, 0, 0]} expanded />)
|