@spatika/react 1.1.0 → 1.2.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 (311) hide show
  1. package/AGENTS.md +71 -0
  2. package/README.md +4 -3
  3. package/dist/composites/Card.d.ts +7 -1
  4. package/dist/composites/Card.d.ts.map +1 -1
  5. package/dist/composites/Card.js +10 -0
  6. package/dist/composites/ChartDataGrid.d.ts +33 -0
  7. package/dist/composites/ChartDataGrid.d.ts.map +1 -0
  8. package/dist/composites/ChartDataGrid.js +77 -0
  9. package/dist/composites/CoverHero.d.ts +1 -1
  10. package/dist/composites/CoverHero.d.ts.map +1 -1
  11. package/dist/composites/CoverHero.js +2 -2
  12. package/dist/composites/EntityMediaCard.d.ts +1 -1
  13. package/dist/composites/EntityMediaCard.d.ts.map +1 -1
  14. package/dist/composites/EntityMediaCard.js +12 -4
  15. package/dist/composites/EventCalendar.d.ts +35 -0
  16. package/dist/composites/EventCalendar.d.ts.map +1 -0
  17. package/dist/composites/EventCalendar.js +339 -0
  18. package/dist/composites/EventEditor.d.ts +22 -0
  19. package/dist/composites/EventEditor.d.ts.map +1 -0
  20. package/dist/composites/EventEditor.js +86 -0
  21. package/dist/composites/EventTimeline.d.ts +24 -0
  22. package/dist/composites/EventTimeline.d.ts.map +1 -0
  23. package/dist/composites/EventTimeline.js +184 -0
  24. package/dist/composites/MapChart.d.ts +24 -0
  25. package/dist/composites/MapChart.d.ts.map +1 -0
  26. package/dist/composites/MapChart.js +58 -0
  27. package/dist/composites/ProfileHero.d.ts +6 -4
  28. package/dist/composites/ProfileHero.d.ts.map +1 -1
  29. package/dist/composites/ProfileHero.js +8 -8
  30. package/dist/composites/ScatterWebGL.d.ts +22 -0
  31. package/dist/composites/ScatterWebGL.d.ts.map +1 -0
  32. package/dist/composites/ScatterWebGL.js +45 -0
  33. package/dist/composites/SiteFooter.d.ts +1 -1
  34. package/dist/composites/SiteFooter.js +1 -1
  35. package/dist/composites/SiteNav.d.ts +7 -2
  36. package/dist/composites/SiteNav.d.ts.map +1 -1
  37. package/dist/composites/SiteNav.js +9 -5
  38. package/dist/composites/StatCard.d.ts +2 -1
  39. package/dist/composites/StatCard.d.ts.map +1 -1
  40. package/dist/composites/StatCard.js +3 -3
  41. package/dist/composites/StatusDot.d.ts +1 -1
  42. package/dist/composites/Wordmark.d.ts +2 -2
  43. package/dist/composites/Wordmark.d.ts.map +1 -1
  44. package/dist/composites/cartesian-charts.d.ts +138 -0
  45. package/dist/composites/cartesian-charts.d.ts.map +1 -0
  46. package/dist/composites/cartesian-charts.js +401 -0
  47. package/dist/composites/chart-composition.d.ts +139 -0
  48. package/dist/composites/chart-composition.d.ts.map +1 -0
  49. package/dist/composites/chart-composition.js +486 -0
  50. package/dist/composites/chart-ui.d.ts +83 -0
  51. package/dist/composites/chart-ui.d.ts.map +1 -0
  52. package/dist/composites/chart-ui.js +108 -0
  53. package/dist/composites/charts-3d.d.ts +37 -0
  54. package/dist/composites/charts-3d.d.ts.map +1 -0
  55. package/dist/composites/charts-3d.js +121 -0
  56. package/dist/composites/flow-charts.d.ts +89 -0
  57. package/dist/composites/flow-charts.d.ts.map +1 -0
  58. package/dist/composites/flow-charts.js +186 -0
  59. package/dist/composites/radial-charts.d.ts +109 -0
  60. package/dist/composites/radial-charts.d.ts.map +1 -0
  61. package/dist/composites/radial-charts.js +176 -0
  62. package/dist/composites/scheduler-ui.d.ts +43 -0
  63. package/dist/composites/scheduler-ui.d.ts.map +1 -0
  64. package/dist/composites/scheduler-ui.js +71 -0
  65. package/dist/index.d.ts +67 -7
  66. package/dist/index.d.ts.map +1 -1
  67. package/dist/index.js +57 -7
  68. package/dist/lib/chart-export.d.ts +10 -0
  69. package/dist/lib/chart-export.d.ts.map +1 -0
  70. package/dist/lib/chart-export.js +102 -0
  71. package/dist/lib/charts.d.ts +235 -0
  72. package/dist/lib/charts.d.ts.map +1 -0
  73. package/dist/lib/charts.js +744 -0
  74. package/dist/lib/geo.d.ts +39 -0
  75. package/dist/lib/geo.d.ts.map +1 -0
  76. package/dist/lib/geo.js +101 -0
  77. package/dist/lib/iso.d.ts +21 -0
  78. package/dist/lib/iso.d.ts.map +1 -0
  79. package/dist/lib/iso.js +56 -0
  80. package/dist/lib/rrule.d.ts +20 -0
  81. package/dist/lib/rrule.d.ts.map +1 -0
  82. package/dist/lib/rrule.js +236 -0
  83. package/dist/lib/scheduler.d.ts +152 -0
  84. package/dist/lib/scheduler.d.ts.map +1 -0
  85. package/dist/lib/scheduler.js +501 -0
  86. package/dist/lib/webgl-scatter.d.ts +44 -0
  87. package/dist/lib/webgl-scatter.d.ts.map +1 -0
  88. package/dist/lib/webgl-scatter.js +185 -0
  89. package/dist/primitives/Accordion.d.ts +44 -20
  90. package/dist/primitives/Accordion.d.ts.map +1 -1
  91. package/dist/primitives/Accordion.js +82 -22
  92. package/dist/primitives/AppBar.d.ts +13 -0
  93. package/dist/primitives/AppBar.d.ts.map +1 -0
  94. package/dist/primitives/AppBar.js +27 -0
  95. package/dist/primitives/Autocomplete.d.ts +28 -0
  96. package/dist/primitives/Autocomplete.d.ts.map +1 -0
  97. package/dist/primitives/Autocomplete.js +118 -0
  98. package/dist/primitives/Avatar.d.ts +13 -1
  99. package/dist/primitives/Avatar.d.ts.map +1 -1
  100. package/dist/primitives/Avatar.js +17 -2
  101. package/dist/primitives/Backdrop.d.ts +17 -0
  102. package/dist/primitives/Backdrop.d.ts.map +1 -0
  103. package/dist/primitives/Backdrop.js +17 -0
  104. package/dist/primitives/BottomNavigation.d.ts +23 -0
  105. package/dist/primitives/BottomNavigation.d.ts.map +1 -0
  106. package/dist/primitives/BottomNavigation.js +30 -0
  107. package/dist/primitives/ButtonBase.d.ts +14 -0
  108. package/dist/primitives/ButtonBase.d.ts.map +1 -0
  109. package/dist/primitives/ButtonBase.js +13 -0
  110. package/dist/primitives/ButtonGroup.d.ts +25 -0
  111. package/dist/primitives/ButtonGroup.d.ts.map +1 -0
  112. package/dist/primitives/ButtonGroup.js +44 -0
  113. package/dist/primitives/CircularProgress.d.ts +16 -0
  114. package/dist/primitives/CircularProgress.d.ts.map +1 -0
  115. package/dist/primitives/CircularProgress.js +17 -0
  116. package/dist/primitives/ClickAwayListener.d.ts +16 -0
  117. package/dist/primitives/ClickAwayListener.d.ts.map +1 -0
  118. package/dist/primitives/ClickAwayListener.js +33 -0
  119. package/dist/primitives/Collapse.d.ts +19 -0
  120. package/dist/primitives/Collapse.d.ts.map +1 -0
  121. package/dist/primitives/Collapse.js +14 -0
  122. package/dist/primitives/Container.d.ts +13 -0
  123. package/dist/primitives/Container.d.ts.map +1 -0
  124. package/dist/primitives/Container.js +26 -0
  125. package/dist/primitives/Fab.d.ts +22 -0
  126. package/dist/primitives/Fab.d.ts.map +1 -0
  127. package/dist/primitives/Fab.js +42 -0
  128. package/dist/primitives/FormControl.d.ts +27 -0
  129. package/dist/primitives/FormControl.d.ts.map +1 -0
  130. package/dist/primitives/FormControl.js +24 -0
  131. package/dist/primitives/FormControlLabel.d.ts +18 -0
  132. package/dist/primitives/FormControlLabel.d.ts.map +1 -0
  133. package/dist/primitives/FormControlLabel.js +19 -0
  134. package/dist/primitives/Grid.d.ts +32 -0
  135. package/dist/primitives/Grid.d.ts.map +1 -0
  136. package/dist/primitives/Grid.js +50 -0
  137. package/dist/primitives/IconButton.d.ts +22 -0
  138. package/dist/primitives/IconButton.d.ts.map +1 -0
  139. package/dist/primitives/IconButton.js +39 -0
  140. package/dist/primitives/ImageList.d.ts +31 -0
  141. package/dist/primitives/ImageList.d.ts.map +1 -0
  142. package/dist/primitives/ImageList.js +33 -0
  143. package/dist/primitives/InputAdornment.d.ts +28 -0
  144. package/dist/primitives/InputAdornment.d.ts.map +1 -0
  145. package/dist/primitives/InputAdornment.js +13 -0
  146. package/dist/primitives/Link.d.ts +17 -0
  147. package/dist/primitives/Link.d.ts.map +1 -0
  148. package/dist/primitives/Link.js +29 -0
  149. package/dist/primitives/List.d.ts +41 -0
  150. package/dist/primitives/List.d.ts.map +1 -0
  151. package/dist/primitives/List.js +22 -0
  152. package/dist/primitives/Masonry.d.ts +14 -0
  153. package/dist/primitives/Masonry.d.ts.map +1 -0
  154. package/dist/primitives/Masonry.js +15 -0
  155. package/dist/primitives/Menu.d.ts +28 -0
  156. package/dist/primitives/Menu.d.ts.map +1 -0
  157. package/dist/primitives/Menu.js +54 -0
  158. package/dist/primitives/MobileStepper.d.ts +15 -0
  159. package/dist/primitives/MobileStepper.d.ts.map +1 -0
  160. package/dist/primitives/MobileStepper.js +8 -0
  161. package/dist/primitives/Modal.d.ts +18 -0
  162. package/dist/primitives/Modal.d.ts.map +1 -0
  163. package/dist/primitives/Modal.js +29 -0
  164. package/dist/primitives/NoSsr.d.ts +15 -0
  165. package/dist/primitives/NoSsr.d.ts.map +1 -0
  166. package/dist/primitives/NoSsr.js +19 -0
  167. package/dist/primitives/Paper.d.ts +22 -0
  168. package/dist/primitives/Paper.d.ts.map +1 -0
  169. package/dist/primitives/Paper.js +33 -0
  170. package/dist/primitives/Progress.d.ts +6 -2
  171. package/dist/primitives/Progress.d.ts.map +1 -1
  172. package/dist/primitives/Progress.js +8 -3
  173. package/dist/primitives/Snackbar.d.ts +23 -0
  174. package/dist/primitives/Snackbar.d.ts.map +1 -0
  175. package/dist/primitives/Snackbar.js +33 -0
  176. package/dist/primitives/SpeedDial.d.ts +26 -0
  177. package/dist/primitives/SpeedDial.d.ts.map +1 -0
  178. package/dist/primitives/SpeedDial.js +26 -0
  179. package/dist/primitives/Stack.d.ts +15 -0
  180. package/dist/primitives/Stack.d.ts.map +1 -0
  181. package/dist/primitives/Stack.js +45 -0
  182. package/dist/primitives/StarRating.d.ts +19 -1
  183. package/dist/primitives/StarRating.d.ts.map +1 -1
  184. package/dist/primitives/StarRating.js +36 -6
  185. package/dist/primitives/Stepper.d.ts +36 -0
  186. package/dist/primitives/Stepper.d.ts.map +1 -0
  187. package/dist/primitives/Stepper.js +53 -0
  188. package/dist/primitives/TablePagination.d.ts +16 -0
  189. package/dist/primitives/TablePagination.d.ts.map +1 -0
  190. package/dist/primitives/TablePagination.js +12 -0
  191. package/dist/primitives/TextField.d.ts +24 -0
  192. package/dist/primitives/TextField.d.ts.map +1 -0
  193. package/dist/primitives/TextField.js +19 -0
  194. package/dist/primitives/TextareaAutosize.d.ts +11 -0
  195. package/dist/primitives/TextareaAutosize.d.ts.map +1 -0
  196. package/dist/primitives/TextareaAutosize.js +26 -0
  197. package/dist/primitives/ToggleButton.d.ts +28 -0
  198. package/dist/primitives/ToggleButton.d.ts.map +1 -0
  199. package/dist/primitives/ToggleButton.js +56 -0
  200. package/dist/primitives/TransferList.d.ts +21 -0
  201. package/dist/primitives/TransferList.d.ts.map +1 -0
  202. package/dist/primitives/TransferList.js +40 -0
  203. package/dist/primitives/Typography.d.ts +23 -0
  204. package/dist/primitives/Typography.d.ts.map +1 -0
  205. package/dist/primitives/Typography.js +69 -0
  206. package/dist/primitives/transitions.d.ts +29 -0
  207. package/dist/primitives/transitions.d.ts.map +1 -0
  208. package/dist/primitives/transitions.js +52 -0
  209. package/package.json +28 -4
  210. package/skills/spatika-ui/SKILL.md +96 -0
  211. package/src/composites/Card.tsx +51 -1
  212. package/src/composites/ChartDataGrid.test.tsx +57 -0
  213. package/src/composites/ChartDataGrid.tsx +196 -0
  214. package/src/composites/CoverHero.test.tsx +38 -0
  215. package/src/composites/CoverHero.tsx +29 -22
  216. package/src/composites/EntityMediaCard.test.tsx +58 -0
  217. package/src/composites/EntityMediaCard.tsx +31 -13
  218. package/src/composites/EventCalendar.tsx +928 -0
  219. package/src/composites/EventEditor.tsx +268 -0
  220. package/src/composites/EventTimeline.tsx +357 -0
  221. package/src/composites/MapChart.test.tsx +39 -0
  222. package/src/composites/MapChart.tsx +130 -0
  223. package/src/composites/ProfileHero.test.tsx +33 -0
  224. package/src/composites/ProfileHero.tsx +96 -79
  225. package/src/composites/ScatterWebGL.test.tsx +25 -0
  226. package/src/composites/ScatterWebGL.tsx +79 -0
  227. package/src/composites/SiteFooter.test.tsx +37 -0
  228. package/src/composites/SiteFooter.tsx +1 -1
  229. package/src/composites/SiteNav.test.tsx +46 -0
  230. package/src/composites/SiteNav.tsx +14 -4
  231. package/src/composites/StatCard.test.tsx +30 -0
  232. package/src/composites/StatCard.tsx +4 -0
  233. package/src/composites/Wordmark.test.tsx +23 -0
  234. package/src/composites/Wordmark.tsx +2 -2
  235. package/src/composites/cartesian-charts.test.tsx +52 -0
  236. package/src/composites/cartesian-charts.tsx +1287 -0
  237. package/src/composites/chart-composition.test.tsx +53 -0
  238. package/src/composites/chart-composition.tsx +898 -0
  239. package/src/composites/chart-ui.tsx +328 -0
  240. package/src/composites/charts-3d.test.tsx +25 -0
  241. package/src/composites/charts-3d.tsx +318 -0
  242. package/src/composites/charts.test.tsx +254 -0
  243. package/src/composites/chrome.test.tsx +29 -0
  244. package/src/composites/flow-charts.test.tsx +79 -0
  245. package/src/composites/flow-charts.tsx +607 -0
  246. package/src/composites/marketing.test.tsx +38 -34
  247. package/src/composites/radial-charts.test.tsx +74 -0
  248. package/src/composites/radial-charts.tsx +617 -0
  249. package/src/composites/scheduler-ui.tsx +304 -0
  250. package/src/composites/scheduler.test.tsx +194 -0
  251. package/src/index.test.ts +22 -0
  252. package/src/index.ts +277 -5
  253. package/src/lib/chart-export.test.ts +20 -0
  254. package/src/lib/chart-export.ts +99 -0
  255. package/src/lib/charts-platform.test.ts +93 -0
  256. package/src/lib/charts.test.ts +132 -0
  257. package/src/lib/charts.ts +1021 -0
  258. package/src/lib/geo.test.ts +42 -0
  259. package/src/lib/geo.ts +138 -0
  260. package/src/lib/iso.test.ts +22 -0
  261. package/src/lib/iso.ts +102 -0
  262. package/src/lib/rrule.test.ts +53 -0
  263. package/src/lib/rrule.ts +281 -0
  264. package/src/lib/scheduler.test.ts +186 -0
  265. package/src/lib/scheduler.ts +688 -0
  266. package/src/lib/tokens.test.ts +2 -2
  267. package/src/lib/webgl-scatter.test.ts +36 -0
  268. package/src/lib/webgl-scatter.ts +230 -0
  269. package/src/primitives/Accordion.tsx +218 -63
  270. package/src/primitives/AppBar.tsx +39 -0
  271. package/src/primitives/Autocomplete.tsx +236 -0
  272. package/src/primitives/Avatar.tsx +52 -1
  273. package/src/primitives/Backdrop.tsx +48 -0
  274. package/src/primitives/BottomNavigation.tsx +102 -0
  275. package/src/primitives/ButtonBase.tsx +34 -0
  276. package/src/primitives/ButtonGroup.tsx +90 -0
  277. package/src/primitives/CircularProgress.tsx +104 -0
  278. package/src/primitives/ClickAwayListener.tsx +46 -0
  279. package/src/primitives/Collapse.tsx +39 -0
  280. package/src/primitives/Container.tsx +40 -0
  281. package/src/primitives/Fab.tsx +62 -0
  282. package/src/primitives/FormControl.tsx +90 -0
  283. package/src/primitives/FormControlLabel.tsx +51 -0
  284. package/src/primitives/Grid.tsx +101 -0
  285. package/src/primitives/IconButton.tsx +59 -0
  286. package/src/primitives/ImageList.test.tsx +48 -0
  287. package/src/primitives/ImageList.tsx +112 -0
  288. package/src/primitives/InputAdornment.tsx +86 -0
  289. package/src/primitives/Link.tsx +48 -0
  290. package/src/primitives/List.tsx +121 -0
  291. package/src/primitives/Masonry.tsx +32 -0
  292. package/src/primitives/Menu.tsx +112 -0
  293. package/src/primitives/MobileStepper.tsx +64 -0
  294. package/src/primitives/Modal.tsx +70 -0
  295. package/src/primitives/NoSsr.tsx +28 -0
  296. package/src/primitives/Paper.tsx +55 -0
  297. package/src/primitives/Progress.tsx +25 -7
  298. package/src/primitives/Snackbar.tsx +83 -0
  299. package/src/primitives/SpeedDial.tsx +110 -0
  300. package/src/primitives/Stack.tsx +88 -0
  301. package/src/primitives/StarRating.tsx +87 -28
  302. package/src/primitives/Stepper.tsx +184 -0
  303. package/src/primitives/TablePagination.tsx +58 -0
  304. package/src/primitives/TextField.tsx +105 -0
  305. package/src/primitives/TextareaAutosize.tsx +49 -0
  306. package/src/primitives/ToggleButton.tsx +148 -0
  307. package/src/primitives/TransferList.tsx +110 -0
  308. package/src/primitives/Typography.tsx +90 -0
  309. package/src/primitives/mui-more.test.tsx +76 -0
  310. package/src/primitives/mui.test.tsx +134 -0
  311. package/src/primitives/transitions.tsx +120 -0
