@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
@@ -4,7 +4,7 @@ import { View, Text, Pressable, type ViewProps } from 'react-native'
4
4
  import { roundTempo } from '../../../utils/workout-format'
5
5
  import { alpha } from '../../../utils/colors'
6
6
  import { getSemanticColors } from '../../../theme/tokens/semantic'
7
- import { getTempoFillPct, getTempoPacingState } from './TempoBar'
7
+ import { primitiveRamps } from '../../../theme/tokens/primitives'
8
8
  import { MetricCell } from './metricText'
9
9
 
10
10
  const t = getSemanticColors('dark')
@@ -22,14 +22,20 @@ export interface TempoLiveState {
22
22
  activePhase: TempoLivePhase | null
23
23
  /** Elapsed time (ms) within the active phase. */
24
24
  phaseElapsedMs: number
25
+ /**
26
+ * Actual completed durations (ms) for phases finished earlier THIS rep. A done cell
27
+ * freezes at its final readout (not the target) until the next rep resets the row.
28
+ * Omit a phase to fall back to its target (assume it completed on pace).
29
+ */
30
+ completed?: Partial<Record<TempoLivePhase, number>>
25
31
  }
26
32
 
27
33
  export interface TempoDisplayProps extends ViewProps {
28
34
  /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
29
35
  tempo: [number, number, number, number]
30
36
  size?: 'sm' | 'md'
31
- /** Colored phases or mono (all gray) */
32
- colored?: boolean
37
+ /** Override the digit font size (px). Defaults from `size` (9 sm / 11 md); raise for a wall read-out. */
38
+ fontSize?: number
33
39
  /** Show the "TEMPO" caption before the values. Default true. */
34
40
  showLabel?: boolean
35
41
  /** Show info tooltip on press */
@@ -40,19 +46,60 @@ export interface TempoDisplayProps extends ViewProps {
40
46
  * prescription string (default, backward compatible).
41
47
  */
42
48
  live?: TempoLiveState
49
+ /** Active-phase readout when `live`: `countdown` remaining to 0.0 (default), or `countup` elapsed. */
50
+ liveReadout?: TempoLiveReadout
43
51
  onPress?: () => void
44
52
  className?: string
45
53
  }
46
54
 
47
55
  const INTER = 'Inter, sans-serif'
48
56
  const TEXT_TERTIARY = '#6B7280'
49
- const STATUS_ERROR = t['status-error'] // shown when a phase runs behind pace
57
+ const STATUS_ERROR = t['status-error'] // slow over the target time
58
+ const STATUS_SUCCESS = t['status-success'] // on target (within the band of 0.0)
59
+ const STATUS_WARNING = t['status-warning'] // ahead — still time left to the target
60
+
61
+ /** Live active-phase readout: `countdown` remaining to 0.0, or `countup` elapsed to target. */
62
+ export type TempoLiveReadout = 'countdown' | 'countup'
63
+
64
+ /** ± this window (ms) around the target still counts as on target (0.0 ± 0.1s). */
65
+ const ON_TARGET_MS = 100
66
+
67
+ /** Phase-progress fill percent (0–100): how far the active phase has run toward its target. */
68
+ function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
69
+ if (!targetMs) return 100
70
+ return Math.min(100, (elapsedMs / targetMs) * 100)
71
+ }
72
+
73
+ /**
74
+ * Semantic pacing tone for the active/completed phase's NUMBER (the fill bar carries phase
75
+ * identity, so the number is free to carry pacing). Keyed on time remaining to target:
76
+ * ahead of target (still counting) → warning; within ±0.1s of 0.0 → success; over → error.
77
+ */
78
+ function activeNumberTone(elapsedMs: number, targetMs: number | null): string {
79
+ if (targetMs == null) return t['text-primary']
80
+ const remainingMs = targetMs - elapsedMs
81
+ if (remainingMs > ON_TARGET_MS) return STATUS_WARNING
82
+ if (remainingMs >= -ON_TARGET_MS) return STATUS_SUCCESS
83
+ return STATUS_ERROR
84
+ }
50
85
 
