@titan-design/react-ui 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/dist/index.d.mts +112 -55
  2. package/dist/index.d.ts +112 -55
  3. package/dist/index.js +795 -501
  4. package/dist/index.js.map +1 -1
  5. package/dist/index.mjs +795 -498
  6. package/dist/index.mjs.map +1 -1
  7. package/dist/{pages-8u_4WbL-.d.mts → pages-D7hOD4Vj.d.mts} +17 -8
  8. package/dist/{pages-DaWiBOGa.d.ts → pages-DyEx-lBf.d.ts} +17 -8
  9. package/dist/pages.d.mts +1 -1
  10. package/dist/pages.d.ts +1 -1
  11. package/dist/pages.js +691 -250
  12. package/dist/pages.js.map +1 -1
  13. package/dist/pages.mjs +692 -251
  14. package/dist/pages.mjs.map +1 -1
  15. package/package.json +1 -1
  16. package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
  17. package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
  18. package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
  19. package/src/components/custom/CircularTimer/index.ts +1 -0
  20. package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
  21. package/src/components/custom/Workout/FatigueMeter.stories.tsx +2 -2
  22. package/src/components/custom/Workout/FatigueMeter.test.tsx +12 -0
  23. package/src/components/custom/Workout/FatigueMeter.tsx +7 -3
  24. package/src/components/custom/Workout/README.md +70 -3
  25. package/src/components/custom/Workout/RestTimer.stories.tsx +132 -2
  26. package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
  27. package/src/components/custom/Workout/RestTimer.tsx +141 -50
  28. package/src/components/custom/Workout/SetsRepsLoad.test.tsx +6 -0
  29. package/src/components/custom/Workout/SetsRepsLoad.tsx +15 -6
  30. package/src/components/custom/Workout/TempoDisplay.stories.tsx +240 -65
  31. package/src/components/custom/Workout/TempoDisplay.test.tsx +61 -16
  32. package/src/components/custom/Workout/TempoDisplay.tsx +173 -60
  33. package/src/components/custom/Workout/VelocityStrip.stories.tsx +152 -9
  34. package/src/components/custom/Workout/VelocityStrip.test.tsx +67 -3
  35. package/src/components/custom/Workout/VelocityStrip.tsx +323 -42
  36. package/src/components/custom/Workout/ZoneTrack.stories.tsx +2 -3
  37. package/src/components/custom/Workout/ZoneTrack.test.tsx +34 -0
  38. package/src/components/custom/Workout/ZoneTrack.tsx +91 -19
  39. package/src/components/custom/Workout/index.ts +1 -9
  40. package/src/components/custom/index.ts +1 -0
  41. package/src/components/ui/alert/Alert.stories.tsx +85 -2
  42. package/src/components/ui/alert/Alert.test.tsx +52 -0
  43. package/src/components/ui/alert/Alert.tsx +54 -20
  44. package/src/components/ui/progress/Progress.tsx +47 -22
  45. package/src/lab/north-star/LivePage.tsx +101 -0
  46. package/src/lab/north-star/LiveView.tsx +409 -0
  47. package/src/lab/north-star/LiveWallDashboard.stories.tsx +129 -0
  48. package/src/lab/north-star/RestView.tsx +140 -0
  49. package/src/lab/north-star/fixtures.ts +224 -0
  50. package/src/components/custom/Workout/TempoBar.stories.tsx +0 -95
  51. package/src/components/custom/Workout/TempoBar.test.tsx +0 -103
  52. package/src/components/custom/Workout/TempoBar.tsx +0 -215
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.9.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'
@@ -12,7 +12,7 @@ import {
12
12
  type IconProps,
13
13
  } from '../../icons'
14
14
 
15
- // Literal-hex status pins (the TempoBar pattern) — `resolveColor()` yields a
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
- /** 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 }}
@@ -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 · TempoBar ·
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')` the TempoBar
122
- pattern). Glyph = the specific signal; color = the severity tier. Chips are
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 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
+ }