@titan-design/react-ui 0.8.0 → 0.9.1

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 (30) 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 +2 -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/test/no-device-internals.test.ts +51 -0
  28. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
  29. package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
  30. package/src/components/custom/Workout/TempoBar.tsx +0 -300
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.8.0",
3
+ "version": "0.9.1",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -147,6 +147,7 @@
147
147
  "files": [
148
148
  "dist",
149
149
  "src",
150
+ "!src/lab",
150
151
  "docs",
151
152
  "tailwind.config.js"
152
153
  ],
@@ -12,7 +12,7 @@ import {
12
12
  type IconProps,
13
13
  } from '../../icons'
14
14
 
15
- // Literal-hex status pins (the TempoBar pattern) — `resolveColor()` yields a
15
+ // Literal-hex status pins (getSemanticColors, not resolveColor) — `resolveColor()` yields a
16
16
  // `var(...)` under the RNW vitest alias, which breaks `toHaveStyle` in tests.
17
17
  const t = getSemanticColors('dark')
18
18
 
@@ -8,7 +8,7 @@ component is pure import churn with no file moves.
8
8
  ## Tier map
9
9
 
10
10
  **Atoms** — single-purpose, no cross-component state:
11
- BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip · TempoBar ·
11
+ BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip ·
12
12
  DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
13
13
  SupersetWrapper · InputBar · MetricCell · SetsRepsLoad · ExerciseIndicator · SetBar
14
14
 
@@ -48,6 +48,29 @@ type props without pulling the dependency.
48
48
  letter-spacing-1 value/separator cell. `TempoDisplay` (tempo digits) and
49
49
  `SetsRepsLoad` (sets × reps @ load) both compose it so the two read as one
50
50
  visual language. Exported for composition, not for direct app use.
51
+ - **TempoDisplay is deliberately NOT decomposed further** — it is the single tempo
52
+ component (the standalone `TempoBar` was retired into it). It renders two modes from
53
+ one chip: the static phase-coloured **prescription** and, with the `live` prop, the
54
+ running tempo (per-phase countdown/count-up to 0.1s, a bottom-anchored phase-progress
55
+ fill, semantic pacing tones, and banked/frozen finals). Its internal parts —
56
+ `LiveTempoRow`, `LiveTempoCell`, `CellFill` (the fill behind a number), and the
57
+ `activeNumberTone` pacing helper — are **TempoDisplay-private with no second consumer**,
58
+ so per the ≥2-consumer rule they stay internal rather than becoming top-level primitives.
59
+ The only already-shared primitive is `MetricCell`.
60
+
61
+ **If reuse emerges, decompose along these seams** (in likely order):
62
+ 1. **`CellFill` → a shared `ProgressCell`/`FillBehind` primitive** — the moment a
63
+ second component needs "a bottom-anchored progress fill behind centred text" (e.g. a
64
+ generic timer/meter cell). Cleanest extraction; pure presentation, no tempo semantics.
65
+ 2. **`activeNumberTone` + `ON_TARGET_MS` → a `tempo-pacing` util** — if another surface
66
+ needs the same yellow/green/red "time-to-target" tone (a coach card, a rep-tempo
67
+ summary). It is pure logic, trivially portable.
68
+ 3. **`LiveTempoRow` → a `TempoLiveRow` molecule** — only if a consumer wants the running
69
+ row *without* the chip chrome (label/background/padding). Until then the chrome and the
70
+ row belong together as one component with a `live` prop, not two.
71
+
72
+ Not worth splitting today (speculative extraction the workflow warns against); this note
73
+ is the trigger list for when it stops being speculative.
51
74
  - **S3 session-rail family** — `SessionRail` (organism) composes the standalone
52
75
  `ExerciseCardHeading` molecule per exercise, which composes an `ExerciseHeading`
53
76
  info block + a `SetStrip`:
@@ -147,26 +170,23 @@ type props without pulling the dependency.
147
170
  `size` (default 180) sets the diameter; the eyebrow/section title is organism
148
171
  chrome. Web/RNW-only (like `CircularProgress`) — the wall variant; mobile keeps
149
172
  `bar`. `Ring*` (RestTimer) + `Custom/CircularTimer` stories on the wall background.
150
- - **`size="wall"` density (TempoBar · FatigueMeter · ZoneTrack)** — the across-the-room
173
+ - **`size="wall"` density (FatigueMeter · ZoneTrack)** — the across-the-room
151
174
  dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
152
175
  component; **`default` values are byte-identical** to before, so existing consumers are
