@titan-design/react-ui 0.9.0 → 0.9.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -147,6 +147,7 @@
147
147
  "files": [
148
148
  "dist",
149
149
  "src",
150
+ "!src/lab",
150
151
  "docs",
151
152
  "tailwind.config.js"
152
153
  ],
@@ -0,0 +1,51 @@
1
+ /* eslint-disable titan/no-device-internals --
2
+ * This file is the rule's own test bench, so it necessarily contains the shapes
3
+ * the rule rejects. Every value below is invented for the test — none is a real
4
+ * device value.
5
+ */
6
+ import { RuleTester } from 'eslint'
7
+ import rule from '../../eslint-rules/no-device-internals'
8
+
9
+ const ruleTester = new RuleTester({
10
+ languageOptions: { ecmaVersion: 2022, sourceType: 'module' },
11
+ })
12
+
13
+ describe('no-device-internals', () => {
14
+ it('flags device internals without flagging design-system content', () => {
15
+ ruleTester.run('no-device-internals', rule as never, {
16
+ valid: [
17
+ // Hex colours are the dominant hex form in this package.
18
+ { code: "const bg = '#1C1C1C'" },
19
+ { code: "const bg = '#fff'" },
20
+ { code: 'const bg = `${base} #1C1C1CFF`' },
21
+ // Dimension and ratio strings: the `\b` guard before `0x` means a digit
22
+ // followed by `x` never reads as a hex literal. These exact strings
23
+ // tripped an earlier, looser sweep.
24
+ { code: "// icons render at 20x20" },
25
+ { code: "// 200x400 intrinsic -> ~160x320 px" },
26
+ // SVG path coordinates — digit-only runs are not byte frames.
27
+ { code: 'const d = "M12 2 22 12 12 22 2 12Z"' },
28
+ { code: 'const d = "M7 7 17 17 12 22 12 2 17 7 7 17"' },
29
+ // Timestamps — `-` and `:` are not byte separators.
30
+ { code: "// datetime // 2024-01-15 14:30" },
31
+ // "cascade" in its CSS sense is ordinary styling vocabulary.
32
+ { code: "// RN doesn't cascade text color, so hand-color the text" },
33
+ // Plain numbers and durations.
34
+ { code: 'const duration = 300' },
35
+ ],
36
+ invalid: [
37
+ // Bare hex identifier, in code and in a comment.
38
+ { code: 'const mode = 0xab', errors: [{ messageId: 'hex' }] },
39
+ { code: '// resistance mode (cmd=0xab)', errors: [{ messageId: 'hex' }] },
40
+ { code: "const s = 'send cmd=0xab now'", errors: [{ messageId: 'hex' }] },
41
+ // Raw byte frame: 4+ space-separated pairs including hex letters.
42
+ { code: '// frame: ab cd ef 01', errors: [{ messageId: 'frame' }] },
43
+ // Transport UUID.
44
+ {
45
+ code: "const id = '0000fe59-1234-5678-9abc-def012345678'",
46
+ errors: [{ messageId: 'uuid' }],
47
+ },
48
+ ],
49
+ })
50
+ })
51
+ })
@@ -1,101 +0,0 @@
1
- // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { ScrollView, View } from 'react-native'
3
- import { SessionRail } from '../../components'
4
- import { ExerciseHeader, LiveView } from './LiveView'
5
- import { RestView } from './RestView'
6
- import {
7
- dashboardFixture,
8
- deriveDualModel,
9
- deriveRailExercises,
10
- type DashboardModel,
11
- } from './fixtures'
12
-
13
- export type LivePageVariant = 'live' | 'live-dual' | 'rest'
14
-
15
- /** Floor the live panel around phone width so it stops collapsing on a narrow window. */
16
- const PANEL_MIN_WIDTH = 390
17
-
18
- export interface LivePageProps {
19
- /** Which stage to show in the main region. */
20
- variant?: LivePageVariant
21
- /** The dashboard store snapshot. Defaults to the built-in fixture. */
22
- model?: DashboardModel
23
- }
24
-
25
- /**
26
- * Lab specimen — the North Star wall-dashboard CONTENT (mounts inside `DashboardShell`'s
27
- * children slot): the persistent {@link SessionRail} context beside a swappable stage
28
- * ({@link LiveView} mid-set / {@link RestView} between sets).
29
- *
30
- * `live-dual` renders a dual-mode (bilateral) exercise as TWO stacked {@link LiveView}
31
- * layers — one per voltra (left/right) — the v1 treatment for two-device sets. A
32
- * unified split-bar view is a later exploration.
33
- *
34
- * NOT a published component — lab-scoped composition of production primitives only.
35
- * The rail footer pace read-out is intentionally OMITTED (NO-DATA — no store field yet).
36
- */
37
- export function LivePage({ variant = 'live', model = dashboardFixture }: LivePageProps) {
38
- const exercises = deriveRailExercises(model)
39
- const completedSets = model.session.completedSets.length
40
- const isLive = variant === 'live' || variant === 'live-dual'
41
-
42
- return (
43
- <View className="flex-1 flex-row bg-surface-base">
44
- <SessionRail
45
- title={model.session.title}
46
- exercises={exercises}
47
- setsDone={isLive ? completedSets + 0.75 : completedSets}
48
- elapsedMs={18 * 60_000}
49
- budgetMs={45 * 60_000}
50
- running={isLive}
51
- width={272}
52
- metrics={[
53
- { label: 'Volume', value: '76%' },
54
- { label: 'Load', value: '7.3k' },
55
- { label: 'Fatigue', value: 'MOD' },
56
- ]}
57
- />
58
- {/* footer pace strip — STUB / NO-DATA: intentionally omitted (no store field yet). */}
59
-
60
- {/* Panel floors at ~phone width so the live view stops collapsing; rail-aware
61
- breakpoints below this are a later pass. */}
62
- <View className="flex-1" style={{ minWidth: PANEL_MIN_WIDTH }}>
63
- {/* workout title + targets — page-level, always visible, independent of single/dual. */}
64
- <ExerciseHeader session={model.session} />
65
- <View className="flex-1">
66
- {variant === 'live-dual' ? (
67
- <DualLiveStage model={model} />
68
- ) : variant === 'live' ? (
69
- // `slot` names the active voltra — the shell has two connected, so the live view
70
- // flags which one it is reading from (the multi-device single-view case).
71
- <LiveView model={model} slot="L" />
72
- ) : (
73
- <RestView model={model} />
74
- )}
75
- </View>
76
- </View>
77
- </View>
78
- )
79
- }
80
-
81
- /**
82
- * Dual-mode stage: two stacked live layers, one per voltra (left dominant, right lags).
83
- *
84
- * The stage SCROLLS so two full live read-outs never clip on a height-restricted wall.
85
- * Each layer also compresses its height-drivers (shorter hero, tighter gaps — see
86
- * {@link LiveView}'s `side`/`dual` handling) so a tall wall fits both with little or no
87
- * scroll; a trimmed-per-layer treatment is a later option if scrolling proves awkward.
88
- */
89
- function DualLiveStage({ model }: { model: DashboardModel }) {
90
- const { left, right } = deriveDualModel(model)
91
- return (
92
- <ScrollView style={{ flex: 1 }} contentContainerStyle={{ flexGrow: 1 }}>
93
- <View style={{ flex: 1, minHeight: 340 }}>
94
- <LiveView model={left} side="left" />
95
- </View>
96
- <View style={{ flex: 1, minHeight: 340 }}>
97
- <LiveView model={right} side="right" />
98
- </View>
99
- </ScrollView>
100
- )
101
- }
@@ -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
- }
@@ -1,224 +0,0 @@
1
- /**
2
- * Fixtures for the `Lab/North Star/Live Wall Dashboard` specimen.
3
- *
4
- * Shapes deliberately MIRROR the voltras-mcp dashboard store read-models so a later
5
- * provisional wiring is a mechanical field map, not a reshape:
6
- * - {@link LiveModel} — the live-set telemetry read-model (`velocity`, `force`,
7
- * `phase`, `lastRep`, `velocityLossPct`).
8
- * - {@link SessionModel} — the session read-model (`exerciseName` + completed sets).
9
- * - {@link CompletedSet} — a logged set (`weightLbs`, `mode`, `repCount`, `reps`).
10
- *
11
- * The presentational titan props each view consumes are DERIVED from these read-models
12
- * (see the `derive*` helpers) — the same projection the store→component adapter will do.
13
- * Numbers are realistic cable chest-press strength-training values.
14
- */
15
- import type { SessionRailExercise } from '../../components'
16
-
17
- // --- Store read-model shapes (mirror voltras-mcp dashboard store) -------------
18
-
19
- /** One completed rep's per-rep telemetry (mean-concentric metric family). */
20
- export interface RepModel {
21
- /** Mean CONCENTRIC velocity (m/s) — the canonical metric (brain WA-D02). */
22
- vCon: number
23
- /** Range of motion (m). */
24
- rom: number
25
- /** Peak instantaneous velocity (m/s). */
26
- peakVelocity: number
27
- }
28
-
29
- /** The live-set telemetry read-model. */
30
- export interface LiveModel {
31
- /** Instantaneous cable velocity (m/s). */
32
- velocity: number
33
- /** Instantaneous cable force (N). */
34
- force: number
35
- /** Current movement phase. */
36
- phase: 'concentric' | 'hold' | 'eccentric' | 'idle'
37
- /** Elapsed time (ms) within the current phase — drives the live tempo fill. */
38
- phaseElapsedMs: number
39
- /** The most-recently completed rep. */
40
- lastRep: RepModel
41
- /** Per-rep mean-concentric velocities logged so far this set (m/s). */
42
- repVelocities: number[]
43
- /** Velocity loss vs the set's best rep (%). Drives verdict + aura + fatigue. */
44
- velocityLossPct: number
45
- /** Peak concentric force this set (N). */
46
- peakForce: number
47
- }
48
-
49
- /** A logged set on the session read-model. */
50
- export interface CompletedSet {
51
- weightLbs: number
52
- /** Resistance mode (cascade echo, cmd=0x10). */
53
- mode: 'weight' | 'chains' | 'eccentric' | 'isokinetic'
54
- repCount: number
55
- /** Per-rep mean-concentric velocities (m/s). */
56
- reps: number[]
57
- }
58
-
59
- /** The session read-model. */
60
- export interface SessionModel {
61
- exerciseName: string
62
- title: string
63
- weightLbs: number
64
- unit: 'lbs' | 'kg'
65
- /**
66
- * Prescribed tempo tuple [ecc, pauseBottom, con, pauseTop]. OPTIONAL — a set may carry no
67
- * prescribed tempo (coach left it unset and no exercise default); the live view then hides
68
- * the tempo readout entirely rather than inventing one.
69
- */
70
- tempo?: [number, number, number, number]
71
- completedSets: CompletedSet[]
72
- plannedSets: number
73
- }
74
-
75
- /** The full store snapshot the specimen reads. */
76
- export interface DashboardModel {
77
- live: LiveModel
78
- session: SessionModel
79
- }
80
-
81
- // --- The fixture instance -----------------------------------------------------
82
-
83
- /**
84
- * A mid-set cable chest press: 6 reps in, velocity decaying from 0.52 → 0.405 m/s,
85
- * ~22% velocity loss (the VL20–VL30 mid-zone → `threshold` verdict / amber aura).
86
- */
87
- export const dashboardFixture: DashboardModel = {
88
- live: {
89
- velocity: 0.41,
90
- force: 498,
91
- phase: 'concentric',
92
- phaseElapsedMs: 700,
93
- lastRep: { vCon: 0.405, rom: 0.58, peakVelocity: 0.61 },
94
- repVelocities: [0.52, 0.5, 0.48, 0.46, 0.44, 0.405],
95
- velocityLossPct: 22,
96
- peakForce: 542,
97
- },
98
- session: {
99
- exerciseName: 'Cable Chest Press',
100
- title: 'Push A · Hypertrophy',
101
- weightLbs: 140,
102
- unit: 'lbs',
103
- tempo: [3, 1, 1, 0],
104
- plannedSets: 4,
105
- completedSets: [
106
- {
107
- weightLbs: 140,
108
- mode: 'weight',
109
- repCount: 8,
110
- reps: [0.55, 0.54, 0.52, 0.51, 0.49, 0.47, 0.45, 0.43],
111
- },
112
- {
113
- weightLbs: 140,
114
- mode: 'weight',
115
- repCount: 8,
116
- reps: [0.53, 0.52, 0.5, 0.48, 0.46, 0.44, 0.42, 0.4],
117
- },
118
- ],
119
- },
120
- }
121
-
122
- // --- Derived projections (store read-model → titan presentational props) -------
123
-
124
- /** Arithmetic mean of a per-rep velocity array (m/s). */
125
- export function meanVelocity(reps: number[]): number {
126
- if (reps.length === 0) return 0
127
- return reps.reduce((a, v) => a + v, 0) / reps.length
128
- }
129
-
130
- /** Verdict status from velocity loss %, matching FatigueMeter's VL20/VL30 bands. */
131
- export function verdictFromLoss(lossPct: number): 'productive' | 'threshold' | 'stop' {
132
- if (lossPct >= 30) return 'stop'
133
- if (lossPct >= 20) return 'threshold'
134
- return 'productive'
135
- }
136
-
137
- /**
138
- * The session rail's exercise list, projected from the model: the current exercise
139
- * (active), the completed sets behind it, and an upcoming accessory.
140
- */
141
- export function deriveRailExercises(model: DashboardModel): SessionRailExercise[] {
142
- const { session, live } = model
143
- return [
144
- {
145
- id: 'chest-press',
146
- name: session.exerciseName,
147
- summary: {
148
- sets: session.plannedSets,
149
- reps: 8,
150
- weight: session.weightLbs,
151
- unit: session.unit,
152
- },
153
- tempo: session.tempo,
154
- indicator: 'velocity-loss',
155
- setStates: [
156
- { status: 'done', velocities: session.completedSets[0].reps },
157
- { status: 'done', velocities: session.completedSets[1].reps },
158
- { status: 'active', velocities: live.repVelocities, planned: 8 },
159
- { status: 'todo', planned: 8 },
160
- ],
161
- },
162
- {
163
- id: 'incline-db',
164
- name: 'Incline DB Press',
165
- summary: { sets: 3, reps: 10, weight: 55, unit: 'lbs' },
166
- tempo: [2, 0, 1, 0],
167
- setStates: [
168
- { status: 'todo', planned: 10 },
169
- { status: 'todo', planned: 10 },
170
- { status: 'todo', planned: 10 },
171
- ],
172
- upcoming: true,
173
- },
174
- {
175
- id: 'cable-fly',
176
- name: 'Cable Fly',
177
- summary: { sets: 3, reps: 12, weight: 30, unit: 'lbs' },
178
- setStates: [
179
- { status: 'todo', planned: 12 },
180
- { status: 'todo', planned: 12 },
181
- { status: 'todo', planned: 12 },
182
- ],
183
- upcoming: true,
184
- },
185
- ]
186
- }
187
-
188
- // --- Dual-mode (bilateral) projection -----------------------------------------
189
-
190
- /**
191
- * Split a single-cable model into a bilateral (dual-mode) pair — one model per voltra.
192
- *
193
- * A dual-mode exercise drives two Voltra devices as a left/right pair. For v1 the two
194
- * layers are rendered independently (one {@link LiveModel} each); a unified split-bar
195
- * treatment is deferred. The fixture gives the RIGHT side a realistic left-dominant
196
- * deficit (slightly slower reps, more velocity loss, a touch less force) so the two
197
- * layers read as genuinely different streams rather than a mirror.
198
- */
199
- export function deriveDualModel(base: DashboardModel = dashboardFixture): {
200
- left: DashboardModel
201
- right: DashboardModel
202
- } {
203
- // Left is the dominant side — the base fixture verbatim.
204
- const left: DashboardModel = base
205
- // Right lags: ~8% slower per-rep concentric velocity, +7 pts velocity loss, ~6% less force.
206
- const scale = (v: number) => Number((v * 0.92).toFixed(3))
207
- const right: DashboardModel = {
208
- session: base.session,
209
- live: {
210
- ...base.live,
211
- velocity: scale(base.live.velocity),
212
- force: Math.round(base.live.force * 0.94),
213
- peakForce: Math.round(base.live.peakForce * 0.94),
214
- velocityLossPct: base.live.velocityLossPct + 7,
215
- repVelocities: base.live.repVelocities.map(scale),
216
- lastRep: {
217
- vCon: scale(base.live.lastRep.vCon),
218
- rom: Number((base.live.lastRep.rom * 0.96).toFixed(2)),
219
- peakVelocity: scale(base.live.lastRep.peakVelocity),
220
- },
221
- },
222
- }
223
- return { left, right }
224
- }