@titan-design/react-ui 0.7.0 → 0.8.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 (40) hide show
  1. package/dist/index.d.mts +109 -12
  2. package/dist/index.d.ts +109 -12
  3. package/dist/index.js +648 -231
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +648 -232
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +495 -92
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +496 -93
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
  17. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  18. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  19. package/src/components/custom/CircularTimer/index.ts +1 -0
  20. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  21. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  22. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  23. package/src/components/custom/Workout/README.md +47 -0
  24. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  25. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  26. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  27. package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
  28. package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
  29. package/src/components/custom/Workout/TempoBar.tsx +107 -22
  30. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  31. package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
  32. package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
  33. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  34. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  35. package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
  36. package/src/components/custom/index.ts +1 -0
  37. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  38. package/src/components/ui/alert/Alert.test.tsx +52 -0
  39. package/src/components/ui/alert/Alert.tsx +54 -20
  40. package/src/components/ui/progress/Progress.tsx +47 -22
@@ -3,9 +3,13 @@ import { View, Text, Pressable } from 'react-native'
3
3
  import { resolveColor } from '../../../theme/resolve-color'
4
4
  import { getSemanticColors } from '../../../theme/tokens/semantic'
5
5
  import { useTimer } from '../../../hooks/useTimer'
6
+ import { CircularTimer } from '../CircularTimer/CircularTimer'
6
7
 
7
8
  const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
8
9
 
