@titan-design/react-ui 0.9.3 → 0.11.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 (120) hide show
  1. package/dist/bodymap.js +78 -24
  2. package/dist/bodymap.js.map +1 -1
  3. package/dist/bodymap.mjs +78 -24
  4. package/dist/bodymap.mjs.map +1 -1
  5. package/dist/index.d.mts +429 -11
  6. package/dist/index.d.ts +429 -11
  7. package/dist/index.js +2386 -885
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +2306 -836
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
  12. package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
  13. package/dist/pages.d.mts +1 -1
  14. package/dist/pages.d.ts +1 -1
  15. package/dist/pages.js +1900 -1609
  16. package/dist/pages.js.map +1 -1
  17. package/dist/pages.mjs +1901 -1610
  18. package/dist/pages.mjs.map +1 -1
  19. package/dist/theme/index.d.mts +129 -34
  20. package/dist/theme/index.d.ts +129 -34
  21. package/dist/theme/index.js +95 -19
  22. package/dist/theme/index.js.map +1 -1
  23. package/dist/theme/index.mjs +92 -20
  24. package/dist/theme/index.mjs.map +1 -1
  25. package/dist/theme/tokens-css.js +88 -24
  26. package/dist/theme/tokens-css.js.map +1 -1
  27. package/dist/theme/tokens-css.mjs +88 -24
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +8 -3
  30. package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
  31. package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
  32. package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
  33. package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
  34. package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
  35. package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
  36. package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
  37. package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
  38. package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
  39. package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
  40. package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
  41. package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
  42. package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
  43. package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
  44. package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
  45. package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
  46. package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
  47. package/src/components/custom/Fatigue/README.md +93 -0
  48. package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
  49. package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
  50. package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
  51. package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
  52. package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
  53. package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
  54. package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
  55. package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
  56. package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
  57. package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
  58. package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
  59. package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
  60. package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
  61. package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
  62. package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
  63. package/src/components/custom/Fatigue/index.ts +41 -0
  64. package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
  65. package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
  66. package/src/components/custom/Workout/BaseBadge.tsx +5 -5
  67. package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
  68. package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
  69. package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
  70. package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
  71. package/src/components/custom/Workout/README.md +62 -16
  72. package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
  73. package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
  74. package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
  75. package/src/components/custom/Workout/SessionHeader.tsx +2 -2
  76. package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
  77. package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
  78. package/src/components/custom/Workout/SessionRail.tsx +55 -18
  79. package/src/components/custom/Workout/SetRow.test.tsx +6 -6
  80. package/src/components/custom/Workout/SetRow.tsx +9 -2
  81. package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
  82. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
  83. package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
  84. package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
  85. package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
  86. package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
  87. package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
  88. package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
  89. package/src/components/custom/Workout/VelocityStrip.test.tsx +196 -79
  90. package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
  91. package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
  92. package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
  93. package/src/components/custom/Workout/index.ts +3 -0
  94. package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
  95. package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
  96. package/src/components/custom/charts/SetBarChart.tsx +651 -0
  97. package/src/components/custom/charts/live-rep-growth.ts +91 -0
  98. package/src/components/custom/index.ts +1 -0
  99. package/src/components/ui/surface/Surface.stories.tsx +195 -1
  100. package/src/components/ui/surface/Surface.test.tsx +148 -9
  101. package/src/components/ui/surface/Surface.tsx +37 -9
  102. package/src/components/ui/surface/SurfaceContext.ts +48 -8
  103. package/src/components/ui/surface/index.ts +1 -0
  104. package/src/components/ui/surface/surface.contract.test.ts +283 -0
  105. package/src/test/raw-color-patterns.test.ts +71 -0
  106. package/src/theme/ColorPalettes.stories.tsx +871 -0
  107. package/src/theme/ColorPrimitives.stories.tsx +300 -0
  108. package/src/theme/bar-paper.ts +59 -0
  109. package/src/theme/color-stories.coverage.test.ts +130 -0
  110. package/src/theme/color-story-kit.tsx +170 -0
  111. package/src/theme/config.ts +12 -0
  112. package/src/theme/elevation.ts +90 -67
  113. package/src/theme/global.css +35 -9
  114. package/src/theme/tokens/primitives.ts +40 -0
  115. package/src/theme/tokens/semantic.ts +63 -14
  116. package/tailwind.config.js +8 -0
  117. package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
  118. package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
  119. package/src/theme/ColorSystem.stories.tsx +0 -323
  120. package/src/theme/Colors.stories.tsx +0 -323
