@titan-design/react-ui 0.6.0 → 0.8.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 +12 -7
- package/dist/bodymap.js.map +1 -1
- package/dist/bodymap.mjs +12 -7
- package/dist/bodymap.mjs.map +1 -1
- package/dist/index.d.mts +956 -128
- package/dist/index.d.ts +956 -128
- package/dist/index.js +2873 -981
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +2826 -982
- package/dist/index.mjs.map +1 -1
- package/dist/{pages-MO0JCySL.d.mts → pages-DCFBqp79.d.mts} +289 -57
- package/dist/{pages-D7Jlssvp.d.ts → pages-_fI3-x7Z.d.ts} +289 -57
- package/dist/pages.d.mts +1 -1
- package/dist/pages.d.ts +1 -1
- package/dist/pages.js +1659 -799
- package/dist/pages.js.map +1 -1
- package/dist/pages.mjs +1660 -800
- package/dist/pages.mjs.map +1 -1
- package/dist/theme/index.d.mts +16 -82
- package/dist/theme/index.d.ts +16 -82
- package/dist/theme/index.js +13 -115
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +13 -115
- package/dist/theme/index.mjs.map +1 -1
- package/dist/theme/tokens-css.js +14 -6
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +14 -6
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +1 -1
- package/src/arch/Architecture.stories.tsx +870 -0
- package/src/arch/annotations.json +48 -0
- package/src/arch/arch-graph.json +4301 -0
- package/src/components/custom/CircularTimer/CircularTimer.stories.tsx +90 -0
- package/src/components/custom/CircularTimer/CircularTimer.test.tsx +62 -0
- package/src/components/custom/CircularTimer/CircularTimer.tsx +112 -0
- package/src/components/custom/CircularTimer/index.ts +1 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/EmptyState/EmptyState.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +1 -1
- package/src/components/custom/Metric/Metric.stories.tsx +1 -1
- package/src/components/custom/Scatter/Scatter.stories.tsx +1 -1
- package/src/components/custom/Sidebar/Sidebar.stories.tsx +1 -1
- package/src/components/custom/Table/Table.stories.tsx +1 -1
- package/src/components/custom/TimerReadout/TimerReadout.stories.tsx +62 -0
- package/src/components/custom/TimerReadout/TimerReadout.test.tsx +72 -0
- package/src/components/custom/TimerReadout/TimerReadout.tsx +75 -0
- package/src/components/custom/TimerReadout/index.ts +1 -0
- package/src/components/custom/Treemap/Treemap.stories.tsx +1 -1
- package/src/components/custom/Typography/Typography.stories.tsx +1 -1
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +21 -13
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +16 -13
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +9 -15
- package/src/components/custom/Workout/BaseBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/BodyMap.stories.tsx +1 -1
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +1 -1
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +1 -1
- package/src/components/custom/Workout/DeviationBar.stories.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +51 -66
- package/src/components/custom/Workout/ExerciseCard.test.tsx +146 -143
- package/src/components/custom/Workout/ExerciseCard.tsx +176 -198
- package/src/components/custom/Workout/ExerciseCardHeading.stories.tsx +99 -0
- package/src/components/custom/Workout/ExerciseCardHeading.test.tsx +72 -0
- package/src/components/custom/Workout/ExerciseCardHeading.tsx +43 -0
- package/src/components/custom/Workout/ExerciseCardUnified.stories.tsx +151 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +5 -3
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +4 -4
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +4 -5
- package/src/components/custom/Workout/ExerciseHeading.stories.tsx +81 -0
- package/src/components/custom/Workout/ExerciseHeading.test.tsx +77 -0
- package/src/components/custom/Workout/ExerciseHeading.tsx +85 -0
- package/src/components/custom/Workout/ExerciseIndicator.stories.tsx +110 -0
- package/src/components/custom/Workout/ExerciseIndicator.test.tsx +93 -0
- package/src/components/custom/Workout/ExerciseIndicator.tsx +120 -0
- package/src/components/custom/Workout/FatigueMeter.stories.tsx +51 -0
- package/src/components/custom/Workout/FatigueMeter.test.tsx +85 -0
- package/src/components/custom/Workout/FatigueMeter.tsx +86 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +1 -1
- package/src/components/custom/Workout/IntensityBar.stories.tsx +1 -1
- package/src/components/custom/Workout/LiveAuraFrame.stories.tsx +153 -0
- package/src/components/custom/Workout/LiveAuraFrame.test.tsx +74 -0
- package/src/components/custom/Workout/LiveAuraFrame.tsx +105 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +1 -1
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +1 -1
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +1 -1
- package/src/components/custom/Workout/MetricTiles.stories.tsx +60 -0
- package/src/components/custom/Workout/MetricTiles.test.tsx +58 -0
- package/src/components/custom/Workout/MetricTiles.tsx +48 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +1 -1
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +1 -1
- package/src/components/custom/Workout/PrBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +1 -1
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +4 -16
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +8 -6
- package/src/components/custom/Workout/README.md +193 -4
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +1 -1
- package/src/components/custom/Workout/RestTimer.stories.tsx +133 -3
- package/src/components/custom/Workout/RestTimer.test.tsx +73 -0
- package/src/components/custom/Workout/RestTimer.tsx +155 -59
- package/src/components/custom/Workout/RowInventory.stories.tsx +405 -0
- package/src/components/custom/Workout/S3FullRail.stories.tsx +34 -0
- package/src/components/custom/Workout/S3SessionPace.stories.tsx +95 -0
- package/src/components/custom/Workout/S3SessionRailHeading.stories.tsx +61 -0
- package/src/components/custom/Workout/S3SetTypes.stories.tsx +176 -0
- package/src/components/custom/Workout/S3WorkoutExpansion.stories.tsx +240 -0
- package/src/components/custom/Workout/ScheduleTiles.stories.tsx +40 -0
- package/src/components/custom/Workout/ScheduleTiles.test.tsx +34 -0
- package/src/components/custom/Workout/ScheduleTiles.tsx +70 -0
- package/src/components/custom/Workout/SegmentedBar.stories.tsx +99 -0
- package/src/components/custom/Workout/SegmentedBar.test.tsx +77 -0
- package/src/components/custom/Workout/SegmentedBar.tsx +172 -0
- package/src/components/custom/Workout/SegmentedProgressBar.stories.tsx +50 -0
- package/src/components/custom/Workout/SegmentedProgressBar.test.tsx +80 -0
- package/src/components/custom/Workout/SegmentedProgressBar.tsx +67 -0
- package/src/components/custom/Workout/SessionHeader.stories.tsx +121 -0
- package/src/components/custom/Workout/SessionHeader.test.tsx +122 -0
- package/src/components/custom/Workout/SessionHeader.tsx +152 -0
- package/src/components/custom/Workout/SessionRail.stories.tsx +153 -0
- package/src/components/custom/Workout/SessionRail.test.tsx +86 -0
- package/src/components/custom/Workout/SessionRail.tsx +126 -0
- package/src/components/custom/Workout/SetBar.stories.tsx +124 -0
- package/src/components/custom/Workout/SetBar.test.tsx +123 -0
- package/src/components/custom/Workout/SetBar.tsx +171 -0
- package/src/components/custom/Workout/SetRow.stories.tsx +50 -100
- package/src/components/custom/Workout/SetRow.test.tsx +94 -166
- package/src/components/custom/Workout/SetRow.tsx +169 -232
- package/src/components/custom/Workout/SetStrip.stories.tsx +154 -0
- package/src/components/custom/Workout/SetStrip.test.tsx +109 -0
- package/src/components/custom/Workout/SetStrip.tsx +71 -0
- package/src/components/custom/Workout/SetTableHeader.stories.tsx +66 -0
- package/src/components/custom/Workout/SetTableHeader.test.tsx +47 -0
- package/src/components/custom/Workout/SetTableHeader.tsx +88 -0
- package/src/components/custom/Workout/SetsRepsLoad.stories.tsx +47 -0
- package/src/components/custom/Workout/SetsRepsLoad.test.tsx +44 -0
- package/src/components/custom/Workout/SetsRepsLoad.tsx +52 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +1 -1
- package/src/components/custom/Workout/StatusDot.stories.tsx +1 -1
- package/src/components/custom/Workout/StatusPill.stories.tsx +59 -0
- package/src/components/custom/Workout/StatusPill.test.tsx +63 -0
- package/src/components/custom/Workout/StatusPill.tsx +88 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +1 -1
- package/src/components/custom/Workout/SupersetWrapper.stories.tsx +1 -1
- package/src/components/custom/Workout/TempoBar.stories.tsx +70 -1
- package/src/components/custom/Workout/TempoBar.test.tsx +119 -4
- package/src/components/custom/Workout/TempoBar.tsx +107 -22
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +12 -1
- package/src/components/custom/Workout/TempoDisplay.tsx +23 -33
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +1 -1
- package/src/components/custom/Workout/VelocityStrip.modalities.stories.tsx +254 -0
- package/src/components/custom/Workout/VelocityStrip.responsive.stories.tsx +145 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +262 -56
- package/src/components/custom/Workout/VelocityStrip.test.tsx +440 -141
- package/src/components/custom/Workout/VelocityStrip.tsx +776 -141
- package/src/components/custom/Workout/VolumeLandmarkBar.stories.tsx +73 -0
- package/src/components/custom/Workout/VolumeLandmarkBar.test.tsx +119 -0
- package/src/components/custom/Workout/VolumeLandmarkBar.tsx +154 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +1 -1
- package/src/components/custom/Workout/WeightBadge.stories.tsx +1 -1
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +2 -6
- package/src/components/custom/Workout/WorkoutCard.test.tsx +13 -46
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +1 -1
- package/src/components/custom/Workout/ZoneTrack.stories.tsx +139 -0
- package/src/components/custom/Workout/ZoneTrack.test.tsx +234 -0
- package/src/components/custom/Workout/ZoneTrack.tsx +373 -0
- package/src/components/custom/Workout/index.ts +55 -2
- package/src/components/custom/Workout/metricText.stories.tsx +69 -0
- package/src/components/custom/Workout/metricText.tsx +34 -0
- package/src/components/custom/Workout/paceTone.test.ts +29 -0
- package/src/components/custom/Workout/paceTone.ts +27 -0
- package/src/components/custom/Workout/setHeadingKit.tsx +177 -0
- package/src/components/custom/index.ts +2 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/icons/icons.stories.tsx +67 -2
- package/src/components/icons/icons.test.tsx +32 -2
- package/src/components/icons/icons.tsx +108 -0
- package/src/components/shell/BrandLockup.stories.tsx +3 -3
- package/src/components/shell/DashboardShell.stories.tsx +65 -0
- package/src/components/shell/DashboardShell.test.tsx +42 -0
- package/src/components/shell/DashboardShell.tsx +74 -0
- package/src/components/shell/DeviceIndicator.stories.tsx +2 -2
- package/src/components/shell/DeviceMenu.stories.tsx +5 -5
- package/src/components/shell/DeviceRow.stories.tsx +3 -3
- package/src/components/shell/NavItem.stories.tsx +52 -0
- package/src/components/shell/NavItem.test.tsx +35 -0
- package/src/components/shell/NavItem.tsx +75 -0
- package/src/components/shell/README.md +30 -0
- package/src/components/shell/SessionRailSpecimen.stories.tsx +753 -0
- package/src/components/shell/SessionStatePill.stories.tsx +3 -3
- package/src/components/shell/SessionStatePill.tsx +1 -1
- package/src/components/shell/SideNav.stories.tsx +81 -0
- package/src/components/shell/SideNav.test.tsx +51 -0
- package/src/components/shell/SideNav.tsx +74 -0
- package/src/components/shell/TopBar.stories.tsx +5 -5
- package/src/components/shell/index.ts +5 -0
- package/src/components/ui/alert/Alert.stories.tsx +86 -3
- package/src/components/ui/alert/Alert.test.tsx +52 -0
- package/src/components/ui/alert/Alert.tsx +54 -20
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/avatar/Avatar.stories.tsx +1 -1
- package/src/components/ui/badge/Badge.stories.tsx +1 -1
- package/src/components/ui/breadcrumbs/Breadcrumbs.stories.tsx +1 -1
- package/src/components/ui/button/Button.stories.tsx +1 -1
- package/src/components/ui/card/Card.stories.tsx +1 -1
- package/src/components/ui/checkbox/Checkbox.stories.tsx +1 -1
- package/src/components/ui/chip/Chip.stories.tsx +1 -1
- package/src/components/ui/collapse/Collapse.stories.tsx +1 -1
- package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
- package/src/components/ui/divider/Divider.stories.tsx +1 -1
- package/src/components/ui/drawer/Drawer.stories.tsx +1 -1
- package/src/components/ui/form-field/FormField.stories.tsx +1 -1
- package/src/components/ui/help-tip/HelpTip.stories.tsx +1 -1
- package/src/components/ui/icon-box/IconBox.stories.tsx +1 -1
- package/src/components/ui/index.ts +1 -0
- package/src/components/ui/indicator/Indicator.stories.tsx +2 -2
- package/src/components/ui/indicator/Indicator.test.tsx +1 -0
- package/src/components/ui/indicator/Indicator.tsx +6 -1
- package/src/components/ui/input/Input.stories.tsx +1 -1
- package/src/components/ui/link/Link.stories.tsx +1 -1
- package/src/components/ui/list-item/ListItem.stories.tsx +1 -1
- package/src/components/ui/menu/Menu.stories.tsx +1 -1
- package/src/components/ui/modal/Modal.stories.tsx +1 -1
- package/src/components/ui/pill/Pill.stories.tsx +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +1 -1
- package/src/components/ui/progress/Progress.stories.tsx +1 -1
- package/src/components/ui/progress/Progress.tsx +47 -22
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +1 -1
- package/src/components/ui/select/Select.stories.tsx +1 -1
- package/src/components/ui/skeleton/Skeleton.stories.tsx +1 -1
- package/src/components/ui/spinner/Spinner.stories.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +1 -1
- package/src/components/ui/surface/Surface.stories.tsx +1 -1
- package/src/components/ui/switch/Switch.stories.tsx +1 -1
- package/src/components/ui/tabs/Tabs.stories.tsx +1 -1
- package/src/components/ui/tile/Tile.stories.tsx +79 -0
- package/src/components/ui/tile/Tile.test.tsx +48 -0
- package/src/components/ui/tile/Tile.tsx +64 -0
- package/src/components/ui/tile/index.ts +2 -0
- package/src/components/ui/toast/Toast.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/tooltip/Tooltip.stories.tsx +1 -1
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +1 -1
- package/src/hooks/index.ts +2 -0
- package/src/hooks/useTimer.test.ts +150 -0
- package/src/hooks/useTimer.ts +77 -0
- package/src/index.ts +3 -0
- package/src/pages.ts +1 -1
- package/src/stories/PresetShowcase.stories.tsx +1 -1
- package/src/stories/ThemePresets.stories.tsx +1 -1
- package/src/theme/ColorSystem.stories.tsx +1 -1
- package/src/theme/Colors.stories.tsx +5 -8
- package/src/theme/config.ts +4 -0
- package/src/theme/elevation.stories.tsx +1 -1
- package/src/theme/global.css +5 -1
- package/src/theme/shadows.stories.tsx +1 -1
- package/src/theme/tokens/primitives.ts +11 -107
- package/src/theme/tokens/semantic.ts +8 -0
- package/src/theme/workout-tokens.ts +11 -7
- package/tailwind.config.js +5 -0
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from 'vitest'
|
|
2
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import {
|
|
5
|
+
ExerciseIndicator,
|
|
6
|
+
resolveIndicator,
|
|
7
|
+
INDICATOR_PRECEDENCE,
|
|
8
|
+
type ExerciseIndicatorKind,
|
|
9
|
+
} from './ExerciseIndicator'
|
|
10
|
+
|
|
11
|
+
// tier color = titan status token, applied as the glyph's SVG stroke (literal hex):
|
|
12
|
+
// danger=status-error, warning=status-warning, success=status-success, info=status-info.
|
|
13
|
+
const kinds: { kind: ExerciseIndicatorKind; label: string; tier: string }[] = [
|
|
14
|
+
{ kind: 'imbalance', label: 'Left/right imbalance', tier: '#D14343' },
|
|
15
|
+
{ kind: 'overshoot', label: 'Load overshoot', tier: '#D14343' },
|
|
16
|
+
{ kind: 'velocity-loss', label: 'Velocity loss', tier: '#F9B415' },
|
|
17
|
+
{ kind: 'missed-reps', label: 'Missed reps', tier: '#F9B415' },
|
|
18
|
+
{ kind: 'pr', label: 'Personal record', tier: '#2ED573' },
|
|
19
|
+
{ kind: 'info', label: 'More info', tier: '#2196F3' },
|
|
20
|
+
]
|
|
21
|
+
|
|
22
|
+
describe('ExerciseIndicator', () => {
|
|
23
|
+
it.each(kinds)('renders the $kind chip glyph and accessible label', ({ kind, label }) => {
|
|
24
|
+
const { container } = render(<ExerciseIndicator kind={kind} />)
|
|
25
|
+
// glyph is an inline SVG (lucide-mirrored), not a text character
|
|
26
|
+
expect(container.querySelector('svg')).toBeInTheDocument()
|
|
27
|
+
expect(screen.getByLabelText(label)).toBeInTheDocument()
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it.each(kinds)('tints the $kind glyph with its tier color', ({ kind, tier }) => {
|
|
31
|
+
const { container } = render(<ExerciseIndicator kind={kind} />)
|
|
32
|
+
expect(container.querySelector('svg')!.getAttribute('stroke')).toBe(tier)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('renders a decorative (aria-hidden) glyph so the chip owns the label', () => {
|
|
36
|
+
const { container } = render(<ExerciseIndicator kind="pr" />)
|
|
37
|
+
expect(container.querySelector('svg')!.getAttribute('aria-hidden')).toBe('true')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('is presentational (no button role) without onPress', () => {
|
|
41
|
+
render(<ExerciseIndicator kind="pr" />)
|
|
42
|
+
expect(screen.queryByRole('button')).not.toBeInTheDocument()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('becomes a button and fires onPress when interactive', () => {
|
|
46
|
+
const onPress = vi.fn()
|
|
47
|
+
render(<ExerciseIndicator kind="pr" onPress={onPress} />)
|
|
48
|
+
const button = screen.getByRole('button', { name: 'Personal record' })
|
|
49
|
+
fireEvent.click(button)
|
|
50
|
+
expect(onPress).toHaveBeenCalledOnce()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('accessibility', () => {
|
|
54
|
+
it.each(kinds)('has no accessibility violations for $kind', async ({ kind }) => {
|
|
55
|
+
const { container } = render(<ExerciseIndicator kind={kind} />)
|
|
56
|
+
const results = await axe(container)
|
|
57
|
+
expect(results).toHaveNoViolations()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
describe('resolveIndicator', () => {
|
|
63
|
+
it('returns undefined when no signals are active', () => {
|
|
64
|
+
expect(resolveIndicator([])).toBeUndefined()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('returns the sole candidate unchanged', () => {
|
|
68
|
+
expect(resolveIndicator(['velocity-loss'])).toBe('velocity-loss')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('picks the highest-precedence kind regardless of input order', () => {
|
|
72
|
+
expect(resolveIndicator(['info', 'pr', 'overshoot'])).toBe('overshoot')
|
|
73
|
+
expect(resolveIndicator(['overshoot', 'pr', 'info'])).toBe('overshoot')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('lets imbalance (rank 1) win over every other signal', () => {
|
|
77
|
+
expect(resolveIndicator([...INDICATOR_PRECEDENCE])).toBe('imbalance')
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('ranks the soft alerts above pr (alert > pr)', () => {
|
|
81
|
+
expect(resolveIndicator(['pr', 'missed-reps'])).toBe('missed-reps')
|
|
82
|
+
expect(resolveIndicator(['pr', 'velocity-loss'])).toBe('velocity-loss')
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('falls to info only when it is the lowest active signal', () => {
|
|
86
|
+
expect(resolveIndicator(['info', 'pr'])).toBe('pr')
|
|
87
|
+
expect(resolveIndicator(['info'])).toBe('info')
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('ignores duplicate candidates', () => {
|
|
91
|
+
expect(resolveIndicator(['pr', 'pr', 'info'])).toBe('pr')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { ComponentType } from 'react'
|
|
3
|
+
import { View, Pressable, type ViewProps } from 'react-native'
|
|
4
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
|
+
import {
|
|
6
|
+
ScaleIcon,
|
|
7
|
+
AlertTriangleIcon,
|
|
8
|
+
TrendingDownIcon,
|
|
9
|
+
CircleSlashIcon,
|
|
10
|
+
AwardIcon,
|
|
11
|
+
InfoIcon,
|
|
12
|
+
type IconProps,
|
|
13
|
+
} from '../../icons'
|
|
14
|
+
|
|
15
|
+
// Literal-hex status pins (the TempoBar pattern) — `resolveColor()` yields a
|
|
16
|
+
// `var(...)` under the RNW vitest alias, which breaks `toHaveStyle` in tests.
|
|
17
|
+
const t = getSemanticColors('dark')
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The LOCKED 6-kind indicator taxonomy (TD-03.51). One precedence-ranked slot is
|
|
21
|
+
* ever shown per exercise, resolved by {@link resolveIndicator}. Glyph = the
|
|
22
|
+
* specific signal; color = the severity tier (danger/warning/success/info, bound
|
|
23
|
+
* to titan status tokens). Outlined (border + glyph, no fill) so a chip never
|
|
24
|
+
* collides with the filled velocity strip beside it.
|
|
25
|
+
*/
|
|
26
|
+
export type ExerciseIndicatorKind =
|
|
27
|
+
| 'imbalance'
|
|
28
|
+
| 'overshoot'
|
|
29
|
+
| 'velocity-loss'
|
|
30
|
+
| 'missed-reps'
|
|
31
|
+
| 'pr'
|
|
32
|
+
| 'info'
|
|
33
|
+
|
|
34
|
+
interface IndicatorConfig {
|
|
35
|
+
Icon: ComponentType<IconProps>
|
|
36
|
+
/** Tier color — a titan status semantic token (literal hex on `t`). */
|
|
37
|
+
color: string
|
|
38
|
+
/** Accessible description (also the intended tooltip subject). */
|
|
39
|
+
label: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const INDICATOR_CONFIG: Record<ExerciseIndicatorKind, IndicatorConfig> = {
|
|
43
|
+
imbalance: { Icon: ScaleIcon, color: t['status-error'], label: 'Left/right imbalance' },
|
|
44
|
+
overshoot: { Icon: AlertTriangleIcon, color: t['status-error'], label: 'Load overshoot' },
|
|
45
|
+
'velocity-loss': { Icon: TrendingDownIcon, color: t['status-warning'], label: 'Velocity loss' },
|
|
46
|
+
'missed-reps': { Icon: CircleSlashIcon, color: t['status-warning'], label: 'Missed reps' },
|
|
47
|
+
pr: { Icon: AwardIcon, color: t['status-success'], label: 'Personal record' },
|
|
48
|
+
info: { Icon: InfoIcon, color: t['status-info'], label: 'More info' },
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Precedence order (1=highest → last=lowest). A single chip is shown, so when
|
|
53
|
+
* several signals are true the highest-ranked wins. Alerts outrank `pr`; `info`
|
|
54
|
+
* is the floor. This ordering is LOCKED — do not reshuffle.
|
|
55
|
+
*/
|
|
56
|
+
export const INDICATOR_PRECEDENCE: readonly ExerciseIndicatorKind[] = [
|
|
57
|
+
'imbalance',
|
|
58
|
+
'overshoot',
|
|
59
|
+
'velocity-loss',
|
|
60
|
+
'missed-reps',
|
|
61
|
+
'pr',
|
|
62
|
+
'info',
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Given the signals currently true for an exercise, pick the single
|
|
67
|
+
* highest-precedence kind to surface (or `undefined` when none apply). Order and
|
|
68
|
+
* duplicates in `candidates` don't matter — {@link INDICATOR_PRECEDENCE} decides.
|
|
69
|
+
*/
|
|
70
|
+
export function resolveIndicator(
|
|
71
|
+
candidates: ExerciseIndicatorKind[],
|
|
72
|
+
): ExerciseIndicatorKind | undefined {
|
|
73
|
+
return INDICATOR_PRECEDENCE.find((kind) => candidates.includes(kind))
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface ExerciseIndicatorProps extends Omit<ViewProps, 'children'> {
|
|
77
|
+
kind: ExerciseIndicatorKind
|
|
78
|
+
/** Optional tap handler (surfaces detail — PR history / alert / info). */
|
|
79
|
+
onPress?: () => void
|
|
80
|
+
className?: string
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A small circular outlined chip for an exercise heading. Presentational and
|
|
85
|
+
* static (chips are chrome — no pulse); the tap detail (tooltip/sheet) is wired
|
|
86
|
+
* by the consumer.
|
|
87
|
+
*/
|
|
88
|
+
export function ExerciseIndicator({ kind, onPress, className, ...props }: ExerciseIndicatorProps) {
|
|
89
|
+
const { Icon, color, label } = INDICATOR_CONFIG[kind]
|
|
90
|
+
|
|
91
|
+
const chip = (
|
|
92
|
+
<View
|
|
93
|
+
className={className}
|
|
94
|
+
style={{
|
|
95
|
+
width: 16,
|
|
96
|
+
height: 16,
|
|
97
|
+
borderRadius: 8,
|
|
98
|
+
borderWidth: 1,
|
|
99
|
+
borderColor: color,
|
|
100
|
+
alignItems: 'center',
|
|
101
|
+
justifyContent: 'center',
|
|
102
|
+
}}
|
|
103
|
+
accessibilityRole="image"
|
|
104
|
+
accessibilityLabel={label}
|
|
105
|
+
testID="exercise-indicator"
|
|
106
|
+
{...props}
|
|
107
|
+
>
|
|
108
|
+
<Icon size={11} color={color} />
|
|
109
|
+
</View>
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if (onPress) {
|
|
113
|
+
return (
|
|
114
|
+
<Pressable onPress={onPress} accessibilityRole="button" accessibilityLabel={label}>
|
|
115
|
+
{chip}
|
|
116
|
+
</Pressable>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
return chip
|
|
120
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
3
|
+
import { View } from 'react-native'
|
|
4
|
+
import { FatigueMeter } from './FatigueMeter'
|
|
5
|
+
|
|
6
|
+
const meta: Meta<typeof FatigueMeter> = {
|
|
7
|
+
title: 'Workout/DataViz/FatigueMeter',
|
|
8
|
+
component: FatigueMeter,
|
|
9
|
+
tags: ['autodocs'],
|
|
10
|
+
argTypes: {
|
|
11
|
+
value: { control: { type: 'range', min: 0, max: 40, step: 1 } },
|
|
12
|
+
},
|
|
13
|
+
parameters: {
|
|
14
|
+
docs: {
|
|
15
|
+
description: {
|
|
16
|
+
component:
|
|
17
|
+
'**Component.** The winning fatigue-visual lineage: a sliding needle over a fixed ' +
|
|
18
|
+
'green→gold→orange→red velocity-loss gradient with VL10/VL20/VL30/stop markers. ' +
|
|
19
|
+
'Composes the [ZoneTrack](?path=/docs/workout-dataviz-zonetrack--docs) primitive. ' +
|
|
20
|
+
'Prop-driven: `value` plus overridable `thresholds` / `max` / `zoneColors` / `labels`.',
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
decorators: [
|
|
25
|
+
(Story) => (
|
|
26
|
+
<View style={{ width: 360, padding: 20, backgroundColor: '#131313' }}>
|
|
27
|
+
<Story />
|
|
28
|
+
</View>
|
|
29
|
+
),
|
|
30
|
+
],
|
|
31
|
+
}
|
|
32
|
+
export default meta
|
|
33
|
+
|
|
34
|
+
type Story = StoryObj<typeof FatigueMeter>
|
|
35
|
+
|
|
36
|
+
/** Fresh — needle sits in the green zone. */
|
|
37
|
+
export const Fresh: Story = { args: { value: 5 } }
|
|
38
|
+
|
|
39
|
+
/** VL20 — needle crossing into the orange zone. */
|
|
40
|
+
export const Vl20: Story = { args: { value: 21 } }
|
|
41
|
+
|
|
42
|
+
/** Stop zone — deep into the red. */
|
|
43
|
+
export const StopZone: Story = { args: { value: 34 } }
|
|
44
|
+
|
|
45
|
+
/** The across-the-room wall scale — `size="wall"` grows the track, needle and tick labels together. */
|
|
46
|
+
export const WallDensity: Story = { args: { value: 21, size: 'wall' } }
|
|
47
|
+
|
|
48
|
+
/** Custom scale — a tighter 0..30 domain with earlier thresholds. */
|
|
49
|
+
export const CustomScale: Story = {
|
|
50
|
+
args: { value: 12, max: 30, thresholds: [8, 16, 24], labels: ['ok', 'w1', 'w2', 'w3', 'max'] },
|
|
51
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { FatigueMeter } from './FatigueMeter'
|
|
5
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
6
|
+
|
|
7
|
+
const { green, red } = WORKOUT_TOKENS.scale
|
|
8
|
+
|
|
9
|
+
describe('FatigueMeter', () => {
|
|
10
|
+
it('renders the four fatigue zone bands', () => {
|
|
11
|
+
render(<FatigueMeter value={0} />)
|
|
12
|
+
expect(screen.getAllByTestId('zone-track-band')).toHaveLength(4)
|
|
13
|
+
})
|
|
14
|
+
|
|
15
|
+
it('colours the bands from the canonical effort scale', () => {
|
|
16
|
+
render(<FatigueMeter value={0} />)
|
|
17
|
+
const bands = screen.getAllByTestId('zone-track-band')
|
|
18
|
+
expect(bands[0]).toHaveStyle({ backgroundColor: green })
|
|
19
|
+
expect(bands[3]).toHaveStyle({ backgroundColor: red })
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('renders the default VL scale labels', () => {
|
|
23
|
+
render(<FatigueMeter value={0} />)
|
|
24
|
+
for (const label of ['fresh', 'VL10', 'VL20', 'VL30', 'stop']) {
|
|
25
|
+
expect(screen.getByText(label)).toBeInTheDocument()
|
|
26
|
+
}
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('positions the needle at the value on the 0..40 loss scale', () => {
|
|
30
|
+
render(<FatigueMeter value={20} />)
|
|
31
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ left: '50%' })
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
it('clamps a value beyond the stop end to the track end', () => {
|
|
35
|
+
render(<FatigueMeter value={60} />)
|
|
36
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ left: '100%' })
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('reshapes the zones from custom thresholds + max', () => {
|
|
40
|
+
render(<FatigueMeter value={5} max={20} thresholds={[5, 10, 15]} />)
|
|
41
|
+
const bands = screen.getAllByTestId('zone-track-band')
|
|
42
|
+
expect(bands[0]).toHaveStyle({ flexGrow: 5 })
|
|
43
|
+
expect(bands[3]).toHaveStyle({ flexGrow: 5 })
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('honours custom labels', () => {
|
|
47
|
+
render(<FatigueMeter value={5} labels={['A', 'B', 'C', 'D', 'E']} />)
|
|
48
|
+
expect(screen.getByText('A')).toBeInTheDocument()
|
|
49
|
+
expect(screen.getByText('E')).toBeInTheDocument()
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('honours a needle colour override', () => {
|
|
53
|
+
render(<FatigueMeter value={10} needleColor="#FF00FF" />)
|
|
54
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ backgroundColor: '#FF00FF' })
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
// RNW does not map accessibilityValue → aria-valuenow under jsdom (gotcha #11), so the
|
|
58
|
+
// loss value is verified via the needle position + the accessible name.
|
|
59
|
+
it('exposes the progressbar role with the loss value in its accessible name', () => {
|
|
60
|
+
render(<FatigueMeter value={17} />)
|
|
61
|
+
expect(
|
|
62
|
+
screen.getByRole('progressbar', { name: 'Fatigue: 17% velocity loss' })
|
|
63
|
+
).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('accessibility', () => {
|
|
67
|
+
it('has no accessibility violations', async () => {
|
|
68
|
+
const { container } = render(<FatigueMeter value={21} />)
|
|
69
|
+
const results = await axe(container)
|
|
70
|
+
expect(results).toHaveNoViolations()
|
|
71
|
+
})
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
describe('FatigueMeter size="wall"', () => {
|
|
76
|
+
it('passes wall density through to the composed ZoneTrack (thicker needle)', () => {
|
|
77
|
+
render(<FatigueMeter value={20} size="wall" />)
|
|
78
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '7px' })
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('keeps the compact needle by default', () => {
|
|
82
|
+
render(<FatigueMeter value={20} />)
|
|
83
|
+
expect(screen.getByTestId('zone-track-needle')).toHaveStyle({ width: '4px' })
|
|
84
|
+
})
|
|
85
|
+
})
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
|
|
2
|
+
import type { ViewProps } from 'react-native'
|
|
3
|
+
import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
|
|
4
|
+
import { ZoneTrack, type ZoneTrackZone, type ZoneTrackTick, type ZoneTrackSize } from './ZoneTrack'
|
|
5
|
+
|
|
6
|
+
const { green, yellow, orange, red } = WORKOUT_TOKENS.scale
|
|
7
|
+
|
|
8
|
+
/** Canonical fatigue band colours (fresh → stop) — the even effort-scale hue walk. */
|
|
9
|
+
const DEFAULT_ZONE_COLORS: [string, string, string, string] = [green, yellow, orange, red]
|
|
10
|
+
/** Zone boundaries in velocity-loss %, matching the VL10/VL20/VL30 coaching cues. */
|
|
11
|
+
const DEFAULT_THRESHOLDS: [number, number, number] = [10, 20, 30]
|
|
12
|
+
/** Scale labels at [min, ...thresholds, max]. */
|
|
13
|
+
const DEFAULT_LABELS = ['fresh', 'VL10', 'VL20', 'VL30', 'stop']
|
|
14
|
+
const DEFAULT_MAX = 40
|
|
15
|
+
|
|
16
|
+
export interface FatigueMeterProps extends ViewProps {
|
|
17
|
+
/** Current velocity-loss percentage (0 = fresh). Drives the needle position. */
|
|
18
|
+
value: number
|
|
19
|
+
/** Upper bound of the fatigue scale — the "stop" end, in loss %. Default 40. */
|
|
20
|
+
max?: number
|
|
21
|
+
/** Zone boundary thresholds (loss %): green→gold, gold→orange, orange→red. Default [10,20,30]. */
|
|
22
|
+
thresholds?: [number, number, number]
|
|
23
|
+
/** Zone band colours low→high (fresh→stop). Default the canonical effort scale. */
|
|
24
|
+
zoneColors?: [string, string, string, string]
|
|
25
|
+
/** Tick labels at [min, ...thresholds, max]. Default ['fresh','VL10','VL20','VL30','stop']. */
|
|
26
|
+
labels?: string[]
|
|
27
|
+
/** Density: `default` (compact) or `wall` (the across-the-room dashboard scale — bigger track, needle and tick labels). */
|
|
28
|
+
size?: ZoneTrackSize
|
|
29
|
+
/** Track height in px. Overrides the `size` default (14 default / 24 wall). */
|
|
30
|
+
trackHeight?: number
|
|
31
|
+
/** Needle colour. Default white. */
|
|
32
|
+
needleColor?: string
|
|
33
|
+
className?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A fatigue meter: a sliding needle over a fixed green→gold→orange→red velocity-loss
|
|
38
|
+
* gradient with VL10/VL20/VL30/stop scale markers. This is the winning fatigue-visual
|
|
39
|
+
* lineage (needle-over-zoned-gradient, three independent sources agreed) — it composes
|
|
40
|
+
* the shared {@link ZoneTrack} primitive, supplying the fatigue domain, zone bands from
|
|
41
|
+
* the effort-scale tokens, and the coaching-cue tick labels. Prop-driven: `value` plus
|
|
42
|
+
* overridable `thresholds` / `max` / `zoneColors` / `labels`.
|
|
43
|
+
*
|
|
44
|
+
* @example
|
|
45
|
+
* <FatigueMeter value={21} /> // needle in the VL20 (orange) zone
|
|
46
|
+
* <FatigueMeter value={8} max={40} thresholds={[10, 20, 30]} />
|
|
47
|
+
*/
|
|
48
|
+
export function FatigueMeter({
|
|
49
|
+
value,
|
|
50
|
+
max = DEFAULT_MAX,
|
|
51
|
+
thresholds = DEFAULT_THRESHOLDS,
|
|
52
|
+
zoneColors = DEFAULT_ZONE_COLORS,
|
|
53
|
+
labels = DEFAULT_LABELS,
|
|
54
|
+
size = 'default',
|
|
55
|
+
trackHeight,
|
|
56
|
+
needleColor,
|
|
57
|
+
className,
|
|
58
|
+
...props
|
|
59
|
+
}: FatigueMeterProps) {
|
|
60
|
+
const [t1, t2, t3] = thresholds
|
|
61
|
+
const zones: ZoneTrackZone[] = [
|
|
62
|
+
{ upTo: t1, color: zoneColors[0] },
|
|
63
|
+
{ upTo: t2, color: zoneColors[1] },
|
|
64
|
+
{ upTo: t3, color: zoneColors[2] },
|
|
65
|
+
{ upTo: max, color: zoneColors[3] },
|
|
66
|
+
]
|
|
67
|
+
|
|
68
|
+
const tickValues = [0, t1, t2, t3, max]
|
|
69
|
+
const ticks: ZoneTrackTick[] = tickValues.map((v, i) => ({ value: v, label: labels[i] }))
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<ZoneTrack
|
|
73
|
+
className={className}
|
|
74
|
+
zones={zones}
|
|
75
|
+
min={0}
|
|
76
|
+
max={max}
|
|
77
|
+
size={size}
|
|
78
|
+
trackHeight={trackHeight}
|
|
79
|
+
ticks={ticks}
|
|
80
|
+
marker={{ type: 'needle', value, color: needleColor }}
|
|
81
|
+
accessibilityLabel={`Fatigue: ${value}% velocity loss`}
|
|
82
|
+
testID="fatigue-meter"
|
|
83
|
+
{...props}
|
|
84
|
+
/>
|
|
85
|
+
)
|
|
86
|
+
}
|
|
@@ -3,7 +3,7 @@ import { View } from 'react-native'
|
|
|
3
3
|
import { IntensityBar } from './IntensityBar'
|
|
4
4
|
|
|
5
5
|
const meta: Meta<typeof IntensityBar> = {
|
|
6
|
-
title: '
|
|
6
|
+
title: 'Workout/DataViz/IntensityBar',
|
|
7
7
|
component: IntensityBar,
|
|
8
8
|
tags: ['autodocs'],
|
|
9
9
|
argTypes: {
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react-vite'
|
|
2
|
+
import { View, Text } from 'react-native'
|
|
3
|
+
import { LiveAuraFrame } from './LiveAuraFrame'
|
|
4
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
5
|
+
|
|
6
|
+
const t = getSemanticColors('dark')
|
|
7
|
+
|
|
8
|
+
/** A representative live-workout hero so the full-surface flood reads against real content. */
|
|
9
|
+
function LiveHero({ caption }: { caption: string }) {
|
|
10
|
+
return (
|
|
11
|
+
<View style={{ flex: 1, padding: 24, justifyContent: 'space-between' }}>
|
|
12
|
+
<View>
|
|
13
|
+
<Text
|
|
14
|
+
style={{
|
|
15
|
+
fontSize: 11,
|
|
16
|
+
fontWeight: '800',
|
|
17
|
+
letterSpacing: 1.2,
|
|
18
|
+
textTransform: 'uppercase',
|
|
19
|
+
color: t['text-tertiary'],
|
|
20
|
+
}}
|
|
21
|
+
>
|
|
22
|
+
Cable Row · Set 4 / 5
|
|
23
|
+
</Text>
|
|
24
|
+
</View>
|
|
25
|
+
|
|
26
|
+
<View style={{ alignItems: 'center' }}>
|
|
27
|
+
<Text
|
|
28
|
+
style={{ fontSize: 96, fontWeight: '800', lineHeight: 100, color: t['text-primary'] }}
|
|
29
|
+
>
|
|
30
|
+
8
|
|
31
|
+
</Text>
|
|
32
|
+
<Text
|
|
33
|
+
style={{
|
|
34
|
+
fontSize: 12,
|
|
35
|
+
letterSpacing: 2,
|
|
36
|
+
textTransform: 'uppercase',
|
|
37
|
+
color: t['text-tertiary'],
|
|
38
|
+
}}
|
|
39
|
+
>
|
|
40
|
+
reps
|
|
41
|
+
</Text>
|
|
42
|
+
</View>
|
|
43
|
+
|
|
44
|
+
<View>
|
|
45
|
+
<View style={{ flexDirection: 'row', justifyContent: 'space-between', marginBottom: 12 }}>
|
|
46
|
+
{[
|
|
47
|
+
['0.42', 'm/s'],
|
|
48
|
+
['8.5', 'RPE'],
|
|
49
|
+
['−18%', 'vel loss'],
|
|
50
|
+
].map(([v, l]) => (
|
|
51
|
+
<View key={l} style={{ alignItems: 'center' }}>
|
|
52
|
+
<Text style={{ fontSize: 20, fontWeight: '700', color: t['text-primary'] }}>{v}</Text>
|
|
53
|
+
<Text
|
|
54
|
+
style={{
|
|
55
|
+
fontSize: 9,
|
|
56
|
+
letterSpacing: 1,
|
|
57
|
+
textTransform: 'uppercase',
|
|
58
|
+
color: t['text-tertiary'],
|
|
59
|
+
}}
|
|
60
|
+
>
|
|
61
|
+
{l}
|
|
62
|
+
</Text>
|
|
63
|
+
</View>
|
|
64
|
+
))}
|
|
65
|
+
</View>
|
|
66
|
+
<Text style={{ fontSize: 12, color: t['text-secondary'], textAlign: 'center' }}>
|
|
67
|
+
{caption}
|
|
68
|
+
</Text>
|
|
69
|
+
</View>
|
|
70
|
+
</View>
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const PANEL = { width: 360, height: 620, borderRadius: 24, overflow: 'hidden' as const }
|
|
75
|
+
|
|
76
|
+
const meta: Meta<typeof LiveAuraFrame> = {
|
|
77
|
+
title: 'Workout/LiveAuraFrame',
|
|
78
|
+
component: LiveAuraFrame,
|
|
79
|
+
tags: ['autodocs'],
|
|
80
|
+
parameters: {
|
|
81
|
+
layout: 'fullscreen',
|
|
82
|
+
docs: {
|
|
83
|
+
description: {
|
|
84
|
+
component:
|
|
85
|
+
'Full-surface color-flood frame tied to a coaching category. Wraps content on a charcoal ' +
|
|
86
|
+
'base and layers a radial wash — amber at threshold (VL20), red at stop (VL28+, pulsing) — ' +
|
|
87
|
+
'derived from the semantic warning / error tokens via `alpha()`. Children pass through. ' +
|
|
88
|
+
'Rendered here at live-screen size so the flood reads (it is meant to tint a whole panel, ' +
|
|
89
|
+
'not a card).',
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
argTypes: {
|
|
94
|
+
category: {
|
|
95
|
+
control: 'select',
|
|
96
|
+
options: ['productive', 'threshold', 'stop'],
|
|
97
|
+
description: 'Coaching-category flood level',
|
|
98
|
+
},
|
|
99
|
+
pulse: { control: 'boolean', description: 'Pulse the flood on stop' },
|
|
100
|
+
},
|
|
101
|
+
decorators: [
|
|
102
|
+
(Story) => (
|
|
103
|
+
<View
|
|
104
|
+
style={{
|
|
105
|
+
minHeight: 700,
|
|
106
|
+
backgroundColor: '#070707',
|
|
107
|
+
alignItems: 'center',
|
|
108
|
+
justifyContent: 'center',
|
|
109
|
+
padding: 32,
|
|
110
|
+
}}
|
|
111
|
+
>
|
|
112
|
+
<Story />
|
|
113
|
+
</View>
|
|
114
|
+
),
|
|
115
|
+
],
|
|
116
|
+
render: (args) => (
|
|
117
|
+
<LiveAuraFrame {...args} style={PANEL}>
|
|
118
|
+
<LiveHero
|
|
119
|
+
caption={
|
|
120
|
+
args.category === 'stop'
|
|
121
|
+
? 'red flood — VL28+, stop the set'
|
|
122
|
+
: args.category === 'threshold'
|
|
123
|
+
? 'amber flood — VL20 threshold reached'
|
|
124
|
+
: 'no flood — productive, quiet background'
|
|
125
|
+
}
|
|
126
|
+
/>
|
|
127
|
+
</LiveAuraFrame>
|
|
128
|
+
),
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export default meta
|
|
132
|
+
type Story = StoryObj<typeof LiveAuraFrame>
|
|
133
|
+
|
|
134
|
+
export const Productive: Story = { args: { category: 'productive' } }
|
|
135
|
+
export const Threshold: Story = { args: { category: 'threshold' } }
|
|
136
|
+
export const Stop: Story = { args: { category: 'stop' } }
|
|
137
|
+
|
|
138
|
+
/** All three flood levels side by side at live-screen size. */
|
|
139
|
+
export const AllStates: Story = {
|
|
140
|
+
render: () => (
|
|
141
|
+
<View style={{ flexDirection: 'row', gap: 20, flexWrap: 'wrap', justifyContent: 'center' }}>
|
|
142
|
+
<LiveAuraFrame category="productive" style={PANEL}>
|
|
143
|
+
<LiveHero caption="no flood — productive" />
|
|
144
|
+
</LiveAuraFrame>
|
|
145
|
+
<LiveAuraFrame category="threshold" style={PANEL}>
|
|
146
|
+
<LiveHero caption="amber flood — VL20 threshold" />
|
|
147
|
+
</LiveAuraFrame>
|
|
148
|
+
<LiveAuraFrame category="stop" pulse={false} style={PANEL}>
|
|
149
|
+
<LiveHero caption="red flood — VL28+, stop" />
|
|
150
|
+
</LiveAuraFrame>
|
|
151
|
+
</View>
|
|
152
|
+
),
|
|
153
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { render, screen } from '@testing-library/react'
|
|
3
|
+
import { axe } from 'jest-axe'
|
|
4
|
+
import { Text } from 'react-native'
|
|
5
|
+
import { LiveAuraFrame, liveAuraColor, type LiveAuraCategory } from './LiveAuraFrame'
|
|
6
|
+
import { getSemanticColors } from '../../../theme/tokens/semantic'
|
|
7
|
+
import { alpha } from '../../../utils/colors'
|
|
8
|
+
|
|
9
|
+
const t = getSemanticColors('dark')
|
|
10
|
+
|
|
11
|
+
describe('LiveAuraFrame', () => {
|
|
12
|
+
it('renders the frame and passes children through', () => {
|
|
13
|
+
render(
|
|
14
|
+
<LiveAuraFrame category="productive">
|
|
15
|
+
<Text>Cable Row</Text>
|
|
16
|
+
</LiveAuraFrame>
|
|
17
|
+
)
|
|
18
|
+
expect(screen.getByTestId('live-aura-frame')).toBeInTheDocument()
|
|
19
|
+
expect(screen.getByText('Cable Row')).toBeInTheDocument()
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('sits on the charcoal base surface', () => {
|
|
23
|
+
render(<LiveAuraFrame category="productive" />)
|
|
24
|
+
expect(screen.getByTestId('live-aura-frame')).toHaveStyle({
|
|
25
|
+
backgroundColor: t['background-base'],
|
|
26
|
+
})
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('renders no flood overlay for the productive (quiet) category', () => {
|
|
30
|
+
render(<LiveAuraFrame category="productive" />)
|
|
31
|
+
expect(screen.queryByTestId('live-aura-flood')).not.toBeInTheDocument()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const floods: Array<{
|
|
35
|
+
category: LiveAuraCategory
|
|
36
|
+
token: 'status-warning' | 'status-error'
|
|
37
|
+
opacity: number
|
|
38
|
+
}> = [
|
|
39
|
+
{ category: 'threshold', token: 'status-warning', opacity: 0.12 },
|
|
40
|
+
{ category: 'stop', token: 'status-error', opacity: 0.18 },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
floods.forEach(({ category, token, opacity }) => {
|
|
44
|
+
it(`floods ${category} with a ${token}-tinted radial wash that fades to transparent`, () => {
|
|
45
|
+
render(<LiveAuraFrame category={category} />)
|
|
46
|
+
// Web (the target): a top-down radial gradient tinted with the token color,
|
|
47
|
+
// fading to transparent — matches R2's .aura-* CSS, NOT a flat surface fill.
|
|
48
|
+
const flood = screen.getByTestId('live-aura-flood') as HTMLElement
|
|
49
|
+
expect(flood.style.backgroundImage).toContain(alpha(t[token], opacity))
|
|
50
|
+
expect(flood.style.backgroundImage).toContain('transparent')
|
|
51
|
+
expect(flood.style.backgroundColor).toBe('')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('exposes the resolved flood color via liveAuraColor', () => {
|
|
56
|
+
expect(liveAuraColor('productive')).toBeNull()
|
|
57
|
+
expect(liveAuraColor('threshold')).toBe(t['status-warning'])
|
|
58
|
+
expect(liveAuraColor('stop')).toBe(t['status-error'])
|
|
59
|
+
})
|
|
60
|
+
|
|
61
|
+
it('renders the flood for stop when pulse is disabled', () => {
|
|
62
|
+
render(<LiveAuraFrame category="stop" pulse={false} />)
|
|
63
|
+
expect(screen.getByTestId('live-aura-flood')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('has no a11y violations', async () => {
|
|
67
|
+
const { container } = render(
|
|
68
|
+
<LiveAuraFrame category="stop">
|
|
69
|
+
<Text>Set 4/5</Text>
|
|
70
|
+
</LiveAuraFrame>
|
|
71
|
+
)
|
|
72
|
+
expect(await axe(container)).toHaveNoViolations()
|
|
73
|
+
})
|
|
74
|
+
})
|