@titan-design/react-ui 0.4.0 → 0.5.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 (67) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.mjs +1911 -6
  6. package/dist/bodymap.mjs.map +1 -1
  7. package/dist/index.d.mts +31 -738
  8. package/dist/index.d.ts +31 -738
  9. package/dist/index.js +894 -1692
  10. package/dist/index.js.map +1 -1
  11. package/dist/index.mjs +10287 -8203
  12. package/dist/index.mjs.map +1 -1
  13. package/dist/pages-D7Jlssvp.d.ts +791 -0
  14. package/dist/pages-MO0JCySL.d.mts +791 -0
  15. package/dist/pages.d.mts +4 -0
  16. package/dist/pages.d.ts +4 -0
  17. package/dist/pages.js +6646 -0
  18. package/dist/pages.js.map +1 -0
  19. package/dist/pages.mjs +6626 -0
  20. package/dist/pages.mjs.map +1 -0
  21. package/dist/theme/index.d.mts +12 -12
  22. package/dist/theme/index.d.ts +12 -12
  23. package/dist/theme/index.js +30 -30
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +1880 -2
  26. package/dist/theme/index.mjs.map +1 -1
  27. package/dist/theme/tokens-css.mjs +573 -1
  28. package/dist/theme/tokens-css.mjs.map +1 -1
  29. package/package.json +9 -7
  30. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  31. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  32. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  33. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  34. package/src/components/custom/Workout/ExerciseDetailPage.tsx +17 -4
  35. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  36. package/src/components/custom/Workout/IntensityBar.tsx +109 -58
  37. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  38. package/src/components/custom/Workout/PrBadge.tsx +3 -3
  39. package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
  40. package/src/components/custom/Workout/README.md +50 -0
  41. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  42. package/src/components/custom/Workout/RestTimer.tsx +48 -43
  43. package/src/components/custom/Workout/SetRow.tsx +10 -1
  44. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  45. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  46. package/src/components/custom/Workout/TempoDisplay.tsx +104 -1
  47. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  48. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  49. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  50. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  51. package/src/components/custom/Workout/WeightBadge.tsx +2 -2
  52. package/src/components/custom/Workout/icons.tsx +68 -0
  53. package/src/components/custom/Workout/index.ts +38 -47
  54. package/src/index.ts +3 -2
  55. package/src/pages.ts +61 -0
  56. package/src/test/stories-smoke.test.tsx +50 -0
  57. package/src/theme/barrel.ts +53 -0
  58. package/src/theme/index.ts +8 -43
  59. package/src/theme/native-theming-guard.test.ts +117 -0
  60. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  61. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  62. package/dist/chunk-2SISKTWM.mjs +0 -1027
  63. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  64. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  65. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  66. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  67. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { useEffect, useState } from 'react'
2
3
  import { View } from 'react-native'
3
- import { TempoDisplay } from './TempoDisplay'
4
+ import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
4
5
 
5
6
  // tempo = [eccentric, pauseBottom, concentric, pauseTop]