51
- // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
86
+ /** The active number: `countup` elapsed (→ target) or `countdown` remaining (→ 0.0, then −). */
87
+ function liveReadoutText(
88
+ elapsedMs: number,
89
+ targetMs: number,
90
+ readout: TempoLiveReadout,
91
+ ): string {
92
+ const seconds = readout === 'countup' ? elapsedMs / 1000 : (targetMs - elapsedMs) / 1000
93
+ return seconds.toFixed(1)
94
+ }
95
+
96
+ // Phase IDENTITY colours — deliberately NON-semantic (magenta ecc / cyan con) so the
97
+ // phase hue never collides with the semantic pacing tones (success/error) the active
98
+ // number carries. Pauses stay neutral grey. [eccentric, pauseBottom, concentric, pauseTop]
52
99
  const phaseColors = {
53
- eccentric: t['status-warning'],
100
+ eccentric: primitiveRamps.magenta[400],
54
101
  pauseBottom: TEXT_TERTIARY,
55
- concentric: t['status-success'],
102
+ concentric: primitiveRamps.cyan[300],
56
103
  pauseTop: TEXT_TERTIARY,
57
104
  dash: TEXT_TERTIARY,
58
105
  }
@@ -89,39 +136,95 @@ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
89
136
  { key: 'pauseTop', color: phaseColors.pauseTop },
90
137
  ]
91
138
 
139
+ /** A phase's place in the current rep: already done (locked full), filling now, or still to come. */
140
+ type LiveCellStatus = 'done' | 'active' | 'upcoming'
141
+
142
+ /** A bottom-anchored vertical fill behind the number (the phase-progress bar). */
143
+ function CellFill({ color, pct }: { color: string; pct: number }) {
144
+ return (
145
+ <View
146
+ style={{
147
+ position: 'absolute',
148
+ left: 2,
149
+ right: 2,
150
+ bottom: 0,
151
+ height: `${pct}%`,
152
+ backgroundColor: alpha(color, 0.28),
153
+ borderRadius: 3,
154
+ }}
155
+ />
156
+ )
157
+ }
158
+
159
+ /** Monospace stack for the live readout so digit widths never shift. */
160
+ const MONO = 'ui-monospace, SFMono-Regular, Menlo, Consolas, monospace'
161
+ /** Widest readout the cell must hold: sign · two whole digits · point · tenths, e.g. "-12.3". */
162
+ const READOUT_MAX_CHARS = 5
163
+
92
164
  function LiveTempoCell({
93
165
  value,
94
166
  color,
95
- isActive,
167
+ status,
96
168
  phaseElapsedMs,
169
+ completedMs,
97
170
  fontSize,
171
+ readout,
98
172
  }: {
99
173
  value: number
100
174
  color: string
101
- isActive: boolean
175
+ status: LiveCellStatus
102
176
  phaseElapsedMs: number
177
+ completedMs: number | undefined
103
178
  fontSize: number
179
+ readout: TempoLiveReadout
104
180
  }) {
105
- if (!isActive) {
106
- return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
107
- }
108
181
  const targetMs = value > 0 ? value * 1000 : null
109
- const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
182
+ // Fixed, monospace-width cell sized to the widest readout a phase activating or its
183
+ // number changing never shifts the layout (the fill bar stays put too).
184
+ const cellW = Math.ceil(fontSize * 0.62 * READOUT_MAX_CHARS)
185
+ const wrap = {
186
+ position: 'relative' as const,
187
+ width: cellW,
188
+ minHeight: Math.round(fontSize * 1.25),
189
+ alignItems: 'center' as const,
190
+ justifyContent: 'center' as const,
191
+ }
192
+ const num = (c: string, text: string) => (
193
+ <Text style={{ fontFamily: MONO, fontSize, color: c, fontWeight: '700' }}>{text}</Text>
194
+ )
195
+
196
+ // Upcoming / idle: the phase's prescribed time in the FULL phase colour, no fill — so a
197
+ // not-yet-started (or fully idle) row just reads as the base tempo lockup, not a dim mode.
198
+ if (status === 'upcoming') {
199
+ const targetText = targetMs != null ? (targetMs / 1000).toFixed(1) : String(value)
200
+ return <View style={wrap}>{num(color, targetText)}</View>
201
+ }
202
+
203
+ // Done: FROZEN at the phase's final readout (its actual completed time, not the target),
204
+ // fill locked full in the phase colour. The number keeps its SEMANTIC pacing tone (it is
205
+ // showing the final time, not the target) — banked progress until the next rep resets.
206
+ if (status === 'done') {
207
+ const finalMs = completedMs ?? targetMs ?? 0
208
+ const finalText = targetMs != null ? liveReadoutText(finalMs, targetMs, readout) : String(value)
209
+ const finalTone = targetMs != null ? activeNumberTone(finalMs, targetMs) : color
210
+ return (
211
+ <View style={wrap} testID="tempo-live-done">
212
+ <CellFill color={color} pct={100} />
213
+ {num(finalTone, finalText)}
214
+ </View>
215
+ )
216
+ }
217
+
218
+ // Active: the phase-hued fill grows with the phase; the number reads the live time
219
+ // (countdown/countup) to 0.1s, coloured SEMANTICALLY by pacing (neutral/on-target/behind).
220
+ const numberTone = activeNumberTone(phaseElapsedMs, targetMs)
110
221
  const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
222
+ const activeText =
223
+ targetMs != null ? liveReadoutText(phaseElapsedMs, targetMs, readout) : String(value)
111
224
  return (
112
- <View style={{ position: 'relative' }} testID="tempo-live-active">
113
- <View
114
- style={{
115
- position: 'absolute',
116
- left: 0,
117
- top: 0,
118
- bottom: 0,
119
- width: `${fillPct}%`,
120
- backgroundColor: alpha(barColor, 0.3),
121
- borderRadius: 2,
122
- }}
123
- />
124
- <TempoValue value={value} color={barColor} fontSize={fontSize} />
225
+ <View style={wrap} testID="tempo-live-active">
226
+ <CellFill color={color} pct={fillPct} />
227
+ {num(numberTone, activeText)}
125
228
  </View>
126
229
  )
127
230
  }
