@titan-design/react-ui 0.2.6 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (191) hide show
  1. package/dist/bodymap-BhG55xHM.d.mts +318 -0
  2. package/dist/bodymap-BhG55xHM.d.ts +318 -0
  3. package/dist/bodymap.d.mts +3 -0
  4. package/dist/bodymap.d.ts +3 -0
  5. package/dist/bodymap.js +2670 -0
  6. package/dist/bodymap.js.map +1 -0
  7. package/dist/bodymap.mjs +1101 -0
  8. package/dist/bodymap.mjs.map +1 -0
  9. package/dist/{chunk-UXVRPOME.mjs → chunk-7XPB4NAO.mjs} +125 -785
  10. package/dist/chunk-7XPB4NAO.mjs.map +1 -0
  11. package/dist/chunk-P7TSQUN6.mjs +846 -0
  12. package/dist/chunk-P7TSQUN6.mjs.map +1 -0
  13. package/dist/chunk-TOD2WQBG.mjs +1048 -0
  14. package/dist/chunk-TOD2WQBG.mjs.map +1 -0
  15. package/dist/index.d.mts +1131 -4
  16. package/dist/index.d.ts +1131 -4
  17. package/dist/index.js +6396 -225
  18. package/dist/index.js.map +1 -1
  19. package/dist/index.mjs +5572 -641
  20. package/dist/index.mjs.map +1 -1
  21. package/dist/theme/index.d.mts +317 -1
  22. package/dist/theme/index.d.ts +317 -1
  23. package/dist/theme/index.js +185 -2
  24. package/dist/theme/index.js.map +1 -1
  25. package/dist/theme/index.mjs +2 -1
  26. package/dist/theme/tokens-css.d.mts +20 -0
  27. package/dist/theme/tokens-css.d.ts +20 -0
  28. package/dist/theme/tokens-css.js +598 -0
  29. package/dist/theme/tokens-css.js.map +1 -0
  30. package/dist/theme/tokens-css.mjs +24 -0
  31. package/dist/theme/tokens-css.mjs.map +1 -0
  32. package/package.json +25 -5
  33. package/src/bodymap.ts +37 -0
  34. package/src/components/custom/DateTime/DateTime.stories.tsx +1 -1
  35. package/src/components/custom/Gauge/Gauge.stories.tsx +45 -0
  36. package/src/components/custom/Gauge/Gauge.test.tsx +71 -0
  37. package/src/components/custom/Gauge/Gauge.tsx +167 -0
  38. package/src/components/custom/Gauge/index.ts +2 -0
  39. package/src/components/custom/Scatter/Scatter.stories.tsx +85 -0
  40. package/src/components/custom/Scatter/Scatter.test.tsx +96 -0
  41. package/src/components/custom/Scatter/Scatter.tsx +260 -0
  42. package/src/components/custom/Scatter/index.ts +2 -0
  43. package/src/components/custom/Sidebar/Sidebar.tsx +1 -1
  44. package/src/components/custom/Table/Table.test.tsx +27 -0
  45. package/src/components/custom/Table/Table.tsx +33 -17
  46. package/src/components/custom/Treemap/Treemap.stories.tsx +60 -0
  47. package/src/components/custom/Treemap/Treemap.test.tsx +67 -0
  48. package/src/components/custom/Treemap/Treemap.tsx +192 -0
  49. package/src/components/custom/Treemap/index.ts +2 -0
  50. package/src/components/custom/Workout/ActiveWorkoutPage.stories.tsx +118 -0
  51. package/src/components/custom/Workout/ActiveWorkoutPage.test.tsx +171 -0
  52. package/src/components/custom/Workout/ActiveWorkoutPage.tsx +447 -0
  53. package/src/components/custom/Workout/BaseBadge.stories.tsx +75 -0
  54. package/src/components/custom/Workout/BaseBadge.test.tsx +102 -0
  55. package/src/components/custom/Workout/BaseBadge.tsx +113 -0
  56. package/src/components/custom/Workout/BodyMap.stories.tsx +121 -0
  57. package/src/components/custom/Workout/BodyMap.test.tsx +124 -0
  58. package/src/components/custom/Workout/BodyMap.tsx +347 -0
  59. package/src/components/custom/Workout/BodyMapDetailPanel.stories.tsx +171 -0
  60. package/src/components/custom/Workout/BodyMapDetailPanel.test.tsx +225 -0
  61. package/src/components/custom/Workout/BodyMapDetailPanel.tsx +477 -0
  62. package/src/components/custom/Workout/CapacityBandChart.stories.tsx +125 -0
  63. package/src/components/custom/Workout/CapacityBandChart.test.tsx +193 -0
  64. package/src/components/custom/Workout/CapacityBandChart.tsx +493 -0
  65. package/src/components/custom/Workout/DeviationBar.stories.tsx +58 -0
  66. package/src/components/custom/Workout/DeviationBar.test.tsx +86 -0
  67. package/src/components/custom/Workout/DeviationBar.tsx +78 -0
  68. package/src/components/custom/Workout/ExerciseCard.stories.tsx +186 -0
  69. package/src/components/custom/Workout/ExerciseCard.test.tsx +343 -0
  70. package/src/components/custom/Workout/ExerciseCard.tsx +442 -0
  71. package/src/components/custom/Workout/ExerciseDetailPage.stories.tsx +225 -0
  72. package/src/components/custom/Workout/ExerciseDetailPage.test.tsx +128 -0
  73. package/src/components/custom/Workout/ExerciseDetailPage.tsx +630 -0
  74. package/src/components/custom/Workout/InputBar.stories.tsx +75 -0
  75. package/src/components/custom/Workout/InputBar.test.tsx +116 -0
  76. package/src/components/custom/Workout/InputBar.tsx +162 -0
  77. package/src/components/custom/Workout/IntensityBar.stories.tsx +60 -0
  78. package/src/components/custom/Workout/IntensityBar.test.tsx +113 -0
  79. package/src/components/custom/Workout/IntensityBar.tsx +166 -0
  80. package/src/components/custom/Workout/MesoCard.stories.tsx +152 -0
  81. package/src/components/custom/Workout/MesoCard.test.tsx +170 -0
  82. package/src/components/custom/Workout/MesoCard.tsx +237 -0
  83. package/src/components/custom/Workout/MesoProgressBar.stories.tsx +85 -0
  84. package/src/components/custom/Workout/MesoProgressBar.test.tsx +153 -0
  85. package/src/components/custom/Workout/MesoProgressBar.tsx +165 -0
  86. package/src/components/custom/Workout/MesoStatusCard.stories.tsx +126 -0
  87. package/src/components/custom/Workout/MesoStatusCard.test.tsx +209 -0
  88. package/src/components/custom/Workout/MesoStatusCard.tsx +456 -0
  89. package/src/components/custom/Workout/MuscleGroupChip.stories.tsx +118 -0
  90. package/src/components/custom/Workout/MuscleGroupChip.test.tsx +91 -0
  91. package/src/components/custom/Workout/MuscleGroupChip.tsx +92 -0
  92. package/src/components/custom/Workout/PlaceholderStrip.stories.tsx +94 -0
  93. package/src/components/custom/Workout/PlaceholderStrip.test.tsx +83 -0
  94. package/src/components/custom/Workout/PlaceholderStrip.tsx +90 -0
  95. package/src/components/custom/Workout/PrBadge.stories.tsx +105 -0
  96. package/src/components/custom/Workout/PrBadge.test.tsx +111 -0
  97. package/src/components/custom/Workout/PrBadge.tsx +103 -0
  98. package/src/components/custom/Workout/PrHistoryModal.stories.tsx +116 -0
  99. package/src/components/custom/Workout/PrHistoryModal.test.tsx +161 -0
  100. package/src/components/custom/Workout/PrHistoryModal.tsx +244 -0
  101. package/src/components/custom/Workout/ProgramPlanningPage.stories.tsx +257 -0
  102. package/src/components/custom/Workout/ProgramPlanningPage.test.tsx +142 -0
  103. package/src/components/custom/Workout/ProgramPlanningPage.tsx +422 -0
  104. package/src/components/custom/Workout/ReadinessCheck.stories.tsx +126 -0
  105. package/src/components/custom/Workout/ReadinessCheck.test.tsx +181 -0
  106. package/src/components/custom/Workout/ReadinessCheck.tsx +265 -0
  107. package/src/components/custom/Workout/RestTimer.test.tsx +10 -0
  108. package/src/components/custom/Workout/RestTimer.tsx +15 -11
  109. package/src/components/custom/Workout/SetRow.stories.tsx +165 -0
  110. package/src/components/custom/Workout/SetRow.test.tsx +222 -0
  111. package/src/components/custom/Workout/SetRow.tsx +247 -0
  112. package/src/components/custom/Workout/Sparkline.stories.tsx +125 -0
  113. package/src/components/custom/Workout/Sparkline.test.tsx +113 -0
  114. package/src/components/custom/Workout/Sparkline.tsx +188 -0
  115. package/src/components/custom/Workout/StatusDot.stories.tsx +150 -0
  116. package/src/components/custom/Workout/StatusDot.test.tsx +165 -0
  117. package/src/components/custom/Workout/StatusDot.tsx +160 -0
  118. package/src/components/custom/Workout/StrengthTrendChart.stories.tsx +130 -0
  119. package/src/components/custom/Workout/StrengthTrendChart.test.tsx +217 -0
  120. package/src/components/custom/Workout/StrengthTrendChart.tsx +628 -0
  121. package/src/components/custom/Workout/SupersetWrapper.tsx +1 -2
  122. package/src/components/custom/Workout/TempoDisplay.stories.tsx +66 -0
  123. package/src/components/custom/Workout/TempoDisplay.test.tsx +90 -0
  124. package/src/components/custom/Workout/TempoDisplay.tsx +209 -0
  125. package/src/components/custom/Workout/TrainingStatusPage.stories.tsx +212 -0
  126. package/src/components/custom/Workout/TrainingStatusPage.test.tsx +131 -0
  127. package/src/components/custom/Workout/TrainingStatusPage.tsx +321 -0
  128. package/src/components/custom/Workout/VelocityStrip.stories.tsx +125 -0
  129. package/src/components/custom/Workout/VelocityStrip.test.tsx +244 -0
  130. package/src/components/custom/Workout/VelocityStrip.tsx +288 -0
  131. package/src/components/custom/Workout/WeekRow.stories.tsx +121 -0
  132. package/src/components/custom/Workout/WeekRow.test.tsx +117 -0
  133. package/src/components/custom/Workout/WeekRow.tsx +140 -0
  134. package/src/components/custom/Workout/WeightBadge.stories.tsx +87 -0
  135. package/src/components/custom/Workout/WeightBadge.test.tsx +164 -0
  136. package/src/components/custom/Workout/WeightBadge.tsx +113 -0
  137. package/src/components/custom/Workout/WorkoutCard.stories.tsx +135 -0
  138. package/src/components/custom/Workout/WorkoutCard.test.tsx +172 -0
  139. package/src/components/custom/Workout/WorkoutCard.tsx +228 -0
  140. package/src/components/custom/Workout/WorkoutPill.stories.tsx +63 -0
  141. package/src/components/custom/Workout/WorkoutPill.test.tsx +153 -0
  142. package/src/components/custom/Workout/WorkoutPill.tsx +177 -0
  143. package/src/components/custom/Workout/index.ts +152 -0
  144. package/src/components/custom/Workout/muscleTaxonomy.ts +252 -0
  145. package/src/components/custom/index.ts +3 -0
  146. package/src/components/custom/stepper/Stepper.stories.tsx +1 -1
  147. package/src/components/custom/stepper/Stepper.tsx +2 -2
  148. package/src/components/ui/autocomplete/Autocomplete.stories.tsx +1 -1
  149. package/src/components/ui/autocomplete/Autocomplete.tsx +1 -1
  150. package/src/components/ui/card/Card.tsx +1 -1
  151. package/src/components/ui/checkbox/Checkbox.tsx +1 -1
  152. package/src/components/ui/collapse/Collapse.tsx +1 -1
  153. package/src/components/ui/data-row/DataRow.stories.tsx +1 -1
  154. package/src/components/ui/drawer/Drawer.tsx +3 -3
  155. package/src/components/ui/form-field/FormField.tsx +1 -1
  156. package/src/components/ui/help-tip/HelpTip.tsx +1 -1
  157. package/src/components/ui/menu/Menu.tsx +2 -2
  158. package/src/components/ui/pill/Pill.tsx +1 -1
  159. package/src/components/ui/popover/Popover.stories.tsx +2 -2
  160. package/src/components/ui/popover/Popover.tsx +1 -1
  161. package/src/components/ui/radio/Radio.stories.tsx +1 -1
  162. package/src/components/ui/section/Section.stories.tsx +4 -4
  163. package/src/components/ui/select/Select.tsx +1 -1
  164. package/src/components/ui/stack/Stack.stories.tsx +4 -4
  165. package/src/components/ui/tabs/Tabs.tsx +2 -2
  166. package/src/components/ui/toolbar-button/ToolbarButton.stories.tsx +1 -1
  167. package/src/components/ui/toolbar-button/ToolbarButton.tsx +1 -1
  168. package/src/examples/react-hook-form/ControlledFields.tsx +171 -0
  169. package/src/examples/react-hook-form/ReactHookForm.stories.tsx +51 -0
  170. package/src/examples/react-hook-form/ReactHookForm.test.tsx +61 -0
  171. package/src/examples/react-hook-form/RhfContactForm.tsx +99 -0
  172. package/src/stories/PresetShowcase.stories.tsx +15 -15
  173. package/src/theme/config.completeness.test.ts +77 -0
  174. package/src/theme/config.ts +151 -0
  175. package/src/theme/index.ts +2 -1
  176. package/src/theme/manifest/css-property-manifest.example.json +79 -0
  177. package/src/theme/manifest/css-property-manifest.schema.json +93 -0
  178. package/src/theme/manifest/css-property-manifest.test.ts +81 -0
  179. package/src/theme/manifest/css-property-manifest.types.ts +49 -0
  180. package/src/theme/manifest/css-property-manifest.validate.ts +84 -0
  181. package/src/theme/manifest/design-freeze.test.ts +155 -0
  182. package/src/theme/manifest/extract-css-properties.fixture.html +29 -0
  183. package/src/theme/manifest/extract-css-properties.test.ts +112 -0
  184. package/src/theme/manifest/index.ts +11 -0
  185. package/src/theme/tokens-css.test.ts +28 -0
  186. package/src/theme/tokens-css.ts +36 -0
  187. package/src/theme/workout-tokens.ts +59 -0
  188. package/src/utils/index.ts +10 -0
  189. package/src/utils/workout-format.test.ts +81 -0
  190. package/src/utils/workout-format.ts +83 -0
  191. package/dist/chunk-UXVRPOME.mjs.map +0 -1
