@titan-design/react-ui 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/dist/index.d.mts +19 -59
  2. package/dist/index.d.ts +19 -59
  3. package/dist/index.js +248 -371
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +249 -368
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-DCFBqp79.d.mts → pages-D7hOD4Vj.d.mts} +1 -1
  8. package/dist/{pages-_fI3-x7Z.d.ts → pages-DyEx-lBf.d.ts} +1 -1
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +199 -161
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +199 -161
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
  17. package/src/components/custom/Workout/README.md +33 -13
  18. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  19. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  20. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  21. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  22. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  23. package/src/components/custom/Workout/VelocityStrip.test.tsx +3 -3
  24. package/src/components/custom/Workout/VelocityStrip.tsx +45 -11
  25. package/src/components/custom/Workout/ZoneTrack.tsx +15 -1
  26. package/src/components/custom/Workout/index.ts +1 -9
  27. package/src/lab/north-star/LivePage.tsx +101 -0
  28. package/src/lab/north-star/LiveView.tsx +409 -0
  29. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  30. package/src/lab/north-star/RestView.tsx +140 -0
  31. package/src/lab/north-star/fixtures.ts +224 -0
  32. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
  33. package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
  34. package/src/components/custom/Workout/TempoBar.tsx +0 -300
@@ -1,300 +0,0 @@
1
- // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
- import { useState } from 'react'
3
- import { View, Text, Pressable, type ViewProps } from 'react-native'
4
- import { getSemanticColors } from '../../../theme/tokens/semantic'
5
- import { alpha } from '../../../utils/colors'
6
-
7
- const t = getSemanticColors('dark')
8
-
9
- /**
10
- * Titan-local phase key. Consumers map their own movement-phase enum onto this
11
- * presentational key at the call site — TempoBar owns rendering only.
12
- */
13
- export type TempoPhaseKey = 'concentric' | 'hold' | 'eccentric'
14
-
15
- /** Pacing tuning shared with the fill/pacing helpers below. */
16
- export const TEMPO_PACING = {
17
- behindThresholdPct: 0.15,
18
- minPhaseDurationMs: 500,
19
- } as const
20
-
21
- export type TempoPacingState = 'none' | 'on-pace' | 'behind'
22
-
23
- /** Active-phase readout mode: elapsed/target `time`, or a `delta` countdown to the target. */
24
- export type TempoActiveDisplay = 'time' | 'delta'
25
-
26
- /** Signed seconds to one decimal — the delta countdown (`+` remaining, `-` over target). */
27
- export function formatTempoDelta(remainingMs: number): string {
28
- const s = remainingMs / 1000
29
- // Guard `-0.0`; show a leading minus only once past target.
30
- const rounded = Math.abs(s) < 0.05 ? 0 : s
31
- return rounded.toFixed(1)
32
- }
33
-
34
- /**
35
- * Classify how a phase's elapsed time tracks against its target duration.
36
- * `none` when there is no meaningful target; `behind` once elapsed exceeds the
37
- * target by more than the behind threshold; `on-pace` otherwise.
38
- */
39
- export function getTempoPacingState(elapsedMs: number, targetMs: number | null): TempoPacingState {
40
- if (!targetMs || targetMs < TEMPO_PACING.minPhaseDurationMs) return 'none'
41
- const ratio = elapsedMs / targetMs
42
- if (ratio > 1 + TEMPO_PACING.behindThresholdPct) return 'behind'
43
- return 'on-pace'
44
- }
45
-
46
- /** Fill percentage (0–100) for the active phase; full when no target is set. */
47
- export function getTempoFillPct(elapsedMs: number, targetMs: number | null): number {
48
- if (!targetMs) return 100
49
- return Math.min(100, (elapsedMs / targetMs) * 100)
50
- }
51
-
52
- interface PhaseConfig {
53
- /** Compact label + the stable testID suffix. */
54
- label: string
55
- /** Full phase word, spelled out at `wall` density where there's room to read it. */
56
- wallLabel: string
57
- color: string
58
- flex: number
59
- }
60
-
61
- const PHASE_ORDER: TempoPhaseKey[] = ['concentric', 'hold', 'eccentric']
62
-
63
- const PHASE_CONFIG: Record<TempoPhaseKey, PhaseConfig> = {
64
- concentric: { label: 'Con', wallLabel: 'Concentric', color: t['status-success'], flex: 2 },
65
- hold: { label: 'Hold', wallLabel: 'Hold', color: t['brand-primary'], flex: 1 },
66
- eccentric: { label: 'Ecc', wallLabel: 'Eccentric', color: t['status-warning'], flex: 3 },
67
- }
68
-
69
- /** Density. `default` — the compact card/rail treatment. `wall` — the across-the-room dashboard scale. */
70
- export type TempoBarSize = 'default' | 'wall'
71
-
72
- interface TempoBarSizing {
73
- labelMargin: number
74
- labelFont: number
75
- barGap: number
76
- barHeight: number
77
- radius: number
78
- segFont: number
79
- }
80
-
81
- /** Per-density magnitudes. `default` reproduces the original compact values exactly. */
82
- const TEMPO_SIZES: Record<TempoBarSize, TempoBarSizing> = {
83
- default: { labelMargin: 4, labelFont: 10, barGap: 2, barHeight: 20, radius: 4, segFont: 12 },
84
- wall: { labelMargin: 8, labelFont: 15, barGap: 4, barHeight: 40, radius: 6, segFont: 22 },
85
- }
86
-
87
- export interface TempoBarProps extends ViewProps {
88
- /** Phase currently in progress, or null when idle/at rest. */
89
- activePhase: TempoPhaseKey | null
90
- /** Elapsed time (ms) within the active phase. */
91
- phaseElapsedMs: number
92
- /** Completed phase durations (ms) for the current rep, keyed by phase. */
93
- completed?: Partial<Record<TempoPhaseKey, number>>
94
- /** Optional per-phase target durations (seconds) for pacing feedback. */
95
- target?: Partial<Record<TempoPhaseKey, number>>
96
- /** Density: `default` (compact) or `wall` (the across-the-room dashboard scale). */
97
- size?: TempoBarSize
98
- /**
99
- * Active-phase readout. `time` (default) — `elapsed / target`. `delta` — a countdown
100
- * of the remaining time to target (`1.5` → `0.0` → `-1.2` once over), colour-coded like
101
- * the bar. Tapping the active segment toggles between the two at runtime.
102
- */
103
- activeDisplay?: TempoActiveDisplay
104
- className?: string
105
- }
106
-
107
- /**
108
- * TempoBar — live rep phase progression indicator.
109
- *
110
- * Renders a segmented bar (Con → Hold → Ecc) where the active phase fills in
111
- * real time and completed phases show their duration with a ✓/✗ pacing mark.
112
- * Presentational only: the consumer derives which phase is active, its elapsed
113
- * time, and completed durations, then feeds them in.
114
- */
115
- export function TempoBar({
116
- activePhase,
117
- phaseElapsedMs,
118
- completed,
119
- target,
120
- size = 'default',
121
- activeDisplay = 'time',
122
- className,
123
- ...props
124
- }: TempoBarProps) {
125
- const s = TEMPO_SIZES[size]
126
- // Runtime toggle of the active readout, seeded from `activeDisplay`.
127
- const [display, setDisplay] = useState<TempoActiveDisplay>(activeDisplay)
128
- const toggleDisplay = () => setDisplay((d) => (d === 'time' ? 'delta' : 'time'))
129
- return (
130
- <View className={className} testID="tempo-bar" {...props}>
131
- {/* Phase labels */}
132
- <View style={{ flexDirection: 'row', marginBottom: s.labelMargin }}>
133
- {PHASE_ORDER.map((phase) => {
134
- const config = PHASE_CONFIG[phase]
135
- return (
136
- <View key={phase} style={{ flex: config.flex, alignItems: 'center' }}>
137
- <Text numberOfLines={1} style={{ fontSize: s.labelFont, color: t['text-disabled'] }}>
138
- {size === 'wall' ? config.wallLabel : config.label}
139
- </Text>
140
- </View>
141
- )
142
- })}
143
- </View>
144
-
145
- {/* Segmented bar */}
146
- <View style={{ flexDirection: 'row', gap: s.barGap, height: s.barHeight }}>
147
- {PHASE_ORDER.map((phase) => {
148
- const config = PHASE_CONFIG[phase]
149
- const isActive = activePhase === phase
150
- const completedMs = completed?.[phase]
151
- const targetSec = target?.[phase]
152
- const targetMs = targetSec != null ? targetSec * 1000 : null
153
-
154
- return (
155
- <View
156
- key={phase}
157
- style={{
158
- flex: config.flex,
159
- backgroundColor: alpha('#ffffff', 0.06),
160
- borderRadius: s.radius,
161
- overflow: 'hidden',
162
- }}
163
- >
164
- {isActive ? (
165
- <ActiveSegment
166
- config={config}
167
- phaseElapsedMs={phaseElapsedMs}
168
- targetMs={targetMs}
169
- radius={s.radius}
170
- font={s.segFont}
171
- display={display}
172
- onToggleDisplay={toggleDisplay}
173
- />
174
- ) : completedMs != null ? (
175
- <CompletedSegment
176
- config={config}
177
- completedMs={completedMs}
178
- targetMs={targetMs}
179
- font={s.segFont}
180
- />
181
- ) : null}
182
- </View>
183
- )
184
- })}
185
- </View>
186
- </View>
187
- )
188
- }
189
-
190
- function ActiveSegment({
191
- config,
192
- phaseElapsedMs,
193
- targetMs,
194
- radius,
195
- font,
196
- display,
197
- onToggleDisplay,
198
- }: {
199
- config: PhaseConfig
200
- phaseElapsedMs: number
201
- targetMs: number | null
202
- radius: number
203
- font: number
204
- display: TempoActiveDisplay
205
- onToggleDisplay: () => void
206
- }) {
207
- const pacing = getTempoPacingState(phaseElapsedMs, targetMs)
208
- const barColor = pacing === 'behind' ? t['status-error'] : config.color
209
- const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
210
-
211
- // `delta`: count the remaining time to target down to 0.0, then negative once over.
212
- // Falls back to elapsed when there's no target. `time`: the elapsed / target readout.
213
- const label =
214
- display === 'delta' && targetMs != null
215
- ? formatTempoDelta(targetMs - phaseElapsedMs)
216
- : targetMs != null
217
- ? `${formatDuration(phaseElapsedMs)} / ${formatDuration(targetMs)}`
218
- : formatDuration(phaseElapsedMs)
219
-
220
- return (
221
- <Pressable
222
- onPress={onToggleDisplay}
223
- style={{ height: '100%', position: 'relative' }}
224
- accessibilityRole="button"
225
- accessibilityLabel={`Tempo readout: ${display === 'delta' ? 'delta' : 'time'} — tap to switch`}
226
- testID={`tempo-segment-active-${config.label}`}
227
- >
228
- <View
229
- style={{
230
- position: 'absolute',
231
- left: 0,
232
- top: 0,
233
- bottom: 0,
234
- width: `${fillPct}%`,
235
- backgroundColor: alpha(barColor, 0.3),
236
- borderRadius: radius,
237
- }}
238
- />
239
- <View
240
- style={{
241
- height: '100%',
242
- flexDirection: 'row',
243
- alignItems: 'center',
244
- justifyContent: 'center',
245
- }}
246
- >
247
- <Text style={{ fontSize: font, fontWeight: '700', color: barColor }}>{label}</Text>
248
- </View>
249
- </Pressable>
250
- )
251
- }
252
-
253
- function CompletedSegment({
254
- config,
255
- completedMs,
256
- targetMs,
257
- font,
258
- }: {
259
- config: PhaseConfig
260
- completedMs: number
261
- targetMs: number | null
262
- font: number
263
- }) {
264
- const pacing = getTempoPacingState(completedMs, targetMs)
265
- const hitTarget = pacing !== 'behind'
266
- const indicator =
267
- targetMs && targetMs >= TEMPO_PACING.minPhaseDurationMs ? (hitTarget ? ' ✓' : ' ✗') : ''
268
- // A missed target reads by COLOUR first (red fill + red duration), the ✓/✗ second —
269
- // glanceable across a room; a hit keeps the phase's own colour.
270
- const cueColor = hitTarget ? config.color : t['status-error']
271
-
272
- return (
273
- <View
274
- style={{
275
- height: '100%',
276
- flexDirection: 'row',
277
- alignItems: 'center',
278
- justifyContent: 'center',
279
- backgroundColor: alpha(cueColor, 0.15),
280
- }}
281
- testID={`tempo-segment-completed-${config.label}`}
282
- >
283
- <Text
284
- style={{
285
- fontSize: font,
286
- fontWeight: '500',
287
- color: hitTarget ? alpha(config.color, 0.7) : cueColor,
288
- }}
289
- >
290
- {formatDuration(completedMs)}
291
- {indicator}
292
- </Text>
293
- </View>
294
- )
295
- }
296
-
297
- function formatDuration(ms: number): string {
298
- const s = ms / 1000
299
- return s < 10 ? s.toFixed(1) : Math.round(s).toString()
300
- }