@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
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text, Pressable } from 'react-native'
|
|
3
|
+
import { CircularTimer } from './CircularTimer'
|
|
4
|
+
|
|
5
|
+
const meta: Meta<typeof CircularTimer> = {
|
|
6
|
+
title: 'Custom/CircularTimer',
|
|
7
|
+
component: CircularTimer,
|
|
8
|
+
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'A circular countdown / countup with an mm:ss readout in the center — the graphical ' +
|
|
14
|
+
'sibling of `TimerReadout`. Batteries-included: owns `useTimer` (pass controlled ' +
|
|
15
|
+
'`durationMs` + `elapsedMs`) and composes `CircularProgress` for the arc (`down` drains, ' +
|
|
16
|
+
'`up` fills). On completion a `down` timer with a `doneLabel` fills fully in `doneColor` ' +
|
|
17
|
+
'and reads the label (e.g. "GO"). Pass `controls` to render buttons below the ring, or ' +
|
|
18
|
+
"`fill` to make it responsive to its container. `RestTimer`'s `ring` variant wraps this.",
|
|
19
|
+
},
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
argTypes: {
|
|
23
|
+
mode: { control: 'inline-radio', options: ['down', 'up'] },
|
|
24
|
+
size: { control: { type: 'number', min: 96, max: 280, step: 4 } },
|
|
25
|
+
fill: { control: 'boolean' },
|
|
26
|
+
doneLabel: { control: 'text' },
|
|
27
|
+
},
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export default meta
|
|
31
|
+
type Story = StoryObj<typeof CircularTimer>
|
|
32
|
+
|
|
33
|
+
const dark: Decorator = (Story) => (
|
|
34
|
+
<View style={{ padding: 32, alignItems: 'center', backgroundColor: '#0E0E0E' }}>
|
|
35
|
+
<Story />
|
|
36
|
+
</View>
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
/** Controls-driven: flip `mode` / `durationMs` / `elapsedMs` / `size` / `doneLabel`. */
|
|
40
|
+
export const Playground: Story = {
|
|
41
|
+
args: { durationMs: 120000, elapsedMs: 47000, doneLabel: 'GO', size: 180 },
|
|
42
|
+
decorators: [dark],
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Countdown, midway — the ring drains from full. */
|
|
46
|
+
export const CountdownMidway: Story = {
|
|
47
|
+
args: { mode: 'down', durationMs: 120000, elapsedMs: 60000, size: 180 },
|
|
48
|
+
decorators: [dark],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Countup — the ring fills as time elapses. */
|
|
52
|
+
export const CountupMidway: Story = {
|
|
53
|
+
args: { mode: 'up', durationMs: 120000, elapsedMs: 40000, size: 180 },
|
|
54
|
+
decorators: [dark],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Completed countdown with a done label — full ring, flips to success, reads "GO". */
|
|
58
|
+
export const DoneWithLabel: Story = {
|
|
59
|
+
args: { mode: 'down', durationMs: 120000, elapsedMs: 120000, doneLabel: 'GO', size: 180 },
|
|
60
|
+
decorators: [dark],
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const DemoButton = ({ label }: { label: string }) => (
|
|
64
|
+
<Pressable
|
|
65
|
+
style={{
|
|
66
|
+
backgroundColor: 'rgba(255,255,255,0.06)',
|
|
67
|
+
paddingVertical: 8,
|
|
68
|
+
paddingHorizontal: 20,
|
|
69
|
+
borderRadius: 8,
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
<Text style={{ color: '#DADADA', fontSize: 11, fontWeight: '600' }}>{label}</Text>
|
|
73
|
+
</Pressable>
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
/** With controls rendered below the ring. */
|
|
77
|
+
export const WithControls: Story = {
|
|
78
|
+
args: {
|
|
79
|
+
durationMs: 120000,
|
|
80
|
+
elapsedMs: 47000,
|
|
81
|
+
size: 180,
|
|
82
|
+
controls: (
|
|
83
|
+
<View style={{ flexDirection: 'row', gap: 8 }}>
|
|
84
|
+
<DemoButton label="+30s" />
|
|
85
|
+
<DemoButton label="Skip" />
|
|
86
|
+
</View>
|
|
87
|
+
),
|
|
88
|
+
},
|
|
89
|
+
decorators: [dark],
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text } from 'react-native'
|
|
5
|
+
import { CircularTimer } from './CircularTimer'
|
|
6
|
+
|
|
7
|
+
describe('CircularTimer', () => {
|
|
8
|
+
it('renders a timer with a composed progressbar ring', () => {
|
|
9
|
+
render(<CircularTimer durationMs={120000} elapsedMs={30000} />)
|
|
10
|
+
expect(screen.getByTestId('circular-timer')).toBeInTheDocument()
|
|
11
|
+
expect(screen.getByRole('progressbar')).toBeInTheDocument()
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('shows the mm:ss remaining in the center while counting down', () => {
|
|
15
|
+
render(<CircularTimer durationMs={120000} elapsedMs={60000} />)
|
|
16
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('shows the elapsed mm:ss in up mode', () => {
|
|
20
|
+
render(<CircularTimer mode="up" durationMs={120000} elapsedMs={45000} />)
|
|
21
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('0:45')
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('renders the done label (not a time) when a countdown completes', () => {
|
|
25
|
+
render(<CircularTimer durationMs={120000} elapsedMs={120000} doneLabel="GO" />)
|
|
26
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps showing 0:00 at completion when no doneLabel is given', () => {
|
|
30
|
+
render(<CircularTimer durationMs={120000} elapsedMs={120000} />)
|
|
31
|
+
expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('0:00')
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('renders optional controls below the ring', () => {
|
|
35
|
+
render(<CircularTimer durationMs={120000} elapsedMs={0} controls={<Text>+30s</Text>} />)
|
|
36
|
+
expect(screen.getByText('+30s')).toBeInTheDocument()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('defaults its accessibility label to seconds remaining', () => {
|
|
40
|
+
render(<CircularTimer durationMs={120000} elapsedMs={60000} />)
|
|
41
|
+
expect(screen.getAllByLabelText('60 seconds remaining').length).toBeGreaterThan(0)
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('uses a supplied accessibility label', () => {
|
|
45
|
+
render(
|
|
46
|
+
<CircularTimer durationMs={120000} elapsedMs={60000} accessibilityLabel="Rest, 60s left" />
|
|
47
|
+
)
|
|
48
|
+
expect(screen.getAllByLabelText('Rest, 60s left').length).toBeGreaterThan(0)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('emits no NaN when durationMs is 0 (guard)', () => {
|
|
52
|
+
const { container } = render(<CircularTimer durationMs={0} elapsedMs={0} />)
|
|
53
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('has no accessibility violations', async () => {
|
|
57
|
+
const { container } = render(
|
|
58
|
+
<CircularTimer durationMs={120000} elapsedMs={30000} accessibilityLabel="Rest countdown" />
|
|
59
|
+
)
|
|
60
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { ReactNode } from 'react'
|
|
3
|
+
import { View, Text } from 'react-native'
|
|
4
|
+
import { CircularProgress, colorVarMap, type ProgressColor } from '../../ui/progress/Progress'
|
|
5
|
+
import { useTimer, type TimerMode } from '../../../hooks/useTimer'
|
|
6
|
+
|
|
7
|
+
export interface CircularTimerProps {
|
|
8
|
+
/** Total duration (ms). `down`: the countdown length; `up`: the target for progress. */
|
|
9
|
+
durationMs: number
|
|
10
|
+
/** Controlled elapsed time (ms). */
|
|
11
|
+
elapsedMs: number
|
|
12
|
+
/** `down` (default) counts remaining toward 0 (ring drains); `up` counts elapsed up (ring fills). */
|
|
13
|
+
mode?: TimerMode
|
|
14
|
+
/** Ring diameter (px) — the geometry/stroke reference. Default 160. */
|
|
15
|
+
size?: number
|
|
16
|
+
/** Ring stroke width (px). Default 8. */
|
|
17
|
+
strokeWidth?: number
|
|
18
|
+
/** Fill the parent container (responsive) instead of a fixed `size` box. */
|
|
19
|
+
fill?: boolean
|
|
20
|
+
/** Ring color while running. Default `primary`. */
|
|
21
|
+
color?: ProgressColor
|
|
22
|
+
/**
|
|
23
|
+
* `down` mode: the text shown in the center — and the color the ring fills to — when
|
|
24
|
+
* the countdown completes (e.g. `doneLabel="GO"`). Omit to keep showing `0:00`.
|
|
25
|
+
*/
|
|
26
|
+
doneLabel?: string
|
|
27
|
+
/** Ring + center color when complete (with {@link doneLabel}). Default `success`. */
|
|
28
|
+
doneColor?: ProgressColor
|
|
29
|
+
/** Optional controls rendered below the ring (e.g. +30s / Skip). */
|
|
30
|
+
controls?: ReactNode
|
|
31
|
+
/** Accessibility label for the timer. Defaults to a seconds-remaining description. */
|
|
32
|
+
accessibilityLabel?: string
|
|
33
|
+
testID?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A circular countdown / countup with a time readout in the center — the graphical
|
|
38
|
+
* sibling of {@link TimerReadout}. Batteries-included: owns {@link useTimer} (pass
|
|
39
|
+
* controlled `durationMs` + `elapsedMs`) and composes {@link CircularProgress} for the
|
|
40
|
+
* arc (`down` drains as time runs out, `up` fills), rendering the mm:ss label in its
|
|
41
|
+
* center slot. On completion (`down` + {@link doneLabel}) the ring fills fully in
|
|
42
|
+
* {@link doneColor} and the center reads the done label — the "go" moment. An optional
|
|
43
|
+
* {@link controls} node renders below the ring. Consumers (e.g. `RestTimer`'s `ring`
|
|
44
|
+
* variant) wrap this and add their own domain chrome.
|
|
45
|
+
*/
|
|
46
|
+
export function CircularTimer({
|
|
47
|
+
durationMs,
|
|
48
|
+
elapsedMs,
|
|
49
|
+
mode = 'down',
|
|
50
|
+
size = 160,
|
|
51
|
+
strokeWidth = 8,
|
|
52
|
+
fill = false,
|
|
53
|
+
color = 'primary',
|
|
54
|
+
doneLabel,
|
|
55
|
+
doneColor = 'success',
|
|
56
|
+
controls,
|
|
57
|
+
accessibilityLabel,
|
|
58
|
+
testID,
|
|
59
|
+
}: CircularTimerProps) {
|
|
60
|
+
const { remainingMs, progress, label, done } = useTimer({ mode, durationMs, elapsedMs })
|
|
61
|
+
|
|
62
|
+
const isDone = done && mode === 'down'
|
|
63
|
+
const showDone = isDone && doneLabel != null
|
|
64
|
+
// Down → the remaining fraction (ring drains); up → elapsed progress (ring fills).
|
|
65
|
+
// A completed countdown with a done label fills fully to signal "go".
|
|
66
|
+
const remainingFrac = durationMs > 0 ? Math.max(0, Math.min(1, remainingMs / durationMs)) : 0
|
|
67
|
+
const arc = mode === 'down' ? (showDone ? 1 : remainingFrac) : progress
|
|
68
|
+
const ringColor: ProgressColor = showDone ? doneColor : color
|
|
69
|
+
|
|
70
|
+
const remainingSec = Math.ceil(Math.max(0, remainingMs) / 1000)
|
|
71
|
+
const a11y =
|
|
72
|
+
accessibilityLabel ?? (isDone ? 'Timer complete' : `${remainingSec} seconds remaining`)
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<View
|
|
76
|
+
style={{ alignItems: 'center', gap: 16 }}
|
|
77
|
+
accessibilityRole="timer"
|
|
78
|
+
accessibilityLabel={a11y}
|
|
79
|
+
testID={testID ?? 'circular-timer'}
|
|
80
|
+
>
|
|
81
|
+
<CircularProgress
|
|
82
|
+
value={arc}
|
|
83
|
+
max={1}
|
|
84
|
+
size={size}
|
|
85
|
+
strokeWidth={strokeWidth}
|
|
86
|
+
fill={fill}
|
|
87
|
+
color={ringColor}
|
|
88
|
+
// A distinct static name for the ring (the live countdown text lives on the
|
|
89
|
+
// enclosing timer role) — satisfies the progressbar-name a11y rule without
|
|
90
|
+
// duplicating the timer label.
|
|
91
|
+
accessibilityLabel="Timer ring"
|
|
92
|
+
testID="circular-timer-ring"
|
|
93
|
+
>
|
|
94
|
+
<Text
|
|
95
|
+
className={showDone ? undefined : 'text-text-primary'}
|
|
96
|
+
style={{
|
|
97
|
+
fontSize: Math.round(size * (showDone ? 0.28 : 0.24)),
|
|
98
|
+
fontFamily: '"Space Grotesk", sans-serif',
|
|
99
|
+
fontWeight: '800',
|
|
100
|
+
fontVariant: ['tabular-nums'],
|
|
101
|
+
letterSpacing: showDone ? 1 : -1,
|
|
102
|
+
...(showDone ? { color: colorVarMap[doneColor] } : null),
|
|
103
|
+
}}
|
|
104
|
+
testID="circular-timer-label"
|
|
105
|
+
>
|
|
106
|
+
{showDone ? doneLabel : label}
|
|
107
|
+
</Text>
|
|
108
|
+
</CircularProgress>
|
|
109
|
+
{controls}
|
|
110
|
+
</View>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { CircularTimer, type CircularTimerProps } from './CircularTimer'
|
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
type IconProps,
|
|
13
13
|
} from '../../icons'
|
|
14
14
|
|
|
15
|
-
// Literal-hex status pins (
|
|
15
|
+
// Literal-hex status pins (getSemanticColors, not resolveColor) — `resolveColor()` yields a
|
|
16
16
|
// `var(...)` under the RNW vitest alias, which breaks `toHaveStyle` in tests.
|
|
17
17
|
const t = getSemanticColors('dark')
|
|
18
18
|
|
|
@@ -42,8 +42,8 @@ export const Vl20: Story = { args: { value: 21 } }
|
|
|
42
42
|
/** Stop zone — deep into the red. */
|
|
43
43
|
export const StopZone: Story = { args: { value: 34 } }
|
|
44
44
|
|
|
45
|
-
/**
|
|
46
|
-
export const WallDensity: Story = { args: { value: 21,
|
|
45
|
+
/** The across-the-room wall scale — `size="wall"` grows the track, needle and tick labels together. */
|
|
46
|
+
export const WallDensity: Story = { args: { value: 21, size: 'wall' } }
|
|
47
47
|
|
|
48
48
|
/** Custom scale — a tighter 0..30 domain with earlier thresholds. */
|
|
49
49
|
export const CustomScale: Story = {
|
|
@@ -71,3 +71,15 @@ describe('FatigueMeter', () => {
|
|
|
71
71
|
})
|
|
72
72
|
})
|
|
73
73
|
})
|
|
74
|
+
|
|
75
|
+
describe('FatigueMeter size="wall"', () => {
|
|
76
|
+
it('passes wall density through to the composed ZoneTrack (thicker needle)', () => {
|
|
77
|
+
render(<FatigueMeter value={20} size="wall" />)
|
|
78
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '7px' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the compact needle by default', () => {
|
|
82
|
+
render(<FatigueMeter value={20} />)
|
|
83
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '4px' })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import type { ViewProps } from 'react-native'
|
|
3
3
|
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
4
|
-
import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick } from './ZoneTrack'
|
|
4
|
+
import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick, type ZoneTrackSize } from './ZoneTrack'
|
|
5
5
|
|
|
6
6
|
const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
|
|
7
7
|
|
|
@@ -24,7 +24,9 @@ export interface FatigueMeterProps extends ViewProps {
|
|
|
24
24
|
zoneColors?: [string, string, string, string]
|
|
25
25
|
/** Tick labels at [min, ...thresholds, max]. Default ['fresh','VL10','VL20','VL30','stop']. */
|
|
26
26
|
labels?: string[]
|
|
27
|
-
/**
|
|
27
|
+
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale — bigger track, needle and tick labels). */
|
|
28
|
+
size?: ZoneTrackSize
|
|
29
|
+
/** Track height in px. Overrides the `size` default (14 default / 24 wall). */
|
|
28
30
|
trackHeight?: number
|
|
29
31
|
/** Needle colour. Default white. */
|
|
30
32
|
needleColor?: string
|
|
@@ -49,7 +51,8 @@ export function FatigueMeter({
|
|
|
49
51
|
thresholds = DEFAULT_THRESHOLDS,
|
|
50
52
|
zoneColors = DEFAULT_ZONE_COLORS,
|
|
51
53
|
labels = DEFAULT_LABELS,
|
|
52
|
-
|
|
54
|
+
size = 'default',
|
|
55
|
+
trackHeight,
|
|
53
56
|
needleColor,
|
|
54
57
|
className,
|
|
55
58
|
...props
|
|
@@ -71,6 +74,7 @@ export function FatigueMeter({
|
|
|
71
74
|
zones={zones}
|
|
72
75
|
min={0}
|
|
73
76
|
max={max}
|
|
77
|
+
size={size}
|
|
74
78
|
trackHeight={trackHeight}
|
|
75
79
|
ticks={ticks}
|
|
76
80
|
marker={{ type: 'needle', value, color: needleColor }}
|
|
@@ -8,7 +8,7 @@ component is pure import churn with no file moves.
|
|
|
8
8
|
## Tier map
|
|
9
9
|
|
|
10
10
|
**Atoms** — single-purpose, no cross-component state:
|
|
11
|
-
BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip ·
|
|
11
|
+
BaseBadge · WeightBadge · PrBadge · StatusDot · PlaceholderStrip ·
|
|
12
12
|
DeviationBar · IntensityBar · WorkoutPill · MuscleGroupChip · Sparkline ·
|
|
13
13
|
SupersetWrapper · InputBar · MetricCell · SetsRepsLoad · ExerciseIndicator · SetBar
|
|
14
14
|
|
|
@@ -48,6 +48,29 @@ type props without pulling the dependency.
|
|
|
48
48
|
letter-spacing-1 value/separator cell. `TempoDisplay` (tempo digits) and
|
|
49
49
|
`SetsRepsLoad` (sets × reps @ load) both compose it so the two read as one
|
|
50
50
|
visual language. Exported for composition, not for direct app use.
|
|
51
|
+
- **TempoDisplay is deliberately NOT decomposed further** — it is the single tempo
|
|
52
|
+
component (the standalone `TempoBar` was retired into it). It renders two modes from
|
|
53
|
+
one chip: the static phase-coloured **prescription** and, with the `live` prop, the
|
|
54
|
+
running tempo (per-phase countdown/count-up to 0.1s, a bottom-anchored phase-progress
|
|
55
|
+
fill, semantic pacing tones, and banked/frozen finals). Its internal parts —
|
|
56
|
+
`LiveTempoRow`, `LiveTempoCell`, `CellFill` (the fill behind a number), and the
|
|
57
|
+
`activeNumberTone` pacing helper — are **TempoDisplay-private with no second consumer**,
|
|
58
|
+
so per the ≥2-consumer rule they stay internal rather than becoming top-level primitives.
|
|
59
|
+
The only already-shared primitive is `MetricCell`.
|
|
60
|
+
|
|
61
|
+
**If reuse emerges, decompose along these seams** (in likely order):
|
|
62
|
+
1. **`CellFill` → a shared `ProgressCell`/`FillBehind` primitive** — the moment a
|
|
63
|
+
second component needs "a bottom-anchored progress fill behind centred text" (e.g. a
|
|
64
|
+
generic timer/meter cell). Cleanest extraction; pure presentation, no tempo semantics.
|
|
65
|
+
2. **`activeNumberTone` + `ON_TARGET_MS` → a `tempo-pacing` util** — if another surface
|
|
66
|
+
needs the same yellow/green/red "time-to-target" tone (a coach card, a rep-tempo
|
|
67
|
+
summary). It is pure logic, trivially portable.
|
|
68
|
+
3. **`LiveTempoRow` → a `TempoLiveRow` molecule** — only if a consumer wants the running
|
|
69
|
+
row *without* the chip chrome (label/background/padding). Until then the chrome and the
|
|
70
|
+
row belong together as one component with a `live` prop, not two.
|
|
71
|
+
|
|
72
|
+
Not worth splitting today (speculative extraction the workflow warns against); this note
|
|
73
|
+
is the trigger list for when it stops being speculative.
|
|
51
74
|
- **S3 session-rail family** — `SessionRail` (organism) composes the standalone
|
|
52
75
|
`ExerciseCardHeading` molecule per exercise, which composes an `ExerciseHeading`
|
|
53
76
|
info block + a `SetStrip`:
|
|
@@ -112,14 +135,58 @@ type props without pulling the dependency.
|
|
|
112
135
|
with a copy-paste `set` config in **`Workout/DataViz/VelocityStrip/Set
|
|
113
136
|
Modalities`** (each card carries a `Collapse` accordion; promoted from the
|
|
114
137
|
now-deleted `S3SetModalities` Lab specimen).
|
|
138
|
+
- **VelocityStrip `hero` variant** — the across-the-room, single-set **wall**
|
|
139
|
+
treatment (the north-star live page's velocity hero). Tall bars (default 220px
|
|
140
|
+
plot) with a per-bar velocity value label, a dashed **running-best reference
|
|
141
|
+
line** (unlabeled — the tallest bar already shows the number, and the container
|
|
142
|
+
a11y label carries it), and dashed **placeholders for the reps still to come**
|
|
143
|
+
driven by a new `targetReps` prop. Absorbs the R2 `HeroVelocityBars` candidate
|
|
144
|
+
into the atom rather than shipping a parallel component. Reuses the shared zone
|
|
145
|
+
scale (`barColorFor`) and the extracted `useLiveRepPop` entrance (now shared with
|
|
146
|
+
the framed `expanded` chart). **Layout:** width-fluid (flex bars, capped at 52px,
|
|
147
|
+
left-packed) with a caller-set fixed height; the eyebrow/section title is
|
|
148
|
+
organism chrome, not part of the primitive. **Live-update model:** the plan's
|
|
149
|
+
slots are pre-allocated (`max(done, target)` columns), so a landing rep converts
|
|
150
|
+
placeholder→bar in the *same* slot with a pop — no reflow within the plan; pair
|
|
151
|
+
with `scale="fixed"` so heights never rescale either. The one reflow case is
|
|
152
|
+
set-expansion **beyond** `targetReps` (AMRAP overflow adds a column and flex-
|
|
153
|
+
narrows the rest — currently snaps; smooth overflow reflow is a deferred
|
|
154
|
+
follow-up). Documented by the `HeroPlayground` / `Hero*` stories on the wall
|
|
155
|
+
background.
|
|
156
|
+
- **RestTimer `ring` variant** — the across-the-room **wall** rest treatment (the
|
|
157
|
+
north-star rest page). Built as a **three-tier decomposition** (not a one-off):
|
|
158
|
+
`CircularProgress` (atom, gained a **`children`** center slot + a **`fill`**
|
|
159
|
+
responsive mode) → **`CircularTimer`** (new molecule, `custom/CircularTimer/` — a
|
|
160
|
+
batteries-included circular countdown/countup; owns `useTimer`, renders the mm:ss
|
|
161
|
+
readout into the ring center, flips a completed `down` timer to a full `doneColor`
|
|
162
|
+
ring reading its `doneLabel`, and takes an optional `controls` slot) → **RestTimer
|
|
163
|
+
`ring`** which composes `CircularTimer` (`doneLabel="GO"`, `doneColor="success"`,
|
|
164
|
+
`controls={<RestActions/>}`) and adds the rest-specific next-set footer caption.
|
|
165
|
+
No third copy of the arc math and no `react-native-svg` — it rides
|
|
166
|
+
`CircularProgress`'s web-`<svg>` + free `stroke-dashoffset` transition. Absorbs
|
|
167
|
+
the R2 `RestRing` candidate into the atom; the mobile `CircularTimer` countup fork
|
|
168
|
+
is the natural second consumer of the new molecule (via `mode="up"`). `displayOnly`
|
|
169
|
+
hides the shared `RestActions` (+30s/Skip, extracted, used by both variants);
|
|
170
|
+
`size` (default 180) sets the diameter; the eyebrow/section title is organism
|
|
171
|
+
chrome. Web/RNW-only (like `CircularProgress`) — the wall variant; mobile keeps
|
|
172
|
+
`bar`. `Ring*` (RestTimer) + `Custom/CircularTimer` stories on the wall background.
|
|
173
|
+
- **`size="wall"` density (FatigueMeter · ZoneTrack)** — the across-the-room
|
|
174
|
+
dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
|
|
175
|
+
component; **`default` values are byte-identical** to before, so existing consumers are
|
|
176
|
+
untouched). **ZoneTrack** (the shared gauge primitive) gained `size` that scales track,
|
|
177
|
+
needle, tick lines and tick labels together; its *other* consumers (TrainingLoadGauge,
|
|
178
|
+
RpeCalibration) default to `default` and are unaffected. **FatigueMeter** passes `size`
|
|
179
|
+
through and lets `trackHeight` flow from it. `Wall*` / `WallDensity` stories on the wall
|
|
180
|
+
background. (**TempoBar** was retired here — the standalone active-tempo bar is superseded
|
|
181
|
+
by `TempoDisplay`'s live mode; see the TempoDisplay note.)
|
|
115
182
|
- Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
|
|
116
183
|
SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
|
|
117
184
|
to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
|
|
118
185
|
is unchanged.
|
|
119
186
|
- **`ExerciseIndicator` taxonomy (LOCKED, TD-03.51)** — one precedence-ranked slot
|
|
120
187
|
per exercise, six distinct kinds over four tier colors (bound to titan status
|
|
121
|
-
tokens, read as literal hex via `getSemanticColors('dark')
|
|
122
|
-
|
|
188
|
+
tokens, read as literal hex via `getSemanticColors('dark')`, not `resolveColor`,
|
|
189
|
+
so tests can assert them). Glyph = the specific signal; color = the severity tier. Chips are
|
|
123
190
|
outlined (border + glyph, no fill) so they never collide with the *filled*
|
|
124
191
|
velocity strip, and static (no pulse — they are chrome). Precedence, high → low:
|
|
125
192
|
|
|
@@ -1,12 +1,31 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import type { Decorator, Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
3
|
import { RestTimer } from './RestTimer'
|
|
4
4
|
|
|
5
5
|
const meta: Meta<typeof RestTimer> = {
|
|
6
6
|
title: 'Workout/RestTimer',
|
|
7
7
|
component: RestTimer,
|
|
8
8
|
tags: ['autodocs'],
|
|
9
|
+
parameters: {
|
|
10
|
+
docs: {
|
|
11
|
+
description: {
|
|
12
|
+
component:
|
|
13
|
+
'Controlled rest countdown (presentational off `useTimer`; pass `totalSeconds` + ' +
|
|
14
|
+
'`elapsedMs`). Two variants: `bar` (default) — the compact linear card (REST label + ' +
|
|
15
|
+
'mm:ss + progress bar + controls), the mobile bottom-bar treatment; and `ring` — the ' +
|
|
16
|
+
'across-the-room wall treatment: a draining countdown ring (composes `CircularProgress`) ' +
|
|
17
|
+
'with the mm:ss countdown in its center, flipping to a full `success` ring reading "GO" ' +
|
|
18
|
+
'when rest is up. `displayOnly` hides the +30s/Skip controls in either variant; `size` ' +
|
|
19
|
+
'sets the ring diameter.',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
9
23
|
argTypes: {
|
|
24
|
+
variant: {
|
|
25
|
+
control: 'inline-radio',
|
|
26
|
+
options: ['bar', 'ring'],
|
|
27
|
+
description: 'bar (compact linear card) or ring (wall countdown)',
|
|
28
|
+
},
|
|
10
29
|
totalSeconds: {
|
|
11
30
|
control: 'number',
|
|
12
31
|
description: 'Total rest duration in seconds',
|
|
@@ -15,6 +34,10 @@ const meta: Meta<typeof RestTimer> = {
|
|
|
15
34
|
control: 'number',
|
|
16
35
|
description: 'Elapsed time in milliseconds',
|
|
17
36
|
},
|
|
37
|
+
size: {
|
|
38
|
+
control: { type: 'number', min: 96, max: 280, step: 4 },
|
|
39
|
+
description: 'ring diameter in px (default 180)',
|
|
40
|
+
},
|
|
18
41
|
visible: {
|
|
19
42
|
control: 'boolean',
|
|
20
43
|
description: 'Whether the timer is visible',
|
|
@@ -69,3 +92,110 @@ export const JustStarted: Story = {
|
|
|
69
92
|
visible: true,
|
|
70
93
|
},
|
|
71
94
|
}
|
|
95
|
+
|
|
96
|
+
// --- Ring variant ------------------------------------------------------------
|
|
97
|
+
// The across-the-room wall rest treatment. Rendered on the north-star wall
|
|
98
|
+
// background so the draining ring reads the way it will live.
|
|
99
|
+
|
|
100
|
+
/** Wall-background frame for the ring stories (mirrors the north-star rest page). */
|
|
101
|
+
const ringDecorator: Decorator = (Story) => (
|
|
102
|
+
<View style={{ width: 420, padding: 32, alignItems: 'center', backgroundColor: '#0E0E0E' }}>
|
|
103
|
+
<Text
|
|
104
|
+
style={{
|
|
105
|
+
color: '#5A5A5A',
|
|
106
|
+
fontSize: 10,
|
|
107
|
+
fontWeight: '700',
|
|
108
|
+
letterSpacing: 1,
|
|
109
|
+
marginBottom: 20,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
REST · UNTIL NEXT SET · (organism chrome — not the component)
|
|
113
|
+
</Text>
|
|
114
|
+
<Story />
|
|
115
|
+
</View>
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
/** Controls-driven ring: flip `totalSeconds` / `elapsedMs` / `size` / `displayOnly`. */
|
|
119
|
+
export const RingPlayground: Story = {
|
|
120
|
+
args: {
|
|
121
|
+
variant: 'ring',
|
|
122
|
+
totalSeconds: 120,
|
|
123
|
+
elapsedMs: 47000,
|
|
124
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
125
|
+
onSkip: () => {},
|
|
126
|
+
onAddTime: () => {},
|
|
127
|
+
visible: true,
|
|
128
|
+
},
|
|
129
|
+
decorators: [ringDecorator],
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Just started: the ring is nearly full and draining. */
|
|
133
|
+
export const RingJustStarted: Story = {
|
|
134
|
+
args: {
|
|
135
|
+
variant: 'ring',
|
|
136
|
+
totalSeconds: 120,
|
|
137
|
+
elapsedMs: 8000,
|
|
138
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
139
|
+
onSkip: () => {},
|
|
140
|
+
onAddTime: () => {},
|
|
141
|
+
visible: true,
|
|
142
|
+
},
|
|
143
|
+
decorators: [ringDecorator],
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Midway: half the rest has drained. */
|
|
147
|
+
export const RingMidway: Story = {
|
|
148
|
+
args: {
|
|
149
|
+
variant: 'ring',
|
|
150
|
+
totalSeconds: 120,
|
|
151
|
+
elapsedMs: 60000,
|
|
152
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
153
|
+
onSkip: () => {},
|
|
154
|
+
onAddTime: () => {},
|
|
155
|
+
visible: true,
|
|
156
|
+
},
|
|
157
|
+
decorators: [ringDecorator],
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Almost done: a sliver of ring left. */
|
|
161
|
+
export const RingAlmostDone: Story = {
|
|
162
|
+
args: {
|
|
163
|
+
variant: 'ring',
|
|
164
|
+
totalSeconds: 120,
|
|
165
|
+
elapsedMs: 112000,
|
|
166
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
167
|
+
onSkip: () => {},
|
|
168
|
+
onAddTime: () => {},
|
|
169
|
+
visible: true,
|
|
170
|
+
},
|
|
171
|
+
decorators: [ringDecorator],
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Rest is up: the ring empties and flips to success (go time). */
|
|
175
|
+
export const RingDone: Story = {
|
|
176
|
+
args: {
|
|
177
|
+
variant: 'ring',
|
|
178
|
+
totalSeconds: 120,
|
|
179
|
+
elapsedMs: 120000,
|
|
180
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
181
|
+
onSkip: () => {},
|
|
182
|
+
onAddTime: () => {},
|
|
183
|
+
visible: true,
|
|
184
|
+
},
|
|
185
|
+
decorators: [ringDecorator],
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Display-only ring (poll mode): the ring + next-set line, no controls. */
|
|
189
|
+
export const RingDisplayOnly: Story = {
|
|
190
|
+
args: {
|
|
191
|
+
variant: 'ring',
|
|
192
|
+
totalSeconds: 120,
|
|
193
|
+
elapsedMs: 47000,
|
|
194
|
+
nextSetInfo: 'Next · Bench Press · set 3 of 4',
|
|
195
|
+
displayOnly: true,
|
|
196
|
+
onSkip: () => {},
|
|
197
|
+
onAddTime: () => {},
|
|
198
|
+
visible: true,
|
|
199
|
+
},
|
|
200
|
+
decorators: [ringDecorator],
|
|
201
|
+
}
|