@titan-design/react-ui 0.7.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 (52) hide show
  1. package/dist/index.d.mts +112 -55
  2. package/dist/index.d.ts +112 -55
  3. package/dist/index.js +795 -501
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +795 -498
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +691 -250
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +692 -251
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
  17. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  18. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  19. package/src/components/custom/CircularTimer/index.ts +1 -0
  20. package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
  21. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  22. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  23. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  24. package/src/components/custom/Workout/README.md +70 -3
  25. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  26. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  27. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  28. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  29. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  30. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  31. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  32. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  33. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  34. package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
  35. package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
  36. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  37. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  38. package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
  39. package/src/components/custom/Workout/index.ts +1 -9
  40. package/src/components/custom/index.ts +1 -0
  41. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  42. package/src/components/ui/alert/Alert.test.tsx +52 -0
  43. package/src/components/ui/alert/Alert.tsx +54 -20
  44. package/src/components/ui/progress/Progress.tsx +47 -22
  45. package/src/lab/north-star/LivePage.tsx +101 -0
  46. package/src/lab/north-star/LiveView.tsx +409 -0
  47. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  48. package/src/lab/north-star/RestView.tsx +140 -0
  49. package/src/lab/north-star/fixtures.ts +224 -0
  50. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
  51. package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
  52. package/src/components/custom/Workout/TempoBar.tsx +0 -215
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import type { ComponentProps } from 'react'
2
3
  import { useEffect, useState } from 'react'
3
- import { View } from 'react-native'
4
+ import { View, Text } from 'react-native'
4
5
  import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
5
6
 
6
7
  // tempo = [eccentric, pauseBottom, concentric, pauseTop]
@@ -25,7 +26,8 @@ const meta: Meta<typeof TempoDisplay> = {
25
26
  description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
26
27
  },
27
28
  size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
28
- colored: { control: 'boolean', description: 'Colored vs mono display' },
29
+ fontSize: { control: { type: 'range', min: 9, max: 48, step: 1 }, description: 'Digit size (overrides `size`)' },
30
+ showLabel: { control: 'boolean', description: 'Show the TEMPO caption' },
29
31
  showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