@@ -0,0 +1,130 @@
1
+ import { chartColor, formatChartNumber, type ChartMargin } from "../lib/charts";
2
+ import {
3
+ featureId,
4
+ fitFeatures,
5
+ geoPath,
6
+ type GeoJsonFeatureCollection,
7
+ type GeoProjectionKind,
8
+ } from "../lib/geo";
9
+ import { ChartFrame, useChartHover } from "./chart-ui";
10
+
11
+ export type MapShapeDatum = {
12
+ id: string | number;
13
+ value: number;
14
+ label?: string;
15
+ color?: string;
16
+ };
17
+
18
+ export type MapChartProps = {
19
+ geoData: GeoJsonFeatureCollection;
20
+ series?: Array<{ data: MapShapeDatum[] }>;
21
+ idProperty?: string;
22
+ projection?: GeoProjectionKind;
23
+ height?: number;
24
+ width?: number;
25
+ colors?: string[];
26
+ hideLegend?: boolean;
27
+ margin?: ChartMargin;
28
+ className?: string;
29
+ };
30
+
31
+ export function MapChart({
32
+ geoData,
33
+ series,
34
+ idProperty = "id",
35
+ projection = "mercator",
36
+ height = 280,
37
+ width,
38
+ colors,
39
+ hideLegend,
40
+ margin,
41
+ className,
42
+ }: MapChartProps) {
43
+ const features = geoData.features ?? [];
44
+ const data = series?.[0]?.data ?? [];
45
+ const byId = new Map(data.map((item) => [String(item.id), item]));
46
+ const values = data.map((item) => item.value);
47
+ const min = values.length ? Math.min(...values) : 0;
48
+ const max = values.length ? Math.max(...values) : 1;
49
+ const { hover, setHover, clear } = useChartHover();
50
+ const legend = hideLegend
51
+ ? undefined
52
+ : data.slice(0, 8).map((item, i) => ({
53
+ id: String(item.id),
54
+ label: item.label ?? String(item.id),
55
+ color: item.color ?? choroplethFill(item.value, min, max, i, colors),
56
+ }));
57
+
58
+ return (
59
+ <ChartFrame
60
+ slot="map-chart"
61
+ className={className}
62
+ width={width}
63
+ height={height}
64
+ margin={margin ?? { top: 8, right: 8, bottom: 8, left: 8 }}
65
+ legend={legend}
66
+ hover={hover}
67
+ aria-label="Map chart"
68
+ >
69
+ {({ width: w, height: h, m }) => {
70
+ const pad = Math.min(m.left, m.right, m.top, m.bottom);
71
+ const project = fitFeatures(features, w, h, pad, projection);
72
+ return (
73
+ <svg width={w} height={h} viewBox={`0 0 ${w} ${h}`}>
74
+ {features.map((feature, i) => {
75
+ if (!feature.geometry) return null;
76
+ const id = featureId(feature, idProperty) || String(i);
77
+ const datum = byId.get(id);
78
+ const label =
79
+ datum?.label ??
80
+ (feature.properties?.name != null ? String(feature.properties.name) : id);
81
+ const fill = datum
82
+ ? (datum.color ?? choroplethFill(datum.value, min, max, i, colors))
83
+ : "color-mix(in srgb, var(--muted) 55%, transparent)";
84
+ const d = geoPath(feature.geometry, project);
85
+ if (!d) return null;
86
+ return (
87
+ <path
88
+ key={id}
89
+ d={d}
90
+ fill={fill}
91
+ className="spk-chart-map-region spk-chart-mark"
92
+ onMouseEnter={(event) =>
93
+ setHover({
94
+ x: event.nativeEvent.offsetX,
95
+ y: event.nativeEvent.offsetY,
96
+ title: label,
97
+ items: datum
98
+ ? [
99
+ {
100
+ color: fill,
101
+ label: label,
102
+ value: formatChartNumber(datum.value),
103
+ },
104
+ ]
105
+ : [{ color: fill, label: label, value: "—" }],
106
+ })
107
+ }
108
+ onMouseLeave={clear}
109
+ />
110
+ );
111
+ })}
112
+ </svg>
113
+ );
114
+ }}
115
+ </ChartFrame>
116
+ );
117
+ }
118
+
119
+ function choroplethFill(
120
+ value: number,
121
+ min: number,
122
+ max: number,
123
+ index: number,
124
+ colors?: string[],
125
+ ): string {
126
+ const t = max === min ? 0.6 : (value - min) / (max - min);
127
+ const base = chartColor(index, colors);
128
+ const mix = Math.round(28 + t * 72);
129
+ return `color-mix(in srgb, ${base} ${mix}%, var(--muted))`;
130
+ }
@@ -0,0 +1,33 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it, vi } from "vitest";
4
+ import { ProfileHero } from "./ProfileHero";
5
+
6
+ describe("ProfileHero", () => {
7
+ it("renders name, alias, and a notched identity plate", () => {
8
+ const { container } = render(
9
+ <ProfileHero name="Alex Chen" alias="Product designer" coverSeed="alex" bleed={false} />,
10
+ );
11
+ expect(screen.getByRole("heading", { name: "Alex Chen" })).toBeInTheDocument();
12
+ expect(screen.getByText("Product designer")).toBeInTheDocument();
13
+ expect(container.querySelector(".profile-identity")).toBeTruthy();
14
+ expect(container.querySelector(".profile-identity__plate")).toBeTruthy();
15
+ expect(container.querySelector("[data-slot='profile-hero']")).not.toHaveAttribute(
16
+ "data-cover-bleed",
17
+ );
18
+ expect(container.querySelector("[data-slot='profile-hero']")).toHaveClass("w-full");
19
+ });
20
+
21
+ it("keeps the avatar inert until onAvatarClick is provided", async () => {
22
+ const user = userEvent.setup();
23
+ const onAvatarClick = vi.fn();
24
+ const { rerender } = render(<ProfileHero name="Alex Chen" bleed={false} />);
25
+ expect(screen.getByRole("button", { name: "Alex Chen" })).toBeDisabled();
26
+
27
+ rerender(<ProfileHero name="Alex Chen" onAvatarClick={onAvatarClick} bleed={false} />);
28
+ const avatar = screen.getByRole("button", { name: "Alex Chen" });
29
+ expect(avatar).toBeEnabled();
30
+ await user.click(avatar);
31
+ expect(onAvatarClick).toHaveBeenCalledTimes(1);
32
+ });
33
+ });
@@ -33,12 +33,14 @@ export type ProfileHeroProps = {
33
33
  coverKind?: CoverPatternKind | null;
34
34
  dark?: boolean;
35
35
  warm?: boolean;
36
+ /** When false, keep the cover inside this frame (catalog previews). */
37
+ bleed?: boolean;
36
38
  };
