@spatika/react 1.0.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.
- package/AGENTS.md +71 -0
- package/README.md +4 -3
- package/dist/composites/AccentRule.d.ts +6 -0
- package/dist/composites/AccentRule.d.ts.map +1 -0
- package/dist/composites/AccentRule.js +6 -0
- package/dist/composites/AvailabilityBadge.d.ts +9 -0
- package/dist/composites/AvailabilityBadge.d.ts.map +1 -0
- package/dist/composites/AvailabilityBadge.js +9 -0
- package/dist/composites/Card.d.ts +7 -1
- package/dist/composites/Card.d.ts.map +1 -1
- package/dist/composites/Card.js +10 -0
- package/dist/composites/CareerCard.d.ts +20 -0
- package/dist/composites/CareerCard.d.ts.map +1 -0
- package/dist/composites/CareerCard.js +17 -0
- package/dist/composites/ChartDataGrid.d.ts +33 -0
- package/dist/composites/ChartDataGrid.d.ts.map +1 -0
- package/dist/composites/ChartDataGrid.js +77 -0
- package/dist/composites/ContactLink.d.ts +15 -0
- package/dist/composites/ContactLink.d.ts.map +1 -0
- package/dist/composites/ContactLink.js +8 -0
- package/dist/composites/CoverHero.d.ts +1 -1
- package/dist/composites/CoverHero.d.ts.map +1 -1
- package/dist/composites/CoverHero.js +2 -2
- package/dist/composites/DisplayHeading.d.ts +9 -0
- package/dist/composites/DisplayHeading.d.ts.map +1 -0
- package/dist/composites/DisplayHeading.js +6 -0
- package/dist/composites/EntityMediaCard.d.ts +1 -1
- package/dist/composites/EntityMediaCard.d.ts.map +1 -1
- package/dist/composites/EntityMediaCard.js +12 -4
- package/dist/composites/EventCalendar.d.ts +35 -0
- package/dist/composites/EventCalendar.d.ts.map +1 -0
- package/dist/composites/EventCalendar.js +339 -0
- package/dist/composites/EventEditor.d.ts +22 -0
- package/dist/composites/EventEditor.d.ts.map +1 -0
- package/dist/composites/EventEditor.js +86 -0
- package/dist/composites/EventTimeline.d.ts +24 -0
- package/dist/composites/EventTimeline.d.ts.map +1 -0
- package/dist/composites/EventTimeline.js +184 -0
- package/dist/composites/FloatChip.d.ts +15 -0
- package/dist/composites/FloatChip.d.ts.map +1 -0
- package/dist/composites/FloatChip.js +7 -0
- package/dist/composites/GradientText.d.ts +11 -0
- package/dist/composites/GradientText.d.ts.map +1 -0
- package/dist/composites/GradientText.js +6 -0
- package/dist/composites/IconTile.d.ts +10 -0
- package/dist/composites/IconTile.d.ts.map +1 -0
- package/dist/composites/IconTile.js +7 -0
- package/dist/composites/MapChart.d.ts +24 -0
- package/dist/composites/MapChart.d.ts.map +1 -0
- package/dist/composites/MapChart.js +58 -0
- package/dist/composites/MetaChip.d.ts +9 -0
- package/dist/composites/MetaChip.d.ts.map +1 -0
- package/dist/composites/MetaChip.js +6 -0
- package/dist/composites/PresenceDot.d.ts +10 -0
- package/dist/composites/PresenceDot.d.ts.map +1 -0
- package/dist/composites/PresenceDot.js +10 -0
- package/dist/composites/ProfileHero.d.ts +6 -4
- package/dist/composites/ProfileHero.d.ts.map +1 -1
- package/dist/composites/ProfileHero.js +8 -8
- package/dist/composites/ProjectCard.d.ts +12 -0
- package/dist/composites/ProjectCard.d.ts.map +1 -0
- package/dist/composites/ProjectCard.js +9 -0
- package/dist/composites/ScatterWebGL.d.ts +22 -0
- package/dist/composites/ScatterWebGL.d.ts.map +1 -0
- package/dist/composites/ScatterWebGL.js +45 -0
- package/dist/composites/SectionHeading.d.ts +16 -0
- package/dist/composites/SectionHeading.d.ts.map +1 -0
- package/dist/composites/SectionHeading.js +12 -0
- package/dist/composites/SiteFooter.d.ts +18 -0
- package/dist/composites/SiteFooter.d.ts.map +1 -0
- package/dist/composites/SiteFooter.js +15 -0
- package/dist/composites/SiteNav.d.ts +25 -0
- package/dist/composites/SiteNav.d.ts.map +1 -0
- package/dist/composites/SiteNav.js +44 -0
- package/dist/composites/StatCard.d.ts +7 -1
- package/dist/composites/StatCard.d.ts.map +1 -1
- package/dist/composites/StatCard.js +5 -2
- package/dist/composites/StatusDot.d.ts +1 -1
- package/dist/composites/Tag.d.ts +11 -0
- package/dist/composites/Tag.d.ts.map +1 -0
- package/dist/composites/Tag.js +33 -0
- package/dist/composites/Wordmark.d.ts +19 -0
- package/dist/composites/Wordmark.d.ts.map +1 -0
- package/dist/composites/Wordmark.js +15 -0
- package/dist/composites/cartesian-charts.d.ts +138 -0
- package/dist/composites/cartesian-charts.d.ts.map +1 -0
- package/dist/composites/cartesian-charts.js +401 -0
- package/dist/composites/chart-composition.d.ts +139 -0
- package/dist/composites/chart-composition.d.ts.map +1 -0
- package/dist/composites/chart-composition.js +486 -0
- package/dist/composites/chart-ui.d.ts +83 -0
- package/dist/composites/chart-ui.d.ts.map +1 -0
- package/dist/composites/chart-ui.js +108 -0
- package/dist/composites/charts-3d.d.ts +37 -0
- package/dist/composites/charts-3d.d.ts.map +1 -0
- package/dist/composites/charts-3d.js +121 -0
- package/dist/composites/flow-charts.d.ts +89 -0
- package/dist/composites/flow-charts.d.ts.map +1 -0
- package/dist/composites/flow-charts.js +186 -0
- package/dist/composites/radial-charts.d.ts +109 -0
- package/dist/composites/radial-charts.d.ts.map +1 -0
- package/dist/composites/radial-charts.js +176 -0
- package/dist/composites/scheduler-ui.d.ts +43 -0
- package/dist/composites/scheduler-ui.d.ts.map +1 -0
- package/dist/composites/scheduler-ui.js +71 -0
- package/dist/index.d.ts +83 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +74 -7
- package/dist/lib/chart-export.d.ts +10 -0
- package/dist/lib/chart-export.d.ts.map +1 -0
- package/dist/lib/chart-export.js +102 -0
- package/dist/lib/charts.d.ts +235 -0
- package/dist/lib/charts.d.ts.map +1 -0
- package/dist/lib/charts.js +744 -0
- package/dist/lib/geo.d.ts +39 -0
- package/dist/lib/geo.d.ts.map +1 -0
- package/dist/lib/geo.js +101 -0
- package/dist/lib/iso.d.ts +21 -0
- package/dist/lib/iso.d.ts.map +1 -0
- package/dist/lib/iso.js +56 -0
- package/dist/lib/rrule.d.ts +20 -0
- package/dist/lib/rrule.d.ts.map +1 -0
- package/dist/lib/rrule.js +236 -0
- package/dist/lib/scheduler.d.ts +152 -0
- package/dist/lib/scheduler.d.ts.map +1 -0
- package/dist/lib/scheduler.js +501 -0
- package/dist/lib/webgl-scatter.d.ts +44 -0
- package/dist/lib/webgl-scatter.d.ts.map +1 -0
- package/dist/lib/webgl-scatter.js +185 -0
- package/dist/primitives/Accordion.d.ts +44 -20
- package/dist/primitives/Accordion.d.ts.map +1 -1
- package/dist/primitives/Accordion.js +82 -22
- package/dist/primitives/AppBar.d.ts +13 -0
- package/dist/primitives/AppBar.d.ts.map +1 -0
- package/dist/primitives/AppBar.js +27 -0
- package/dist/primitives/Autocomplete.d.ts +28 -0
- package/dist/primitives/Autocomplete.d.ts.map +1 -0
- package/dist/primitives/Autocomplete.js +118 -0
- package/dist/primitives/Avatar.d.ts +13 -1
- package/dist/primitives/Avatar.d.ts.map +1 -1
- package/dist/primitives/Avatar.js +17 -2
- package/dist/primitives/Backdrop.d.ts +17 -0
- package/dist/primitives/Backdrop.d.ts.map +1 -0
- package/dist/primitives/Backdrop.js +17 -0
- package/dist/primitives/BottomNavigation.d.ts +23 -0
- package/dist/primitives/BottomNavigation.d.ts.map +1 -0
- package/dist/primitives/BottomNavigation.js +30 -0
- package/dist/primitives/Button.d.ts +4 -4
- package/dist/primitives/Button.d.ts.map +1 -1
- package/dist/primitives/Button.js +2 -0
- package/dist/primitives/ButtonBase.d.ts +14 -0
- package/dist/primitives/ButtonBase.d.ts.map +1 -0
- package/dist/primitives/ButtonBase.js +13 -0
- package/dist/primitives/ButtonGroup.d.ts +25 -0
- package/dist/primitives/ButtonGroup.d.ts.map +1 -0
- package/dist/primitives/ButtonGroup.js +44 -0
- package/dist/primitives/CircularProgress.d.ts +16 -0
- package/dist/primitives/CircularProgress.d.ts.map +1 -0
- package/dist/primitives/CircularProgress.js +17 -0
- package/dist/primitives/ClickAwayListener.d.ts +16 -0
- package/dist/primitives/ClickAwayListener.d.ts.map +1 -0
- package/dist/primitives/ClickAwayListener.js +33 -0
- package/dist/primitives/Collapse.d.ts +19 -0
- package/dist/primitives/Collapse.d.ts.map +1 -0
- package/dist/primitives/Collapse.js +14 -0
- package/dist/primitives/Container.d.ts +13 -0
- package/dist/primitives/Container.d.ts.map +1 -0
- package/dist/primitives/Container.js +26 -0
- package/dist/primitives/Fab.d.ts +22 -0
- package/dist/primitives/Fab.d.ts.map +1 -0
- package/dist/primitives/Fab.js +42 -0
- package/dist/primitives/FormControl.d.ts +27 -0
- package/dist/primitives/FormControl.d.ts.map +1 -0
- package/dist/primitives/FormControl.js +24 -0
- package/dist/primitives/FormControlLabel.d.ts +18 -0
- package/dist/primitives/FormControlLabel.d.ts.map +1 -0
- package/dist/primitives/FormControlLabel.js +19 -0
- package/dist/primitives/Grid.d.ts +32 -0
- package/dist/primitives/Grid.d.ts.map +1 -0
- package/dist/primitives/Grid.js +50 -0
- package/dist/primitives/IconButton.d.ts +22 -0
- package/dist/primitives/IconButton.d.ts.map +1 -0
- package/dist/primitives/IconButton.js +39 -0
- package/dist/primitives/ImageList.d.ts +31 -0
- package/dist/primitives/ImageList.d.ts.map +1 -0
- package/dist/primitives/ImageList.js +33 -0
- package/dist/primitives/InputAdornment.d.ts +28 -0
- package/dist/primitives/InputAdornment.d.ts.map +1 -0
- package/dist/primitives/InputAdornment.js +13 -0
- package/dist/primitives/Link.d.ts +17 -0
- package/dist/primitives/Link.d.ts.map +1 -0
- package/dist/primitives/Link.js +29 -0
- package/dist/primitives/List.d.ts +41 -0
- package/dist/primitives/List.d.ts.map +1 -0
- package/dist/primitives/List.js +22 -0
- package/dist/primitives/Masonry.d.ts +14 -0
- package/dist/primitives/Masonry.d.ts.map +1 -0
- package/dist/primitives/Masonry.js +15 -0
- package/dist/primitives/Menu.d.ts +28 -0
- package/dist/primitives/Menu.d.ts.map +1 -0
- package/dist/primitives/Menu.js +54 -0
- package/dist/primitives/MobileStepper.d.ts +15 -0
- package/dist/primitives/MobileStepper.d.ts.map +1 -0
- package/dist/primitives/MobileStepper.js +8 -0
- package/dist/primitives/Modal.d.ts +18 -0
- package/dist/primitives/Modal.d.ts.map +1 -0
- package/dist/primitives/Modal.js +29 -0
- package/dist/primitives/NoSsr.d.ts +15 -0
- package/dist/primitives/NoSsr.d.ts.map +1 -0
- package/dist/primitives/NoSsr.js +19 -0
- package/dist/primitives/Paper.d.ts +22 -0
- package/dist/primitives/Paper.d.ts.map +1 -0
- package/dist/primitives/Paper.js +33 -0
- package/dist/primitives/Progress.d.ts +6 -2
- package/dist/primitives/Progress.d.ts.map +1 -1
- package/dist/primitives/Progress.js +8 -3
- package/dist/primitives/Snackbar.d.ts +23 -0
- package/dist/primitives/Snackbar.d.ts.map +1 -0
- package/dist/primitives/Snackbar.js +33 -0
- package/dist/primitives/SpeedDial.d.ts +26 -0
- package/dist/primitives/SpeedDial.d.ts.map +1 -0
- package/dist/primitives/SpeedDial.js +26 -0
- package/dist/primitives/Stack.d.ts +15 -0
- package/dist/primitives/Stack.d.ts.map +1 -0
- package/dist/primitives/Stack.js +45 -0
- package/dist/primitives/StarRating.d.ts +19 -1
- package/dist/primitives/StarRating.d.ts.map +1 -1
- package/dist/primitives/StarRating.js +36 -6
- package/dist/primitives/Stepper.d.ts +36 -0
- package/dist/primitives/Stepper.d.ts.map +1 -0
- package/dist/primitives/Stepper.js +53 -0
- package/dist/primitives/TablePagination.d.ts +16 -0
- package/dist/primitives/TablePagination.d.ts.map +1 -0
- package/dist/primitives/TablePagination.js +12 -0
- package/dist/primitives/TextField.d.ts +24 -0
- package/dist/primitives/TextField.d.ts.map +1 -0
- package/dist/primitives/TextField.js +19 -0
- package/dist/primitives/TextareaAutosize.d.ts +11 -0
- package/dist/primitives/TextareaAutosize.d.ts.map +1 -0
- package/dist/primitives/TextareaAutosize.js +26 -0
- package/dist/primitives/ToggleButton.d.ts +28 -0
- package/dist/primitives/ToggleButton.d.ts.map +1 -0
- package/dist/primitives/ToggleButton.js +56 -0
- package/dist/primitives/TransferList.d.ts +21 -0
- package/dist/primitives/TransferList.d.ts.map +1 -0
- package/dist/primitives/TransferList.js +40 -0
- package/dist/primitives/Typography.d.ts +23 -0
- package/dist/primitives/Typography.d.ts.map +1 -0
- package/dist/primitives/Typography.js +69 -0
- package/dist/primitives/transitions.d.ts +29 -0
- package/dist/primitives/transitions.d.ts.map +1 -0
- package/dist/primitives/transitions.js +52 -0
- package/package.json +30 -5
- package/skills/spatika-ui/SKILL.md +96 -0
- package/src/composites/AccentRule.tsx +12 -0
- package/src/composites/AvailabilityBadge.tsx +32 -0
- package/src/composites/Card.tsx +51 -1
- package/src/composites/CareerCard.tsx +111 -0
- package/src/composites/ChartDataGrid.test.tsx +57 -0
- package/src/composites/ChartDataGrid.tsx +196 -0
- package/src/composites/ContactLink.tsx +66 -0
- package/src/composites/CoverHero.test.tsx +38 -0
- package/src/composites/CoverHero.tsx +29 -22
- package/src/composites/DisplayHeading.tsx +27 -0
- package/src/composites/EntityMediaCard.test.tsx +58 -0
- package/src/composites/EntityMediaCard.tsx +31 -13
- package/src/composites/EventCalendar.tsx +928 -0
- package/src/composites/EventEditor.tsx +268 -0
- package/src/composites/EventTimeline.tsx +357 -0
- package/src/composites/FloatChip.tsx +63 -0
- package/src/composites/GradientText.tsx +31 -0
- package/src/composites/IconTile.tsx +33 -0
- package/src/composites/MapChart.test.tsx +39 -0
- package/src/composites/MapChart.tsx +130 -0
- package/src/composites/MetaChip.tsx +24 -0
- package/src/composites/PresenceDot.tsx +42 -0
- package/src/composites/ProfileHero.test.tsx +33 -0
- package/src/composites/ProfileHero.tsx +96 -79
- package/src/composites/ProjectCard.tsx +65 -0
- package/src/composites/ScatterWebGL.test.tsx +25 -0
- package/src/composites/ScatterWebGL.tsx +79 -0
- package/src/composites/SectionHeading.tsx +61 -0
- package/src/composites/SiteFooter.test.tsx +37 -0
- package/src/composites/SiteFooter.tsx +79 -0
- package/src/composites/SiteNav.test.tsx +46 -0
- package/src/composites/SiteNav.tsx +150 -0
- package/src/composites/StatCard.test.tsx +30 -0
- package/src/composites/StatCard.tsx +36 -1
- package/src/composites/Tag.tsx +50 -0
- package/src/composites/Wordmark.test.tsx +23 -0
- package/src/composites/Wordmark.tsx +46 -0
- package/src/composites/cartesian-charts.test.tsx +52 -0
- package/src/composites/cartesian-charts.tsx +1287 -0
- package/src/composites/chart-composition.test.tsx +53 -0
- package/src/composites/chart-composition.tsx +898 -0
- package/src/composites/chart-ui.tsx +328 -0
- package/src/composites/charts-3d.test.tsx +25 -0
- package/src/composites/charts-3d.tsx +318 -0
- package/src/composites/charts.test.tsx +254 -0
- package/src/composites/chrome.test.tsx +29 -0
- package/src/composites/flow-charts.test.tsx +79 -0
- package/src/composites/flow-charts.tsx +607 -0
- package/src/composites/marketing.test.tsx +192 -0
- package/src/composites/radial-charts.test.tsx +74 -0
- package/src/composites/radial-charts.tsx +617 -0
- package/src/composites/scheduler-ui.tsx +304 -0
- package/src/composites/scheduler.test.tsx +194 -0
- package/src/index.test.ts +22 -0
- package/src/index.ts +300 -5
- package/src/lib/chart-export.test.ts +20 -0
- package/src/lib/chart-export.ts +99 -0
- package/src/lib/charts-platform.test.ts +93 -0
- package/src/lib/charts.test.ts +132 -0
- package/src/lib/charts.ts +1021 -0
- package/src/lib/geo.test.ts +42 -0
- package/src/lib/geo.ts +138 -0
- package/src/lib/iso.test.ts +22 -0
- package/src/lib/iso.ts +102 -0
- package/src/lib/rrule.test.ts +53 -0
- package/src/lib/rrule.ts +281 -0
- package/src/lib/scheduler.test.ts +186 -0
- package/src/lib/scheduler.ts +688 -0
- package/src/lib/tokens.test.ts +2 -2
- package/src/lib/webgl-scatter.test.ts +36 -0
- package/src/lib/webgl-scatter.ts +230 -0
- package/src/primitives/Accordion.tsx +218 -63
- package/src/primitives/AppBar.tsx +39 -0
- package/src/primitives/Autocomplete.tsx +236 -0
- package/src/primitives/Avatar.tsx +52 -1
- package/src/primitives/Backdrop.tsx +48 -0
- package/src/primitives/BottomNavigation.tsx +102 -0
- package/src/primitives/Button.tsx +3 -0
- package/src/primitives/ButtonBase.tsx +34 -0
- package/src/primitives/ButtonGroup.tsx +90 -0
- package/src/primitives/CircularProgress.tsx +104 -0
- package/src/primitives/ClickAwayListener.tsx +46 -0
- package/src/primitives/Collapse.tsx +39 -0
- package/src/primitives/Container.tsx +40 -0
- package/src/primitives/Fab.tsx +62 -0
- package/src/primitives/FormControl.tsx +90 -0
- package/src/primitives/FormControlLabel.tsx +51 -0
- package/src/primitives/Grid.tsx +101 -0
- package/src/primitives/IconButton.tsx +59 -0
- package/src/primitives/ImageList.test.tsx +48 -0
- package/src/primitives/ImageList.tsx +112 -0
- package/src/primitives/InputAdornment.tsx +86 -0
- package/src/primitives/Link.tsx +48 -0
- package/src/primitives/List.tsx +121 -0
- package/src/primitives/Masonry.tsx +32 -0
- package/src/primitives/Menu.tsx +112 -0
- package/src/primitives/MobileStepper.tsx +64 -0
- package/src/primitives/Modal.tsx +70 -0
- package/src/primitives/NoSsr.tsx +28 -0
- package/src/primitives/Paper.tsx +55 -0
- package/src/primitives/Progress.tsx +25 -7
- package/src/primitives/Snackbar.tsx +83 -0
- package/src/primitives/SpeedDial.tsx +110 -0
- package/src/primitives/Stack.tsx +88 -0
- package/src/primitives/StarRating.tsx +87 -28
- package/src/primitives/Stepper.tsx +184 -0
- package/src/primitives/TablePagination.tsx +58 -0
- package/src/primitives/TextField.tsx +105 -0
- package/src/primitives/TextareaAutosize.tsx +49 -0
- package/src/primitives/ToggleButton.tsx +148 -0
- package/src/primitives/TransferList.tsx +110 -0
- package/src/primitives/Typography.tsx +90 -0
- package/src/primitives/mui-more.test.tsx +76 -0
- package/src/primitives/mui.test.tsx +134 -0
- package/src/primitives/transitions.tsx +120 -0
|
@@ -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,61 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { AccentRule } from "./AccentRule";
|
|
4
|
+
import { GradientText } from "./GradientText";
|
|
5
|
+
|
|
6
|
+
export type SectionHeadingProps = {
|
|
7
|
+
eyebrow?: ReactNode;
|
|
8
|
+
title: ReactNode;
|
|
9
|
+
subtitle?: ReactNode;
|
|
10
|
+
align?: "left" | "center";
|
|
11
|
+
className?: string;
|
|
12
|
+
/** Hide the accent rule under the copy. */
|
|
13
|
+
showRule?: boolean;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Marketing / page-section title: optional eyebrow, heading, subtitle, and accent rule.
|
|
18
|
+
* Renders a `div` (not `<header>`) so it never competes with the site navbar.
|
|
19
|
+
*/
|
|
20
|
+
export function SectionHeading({
|
|
21
|
+
eyebrow,
|
|
22
|
+
title,
|
|
23
|
+
subtitle,
|
|
24
|
+
align = "left",
|
|
25
|
+
className,
|
|
26
|
+
showRule = true,
|
|
27
|
+
}: SectionHeadingProps) {
|
|
28
|
+
const centered = align === "center";
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
data-slot="section-heading"
|
|
33
|
+
className={cn("mb-8 sm:mb-10", centered && "text-center", className)}
|
|
34
|
+
>
|
|
35
|
+
{eyebrow ? (
|
|
36
|
+
<p
|
|
37
|
+
className={cn(
|
|
38
|
+
"mb-2 text-[11px] font-bold uppercase tracking-[0.14em]",
|
|
39
|
+
centered && "text-center",
|
|
40
|
+
)}
|
|
41
|
+
>
|
|
42
|
+
<GradientText>{eyebrow}</GradientText>
|
|
43
|
+
</p>
|
|
44
|
+
) : null}
|
|
45
|
+
<h2 className="text-[clamp(1.6rem,3.6vw,2.1rem)] font-[800] leading-[1.12] tracking-tight text-foreground">
|
|
46
|
+
{title}
|
|
47
|
+
</h2>
|
|
48
|
+
{subtitle ? (
|
|
49
|
+
<p
|
|
50
|
+
className={cn(
|
|
51
|
+
"mt-2 text-[14px] leading-relaxed text-muted-foreground",
|
|
52
|
+
centered ? "mx-auto max-w-xl" : "max-w-lg",
|
|
53
|
+
)}
|
|
54
|
+
>
|
|
55
|
+
{subtitle}
|
|
56
|
+
</p>
|
|
57
|
+
) : null}
|
|
58
|
+
{showRule ? <AccentRule className={cn("mt-4", centered && "mx-auto")} /> : null}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import type { SiteNavLink } from "./SiteNav";
|
|
4
|
+
|
|
5
|
+
export type SiteFooterProps = {
|
|
6
|
+
brand?: ReactNode;
|
|
7
|
+
copyright?: ReactNode;
|
|
8
|
+
links?: SiteNavLink[];
|
|
9
|
+
/** Render each link (e.g. Next.js `<Link>`). Defaults to `<a>`. */
|
|
10
|
+
renderLink?: (link: SiteNavLink, className: string) => ReactNode;
|
|
11
|
+
/** Trailing meta row (badges, “powered by”, etc.). */
|
|
12
|
+
meta?: ReactNode;
|
|
13
|
+
className?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const linkClass =
|
|
17
|
+
"font-semibold text-muted-foreground transition-colors hover:text-primary";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Marketing-site footer: brand, copyright, links, optional meta chips.
|
|
21
|
+
* Frosted surface bar with a primary hairline.
|
|
22
|
+
*/
|
|
23
|
+
export function SiteFooter({
|
|
24
|
+
brand,
|
|
25
|
+
copyright,
|
|
26
|
+
links = [],
|
|
27
|
+
renderLink,
|
|
28
|
+
meta,
|
|
29
|
+
className,
|
|
30
|
+
}: SiteFooterProps) {
|
|
31
|
+
const renderItem = (link: SiteNavLink) => {
|
|
32
|
+
if (renderLink) return renderLink(link, linkClass);
|
|
33
|
+
return (
|
|
34
|
+
<a href={link.href} className={linkClass}>
|
|
35
|
+
{link.label}
|
|
36
|
+
</a>
|
|
37
|
+
);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<footer
|
|
42
|
+
data-slot="site-footer"
|
|
43
|
+
className={cn(
|
|
44
|
+
"relative border-t border-border/80 bg-[color-mix(in_srgb,var(--background)_85%,transparent)] backdrop-blur-md",
|
|
45
|
+
className,
|
|
46
|
+
)}
|
|
47
|
+
>
|
|
48
|
+
<div
|
|
49
|
+
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-primary/35 to-transparent"
|
|
50
|
+
aria-hidden
|
|
51
|
+
/>
|
|
52
|
+
<div className="mx-auto max-w-6xl px-6 py-12 lg:px-8">
|
|
53
|
+
<div className="flex flex-col items-center justify-between gap-6 md:flex-row">
|
|
54
|
+
<div className="flex items-center gap-3">
|
|
55
|
+
{brand}
|
|
56
|
+
{brand && copyright ? (
|
|
57
|
+
<span className="text-border" aria-hidden>
|
|
58
|
+
|
|
|
59
|
+
</span>
|
|
60
|
+
) : null}
|
|
61
|
+
{copyright ? (
|
|
62
|
+
<span className="text-[13px] text-muted-foreground">{copyright}</span>
|
|
63
|
+
) : null}
|
|
64
|
+
</div>
|
|
65
|
+
|
|
66
|
+
{links.length > 0 ? (
|
|
67
|
+
<nav className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 text-[12px] text-muted-foreground">
|
|
68
|
+
{links.map((link) => (
|
|
69
|
+
<span key={`${link.href}-${link.label}`}>{renderItem(link)}</span>
|
|
70
|
+
))}
|
|
71
|
+
</nav>
|
|
72
|
+
) : null}
|
|
73
|
+
|
|
74
|
+
{meta}
|
|
75
|
+
</div>
|
|
76
|
+
</div>
|
|
77
|
+
</footer>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { useEffect, useId, useState, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
export type SiteNavLink = {
|
|
5
|
+
href: string;
|
|
6
|
+
label: string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type SiteNavProps = {
|
|
10
|
+
brand: ReactNode;
|
|
11
|
+
links: SiteNavLink[];
|
|
12
|
+
/** Render each link (e.g. Next.js `<Link>`). Defaults to `<a>`. */
|
|
13
|
+
renderLink?: (link: SiteNavLink, className: string) => ReactNode;
|
|
14
|
+
className?: string;
|
|
15
|
+
/** Extra classes for the inner max-width row. */
|
|
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;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const desktopLinkClass =
|
|
25
|
+
"relative rounded-full px-3.5 py-2 text-[13px] font-bold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-foreground";
|
|
26
|
+
|
|
27
|
+
const mobileLinkClass =
|
|
28
|
+
"flex items-center rounded-2xl px-4 py-4 text-xl font-bold text-muted-foreground transition-colors hover:bg-primary/5 hover:text-primary";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Marketing-site navbar: fixed bar, brand, desktop links, and a 3-line mobile menu.
|
|
32
|
+
* Transparent at rest, frosted surface after scroll.
|
|
33
|
+
*/
|
|
34
|
+
export function SiteNav({
|
|
35
|
+
brand,
|
|
36
|
+
links,
|
|
37
|
+
renderLink,
|
|
38
|
+
className,
|
|
39
|
+
innerClassName,
|
|
40
|
+
contained = false,
|
|
41
|
+
}: SiteNavProps) {
|
|
42
|
+
const [scrolled, setScrolled] = useState(contained);
|
|
43
|
+
const [open, setOpen] = useState(false);
|
|
44
|
+
const menuId = useId();
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (contained) return;
|
|
48
|
+
const onScroll = () => setScrolled(window.scrollY > 20);
|
|
49
|
+
onScroll();
|
|
50
|
+
window.addEventListener("scroll", onScroll, { passive: true });
|
|
51
|
+
return () => window.removeEventListener("scroll", onScroll);
|
|
52
|
+
}, [contained]);
|
|
53
|
+
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!open) return;
|
|
56
|
+
const onKey = (event: KeyboardEvent) => {
|
|
57
|
+
if (event.key === "Escape") setOpen(false);
|
|
58
|
+
};
|
|
59
|
+
window.addEventListener("keydown", onKey);
|
|
60
|
+
return () => window.removeEventListener("keydown", onKey);
|
|
61
|
+
}, [open]);
|
|
62
|
+
|
|
63
|
+
const renderItem = (link: SiteNavLink, itemClass: string) => {
|
|
64
|
+
const close = () => setOpen(false);
|
|
65
|
+
if (renderLink) {
|
|
66
|
+
return (
|
|
67
|
+
<span className="contents" onClick={close}>
|
|
68
|
+
{renderLink(link, itemClass)}
|
|
69
|
+
</span>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
return (
|
|
73
|
+
<a href={link.href} className={itemClass} onClick={close}>
|
|
74
|
+
{link.label}
|
|
75
|
+
</a>
|
|
76
|
+
);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
return (
|
|
80
|
+
<nav
|
|
81
|
+
data-slot="site-nav"
|
|
82
|
+
data-scrolled={scrolled ? "true" : "false"}
|
|
83
|
+
className={cn(
|
|
84
|
+
"inset-x-0 top-0 transition-all duration-500",
|
|
85
|
+
contained
|
|
86
|
+
? "absolute z-10"
|
|
87
|
+
: "spk-site-nav fixed z-50",
|
|
88
|
+
scrolled
|
|
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"
|
|
90
|
+
: "bg-transparent",
|
|
91
|
+
className,
|
|
92
|
+
)}
|
|
93
|
+
>
|
|
94
|
+
<div className={cn("mx-auto max-w-6xl px-4 sm:px-6 lg:px-8", innerClassName)}>
|
|
95
|
+
<div className="flex h-14 items-center justify-between sm:h-16">
|
|
96
|
+
<div className="flex min-w-0 items-center">{brand}</div>
|
|
97
|
+
|
|
98
|
+
<div className="hidden items-center gap-0.5 md:flex">
|
|
99
|
+
{links.map((link) => (
|
|
100
|
+
<span key={`${link.href}-${link.label}`}>{renderItem(link, desktopLinkClass)}</span>
|
|
101
|
+
))}
|
|
102
|
+
</div>
|
|
103
|
+
|
|
104
|
+
<button
|
|
105
|
+
type="button"
|
|
106
|
+
className="spk-touch-target -mr-2 p-2 md:hidden"
|
|
107
|
+
aria-label="Toggle menu"
|
|
108
|
+
aria-expanded={open}
|
|
109
|
+
aria-controls={menuId}
|
|
110
|
+
onClick={() => setOpen((value) => !value)}
|
|
111
|
+
>
|
|
112
|
+
<span className="flex h-4 w-6 flex-col justify-between" aria-hidden>
|
|
113
|
+
<span
|
|
114
|
+
className={cn(
|
|
115
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-transform duration-200",
|
|
116
|
+
open && "translate-y-[7px] rotate-45",
|
|
117
|
+
)}
|
|
118
|
+
/>
|
|
119
|
+
<span
|
|
120
|
+
className={cn(
|
|
121
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-opacity duration-200",
|
|
122
|
+
open && "opacity-0",
|
|
123
|
+
)}
|
|
124
|
+
/>
|
|
125
|
+
<span
|
|
126
|
+
className={cn(
|
|
127
|
+
"block h-[2px] w-6 rounded-full bg-foreground transition-transform duration-200",
|
|
128
|
+
open && "-translate-y-[7px] -rotate-45",
|
|
129
|
+
)}
|
|
130
|
+
/>
|
|
131
|
+
</span>
|
|
132
|
+
</button>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
|
|
136
|
+
{open ? (
|
|
137
|
+
<div
|
|
138
|
+
id={menuId}
|
|
139
|
+
className="border-b border-border bg-[color-mix(in_srgb,var(--background)_95%,transparent)] shadow-2xl backdrop-blur-2xl md:hidden"
|
|
140
|
+
>
|
|
141
|
+
<div className="flex flex-col gap-2 px-6 py-8">
|
|
142
|
+
{links.map((link) => (
|
|
143
|
+
<div key={`${link.href}-${link.label}`}>{renderItem(link, mobileLinkClass)}</div>
|
|
144
|
+
))}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
) : null}
|
|
148
|
+
</nav>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
@@ -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
|
+
});
|
|
@@ -6,11 +6,45 @@ export type StatCardProps = {
|
|
|
6
6
|
value: string | number;
|
|
7
7
|
hint?: string;
|
|
8
8
|
icon?: ReactNode;
|
|
9
|
+
chart?: ReactNode;
|
|
9
10
|
className?: string;
|
|
11
|
+
/**
|
|
12
|
+
* `app` — elevated app-card tile (label then value).
|
|
13
|
+
* `metric` — centered value-first tile for marketing stats.
|
|
14
|
+
*/
|
|
15
|
+
variant?: "app" | "metric";
|
|
10
16
|
};
|
|
11
17
|
|
|
12
18
|
/** Compact metric tile using elevated app-card surface. */
|
|
13
|
-
export function StatCard({
|
|
19
|
+
export function StatCard({
|
|
20
|
+
label,
|
|
21
|
+
value,
|
|
22
|
+
hint,
|
|
23
|
+
icon,
|
|
24
|
+
chart,
|
|
25
|
+
className,
|
|
26
|
+
variant = "app",
|
|
27
|
+
}: StatCardProps) {
|
|
28
|
+
if (variant === "metric") {
|
|
29
|
+
return (
|
|
30
|
+
<div
|
|
31
|
+
data-slot="stat-card"
|
|
32
|
+
data-variant="metric"
|
|
33
|
+
className={cn(
|
|
34
|
+
"flex flex-col items-center justify-center gap-1 rounded-[1.35rem] border border-border/80 bg-background/90 px-2 py-6 text-center shadow-[0_4px_24px_rgba(15,13,18,0.04)] backdrop-blur-sm",
|
|
35
|
+
className,
|
|
36
|
+
)}
|
|
37
|
+
>
|
|
38
|
+
<p className="text-xl font-black text-primary sm:text-2xl">{value}</p>
|
|
39
|
+
<p className="text-[10px] font-bold leading-none text-muted-foreground sm:text-[11px]">
|
|
40
|
+
{label}
|
|
41
|
+
</p>
|
|
42
|
+
{hint ? <p className="text-[10px] text-muted-foreground">{hint}</p> : null}
|
|
43
|
+
{chart ? <div className="mt-3 w-full min-w-0">{chart}</div> : null}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
14
48
|
return (
|
|
15
49
|
<div data-slot="stat-card" className={cn("app-stat-card p-4 sm:p-5", className)}>
|
|
16
50
|
<div className="flex items-start justify-between gap-3">
|
|
@@ -21,6 +55,7 @@ export function StatCard({ label, value, hint, icon, className }: StatCardProps)
|
|
|
21
55
|
</div>
|
|
22
56
|
<p className="mt-2 text-2xl font-black tracking-tight text-foreground">{value}</p>
|
|
23
57
|
{hint ? <p className="mt-1 text-xs font-medium text-muted-foreground">{hint}</p> : null}
|
|
58
|
+
{chart ? <div className="mt-3 min-w-0">{chart}</div> : null}
|
|
24
59
|
</div>
|
|
25
60
|
);
|
|
26
61
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import { cva, type VariantProps } from "class-variance-authority";
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
|
|
5
|
+
const tagVariants = cva(
|
|
6
|
+
"inline-flex items-center gap-1.5 whitespace-nowrap border font-bold",
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default:
|
|
11
|
+
"border-border/80 bg-[color-mix(in_srgb,var(--foreground)_4.5%,var(--background))] text-foreground",
|
|
12
|
+
primary: "border-primary/15 bg-primary/10 text-primary",
|
|
13
|
+
outline: "border-border/80 bg-transparent text-foreground",
|
|
14
|
+
muted: "border-transparent bg-secondary text-secondary-foreground",
|
|
15
|
+
red: "border-red-100 bg-red-50 text-red-700",
|
|
16
|
+
green: "border-green-200 bg-green-50 text-green-700",
|
|
17
|
+
orange: "border-orange-200 bg-orange-50 text-orange-700",
|
|
18
|
+
blue: "border-blue-200 bg-blue-50 text-blue-700",
|
|
19
|
+
purple: "border-purple-200 bg-purple-50 text-purple-700",
|
|
20
|
+
cyan: "border-cyan-200 bg-cyan-50 text-cyan-700",
|
|
21
|
+
},
|
|
22
|
+
size: {
|
|
23
|
+
sm: "rounded-md px-2 py-0.5 text-[11px]",
|
|
24
|
+
md: "rounded-lg px-2.5 py-1 text-[12px]",
|
|
25
|
+
lg: "rounded-xl px-3 py-1.5 text-[13px]",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
defaultVariants: {
|
|
29
|
+
variant: "default",
|
|
30
|
+
size: "sm",
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
export type TagProps = React.ComponentProps<"span"> & VariantProps<typeof tagVariants>;
|
|
36
|
+
|
|
37
|
+
/** Static label pill for skills, tech stacks, and metadata. */
|
|
38
|
+
const Tag = React.forwardRef<HTMLSpanElement, TagProps>(
|
|
39
|
+
({ className, variant, size, ...props }, ref) => (
|
|
40
|
+
<span
|
|
41
|
+
ref={ref}
|
|
42
|
+
data-slot="tag"
|
|
43
|
+
className={cn(tagVariants({ variant, size }), className)}
|
|
44
|
+
{...props}
|
|
45
|
+
/>
|
|
46
|
+
),
|
|
47
|
+
);
|
|
48
|
+
Tag.displayName = "Tag";
|
|
49
|
+
|
|
50
|
+
export { Tag, tagVariants };
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { describe, expect, it } from "vitest";
|
|
3
|
+
import { Wordmark } from "./Wordmark";
|
|
4
|
+
|
|
5
|
+
describe("Wordmark", () => {
|
|
6
|
+
it("renders a gradient accent after the name", () => {
|
|
7
|
+
render(<Wordmark name="Acme" accent=".io" />);
|
|
8
|
+
expect(screen.getByText("Acme")).toBeInTheDocument();
|
|
9
|
+
expect(screen.getByText(".io")).toBeInTheDocument();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("renders as a heading with a mark and rest, and omits unused slots", () => {
|
|
13
|
+
const { rerender } = render(
|
|
14
|
+
<Wordmark name="Acme" mark="." rest="io" as="h1" />,
|
|
15
|
+
);
|
|
16
|
+
expect(screen.getByRole("heading", { name: /Acme/ })).toBeInTheDocument();
|
|
17
|
+
expect(screen.getByText(".")).toBeInTheDocument();
|
|
18
|
+
expect(screen.getByText("io")).toBeInTheDocument();
|
|
19
|
+
|
|
20
|
+
rerender(<Wordmark name="Acme" />);
|
|
21
|
+
expect(screen.queryByText(".io")).not.toBeInTheDocument();
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
import { GradientText } from "./GradientText";
|
|
4
|
+
|
|
5
|
+
export type WordmarkProps = {
|
|
6
|
+
/** Primary name, rendered in foreground. */
|
|
7
|
+
name: ReactNode;
|
|
8
|
+
/** Trailing bit (`.io`, last name) in the marketing gradient. */
|
|
9
|
+
accent?: ReactNode;
|
|
10
|
+
/** Extra punctuation in primary (the period in `Name.`). */
|
|
11
|
+
mark?: ReactNode;
|
|
12
|
+
/** Unstyled trailing copy after the mark (`io` in `Acme.io`). */
|
|
13
|
+
rest?: ReactNode;
|
|
14
|
+
size?: "sm" | "md" | "lg" | "display";
|
|
15
|
+
as?: "span" | "h1";
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sizeClass = {
|
|
20
|
+
sm: "text-[14px] font-semibold tracking-tight",
|
|
21
|
+
md: "text-xl font-[900] tracking-tight",
|
|
22
|
+
lg: "text-[clamp(1.6rem,3.6vw,2.1rem)] font-[800] tracking-tight",
|
|
23
|
+
display: "text-[clamp(2.5rem,8vw,5.5rem)] font-[900] leading-[0.95] tracking-tight",
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Marketing wordmark: solid name, optional gradient accent, optional primary mark.
|
|
28
|
+
*/
|
|
29
|
+
export function Wordmark({
|
|
30
|
+
name,
|
|
31
|
+
accent,
|
|
32
|
+
mark,
|
|
33
|
+
rest,
|
|
34
|
+
size = "md",
|
|
35
|
+
as: Tag = "span",
|
|
36
|
+
className,
|
|
37
|
+
}: WordmarkProps) {
|
|
38
|
+
return (
|
|
39
|
+
<Tag data-slot="wordmark" className={cn(sizeClass[size], className)}>
|
|
40
|
+
<span className="text-foreground">{name}</span>
|
|
41
|
+
{accent ? <GradientText>{accent}</GradientText> : null}
|
|
42
|
+
{mark ? <span className="text-primary">{mark}</span> : null}
|
|
43
|
+
{rest ? <span className="text-foreground">{rest}</span> : null}
|
|
44
|
+
</Tag>
|
|
45
|
+
);
|
|
46
|
+
}
|