6
7
  const meta: Meta<typeof TempoDisplay> = {
@@ -8,10 +9,17 @@ const meta: Meta<typeof TempoDisplay> = {
8
9
  component: TempoDisplay,
9
10
  tags: ['autodocs'],
10
11
  argTypes: {
11
- tempo: { control: 'object', description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds' },
12
+ tempo: {
13
+ control: 'object',
14
+ description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
15
+ },
12
16
  size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
13
17
  colored: { control: 'boolean', description: 'Colored vs mono display' },
14
18
  showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
19
+ live: {
20
+ control: 'object',
21
+ description: 'Live phase-fill state (opt-in); omit for static prescription',
22
+ },
15
23
  },
16
24
  }
17
25
 
@@ -64,3 +72,62 @@ export const AllVariants: Story = {
64
72
  </View>
65
73
  ),
66
74
  }
75
+
76
+ // --- Live phase-fill (opt-in) ----------------------------------------------
77
+
78
+ // Deterministic mid-rep frames — the active phase digit fills against its target.
79
+ export const LiveEccentricInProgress: Story = {
80
+ args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
81
+ }
82
+
83
+ // Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
84
+ export const LiveBehindPace: Story = {
85
+ args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
86
+ }
87
+
88
+ const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
89
+ { phase: 'eccentric', ms: 3000 },
90
+ { phase: 'pauseBottom', ms: 1000 },
91
+ { phase: 'concentric', ms: 1000 },
92
+ ]
93
+
94
+ function LiveTempoDemo() {
95
+ const [live, setLive] = useState<TempoLiveState>({ activePhase: 'eccentric', phaseElapsedMs: 0 })
96
+ useEffect(() => {
97
+ let idx = 0
98
+ let start = Date.now()
99
+ const id = setInterval(() => {
100
+ const step = LIVE_CYCLE[idx]
101
+ const elapsed = Date.now() - start
102
+ if (elapsed >= step.ms + 400) {
103
+ idx = (idx + 1) % LIVE_CYCLE.length
104
+ start = Date.now()
105
+ }
106
+ setLive({
107
+ activePhase: LIVE_CYCLE[idx].phase,
108
+ phaseElapsedMs: Math.min(elapsed, step.ms + 400),
109
+ })
110
+ }, 50)
111
+ return () => clearInterval(id)
112
+ }, [])
113
+ return <TempoDisplay tempo={[3, 1, 1, 0]} live={live} />
114
+ }
115
+
116
+ // Self-driving demo cycling Ecc → Pause → Con to show the fill animating.
117
+ export const LiveAnimated: Story = {
118
+ render: () => <LiveTempoDemo />,
119
+ }
120
+
121
+ // Static and live side by side — the same prescription, two modes.
122
+ export const StaticVsLive: Story = {
123
+ render: () => (
124
+ <View style={{ gap: 12 }}>
125
+ <TempoDisplay tempo={[3, 1, 1, 0]} />
126
+ <TempoDisplay
127
+ tempo={[3, 1, 1, 0]}
128
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
129
+ />
130
+ <LiveTempoDemo />
131
+ </View>
132
+ ),
133
+ }
@@ -80,6 +80,41 @@ describe('TempoDisplay', () => {
80
80
  })
81
81
  })
82
82
 
