@titan-design/react-ui 0.8.0 → 0.9.1
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 +19 -59
- package/dist/index.d.ts +19 -59
- package/dist/index.js +248 -371
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +249 -368
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DCFBqp79.d.mts → pages-D7hOD4Vj.d.mts} +1 -1
- package/dist/{pages-_fI3-x7Z.d.ts → pages-DyEx-lBf.d.ts} +1 -1
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +199 -161
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +199 -161
- package/dist/pages.mjs.map +1 -1
- package/package.json +2 -1
- package/src/components/custom/Workout/ExerciseIndicator.tsx +1 -1
- package/src/components/custom/Workout/README.md +33 -13
- 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.test.tsx +3 -3
- package/src/components/custom/Workout/VelocityStrip.tsx +45 -11
- package/src/components/custom/Workout/ZoneTrack.tsx +15 -1
- package/src/components/custom/Workout/index.ts +1 -9
- package/src/test/no-device-internals.test.ts +51 -0
- package/src/components/custom/Workout/TempoBar.stories.tsx +0 -164
- package/src/components/custom/Workout/TempoBar.test.tsx +0 -218
- package/src/components/custom/Workout/TempoBar.tsx +0 -300
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@titan-design/react-ui",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.1",
|
|
4
4
|
"description": "Cross-platform design system built on Gluestack UI",
|
|
5
5
|
"author": "Henry Jewkes",
|
|
6
6
|
"license": "MIT",
|
|
@@ -147,6 +147,7 @@
|
|
|
147
147
|
"files": [
|
|
148
148
|
"dist",
|
|
149
149
|
"src",
|
|
150
|
+
"!src/lab",
|
|
150
151
|
"docs",
|
|
151
152
|
"tailwind.config.js"
|
|
152
153
|
],
|
|
@@ -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
|
|
|
@@ -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`:
|
|
@@ -147,26 +170,23 @@ type props without pulling the dependency.
|
|
|
147
170
|
`size` (default 180) sets the diameter; the eyebrow/section title is organism
|
|
148
171
|
chrome. Web/RNW-only (like `CircularProgress`) — the wall variant; mobile keeps
|
|
149
172
|
`bar`. `Ring*` (RestTimer) + `Custom/CircularTimer` stories on the wall background.
|
|
150
|
-
- **`size="wall"` density (
|
|
173
|
+
- **`size="wall"` density (FatigueMeter · ZoneTrack)** — the across-the-room
|
|
151
174
|
dashboard scale, added as the idiomatic titan `size` union (a JS number-map per
|
|
152
175
|
component; **`default` values are byte-identical** to before, so existing consumers are
|
|
153
|
-
untouched). **
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
(
|
|
158
|
-
|
|
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.
|
|
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.)
|
|
162
182
|
- Badge icons (WeightBadge's dumbbell, PrBadge / PrHistoryModal's star) are inline
|
|
163
183
|
SVGs (`./icons.tsx`), not `lucide-react` — that dependency was dropped in 0.5.0
|
|
164
184
|
to keep the root barrel light. The SVG paths mirror lucide's glyphs so rendering
|
|
165
185
|
is unchanged.
|
|
166
186
|
- **`ExerciseIndicator` taxonomy (LOCKED, TD-03.51)** — one precedence-ranked slot
|
|
167
187
|
per exercise, six distinct kinds over four tier colors (bound to titan status
|
|
168
|
-
tokens, read as literal hex via `getSemanticColors('dark')
|
|
169
|
-
|
|
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
|
|
170
190
|
outlined (border + glyph, no fill) so they never collide with the *filled*
|
|
171
191
|
velocity strip, and static (no pulse — they are chrome). Precedence, high → low:
|
|
172
192
|
|
|
@@ -34,6 +34,12 @@ describe('SetsRepsLoad', () => {
|
|
|
34
34
|
expect(screen.getByTestId('sets-reps-load')).toHaveTextContent('15-20')
|
|
35
35
|
})
|
|
36
36
|
|
|
37
|
+
it('scales the cell font size via fontSize', () => {
|
|
38
|
+
render(<SetsRepsLoad sets={4} reps={8} load={140} fontSize={32} />)
|
|
39
|
+
// The value cells (and separators) render at the supplied size.
|
|
40
|
+
expect(screen.getByText('4')).toHaveStyle({ fontSize: 32 })
|
|
41
|
+
})
|
|
42
|
+
|
|
37
43
|
describe('accessibility', () => {
|
|
38
44
|
it('has no accessibility violations', async () => {
|
|
39
45
|
const { container } = render(<SetsRepsLoad sets={5} reps={8} load={145} />)
|
|
@@ -14,6 +14,8 @@ export interface SetsRepsLoadProps extends ViewProps {
|
|
|
14
14
|
load: number
|
|
15
15
|
/** Displayed load unit label (e.g. "lb", "kg"). */
|
|
16
16
|
unit?: string
|
|
17
|
+
/** Cell font size (px). Default 11 — the compact rail/card scale. Raise for a wall read-out. */
|
|
18
|
+
fontSize?: number
|
|
17
19
|
className?: string
|
|
18
20
|
}
|
|
19
21
|
|
|
@@ -27,6 +29,7 @@ export function SetsRepsLoad({
|
|
|
27
29
|
reps,
|
|
28
30
|
load,
|
|
29
31
|
unit = 'lb',
|
|
32
|
+
fontSize,
|
|
30
33
|
className,
|
|
31
34
|
...props
|
|
32
35
|
}: SetsRepsLoadProps) {
|
|
@@ -41,12 +44,18 @@ export function SetsRepsLoad({
|
|
|
41
44
|
testID="sets-reps-load"
|
|
42
45
|
{...props}
|
|
43
46
|
>
|
|
44
|
-
<MetricCell color={value}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
<MetricCell color={sep}>{` ${
|
|
48
|
-
<MetricCell color={value}
|
|
49
|
-
|
|
47
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
48
|
+
{sets}
|
|
49
|
+
</MetricCell>
|
|
50
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${TIMES} `}</MetricCell>
|
|
51
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
52
|
+
{reps}
|
|
53
|
+
</MetricCell>
|
|
54
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${AT} `}</MetricCell>
|
|
55
|
+
<MetricCell color={value} fontSize={fontSize}>
|
|
56
|
+
{load}
|
|
57
|
+
</MetricCell>
|
|
58
|
+
<MetricCell color={sep} fontSize={fontSize}>{` ${unit}`}</MetricCell>
|
|
50
59
|
</View>
|
|
51
60
|
)
|
|
52
61
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import type { ComponentProps } from 'react'
|
|
2
3
|
import { useEffect, useState } from 'react'
|
|
3
|
-
import { View } from 'react-native'
|
|
4
|
+
import { View, Text } from 'react-native'
|
|
4
5
|
import { TempoDisplay, type TempoLivePhase, type TempoLiveState } from './TempoDisplay'
|
|
5
6
|
|
|
6
7
|
// tempo = [eccentric, pauseBottom, concentric, pauseTop]
|
|
@@ -25,7 +26,8 @@ const meta: Meta<typeof TempoDisplay> = {
|
|
|
25
26
|
description: 'Tempo values [eccentric, pauseBottom, concentric, pauseTop] in seconds',
|
|
26
27
|
},
|
|
27
28
|
size: { control: 'select', options: ['sm', 'md'], description: 'Size variant' },
|
|
28
|
-
|
|
29
|
+
fontSize: { control: { type: 'range', min: 9, max: 48, step: 1 }, description: 'Digit size (overrides `size`)' },
|
|
30
|
+
showLabel: { control: 'boolean', description: 'Show the TEMPO caption' },
|
|
29
31
|
showInfo: { control: 'boolean', description: 'Show info tooltip on press' },
|
|
30
32
|
live: {
|
|
31
33
|
control: 'object',
|
|
@@ -37,98 +39,107 @@ const meta: Meta<typeof TempoDisplay> = {
|
|
|
37
39
|
export default meta
|
|
38
40
|
type Story = StoryObj<typeof TempoDisplay>
|
|
39
41
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
+
// The static prescription — the base tempo lockup (phase-coloured), no live readout.
|
|
43
|
+
// Add the `live` prop (see the Live stories below) to turn it into the running tempo.
|
|
44
|
+
export const Prescription: Story = {
|
|
45
|
+
args: { tempo: [3, 1, 2, 2] },
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
|
|
45
|
-
args: { tempo: [3, 1, 1, 0], colored: false },
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const ColoredExplosive: Story = {
|
|
49
|
-
args: { tempo: [1, 0, 1, 0], colored: true },
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
export const MonoExplosive: Story = {
|
|
53
|
-
args: { tempo: [1, 0, 1, 0], colored: false },
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
export const ColoredSlowEccentric: Story = {
|
|
57
|
-
args: { tempo: [5, 2, 1, 1], colored: true },
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export const SmallColored: Story = {
|
|
61
|
-
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: true },
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
export const SmallMono: Story = {
|
|
65
|
-
args: { tempo: [3, 1, 1, 0], size: 'sm', colored: false },
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export const AllVariants: Story = {
|
|
69
|
-
render: () => (
|
|
70
|
-
<View style={{ gap: 12 }}>
|
|
71
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
72
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} colored />
|
|
73
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />
|
|
74
|
-
</View>
|
|
75
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
76
|
-
<TempoDisplay tempo={[1, 0, 1, 0]} colored />
|
|
77
|
-
<TempoDisplay tempo={[1, 0, 1, 0]} colored={false} />
|
|
78
|
-
</View>
|
|
79
|
-
<View style={{ flexDirection: 'row', gap: 12, alignItems: 'center' }}>
|
|
80
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored />
|
|
81
|
-
<TempoDisplay tempo={[3, 1, 1, 0]} size="sm" colored={false} />
|
|
82
|
-
</View>
|
|
83
|
-
</View>
|
|
84
|
-
),
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// --- Live phase-fill (opt-in) ----------------------------------------------
|
|
88
|
-
|
|
89
|
-
// Deterministic mid-rep frames — the active phase digit fills against its target.
|
|
90
|
-
export const LiveEccentricInProgress: Story = {
|
|
91
|
-
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'eccentric', phaseElapsedMs: 1500 } },
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
// Concentric target is 1s; 1.6s elapsed is behind pace, so the digit turns red.
|
|
95
|
-
export const LiveBehindPace: Story = {
|
|
96
|
-
args: { tempo: [3, 1, 1, 0], live: { activePhase: 'concentric', phaseElapsedMs: 1600 } },
|
|
97
|
-
}
|
|
48
|
+
// --- Live tempo (opt-in via the `live` prop) --------------------------------
|
|
98
49
|
|
|
50
|
+
// A full rep cycle incl. a 2s rest at the top between reps.
|
|
99
51
|
const LIVE_CYCLE: { phase: TempoLivePhase; ms: number }[] = [
|
|
100
52
|
{ phase: 'eccentric', ms: 3000 },
|
|
101
53
|
{ phase: 'pauseBottom', ms: 1000 },
|
|
102
|
-
{ phase: 'concentric', ms:
|
|
54
|
+
{ phase: 'concentric', ms: 2000 },
|
|
55
|
+
{ phase: 'pauseTop', ms: 2000 },
|
|
103
56
|
]
|
|
104
57
|
|
|
105
|
-
function LiveTempoDemo(
|
|
106
|
-
|
|
58
|
+
function LiveTempoDemo({
|
|
59
|
+
fontSize,
|
|
60
|
+
showLabel = false,
|
|
61
|
+
liveReadout,
|
|
62
|
+
}: {
|
|
63
|
+
fontSize?: number
|
|
64
|
+
showLabel?: boolean
|
|
65
|
+
liveReadout?: 'countdown' | 'countup'
|
|
66
|
+
}) {
|
|
67
|
+
const [live, setLive] = useState<TempoLiveState>({
|
|
68
|
+
activePhase: 'eccentric',
|
|
69
|
+
phaseElapsedMs: 0,
|
|
70
|
+
completed: {},
|
|
71
|
+
})
|
|
107
72
|
useEffect(() => {
|
|
108
73
|
let idx = 0
|
|
109
74
|
let start = Date.now()
|
|
75
|
+
let completed: TempoLiveState['completed'] = {}
|
|
110
76
|
const id = setInterval(() => {
|
|
111
77
|
const step = LIVE_CYCLE[idx]
|
|
112
78
|
const elapsed = Date.now() - start
|
|
113
|
-
if (elapsed >= step.ms +
|
|
79
|
+
if (elapsed >= step.ms + 300) {
|
|
80
|
+
// Bank the phase's actual duration, advance; a new rep (wrap to idx 0) resets the row.
|
|
81
|
+
completed = { ...completed, [step.phase]: elapsed }
|
|
114
82
|
idx = (idx + 1) % LIVE_CYCLE.length
|
|
83
|
+
if (idx === 0) completed = {}
|
|
115
84
|
start = Date.now()
|
|
116
85
|
}
|
|
117
86
|
setLive({
|
|
118
87
|
activePhase: LIVE_CYCLE[idx].phase,
|
|
119
|
-
phaseElapsedMs: Math.min(elapsed, step.ms +
|
|
88
|
+
phaseElapsedMs: Math.min(elapsed, step.ms + 300),
|
|
89
|
+
completed,
|
|
120
90
|
})
|
|
121
91
|
}, 50)
|
|
122
92
|
return () => clearInterval(id)
|
|
123
93
|
}, [])
|
|
124
|
-
return
|
|
94
|
+
return (
|
|
95
|
+
<TempoDisplay
|
|
96
|
+
tempo={[3, 1, 2, 2]}
|
|
97
|
+
live={live}
|
|
98
|
+
fontSize={fontSize}
|
|
99
|
+
showLabel={showLabel}
|
|
100
|
+
showInfo={false}
|
|
101
|
+
liveReadout={liveReadout}
|
|
102
|
+
/>
|
|
103
|
+
)
|
|
125
104
|
}
|
|
126
105
|
|
|
127
|
-
// Self-driving demo cycling Ecc → Pause → Con
|
|
106
|
+
// Self-driving demo cycling Ecc → Pause → Con → 2s rest, banking each phase as it completes.
|
|
128
107
|
export const LiveAnimated: Story = {
|
|
129
108
|
render: () => <LiveTempoDemo />,
|
|
130
109
|
}
|
|
131
110
|
|
|
111
|
+
// Interactive: drag the size (form factor) and toggle the TEMPO label / readout mode live.
|
|
112
|
+
interface PlaygroundArgs {
|
|
113
|
+
fontSize: number
|
|
114
|
+
showLabel: boolean
|
|
115
|
+
liveReadout: 'countdown' | 'countup'
|
|
116
|
+
}
|
|
117
|
+
export const LivePlayground: StoryObj<PlaygroundArgs> = {
|
|
118
|
+
args: { fontSize: 26, showLabel: true, liveReadout: 'countdown' },
|
|
119
|
+
argTypes: {
|
|
120
|
+
fontSize: { control: { type: 'range', min: 12, max: 52, step: 2 }, description: 'Form factor' },
|
|
121
|
+
showLabel: { control: 'boolean', description: 'Toggle the TEMPO label' },
|
|
122
|
+
liveReadout: { control: 'inline-radio', options: ['countdown', 'countup'] },
|
|
123
|
+
},
|
|
124
|
+
render: (args) => (
|
|
125
|
+
<LiveTempoDemo
|
|
126
|
+
fontSize={args.fontSize}
|
|
127
|
+
showLabel={args.showLabel}
|
|
128
|
+
liveReadout={args.liveReadout}
|
|
129
|
+
/>
|
|
130
|
+
),
|
|
131
|
+
parameters: {
|
|
132
|
+
docs: {
|
|
133
|
+
description: {
|
|
134
|
+
story:
|
|
135
|
+
'The live tempo is responsive — the whole chip (height, digits, padding, label) ' +
|
|
136
|
+
'scales with the size. Drag **fontSize** to see any form factor, toggle **showLabel** ' +
|
|
137
|
+
'(works at every size), and switch the readout mode.',
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
}
|
|
142
|
+
|
|
132
143
|
// Static and live side by side — the same prescription, two modes.
|
|
133
144
|
export const StaticVsLive: Story = {
|
|
134
145
|
render: () => (
|
|
@@ -142,3 +153,167 @@ export const StaticVsLive: Story = {
|
|
|
142
153
|
</View>
|
|
143
154
|
),
|
|
144
155
|
}
|
|
156
|
+
|
|
157
|
+
// --- Active tempo conditions (the countdown/fill readout under different states) ----
|
|
158
|
+
|
|
159
|
+
/** One captioned live-tempo specimen at a larger scale so the fill + readout read clearly. */
|
|
160
|
+
function Condition({
|
|
161
|
+
caption,
|
|
162
|
+
...props
|
|
163
|
+
}: { caption: string } & ComponentProps<typeof TempoDisplay>) {
|
|
164
|
+
return (
|
|
165
|
+
<View style={{ gap: 5 }}>
|
|
166
|
+
<Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
|
|
167
|
+
{caption}
|
|
168
|
+
</Text>
|
|
169
|
+
<TempoDisplay showLabel={false} showInfo={false} fontSize={26} {...props} />
|
|
170
|
+
</View>
|
|
171
|
+
)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|
175
|
+
return (
|
|
176
|
+
<View style={{ gap: 12 }}>
|
|
177
|
+
<Text
|
|
178
|
+
style={{
|
|
179
|
+
color: '#E5E7EB',
|
|
180
|
+
fontFamily: 'Inter, sans-serif',
|
|
181
|
+
fontSize: 12,
|
|
182
|
+
fontWeight: '700',
|
|
183
|
+
letterSpacing: 1,
|
|
184
|
+
textTransform: 'uppercase',
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{title}
|
|
188
|
+
</Text>
|
|
189
|
+
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 36, alignItems: 'flex-start' }}>
|
|
190
|
+
{children}
|
|
191
|
+
</View>
|
|
192
|
+
</View>
|
|
193
|
+
)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// tempo [3,1,2,2] — 3s eccentric, 1s bottom pause, 2s concentric, and a 2s rest at the top.
|
|
197
|
+
const TEMPO = [3, 1, 2, 2] as [number, number, number, number]
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* The active tempo across the states it moves through: pacing (ahead → on-target →
|
|
201
|
+
* behind), each phase active, the countdown vs count-up readout, and idle. Static
|
|
202
|
+
* frames plus the self-driving animation, all in the one hyphenated display.
|
|
203
|
+
*/
|
|
204
|
+
export const ActiveTempoConditions: Story = {
|
|
205
|
+
render: () => (
|
|
206
|
+
<View style={{ gap: 28, padding: 8 }}>
|
|
207
|
+
<Section title="Pacing — the number by time-to-target (yellow ahead → green ±0.1 → red slow)">
|
|
208
|
+
<Condition
|
|
209
|
+
caption="Ahead · 1.2s → 1.8 left (yellow)"
|
|
210
|
+
tempo={TEMPO}
|
|
211
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1200 }}
|
|
212
|
+
/>
|
|
213
|
+
<Condition
|
|
214
|
+
caption="On target · 2.9s → 0.1 left (green)"
|
|
215
|
+
tempo={TEMPO}
|
|
216
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 2900 }}
|
|
217
|
+
/>
|
|
218
|
+
<Condition
|
|
219
|
+
caption="Slow · 3.8s → −0.8 over (red)"
|
|
220
|
+
tempo={TEMPO}
|
|
221
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 3800 }}
|
|
222
|
+
/>
|
|
223
|
+
</Section>
|
|
224
|
+
|
|
225
|
+
<Section title="Through a rep — earlier phases bank their final time, semantically toned">
|
|
226
|
+
<Condition
|
|
227
|
+
caption="Eccentric active · nothing banked yet"
|
|
228
|
+
tempo={TEMPO}
|
|
229
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
230
|
+
/>
|
|
231
|
+
<Condition
|
|
232
|
+
caption="Pause active · ecc banked slow (−0.5, red)"
|
|
233
|
+
tempo={TEMPO}
|
|
234
|
+
live={{ activePhase: 'pauseBottom', phaseElapsedMs: 500, completed: { eccentric: 3500 } }}
|
|
235
|
+
/>
|
|
236
|
+
<Condition
|
|
237
|
+
caption="Concentric active · ecc on-target, pause fast (+0.2, yellow)"
|
|
238
|
+
tempo={TEMPO}
|
|
239
|
+
live={{
|
|
240
|
+
activePhase: 'concentric',
|
|
241
|
+
phaseElapsedMs: 1000,
|
|
242
|
+
completed: { eccentric: 3000, pauseBottom: 800 },
|
|
243
|
+
}}
|
|
244
|
+
/>
|
|
245
|
+
<Condition
|
|
246
|
+
caption="Rest at top (2s) · whole rep banked (green/yellow/red)"
|
|
247
|
+
tempo={TEMPO}
|
|
248
|
+
live={{
|
|
249
|
+
activePhase: 'pauseTop',
|
|
250
|
+
phaseElapsedMs: 700,
|
|
251
|
+
completed: { eccentric: 3000, pauseBottom: 800, concentric: 2200 },
|
|
252
|
+
}}
|
|
253
|
+
/>
|
|
254
|
+
</Section>
|
|
255
|
+
|
|
256
|
+
<Section title="Readout mode — eccentric, 1.0s into a 3s phase">
|
|
257
|
+
<Condition
|
|
258
|
+
caption="Countdown → 0.0 · shows 2.0 left"
|
|
259
|
+
tempo={TEMPO}
|
|
260
|
+
liveReadout="countdown"
|
|
261
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
262
|
+
/>
|
|
263
|
+
<Condition
|
|
264
|
+
caption="Count-up → target · shows 1.0 elapsed"
|
|
265
|
+
tempo={TEMPO}
|
|
266
|
+
liveReadout="countup"
|
|
267
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
268
|
+
/>
|
|
269
|
+
</Section>
|
|
270
|
+
|
|
271
|
+
<Section title="Form factors — the view scales; the label toggles at any size">
|
|
272
|
+
<Condition
|
|
273
|
+
caption="Compact (14) · label off"
|
|
274
|
+
tempo={TEMPO}
|
|
275
|
+
fontSize={14}
|
|
276
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
277
|
+
/>
|
|
278
|
+
<Condition
|
|
279
|
+
caption="Default (22) · label on"
|
|
280
|
+
tempo={TEMPO}
|
|
281
|
+
fontSize={22}
|
|
282
|
+
showLabel
|
|
283
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
284
|
+
/>
|
|
285
|
+
<Condition
|
|
286
|
+
caption="Wall (34) · label on"
|
|
287
|
+
tempo={TEMPO}
|
|
288
|
+
fontSize={34}
|
|
289
|
+
showLabel
|
|
290
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 900, completed: { eccentric: 3000, pauseBottom: 1000 } }}
|
|
291
|
+
/>
|
|
292
|
+
</Section>
|
|
293
|
+
|
|
294
|
+
<Section title="Idle & animated">
|
|
295
|
+
<Condition
|
|
296
|
+
caption="Idle · no active phase (static)"
|
|
297
|
+
tempo={TEMPO}
|
|
298
|
+
live={{ activePhase: null, phaseElapsedMs: 0 }}
|
|
299
|
+
/>
|
|
300
|
+
<View style={{ gap: 5 }}>
|
|
301
|
+
<Text style={{ color: '#9CA3AF', fontFamily: 'Inter, sans-serif', fontSize: 11 }}>
|
|
302
|
+
Animated · Ecc → Pause → Con → 2s rest, banking each phase
|
|
303
|
+
</Text>
|
|
304
|
+
<LiveTempoDemo />
|
|
305
|
+
</View>
|
|
306
|
+
</Section>
|
|
307
|
+
</View>
|
|
308
|
+
),
|
|
309
|
+
parameters: {
|
|
310
|
+
docs: {
|
|
311
|
+
description: {
|
|
312
|
+
story:
|
|
313
|
+
'The active tempo under different conditions — pacing (ahead → on-target → behind), ' +
|
|
314
|
+
'each phase active, the countdown vs count-up readout, and idle — all in the one ' +
|
|
315
|
+
'hyphenated display with the vertical phase-progress fill.',
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
}
|
|
@@ -22,14 +22,9 @@ describe('TempoDisplay', () => {
|
|
|
22
22
|
expect(tempoValue.children.length).toBeGreaterThan(1)
|
|
23
23
|
})
|
|
24
24
|
|
|
25
|
-
it('renders mono variant', () => {
|
|
26
|
-
render(<TempoDisplay tempo={[3, 1, 1, 0]} colored={false} />)
|
|
27
|
-
expect(screen.getByText('3-1-1-0')).toBeInTheDocument()
|
|
28
|
-
})
|
|
29
|
-
|
|
30
25
|
it('renders order: eccentric-pauseBottom-concentric-pauseTop', () => {
|
|
31
|
-
render(<TempoDisplay tempo={[4, 3, 2, 1]}
|
|
32
|
-
expect(screen.
|
|
26
|
+
render(<TempoDisplay tempo={[4, 3, 2, 1]} />)
|
|
27
|
+
expect(screen.getByTestId('tempo-value')).toHaveTextContent('4-3-2-1')
|
|
33
28
|
})
|
|
34
29
|
|
|
35
30
|
it('has correct accessibility label', () => {
|
|
@@ -78,6 +73,11 @@ describe('TempoDisplay', () => {
|
|
|
78
73
|
render(<TempoDisplay tempo={[3, 1, 1, 0]} size="md" />)
|
|
79
74
|
expect(screen.getByTestId('tempo-value')).toBeInTheDocument()
|
|
80
75
|
})
|
|
76
|
+
|
|
77
|
+
it('overrides the digit font size via fontSize', () => {
|
|
78
|
+
render(<TempoDisplay tempo={[3, 1, 1, 0]} fontSize={32} showLabel={false} />)
|
|
79
|
+
expect(screen.getByText('3')).toHaveStyle({ fontSize: 32 })
|
|
80
|
+
})
|
|
81
81
|
})
|
|
82
82
|
|
|
83
83
|
describe('live phase-fill', () => {
|
|
@@ -86,15 +86,30 @@ describe('TempoDisplay', () => {
|
|
|
86
86
|
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
87
87
|
})
|
|
88
88
|
|
|
89
|
-
it('renders
|
|
89
|
+
it('renders the active phase as a live countdown readout with a fill', () => {
|
|
90
90
|
render(
|
|
91
91
|
<TempoDisplay
|
|
92
92
|
tempo={[3, 1, 1, 0]}
|
|
93
|
-
live={{ activePhase: 'eccentric', phaseElapsedMs:
|
|
93
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }}
|
|
94
94
|
/>
|
|
95
95
|
)
|
|
96
96
|
expect(screen.getByTestId('tempo-live-active')).toBeInTheDocument()
|
|
97
|
-
|
|
97
|
+
// eccentric target 3s, 1s in → 2.0s remaining (countdown is the default)
|
|
98
|
+
expect(screen.getByText('2.0')).toBeInTheDocument()
|
|
99
|
+
// the static eccentric digit is replaced by the readout
|
|
100
|
+
expect(screen.queryByText('3')).not.toBeInTheDocument()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('counts elapsed up when liveReadout is countup', () => {
|
|
104
|
+
render(
|
|
105
|
+
<TempoDisplay
|
|
106
|
+
tempo={[4, 1, 2, 3]}
|
|
107
|
+
liveReadout="countup"
|
|
108
|
+
live={{ activePhase: 'eccentric', phaseElapsedMs: 1500 }}
|
|
109
|
+
/>
|
|
110
|
+
)
|
|
111
|
+
// 1.5s elapsed of the 4s eccentric → 1.5 counting up (unique among the target readouts)
|
|
112
|
+
expect(screen.getByText('1.5')).toBeInTheDocument()
|
|
98
113
|
})
|
|
99
114
|
|
|
100
115
|
it('marks no phase active when idle (activePhase null)', () => {
|
|
@@ -102,16 +117,46 @@ describe('TempoDisplay', () => {
|
|
|
102
117
|
expect(screen.queryByTestId('tempo-live-active')).not.toBeInTheDocument()
|
|
103
118
|
})
|
|
104
119
|
|
|
105
|
-
it('
|
|
120
|
+
it('locks the phases completed earlier in the rep, and resets on the first phase', () => {
|
|
121
|
+
// Concentric active (3rd phase) → eccentric + pauseBottom are locked done.
|
|
122
|
+
const mid = render(
|
|
123
|
+
<TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'concentric', phaseElapsedMs: 800 }} />
|
|
124
|
+
)
|
|
125
|
+
expect(mid.getAllByTestId('tempo-live-done')).toHaveLength(2)
|
|
126
|
+
// Back to the first phase (next rep) → nothing is locked yet.
|
|
127
|
+
mid.rerender(
|
|
128
|
+
<TempoDisplay tempo={[3, 1, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 200 }} />
|
|
129
|
+
)
|
|
130
|
+
expect(mid.queryByTestId('tempo-live-done')).not.toBeInTheDocument()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('colours the TEMPO label live-muted while live', () => {
|
|
134
|
+
const { getByText, rerender } = render(<TempoDisplay tempo={[3, 1, 1, 0]} />)
|
|
135
|
+
const restColor = getByText('TEMPO').style.color
|
|
136
|
+
rerender(<TempoDisplay tempo={[3, 1, 1, 0]} live={{ activePhase: 'eccentric', phaseElapsedMs: 500 }} />)
|
|
137
|
+
expect(getByText('TEMPO').style.color).not.toBe(restColor)
|
|
138
|
+
})
|
|
139
|
+
|
|
140
|
+
it('shows upcoming phases as their prescribed time and the active as the live readout', () => {
|
|
141
|
+
render(
|
|
142
|
+
<TempoDisplay tempo={[5, 3, 2, 1]} live={{ activePhase: 'eccentric', phaseElapsedMs: 1000 }} />
|
|
143
|
+
)
|
|
144
|
+
// active eccentric: 5s target, 1s in → 4.0 remaining
|
|
145
|
+
expect(screen.getByText('4.0')).toBeInTheDocument()
|
|
146
|
+
// upcoming pauseBottom shows its prescribed 3s as 3.0 (no bare integer digits in live mode)
|
|
147
|
+
expect(screen.getByText('3.0')).toBeInTheDocument()
|
|
148
|
+
expect(screen.queryByText('5')).not.toBeInTheDocument()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
it('freezes a completed phase at its actual final time, not the target', () => {
|
|
106
152
|
render(
|
|
107
153
|
<TempoDisplay
|
|
108
|
-
tempo={[
|
|
109
|
-
live={{ activePhase: 'concentric', phaseElapsedMs: 500 }}
|
|
154
|
+
tempo={[3, 1, 2, 2]}
|
|
155
|
+
live={{ activePhase: 'concentric', phaseElapsedMs: 500, completed: { eccentric: 3400 } }}
|
|
110
156
|
/>
|
|
111
157
|
)
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
}
|
|
158
|
+
// eccentric ran 3.4s vs its 3s target → countdown froze at 3.0 − 3.4 = −0.4, not the target
|
|
159
|
+
expect(screen.getByText('-0.4')).toBeInTheDocument()
|
|
115
160
|
})
|
|
116
161
|
})
|
|
117
162
|
|