@titan-design/react-ui 0.10.0 → 0.12.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/bodymap.js +173 -343
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +173 -343
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +416 -18
- package/dist/index.d.ts +416 -18
- package/dist/index.js +2512 -1307
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2480 -1299
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-DyEx-lBf.d.ts → pages-B1hqnGIC.d.ts} +149 -15
- package/dist/{pages-D7hOD4Vj.d.mts → pages-CvsFqNtx.d.mts} +149 -15
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1644 -1575
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1645 -1576
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +409 -415
- package/dist/theme/index.d.ts +409 -415
- package/dist/theme/index.js +234 -408
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +227 -399
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +141 -140
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +141 -140
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- package/src/arch/Architecture.stories.tsx +9 -9
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Fatigue/DualGhostSpark.stories.tsx +258 -0
- package/src/components/custom/Fatigue/DualGhostSpark.test.tsx +138 -0
- package/src/components/custom/Fatigue/DualGhostSpark.tsx +208 -0
- package/src/components/custom/Fatigue/FatigueLights.stories.tsx +74 -0
- package/src/components/custom/Fatigue/FatigueLights.test.tsx +33 -0
- package/src/components/custom/Fatigue/FatigueLights.tsx +83 -0
- package/src/components/custom/Fatigue/GhostBand.test.tsx +235 -0
- package/src/components/custom/Fatigue/GhostBand.tsx +246 -0
- package/src/components/custom/Fatigue/GhostBloom.tsx +132 -0
- package/src/components/custom/Fatigue/GhostSpark.stories.tsx +175 -0
- package/src/components/custom/Fatigue/GhostSpark.test.tsx +45 -0
- package/src/components/custom/Fatigue/GhostSpark.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.stories.tsx +126 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.test.tsx +28 -0
- package/src/components/custom/Fatigue/LiveFatigueCard.tsx +86 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.stories.tsx +95 -0
- package/src/components/custom/Fatigue/LiveFatiguePanel.tsx +151 -0
- package/src/components/custom/Fatigue/README.md +93 -0
- package/src/components/custom/Fatigue/RomProgressionChart.stories.tsx +94 -0
- package/src/components/custom/Fatigue/RomProgressionChart.test.tsx +31 -0
- package/src/components/custom/Fatigue/RomProgressionChart.tsx +132 -0
- package/src/components/custom/Fatigue/SilverRedPalette.stories.tsx +226 -0
- package/src/components/custom/Fatigue/VelocityHero.stories.tsx +70 -0
- package/src/components/custom/Fatigue/VelocityHero.test.tsx +26 -0
- package/src/components/custom/Fatigue/VelocityHero.tsx +46 -0
- package/src/components/custom/Fatigue/VerdictHero.stories.tsx +59 -0
- package/src/components/custom/Fatigue/VerdictHero.test.tsx +49 -0
- package/src/components/custom/Fatigue/VerdictHero.tsx +79 -0
- package/src/components/custom/Fatigue/fatigue-mock.test.ts +59 -0
- package/src/components/custom/Fatigue/fatigue-mock.ts +268 -0
- package/src/components/custom/Fatigue/fatigue-model.ts +129 -0
- package/src/components/custom/Fatigue/fatigue-tokens.test.ts +70 -0
- package/src/components/custom/Fatigue/fatigue-tokens.ts +180 -0
- package/src/components/custom/Fatigue/index.ts +41 -0
- package/src/components/custom/Fatigue/tempo-pacing.test.ts +176 -0
- package/src/components/custom/Fatigue/tempo-pacing.ts +112 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +4 -4
- package/src/components/custom/Table/Table.tsx +2 -2
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +3 -3
- package/src/components/custom/Workout/BaseBadge.test.tsx +6 -1
- package/src/components/custom/Workout/BaseBadge.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.tsx +2 -2
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +2 -2
- package/src/components/custom/Workout/DeviationBar.test.tsx +2 -1
- package/src/components/custom/Workout/DeviationBar.tsx +3 -2
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +7 -7
- package/src/components/custom/Workout/ExerciseCard.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +11 -5
- package/src/components/custom/Workout/InputBar.tsx +2 -2
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.tsx +3 -3
- package/src/components/custom/Workout/MesoCard.tsx +3 -1
- package/src/components/custom/Workout/MuscleGroupChip.tsx +4 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +2 -2
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +1 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/ReadinessCheck.tsx +2 -2
- package/src/components/custom/Workout/RestTimer.tsx +2 -2
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +3 -3
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionHeader.tsx +1 -1
- package/src/components/custom/Workout/SessionRail.stories.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +63 -18
- package/src/components/custom/Workout/SetBar.tsx +3 -3
- package/src/components/custom/Workout/SetRow.test.tsx +12 -9
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/StatusDot.tsx +5 -4
- package/src/components/custom/Workout/StrengthTrendChart.tsx +1 -1
- package/src/components/custom/Workout/TrainingStatusPage.tsx +2 -2
- package/src/components/custom/Workout/VelocityStrip.compact.stories.tsx +191 -0
- package/src/components/custom/Workout/VelocityStrip.dual.stories.tsx +186 -0
- package/src/components/custom/Workout/VelocityStrip.expanded.stories.tsx +296 -0
- package/src/components/custom/Workout/VelocityStrip.hero.stories.tsx +259 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +217 -250
- package/src/components/custom/Workout/VelocityStrip.test.tsx +199 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +3 -3
- package/src/components/custom/Workout/WeightBadge.test.tsx +6 -1
- package/src/components/custom/Workout/WeightBadge.tsx +2 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +3 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +5 -2
- package/src/components/custom/Workout/ZoneTrack.tsx +4 -4
- package/src/components/custom/Workout/index.ts +3 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +14 -8
- package/src/components/custom/Workout/velocity-story-kit.tsx +438 -0
- package/src/components/custom/charts/SetBarChart.test.tsx +275 -0
- package/src/components/custom/charts/SetBarChart.tsx +651 -0
- package/src/components/custom/charts/live-rep-growth.ts +91 -0
- package/src/components/custom/index.ts +1 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +3 -3
- package/src/components/shell/DeviceMenu.tsx +2 -2
- package/src/components/shell/SideNav.tsx +1 -1
- package/src/components/shell/TopBar.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/avatar/Avatar.tsx +2 -2
- package/src/components/ui/badge/Badge.tsx +2 -2
- package/src/components/ui/card/Card.tsx +3 -3
- package/src/components/ui/checkbox/Checkbox.tsx +1 -1
- package/src/components/ui/collapse/Collapse.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.tsx +3 -3
- package/src/components/ui/form-field/FormField.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.tsx +1 -1
- package/src/components/ui/menu/Menu.tsx +2 -2
- package/src/components/ui/pill/Pill.tsx +2 -2
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +3 -3
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/radio/Radio.tsx +4 -4
- package/src/components/ui/select/Select.tsx +3 -3
- package/src/components/ui/surface/Surface.stories.tsx +3 -3
- package/src/components/ui/surface/Surface.test.tsx +32 -19
- package/src/components/ui/surface/Surface.tsx +3 -3
- package/src/components/ui/surface/SurfaceContext.ts +19 -29
- package/src/components/ui/surface/surface.contract.test.ts +57 -44
- package/src/components/ui/switch/Switch.tsx +1 -1
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +5 -5
- package/src/components/ui/toolbar-button/ToolbarButton.test.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +36 -22
- package/src/stories/PresetShowcase.stories.tsx +21 -21
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +874 -0
- package/src/theme/ColorPrimitives.stories.tsx +348 -0
- package/src/theme/Depth.stories.tsx +312 -0
- package/src/theme/DepthCalibration.stories.tsx +655 -0
- package/src/theme/ThemeProvider.tsx +1 -1
- package/src/theme/barrel.ts +7 -14
- package/src/theme/color-stories.coverage.test.ts +125 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/color-utils.ts +197 -0
- package/src/theme/config.ts +0 -8
- package/src/theme/depth-calibration.test.tsx +97 -0
- package/src/theme/elevation.test.ts +62 -1
- package/src/theme/elevation.ts +34 -8
- package/src/theme/global.css +47 -53
- package/src/theme/grey-ramp.test.ts +166 -0
- package/src/theme/materials.test.ts +106 -0
- package/src/theme/materials.ts +211 -0
- package/src/theme/presets/audiobook.ts +4 -4
- package/src/theme/presets/types.ts +3 -3
- package/src/theme/semantic-contrast.test.ts +83 -0
- package/src/theme/tokens/primitives.ts +62 -115
- package/src/theme/tokens/semantic.ts +130 -82
- package/src/utils/colors.ts +1 -1
- package/tailwind.config.js +2 -5
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +0 -254
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +0 -145
- package/src/theme/ColorSystem.stories.tsx +0 -508
- package/src/theme/Colors.stories.tsx +0 -323
- package/src/theme/shadows.stories.tsx +0 -523
- package/src/theme/shadows.ts +0 -439
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { DualGhostSpark } from './DualGhostSpark'
|
|
4
|
+
import { greyRamp } from '../../../theme/tokens/primitives'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
|
+
import { buildMockModel, TARGET_TEMPO_SECONDS } from './fatigue-mock'
|
|
7
|
+
import { GRIND_THRESHOLD } from './fatigue-tokens'
|
|
8
|
+
import type { RepVelocityCurve } from './fatigue-model'
|
|
9
|
+
|
|
10
|
+
const PANEL_BG = greyRamp[950]
|
|
11
|
+
const PAGE_BG = greyRamp[975]
|
|
12
|
+
const t = getSemanticColors('dark')
|
|
13
|
+
|
|
14
|
+
const meta: Meta<typeof DualGhostSpark> = {
|
|
15
|
+
title: 'Workout/Fatigue/Dual Ghost Spark',
|
|
16
|
+
component: DualGhostSpark,
|
|
17
|
+
tags: ['autodocs'],
|
|
18
|
+
parameters: {
|
|
19
|
+
docs: {
|
|
20
|
+
description: {
|
|
21
|
+
component:
|
|
22
|
+
'Dual-Voltra ghost sparkline: ONE shared phase-coloured band with two mirrored blooms — the ' +
|
|
23
|
+
'LEFT device grows UP, the RIGHT device is the SAME bloom flipped `orientation="down"`. Both ' +
|
|
24
|
+
'wings share one time scale and one magnitude scale, so an L/R imbalance reads as bloom size ' +
|
|
25
|
+
'rather than as two independently normalized charts. Each wing is tinted INDEPENDENTLY by its ' +
|
|
26
|
+
'own current rep — silver while the rep stays controlled, warming through shades of red once ' +
|
|
27
|
+
'that side crosses the grind threshold — so one arm can go red while the other stays silver.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
}
|
|
32
|
+
export default meta
|
|
33
|
+
type Story = StoryObj<typeof DualGhostSpark>
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The HEALTHY baseline both stories start from: an early, controlled, on-tempo stretch of
|
|
37
|
+
* the mock set (through rep 3), whose current rep sits well under `GRIND_THRESHOLD` and so
|
|
38
|
+
* tints SILVER. The deep end of the mock set is already grinding, so starting there would
|
|
39
|
+
* paint every story red regardless of what the story is trying to say.
|
|
40
|
+
*/
|
|
41
|
+
const HEALTHY = buildMockModel(2).velocityCurves
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A device that is GRINDING: its concentric collapses AND its grind signature crosses
|
|
45
|
+
* `GRIND_THRESHOLD` — the signal `ghostLineColor` reads to warm the line silver → red.
|
|
46
|
+
* Magnitude and tint move together because on the wall they have ONE cause: the rep is
|
|
47
|
+
* failing. Degradation ramps across the set so the ghost fan shows the side fading, and
|
|
48
|
+
* the current rep carries the full `grind`.
|
|
49
|
+
*
|
|
50
|
+
* @param grind the current rep's grindSignature. Below {@link GRIND_THRESHOLD} (0.35) the
|
|
51
|
+
* line stays silver; 1 is a full collapse and the deepest red.
|
|
52
|
+
*/
|
|
53
|
+
function grinding(curves: RepVelocityCurve[], grind: number): RepVelocityCurve[] {
|
|
54
|
+
const last = Math.max(1, curves.length - 1)
|
|
55
|
+
return curves.map((c, i) => {
|
|
56
|
+
const g = grind * (i / last)
|
|
57
|
+
return {
|
|
58
|
+
...c,
|
|
59
|
+
// Only the concentric slows — a grind is a failure to move the load, not a slow lower.
|
|
60
|
+
samples: c.samples.map((s) => ({
|
|
61
|
+
...s,
|
|
62
|
+
velocityMps: s.phase === 'concentric' ? s.velocityMps * (1 - 0.6 * g) : s.velocityMps,
|
|
63
|
+
})),
|
|
64
|
+
grindSignature: Math.max(c.grindSignature, g),
|
|
65
|
+
tempoDeviation: Math.max(c.tempoDeviation ?? 0, g),
|
|
66
|
+
}
|
|
67
|
+
})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A weaker but still CONTROLLED device: the same reps at a fraction of the velocity —
|
|
71
|
+
* a strength imbalance, not a collapse, so the tint signals stay untouched and silver. */
|
|
72
|
+
function weaken(curves: RepVelocityCurve[], factor: number): RepVelocityCurve[] {
|
|
73
|
+
return curves.map((c) => ({
|
|
74
|
+
...c,
|
|
75
|
+
samples: c.samples.map((s) => ({ ...s, velocityMps: s.velocityMps * factor })),
|
|
76
|
+
}))
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** A device still mid-rep: its current rep's stream is cut short. */
|
|
80
|
+
function lagging(curves: RepVelocityCurve[], fraction: number): RepVelocityCurve[] {
|
|
81
|
+
const cur = curves[curves.length - 1]
|
|
82
|
+
const keep = Math.max(2, Math.round(cur.samples.length * fraction))
|
|
83
|
+
const samples = cur.samples.slice(0, keep)
|
|
84
|
+
const endMs = samples[samples.length - 1].tMs
|
|
85
|
+
return [
|
|
86
|
+
...curves.slice(0, -1),
|
|
87
|
+
{
|
|
88
|
+
...cur,
|
|
89
|
+
samples,
|
|
90
|
+
phaseSegments: cur.phaseSegments
|
|
91
|
+
.filter((s) => s.startMs < endMs)
|
|
92
|
+
.map((s) => ({ ...s, endMs: Math.min(s.endMs, endMs) })),
|
|
93
|
+
},
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const label = (s: string) => (
|
|
98
|
+
<Text
|
|
99
|
+
style={{ fontSize: 9, letterSpacing: 1, fontFamily: 'monospace', color: t['text-tertiary'] }}
|
|
100
|
+
>
|
|
101
|
+
{s}
|
|
102
|
+
</Text>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
const panel = (caption: string, child: React.ReactNode) => (
|
|
106
|
+
<View style={{ gap: 8, alignItems: 'flex-start' }}>
|
|
107
|
+
{label(caption)}
|
|
108
|
+
<View style={{ width: 400, backgroundColor: PANEL_BG, borderRadius: 12, padding: 16 }}>
|
|
109
|
+
{child}
|
|
110
|
+
</View>
|
|
111
|
+
</View>
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
const page = (children: React.ReactNode) => (
|
|
115
|
+
<View
|
|
116
|
+
style={{
|
|
117
|
+
backgroundColor: PAGE_BG,
|
|
118
|
+
padding: 28,
|
|
119
|
+
gap: 24,
|
|
120
|
+
flexDirection: 'row',
|
|
121
|
+
flexWrap: 'wrap',
|
|
122
|
+
alignItems: 'flex-start',
|
|
123
|
+
}}
|
|
124
|
+
>
|
|
125
|
+
{children}
|
|
126
|
+
</View>
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
/** Both devices moving the same load the same way, both still in control — the blooms
|
|
130
|
+
* mirror each other and BOTH wings read silver. This is the healthy baseline. */
|
|
131
|
+
export const Symmetric: Story = {
|
|
132
|
+
render: () =>
|
|
133
|
+
page(
|
|
134
|
+
panel(
|
|
135
|
+
'SYMMETRIC · MATCHED DEVICES · BOTH SILVER',
|
|
136
|
+
<DualGhostSpark
|
|
137
|
+
left={HEALTHY}
|
|
138
|
+
right={HEALTHY}
|
|
139
|
+
width={360}
|
|
140
|
+
height={232}
|
|
141
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
142
|
+
/>
|
|
143
|
+
)
|
|
144
|
+
),
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** The money shot: the LEFT arm holds its rep and stays SILVER while the RIGHT arm's
|
|
148
|
+
* concentric collapses — it crosses the grind threshold, so its wing warms into RED and,
|
|
149
|
+
* because both wings share ONE magnitude scale, shrinks at the same time. */
|
|
150
|
+
export const AsymmetricLeftRight: Story = {
|
|
151
|
+
render: () =>
|
|
152
|
+
page(
|
|
153
|
+
panel(
|
|
154
|
+
'ASYMMETRIC · LEFT SILVER · RIGHT GRINDING RED',
|
|
155
|
+
<DualGhostSpark
|
|
156
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
157
|
+
left={HEALTHY}
|
|
158
|
+
right={grinding(HEALTHY, 0.85)}
|
|
159
|
+
width={360}
|
|
160
|
+
height={232}
|
|
161
|
+
leftLabel="LEFT ARM"
|
|
162
|
+
rightLabel="RIGHT ARM"
|
|
163
|
+
/>
|
|
164
|
+
)
|
|
165
|
+
),
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A strength imbalance with control intact: the right device only reaches ~55% of the
|
|
169
|
+
* left's velocity, so its bloom is visibly smaller — but nothing is FAILING, so both
|
|
170
|
+
* wings stay silver. The contrast with {@link AsymmetricLeftRight} is the point: bloom
|
|
171
|
+
* size reads output, tint reads control, and they are independent readouts. */
|
|
172
|
+
export const AsymmetricControlled: Story = {
|
|
173
|
+
render: () =>
|
|
174
|
+
page(
|
|
175
|
+
panel(
|
|
176
|
+
'ASYMMETRIC · RIGHT AT ~55% VELOCITY · BOTH STILL SILVER',
|
|
177
|
+
<DualGhostSpark
|
|
178
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
179
|
+
left={HEALTHY}
|
|
180
|
+
right={weaken(HEALTHY, 0.55)}
|
|
181
|
+
width={360}
|
|
182
|
+
height={232}
|
|
183
|
+
leftLabel="LEFT ARM"
|
|
184
|
+
rightLabel="RIGHT ARM"
|
|
185
|
+
/>
|
|
186
|
+
)
|
|
187
|
+
),
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/** One side lagging: the right device is still mid-rep, so its line stops short of the
|
|
191
|
+
* left's and the band goes idle where the two devices no longer share a phase. Both sides
|
|
192
|
+
* are CONTROLLED, so both read silver — the only difference here is temporal. */
|
|
193
|
+
export const OneSideLagging: Story = {
|
|
194
|
+
render: () =>
|
|
195
|
+
page(
|
|
196
|
+
panel(
|
|
197
|
+
'LAGGING · RIGHT STILL MID-REP · BOTH CONTROLLED',
|
|
198
|
+
<DualGhostSpark
|
|
199
|
+
left={HEALTHY}
|
|
200
|
+
right={lagging(HEALTHY, 0.6)}
|
|
201
|
+
width={360}
|
|
202
|
+
height={232}
|
|
203
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
204
|
+
/>
|
|
205
|
+
)
|
|
206
|
+
),
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The tint ramp itself, one panel per step: the LEFT arm is held healthy as a silver
|
|
210
|
+
* reference while the RIGHT arm's grind signature is walked from controlled up to a full
|
|
211
|
+
* collapse. The first two steps sit BELOW `GRIND_THRESHOLD` and stay silver (dimming a
|
|
212
|
+
* touch as tempo drifts); every step at or above it is a shade of red. */
|
|
213
|
+
export const TintRange: Story = {
|
|
214
|
+
render: () =>
|
|
215
|
+
page(
|
|
216
|
+
<>
|
|
217
|
+
{[0, 0.2, GRIND_THRESHOLD, 0.55, 0.8, 1].map((grind) => (
|
|
218
|
+
<View key={grind}>
|
|
219
|
+
{panel(
|
|
220
|
+
`RIGHT GRIND ${grind.toFixed(2)} · ${grind < GRIND_THRESHOLD ? 'CONTROLLED · SILVER' : 'GRINDING · RED'}`,
|
|
221
|
+
<DualGhostSpark
|
|
222
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
223
|
+
left={HEALTHY}
|
|
224
|
+
right={grinding(HEALTHY, grind)}
|
|
225
|
+
width={360}
|
|
226
|
+
height={232}
|
|
227
|
+
leftLabel="LEFT · HEALTHY"
|
|
228
|
+
rightLabel={`RIGHT · GRIND ${grind.toFixed(2)}`}
|
|
229
|
+
/>
|
|
230
|
+
)}
|
|
231
|
+
</View>
|
|
232
|
+
))}
|
|
233
|
+
</>
|
|
234
|
+
),
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** No data on either device — a reserved empty box, no axis furniture. */
|
|
238
|
+
export const Empty: Story = {
|
|
239
|
+
render: () =>
|
|
240
|
+
page(
|
|
241
|
+
<>
|
|
242
|
+
{panel(
|
|
243
|
+
'EMPTY · NO REPS ON EITHER DEVICE',
|
|
244
|
+
<DualGhostSpark left={[]} right={[]} width={360} height={232} />
|
|
245
|
+
)}
|
|
246
|
+
{panel(
|
|
247
|
+
'SINGLE-SIDED · ONLY THE LEFT DEVICE BOUND',
|
|
248
|
+
<DualGhostSpark
|
|
249
|
+
left={HEALTHY}
|
|
250
|
+
right={[]}
|
|
251
|
+
width={360}
|
|
252
|
+
height={232}
|
|
253
|
+
targetTempoSeconds={TARGET_TEMPO_SECONDS}
|
|
254
|
+
/>
|
|
255
|
+
)}
|
|
256
|
+
</>
|
|
257
|
+
),
|
|
258
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { DualGhostSpark, mergePhaseSegments } from './DualGhostSpark'
|
|
4
|
+
import { buildMockModel } from './fatigue-mock'
|
|
5
|
+
import type { PhaseSegment, RepVelocityCurve } from './fatigue-model'
|
|
6
|
+
|
|
7
|
+
const CURVES = buildMockModel(5).velocityCurves
|
|
8
|
+
|
|
9
|
+
function scaled(curves: RepVelocityCurve[], factor: number): RepVelocityCurve[] {
|
|
10
|
+
return curves.map((c) => ({
|
|
11
|
+
...c,
|
|
12
|
+
samples: c.samples.map((s) => ({ ...s, velocityMps: s.velocityMps * factor })),
|
|
13
|
+
}))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** On-curve points of a smoothPath `d`: the `M` point then each cubic's endpoint. */
|
|
17
|
+
function anchors(d: string): Array<[number, number]> {
|
|
18
|
+
const nums = (d.match(/-?\d+(?:\.\d+)?/g) ?? []).map(Number)
|
|
19
|
+
const pairs: Array<[number, number]> = []
|
|
20
|
+
for (let i = 0; i + 1 < nums.length; i += 2) pairs.push([nums[i], nums[i + 1]])
|
|
21
|
+
return pairs.filter((_, i) => i === 0 || i % 3 === 0)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The current (tinted) line of one wing — the last path drawn in its group. */
|
|
25
|
+
function wingLine(container: HTMLElement, side: 'left' | 'right'): Array<[number, number]> {
|
|
26
|
+
const paths = container.querySelectorAll(`[data-testid="dual-ghost-bloom-${side}"] path`)
|
|
27
|
+
const d = paths[paths.length - 1].getAttribute('d') ?? ''
|
|
28
|
+
return anchors(d)
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const ySpan = (pts: Array<[number, number]>) =>
|
|
32
|
+
Math.max(...pts.map((p) => p[1])) - Math.min(...pts.map((p) => p[1]))
|
|
33
|
+
const xMax = (pts: Array<[number, number]>) => Math.max(...pts.map((p) => p[0]))
|
|
34
|
+
|
|
35
|
+
function renderDual(left: RepVelocityCurve[], right: RepVelocityCurve[]) {
|
|
36
|
+
return render(<DualGhostSpark left={left} right={right} width={360} height={232} />)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
describe('DualGhostSpark', () => {
|
|
40
|
+
it('renders both device blooms for a populated dual set', () => {
|
|
41
|
+
const { container } = renderDual(CURVES, CURVES)
|
|
42
|
+
expect(screen.getByTestId('dual-ghost-spark')).toBeInTheDocument()
|
|
43
|
+
expect(container.querySelector('[data-testid="dual-ghost-bloom-left"]')).toBeInTheDocument()
|
|
44
|
+
expect(container.querySelector('[data-testid="dual-ghost-bloom-right"]')).toBeInTheDocument()
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('draws exactly ONE shared band for the two blooms', () => {
|
|
48
|
+
const { container } = renderDual(CURVES, CURVES)
|
|
49
|
+
expect(container.querySelectorAll('[data-testid="ghost-band-floor"]')).toHaveLength(1)
|
|
50
|
+
expect(screen.getByText('ECC')).toBeInTheDocument()
|
|
51
|
+
expect(screen.getByText('CON')).toBeInTheDocument()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('mirrors the wings: the left bloom stays above the band, the right below', () => {
|
|
55
|
+
const { container } = renderDual(CURVES, CURVES)
|
|
56
|
+
const l = wingLine(container, 'left')
|
|
57
|
+
const r = wingLine(container, 'right')
|
|
58
|
+
expect(Math.max(...l.map((p) => p[1]))).toBeLessThan(Math.min(...r.map((p) => p[1])))
|
|
59
|
+
// Each wing starts at zero velocity ON its baseline and grows away from the band.
|
|
60
|
+
expect(l[0][1]).toBe(Math.max(...l.map((p) => p[1])))
|
|
61
|
+
expect(r[0][1]).toBe(Math.min(...r.map((p) => p[1])))
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it('gives matched devices identical bloom extents (one magnitude scale)', () => {
|
|
65
|
+
const { container } = renderDual(CURVES, CURVES)
|
|
66
|
+
expect(ySpan(wingLine(container, 'left'))).toBeCloseTo(ySpan(wingLine(container, 'right')), 1)
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('scales a weaker device DOWN instead of re-normalizing it to its own wing', () => {
|
|
70
|
+
const { container } = renderDual(CURVES, scaled(CURVES, 0.5))
|
|
71
|
+
const ratio = ySpan(wingLine(container, 'left')) / ySpan(wingLine(container, 'right'))
|
|
72
|
+
expect(ratio).toBeGreaterThan(1.9)
|
|
73
|
+
expect(ratio).toBeLessThan(2.1)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('shares one time scale — a shorter side ends earlier, it is not stretched to full width', () => {
|
|
77
|
+
const short = CURVES.map((c) => ({
|
|
78
|
+
...c,
|
|
79
|
+
samples: c.samples.slice(0, Math.round(c.samples.length * 0.5)),
|
|
80
|
+
}))
|
|
81
|
+
const { container } = renderDual(CURVES, short)
|
|
82
|
+
expect(xMax(wingLine(container, 'right'))).toBeLessThan(xMax(wingLine(container, 'left')) * 0.7)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('draws no band furniture past the end of the phase runs', () => {
|
|
86
|
+
const { container } = renderDual(CURVES, CURVES)
|
|
87
|
+
const right = (r: Element) =>
|
|
88
|
+
Number(r.getAttribute('x') ?? 0) + Number(r.getAttribute('width') ?? 0)
|
|
89
|
+
const floor = container.querySelector('[data-testid="ghost-band-floor"]')!
|
|
90
|
+
const rects = Array.from(container.querySelectorAll('rect'))
|
|
91
|
+
expect(Math.max(...rects.map(right))).toBeCloseTo(right(floor), 1)
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
it('renders an empty box when neither device has reps', () => {
|
|
95
|
+
const { container } = renderDual([], [])
|
|
96
|
+
expect(screen.getByTestId('dual-ghost-spark')).toBeInTheDocument()
|
|
97
|
+
expect(container.querySelectorAll('path')).toHaveLength(0)
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('renders the bound side alone when only one device has reps', () => {
|
|
101
|
+
const { container } = renderDual(CURVES, [])
|
|
102
|
+
expect(container.querySelector('[data-testid="dual-ghost-bloom-left"]')).toBeInTheDocument()
|
|
103
|
+
expect(container.querySelector('[data-testid="dual-ghost-bloom-right"]')).toBeNull()
|
|
104
|
+
})
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
describe('mergePhaseSegments', () => {
|
|
108
|
+
const a: PhaseSegment[] = [
|
|
109
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 },
|
|
110
|
+
{ phase: 'concentric', startMs: 1000, endMs: 2000 },
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
it('keeps a phase both devices agree on', () => {
|
|
114
|
+
expect(mergePhaseSegments(a, a)).toEqual(a)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('reads idle where the devices are in different phases', () => {
|
|
118
|
+
const b: PhaseSegment[] = [
|
|
119
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1400 },
|
|
120
|
+
{ phase: 'concentric', startMs: 1400, endMs: 2000 },
|
|
121
|
+
]
|
|
122
|
+
expect(mergePhaseSegments(a, b)).toEqual([
|
|
123
|
+
{ phase: 'eccentric', startMs: 0, endMs: 1000 },
|
|
124
|
+
{ phase: 'idle', startMs: 1000, endMs: 1400 },
|
|
125
|
+
{ phase: 'concentric', startMs: 1400, endMs: 2000 },
|
|
126
|
+
])
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('lets the covered side speak where the other has no stream yet', () => {
|
|
130
|
+
const short: PhaseSegment[] = [{ phase: 'eccentric', startMs: 0, endMs: 1000 }]
|
|
131
|
+
expect(mergePhaseSegments(a, short)).toEqual(a)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('falls back to the only populated side', () => {
|
|
135
|
+
expect(mergePhaseSegments(a, [])).toEqual(a)
|
|
136
|
+
expect(mergePhaseSegments([], a)).toEqual(a)
|
|
137
|
+
})
|
|
138
|
+
})
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* DualGhostSpark — the DUAL-VOLTRA ghost sparkline: ONE shared {@link GhostBand} with TWO
|
|
4
|
+
* {@link GhostBloom}s mirrored around it, the LEFT device blooming UP and the RIGHT device
|
|
5
|
+
* blooming DOWN. Up/down is spent on DEVICE identity, so phase rides the band COLOUR alone
|
|
6
|
+
* (magenta ecc / cyan con) with the ECC / CON labels INSIDE the band.
|
|
7
|
+
*
|
|
8
|
+
* It is built exactly the way the single {@link GhostSpark} is — the same band, the same
|
|
9
|
+
* bloom, the bottom one just `orientation="down"`. There is no forked path / band / tint
|
|
10
|
+
* code, so any bloom improvement (the paper-inspired line ground, the silver→red tint)
|
|
11
|
+
* reaches the dual for free; single↔dual drift is structurally impossible rather than
|
|
12
|
+
* merely avoided. (The ghost analogue of the diverging VelocityStrip = two composed
|
|
13
|
+
* VelocityStrips.)
|
|
14
|
+
*
|
|
15
|
+
* Both wings share ONE time scale and ONE magnitude scale (a single `vmax` over every
|
|
16
|
+
* sample on both sides, and equal wing heights), so an L/R asymmetry reads as BLOOM SIZE.
|
|
17
|
+
* Per-side scales would let a weak side fill its wing and hide the imbalance.
|
|
18
|
+
*
|
|
19
|
+
* Layout is the locked B3 exploration
|
|
20
|
+
* (`lab/north-star/DualGhostLine.exploration.stories.tsx`): centred band + silver line.
|
|
21
|
+
*/
|
|
22
|
+
import { View } from 'react-native'
|
|
23
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
24
|
+
import { alpha } from '../../../utils/colors'
|
|
25
|
+
import { FONT_UI, ghostLineColor, clamp01 } from './fatigue-tokens'
|
|
26
|
+
import { GhostBand, BAND_H, BAND_GAP } from './GhostBand'
|
|
27
|
+
import type { TempoTuple } from './tempo-pacing'
|
|
28
|
+
import { GhostBloom, type Pt } from './GhostBloom'
|
|
29
|
+
import type { PhaseSegment, RepVelocityCurve } from './fatigue-model'
|
|
30
|
+
|
|
31
|
+
const t = getSemanticColors('dark')
|
|
32
|
+
|
|
33
|
+
export interface DualGhostSparkProps {
|
|
34
|
+
/** LEFT device per-rep curves, oldest first (last = current rep) — blooms UP. */
|
|
35
|
+
left: RepVelocityCurve[]
|
|
36
|
+
/** RIGHT device per-rep curves, oldest first (last = current rep) — blooms DOWN. */
|
|
37
|
+
right: RepVelocityCurve[]
|
|
38
|
+
width: number
|
|
39
|
+
/** Plot height in px. Default 232. */
|
|
40
|
+
height?: number
|
|
41
|
+
/** Caption above the upper wing. Default `LEFT`. */
|
|
42
|
+
leftLabel?: string
|
|
43
|
+
/** Caption below the lower wing. Default `RIGHT`. */
|
|
44
|
+
rightLabel?: string
|
|
45
|
+
/** Reveal the device captions. Default `true`. */
|
|
46
|
+
showDeviceLabels?: boolean
|
|
47
|
+
/** Prescribed tempo — turns on the shared band's phase pacing. */
|
|
48
|
+
targetTempoSeconds?: TempoTuple | null
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** The phase covering `ms`, or `null` where the side has no coverage. */
|
|
52
|
+
function phaseAt(segments: PhaseSegment[], ms: number): PhaseSegment['phase'] | null {
|
|
53
|
+
const hit = segments.find((s) => ms >= s.startMs && ms < s.endMs)
|
|
54
|
+
return hit ? hit.phase : null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The SHARED band's phase runs: the two devices' current-rep runs reconciled onto one
|
|
59
|
+
* timeline. Where both sides are in the same phase the band takes it; where they are in
|
|
60
|
+
* DIFFERENT phases it reads `idle`, so the band never claims a phase the devices are not
|
|
61
|
+
* actually sharing. Where only one side has a stream at all (the other lags, or is still
|
|
62
|
+
* mid-rep), the covered side speaks for the band — a lagging device shouldn't grey out the
|
|
63
|
+
* phase the other device is genuinely in.
|
|
64
|
+
*/
|
|
65
|
+
export function mergePhaseSegments(a: PhaseSegment[], b: PhaseSegment[]): PhaseSegment[] {
|
|
66
|
+
if (a.length === 0) return b
|
|
67
|
+
if (b.length === 0) return a
|
|
68
|
+
const bounds = Array.from(new Set([...a, ...b].flatMap((s) => [s.startMs, s.endMs]))).sort(
|
|
69
|
+
(p, q) => p - q
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
const out: PhaseSegment[] = []
|
|
73
|
+
for (let i = 0; i < bounds.length - 1; i++) {
|
|
74
|
+
const startMs = bounds[i]
|
|
75
|
+
const endMs = bounds[i + 1]
|
|
76
|
+
if (endMs <= startMs) continue
|
|
77
|
+
const mid = (startMs + endMs) / 2
|
|
78
|
+
const pa = phaseAt(a, mid)
|
|
79
|
+
const pb = phaseAt(b, mid)
|
|
80
|
+
const phase = pa == null ? (pb ?? 'idle') : pb == null ? pa : pa === pb ? pa : 'idle'
|
|
81
|
+
const last = out[out.length - 1]
|
|
82
|
+
if (last && last.phase === phase && last.endMs === startMs) last.endMs = endMs
|
|
83
|
+
else out.push({ phase, startMs, endMs })
|
|
84
|
+
}
|
|
85
|
+
return out
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Last sample time across a curve set, 0 when there is nothing to draw. */
|
|
89
|
+
function lastTMs(curves: RepVelocityCurve[]): number {
|
|
90
|
+
return Math.max(0, ...curves.map((c) => c.samples[c.samples.length - 1]?.tMs ?? 0))
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function DualGhostSpark({
|
|
94
|
+
left,
|
|
95
|
+
right,
|
|
96
|
+
width,
|
|
97
|
+
height = 232,
|
|
98
|
+
leftLabel = 'LEFT',
|
|
99
|
+
rightLabel = 'RIGHT',
|
|
100
|
+
showDeviceLabels = true,
|
|
101
|
+
targetTempoSeconds = null,
|
|
102
|
+
}: DualGhostSparkProps) {
|
|
103
|
+
const w = width
|
|
104
|
+
const h = height
|
|
105
|
+
const padL = 14
|
|
106
|
+
const padR = 14
|
|
107
|
+
const padTop = 22
|
|
108
|
+
const padBot = 22
|
|
109
|
+
|
|
110
|
+
if (left.length === 0 && right.length === 0) {
|
|
111
|
+
return <View testID="dual-ghost-spark" style={{ width: w, height: h }} />
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// The band sits on the vertical midline; each bloom's baseline is offset off a band edge
|
|
115
|
+
// by BAND_GAP so a zero-velocity moment still sits clear of the band.
|
|
116
|
+
const mid = padTop + (h - padTop - padBot) / 2
|
|
117
|
+
const bandTop = mid - BAND_H / 2
|
|
118
|
+
const baseUp = bandTop - BAND_GAP
|
|
119
|
+
const baseDown = mid + BAND_H / 2 + BAND_GAP
|
|
120
|
+
// ONE wing height for both sides — the second half of the shared magnitude scale.
|
|
121
|
+
const wingH = Math.max(1, Math.min(baseUp - padTop, h - padBot - baseDown))
|
|
122
|
+
|
|
123
|
+
const allSamples = [...left, ...right].flatMap((c) => c.samples)
|
|
124
|
+
const vmax = Math.max(0.01, ...allSamples.map((s) => s.velocityMps)) * 1.06
|
|
125
|
+
const axisMaxT = Math.max(1, lastTMs(left), lastTMs(right)) * 1.04
|
|
126
|
+
const x = (ms: number) => padL + (ms / axisMaxT) * (w - padL - padR)
|
|
127
|
+
const mag = (v: number) => clamp01(v / vmax) * wingH
|
|
128
|
+
|
|
129
|
+
const toPts = (c: RepVelocityCurve): Pt[] => c.samples.map((s) => [x(s.tMs), mag(s.velocityMps)])
|
|
130
|
+
const wing = (curves: RepVelocityCurve[]) => {
|
|
131
|
+
const cur = curves[curves.length - 1]
|
|
132
|
+
return {
|
|
133
|
+
cur,
|
|
134
|
+
current: cur ? toPts(cur) : [],
|
|
135
|
+
ghosts: curves.slice(0, -1).map(toPts),
|
|
136
|
+
tint: cur ? ghostLineColor(cur.tempoDeviation, cur.grindSignature) : t['text-tertiary'],
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
const up = wing(left)
|
|
140
|
+
const down = wing(right)
|
|
141
|
+
const segments = mergePhaseSegments(up.cur?.phaseSegments ?? [], down.cur?.phaseSegments ?? [])
|
|
142
|
+
|
|
143
|
+
return (
|
|
144
|
+
<View testID="dual-ghost-spark" style={{ paddingHorizontal: 4 }}>
|
|
145
|
+
<svg width={w} height={h}>
|
|
146
|
+
{/* Same bloom, one prop flipped: LEFT grows UP, RIGHT grows DOWN. */}
|
|
147
|
+
{up.current.length > 0 && (
|
|
148
|
+
<g data-testid="dual-ghost-bloom-left">
|
|
149
|
+
<GhostBloom
|
|
150
|
+
current={up.current}
|
|
151
|
+
ghosts={up.ghosts}
|
|
152
|
+
tint={up.tint}
|
|
153
|
+
baseline={baseUp}
|
|
154
|
+
orientation="up"
|
|
155
|
+
/>
|
|
156
|
+
</g>
|
|
157
|
+
)}
|
|
158
|
+
{down.current.length > 0 && (
|
|
159
|
+
<g data-testid="dual-ghost-bloom-right">
|
|
160
|
+
<GhostBloom
|
|
161
|
+
current={down.current}
|
|
162
|
+
ghosts={down.ghosts}
|
|
163
|
+
tint={down.tint}
|
|
164
|
+
baseline={baseDown}
|
|
165
|
+
orientation="down"
|
|
166
|
+
/>
|
|
167
|
+
</g>
|
|
168
|
+
)}
|
|
169
|
+
|
|
170
|
+
{/* the ONE shared phase-coloured band, on top — ECC / CON labelled inside. */}
|
|
171
|
+
<GhostBand
|
|
172
|
+
segments={segments}
|
|
173
|
+
x={x}
|
|
174
|
+
top={bandTop}
|
|
175
|
+
showLabels
|
|
176
|
+
labelColor={alpha(t['text-primary'], 0.92)}
|
|
177
|
+
targetTempoSeconds={targetTempoSeconds}
|
|
178
|
+
/>
|
|
179
|
+
{showDeviceLabels && (
|
|
180
|
+
<>
|
|
181
|
+
<text
|
|
182
|
+
x={padL}
|
|
183
|
+
y={padTop - 9}
|
|
184
|
+
fontSize={9}
|
|
185
|
+
fontWeight={800}
|
|
186
|
+
letterSpacing={1}
|
|
187
|
+
fontFamily={FONT_UI}
|
|
188
|
+
fill={t['text-tertiary']}
|
|
189
|
+
>
|
|
190
|
+
{leftLabel}
|
|
191
|
+
</text>
|
|
192
|
+
<text
|
|
193
|
+
x={padL}
|
|
194
|
+
y={h - padBot + 15}
|
|
195
|
+
fontSize={9}
|
|
196
|
+
fontWeight={800}
|
|
197
|
+
letterSpacing={1}
|
|
198
|
+
fontFamily={FONT_UI}
|
|
199
|
+
fill={t['text-tertiary']}
|
|
200
|
+
>
|
|
201
|
+
{rightLabel}
|
|
202
|
+
</text>
|
|
203
|
+
</>
|
|
204
|
+
)}
|
|
205
|
+
</svg>
|
|
206
|
+
</View>
|
|
207
|
+
)
|
|
208
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { FatigueLights } from './FatigueLights'
|
|
4
|
+
import { greyRamp } from '../../../theme/tokens/primitives'
|
|
5
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
6
|
+
import { FATIGUE_STATES } from './fatigue-mock'
|
|
7
|
+
|
|
8
|
+
const PAGE_BG = greyRamp[975]
|
|
9
|
+
const t = getSemanticColors('dark')
|
|
10
|
+
|
|
11
|
+
const meta: Meta<typeof FatigueLights> = {
|
|
12
|
+
title: 'Workout/Fatigue/Fatigue Lights',
|
|
13
|
+
component: FatigueLights,
|
|
14
|
+
tags: ['autodocs'],
|
|
15
|
+
parameters: {
|
|
16
|
+
docs: {
|
|
17
|
+
description: {
|
|
18
|
+
component:
|
|
19
|
+
'The three VEL/ROM/TEMPO "why" dots behind the verdict. Composes the shared `StatusDot` ' +
|
|
20
|
+
'(glow) inside a `Tooltip` that reveals the dimension detail on hover. `dimensions === null` ' +
|
|
21
|
+
'⇒ three neutral dots (warming up).',
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
argTypes: { spread: { control: 'boolean' } },
|
|
26
|
+
}
|
|
27
|
+
export default meta
|
|
28
|
+
type Story = StoryObj<typeof FatigueLights>
|
|
29
|
+
|
|
30
|
+
export const AcrossStates: Story = {
|
|
31
|
+
render: () => (
|
|
32
|
+
<View style={{ backgroundColor: PAGE_BG, padding: 28, gap: 18 }}>
|
|
33
|
+
{FATIGUE_STATES.map((s) => (
|
|
34
|
+
<View key={s.name} style={{ gap: 6 }}>
|
|
35
|
+
<Text
|
|
36
|
+
style={{
|
|
37
|
+
fontSize: 9,
|
|
38
|
+
letterSpacing: 1,
|
|
39
|
+
fontFamily: 'monospace',
|
|
40
|
+
color: t['text-tertiary'],
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
{s.name}
|
|
44
|
+
</Text>
|
|
45
|
+
<FatigueLights dimensions={s.model.verdict?.dimensions ?? null} />
|
|
46
|
+
</View>
|
|
47
|
+
))}
|
|
48
|
+
<View style={{ gap: 6 }}>
|
|
49
|
+
<Text
|
|
50
|
+
style={{
|
|
51
|
+
fontSize: 9,
|
|
52
|
+
letterSpacing: 1,
|
|
53
|
+
fontFamily: 'monospace',
|
|
54
|
+
color: t['text-tertiary'],
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
WARMING UP
|
|
58
|
+
</Text>
|
|
59
|
+
<FatigueLights dimensions={null} />
|
|
60
|
+
</View>
|
|
61
|
+
</View>
|
|
62
|
+
),
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Spread evenly across a fixed width (the in-card layout). */
|
|
66
|
+
export const Spread: Story = {
|
|
67
|
+
render: () => (
|
|
68
|
+
<View style={{ backgroundColor: PAGE_BG, padding: 28 }}>
|
|
69
|
+
<View style={{ width: 280 }}>
|
|
70
|
+
<FatigueLights dimensions={FATIGUE_STATES[3].model.verdict?.dimensions ?? null} spread />
|
|
71
|
+
</View>
|
|
72
|
+
</View>
|
|
73
|
+
),
|
|
74
|
+
}
|