@titan-design/react-ui 0.8.0 → 0.9.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 (34) hide show
  1. package/dist/index.d.mts +19 -59
  2. package/dist/index.d.ts +19 -59
  3. package/dist/index.js +248 -371
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +249 -368
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DCFBqp79.d.mts → pages-D7hOD4Vj.d.mts} +1 -1
  8. package/dist/{pages-_fI3-x7Z.d.ts → pages-DyEx-lBf.d.ts} +1 -1
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +199 -161
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +199 -161
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
  17. package/src/components/custom/Workout/README.md +33 -13
  18. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  19. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  20. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  21. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  22. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  23. package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
  24. package/src/components/custom/Workout/VelocityStrip.tsx +45 -11
  25. package/src/components/custom/Workout/ZoneTrack.tsx +15 -1
  26. package/src/components/custom/Workout/index.ts +1 -9
  27. package/src/lab/north-star/LivePage.tsx +101 -0
  28. package/src/lab/north-star/LiveView.tsx +409 -0
  29. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  30. package/src/lab/north-star/RestView.tsx +140 -0
  31. package/src/lab/north-star/fixtures.ts +224 -0
  32. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
  33. package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
  34. package/src/components/custom/Workout/TempoBar.tsx +0 -300
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Fixtures for the `Lab/North Star/Live Wall Dashboard` specimen.
3
+ *
4
+ * Shapes deliberately MIRROR the voltras-mcp dashboard store read-models so a later
5
+ * provisional wiring is a mechanical field map, not a reshape:
6
+ * - {@link LiveModel} — the live-set telemetry read-model (`velocity`, `force`,
7
+ * `phase`, `lastRep`, `velocityLossPct`).
8
+ * - {@link SessionModel} — the session read-model (`exerciseName` + completed sets).
9
+ * - {@link CompletedSet} — a logged set (`weightLbs`, `mode`, `repCount`, `reps`).
10
+ *
11
+ * The presentational titan props each view consumes are DERIVED from these read-models
12
+ * (see the `derive*` helpers) — the same projection the store→component adapter will do.
13
+ * Numbers are realistic cable chest-press strength-training values.
14
+ */
15
+ import type { SessionRailExercise } from '../../components'
16
+
17
+ // --- Store read-model shapes (mirror voltras-mcp dashboard store) -------------
18
+
19
+ /** One completed rep's per-rep telemetry (mean-concentric metric family). */
20
+ export interface RepModel {
21
+ /** Mean CONCENTRIC velocity (m/s) — the canonical metric (brain WA-D02). */
22
+ vCon: number
23
+ /** Range of motion (m). */
24
+ rom: number
25
+ /** Peak instantaneous velocity (m/s). */
26
+ peakVelocity: number
27
+ }
28
+
29
+ /** The live-set telemetry read-model. */
30
+ export interface LiveModel {
31
+ /** Instantaneous cable velocity (m/s). */
32
+ velocity: number
33
+ /** Instantaneous cable force (N). */
34
+ force: number
35
+ /** Current movement phase. */
36
+ phase: 'concentric' | 'hold' | 'eccentric' | 'idle'
37
+ /** Elapsed time (ms) within the current phase — drives the live tempo fill. */
38
+ phaseElapsedMs: number
39
+ /** The most-recently completed rep. */
40
+ lastRep: RepModel
41
+ /** Per-rep mean-concentric velocities logged so far this set (m/s). */
42
+ repVelocities: number[]
43
+ /** Velocity loss vs the set's best rep (%). Drives verdict + aura + fatigue. */
44
+ velocityLossPct: number
45
+ /** Peak concentric force this set (N). */
46
+ peakForce: number
47
+ }
48
+
49
+ /** A logged set on the session read-model. */
50
+ export interface CompletedSet {
51
+ weightLbs: number
52
+ /** Resistance mode (cascade echo, cmd=0x10). */
53
+ mode: 'weight' | 'chains' | 'eccentric' | 'isokinetic'
54
+ repCount: number
55
+ /** Per-rep mean-concentric velocities (m/s). */
56
+ reps: number[]
57
+ }
58
+
59
+ /** The session read-model. */
60
+ export interface SessionModel {
61
+ exerciseName: string
62
+ title: string
63
+ weightLbs: number
64
+ unit: 'lbs' | 'kg'
65
+ /**
66
+ * Prescribed tempo tuple [ecc, pauseBottom, con, pauseTop]. OPTIONAL — a set may carry no
67
+ * prescribed tempo (coach left it unset and no exercise default); the live view then hides
68
+ * the tempo readout entirely rather than inventing one.
69
+ */
70
+ tempo?: [number, number, number, number]
71
+ completedSets: CompletedSet[]
72
+ plannedSets: number
73
+ }
74
+
75
+ /** The full store snapshot the specimen reads. */
76
+ export interface DashboardModel {
77
+ live: LiveModel
78
+ session: SessionModel
79
+ }
80
+
81
+ // --- The fixture instance -----------------------------------------------------
82
+
83
+ /**
84
+ * A mid-set cable chest press: 6 reps in, velocity decaying from 0.52 → 0.405 m/s,
85
+ * ~22% velocity loss (the VL20–VL30 mid-zone → `threshold` verdict / amber aura).
86
+ */
87
+ export const dashboardFixture: DashboardModel = {
88
+ live: {
89
+ velocity: 0.41,
90
+ force: 498,
91
+ phase: 'concentric',
92
+ phaseElapsedMs: 700,
93
+ lastRep: { vCon: 0.405, rom: 0.58, peakVelocity: 0.61 },
94
+ repVelocities: [0.52, 0.5, 0.48, 0.46, 0.44, 0.405],
95
+ velocityLossPct: 22,
96
+ peakForce: 542,
97
+ },
98
+ session: {
99
+ exerciseName: 'Cable Chest Press',
100
+ title: 'Push A · Hypertrophy',
101
+ weightLbs: 140,
102
+ unit: 'lbs',
103
+ tempo: [3, 1, 1, 0],
104
+ plannedSets: 4,
105
+ completedSets: [
106
+ {
107
+ weightLbs: 140,
108
+ mode: 'weight',
109
+ repCount: 8,
110
+ reps: [0.55, 0.54, 0.52, 0.51, 0.49, 0.47, 0.45, 0.43],
111
+ },
112
+ {
113
+ weightLbs: 140,
114
+ mode: 'weight',
115
+ repCount: 8,
116
+ reps: [0.53, 0.52, 0.5, 0.48, 0.46, 0.44, 0.42, 0.4],
117
+ },
118
+ ],
119
+ },
120
+ }
121
+
122
+ // --- Derived projections (store read-model → titan presentational props) -------
123
+
124
+ /** Arithmetic mean of a per-rep velocity array (m/s). */
125
+ export function meanVelocity(reps: number[]): number {
126
+ if (reps.length === 0) return 0
127
+ return reps.reduce((a, v) => a + v, 0) / reps.length
128
+ }
129
+
130
+ /** Verdict status from velocity loss %, matching FatigueMeter's VL20/VL30 bands. */
131
+ export function verdictFromLoss(lossPct: number): 'productive' | 'threshold' | 'stop' {
132
+ if (lossPct >= 30) return 'stop'
133
+ if (lossPct >= 20) return 'threshold'
134
+ return 'productive'
135
+ }
136
+
137
+ /**
138
+ * The session rail's exercise list, projected from the model: the current exercise
139
+ * (active), the completed sets behind it, and an upcoming accessory.
140
+ */
141
+ export function deriveRailExercises(model: DashboardModel): SessionRailExercise[] {
142
+ const { session, live } = model
143
+ return [
144
+ {
145
+ id: 'chest-press',
146
+ name: session.exerciseName,
147
+ summary: {
148
+ sets: session.plannedSets,
149
+ reps: 8,
150
+ weight: session.weightLbs,
151
+ unit: session.unit,
152
+ },
153
+ tempo: session.tempo,
154
+ indicator: 'velocity-loss',
155
+ setStates: [
156
+ { status: 'done', velocities: session.completedSets[0].reps },
157
+ { status: 'done', velocities: session.completedSets[1].reps },
158
+ { status: 'active', velocities: live.repVelocities, planned: 8 },
159
+ { status: 'todo', planned: 8 },
160
+ ],
161
+ },
162
+ {
163
+ id: 'incline-db',
164
+ name: 'Incline DB Press',
165
+ summary: { sets: 3, reps: 10, weight: 55, unit: 'lbs' },
166
+ tempo: [2, 0, 1, 0],
167
+ setStates: [
168
+ { status: 'todo', planned: 10 },
169
+ { status: 'todo', planned: 10 },
170
+ { status: 'todo', planned: 10 },
171
+ ],
172
+ upcoming: true,
173
+ },
174
+ {
175
+ id: 'cable-fly',
176
+ name: 'Cable Fly',
177
+ summary: { sets: 3, reps: 12, weight: 30, unit: 'lbs' },
178
+ setStates: [
179
+ { status: 'todo', planned: 12 },
180
+ { status: 'todo', planned: 12 },
181
+ { status: 'todo', planned: 12 },
182
+ ],
183
+ upcoming: true,
184
+ },
185
+ ]
186
+ }
187
+
188
+ // --- Dual-mode (bilateral) projection -----------------------------------------
189
+
190
+ /**
191
+ * Split a single-cable model into a bilateral (dual-mode) pair — one model per voltra.
192
+ *
193
+ * A dual-mode exercise drives two Voltra devices as a left/right pair. For v1 the two
194
+ * layers are rendered independently (one {@link LiveModel} each); a unified split-bar
195
+ * treatment is deferred. The fixture gives the RIGHT side a realistic left-dominant
196
+ * deficit (slightly slower reps, more velocity loss, a touch less force) so the two
197
+ * layers read as genuinely different streams rather than a mirror.
198
+ */
199
+ export function deriveDualModel(base: DashboardModel = dashboardFixture): {
200
+ left: DashboardModel
201
+ right: DashboardModel
202
+ } {
203
+ // Left is the dominant side — the base fixture verbatim.
204
+ const left: DashboardModel = base
205
+ // Right lags: ~8% slower per-rep concentric velocity, +7 pts velocity loss, ~6% less force.
206
+ const scale = (v: number) => Number((v * 0.92).toFixed(3))
207
+ const right: DashboardModel = {
208
+ session: base.session,
209
+ live: {
210
+ ...base.live,
211
+ velocity: scale(base.live.velocity),
212
+ force: Math.round(base.live.force * 0.94),
213
+ peakForce: Math.round(base.live.peakForce * 0.94),
214
+ velocityLossPct: base.live.velocityLossPct + 7,
215
+ repVelocities: base.live.repVelocities.map(scale),
216
+ lastRep: {
217
+ vCon: scale(base.live.lastRep.vCon),
218
+ rom: Number((base.live.lastRep.rom * 0.96).toFixed(2)),
219
+ peakVelocity: scale(base.live.lastRep.peakVelocity),
220
+ },
221
+ },
222
+ }
223
+ return { left, right }
224
+ }
@@ -1,164 +0,0 @@
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: '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
- size: {
19
- control: 'inline-radio',
20
- options: ['default', 'wall'],
21
- description: 'Density: default (compact) or wall (across-the-room dashboard scale)',
22
- },
23
- activeDisplay: {
24
- control: 'inline-radio',
25
- options: ['time', 'delta'],
26
- description: 'Active readout: time (elapsed/target) or delta (countdown). Tap to toggle.',
27
- },
28
- },
29
- decorators: [
30
- (Story) => (
31
- <View style={{ width: 240 }}>
32
- <Story />
33
- </View>
34
- ),
35
- ],
36
- }
37
-
38
- export default meta
39
- type Story = StoryObj<typeof TempoBar>
40
-
41
- // tempo target: 1s concentric, 1s hold-top, 3s eccentric
42
- const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
43
-
44
- export const Idle: Story = {
45
- args: { activePhase: null, phaseElapsedMs: 0, target: TARGET },
46
- }
47
-
48
- export const ConcentricActive: Story = {
49
- args: { activePhase: 'concentric', phaseElapsedMs: 600, target: TARGET },
50
- }
51
-
52
- export const EccentricMidRep: Story = {
53
- args: {
54
- activePhase: 'eccentric',
55
- phaseElapsedMs: 1500,
56
- completed: { concentric: 900, hold: 1000 },
57
- target: TARGET,
58
- },
59
- }
60
-
61
- export const BehindPace: Story = {
62
- args: {
63
- activePhase: 'eccentric',
64
- phaseElapsedMs: 4200,
65
- completed: { concentric: 1600, hold: 1000 },
66
- target: TARGET,
67
- },
68
- }
69
-
70
- export const RepComplete: Story = {
71
- args: {
72
- activePhase: null,
73
- phaseElapsedMs: 0,
74
- completed: { concentric: 950, hold: 1050, eccentric: 3100 },
75
- target: TARGET,
76
- },
77
- }
78
-
79
- export const NoTarget: Story = {
80
- args: {
81
- activePhase: 'concentric',
82
- phaseElapsedMs: 800,
83
- },
84
- }
85
-
86
- export const AllStates: Story = {
87
- render: () => (
88
- <View style={{ gap: 20 }}>
89
- <TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />
90
- <TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />
91
- <TempoBar
92
- activePhase="eccentric"
93
- phaseElapsedMs={1500}
94
- completed={{ concentric: 900, hold: 1000 }}
95
- target={TARGET}
96
- />
97
- <TempoBar
98
- activePhase={null}
99
- phaseElapsedMs={0}
100
- completed={{ concentric: 950, hold: 1050, eccentric: 3100 }}
101
- target={TARGET}
102
- />
103
- </View>
104
- ),
105
- }
106
-
107
- /**
108
- * The across-the-room wall scale — bigger bar, full phase words, and colour-coded
109
- * pacing (a missed target reads red). Here the eccentric ran long → red.
110
- */
111
- export const Wall: Story = {
112
- args: {
113
- activePhase: null,
114
- phaseElapsedMs: 0,
115
- completed: { concentric: 950, hold: 1000, eccentric: 4200 },
116
- target: TARGET,
117
- size: 'wall',
118
- },
119
- decorators: [
120
- (Story) => (
121
- <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
122
- <Story />
123
- </View>
124
- ),
125
- ],
126
- }
127
-
128
- /** Wall, mid-rep — the eccentric is active, showing the delta countdown (1.5s to target). Tap to switch to elapsed/target. */
129
- export const WallActiveDelta: Story = {
130
- args: {
131
- activePhase: 'eccentric',
132
- phaseElapsedMs: 1500,
133
- completed: { concentric: 950, hold: 1000 },
134
- target: TARGET,
135
- size: 'wall',
136
- activeDisplay: 'delta',
137
- },
138
- decorators: [
139
- (Story) => (
140
- <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
141
- <Story />
142
- </View>
143
- ),
144
- ],
145
- }
146
-
147
- /** Wall, over target — the eccentric ran 1.2s past its target, so the delta is negative and red. */
148
- export const WallActiveOver: Story = {
149
- args: {
150
- activePhase: 'eccentric',
151
- phaseElapsedMs: 4200,
152
- completed: { concentric: 950, hold: 1000 },
153
- target: TARGET,
154
- size: 'wall',
155
- activeDisplay: 'delta',
156
- },
157
- decorators: [
158
- (Story) => (
159
- <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
160
- <Story />
161
- </View>
162
- ),
163
- ],
164
- }
@@ -1,218 +0,0 @@
1
- import { describe, it, expect } from 'vitest'
2
- import { render, screen, fireEvent } from '@testing-library/react'
3
- import { axe } from 'jest-axe'
4
- import {
5
- TempoBar,
6
- getTempoPacingState,
7
- getTempoFillPct,
8
- formatTempoDelta,
9
- TEMPO_PACING,
10
- } from './TempoBar'
11
- import { getSemanticColors } from '../../../theme/tokens/semantic'
12
-
13
- const t = getSemanticColors('dark')
14
- const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
15
-
16
- describe('getTempoPacingState', () => {
17
- it('returns none when there is no target', () => {
18
- expect(getTempoPacingState(1000, null)).toBe('none')
19
- })
20
-
21
- it('returns none for a sub-threshold target', () => {
22
- expect(getTempoPacingState(1000, TEMPO_PACING.minPhaseDurationMs - 1)).toBe('none')
23
- })
24
-
25
- it('returns on-pace when within the behind threshold', () => {
26
- expect(getTempoPacingState(1000, 1000)).toBe('on-pace')
27
- expect(getTempoPacingState(1100, 1000)).toBe('on-pace') // +10%, under 15%
28
- })
29
-
30
- it('returns behind once elapsed exceeds the behind threshold', () => {
31
- expect(getTempoPacingState(1200, 1000)).toBe('behind') // +20%, over 15%
32
- })
33
- })
34
-
35
- describe('getTempoFillPct', () => {
36
- it('fills fully when there is no target', () => {
37
- expect(getTempoFillPct(500, null)).toBe(100)
38
- })
39
-
40
- it('is proportional to elapsed vs target', () => {
41
- expect(getTempoFillPct(500, 1000)).toBe(50)
42
- })
43
-
44
- it('clamps at 100 when past target', () => {
45
- expect(getTempoFillPct(2000, 1000)).toBe(100)
46
- })
47
- })
48
-
49
- describe('TempoBar', () => {
50
- it('renders the bar with phase labels', () => {
51
- render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET} />)
52
- expect(screen.getByTestId('tempo-bar')).toBeInTheDocument()
53
- expect(screen.getByText('Con')).toBeInTheDocument()
54
- expect(screen.getByText('Hold')).toBeInTheDocument()
55
- expect(screen.getByText('Ecc')).toBeInTheDocument()
56
- })
57
-
58
- it('renders the active segment with an elapsed/target label', () => {
59
- render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
60
- expect(screen.getByTestId('tempo-segment-active-Con')).toBeInTheDocument()
61
- expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
62
- })
63
-
64
- it('shows only elapsed when the active phase has no target', () => {
65
- render(<TempoBar activePhase="concentric" phaseElapsedMs={800} />)
66
- expect(screen.getByText('0.8')).toBeInTheDocument()
67
- })
68
-
69
- it('marks a completed phase that hit its target with a check', () => {
70
- render(
71
- <TempoBar
72
- activePhase="hold"
73
- phaseElapsedMs={100}
74
- completed={{ concentric: 950 }}
75
- target={TARGET}
76
- />
77
- )
78
- expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
79
- expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
80
- })
81
-
82
- it('marks a completed phase that missed its target with a cross', () => {
83
- render(
84
- <TempoBar
85
- activePhase="hold"
86
- phaseElapsedMs={100}
87
- completed={{ eccentric: 4000 }}
88
- target={TARGET}
89
- />
90
- )
91
- // eccentric target 3s, 4s elapsed => behind => cross
92
- expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
93
- })
94
-
95
- it('has no accessibility violations', async () => {
96
- const { container } = render(
97
- <TempoBar
98
- activePhase="eccentric"
99
- phaseElapsedMs={1500}
100
- completed={{ concentric: 900, hold: 1000 }}
101
- target={TARGET}
102
- />
103
- )
104
- expect(await axe(container)).toHaveNoViolations()
105
- })
106
- })
107
-
108
- describe('TempoBar size="wall"', () => {
109
- const TARGET_W = { concentric: 1, hold: 1, eccentric: 3 }
110
-
111
- it('scales the active label up at wall density', () => {
112
- render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} size="wall" />)
113
- expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 22 })
114
- })
115
-
116
- it('keeps the compact label size by default', () => {
117
- render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} />)
118
- expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 12 })
119
- })
120
-
121
- it('preserves the segment testIDs at wall density', () => {
122
- render(
123
- <TempoBar
124
- activePhase="eccentric"
125
- phaseElapsedMs={1500}
126
- completed={{ concentric: 900 }}
127
- target={TARGET_W}
128
- size="wall"
129
- />
130
- )
131
- expect(screen.getByTestId('tempo-segment-active-Ecc')).toBeInTheDocument()
132
- expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
133
- })
134
-
135
- it('spells out the full phase words at wall density', () => {
136
- render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET_W} size="wall" />)
137
- expect(screen.getByText('Concentric')).toBeInTheDocument()
138
- expect(screen.getByText('Eccentric')).toBeInTheDocument()
139
- expect(screen.queryByText('Con')).not.toBeInTheDocument()
140
- })
141
- })
142
-
143
- describe('formatTempoDelta', () => {
144
- it('shows remaining time to target as a positive value', () => {
145
- expect(formatTempoDelta(1500)).toBe('1.5')
146
- })
147
-
148
- it('shows over-target time as negative', () => {
149
- expect(formatTempoDelta(-1200)).toBe('-1.2')
150
- })
151
-
152
- it('shows 0.0 at the target (no -0.0)', () => {
153
- expect(formatTempoDelta(0)).toBe('0.0')
154
- expect(formatTempoDelta(-10)).toBe('0.0')
155
- })
156
- })
157
-
158
- describe('TempoBar activeDisplay (delta countdown + toggle)', () => {
159
- it('counts the remaining time down to the target in delta mode', () => {
160
- render(
161
- <TempoBar
162
- activePhase="eccentric"
163
- phaseElapsedMs={1800}
164
- target={TARGET}
165
- activeDisplay="delta"
166
- />
167
- )
168
- // eccentric target 3.0s, elapsed 1.8s → 1.2s remaining
169
- expect(screen.getByText('1.2')).toBeInTheDocument()
170
- })
171
-
172
- it('goes negative once the phase runs past its target', () => {
173
- render(
174
- <TempoBar
175
- activePhase="eccentric"
176
- phaseElapsedMs={4200}
177
- target={TARGET}
178
- activeDisplay="delta"
179
- />
180
- )
181
- expect(screen.getByText('-1.2')).toBeInTheDocument()
182
- })
183
-
184
- it('tapping the active segment toggles time ↔ delta', () => {
185
- render(
186
- <TempoBar
187
- activePhase="concentric"
188
- phaseElapsedMs={600}
189
- target={TARGET}
190
- activeDisplay="time"
191
- />
192
- )
193
- expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
194
- fireEvent.click(screen.getByTestId('tempo-segment-active-Con'))
195
- // concentric target 1.0s, elapsed 0.6s → 0.4s remaining
196
- expect(screen.getByText('0.4')).toBeInTheDocument()
197
- expect(screen.queryByText('0.6 / 1.0')).not.toBeInTheDocument()
198
- })
199
-
200
- it('defaults to the time readout', () => {
201
- render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
202
- expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
203
- })
204
- })
205
-
206
- describe('TempoBar completed pacing colour', () => {
207
- it('colours a missed completed phase red', () => {
208
- render(
209
- <TempoBar
210
- activePhase="hold"
211
- phaseElapsedMs={100}
212
- completed={{ eccentric: 4000 }}
213
- target={TARGET}
214
- />
215
- )
216
- expect(screen.getByText('4.0 ✗')).toHaveStyle({ color: t['status-error'] })
217
- })
218
- })