@titan-design/react-ui 0.9.0 → 0.9.2
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 +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +20 -21
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +20 -21
- package/dist/index.mjs.map +1 -1
- package/dist/pages.js +2 -1
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +2 -1
- package/dist/pages.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/custom/Workout/ExerciseHeading.tsx +5 -3
- package/src/components/custom/Workout/SessionHeader.tsx +6 -5
- package/src/components/custom/Workout/SessionRail.tsx +18 -7
- package/src/components/custom/Workout/SetsRepsLoad.tsx +2 -1
- package/src/components/shell/DashboardShell.tsx +7 -9
- package/src/components/shell/NavItem.tsx +6 -1
- package/src/test/no-device-internals.test.ts +51 -0
- package/src/lab/north-star/LivePage.tsx +0 -101
- package/src/lab/north-star/LiveView.tsx +0 -409
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +0 -129
- package/src/lab/north-star/RestView.tsx +0 -140
- package/src/lab/north-star/fixtures.ts +0 -224
|
@@ -1,409 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { useState, type ReactElement } from 'react'
|
|
3
|
-
import { View, Text, type LayoutChangeEvent } from 'react-native'
|
|
4
|
-
import {
|
|
5
|
-
LiveAuraFrame,
|
|
6
|
-
VelocityStrip,
|
|
7
|
-
TempoDisplay,
|
|
8
|
-
SetsRepsLoad,
|
|
9
|
-
ActivityIcon,
|
|
10
|
-
AlertTriangleIcon,
|
|
11
|
-
CircleSlashIcon,
|
|
12
|
-
type IconProps,
|
|
13
|
-
} from '../../components'
|
|
14
|
-
import { Tooltip } from '../../components/ui/tooltip/Tooltip'
|
|
15
|
-
import { getSemanticColors } from '../../theme/tokens/semantic'
|
|
16
|
-
import { alpha } from '../../utils/colors'
|
|
17
|
-
import { neumorphicShadows } from '../../theme/shadows'
|
|
18
|
-
import { type DashboardModel, verdictFromLoss } from './fixtures'
|
|
19
|
-
|
|
20
|
-
const t = getSemanticColors('dark')
|
|
21
|
-
|
|
22
|
-
/** Raised-card elevation shared by the alert + tempo cards. */
|
|
23
|
-
const CARD_SHADOW = neumorphicShadows.charcoal.raised.medium
|
|
24
|
-
/** One row height for the tempo + alert cards, so they line up regardless of tempo font size. */
|
|
25
|
-
const CONTROL_HEIGHT = 34
|
|
26
|
-
/** The tempo card ground — mirrors TempoDisplay's own charcoal so a shorter inner pill reads seamless. */
|
|
27
|
-
const TEMPO_GROUND = '#1C1C1C'
|
|
28
|
-
|
|
29
|
-
/** Clamped linear interpolation of `v` between `vLo..vHi` as `w` runs `wLo..wHi`. */
|
|
30
|
-
function clampLerp(w: number, wLo: number, wHi: number, vLo: number, vHi: number): number {
|
|
31
|
-
if (w <= wLo) return vLo
|
|
32
|
-
if (w >= wHi) return vHi
|
|
33
|
-
return vLo + ((w - wLo) / (wHi - wLo)) * (vHi - vLo)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
// --- Voltra slot --------------------------------------------------------------
|
|
37
|
-
|
|
38
|
-
/** Which Voltra a live view is reading from — for dual mode and multi-device sessions. */
|
|
39
|
-
export type VoltraSlot = 'L' | 'R'
|
|
40
|
-
|
|
41
|
-
const SLOT_META: Record<VoltraSlot, { label: string }> = {
|
|
42
|
-
L: { label: 'LEFT VOLTRA' },
|
|
43
|
-
R: { label: 'RIGHT VOLTRA' },
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** The voltra name set vertically down the far-left edge of a layer (dual / multi-device). */
|
|
47
|
-
function VerticalSlotLabel({ slot }: { slot: VoltraSlot }) {
|
|
48
|
-
const { label } = SLOT_META[slot]
|
|
49
|
-
return (
|
|
50
|
-
<View
|
|
51
|
-
className="border-border"
|
|
52
|
-
style={{ width: 34, alignItems: 'center', justifyContent: 'center', borderRightWidth: 1 }}
|
|
53
|
-
>
|
|
54
|
-
{/* Fixed width holds the full label before rotation (a bare rotate clips to the strip). */}
|
|
55
|
-
<Text
|
|
56
|
-
className="text-text-tertiary"
|
|
57
|
-
style={{
|
|
58
|
-
width: 150,
|
|
59
|
-
textAlign: 'center',
|
|
60
|
-
fontSize: 12,
|
|
61
|
-
fontWeight: '700',
|
|
62
|
-
letterSpacing: 3,
|
|
63
|
-
transform: [{ rotate: '-90deg' }],
|
|
64
|
-
}}
|
|
65
|
-
>
|
|
66
|
-
{label}
|
|
67
|
-
</Text>
|
|
68
|
-
</View>
|
|
69
|
-
)
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
// --- Alert cue ----------------------------------------------------------------
|
|
73
|
-
|
|
74
|
-
type Verdict = 'productive' | 'threshold' | 'stop'
|
|
75
|
-
const STATUS_COLOR: Record<Verdict, string> = {
|
|
76
|
-
productive: t['status-success'],
|
|
77
|
-
threshold: t['status-warning'],
|
|
78
|
-
stop: t['status-error'],
|
|
79
|
-
}
|
|
80
|
-
const VERDICT_LABEL: Record<Verdict, string> = {
|
|
81
|
-
productive: 'Productive',
|
|
82
|
-
threshold: 'Threshold',
|
|
83
|
-
stop: 'Stop',
|
|
84
|
-
}
|
|
85
|
-
// A CONTEXTUAL glyph keyed on proximity to the velocity-loss threshold (replaces the flat
|
|
86
|
-
// colour dot): a healthy pulse well under, a warning triangle at the threshold band, a
|
|
87
|
-
// slashed circle once past it.
|
|
88
|
-
const STATUS_ICON: Record<Verdict, (props: IconProps) => ReactElement> = {
|
|
89
|
-
productive: ActivityIcon,
|
|
90
|
-
threshold: AlertTriangleIcon,
|
|
91
|
-
stop: CircleSlashIcon,
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/** How much of the alert survives at the current width. */
|
|
95
|
-
export type AlertMode = 'full' | 'compact' | 'icon'
|
|
96
|
-
|
|
97
|
-
/** The tinted alert surface (border + wash + raised shadow) shared by the card and the icon pill. */
|
|
98
|
-
function alertSurface(tone: string) {
|
|
99
|
-
return {
|
|
100
|
-
borderWidth: 1,
|
|
101
|
-
borderColor: alpha(tone, 0.45),
|
|
102
|
-
backgroundColor: alpha(tone, 0.14),
|
|
103
|
-
...CARD_SHADOW,
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/**
|
|
108
|
-
* The single status element — a tinted alert card carrying the exertion message. It sheds
|
|
109
|
-
* detail as space tightens: `full` shows the contextual icon + verdict + inline message
|
|
110
|
-
* (capped at `availWidth` so it ellipsises + keeps a hover tip rather than running off-page);
|
|
111
|
-
* `compact` drops the message to the tip; `icon` collapses to just the contextual glyph,
|
|
112
|
-
* verdict + message on hover.
|
|
113
|
-
*/
|
|
114
|
-
function AlertCue({
|
|
115
|
-
status,
|
|
116
|
-
message,
|
|
117
|
-
mode,
|
|
118
|
-
availWidth,
|
|
119
|
-
}: {
|
|
120
|
-
status: Verdict
|
|
121
|
-
message: string
|
|
122
|
-
mode: AlertMode
|
|
123
|
-
/** Pixels the alert may occupy (row width − tempo − gap); caps the card so the message clips. */
|
|
124
|
-
availWidth?: number
|
|
125
|
-
}) {
|
|
126
|
-
const tone = STATUS_COLOR[status]
|
|
127
|
-
const Icon = STATUS_ICON[status]
|
|
128
|
-
const meaningful = status === 'threshold' || status === 'stop'
|
|
129
|
-
|
|
130
|
-
// Tightest: icon-only pill. Verdict + message live in the hover tip.
|
|
131
|
-
if (mode === 'icon') {
|
|
132
|
-
const pill = (
|
|
133
|
-
<View
|
|
134
|
-
style={{
|
|
135
|
-
width: CONTROL_HEIGHT,
|
|
136
|
-
height: CONTROL_HEIGHT,
|
|
137
|
-
borderRadius: 9,
|
|
138
|
-
alignItems: 'center',
|
|
139
|
-
justifyContent: 'center',
|
|
140
|
-
...alertSurface(tone),
|
|
141
|
-
}}
|
|
142
|
-
>
|
|
143
|
-
<Icon size={17} color={tone} />
|
|
144
|
-
</View>
|
|
145
|
-
)
|
|
146
|
-
return meaningful ? (
|
|
147
|
-
<Tooltip label={`${VERDICT_LABEL[status]} · ${message}`} placement="bottom">
|
|
148
|
-
{pill}
|
|
149
|
-
</Tooltip>
|
|
150
|
-
) : (
|
|
151
|
-
pill
|
|
152
|
-
)
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
const card = (
|
|
156
|
-
<View
|
|
157
|
-
className="flex-row items-center"
|
|
158
|
-
style={{
|
|
159
|
-
gap: 8,
|
|
160
|
-
height: CONTROL_HEIGHT,
|
|
161
|
-
// Concrete px cap (not %) so the single-line message ellipsises through the wrapper chain.
|
|
162
|
-
maxWidth: availWidth,
|
|
163
|
-
borderRadius: 10,
|
|
164
|
-
paddingHorizontal: 12,
|
|
165
|
-
...alertSurface(tone),
|
|
166
|
-
}}
|
|
167
|
-
>
|
|
168
|
-
<Icon size={15} color={tone} />
|
|
169
|
-
<Text style={{ color: tone, fontSize: 13, fontWeight: '700', flexShrink: 0 }}>
|
|
170
|
-
{VERDICT_LABEL[status]}
|
|
171
|
-
</Text>
|
|
172
|
-
{mode === 'full' && meaningful && (
|
|
173
|
-
// Bounded + single-line: ellipsises instead of pushing off the page (full text on hover).
|
|
174
|
-
<Text
|
|
175
|
-
numberOfLines={1}
|
|
176
|
-
style={{ color: tone, fontSize: 13, fontWeight: '600', flexShrink: 1, minWidth: 0 }}
|
|
177
|
-
>
|
|
178
|
-
· {message}
|
|
179
|
-
</Text>
|
|
180
|
-
)}
|
|
181
|
-
</View>
|
|
182
|
-
)
|
|
183
|
-
// Keep the full message a hover away whenever it isn't fully spelled out (compact) or may be
|
|
184
|
-
// clipped (full → ellipsis).
|
|
185
|
-
return meaningful ? (
|
|
186
|
-
<Tooltip label={message} placement="bottom">
|
|
187
|
-
{card}
|
|
188
|
-
</Tooltip>
|
|
189
|
-
) : (
|
|
190
|
-
card
|
|
191
|
-
)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
// --- Live tempo phase mapping -------------------------------------------------
|
|
195
|
-
|
|
196
|
-
/** Map the model's movement phase onto TempoDisplay's live-fill phase key. */
|
|
197
|
-
function mapLivePhase(
|
|
198
|
-
phase: DashboardModel['live']['phase']
|
|
199
|
-
): 'eccentric' | 'pauseBottom' | 'concentric' | null {
|
|
200
|
-
switch (phase) {
|
|
201
|
-
case 'concentric':
|
|
202
|
-
return 'concentric'
|
|
203
|
-
case 'eccentric':
|
|
204
|
-
return 'eccentric'
|
|
205
|
-
case 'hold':
|
|
206
|
-
return 'pauseBottom'
|
|
207
|
-
default:
|
|
208
|
-
return null
|
|
209
|
-
}
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
// --- Page-level exercise header -----------------------------------------------
|
|
213
|
-
|
|
214
|
-
/** Below this header width the targets line wraps under the name (at the set-heading ratio). */
|
|
215
|
-
const HEADER_WRAP = 480
|
|
216
|
-
/** At/above this header width the targets render at full size. */
|
|
217
|
-
const HEADER_WIDE = 760
|
|
218
|
-
/** Targets:name size ratio on the wrapped second line — matches ExerciseHeading (11 / 14). */
|
|
219
|
-
const SET_HEADING_RATIO = 11 / 14
|
|
220
|
-
const HEADER_NAME_SIZE = 30
|
|
221
|
-
|
|
222
|
-
/**
|
|
223
|
-
* The workout title + targets — the exercise being performed, independent of how many
|
|
224
|
-
* voltras drive it, so it lives at the TOP OF THE PAGE (above the live stage) and stays
|
|
225
|
-
* visible across single/dual. The targets shrink with width and only wrap under the name
|
|
226
|
-
* (at the set-heading size ratio) once too tight to shrink further. NOT a published component.
|
|
227
|
-
*/
|
|
228
|
-
export function ExerciseHeader({ session }: { session: DashboardModel['session'] }) {
|
|
229
|
-
const [w, setW] = useState(0)
|
|
230
|
-
const onLayout = (e: LayoutChangeEvent) => setW(e.nativeEvent.layout.width)
|
|
231
|
-
const wrap = w > 0 && w < HEADER_WRAP
|
|
232
|
-
const targetSize = wrap
|
|
233
|
-
? Math.round(HEADER_NAME_SIZE * SET_HEADING_RATIO) // set-heading ratio on the second line
|
|
234
|
-
: Math.round(clampLerp(w || HEADER_WIDE, HEADER_WRAP, HEADER_WIDE, 22, 28))
|
|
235
|
-
|
|
236
|
-
return (
|
|
237
|
-
<View
|
|
238
|
-
onLayout={onLayout}
|
|
239
|
-
className="border-border"
|
|
240
|
-
style={{
|
|
241
|
-
flexDirection: wrap ? 'column' : 'row',
|
|
242
|
-
alignItems: wrap ? 'flex-start' : 'baseline',
|
|
243
|
-
justifyContent: 'space-between',
|
|
244
|
-
gap: wrap ? 4 : 22,
|
|
245
|
-
paddingHorizontal: 24,
|
|
246
|
-
paddingTop: 20,
|
|
247
|
-
paddingBottom: 16,
|
|
248
|
-
borderBottomWidth: 1,
|
|
249
|
-
}}
|
|
250
|
-
>
|
|
251
|
-
<Text
|
|
252
|
-
className="text-text-primary"
|
|
253
|
-
style={{
|
|
254
|
-
fontSize: HEADER_NAME_SIZE,
|
|
255
|
-
fontFamily: '"Space Grotesk", sans-serif',
|
|
256
|
-
fontWeight: '700',
|
|
257
|
-
}}
|
|
258
|
-
>
|
|
259
|
-
{session.exerciseName}
|
|
260
|
-
</Text>
|
|
261
|
-
{/* targets: pinned right when inline, tucked under the name (smaller) when wrapped. */}
|
|
262
|
-
<SetsRepsLoad
|
|
263
|
-
sets={session.plannedSets}
|
|
264
|
-
reps={8}
|
|
265
|
-
load={session.weightLbs}
|
|
266
|
-
unit={session.unit}
|
|
267
|
-
fontSize={targetSize}
|
|
268
|
-
/>
|
|
269
|
-
</View>
|
|
270
|
-
)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
// --- Live stage ---------------------------------------------------------------
|
|
274
|
-
|
|
275
|
-
/** Below this content width the alert drops its inline message to a hover tip. */
|
|
276
|
-
const ALERT_COMPACT = 620
|
|
277
|
-
/** Below this content width the alert collapses to just its contextual icon. */
|
|
278
|
-
const ALERT_ICON = 430
|
|
279
|
-
/** Tempo digit size at rest — matched to sit within {@link CONTROL_HEIGHT}. */
|
|
280
|
-
const TEMPO_BASE_FONT = 18
|
|
281
|
-
/** Content width at which the tempo has shrunk as far as it goes (near the panel min). */
|
|
282
|
-
const TEMPO_SHRINK_FLOOR = 300
|
|
283
|
-
|
|
284
|
-
/**
|
|
285
|
-
* Lab specimen — the LIVE (mid-set) stage of one voltra. The exercise identity + targets
|
|
286
|
-
* are the PAGE header ({@link ExerciseHeader}); this layer carries only per-voltra live
|
|
287
|
-
* data: an optional vertical slot label (far left), the alert + live tempo in a row, and
|
|
288
|
-
* the velocity hero. NOT a published component.
|
|
289
|
-
*
|
|
290
|
-
* `side` renders this as one LAYER of a dual-mode set; `slot` names the active voltra in a
|
|
291
|
-
* single-view multi-device session. Either shows the vertical slot label.
|
|
292
|
-
*/
|
|
293
|
-
export function LiveView({
|
|
294
|
-
model,
|
|
295
|
-
side,
|
|
296
|
-
slot,
|
|
297
|
-
}: {
|
|
298
|
-
model: DashboardModel
|
|
299
|
-
side?: 'left' | 'right'
|
|
300
|
-
slot?: VoltraSlot
|
|
301
|
-
}) {
|
|
302
|
-
const { live, session } = model
|
|
303
|
-
const verdict = verdictFromLoss(live.velocityLossPct)
|
|
304
|
-
const dual = side != null
|
|
305
|
-
const badgeSlot: VoltraSlot | null = side ? (side === 'left' ? 'L' : 'R') : (slot ?? null)
|
|
306
|
-
|
|
307
|
-
const [contentW, setContentW] = useState(0)
|
|
308
|
-
const onContentLayout = (e: LayoutChangeEvent) => setContentW(e.nativeEvent.layout.width)
|
|
309
|
-
const [rowW, setRowW] = useState(0)
|
|
310
|
-
const onRowLayout = (e: LayoutChangeEvent) => setRowW(e.nativeEvent.layout.width)
|
|
311
|
-
const [tempoW, setTempoW] = useState(0)
|
|
312
|
-
const onTempoLayout = (e: LayoutChangeEvent) => setTempoW(e.nativeEvent.layout.width)
|
|
313
|
-
// The alert sheds detail first (message → verdict → icon); the tempo holds its full size
|
|
314
|
-
// until the alert can't shrink any further, then it takes over shrinking.
|
|
315
|
-
const alertMode: AlertMode =
|
|
316
|
-
contentW === 0 || contentW >= ALERT_COMPACT
|
|
317
|
-
? 'full'
|
|
318
|
-
: contentW >= ALERT_ICON
|
|
319
|
-
? 'compact'
|
|
320
|
-
: 'icon'
|
|
321
|
-
const tempoFont =
|
|
322
|
-
contentW === 0 || contentW >= ALERT_ICON
|
|
323
|
-
? TEMPO_BASE_FONT
|
|
324
|
-
: Math.round(clampLerp(contentW, TEMPO_SHRINK_FLOOR, ALERT_ICON, 14, TEMPO_BASE_FONT))
|
|
325
|
-
// Tempo is optional: a set may have no prescribed tempo — then the card is hidden entirely
|
|
326
|
-
// and the alert takes the whole row.
|
|
327
|
-
const hasTempo = session.tempo != null
|
|
328
|
-
// Width the alert may take — measured off the ROW (inside the panel padding) so a long
|
|
329
|
-
// message ellipsises at the side margin rather than running to the panel edge. With no tempo
|
|
330
|
-
// card the alert gets the full row; otherwise it's the row minus the (measured) tempo + gap.
|
|
331
|
-
const CONTROLS_GAP = 16
|
|
332
|
-
const alertAvail =
|
|
333
|
-
rowW > 0
|
|
334
|
-
? hasTempo
|
|
335
|
-
? tempoW > 0
|
|
336
|
-
? Math.max(0, rowW - tempoW - CONTROLS_GAP)
|
|
337
|
-
: undefined
|
|
338
|
-
: rowW
|
|
339
|
-
: undefined
|
|
340
|
-
|
|
341
|
-
const [heroH, setHeroH] = useState(0)
|
|
342
|
-
const onHeroLayout = (e: LayoutChangeEvent) => setHeroH(e.nativeEvent.layout.height)
|
|
343
|
-
const heroHeight = heroH > 0 ? heroH : dual ? 200 : 320
|
|
344
|
-
|
|
345
|
-
const activePhase = mapLivePhase(live.phase)
|
|
346
|
-
const message = `VL${live.velocityLossPct} · approaching threshold — 1–2 productive reps left`
|
|
347
|
-
|
|
348
|
-
return (
|
|
349
|
-
// head verdict → full-surface aura flood; fills its section edge-to-edge — squared off
|
|
350
|
-
// (no radius/border), since it's the section background, not a card within it.
|
|
351
|
-
<LiveAuraFrame category={verdict} style={{ flex: 1, borderRadius: 0, borderWidth: 0 }}>
|
|
352
|
-
<View className="flex-row" style={{ flex: 1 }}>
|
|
353
|
-
{badgeSlot && <VerticalSlotLabel slot={badgeSlot} />}
|
|
354
|
-
<View
|
|
355
|
-
onLayout={onContentLayout}
|
|
356
|
-
style={{ flex: 1, padding: dual ? 18 : 24, gap: dual ? 8 : 10 }}
|
|
357
|
-
>
|
|
358
|
-
{/* controls row: tempo upper-left (when prescribed), alert upper-right. With no tempo
|
|
359
|
-
the alert simply pins right (flex-end); otherwise they split (space-between). */}
|
|
360
|
-
<View
|
|
361
|
-
onLayout={onRowLayout}
|
|
362
|
-
className="flex-row items-center"
|
|
363
|
-
style={{ gap: CONTROLS_GAP, justifyContent: hasTempo ? 'space-between' : 'flex-end' }}
|
|
364
|
-
>
|
|
365
|
-
{/* tempo card — locked to the alert's height (this view only); the inner TempoDisplay
|
|
366
|
-
shrinks its font but stays centred on the shared charcoal ground so it reads seamless. */}
|
|
367
|
-
{session.tempo != null && (
|
|
368
|
-
<View
|
|
369
|
-
onLayout={onTempoLayout}
|
|
370
|
-
style={{
|
|
371
|
-
height: CONTROL_HEIGHT,
|
|
372
|
-
justifyContent: 'center',
|
|
373
|
-
alignItems: 'flex-start',
|
|
374
|
-
backgroundColor: TEMPO_GROUND,
|
|
375
|
-
borderRadius: 9,
|
|
376
|
-
overflow: 'hidden',
|
|
377
|
-
...CARD_SHADOW,
|
|
378
|
-
}}
|
|
379
|
-
>
|
|
380
|
-
<TempoDisplay
|
|
381
|
-
tempo={session.tempo}
|
|
382
|
-
fontSize={tempoFont}
|
|
383
|
-
live={
|
|
384
|
-
activePhase ? { activePhase, phaseElapsedMs: live.phaseElapsedMs } : undefined
|
|
385
|
-
}
|
|
386
|
-
showLabel={false}
|
|
387
|
-
showInfo={false}
|
|
388
|
-
/>
|
|
389
|
-
</View>
|
|
390
|
-
)}
|
|
391
|
-
<AlertCue status={verdict} message={message} mode={alertMode} availWidth={alertAvail} />
|
|
392
|
-
</View>
|
|
393
|
-
|
|
394
|
-
{/* the velocity hero fills the rest. */}
|
|
395
|
-
<View style={{ flex: 1 }} onLayout={onHeroLayout}>
|
|
396
|
-
<VelocityStrip
|
|
397
|
-
variant="hero"
|
|
398
|
-
velocities={live.repVelocities}
|
|
399
|
-
liveRepIndex={live.repVelocities.length - 1}
|
|
400
|
-
targetReps={8}
|
|
401
|
-
height={heroHeight}
|
|
402
|
-
scale="peak"
|
|
403
|
-
/>
|
|
404
|
-
</View>
|
|
405
|
-
</View>
|
|
406
|
-
</View>
|
|
407
|
-
</LiveAuraFrame>
|
|
408
|
-
)
|
|
409
|
-
}
|
|
@@ -1,129 +0,0 @@
|
|
|
1
|
-
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
-
import { View } from 'react-native'
|
|
3
|
-
import { DashboardShell, type Device } from '../../components'
|
|
4
|
-
import { LivePage, type LivePageVariant } from './LivePage'
|
|
5
|
-
import { dashboardFixture } from './fixtures'
|
|
6
|
-
|
|
7
|
-
/** The fixture with its prescribed tempo stripped — a set with no tempo (hidden readout). */
|
|
8
|
-
const noTempoModel = {
|
|
9
|
-
...dashboardFixture,
|
|
10
|
-
session: { ...dashboardFixture.session, tempo: undefined },
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* `Lab/North Star/Live Wall Dashboard` — the north-star wall-dashboard specimen.
|
|
15
|
-
*
|
|
16
|
-
* A LAB specimen: it COMPOSES existing production components ({@link DashboardShell}
|
|
17
|
-
* around a lab-scoped `LivePage`) into the target surface. It is NOT a published
|
|
18
|
-
* library component — nothing here is added to a package barrel.
|
|
19
|
-
*
|
|
20
|
-
* The live-stage slots render their REAL treatments: the VelocityStrip hero, the live
|
|
21
|
-
* TempoDisplay (running tempo, folded into the head), the consolidated status cue, and
|
|
22
|
-
* the RestTimer ring. `live-dual` stacks two live layers, one per voltra.
|
|
23
|
-
*/
|
|
24
|
-
const DEVICES: Device[] = [
|
|
25
|
-
{ id: 'Voltra-A3F2', nickname: 'Left Cable', slot: 'L', state: 'connected' },
|
|
26
|
-
{ id: 'Voltra-9B1C', nickname: 'Right Cable', slot: 'R', state: 'connected' },
|
|
27
|
-
]
|
|
28
|
-
|
|
29
|
-
interface WallArgs {
|
|
30
|
-
variant: LivePageVariant
|
|
31
|
-
/** Whether the current set has a prescribed tempo; off hides the tempo readout. */
|
|
32
|
-
tempo: boolean
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
const meta: Meta<WallArgs> = {
|
|
36
|
-
title: 'Lab/North Star/Live Wall Dashboard',
|
|
37
|
-
args: { variant: 'live', tempo: true },
|
|
38
|
-
argTypes: {
|
|
39
|
-
variant: { control: 'inline-radio', options: ['live', 'live-dual', 'rest'] },
|
|
40
|
-
tempo: { control: 'boolean' },
|
|
41
|
-
},
|
|
42
|
-
render: ({ variant, tempo }) => (
|
|
43
|
-
<DashboardShell
|
|
44
|
-
activeKey="live"
|
|
45
|
-
state={variant === 'rest' ? 'rest' : 'live'}
|
|
46
|
-
liveKey={variant === 'rest' ? 'live' : null}
|
|
47
|
-
devices={DEVICES}
|
|
48
|
-
subtitle="wall dashboard"
|
|
49
|
-
>
|
|
50
|
-
<LivePage variant={variant} model={tempo ? dashboardFixture : noTempoModel} />
|
|
51
|
-
</DashboardShell>
|
|
52
|
-
),
|
|
53
|
-
decorators: [
|
|
54
|
-
(Story) => (
|
|
55
|
-
<View style={{ height: '100vh' as unknown as number, backgroundColor: '#0E0E0E' }}>
|
|
56
|
-
<Story />
|
|
57
|
-
</View>
|
|
58
|
-
),
|
|
59
|
-
],
|
|
60
|
-
parameters: {
|
|
61
|
-
layout: 'fullscreen',
|
|
62
|
-
docs: {
|
|
63
|
-
description: {
|
|
64
|
-
component:
|
|
65
|
-
'**North Star wall dashboard** (lab specimen). Composes ' +
|
|
66
|
-
'[DashboardShell](?path=/docs/pages-dashboardshell--docs) around a lab `LivePage` ' +
|
|
67
|
-
'(SessionRail + a Live/Rest stage). Toggle **variant** to switch between the ' +
|
|
68
|
-
'mid-set live read-out and the between-sets rest read-out. Tier-C slots render ' +
|
|
69
|
-
'their base component as a labelled stub.',
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
},
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export default meta
|
|
76
|
-
type Story = StoryObj<WallArgs>
|
|
77
|
-
|
|
78
|
-
/** Mid-set: velocity hero, live metrics, verdict pill + amber threshold aura. */
|
|
79
|
-
export const Live: Story = {
|
|
80
|
-
args: { variant: 'live' },
|
|
81
|
-
parameters: {
|
|
82
|
-
docs: {
|
|
83
|
-
description: { story: 'The live (mid-set) stage — ~22% velocity loss, threshold verdict.' },
|
|
84
|
-
},
|
|
85
|
-
},
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
/** Mid-set with NO prescribed tempo — the tempo readout is hidden; the alert takes the row. */
|
|
89
|
-
export const LiveNoTempo: Story = {
|
|
90
|
-
args: { variant: 'live', tempo: false },
|
|
91
|
-
parameters: {
|
|
92
|
-
docs: {
|
|
93
|
-
description: {
|
|
94
|
-
story:
|
|
95
|
-
'The live stage for a set with no prescribed tempo (coach left it unset and no ' +
|
|
96
|
-
'exercise default). The tempo card is hidden entirely — no invented placeholder — ' +
|
|
97
|
-
'and the alert cue simply pins to the right of the row.',
|
|
98
|
-
},
|
|
99
|
-
},
|
|
100
|
-
},
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** Dual-mode (bilateral): two stacked live layers, one per voltra — left dominant, right lags. */
|
|
104
|
-
export const LiveDual: Story = {
|
|
105
|
-
args: { variant: 'live-dual' },
|
|
106
|
-
parameters: {
|
|
107
|
-
docs: {
|
|
108
|
-
description: {
|
|
109
|
-
story:
|
|
110
|
-
'A dual-mode (bilateral) exercise — the stage stacks two live layers, one per ' +
|
|
111
|
-
'voltra. The RIGHT voltra shows a realistic left-dominant deficit (slower reps, ' +
|
|
112
|
-
'more velocity loss). v1 renders the two sides independently; a unified split-bar ' +
|
|
113
|
-
'treatment is a later exploration.',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
},
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
/** Between sets: rest countdown, completed-set recap, verdict metrics + next-set mock. */
|
|
120
|
-
export const Rest: Story = {
|
|
121
|
-
args: { variant: 'rest' },
|
|
122
|
-
parameters: {
|
|
123
|
-
docs: {
|
|
124
|
-
description: {
|
|
125
|
-
story: 'The rest stage — countdown, set recap, verdict, and a mock next-set preview.',
|
|
126
|
-
},
|
|
127
|
-
},
|
|
128
|
-
},
|
|
129
|
-
}
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
-
import { View, Text } from 'react-native'
|
|
3
|
-
import { RestTimer, ExerciseCard, Metric, MetricGroup, type SetRowProps } from '../../components'
|
|
4
|
-
import { type DashboardModel, meanVelocity, verdictFromLoss } from './fixtures'
|
|
5
|
-
|
|
6
|
-
/** Project the fixture's completed + current sets onto the recap card's set rows. */
|
|
7
|
-
function deriveRecapSets(model: DashboardModel): SetRowProps[] {
|
|
8
|
-
const { session, live } = model
|
|
9
|
-
const done: SetRowProps[] = session.completedSets.map((set, i) => ({
|
|
10
|
-
state: 'done',
|
|
11
|
-
setNumber: i + 1,
|
|
12
|
-
unit: session.unit,
|
|
13
|
-
reps: set.repCount,
|
|
14
|
-
weight: set.weightLbs,
|
|
15
|
-
rpe: 8 + i * 0.5,
|
|
16
|
-
velocities: set.reps,
|
|
17
|
-
}))
|
|
18
|
-
const current: SetRowProps = {
|
|
19
|
-
state: 'done',
|
|
20
|
-
setNumber: session.completedSets.length + 1,
|
|
21
|
-
unit: session.unit,
|
|
22
|
-
reps: live.repVelocities.length,
|
|
23
|
-
weight: session.weightLbs,
|
|
24
|
-
rpe: 9,
|
|
25
|
-
velocities: live.repVelocities,
|
|
26
|
-
}
|
|
27
|
-
return [...done, current]
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/**
|
|
31
|
-
* Lab specimen — the REST stage of the North Star wall dashboard.
|
|
32
|
-
*
|
|
33
|
-
* A rest read-out: the countdown, a recap of the set just finished, the set's verdict
|
|
34
|
-
* metrics, and a preview of what's next. NOT a published component — lab-scoped only.
|
|
35
|
-
*/
|
|
36
|
-
export function RestView({ model }: { model: DashboardModel }) {
|
|
37
|
-
const { live, session } = model
|
|
38
|
-
const verdict = verdictFromLoss(live.velocityLossPct)
|
|
39
|
-
const meanCon = meanVelocity(live.repVelocities)
|
|
40
|
-
const peakCon = Math.max(...live.repVelocities)
|
|
41
|
-
|
|
42
|
-
return (
|
|
43
|
-
<View className="flex-row" style={{ flex: 1, padding: 20, gap: 20 }}>
|
|
44
|
-
{/* left: the countdown + the just-completed exercise recap. */}
|
|
45
|
-
<View style={{ flex: 2, gap: 20 }}>
|
|
46
|
-
{/* Rest countdown — ring variant (across-the-room wall treatment). */}
|
|
47
|
-
<RestTimer
|
|
48
|
-
variant="ring"
|
|
49
|
-
size={220}
|
|
50
|
-
totalSeconds={120}
|
|
51
|
-
elapsedMs={47_000}
|
|
52
|
-
visible
|
|
53
|
-
nextSetInfo={`Next · ${session.exerciseName} · set ${session.completedSets.length + 2} of ${session.plannedSets}`}
|
|
54
|
-
onSkip={() => {}}
|
|
55
|
-
onAddTime={() => {}}
|
|
56
|
-
/>
|
|
57
|
-
|
|
58
|
-
<View style={{ gap: 8 }}>
|
|
59
|
-
<Text
|
|
60
|
-
className="text-text-tertiary"
|
|
61
|
-
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1 }}
|
|
62
|
-
>
|
|
63
|
-
SET JUST COMPLETED
|
|
64
|
-
</Text>
|
|
65
|
-
<ExerciseCard
|
|
66
|
-
name={session.exerciseName}
|
|
67
|
-
expanded
|
|
68
|
-
summary={{
|
|
69
|
-
sets: session.plannedSets,
|
|
70
|
-
reps: 8,
|
|
71
|
-
weight: session.weightLbs,
|
|
72
|
-
unit: session.unit,
|
|
73
|
-
}}
|
|
74
|
-
tempo={session.tempo}
|
|
75
|
-
indicator="velocity-loss"
|
|
76
|
-
sets={deriveRecapSets(model)}
|
|
77
|
-
/>
|
|
78
|
-
</View>
|
|
79
|
-
</View>
|
|
80
|
-
|
|
81
|
-
{/* right: the set verdict + the next-set preview (mock). */}
|
|
82
|
-
<View style={{ flex: 1, gap: 24 }}>
|
|
83
|
-
<View style={{ gap: 6 }}>
|
|
84
|
-
<Text
|
|
85
|
-
className="text-text-tertiary"
|
|
86
|
-
style={{ fontSize: 11, fontWeight: '700', letterSpacing: 1 }}
|
|
87
|
-
>
|
|
88
|
-
SET VERDICT
|
|
89
|
-
</Text>
|
|
90
|
-
{/* MetricGroup ×4 — the read-once summary of the finished set. */}
|
|
91
|
-
<MetricGroup>
|
|
92
|
-
<Metric size="md" value={meanCon.toFixed(2)} unit="m/s" label="Mean con" />
|
|
93
|
-
<Metric size="md" value={peakCon.toFixed(2)} unit="m/s" label="Peak con" />
|
|
94
|
-
</MetricGroup>
|
|
95
|
-
<MetricGroup>
|
|
96
|
-
<Metric size="md" value={`${live.velocityLossPct}%`} label="Vel loss" trend="down" />
|
|
97
|
-
<Metric size="md" value={String(live.repVelocities.length)} label="Reps" />
|
|
98
|
-
</MetricGroup>
|
|
99
|
-
<MetricGroup>
|
|
100
|
-
<Metric size="md" value={String(session.weightLbs)} unit="lbs" label="Load" />
|
|
101
|
-
<Metric size="md" value={String(live.peakForce)} unit="N" label="Peak force" />
|
|
102
|
-
</MetricGroup>
|
|
103
|
-
<MetricGroup>
|
|
104
|
-
<Metric
|
|
105
|
-
size="md"
|
|
106
|
-
value={verdict === 'threshold' ? 'MOD' : verdict === 'stop' ? 'HIGH' : 'LOW'}
|
|
107
|
-
label="Fatigue"
|
|
108
|
-
trend={verdict === 'productive' ? 'up' : 'neutral'}
|
|
109
|
-
/>
|
|
110
|
-
<Metric
|
|
111
|
-
size="md"
|
|
112
|
-
value={String(live.lastRep.rom.toFixed(2))}
|
|
113
|
-
unit="m"
|
|
114
|
-
label="Avg ROM"
|
|
115
|
-
/>
|
|
116
|
-
</MetricGroup>
|
|
117
|
-
</View>
|
|
118
|
-
|
|
119
|
-
{/* mock "next set" block — clearly labelled NO-DATA / mock, not a wired read-model. */}
|
|
120
|
-
<View
|
|
121
|
-
className="border-border"
|
|
122
|
-
style={{ borderWidth: 1, borderStyle: 'dashed', borderRadius: 10, padding: 16, gap: 4 }}
|
|
123
|
-
>
|
|
124
|
-
<Text
|
|
125
|
-
className="text-text-tertiary"
|
|
126
|
-
style={{ fontSize: 10, fontWeight: '700', letterSpacing: 1 }}
|
|
127
|
-
>
|
|
128
|
-
NEXT SET · MOCK (NO DATA)
|
|
129
|
-
</Text>
|
|
130
|
-
<Text className="text-text-primary" style={{ fontSize: 18, fontWeight: '700' }}>
|
|
131
|
-
{session.exerciseName}
|
|
132
|
-
</Text>
|
|
133
|
-
<Text className="text-text-secondary" style={{ fontSize: 13 }}>
|
|
134
|
-
{`Set ${session.completedSets.length + 2} · target 8 × ${session.weightLbs} ${session.unit}`}
|
|
135
|
-
</Text>
|
|
136
|
-
</View>
|
|
137
|
-
</View>
|
|
138
|
-
</View>
|
|
139
|
-
)
|
|
140
|
-
}
|