@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
@@ -13,16 +13,8 @@ export {
13
13
  type TempoDisplayProps,
14
14
  type TempoLivePhase,
15
15
  type TempoLiveState,
16
+ type TempoLiveReadout,
16
17
  } from './TempoDisplay'
17
- export {
18
- TempoBar,
19
- type TempoBarProps,
20
- type TempoPhaseKey,
21
- type TempoPacingState,
22
- TEMPO_PACING,
23
- getTempoPacingState,
24
- getTempoFillPct,
25
- } from './TempoBar'
26
18
  export { DeviationBar, type DeviationBarProps } from './DeviationBar'
27
19
  export { IntensityBar, type IntensityBarProps } from './IntensityBar'
28
20
  export { WorkoutPill, type WorkoutPillProps, type WorkoutPillStatus } from './WorkoutPill'
@@ -6,6 +6,7 @@ export * from './EmptyState'
6
6
  export * from './stepper'
7
7
  export * from './DateTime'
8
8
  export * from './TimerReadout'
9
+ export * from './CircularTimer'
9
10
  export * from './Metric'
10
11
  export * from './Workout'
11
12
  export * from './Treemap'
@@ -1,11 +1,25 @@
1
- import type { Meta, StoryObj } from '@storybook/react-vite'
2
- import { View } from 'react-native'
1
+ import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
2
+ import { View, Text } from 'react-native'
3
3
  import { Alert, AlertTitle, AlertDescription } from './Alert'
4
4
 
5
5
  const meta: Meta<typeof Alert> = {
6
6
  title: 'Components/Molecules/Alert',
7
7
  component: Alert,
8
8
  tags: ['autodocs'],
9
+ parameters: {
10
+ docs: {
11
+ description: {
12
+ component:
13
+ 'Status callout across 4 statuses (success/info/warning/error) × 3 variants ' +
14
+ '(subtle/outline/solid). Two densities via `size`: `default` — the padded ' +
15
+ 'icon + title/description block; `compact` — a single-line **cue pill** (tighter, ' +
16
+ 'center-aligned, hairline status border on `subtle`) that absorbs the R2 `CueFlag` ' +
17
+ 'live-coaching cue. Pass `size="compact"` + `message="…"` for the batteries-included ' +
18
+ "status-colored one-liner (RN doesn't cascade text color, so the `message` prop " +
19
+ 'colors it for you). Use `<AlertTitle>` / `<AlertDescription>` children for richer content.',
20
+ },
21
+ },
22
+ },
9
23
  argTypes: {
10
24
  status: {
11
25
  control: 'select',
@@ -15,6 +29,11 @@ const meta: Meta<typeof Alert> = {
15
29
  control: 'select',
16
30
  options: ['subtle', 'outline', 'solid'],
17
31
  },
32
+ size: {
33
+ control: 'inline-radio',
34
+ options: ['default', 'compact'],
35
+ },
36
+ message: { control: 'text' },
18
37
  showIcon: { control: 'boolean' },
19
38
  },
20
39
  }
@@ -152,3 +171,67 @@ export const DescriptionOnly: Story = {
152
171
  </Alert>
153
172
  ),
154
173
  }