30
32
  live: {
31
33
  control: 'object',
@@ -37,98 +39,107 @@ const meta: Meta<typeof TempoDisplay> = {
37
39
  export default meta
38
40
  type Story = StoryObj<typeof TempoDisplay>
39
41
 
40
- export const ColoredStandard: Story = {
41
- args: { tempo: [3, 1, 1, 0], colored: true },
42
+ // The static prescription the base tempo lockup (phase-coloured), no live readout.
43
+ // Add the `live` prop (see the Live stories below) to turn it into the running tempo.
44
+ export const Prescription: Story = {
45
+ args: { tempo: [3, 1, 2, 2] },
42
46
  }
43
47
 
44
- export const MonoStandard: Story = {
45
- args: { tempo: [3, 1, 1, 0], colored: false },
46
- }
47
-
48
- export const ColoredExplosive: Story = {
49
- args: { tempo: [1, 0, 1, 0], colored: true },
50
- }
51
-
52
- export const MonoExplosive: Story = {
53
- args: { tempo: [1, 0, 1, 0], colored: false },
54
- }
55
-
56
- export const ColoredSlowEccentric: Story = {
57
- args: { tempo: [5, 2, 1, 1], colored: true },
58
- }
59
-
60
- export const SmallColored: Story = {
61
- args: { tempo: [3, 1, 1, 0], size: 'sm', colored: true },
62
- }
63
-
64
- export const SmallMono: Story = {
65
- args: { tempo: [3, 1, 1, 0], size: 'sm', colored: false },
66
- }
67
-
68
- export const AllVariants: Story = {
69
- render: () => (
70
- <View style={{ gap: 12 }}>
71
- <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
72
- <TempoDisplay tempo={[3, 1, 1, 0]} colored />
73
- <TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />
74
- </View>
75
- <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
76
- <TempoDisplay tempo={[1, 0, 1, 0]} colored />
77
- <TempoDisplay tempo={[1, 0, 1, 0]} colored={false} />
78
- </View>
79
- <View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
80
- <TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored />
81
- <TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored={false} />
82
- </View>
83
- </View>
84
- ),
85
- }
86
-
87
- // --- Live phase-fill (opt-in) ----------------------------------------------
88
-
89
- // Deterministic mid-rep frames — the active phase digit fills against its target.
90
- export const LiveEccentricInProgress: Story = {
91
- args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
92
- }
93
-
94
- // Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
95
- export const LiveBehindPace: Story = {
96
- args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
97
- }
48
+ // --- Live tempo (opt-in via the `live` prop) --------------------------------
98
49
 
50
+ // A full rep cycle incl. a 2s rest at the top between reps.
99
51
  const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
100
52
  { phase: 'eccentric', ms: 3000 },
101
53
  { phase: 'pauseBottom', ms: 1000 },
102
- { phase: 'concentric', ms: 1000 },
54
+ { phase: 'concentric', ms: 2000 },
55
+ { phase: 'pauseTop', ms: 2000 },
103
56
  ]
104
57
 
105
- function LiveTempoDemo() {
106
- const [live, setLive] = useState<TempoLiveState>({ activePhase: 'eccentric', phaseElapsedMs: 0 })
58
+ function LiveTempoDemo({
59
+ fontSize,
60
+ showLabel = false,
61
+ liveReadout,
62
+ }: {
63
+ fontSize?: number
64
+ showLabel?: boolean
65
+ liveReadout?: 'countdown' | 'countup'
66
+ }) {
67
+ const [live, setLive] = useState<TempoLiveState>({
68
+ activePhase: 'eccentric',
69
+ phaseElapsedMs: 0,
70
+ completed: {},
71
+ })
107
72
  useEffect(() => {
108
73
  let idx = 0
109
74
  let start = Date.now()
75
+ let completed: TempoLiveState['completed'] = {}
110
76
  const id = setInterval(() => {
111
77
  const step = LIVE_CYCLE[idx]
112
78
  const elapsed = Date.now() - start
113
- if (elapsed >= step.ms + 400) {
79
+ if (elapsed >= step.ms + 300) {
80
+ // Bank the phase's actual duration, advance; a new rep (wrap to idx 0) resets the row.
81
+ completed = { ...completed, [step.phase]: elapsed }
114
82
  idx = (idx + 1) % LIVE_CYCLE.length
83
+ if (idx === 0) completed = {}
115
84
  start = Date.now()
116
85
  }
117
86
  setLive({
118
87
  activePhase: LIVE_CYCLE[idx].phase,
119
- phaseElapsedMs: Math.min(elapsed, step.ms + 400),
88
+ phaseElapsedMs: Math.min(elapsed, step.ms + 300),
89
+ completed,
120
90
  })
121
91
  }, 50)
122
92
  return () => clearInterval(id)
123
93
  }, [])
124
- return <TempoDisplay tempo={[3, 1, 1, 0]} live={live} />
94
+ return (
95
+ <TempoDisplay
96
+ tempo={[3, 1, 2, 2]}
97
+ live={live}
98
+ fontSize={fontSize}
99
+ showLabel={showLabel}
100
+ showInfo={false}
101
+ liveReadout={liveReadout}
102
+ />
103
+ )
125
104
  }
126
105
 
127
- // Self-driving demo cycling Ecc → Pause → Con to show the fill animating.
106
+ // Self-driving demo cycling Ecc → Pause → Con 2s rest, banking each phase as it completes.
128
107
  export const LiveAnimated: Story = {
129
108
  render: () => <LiveTempoDemo />,
130
109
  }
131
110
 
111
+ // Interactive: drag the size (form factor) and toggle the TEMPO label / readout mode live.
112
+ interface PlaygroundArgs {
113
+ fontSize: number
114
+ showLabel: boolean
115
+ liveReadout: 'countdown' | 'countup'
116
+ }
117
+ export const LivePlayground: StoryObj<PlaygroundArgs> = {
118
+ args: { fontSize: 26, showLabel: true, liveReadout: 'countdown' },
119
+ argTypes: {
120
+ fontSize: { control: { type: 'range', min: 12, max: 52, step: 2 }, description: 'Form factor' },
121
+ showLabel: { control: 'boolean', description: 'Toggle the TEMPO label' },
122
+ liveReadout: { control: 'inline-radio', options: ['countdown', 'countup'] },
123
+ },
124
+ render: (args) => (
125
+ <LiveTempoDemo
126
+ fontSize={args.fontSize}
127
+ showLabel={args.showLabel}
128
+ liveReadout={args.liveReadout}
129
+ />
130
+ ),
131
+ parameters: {
132
+ docs: {
133
+ description: {
134
+ story:
135
+ 'The live tempo is responsive — the whole chip (height, digits, padding, label) ' +
136
+ 'scales with the size. Drag **fontSize** to see any form factor, toggle **showLabel** ' +
137
+ '(works at every size), and switch the readout mode.',
138
+ },
139
+ },
140
+ },
141
+ }
142
+
132
143
  // Static and live side by side — the same prescription, two modes.
133
144
  export const StaticVsLive: Story = {
134
145
  render: () => (
@@ -142,3 +153,167 @@ export const StaticVsLive: Story = {
142
153
  </View>
143
154
  ),
144
155
  }
156
+
157
+ // --- Active tempo conditions (the countdown/fill readout under different states) ----
158
+
159
+ /** One captioned live-tempo specimen at a larger scale so the fill + readout read clearly. */
160
+ function Condition({
161
+ caption,
162
+ ...props
163
+ }: { caption: string } & ComponentProps<typeof TempoDisplay>) {
164
+ return (
165
+ <View style={{ gap: 5 }}>
166
+ <Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
167
+ {caption}
168
+ </Text>
169
+ <TempoDisplay showLabel={false} showInfo={false} fontSize={26} {...props} />
170
+ </View>
171
+ )
172
+ }
173
+
174
+ function Section({ title, children }: { title: string; children: React.ReactNode }) {
175
+ return (
176
+ <View style={{ gap: 12 }}>
177
+ <Text
178
+ style={{
179
+ color: '#E5E7EB',
180
+ fontFamily: 'Inter, sans-serif',
181
+ fontSize: 12,
182
+ fontWeight: '700',
183
+ letterSpacing: 1,
184
+ textTransform: 'uppercase',
185
+ }}
186
+ >
187
+ {title}
188
+ </Text>
189
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 36, alignItems: 'flex-start' }}>
190
+ {children}
191
+ </View>
192
+ </View>
193
+ )
194
+ }
195
+
196
+ // tempo [3,1,2,2] — 3s eccentric, 1s bottom pause, 2s concentric, and a 2s rest at the top.
197
+ const TEMPO = [3, 1, 2, 2] as [number, number, number, number]
198
+
199
+ /**
200
+ * The active tempo across the states it moves through: pacing (ahead → on-target →
201
+ * behind), each phase active, the countdown vs count-up readout, and idle. Static
202
+ * frames plus the self-driving animation, all in the one hyphenated display.
203
+ */
204
+ export const ActiveTempoConditions: Story = {
205
+ render: () => (
206
+ <View style={{ gap: 28, padding: 8 }}>
207
+ <Section title="Pacing — the number by time-to-target (yellow ahead → green ±0.1 → red slow)">
208
+ <Condition
209
+ caption="Ahead · 1.2s → 1.8 left (yellow)"
210
+ tempo={TEMPO}
211
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1200 }}
212
+ />
213
+ <Condition
214
+ caption="On target · 2.9s → 0.1 left (green)"
215
+ tempo={TEMPO}
216
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 2900 }}
217
+ />
218
+ <Condition
219
+ caption="Slow · 3.8s → −0.8 over (red)"
220
+ tempo={TEMPO}
221
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 3800 }}
222
+ />
223
+ </Section>
224
+
225
+ <Section title="Through a rep — earlier phases bank their final time, semantically toned">
226
+ <Condition
227
+ caption="Eccentric active · nothing banked yet"
228
+ tempo={TEMPO}
229
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
230
+ />
231
+ <Condition
232
+ caption="Pause active · ecc banked slow (−0.5, red)"
233
+ tempo={TEMPO}
234
+ live={{ activePhase: 'pauseBottom', phaseElapsedMs: 500, completed: { eccentric: 3500 } }}
235
+ />
236
+ <Condition
237
+ caption="Concentric active · ecc on-target, pause fast (+0.2, yellow)"
238
+ tempo={TEMPO}
239
+ live={{
240
+ activePhase: 'concentric',
241
+ phaseElapsedMs: 1000,
242
+ completed: { eccentric: 3000, pauseBottom: 800 },
243
+ }}
244
+ />
245
+ <Condition
246
+ caption="Rest at top (2s) · whole rep banked (green/yellow/red)"
247
+ tempo={TEMPO}
248
+ live={{
249
+ activePhase: 'pauseTop',
250
+ phaseElapsedMs: 700,
251
+ completed: { eccentric: 3000, pauseBottom: 800, concentric: 2200 },
252
+ }}
253
+ />
254
+ </Section>
255
+
256
+ <Section title="Readout mode — eccentric, 1.0s into a 3s phase">
257
+ <Condition
258
+ caption="Countdown → 0.0 · shows 2.0 left"
259
+ tempo={TEMPO}
260
+ liveReadout="countdown"
261
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
262
+ />
263
+ <Condition
264
+ caption="Count-up → target · shows 1.0 elapsed"
265
+ tempo={TEMPO}
266
+ liveReadout="countup"
267
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
268
+ />
269
+ </Section>
270
+
271
+ <Section title="Form factors — the view scales; the label toggles at any size">
272
+ <Condition
273
+ caption="Compact (14) · label off"
274
+ tempo={TEMPO}
275
+ fontSize={14}
276
+ live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
277
+ />
278
+ <Condition
279
+ caption="Default (22) · label on"
280
+ tempo={TEMPO}
281
+ fontSize={22}
282
+ showLabel
283
+ live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
284
+ />
285
+ <Condition
286
+ caption="Wall (34) · label on"
287
+ tempo={TEMPO}
288
+ fontSize={34}
289
+ showLabel
290
+ live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
291
+ />
292
+ </Section>
293
+
294
+ <Section title="Idle & animated">
295
+ <Condition
296
+ caption="Idle · no active phase (static)"
297
+ tempo={TEMPO}
298
+ live={{ activePhase: null, phaseElapsedMs: 0 }}
299
+ />
300
+ <View style={{ gap: 5 }}>
301
+ <Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
302
+ Animated · Ecc → Pause → Con → 2s rest, banking each phase
303
+ </Text>
304
+ <LiveTempoDemo />
305
+ </View>
306
+ </Section>
307
+ </View>
308
+ ),
309
+ parameters: {
310
+ docs: {
311
+ description: {
312
+ story:
313
+ 'The active tempo under different conditions — pacing (ahead → on-target → behind), ' +
314
+ 'each phase active, the countdown vs count-up readout, and idle — all in the one ' +
315
+ 'hyphenated display with the vertical phase-progress fill.',
316
+ },
317
+ },
318
+ },
319
+ }
@@ -22,14 +22,9 @@ describe('TempoDisplay', () => {
22
22
  expect(tempoValue.children.length).toBeGreaterThan(1)
23
23
  })
