@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.
- package/dist/index.d.mts +112 -55
- package/dist/index.d.ts +112 -55
- package/dist/index.js +795 -501
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +795 -498
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
- package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +691 -250
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +692 -251
- package/dist/pages.mjs.map +1 -1
- package/package.json +1 -1
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
- package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
- package/src/components/custom/Workout/README.md +70 -3
- package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +141 -50
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
- package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
- package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
- package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
- package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/components/custom/index.ts +1 -0
- package/src/components/ui/alert/Alert.stories.tsx +85 -2
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/progress/Progress.tsx +47 -22
- package/src/lab/north-star/LivePage.tsx +101 -0
- package/src/lab/north-star/LiveView.tsx +409 -0
- package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
- package/src/lab/north-star/RestView.tsx +140 -0
- package/src/lab/north-star/fixtures.ts +224 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
- 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'
|
|
@@ -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<
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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=
|
|
139
|
+
<View className={isCompact ? 'mr-2' : 'mr-3 mt-0.5'}>
|
|
107
140
|
{icon || (
|
|
108
141
|
<Text
|
|
109
142
|
className={cn(
|
|
110
|
-
'
|
|
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={
|
|
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=
|
|
212
|
-
height=
|
|
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
|
|
243
|
-
{
|
|
244
|
-
<
|
|
245
|
-
|
|
246
|
-
|
|
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
|
-
{
|
|
249
|
-
</
|
|
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
|
+
}
|