10
+ /** Default `ring` diameter (px) — the across-the-room wall rest treatment. */
11
+ const RING_DEFAULT_SIZE = 180
12
+
9
13
  export interface RestTimerProps {
10
14
  totalSeconds: number
11
15
  elapsedMs: number
@@ -15,6 +19,123 @@ export interface RestTimerProps {
15
19
  visible: boolean
16
20
  /** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
17
21
  displayOnly?: boolean
22
+ /**
23
+ * `bar` (default) — the compact linear card (REST label + mm:ss + progress bar +
24
+ * controls), the mobile bottom-bar treatment. `ring` — the across-the-room wall
25
+ * treatment: a draining countdown ring (composes {@link CircularProgress}) with a
26
+ * big remaining-seconds numeral, the next-set line, and the same controls.
27
+ */
28
+ variant?: 'bar' | 'ring'
29
+ /** `ring` diameter in px. Default 180. */
30
+ size?: number
31
+ }
32
+
33
+ /** The +30s / Skip control row — shared by both variants (hidden in `displayOnly`). */
34
+ function RestActions({ onAddTime, onSkip }: { onAddTime: () => void; onSkip: () => void }) {
35
+ return (
36
+ <View style={{ flexDirection: 'row', gap: 8 }}>
37
+ <Pressable
38
+ onPress={onAddTime}
39
+ style={{
40
+ backgroundColor: 'rgba(255,255,255,0.06)',
41
+ paddingVertical: 8,
42
+ paddingHorizontal: 20,
43
+ borderRadius: 8,
44
+ }}
45
+ accessibilityRole="button"
46
+ accessibilityLabel="Add 30 seconds"
47
+ testID="rest-timer-add-time"
48
+ >
49
+ <Text
50
+ className="text-text-secondary"
51
+ style={{ fontSize: 11, fontFamily: 'Inter, sans-serif', fontWeight: '600' }}
52
+ >
53
+ +30s
54
+ </Text>
55
+ </Pressable>
56
+ <Pressable
57
+ onPress={onSkip}
58
+ style={{
59
+ backgroundColor: 'rgba(255,121,0,0.12)',
60
+ paddingVertical: 8,
61
+ paddingHorizontal: 20,
62
+ borderRadius: 8,
63
+ }}
64
+ accessibilityRole="button"
65
+ accessibilityLabel="Skip rest"
66
+ testID="rest-timer-skip"
67
+ >
68
+ <Text
69
+ style={{
70
+ fontSize: 11,
71
+ fontFamily: 'Inter, sans-serif',
72
+ fontWeight: '600',
73
+ color: BRAND_PRIMARY,
74
+ }}
75
+ >
76
+ Skip
77
+ </Text>
78
+ </Pressable>
79
+ </View>
80
+ )
81
+ }
82
+
83
+ interface RestTimerRingProps {
84
+ totalSeconds: number
85
+ elapsedMs: number
86
+ remainingSec: number
87
+ done: boolean
88
+ size: number
89
+ nextSetInfo?: string
90
+ displayOnly: boolean
91
+ onSkip: () => void
92
+ onAddTime: () => void
93
+ }
94
+
95
+ /**
96
+ * The `ring` render: the wall rest treatment. Composes the generic {@link CircularTimer}
97
+ * (which owns the arc + mm:ss readout + the fill-green "GO" done state), passing the
98
+ * shared {@link RestActions} as its controls, and adds the rest-specific next-set footer
99
+ * caption below. Rest-domain chrome only — the timer mechanics live in `CircularTimer`.
100
+ */
101
+ function RestTimerRing({
102
+ totalSeconds,
103
+ elapsedMs,
104
+ remainingSec,
105
+ done,
106
+ size,
107
+ nextSetInfo,
108
+ displayOnly,
109
+ onSkip,
110
+ onAddTime,
111
+ }: RestTimerRingProps) {
112
+ const a11yLabel = done
113
+ ? 'Rest complete, next set ready'
114
+ : `Rest timer, ${Math.max(0, remainingSec)} seconds remaining`
115
+ return (
116
+ <View style={{ alignItems: 'center', gap: 16 }} testID="rest-timer">
117
+ <CircularTimer
118
+ durationMs={totalSeconds * 1000}
119
+ elapsedMs={elapsedMs}
120
+ mode="down"
121
+ size={size}
122
+ doneLabel="GO"
123
+ doneColor="success"
124
+ accessibilityLabel={a11yLabel}
125
+ controls={!displayOnly ? <RestActions onAddTime={onAddTime} onSkip={onSkip} /> : undefined}
126
+ testID="rest-timer-ring"
127
+ />
128
+ {nextSetInfo != null && (
129
+ <Text
130
+ className="text-text-tertiary"
131
+ style={{ fontSize: 13, fontFamily: 'Inter, sans-serif' }}
132
+ testID="rest-timer-next-set"
133
+ >
134
+ {nextSetInfo}
135
+ </Text>
136
+ )}
137
+ </View>
138
+ )
18
139
  }
19
140
 
20
141
  export function RestTimer({
@@ -25,6 +146,8 @@ export function RestTimer({
25
146
  nextSetInfo,
26
147
  visible,
27
148
  displayOnly = false,
149
+ variant = 'bar',
150
+ size = RING_DEFAULT_SIZE,
28
151
  }: RestTimerProps) {
29
152
  // useTimer owns the countdown math (remaining/progress/mm:ss); a zero-duration
30
153
  // timer is complete, which keeps the width out of the 0/0 === NaN case.
@@ -32,6 +155,7 @@ export function RestTimer({
32
155
  remainingMs,
33
156
  progress,
34
157
  label: timeDisplay,
158
+ done,
35
159
  } = useTimer({
36
160
  mode: 'down',
37
161
  durationMs: totalSeconds * 1000,
@@ -42,6 +166,22 @@ export function RestTimer({
42
166
 
43
167
  if (!visible) return null
44
168
 
169
+ if (variant === 'ring') {
170
+ return (
171
+ <RestTimerRing
172
+ totalSeconds={totalSeconds}
173
+ elapsedMs={elapsedMs}
174
+ remainingSec={remainingSec}
175
+ done={done}
176
+ size={size}
177
+ nextSetInfo={nextSetInfo}
178
+ displayOnly={displayOnly}
179
+ onSkip={onSkip}
180
+ onAddTime={onAddTime}
181
+ />
182
+ )
183
+ }
184
+
45
185
  return (
46
186
  <View
47
187
  className="bg-surface-raised"
@@ -133,56 +273,7 @@ export function RestTimer({
133
273
  </View>
134
274
 
135
275
  {/* Actions row — hidden in display-only (poll-only) mode */}
136
- {!displayOnly && (
137
- <View style={{ flexDirection: 'row', gap: 8 }}>
138
- <Pressable
139
- onPress={onAddTime}
140
- style={{
141
- backgroundColor: 'rgba(255,255,255,0.06)',
142
- paddingVertical: 8,
143
- paddingHorizontal: 20,
144
- borderRadius: 8,
145
- }}
146
- accessibilityRole="button"
147
- accessibilityLabel="Add 30 seconds"
148
- testID="rest-timer-add-time"
149
- >
150
- <Text
151
- className="text-text-secondary"
152
- style={{
153
- fontSize: 11,
154
- fontFamily: 'Inter, sans-serif',
155
- fontWeight: '600',
156
- }}
157
- >
158
- +30s
159
- </Text>
160
- </Pressable>
161
- <Pressable
162
- onPress={onSkip}
163
- style={{
164
- backgroundColor: 'rgba(255,121,0,0.12)',
165
- paddingVertical: 8,
166
- paddingHorizontal: 20,
167
- borderRadius: 8,
168
- }}
169
- accessibilityRole="button"
170
- accessibilityLabel="Skip rest"
171
- testID="rest-timer-skip"
172
- >
173
- <Text
174
- style={{
175
- fontSize: 11,
176
- fontFamily: 'Inter, sans-serif',
177
- fontWeight: '600',
178
- color: BRAND_PRIMARY,
179
- }}
180
- >
181
- Skip
182
- </Text>
183
- </Pressable>
184
- </View>
185
- )}
276
+ {!displayOnly && <RestActions onAddTime={onAddTime} onSkip={onSkip} />}
186
277
  </View>
187
278
  )
188
279
  }
@@ -15,6 +15,16 @@ const meta: Meta<typeof TempoBar> = {
15
15
  phaseElapsedMs: { control: 'number', description: 'Elapsed ms in the active phase' },
16
16
  completed: { control: 'object', description: 'Completed phase durations (ms)' },
17
17
  target: { control: 'object', description: 'Per-phase target durations (seconds)' },
18
+ size: {
19
+ control: 'inline-radio',
20
+ options: ['default', 'wall'],
21
+ description: 'Density: default (compact) or wall (across-the-room dashboard scale)',
22
+ },
23
+ activeDisplay: {
24
+ control: 'inline-radio',
25
+ options: ['time', 'delta'],
26
+ description: 'Active readout: time (elapsed/target) or delta (countdown). Tap to toggle.',
27
+ },
18
28
  },
19
29
  decorators: [
20
30
  (Story) => (
@@ -93,3 +103,62 @@ export const AllStates: Story = {
93
103
  </View>
94
104
  ),
95
105
  }
106
+
107
+ /**
108
+ * The across-the-room wall scale — bigger bar, full phase words, and colour-coded
109
+ * pacing (a missed target reads red). Here the eccentric ran long → red.
110
+ */
111
+ export const Wall: Story = {
112
+ args: {
113
+ activePhase: null,
114
+ phaseElapsedMs: 0,
115
+ completed: { concentric: 950, hold: 1000, eccentric: 4200 },
116
+ target: TARGET,
117
+ size: 'wall',
118
+ },
119
+ decorators: [
120
+ (Story) => (
121
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
122
+ <Story />
123
+ </View>
124
+ ),
125
+ ],
126
+ }
127
+
128
+ /** Wall, mid-rep — the eccentric is active, showing the delta countdown (1.5s to target). Tap to switch to elapsed/target. */
129
+ export const WallActiveDelta: Story = {
130
+ args: {
131
+ activePhase: 'eccentric',
132
+ phaseElapsedMs: 1500,
133
+ completed: { concentric: 950, hold: 1000 },
134
+ target: TARGET,
135
+ size: 'wall',
136
+ activeDisplay: 'delta',
137
+ },
138
+ decorators: [
139
+ (Story) => (
140
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
141
+ <Story />
142
+ </View>
143
+ ),
144
+ ],
145
+ }
146
+
147
+ /** Wall, over target — the eccentric ran 1.2s past its target, so the delta is negative and red. */
148
+ export const WallActiveOver: Story = {
149
+ args: {
150
+ activePhase: 'eccentric',
151
+ phaseElapsedMs: 4200,
152
+ completed: { concentric: 950, hold: 1000 },
153
+ target: TARGET,
154
+ size: 'wall',
155
+ activeDisplay: 'delta',
156
+ },
157
+ decorators: [
158
+ (Story) => (
159
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
160
+ <Story />
161
+ </View>
162
+ ),
163
+ ],
164
+ }
@@ -1,13 +1,16 @@
1
1
  import { describe, it, expect } from 'vitest'
2
- import { render, screen } from '@testing-library/react'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
3
  import { axe } from 'jest-axe'
4
4
  import {
5
5
  TempoBar,
6
6
  getTempoPacingState,
7
7
  getTempoFillPct,
8
+ formatTempoDelta,
8
9
  TEMPO_PACING,
9
10
  } from './TempoBar'
11
+ import { getSemanticColors } from '../../../theme/tokens/semantic'
10
12
 
13
+ const t = getSemanticColors('dark')
11
14
  const TARGET = { concentric: 1, hold: 1, eccentric: 3 }
12
15
 
13
16
  describe('getTempoPacingState', () => {
@@ -70,7 +73,7 @@ describe('TempoBar', () => {
70
73
  phaseElapsedMs={100}
71
74
  completed={{ concentric: 950 }}
72
75
  target={TARGET}
73
- />,
76
+ />
74
77
  )
75
78
  expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
76
79
  expect(screen.getByText('0.9 ✓')).toBeInTheDocument()
@@ -83,7 +86,7 @@ describe('TempoBar', () => {
83
86
  phaseElapsedMs={100}
84
87
  completed={{ eccentric: 4000 }}
85
88
  target={TARGET}
86
- />,
89
+ />
87
90
  )
88
91
  // eccentric target 3s, 4s elapsed => behind => cross
89
92
  expect(screen.getByText('4.0 ✗')).toBeInTheDocument()
@@ -96,8 +99,120 @@ describe('TempoBar', () => {
96
99
  phaseElapsedMs={1500}
97
100
  completed={{ concentric: 900, hold: 1000 }}
98
101
  target={TARGET}
99
- />,
102
+ />
100
103
  )
101
104
  expect(await axe(container)).toHaveNoViolations()
102
105
  })
103
106
  })
107
+
108
+ describe('TempoBar size="wall"', () => {
109
+ const TARGET_W = { concentric: 1, hold: 1, eccentric: 3 }
110
+
111
+ it('scales the active label up at wall density', () => {
112
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} size="wall" />)
113
+ expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 22 })
114
+ })
115
+
116
+ it('keeps the compact label size by default', () => {
117
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET_W} />)
118
+ expect(screen.getByText('0.6 / 1.0')).toHaveStyle({ fontSize: 12 })
119
+ })
120
+
121
+ it('preserves the segment testIDs at wall density', () => {
122
+ render(
123
+ <TempoBar
124
+ activePhase="eccentric"
125
+ phaseElapsedMs={1500}
126
+ completed={{ concentric: 900 }}
127
+ target={TARGET_W}
128
+ size="wall"
129
+ />
130
+ )
131
+ expect(screen.getByTestId('tempo-segment-active-Ecc')).toBeInTheDocument()
132
+ expect(screen.getByTestId('tempo-segment-completed-Con')).toBeInTheDocument()
133
+ })
134
+
135
+ it('spells out the full phase words at wall density', () => {
136
+ render(<TempoBar activePhase={null} phaseElapsedMs={0} target={TARGET_W} size="wall" />)
137
+ expect(screen.getByText('Concentric')).toBeInTheDocument()
138
+ expect(screen.getByText('Eccentric')).toBeInTheDocument()
139
+ expect(screen.queryByText('Con')).not.toBeInTheDocument()
140
+ })
141
+ })
142
+
143
+ describe('formatTempoDelta', () => {
144
+ it('shows remaining time to target as a positive value', () => {
145
+ expect(formatTempoDelta(1500)).toBe('1.5')
146
+ })
147
+
148
+ it('shows over-target time as negative', () => {
149
+ expect(formatTempoDelta(-1200)).toBe('-1.2')
150
+ })
151
+
152
+ it('shows 0.0 at the target (no -0.0)', () => {
153
+ expect(formatTempoDelta(0)).toBe('0.0')
154
+ expect(formatTempoDelta(-10)).toBe('0.0')
155
+ })
156
+ })
157
+
158
+ describe('TempoBar activeDisplay (delta countdown + toggle)', () => {
159
+ it('counts the remaining time down to the target in delta mode', () => {
160
+ render(
161
+ <TempoBar
162
+ activePhase="eccentric"
163
+ phaseElapsedMs={1800}
164
+ target={TARGET}
165
+ activeDisplay="delta"
166
+ />
167
+ )
168
+ // eccentric target 3.0s, elapsed 1.8s → 1.2s remaining
169
+ expect(screen.getByText('1.2')).toBeInTheDocument()
170
+ })
171
+
172
+ it('goes negative once the phase runs past its target', () => {
173
+ render(
174
+ <TempoBar
175
+ activePhase="eccentric"
176
+ phaseElapsedMs={4200}
177
+ target={TARGET}
178
+ activeDisplay="delta"
179
+ />
180
+ )
181
+ expect(screen.getByText('-1.2')).toBeInTheDocument()
182
+ })
183
+
184
+ it('tapping the active segment toggles time ↔ delta', () => {
185
+ render(
186
+ <TempoBar
187
+ activePhase="concentric"
188
+ phaseElapsedMs={600}
189
+ target={TARGET}
190
+ activeDisplay="time"
191
+ />
192
+ )
193
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
194
+ fireEvent.click(screen.getByTestId('tempo-segment-active-Con'))
195
+ // concentric target 1.0s, elapsed 0.6s → 0.4s remaining
196
+ expect(screen.getByText('0.4')).toBeInTheDocument()
197
+ expect(screen.queryByText('0.6 / 1.0')).not.toBeInTheDocument()
198
+ })
199
+
200
+ it('defaults to the time readout', () => {
201
+ render(<TempoBar activePhase="concentric" phaseElapsedMs={600} target={TARGET} />)
202
+ expect(screen.getByText('0.6 / 1.0')).toBeInTheDocument()
203
+ })
204
+ })
205
+
206
+ describe('TempoBar completed pacing colour', () => {
207
+ it('colours a missed completed phase red', () => {
208
+ render(
209
+ <TempoBar
210
+ activePhase="hold"
211
+ phaseElapsedMs={100}
212
+ completed={{ eccentric: 4000 }}
213
+ target={TARGET}
214
+ />
215
+ )
216
+ expect(screen.getByText('4.0 ✗')).toHaveStyle({ color: t['status-error'] })
217
+ })
218
+ })