@titan-design/react-ui 0.2.6 → 0.3.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-BhG55xHM.d.mts +318 -0
- package/dist/bodymap-BhG55xHM.d.ts +318 -0
- package/dist/bodymap.d.mts +3 -0
- package/dist/bodymap.d.ts +3 -0
- package/dist/bodymap.js +2670 -0
- package/dist/bodymap.js.map +1 -0
- package/dist/bodymap.mjs +1101 -0
- package/dist/bodymap.mjs.map +1 -0
- package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/chunk-P7TSQUN6.mjs +846 -0
- package/dist/chunk-P7TSQUN6.mjs.map +1 -0
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +1131 -4
- package/dist/index.d.ts +1131 -4
- package/dist/index.js +6396 -225
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +5572 -641
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +317 -1
- package/dist/theme/index.d.ts +317 -1
- package/dist/theme/index.js +185 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -1
- package/dist/theme/tokens-css.d.mts +20 -0
- package/dist/theme/tokens-css.d.ts +20 -0
- package/dist/theme/tokens-css.js +598 -0
- package/dist/theme/tokens-css.js.map +1 -0
- package/dist/theme/tokens-css.mjs +24 -0
- package/dist/theme/tokens-css.mjs.map +1 -0
- package/package.json +25 -5
- package/src/bodymap.ts +37 -0
- package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
- package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
- package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
- package/src/components/custom/Gauge/Gauge.tsx +167 -0
- package/src/components/custom/Gauge/index.ts +2 -0
- package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
- package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
- package/src/components/custom/Scatter/Scatter.tsx +260 -0
- package/src/components/custom/Scatter/index.ts +2 -0
- package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
- package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
- package/src/components/custom/Treemap/Treemap.tsx +192 -0
- package/src/components/custom/Treemap/index.ts +2 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
- package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
- package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
- package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
- package/src/components/custom/Workout/BaseBadge.tsx +113 -0
- package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
- package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
- package/src/components/custom/Workout/BodyMap.tsx +347 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
- package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
- package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
- package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
- package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
- package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
- package/src/components/custom/Workout/DeviationBar.tsx +78 -0
- package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
- package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
- package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
- package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
- package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
- package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
- package/src/components/custom/Workout/InputBar.test.tsx +116 -0
- package/src/components/custom/Workout/InputBar.tsx +162 -0
- package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
- package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
- package/src/components/custom/Workout/IntensityBar.tsx +166 -0
- package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
- package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
- package/src/components/custom/Workout/MesoCard.tsx +237 -0
- package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
- package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
- package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
- package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
- package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
- package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
- package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
- package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
- package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
- package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
- package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
- package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
- package/src/components/custom/Workout/PrBadge.tsx +103 -0
- package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
- package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
- package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
- package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
- package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
- package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
- package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
- package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
- package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +15 -11
- package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
- package/src/components/custom/Workout/SetRow.test.tsx +222 -0
- package/src/components/custom/Workout/SetRow.tsx +247 -0
- package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
- package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
- package/src/components/custom/Workout/Sparkline.tsx +188 -0
- package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
- package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
- package/src/components/custom/Workout/StatusDot.tsx +160 -0
- package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
- package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
- package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
- package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
- package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
- package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
- package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
- package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
- package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
- package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
- package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
- package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
- package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
- package/src/components/custom/Workout/WeekRow.tsx +140 -0
- package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
- package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
- package/src/components/custom/Workout/WeightBadge.tsx +113 -0
- package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
- package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
- package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
- package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
- package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
- package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
- package/src/components/custom/Workout/index.ts +152 -0
- package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
- package/src/components/custom/index.ts +3 -0
- package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
- package/src/components/custom/stepper/Stepper.tsx +2 -2
- package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
- package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
- package/src/components/ui/card/Card.tsx +1 -1
- 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 +1 -1
- package/src/components/ui/popover/Popover.stories.tsx +2 -2
- package/src/components/ui/popover/Popover.tsx +1 -1
- package/src/components/ui/radio/Radio.stories.tsx +1 -1
- package/src/components/ui/section/Section.stories.tsx +4 -4
- package/src/components/ui/select/Select.tsx +1 -1
- package/src/components/ui/stack/Stack.stories.tsx +4 -4
- package/src/components/ui/tabs/Tabs.tsx +2 -2
- package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
- package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
- package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
- package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
- package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
- package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
- package/src/stories/PresetShowcase.stories.tsx +15 -15
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -0
- package/src/theme/index.ts +2 -1
- package/src/theme/manifest/css-property-manifest.example.json +79 -0
- package/src/theme/manifest/css-property-manifest.schema.json +93 -0
- package/src/theme/manifest/css-property-manifest.test.ts +81 -0
- package/src/theme/manifest/css-property-manifest.types.ts +49 -0
- package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
- package/src/theme/manifest/design-freeze.test.ts +155 -0
- package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
- package/src/theme/manifest/extract-css-properties.test.ts +112 -0
- package/src/theme/manifest/index.ts +11 -0
- package/src/theme/tokens-css.test.ts +28 -0
- package/src/theme/tokens-css.ts +36 -0
- package/src/theme/workout-tokens.ts +59 -0
- package/src/utils/index.ts +10 -0
- package/src/utils/workout-format.test.ts +81 -0
- package/src/utils/workout-format.ts +83 -0
- package/dist/chunk-UXVRPOME.mjs.map +0 -1
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
buildFreezeTagName,
|
|
4
|
+
buildFreezeTagMessage,
|
|
5
|
+
assertFileCommitted,
|
|
6
|
+
slugifyVariant,
|
|
7
|
+
generateSpecimenSkeleton,
|
|
8
|
+
parseArgs,
|
|
9
|
+
} from '../../../scripts/design-freeze.mjs'
|
|
10
|
+
|
|
11
|
+
describe('buildFreezeTagName', () => {
|
|
12
|
+
it('namespaces the tag under design-freeze/ with the component and version', () => {
|
|
13
|
+
expect(buildFreezeTagName('e1rm-badge', '2026-06-30')).toBe(
|
|
14
|
+
'design-freeze/e1rm-badge-2026-06-30'
|
|
15
|
+
)
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('throws when component is missing', () => {
|
|
19
|
+
expect(() => buildFreezeTagName('', '1')).toThrow(/requires a component name/)
|
|
20
|
+
})
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
describe('buildFreezeTagMessage', () => {
|
|
24
|
+
it('records the prototype path and selector the freeze covers', () => {
|
|
25
|
+
const message = buildFreezeTagMessage({
|
|
26
|
+
component: 'status-dot',
|
|
27
|
+
htmlPath: 'docs/prototypes/component-demo.html',
|
|
28
|
+
selector: '.status-dot',
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
expect(message).toContain('Design freeze: status-dot')
|
|
32
|
+
expect(message).toContain('docs/prototypes/component-demo.html')
|
|
33
|
+
expect(message).toContain('.status-dot')
|
|
34
|
+
expect(message).toContain('re-freeze')
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
describe('assertFileCommitted', () => {
|
|
39
|
+
it('does not throw for a clean porcelain result', () => {
|
|
40
|
+
expect(() => assertFileCommitted('', 'docs/prototypes/component-demo.html')).not.toThrow()
|
|
41
|
+
expect(() => assertFileCommitted(' \n', 'docs/prototypes/component-demo.html')).not.toThrow()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('throws with the porcelain output when the file has uncommitted changes', () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
assertFileCommitted(
|
|
47
|
+
' M docs/prototypes/component-demo.html\n',
|
|
48
|
+
'docs/prototypes/component-demo.html'
|
|
49
|
+
)
|
|
50
|
+
).toThrow(/uncommitted changes/)
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('throws for an untracked file', () => {
|
|
54
|
+
expect(() => assertFileCommitted('?? new-prototype.html\n', 'new-prototype.html')).toThrow(
|
|
55
|
+
/uncommitted changes/
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('slugifyVariant', () => {
|
|
61
|
+
it('returns an empty string when there are no variant axes', () => {
|
|
62
|
+
expect(slugifyVariant(undefined)).toBe('')
|
|
63
|
+
expect(slugifyVariant({})).toBe('')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('joins axis/value pairs with hyphens', () => {
|
|
67
|
+
expect(slugifyVariant({ size: 'md' })).toBe('size-md')
|
|
68
|
+
expect(slugifyVariant({ variant: 'solid', size: 'md' })).toBe('variant-solid-size-md')
|
|
69
|
+
})
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
describe('generateSpecimenSkeleton', () => {
|
|
73
|
+
it('embeds the frozen markup and a TODO placeholder for the React side', () => {
|
|
74
|
+
const skeleton = generateSpecimenSkeleton({
|
|
75
|
+
component: 'e1rm-badge',
|
|
76
|
+
htmlSnippet: '<div class="e1rm-badge md">217 lbs</div>',
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
expect(skeleton).toContain('testId="compare-e1rm-badge"')
|
|
80
|
+
expect(skeleton).toContain('label="e1rm-badge"')
|
|
81
|
+
expect(skeleton).toContain('<div class="e1rm-badge md">217 lbs</div>')
|
|
82
|
+
expect(skeleton).toContain('<ComparisonPair')
|
|
83
|
+
expect(skeleton).toContain('render <e1rm-badge /> here')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('suffixes testId and label with the slugified baseVariant', () => {
|
|
87
|
+
const skeleton = generateSpecimenSkeleton({
|
|
88
|
+
component: 'status-dot',
|
|
89
|
+
htmlSnippet: '<div class="status-dot sm success"></div>',
|
|
90
|
+
baseVariant: { size: 'sm', variant: 'success' },
|
|
91
|
+
})
|
|
92
|
+
|
|
93
|
+
expect(skeleton).toContain('testId="compare-status-dot-size-sm-variant-success"')
|
|
94
|
+
expect(skeleton).toContain('label="status-dot size sm variant success"')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('escapes backticks in the markup so the template literal stays valid', () => {
|
|
98
|
+
const skeleton = generateSpecimenSkeleton({
|
|
99
|
+
component: 'quote',
|
|
100
|
+
htmlSnippet: '<div>`backtick`</div>',
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
expect(skeleton).toContain('\\`backtick\\`')
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
it('throws when required fields are missing', () => {
|
|
107
|
+
expect(() => generateSpecimenSkeleton({ component: '', htmlSnippet: '<div></div>' })).toThrow(
|
|
108
|
+
/requires a component name/
|
|
109
|
+
)
|
|
110
|
+
expect(() => generateSpecimenSkeleton({ component: 'x', htmlSnippet: '' })).toThrow(
|
|
111
|
+
/requires an htmlSnippet/
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
describe('parseArgs', () => {
|
|
117
|
+
it('parses positional args and design-freeze-specific flags', () => {
|
|
118
|
+
const result = parseArgs([
|
|
119
|
+
'docs/prototypes/component-demo.html',
|
|
120
|
+
'.e1rm-badge',
|
|
121
|
+
'e1rm-badge',
|
|
122
|
+
'--out-manifest',
|
|
123
|
+
'src/theme/manifest/e1rm-badge.manifest.json',
|
|
124
|
+
'--out-skeleton',
|
|
125
|
+
'e1rm-badge.skeleton.tsx',
|
|
126
|
+
'--tag-version',
|
|
127
|
+
'2026-06-30',
|
|
128
|
+
'--variant',
|
|
129
|
+
'size=md',
|
|
130
|
+
])
|
|
131
|
+
|
|
132
|
+
expect(result).toMatchObject({
|
|
133
|
+
htmlPath: 'docs/prototypes/component-demo.html',
|
|
134
|
+
selector: '.e1rm-badge',
|
|
135
|
+
component: 'e1rm-badge',
|
|
136
|
+
outManifest: 'src/theme/manifest/e1rm-badge.manifest.json',
|
|
137
|
+
outSkeleton: 'e1rm-badge.skeleton.tsx',
|
|
138
|
+
version: '2026-06-30',
|
|
139
|
+
baseVariant: { size: 'md' },
|
|
140
|
+
skipGitTag: false,
|
|
141
|
+
})
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
it('defaults tag version to an ISO date and recognizes --skip-git-tag', () => {
|
|
145
|
+
const result = parseArgs([
|
|
146
|
+
'docs/prototypes/component-demo.html',
|
|
147
|
+
'.badge',
|
|
148
|
+
'badge',
|
|
149
|
+
'--skip-git-tag',
|
|
150
|
+
])
|
|
151
|
+
|
|
152
|
+
expect(result.skipGitTag).toBe(true)
|
|
153
|
+
expect(result.version).toMatch(/^\d{4}-\d{2}-\d{2}$/)
|
|
154
|
+
})
|
|
155
|
+
})
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8" />
|
|
5
|
+
<title>CSS extraction fixture — button specimen</title>
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<button
|
|
9
|
+
data-testid="button"
|
|
10
|
+
style="
|
|
11
|
+
background-color: rgb(255, 121, 0);
|
|
12
|
+
color: rgb(255, 255, 255);
|
|
13
|
+
border-color: rgba(0, 0, 0, 0);
|
|
14
|
+
border-style: solid;
|
|
15
|
+
border-width: 0px;
|
|
16
|
+
border-radius: 6px;
|
|
17
|
+
font-family: Inter, sans-serif;
|
|
18
|
+
font-size: 16px;
|
|
19
|
+
font-weight: 600;
|
|
20
|
+
padding-top: 10px;
|
|
21
|
+
padding-right: 20px;
|
|
22
|
+
padding-bottom: 10px;
|
|
23
|
+
padding-left: 20px;
|
|
24
|
+
"
|
|
25
|
+
>
|
|
26
|
+
Start workout
|
|
27
|
+
</button>
|
|
28
|
+
</body>
|
|
29
|
+
</html>
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_VISUAL_PROPERTIES,
|
|
6
|
+
buildCssPropertyManifest,
|
|
7
|
+
readComputedStyleMap,
|
|
8
|
+
styleMapToProperties,
|
|
9
|
+
toKebabCase,
|
|
10
|
+
parseArgs,
|
|
11
|
+
} from '../../../scripts/extract-css-properties.mjs'
|
|
12
|
+
import { validateCssPropertyManifest } from './css-property-manifest.validate'
|
|
13
|
+
|
|
14
|
+
const loadFixtureBody = (fileName: string): string => {
|
|
15
|
+
const html = readFileSync(join(__dirname, fileName), 'utf-8')
|
|
16
|
+
return new DOMParser().parseFromString(html, 'text/html').body.innerHTML
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('toKebabCase', () => {
|
|
20
|
+
it('converts camelCase CSS property names to kebab-case', () => {
|
|
21
|
+
expect(toKebabCase('backgroundColor')).toBe('background-color')
|
|
22
|
+
expect(toKebabCase('paddingLeft')).toBe('padding-left')
|
|
23
|
+
expect(toKebabCase('color')).toBe('color')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
describe('styleMapToProperties', () => {
|
|
28
|
+
it('drops empty computed values and tags each entry with a null token', () => {
|
|
29
|
+
const properties = styleMapToProperties({
|
|
30
|
+
backgroundColor: 'rgb(255, 121, 0)',
|
|
31
|
+
borderRadius: ' 6px ',
|
|
32
|
+
letterSpacing: '',
|
|
33
|
+
lineHeight: ' ',
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
expect(properties).toEqual([
|
|
37
|
+
{ property: 'backgroundColor', token: null, resolvedValue: 'rgb(255, 121, 0)' },
|
|
38
|
+
{ property: 'borderRadius', token: null, resolvedValue: '6px' },
|
|
39
|
+
])
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
describe('buildCssPropertyManifest', () => {
|
|
44
|
+
it('throws when no property has a non-empty computed value', () => {
|
|
45
|
+
expect(() =>
|
|
46
|
+
buildCssPropertyManifest({ component: 'button', styleMap: { color: '' } })
|
|
47
|
+
).toThrow(/No non-empty computed CSS properties/)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('includes baseVariant and description only when provided', () => {
|
|
51
|
+
const manifest = buildCssPropertyManifest({
|
|
52
|
+
component: 'button',
|
|
53
|
+
styleMap: { color: 'rgb(255, 255, 255)' },
|
|
54
|
+
baseVariant: { variant: 'solid' },
|
|
55
|
+
description: 'from fixture',
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
expect(manifest).toMatchObject({
|
|
59
|
+
component: 'button',
|
|
60
|
+
description: 'from fixture',
|
|
61
|
+
baseVariant: { variant: 'solid' },
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
describe('parseArgs', () => {
|
|
67
|
+
it('parses positional args, --out, and repeated --variant flags', () => {
|
|
68
|
+
const result = parseArgs([
|
|
69
|
+
'specimen/index.html',
|
|
70
|
+
'.btn',
|
|
71
|
+
'button',
|
|
72
|
+
'--out',
|
|
73
|
+
'out.json',
|
|
74
|
+
'--variant',
|
|
75
|
+
'variant=solid',
|
|
76
|
+
'--variant',
|
|
77
|
+
'size=md',
|
|
78
|
+
])
|
|
79
|
+
|
|
80
|
+
expect(result).toEqual({
|
|
81
|
+
htmlPath: 'specimen/index.html',
|
|
82
|
+
selector: '.btn',
|
|
83
|
+
component: 'button',
|
|
84
|
+
out: 'out.json',
|
|
85
|
+
baseVariant: { variant: 'solid', size: 'md' },
|
|
86
|
+
})
|
|
87
|
+
})
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
describe('extraction pipeline against a fixture specimen', () => {
|
|
91
|
+
it('produces a manifest that validates against the css-property-manifest schema', () => {
|
|
92
|
+
document.body.innerHTML = loadFixtureBody('extract-css-properties.fixture.html')
|
|
93
|
+
const element = document.querySelector('[data-testid="button"]')
|
|
94
|
+
expect(element).not.toBeNull()
|
|
95
|
+
|
|
96
|
+
const styleMap = readComputedStyleMap(element as Element, DEFAULT_VISUAL_PROPERTIES, (el) =>
|
|
97
|
+
getComputedStyle(el as Element)
|
|
98
|
+
)
|
|
99
|
+
const manifest = buildCssPropertyManifest({
|
|
100
|
+
component: 'button',
|
|
101
|
+
styleMap,
|
|
102
|
+
baseVariant: { variant: 'solid' },
|
|
103
|
+
description: 'Extracted from extract-css-properties.fixture.html',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
expect(validateCssPropertyManifest(manifest)).toEqual({ valid: true, errors: [] })
|
|
107
|
+
const properties = manifest.properties.map((entry) => entry.property)
|
|
108
|
+
expect(properties).toContain('backgroundColor')
|
|
109
|
+
expect(properties).toContain('paddingLeft')
|
|
110
|
+
expect(manifest.properties.every((entry) => entry.token === null)).toBe(true)
|
|
111
|
+
})
|
|
112
|
+
})
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
CssPropertyEntry,
|
|
3
|
+
CssPropertyManifest,
|
|
4
|
+
VariantAxes,
|
|
5
|
+
VariantOverride,
|
|
6
|
+
} from './css-property-manifest.types'
|
|
7
|
+
export {
|
|
8
|
+
isCssPropertyManifest,
|
|
9
|
+
validateCssPropertyManifest,
|
|
10
|
+
type ManifestValidationResult,
|
|
11
|
+
} from './css-property-manifest.validate'
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { generateTokensCss } from './tokens-css'
|
|
3
|
+
import { darkThemeCSSVars, lightThemeCSSVars } from './config'
|
|
4
|
+
|
|
5
|
+
describe('generateTokensCss', () => {
|
|
6
|
+
const css = generateTokensCss()
|
|
7
|
+
|
|
8
|
+
it('emits a default :root block and a .light override block', () => {
|
|
9
|
+
expect(css).toContain(':root {')
|
|
10
|
+
expect(css).toContain('.light, :root.light {')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('emits every dark token in :root with its exact value', () => {
|
|
14
|
+
for (const [name, value] of Object.entries(darkThemeCSSVars)) {
|
|
15
|
+
expect(css).toContain(` ${name}: ${value};`)
|
|
16
|
+
}
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
it('emits every light token with its exact value', () => {
|
|
20
|
+
for (const [name, value] of Object.entries(lightThemeCSSVars)) {
|
|
21
|
+
expect(css).toContain(` ${name}: ${value};`)
|
|
22
|
+
}
|
|
23
|
+
})
|
|
24
|
+
|
|
25
|
+
it('is deterministic across invocations', () => {
|
|
26
|
+
expect(generateTokensCss()).toBe(css)
|
|
27
|
+
})
|
|
28
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Token CSS Generator
|
|
3
|
+
*
|
|
4
|
+
* Emits a static `:root {}` CSS block from the canonical theme token maps
|
|
5
|
+
* (`darkThemeCSSVars` / `lightThemeCSSVars`). Those maps mirror every `:root`
|
|
6
|
+
* custom property in `theme/global.css` — completeness and value parity are
|
|
7
|
+
* enforced by `config.completeness.test.ts`, so HTML prototypes that import the
|
|
8
|
+
* generated stylesheet resolve to the exact same values as the design system.
|
|
9
|
+
* Pure codegen: deterministic and diffable against source.
|
|
10
|
+
*
|
|
11
|
+
* Dark mode is the default (`:root`); light mode is activated with `.light`,
|
|
12
|
+
* matching the convention in `theme/global.css`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { darkThemeCSSVars, lightThemeCSSVars } from './config'
|
|
16
|
+
|
|
17
|
+
type CSSVarMap = Record<string, string>
|
|
18
|
+
|
|
19
|
+
const GENERATED_HEADER = '/* Generated from theme token maps. Do not edit by hand. */'
|
|
20
|
+
|
|
21
|
+
function formatBlock(selector: string, vars: CSSVarMap): string {
|
|
22
|
+
const declarations = Object.entries(vars)
|
|
23
|
+
.map(([name, value]) => ` ${name}: ${value};`)
|
|
24
|
+
.join('\n')
|
|
25
|
+
return `${selector} {\n${declarations}\n}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Build the full `tokens.css` contents: a default (dark) `:root` block plus a
|
|
30
|
+
* `.light` override block, driven entirely by the exported token maps.
|
|
31
|
+
*/
|
|
32
|
+
export function generateTokensCss(): string {
|
|
33
|
+
const root = formatBlock(':root', darkThemeCSSVars)
|
|
34
|
+
const light = formatBlock('.light, :root.light', lightThemeCSSVars)
|
|
35
|
+
return `${GENERATED_HEADER}\n\n${root}\n\n${light}\n`
|
|
36
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workout-specific tokens not yet in the main Tailwind config.
|
|
3
|
+
* Use these inline instead of Tailwind classes.
|
|
4
|
+
*/
|
|
5
|
+
export const WORKOUT_TOKENS = {
|
|
6
|
+
// Canonical 4-band performance scale — the single source for BOTH the
|
|
7
|
+
// VelocityStrip zone bars and the SetRow RPE color (TD-03.43). The direction
|
|
8
|
+
// is intentionally inverted between the two consumers: for velocity, green =
|
|
9
|
+
// fastest/best; for RPE, green = easiest. Each component maps its own
|
|
10
|
+
// thresholds onto these four colors. Kept as vivid data-viz values (distinct
|
|
11
|
+
// from the muted status tokens) and theme-independent, so — like the heatmap
|
|
12
|
+
// scale below — they are consumed inline as a JS import rather than CSS vars.
|
|
13
|
+
// A JS import also resolves on native RN, unlike var(--…) strings.
|
|
14
|
+
scale: {
|
|
15
|
+
green: '#2ed573',
|
|
16
|
+
yellow: '#ffd43b',
|
|
17
|
+
orange: '#ffa502',
|
|
18
|
+
red: '#ff4757',
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
// BodyMap volume heatmap scale (drive dynamic SVG fills, so inline values).
|
|
22
|
+
// Maps weekly-volume status against MEV/MAV/MRV landmarks to a fill color.
|
|
23
|
+
heatmap: {
|
|
24
|
+
none: '#E0E0E0', // gray — no training data
|
|
25
|
+
under: '#4A90D9', // cool blue — below MEV
|
|
26
|
+
maintenance: '#F5C842', // warm yellow — MEV to MAV
|
|
27
|
+
productive: '#4CAF50', // green — MAV to MRV
|
|
28
|
+
approaching: '#FFA502', // orange — near MRV
|
|
29
|
+
over: '#FF6B35', // red-orange — over MRV
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Badge border-radius (rounded-sm is 4px, we need 2px)
|
|
33
|
+
badgeRadius: 2,
|
|
34
|
+
|
|
35
|
+
// Surface and border colors were removed here: those flip with the theme, so
|
|
36
|
+
// components use the CSS custom properties (var(--color-surface-*/border-*))
|
|
37
|
+
// directly. Only theme-independent data-viz values remain below.
|
|
38
|
+
|
|
39
|
+
// Intensity bar specific
|
|
40
|
+
intensity: {
|
|
41
|
+
track: '#333333',
|
|
42
|
+
over1: '#D14343',
|
|
43
|
+
over2: '#A62626',
|
|
44
|
+
over3: '#7A1C1C',
|
|
45
|
+
targetLine: 'rgba(33, 150, 243, 0.5)',
|
|
46
|
+
atTargetGlow:
|
|
47
|
+
'0 0 5px 1px rgba(33, 150, 243, 0.35), 0 0 10px 3px rgba(33, 150, 243, 0.15)',
|
|
48
|
+
},
|
|
49
|
+
|
|
50
|
+
// Placeholder strip
|
|
51
|
+
placeholder: {
|
|
52
|
+
fill: '#3A3A3A',
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
// Deviation bar
|
|
56
|
+
deviation: {
|
|
57
|
+
track: '#333333',
|
|
58
|
+
},
|
|
59
|
+
} as const
|
package/src/utils/index.ts
CHANGED
|
@@ -27,3 +27,13 @@ export type {
|
|
|
27
27
|
FormErrors,
|
|
28
28
|
FormTouched,
|
|
29
29
|
} from './form'
|
|
30
|
+
export {
|
|
31
|
+
roundRpe,
|
|
32
|
+
rpeColor,
|
|
33
|
+
roundWeight,
|
|
34
|
+
formatVelocity,
|
|
35
|
+
roundTempo,
|
|
36
|
+
formatSignedPct,
|
|
37
|
+
formatPrescription,
|
|
38
|
+
} from './workout-format'
|
|
39
|
+
export type { PrescriptionInput } from './workout-format'
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
roundRpe,
|
|
4
|
+
rpeColor,
|
|
5
|
+
roundWeight,
|
|
6
|
+
formatVelocity,
|
|
7
|
+
roundTempo,
|
|
8
|
+
formatSignedPct,
|
|
9
|
+
formatPrescription,
|
|
10
|
+
} from './workout-format'
|
|
11
|
+
import { WORKOUT_TOKENS } from '../theme/workout-tokens'
|
|
12
|
+
|
|
13
|
+
describe('roundRpe', () => {
|
|
14
|
+
it('rounds an exact RPE to the nearest 0.5', () => {
|
|
15
|
+
expect(roundRpe(8.3)).toBe(8.5)
|
|
16
|
+
expect(roundRpe(8.24)).toBe(8)
|
|
17
|
+
expect(roundRpe(9)).toBe(9)
|
|
18
|
+
expect(roundRpe(7.75)).toBe(8)
|
|
19
|
+
})
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('rpeColor', () => {
|
|
23
|
+
it('bands onto the canonical scale, red for hardest', () => {
|
|
24
|
+
expect(rpeColor(9.5)).toBe(WORKOUT_TOKENS.scale.red)
|
|
25
|
+
expect(rpeColor(8.5)).toBe(WORKOUT_TOKENS.scale.orange)
|
|
26
|
+
expect(rpeColor(7)).toBe(WORKOUT_TOKENS.scale.yellow)
|
|
27
|
+
expect(rpeColor(6.9)).toBe(WORKOUT_TOKENS.scale.green)
|
|
28
|
+
})
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
describe('roundWeight', () => {
|
|
32
|
+
it('rounds to a whole unit', () => {
|
|
33
|
+
expect(roundWeight(62.4)).toBe(62)
|
|
34
|
+
expect(roundWeight(62.5)).toBe(63)
|
|
35
|
+
expect(roundWeight(60)).toBe(60)
|
|
36
|
+
})
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
describe('formatVelocity', () => {
|
|
40
|
+
it('formats m/s to 2 decimal places', () => {
|
|
41
|
+
expect(formatVelocity(0.6)).toBe('0.60')
|
|
42
|
+
expect(formatVelocity(1.234)).toBe('1.23')
|
|
43
|
+
})
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
describe('roundTempo', () => {
|
|
47
|
+
it('rounds each phase to 1 dp', () => {
|
|
48
|
+
expect(roundTempo([2.34, 0, 1.06, 0.5])).toEqual([2.3, 0, 1.1, 0.5])
|
|
49
|
+
})
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
describe('formatSignedPct', () => {
|
|
53
|
+
it('renders a signed percentage from a ratio', () => {
|
|
54
|
+
expect(formatSignedPct(0.09)).toBe('+9%')
|
|
55
|
+
expect(formatSignedPct(-0.05)).toBe('-5%')
|
|
56
|
+
expect(formatSignedPct(0)).toBe('0%')
|
|
57
|
+
})
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
describe('formatPrescription', () => {
|
|
61
|
+
it('builds a full prescription string', () => {
|
|
62
|
+
expect(formatPrescription({ repsLow: 8, repsHigh: 10, weightLbs: 62, rpe: 8 })).toBe(
|
|
63
|
+
'8–10 @ 62 lb · RPE 8'
|
|
64
|
+
)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('collapses an equal rep range to a single number', () => {
|
|
68
|
+
expect(formatPrescription({ repsLow: 5, repsHigh: 5, weightLbs: 100 })).toBe('5 @ 100 lb')
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
it('omits missing parts', () => {
|
|
72
|
+
expect(formatPrescription({ rpe: 7 })).toBe('RPE 7')
|
|
73
|
+
expect(formatPrescription({ weightLbs: 50 })).toBe('50 lb')
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns null when there is nothing to show', () => {
|
|
77
|
+
expect(formatPrescription(null)).toBeNull()
|
|
78
|
+
expect(formatPrescription(undefined)).toBeNull()
|
|
79
|
+
expect(formatPrescription({})).toBeNull()
|
|
80
|
+
})
|
|
81
|
+
})
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// Font mapping context: presentation-only helpers, no rendering.
|
|
2
|
+
import { WORKOUT_TOKENS } from '../theme/workout-tokens'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Presentation helpers for workout metrics.
|
|
6
|
+
*
|
|
7
|
+
* The data layer (`@voltras/workout-analytics` view-model derivations) supplies
|
|
8
|
+
* EXACT, unrounded values; these turn them into the rounded / banded / formatted
|
|
9
|
+
* forms a view renders. Keeping display munging here — in the design system, not
|
|
10
|
+
* the analytics layer — is the render half of the model↔render split: one view
|
|
11
|
+
* can round while another (or a hover) shows the exact value from the same real
|
|
12
|
+
* data. Workout components consume these so the rules live in one place.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Round an exact RPE onto its conventional 0.5 granularity for display. */
|
|
16
|
+
export function roundRpe(rpe: number): number {
|
|
17
|
+
return Math.round(rpe * 2) / 2
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Color band for an RPE on the canonical performance scale. Higher RPE = harder
|
|
22
|
+
* = red (direction inverted vs velocity, which shares the same scale). Bands:
|
|
23
|
+
* `<7` green, `7–8.5` yellow, `8.5–9.5` orange, `≥9.5` red.
|
|
24
|
+
*/
|
|
25
|
+
export function rpeColor(rpe: number): string {
|
|
26
|
+
if (rpe >= 9.5) return WORKOUT_TOKENS.scale.red
|
|
27
|
+
if (rpe >= 8.5) return WORKOUT_TOKENS.scale.orange
|
|
28
|
+
if (rpe >= 7) return WORKOUT_TOKENS.scale.yellow
|
|
29
|
+
return WORKOUT_TOKENS.scale.green
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Round a weight or e1RM to a whole unit for compact display. */
|
|
33
|
+
export function roundWeight(weight: number): number {
|
|
34
|
+
return Math.round(weight)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Format an exact velocity (m/s) to the 2-dp string shown on the velocity strip. */
|
|
38
|
+
export function formatVelocity(velocity: number): string {
|
|
39
|
+
return velocity.toFixed(2)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Round an exact tempo tuple (seconds) to 1 dp per phase for display. */
|
|
43
|
+
export function roundTempo(
|
|
44
|
+
tempo: [number, number, number, number]
|
|
45
|
+
): [number, number, number, number] {
|
|
46
|
+
return tempo.map((v) => Math.round(v * 10) / 10) as [number, number, number, number]
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Signed percentage label for a deviation ratio, e.g. `+9%` / `-5%` / `0%`. */
|
|
50
|
+
export function formatSignedPct(ratio: number): string {
|
|
51
|
+
const pct = Math.round(ratio * 100)
|
|
52
|
+
return `${pct > 0 ? '+' : ''}${pct}%`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Structured prescription for the active exercise (from the plan). */
|
|
56
|
+
export interface PrescriptionInput {
|
|
57
|
+
repsLow?: number
|
|
58
|
+
repsHigh?: number
|
|
59
|
+
weightLbs?: number
|
|
60
|
+
rpe?: number
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Human prescription string, e.g. `"8–10 @ 62 lb · RPE 8"`. `null` when there is
|
|
65
|
+
* nothing to show. Presentation-only: the plan supplies the numbers, this shapes
|
|
66
|
+
* the label.
|
|
67
|
+
*/
|
|
68
|
+
export function formatPrescription(p: PrescriptionInput | null | undefined): string | null {
|
|
69
|
+
if (p == null) return null
|
|
70
|
+
let reps: string | null = null
|
|
71
|
+
if (p.repsLow != null && p.repsHigh != null) {
|
|
72
|
+
reps = p.repsLow === p.repsHigh ? `${p.repsLow}` : `${p.repsLow}–${p.repsHigh}`
|
|
73
|
+
} else if (p.repsLow != null) {
|
|
74
|
+
reps = `${p.repsLow}`
|
|
75
|
+
}
|
|
76
|
+
const weight = p.weightLbs != null ? `${p.weightLbs} lb` : null
|
|
77
|
+
const head = [reps, weight].filter((s): s is string => s != null).join(' @ ')
|
|
78
|
+
const rpe = p.rpe != null ? `RPE ${p.rpe}` : null
|
|
79
|
+
const full = [head.length > 0 ? head : null, rpe]
|
|
80
|
+
.filter((s): s is string => s != null)
|
|
81
|
+
.join(' · ')
|
|
82
|
+
return full.length > 0 ? full : null
|
|
83
|
+
}
|