@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.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. 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
@@ -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
+ }