@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.
@@ -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
- }