174
+
175
+ // --- Compact cue pill (absorbs CueFlag) --------------------------------------
176
+ // The across-the-room live coaching cue. Rendered on the north-star wall background
177
+ // so the tinted + bordered pill reads the way it will live.
178
+
179
+ /** Wall-background frame for the compact cue stories. */
180
+ const wall: Decorator = (Story) => (
181
+ <View style={{ width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
182
+ <Story />
183
+ </View>
184
+ )
185
+
186
+ /** Compact warning cue — the VL20 approach-threshold flag (batteries-included `message`). */
187
+ export const CompactWarningCue: Story = {
188
+ args: {
189
+ status: 'warning',
190
+ variant: 'subtle',
191
+ size: 'compact',
192
+ message: 'VL20 · target reps met — final rep or end the set.',
193
+ },
194
+ decorators: [wall],
195
+ }
196
+
197
+ /** Compact error cue — the stop-now flag. */
198
+ export const CompactErrorCue: Story = {
199
+ args: {
200
+ status: 'error',
201
+ variant: 'subtle',
202
+ size: 'compact',
203
+ message: 'Velocity loss 31% — end the set now.',
204
+ },
205
+ decorators: [wall],
206
+ }
207
+
208
+ /** The consumption target: the north-star live cue (interpolated loss). */
209
+ export const CompactLiveCue: Story = {
210
+ args: {
211
+ status: 'warning',
212
+ variant: 'subtle',
213
+ size: 'compact',
214
+ message: 'Approaching threshold · 18% velocity loss — one or two reps left.',
215
+ },
216
+ decorators: [wall],
217
+ }
218
+
219
+ /** Default vs compact, side by side, for the density comparison. */
220
+ export const DefaultVsCompact: Story = {
221
+ render: () => (
222
+ <View style={{ gap: 16, width: 460, padding: 28, backgroundColor: '#0E0E0E' }}>
223
+ <Text style={{ color: '#5A5A5A', fontSize: 10, fontWeight: '700' }}>DEFAULT</Text>
224
+ <Alert status="warning" variant="subtle">
225
+ <AlertTitle>Approaching threshold</AlertTitle>
226
+ <AlertDescription>18% velocity loss — one or two reps left in the band.</AlertDescription>
227
+ </Alert>
228
+ <Text style={{ color: '#5A5A5A', fontSize: 10, fontWeight: '700' }}>COMPACT (cue pill)</Text>
229
+ <Alert
230
+ status="warning"
231
+ variant="subtle"
232
+ size="compact"
233
+ message="Approaching threshold · 18% velocity loss — reps left in band."
234
+ />
235
+ </View>
236
+ ),
237
+ }
@@ -200,3 +200,55 @@ describe('Alert', () => {
200
200
  })
201
201
  })
202
202
  })
203
+
204
+ describe('Alert compact cue (absorbs CueFlag)', () => {
205
+ it('renders the message as content (batteries-included cue)', () => {
206
+ render(<Alert status="warning" size="compact" message="VL20 · end the set" />)
207
+ expect(screen.getByTestId('alert-message')).toHaveTextContent('VL20 · end the set')
208
+ })
209
+
210
+ it('renders the message alongside a status glyph and the alert role', () => {
211
+ render(<Alert status="error" size="compact" message="Stop now" />)
212
+ expect(screen.getByRole('alert')).toBeInTheDocument()
213
+ expect(screen.getByText('✕')).toBeInTheDocument()
214
+ expect(screen.getByText('Stop now')).toBeInTheDocument()
215
+ })
216
+
217
+ it('renders message in the default size too', () => {
218
+ render(<Alert status="info" message="Heads up" />)
219
+ expect(screen.getByTestId('alert-message')).toHaveTextContent('Heads up')
220
+ })
221
+
222
+ it('renders children after the message when both are given', () => {
223
+ render(
224
+ <Alert status="warning" size="compact" message="Cue line">
225
+ <AlertDescription>Extra detail</AlertDescription>
226
+ </Alert>
227
+ )
228
+ expect(screen.getByTestId('alert-message')).toHaveTextContent('Cue line')
229
+ expect(screen.getByText('Extra detail')).toBeInTheDocument()
230
+ })
231
+
232
+ it('omits the message node when no message is passed', () => {
233
+ render(
234
+ <Alert status="warning" size="compact">
235
+ <AlertDescription>Just children</AlertDescription>
236
+ </Alert>
237
+ )
238
+ expect(screen.queryByTestId('alert-message')).not.toBeInTheDocument()
239
+ expect(screen.getByText('Just children')).toBeInTheDocument()
240
+ })
241
+
242
+ it('still respects showIcon=false in compact', () => {
243
+ render(<Alert status="warning" size="compact" message="No icon cue" showIcon={false} />)
244
+ expect(screen.queryByText('⚠')).not.toBeInTheDocument()
245
+ expect(screen.getByText('No icon cue')).toBeInTheDocument()
246
+ })
247
+
248
+ it('has no accessibility violations as a compact cue', async () => {
249
+ const { container } = render(
250
+ <Alert status="warning" size="compact" message="VL20 · target reps met — end the set." />
251
+ )
252
+ expect(await axe(container)).toHaveNoViolations()
253
+ })
254
+ })
@@ -4,12 +4,29 @@ import { cn } from '../../../utils/cn'
4
4
 
