@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.
- package/dist/index.d.mts +109 -12
- package/dist/index.d.ts +109 -12
- package/dist/index.js +648 -231
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +648 -232
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
- package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +495 -92
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +496 -93
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +47 -0
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
- package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
- package/src/components/custom/Workout/TempoBar.tsx +107 -22
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- 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 {
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
<
|
|
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:
|
|
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:
|
|
247
|
+
<Text style={{ fontSize: font, fontWeight: '700', color: barColor }}>{label}</Text>
|
|
174
248
|
</View>
|
|
175
|
-
</
|
|
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(
|
|
279
|
+
backgroundColor: alpha(cueColor, 0.15),
|
|
201
280
|
}}
|
|
202
281
|
testID={`tempo-segment-completed-${config.label}`}
|
|
203
282
|
>
|
|
204
|
-
<Text
|
|
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.
|
|
15
|
-
'(the velocity-HEIGHT bar chart, rounded tops)
|
|
16
|
-
'
|
|
17
|
-
'
|
|
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
|
|
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:
|
|
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
|
+
})
|