@titan-design/react-ui 0.2.7 → 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 (82) 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-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/{chunk-3VLOBS6O.mjs → chunk-P7TSQUN6.mjs} +3 -3
  12. package/dist/{chunk-3VLOBS6O.mjs.map → chunk-P7TSQUN6.mjs.map} +1 -1
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +375 -249
  16. package/dist/index.d.ts +375 -249
  17. package/dist/index.js +1259 -918
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +1314 -2107
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +260 -0
  22. package/dist/theme/index.d.ts +260 -0
  23. package/dist/theme/index.js +122 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -2
  26. package/dist/theme/tokens-css.d.mts +5 -3
  27. package/dist/theme/tokens-css.d.ts +5 -3
  28. package/dist/theme/tokens-css.js +318 -9
  29. package/dist/theme/tokens-css.js.map +1 -1
  30. package/dist/theme/tokens-css.mjs +1 -1
  31. package/dist/theme/tokens-css.mjs.map +1 -1
  32. package/package.json +10 -1
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/Table/Table.test.tsx +27 -0
  35. package/src/components/custom/Table/Table.tsx +33 -17
  36. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  37. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  38. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  39. package/src/components/custom/Workout/BodyMap.tsx +5 -6
  40. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +7 -8
  41. package/src/components/custom/Workout/CapacityBandChart.tsx +1 -1
  42. package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
  43. package/src/components/custom/Workout/ExerciseCard.tsx +28 -15
  44. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  45. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  46. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  47. package/src/components/custom/Workout/InputBar.tsx +6 -7
  48. package/src/components/custom/Workout/MesoCard.tsx +4 -2
  49. package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
  50. package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
  51. package/src/components/custom/Workout/MesoStatusCard.tsx +5 -5
  52. package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
  53. package/src/components/custom/Workout/PrHistoryModal.tsx +5 -5
  54. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  55. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  56. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  57. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -8
  58. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  59. package/src/components/custom/Workout/RestTimer.tsx +11 -9
  60. package/src/components/custom/Workout/SetRow.tsx +13 -19
  61. package/src/components/custom/Workout/StatusDot.tsx +1 -0
  62. package/src/components/custom/Workout/StrengthTrendChart.tsx +5 -5
  63. package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
  64. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  65. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  66. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  67. package/src/components/custom/Workout/VelocityStrip.test.tsx +10 -0
  68. package/src/components/custom/Workout/VelocityStrip.tsx +16 -15
  69. package/src/components/custom/Workout/WeekRow.tsx +1 -1
  70. package/src/components/custom/Workout/WorkoutCard.tsx +5 -3
  71. package/src/components/custom/Workout/index.ts +71 -32
  72. package/src/theme/config.completeness.test.ts +77 -0
  73. package/src/theme/config.ts +151 -0
  74. package/src/theme/manifest/design-freeze.test.ts +155 -0
  75. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  76. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  77. package/src/theme/tokens-css.ts +5 -3
  78. package/src/theme/workout-tokens.ts +12 -14
  79. package/src/utils/index.ts +10 -0
  80. package/src/utils/workout-format.test.ts +81 -0
  81. package/src/utils/workout-format.ts +83 -0
  82. package/dist/chunk-5SSKGS6E.mjs.map +0 -1
@@ -232,3 +232,13 @@ describe('calculateMeanVelocity', () => {
232
232
  expect(calculateMeanVelocity([])).toBe(0)
233
233
  })
234
234
  })
235
+
236
+ describe('VelocityStrip all-zero velocities (NaN guard)', () => {
237
+ it('emits a valid 0% bar height (not NaN) when every velocity is zero (expanded)', () => {
238
+ render(<VelocityStrip velocities={[0, 0, 0]} expanded />)
239
+ // Without the guard, 0 / (0 * 1.15) === NaN and the bar height becomes
240
+ // 'NaN%' (dropped by jsdom, leaving no height); the guard flattens to '0%'.
241
+ expect(screen.getByTestId('velocity-bar-0')).toHaveStyle({ height: '0%' })
242
+ expect(screen.getByTestId('velocity-bar-2')).toHaveStyle({ height: '0%' })
243
+ })
244
+ })
@@ -1,6 +1,8 @@
1
1
  // Font mapping: font-heading=Space Grotesk, font-body=Nunito Sans (UI), font-sans=Inter (body)
