@titan-design/react-ui 0.9.3 → 0.11.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 +78 -24
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +78 -24
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +429 -11
- package/dist/index.d.ts +429 -11
- package/dist/index.js +2386 -885
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2306 -836
- 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 +1900 -1609
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1901 -1610
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +129 -34
- package/dist/theme/index.d.ts +129 -34
- package/dist/theme/index.js +95 -19
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +92 -20
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +88 -24
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +88 -24
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +8 -3
- 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/Workout/BaseBadge.tsx +5 -5
- package/src/components/custom/Workout/DualVelocityStrip.test.tsx +685 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseCard.tsx +3 -1
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +7 -1
- package/src/components/custom/Workout/README.md +62 -16
- package/src/components/custom/Workout/RowInventory.stories.tsx +5 -2
- package/src/components/custom/Workout/S3FullRail.stories.tsx +13 -1
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +3 -3
- package/src/components/custom/Workout/SessionHeader.tsx +2 -2
- package/src/components/custom/Workout/SessionRail.stories.tsx +4 -1
- package/src/components/custom/Workout/SessionRail.test.tsx +43 -0
- package/src/components/custom/Workout/SessionRail.tsx +55 -18
- package/src/components/custom/Workout/SetRow.test.tsx +6 -6
- package/src/components/custom/Workout/SetRow.tsx +9 -2
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +16 -25
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -7
- 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 +196 -79
- package/src/components/custom/Workout/VelocityStrip.tsx +953 -609
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +4 -1
- package/src/components/custom/Workout/WorkoutPill.tsx +2 -2
- package/src/components/custom/Workout/index.ts +3 -0
- 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/ui/surface/Surface.stories.tsx +195 -1
- package/src/components/ui/surface/Surface.test.tsx +148 -9
- package/src/components/ui/surface/Surface.tsx +37 -9
- package/src/components/ui/surface/SurfaceContext.ts +48 -8
- package/src/components/ui/surface/index.ts +1 -0
- package/src/components/ui/surface/surface.contract.test.ts +283 -0
- package/src/test/raw-color-patterns.test.ts +71 -0
- package/src/theme/ColorPalettes.stories.tsx +871 -0
- package/src/theme/ColorPrimitives.stories.tsx +300 -0
- package/src/theme/bar-paper.ts +59 -0
- package/src/theme/color-stories.coverage.test.ts +130 -0
- package/src/theme/color-story-kit.tsx +170 -0
- package/src/theme/config.ts +12 -0
- package/src/theme/elevation.ts +90 -67
- package/src/theme/global.css +35 -9
- package/src/theme/tokens/primitives.ts +40 -0
- package/src/theme/tokens/semantic.ts +63 -14
- package/tailwind.config.js +8 -0
- 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 -323
- package/src/theme/Colors.stories.tsx +0 -323
|
@@ -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
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
2
|
import { useState, type ReactNode } from 'react'
|
|
3
3
|
import { View, Pressable, Animated, type ViewProps } from 'react-native'
|
|
4
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
|
+
|
|
6
|
+
const t = getSemanticColors('dark')
|
|
4
7
|
|
|
5
8
|
export type BaseBadgeVariant = 'plain' | 'pr'
|
|
6
9
|
export type BaseBadgeSize = 'sm' | 'md' | 'lg'
|
|
@@ -26,11 +29,8 @@ export const baseBadgeSizeConfig: Record<
|
|
|
26
29
|
lg: { fontSize: 12, paddingH: 10, paddingV: 4, iconSize: 14 },
|
|
27
30
|
}
|
|
28
31
|
|
|
29
|
-
const variantColors: Record<
|
|
30
|
-
|
|
31
|
-
{ backgroundColor: string; borderColor: string }
|
|
32
|
-
> = {
|
|
33
|
-
plain: { backgroundColor: '#1C1C1C', borderColor: '#1F1F1F' },
|
|
32
|
+
const variantColors: Record<BaseBadgeVariant, { backgroundColor: string; borderColor: string }> = {
|
|
33
|
+
plain: { backgroundColor: t['surface-raised'], borderColor: '#1F1F1F' },
|
|
34
34
|
pr: {
|
|
35
35
|
backgroundColor: 'rgba(255, 121, 0, 0.12)',
|
|
36
36
|
borderColor: 'rgba(255, 121, 0, 0.3)',
|