@titan-design/react-ui 0.4.0 → 0.6.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 (150) hide show
  1. package/dist/MesoStatusCard-CQk71hSi.d.mts +77 -0
  2. package/dist/MesoStatusCard-CQk71hSi.d.ts +77 -0
  3. package/dist/bodymap.d.mts +246 -3
  4. package/dist/bodymap.d.ts +246 -3
  5. package/dist/bodymap.js +232 -185
  6. package/dist/bodymap.js.map +1 -1
  7. package/dist/bodymap.mjs +1963 -11
  8. package/dist/bodymap.mjs.map +1 -1
  9. package/dist/index.d.mts +193 -744
  10. package/dist/index.d.ts +193 -744
  11. package/dist/index.js +1551 -1856
  12. package/dist/index.js.map +1 -1
  13. package/dist/index.mjs +10770 -8213
  14. package/dist/index.mjs.map +1 -1
  15. package/dist/pages-D7Jlssvp.d.ts +791 -0
  16. package/dist/pages-MO0JCySL.d.mts +791 -0
  17. package/dist/pages.d.mts +4 -0
  18. package/dist/pages.d.ts +4 -0
  19. package/dist/pages.js +6694 -0
  20. package/dist/pages.js.map +1 -0
  21. package/dist/pages.mjs +6674 -0
  22. package/dist/pages.mjs.map +1 -0
  23. package/dist/theme/index.d.mts +473 -211
  24. package/dist/theme/index.d.ts +473 -211
  25. package/dist/theme/index.js +341 -127
  26. package/dist/theme/index.js.map +1 -1
  27. package/dist/theme/index.mjs +2085 -2
  28. package/dist/theme/index.mjs.map +1 -1
  29. package/dist/theme/tokens-css.js +148 -139
  30. package/dist/theme/tokens-css.js.map +1 -1
  31. package/dist/theme/tokens-css.mjs +582 -1
  32. package/dist/theme/tokens-css.mjs.map +1 -1
  33. package/package.json +11 -7
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +82 -6
  35. package/src/components/custom/DateTime/DateTime.test.tsx +29 -3
  36. package/src/components/custom/DateTime/DateTime.tsx +55 -15
  37. package/src/components/custom/Gauge/Gauge.test.tsx +2 -2
  38. package/src/components/custom/Gauge/Gauge.tsx +6 -3
  39. package/src/components/custom/Scatter/Scatter.tsx +3 -5
  40. package/src/components/custom/Treemap/Treemap.tsx +3 -5
  41. package/src/components/custom/Typography/Typography.stories.tsx +45 -27
  42. package/src/components/custom/Typography/Typography.test.tsx +13 -3
  43. package/src/components/custom/Typography/Typography.tsx +5 -0
  44. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +2 -1
  45. package/src/components/custom/Workout/BaseBadge.stories.tsx +3 -3
  46. package/src/components/custom/Workout/BaseBadge.test.tsx +1 -1
  47. package/src/components/custom/Workout/BaseBadge.tsx +1 -1
  48. package/src/components/custom/Workout/BodyMap.tsx +6 -3
  49. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +6 -3
  50. package/src/components/custom/Workout/CapacityBandChart.test.tsx +2 -2
  51. package/src/components/custom/Workout/CapacityBandChart.tsx +9 -6
  52. package/src/components/custom/Workout/DeviationBar.test.tsx +1 -1
  53. package/src/components/custom/Workout/DeviationBar.tsx +7 -4
  54. package/src/components/custom/Workout/ExerciseCard.tsx +5 -1
  55. package/src/components/custom/Workout/ExerciseDetailPage.tsx +19 -5
  56. package/src/components/custom/Workout/InputBar.tsx +2 -1
  57. package/src/components/custom/Workout/IntensityBar.test.tsx +112 -60
  58. package/src/components/custom/Workout/IntensityBar.tsx +113 -58
  59. package/src/components/custom/Workout/MesoCard.tsx +8 -3
  60. package/src/components/custom/Workout/MesoProgressBar.test.tsx +1 -1
  61. package/src/components/custom/Workout/MesoProgressBar.tsx +3 -2
  62. package/src/components/custom/Workout/MesoStatusCard.tsx +20 -13
  63. package/src/components/custom/Workout/MuscleGroupChip.tsx +7 -4
  64. package/src/components/custom/Workout/PlaceholderStrip.tsx +5 -9
  65. package/src/components/custom/Workout/PrBadge.tsx +7 -4
  66. package/src/components/custom/Workout/PrHistoryModal.tsx +6 -5
  67. package/src/components/custom/Workout/ProgramPlanningPage.tsx +2 -1
  68. package/src/components/custom/Workout/README.md +50 -0
  69. package/src/components/custom/Workout/ReadinessCheck.tsx +7 -4
  70. package/src/components/custom/Workout/RestTimer.test.tsx +41 -10
  71. package/src/components/custom/Workout/RestTimer.tsx +50 -44
  72. package/src/components/custom/Workout/SetRow.tsx +12 -2
  73. package/src/components/custom/Workout/Sparkline.tsx +2 -1
  74. package/src/components/custom/Workout/StatusDot.test.tsx +2 -2
  75. package/src/components/custom/Workout/StatusDot.tsx +12 -9
  76. package/src/components/custom/Workout/StrengthTrendChart.tsx +9 -6
  77. package/src/components/custom/Workout/SupersetWrapper.tsx +2 -1
  78. package/src/components/custom/Workout/TempoDisplay.stories.tsx +69 -2
  79. package/src/components/custom/Workout/TempoDisplay.test.tsx +35 -0
  80. package/src/components/custom/Workout/TempoDisplay.tsx +109 -3
  81. package/src/components/custom/Workout/VelocityStrip.test.tsx +89 -2
  82. package/src/components/custom/Workout/VelocityStrip.tsx +203 -38
  83. package/src/components/custom/Workout/WeekRow.test.tsx +2 -2
  84. package/src/components/custom/Workout/WeekRow.tsx +3 -2
  85. package/src/components/custom/Workout/WeightBadge.test.tsx +2 -2
  86. package/src/components/custom/Workout/WeightBadge.tsx +8 -4
  87. package/src/components/custom/Workout/WorkoutCard.tsx +5 -2
  88. package/src/components/custom/Workout/WorkoutPill.tsx +12 -8
  89. package/src/components/custom/Workout/icons.tsx +7 -0
  90. package/src/components/custom/Workout/index.ts +38 -47
  91. package/src/components/icons/SvgIcon.tsx +50 -0
  92. package/src/components/icons/icons.stories.tsx +57 -0
  93. package/src/components/icons/icons.test.tsx +43 -0
  94. package/src/components/icons/icons.tsx +45 -0
  95. package/src/components/icons/index.ts +4 -0
  96. package/src/components/index.ts +6 -0
  97. package/src/components/shell/BrandLockup.stories.tsx +27 -0
  98. package/src/components/shell/BrandLockup.test.tsx +23 -0
  99. package/src/components/shell/BrandLockup.tsx +47 -0
  100. package/src/components/shell/DeviceIndicator.stories.tsx +28 -0
  101. package/src/components/shell/DeviceIndicator.test.tsx +26 -0
  102. package/src/components/shell/DeviceIndicator.tsx +52 -0
  103. package/src/components/shell/DeviceMenu.stories.tsx +47 -0
  104. package/src/components/shell/DeviceMenu.test.tsx +40 -0
  105. package/src/components/shell/DeviceMenu.tsx +65 -0
  106. package/src/components/shell/DeviceRow.stories.tsx +38 -0
  107. package/src/components/shell/DeviceRow.test.tsx +37 -0
  108. package/src/components/shell/DeviceRow.tsx +58 -0
  109. package/src/components/shell/README.md +86 -0
  110. package/src/components/shell/SessionStatePill.stories.tsx +27 -0
  111. package/src/components/shell/SessionStatePill.test.tsx +25 -0
  112. package/src/components/shell/SessionStatePill.tsx +46 -0
  113. package/src/components/shell/TopBar.stories.tsx +49 -0
  114. package/src/components/shell/TopBar.test.tsx +36 -0
  115. package/src/components/shell/TopBar.tsx +89 -0
  116. package/src/components/shell/index.ts +8 -0
  117. package/src/components/ui/indicator/Indicator.stories.tsx +37 -1
  118. package/src/components/ui/indicator/Indicator.test.tsx +19 -1
  119. package/src/components/ui/indicator/Indicator.tsx +45 -6
  120. package/src/components/ui/indicator/index.ts +1 -1
  121. package/src/components/ui/spinner/Spinner.tsx +3 -2
  122. package/src/components/ui/toolbar-button/ToolbarButton.tsx +2 -1
  123. package/src/index.ts +3 -2
  124. package/src/pages.ts +61 -0
  125. package/src/test/stories-smoke.test.tsx +50 -0
  126. package/src/theme/ColorSystem.stories.tsx +323 -0
  127. package/src/theme/Colors.stories.tsx +69 -1
  128. package/src/theme/barrel.ts +54 -0
  129. package/src/theme/config.ts +16 -3
  130. package/src/theme/extracted-colors-dataviz.ts +21 -0
  131. package/src/theme/extracted-colors-ui.ts +20 -0
  132. package/src/theme/global.css +64 -52
  133. package/src/theme/gradients.test.ts +25 -0
  134. package/src/theme/gradients.ts +36 -0
  135. package/src/theme/index.ts +8 -43
  136. package/src/theme/native-theming-guard.test.ts +117 -0
  137. package/src/theme/tokens/primitives.test.ts +232 -0
  138. package/src/theme/tokens/primitives.ts +247 -0
  139. package/src/theme/tokens/semantic.ts +93 -81
  140. package/src/theme/workout-tokens.ts +22 -21
  141. package/src/utils/avatar-color.ts +3 -3
  142. package/tailwind.config.js +10 -3
  143. package/dist/bodymap-BhG55xHM.d.mts +0 -318
  144. package/dist/bodymap-BhG55xHM.d.ts +0 -318
  145. package/dist/chunk-2SISKTWM.mjs +0 -1027
  146. package/dist/chunk-2SISKTWM.mjs.map +0 -1
  147. package/dist/chunk-7XPB4NAO.mjs +0 -1013
  148. package/dist/chunk-7XPB4NAO.mjs.map +0 -1
  149. package/dist/chunk-SNVKL5WZ.mjs +0 -883
  150. package/dist/chunk-SNVKL5WZ.mjs.map +0 -1
