@titan-design/react-ui 0.4.0 → 0.6.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/MesoStatusCard-CQk71hSi.d.mts +77 -0
- package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
- package/dist/bodymap.d.mts +246 -3
- package/dist/bodymap.d.ts +246 -3
- package/dist/bodymap.js +232 -185
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +1963 -11
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +193 -744
- package/dist/index.d.ts +193 -744
- package/dist/index.js +1551 -1856
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +10770 -8213
- package/dist/index.mjs.map +1 -1
- package/dist/pages-D7Jlssvp.d.ts +791 -0
- package/dist/pages-MO0JCySL.d.mts +791 -0
- package/dist/pages.d.mts +4 -0
- package/dist/pages.d.ts +4 -0
- package/dist/pages.js +6694 -0
- package/dist/pages.js.map +1 -0
- package/dist/pages.mjs +6674 -0
- package/dist/pages.mjs.map +1 -0
- package/dist/theme/index.d.mts +473 -211
- package/dist/theme/index.d.ts +473 -211
- package/dist/theme/index.js +341 -127
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2085 -2
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +148 -139
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +582 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +11 -7
- package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
- package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
- package/src/components/custom/DateTime/DateTime.tsx +55 -15
- package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
- package/src/components/custom/Gauge/Gauge.tsx +6 -3
- package/src/components/custom/Scatter/Scatter.tsx +3 -5
- package/src/components/custom/Treemap/Treemap.tsx +3 -5
- package/src/components/custom/Typography/Typography.stories.tsx +45 -27
- package/src/components/custom/Typography/Typography.test.tsx +13 -3
- package/src/components/custom/Typography/Typography.tsx +5 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
- package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +6 -3
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
- package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
- package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.tsx +7 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -1
- package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
- package/src/components/custom/Workout/IntensityBar.tsx +113 -58
- package/src/components/custom/Workout/MesoCard.tsx +8 -3
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
- package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
- package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
- package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
- package/src/components/custom/Workout/PrBadge.tsx +7 -4
- package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
- package/src/components/custom/Workout/README.md +50 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
- package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
- package/src/components/custom/Workout/RestTimer.tsx +50 -44
- package/src/components/custom/Workout/SetRow.tsx +12 -2
- package/src/components/custom/Workout/Sparkline.tsx +2 -1
- package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
- package/src/components/custom/Workout/StatusDot.tsx +12 -9
- package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
- package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
- package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +109 -3
- package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
- package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
- package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
- package/src/components/custom/Workout/WeekRow.tsx +3 -2
- package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
- package/src/components/custom/Workout/WeightBadge.tsx +8 -4
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
- package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
- package/src/components/custom/Workout/icons.tsx +7 -0
- package/src/components/custom/Workout/index.ts +38 -47
- package/src/components/icons/SvgIcon.tsx +50 -0
- package/src/components/icons/icons.stories.tsx +57 -0
- package/src/components/icons/icons.test.tsx +43 -0
- package/src/components/icons/icons.tsx +45 -0
- package/src/components/icons/index.ts +4 -0
- package/src/components/index.ts +6 -0
- package/src/components/shell/BrandLockup.stories.tsx +27 -0
- package/src/components/shell/BrandLockup.test.tsx +23 -0
- package/src/components/shell/BrandLockup.tsx +47 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
- package/src/components/shell/DeviceIndicator.test.tsx +26 -0
- package/src/components/shell/DeviceIndicator.tsx +52 -0
- package/src/components/shell/DeviceMenu.stories.tsx +47 -0
- package/src/components/shell/DeviceMenu.test.tsx +40 -0
- package/src/components/shell/DeviceMenu.tsx +65 -0
- package/src/components/shell/DeviceRow.stories.tsx +38 -0
- package/src/components/shell/DeviceRow.test.tsx +37 -0
- package/src/components/shell/DeviceRow.tsx +58 -0
- package/src/components/shell/README.md +86 -0
- package/src/components/shell/SessionStatePill.stories.tsx +27 -0
- package/src/components/shell/SessionStatePill.test.tsx +25 -0
- package/src/components/shell/SessionStatePill.tsx +46 -0
- package/src/components/shell/TopBar.stories.tsx +49 -0
- package/src/components/shell/TopBar.test.tsx +36 -0
- package/src/components/shell/TopBar.tsx +89 -0
- package/src/components/shell/index.ts +8 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
- package/src/components/ui/indicator/Indicator.test.tsx +19 -1
- package/src/components/ui/indicator/Indicator.tsx +45 -6
- package/src/components/ui/indicator/index.ts +1 -1
- package/src/components/ui/spinner/Spinner.tsx +3 -2
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
- package/src/index.ts +3 -2
- package/src/pages.ts +61 -0
- package/src/test/stories-smoke.test.tsx +50 -0
- package/src/theme/ColorSystem.stories.tsx +323 -0
- package/src/theme/Colors.stories.tsx +69 -1
- package/src/theme/barrel.ts +54 -0
- package/src/theme/config.ts +16 -3
- package/src/theme/extracted-colors-dataviz.ts +21 -0
- package/src/theme/extracted-colors-ui.ts +20 -0
- package/src/theme/global.css +64 -52
- package/src/theme/gradients.test.ts +25 -0
- package/src/theme/gradients.ts +36 -0
- package/src/theme/index.ts +8 -43
- package/src/theme/native-theming-guard.test.ts +117 -0
- package/src/theme/tokens/primitives.test.ts +232 -0
- package/src/theme/tokens/primitives.ts +247 -0
- package/src/theme/tokens/semantic.ts +93 -81
- package/src/theme/workout-tokens.ts +22 -21
- package/src/utils/avatar-color.ts +3 -3
- package/tailwind.config.js +10 -3
- package/dist/bodymap-BhG55xHM.d.mts +0 -318
- package/dist/bodymap-BhG55xHM.d.ts +0 -318
- package/dist/chunk-2SISKTWM.mjs +0 -1027
- package/dist/chunk-2SISKTWM.mjs.map +0 -1
- package/dist/chunk-7XPB4NAO.mjs +0 -1013
- package/dist/chunk-7XPB4NAO.mjs.map +0 -1
- package/dist/chunk-SNVKL5WZ.mjs +0 -883
- package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useEffect, useState } from 'react'
|
|
3
3
|
import { View, Text, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
|
-
import {
|
|
4
|
+
import { StarIcon } from './icons'
|
|
5
5
|
import { BaseBadge } from './BaseBadge'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
+
|
|
8
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
6
9
|
|
|
7
10
|
export type PRType = 'e1rm' | 'weight' | 'reps' | 'volume' | 'velocity'
|
|
8
11
|
|
|
@@ -66,7 +69,7 @@ export function PrBadge({
|
|
|
66
69
|
testID="pr-badge-star"
|
|
67
70
|
{...props}
|
|
68
71
|
>
|
|
69
|
-
<
|
|
72
|
+
<StarIcon size={14} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
|
|
70
73
|
</View>
|
|
71
74
|
) : (
|
|
72
75
|
<BaseBadge
|
|
@@ -77,8 +80,8 @@ export function PrBadge({
|
|
|
77
80
|
>
|
|
78
81
|
<Text
|
|
79
82
|
style={{
|
|
80
|
-
fontSize:
|
|
81
|
-
color:
|
|
83
|
+
fontSize: 12,
|
|
84
|
+
color: BRAND_PRIMARY,
|
|
82
85
|
fontWeight: '700',
|
|
83
86
|
fontFamily: 'Inter, sans-serif',
|
|
84
87
|
}}
|
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, Pressable, type ViewProps } from 'react-native'
|
|
3
|
-
import {
|
|
3
|
+
import { StarIcon } from './icons'
|
|
4
4
|
import { Drawer, DrawerBody } from '../../ui/drawer'
|
|
5
5
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
7
|
|
|
7
|
-
const BRAND_PRIMARY = '
|
|
8
|
-
const RECENT_BORDER = 'rgba(
|
|
9
|
-
const RECENT_GLOW = '0 0 12px rgba(
|
|
8
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
9
|
+
const RECENT_BORDER = 'rgba(249,180,21,0.3)'
|
|
10
|
+
const RECENT_GLOW = '0 0 12px rgba(249,180,21,0.06)'
|
|
10
11
|
// Native-safe fallback for the conditional row border (recent uses a computed
|
|
11
12
|
// rgba, so className can't express both branches).
|
|
12
13
|
const BORDER_DEFAULT = resolveColor('border-default')
|
|
@@ -84,7 +85,7 @@ function PrRecordRow({ record, index }: { record: PrRecord; index: number }) {
|
|
|
84
85
|
}}
|
|
85
86
|
testID={`pr-history-modal-record-${index}`}
|
|
86
87
|
>
|
|
87
|
-
<
|
|
88
|
+
<StarIcon size={16} color={BRAND_PRIMARY} fill={BRAND_PRIMARY} strokeWidth={2} />
|
|
88
89
|
<Text
|
|
89
90
|
className="text-text-secondary"
|
|
90
91
|
style={{
|
|
@@ -8,8 +8,9 @@ import { WorkoutCard, type WorkoutStatus, type WorkoutMuscleGroup } from './Work
|
|
|
8
8
|
import { type WorkoutPillStatus } from './WorkoutPill'
|
|
9
9
|
import { type ExerciseCardProps } from './ExerciseCard'
|
|
10
10
|
import { cn } from '../../../utils/cn'
|
|
11
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
11
12
|
|
|
12
|
-
const BRAND_PRIMARY = '
|
|
13
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
13
14
|
|
|
14
15
|
/** A single workout within a planned week, plus its exercise breakdown. */
|
|
15
16
|
export interface PlanWorkout {
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# Workout component family
|
|
2
|
+
|
|
3
|
+
Training-specific components (badges, bars, timers, cards, charts, and page-level
|
|
4
|
+
screens) built on titan's atoms. They live flat on disk — the tiering below is a
|
|
5
|
+
documentation contract, not a directory layout, so promoting or composing a
|
|
6
|
+
component is pure import churn with no file moves.
|
|
7
|
+
|
|
8
|
+
## Tier map
|
|
9
|
+
|
|
10
|
+
**Atoms** — single-purpose, no cross-component state:
|
|
11
|
+
BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip · TempoBar ·
|
|
12
|
+
DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
|
|
13
|
+
SupersetWrapper · InputBar
|
|
14
|
+
|
|
15
|
+
**Molecules** — compose atoms, own a little local state:
|
|
16
|
+
VelocityStrip · SetRow · TempoDisplay · RestTimer · MesoProgressBar · WeekRow ·
|
|
17
|
+
WorkoutCard
|
|
18
|
+
|
|
19
|
+
**Organisms** — full features, often with their own data contract:
|
|
20
|
+
ExerciseCard · MesoCard · MesoStatusCard · PrHistoryModal · ReadinessCheck ·
|
|
21
|
+
StrengthTrendChart · CapacityBandChart · BodyMap · BodyMapDetailPanel
|
|
22
|
+
|
|
23
|
+
**Pages** — phone-shaped reference screens (whole-screen compositions):
|
|
24
|
+
ActiveWorkoutPage · ExerciseDetailPage · ProgramPlanningPage · TrainingStatusPage
|
|
25
|
+
|
|
26
|
+
## Subpaths
|
|
27
|
+
|
|
28
|
+
Two families are kept off the root barrel so `@titan-design/react-ui` stays free
|
|
29
|
+
of their heavy runtime dependencies. In both cases the value exports move to the
|
|
30
|
+
subpath while type-only re-exports remain on the root, so consumers can still
|
|
31
|
+
type props without pulling the dependency.
|
|
32
|
+
|
|
33
|
+
- **`@titan-design/react-ui/bodymap`** — isolates
|
|
34
|
+
`react-native-body-highlighter` (a native SVG dep). BodyMap, BodyMapDetailPanel,
|
|
35
|
+
TrainingStatusPage, and the muscle taxonomy live here.
|
|
36
|
+
- **`@titan-design/react-ui/pages`** — isolates the page-level organisms
|
|
37
|
+
(ActiveWorkoutPage, ExerciseDetailPage, ProgramPlanningPage). They are full app
|
|
38
|
+
screens orphaned by both consumers, kept as reference implementations while their
|
|
39
|
+
successors (the responsive level views) are built. TrainingStatusPage is the
|
|
40
|
+
exception — it stays under `/bodymap` because of the body-highlighter dep.
|
|
41
|
+
|
|
42
|
+
## Notes
|
|
43
|
+
|
|
44
|
+
- **BaseBadge is an internal composition primitive** — the shared shell that
|
|
45
|
+
WeightBadge and PrBadge build on. It is exempt from orphan accounting; it is not
|
|
46
|
+
meant to be consumed directly even though it is exported for composition.
|
|
47
|
+
- Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
|
|
48
|
+
SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
|
|
49
|
+
to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
|
|
50
|
+
is unchanged.
|
|
@@ -2,12 +2,15 @@
|
|
|
2
2
|
import { View, Text, Pressable } from 'react-native'
|
|
3
3
|
import { Card } from '../../ui/card'
|
|
4
4
|
import { Badge, type BadgeColor } from '../../ui/badge'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
6
|
|
|
6
|
-
const
|
|
7
|
+
const t = getSemanticColors('dark')
|
|
8
|
+
|
|
9
|
+
const BRAND_PRIMARY = t['brand-primary']
|
|
7
10
|
const BRAND_PRIMARY_SUBTLE = 'rgba(255,121,0,0.12)'
|
|
8
|
-
const STATUS_SUCCESS = '
|
|
9
|
-
const STATUS_WARNING = '
|
|
10
|
-
const STATUS_ERROR = '
|
|
11
|
+
const STATUS_SUCCESS = t['status-success']
|
|
12
|
+
const STATUS_WARNING = t['status-warning']
|
|
13
|
+
const STATUS_ERROR = t['status-error']
|
|
11
14
|
|
|
12
15
|
/** Emoji option sets per known factor, keyed by lowercase id/label. */
|
|
13
16
|
const EMOJI_SETS: Record<string, readonly string[]> = {
|
|
@@ -75,16 +75,51 @@ describe('RestTimer', () => {
|
|
|
75
75
|
})
|
|
76
76
|
})
|
|
77
77
|
|
|
78
|
-
describe('
|
|
79
|
-
it('
|
|
78
|
+
describe('display-only mode', () => {
|
|
79
|
+
it('renders the Skip and +30s actions by default', () => {
|
|
80
|
+
render(<RestTimer {...defaultProps} />)
|
|
81
|
+
expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
|
|
82
|
+
expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('hides the Skip and +30s actions when displayOnly', () => {
|
|
86
|
+
render(<RestTimer {...defaultProps} displayOnly />)
|
|
87
|
+
expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
|
|
88
|
+
expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it('keeps the countdown, progress bar, and next-set line when displayOnly', () => {
|
|
80
92
|
render(
|
|
81
93
|
<RestTimer
|
|
82
94
|
{...defaultProps}
|
|
95
|
+
elapsedMs={30000}
|
|
96
|
+
displayOnly
|
|
83
97
|
nextSetInfo="Bench Press — Set 3 of 4"
|
|
84
|
-
|
|
98
|
+
/>
|
|
99
|
+
)
|
|
100
|
+
expect(screen.getByTestId('rest-timer-time')).toHaveTextContent('2:00')
|
|
101
|
+
expect(screen.getByTestId('rest-timer-progress-fill')).toBeInTheDocument()
|
|
102
|
+
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
|
|
103
|
+
'Bench Press — Set 3 of 4'
|
|
85
104
|
)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does not fire callbacks in display-only mode (controls absent)', () => {
|
|
108
|
+
const onSkip = vi.fn()
|
|
109
|
+
const onAddTime = vi.fn()
|
|
110
|
+
render(<RestTimer {...defaultProps} onSkip={onSkip} onAddTime={onAddTime} displayOnly />)
|
|
111
|
+
expect(screen.queryByLabelText('Skip rest')).not.toBeInTheDocument()
|
|
112
|
+
expect(screen.queryByLabelText('Add 30 seconds')).not.toBeInTheDocument()
|
|
113
|
+
expect(onSkip).not.toHaveBeenCalled()
|
|
114
|
+
expect(onAddTime).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('next set info', () => {
|
|
119
|
+
it('displays nextSetInfo when provided', () => {
|
|
120
|
+
render(<RestTimer {...defaultProps} nextSetInfo="Bench Press — Set 3 of 4" />)
|
|
86
121
|
expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent(
|
|
87
|
-
'Bench Press — Set 3 of 4'
|
|
122
|
+
'Bench Press — Set 3 of 4'
|
|
88
123
|
)
|
|
89
124
|
})
|
|
90
125
|
|
|
@@ -127,9 +162,7 @@ describe('RestTimer', () => {
|
|
|
127
162
|
|
|
128
163
|
it('has timer role with remaining seconds in label', () => {
|
|
129
164
|
render(<RestTimer {...defaultProps} totalSeconds={90} elapsedMs={30000} />)
|
|
130
|
-
expect(
|
|
131
|
-
screen.getByLabelText('Rest timer, 60 seconds remaining'),
|
|
132
|
-
).toBeInTheDocument()
|
|
165
|
+
expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
|
|
133
166
|
})
|
|
134
167
|
|
|
135
168
|
it('skip button has accessible label', () => {
|
|
@@ -146,9 +179,7 @@ describe('RestTimer', () => {
|
|
|
146
179
|
|
|
147
180
|
describe('RestTimer zero-duration timer (NaN guard)', () => {
|
|
148
181
|
it('emits a valid 100% width (not NaN) when totalSeconds is 0', () => {
|
|
149
|
-
const { container } = render(
|
|
150
|
-
<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />,
|
|
151
|
-
)
|
|
182
|
+
const { container } = render(<RestTimer {...defaultProps} totalSeconds={0} elapsedMs={0} />)
|
|
152
183
|
expect(screen.getByTestId('rest-timer-progress-fill')).toHaveStyle({ width: '100%' })
|
|
153
184
|
expect(container.innerHTML).not.toContain('NaN')
|
|
154
185
|
})
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, Pressable } from 'react-native'
|
|
3
3
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
4
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
5
|
|
|
5
|
-
const BRAND_PRIMARY = '
|
|
6
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
6
7
|
|
|
7
8
|
export interface RestTimerProps {
|
|
8
9
|
totalSeconds: number
|
|
@@ -11,6 +12,8 @@ export interface RestTimerProps {
|
|
|
11
12
|
onAddTime: () => void
|
|
12
13
|
nextSetInfo?: string
|
|
13
14
|
visible: boolean
|
|
15
|
+
/** Passive/poll-only mode: hides the Skip and +30s controls, keeps the countdown, progress bar, and next-set line. */
|
|
16
|
+
displayOnly?: boolean
|
|
14
17
|
}
|
|
15
18
|
|
|
16
19
|
export function RestTimer({
|
|
@@ -20,6 +23,7 @@ export function RestTimer({
|
|
|
20
23
|
onAddTime,
|
|
21
24
|
nextSetInfo,
|
|
22
25
|
visible,
|
|
26
|
+
displayOnly = false,
|
|
23
27
|
}: RestTimerProps) {
|
|
24
28
|
if (!visible) return null
|
|
25
29
|
|
|
@@ -123,55 +127,57 @@ export function RestTimer({
|
|
|
123
127
|
/>
|
|
124
128
|
</View>
|
|
125
129
|
|
|
126
|
-
{/* Actions row */}
|
|
127
|
-
|
|
128
|
-
<
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
132
|
-
paddingVertical: 8,
|
|
133
|
-
paddingHorizontal: 20,
|
|
134
|
-
borderRadius: 8,
|
|
135
|
-
}}
|
|
136
|
-
accessibilityRole="button"
|
|
137
|
-
accessibilityLabel="Add 30 seconds"
|
|
138
|
-
testID="rest-timer-add-time"
|
|
139
|
-
>
|
|
140
|
-
<Text
|
|
141
|
-
className="text-text-secondary"
|
|
130
|
+
{/* Actions row — hidden in display-only (poll-only) mode */}
|
|
131
|
+
{!displayOnly && (
|
|
132
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
133
|
+
<Pressable
|
|
134
|
+
onPress={onAddTime}
|
|
142
135
|
style={{
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
136
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
137
|
+
paddingVertical: 8,
|
|
138
|
+
paddingHorizontal: 20,
|
|
139
|
+
borderRadius: 8,
|
|
146
140
|
}}
|
|
141
|
+
accessibilityRole="button"
|
|
142
|
+
accessibilityLabel="Add 30 seconds"
|
|
143
|
+
testID="rest-timer-add-time"
|
|
147
144
|
>
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
testID="rest-timer-skip"
|
|
162
|
-
>
|
|
163
|
-
<Text
|
|
145
|
+
<Text
|
|
146
|
+
className="text-text-secondary"
|
|
147
|
+
style={{
|
|
148
|
+
fontSize: 11,
|
|
149
|
+
fontFamily: 'Inter, sans-serif',
|
|
150
|
+
fontWeight: '600',
|
|
151
|
+
}}
|
|
152
|
+
>
|
|
153
|
+
+30s
|
|
154
|
+
</Text>
|
|
155
|
+
</Pressable>
|
|
156
|
+
<Pressable
|
|
157
|
+
onPress={onSkip}
|
|
164
158
|
style={{
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
159
|
+
backgroundColor: 'rgba(255,121,0,0.12)',
|
|
160
|
+
paddingVertical: 8,
|
|
161
|
+
paddingHorizontal: 20,
|
|
162
|
+
borderRadius: 8,
|
|
169
163
|
}}
|
|
164
|
+
accessibilityRole="button"
|
|
165
|
+
accessibilityLabel="Skip rest"
|
|
166
|
+
testID="rest-timer-skip"
|
|
170
167
|
>
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
168
|
+
<Text
|
|
169
|
+
style={{
|
|
170
|
+
fontSize: 11,
|
|
171
|
+
fontFamily: 'Inter, sans-serif',
|
|
172
|
+
fontWeight: '600',
|
|
173
|
+
color: BRAND_PRIMARY,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
Skip
|
|
177
|
+
</Text>
|
|
178
|
+
</Pressable>
|
|
179
|
+
</View>
|
|
180
|
+
)}
|
|
175
181
|
</View>
|
|
176
182
|
)
|
|
177
183
|
}
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text } from 'react-native'
|
|
3
|
-
import { VelocityStrip } from './VelocityStrip'
|
|
3
|
+
import { VelocityStrip, type VelocityZoneBandProp } from './VelocityStrip'
|
|
4
4
|
import { PrBadge, type PRType } from './PrBadge'
|
|
5
5
|
import { roundRpe, rpeColor, roundWeight } from '../../../utils/workout-format'
|
|
6
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
6
7
|
|
|
7
8
|
export type SetRowMode = 'active' | 'completed' | 'history'
|
|
8
9
|
|
|
@@ -14,7 +15,12 @@ export interface SetRowProps {
|
|
|
14
15
|
weight: number | null
|
|
15
16
|
rpe?: number | null
|
|
16
17
|
unit: 'lbs' | 'kg'
|
|
18
|
+
/** Per-rep MEAN concentric velocities (m/s). */
|
|
17
19
|
velocities?: number[]
|
|
20
|
+
/** Optional velocity-zone bands (WA `VelocityZones.bands`); default scale when absent. */
|
|
21
|
+
velocityZones?: readonly VelocityZoneBandProp[]
|
|
22
|
+
/** Live mode: index of the newest rep bar to animate (pop / new-peak bounce). */
|
|
23
|
+
velocityLiveRepIndex?: number
|
|
18
24
|
velocityExpanded?: boolean
|
|
19
25
|
onVelocityToggle?: () => void
|
|
20
26
|
setType?: string
|
|
@@ -43,6 +49,8 @@ export function SetRow({
|
|
|
43
49
|
rpe,
|
|
44
50
|
unit,
|
|
45
51
|
velocities,
|
|
52
|
+
velocityZones,
|
|
53
|
+
velocityLiveRepIndex,
|
|
46
54
|
velocityExpanded,
|
|
47
55
|
onVelocityToggle,
|
|
48
56
|
setType,
|
|
@@ -93,7 +101,7 @@ export function SetRow({
|
|
|
93
101
|
fontSize: 10,
|
|
94
102
|
fontWeight: '700',
|
|
95
103
|
fontFamily: 'Inter, sans-serif',
|
|
96
|
-
color:
|
|
104
|
+
color: WORKOUT_TOKENS.scale.orange,
|
|
97
105
|
backgroundColor: 'rgba(255,165,2,0.12)',
|
|
98
106
|
paddingVertical: 1,
|
|
99
107
|
paddingHorizontal: 5,
|
|
@@ -239,6 +247,8 @@ export function SetRow({
|
|
|
239
247
|
>
|
|
240
248
|
<VelocityStrip
|
|
241
249
|
velocities={velocities}
|
|
250
|
+
zones={velocityZones}
|
|
251
|
+
liveRepIndex={velocityLiveRepIndex}
|
|
242
252
|
expanded={velocityExpanded}
|
|
243
253
|
onToggle={onVelocityToggle}
|
|
244
254
|
variant={onVelocityToggle ? 'full' : 'mini'}
|
|
@@ -2,8 +2,9 @@
|
|
|
2
2
|
import React from 'react'
|
|
3
3
|
import { View, Text, type ViewProps } from 'react-native'
|
|
4
4
|
import { cn } from '../../../utils/cn'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
6
|
|
|
6
|
-
const BRAND_PRIMARY = '
|
|
7
|
+
const BRAND_PRIMARY = getSemanticColors('dark')['brand-primary']
|
|
7
8
|
|
|
8
9
|
export interface SparklineProps extends ViewProps {
|
|
9
10
|
data: number[]
|
|
@@ -103,13 +103,13 @@ describe('StatusDot', () => {
|
|
|
103
103
|
it('applies a box-shadow when glow is set on a solid variant', () => {
|
|
104
104
|
render(<StatusDot variant="success" glow />)
|
|
105
105
|
const dot = screen.getByTestId('status-dot')
|
|
106
|
-
expect(dot.style.boxShadow).toContain('rgba(
|
|
106
|
+
expect(dot.style.boxShadow).toContain('rgba(46,213,115,0.4)')
|
|
107
107
|
})
|
|
108
108
|
|
|
109
109
|
it('applies a box-shadow when glow is set on a ring variant', () => {
|
|
110
110
|
render(<StatusDot variant="on-track" glow />)
|
|
111
111
|
const dot = screen.getByTestId('status-dot')
|
|
112
|
-
expect(dot.style.boxShadow).toContain('rgba(
|
|
112
|
+
expect(dot.style.boxShadow).toContain('rgba(46,213,115,0.4)')
|
|
113
113
|
})
|
|
114
114
|
|
|
115
115
|
it('applies a gray box-shadow when glow is set on the future variant', () => {
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, Text, type ViewProps } from 'react-native'
|
|
3
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
|
+
|
|
5
|
+
const t = getSemanticColors('dark')
|
|
3
6
|
|
|
4
7
|
export type StatusDotVariant =
|
|
5
8
|
| 'success'
|
|
@@ -22,17 +25,17 @@ export interface StatusDotProps extends ViewProps {
|
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
const solidVariantColors: Record<string, string> = {
|
|
25
|
-
success: '
|
|
26
|
-
warning: '
|
|
27
|
-
error: '
|
|
28
|
+
success: t['status-success'],
|
|
29
|
+
warning: t['status-warning'],
|
|
30
|
+
error: t['status-error'],
|
|
28
31
|
neutral: '#6B7280',
|
|
29
32
|
}
|
|
30
33
|
|
|
31
34
|
const ringVariantStyles: Record<string, Record<string, unknown>> = {
|
|
32
35
|
'on-track': {
|
|
33
|
-
backgroundColor: 'rgba(
|
|
36
|
+
backgroundColor: 'rgba(46,213,115,0.15)',
|
|
34
37
|
borderWidth: 1,
|
|
35
|
-
borderColor: 'rgba(
|
|
38
|
+
borderColor: 'rgba(46,213,115,0.3)',
|
|
36
39
|
},
|
|
37
40
|
deviation: {
|
|
38
41
|
backgroundColor: 'rgba(245,158,11,0.15)',
|
|
@@ -48,11 +51,11 @@ const ringVariantStyles: Record<string, Record<string, unknown>> = {
|
|
|
48
51
|
}
|
|
49
52
|
|
|
50
53
|
const glowStyles: Record<StatusDotVariant, Record<string, unknown>> = {
|
|
51
|
-
success: { boxShadow: '0 0 4px rgba(
|
|
54
|
+
success: { boxShadow: '0 0 4px rgba(46,213,115,0.4)' },
|
|
52
55
|
warning: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
|
|
53
56
|
error: { boxShadow: '0 0 4px rgba(239,68,68,0.4)' },
|
|
54
57
|
neutral: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
|
|
55
|
-
'on-track': { boxShadow: '0 0 4px rgba(
|
|
58
|
+
'on-track': { boxShadow: '0 0 4px rgba(46,213,115,0.4)' },
|
|
56
59
|
deviation: { boxShadow: '0 0 4px rgba(245,158,11,0.4)' },
|
|
57
60
|
future: { boxShadow: '0 0 4px rgba(107,114,128,0.4)' },
|
|
58
61
|
}
|
|
@@ -69,8 +72,8 @@ const iconColors: Record<StatusDotVariant, string> = {
|
|
|
69
72
|
warning: '#6B4000',
|
|
70
73
|
error: '#5C1A1A',
|
|
71
74
|
neutral: '#D1D5DB',
|
|
72
|
-
'on-track': '
|
|
73
|
-
deviation: '
|
|
75
|
+
'on-track': t['status-success'],
|
|
76
|
+
deviation: t['status-warning'],
|
|
74
77
|
future: '#6B7280',
|
|
75
78
|
}
|
|
76
79
|
|
|
@@ -3,17 +3,20 @@ import { useEffect, useMemo, useState } from 'react'
|
|
|
3
3
|
import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
|
|
4
4
|
import { cn } from '../../../utils/cn'
|
|
5
5
|
import { resolveColor } from '../../../theme/resolve-color'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
7
|
|
|
7
|
-
const
|
|
8
|
+
const sem = getSemanticColors('dark')
|
|
9
|
+
|
|
10
|
+
const BRAND_PRIMARY = sem['brand-primary']
|
|
8
11
|
// Line/border color for the dashed projection, used where a className cannot
|
|
9
12
|
// apply (passed as a prop into an inline style, and a per-edge borderTopColor).
|
|
10
13
|
const PROJECTION_LINE_COLOR = resolveColor('text-tertiary')
|
|
11
|
-
const STATUS_SUCCESS = '
|
|
12
|
-
const STATUS_ERROR = '
|
|
13
|
-
const STATUS_WARNING = '
|
|
14
|
+
const STATUS_SUCCESS = sem['status-success']
|
|
15
|
+
const STATUS_ERROR = sem['status-error']
|
|
16
|
+
const STATUS_WARNING = sem['status-warning']
|
|
14
17
|
const GRID_LINE = 'rgba(255,255,255,0.06)'
|
|
15
|
-
const SUCCESS_PILL_BG = 'rgba(
|
|
16
|
-
const SUCCESS_PILL_BORDER = 'rgba(
|
|
18
|
+
const SUCCESS_PILL_BG = 'rgba(46,213,115,0.10)'
|
|
19
|
+
const SUCCESS_PILL_BORDER = 'rgba(46,213,115,0.20)'
|
|
17
20
|
const ERROR_PILL_BG = 'rgba(209,67,67,0.10)'
|
|
18
21
|
const ERROR_PILL_BORDER = 'rgba(209,67,67,0.20)'
|
|
19
22
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import React from 'react'
|
|
3
3
|
import { View, Text } from 'react-native'
|
|
4
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
5
|
|
|
5
6
|
export interface SupersetWrapperProps {
|
|
6
7
|
label?: string
|
|
@@ -10,7 +11,7 @@ export interface SupersetWrapperProps {
|
|
|
10
11
|
|
|
11
12
|
const DEFAULTS = {
|
|
12
13
|
label: 'SS',
|
|
13
|
-
color: '
|
|
14
|
+
color: getSemanticColors('dark')['brand-primary'],
|
|
14
15
|
bgBase: '#101010',
|
|
15
16
|
} as const
|
|
16
17
|
|
|
@@ -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: {
|
|
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]} />)
|