24
24
 
25
- it('renders mono variant', () => {
26
- render(<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />)
27
- expect(screen.getByText('3-1-1-0')).toBeInTheDocument()
28
- })
29
-
30
25
  it('renders order: eccentric-pauseBottom-concentric-pauseTop', () => {
31
- render(<TempoDisplay tempo={[4, 3, 2, 1]} colored={false} />)
32
- expect(screen.getByText('4-3-2-1')).toBeInTheDocument()
26
+ render(<TempoDisplay tempo={[4, 3, 2, 1]} />)
27
+ expect(screen.getByTestId('tempo-value')).toHaveTextContent('4-3-2-1')
33
28
  })
34
29
 
35
30
  it('has correct accessibility label', () => {
@@ -78,6 +73,11 @@ describe('TempoDisplay', () => {
78
73
  render(<TempoDisplay tempo={[3, 1, 1, 0]} size="md" />)
79
74
  expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
80
75
  })
76
+
77
+ it('overrides the digit font size via fontSize', () => {
78
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} fontSize={32} showLabel={false} />)
79
+ expect(screen.getByText('3')).toHaveStyle({ fontSize: 32 })
80
+ })
81
81
  })
82
82
 
83
83
  describe('live phase-fill', () => {
@@ -86,15 +86,30 @@ describe('TempoDisplay', () => {
86
86
  expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
87
87
  })
88
88
 
89
- it('renders an active fill overlay for the in-progress phase when live', () => {
89
+ it('renders the active phase as a live countdown readout with a fill', () => {
90
90
  render(
91
91
  <TempoDisplay
92
92
  tempo={[3, 1, 1, 0]}
93
- live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
93
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
94
94
  />
95
95
  )
96
96
  expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
97
- expect(screen.getByText('3')).toBeInTheDocument()
97
+ // eccentric target 3s, 1s in → 2.0s remaining (countdown is the default)
98
+ expect(screen.getByText('2.0')).toBeInTheDocument()
99
+ // the static eccentric digit is replaced by the readout
100
+ expect(screen.queryByText('3')).not.toBeInTheDocument()
101
+ })
102
+
103
+ it('counts elapsed up when liveReadout is countup', () => {
104
+ render(
105
+ <TempoDisplay
106
+ tempo={[4, 1, 2, 3]}
107
+ liveReadout="countup"
108
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
109
+ />
110
+ )
111
+ // 1.5s elapsed of the 4s eccentric → 1.5 counting up (unique among the target readouts)
112
+ expect(screen.getByText('1.5')).toBeInTheDocument()
98
113
  })
99
114
 
100
115
  it('marks no phase active when idle (activePhase null)', () => {
@@ -102,16 +117,46 @@ describe('TempoDisplay', () => {
102
117
  expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
103
118
  })
104
119
 
105
- it('still renders all four phase digits in live mode', () => {
120
+ it('locks the phases completed earlier in the rep, and resets on the first phase', () => {
121
+ // Concentric active (3rd phase) → eccentric + pauseBottom are locked done.
122
+ const mid = render(
123
+ <TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'concentric', phaseElapsedMs: 800 }} />
124
+ )
125
+ expect(mid.getAllByTestId('tempo-live-done')).toHaveLength(2)
126
+ // Back to the first phase (next rep) → nothing is locked yet.
127
+ mid.rerender(
128
+ <TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 200 }} />
129
+ )
130
+ expect(mid.queryByTestId('tempo-live-done')).not.toBeInTheDocument()
131
+ })
132
+
133
+ it('colours the TEMPO label live-muted while live', () => {
134
+ const { getByText, rerender } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
135
+ const restColor = getByText('TEMPO').style.color
136
+ rerender(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: 'eccentric', phaseElapsedMs: 500 }} />)
137
+ expect(getByText('TEMPO').style.color).not.toBe(restColor)
138
+ })
139
+
140
+ it('shows upcoming phases as their prescribed time and the active as the live readout', () => {
141
+ render(
142
+ <TempoDisplay tempo={[5, 3, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }} />
143
+ )
144
+ // active eccentric: 5s target, 1s in → 4.0 remaining
145
+ expect(screen.getByText('4.0')).toBeInTheDocument()
146
+ // upcoming pauseBottom shows its prescribed 3s as 3.0 (no bare integer digits in live mode)
147
+ expect(screen.getByText('3.0')).toBeInTheDocument()
148
+ expect(screen.queryByText('5')).not.toBeInTheDocument()
149
+ })
150
+
151
+ it('freezes a completed phase at its actual final time, not the target', () => {
106
152
  render(
107
153
  <TempoDisplay
108
- tempo={[4, 3, 2, 1]}
109
- live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
154
+ tempo={[3, 1, 2, 2]}
155
+ live={{ activePhase: 'concentric', phaseElapsedMs: 500, completed: { eccentric: 3400 } }}
110
156
  />
111
157
  )
112
- for (const digit of ['4', '3', '2', '1']) {
113
- expect(screen.getByText(digit)).toBeInTheDocument()
114
- }
158
+ // eccentric ran 3.4s vs its 3s target → countdown froze at 3.0 − 3.4 = −0.4, not the target
159
+ expect(screen.getByText('-0.4')).toBeInTheDocument()
115
160
  })
116
161
  })
117
162