@@ -8,7 +8,10 @@ const meta: Meta<typeof Indicator> = {
8
8
  tags: ['autodocs'],
9
9
  argTypes: {
10
10
  size: { control: 'select', options: ['xs', 'sm', 'md', 'lg'] },
11
- color: { control: 'select', options: ['default', 'primary', 'success', 'error', 'warning', 'info'] },
11
+ color: {
12
+ control: 'select',
13
+ options: ['default', 'primary', 'success', 'error', 'warning', 'info'],
14
+ },
12
15
  glow: { control: 'boolean' },
13
16
  ring: { control: 'boolean' },
14
17
  },
@@ -72,3 +75,36 @@ export const CustomColor: Story = {
72
75
  </View>
73
76
  ),
74
77
  }
78
+
79
+ export const Pulse: Story = {
80
+ render: () => (
81
+ <View className="flex-row gap-6 items-center">
82
+ <Indicator size="md" color="success" pulse />
83
+ <Indicator size="md" color="success" pulse="ping" />
84
+ <Indicator size="md" color="warning" />
85
+ <Indicator size="md" color="default" />
86
+ </View>
87
+ ),
88
+ parameters: {
89
+ docs: {
90
+ description: {
91
+ story:
92
+ 'Opacity pulse (default), expanding-ring pulse (`pulse="ping"`, green for live), solid, dim.',
93
+ },
94
+ },
95
+ },
96
+ }
97
+
98
+ export const Vivid: Story = {
99
+ render: () => (
100
+ <View className="flex-row gap-4 items-center">
101
+ <Indicator size="md" color="success" />
102
+ <Indicator size="md" color="success" />
103
+ <Indicator size="md" color="error" />
104
+ <Indicator size="md" color="error-vivid" />
105
+ </View>
106
+ ),
107
+ parameters: {
108
+ docs: { description: { story: 'Standard status vs the vivid palette (success/error).' } },
109
+ },
110
+ }
@@ -18,7 +18,15 @@ describe('Indicator', () => {
18
18
  })