@@ -130,25 +233,38 @@ function LiveTempoRow({
130
233
  values,
131
234
  live,
132
235
  fontSize,
236
+ readout,
133
237
  }: {
134
238
  values: [number, number, number, number]
135
239
  live: TempoLiveState
136
240
  fontSize: number
241
+ readout: TempoLiveReadout
137
242
  }) {
243
+ // Phases run in a fixed order within a rep, so the active phase's position tells us which
244
+ // phases are already done (locked). When it wraps back to the first phase, the row resets.
245
+ const activeIndex = live.activePhase
246
+ ? LIVE_PHASES.findIndex((p) => p.key === live.activePhase)
247
+ : -1
138
248
  return (
139
249
  <>
140
- {LIVE_PHASES.map((phase, i) => (
141
- <View key={phase.key} style={{ flexDirection: 'row' }}>
142
- {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
143
- <LiveTempoCell
144
- value={values[i]}
145
- color={phase.color}
146
- isActive={live.activePhase === phase.key}
147
- phaseElapsedMs={live.phaseElapsedMs}
148
- fontSize={fontSize}
149
- />
150
- </View>
151
- ))}
250
+ {LIVE_PHASES.map((phase, i) => {
251
+ const status: LiveCellStatus =
252
+ activeIndex < 0 || i > activeIndex ? 'upcoming' : i < activeIndex ? 'done' : 'active'
253
+ return (
254
+ <View key={phase.key} style={{ flexDirection: 'row', alignItems: 'center' }}>
255
+ {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
256
+ <LiveTempoCell
257
+ value={values[i]}
258
+ color={phase.color}
259
+ status={status}
260
+ phaseElapsedMs={live.phaseElapsedMs}
261
+ completedMs={live.completed?.[phase.key]}
262
+ fontSize={fontSize}
263
+ readout={readout}
264
+ />
265
+ </View>
266
+ )
267
+ })}
152
268
  </>
153
269
  )
154
270
  }
@@ -156,10 +272,11 @@ function LiveTempoRow({
156
272
  export function TempoDisplay({
157
273
  tempo,
158
274
  size = 'md',
159
- colored = true,
275
+ fontSize: fontSizeProp,
160
276
  showLabel = true,
161
277
  showInfo = true,
162
278
  live,
279
+ liveReadout = 'countdown',
163
280
  onPress,
164
281
  className,
165
282
  ...props
@@ -168,8 +285,13 @@ export function TempoDisplay({
168
285
  // Round exact (unrounded) tempo seconds to the 1-dp display granularity.
169
286
  const [eccentric, pauseBottom, concentric, pauseTop] = roundTempo(tempo)
170
287
  const isSm = size === 'sm'
171
- const fontSize = isSm ? 9 : 11
172
- const monoColor = TEXT_TERTIARY
288
+ const fontSize = fontSizeProp ?? (isSm ? 9 : 11)
289
+ // The chrome (padding, radius, label) scales with the digit size so the whole view stays
290
+ // proportional at any form factor — a compact rail chip up to a wall read-out.
291
+ const chromePadX = Math.round(fontSize * 0.6)
292
+ const chromePadY = Math.round(fontSize * 0.3)
293
+ const chromeRadius = Math.round(fontSize * 0.4)
294
+ const labelFont = Math.max(9, Math.round(fontSize * 0.5))
173
295
 
174
296
  const handlePress = useCallback(() => {
175
297
  if (onPress) {
@@ -187,9 +309,9 @@ export function TempoDisplay({
187
309
  flexDirection: 'row',
188
310
  alignItems: 'center',
189
311
  backgroundColor: '#1C1C1C',
190
- paddingHorizontal: isSm ? 6 : 8,
191
- paddingVertical: 3,
192
- borderRadius: 4,
312
+ paddingHorizontal: chromePadX,
313
+ paddingVertical: chromePadY,
314
+ borderRadius: chromeRadius,
193
315
  }}
194
316
  {...props}
195
317
  >
@@ -197,12 +319,13 @@ export function TempoDisplay({
197
319
  <Text
198
320
  style={{
199
321
  fontFamily: INTER,
200
- fontSize: 9,
322
+ fontSize: labelFont,
201
323
  fontWeight: '500',
202
- color: TEXT_TERTIARY,
324
+ // Live-muted green while a rep is running, tertiary at rest.
325
+ color: live ? t['status-live-muted'] : TEXT_TERTIARY,
203
326
  letterSpacing: 0.5,
204
327
  textTransform: 'uppercase',
205
- marginRight: 6,
328
+ marginRight: Math.round(fontSize * 0.5),
206
329
  }}
207
330
  >
208
331
  TEMPO
@@ -214,8 +337,10 @@ export function TempoDisplay({
214
337
  values={[eccentric, pauseBottom, concentric, pauseTop]}
215
338
  live={live}
216
339
  fontSize={fontSize}
340
+ readout={liveReadout}
217
341
  />
218
- ) : colored ? (
342
+ ) : (
343
+ // Static prescription: the phase-coloured lockup (the same colours the live row rests at).
219
344
  <>
220
345
  <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
221
346
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
@@ -225,18 +350,6 @@ export function TempoDisplay({
225
350
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
226
351
  <TempoValue value={pauseTop} color={phaseColors.pauseTop} fontSize={fontSize} />
227
352
  </>
228
- ) : (
229
- <Text
230
- style={{
231
- fontFamily: INTER,
232
- fontSize,
233
- color: monoColor,
234
- fontWeight: '600',
235
- letterSpacing: 1,
236
- }}
237
- >
238
- {eccentric}-{pauseBottom}-{concentric}-{pauseTop}
239
- </Text>
240
353
  )}
241
354
  </View>
242
355
  </View>
@@ -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: {
@@ -78,10 +78,10 @@ describe('VelocityStrip expanded (framed chart)', () => {
78
78
  })
79
79
 
80
80
  describe('scale', () => {
81
- it('peak (default) scales the tallest rep to full height', () => {
82
- // maxV=1.0 -> denom 1.15 -> 1.0/1.15 = 87%.
81
+ it('peak (default) scales the tallest rep to nearly full height', () => {
82
+ // maxV=1.0 -> denom 1.06 (peak headroom) -> 1.0/1.06 = 94%.
83
83
  render(<VelocityStrip velocities={[1.0, 0.5]} showInfo={false} />)
84
- expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '87%' })
84
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '94%' })
85
85
  })
86
86
 
87
87
  it('fixed scales against the 1.15 ceiling regardless of the set max', () => {
@@ -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
+ })