@titan-design/react-ui 0.2.6 → 0.2.7

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 (157) hide show
  1. package/dist/chunk-3VLOBS6O.mjs +846 -0
  2. package/dist/chunk-3VLOBS6O.mjs.map +1 -0
  3. package/dist/{chunk-UXVRPOME.mjs → chunk-5SSKGS6E.mjs} +3 -783
  4. package/dist/chunk-5SSKGS6E.mjs.map +1 -0
  5. package/dist/index.d.mts +1005 -4
  6. package/dist/index.d.ts +1005 -4
  7. package/dist/index.js +6043 -213
  8. package/dist/index.js.map +1 -1
  9. package/dist/index.mjs +5901 -177
  10. package/dist/index.mjs.map +1 -1
  11. package/dist/theme/index.d.mts +57 -1
  12. package/dist/theme/index.d.ts +57 -1
  13. package/dist/theme/index.js +63 -0
  14. package/dist/theme/index.js.map +1 -1
  15. package/dist/theme/index.mjs +2 -1
  16. package/dist/theme/tokens-css.d.mts +18 -0
  17. package/dist/theme/tokens-css.d.ts +18 -0
  18. package/dist/theme/tokens-css.js +289 -0
  19. package/dist/theme/tokens-css.js.map +1 -0
  20. package/dist/theme/tokens-css.mjs +24 -0
  21. package/dist/theme/tokens-css.mjs.map +1 -0
  22. package/package.json +16 -5
  23. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  24. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  25. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  26. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  27. package/src/components/custom/Gauge/index.ts +2 -0
  28. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  29. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  30. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  31. package/src/components/custom/Scatter/index.ts +2 -0
  32. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  33. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  34. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  35. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  36. package/src/components/custom/Treemap/index.ts +2 -0
  37. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  38. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  39. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  40. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  41. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  42. package/src/components/custom/Workout/BodyMap.tsx +348 -0
  43. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  44. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  45. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +478 -0
  46. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  47. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  48. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  49. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  50. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  51. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  52. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  53. package/src/components/custom/Workout/ExerciseCard.test.tsx +319 -0
  54. package/src/components/custom/Workout/ExerciseCard.tsx +429 -0
  55. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  56. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  57. package/src/components/custom/Workout/InputBar.tsx +163 -0
  58. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  59. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  60. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  61. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  62. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  63. package/src/components/custom/Workout/MesoCard.tsx +235 -0
  64. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  65. package/src/components/custom/Workout/MesoProgressBar.test.tsx +141 -0
  66. package/src/components/custom/Workout/MesoProgressBar.tsx +161 -0
  67. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  68. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  69. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  70. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  71. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  72. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  73. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  74. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  75. package/src/components/custom/Workout/PlaceholderStrip.tsx +91 -0
  76. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  77. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  78. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  79. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  80. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  81. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  82. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  83. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  84. package/src/components/custom/Workout/ReadinessCheck.tsx +266 -0
  85. package/src/components/custom/Workout/RestTimer.tsx +8 -6
  86. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  87. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  88. package/src/components/custom/Workout/SetRow.tsx +253 -0
  89. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  90. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  91. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  92. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  93. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  94. package/src/components/custom/Workout/StatusDot.tsx +159 -0
  95. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  96. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  97. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  98. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  99. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  100. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  101. package/src/components/custom/Workout/TempoDisplay.tsx +207 -0
  102. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  103. package/src/components/custom/Workout/VelocityStrip.test.tsx +234 -0
  104. package/src/components/custom/Workout/VelocityStrip.tsx +287 -0
  105. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  106. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  107. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  108. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  109. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  110. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  111. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  112. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  113. package/src/components/custom/Workout/WorkoutCard.tsx +226 -0
  114. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  115. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  116. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  117. package/src/components/custom/Workout/index.ts +113 -0
  118. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  119. package/src/components/custom/index.ts +3 -0
  120. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  121. package/src/components/custom/stepper/Stepper.tsx +2 -2
  122. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  123. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  124. package/src/components/ui/card/Card.tsx +1 -1
  125. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  126. package/src/components/ui/collapse/Collapse.tsx +1 -1
  127. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  128. package/src/components/ui/drawer/Drawer.tsx +3 -3
  129. package/src/components/ui/form-field/FormField.tsx +1 -1
  130. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  131. package/src/components/ui/menu/Menu.tsx +2 -2
  132. package/src/components/ui/pill/Pill.tsx +1 -1
  133. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  134. package/src/components/ui/popover/Popover.tsx +1 -1
  135. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  136. package/src/components/ui/section/Section.stories.tsx +4 -4
  137. package/src/components/ui/select/Select.tsx +1 -1
  138. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  139. package/src/components/ui/tabs/Tabs.tsx +2 -2
  140. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  141. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  142. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  143. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  144. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  145. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  146. package/src/stories/PresetShowcase.stories.tsx +15 -15
  147. package/src/theme/index.ts +2 -1
  148. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  149. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  150. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  151. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  152. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  153. package/src/theme/manifest/index.ts +11 -0
  154. package/src/theme/tokens-css.test.ts +28 -0
  155. package/src/theme/tokens-css.ts +34 -0
  156. package/src/theme/workout-tokens.ts +61 -0
  157. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,71 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { render, screen } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Gauge } from './Gauge'