2
2
  import { useEffect, useState } from 'react'
3
3
  import { View, Text, Pressable, Animated, Easing, type ViewProps } from 'react-native'
4
+ import { WORKOUT_TOKENS } from '../../../theme/workout-tokens'
5
+ import { formatVelocity } from '../../../utils/workout-format'
4
6
 
5
7
  export interface VelocityStripProps extends ViewProps {
6
8
  velocities: number[]
@@ -12,12 +14,8 @@ export interface VelocityStripProps extends ViewProps {
12
14
  className?: string
13
15
  }
14
16
 
15
- const VEL_COLORS = {
16
- green: '#2ed573',
17
- yellow: '#ffd43b',
18
- orange: '#ffa502',
19
- red: '#ff4757',
20
- }
17
+ // Canonical scale shared with SetRow's RPE color (see theme/workout-tokens.ts).
18
+ const VEL_COLORS = WORKOUT_TOKENS.scale
21
19
 
22
20
  export function getVelocityZoneColor(velocity: number): string {
23
21
  if (velocity >= 1.0) return 'vel-green'
@@ -164,7 +162,7 @@ export function VelocityStrip({
164
162
  paddingBottom: expanded ? 24 : 0,
165
163
  paddingHorizontal: expanded ? 6 : 0,
166
164
  paddingVertical: expanded ? undefined : 8,
167
- backgroundColor: expanded ? '#1C1C1C' : 'transparent',
165
+ backgroundColor: expanded ? 'var(--color-surface-raised)' : 'transparent',
168
166
  overflow: expanded ? 'visible' : 'hidden',
169
167
  },
170
168
  ]}
@@ -178,7 +176,10 @@ export function VelocityStrip({
178
176
  >
179
177
  {velocities.map((v, i) => {
180
178
  const zone = getVelocityZoneColor(v)
181
- const barHeightPct = Math.round((v / (maxVelocity * 1.15)) * 100)
179
+ // Guard all-zero velocities (idle / pre-rep): maxVelocity === 0 makes
180
+ // this 0 / 0 === NaN and emits height:'NaN%'. Flatten the bars instead.
181
+ const barHeightPct =
182
+ maxVelocity > 0 ? Math.round((v / (maxVelocity * 1.15)) * 100) : 0
182
183
 
183
184
  const bar = (
184
185
  <View
@@ -188,10 +189,10 @@ export function VelocityStrip({
188
189
  {expanded && (
189
190
  <Animated.View style={{ opacity: labelOpacity, alignItems: 'center', position: 'absolute', top: -13, left: 0, right: 0 }} accessibilityElementsHidden>
190
191
  <Text
191
- style={{ fontSize: 8, fontWeight: '600', color: '#9CA3AF' }}
192
+ style={{ fontSize: 8, fontWeight: '600', color: 'var(--color-text-secondary)' }}
192
193
  testID={`velocity-label-${i}`}
193
194
  >
194
- {v.toFixed(2)}
195
+ {formatVelocity(v)}
195
196
  </Text>
196
197
  </Animated.View>
197
198
  )}
@@ -210,7 +211,7 @@ export function VelocityStrip({
210
211
  }
211
212
  testID={`velocity-bar-${i}`}
212
213
  accessibilityRole="image"
213
- accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second`}
214
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second`}
214
215
  />
215
216
  </View>
216
217
  )
@@ -222,7 +223,7 @@ export function VelocityStrip({
222
223
  style={{ flex: 1, height: '100%' }}
223
224
  onPress={() => onRepPress(i, v)}
224
225
  accessibilityRole="button"
225
- accessibilityLabel={`Rep ${i + 1}: ${v.toFixed(2)} meters per second, tap for details`}
226
+ accessibilityLabel={`Rep ${i + 1}: ${formatVelocity(v)} meters per second, tap for details`}
226
227
  testID={`velocity-bar-pressable-${i}`}
227
228
  >
228
229
  {bar}
@@ -249,16 +250,16 @@ export function VelocityStrip({
249
250
  <Text
250
251
  style={{
251
252
  fontSize: 10,
252
- color: '#9CA3AF',
253
+ color: 'var(--color-text-secondary)',
253
254
  fontFamily: 'Inter, sans-serif',
254
255
  }}
255
256
  >
256
- {meanZone} {'\u00B7'} {meanVelocity.toFixed(2)} m/s
257
+ {meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
257
258
  </Text>
258
259
  <Text
259
260
  style={{
260
261
  fontSize: 10,
261
- color: '#9CA3AF',
262
+ color: 'var(--color-text-secondary)',
262
263
  fontFamily: 'Inter, sans-serif',
263
264
  ...getLossStyle(loss),
264
265
  }}
@@ -104,7 +104,7 @@ export function WeekRow({
104
104
  fontSize: 13,
105
105
  fontWeight: '700',
106
106
  fontFamily: 'Inter, sans-serif',
107
- color: isCurrent ? BRAND_PRIMARY : '#F3F4F6',
107
+ color: isCurrent ? BRAND_PRIMARY : 'var(--color-text-primary)',
108
108
  }}
109
109
  accessibilityElementsHidden
110
110
  >
@@ -43,9 +43,11 @@ export interface WorkoutCardProps extends ViewProps {
43
43
  }
44
44
 
45
45
  const COLORS = {
46
- textPrimary: '#F3F4F6',
47
- textSecondary: '#9CA3AF',
48
- textTertiary: '#6B7280',
46
+ // Theme-aware foregrounds: resolve to a readable color in both light and dark
47
+ // (react-native-web passes the CSS var through to the inline style).
48
+ textPrimary: 'var(--color-text-primary)',
49
+ textSecondary: 'var(--color-text-secondary)',
50
+ textTertiary: 'var(--color-text-tertiary)',
49
51
  statusSuccess: '#14B8A6',
50
52
  brandPrimary: '#FF7900',
51
53
  borderDefault: '#1F1F1F',
@@ -4,11 +4,7 @@ export {
4
4
  type BaseBadgeVariant,
5
5
  type BaseBadgeSize,
6
6
  } from './BaseBadge'
7
- export {
8
- WeightBadge,
9
- type WeightBadgeProps,
10
- type WeightBadgeSize,
11
- } from './WeightBadge'
7
+ export { WeightBadge, type WeightBadgeProps, type WeightBadgeSize } from './WeightBadge'
12
8
  export { PrBadge, type PrBadgeProps, type PRType } from './PrBadge'
13
9
  export { StatusDot, type StatusDotVariant, type StatusDotProps } from './StatusDot'
14
10
  export { PlaceholderStrip, type PlaceholderStripProps } from './PlaceholderStrip'
@@ -24,11 +20,7 @@ export {
24
20
  calculateVelocityLoss,
25
21
  calculateMeanVelocity,
26
22
  } from './VelocityStrip'
27
- export {
28
- MuscleGroupChip,
29
- type MuscleGroupChipProps,
30
- type VolumeStatus,
31
- } from './MuscleGroupChip'
23
+ export { MuscleGroupChip, type MuscleGroupChipProps, type VolumeStatus } from './MuscleGroupChip'
32
24
  export { Sparkline, type SparklineProps } from './Sparkline'
33
25
  export { SetRow, type SetRowProps, type SetRowMode } from './SetRow'
34
26
  export { InputBar, type InputBarProps } from './InputBar'
@@ -49,11 +41,7 @@ export {
49
41
  type WorkoutMuscleGroup,
50
42
  type WorkoutMuscleVolumeStatus,
51
43
  } from './WorkoutCard'
52
- export {
53
- MesoCard,
54
- type MesoCardProps,
55
- type MesoVolumeHeatmapEntry,
56
- } from './MesoCard'
44
+ export { MesoCard, type MesoCardProps, type MesoVolumeHeatmapEntry } from './MesoCard'
57
45
  export {
58
46
  PrHistoryModal,
59
47
  type PrHistoryModalProps,
@@ -91,25 +79,76 @@ export {
91
79
  type WorkoutDot,
92
80
  type WorkoutDotStatus,
93
81
  } from './CapacityBandChart'
94
- export { BodyMap, type BodyMapProps, type BodyMapData } from './BodyMap'
95
- export {
96
- BodyMapDetailPanel,
97
- type BodyMapDetailPanelProps,
98
- type ContributingExercise,
99
- type UpcomingExercise,
82
+ // BodyMap / BodyMapDetailPanel / TrainingStatusPage depend on
83
+ // `react-native-body-highlighter` at runtime. Their VALUE exports live behind the
84
+ // `@titan-design/react-ui/bodymap` subpath to keep this barrel body-highlighter-free.
85
+ // Type-only re-exports stay here (erased at build, so no runtime pull).
86
+ export type { BodyMapProps, BodyMapData } from './BodyMap'
87
+ export type {
88
+ BodyMapDetailPanelProps,
89
+ ContributingExercise,
90
+ UpcomingExercise,
100
91
  } from './BodyMapDetailPanel'
92
+ export type {
93
+ TrainingStatusPageProps,
94
+ TrainingStatusMuscle,
95
+ TrainingStatusSummary,
96
+ } from './TrainingStatusPage'
97
+ export {
98
+ ProgramPlanningPage,
99
+ deriveNavLevel,
100
+ toProgressBarMesos,
101
+ findMeso,
102
+ findWeek,
103
+ findWorkout,
104
+ buildBreadcrumbs,
105
+ type ProgramPlanningPageProps,
106
+ type PlanMeso,
107
+ type PlanWeek,
108
+ type PlanWorkout,
109
+ type ProgramNavLevel,
110
+ type ProgramSelection,
111
+ type ProgramBreadcrumb,
112
+ } from './ProgramPlanningPage'
101
113
  export {
114
+ ExerciseDetailPage,
115
+ deriveExerciseStats,
116
+ toExerciseCardProps,
117
+ summarizeVbt,
118
+ EXERCISE_DETAIL_TABS,
119
+ type ExerciseDetailPageProps,
120
+ type ExerciseDetailTab,
121
+ type ExerciseDetailHeader,
122
+ type ExerciseDetailEntry,
123
+ type ExerciseTrend,
124
+ type ExerciseVbtSet,
125
+ type ExerciseVbt,
126
+ type ExerciseDetailStats,
127
+ type VbtSummary,
128
+ } from './ExerciseDetailPage'
129
+ // muscleTaxonomy imports `react-native-body-highlighter` at runtime. Its value
130
+ // exports (incl. the MuscleGroup / SimpleMuscleGroup enums) live behind the
131
+ // `@titan-design/react-ui/bodymap` subpath. Only type-only re-exports stay here.
132
+ export type {
102
133
  MuscleGroup,
103
134
  SimpleMuscleGroup,
104
- SIMPLE_TO_DETAILED,
105
- DETAILED_TO_SIMPLE,
106
- MUSCLE_TO_SVG_SLUGS,
107
- MUSCLE_TO_CATEGORY,
108
- MUSCLE_DISPLAY_NAMES,
109
- SIMPLE_DISPLAY_NAMES,
110
- DEFAULT_VOLUME_LANDMARKS,
111
- VOLUME_STATUS_LABELS,
112
- getHeatmapColor,
113
- type MovementCategory,
114
- type VolumeLandmarks,
135
+ MovementCategory,
136
+ VolumeLandmarks,
115
137
  } from './muscleTaxonomy'
138
+ export {
139
+ ActiveWorkoutPage,
140
+ countCompletedSets,
141
+ deriveWorkoutProgress,
142
+ findActiveExercise,
143
+ statusToCardState,
144
+ toActiveCardProps,
145
+ groupExercises,
146
+ type ActiveWorkoutPageProps,
147
+ type ActiveWorkoutExercise,
148
+ type ActiveWorkoutSuperset,
149
+ type ActiveWorkoutInput,
150
+ type ActiveWorkoutRest,
151
+ type ActiveExerciseStatus,
152
+ type WorkoutProgress,
153
+ type WorkoutGroup,
154
+ } from './ActiveWorkoutPage'
@@ -0,0 +1,77 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { readFileSync } from 'node:fs'
3
+ import path from 'node:path'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { darkThemeCSSVars, lightThemeCSSVars } from './config'
6
+
7
+ /**
8
+ * Drift guard for the token codegen source (TD-05.08).
9
+ *
10
+ * `config.ts` (darkThemeCSSVars / lightThemeCSSVars) is the source that
11
+ * `tokens-css.ts` renders into `dist/tokens.css`. It must stay a complete,
12
+ * value-accurate mirror of the `:root` / `.light` custom properties in
13
+ * `global.css`; otherwise the generated stylesheet silently drifts from the
14
+ * design system (the exact gap this ticket closed). This test fails if
15
+ * global.css gains a `:root` var that config.ts does not cover, or if any
16
+ * shared var's value diverges.
17
+ */
18
+
19
+ const themeDir = path.dirname(fileURLToPath(import.meta.url))
20
+ const css = readFileSync(path.join(themeDir, 'global.css'), 'utf8')
21
+
22
+ /**
23
+ * global.css :root vars intentionally excluded from the codegen source.
24
+ * Empty today — every :root var is mirrored. Add a var here (with a reason)
25
+ * only if it is genuinely prototype-irrelevant, rather than silently skipping.
26
+ */
27
+ const CODEGEN_ALLOWLIST = new Set<string>([])
28
+
29
+ function parseVarBlock(header: RegExp): Map<string, string> {
30
+ const match = css.match(header)
31
+ if (!match) throw new Error(`could not locate CSS block for ${header}`)
32
+ const vars = new Map<string, string>()
33
+ for (const line of match[1].split('\n')) {
34
+ const declaration = line.match(/^\s*(--[a-z0-9-]+):\s*(.+?);\s*$/i)
35
+ if (declaration) vars.set(declaration[1], declaration[2].trim())
36
+ }
37
+ return vars
38
+ }
39
+
40
+ const globalDark = parseVarBlock(/:root\s*\{([\s\S]*?)\n\s*\}/)
41
+ const globalLight = parseVarBlock(/\.light,\s*\n?\s*:root\.light\s*\{([\s\S]*?)\n\s*\}/)
42
+
43
+ const cases = [
44
+ ['dark', globalDark, darkThemeCSSVars as Record<string, string>],
45
+ ['light', globalLight, lightThemeCSSVars as Record<string, string>],
46
+ ] as const
47
+
48
+ describe('token codegen completeness (config.ts vs global.css)', () => {
49
+ it('parses both global.css theme blocks', () => {
50
+ expect(globalDark.size).toBeGreaterThan(90)
51
+ expect(globalLight.size).toBe(globalDark.size)
52
+ })
53
+
54
+ for (const [mode, globalVars, cfg] of cases) {
55
+ it(`${mode}: config covers every global.css :root var with the exact value`, () => {
56
+ const missing: string[] = []
57
+ const mismatched: string[] = []
58
+ for (const [name, value] of globalVars) {
59
+ if (CODEGEN_ALLOWLIST.has(name)) continue
60
+ if (!(name in cfg)) {
61
+ missing.push(name)
62
+ } else if (cfg[name] !== value) {
63
+ mismatched.push(`${name}: global='${value}' config='${cfg[name]}'`)
64
+ }
65
+ }
66
+ expect(missing, 'global.css vars missing from config.ts codegen source').toEqual([])
67
+ expect(mismatched, 'value drift between config.ts and global.css').toEqual([])
68
+ })
69
+
70
+ it(`${mode}: config emits no var absent from global.css`, () => {
71
+ const extra = Object.keys(cfg).filter(
72
+ (name) => !globalVars.has(name) && !CODEGEN_ALLOWLIST.has(name),
73
+ )
74
+ expect(extra, 'config.ts vars not present in global.css :root').toEqual([])
75
+ })
76
+ }
77
+ })
@@ -7,6 +7,35 @@
7
7
 
8
8
  import { semanticColorsLight, semanticColorsDark, type ThemeMode } from './tokens/semantic'
9
9
 
10
+ // Theme-independent custom properties: identical in light and dark in
11
+ // global.css (RGB glow decompositions, font families, animation tokens). Kept
12
+ // as literals since they are not part of the semantic COLOR maps, and spread
13
+ // into both theme maps so codegen mirrors global.css exactly.
14
+ const themeIndependentCSSVars = {
15
+ // RGB decomposed values for glow shadows
16
+ '--color-brand-primary-rgb': '255, 121, 0',
17
+ '--color-brand-secondary-rgb': '64, 109, 135',
18
+ '--color-status-success-rgb': '20, 184, 166',
19
+ '--color-status-error-rgb': '209, 67, 67',
20
+ '--color-status-warning-rgb': '255, 176, 32',
21
+ '--color-status-info-rgb': '33, 150, 243',
22
+ '--color-background-base-rgb': '16, 16, 16',
23
+
24
+ // Font families
25
+ '--font-family-sans': "'Inter'",
26
+ '--font-family-body': "'Nunito Sans'",
27
+ '--font-family-heading': "'Space Grotesk'",
28
+ '--font-family-mono': "'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace",
29
+
30
+ // Animation tokens
31
+ '--ease-out': 'cubic-bezier(0.22, 1, 0.36, 1)',
32
+ '--ease-in-out': 'cubic-bezier(0.65, 0, 0.35, 1)',
33
+ '--ease-spring': 'cubic-bezier(0.34, 1.56, 0.64, 1)',
34
+ '--duration-fast': '150ms',
35
+ '--duration-normal': '250ms',
36
+ '--duration-slow': '400ms',
37
+ } as const
38
+
10
39
  // CSS custom properties for light mode
11
40
  export const lightThemeCSSVars = {
12
41
  '--color-brand-primary': semanticColorsLight['brand-primary'],
@@ -57,6 +86,67 @@ export const lightThemeCSSVars = {
57
86
  '--color-interactive-disabled': semanticColorsLight['interactive-disabled'],
58
87
 
59
88
  '--color-divider': semanticColorsLight['divider'],
89
+
90
+ '--color-brand-primary-hover': semanticColorsLight['brand-primary-hover'],
91
+ '--color-brand-primary-active': semanticColorsLight['brand-primary-active'],
92
+ '--color-brand-secondary-hover': semanticColorsLight['brand-secondary-hover'],
93
+ '--color-brand-secondary-active': semanticColorsLight['brand-secondary-active'],
94
+
95
+ '--color-status-success-light': semanticColorsLight['status-success-light'],
96
+ '--color-status-success-dark': semanticColorsLight['status-success-dark'],
97
+ '--color-status-error-light': semanticColorsLight['status-error-light'],
98
+ '--color-status-error-dark': semanticColorsLight['status-error-dark'],
99
+ '--color-status-warning-light': semanticColorsLight['status-warning-light'],
100
+ '--color-status-warning-dark': semanticColorsLight['status-warning-dark'],
101
+ '--color-status-info-light': semanticColorsLight['status-info-light'],
102
+ '--color-status-info-dark': semanticColorsLight['status-info-dark'],
103
+
104
+ '--color-on-status-success': semanticColorsLight['on-status-success'],
105
+ '--color-on-status-error': semanticColorsLight['on-status-error'],
106
+ '--color-on-status-warning': semanticColorsLight['on-status-warning'],
107
+ '--color-on-status-info': semanticColorsLight['on-status-info'],
108
+
109
+ '--color-result-improve': semanticColorsLight['result-improve'],
110
+ '--color-result-improve-light': semanticColorsLight['result-improve-light'],
111
+ '--color-result-improve-dark': semanticColorsLight['result-improve-dark'],
112
+ '--color-result-degrade': semanticColorsLight['result-degrade'],
113
+ '--color-result-degrade-light': semanticColorsLight['result-degrade-light'],
114
+ '--color-result-degrade-dark': semanticColorsLight['result-degrade-dark'],
115
+ '--color-result-inconclusive': semanticColorsLight['result-inconclusive'],
116
+ '--color-result-inconclusive-light': semanticColorsLight['result-inconclusive-light'],
117
+ '--color-result-neutral': semanticColorsLight['result-neutral'],
118
+
119
+ '--color-on-result-improve': semanticColorsLight['on-result-improve'],
120
+ '--color-on-result-degrade': semanticColorsLight['on-result-degrade'],
121
+ '--color-on-result-inconclusive': semanticColorsLight['on-result-inconclusive'],
122
+
123
+ '--color-data-1': semanticColorsLight['data-1'],
124
+ '--color-data-2': semanticColorsLight['data-2'],
125
+ '--color-data-3': semanticColorsLight['data-3'],
126
+ '--color-data-4': semanticColorsLight['data-4'],
127
+ '--color-data-5': semanticColorsLight['data-5'],
128
+ '--color-data-6': semanticColorsLight['data-6'],
129
+ '--color-data-7': semanticColorsLight['data-7'],
130
+ '--color-data-8': semanticColorsLight['data-8'],
131
+ '--color-data-9': semanticColorsLight['data-9'],
132
+ '--color-data-10': semanticColorsLight['data-10'],
133
+
134
+ '--color-text-link-hover': semanticColorsLight['text-link-hover'],
135
+
136
+ '--color-surface-overlay': semanticColorsLight['surface-overlay'],
137
+ '--color-surface-input': semanticColorsLight['surface-input'],
138
+
139
+ '--color-border-input': semanticColorsLight['border-input'],
140
+ '--color-border-input-hover': semanticColorsLight['border-input-hover'],
141
+ '--color-border-input-focus': semanticColorsLight['border-input-focus'],
142
+ '--color-border-input-error': semanticColorsLight['border-input-error'],
143
+
144
+ '--color-interactive-disabled-text': semanticColorsLight['interactive-disabled-text'],
145
+
146
+ '--color-avatar-background': semanticColorsLight['avatar-background'],
147
+ '--color-avatar-text': semanticColorsLight['avatar-text'],
148
+
149
+ ...themeIndependentCSSVars,
60
150
  } as const
61
151
 
62
152
  // CSS custom properties for dark mode (default)
@@ -109,6 +199,67 @@ export const darkThemeCSSVars = {
109
199
  '--color-interactive-disabled': semanticColorsDark['interactive-disabled'],
110
200
 
111
201
  '--color-divider': semanticColorsDark['divider'],
202
+
203
+ '--color-brand-primary-hover': semanticColorsDark['brand-primary-hover'],
204
+ '--color-brand-primary-active': semanticColorsDark['brand-primary-active'],
205
+ '--color-brand-secondary-hover': semanticColorsDark['brand-secondary-hover'],
206
+ '--color-brand-secondary-active': semanticColorsDark['brand-secondary-active'],
207
+
208
+ '--color-status-success-light': semanticColorsDark['status-success-light'],
209
+ '--color-status-success-dark': semanticColorsDark['status-success-dark'],
210
+ '--color-status-error-light': semanticColorsDark['status-error-light'],
211
+ '--color-status-error-dark': semanticColorsDark['status-error-dark'],
212
+ '--color-status-warning-light': semanticColorsDark['status-warning-light'],
213
+ '--color-status-warning-dark': semanticColorsDark['status-warning-dark'],
214
+ '--color-status-info-light': semanticColorsDark['status-info-light'],
215
+ '--color-status-info-dark': semanticColorsDark['status-info-dark'],
216
+
217
+ '--color-on-status-success': semanticColorsDark['on-status-success'],
218
+ '--color-on-status-error': semanticColorsDark['on-status-error'],
219
+ '--color-on-status-warning': semanticColorsDark['on-status-warning'],
220
+ '--color-on-status-info': semanticColorsDark['on-status-info'],
221
+
222
+ '--color-result-improve': semanticColorsDark['result-improve'],
223
+ '--color-result-improve-light': semanticColorsDark['result-improve-light'],
224
+ '--color-result-improve-dark': semanticColorsDark['result-improve-dark'],
225
+ '--color-result-degrade': semanticColorsDark['result-degrade'],
226
+ '--color-result-degrade-light': semanticColorsDark['result-degrade-light'],
227
+ '--color-result-degrade-dark': semanticColorsDark['result-degrade-dark'],
228
+ '--color-result-inconclusive': semanticColorsDark['result-inconclusive'],
229
+ '--color-result-inconclusive-light': semanticColorsDark['result-inconclusive-light'],
230
+ '--color-result-neutral': semanticColorsDark['result-neutral'],
231
+
232
+ '--color-on-result-improve': semanticColorsDark['on-result-improve'],
233
+ '--color-on-result-degrade': semanticColorsDark['on-result-degrade'],
234
+ '--color-on-result-inconclusive': semanticColorsDark['on-result-inconclusive'],
235
+
236
+ '--color-data-1': semanticColorsDark['data-1'],
237
+ '--color-data-2': semanticColorsDark['data-2'],
238
+ '--color-data-3': semanticColorsDark['data-3'],
239
+ '--color-data-4': semanticColorsDark['data-4'],
240
+ '--color-data-5': semanticColorsDark['data-5'],
241
+ '--color-data-6': semanticColorsDark['data-6'],
242
+ '--color-data-7': semanticColorsDark['data-7'],
243
+ '--color-data-8': semanticColorsDark['data-8'],
244
+ '--color-data-9': semanticColorsDark['data-9'],
245
+ '--color-data-10': semanticColorsDark['data-10'],
246
+
247
+ '--color-text-link-hover': semanticColorsDark['text-link-hover'],
248
+
249
+ '--color-surface-overlay': semanticColorsDark['surface-overlay'],
250
+ '--color-surface-input': semanticColorsDark['surface-input'],
251
+
252
+ '--color-border-input': semanticColorsDark['border-input'],
253
+ '--color-border-input-hover': semanticColorsDark['border-input-hover'],
254
+ '--color-border-input-focus': semanticColorsDark['border-input-focus'],
255
+ '--color-border-input-error': semanticColorsDark['border-input-error'],
256
+
257
+ '--color-interactive-disabled-text': semanticColorsDark['interactive-disabled-text'],
258
+
259
+ '--color-avatar-background': semanticColorsDark['avatar-background'],
260
+ '--color-avatar-text': semanticColorsDark['avatar-text'],
261
+
262
+ ...themeIndependentCSSVars,
112
263
  } as const
113
264
 
114
265
  // Helper to get CSS vars for a theme mode
@@ -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
+ })