153
- untouched). **TempoBar** scales its bar/labels/durations and at wall spells out the full
154
- phase words (Concentric / Hold / Eccentric); its completed segments are **colour-coded**
155
- (a missed target reads red, not just a ✗), and the **active** segment shows a **delta
156
- countdown** (`activeDisplay`) remaining time to target counting to `0.0` then negative
157
- (red) once over**tap to toggle** delta elapsed/target. **ZoneTrack** (the shared
158
- gauge primitive) gained `size` that scales track, needle, tick lines and tick labels
159
- together; its *other* consumers (TrainingLoadGauge, RpeCalibration) default to `default`
160
- and are unaffected. **FatigueMeter** passes `size` through and lets `trackHeight` flow
161
- from it. `Wall*` / `WallDensity` stories on the wall background.
176
+ untouched). **ZoneTrack** (the shared gauge primitive) gained `size` that scales track,
177
+ needle, tick lines and tick labels together; its *other* consumers (TrainingLoadGauge,
178
+ RpeCalibration) default to `default` and are unaffected. **FatigueMeter** passes `size`
179
+ through and lets `trackHeight` flow from it. `Wall*` / `WallDensity` stories on the wall
180
+ background. (**TempoBar** was retired here the standalone active-tempo bar is superseded
181
+ by `TempoDisplay`'s live mode; see the TempoDisplay note.)
162
182
  - Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
163
183
  SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
164
184
  to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
165
185
  is unchanged.
166
186
  - **`ExerciseIndicator` taxonomy (LOCKED, TD-03.51)** — one precedence-ranked slot
167
187
  per exercise, six distinct kinds over four tier colors (bound to titan status
168
- tokens, read as literal hex via `getSemanticColors('dark')` the TempoBar
169
- pattern). Glyph = the specific signal; color = the severity tier. Chips are
188
+ tokens, read as literal hex via `getSemanticColors('dark')`, not `resolveColor`,
189
+ so tests can assert them). Glyph = the specific signal; color = the severity tier. Chips are
170
190
  outlined (border + glyph, no fill) so they never collide with the *filled*
171
191
  velocity strip, and static (no pulse — they are chrome). Precedence, high → low:
172
192
 
@@ -34,6 +34,12 @@ describe('SetsRepsLoad', () => {
34
34
  expect(screen.getByTestId('sets-reps-load')).toHaveTextContent('15-20')
35
35
  })
36
36
 
37
+ it('scales the cell font size via fontSize', () => {
38
+ render(<SetsRepsLoad sets={4} reps={8} load={140} fontSize={32} />)
39
+ // The value cells (and separators) render at the supplied size.
40
+ expect(screen.getByText('4')).toHaveStyle({ fontSize: 32 })
41
+ })
42
+
37
43
  describe('accessibility', () => {
38
44
  it('has no accessibility violations', async () => {
39
45
  const { container } = render(<SetsRepsLoad sets={5} reps={8} load={145} />)
@@ -14,6 +14,8 @@ export interface SetsRepsLoadProps extends ViewProps {
14
14
  load: number
15
15
  /** Displayed load unit label (e.g. "lb", "kg"). */
16
16
  unit?: string
17
+ /** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
18
+ fontSize?: number
17
19
  className?: string
18
20
  }
19
21
 
@@ -27,6 +29,7 @@ export function SetsRepsLoad({
27
29
  reps,
28
30
  load,
29
31
  unit = 'lb',
32
+ fontSize,
30
33
  className,
31
34
  ...props
32
35
  }: SetsRepsLoadProps) {
@@ -41,12 +44,18 @@ export function SetsRepsLoad({
41
44
  testID="sets-reps-load"
42
45
  {...props}
43
46
  >
44
- <MetricCell color={value}>{sets}</MetricCell>
45
- <MetricCell color={sep}>{` ${TIMES} `}</MetricCell>
46
- <MetricCell color={value}>{reps}</MetricCell>
47
- <MetricCell color={sep}>{` ${AT} `}</MetricCell>
48
- <MetricCell color={value}>{load}</MetricCell>
49
- <MetricCell color={sep}>{` ${unit}`}</MetricCell>
47
+ <MetricCell color={value} fontSize={fontSize}>
48
+ {sets}
49
+ </MetricCell>
50
+ <MetricCell color={sep} fontSize={fontSize}>{` ${TIMES} `}</MetricCell>
51
+ <MetricCell color={value} fontSize={fontSize}>
52
+ {reps}
53
+ </MetricCell>
54
+ <MetricCell color={sep} fontSize={fontSize}>{` ${AT} `}</MetricCell>
55
+ <MetricCell color={value} fontSize={fontSize}>
56
+ {load}
57
+ </MetricCell>
58
+ <MetricCell color={sep} fontSize={fontSize}>{` ${unit}`}</MetricCell>
50
59
  </View>
51
60
  )
52
61
  }
@@ -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