5
+
6
+ describe('Gauge', () => {
7
+ it('renders the arc as a fixed set of segments', () => {
8
+ render(<Gauge value={50} />)
9
+ expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
10
+ })
11
+
12
+ it('renders the value, unit, and label', () => {
13
+ render(<Gauge value={82} unit="%" label="Health" />)
14
+ expect(screen.getByTestId('gauge-value')).toHaveTextContent('82')
15
+ expect(screen.getByTestId('gauge-label')).toHaveTextContent('Health')
16
+ expect(screen.getByText('%')).toBeInTheDocument()
17
+ })
18
+
19
+ it('colors the value by the active status band', () => {
20
+ const { rerender } = render(<Gauge value={30} />)
21
+ expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#D14343' })
22
+ rerender(<Gauge value={70} />)
23
+ expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#FFB020' })
24
+ rerender(<Gauge value={90} />)
25
+ expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
26
+ })
27
+
28
+ it('honors a single-color override over thresholds', () => {
29
+ render(<Gauge value={10} color="#5B9BD5" />)
30
+ expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#5B9BD5' })
31
+ })
32
+
33
+ it('clamps an out-of-range value without crashing', () => {
34
+ render(<Gauge value={150} unit="%" />)
35
+ // Displayed value is verbatim; only the fill is clamped.
36
+ expect(screen.getByTestId('gauge-value')).toHaveTextContent('150')
37
+ expect(screen.getAllByTestId('gauge-segment')).toHaveLength(40)
38
+ })
39
+
40
+ it('supports an arbitrary min/max domain', () => {
41
+ render(<Gauge value={5} min={0} max={10} label="Score" />)
42
+ expect(screen.getByTestId('gauge-value')).toHaveTextContent('5')
43
+ })
44
+
45
+ it('respects custom thresholds', () => {
46
+ render(
47
+ <Gauge
48
+ value={45}
49
+ thresholds={[
50
+ { value: 0, color: '#14B8A6' },
51
+ { value: 50, color: '#D14343' },
52
+ ]}
53
+ />,
54
+ )
55
+ expect(screen.getByTestId('gauge-value')).toHaveStyle({ color: '#14B8A6' })
56
+ })
57
+
58
+ describe('accessibility', () => {
59
+ it('exposes an image role with a descriptive label', () => {
60
+ render(<Gauge value={82} unit="%" label="Health" />)
61
+ const gauge = screen.getByTestId('gauge')
62
+ expect(gauge).toHaveAttribute('role', 'img')
63
+ expect(gauge.getAttribute('aria-label')).toContain('Health: 82% of 100')
64
+ })
65
+
66
+ it('has no accessibility violations', async () => {
67
+ const { container } = render(<Gauge value={82} unit="%" label="Health" />)
68
+ expect(await axe(container)).toHaveNoViolations()
69
+ })
70
+ })
71
+ })
@@ -0,0 +1,167 @@
1
+ import { View, Text, type ViewProps } from 'react-native'
2
+ import { cn } from '../../../utils/cn'
3
+
4
+ export interface GaugeThreshold {
5
+ /** Band start, in the gauge's value units. */
6
+ value: number
7
+ /** Color applied to filled segments at or above this band. */
8
+ color: string
9
+ }
10
+
11
+ export interface GaugeProps extends Omit<ViewProps, 'children'> {
12
+ /** Current value. Clamped to [min, max]. */
13
+ value: number
14
+ min?: number
15
+ max?: number
16
+ /** Diameter in px. */
17
+ size?: number
18
+ /** Caption under the big number. */
19
+ label?: string
20
+ /** Unit suffix beside the big number (e.g. "%"). */
21
+ unit?: string
22
+ /**
23
+ * Band coloring, ascending by `value`. A filled segment takes the color of
24
+ * the highest band whose `value` it reaches. Defaults to Titan status tokens
25
+ * (red < 60, amber < 80, green ≥ 80 on a 0–100 scale).
26
+ */
27
+ thresholds?: GaugeThreshold[]
28
+ /** Single-color override for filled segments (ignores `thresholds`). */
29
+ color?: string
30
+ className?: string
31
+ }
32
+
33
+ const STATUS_SUCCESS = '#14B8A6'
34
+ const STATUS_WARNING = '#FFB020'
35
+ const STATUS_ERROR = '#D14343'
36
+ const TRACK = 'rgba(255,255,255,0.08)'
37
+
38
+ /** Titan status-token bands for a 0–100 score. */
39
+ const DEFAULT_THRESHOLDS: GaugeThreshold[] = [
40
+ { value: 0, color: STATUS_ERROR },
41
+ { value: 60, color: STATUS_WARNING },
42
+ { value: 80, color: STATUS_SUCCESS },
43
+ ]
44
+
45
+ const ARC_START = 135
46
+ const ARC_SWEEP = 270
47
+ const SEGMENTS = 40
48
+
49
+ function clamp01(n: number): number {
50
+ if (Number.isNaN(n)) return 0
51
+ return Math.max(0, Math.min(1, n))
52
+ }
53
+
54
+ function formatValue(v: number): string {
55
+ return Number.isInteger(v) ? `${v}` : v.toFixed(1)
56
+ }
57
+
58
+ /** Color of the highest band whose start value the fraction reaches. */
59
+ function bandColor(fraction: number, bands: GaugeThreshold[], min: number, max: number): string {
60
+ const value = min + fraction * (max - min)
61
+ const sorted = [...bands].sort((a, b) => a.value - b.value)
62
+ let color = sorted[0]?.color ?? STATUS_SUCCESS
63
+ for (const band of sorted) {
64
+ if (value >= band.value) color = band.color
65
+ }
66
+ return color
67
+ }
68
+
69
+ interface Tick {
70
+ key: number
71
+ left: number
72
+ top: number
73
+ rotate: string
74
+ color: string
75
+ }
76
+
77
+ /** Position + color each radial segment of the arc. */
78
+ function buildTicks(fill: number, size: number, activeColor: (f: number) => string): Tick[] {
79
+ const center = size / 2
80
+ const radius = center - size * 0.08
81
+ return Array.from({ length: SEGMENTS }, (_, i) => {
82
+ const fraction = (i + 0.5) / SEGMENTS
83
+ const deg = ARC_START + fraction * ARC_SWEEP
84
+ const rad = (deg * Math.PI) / 180
85
+ return {
86
+ key: i,
87
+ left: center + radius * Math.cos(rad),
88
+ top: center + radius * Math.sin(rad),
89
+ rotate: `${deg - 90}deg`,
90
+ color: fraction <= fill ? activeColor(fraction) : TRACK,
91
+ }
92
+ })
93
+ }
94
+
95
+ /**
96
+ * SVG-free radial gauge (absolutely-positioned segment Views), matching the
97
+ * codebase's chart convention so it renders identically on web and native. The
98
+ * 270° arc fills with band colors as the value rises; the center reuses the
99
+ * Metric visual language (large bold value + unit + caption). Ideal for a 0–100
100
+ * health / quality score.
101
+ */
102
+ export function Gauge({
103
+ value,
104
+ min = 0,
105
+ max = 100,
106
+ size = 160,
107
+ label,
108
+ unit,
109
+ thresholds = DEFAULT_THRESHOLDS,
110
+ color,
111
+ className,
112
+ ...props
113
+ }: GaugeProps) {
114
+ const span = max - min || 1
115
+ const fill = clamp01((value - min) / span)
116
+ const activeColor = (f: number) => color ?? bandColor(f, thresholds, min, max)
117
+ const ticks = buildTicks(fill, size, activeColor)
118
+ const displayColor = color ?? bandColor(fill, thresholds, min, max)
119
+ const tickLength = size * 0.11
120
+ const tickThickness = Math.max(2, (size * 0.9) / SEGMENTS)
121
+
122
+ const ariaLabel = `${label ? `${label}: ` : ''}${formatValue(value)}${unit ?? ''} of ${formatValue(max)}`
123
+
124
+ return (
125
+ <View
126
+ className={cn('relative', className)}
127
+ style={{ width: size, height: size }}
128
+ accessibilityRole="image"
129
+ accessibilityLabel={ariaLabel}
130
+ testID="gauge"
131
+ {...props}
132
+ >
133
+ {ticks.map((t) => (
134
+ <View
135
+ key={t.key}
136
+ accessibilityElementsHidden
137
+ testID="gauge-segment"
138
+ style={{
139
+ position: 'absolute',
140
+ left: t.left - tickThickness / 2,
141
+ top: t.top - tickLength / 2,
142
+ width: tickThickness,
143
+ height: tickLength,
144
+ borderRadius: tickThickness / 2,
145
+ backgroundColor: t.color,
146
+ transform: [{ rotate: t.rotate }],
147
+ }}
148
+ />
149
+ ))}
150
+
151
+ {/* Center readout — Metric visual language. */}
152
+ <View style={{ position: 'absolute', top: 0, left: 0, width: size, height: size }} className="items-center justify-center">
153
+ <View className="flex-row items-baseline gap-0.5">
154
+ <Text testID="gauge-value" className="text-3xl font-bold" style={{ color: displayColor }}>
155
+ {formatValue(value)}
156
+ </Text>
157
+ {unit && <Text className="text-sm text-text-tertiary">{unit}</Text>}
158
+ </View>
159
+ {label && (
160
+ <Text testID="gauge-label" className="text-xs text-text-secondary mt-1">
161
+ {label}
162
+ </Text>
163
+ )}
164
+ </View>
165
+ </View>
166
+ )
167
+ }
@@ -0,0 +1,2 @@
1
+ export { Gauge } from './Gauge'
2
+ export type { GaugeProps, GaugeThreshold } from './Gauge'
@@ -0,0 +1,85 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Scatter, type ScatterDatum } from './Scatter'
3
+
4
+ const meta: Meta<typeof Scatter> = {
5
+ title: 'Custom/Scatter',
6
+ component: Scatter,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ width: { control: { type: 'range', min: 240, max: 800, step: 20 } },
10
+ height: { control: { type: 'range', min: 180, max: 600, step: 20 } },
11
+ diagonal: { control: 'boolean' },
12
+ },
13
+ }
14
+ export default meta
15
+ type Story = StoryObj<typeof Scatter>
16
+
17
+ /** Modules on the instability × abstractness "main sequence". */
18
+ const mainSequence: ScatterDatum[] = [
19
+ { id: 'core', x: 0.08, y: 0.85, r: 10, color: '#14B8A6', label: 'core' },
20
+ { id: 'domain', x: 0.22, y: 0.7, r: 8, color: '#14B8A6', label: 'domain' },
21
+ { id: 'services', x: 0.45, y: 0.4, r: 12, color: '#5B9BD5', label: 'services' },
22
+ { id: 'api', x: 0.6, y: 0.35, r: 7, color: '#5B9BD5', label: 'api' },
23
+ { id: 'cli', x: 0.9, y: 0.08, r: 9, color: '#F4A736', label: 'cli' },
24
+ { id: 'utils', x: 0.85, y: 0.75, r: 6, color: '#F83030', label: 'utils (pain?)' },
25
+ ]
26
+
27
+ export const Default: Story = {
28
+ args: {
29
+ data: mainSequence,
30
+ width: 480,
31
+ height: 340,
32
+ diagonal: true,
33
+ axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
34
+ },
35
+ }
36
+
37
+ export const NoDiagonal: Story = {
38
+ args: {
39
+ data: mainSequence,
40
+ width: 480,
41
+ height: 340,
42
+ axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
43
+ },
44
+ }
45
+
46
+ export const Selected: Story = {
47
+ args: {
48
+ data: mainSequence,
49
+ width: 480,
50
+ height: 340,
51
+ diagonal: true,
52
+ selectedId: 'utils',
53
+ axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
54
+ },
55
+ }
56
+
57
+ /** Auto-domain from a single point — the frame still renders cleanly. */
58
+ export const SinglePoint: Story = {
59
+ args: {
60
+ data: [{ id: 'only', x: 0.5, y: 0.5, r: 10, label: 'only module' }],
61
+ width: 400,
62
+ height: 300,
63
+ axis: { xLabel: 'I', yLabel: 'A' },
64
+ },
65
+ }
66
+
67
+ /** An outlier far outside the main cluster, kept legible by an explicit domain. */
68
+ export const Outlier: Story = {
69
+ args: {
70
+ data: [...mainSequence, { id: 'legacy', x: 0.98, y: 0.98, r: 16, color: '#F83030', label: 'legacy.ts' }],
71
+ width: 480,
72
+ height: 340,
73
+ diagonal: true,
74
+ axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
75
+ },
76
+ }
77
+
78
+ export const Empty: Story = {
79
+ args: {
80
+ data: [],
81
+ width: 400,
82
+ height: 300,
83
+ axis: { xLabel: 'Instability (I)', yLabel: 'Abstractness (A)', xMin: 0, xMax: 1, yMin: 0, yMax: 1 },
84
+ },
85
+ }
@@ -0,0 +1,96 @@
1
+ import { describe, it, expect, vi } from 'vitest'
2
+ import { render, screen, fireEvent } from '@testing-library/react'
3
+ import { axe } from 'jest-axe'
4
+ import { Scatter, type ScatterDatum } from './Scatter'
5
+
6
+ const data: ScatterDatum[] = [
7
+ { id: 'a', x: 0.1, y: 0.9, label: 'core' },
8
+ { id: 'b', x: 0.5, y: 0.5, label: 'mid' },
9
+ { id: 'c', x: 0.9, y: 0.1, label: 'leaf' },
10
+ ]
11
+
12
+ const base = { data, width: 320, height: 240 }
13
+
14
+ describe('Scatter', () => {
15
+ it('renders one point per datum', () => {
16
+ render(<Scatter {...base} />)
17
+ expect(screen.getByTestId('scatter-point-a')).toBeInTheDocument()
18
+ expect(screen.getByTestId('scatter-point-b')).toBeInTheDocument()
19
+ expect(screen.getByTestId('scatter-point-c')).toBeInTheDocument()
20
+ })
21
+
22
+ it('renders gridlines on both axes', () => {
23
+ render(<Scatter {...base} />)
24
+ expect(screen.getAllByTestId('scatter-gridline-x').length).toBeGreaterThanOrEqual(2)
25
+ expect(screen.getAllByTestId('scatter-gridline-y').length).toBeGreaterThanOrEqual(2)
26
+ })
27
+
28
+ it('fires onPress with the point id', () => {
29
+ const onPress = vi.fn()
30
+ render(<Scatter {...base} onPress={onPress} />)
31
+ fireEvent.click(screen.getByTestId('scatter-point-b'))
32
+ expect(onPress).toHaveBeenCalledWith('b')
33
+ })
34
+
35
+ it('draws the main-sequence diagonal only when requested', () => {
36
+ const { rerender } = render(<Scatter {...base} />)
37
+ expect(screen.queryByTestId('scatter-diagonal')).not.toBeInTheDocument()
38
+ rerender(<Scatter {...base} diagonal />)
39
+ expect(screen.getByTestId('scatter-diagonal')).toBeInTheDocument()
40
+ })
41
+
42
+ it('renders axis labels when provided', () => {
43
+ render(<Scatter {...base} axis={{ xLabel: 'Instability', yLabel: 'Abstractness' }} />)
44
+ expect(screen.getByTestId('scatter-x-label')).toHaveTextContent('Instability')
45
+ expect(screen.getByTestId('scatter-y-label')).toHaveTextContent('Abstractness')
46
+ })
47
+
48
+ it('labels a point by its label, falling back to id', () => {
49
+ render(<Scatter data={[{ id: 'solo', x: 0.2, y: 0.4 }]} width={200} height={200} />)
50
+ expect(screen.getByLabelText('solo')).toBeInTheDocument()
51
+ })
52
+
53
+ it('renders a single point without crashing', () => {
54
+ render(<Scatter data={[{ id: 'one', x: 5, y: 5 }]} width={200} height={200} />)
55
+ expect(screen.getByTestId('scatter-point-one')).toBeInTheDocument()
56
+ })
57
+
58
+ it('renders an empty plot frame with no points', () => {
59
+ render(<Scatter data={[]} width={200} height={200} />)
60
+ expect(screen.getByTestId('scatter-canvas')).toBeInTheDocument()
61
+ expect(screen.queryByTestId('scatter-points')?.childNodes.length ?? 0).toBe(0)
62
+ })
63
+
64
+ it('places an outlier inside the plot when domains are overridden', () => {
65
+ render(
66
+ <Scatter
67
+ data={[{ id: 'big', x: 999, y: 999 }]}
68
+ width={200}
69
+ height={200}
70
+ axis={{ xMin: 0, xMax: 1000, yMin: 0, yMax: 1000 }}
71
+ />,
72
+ )
73
+ expect(screen.getByTestId('scatter-point-big')).toBeInTheDocument()
74
+ })
75
+
76
+ describe('accessibility', () => {
77
+ it('labels the canvas as an image', () => {
78
+ render(<Scatter {...base} axis={{ xLabel: 'I', yLabel: 'A' }} />)
79
+ const canvas = screen.getByTestId('scatter-canvas')
80
+ expect(canvas).toHaveAttribute('role', 'img')
81
+ expect(canvas.getAttribute('aria-label')).toContain('3 points')
82
+ })
83
+
84
+ it('has no accessibility violations', async () => {
85
+ const { container } = render(
86
+ <Scatter {...base} diagonal axis={{ xLabel: 'I', yLabel: 'A' }} selectedId="b" />,
87
+ )
88
+ expect(await axe(container)).toHaveNoViolations()
89
+ })
90
+
91
+ it('has no accessibility violations when empty', async () => {
92
+ const { container } = render(<Scatter data={[]} width={200} height={200} />)
93
+ expect(await axe(container)).toHaveNoViolations()
94
+ })
95
+ })
96
+ })
@@ -0,0 +1,260 @@
1
+ import { View, Text, Pressable, type ViewProps } from 'react-native'
2
+ import { cn } from '../../../utils/cn'
3
+
4
+ export interface ScatterDatum {
5
+ /** Stable identity — returned by onPress and used as the React key. */
6
+ id: string
7
+ /** Horizontal position in data space. */
8
+ x: number
9
+ /** Vertical position in data space. */
10
+ y: number
11
+ /** Bubble radius in px. Falls back to a fixed default when omitted. */
12
+ r?: number
13
+ /** Bubble fill color. Falls back to a built-in categorical palette by index. */
14
+ color?: string
15
+ /** Optional label shown for large or selected points. */
16
+ label?: string
17
+ }
18
+
19
+ export interface ScatterAxis {
20
+ xLabel?: string
21
+ yLabel?: string
22
+ /** Domain overrides. Any omitted bound is derived from the data. */
23
+ xMin?: number
24
+ xMax?: number
25
+ yMin?: number
26
+ yMax?: number
27
+ }
28
+
29
+ export interface ScatterProps extends Omit<ViewProps, 'children'> {
30
+ data: ScatterDatum[]
31
+ /** Layout box. Required — the plot fills exactly this area. */
32
+ width: number
33
+ height: number
34
+ /** Axis labels and optional domain overrides. */
35
+ axis?: ScatterAxis
36
+ /**
37
+ * Draw the y = 1 − x "main sequence" reference line (NDepend idiom, where
38
+ * distance from the diagonal measures the zone of pain / zone of uselessness).
39
+ */
40
+ diagonal?: boolean
41
+ /** Fires with a point's id on press. */
42
+ onPress?: (id: string) => void
43
+ /** Draws a highlight ring on the matching point. */
44
+ selectedId?: string
45
+ className?: string
46
+ }
47
+
48
+ /** Titan categorical fallback palette (data-1..data-8). */
49
+ const PALETTE = [
50
+ '#5B9BD5', '#14B8A6', '#F4A736', '#F83030',
51
+ '#823CA0', '#D4A520', '#406D87', '#43C6B7',
52
+ ]
53
+
54
+ const GRID_LINE = 'rgba(255,255,255,0.07)'
55
+ const AXIS_LINE = 'rgba(255,255,255,0.18)'
56
+ const DIAGONAL_LINE = 'rgba(255,255,255,0.28)'
57
+ const DEFAULT_R = 6
58
+
59
+ const PLOT_LEFT = 40
60
+ const PLOT_RIGHT = 12
61
+ const PLOT_TOP = 12
62
+ const PLOT_BOTTOM = 34
63
+ const TICK_COUNT = 5
64
+
65
+ interface Domain {
66
+ min: number
67
+ max: number
68
+ }
69
+
70
+ /** Derive a padded [min, max] from values, honoring explicit overrides. */
71
+ function domainOf(values: number[], min?: number, max?: number): Domain {
72
+ const lo = min ?? (values.length ? Math.min(...values) : 0)
73
+ const hi = max ?? (values.length ? Math.max(...values) : 1)
74
+ if (hi > lo) return { min: lo, max: hi }
75
+ const pad = Math.abs(hi) * 0.1 || 0.5
76
+ return { min: lo - pad, max: hi + pad }
77
+ }
78
+
79
+ /** Evenly spaced tick values across a domain. */
80
+ function ticksOf(d: Domain, count: number): number[] {
81
+ return Array.from({ length: count }, (_, i) => d.min + ((d.max - d.min) * i) / (count - 1))
82
+ }
83
+
84
+ function formatTick(v: number): string {
85
+ if (Number.isInteger(v)) return `${v}`
86
+ return v.toFixed(Math.abs(v) < 1 ? 2 : 1)
87
+ }
88
+
89
+ /** A single straight segment drawn as one rotated View (SVG-free). */
90
+ function LineSegment({
91
+ x1, y1, x2, y2, color, testID,
92
+ }: { x1: number; y1: number; x2: number; y2: number; color: string; testID: string }) {
93
+ const dx = x2 - x1
94
+ const dy = y2 - y1
95
+ const length = Math.sqrt(dx * dx + dy * dy)
96
+ const angle = Math.atan2(dy, dx) * (180 / Math.PI)
97
+ return (
98
+ <View
99
+ testID={testID}
100
+ accessibilityElementsHidden
101
+ style={{
102
+ position: 'absolute',
103
+ left: x1,
104
+ top: y1,
105
+ width: length,
106
+ height: 0,
107
+ borderTopWidth: 1,
108
+ borderStyle: 'dashed',
109
+ borderTopColor: color,
110
+ transformOrigin: '0 0',
111
+ transform: [{ rotate: `${angle}deg` }],
112
+ }}
113
+ />
114
+ )
115
+ }
116
+
117
+ /**
118
+ * SVG-free scatter / bubble plot (absolutely-positioned Views), matching the
119
+ * codebase's chart convention so it renders identically on web and native. Used
120
+ * for "main-sequence" plots (instability × abstractness) via the `diagonal`
121
+ * reference line, or any two metrics. Positions and bubble sizes are computed
122
+ * from the data; color and labels are caller-supplied.
123
+ */
124
+ export function Scatter({
125
+ data,
126
+ width,
127
+ height,
128
+ axis = {},
129
+ diagonal = false,
130
+ onPress,
131
+ selectedId,
132
+ className,
133
+ ...props
134
+ }: ScatterProps) {
135
+ const innerW = Math.max(1, width - PLOT_LEFT - PLOT_RIGHT)
136
+ const innerH = Math.max(1, height - PLOT_TOP - PLOT_BOTTOM)
137
+ const xd = domainOf(data.map((d) => d.x), axis.xMin, axis.xMax)
138
+ const yd = domainOf(data.map((d) => d.y), axis.yMin, axis.yMax)
139
+
140
+ const toX = (x: number) => PLOT_LEFT + ((x - xd.min) / (xd.max - xd.min)) * innerW
141
+ const toY = (y: number) => PLOT_TOP + (1 - (y - yd.min) / (yd.max - yd.min)) * innerH
142
+
143
+ const points = data.map((d, i) => ({
144
+ datum: d,
145
+ cx: toX(d.x),
146
+ cy: toY(d.y),
147
+ radius: d.r ?? DEFAULT_R,
148
+ color: d.color ?? PALETTE[i % PALETTE.length],
149
+ }))
150
+
151
+ const ariaLabel = `Scatter plot of ${axis.xLabel ?? 'x'} versus ${axis.yLabel ?? 'y'}, ${data.length} point${data.length === 1 ? '' : 's'}`
152
+
153
+ return (
154
+ <View className={cn('relative', className)} style={{ width, height }} {...props}>
155
+ {/* Non-interactive labelled canvas: grid, axes, ticks, diagonal. */}
156
+ <View
157
+ accessibilityRole="image"
158
+ accessibilityLabel={ariaLabel}
159
+ testID="scatter-canvas"
160
+ style={{ position: 'absolute', top: 0, left: 0, width, height, overflow: 'hidden' }}
161
+ >
162
+ {ticksOf(yd, TICK_COUNT).map((t, i) => {
163
+ const y = toY(t)
164
+ return (
165
+ <View
166
+ key={`y-${i}`}
167
+ accessibilityElementsHidden
168
+ testID="scatter-gridline-y"
169
+ style={{ position: 'absolute', left: PLOT_LEFT, width: innerW, top: y, height: 1, backgroundColor: GRID_LINE }}
170
+ >
171
+ <Text className="text-[9px] text-text-tertiary" style={{ position: 'absolute', left: -PLOT_LEFT, top: -6, width: PLOT_LEFT - 6, textAlign: 'right' }}>
172
+ {formatTick(t)}
173
+ </Text>
174
+ </View>
175
+ )
176
+ })}
177
+ {ticksOf(xd, TICK_COUNT).map((t, i) => {
178
+ const x = toX(t)
179
+ return (
180
+ <View
181
+ key={`x-${i}`}
182
+ accessibilityElementsHidden
183
+ testID="scatter-gridline-x"
184
+ style={{ position: 'absolute', top: PLOT_TOP, height: innerH, left: x, width: 1, backgroundColor: GRID_LINE }}
185
+ >
186
+ <Text className="text-[9px] text-text-tertiary" style={{ position: 'absolute', top: innerH + 4, left: -16, width: 32, textAlign: 'center' }}>
187
+ {formatTick(t)}
188
+ </Text>
189
+ </View>
190
+ )
191
+ })}
192
+
193
+ {/* Axis frame (left + bottom). */}
194
+ <View accessibilityElementsHidden style={{ position: 'absolute', left: PLOT_LEFT, top: PLOT_TOP, width: 1, height: innerH, backgroundColor: AXIS_LINE }} />
195
+ <View accessibilityElementsHidden style={{ position: 'absolute', left: PLOT_LEFT, top: PLOT_TOP + innerH, width: innerW, height: 1, backgroundColor: AXIS_LINE }} />
196
+
197
+ {diagonal && (
198
+ <LineSegment
199
+ testID="scatter-diagonal"
200
+ color={DIAGONAL_LINE}
201
+ x1={toX(xd.min)}
202
+ y1={toY(1 - xd.min)}
203
+ x2={toX(xd.max)}
204
+ y2={toY(1 - xd.max)}
205
+ />
206
+ )}
207
+
208
+ {axis.xLabel && (
209
+ <Text testID="scatter-x-label" className="text-[10px] font-semibold text-text-secondary" style={{ position: 'absolute', left: PLOT_LEFT, width: innerW, bottom: 2, textAlign: 'center' }}>
210
+ {axis.xLabel}
211
+ </Text>
212
+ )}
213
+ {axis.yLabel && (
214
+ <Text testID="scatter-y-label" className="text-[10px] font-semibold text-text-secondary" style={{ position: 'absolute', left: -innerH / 2 + 6, top: PLOT_TOP + innerH / 2, width: innerH, textAlign: 'center', transformOrigin: '50% 50%', transform: [{ rotate: '-90deg' }] }}>
215
+ {axis.yLabel}
216
+ </Text>
217
+ )}
218
+ </View>
219
+
220
+ {/* Interactive point overlay (kept out of the image-role canvas). */}
221
+ <View style={{ position: 'absolute', top: 0, left: 0, width, height }} testID="scatter-points">
222
+ {points.map((p) => {
223
+ const selected = p.datum.id === selectedId
224
+ const showLabel = p.datum.label && (selected || p.radius >= 10)
225
+ return (
226
+ <Pressable
227
+ key={p.datum.id}
228
+ testID={`scatter-point-${p.datum.id}`}
229
+ accessibilityRole="button"
230
+ accessibilityLabel={p.datum.label ?? p.datum.id}
231
+ onPress={() => onPress?.(p.datum.id)}
232
+ style={{
233
+ position: 'absolute',
234
+ left: p.cx - p.radius,
235
+ top: p.cy - p.radius,
236
+ width: p.radius * 2,
237
+ height: p.radius * 2,
238
+ borderRadius: p.radius,
239
+ backgroundColor: p.color,
240
+ opacity: selected ? 1 : 0.82,
241
+ borderWidth: selected ? 2 : 0,
242
+ borderColor: '#ffffff',
243
+ }}
244
+ >
245
+ {showLabel && (
246
+ <Text
247
+ numberOfLines={1}
248
+ className="text-[9px] font-semibold text-text-primary"
249
+ style={{ position: 'absolute', left: p.radius * 2 + 3, top: p.radius - 6, width: 80 }}
250
+ >
251
+ {p.datum.label}
252
+ </Text>
253
+ )}
254
+ </Pressable>
255
+ )
256
+ })}
257
+ </View>
258
+ </View>
259
+ )
260
+ }
@@ -0,0 +1,2 @@
1
+ export { Scatter } from './Scatter'
2
+ export type { ScatterProps, ScatterDatum, ScatterAxis } from './Scatter'