19
19
 
20
20
  it('renders with all color options', () => {
21
- const colors = ['default', 'primary', 'success', 'error', 'warning', 'info'] as const
21
+ const colors = [
22
+ 'default',
23
+ 'primary',
24
+ 'success',
25
+ 'error',
26
+ 'warning',
27
+ 'info',
28
+ 'error-vivid',
29
+ ] as const
22
30
  colors.forEach((color) => {
23
31
  const { unmount } = render(<Indicator color={color} />)
24
32
  unmount()
@@ -45,6 +53,16 @@ describe('Indicator', () => {
45
53
  expect(container.firstChild).toBeInTheDocument()
46
54
  })
47
55
 
56
+ it('supports pulse prop (opacity)', () => {
57
+ const { container } = render(<Indicator pulse color="success" />)
58
+ expect(container.firstChild).toBeInTheDocument()
59
+ })
60
+
61
+ it('supports the ping pulse variant', () => {
62
+ const { container } = render(<Indicator pulse="ping" color="success" />)
63
+ expect(container.firstChild).toBeInTheDocument()
64
+ })
65
+
48
66
  describe('accessibility', () => {
49
67
  it('has no accessibility violations', async () => {
50
68
  const { container } = render(<Indicator color="success" />)
@@ -1,9 +1,18 @@
1
- import React from 'react'
2
1
  import { View, type ViewProps } from 'react-native'
3
2
  import { cn } from '../../../utils/cn'
4
3
 
5
4
  export type IndicatorSize = 'xs' | 'sm' | 'md' | 'lg'
6
- export type IndicatorColor = 'default' | 'primary' | 'success' | 'error' | 'warning' | 'info'
5
+ export type IndicatorColor =
6
+ | 'default'
7
+ | 'primary'
8
+ | 'success'
9
+ | 'error'
10
+ | 'warning'
11
+ | 'info'
12
+ | 'error-vivid'
13
+
14
+ /** How a pulsing indicator animates: a subtle opacity fade, or an expanding ring. */
15
+ export type IndicatorPulse = 'opacity' | 'ping'
7
16
 
8
17
  export interface IndicatorProps extends ViewProps {
9
18
  /** Dot size */
@@ -16,6 +25,8 @@ export interface IndicatorProps extends ViewProps {
16
25
  glow?: boolean
17
26
  /** Add a ring border */
18
27
  ring?: boolean
28
+ /** Animate for live/active status. `true` = opacity fade; `'ping'` = expanding ring. */
29
+ pulse?: boolean | IndicatorPulse
19
30
  /** Additional className */
20
31
  className?: string
21
32
  }
@@ -34,6 +45,7 @@ const colorStyles: Record<IndicatorColor, string> = {
34
45
  error: 'bg-status-error',
35
46
  warning: 'bg-status-warning',
36
47
  info: 'bg-status-info',
48
+ 'error-vivid': 'bg-status-error-vivid',
37
49
  }
38
50
 
39
51
  export function Indicator({
@@ -42,14 +54,39 @@ export function Indicator({
42
54
  customColor,
43
55
  glow = false,
44
56
  ring = false,
57
+ pulse = false,
45
58
  className,
46
59
  style,
47
60
  ...props
48
61
  }: IndicatorProps) {
62
+ const pulseMode: IndicatorPulse | false = pulse === true ? 'opacity' : pulse || false
63
+ const colorClass = !customColor ? colorStyles[color] : undefined
64
+ const colorStyle = customColor ? { backgroundColor: customColor } : undefined
65
+
66
+ // Expanding-ring pulse: a ping layer behind the solid dot (for live/active status).
67
+ if (pulseMode === 'ping') {
68
+ return (
69
+ <View className={cn('relative', sizeStyles[size], className)} style={style} {...props}>
70
+ <View
71
+ className={cn('absolute inset-0 rounded-full animate-ping', colorClass)}
72
+ style={colorStyle}
73
+ />
74
+ <View
75
+ className={cn(
76
+ 'relative rounded-full',
77
+ sizeStyles[size],
78
+ colorClass,
79
+ ring && 'border-2 border-background-base'
80
+ )}
81
+ style={colorStyle}
82
+ />
83
+ </View>
84
+ )
85
+ }
86
+
49
87
  const dynamicStyle = {
50
88
  ...(typeof style === 'object' ? style : {}),
51
- ...(customColor ? { backgroundColor: customColor } : {}),
52
- ...(glow && !customColor ? {} : {}),
89
+ ...(colorStyle ?? {}),
53
90
  ...(glow && customColor ? { boxShadow: `0 0 6px ${customColor}` } : {}),
54
91
  }
55
92
 
@@ -58,12 +95,14 @@ export function Indicator({
58
95
  className={cn(
59
96
  'rounded-full shrink-0',
60
97
  sizeStyles[size],
61
- !customColor && colorStyles[color],
98
+ colorClass,
99
+ pulseMode === 'opacity' && 'animate-pulse',
62
100
  ring && 'border-2 border-background-base',
63
- glow && !customColor && color === 'success' && 'shadow-[0_0_6px_rgba(20,184,166,0.6)]',
101
+ glow && !customColor && color === 'success' && 'shadow-[0_0_6px_rgba(46,213,115,0.6)]',
64
102
  glow && !customColor && color === 'error' && 'shadow-[0_0_6px_rgba(239,68,68,0.6)]',
65
103
  glow && !customColor && color === 'warning' && 'shadow-[0_0_6px_rgba(245,158,11,0.6)]',
66
104
  glow && !customColor && color === 'primary' && 'shadow-[0_0_6px_rgba(255,121,0,0.6)]',
105
+ glow && !customColor && color === 'error-vivid' && 'shadow-[0_0_6px_rgba(255,71,87,0.6)]',
67
106
  className
68
107
  )}
69
108
  style={Object.keys(dynamicStyle).length > 0 ? dynamicStyle : style}
@@ -1,2 +1,2 @@
1
1
  export { Indicator } from './Indicator'
2
- export type { IndicatorProps, IndicatorSize, IndicatorColor } from './Indicator'
2
+ export type { IndicatorProps, IndicatorSize, IndicatorColor, IndicatorPulse } from './Indicator'
@@ -1,6 +1,7 @@
1
1
  import React from 'react'
2
2
  import { ActivityIndicator, View, type ViewProps } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
+ import { SPINNER_PRIMARY, SPINNER_SECONDARY } from '../../../theme/extracted-colors-ui'
4
5
 
5
6
  export type SpinnerSize = 'sm' | 'md' | 'lg' | 'xl'
6
7
  export type SpinnerColor = 'primary' | 'secondary' | 'white' | 'default'
@@ -24,8 +25,8 @@ const sizeMap: Record<SpinnerSize, 'small' | 'large'> = {
24
25
  }
25
26
 
26
27
  const colorMap: Record<SpinnerColor, string> = {
27
- primary: '#5048E5',
28
- secondary: '#10B981',
28
+ primary: SPINNER_PRIMARY,
29
+ secondary: SPINNER_SECONDARY,
29
30
  white: '#FFFFFF',
30
31
  default: '#6B7280',
31
32
  }
@@ -2,6 +2,7 @@ import React, { useState, useCallback, createContext, useContext } from 'react'
2
2
  import { View, Text, Pressable, type ViewProps, StyleSheet, Platform } from 'react-native'
3
3
  import { cn } from '../../../utils/cn'
4
4
  import { neumorphicShadows, getHoverColors } from '../../../theme'
5
+ import { resolveColor } from '../../../theme/resolve-color'
5
6
 
6
7
  export type ToolbarButtonVariant = 'default' | 'neumorphic'
7
8
  export type ToolbarButtonSize = 'sm' | 'md' | 'lg'
@@ -129,7 +130,7 @@ export function ToolbarButton({
129
130
 
130
131
  // Determine icon color based on state
131
132
  const getIconColor = () => {
132
- if (isActive === true) return '#FF7900' // brand-primary
133
+ if (isActive === true) return resolveColor('brand-primary')
133
134
  return showActive ? '#FFFFFF' : '#D1D1D1'
134
135
  }
135
136
 
package/src/index.ts CHANGED
@@ -3,8 +3,9 @@
3
3
  // Components
4
4
  export * from './components'
5
5
 
6
- // Theme
7
- export * from './theme'
6
+ // Theme (nativewind-free surface; ThemeProvider value lives on the /theme
7
+ // subpath so the root barrel does not pull the nativewind import chain)
8
+ export * from './theme/barrel'
8
9
 
9
10
  // Utilities
10
11
  export * from './utils'
package/src/pages.ts ADDED
@@ -0,0 +1,61 @@
1
+ // Subpath entry: @titan-design/react-ui/pages
2
+ //
3
+ // Page-level organisms — phone-shaped reference screens (ActiveWorkoutPage,
4
+ // ExerciseDetailPage, ProgramPlanningPage). Demoted out of the root barrel for
5
+ // titan 0.5.0: they are full app screens, orphaned by both consumers, and their
6
+ // successors are the form-factor-responsive level views (plan B2). Their VALUE
7
+ // exports (components + derivation helpers) live HERE; type-only re-exports
8
+ // stay on the root barrel so consumers can still type props without importing
9
+ // the screens. Mirrors the `/bodymap` isolation pattern.
10
+ //
11
+ // TrainingStatusPage is NOT here — it depends on react-native-body-highlighter
12
+ // and lives under `@titan-design/react-ui/bodymap`.
13
+ export {
14
+ ActiveWorkoutPage,
15
+ countCompletedSets,
16
+ deriveWorkoutProgress,
17
+ findActiveExercise,
18
+ statusToCardState,
19
+ toActiveCardProps,
20
+ groupExercises,
21
+ type ActiveWorkoutPageProps,
22
+ type ActiveWorkoutExercise,
23
+ type ActiveWorkoutSuperset,
24
+ type ActiveWorkoutInput,
25
+ type ActiveWorkoutRest,
26
+ type ActiveExerciseStatus,
27
+ type WorkoutProgress,
28
+ type WorkoutGroup,
29
+ } from './components/custom/Workout/ActiveWorkoutPage'
30
+ export {
31
+ ExerciseDetailPage,
32
+ deriveExerciseStats,
33
+ toExerciseCardProps,
34
+ summarizeVbt,
35
+ EXERCISE_DETAIL_TABS,
36
+ type ExerciseDetailPageProps,
37
+ type ExerciseDetailTab,
38
+ type ExerciseDetailHeader,
39
+ type ExerciseDetailEntry,
40
+ type ExerciseTrend,
41
+ type ExerciseVbtSet,
42
+ type ExerciseVbt,
43
+ type ExerciseDetailStats,
44
+ type VbtSummary,
45
+ } from './components/custom/Workout/ExerciseDetailPage'
46
+ export {
47
+ ProgramPlanningPage,
48
+ deriveNavLevel,
49
+ toProgressBarMesos,
50
+ findMeso,
51
+ findWeek,
52
+ findWorkout,
53
+ buildBreadcrumbs,
54
+ type ProgramPlanningPageProps,
55
+ type PlanMeso,
56
+ type PlanWeek,
57
+ type PlanWorkout,
58
+ type ProgramNavLevel,
59
+ type ProgramSelection,
60
+ type ProgramBreadcrumb,
61
+ } from './components/custom/Workout/ProgramPlanningPage'
@@ -0,0 +1,50 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render } from '@testing-library/react'
3
+ import { composeStories } from '@storybook/react'
4
+
5
+ /**
6
+ * Storybook → render-test bridge (TD-04.11 / VW-20).
7
+ *
8
+ * The design system has a complete `*.stories.tsx` set but no test consumed it.
9
+ * This uses Storybook's portable-stories `composeStories` to mount EVERY story
10
+ * (with its args/decorators applied) through the same react-native-web + jsdom
11
+ * pipeline the unit tests use, asserting each renders without throwing and emits
12
+ * output. It's broad, near-free coverage that catches "this organism/variant
13
+ * crashes on render" regressions across the whole component set — the long tail
14
+ * the hand-written per-organism tests don't enumerate.
15
+ *
16
+ * Project annotations (the preview's theme decorator) are intentionally NOT
17
+ * applied: a smoke test only needs to prove each story renders, and skipping
18
+ * them avoids importing global.css / addon-themes into the test env.
19
+ */
20
+
21
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
22
+ type StoryModule = Record<string, any>
23
+
24
+ const storyModules = import.meta.glob<StoryModule>('../components/**/*.stories.tsx', {
25
+ eager: true,
26
+ })
27
+
28
+ describe('storybook stories render (composeStories smoke)', () => {
29
+ const entries = Object.entries(storyModules)
30
+
31
+ it('discovers a non-trivial number of story modules', () => {
32
+ expect(entries.length).toBeGreaterThan(10)
33
+ })
34
+
35
+ for (const [file, mod] of entries) {
36
+ const label = file.replace('../components/', '')
37
+ const composed = composeStories(mod)
38
+ for (const [name, Story] of Object.entries(composed)) {
39
+ it(`${label} › ${name} renders`, () => {
40
+ // Smoke level: every story must MOUNT without throwing. `firstChild` is
41
+ // not asserted — portal/modal organisms (PrHistoryModal) render into
42
+ // document.body, and empty-state stories legitimately render null; the
43
+ // hand-written per-organism tests own the detailed output assertions.
44
+ const result = render(<Story />)
45
+ expect(result.unmount).toBeTypeOf('function')
46
+ result.unmount()
47
+ })
48
+ }
49
+ }
50
+ })
@@ -0,0 +1,323 @@
1
+ import type { ReactNode } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react-vite'
3
+ import { View, Text } from 'react-native'
4
+ import {
5
+ primitiveColors,
6
+ primitiveRamps,
7
+ categoricalPalette,
8
+ getCategoricalColor,
9
+ CATEGORICAL_CVD_SAFE_MAX,
10
+ divergingScale,
11
+ sequentialEffort,
12
+ bestTextColor,
13
+ } from './tokens/primitives'
14
+
15
+ const meta: Meta = {
16
+ title: 'Design Tokens/Color System',
17
+ tags: ['autodocs'],
18
+ }
19
+ export default meta
20
+
21
+ // ---- color math (self-contained; drives the accessibility panel) ----
22
+ const hexToRgb = (hex: string): [number, number, number] => {
23
+ const h = hex.replace('#', '')
24
+ return [0, 2, 4].map((i) => parseInt(h.slice(i, i + 2), 16) / 255) as [number, number, number]
25
+ }
26
+ const toHex = (v: number) => Math.round(Math.min(1, Math.max(0, v)) * 255).toString(16).padStart(2, '0')
27
+ const lin = (c: number) => (c >= 0.04045 ? ((c + 0.055) / 1.055) ** 2.4 : c / 12.92)
28
+ const gamma = (c: number) => (c >= 0.0031308 ? 1.055 * Math.max(0, c) ** (1 / 2.4) - 0.055 : 12.92 * c)
29
+ const relLum = (hex: string) => {
30
+ const [r, g, b] = hexToRgb(hex).map(lin)
31
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
32
+ }
33
+ const contrast = (a: string, b: string) => {
34
+ const [l1, l2] = [relLum(a), relLum(b)].sort((x, y) => y - x)
35
+ return (l1 + 0.05) / (l2 + 0.05)
36
+ }
37
+ const bestText = (hex: string) => (contrast(hex, '#000000') >= contrast(hex, '#FFFFFF') ? '#000000' : '#FFFFFF')
38
+ // Machado-2009 dichromacy simulation (severity 1.0)
39
+ const CVD = {
40
+ deuteranopia: [0.367322, 0.860646, -0.227968, 0.280085, 0.672501, 0.047413, -0.01182, 0.04294, 0.968881],
41
+ protanopia: [0.152286, 1.052583, -0.204868, 0.114503, 0.786281, 0.099216, -0.003882, -0.048116, 1.051998],
42
+ } as const
43
+ const simulate = (M: readonly number[], hex: string) => {
44
+ const [r, g, b] = hexToRgb(hex).map(lin)
45
+ const out = [
46
+ M[0] * r + M[1] * g + M[2] * b,
47
+ M[3] * r + M[4] * g + M[5] * b,
48
+ M[6] * r + M[7] * g + M[8] * b,
49
+ ].map(gamma)
50
+ return '#' + out.map(toHex).join('')
51
+ }
52
+
53
+ const STEPS = [50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950] as const
54
+
55
+ function SectionTitle({ children }: { children: string }) {
56
+ return <Text className="text-lg font-bold text-text-primary mb-3 mt-2">{children}</Text>
57
+ }
58
+
59
+ function Ramp({ name, ramp }: { name: string; ramp: Record<number, string> }) {
60
+ return (
61
+ <View style={{ flexDirection: 'row', alignItems: 'center', gap: 12, marginBottom: 6 }}>
62
+ <Text className="text-text-secondary text-xs" style={{ width: 150 }}>
63
+ {name}
64
+ </Text>
65
+ <View style={{ flexDirection: 'row', flex: 1, borderRadius: 6, overflow: 'hidden', borderWidth: 1, borderColor: '#2C2C2C' }}>
66
+ {STEPS.map((s) => (
67
+ <View key={s} style={{ flex: 1, height: 30, backgroundColor: ramp[s] }} />
68
+ ))}
69
+ </View>
70
+ </View>
71
+ )
72
+ }
73
+
74
+ function Chip({ hex, label, sub }: { hex: string; label?: string; sub?: string }) {
75
+ return (
76
+ <View style={{ alignItems: 'center' }}>
77
+ <View
78
+ style={{
79
+ width: 58,
80
+ height: 44,
81
+ borderRadius: 8,
82
+ backgroundColor: hex,
83
+ borderWidth: 1,
84
+ borderColor: '#2C2C2C',
85
+ alignItems: 'center',
86
+ justifyContent: 'center',
87
+ }}
88
+ >
89
+ {label ? <Text style={{ color: bestText(hex), fontSize: 11, fontWeight: '700' }}>{label}</Text> : null}
90
+ </View>
91
+ {sub ? <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>{sub}</Text> : null}
92
+ </View>
93
+ )
94
+ }
95
+
96
+ const RAMP_NAMES: Array<[keyof typeof primitiveRamps, string]> = [
97
+ ['red', 'Red'],
98
+ ['orange', 'Orange'],
99
+ ['amber', 'Amber'],
100
+ ['green', 'Green'],
101
+ ['cyan', 'Cyan'],
102
+ ['blue', 'Blue'],
103
+ ['magenta', 'Magenta'],
104
+ ]
105
+
106
+ const SEM = {
107
+ success: primitiveRamps.green[300],
108
+ warning: primitiveRamps.amber[300],
109
+ error: primitiveRamps.red[600],
110
+ info: primitiveRamps.blue[500],
111
+ deload: primitiveRamps.magenta[600],
112
+ brand: primitiveRamps.orange[400],
113
+ brandDark: primitiveRamps.orange[600],
114
+ brandSecondary: primitiveRamps.cyan[600],
115
+ spinner: primitiveRamps.cyan[600],
116
+ neutral: primitiveColors.neutral[500],
117
+ } as const
118
+
119
+ // BodyMap training-status diverging scale (from the divergingScale token).
120
+ const D3_STATUS = divergingScale
121
+
122
+ export const Overview: StoryObj = {
123
+ render: () => (
124
+ <View style={{ padding: 24, gap: 6 }}>
125
+ <Text className="text-2xl font-bold text-text-primary mb-1">Color System</Text>
126
+ <Text className="text-text-secondary mb-4">
127
+ Seven OKLCH tonal ramps and an ordered, colorblind-safe categorical palette. Ramps are the single
128
+ source of truth; the categorical references ramp steps.
129
+ </Text>
130
+
131
+ <SectionTitle>Tonal ramps</SectionTitle>
132
+ {RAMP_NAMES.map(([key, name]) => (
133
+ <Ramp key={key} name={name} ramp={primitiveRamps[key]} />
134
+ ))}
135
+
136
+ <SectionTitle>Semantic tokens</SectionTitle>
137
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 18 }}>
138
+ {Object.entries(SEM).map(([label, hex]) => (
139
+ <View key={label} style={{ alignItems: 'center' }}>
140
+ <Chip hex={hex} />
141
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginTop: 3 }}>
142
+ {label}
143
+ </Text>
144
+ </View>
145
+ ))}
146
+ </View>
147
+
148
+ <SectionTitle>Categorical — ordered, nested, CVD-safe to 6</SectionTitle>
149
+ {(['default', 'dark'] as const).map((variant) => (
150
+ <View key={variant} style={{ marginBottom: 10 }}>
151
+ <Text className="text-text-secondary text-xs mb-1" style={{ textTransform: 'capitalize' }}>
152
+ {variant}
153
+ </Text>
154
+ <View style={{ flexDirection: 'row', gap: 6 }}>
155
+ {categoricalPalette[variant].map((hex, i) => (
156
+ <Chip key={i} hex={hex} label={`${i + 1}`} sub={i < CATEGORICAL_CVD_SAFE_MAX ? undefined : 'ext'} />
157
+ ))}
158
+ </View>
159
+ </View>
160
+ ))}
161
+
162
+ <SectionTitle>Data-viz scales</SectionTitle>
163
+ <Text className="text-text-secondary text-xs mb-2">
164
+ Diverging (BodyMap training-status) — light-centered so it reads in lightness and stays
165
+ colorblind-stable; direction is the blue↔red axis. Labels use per-cell best-contrast text.
166
+ </Text>
167
+ <View style={{ flexDirection: 'row', gap: 6, marginBottom: 12 }}>
168
+ {['under', 'maint', 'optimal', 'approach', 'over'].map((l, i) => (
169
+ <View key={l} style={{ flex: 1, height: 40, borderRadius: 6, backgroundColor: divergingScale[i], alignItems: 'center', justifyContent: 'center' }}>
170
+ <Text style={{ color: bestTextColor(divergingScale[i]), fontSize: 10, fontWeight: '700' }}>{l}</Text>
171
+ </View>
172
+ ))}
173
+ </View>
174
+ <Text className="text-text-secondary text-xs mb-2">
175
+ Sequential effort (low → high) — an ordinal green→yellow→orange→red scale that IntensityBar uses
176
+ in full and VelocityStrip/RPE sub-samples.
177
+ </Text>
178
+ <View style={{ flexDirection: 'row', gap: 4, marginBottom: 4 }}>
179
+ {sequentialEffort.map((hex, i) => (
180
+ <View key={i} style={{ flex: 1, height: 34, borderRadius: 5, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
181
+ <Text style={{ color: bestTextColor(hex), fontSize: 10, fontWeight: '700' }}>{i + 1}</Text>
182
+ </View>
183
+ ))}
184
+ </View>
185
+
186
+ <SectionTitle>Component examples — correct color roles</SectionTitle>
187
+ <View style={{ flexDirection: 'row', gap: 20, flexWrap: 'wrap', alignItems: 'flex-start' }}>
188
+ {/* BodyMap muscle heatmap — training-status diverging (D3) */}
189
+ <Demo label="BodyMap · training status">
190
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 132, gap: 4 }}>
191
+ {[
192
+ ['Chest', 4], ['Quads', 2], ['Back', 3],
193
+ ['Delts', 0], ['Biceps', 4], ['Calves', 1],
194
+ ].map(([m, idx]) => (
195
+ <View key={m as string} style={{ width: 40, backgroundColor: D3_STATUS[idx as number], borderRadius: 5, paddingVertical: 6 }}>
196
+ <Text style={{ color: bestTextColor(D3_STATUS[idx as number]), fontSize: 8, fontWeight: '700', textAlign: 'center' }}>{m as string}</Text>
197
+ </View>
198
+ ))}
199
+ </View>
200
+ </Demo>
201
+ {/* StatusDot — semantic */}
202
+ <Demo label="StatusDot · semantic">
203
+ <View style={{ gap: 6 }}>
204
+ {[['ok', SEM.success], ['warn', SEM.warning], ['err', SEM.error], ['neutral', SEM.neutral]].map(([l, c]) => (
205
+ <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 6 }}>
206
+ <View style={{ width: 12, height: 12, borderRadius: 6, backgroundColor: c as string }} />
207
+ <Text className="text-text-secondary" style={{ fontSize: 11 }}>{l as string}</Text>
208
+ </View>
209
+ ))}
210
+ </View>
211
+ </Demo>
212
+ {/* WorkoutPill — status */}
213
+ <Demo label="WorkoutPill">
214
+ <View style={{ flexDirection: 'row', gap: 6, flexWrap: 'wrap', maxWidth: 220 }}>
215
+ {[['Done', SEM.success], ['Now', SEM.brand], ['Next', SEM.neutral], ['Deload', SEM.deload]].map(([t, c]) => (
216
+ <View key={t as string} style={{ borderRadius: 99, borderWidth: 1, borderColor: c as string, paddingHorizontal: 10, paddingVertical: 3 }}>
217
+ <Text style={{ color: c as string, fontSize: 11, fontWeight: '700' }}>{t as string}</Text>
218
+ </View>
219
+ ))}
220
+ </View>
221
+ </Demo>
222
+ {/* StrengthTrend legend — series → semantic */}
223
+ <Demo label="StrengthTrend · series">
224
+ <View style={{ flexDirection: 'row', gap: 12, flexWrap: 'wrap' }}>
225
+ {[['1RM', SEM.brand], ['vel', SEM.success], ['miss', SEM.error], ['RPE', SEM.warning]].map(([l, c]) => (
226
+ <View key={l as string} style={{ flexDirection: 'row', alignItems: 'center', gap: 4 }}>
227
+ <View style={{ width: 14, height: 3, backgroundColor: c as string, borderRadius: 2 }} />
228
+ <Text className="text-text-secondary" style={{ fontSize: 10 }}>{l as string}</Text>
229
+ </View>
230
+ ))}
231
+ </View>
232
+ </Demo>
233
+ {/* Treemap — ordered categorical */}
234
+ <Demo label="Treemap · categorical">
235
+ <View style={{ flexDirection: 'row', flexWrap: 'wrap', width: 140, gap: 2 }}>
236
+ {[46, 30, 30, 24, 24, 24, 20].map((h, i) => (
237
+ <View key={i} style={{ height: h, flexGrow: 1, flexBasis: h > 40 ? 60 : 40, backgroundColor: getCategoricalColor(i), borderRadius: 3 }} />
238
+ ))}
239
+ </View>
240
+ </Demo>
241
+ {/* Spinner */}
242
+ <Demo label="Spinner">
243
+ <View style={{ width: 24, height: 24, borderRadius: 12, borderWidth: 3, borderColor: '#2C2C2C', borderTopColor: SEM.spinner }} />
244
+ </Demo>
245
+ {/* IntensityBar — full effort scale */}
246
+ <Demo label="IntensityBar · effort">
247
+ <View style={{ flexDirection: 'row', gap: 2, width: 132 }}>
248
+ {sequentialEffort.map((hex, i) => (
249
+ <View key={i} style={{ flex: 1, height: 20, backgroundColor: hex, borderRadius: 2 }} />
250
+ ))}
251
+ </View>
252
+ </Demo>
253
+ {/* VelocityStrip / RPE — effort sub-sample */}
254
+ <Demo label="VelocityStrip · RPE">
255
+ <View style={{ flexDirection: 'row', gap: 3 }}>
256
+ {[sequentialEffort[0], sequentialEffort[2], sequentialEffort[3], sequentialEffort[5]].map((hex, i) => (
257
+ <View key={i} style={{ width: 30, height: 26, borderRadius: 4, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
258
+ <Text style={{ color: bestTextColor(hex), fontSize: 9, fontWeight: '700' }}>{['S', 'M', 'F', 'X'][i]}</Text>
259
+ </View>
260
+ ))}
261
+ </View>
262
+ </Demo>
263
+ </View>
264
+ </View>
265
+ ),
266
+ }
267
+
268
+ function Demo({ label, children }: { label: string; children: ReactNode }) {
269
+ return (
270
+ <View style={{ padding: 10, backgroundColor: '#1A1A1A', borderRadius: 8 }}>
271
+ <Text className="text-text-secondary" style={{ fontSize: 9, marginBottom: 6 }}>{label}</Text>
272
+ {children}
273
+ </View>
274
+ )
275
+ }
276
+
277
+ export const Accessibility: StoryObj = {
278
+ render: () => (
279
+ <View style={{ padding: 24 }}>
280
+ <Text className="text-2xl font-bold text-text-primary mb-1">Categorical Accessibility</Text>
281
+ <Text className="text-text-secondary mb-5">
282
+ In-place text contrast and colorblind simulation for the categorical series. Converging strips across
283
+ two swatches signal a collision for that viewer; all pairs hold ΔE ≥ 8 through the first {CATEGORICAL_CVD_SAFE_MAX}.
284
+ </Text>
285
+
286
+ {(['default', 'dark'] as const).map((variant) => {
287
+ const colors = categoricalPalette[variant]
288
+ const textColor = variant === 'dark' ? '#FFFFFF' : '#000000'
289
+ return (
290
+ <View key={variant} style={{ marginBottom: 22 }}>
291
+ <Text className="text-lg font-bold text-text-primary mb-2" style={{ textTransform: 'capitalize' }}>
292
+ {variant}
293
+ </Text>
294
+ {/* swatches with in-place text + contrast ratio */}
295
+ <View style={{ flexDirection: 'row', gap: 6, marginBottom: 6 }}>
296
+ {colors.map((hex, i) => (
297
+ <View key={i} style={{ alignItems: 'center' }}>
298
+ <View style={{ width: 56, height: 40, borderRadius: 6, backgroundColor: hex, alignItems: 'center', justifyContent: 'center' }}>
299
+ <Text style={{ color: textColor, fontSize: 11, fontWeight: '700' }}>Aa</Text>
300
+ </View>
301
+ <Text className="text-text-secondary" style={{ fontSize: 8, marginTop: 2 }}>
302
+ {contrast(hex, textColor).toFixed(1)}:1
303
+ </Text>
304
+ </View>
305
+ ))}
306
+ </View>
307
+ {/* CVD simulation strips */}
308
+ {(['deuteranopia', 'protanopia'] as const).map((mode) => (
309
+ <View key={mode} style={{ flexDirection: 'row', alignItems: 'center', marginTop: 3 }}>
310
+ <Text className="text-text-secondary" style={{ fontSize: 9, width: 96 }}>{mode}</Text>
311
+ <View style={{ flexDirection: 'row', gap: 6 }}>
312
+ {colors.map((hex, i) => (
313
+ <View key={i} style={{ width: 56, height: 14, borderRadius: 3, backgroundColor: simulate(CVD[mode], hex) }} />
314
+ ))}
315
+ </View>
316
+ </View>
317
+ ))}
318
+ </View>
319
+ )
320
+ })}
321
+ </View>
322
+ ),
323
+ }