@titan-design/react-ui 0.7.0 → 0.8.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 (40) hide show
  1. package/dist/index.d.mts +109 -12
  2. package/dist/index.d.ts +109 -12
  3. package/dist/index.js +648 -231
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +648 -232
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +495 -92
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +496 -93
  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/FatigueMeter.stories.tsx +2 -2
  21. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  22. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  23. package/src/components/custom/Workout/README.md +47 -0
  24. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  25. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  26. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  27. package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
  28. package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
  29. package/src/components/custom/Workout/TempoBar.tsx +107 -22
  30. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  31. package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
  32. package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
  33. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  34. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  35. package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
  36. package/src/components/custom/index.ts +1 -0
  37. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  38. package/src/components/ui/alert/Alert.test.tsx +52 -0
  39. package/src/components/ui/alert/Alert.tsx +54 -20
  40. package/src/components/ui/progress/Progress.tsx +47 -22
@@ -1,5 +1,6 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { View, Text, type ViewProps } from 'react-native'
2
+ import { useState } from 'react'
3
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
3
4
  import { getSemanticColors } from '../../../theme/tokens/semantic'
4
5
  import { alpha } from '../../../utils/colors'
5
6
 
@@ -19,15 +20,23 @@ export const TEMPO_PACING = {
19
20
 
20
21
  export type TempoPacingState = 'none' | 'on-pace' | 'behind'
21
22
 
23
+ /** Active-phase readout mode: elapsed/target `time`, or a `delta` countdown to the target. */
24
+ export type TempoActiveDisplay = 'time' | 'delta'
25
+
26
+ /** Signed seconds to one decimal — the delta countdown (`+` remaining, `-` over target). */
27
+ export function formatTempoDelta(remainingMs: number): string {
28
+ const s = remainingMs / 1000
29
+ // Guard `-0.0`; show a leading minus only once past target.
30
+ const rounded = Math.abs(s) < 0.05 ? 0 : s
31
+ return rounded.toFixed(1)
32
+ }
33
+
22
34
  /**
23
35
  * Classify how a phase's elapsed time tracks against its target duration.
24
36
  * `none` when there is no meaningful target; `behind` once elapsed exceeds the
25
37
  * target by more than the behind threshold; `on-pace` otherwise.
26
38
  */
27
- export function getTempoPacingState(
28
- elapsedMs: number,
29
- targetMs: number | null,
30
- ): TempoPacingState {
39
+ export function getTempoPacingState(elapsedMs: number, targetMs: number | null): TempoPacingState {
31
40
  if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
32
41
  const ratio = elapsedMs / targetMs
33
42
  if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
@@ -41,7 +50,10 @@ export function getTempoFillPct(elapsedMs: number, targetMs: number | null): num
41
50
  }
42
51
 
43
52
  interface PhaseConfig {
53
+ /** Compact label + the stable testID suffix. */
44
54
  label: string
55
+ /** Full phase word, spelled out at `wall` density where there's room to read it. */
56
+ wallLabel: string
45
57
  color: string
46
58
  flex: number
47
59
  }
@@ -49,9 +61,27 @@ interface PhaseConfig {
49
61
  const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
50
62
 
51
63
  const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
52
- concentric: { label: 'Con', color: t['status-success'], flex: 2 },
53
- hold: { label: 'Hold', color: t['brand-primary'], flex: 1 },
54
- eccentric: { label: 'Ecc', color: t['status-warning'], flex: 3 },
64
+ concentric: { label: 'Con', wallLabel: 'Concentric', color: t['status-success'], flex: 2 },
65
+ hold: { label: 'Hold', wallLabel: 'Hold', color: t['brand-primary'], flex: 1 },
66
+ eccentric: { label: 'Ecc', wallLabel: 'Eccentric', color: t['status-warning'], flex: 3 },
67
+ }
68
+
69
+ /** Density. `default` — the compact card/rail treatment. `wall` — the across-the-room dashboard scale. */
70
+ export type TempoBarSize = 'default' | 'wall'
71
+
72
+ interface TempoBarSizing {
73
+ labelMargin: number
74
+ labelFont: number
75
+ barGap: number
76
+ barHeight: number
77
+ radius: number
78
+ segFont: number
79
+ }
80
+
81
+ /** Per-density magnitudes. `default` reproduces the original compact values exactly. */
82
+ const TEMPO_SIZES: Record<TempoBarSize, TempoBarSizing> = {
83
+ default: { labelMargin: 4, labelFont: 10, barGap: 2, barHeight: 20, radius: 4, segFont: 12 },
84
+ wall: { labelMargin: 8, labelFont: 15, barGap: 4, barHeight: 40, radius: 6, segFont: 22 },
55
85
  }
56
86
 
57
87
  export interface TempoBarProps extends ViewProps {
@@ -63,6 +93,14 @@ export interface TempoBarProps extends ViewProps {
63
93
  completed?: Partial<Record<TempoPhaseKey, number>>
64
94
  /** Optional per-phase target durations (seconds) for pacing feedback. */
65
95
  target?: Partial<Record<TempoPhaseKey, number>>
96
+ /** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). */
97
+ size?: TempoBarSize
98
+ /**
99
+ * Active-phase readout. `time` (default) — `elapsed / target`. `delta` — a countdown
100
+ * of the remaining time to target (`1.5` → `0.0` → `-1.2` once over), colour-coded like
101
+ * the bar. Tapping the active segment toggles between the two at runtime.
102
+ */
103
+ activeDisplay?: TempoActiveDisplay
66
104
  className?: string
67
105
  }
68
106
 
@@ -79,25 +117,33 @@ export function TempoBar({
79
117
  phaseElapsedMs,
80
118
  completed,
81
119
  target,
120
+ size = 'default',
121
+ activeDisplay = 'time',
82
122
  className,
83
123
  ...props
84
124
  }: TempoBarProps) {
125
+ const s = TEMPO_SIZES[size]
126
+ // Runtime toggle of the active readout, seeded from `activeDisplay`.
127
+ const [display, setDisplay] = useState<TempoActiveDisplay>(activeDisplay)
128
+ const toggleDisplay = () => setDisplay((d) => (d === 'time' ? 'delta' : 'time'))
85
129
  return (
86
130
  <View className={className} testID="tempo-bar" {...props}>
87
131
  {/* Phase labels */}
88
- <View style={{ flexDirection: 'row', marginBottom: 4 }}>
132
+ <View style={{ flexDirection: 'row', marginBottom: s.labelMargin }}>
89
133
  {PHASE_ORDER.map((phase) => {
90
134
  const config = PHASE_CONFIG[phase]
91
135
  return (
92
136
  <View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
93
- <Text style={{ fontSize: 10, color: t['text-disabled'] }}>{config.label}</Text>
137
+ <Text numberOfLines={1} style={{ fontSize: s.labelFont, color: t['text-disabled'] }}>
138
+ {size === 'wall' ? config.wallLabel : config.label}
139
+ </Text>
94
140
  </View>
95
141
  )
96
142
  })}
97
143
  </View>
98
144
 
99
145
  {/* Segmented bar */}
100
- <View style={{ flexDirection: 'row', gap: 2, height: 20 }}>
146
+ <View style={{ flexDirection: 'row', gap: s.barGap, height: s.barHeight }}>
101
147
  {PHASE_ORDER.map((phase) => {
102
148
  const config = PHASE_CONFIG[phase]
103
149
  const isActive = activePhase === phase
@@ -111,7 +157,7 @@ export function TempoBar({
111
157
  style={{
112
158
  flex: config.flex,
113
159
  backgroundColor: alpha('#ffffff', 0.06),
114
- borderRadius: 4,
160
+ borderRadius: s.radius,
115
161
  overflow: 'hidden',
116
162
  }}
117
163
  >
@@ -120,9 +166,18 @@ export function TempoBar({
120
166
  config={config}
121
167
  phaseElapsedMs={phaseElapsedMs}
122
168
  targetMs={targetMs}
169
+ radius={s.radius}
170
+ font={s.segFont}
171
+ display={display}
172
+ onToggleDisplay={toggleDisplay}
123
173
  />
124
174
  ) : completedMs != null ? (
125
- <CompletedSegment config={config} completedMs={completedMs} targetMs={targetMs} />
175
+ <CompletedSegment
176
+ config={config}
177
+ completedMs={completedMs}
178
+ targetMs={targetMs}
179
+ font={s.segFont}
180
+ />
126
181
  ) : null}
127
182
  </View>
128
183
  )
@@ -136,21 +191,40 @@ function ActiveSegment({
136
191
  config,
137
192
  phaseElapsedMs,
138
193
  targetMs,
194
+ radius,
195
+ font,
196
+ display,
197
+ onToggleDisplay,
139
198
  }: {
140
199
  config: PhaseConfig
141
200
  phaseElapsedMs: number
142
201
  targetMs: number | null
202
+ radius: number
203
+ font: number
204
+ display: TempoActiveDisplay
205
+ onToggleDisplay: () => void
143
206
  }) {
144
207
  const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
145
208
  const barColor = pacing === 'behind' ? t['status-error'] : config.color
146
209
  const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
147
210
 
148
- const label = targetMs
149
- ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
150
- : formatDuration(phaseElapsedMs)
211
+ // `delta`: count the remaining time to target down to 0.0, then negative once over.
212
+ // Falls back to elapsed when there's no target. `time`: the elapsed / target readout.
213
+ const label =
214
+ display === 'delta' && targetMs != null
215
+ ? formatTempoDelta(targetMs - phaseElapsedMs)
216
+ : targetMs != null
217
+ ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
218
+ : formatDuration(phaseElapsedMs)
151
219
 
152
220
  return (
153
- <View style={{ height: '100%', position: 'relative' }} testID={`tempo-segment-active-${config.label}`}>
221
+ <Pressable
222
+ onPress={onToggleDisplay}
223
+ style={{ height: '100%', position: 'relative' }}
224
+ accessibilityRole="button"
225
+ accessibilityLabel={`Tempo readout: ${display === 'delta' ? 'delta' : 'time'} — tap to switch`}
226
+ testID={`tempo-segment-active-${config.label}`}
227
+ >
154
228
  <View
155
229
  style={{
156
230
  position: 'absolute',
@@ -159,7 +233,7 @@ function ActiveSegment({
159
233
  bottom: 0,
160
234
  width: `${fillPct}%`,
161
235
  backgroundColor: alpha(barColor, 0.3),
162
- borderRadius: 4,
236
+ borderRadius: radius,
163
237
  }}
164
238
  />
165
239
  <View
@@ -170,9 +244,9 @@ function ActiveSegment({
170
244
  justifyContent: 'center',
171
245
  }}
172
246
  >
173
- <Text style={{ fontSize: 12, fontWeight: '700', color: barColor }}>{label}</Text>
247
+ <Text style={{ fontSize: font, fontWeight: '700', color: barColor }}>{label}</Text>
174
248
  </View>
175
- </View>
249
+ </Pressable>
176
250
  )
177
251
  }
178
252
 
@@ -180,15 +254,20 @@ function CompletedSegment({
180
254
  config,
181
255
  completedMs,
182
256
  targetMs,
257
+ font,
183
258
  }: {
184
259
  config: PhaseConfig
185
260
  completedMs: number
186
261
  targetMs: number | null
262
+ font: number
187
263
  }) {
188
264
  const pacing = getTempoPacingState(completedMs, targetMs)
189
265
  const hitTarget = pacing !== 'behind'
190
266
  const indicator =
191
267
  targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
268
+ // A missed target reads by COLOUR first (red fill + red duration), the ✓/✗ second —
269
+ // glanceable across a room; a hit keeps the phase's own colour.
270
+ const cueColor = hitTarget ? config.color : t['status-error']
192
271
 
193
272
  return (
194
273
  <View
@@ -197,11 +276,17 @@ function CompletedSegment({
197
276
  flexDirection: 'row',
198
277
  alignItems: 'center',
199
278
  justifyContent: 'center',
200
- backgroundColor: alpha(config.color, 0.15),
279
+ backgroundColor: alpha(cueColor, 0.15),
201
280
  }}
202
281
  testID={`tempo-segment-completed-${config.label}`}
203
282
  >
204
- <Text style={{ fontSize: 12, fontWeight: '500', color: alpha(config.color, 0.7) }}>
283
+ <Text
284
+ style={{
285
+ fontSize: font,
286
+ fontWeight: '500',
287
+ color: hitTarget ? alpha(config.color, 0.7) : cueColor,
288
+ }}
289
+ >
205
290
  {formatDuration(completedMs)}
206
291
  {indicator}
207
292
  </Text>
@@ -1,5 +1,5 @@
1
1
  import { useState } from 'react'
2
- import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
3
3
  import { View, Text } from 'react-native'
4
4
  import { VelocityStrip } from './VelocityStrip'
5
5
 
@@ -11,12 +11,15 @@ const meta: Meta<typeof VelocityStrip> = {
11
11
  docs: {
12
12
  description: {
13
13
  component:
14
- 'Per-rep velocity strip. Two variants: `mini` (a flat 3px static strip) and `expanded` ' +
15
- '(the velocity-HEIGHT bar chart, rounded tops). The `expanded` chrome is prop-driven ' +
16
- 'with `showNumbers`/`showInfo` on it is the framed chart (raised surface, per-bar m/s ' +
17
- 'labels, mean/loss info row, interactive tap-to-expand); with both off it is a bare strip, ' +
14
+ 'Per-rep velocity strip. Three variants: `mini` (a flat 3px static strip), `expanded` ' +
15
+ '(the velocity-HEIGHT bar chart, rounded tops), and `hero` (the across-the-room, single-set ' +
16
+ 'wall treatment tall bars, per-bar value labels, a dashed running-best reference line, and ' +
17
+ 'dashed placeholders for the reps still to come via `targetReps`). The `expanded` chrome is ' +
18
+ 'prop-driven — with `showNumbers`/`showInfo` on it is the framed chart (raised surface, per-bar ' +
19
+ 'm/s labels, mean/loss info row, interactive tap-to-expand); with both off it is a bare strip, ' +
18
20
  'the active-set spotlight. `height` sets the plot height; `scale` is `peak` (to the set max) ' +
19
- 'or `fixed` (a fixed ceiling, cross-set-comparable). Feed either `velocities` or a `set` ' +
21
+ 'or `fixed` (a fixed ceiling, cross-set-comparable recommended for the live `hero` so bar ' +
22
+ 'heights never reflow as reps land). Feed either `velocities` or a `set` ' +
20
23
  'descriptor (set-type aware — see the ' +
21
24
  '[modalities](?path=/docs/workout-dataviz-velocitystrip-modalities--docs) sheet).',
22
25
  },
@@ -25,9 +28,13 @@ const meta: Meta<typeof VelocityStrip> = {
25
28
  argTypes: {
26
29
  variant: {
27
30
  control: 'select',
28
- options: ['mini', 'expanded'],
31
+ options: ['mini', 'expanded', 'hero'],
29
32
  description: 'Display variant',
30
33
  },
34
+ targetReps: {
35
+ control: 'number',
36
+ description: 'hero: planned rep count — reps beyond `velocities` draw as dashed placeholders',
37
+ },
31
38
  expanded: {
32
39
  control: 'boolean',
33
40
  description: 'expanded (framed): whether the chart is open (toggle for tap-to-expand)',
@@ -41,8 +48,8 @@ const meta: Meta<typeof VelocityStrip> = {
41
48
  description: 'expanded framed chart: the mean/loss info row (default true)',
42
49
  },
43
50
  height: {
44
- control: { type: 'number', min: 12, max: 96, step: 2 },
45
- description: 'expanded plot height in px (bars scale to this). Default 60.',
51
+ control: { type: 'number', min: 12, max: 260, step: 2 },
52
+ description: 'expanded/hero plot height in px (bars scale to this). Default 60 / 220 (hero).',
46
53
  },
47
54
  scale: {
48
55
  control: 'inline-radio',
@@ -117,6 +124,142 @@ export const ExpandedBareSpotlight: Story = {
117
124
  },
118
125
  }
119
126
 
127
+ // --- Hero variant ------------------------------------------------------------
128
+ // The across-the-room, single-set wall treatment. Rendered on the north-star wall
129
+ // background so the dashed reference line + placeholders read the way they will live.
130
+
131
+ /**
132
+ * Wall-background frame for the hero stories. The eyebrow label is ORGANISM chrome
133
+ * (the north-star live page renders it), NOT part of the primitive — it sits here only
134
+ * to show the component in the context it will live in. The `VelocityStrip` hero variant
135
+ * itself renders no title.
136
+ */
137
+ const heroDecorator: Decorator = (Story) => (
138
+ <View style={{ width: 560, padding: 28, backgroundColor: '#0E0E0E' }}>
139
+ <Text
140
+ style={{
141
+ color: '#5A5A5A',
142
+ fontSize: 10,
143
+ fontWeight: '700',
144
+ letterSpacing: 1,
145
+ marginBottom: 12,
146
+ }}
147
+ >
148
+ CONCENTRIC VELOCITY · THIS SET · (organism chrome — not the component)
149
+ </Text>
150
+ <Story />
151
+ </View>
152
+ )
153
+
154
+ const heroMidSet = [0.82, 0.79, 0.81, 0.76]
155
+ const heroNearComplete = [0.82, 0.79, 0.81, 0.76, 0.74, 0.71, 0.68]
156
+ const heroFatigue = [0.96, 0.91, 0.83, 0.72, 0.61, 0.5]
157
+
158
+ /** Controls-driven hero: flip `velocities` / `targetReps` / `liveRepIndex` / `scale` / `height`. */
159
+ export const HeroPlayground: Story = {
160
+ args: {
161
+ velocities: heroMidSet,
162
+ variant: 'hero',
163
+ targetReps: 8,
164
+ liveRepIndex: 3,
165
+ scale: 'fixed',
166
+ height: 220,
167
+ },
168
+ decorators: [heroDecorator],
169
+ }
170
+
171
+ /** Mid-set: 4 of 8 reps done, the newest bar popping, 4 dashed reps still to come. */
172
+ export const HeroMidSet: Story = {
173
+ args: { velocities: heroMidSet, variant: 'hero', targetReps: 8, liveRepIndex: 3, scale: 'fixed' },
174
+ decorators: [heroDecorator],
175
+ }
176
+
177
+ /** Near complete: 7 of 8, one placeholder left; the running-best line sits at rep 3. */
178
+ export const HeroNearComplete: Story = {
179
+ args: {
180
+ velocities: heroNearComplete,
181
+ variant: 'hero',
182
+ targetReps: 8,
183
+ liveRepIndex: 6,
184
+ scale: 'fixed',
185
+ },
186
+ decorators: [heroDecorator],
187
+ }
188
+
189
+ /** Fatigue decline: velocity falling rep over rep — the shape you read across the room. */
190
+ export const HeroFatigueDecline: Story = {
191
+ args: {
192
+ velocities: heroFatigue,
193
+ variant: 'hero',
194
+ targetReps: 8,
195
+ liveRepIndex: 5,
196
+ scale: 'fixed',
197
+ },
198
+ decorators: [heroDecorator],
199
+ }
200
+
201
+ /** Ends on the best rep: the running-best line lands on the last (live) bar's top. */
202
+ export const HeroEndsOnBest: Story = {
203
+ args: {
204
+ velocities: [0.7, 0.76, 0.83, 0.9],
205
+ variant: 'hero',
206
+ targetReps: 4,
207
+ liveRepIndex: 3,
208
+ scale: 'peak',
209
+ },
210
+ decorators: [heroDecorator],
211
+ }
212
+
213
+ /**
214
+ * Set expansion — reps performed BEYOND `targetReps` (11 done vs 8 planned). Placeholders
215
+ * are gone (target met) and the extra reps just render as more bars; `flex` bars narrow to
216
+ * fit, so the chart absorbs the overflow with no clipping. The AMRAP "keep going" case.
217
+ */
218
+ export const HeroExceedsTarget: Story = {
219
+ args: {
220
+ velocities: [0.9, 0.87, 0.84, 0.8, 0.77, 0.74, 0.71, 0.68, 0.64, 0.6, 0.55],
221
+ variant: 'hero',
222
+ targetReps: 8,
223
+ liveRepIndex: 10,
224
+ scale: 'fixed',
225
+ },
226
+ decorators: [heroDecorator],
227
+ }
228
+
229
+ /** Set complete: 8 of 8, no placeholders. */
230
+ export const HeroComplete: Story = {
231
+ args: {
232
+ velocities: [0.96, 0.91, 0.83, 0.79, 0.76, 0.72, 0.68, 0.61],
233
+ variant: 'hero',
234
+ targetReps: 8,
235
+ scale: 'fixed',
236
+ },
237
+ decorators: [heroDecorator],
238
+ }
239
+
240
+ /** The same set at three container widths — the hero is width-fluid (flex bars, capped width, fixed height). */
241
+ export const HeroResponsive: Story = {
242
+ render: () => (
243
+ <View style={{ gap: 24, padding: 24, backgroundColor: '#0E0E0E' }}>
244
+ {[360, 560, 900].map((w) => (
245
+ <View key={w} style={{ width: w }}>
246
+ <Text style={{ color: '#5A5A5A', fontSize: 10, fontWeight: '700', marginBottom: 8 }}>
247
+ {w}px
248
+ </Text>
249
+ <VelocityStrip
250
+ velocities={heroFatigue}
251
+ variant="hero"
252
+ targetReps={8}
253
+ liveRepIndex={5}
254
+ scale="fixed"
255
+ height={180}
256
+ />
257
+ </View>
258
+ ))}
259
+ </View>
260
+ ),
261
+ }
262
+
120
263
  /** A structured set descriptor (a drop set): the mini variant carries the set-type gap/color encoding. */
121
264
  export const SetTypeMini: Story = {
122
265
  args: {
@@ -564,3 +564,67 @@ describe('VelocityStrip expanded bare strip (spotlight: numbers + info off)', ()
564
564
  expect(await axe(container)).toHaveNoViolations()
565
565
  })
566
566
  })
567
+
568
+ describe('VelocityStrip hero variant', () => {
569
+ const heroSet = [0.9, 0.86, 0.82, 0.78]
570
+
571
+ it('renders one bar per performed rep, each with a value label', () => {
572
+ render(<VelocityStrip velocities={heroSet} variant="hero" targetReps={8} />)
573
+ expect(screen.getByTestId('velocity-strip-hero')).toBeInTheDocument()
574
+ expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(heroSet.length)
575
+ expect(screen.getAllByTestId(/^velocity-label-\d+$/)).toHaveLength(heroSet.length)
576
+ expect(screen.getByTestId('velocity-label-0')).toHaveTextContent('0.90')
577
+ })
578
+
579
+ it('draws a dashed placeholder for each rep still to come (target − done)', () => {
580
+ render(<VelocityStrip velocities={heroSet} variant="hero" targetReps={8} />)
581
+ expect(screen.getAllByTestId('velocity-slot-todo')).toHaveLength(4)
582
+ })
583
+
584
+ it('draws no placeholders when the target is met exactly', () => {
585
+ render(<VelocityStrip velocities={heroSet} variant="hero" targetReps={heroSet.length} />)
586
+ expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
587
+ })
588
+
589
+ it('set expansion: reps beyond target render as bars with no negative placeholders', () => {
590
+ const overflow = [0.9, 0.86, 0.82, 0.78, 0.74, 0.7, 0.66, 0.62, 0.58]
591
+ render(<VelocityStrip velocities={overflow} variant="hero" targetReps={8} />)
592
+ expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(overflow.length)
593
+ expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
594
+ })
595
+
596
+ it('draws no placeholders when targetReps is omitted', () => {
597
+ render(<VelocityStrip velocities={heroSet} variant="hero" />)
598
+ expect(screen.getAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(heroSet.length)
599
+ expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
600
+ })
601
+
602
+ it('renders the running-best reference line when there is a positive best', () => {
603
+ render(<VelocityStrip velocities={heroSet} variant="hero" targetReps={8} />)
604
+ expect(screen.getByTestId('velocity-hero-reference')).toBeInTheDocument()
605
+ })
606
+
607
+ it('omits the reference line when every velocity is zero (NaN / no-best guard)', () => {
608
+ render(<VelocityStrip velocities={[0, 0, 0]} variant="hero" targetReps={4} />)
609
+ expect(screen.queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
610
+ })
611
+
612
+ it('summarizes reps done, target and best in the accessibility label', () => {
613
+ render(<VelocityStrip velocities={heroSet} variant="hero" targetReps={8} />)
614
+ expect(
615
+ screen.getByLabelText('Velocity chart, 4 of 8 reps, best 0.90 meters per second')
616
+ ).toBeInTheDocument()
617
+ })
618
+
619
+ it('renders nothing when neither velocities nor set is provided', () => {
620
+ render(<VelocityStrip variant="hero" targetReps={8} />)
621
+ expect(screen.queryByTestId('velocity-strip-hero')).not.toBeInTheDocument()
622
+ })
623
+
624
+ it('has no accessibility violations', async () => {
625
+ const { container } = render(
626
+ <VelocityStrip velocities={heroSet} variant="hero" targetReps={8} liveRepIndex={3} />
627
+ )
628
+ expect(await axe(container)).toHaveNoViolations()
629
+ })
630
+ })