@titan-design/react-ui 0.7.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 (52) hide show
  1. package/dist/index.d.mts +112 -55
  2. package/dist/index.d.ts +112 -55
  3. package/dist/index.js +795 -501
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +795 -498
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +691 -250
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +692 -251
  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/ExerciseIndicator.tsx +1 -1
  21. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  22. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  23. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  24. package/src/components/custom/Workout/README.md +70 -3
  25. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  26. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  27. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  28. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  29. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  30. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  31. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  32. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  33. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  34. package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
  35. package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
  36. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  37. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  38. package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
  39. package/src/components/custom/Workout/index.ts +1 -9
  40. package/src/components/custom/index.ts +1 -0
  41. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  42. package/src/components/ui/alert/Alert.test.tsx +52 -0
  43. package/src/components/ui/alert/Alert.tsx +54 -20
  44. package/src/components/ui/progress/Progress.tsx +47 -22
  45. package/src/lab/north-star/LivePage.tsx +101 -0
  46. package/src/lab/north-star/LiveView.tsx +409 -0
  47. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  48. package/src/lab/north-star/RestView.tsx +140 -0
  49. package/src/lab/north-star/fixtures.ts +224 -0
  50. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
  51. package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
  52. package/src/components/custom/Workout/TempoBar.tsx +0 -215
@@ -184,3 +184,76 @@ describe('RestTimer zero-duration timer (NaN guard)', () => {
184
184
  expect(container.innerHTML).not.toContain('NaN')
185
185
  })
186
186
  })
187
+
188
+ describe('RestTimer ring variant', () => {
189
+ const ringProps = { ...defaultProps, variant: 'ring' as const }
190
+
191
+ it('renders the ring (composed CircularProgress) instead of the linear bar', () => {
192
+ render(<RestTimer {...ringProps} />)
193
+ expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
194
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
195
+ expect(screen.queryByTestId('rest-timer-progress-track')).not.toBeInTheDocument()
196
+ })
197
+
198
+ it('shows the mm:ss countdown in the ring center while resting', () => {
199
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
200
+ expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
201
+ })
202
+
203
+ it('shows GO (not a time) when rest is complete', () => {
204
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={120000} />)
205
+ expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
206
+ })
207
+
208
+ it('labels the completed ring as rest complete', () => {
209
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={125000} />)
210
+ expect(screen.getByLabelText('Rest complete, next set ready')).toBeInTheDocument()
211
+ })
212
+
213
+ it('labels the resting ring with seconds remaining', () => {
214
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
215
+ expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
216
+ })
217
+
218
+ it('renders the next-set line and controls by default', () => {
219
+ render(<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />)
220
+ expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent('Next · Bench · set 3 of 4')
221
+ expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
222
+ expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
223
+ })
224
+
225
+ it('hides the controls in displayOnly mode (keeps ring + next-set)', () => {
226
+ render(<RestTimer {...ringProps} nextSetInfo="Next" displayOnly />)
227
+ expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
228
+ expect(screen.getByTestId('rest-timer-next-set')).toBeInTheDocument()
229
+ expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
230
+ expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
231
+ })
232
+
233
+ it('fires onSkip / onAddTime from the ring controls', () => {
234
+ const onSkip = vi.fn()
235
+ const onAddTime = vi.fn()
236
+ render(<RestTimer {...ringProps} onSkip={onSkip} onAddTime={onAddTime} />)
237
+ fireEvent.click(screen.getByTestId('rest-timer-skip'))
238
+ fireEvent.click(screen.getByTestId('rest-timer-add-time'))
239
+ expect(onSkip).toHaveBeenCalledOnce()
240
+ expect(onAddTime).toHaveBeenCalledOnce()
241
+ })
242
+
243
+ it('respects visible=false', () => {
244
+ render(<RestTimer {...ringProps} visible={false} />)
245
+ expect(screen.queryByTestId('rest-timer-ring')).not.toBeInTheDocument()
246
+ })
247
+
248
+ it('emits no NaN when totalSeconds is 0', () => {
249
+ const { container } = render(<RestTimer {...ringProps} totalSeconds={0} elapsedMs={0} />)
250
+ expect(container.innerHTML).not.toContain('NaN')
251
+ })
252
+
253
+ it('has no accessibility violations', async () => {
254
+ const { container } = render(
255
+ <RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />
256
+ )
257
+ expect(await axe(container)).toHaveNoViolations()
258
+ })
259
+ })
@@ -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
  }
@@ -34,6 +34,12 @@ describe('SetsRepsLoad', () => {
34
34
  expect(screen.getByTestId('sets-reps-load')).toHaveTextContent('15-20')
35
35
  })
36
36
 
37
+ it('scales the cell font size via fontSize', () => {
38
+ render(<SetsRepsLoad sets={4} reps={8} load={140} fontSize={32} />)
39
+ // The value cells (and separators) render at the supplied size.
40
+ expect(screen.getByText('4')).toHaveStyle({ fontSize: 32 })
41
+ })
42
+
37
43
  describe('accessibility', () => {
38
44
  it('has no accessibility violations', async () => {
39
45
  const { container } = render(<SetsRepsLoad sets={5} reps={8} load={145} />)
@@ -14,6 +14,8 @@ export interface SetsRepsLoadProps extends ViewProps {
14
14
  load: number
15
15
  /** Displayed load unit label (e.g. "lb", "kg"). */
16
16
  unit?: string
17
+ /** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
18
+ fontSize?: number
17
19
  className?: string
18
20
  }
19
21
 
@@ -27,6 +29,7 @@ export function SetsRepsLoad({
27
29
  reps,
28
30
  load,
29
31
  unit = 'lb',
32
+ fontSize,
30
33
  className,
31
34
  ...props
32
35
  }: SetsRepsLoadProps) {
@@ -41,12 +44,18 @@ export function SetsRepsLoad({
41
44
  testID="sets-reps-load"
42
45
  {...props}
43
46
  >
44
- <MetricCell color={value}>{sets}</MetricCell>
45
- <MetricCell color={sep}>{` ${TIMES} `}</MetricCell>
46
- <MetricCell color={value}>{reps}</MetricCell>
47
- <MetricCell color={sep}>{` ${AT} `}</MetricCell>
48
- <MetricCell color={value}>{load}</MetricCell>
49
- <MetricCell color={sep}>{` ${unit}`}</MetricCell>
47
+ <MetricCell color={value} fontSize={fontSize}>
48
+ {sets}
49
+ </MetricCell>
50
+ <MetricCell color={sep} fontSize={fontSize}>{` ${TIMES} `}</MetricCell>
51
+ <MetricCell color={value} fontSize={fontSize}>
52
+ {reps}
53
+ </MetricCell>
54
+ <MetricCell color={sep} fontSize={fontSize}>{` ${AT} `}</MetricCell>
55
+ <MetricCell color={value} fontSize={fontSize}>
56
+ {load}
57
+ </MetricCell>
58
+ <MetricCell color={sep} fontSize={fontSize}>{` ${unit}`}</MetricCell>
50
59
  </View>
51
60
  )
52
61
  }