@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,176 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
phaseFillFraction,
|
|
4
|
+
pacingTone,
|
|
5
|
+
phaseTargetsMs,
|
|
6
|
+
prescribedSegments,
|
|
7
|
+
ON_TARGET_MS,
|
|
8
|
+
} from './tempo-pacing'
|
|
9
|
+
import {
|
|
10
|
+
PACING_TONE,
|
|
11
|
+
PACING_TONE_MIN_CONTRAST,
|
|
12
|
+
PHASE_AXIS_COLOR,
|
|
13
|
+
PHASE_AXIS_BASE_COLOR,
|
|
14
|
+
} from './fatigue-tokens'
|
|
15
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
16
|
+
import { primitiveRamps } from '../../../theme/tokens/primitives'
|
|
17
|
+
import type { SamplePhase } from './fatigue-model'
|
|
18
|
+
|
|
19
|
+
const t = getSemanticColors('dark')
|
|
20
|
+
const TEMPO: [number, number, number, number] = [2.6, 0.4, 0.95, 0.28]
|
|
21
|
+
|
|
22
|
+
/** WCAG 2.1 relative luminance / contrast ratio. */
|
|
23
|
+
function contrastRatio(a: string, b: string): number {
|
|
24
|
+
const lum = (hex: string): number => {
|
|
25
|
+
const h = hex.replace('#', '')
|
|
26
|
+
const chan = [0, 2, 4].map((i) => {
|
|
27
|
+
const c = parseInt(h.slice(i, i + 2), 16) / 255
|
|
28
|
+
return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4)
|
|
29
|
+
})
|
|
30
|
+
return 0.2126 * chan[0] + 0.7152 * chan[1] + 0.0722 * chan[2]
|
|
31
|
+
}
|
|
32
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x)
|
|
33
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe('phaseFillFraction', () => {
|
|
37
|
+
it('is the elapsed share of the target', () => {
|
|
38
|
+
expect(phaseFillFraction(500, 1000)).toBeCloseTo(0.5, 5)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('CAPS at full so an over-long phase does not overflow its run', () => {
|
|
42
|
+
expect(phaseFillFraction(4000, 1000)).toBe(1)
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('reads complete when there is no target to pace against', () => {
|
|
46
|
+
expect(phaseFillFraction(500, null)).toBe(1)
|
|
47
|
+
expect(phaseFillFraction(500, 0)).toBe(1)
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('pacingTone', () => {
|
|
52
|
+
it('reads AHEAD while still short of target', () => {
|
|
53
|
+
expect(pacingTone(500, 2000)).toBe(PACING_TONE.ahead)
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('reads ON PACE within the on-target window either side', () => {
|
|
57
|
+
expect(pacingTone(2000, 2000)).toBe(PACING_TONE.onPace)
|
|
58
|
+
expect(pacingTone(2000 - ON_TARGET_MS, 2000)).toBe(PACING_TONE.onPace)
|
|
59
|
+
expect(pacingTone(2000 + ON_TARGET_MS, 2000)).toBe(PACING_TONE.onPace)
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
it('reads OVER once past the window', () => {
|
|
63
|
+
expect(pacingTone(2000 + ON_TARGET_MS + 1, 2000)).toBe(PACING_TONE.over)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('stays neutral with no target', () => {
|
|
67
|
+
expect(pacingTone(500, null)).toBe(t['text-primary'])
|
|
68
|
+
})
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
describe('pacing tone legibility', () => {
|
|
72
|
+
// Only ECC and CON are labelled, so only those runs can put a colour behind a pacing
|
|
73
|
+
// tone — each over its fill and its muted base. `GhostBand.test.tsx` pins the fact that
|
|
74
|
+
// hold and idle stay unlabelled, which is what keeps this narrower set honest.
|
|
75
|
+
const LABELLED: SamplePhase[] = ['eccentric', 'concentric']
|
|
76
|
+
const backgrounds = LABELLED.flatMap((p) => [
|
|
77
|
+
[`${p} fill`, PHASE_AXIS_COLOR[p]] as const,
|
|
78
|
+
[`${p} base`, PHASE_AXIS_BASE_COLOR[p]] as const,
|
|
79
|
+
])
|
|
80
|
+
|
|
81
|
+
for (const [toneName, tone] of Object.entries(PACING_TONE)) {
|
|
82
|
+
it(`keeps '${toneName}' legible on every background a label can sit on`, () => {
|
|
83
|
+
for (const [bgName, bg] of backgrounds) {
|
|
84
|
+
const ratio = contrastRatio(tone, bg)
|
|
85
|
+
expect(
|
|
86
|
+
ratio,
|
|
87
|
+
`${toneName} (${tone}) on ${bgName} (${bg}) = ${ratio.toFixed(2)}:1`
|
|
88
|
+
).toBeGreaterThanOrEqual(PACING_TONE_MIN_CONTRAST)
|
|
89
|
+
}
|
|
90
|
+
})
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
it('rejects the status-error token the band used to borrow', () => {
|
|
94
|
+
// Proves the guard has teeth rather than passing by luck: red[600] on the concentric
|
|
95
|
+
// fill is 2.18:1, which is what made the label unreadable in the first place.
|
|
96
|
+
expect(contrastRatio(t['status-error'], PHASE_AXIS_COLOR.concentric)).toBeLessThan(
|
|
97
|
+
PACING_TONE_MIN_CONTRAST
|
|
98
|
+
)
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('sits at the DEEPEST ramp step that still clears the floor', () => {
|
|
102
|
+
// Guards the other direction — the tones should be as saturated as legibility allows,
|
|
103
|
+
// so a "make it lighter" edit has to justify itself. Derived from the ramp rather than
|
|
104
|
+
// hardcoded, so it catches a change in EITHER direction.
|
|
105
|
+
const STEPS = [100, 200, 300, 400, 500, 600, 700, 800, 900] as const
|
|
106
|
+
const worstOf = (c: string): number =>
|
|
107
|
+
Math.min(...backgrounds.map(([, bg]) => contrastRatio(c, bg)))
|
|
108
|
+
|
|
109
|
+
const cases = [
|
|
110
|
+
['ahead', PACING_TONE.ahead, primitiveRamps.amber],
|
|
111
|
+
['onPace', PACING_TONE.onPace, primitiveRamps.green],
|
|
112
|
+
['over', PACING_TONE.over, primitiveRamps.red],
|
|
113
|
+
] as const
|
|
114
|
+
|
|
115
|
+
for (const [name, configured, ramp] of cases) {
|
|
116
|
+
const deepestPassing = STEPS.filter((s) => worstOf(ramp[s]) >= PACING_TONE_MIN_CONTRAST).pop()
|
|
117
|
+
expect(
|
|
118
|
+
configured,
|
|
119
|
+
`${name}: deepest step clearing ${PACING_TONE_MIN_CONTRAST}:1 is ${deepestPassing}`
|
|
120
|
+
).toBe(ramp[deepestPassing!])
|
|
121
|
+
}
|
|
122
|
+
})
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
describe('prescribedSegments', () => {
|
|
126
|
+
it('lays the prescribed rep out end to end at its target durations', () => {
|
|
127
|
+
expect(prescribedSegments(TEMPO)).toEqual([
|
|
128
|
+
{ phase: 'eccentric', startMs: 0, endMs: 2600 },
|
|
129
|
+
{ phase: 'hold', startMs: 2600, endMs: 3000 },
|
|
130
|
+
{ phase: 'concentric', startMs: 3000, endMs: 3950 },
|
|
131
|
+
{ phase: 'hold', startMs: 3950, endMs: 4230 },
|
|
132
|
+
])
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('drops zero-length phases rather than drawing slivers', () => {
|
|
136
|
+
const noHolds = prescribedSegments([3, 0, 2, 0])
|
|
137
|
+
expect(noHolds.map((s) => s.phase)).toEqual(['eccentric', 'concentric'])
|
|
138
|
+
// The concentric still starts where the eccentric ended — no gap from the dropped hold.
|
|
139
|
+
expect(noHolds[1].startMs).toBe(noHolds[0].endMs)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('has nothing to describe without a prescription', () => {
|
|
143
|
+
expect(prescribedSegments(null)).toEqual([])
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('round-trips against phaseTargetsMs — every run matches its own target', () => {
|
|
147
|
+
const segs = prescribedSegments(TEMPO)
|
|
148
|
+
const targets = phaseTargetsMs(
|
|
149
|
+
segs.map((s) => s.phase),
|
|
150
|
+
TEMPO
|
|
151
|
+
)
|
|
152
|
+
segs.forEach((s, i) => expect(s.endMs - s.startMs).toBe(targets[i]))
|
|
153
|
+
})
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
describe('phaseTargetsMs', () => {
|
|
157
|
+
it('maps a hold to BOTTOM before the concentric and TOP after it', () => {
|
|
158
|
+
const phases: SamplePhase[] = ['eccentric', 'hold', 'concentric', 'hold']
|
|
159
|
+
expect(phaseTargetsMs(phases, TEMPO)).toEqual([2600, 400, 950, 280])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('gives idle no target — dead time never paces', () => {
|
|
163
|
+
const phases: SamplePhase[] = ['eccentric', 'idle', 'concentric']
|
|
164
|
+
expect(phaseTargetsMs(phases, TEMPO)[1]).toBeNull()
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
it('targets nothing at all when no tempo is prescribed', () => {
|
|
168
|
+
const phases: SamplePhase[] = ['eccentric', 'hold', 'concentric']
|
|
169
|
+
expect(phaseTargetsMs(phases, null)).toEqual([null, null, null])
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
it('treats a rep that opens on the concentric as having a TOP hold after it', () => {
|
|
173
|
+
const phases: SamplePhase[] = ['concentric', 'hold']
|
|
174
|
+
expect(phaseTargetsMs(phases, TEMPO)).toEqual([950, 280])
|
|
175
|
+
})
|
|
176
|
+
})
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
/**
|
|
3
|
+
* Tempo PACING — how far through its prescribed duration a phase has run, and the
|
|
4
|
+
* semantic tone that says whether the lifter is ahead, on pace, or over.
|
|
5
|
+
*
|
|
6
|
+
* The rule this encodes (borrowed from `TempoDisplay`, which established it): the phase
|
|
7
|
+
* HUE carries phase identity and is deliberately non-semantic, so the pacing tone is free
|
|
8
|
+
* to use success/warning/error without the two colliding. Anything that paints a phase
|
|
9
|
+
* should take its identity colour from `PHASE_AXIS_COLOR` and its pacing colour from here.
|
|
10
|
+
*
|
|
11
|
+
* ⚠️ `TempoDisplay` currently carries its own private copy of this logic
|
|
12
|
+
* (`getTempoFillPct` / `activeNumberTone`). The two agree today and this module is the
|
|
13
|
+
* canonical one; migrating TempoDisplay onto it is deliberately left out of the change
|
|
14
|
+
* that introduced this file, so its rendering is untouched.
|
|
15
|
+
*/
|
|
16
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
17
|
+
import { PACING_TONE } from './fatigue-tokens'
|
|
18
|
+
import type { SamplePhase, PhaseSegment } from './fatigue-model'
|
|
19
|
+
|
|
20
|
+
const t = getSemanticColors('dark')
|
|
21
|
+
|
|
22
|
+
/** ± this window (ms) around the target still counts as on pace. Matches TempoDisplay. */
|
|
23
|
+
export const ON_TARGET_MS = 100
|
|
24
|
+
|
|
25
|
+
/** The canonical tempo tuple order: `[ecc, pauseBottom, con, pauseTop]`, seconds. */
|
|
26
|
+
export type TempoTuple = [number, number, number, number]
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* How far a phase has run toward its prescribed duration, 0..1, CAPPED at 1.
|
|
30
|
+
*
|
|
31
|
+
* The cap is what makes an over-long phase read correctly: the fill stops at full and the
|
|
32
|
+
* TONE takes over to say "you are past target". Without a target there is nothing to pace
|
|
33
|
+
* against, so the phase reads complete rather than perpetually empty.
|
|
34
|
+
*/
|
|
35
|
+
export function phaseFillFraction(elapsedMs: number, targetMs: number | null): number {
|
|
36
|
+
if (targetMs == null || targetMs <= 0) return 1
|
|
37
|
+
return Math.min(1, Math.max(0, elapsedMs / targetMs))
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Pacing tone for a phase's LABEL: still short of target → ahead; within ±100 ms → on pace;
|
|
42
|
+
* past target → over. Keyed on time REMAINING, so it reads the same whether the phase is in
|
|
43
|
+
* flight or finished.
|
|
44
|
+
*
|
|
45
|
+
* Takes {@link PACING_TONE} rather than the `status-*` semantic tokens — the label sits on a
|
|
46
|
+
* saturated phase fill, where `status-error` measures 1.88:1. See the token's note.
|
|
47
|
+
*/
|
|
48
|
+
export function pacingTone(elapsedMs: number, targetMs: number | null): string {
|
|
49
|
+
if (targetMs == null || targetMs <= 0) return t['text-primary']
|
|
50
|
+
const remainingMs = targetMs - elapsedMs
|
|
51
|
+
if (remainingMs > ON_TARGET_MS) return PACING_TONE.ahead
|
|
52
|
+
if (remainingMs >= -ON_TARGET_MS) return PACING_TONE.onPace
|
|
53
|
+
return PACING_TONE.over
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The rep the prescription DESCRIBES, as phase runs — ecc, bottom hold, con, top hold, laid
|
|
58
|
+
* end to end at their target durations from t=0.
|
|
59
|
+
*
|
|
60
|
+
* This is the only place a band's geometry may come from the prescription rather than from
|
|
61
|
+
* actual samples, and it exists for exactly one case: nothing has been performed yet, so
|
|
62
|
+
* there is no actual time to lay out. Zero-length phases (a tempo with no holds) are
|
|
63
|
+
* dropped rather than drawn as slivers.
|
|
64
|
+
*/
|
|
65
|
+
export function prescribedSegments(tempo: TempoTuple | null): PhaseSegment[] {
|
|
66
|
+
if (tempo == null) return []
|
|
67
|
+
const [ecc, pauseBottom, con, pauseTop] = tempo
|
|
68
|
+
const order: Array<[SamplePhase, number]> = [
|
|
69
|
+
['eccentric', ecc],
|
|
70
|
+
['hold', pauseBottom],
|
|
71
|
+
['concentric', con],
|
|
72
|
+
['hold', pauseTop],
|
|
73
|
+
]
|
|
74
|
+
const out: PhaseSegment[] = []
|
|
75
|
+
let t = 0
|
|
76
|
+
for (const [phase, seconds] of order) {
|
|
77
|
+
const ms = seconds * 1000
|
|
78
|
+
if (ms > 0) out.push({ phase, startMs: t, endMs: t + ms })
|
|
79
|
+
t += ms
|
|
80
|
+
}
|
|
81
|
+
return out
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Target duration (ms) for each phase run of a rep, in stream order.
|
|
86
|
+
*
|
|
87
|
+
* A `hold` maps to `pauseBottom` or `pauseTop` by POSITION, not by name: the tuple
|
|
88
|
+
* distinguishes the two but the sample phase does not, so the first hold of a rep is the
|
|
89
|
+
* bottom and a hold after the concentric is the top. Phases with no prescribed duration
|
|
90
|
+
* (`idle` — undirected dead time) get `null` and never pace.
|
|
91
|
+
*/
|
|
92
|
+
export function phaseTargetsMs(
|
|
93
|
+
phases: readonly SamplePhase[],
|
|
94
|
+
tempo: TempoTuple | null
|
|
95
|
+
): Array<number | null> {
|
|
96
|
+
if (tempo == null) return phases.map(() => null)
|
|
97
|
+
const [eccS, pauseBottomS, conS, pauseTopS] = tempo
|
|
98
|
+
let seenConcentric = false
|
|
99
|
+
return phases.map((phase) => {
|
|
100
|
+
switch (phase) {
|
|
101
|
+
case 'eccentric':
|
|
102
|
+
return eccS * 1000
|
|
103
|
+
case 'concentric':
|
|
104
|
+
seenConcentric = true
|
|
105
|
+
return conS * 1000
|
|
106
|
+
case 'hold':
|
|
107
|
+
return (seenConcentric ? pauseTopS : pauseBottomS) * 1000
|
|
108
|
+
default:
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
})
|
|
112
|
+
}
|
|
@@ -64,7 +64,7 @@ export function Sidebar({
|
|
|
64
64
|
<View
|
|
65
65
|
style={{ width: currentWidth }}
|
|
66
66
|
className={cn(
|
|
67
|
-
'h-full flex-col bg-surface-base border-r border-
|
|
67
|
+
'h-full flex-col bg-surface-base border-r border-hairline transition-all duration-200',
|
|
68
68
|
className
|
|
69
69
|
)}
|
|
70
70
|
{...props}
|
|
@@ -85,7 +85,7 @@ export interface SidebarHeaderProps {
|
|
|
85
85
|
*/
|
|
86
86
|
export function SidebarHeader({ children, className }: SidebarHeaderProps) {
|
|
87
87
|
return (
|
|
88
|
-
<View className={cn('px-4 py-4 border-b border-
|
|
88
|
+
<View className={cn('px-4 py-4 border-b border-hairline-subtle', className)}>
|
|
89
89
|
{children}
|
|
90
90
|
</View>
|
|
91
91
|
)
|
|
@@ -120,7 +120,7 @@ export interface SidebarFooterProps {
|
|
|
120
120
|
*/
|
|
121
121
|
export function SidebarFooter({ children, className }: SidebarFooterProps) {
|
|
122
122
|
return (
|
|
123
|
-
<View className={cn('px-2 py-4 border-t border-
|
|
123
|
+
<View className={cn('px-2 py-4 border-t border-hairline-subtle', className)}>
|
|
124
124
|
{children}
|
|
125
125
|
</View>
|
|
126
126
|
)
|
|
@@ -252,6 +252,6 @@ export interface SidebarDividerProps {
|
|
|
252
252
|
*/
|
|
253
253
|
export function SidebarDivider({ className }: SidebarDividerProps) {
|
|
254
254
|
return (
|
|
255
|
-
<View className={cn('h-px mx-4 my-2 bg-
|
|
255
|
+
<View className={cn('h-px mx-4 my-2 bg-hairline-subtle', className)} />
|
|
256
256
|
)
|
|
257
257
|
}
|
|
@@ -590,7 +590,7 @@ export function TableSelectAllCell({ className }: TableSelectAllCellProps) {
|
|
|
590
590
|
? 'bg-brand-primary border-brand-primary'
|
|
591
591
|
: someSelected
|
|
592
592
|
? 'bg-brand-primary-subtle border-brand-primary'
|
|
593
|
-
: 'border-
|
|
593
|
+
: 'border-hairline-strong bg-transparent'
|
|
594
594
|
)}
|
|
595
595
|
>
|
|
596
596
|
{(allSelected || someSelected) && (
|
|
@@ -629,7 +629,7 @@ export function TableSelectCell({ rowId, className }: TableSelectCellProps) {
|
|
|
629
629
|
'w-5 h-5 rounded border-2 items-center justify-center',
|
|
630
630
|
isSelected
|
|
631
631
|
? 'bg-brand-primary border-brand-primary'
|
|
632
|
-
: 'border-
|
|
632
|
+
: 'border-hairline-strong bg-transparent web:hover:border-brand-primary'
|
|
633
633
|
)}
|
|
634
634
|
>
|
|
635
635
|
{isSelected && (
|
|
@@ -273,7 +273,7 @@ function WorkoutHeader({
|
|
|
273
273
|
</View>
|
|
274
274
|
</View>
|
|
275
275
|
<View
|
|
276
|
-
className="bg-
|
|
276
|
+
className="bg-hairline"
|
|
277
277
|
style={{ height: 4, borderRadius: 2 }}
|
|
278
278
|
testID="active-workout-page-progress-track"
|
|
279
279
|
>
|
|
@@ -305,7 +305,7 @@ function ExerciseList({ groups, focusedId, onToggle }: ExerciseListProps) {
|
|
|
305
305
|
return (
|
|
306
306
|
<View
|
|
307
307
|
key={group.exercise.id}
|
|
308
|
-
className="bg-surface-elevated border-
|
|
308
|
+
className="bg-surface-elevated border-hairline"
|
|
309
309
|
style={{
|
|
310
310
|
borderWidth: 1,
|
|
311
311
|
borderRadius: 12,
|
|
@@ -399,7 +399,7 @@ export function ActiveWorkoutPage({
|
|
|
399
399
|
|
|
400
400
|
{(showRest || showInput) && (
|
|
401
401
|
<View
|
|
402
|
-
style={{ borderTopWidth: 1, borderTopColor: resolveColor('
|
|
402
|
+
style={{ borderTopWidth: 1, borderTopColor: resolveColor('hairline-default') }}
|
|
403
403
|
testID="active-workout-page-bottom-bar"
|
|
404
404
|
>
|
|
405
405
|
{showRest && rest != null ? (
|
|
@@ -3,6 +3,11 @@ import { render, screen, fireEvent } from '@testing-library/react'
|
|
|
3
3
|
import { axe } from 'jest-axe'
|
|
4
4
|
import { Text, View } from 'react-native'
|
|
5
5
|
import { BaseBadge } from './BaseBadge'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
+
|
|
8
|
+
// Read from the token, not pinned: these used to be hand-copied hexes and
|
|
9
|
+
// silently desynced when borders became alpha hairlines (TD-07.16).
|
|
10
|
+
const T = getSemanticColors('dark')
|
|
6
11
|
|
|
7
12
|
describe('BaseBadge', () => {
|
|
8
13
|
it('renders children', () => {
|
|
@@ -12,7 +17,7 @@ describe('BaseBadge', () => {
|
|
|
12
17
|
|
|
13
18
|
it('defaults to the plain variant border color', () => {
|
|
14
19
|
render(<BaseBadge testID="bb"><Text>x</Text></BaseBadge>)
|
|
15
|
-
expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: '
|
|
20
|
+
expect(screen.getByTestId('bb')).toHaveStyle({ borderTopColor: T['hairline-default'] })
|
|
16
21
|
})
|
|
17
22
|
|
|
18
23
|
it('applies the pr variant border color', () => {
|
|
@@ -30,7 +30,7 @@ export const baseBadgeSizeConfig: Record<
|
|
|
30
30
|
}
|
|
31
31
|
|
|
32
32
|
const variantColors: Record<BaseBadgeVariant, { backgroundColor: string; borderColor: string }> = {
|
|
33
|
-
plain: { backgroundColor: t['surface-raised'], borderColor: '
|
|
33
|
+
plain: { backgroundColor: t['surface-raised'], borderColor: t['hairline-default'] },
|
|
34
34
|
pr: {
|
|
35
35
|
backgroundColor: 'rgba(255, 121, 0, 0.12)',
|
|
36
36
|
borderColor: 'rgba(255, 121, 0, 0.3)',
|
|
@@ -278,7 +278,7 @@ function ViewToggle({ view, onViewChange }: ViewToggleProps) {
|
|
|
278
278
|
borderRadius: 9999,
|
|
279
279
|
backgroundColor: active ? 'rgba(255,121,0,0.16)' : 'transparent',
|
|
280
280
|
borderWidth: 1,
|
|
281
|
-
borderColor: active ? BRAND_PRIMARY : resolveColor('
|
|
281
|
+
borderColor: active ? BRAND_PRIMARY : resolveColor('hairline-strong'),
|
|
282
282
|
}}
|
|
283
283
|
testID={`body-map-toggle-${side}`}
|
|
284
284
|
>
|
|
@@ -324,7 +324,7 @@ function MuscleButton({ entry, highlighted, onMusclePress }: MuscleButtonProps)
|
|
|
324
324
|
paddingVertical: 3,
|
|
325
325
|
borderRadius: 9999,
|
|
326
326
|
borderWidth: 1,
|
|
327
|
-
borderColor: highlighted ? BRAND_PRIMARY : resolveColor('
|
|
327
|
+
borderColor: highlighted ? BRAND_PRIMARY : resolveColor('hairline-default'),
|
|
328
328
|
}}
|
|
329
329
|
testID={`body-map-muscle-${entry.key}`}
|
|
330
330
|
>
|
|
@@ -220,7 +220,7 @@ export function BodyMapDetailPanel({
|
|
|
220
220
|
testID="body-map-detail-panel-handle"
|
|
221
221
|
>
|
|
222
222
|
<View
|
|
223
|
-
className="bg-
|
|
223
|
+
className="bg-hairline-strong"
|
|
224
224
|
style={{ width: 40, height: 4, borderRadius: 2 }}
|
|
225
225
|
accessibilityElementsHidden
|
|
226
226
|
/>
|
|
@@ -380,7 +380,7 @@ export function BodyMapDetailPanel({
|
|
|
380
380
|
{contributingExercises!.map((exercise, index) => (
|
|
381
381
|
<View
|
|
382
382
|
key={`${exercise.name}-${index}`}
|
|
383
|
-
className="flex-row items-center justify-between bg-surface-raised border-
|
|
383
|
+
className="flex-row items-center justify-between bg-surface-raised border-hairline"
|
|
384
384
|
style={{
|
|
385
385
|
gap: 10,
|
|
386
386
|
paddingVertical: 8,
|
|
@@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest'
|
|
|
2
2
|
import { render, screen } from '@testing-library/react'
|
|
3
3
|
import { axe } from 'jest-axe'
|
|
4
4
|
import { DeviationBar } from './DeviationBar'
|
|
5
|
+
import { greyRamp } from '../../../theme/tokens/primitives'
|
|
5
6
|
|
|
6
7
|
describe('DeviationBar', () => {
|
|
7
8
|
it('renders the bar and dot', () => {
|
|
@@ -62,7 +63,7 @@ describe('DeviationBar', () => {
|
|
|
62
63
|
const { getByTestId } = render(<DeviationBar deviation={0} />)
|
|
63
64
|
const dot = getByTestId('deviation-dot')
|
|
64
65
|
expect(dot).toHaveStyle({ width: '8px', height: '8px' })
|
|
65
|
-
expect(dot).toHaveStyle({ borderTopWidth: '1.5px', borderTopColor:
|
|
66
|
+
expect(dot).toHaveStyle({ borderTopWidth: '1.5px', borderTopColor: greyRamp[50] })
|
|
66
67
|
expect(dot).toHaveStyle({ boxShadow: '0 0 4px rgba(0,0,0,0.5)' })
|
|
67
68
|
})
|
|
68
69
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { View, type ViewProps, type ViewStyle } from 'react-native'
|
|
3
3
|
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
4
|
+
import { greyRamp } from '../../../theme/tokens/primitives'
|
|
4
5
|
|
|
5
6
|
const t = getSemanticColors('dark')
|
|
6
7
|
|
|
@@ -13,7 +14,7 @@ export interface DeviationBarProps extends ViewProps {
|
|
|
13
14
|
function getDotColor(deviation: number): string {
|
|
14
15
|
const abs = Math.abs(deviation)
|
|
15
16
|
if (deviation < -0.3) return t['status-success']
|
|
16
|
-
if (abs <= 0.3) return
|
|
17
|
+
if (abs <= 0.3) return greyRamp[500]
|
|
17
18
|
if (abs <= 0.7) return t['status-warning']
|
|
18
19
|
return t['status-error']
|
|
19
20
|
}
|
|
@@ -68,7 +69,7 @@ export function DeviationBar({
|
|
|
68
69
|
height: dotSize,
|
|
69
70
|
borderRadius: 9999,
|
|
70
71
|
borderWidth: 1.5,
|
|
71
|
-
borderColor:
|
|
72
|
+
borderColor: greyRamp[50],
|
|
72
73
|
boxShadow: '0 0 4px rgba(0,0,0,0.5)',
|
|
73
74
|
backgroundColor: getDotColor(clamped),
|
|
74
75
|
left: Math.max(0, Math.min(dotPosition - dotSize / 2, resolvedWidth - dotSize)),
|