@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.
- package/dist/index.d.mts +112 -55
- package/dist/index.d.ts +112 -55
- package/dist/index.js +795 -501
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +795 -498
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
- package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +691 -250
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +692 -251
- 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/ExerciseIndicator.tsx +1 -1
- 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 +70 -3
- 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/SetsRepsLoad.test.tsx +6 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
- package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
- package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
- 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 +91 -19
- package/src/components/custom/Workout/index.ts +1 -9
- 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
- package/src/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
- 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 {
|
|
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
|
-
/**
|
|
32
|
-
|
|
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'] //
|
|
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
|
-
|
|
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:
|
|
100
|
+
eccentric: primitiveRamps.magenta[400],
|
|
54
101
|
pauseBottom: TEXT_TERTIARY,
|
|
55
|
-
concentric:
|
|
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
|
-
|
|
167
|
+
status,
|
|
96
168
|
phaseElapsedMs,
|
|
169
|
+
completedMs,
|
|
97
170
|
fontSize,
|
|
171
|
+
readout,
|
|
98
172
|
}: {
|
|
99
173
|
value: number
|
|
100
174
|
color: string
|
|
101
|
-
|
|
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
|
-
|
|
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={
|
|
113
|
-
<
|
|
114
|
-
|
|
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
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
color={
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
191
|
-
paddingVertical:
|
|
192
|
-
borderRadius:
|
|
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:
|
|
322
|
+
fontSize: labelFont,
|
|
201
323
|
fontWeight: '500',
|
|
202
|
-
|
|
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:
|
|
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
|
-
) :
|
|
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.
|
|
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: {
|
|
@@ -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.
|
|
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: '
|
|
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
|
+
})
|