83
+ describe('live phase-fill', () => {
84
+ it('is inert by default (no active fill) for backward-compatible static use', () => {
85
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
86
+ expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
87
+ })
88
+
89
+ it('renders an active fill overlay for the in-progress phase when live', () => {
90
+ render(
91
+ <TempoDisplay
92
+ tempo={[3, 1, 1, 0]}
93
+ live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
94
+ />
95
+ )
96
+ expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
97
+ expect(screen.getByText('3')).toBeInTheDocument()
98
+ })
99
+
100
+ it('marks no phase active when idle (activePhase null)', () => {
101
+ render(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: null, phaseElapsedMs: 0 }} />)
102
+ expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
103
+ })
104
+
105
+ it('still renders all four phase digits in live mode', () => {
106
+ render(
107
+ <TempoDisplay
108
+ tempo={[4, 3, 2, 1]}
109
+ live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
110
+ />
111
+ )
112
+ for (const digit of ['4', '3', '2', '1']) {
113
+ expect(screen.getByText(digit)).toBeInTheDocument()
114
+ }
115
+ })
116
+ })
117
+
83
118
  describe('accessibility', () => {
84
119
  it('has no accessibility violations', async () => {
85
120
  const { container } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
@@ -2,6 +2,23 @@
2
2
  import { useState, useCallback } from 'react'
3
3
  import { View, Text, Pressable, type ViewProps } from 'react-native'
4
4
  import { roundTempo } from '../../../utils/workout-format'
5
+ import { alpha } from '../../../utils/colors'
6
+ import { getTempoFillPct, getTempoPacingState } from './TempoBar'
7
+
8
+ /** The four tempo phases, in the order the display renders them. */
9
+ export type TempoLivePhase = 'eccentric' | 'pauseBottom' | 'concentric' | 'pauseTop'
10
+
11
+ /**
12
+ * Live rep state driving the phase-fill overlay. Controlled by the consumer
13
+ * (matching titan's `elapsedMs`-in convention) so the same real timer feeds
14
+ * both web dashboard and native app — TempoDisplay owns rendering only.
15
+ */
16
+ export interface TempoLiveState {
17
+ /** Phase currently in progress, or null when idle/at rest. */
18
+ activePhase: TempoLivePhase | null
19
+ /** Elapsed time (ms) within the active phase. */
20
+ phaseElapsedMs: number
21
+ }
5
22
 
6
23
  export interface TempoDisplayProps extends ViewProps {
7
24
  /** Tempo values: [eccentric, pauseBottom, concentric, pauseTop] in seconds */
@@ -11,12 +28,19 @@ export interface TempoDisplayProps extends ViewProps {
11
28
  colored?: boolean
12
29
  /** Show info tooltip on press */
13
30
  showInfo?: boolean
31
+ /**
32
+ * When set, renders a live phase-fill: the active phase digit fills against
33
+ * its prescribed duration and inactive phases dim. Omit for the static
34
+ * prescription string (default, backward compatible).
35
+ */
36
+ live?: TempoLiveState
14
37
  onPress?: () => void
15
38
  className?: string
16
39
  }
17
40
 
18
41
  const INTER = 'Inter, sans-serif'
19
42
  const TEXT_TERTIARY = '#6B7280'
43
+ const STATUS_ERROR = '#D14343' // status-error (red), shown when a phase runs behind pace
20
44
 
21
45
  // Phase colors: [eccentric, pauseBottom, concentric, pauseTop]
22
46
  const phaseColors = {
@@ -67,11 +91,84 @@ function TempoSeparator({ color, fontSize }: { color: string; fontSize: number }
67
91
  )
68
92
  }
69
93
 
94
+ // Phase order + color matching the tempo tuple, for the live phase-fill row.
95
+ const LIVE_PHASES: { key: TempoLivePhase; color: string }[] = [
96
+ { key: 'eccentric', color: phaseColors.eccentric },
97
+ { key: 'pauseBottom', color: phaseColors.pauseBottom },
98
+ { key: 'concentric', color: phaseColors.concentric },
99
+ { key: 'pauseTop', color: phaseColors.pauseTop },
100
+ ]
101
+
102
+ function LiveTempoCell({
103
+ value,
104
+ color,
105
+ isActive,
106
+ phaseElapsedMs,
107
+ fontSize,
108
+ }: {
109
+ value: number
110
+ color: string
111
+ isActive: boolean
112
+ phaseElapsedMs: number
113
+ fontSize: number
114
+ }) {
115
+ if (!isActive) {
116
+ return <TempoValue value={value} color={alpha(color, 0.35)} fontSize={fontSize} />
117
+ }
118
+ const targetMs = value > 0 ? value * 1000 : null
119
+ const barColor = getTempoPacingState(phaseElapsedMs, targetMs) === 'behind' ? STATUS_ERROR : color
120
+ const fillPct = getTempoFillPct(phaseElapsedMs, targetMs)
121
+ return (
122
+ <View style={{ position: 'relative' }} testID="tempo-live-active">
123
+ <View
124
+ style={{
125
+ position: 'absolute',
126
+ left: 0,
127
+ top: 0,
128
+ bottom: 0,
129
+ width: `${fillPct}%`,
130
+ backgroundColor: alpha(barColor, 0.3),
131
+ borderRadius: 2,
132
+ }}
133
+ />
134
+ <TempoValue value={value} color={barColor} fontSize={fontSize} />
135
+ </View>
136
+ )
137
+ }
138
+
139
+ function LiveTempoRow({
140
+ values,
141
+ live,
142
+ fontSize,
143
+ }: {
144
+ values: [number, number, number, number]
145
+ live: TempoLiveState
146
+ fontSize: number
147
+ }) {
148
+ return (
149
+ <>
150
+ {LIVE_PHASES.map((phase, i) => (
151
+ <View key={phase.key} style={{ flexDirection: 'row' }}>
152
+ {i > 0 && <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />}
153
+ <LiveTempoCell
154
+ value={values[i]}
155
+ color={phase.color}
156
+ isActive={live.activePhase === phase.key}
157
+ phaseElapsedMs={live.phaseElapsedMs}
158
+ fontSize={fontSize}
159
+ />
160
+ </View>
161
+ ))}
162
+ </>
163
+ )
164
+ }
165
+
70
166
  export function TempoDisplay({
71
167
  tempo,
72
168
  size = 'md',
73
169
  colored = true,
74
170
  showInfo = true,
171
+ live,
75
172
  onPress,
76
173
  className,
77
174
  ...props
@@ -119,7 +216,13 @@ export function TempoDisplay({
119
216
  TEMPO
120
217
  </Text>
121
218
  <View style={{ flexDirection: 'row' }} testID="tempo-value">
122
- {colored ? (
219
+ {live ? (
220
+ <LiveTempoRow
221
+ values={[eccentric, pauseBottom, concentric, pauseTop]}
222
+ live={live}
223
+ fontSize={fontSize}
224
+ />
225
+ ) : colored ? (
123
226
  <>
124
227
  <TempoValue value={eccentric} color={phaseColors.eccentric} fontSize={fontSize} />
125
228
  <TempoSeparator color={phaseColors.dash} fontSize={fontSize} />
@@ -1,4 +1,4 @@
1
- import { describe, it, expect, vi } from 'vitest'
1
+ import { describe, it, expect, vi, afterEach } from 'vitest'
2
2
  import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import {
@@ -210,10 +210,26 @@ describe('getVelocityZoneName', () => {
210
210
  })
211
211
 
212
212
  describe('calculateVelocityLoss', () => {
213
- it('calculates percentage loss from first to last rep', () => {
213
+ it('calculates percentage loss from running-best to last rep', () => {
214
+ // Best is the first rep here, so best->last matches the legacy result.
214
215
  expect(calculateVelocityLoss([1.0, 0.8])).toBe(20)
215
216
  })
216
217
 
218
+ it('uses the best rep (not the first) as the reference for non-monotonic sets', () => {
219
+ // best = 1.0 (rep 2), last = 0.6 -> (1.0 - 0.6) / 1.0 = 40%.
220
+ // Legacy first->last would have been (0.8 - 0.6) / 0.8 = 25%.
221
+ expect(calculateVelocityLoss([0.8, 1.0, 0.6])).toBe(40)
222
+ })
223
+
224
+ it('clamps to 0 when the set ends on its best rep', () => {
225
+ expect(calculateVelocityLoss([0.6, 0.8, 1.0])).toBe(0)
226
+ })
227
+
228
+ it('clamps to 0 when a later rep exceeds the first rep', () => {
229
+ // best = 1.2 (last), so loss is negative pre-clamp -> 0.
230
+ expect(calculateVelocityLoss([1.0, 0.9, 1.2])).toBe(0)
231
+ })
232
+
217
233
  it('returns 0 for single rep', () => {
218
234
  expect(calculateVelocityLoss([1.0])).toBe(0)
219
235
  })
@@ -221,6 +237,10 @@ describe('calculateVelocityLoss', () => {
221
237
  it('returns 0 for empty array', () => {
222
238
  expect(calculateVelocityLoss([])).toBe(0)
223
239
  })
240
+
241
+ it('returns 0 when the best rep is non-positive', () => {
242
+ expect(calculateVelocityLoss([0, 0])).toBe(0)
243
+ })
224
244
  })
225
245
 
226
246
  describe('calculateMeanVelocity', () => {
@@ -233,6 +253,73 @@ describe('calculateMeanVelocity', () => {
233
253
  })
234
254
  })
235
255
 
256
+ // WA-shaped 5-band zone set (compound movement-class defaults, mean m/s).
257
+ const compoundBands = [
258
+ { id: 'grinding', label: 'Grinding', min: 0, max: 0.35 },
259
+ { id: 'maximalStrength', label: 'Max Strength', min: 0.35, max: 0.5 },
260
+ { id: 'strengthSpeed', label: 'Strength-Speed', min: 0.5, max: 0.75 },
261
+ { id: 'power', label: 'Power', min: 0.75, max: 1.0 },
262
+ { id: 'speed', label: 'Speed', min: 1.0, max: null },
263
+ ] as const
264
+
265
+ describe('VelocityStrip zones prop', () => {
266
+ it('colors bars from the supplied bands (mini)', () => {
267
+ render(<VelocityStrip velocities={[1.1, 0.45]} zones={compoundBands} variant="mini" />)
268
+ // 1.1 -> speed -> green; 0.45 -> maximalStrength -> red (shared with grinding).
269
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
270
+ expect(screen.getByTestId('velocity-bar-1')).toHaveStyle({ backgroundColor: '#ff4757' })
271
+ })
272
+
273
+ it('labels the summary row with the band containing the mean velocity', () => {
274
+ // mean of the sample = 0.80 -> power band.
275
+ render(<VelocityStrip velocities={sampleVelocities} zones={compoundBands} expanded />)
276
+ expect(screen.getByTestId('velocity-info-row')).toHaveTextContent('Power')
277
+ })
278
+
279
+ it('falls back to the default scale when zones is absent', () => {
280
+ render(<VelocityStrip velocities={[1.1]} variant="mini" />)
281
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ backgroundColor: '#2ed573' })
282
+ })
283
+ })
284
+
285
+ describe('VelocityStrip live mode', () => {
286
+ const originalMatchMedia = window.matchMedia
287
+
288
+ afterEach(() => {
289
+ if (originalMatchMedia) window.matchMedia = originalMatchMedia
290
+ else delete (window as { matchMedia?: unknown }).matchMedia
291
+ })
292
+
293
+ it('marks the latest rep bar in its accessibility label', () => {
294
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={3} expanded />)
295
+ expect(
296
+ screen.getByLabelText(/Rep 4: 0\.68 meters per second, latest rep$/),
297
+ ).toBeInTheDocument()
298
+ })
299
+
300
+ it('flags a new set peak on the latest bar', () => {
301
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
302
+ expect(
303
+ screen.getByLabelText(/Rep 1: 1\.10 meters per second, latest rep, new set peak$/),
304
+ ).toBeInTheDocument()
305
+ })
306
+
307
+ it('renders without animation when prefers-reduced-motion is set', () => {
308
+ window.matchMedia = vi.fn().mockReturnValue({
309
+ matches: true,
310
+ addEventListener: vi.fn(),
311
+ removeEventListener: vi.fn(),
312
+ }) as unknown as typeof window.matchMedia
313
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} expanded />)
314
+ expect(screen.getByTestId('velocity-bar-0')).toBeInTheDocument()
315
+ })
316
+
317
+ it('does not apply live marking in the mini variant', () => {
318
+ render(<VelocityStrip velocities={sampleVelocities} liveRepIndex={0} variant="mini" />)
319
+ expect(screen.queryByLabelText(/latest rep/)).not.toBeInTheDocument()
320
+ })
321
+ })
322
+
236
323
  describe('VelocityStrip all-zero velocities (NaN guard)', () => {
237
324
  it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
238
325
  render(<VelocityStrip velocities={[0, 0, 0]} expanded />)