@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.
- 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-5SSKGS6E.mjs → chunk-7XPB4NAO.mjs} +124 -4
- package/dist/chunk-7XPB4NAO.mjs.map +1 -0
- package/dist/{chunk-3VLOBS6O.mjs → chunk-P7TSQUN6.mjs} +3 -3
- package/dist/{chunk-3VLOBS6O.mjs.map → chunk-P7TSQUN6.mjs.map} +1 -1
- package/dist/chunk-TOD2WQBG.mjs +1048 -0
- package/dist/chunk-TOD2WQBG.mjs.map +1 -0
- package/dist/index.d.mts +375 -249
- package/dist/index.d.ts +375 -249
- package/dist/index.js +1259 -918
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +1314 -2107
- package/dist/index.mjs.map +1 -1
- package/dist/theme/index.d.mts +260 -0
- package/dist/theme/index.d.ts +260 -0
- package/dist/theme/index.js +122 -2
- package/dist/theme/index.js.map +1 -1
- package/dist/theme/index.mjs +2 -2
- package/dist/theme/tokens-css.d.mts +5 -3
- package/dist/theme/tokens-css.d.ts +5 -3
- package/dist/theme/tokens-css.js +318 -9
- package/dist/theme/tokens-css.js.map +1 -1
- package/dist/theme/tokens-css.mjs +1 -1
- package/dist/theme/tokens-css.mjs.map +1 -1
- package/package.json +10 -1
- package/src/bodymap.ts +37 -0
- package/src/components/custom/Table/Table.test.tsx +27 -0
- package/src/components/custom/Table/Table.tsx +33 -17
- 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/BodyMap.tsx +5 -6
- package/src/components/custom/Workout/BodyMapDetailPanel.tsx +7 -8
- package/src/components/custom/Workout/CapacityBandChart.tsx +1 -1
- package/src/components/custom/Workout/ExerciseCard.test.tsx +24 -0
- package/src/components/custom/Workout/ExerciseCard.tsx +28 -15
- 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.tsx +6 -7
- package/src/components/custom/Workout/MesoCard.tsx +4 -2
- package/src/components/custom/Workout/MesoProgressBar.test.tsx +12 -0
- package/src/components/custom/Workout/MesoProgressBar.tsx +5 -1
- package/src/components/custom/Workout/MesoStatusCard.tsx +5 -5
- package/src/components/custom/Workout/PlaceholderStrip.tsx +2 -3
- package/src/components/custom/Workout/PrHistoryModal.tsx +5 -5
- 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.tsx +7 -8
- package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
- package/src/components/custom/Workout/RestTimer.tsx +11 -9
- package/src/components/custom/Workout/SetRow.tsx +13 -19
- package/src/components/custom/Workout/StatusDot.tsx +1 -0
- package/src/components/custom/Workout/StrengthTrendChart.tsx +5 -5
- package/src/components/custom/Workout/TempoDisplay.tsx +3 -1
- 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.test.tsx +10 -0
- package/src/components/custom/Workout/VelocityStrip.tsx +16 -15
- package/src/components/custom/Workout/WeekRow.tsx +1 -1
- package/src/components/custom/Workout/WorkoutCard.tsx +5 -3
- package/src/components/custom/Workout/index.ts +71 -32
- package/src/theme/config.completeness.test.ts +77 -0
- package/src/theme/config.ts +151 -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/tokens-css.ts +5 -3
- package/src/theme/workout-tokens.ts +12 -14
- 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-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
|
-
|
|
16
|
-
|
|
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 ? '
|
|
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
|
-
|
|
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: '
|
|
192
|
+
style={{ fontSize: 8, fontWeight: '600', color: 'var(--color-text-secondary)' }}
|
|
192
193
|
testID={`velocity-label-${i}`}
|
|
193
194
|
>
|
|
194
|
-
{v
|
|
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
|
|
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
|
|
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: '
|
|
253
|
+
color: 'var(--color-text-secondary)',
|
|
253
254
|
fontFamily: 'Inter, sans-serif',
|
|
254
255
|
}}
|
|
255
256
|
>
|
|
256
|
-
{meanZone} {'\u00B7'} {meanVelocity
|
|
257
|
+
{meanZone} {'\u00B7'} {formatVelocity(meanVelocity)} m/s
|
|
257
258
|
</Text>
|
|
258
259
|
<Text
|
|
259
260
|
style={{
|
|
260
261
|
fontSize: 10,
|
|
261
|
-
color: '
|
|
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 : '
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
+
})
|
package/src/theme/config.ts
CHANGED
|
@@ -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
|
+
})
|