@titan-design/react-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/index.d.mts +109 -12
  2. package/dist/index.d.ts +109 -12
  3. package/dist/index.js +648 -231
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +648 -232
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-DCFBqp79.d.mts} +16 -7
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-_fI3-x7Z.d.ts} +16 -7
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +495 -92
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +496 -93
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
  17. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  18. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  19. package/src/components/custom/CircularTimer/index.ts +1 -0
  20. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  21. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  22. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  23. package/src/components/custom/Workout/README.md +47 -0
  24. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  25. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  26. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  27. package/src/components/custom/Workout/TempoBar.stories.tsx +69 -0
  28. package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
  29. package/src/components/custom/Workout/TempoBar.tsx +107 -22
  30. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  31. package/src/components/custom/Workout/VelocityStrip.test.tsx +64 -0
  32. package/src/components/custom/Workout/VelocityStrip.tsx +287 -40
  33. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  34. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  35. package/src/components/custom/Workout/ZoneTrack.tsx +76 -18
  36. package/src/components/custom/index.ts +1 -0
  37. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  38. package/src/components/ui/alert/Alert.test.tsx +52 -0
  39. package/src/components/ui/alert/Alert.tsx +54 -20
  40. package/src/components/ui/progress/Progress.tsx +47 -22
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@titan-design/react-ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Cross-platform design system built on Gluestack UI",
5
5
  "author": "Henry Jewkes",
6
6
  "license": "MIT",
@@ -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'
@@ -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
- /** Chunky wall-glanceable density via a taller track. */
46
- export const WallDensity: Story = { args: { value: 21, trackHeight: 22 } }
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
- /** Track height in px. Default 14. */
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
- trackHeight = 14,
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 }}
@@ -112,6 +112,53 @@ type props without pulling the dependency.
112
112
  with a copy-paste `set` config in **`Workout/DataViz/VelocityStrip/Set
113
113
  Modalities`** (each card carries a `Collapse` accordion; promoted from the
114
114
  now-deleted `S3SetModalities` Lab specimen).
115
+ - **VelocityStrip `hero` variant** — the across-the-room, single-set **wall**
116
+ treatment (the north-star live page's velocity hero). Tall bars (default 220px
117
+ plot) with a per-bar velocity value label, a dashed **running-best reference
118
+ line** (unlabeled — the tallest bar already shows the number, and the container
119
+ a11y label carries it), and dashed **placeholders for the reps still to come**
120
+ driven by a new `targetReps` prop. Absorbs the R2 `HeroVelocityBars` candidate
121
+ into the atom rather than shipping a parallel component. Reuses the shared zone
122
+ scale (`barColorFor`) and the extracted `useLiveRepPop` entrance (now shared with
123
+ the framed `expanded` chart). **Layout:** width-fluid (flex bars, capped at 52px,
124
+ left-packed) with a caller-set fixed height; the eyebrow/section title is
125
+ organism chrome, not part of the primitive. **Live-update model:** the plan's
126
+ slots are pre-allocated (`max(done, target)` columns), so a landing rep converts
127
+ placeholder→bar in the *same* slot with a pop — no reflow within the plan; pair
128
+ with `scale="fixed"` so heights never rescale either. The one reflow case is
129
+ set-expansion **beyond** `targetReps` (AMRAP overflow adds a column and flex-
130
+ narrows the rest — currently snaps; smooth overflow reflow is a deferred
131
+ follow-up). Documented by the `HeroPlayground` / `Hero*` stories on the wall
132
+ background.
133
+ - **RestTimer `ring` variant** — the across-the-room **wall** rest treatment (the
134
+ north-star rest page). Built as a **three-tier decomposition** (not a one-off):
135
+ `CircularProgress` (atom, gained a **`children`** center slot + a **`fill`**
136
+ responsive mode) → **`CircularTimer`** (new molecule, `custom/CircularTimer/` — a
137
+ batteries-included circular countdown/countup; owns `useTimer`, renders the mm:ss
138
+ readout into the ring center, flips a completed `down` timer to a full `doneColor`
139
+ ring reading its `doneLabel`, and takes an optional `controls` slot) → **RestTimer
140
+ `ring`** which composes `CircularTimer` (`doneLabel="GO"`, `doneColor="success"`,
141
+ `controls={<RestActions/>}`) and adds the rest-specific next-set footer caption.
142
+ No third copy of the arc math and no `react-native-svg` — it rides
143
+ `CircularProgress`'s web-`<svg>` + free `stroke-dashoffset` transition. Absorbs
144
+ the R2 `RestRing` candidate into the atom; the mobile `CircularTimer` countup fork
145
+ is the natural second consumer of the new molecule (via `mode="up"`). `displayOnly`
146
+ hides the shared `RestActions` (+30s/Skip, extracted, used by both variants);
147
+ `size` (default 180) sets the diameter; the eyebrow/section title is organism
148
+ chrome. Web/RNW-only (like `CircularProgress`) — the wall variant; mobile keeps
149
+ `bar`. `Ring*` (RestTimer) + `Custom/CircularTimer` stories on the wall background.
150
+ - **`size="wall"` density (TempoBar · FatigueMeter · ZoneTrack)** — the across-the-room
151
+ dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
152
+ component; **`default` values are byte-identical** to before, so existing consumers are
153
+ untouched). **TempoBar** scales its bar/labels/durations and at wall spells out the full
154
+ phase words (Concentric / Hold / Eccentric); its completed segments are **colour-coded**
155
+ (a missed target reads red, not just a ✗), and the **active** segment shows a **delta
156
+ countdown** (`activeDisplay`) — remaining time to target counting to `0.0` then negative
157
+ (red) once over — **tap to toggle** delta ↔ elapsed/target. **ZoneTrack** (the shared
158
+ gauge primitive) gained `size` that scales track, needle, tick lines and tick labels
159
+ together; its *other* consumers (TrainingLoadGauge, RpeCalibration) default to `default`
160
+ and are unaffected. **FatigueMeter** passes `size` through and lets `trackHeight` flow
161
+ from it. `Wall*` / `WallDensity` stories on the wall background.
115
162
  - Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