5
5
  export type AlertStatus = 'success' | 'info' | 'warning' | 'error'
6
6
  export type AlertVariant = 'subtle' | 'outline' | 'solid'
7
+ export type AlertSize = 'default' | 'compact'
7
8
 
8
9
  export interface AlertProps extends ViewProps {
9
10
  /** Alert status type */
10
11
  status?: AlertStatus
11
12
  /** Visual variant */
12
13
  variant?: AlertVariant
14
+ /**
15
+ * Density. `default` — the padded callout (icon + title/description block).
16
+ * `compact` — a single-line **cue pill** (tighter padding, center-aligned, and a
17
+ * hairline status border on the `subtle` variant): the across-the-room live coaching
18
+ * cue (absorbs the R2 `CueFlag` — e.g. a velocity-loss threshold warning). Pair with
19
+ * the {@link message} prop for the batteries-included colored one-liner.
20
+ */
21
+ size?: AlertSize
22
+ /**
23
+ * Convenience single-line content: renders as **status-colored, bold** text (white on
24
+ * `solid`) — the batteries-included cue message, so a `compact` cue is just
25
+ * `<Alert status="warning" size="compact" message="VL20 · …" />` with no need to
26
+ * hand-color the text (RN doesn't cascade color). Renders before {@link children},
27
+ * which stay available for richer content.
28
+ */
29
+ message?: string
13
30
  /** Custom icon element */
14
31
  icon?: React.ReactNode
15
32
  /** Whether to show the default icon */
@@ -21,17 +38,22 @@ export interface AlertProps extends ViewProps {
21
38
  children?: React.ReactNode
22
39
  }
23
40
 
24
- const statusColors: Record<AlertStatus, {
25
- subtle: string
26
- outline: string
27
- solid: string
28
- icon: string
29
- text: string
30
- }> = {
41
+ const statusColors: Record<
42
+ AlertStatus,
43
+ {
44
+ subtle: string
45
+ outline: string
46
+ solid: string
47
+ border: string
48
+ icon: string
49
+ text: string
50
+ }
51
+ > = {
31
52
  success: {
32
53
  subtle: 'bg-status-success-subtle',
33
54
  outline: 'border-2 border-status-success bg-transparent',
34
55
  solid: 'bg-status-success',
56
+ border: 'border-status-success',
35
57
  icon: 'text-status-success',
36
58
  text: 'text-status-success',
37
59
  },
@@ -39,6 +61,7 @@ const statusColors: Record<AlertStatus, {
39
61
  subtle: 'bg-status-info-subtle',
40
62
  outline: 'border-2 border-status-info bg-transparent',
41
63
  solid: 'bg-status-info',
64
+ border: 'border-status-info',
42
65
  icon: 'text-status-info',
43
66
  text: 'text-status-info',
44
67
  },
@@ -46,6 +69,7 @@ const statusColors: Record<AlertStatus, {
46
69
  subtle: 'bg-status-warning-subtle',
47
70
  outline: 'border-2 border-status-warning bg-transparent',
48
71
  solid: 'bg-status-warning',
72
+ border: 'border-status-warning',
49
73
  icon: 'text-status-warning',
50
74
  text: 'text-status-warning',
51
75
  },
@@ -53,6 +77,7 @@ const statusColors: Record<AlertStatus, {
53
77
  subtle: 'bg-status-error-subtle',
54
78
  outline: 'border-2 border-status-error bg-transparent',
55
79
  solid: 'bg-status-error',
80
+ border: 'border-status-error',
56
81
  icon: 'text-status-error',
57
82
  text: 'text-status-error',
58
83
  },
@@ -82,6 +107,8 @@ const defaultIcons: Record<AlertStatus, string> = {
82
107
  export function Alert({
83
108
  status = 'info',
84
109
  variant = 'subtle',
110
+ size = 'default',
111
+ message,
85
112
  icon,
86
113
  showIcon = true,
87
114
  onClose,
@@ -91,23 +118,30 @@ export function Alert({
91
118
  }: AlertProps) {
92
119
  const colors = statusColors[status]
93
120
  const isSolid = variant === 'solid'
121
+ const isCompact = size === 'compact'
94
122
 
95
123
  return (
96
124
  <View
97
125
  accessibilityRole="alert"
98
126
  className={cn(
99
- 'flex-row items-start p-4 rounded-lg',
127
+ isCompact
128
+ ? 'flex-row items-center px-3 py-2 rounded-lg'
129
+ : 'flex-row items-start p-4 rounded-lg',
100
130
  colors[variant],
131
+ // A compact `subtle` pill gets a hairline status border so it reads as a
132
+ // defined cue on a dark wall (the CueFlag look); other variants carry their own.
133
+ isCompact && variant === 'subtle' && cn('border', colors.border),
101
134
  className
102
135
  )}
103
136
  {...props}
104
137
  >
105
138
  {showIcon && (
106
- <View className="mr-3 mt-0.5">
139
+ <View className={isCompact ? 'mr-2' : 'mr-3 mt-0.5'}>
107
140
  {icon || (
108
141
  <Text
109
142
  className={cn(
110
- 'text-lg font-bold',
143
+ 'font-bold',
144
+ isCompact ? 'text-base' : 'text-lg',
111
145
  isSolid ? 'text-white' : colors.icon
112
146
  )}
113
147
  >
@@ -118,6 +152,14 @@ export function Alert({
118
152
  )}
119
153
 
120
154
  <View className="flex-1">
155
+ {message != null && (
156
+ <Text
157
+ className={cn('text-sm font-semibold', isSolid ? 'text-white' : colors.text)}
158
+ testID="alert-message"
159
+ >
160
+ {message}
161
+ </Text>
162
+ )}
121
163
  {children}
122
164
  </View>
123
165
 
@@ -146,11 +188,7 @@ export interface AlertTitleProps {
146
188
  * Title for Alert component.
147
189
  */
148
190
  export function AlertTitle({ children, className }: AlertTitleProps) {
149
- return (
150
- <Text className={cn('font-semibold text-text-primary mb-1', className)}>
151
- {children}
152
- </Text>
153
- )
191
+ return <Text className={cn('font-semibold text-text-primary mb-1', className)}>{children}</Text>
154
192
  }
155
193
 
156
194
  export interface AlertDescriptionProps {
@@ -162,9 +200,5 @@ export interface AlertDescriptionProps {
162
200
  * Description for Alert component.
163
201
  */
164
202
  export function AlertDescription({ children, className }: AlertDescriptionProps) {
165
- return (
166
- <Text className={cn('text-sm text-text-secondary', className)}>
167
- {children}
168
- </Text>
169
- )
203
+ return <Text className={cn('text-sm text-text-secondary', className)}>{children}</Text>
170
204
  }
@@ -7,7 +7,7 @@ export type ProgressColor = 'primary' | 'secondary' | 'success' | 'error' | 'war
7
7
  export type ProgressVariant = 'linear' | 'circular'
8
8
 
9
9
  /** Maps the color prop to a CSS variable reference for inline styles */
10
- const colorVarMap: Record<ProgressColor, string> = {
10
+ export const colorVarMap: Record<ProgressColor, string> = {
11
11
  primary: 'var(--color-brand-primary)',
12
12
  secondary: 'var(--color-brand-secondary)',
13
13
  success: 'var(--color-status-success)',
@@ -101,13 +101,9 @@ export function Progress({
101
101
  {/* Label and Value Row */}
102
102
  {(label || showValue) && (
103
103
  <View className="flex-row justify-between items-center mb-1">
104
- {label && (
105
- <Text className="text-sm text-text-secondary">{label}</Text>
106
- )}
104
+ {label && <Text className="text-sm text-text-secondary">{label}</Text>}
107
105
  {showValue && !isIndeterminate && (
108
- <Text className="text-sm font-medium text-text-primary">
109
- {formatValue(value, max)}
110
- </Text>
106
+ <Text className="text-sm font-medium text-text-primary">{formatValue(value, max)}</Text>
111
107
  )}
112
108
  </View>
113
109
  )}
@@ -162,6 +158,19 @@ export interface CircularProgressProps extends ViewProps {
162
158
  showValue?: boolean
163
159
  /** Custom value format function */
164
160
  formatValue?: (value: number, max: number) => string
161
+ /**
162
+ * Custom center content (a numeral, a timer readout, an icon). When provided it
163
+ * replaces the {@link showValue} text — the ring becomes a generic container for
164
+ * any centered element. See {@link CircularTimer} for the batteries-included timer.
165
+ */
166
+ children?: React.ReactNode
167
+ /**
168
+ * Fill the parent container (responsive) instead of drawing a fixed `size` box.
169
+ * The SVG uses a `0 0 size size` viewBox scaled to the container, so `size` still
170
+ * sets the geometry proportions (radius, stroke) while the ring grows/shrinks with
171
+ * its parent — e.g. a wall column. Default false (fixed `size` px).
172
+ */
173
+ fill?: boolean
165
174
  /** Additional className */
166
175
  className?: string
167
176
  }
@@ -181,6 +190,8 @@ export function CircularProgress({
181
190
  color = 'primary',
182
191
  showValue = false,
183
192
  formatValue = (v, m) => `${Math.round((v / m) * 100)}%`,
193
+ children,
194
+ fill = false,
184
195
  className,
185
196
  ...props
186
197
  }: CircularProgressProps) {
@@ -189,11 +200,14 @@ export function CircularProgress({
189
200
  const circumference = 2 * Math.PI * radius
190
201
  const strokeDashoffset = circumference - (percentage / 100) * circumference
191
202
  const center = size / 2
203
+ // Fixed: a `size × size` px box. Fill: fill the parent, staying square, and let
204
+ // the `0 0 size size` viewBox scale the geometry to fit (a responsive ring).
205
+ const boxStyle = fill ? { width: '100%' as const, aspectRatio: 1 } : { width: size, height: size }
192
206
 
193
207
  return (
194
208
  <View
195
209
  className={cn('items-center justify-center', className)}
196
- style={{ width: size, height: size }}
210
+ style={boxStyle}
197
211
  accessibilityRole="progressbar"
198
212
  accessibilityValue={{
199
213
  min: 0,
@@ -202,14 +216,11 @@ export function CircularProgress({
202
216
  }}
203
217
  {...props}
204
218
  >
205
- {/* SVG implementation for web */}
206
- <View
207
- className={cn(isIndeterminate && 'animate-spin')}
208
- style={{ width: size, height: size }}
209
- >
219
+ {/* SVG implementation for web — width/height 100% so it scales with `boxStyle`. */}
220
+ <View className={cn(isIndeterminate && 'animate-spin')} style={boxStyle}>
210
221
  <svg
211
- width={size}
212
- height={size}
222
+ width="100%"
223
+ height="100%"
213
224
  viewBox={`0 0 ${size} ${size}`}
214
225
  style={{ position: 'absolute' }}
215
226
  >
@@ -239,14 +250,28 @@ export function CircularProgress({
239
250
  </svg>
240
251
  </View>
241
252
 
242
- {/* Center value */}
243
- {showValue && !isIndeterminate && (
244
- <Text
245
- className="text-xs font-semibold text-text-primary absolute"
246
- style={{ fontSize: Math.max(size * 0.22, 10) }}
253
+ {/* Center: custom children take precedence over the showValue readout. */}
254
+ {children != null ? (
255
+ <View
256
+ style={{
257
+ position: 'absolute',
258
+ alignItems: 'center',
259
+ justifyContent: 'center',
260
+ pointerEvents: 'none',
261
+ }}
247
262
  >
248
- {formatValue(value, max)}
249
- </Text>
263
+ {children}
264
+ </View>
265
+ ) : (
266
+ showValue &&
267
+ !isIndeterminate && (
268
+ <Text
269
+ className="text-xs font-semibold text-text-primary absolute"
270
+ style={{ fontSize: Math.max(size * 0.22, 10) }}
271
+ >
272
+ {formatValue(value, max)}
273
+ </Text>
274
+ )
250
275
  )}
251
276
  </View>
252
277
  )
@@ -0,0 +1,101 @@
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
+ }