@@ -0,0 +1,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'
@@ -64,7 +64,7 @@ export function Sidebar({
64
64
  <View
65
65
  style={{ width: currentWidth }}
66
66
  className={cn(
67
- 'h-full flex-col bg-surface-base border-r border-border-default transition-all duration-200',
67
+ 'h-full flex-col bg-surface-base border-r border-border transition-all duration-200',
68
68
  className
69
69
  )}
70
70
  {...props}
@@ -515,6 +515,33 @@ describe('Table', () => {
515
515
  expect(screen.getByRole('button', { name: /sort by name/i })).toBeInTheDocument()
516
516
  })
517
517
 
518
+ it('exposes real ARIA table semantics (table/rowgroup/row/columnheader/cell)', () => {
519
+ renderBasicTable()
520
+ expect(screen.getByRole('table')).toBeInTheDocument()
521
+ // header rowgroup + body rowgroup
522
+ expect(screen.getAllByRole('rowgroup')).toHaveLength(2)
523
+ // 1 header row + 2 body rows
524
+ expect(screen.getAllByRole('row')).toHaveLength(3)
525
+ expect(screen.getAllByRole('columnheader')).toHaveLength(3)
526
+ expect(screen.getAllByRole('cell')).toHaveLength(6)
527
+ })
528
+
529
+ it('sortable header reflects sort state via aria-sort', () => {
530
+ render(
531
+ <Table sortColumn="name" sortDirection="asc" onSort={vi.fn()}>
532
+ <TableHeader>
533
+ <TableRow>
534
+ <TableHeaderCell sortKey="name">Name</TableHeaderCell>
535
+ <TableHeaderCell sortKey="email">Email</TableHeaderCell>
536
+ </TableRow>
537
+ </TableHeader>
538
+ </Table>
539
+ )
540
+ const [name, email] = screen.getAllByRole('columnheader')
541
+ expect(name).toHaveAttribute('aria-sort', 'ascending')
542
+ expect(email).toHaveAttribute('aria-sort', 'none')
543
+ })
544
+
518
545
  it('pagination buttons have accessible labels', () => {
519
546
  render(
520
547
  <TablePagination
@@ -101,7 +101,7 @@ export function Table({
101
101
  >
102
102
  <View className={cn('w-full', className)} {...props}>
103
103
  <ScrollView horizontal showsHorizontalScrollIndicator={false}>
104
- <View className="w-full min-w-full">
104
+ <View role="table" className="w-full min-w-full">
105
105
  {isLoading ? (
106
106
  <TableLoadingSkeleton rowCount={loadingRowCount} />
107
107
  ) : (
@@ -125,7 +125,7 @@ export interface TableHeaderProps {
125
125
  export function TableHeader({ children, className }: TableHeaderProps) {
126
126
  return (
127
127
  <View
128
- accessibilityRole="none"
128
+ role="rowgroup"
129
129
  className={cn('bg-background-subtle rounded-t-lg', className)}
130
130
  >
131
131
  {children}
@@ -143,7 +143,7 @@ export interface TableBodyProps {
143
143
  */
144
144
  export function TableBody({ children, className }: TableBodyProps) {
145
145
  return (
146
- <View accessibilityRole="none" className={className}>
146
+ <View role="rowgroup" className={className}>
147
147
  {children}
148
148
  </View>
149
149
  )
@@ -171,7 +171,7 @@ export function TableRow({
171
171
  }: TableRowProps) {
172
172
  return (
173
173
  <View
174
- accessibilityRole="none"
174
+ role="row"
175
175
  className={cn(
176
176
  'flex-row border-b border-divider',
177
177
  isHoverable && 'web:hover:bg-interactive-hover',
@@ -212,6 +212,13 @@ export function TableHeaderCell({
212
212
  const { sortColumn, sortDirection, onSort } = useContext(TableContext)
213
213
  const isSorted = sortKey && sortColumn === sortKey
214
214
  const isSortable = !!sortKey && !!onSort
215
+ const ariaSort = isSorted
216
+ ? sortDirection === 'asc'
217
+ ? 'ascending'
218
+ : sortDirection === 'desc'
219
+ ? 'descending'
220
+ : 'none'
221
+ : 'none'
215
222
 
216
223
  const handlePress = (e: any) => {
217
224
  if (isSortable && sortKey) {
@@ -245,27 +252,35 @@ export function TableHeaderCell({
245
252
  )
246
253
 
247
254
  if (isSortable) {
255
+ // columnheader cell wraps the sort button so it carries proper table
256
+ // semantics (role + aria-sort) while the inner control keeps its button role.
248
257
  return (
249
- <Pressable
250
- accessibilityRole="button"
251
- accessibilityLabel={`Sort by ${children}`}
252
- onPress={handlePress}
258
+ <View
259
+ role="columnheader"
260
+ aria-sort={ariaSort}
253
261
  style={width ? { width } : { flex: 1 }}
254
- className={cn(
255
- 'flex-row items-center px-4 py-3',
256
- alignStyles[align],
257
- 'web:hover:bg-interactive-hover active:bg-interactive-active',
258
- className
259
- )}
260
- {...props}
261
262
  >
262
- {content}
263
- </Pressable>
263
+ <Pressable
264
+ accessibilityRole="button"
265
+ accessibilityLabel={`Sort by ${children}`}
266
+ onPress={handlePress}
267
+ className={cn(
268
+ 'flex-row items-center px-4 py-3',
269
+ alignStyles[align],
270
+ 'web:hover:bg-interactive-hover active:bg-interactive-active',
271
+ className
272
+ )}
273
+ {...props}
274
+ >
275
+ {content}
276
+ </Pressable>
277
+ </View>
264
278
  )
265
279
  }
266
280
 
267
281
  return (
268
282
  <View
283
+ role="columnheader"
269
284
  style={width ? { width } : { flex: 1 }}
270
285
  className={cn(
271
286
  'flex-row items-center px-4 py-3',
@@ -306,6 +321,7 @@ export function TableCell({
306
321
 
307
322
  return (
308
323
  <View
324
+ role="cell"
309
325
  style={width ? { width } : { flex: 1 }}
310
326
  className={cn(
311
327
  'justify-center px-4 py-3.5',
@@ -0,0 +1,60 @@
1
+ import type { Meta, StoryObj } from '@storybook/react-vite'
2
+ import { Treemap, type TreemapDatum } from './Treemap'
3
+
4
+ const meta: Meta<typeof Treemap> = {
5
+ title: 'Custom/Treemap',
6
+ component: Treemap,
7
+ tags: ['autodocs'],
8
+ argTypes: {
9
+ width: { control: { type: 'range', min: 200, max: 900, step: 20 } },
10
+ height: { control: { type: 'range', min: 120, max: 600, step: 20 } },
11
+ scale: { control: 'select', options: ['linear', 'sqrt', 'log'] },
12
+ maxTiles: { control: { type: 'range', min: 4, max: 80, step: 1 } },
13
+ },
14
+ }
15
+ export default meta
16
+ type Story = StoryObj<typeof Treemap>
17
+
18
+ const sample: TreemapDatum[] = [
19
+ { id: 'router', value: 5035, color: '#F83030', label: 'router.ts' },
20
+ { id: 'engine', value: 4378, color: '#F83030', label: 'engine.ts' },
21
+ { id: 'linker', value: 1695, color: '#F4A736', label: 'linker.ts' },
22
+ { id: 'validate', value: 1288, color: '#F4A736', label: 'validate.ts' },
23
+ { id: 'hook', value: 1050, color: '#F4A736', label: 'hook.ts' },
24
+ { id: 'update', value: 890, color: '#5B9BD5', label: 'update.ts' },
25
+ { id: 'pool', value: 504, color: '#5B9BD5', label: 'pool.ts' },
26
+ { id: 'log', value: 342, color: '#5B9BD5', label: 'log.ts' },
27
+ ]
28
+
29
+ export const Default: Story = {
30
+ args: { data: sample, width: 560, height: 300, scale: 'sqrt' },
31
+ }
32
+
33
+ /** A single outlier under a linear scale swallows the canvas... */
34
+ export const OutlierLinear: Story = {
35
+ args: {
36
+ data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
37
+ width: 560,
38
+ height: 300,
39
+ scale: 'linear',
40
+ },
41
+ }
42
+
43
+ /** ...the same data with a sqrt scale keeps every tile legible. */
44
+ export const OutlierSqrt: Story = {
45
+ args: {
46
+ data: [{ id: 'mega', value: 205422, color: '#F83030', label: 'mega.ts' }, ...sample],
47
+ width: 560,
48
+ height: 300,
49
+ scale: 'sqrt',
50
+ },
51
+ }
52
+
53
+ export const Truncated: Story = {
54
+ args: {
55
+ data: Array.from({ length: 200 }, (_, i) => ({ id: `n${i}`, value: 200 - i })),
56
+ width: 560,
57
+ height: 300,
58
+ maxTiles: 40,
59
+ },
60
+ }