116
163
  SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
117
164
  to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
@@ -1,12 +1,31 @@
1
- import React from 'react'
2
- import type { Meta, StoryObj } from '@storybook/react-vite'
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
+ }
@@ -184,3 +184,76 @@ describe('RestTimer zero-duration timer (NaN guard)', () => {
184
184
  expect(container.innerHTML).not.toContain('NaN')
185
185
  })
186
186
  })
187
+
188
+ describe('RestTimer ring variant', () => {
189
+ const ringProps = { ...defaultProps, variant: 'ring' as const }
190
+
191
+ it('renders the ring (composed CircularProgress) instead of the linear bar', () => {
192
+ render(<RestTimer {...ringProps} />)
193
+ expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
194
+ expect(screen.getByRole('progressbar')).toBeInTheDocument()
195
+ expect(screen.queryByTestId('rest-timer-progress-track')).not.toBeInTheDocument()
196
+ })
197
+
198
+ it('shows the mm:ss countdown in the ring center while resting', () => {
199
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
200
+ expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('1:00')
201
+ })
202
+
203
+ it('shows GO (not a time) when rest is complete', () => {
204
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={120000} />)
205
+ expect(screen.getByTestId('circular-timer-label')).toHaveTextContent('GO')
206
+ })
207
+
208
+ it('labels the completed ring as rest complete', () => {
209
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={125000} />)
210
+ expect(screen.getByLabelText('Rest complete, next set ready')).toBeInTheDocument()
211
+ })
212
+
213
+ it('labels the resting ring with seconds remaining', () => {
214
+ render(<RestTimer {...ringProps} totalSeconds={120} elapsedMs={60000} />)
215
+ expect(screen.getByLabelText('Rest timer, 60 seconds remaining')).toBeInTheDocument()
216
+ })
217
+
218
+ it('renders the next-set line and controls by default', () => {
219
+ render(<RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />)
220
+ expect(screen.getByTestId('rest-timer-next-set')).toHaveTextContent('Next · Bench · set 3 of 4')
221
+ expect(screen.getByTestId('rest-timer-add-time')).toBeInTheDocument()
222
+ expect(screen.getByTestId('rest-timer-skip')).toBeInTheDocument()
223
+ })
224
+
225
+ it('hides the controls in displayOnly mode (keeps ring + next-set)', () => {
226
+ render(<RestTimer {...ringProps} nextSetInfo="Next" displayOnly />)
227
+ expect(screen.getByTestId('rest-timer-ring')).toBeInTheDocument()
228
+ expect(screen.getByTestId('rest-timer-next-set')).toBeInTheDocument()
229
+ expect(screen.queryByTestId('rest-timer-skip')).not.toBeInTheDocument()
230
+ expect(screen.queryByTestId('rest-timer-add-time')).not.toBeInTheDocument()
231
+ })
232
+
233
+ it('fires onSkip / onAddTime from the ring controls', () => {
234
+ const onSkip = vi.fn()
235
+ const onAddTime = vi.fn()
236
+ render(<RestTimer {...ringProps} onSkip={onSkip} onAddTime={onAddTime} />)
237
+ fireEvent.click(screen.getByTestId('rest-timer-skip'))
238
+ fireEvent.click(screen.getByTestId('rest-timer-add-time'))
239
+ expect(onSkip).toHaveBeenCalledOnce()
240
+ expect(onAddTime).toHaveBeenCalledOnce()
241
+ })
242
+
243
+ it('respects visible=false', () => {
244
+ render(<RestTimer {...ringProps} visible={false} />)
245
+ expect(screen.queryByTestId('rest-timer-ring')).not.toBeInTheDocument()
246
+ })
247
+
248
+ it('emits no NaN when totalSeconds is 0', () => {
249
+ const { container } = render(<RestTimer {...ringProps} totalSeconds={0} elapsedMs={0} />)
250
+ expect(container.innerHTML).not.toContain('NaN')
251
+ })
252
+
253
+ it('has no accessibility violations', async () => {
254
+ const { container } = render(
255
+ <RestTimer {...ringProps} nextSetInfo="Next · Bench · set 3 of 4" />
256
+ )
257
+ expect(await axe(container)).toHaveNoViolations()
258
+ })
259
+ })