37
39
 
38
40
  /**
39
- * Profile / entity hero: cover bleeds into app chrome, overlapping avatar
40
- * notches the frosted identity plate. Stacked in a narrow column; left-notched
41
- * once the plate is wide enough (container query, not viewport).
41
+ * Profile / entity hero: cover fills the frame, overlapping avatar notches
42
+ * the frosted identity plate. Stacked in a narrow column; left-notched once
43
+ * the plate is wide enough (container query, not viewport).
42
44
  */
43
45
  export function ProfileHero({
44
46
  name,
@@ -63,6 +65,7 @@ export function ProfileHero({
63
65
  coverKind,
64
66
  dark,
65
67
  warm,
68
+ bleed = true,
66
69
  }: ProfileHeroProps) {
67
70
  const initials = getInitials(name);
68
71
  const seed = coverSeed ?? name;
@@ -70,92 +73,106 @@ export function ProfileHero({
70
73
  const ink = scene.headerInk;
71
74
 
72
75
  return (
73
- <div data-slot="profile-hero" className={cn("relative overflow-visible", className)}>
76
+ <div
77
+ data-slot="profile-hero"
78
+ data-cover-bleed={bleed ? "true" : undefined}
79
+ className={cn(
80
+ "relative w-full",
81
+ bleed ? "overflow-visible" : "overflow-hidden rounded-[1.35rem] sm:rounded-[1.75rem]",
82
+ className,
83
+ )}
84
+ >
74
85
  <div
75
- className={cn(
76
- "relative min-h-[9rem] overflow-hidden sm:min-h-[12rem] lg:min-h-[13rem]",
77
- coverClassName,
78
- )}
86
+ className={cn("cover-media cover-media--profile", bleed && "sm:rounded-b-[2rem]")}
87
+ aria-hidden
79
88
  >
80
- <div className="cover-media cover-media--profile sm:rounded-b-[2rem]" aria-hidden>
81
- {!hideImages && coverUrl ? (
82
- <img src={coverUrl} alt="" className="absolute inset-0 size-full object-cover" />
83
- ) : (
84
- <CoverPattern seed={seed} hue={coverHue} dark={dark} warm={warm} kind={coverKind} />
89
+ {!hideImages && coverUrl ? (
90
+ <img src={coverUrl} alt="" className="absolute inset-0 size-full object-cover" />
91
+ ) : (
92
+ <CoverPattern seed={seed} hue={coverHue} dark={dark} warm={warm} kind={coverKind} />
93
+ )}
94
+ <div className="absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-black/[0.04] to-transparent dark:from-black/12" />
95
+ <div className="absolute inset-x-0 bottom-0 h-[22%] bg-gradient-to-t from-black/[0.03] via-transparent to-transparent dark:from-black/10" />
96
+ </div>
97
+
98
+ <div className="relative z-10 flex flex-col">
99
+ <div
100
+ className={cn(
101
+ "relative min-h-[8.5rem] sm:min-h-[11rem] lg:min-h-[12rem]",
102
+ coverClassName,
85
103
  )}
86
- <div className="absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-black/[0.04] to-transparent dark:from-black/12" />
87
- <div className="absolute inset-x-0 bottom-0 h-[22%] bg-gradient-to-t from-black/[0.03] via-transparent to-transparent dark:from-black/10" />
88
- </div>
89
- <div className="relative z-10 flex min-h-[9rem] items-start justify-between gap-2 p-3 sm:min-h-[12rem] sm:p-4 lg:min-h-[13rem]">
90
- <div>{topLeading}</div>
91
- {topActions ? (
92
- <div
93
- data-cover-ink={ink}
94
- className="cover-floating-shell flex items-center gap-1 p-1"
95
- >
96
- {topActions}
97
- </div>
98
- ) : null}
104
+ >
105
+ <div className="absolute inset-x-0 top-0 z-30 flex items-start justify-between gap-2 p-3 sm:p-4">
106
+ <div>{topLeading}</div>
107
+ {topActions ? (
108
+ <div
109
+ data-cover-ink={ink}
110
+ className="cover-floating-shell flex items-center gap-1 p-1"
111
+ >
112
+ {topActions}
113
+ </div>
114
+ ) : null}
115
+ </div>
99
116
  </div>
100
- </div>
101
117
 
102
- <div className="relative z-10 px-3 pb-4 sm:px-4 sm:pb-5 lg:px-8">
103
- <div className={cn("profile-identity", identityClassName)}>
104
- <div
105
- aria-hidden
106
- className={cn(
107
- "pointer-events-none absolute inset-0 rounded-[1.35rem] profile-identity__plate",
108
- coverPlateClass(ink),
109
- )}
110
- />
111
- <div className="profile-identity__body">
112
- <button
113
- type="button"
114
- disabled={!onAvatarClick}
115
- onClick={onAvatarClick}
116
- aria-label={name}
117
- className="profile-identity__avatar"
118
- >
119
- <span
120
- className="flex items-center justify-center overflow-hidden rounded-full border-[3px] border-white bg-muted text-lg font-black leading-none text-primary shadow-[0_16px_40px_rgba(0,0,0,0.38)] dark:border-white/30"
121
- style={{
122
- width: "var(--profile-avatar-size)",
123
- height: "var(--profile-avatar-size)",
124
- }}
118
+ <div className="relative z-20 px-3 pb-4 sm:px-4 sm:pb-5 lg:px-6">
119
+ <div className={cn("profile-identity", identityClassName)}>
120
+ <div
121
+ aria-hidden
122
+ className={cn(
123
+ "pointer-events-none absolute inset-0 rounded-[1.35rem] profile-identity__plate",
124
+ coverPlateClass(ink),
125
+ )}
126
+ />
127
+ <div className="profile-identity__body">
128
+ <button
129
+ type="button"
130
+ disabled={!onAvatarClick}
131
+ onClick={onAvatarClick}
132
+ aria-label={name}
133
+ className="profile-identity__avatar"
125
134
  >
126
- {!hideImages && avatarUrl ? (
127
- <img src={avatarUrl} alt="" className="size-full object-cover" />
128
- ) : (
129
- initials
130
- )}
131
- </span>
132
- {avatarAccessory}
133
- </button>
134
- <div className="profile-identity__copy">
135
- <div className="profile-identity__heading">
136
- <h1
137
- className={cn(
138
- "truncate text-[1.15rem] font-black leading-tight tracking-tight sm:text-2xl",
139
- coverTitleClass(ink),
140
- )}
135
+ <span
136
+ className="flex items-center justify-center overflow-hidden rounded-full border-[3px] border-white bg-muted text-lg font-black leading-none text-primary shadow-[0_16px_40px_rgba(0,0,0,0.38)] dark:border-white/30"
137
+ style={{
138
+ width: "var(--profile-avatar-size)",
139
+ height: "var(--profile-avatar-size)",
140
+ }}
141
141
  >
142
- {name}
143
- </h1>
144
- {statusBadges}
142
+ {!hideImages && avatarUrl ? (
143
+ <img src={avatarUrl} alt="" className="size-full object-cover" />
144
+ ) : (
145
+ initials
146
+ )}
147
+ </span>
148
+ {avatarAccessory}
149
+ </button>
150
+ <div className="profile-identity__copy">
151
+ <div className="profile-identity__heading">
152
+ <h1
153
+ className={cn(
154
+ "truncate text-[1.15rem] font-black leading-tight tracking-tight sm:text-2xl",
155
+ coverTitleClass(ink),
156
+ )}
157
+ >
158
+ {name}
159
+ </h1>
160
+ {statusBadges}
161
+ </div>
162
+ {alias ? (
163
+ <p className={cn("mt-0.5 truncate text-sm font-medium", coverMutedClass(ink))}>
164
+ {alias}
165
+ </p>
166
+ ) : null}
167
+ {metaLines ? (
168
+ <div className={cn("mt-2 space-y-1 text-sm", coverMutedClass(ink))}>{metaLines}</div>
169
+ ) : null}
170
+ {stats ? <div className="mt-3">{stats}</div> : null}
171
+ {tags ? <div className="profile-identity__tags">{tags}</div> : null}
145
172
  </div>
146
- {alias ? (
147
- <p className={cn("mt-0.5 truncate text-sm font-medium", coverMutedClass(ink))}>
148
- {alias}
149
- </p>
150
- ) : null}
151
- {metaLines ? (
152
- <div className={cn("mt-2 space-y-1 text-sm", coverMutedClass(ink))}>{metaLines}</div>
153
- ) : null}
154
- {stats ? <div className="mt-3">{stats}</div> : null}
155
- {tags ? <div className="profile-identity__tags">{tags}</div> : null}
156
173
  </div>
174
+ {children ? <div className="relative z-10 px-3 pb-3 sm:px-6">{children}</div> : null}
157
175
  </div>
158
- {children ? <div className="relative z-10 px-3 pb-3 sm:px-6">{children}</div> : null}
159
176
  </div>
160
177
  </div>
161
178
  </div>
@@ -0,0 +1,25 @@
1
+ import { render } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { ScatterWebGLLayer } from "./ScatterWebGL";
4
+
5
+ describe("ScatterWebGLLayer", () => {
6
+ it("renders a canvas layer and reports when WebGL is unavailable", () => {
7
+ const onUnavailable = vi.fn();
8
+ const { container } = render(
9
+ <ScatterWebGLLayer
10
+ width={120}
11
+ height={80}
12
+ series={[{ data: [{ x: 1, y: 2 }] }]}
13
+ meta={[{ id: "a", label: "A", color: "#2563eb" }]}
14
+ hidden={new Set()}
15
+ xAt={(value) => value}
16
+ yAt={(value) => value}
17
+ sizeAt={(z) => z}
18
+ bubble={false}
19
+ onUnavailable={onUnavailable}
20
+ />,
21
+ );
22
+ expect(container.querySelector('[data-slot="scatter-webgl"]')).toBeTruthy();
23
+ expect(onUnavailable).toHaveBeenCalled();
24
+ });
25
+ });
@@ -0,0 +1,79 @@
1
+ import { useEffect, useRef } from "react";
2
+ import { createScatterGL, destroyScatterGL, drawScatterGL, type ScatterGL } from "../lib/webgl-scatter";
3
+ import type { ChartLegendItem } from "./chart-ui";
4
+
5
+ type ScatterSeriesLike = {
6
+ data: Array<{ x: number; y: number; z?: number }>;
7
+ };
8
+
9
+ export function ScatterWebGLLayer({
10
+ width,
11
+ height,
12
+ series,
13
+ meta,
14
+ hidden,
15
+ xAt,
16
+ yAt,
17
+ sizeAt,
18
+ bubble,
19
+ onUnavailable,
20
+ }: {
21
+ width: number;
22
+ height: number;
23
+ series: ScatterSeriesLike[];
24
+ meta: ChartLegendItem[];
25
+ hidden: Set<string>;
26
+ xAt: (value: number) => number;
27
+ yAt: (value: number) => number;
28
+ sizeAt: (z: number) => number;
29
+ bubble: boolean;
30
+ onUnavailable?: () => void;
31
+ }) {
32
+ const canvasRef = useRef<HTMLCanvasElement>(null);
33
+ const gpuRef = useRef<ScatterGL | null>(null);
34
+
35
+ useEffect(() => {
36
+ const canvas = canvasRef.current;
37
+ if (!canvas) return;
38
+ const gpu = createScatterGL(canvas);
39
+ if (!gpu) {
40
+ onUnavailable?.();
41
+ return;
42
+ }
43
+ gpuRef.current = gpu;
44
+ return () => {
45
+ destroyScatterGL(gpu);
46
+ gpuRef.current = null;
47
+ };
48
+ }, [onUnavailable]);
49
+
50
+ useEffect(() => {
51
+ const gpu = gpuRef.current;
52
+ const canvas = canvasRef.current;
53
+ if (!gpu || !canvas) return;
54
+ const batches = series.flatMap((item, s) => {
55
+ const info = meta[s]!;
56
+ if (hidden.has(info.id)) return [];
57
+ return [
58
+ {
59
+ color: info.color,
60
+ positions: item.data.map((point) => ({
61
+ x: xAt(point.x),
62
+ y: yAt(point.y),
63
+ size: bubble ? sizeAt(point.z ?? 1) * 2 : 9,
64
+ })),
65
+ },
66
+ ];
67
+ });
68
+ drawScatterGL(gpu, canvas, width, height, batches);
69
+ });
70
+
71
+ return (
72
+ <canvas
73
+ ref={canvasRef}
74
+ className="spk-chart-webgl"
75
+ data-slot="scatter-webgl"
76
+ aria-hidden
77
+ />
78
+ );
79
+ }
@@ -0,0 +1,37 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { SiteFooter } from "./SiteFooter";
4
+
5
+ describe("SiteFooter", () => {
6
+ it("renders brand, copyright, links, and meta", () => {
7
+ render(
8
+ <SiteFooter
9
+ brand={<span>Acme</span>}
10
+ copyright="© 2026"
11
+ links={[{ href: "#about", label: "About" }]}
12
+ meta={<span>Powered by Spatika</span>}
13
+ />,
14
+ );
15
+ expect(screen.getByText("Acme")).toBeInTheDocument();
16
+ expect(screen.getByText("© 2026")).toBeInTheDocument();
17
+ expect(screen.getByRole("link", { name: "About" })).toHaveAttribute("href", "#about");
18
+ expect(screen.getByText("Powered by Spatika")).toBeInTheDocument();
19
+ });
20
+
21
+ it("omits the nav when there are no links and uses renderLink when provided", () => {
22
+ const { rerender } = render(<SiteFooter brand={<span>Acme</span>} />);
23
+ expect(screen.queryByRole("navigation")).not.toBeInTheDocument();
24
+
25
+ rerender(
26
+ <SiteFooter
27
+ links={[{ href: "/docs", label: "Docs" }]}
28
+ renderLink={(link, className) => (
29
+ <a href={link.href} className={className} data-custom="true">
30
+ {link.label}
31
+ </a>
32
+ )}
33
+ />,
34
+ );
35
+ expect(screen.getByRole("link", { name: "Docs" })).toHaveAttribute("data-custom", "true");
36
+ });
37
+ });
@@ -18,7 +18,7 @@ const linkClass =
18
18
 
19
19
  /**
20
20
  * Marketing-site footer: brand, copyright, links, optional meta chips.
21
- * Frosted surface bar with a primary hairline — matches sreelal.me.
21
+ * Frosted surface bar with a primary hairline.
22
22
  */
23
23
  export function SiteFooter({
24
24
  brand,
@@ -0,0 +1,46 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { describe, expect, it } from "vitest";
4
+ import { SiteNav } from "./SiteNav";
5
+
6
+ describe("SiteNav", () => {
7
+ it("renders brand and links and opens the mobile menu", async () => {
8
+ const user = userEvent.setup();
9
+ render(
10
+ <SiteNav
11
+ brand={<a href="/">Brand</a>}
12
+ links={[
13
+ { href: "#about", label: "About" },
14
+ { href: "/blog", label: "Blog" },
15
+ ]}
16
+ />,
17
+ );
18
+ expect(screen.getByText("Brand")).toBeInTheDocument();
19
+ expect(screen.getAllByText("About").length).toBeGreaterThan(0);
20
+ expect(screen.getByRole("navigation").className).toContain("spk-site-nav");
21
+ const toggle = screen.getByRole("button", { name: "Toggle menu" });
22
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
23
+ await user.click(toggle);
24
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
25
+ });
26
+
27
+ it("does not pin to the viewport when contained and closes the menu on Escape", async () => {
28
+ const user = userEvent.setup();
29
+ render(
30
+ <SiteNav
31
+ contained
32
+ brand={<a href="/">Brand</a>}
33
+ links={[{ href: "#about", label: "About" }]}
34
+ />,
35
+ );
36
+ const nav = screen.getByRole("navigation");
37
+ expect(nav.className).toContain("absolute");
38
+ expect(nav.className).not.toMatch(/\bfixed\b/);
39
+
40
+ const toggle = screen.getByRole("button", { name: "Toggle menu" });
41
+ await user.click(toggle);
42
+ expect(toggle).toHaveAttribute("aria-expanded", "true");
43
+ await user.keyboard("{Escape}");
44
+ expect(toggle).toHaveAttribute("aria-expanded", "false");
45
+ });
46
+ });
@@ -14,6 +14,11 @@ export type SiteNavProps = {
14
14
  className?: string;
15
15
  /** Extra classes for the inner max-width row. */
16
16
  innerClassName?: string;
17
+ /**
18
+ * Pin the bar to a relatively positioned parent instead of the viewport.
19
+ * Use this in docs previews and embedded shells.
20
+ */
21
+ contained?: boolean;
17
22
  };
18
23
 
19
24
  const desktopLinkClass =
@@ -24,7 +29,7 @@ const mobileLinkClass =
24
29
 
25
30
  /**
26
31
  * Marketing-site navbar: fixed bar, brand, desktop links, and a 3-line mobile menu.
27
- * Matches the sreelal.me chrome: transparent at rest, frosted surface after scroll.
32
+ * Transparent at rest, frosted surface after scroll.
28
33
  */
29
34
  export function SiteNav({
30
35
  brand,
@@ -32,17 +37,19 @@ export function SiteNav({
32
37
  renderLink,
33
38
  className,
34
39
  innerClassName,
40
+ contained = false,
35
41
  }: SiteNavProps) {
36
- const [scrolled, setScrolled] = useState(false);
42
+ const [scrolled, setScrolled] = useState(contained);
37
43
  const [open, setOpen] = useState(false);
38
44
  const menuId = useId();
39
45
 
40
46
  useEffect(() => {
47
+ if (contained) return;
41
48
  const onScroll = () => setScrolled(window.scrollY > 20);
42
49
  onScroll();
43
50
  window.addEventListener("scroll", onScroll, { passive: true });
44
51
  return () => window.removeEventListener("scroll", onScroll);
45
- }, []);
52
+ }, [contained]);
46
53
 
47
54
  useEffect(() => {
48
55
  if (!open) return;
@@ -74,7 +81,10 @@ export function SiteNav({
74
81
  data-slot="site-nav"
75
82
  data-scrolled={scrolled ? "true" : "false"}
76
83
  className={cn(
77
- "spk-site-nav fixed inset-x-0 top-0 z-50 transition-all duration-500",
84
+ "inset-x-0 top-0 transition-all duration-500",
85
+ contained
86
+ ? "absolute z-10"
87
+ : "spk-site-nav fixed z-50",
78
88
  scrolled
79
89
  ? "border-b border-border/90 bg-[color-mix(in_srgb,var(--background)_88%,transparent)] shadow-[0_8px_30px_rgba(15,13,18,0.06)] backdrop-blur-xl"
80
90
  : "bg-transparent",
@@ -0,0 +1,30 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it } from "vitest";
3
+ import { StatCard } from "./StatCard";
4
+
5
+ describe("StatCard", () => {
6
+ it("renders label, value, and hint", () => {
7
+ render(<StatCard label="Users" value="144k" hint="+12 this week" />);
8
+ expect(screen.getByText("Users")).toBeInTheDocument();
9
+ expect(screen.getByText("144k")).toBeInTheDocument();
10
+ expect(screen.getByText("+12 this week")).toBeInTheDocument();
11
+ });
12
+
13
+ it("renders an optional chart under the value", () => {
14
+ render(<StatCard label="Users" value="144k" chart={<span>trend</span>} />);
15
+ expect(screen.getByText("144k")).toBeInTheDocument();
16
+ expect(screen.getByText("trend")).toBeInTheDocument();
17
+ });
18
+
19
+ it("renders value-first metric layout", () => {
20
+ render(<StatCard variant="metric" label="Uptime" value="99.9%" />);
21
+ expect(screen.getByText("99.9%")).toBeInTheDocument();
22
+ expect(screen.getByText("Uptime")).toBeInTheDocument();
23
+ });
24
+
25
+ it("omits the chart slot when none is provided", () => {
26
+ render(<StatCard label="Users" value="144k" />);
27
+ expect(screen.queryByText("trend")).not.toBeInTheDocument();
28
+ expect(screen.getByText("144k")).toBeInTheDocument();
29
+ });
30
+ });