@@ -0,0 +1,685 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
2
+ import { render, screen, within } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { DualVelocityStrip } from './VelocityStrip'
5
+
6
+ /**
7
+ * The newest-rep entrance is an `Animated` value applied IMPERATIVELY, so jsdom never reflects it:
8
+ * a live bar's inline height sits at its static value for the whole animation, and driving
9
+ * requestAnimationFrame does not change that. The shared hook is therefore the only observable
10
+ * seam for "did the entrance actually arm on this wing".
11
+ *
12
+ * The spy PASSES THROUGH to the real implementation, so every other test in this file renders
13
+ * exactly as it would unmocked; it only records the arguments each composed strip armed with.
14
+ */
15
+ const liveEntranceCalls = vi.hoisted(
16
+ () => [] as Array<[boolean, number | undefined, number | undefined, boolean]>
17
+ )
18
+
19
+ vi.mock('../charts/live-rep-growth', async () => {
20
+ const actual =
21
+ await vi.importActual<typeof import('../charts/live-rep-growth')>('../charts/live-rep-growth')
22
+ return {
23
+ ...actual,
24
+ useLiveRepGrowth: (
25
+ active: boolean,
26
+ liveRepIndex: number | undefined,
27
+ liveVelocity: number | undefined,
28
+ isNewPeak: boolean
29
+ ) => {
30
+ liveEntranceCalls.push([active, liveRepIndex, liveVelocity, isNewPeak])
31
+ return actual.useLiveRepGrowth(active, liveRepIndex, liveVelocity, isNewPeak)
32
+ },
33
+ }
34
+ })
35
+
36
+ /**
37
+ * The entrance each composed wing armed with, in render order (up wing, then down wing).
38
+ * `velocity: undefined` means the wing resolved no live rep, so its entrance stays inert.
39
+ */
40
+ const wingEntrances = () =>
41
+ liveEntranceCalls.map(([, index, velocity, isNewPeak]) => ({ index, velocity, isNewPeak }))
42
+
43
+ // Literal-hex zone colours (must match the component's tokens exactly — same as the
44
+ // single-strip suite): the default effort scale and the WA 5-band mapping.
45
+ const GREEN = '#2ED573' // speed / ≥1.0
46
+ const RED = '#D14343' // maximalStrength / <0.5
47
+ const DISTINCT_RED = '#A4221C' // grinding (5th band, no collapse)
48
+
49
+ // The `hero` variant COMPOSES two single VelocityStrip heroes, one per wing, wrapped in
50
+ // `dual-velocity-wing-up` / `-down` so a test can scope to one side. Inside each wing the
51
+ // composed hero emits the SINGLE-strip testIDs: `velocity-bar-N`, `velocity-label-N`,
52
+ // `velocity-slot-todo`, `velocity-hero-reference`. The `rail` variant is a lean dedicated
53
+ // renderer that keeps the `dual-velocity-bar-L/R-N` testIDs.
54
+ const HERO_BARS = /^velocity-bar-\d+$/
55
+ const RAIL_LEFT_BARS = /^dual-velocity-bar-L-\d+$/
56
+ const RAIL_RIGHT_BARS = /^dual-velocity-bar-R-\d+$/
57
+ const wingUp = () => within(screen.getByTestId('dual-velocity-wing-up'))
58
+ const wingDown = () => within(screen.getByTestId('dual-velocity-wing-down'))
59
+
60
+ // WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
61
+ const compoundBands = [
62
+ { id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
63
+ { id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
64
+ { id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
65
+ { id: 'power', label: 'Power', min: 0.75, max: 1.0 },
66
+ { id: 'speed', label: 'Speed', min: 1.0, max: null },
67
+ ] as const
68
+
69
+ describe('DualVelocityStrip composition', () => {
70
+ it('composes an up wing and a down wing separated by the shared wing gap (no centre axis)', () => {
71
+ render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
72
+ expect(screen.getByTestId('dual-velocity-strip')).toBeInTheDocument()
73
+ expect(screen.getByTestId('dual-velocity-wing-up')).toBeInTheDocument()
74
+ expect(screen.getByTestId('dual-velocity-wing-down')).toBeInTheDocument()
75
+ // Exactly two composed single-strip heroes, one per wing.
76
+ expect(screen.getAllByTestId('velocity-strip-hero')).toHaveLength(2)
77
+ // No centre-axis rule any more — the wings read as one lockup via the gap between them.
78
+ expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
79
+ })
80
+
81
+ it('a 120px dual sizes each wing at ~59px — NOT ballooned to 220 by the hero height sentinel', () => {
82
+ // Regression: the single hero bumps an unset height (the 60px expanded default) to 220. A 120px
83
+ // dual splits into two wings of (120 − wing gap) / 2 = 59px, which — before the fix — collided
84
+ // with that sentinel and blew the wings up to 220 (bars overflowing). The columnSlots (dual) path
85
+ // is exempt, so each wing renders at the split height, not 220.
86
+ render(
87
+ <DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} height={120} />
88
+ )
89
+ for (const wing of screen.getAllByTestId('velocity-strip-hero')) {
90
+ expect(wing).toHaveStyle({ height: '59px' })
91
+ }
92
+ })
93
+
94
+ it('renders one bar per performed rep on each side (each wing scoped independently)', () => {
95
+ render(
96
+ <DualVelocityStrip
97
+ left={{ velocities: [0.9, 0.85, 0.8] }}
98
+ right={{ velocities: [0.82, 0.74] }}
99
+ />
100
+ )
101
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(3)
102
+ expect(wingDown().queryAllByTestId(HERO_BARS)).toHaveLength(2)
103
+ })
104
+
105
+ it('summarizes both sides’ reps done vs target in the container accessibility label', () => {
106
+ render(
107
+ <DualVelocityStrip
108
+ left={{ velocities: [0.9, 0.85, 0.8] }}
109
+ right={{ velocities: [0.82, 0.74] }}
110
+ targetReps={8}
111
+ />
112
+ )
113
+ expect(
114
+ screen.getByLabelText('Dual velocity chart, left 3 of 8 reps, right 2 of 8 reps')
115
+ ).toBeInTheDocument()
116
+ })
117
+
118
+ it('shows a per-rep value label per side at hero scale', () => {
119
+ render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.82] }} />)
120
+ expect(wingUp().getByTestId('velocity-label-0')).toHaveTextContent('0.90')
121
+ expect(wingDown().getByTestId('velocity-label-0')).toHaveTextContent('0.82')
122
+ })
123
+ })
124
+
125
+ describe('DualVelocityStrip side labels', () => {
126
+ it('renders each side’s slot name from its stream label (no hardcoded LEFT/RIGHT)', () => {
127
+ render(
128
+ <DualVelocityStrip
129
+ left={{ velocities: [0.9], label: 'Left Arm' }}
130
+ right={{ velocities: [0.8], label: 'Right Arm' }}
131
+ />
132
+ )
133
+ // The DATA keeps its casing (textContent is the original string); the label is
134
+ // uppercased visually via the small-caps/eyebrow `textTransform`, not by mutating text.
135
+ expect(screen.getByTestId('dual-velocity-side-label-L')).toHaveTextContent('Left Arm')
136
+ expect(screen.getByTestId('dual-velocity-side-label-R')).toHaveTextContent('Right Arm')
137
+ // The retired hardcoded copy is gone.
138
+ expect(screen.queryByText('LEFT VOLTRA')).not.toBeInTheDocument()
139
+ expect(screen.queryByText('RIGHT VOLTRA')).not.toBeInTheDocument()
140
+ })
141
+
142
+ it('renders the slot name in the uppercase small-caps label treatment', () => {
143
+ render(
144
+ <DualVelocityStrip
145
+ left={{ velocities: [0.9], label: 'Left Arm' }}
146
+ right={{ velocities: [0.8], label: 'Right Arm' }}
147
+ />
148
+ )
149
+ expect(screen.getByTestId('dual-velocity-side-label-L')).toHaveStyle({
150
+ textTransform: 'uppercase',
151
+ })
152
+ expect(screen.getByTestId('dual-velocity-side-label-R')).toHaveStyle({
153
+ textTransform: 'uppercase',
154
+ })
155
+ })
156
+
157
+ it('omits a side’s label when its stream has no label (no left/right fallback)', () => {
158
+ render(
159
+ <DualVelocityStrip
160
+ left={{ velocities: [0.9], label: 'Left Arm' }}
161
+ right={{ velocities: [0.8] }}
162
+ />
163
+ )
164
+ expect(screen.getByTestId('dual-velocity-side-label-L')).toBeInTheDocument()
165
+ expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
166
+ })
167
+
168
+ it('renders no side label when neither stream carries one', () => {
169
+ render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
170
+ expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
171
+ expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
172
+ })
173
+
174
+ it('treats an empty-string label as absent', () => {
175
+ render(
176
+ <DualVelocityStrip
177
+ left={{ velocities: [0.9], label: '' }}
178
+ right={{ velocities: [0.8], label: '' }}
179
+ />
180
+ )
181
+ expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
182
+ })
183
+
184
+ it('drops the gutter/slot labels AND the centre axis at rail scale (the lean dual-expanded)', () => {
185
+ render(
186
+ <DualVelocityStrip
187
+ left={{ velocities: [0.9], label: 'Left Arm' }}
188
+ right={{ velocities: [0.8], label: 'Right Arm' }}
189
+ variant="rail"
190
+ />
191
+ )
192
+ // Rail = the lean dual-expanded: no gutter/vertical-axis + no slot labels (the two wings read as
193
+ // separate rows via the vertical gap), and no centre axis — only the bolder hero keeps those.
194
+ expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
195
+ expect(screen.queryByTestId('dual-velocity-side-label-R')).not.toBeInTheDocument()
196
+ expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
197
+ })
198
+ })
199
+
200
+ describe('DualVelocityStrip diverging orientation', () => {
201
+ // The up wing grows upright; the down wing is the SAME hero vertically mirrored (a scaleY(-1)
202
+ // on the whole plot, NOT swapped radii — so both bars keep their DOM top-radius). The mirror is
203
+ // observable via the down wing counter-flipping its value text to stay upright.
204
+ it('grows the up wing upright and the down wing mirrored', () => {
205
+ render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [0.8] }} />)
206
+ expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ borderTopLeftRadius: '5px' })
207
+ // Up text upright (no transform); down text counter-flipped (has a transform) — proof the
208
+ // down wing is the mirrored orientation.
209
+ expect(wingUp().getByTestId('velocity-label-0').style.transform).toBeFalsy()
210
+ expect(wingDown().getByTestId('velocity-label-0').style.transform).toBeTruthy()
211
+ })
212
+ })
213
+
214
+ describe('DualVelocityStrip reference lines', () => {
215
+ it('draws a per-side running-best reference line at hero scale', () => {
216
+ render(
217
+ <DualVelocityStrip
218
+ left={{ velocities: [0.9, 0.85] }}
219
+ right={{ velocities: [0.82, 0.74] }}
220
+ variant="hero"
221
+ />
222
+ )
223
+ expect(wingUp().getByTestId('velocity-hero-reference')).toBeInTheDocument()
224
+ expect(wingDown().getByTestId('velocity-hero-reference')).toBeInTheDocument()
225
+ })
226
+
227
+ it('omits reference lines at rail scale', () => {
228
+ render(
229
+ <DualVelocityStrip
230
+ left={{ velocities: [0.9, 0.85] }}
231
+ right={{ velocities: [0.82, 0.74] }}
232
+ variant="rail"
233
+ />
234
+ )
235
+ expect(screen.queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
236
+ })
237
+
238
+ it('omits a side’s reference line when that side has no positive velocity', () => {
239
+ render(<DualVelocityStrip left={{ velocities: [0.9] }} right={{ velocities: [] }} />)
240
+ expect(wingUp().getByTestId('velocity-hero-reference')).toBeInTheDocument()
241
+ expect(wingDown().queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
242
+ })
243
+ })
244
+
245
+ describe('DualVelocityStrip planned stubs', () => {
246
+ it('index-locks the columns: the lagging side fills its un-logged rep column as empty, not shifted', () => {
247
+ render(
248
+ <DualVelocityStrip
249
+ left={{ velocities: [0.9, 0.85] }}
250
+ right={{ velocities: [0.8] }}
251
+ targetReps={4}
252
+ />
253
+ )
254
+ // 4 shared columns. left logs reps 0–1 → cols 2,3 planned todo. right logs rep 0 → col 1 is its
255
+ // un-logged rep column (EMPTY, index-locked under left's rep 1), cols 2,3 planned todo.
256
+ expect(wingUp().queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
257
+ expect(wingUp().queryAllByTestId('velocity-slot-empty')).toHaveLength(0)
258
+ expect(wingDown().queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
259
+ expect(wingDown().queryAllByTestId('velocity-slot-empty')).toHaveLength(1)
260
+ })
261
+
262
+ it('renders the planned stub as the solid to-do section (inherited hero styling, not dashed)', () => {
263
+ render(
264
+ <DualVelocityStrip
265
+ left={{ velocities: [0.9] }}
266
+ right={{ velocities: [0.8] }}
267
+ targetReps={2}
268
+ />
269
+ )
270
+ const stub = wingUp().getAllByTestId('velocity-slot-todo')[0]
271
+ // The hero (and the dual composing it) now use a solid surface-relative section, not a
272
+ // dashed outline. ROM is the only consumer that keeps the dashed stub.
273
+ expect(stub).not.toHaveStyle({ borderTopStyle: 'dashed' })
274
+ })
275
+
276
+ it('draws no stubs when the target is met', () => {
277
+ render(
278
+ <DualVelocityStrip
279
+ left={{ velocities: [0.9, 0.85] }}
280
+ right={{ velocities: [0.8, 0.7] }}
281
+ targetReps={2}
282
+ />
283
+ )
284
+ expect(screen.queryByTestId('velocity-slot-todo')).not.toBeInTheDocument()
285
+ })
286
+
287
+ it('renders both wings entirely as stubs for a planned-but-unstarted set', () => {
288
+ render(
289
+ <DualVelocityStrip left={{ velocities: [] }} right={{ velocities: [] }} targetReps={3} />
290
+ )
291
+ expect(wingUp().queryAllByTestId('velocity-slot-todo')).toHaveLength(3)
292
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(0)
293
+ })
294
+
295
+ it('symmetric index-lock: left logs 5, right logs 3 → 5 columns, right cols 3–4 aligned empty', () => {
296
+ render(
297
+ <DualVelocityStrip
298
+ left={{ velocities: [0.9, 0.88, 0.86, 0.84, 0.82] }}
299
+ right={{ velocities: [0.85, 0.8, 0.75] }}
300
+ />
301
+ )
302
+ // Both wings render 5 columns. Left: 5 rep bars. Right: 3 rep bars + cols 3,4 EMPTY (under
303
+ // left's reps 3,4), no shift, no todo (no planned remainder).
304
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(5)
305
+ expect(wingUp().queryAllByTestId('velocity-slot-empty')).toHaveLength(0)
306
+ expect(wingDown().queryAllByTestId(HERO_BARS)).toHaveLength(3)
307
+ expect(wingDown().queryAllByTestId('velocity-slot-empty')).toHaveLength(2)
308
+ })
309
+ })
310
+
311
+ describe('DualVelocityStrip colour mapping', () => {
312
+ it('zone mode colours reps from the absolute effort scale (side is position, not hue)', () => {
313
+ render(
314
+ <DualVelocityStrip
315
+ left={{ velocities: [1.1] }}
316
+ right={{ velocities: [0.4] }}
317
+ barColor="zone"
318
+ />
319
+ )
320
+ expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: GREEN })
321
+ expect(wingDown().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: RED })
322
+ })
323
+
324
+ it('zone mode colours both sides from supplied bands, with distinct 5-band reds', () => {
325
+ render(
326
+ <DualVelocityStrip
327
+ left={{ velocities: [1.1, 0.45] }}
328
+ right={{ velocities: [0.2] }}
329
+ zones={compoundBands}
330
+ barColor="zone"
331
+ />
332
+ )
333
+ expect(wingUp().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: GREEN })
334
+ expect(wingUp().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: RED })
335
+ expect(wingDown().getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: DISTINCT_RED })
336
+ })
337
+
338
+ it('loss mode (default) colours each wing off its OWN best — per-wing fatigue, shared height', () => {
339
+ // Left ends on its own best (loss 0 → green); right drops hard from its own best (→ red).
340
+ // The shared height scale must NOT bleed the stronger arm's best into the weaker arm's colour.
341
+ render(
342
+ <DualVelocityStrip left={{ velocities: [0.9, 0.9] }} right={{ velocities: [0.9, 0.5] }} />
343
+ )
344
+ expect(wingUp().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: GREEN })
345
+ expect(wingDown().getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: RED })
346
+ })
347
+ })
348
+
349
+ describe('DualVelocityStrip set-type streams (windows render on the composed hero)', () => {
350
+ // Each side passes its `set` descriptor straight into the composed hero, which builds its own
351
+ // typed slots — so the set-type WINDOWS (the range cyan variable window, the AMRAP/myo "continue")
352
+ // render on the dual, not just the flattened reps. This is the set-type vocabulary regained.
353
+ it('range set: renders the committed reps as bars AND the cyan variable window', () => {
354
+ render(
355
+ <DualVelocityStrip
356
+ left={{ set: { type: 'range', velocities: [0.9, 0.85], floor: 3, max: 5 } }}
357
+ right={{ velocities: [0.8] }}
358
+ />
359
+ )
360
+ // floor 3 / max 5, 2 done → 2 rep bars, 1 todo (i<floor), 2 variable (floor..max).
361
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(2)
362
+ expect(wingUp().queryAllByTestId('velocity-slot-variable')).toHaveLength(2)
363
+ })
364
+
365
+ it('amrap set: renders performed reps as bars AND the trailing continue window', () => {
366
+ render(
367
+ <DualVelocityStrip
368
+ left={{ set: { type: 'amrap', velocities: [0.9, 0.85] } }}
369
+ right={{ velocities: [0.8] }}
370
+ />
371
+ )
372
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(2)
373
+ expect(wingUp().getByTestId('velocity-slot-continue')).toBeInTheDocument()
374
+ })
375
+
376
+ it('myo set: activation + clusters render one bar per rep plus the open continue window', () => {
377
+ render(
378
+ <DualVelocityStrip
379
+ left={{
380
+ set: {
381
+ type: 'myo',
382
+ activation: [0.95, 0.9, 0.85],
383
+ clusters: [
384
+ [0.78, 0.72],
385
+ [0.68, 0.61],
386
+ ],
387
+ open: true,
388
+ },
389
+ }}
390
+ right={{ velocities: [0.8] }}
391
+ />
392
+ )
393
+ // 3 activation + 2 + 2 cluster reps = 7 filled bars, plus the open "continue" window.
394
+ expect(wingUp().queryAllByTestId(HERO_BARS)).toHaveLength(7)
395
+ expect(wingUp().getByTestId('velocity-slot-continue')).toBeInTheDocument()
396
+ })
397
+ })
398
+
399
+ describe('DualVelocityStrip live mode', () => {
400
+ const originalMatchMedia = window.matchMedia
401
+ beforeEach(() => {
402
+ liveEntranceCalls.length = 0
403
+ })
404
+ afterEach(() => {
405
+ if (originalMatchMedia) window.matchMedia = originalMatchMedia
406
+ else delete (window as { matchMedia?: unknown }).matchMedia
407
+ })
408
+
409
+ // The newest rep must animate in on BOTH wings. Before the dual composed real VelocityStrips it
410
+ // hand-rolled its own wing bars and drove them from a dual-only entrance hook, so the animation
411
+ // was a second implementation that could — and did — drift from the single strip's. Now both
412
+ // wings ARE single strips, and `liveRepIndex` reaches them through the ordinary composed prop:
413
+ // an entrance that fires on one wing only is no longer expressible.
414
+ it('arms the newest-rep entrance on BOTH wings, each with its OWN live velocity', () => {
415
+ render(
416
+ <DualVelocityStrip
417
+ left={{ velocities: [0.9, 0.85, 0.8] }}
418
+ right={{ velocities: [0.82, 0.78, 0.7] }}
419
+ variant="hero"
420
+ liveRepIndex={2}
421
+ />
422
+ )
423
+ expect(wingEntrances()).toEqual([
424
+ { index: 2, velocity: 0.8, isNewPeak: false },
425
+ { index: 2, velocity: 0.7, isNewPeak: false },
426
+ ])
427
+ })
428
+
429
+ it('flags a new set peak PER WING — a peak on one side never speaks for the other', () => {
430
+ // Up wing climbs to its best on rep 2; down wing declines from its best. The peak bounce is
431
+ // the loud half of the entrance, so a shared flag would fire it on a wing that got slower.
432
+ render(
433
+ <DualVelocityStrip
434
+ left={{ velocities: [0.7, 0.8, 0.9] }}
435
+ right={{ velocities: [0.9, 0.8, 0.7] }}
436
+ variant="hero"
437
+ liveRepIndex={2}
438
+ />
439
+ )
440
+ expect(wingEntrances()).toEqual([
441
+ { index: 2, velocity: 0.9, isNewPeak: true },
442
+ { index: 2, velocity: 0.7, isNewPeak: false },
443
+ ])
444
+ })
445
+
446
+ it('keeps a LAGGING wing inert while the leading wing still animates', () => {
447
+ // The lagging side holds an index-locked empty at that column, so it has no rep to animate —
448
+ // but that must not disarm its partner's entrance.
449
+ render(
450
+ <DualVelocityStrip
451
+ left={{ velocities: [0.9, 0.85, 0.8] }}
452
+ right={{ velocities: [0.82, 0.78] }}
453
+ variant="hero"
454
+ liveRepIndex={2}
455
+ />
456
+ )
457
+ expect(wingEntrances()).toEqual([
458
+ { index: 2, velocity: 0.8, isNewPeak: false },
459
+ { index: 2, velocity: undefined, isNewPeak: false },
460
+ ])
461
+ })
462
+
463
+ it('reaches both wings through a set descriptor, past its set-type windows', () => {
464
+ // A `range` set puts cyan variable-window stubs after the performed reps. Rep indices are
465
+ // counted over rep cells only, so the window must not shift which bar the entrance lands on.
466
+ render(
467
+ <DualVelocityStrip
468
+ left={{ set: { type: 'range', velocities: [0.9, 0.85, 0.8], floor: 4, max: 6 } }}
469
+ right={{ set: { type: 'range', velocities: [0.8, 0.75, 0.7], floor: 4, max: 6 } }}
470
+ variant="hero"
471
+ liveRepIndex={2}
472
+ />
473
+ )
474
+ expect(wingEntrances()).toEqual([
475
+ { index: 2, velocity: 0.8, isNewPeak: false },
476
+ { index: 2, velocity: 0.7, isNewPeak: false },
477
+ ])
478
+ })
479
+
480
+ it('arms both wings at rail scale too — its wings are composed strips as well', () => {
481
+ render(
482
+ <DualVelocityStrip
483
+ left={{ velocities: [0.9, 0.85, 0.8] }}
484
+ right={{ velocities: [0.82, 0.78, 0.7] }}
485
+ variant="rail"
486
+ liveRepIndex={2}
487
+ />
488
+ )
489
+ expect(wingEntrances()).toEqual([
490
+ { index: 2, velocity: 0.8, isNewPeak: false },
491
+ { index: 2, velocity: 0.7, isNewPeak: false },
492
+ ])
493
+ })
494
+
495
+ it('leaves the folded compact dual inert — flat bars have no height to animate', () => {
496
+ render(
497
+ <DualVelocityStrip
498
+ left={{ velocities: [0.9, 0.8] }}
499
+ right={{ velocities: [0.8, 0.7] }}
500
+ variant="compact"
501
+ liveRepIndex={1}
502
+ />
503
+ )
504
+ expect(wingEntrances()).toEqual([
505
+ { index: undefined, velocity: undefined, isNewPeak: false },
506
+ { index: undefined, velocity: undefined, isNewPeak: false },
507
+ ])
508
+ })
509
+
510
+ it('renders the live mirrored pair at the given index (hero)', () => {
511
+ render(
512
+ <DualVelocityStrip
513
+ left={{ velocities: [0.9, 0.85, 0.8] }}
514
+ right={{ velocities: [0.82, 0.78, 0.7] }}
515
+ variant="hero"
516
+ liveRepIndex={2}
517
+ />
518
+ )
519
+ expect(wingUp().getByTestId('velocity-bar-2')).toBeInTheDocument()
520
+ expect(wingDown().getByTestId('velocity-bar-2')).toBeInTheDocument()
521
+ })
522
+
523
+ it('renders stably with prefers-reduced-motion set', () => {
524
+ window.matchMedia = vi.fn().mockReturnValue({
525
+ matches: true,
526
+ addEventListener: vi.fn(),
527
+ removeEventListener: vi.fn(),
528
+ }) as unknown as typeof window.matchMedia
529
+ render(
530
+ <DualVelocityStrip
531
+ left={{ velocities: [1.1, 0.9] }}
532
+ right={{ velocities: [0.8, 0.7] }}
533
+ liveRepIndex={0}
534
+ />
535
+ )
536
+ expect(wingUp().getByTestId('velocity-bar-0')).toBeInTheDocument()
537
+ })
538
+ })
539
+
540
+ describe('DualVelocityStrip compact variant (folded)', () => {
541
+ it('keeps the FOLDED footprint — the pair costs one strip height, not two', () => {
542
+ render(
543
+ <DualVelocityStrip
544
+ left={{ velocities: [0.9, 0.85, 0.8], label: 'Left Arm' }}
545
+ right={{ velocities: [0.82, 0.74], label: 'Right Arm' }}
546
+ variant="compact"
547
+ />
548
+ )
549
+ // The fold is a FOOTPRINT guarantee, not a DOM one: compact is composed from two wings like
550
+ // the hero and rail, but they share DUAL_COMPACT_HEIGHT (5 + 1.5 gap + 5) rather than stacking
551
+ // to 2x a full strip. Asserting the height keeps the design decision locked while leaving the
552
+ // implementation free to compose.
553
+ expect(screen.getByTestId('dual-velocity-strip')).toHaveStyle({ height: '11.5px' })
554
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
555
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
556
+ expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
557
+ expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
558
+ // Folded height leaves no room for chrome: no gutter/slot labels, no centre axis, no values.
559
+ expect(screen.queryByTestId('dual-velocity-side-label-L')).not.toBeInTheDocument()
560
+ expect(screen.queryByTestId('dual-velocity-axis')).not.toBeInTheDocument()
561
+ expect(screen.queryByTestId(/^velocity-label-\d+$/)).not.toBeInTheDocument()
562
+ })
563
+
564
+ it('index-locks — a lagging side keeps its columns as empties', () => {
565
+ render(
566
+ <DualVelocityStrip
567
+ left={{ velocities: [0.9, 0.88, 0.86, 0.84] }}
568
+ right={{ velocities: [0.85, 0.8] }}
569
+ variant="compact"
570
+ />
571
+ )
572
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
573
+ expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
574
+ // Four columns overall, so the lagging side holds two of them as empties rather than shrinking.
575
+ expect(down.queryAllByTestId('velocity-slot-empty')).toHaveLength(2)
576
+ })
577
+
578
+ it('carries the set-type vocabulary that the hand-rolled fold used to drop', () => {
579
+ render(
580
+ <DualVelocityStrip
581
+ left={{ set: { type: 'straight', velocities: [0.9, 0.86], planned: 4 } }}
582
+ right={{ set: { type: 'straight', velocities: [0.88, 0.84], planned: 4 } }}
583
+ variant="compact"
584
+ />
585
+ )
586
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
587
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
588
+ expect(up.queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
589
+ expect(down.queryAllByTestId('velocity-slot-todo')).toHaveLength(2)
590
+ })
591
+ })
592
+
593
+ describe('DualVelocityStrip rail variant', () => {
594
+ it('renders compact bars but no value labels or reference lines', () => {
595
+ render(
596
+ <DualVelocityStrip
597
+ left={{ velocities: [0.9, 0.85] }}
598
+ right={{ velocities: [0.82, 0.74] }}
599
+ variant="rail"
600
+ />
601
+ )
602
+ // The rail is now COMPOSED from two bare `expanded` strips, so each wing emits the shared
603
+ // SetBarChart testIDs rather than the old bespoke `dual-velocity-bar-*` ones. Query per wing.
604
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
605
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
606
+ expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
607
+ expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
608
+ // Still lean: no per-bar value labels, no running-best reference line.
609
+ expect(screen.queryByTestId(/^velocity-label-\d+$/)).not.toBeInTheDocument()
610
+ expect(screen.queryByTestId('velocity-hero-reference')).not.toBeInTheDocument()
611
+ })
612
+
613
+ it('draws a to-do slot on each wing for the unperformed remainder', () => {
614
+ render(
615
+ <DualVelocityStrip
616
+ left={{ velocities: [0.9] }}
617
+ right={{ velocities: [0.8] }}
618
+ variant="rail"
619
+ targetReps={2}
620
+ />
621
+ )
622
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
623
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
624
+ expect(up.queryAllByTestId('velocity-slot-todo')).toHaveLength(1)
625
+ expect(down.queryAllByTestId('velocity-slot-todo')).toHaveLength(1)
626
+ })
627
+
628
+ it('index-locks a lagging side, giving it an aligned empty rather than fewer bars', () => {
629
+ // The defect this replaces: each side built its own columns, so a lagging side simply
630
+ // rendered FEWER bars and its remaining reps slid left out of alignment.
631
+ render(
632
+ <DualVelocityStrip
633
+ left={{ velocities: [0.9, 0.86, 0.82] }}
634
+ right={{ velocities: [0.85, 0.8] }}
635
+ variant="rail"
636
+ />
637
+ )
638
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
639
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
640
+ expect(up.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(3)
641
+ // The lagging side keeps three COLUMNS — two bars plus an aligned empty.
642
+ expect(down.queryAllByTestId(/^velocity-bar-\d+$/)).toHaveLength(2)
643
+ expect(down.queryAllByTestId('velocity-slot-empty')).toHaveLength(1)
644
+ })
645
+
646
+ it('carries set-type windows onto both wings', () => {
647
+ // The bespoke rail flattened to velocities, so a range's variable window never rendered.
648
+ render(
649
+ <DualVelocityStrip
650
+ left={{ set: { type: 'range', velocities: [0.9, 0.86], floor: 3, max: 4 } }}
651
+ right={{ set: { type: 'range', velocities: [0.85, 0.8], floor: 3, max: 4 } }}
652
+ variant="rail"
653
+ />
654
+ )
655
+ const up = within(screen.getByTestId('dual-velocity-wing-up'))
656
+ const down = within(screen.getByTestId('dual-velocity-wing-down'))
657
+ expect(up.queryAllByTestId('velocity-slot-variable').length).toBeGreaterThan(0)
658
+ expect(down.queryAllByTestId('velocity-slot-variable').length).toBeGreaterThan(0)
659
+ })
660
+ })
661
+
662
+ describe('DualVelocityStrip accessibility', () => {
663
+ it('has no violations at hero scale with a live rep and planned stubs', async () => {
664
+ const { container } = render(
665
+ <DualVelocityStrip
666
+ left={{ velocities: [0.9, 0.85, 0.8] }}
667
+ right={{ velocities: [0.82, 0.74] }}
668
+ targetReps={8}
669
+ liveRepIndex={2}
670
+ />
671
+ )
672
+ expect(await axe(container)).toHaveNoViolations()
673
+ })
674
+
675
+ it('has no violations at rail scale', async () => {
676
+ const { container } = render(
677
+ <DualVelocityStrip
678
+ left={{ velocities: [0.9] }}
679
+ right={{ velocities: [0.8] }}
680
+ variant="rail"
681
+ />
682
+ )
683
+ expect(await axe(container)).toHaveNoViolations()
684
+ })
685
+ })