@blurrow/ui 0.1.0 → 0.2.1

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 (272) hide show
  1. package/README.md +60 -20
  2. package/dist/Accordion.d.ts +17 -0
  3. package/dist/Accordion.js +138 -0
  4. package/dist/Accordion.js.map +1 -0
  5. package/dist/Alert.d.ts +13 -0
  6. package/dist/Alert.js +69 -0
  7. package/dist/Alert.js.map +1 -0
  8. package/dist/AuthorCard.d.ts +12 -0
  9. package/dist/AuthorCard.js +57 -0
  10. package/dist/AuthorCard.js.map +1 -0
  11. package/dist/Avatar.d.ts +21 -0
  12. package/dist/Avatar.js +96 -0
  13. package/dist/Avatar.js.map +1 -0
  14. package/dist/Badge.d.ts +12 -0
  15. package/dist/Badge.js +46 -0
  16. package/dist/Badge.js.map +1 -0
  17. package/dist/Banner.d.ts +15 -0
  18. package/dist/Banner.js +68 -0
  19. package/dist/Banner.js.map +1 -0
  20. package/dist/Breadcrumb.d.ts +13 -0
  21. package/dist/Breadcrumb.js +45 -0
  22. package/dist/Breadcrumb.js.map +1 -0
  23. package/dist/Button.d.ts +19 -0
  24. package/dist/Button.js +282 -0
  25. package/dist/Button.js.map +1 -0
  26. package/dist/ButtonGroup.d.ts +19 -0
  27. package/dist/ButtonGroup.js +62 -0
  28. package/dist/ButtonGroup.js.map +1 -0
  29. package/dist/Card.d.ts +40 -0
  30. package/dist/Card.js +175 -0
  31. package/dist/Card.js.map +1 -0
  32. package/dist/CardShell.d.ts +18 -0
  33. package/dist/CardShell.js +58 -0
  34. package/dist/CardShell.js.map +1 -0
  35. package/dist/Carousel.d.ts +22 -0
  36. package/dist/Carousel.js +247 -0
  37. package/dist/Carousel.js.map +1 -0
  38. package/dist/Checkbox.d.ts +15 -0
  39. package/dist/Checkbox.js +84 -0
  40. package/dist/Checkbox.js.map +1 -0
  41. package/dist/CloseButton.d.ts +12 -0
  42. package/dist/CloseButton.js +63 -0
  43. package/dist/CloseButton.js.map +1 -0
  44. package/dist/CodeBlock.d.ts +12 -0
  45. package/dist/CodeBlock.js +221 -0
  46. package/dist/CodeBlock.js.map +1 -0
  47. package/dist/ColorPicker.d.ts +24 -0
  48. package/dist/ColorPicker.js +366 -0
  49. package/dist/ColorPicker.js.map +1 -0
  50. package/dist/Combobox.d.ts +34 -0
  51. package/dist/Combobox.js +255 -0
  52. package/dist/Combobox.js.map +1 -0
  53. package/dist/CommandPalette.d.ts +25 -0
  54. package/dist/CommandPalette.js +304 -0
  55. package/dist/CommandPalette.js.map +1 -0
  56. package/dist/ContextMenu.d.ts +16 -0
  57. package/dist/ContextMenu.js +209 -0
  58. package/dist/ContextMenu.js.map +1 -0
  59. package/dist/DataList.d.ts +15 -0
  60. package/dist/DataList.js +42 -0
  61. package/dist/DataList.js.map +1 -0
  62. package/dist/DatePicker.d.ts +65 -0
  63. package/dist/DatePicker.js +391 -0
  64. package/dist/DatePicker.js.map +1 -0
  65. package/dist/DateRangePicker.d.ts +34 -0
  66. package/dist/DateRangePicker.js +180 -0
  67. package/dist/DateRangePicker.js.map +1 -0
  68. package/dist/Drawer.d.ts +17 -0
  69. package/dist/Drawer.js +132 -0
  70. package/dist/Drawer.js.map +1 -0
  71. package/dist/DropdownMenu.d.ts +23 -0
  72. package/dist/DropdownMenu.js +185 -0
  73. package/dist/DropdownMenu.js.map +1 -0
  74. package/dist/EmptyState.d.ts +13 -0
  75. package/dist/EmptyState.js +55 -0
  76. package/dist/EmptyState.js.map +1 -0
  77. package/dist/FieldMessage.d.ts +26 -0
  78. package/dist/FieldMessage.js +95 -0
  79. package/dist/FieldMessage.js.map +1 -0
  80. package/dist/FileUpload.d.ts +32 -0
  81. package/dist/FileUpload.js +253 -0
  82. package/dist/FileUpload.js.map +1 -0
  83. package/dist/Floating.d.ts +32 -0
  84. package/dist/Floating.js +95 -0
  85. package/dist/Floating.js.map +1 -0
  86. package/dist/HoverCard.d.ts +18 -0
  87. package/dist/HoverCard.js +90 -0
  88. package/dist/HoverCard.js.map +1 -0
  89. package/dist/Input.d.ts +17 -0
  90. package/dist/Input.js +96 -0
  91. package/dist/Input.js.map +1 -0
  92. package/dist/Kbd.d.ts +9 -0
  93. package/dist/Kbd.js +24 -0
  94. package/dist/Kbd.js.map +1 -0
  95. package/dist/Modal.d.ts +15 -0
  96. package/dist/Modal.js +109 -0
  97. package/dist/Modal.js.map +1 -0
  98. package/dist/NumberInput.d.ts +23 -0
  99. package/dist/NumberInput.js +162 -0
  100. package/dist/NumberInput.js.map +1 -0
  101. package/dist/OtpInput.d.ts +26 -0
  102. package/dist/OtpInput.js +173 -0
  103. package/dist/OtpInput.js.map +1 -0
  104. package/dist/Pagination.d.ts +11 -0
  105. package/dist/Pagination.js +148 -0
  106. package/dist/Pagination.js.map +1 -0
  107. package/dist/Popover.d.ts +13 -0
  108. package/dist/Popover.js +98 -0
  109. package/dist/Popover.js.map +1 -0
  110. package/dist/Progress.d.ts +12 -0
  111. package/dist/Progress.js +61 -0
  112. package/dist/Progress.js.map +1 -0
  113. package/dist/QuoteCard.d.ts +13 -0
  114. package/dist/QuoteCard.js +92 -0
  115. package/dist/QuoteCard.js.map +1 -0
  116. package/dist/Radio.d.ts +19 -0
  117. package/dist/Radio.js +161 -0
  118. package/dist/Radio.js.map +1 -0
  119. package/dist/RadioCards.d.ts +27 -0
  120. package/dist/RadioCards.js +205 -0
  121. package/dist/RadioCards.js.map +1 -0
  122. package/dist/ReactionBar.d.ts +15 -0
  123. package/dist/ReactionBar.js +92 -0
  124. package/dist/ReactionBar.js.map +1 -0
  125. package/dist/ReadingProgress.d.ts +8 -0
  126. package/dist/ReadingProgress.js +51 -0
  127. package/dist/ReadingProgress.js.map +1 -0
  128. package/dist/ReadingTime.d.ts +9 -0
  129. package/dist/ReadingTime.js +39 -0
  130. package/dist/ReadingTime.js.map +1 -0
  131. package/dist/Select.d.ts +20 -0
  132. package/dist/Select.js +244 -0
  133. package/dist/Select.js.map +1 -0
  134. package/dist/Separator.d.ts +14 -0
  135. package/dist/Separator.js +33 -0
  136. package/dist/Separator.js.map +1 -0
  137. package/dist/ShareButtons.d.ts +10 -0
  138. package/dist/ShareButtons.js +107 -0
  139. package/dist/ShareButtons.js.map +1 -0
  140. package/dist/Sidebar.d.ts +30 -0
  141. package/dist/Sidebar.js +140 -0
  142. package/dist/Sidebar.js.map +1 -0
  143. package/dist/Skeleton.d.ts +15 -0
  144. package/dist/Skeleton.js +67 -0
  145. package/dist/Skeleton.js.map +1 -0
  146. package/dist/Slider.d.ts +27 -0
  147. package/dist/Slider.js +182 -0
  148. package/dist/Slider.js.map +1 -0
  149. package/dist/Spinner.d.ts +12 -0
  150. package/dist/Spinner.js +24 -0
  151. package/dist/Spinner.js.map +1 -0
  152. package/dist/SpotlightCard.d.ts +17 -0
  153. package/dist/SpotlightCard.js +87 -0
  154. package/dist/SpotlightCard.js.map +1 -0
  155. package/dist/Stat.d.ts +19 -0
  156. package/dist/Stat.js +71 -0
  157. package/dist/Stat.js.map +1 -0
  158. package/dist/StatCard.d.ts +24 -0
  159. package/dist/StatCard.js +212 -0
  160. package/dist/StatCard.js.map +1 -0
  161. package/dist/Steps.d.ts +14 -0
  162. package/dist/Steps.js +94 -0
  163. package/dist/Steps.js.map +1 -0
  164. package/dist/Table.d.ts +21 -0
  165. package/dist/Table.js +93 -0
  166. package/dist/Table.js.map +1 -0
  167. package/dist/TableOfContents.d.ts +17 -0
  168. package/dist/TableOfContents.js +92 -0
  169. package/dist/TableOfContents.js.map +1 -0
  170. package/dist/Tabs.d.ts +17 -0
  171. package/dist/Tabs.js +123 -0
  172. package/dist/Tabs.js.map +1 -0
  173. package/dist/TagInput.d.ts +16 -0
  174. package/dist/TagInput.js +169 -0
  175. package/dist/TagInput.js.map +1 -0
  176. package/dist/Textarea.d.ts +13 -0
  177. package/dist/Textarea.js +74 -0
  178. package/dist/Textarea.js.map +1 -0
  179. package/dist/TimePicker.d.ts +28 -0
  180. package/dist/TimePicker.js +264 -0
  181. package/dist/TimePicker.js.map +1 -0
  182. package/dist/Timeline.d.ts +19 -0
  183. package/dist/Timeline.js +76 -0
  184. package/dist/Timeline.js.map +1 -0
  185. package/dist/Toast.d.ts +18 -0
  186. package/dist/Toast.js +318 -0
  187. package/dist/Toast.js.map +1 -0
  188. package/dist/Toggle.d.ts +13 -0
  189. package/dist/Toggle.js +84 -0
  190. package/dist/Toggle.js.map +1 -0
  191. package/dist/Tooltip.d.ts +13 -0
  192. package/dist/Tooltip.js +138 -0
  193. package/dist/Tooltip.js.map +1 -0
  194. package/dist/cli.js +1644 -0
  195. package/dist/index.d.ts +68 -631
  196. package/dist/index.js +67 -5558
  197. package/dist/index.js.map +1 -1
  198. package/dist/registry/Accordion.tsx +140 -0
  199. package/dist/registry/Alert.tsx +95 -0
  200. package/dist/registry/AuthorCard.tsx +67 -0
  201. package/dist/registry/Avatar.tsx +116 -0
  202. package/dist/registry/Badge.tsx +55 -0
  203. package/dist/registry/Banner.tsx +86 -0
  204. package/dist/registry/Breadcrumb.tsx +60 -0
  205. package/dist/registry/Button.tsx +361 -0
  206. package/dist/registry/ButtonGroup.tsx +82 -0
  207. package/dist/registry/Card.tsx +241 -0
  208. package/dist/registry/CardShell.tsx +78 -0
  209. package/dist/registry/Carousel.tsx +296 -0
  210. package/dist/registry/Checkbox.tsx +104 -0
  211. package/dist/registry/CloseButton.tsx +68 -0
  212. package/dist/registry/CodeBlock.tsx +202 -0
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +356 -0
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +49 -0
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +166 -0
  221. package/dist/registry/DropdownMenu.tsx +219 -0
  222. package/dist/registry/EmptyState.tsx +55 -0
  223. package/dist/registry/FieldMessage.tsx +123 -0
  224. package/dist/registry/FileUpload.tsx +318 -0
  225. package/dist/registry/Floating.tsx +134 -0
  226. package/dist/registry/HoverCard.tsx +113 -0
  227. package/dist/registry/Input.tsx +108 -0
  228. package/dist/registry/Kbd.tsx +23 -0
  229. package/dist/registry/Modal.tsx +130 -0
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +160 -0
  233. package/dist/registry/Popover.tsx +116 -0
  234. package/dist/registry/Progress.tsx +66 -0
  235. package/dist/registry/QuoteCard.tsx +93 -0
  236. package/dist/registry/Radio.tsx +205 -0
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +101 -0
  239. package/dist/registry/ReadingProgress.tsx +51 -0
  240. package/dist/registry/ReadingTime.tsx +35 -0
  241. package/dist/registry/Select.tsx +265 -0
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +134 -0
  244. package/dist/registry/Sidebar.tsx +182 -0
  245. package/dist/registry/Skeleton.tsx +83 -0
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +106 -0
  249. package/dist/registry/Stat.tsx +82 -0
  250. package/dist/registry/StatCard.tsx +256 -0
  251. package/dist/registry/Steps.tsx +109 -0
  252. package/dist/registry/Table.tsx +123 -0
  253. package/dist/registry/TableOfContents.tsx +116 -0
  254. package/dist/registry/Tabs.tsx +136 -0
  255. package/dist/registry/TagInput.tsx +191 -0
  256. package/dist/registry/Textarea.tsx +84 -0
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +102 -0
  259. package/dist/registry/Toast.tsx +388 -0
  260. package/dist/registry/Toggle.tsx +96 -0
  261. package/dist/registry/Tooltip.tsx +160 -0
  262. package/dist/registry/styles.css +1207 -0
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/registry/useModalBehavior.ts +77 -0
  265. package/dist/styles.css +868 -62
  266. package/dist/useFloatingPanel.d.ts +24 -0
  267. package/dist/useFloatingPanel.js +89 -0
  268. package/dist/useFloatingPanel.js.map +1 -0
  269. package/dist/useModalBehavior.d.ts +15 -0
  270. package/dist/useModalBehavior.js +57 -0
  271. package/dist/useModalBehavior.js.map +1 -0
  272. package/package.json +23 -6
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+
3
+ type SpinnerSize = "sm" | "md" | "lg";
4
+
5
+ interface SpinnerProps {
6
+ size?: SpinnerSize | number;
7
+ /** Read out to screen readers; the spinner is silent without it */
8
+ label?: string;
9
+ className?: string;
10
+ }
11
+
12
+ const SIZES: Record<SpinnerSize, number> = { sm: 14, md: 18, lg: 24 };
13
+
14
+ /* A short arc chasing round a faint ring. It takes the text colour, so
15
+ inside a primary button it's light and on the page it's ink. */
16
+ export function Spinner({ size = "md", label, className = "" }: SpinnerProps) {
17
+ const px = typeof size === "number" ? size : SIZES[size];
18
+ return (
19
+ <span
20
+ role={label ? "status" : undefined}
21
+ aria-label={label}
22
+ aria-hidden={label ? undefined : true}
23
+ className={["inline-flex shrink-0", className].join(" ")}
24
+ style={{ width: px, height: px }}
25
+ >
26
+ <svg viewBox="0 0 24 24" width={px} height={px} fill="none" className="animate-spin">
27
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" strokeWidth="2.5" opacity="0.18" />
28
+ <path d="M21.5 12a9.5 9.5 0 0 0-9.5-9.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
29
+ </svg>
30
+ </span>
31
+ );
32
+ }
@@ -0,0 +1,106 @@
1
+ import React from "react";
2
+ import { CardShell } from "./CardShell";
3
+
4
+ interface SpotlightCardProps {
5
+ title: string;
6
+ subtitle?: string;
7
+ eyebrow?: string;
8
+ icon?: React.ReactNode;
9
+ actions?: React.ReactNode;
10
+ children?: React.ReactNode;
11
+ theme?: "light" | "dark";
12
+ size?: "sm" | "md" | "lg";
13
+ className?: string;
14
+ }
15
+
16
+ const paddingBySize: Record<NonNullable<SpotlightCardProps["size"]>, string> = {
17
+ // Feature cards pad 32px whatever their size; only the type scales
18
+ sm: "p-6",
19
+ md: "p-8",
20
+ lg: "p-8",
21
+ };
22
+
23
+ const titleSizeBySize: Record<
24
+ NonNullable<SpotlightCardProps["size"]>,
25
+ string
26
+ > = {
27
+ sm: "text-[20px]",
28
+ md: "text-[26px]",
29
+ lg: "text-[34px]",
30
+ };
31
+
32
+ export function SpotlightCard({
33
+ title,
34
+ subtitle,
35
+ eyebrow,
36
+ icon,
37
+ actions,
38
+ children,
39
+ theme = "dark",
40
+ size = "md",
41
+ className = "",
42
+ }: SpotlightCardProps) {
43
+ const dark = theme === "dark";
44
+
45
+ return (
46
+ /* Built on CardShell: icon at the top of the face, words and actions
47
+ settled at the bottom — the same rhythm as Card and QuoteCard. */
48
+ <CardShell as="article" theme={theme} className={className}>
49
+ {/* Headline sits low on the face, so tall cards breathe above it, not below */}
50
+ <div className={["relative flex-1 flex flex-col gap-3", paddingBySize[size]].join(" ")}>
51
+ {icon && (
52
+ <div
53
+ className={[
54
+ "w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
55
+ dark ? "text-white" : "text-ink-soft",
56
+ ].join(" ")}
57
+ style={{
58
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
59
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
60
+ }}
61
+ >
62
+ {icon}
63
+ </div>
64
+ )}
65
+
66
+ {eyebrow && (
67
+ <p
68
+ className={[
69
+ // Without an icon above to push it down, the copy still sits low
70
+ icon ? "pt-8" : "mt-auto",
71
+ "text-[14px] font-medium tracking-[-0.01em]",
72
+ dark ? "text-neutral-400" : "text-ink-muted",
73
+ ].join(" ")}
74
+ >
75
+ {eyebrow}
76
+ </p>
77
+ )}
78
+
79
+ <h3
80
+ className={[
81
+ titleSizeBySize[size],
82
+ !icon && !eyebrow ? "mt-auto" : "",
83
+ "font-medium leading-[1.12] tracking-[-0.03em] text-balance",
84
+ dark ? "text-white" : "text-ink",
85
+ ].join(" ")}
86
+ >
87
+ {title}
88
+ </h3>
89
+
90
+ {subtitle && (
91
+ <p
92
+ className={[
93
+ "text-[15px] leading-[1.6] max-w-lg text-pretty",
94
+ dark ? "text-neutral-400" : "text-ink-body",
95
+ ].join(" ")}
96
+ >
97
+ {subtitle}
98
+ </p>
99
+ )}
100
+
101
+ {children}
102
+ {actions && <div className="pt-4 flex items-center gap-2">{actions}</div>}
103
+ </div>
104
+ </CardShell>
105
+ );
106
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+
5
+ interface StatProps {
6
+ value: number | string;
7
+ label: string;
8
+ prefix?: string;
9
+ suffix?: string;
10
+ trend?: string;
11
+ className?: string;
12
+ }
13
+
14
+ function useCountUp(target: number, duration = 800) {
15
+ const [count, setCount] = useState(0);
16
+ const raf = useRef<number | null>(null);
17
+
18
+ useEffect(() => {
19
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
20
+ raf.current = requestAnimationFrame(() => setCount(target));
21
+ return () => { if (raf.current) cancelAnimationFrame(raf.current); };
22
+ }
23
+ let start: number | null = null;
24
+ const step = (now: number) => {
25
+ if (!start) start = now;
26
+ const p = Math.min((now - start) / duration, 1);
27
+ setCount(Math.round((1 - Math.pow(1 - p, 3)) * target));
28
+ if (p < 1) raf.current = requestAnimationFrame(step);
29
+ };
30
+ raf.current = requestAnimationFrame(step);
31
+ return () => { if (raf.current) cancelAnimationFrame(raf.current); };
32
+ }, [target, duration]);
33
+
34
+ return count;
35
+ }
36
+
37
+ export function Stat({ value, label, prefix = "", suffix = "", trend, className = "" }: StatProps) {
38
+ const isNum = typeof value === "number";
39
+ const counted = useCountUp(isNum ? (value as number) : 0);
40
+ const displayed = isNum ? counted : value;
41
+
42
+ return (
43
+ <div className={["flex flex-col gap-0.5", className].join(" ")}>
44
+ <div className="flex items-baseline gap-1.5">
45
+ <span
46
+ className="text-[32px] font-semibold text-ink leading-none tabular-nums"
47
+ style={{ letterSpacing: "-0.03em" }}
48
+ >
49
+ {prefix}{displayed}{suffix}
50
+ </span>
51
+ {trend && (
52
+ <span className="text-[12px] text-ink-muted font-medium">{trend}</span>
53
+ )}
54
+ </div>
55
+ <span className="text-[12px] text-ink-muted">{label}</span>
56
+ </div>
57
+ );
58
+ }
59
+
60
+ interface StatRowProps {
61
+ children: React.ReactNode;
62
+ className?: string;
63
+ }
64
+
65
+ export function StatRow({ children, className = "" }: StatRowProps) {
66
+ const arr = React.Children.toArray(children);
67
+ return (
68
+ <div className={["grid grid-cols-2 gap-6 sm:flex sm:divide-x sm:divide-line-strong sm:gap-0", className].join(" ")}>
69
+ {arr.map((child, i) => (
70
+ <div
71
+ key={i}
72
+ className={[
73
+ "min-w-0",
74
+ i === 0 ? "sm:pr-6" : i === arr.length - 1 ? "sm:pl-6" : "sm:px-6",
75
+ ].join(" ")}
76
+ >
77
+ {child}
78
+ </div>
79
+ ))}
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,256 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useRef, useState } from "react";
4
+ import { CardShell } from "./CardShell";
5
+
6
+ type StatTheme = "light" | "dark";
7
+ type DeltaDirection = "up" | "down" | "neutral";
8
+
9
+ interface StatCardProps {
10
+ label: string;
11
+ value: number;
12
+ prefix?: string;
13
+ suffix?: string;
14
+ decimals?: number;
15
+ delta?: { value: string; direction: DeltaDirection };
16
+ hint?: string;
17
+ sparkline?: number[];
18
+ icon?: React.ReactNode;
19
+ theme?: StatTheme;
20
+ className?: string;
21
+ }
22
+
23
+ function useAnimatedNumber(target: number, duration: number) {
24
+ const [v, setV] = useState(0);
25
+ const prevRef = useRef(0);
26
+
27
+ useEffect(() => {
28
+ const from = prevRef.current;
29
+ const delta = target - from;
30
+ if (delta === 0) return;
31
+ // Reduced motion: show the number, skip the count
32
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
33
+ prevRef.current = target;
34
+ const id = requestAnimationFrame(() => setV(target));
35
+ return () => cancelAnimationFrame(id);
36
+ }
37
+ const start = performance.now();
38
+ let raf = 0;
39
+ const tick = (now: number) => {
40
+ const t = Math.min(1, (now - start) / duration);
41
+ const eased = 1 - Math.pow(1 - t, 3);
42
+ setV(from + delta * eased);
43
+ if (t < 1) {
44
+ raf = requestAnimationFrame(tick);
45
+ } else {
46
+ prevRef.current = target;
47
+ }
48
+ };
49
+ raf = requestAnimationFrame(tick);
50
+ return () => cancelAnimationFrame(raf);
51
+ }, [target, duration]);
52
+
53
+ return v;
54
+ }
55
+
56
+ function Sparkline({ data, dark }: { data: number[]; dark?: boolean }) {
57
+ const uid = useId();
58
+ if (data.length < 2) return null;
59
+ const W = 120;
60
+ const H = 44;
61
+ const P = 6;
62
+ const max = Math.max(...data);
63
+ const min = Math.min(...data);
64
+ const range = max - min || 1;
65
+ const step = (W - P * 2) / (data.length - 1);
66
+
67
+ const pts = data.map((d, i) => {
68
+ const x = P + i * step;
69
+ const y = H - P - ((d - min) / range) * (H - P * 2);
70
+ return [x, y] as const;
71
+ });
72
+
73
+ const line = pts
74
+ .map(([x, y], i) => `${i === 0 ? "M" : "L"}${x.toFixed(1)},${y.toFixed(1)}`)
75
+ .join(" ");
76
+ // The fill runs off both edges of the face so it reads as part of the surface
77
+ const area = `M0,${pts[0][1].toFixed(1)} ${line.replace(/^M/, "L")} L${W},${pts[pts.length - 1][1].toFixed(1)} L${W},${H} L0,${H} Z`;
78
+
79
+ const stroke = dark ? "var(--spark-dark)" : "var(--spark)";
80
+ const gradId = `spark-fade-${uid.replace(/:/g, "")}`;
81
+ const [lx, ly] = pts[pts.length - 1];
82
+
83
+ return (
84
+ <div
85
+ aria-hidden
86
+ className="relative h-14"
87
+ // Fades in from the left so the line arrives rather than starts at a hard edge
88
+ style={{
89
+ maskImage: "linear-gradient(90deg, transparent 0%, black 18%)",
90
+ WebkitMaskImage: "linear-gradient(90deg, transparent 0%, black 18%)",
91
+ }}
92
+ >
93
+ <svg viewBox={`0 0 ${W} ${H}`} preserveAspectRatio="none" className="absolute inset-0 w-full h-full overflow-visible">
94
+ <defs>
95
+ <linearGradient id={gradId} x1="0" y1="0" x2="0" y2="1">
96
+ <stop offset="0%" style={{ stopColor: stroke }} stopOpacity={dark ? 0.2 : 0.08} />
97
+ <stop offset="100%" style={{ stopColor: stroke }} stopOpacity="0" />
98
+ </linearGradient>
99
+ </defs>
100
+ <path d={area} fill={`url(#${gradId})`} />
101
+ <path
102
+ d={line}
103
+ fill="none"
104
+ style={{ stroke }}
105
+ strokeOpacity={dark ? 0.85 : 0.9}
106
+ strokeWidth={1.5}
107
+ strokeLinecap="round"
108
+ strokeLinejoin="round"
109
+ vectorEffect="non-scaling-stroke"
110
+ />
111
+ </svg>
112
+ {/* The latest value, as a dot — drawn in HTML so it stays round when the SVG stretches */}
113
+ <span
114
+ className="absolute w-[7px] h-[7px] rounded-full -translate-x-1/2 -translate-y-1/2"
115
+ style={{
116
+ left: `${(lx / W) * 100}%`,
117
+ top: `${(ly / H) * 100}%`,
118
+ background: stroke,
119
+ boxShadow: dark ? "0 0 0 3px var(--spark-dark-halo)" : "0 0 0 3px var(--spark-halo)",
120
+ }}
121
+ />
122
+ </div>
123
+ );
124
+ }
125
+
126
+ const deltaTone: Record<DeltaDirection, { light: string; dark: string; label: string }> = {
127
+ up: { light: "text-success", dark: "text-emerald-400", label: "up" },
128
+ down: { light: "text-danger", dark: "text-red-400", label: "down" },
129
+ neutral: { light: "text-ink-body", dark: "text-neutral-300", label: "unchanged" },
130
+ };
131
+
132
+ function DeltaArrow({ direction }: { direction: DeltaDirection }) {
133
+ const d =
134
+ direction === "up" ? "M12 19V5m0 0l-6 6m6-6l6 6" : direction === "down" ? "M12 5v14m0 0l-6-6m6 6l6-6" : "M5 12h14m0 0l-6-6m6 6l-6 6";
135
+ return (
136
+ <svg aria-hidden width="10" height="10" fill="none" stroke="currentColor" strokeWidth="2.5" viewBox="0 0 24 24">
137
+ <path strokeLinecap="round" strokeLinejoin="round" d={d} />
138
+ </svg>
139
+ );
140
+ }
141
+
142
+ /* Built on CardShell; the trend line bleeds off the bottom of the face
143
+ like a chart on a screen. */
144
+ export function StatCard({
145
+ label,
146
+ value,
147
+ prefix = "",
148
+ suffix = "",
149
+ decimals = 0,
150
+ delta,
151
+ hint,
152
+ sparkline,
153
+ icon,
154
+ theme = "light",
155
+ className = "",
156
+ }: StatCardProps) {
157
+ const animated = useAnimatedNumber(value, 900);
158
+ const dark = theme === "dark";
159
+
160
+ const formatter = new Intl.NumberFormat("en-US", {
161
+ minimumFractionDigits: decimals,
162
+ maximumFractionDigits: decimals,
163
+ });
164
+ const finalText = `${prefix}${formatter.format(value)}${suffix}`;
165
+
166
+ const badge = delta && (
167
+ <span
168
+ aria-label={`${deltaTone[delta.direction].label} ${delta.value}`}
169
+ className={[
170
+ "inline-flex items-center gap-1 h-6 px-2 rounded-[7px] text-[12px] font-medium tabular-nums shrink-0",
171
+ dark ? deltaTone[delta.direction].dark : deltaTone[delta.direction].light,
172
+ ].join(" ")}
173
+ style={{
174
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
175
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
176
+ }}
177
+ >
178
+ <DeltaArrow direction={delta.direction} />
179
+ {delta.value}
180
+ </span>
181
+ );
182
+
183
+ /* Label and change share the top line — what it is, and which way it's
184
+ going. The number carries the card; the hint qualifies it in a
185
+ quieter voice right underneath. */
186
+ return (
187
+ <CardShell theme={theme} className={className}>
188
+ <div className="px-6 pt-5 flex items-center justify-between gap-3 min-h-11">
189
+ <div className="flex items-center gap-2.5 min-w-0">
190
+ {icon && (
191
+ <div
192
+ aria-hidden
193
+ className={[
194
+ "w-7 h-7 rounded-[9px] flex items-center justify-center shrink-0",
195
+ dark ? "text-neutral-100" : "text-ink-body",
196
+ ].join(" ")}
197
+ style={{
198
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
199
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
200
+ }}
201
+ >
202
+ {icon}
203
+ </div>
204
+ )}
205
+ <p
206
+ className={[
207
+ "text-[14px] font-medium tracking-[-0.01em] truncate",
208
+ dark ? "text-neutral-300" : "text-ink-label",
209
+ ].join(" ")}
210
+ >
211
+ {label}
212
+ </p>
213
+ </div>
214
+ {badge}
215
+ </div>
216
+
217
+ {/* Screen readers get the final value once, not every frame of the count */}
218
+ <p className="sr-only">{finalText}</p>
219
+ <p
220
+ aria-hidden
221
+ className={[
222
+ "px-6 mt-5 text-[44px] font-semibold tabular-nums tracking-[-0.045em] leading-none",
223
+ dark ? "text-white" : "text-ink",
224
+ ].join(" ")}
225
+ >
226
+ {prefix}
227
+ {formatter.format(animated)}
228
+ {/* Units are context, not the headline — set them smaller and quieter */}
229
+ {suffix && (
230
+ <span
231
+ className={[
232
+ "ml-1 text-[17px] font-medium tracking-[-0.01em]",
233
+ dark ? "text-neutral-500" : "text-ink-faint",
234
+ ].join(" ")}
235
+ >
236
+ {suffix.trim()}
237
+ </span>
238
+ )}
239
+ </p>
240
+ {hint && (
241
+ <p className={["px-6 mt-2.5 text-[13px] truncate", dark ? "text-neutral-500" : "text-ink-muted"].join(" ")}>
242
+ {hint}
243
+ </p>
244
+ )}
245
+
246
+ {/* The trend line bleeds to the face's edges, like a chart on a screen */}
247
+ {sparkline && sparkline.length > 1 ? (
248
+ <div className="mt-auto pt-6">
249
+ <Sparkline data={sparkline} dark={dark} />
250
+ </div>
251
+ ) : (
252
+ <div className="pb-6" />
253
+ )}
254
+ </CardShell>
255
+ );
256
+ }
@@ -0,0 +1,109 @@
1
+ import React from "react";
2
+
3
+ interface StepItem {
4
+ label: string;
5
+ description?: string;
6
+ }
7
+
8
+ interface StepsProps {
9
+ steps: StepItem[];
10
+ current: number;
11
+ className?: string;
12
+ }
13
+
14
+ function CheckIcon() {
15
+ return (
16
+ <svg
17
+ width="13"
18
+ height="13"
19
+ viewBox="0 0 24 24"
20
+ fill="none"
21
+ stroke="currentColor"
22
+ strokeWidth="2.75"
23
+ strokeLinecap="round"
24
+ strokeLinejoin="round"
25
+ >
26
+ <polyline points="20 6 9 17 4 12" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ /* Upcoming steps are sunken (not yet filled in), done and current steps
32
+ are dark raised faces; the current one also wears a soft halo. Each step owns an equal column, so labels get
33
+ real width instead of being squeezed under a 32px circle. */
34
+ export function Steps({ steps, current, className = "" }: StepsProps) {
35
+ return (
36
+ <ol className={["flex items-start", className].filter(Boolean).join(" ")}>
37
+ {steps.map((step, i) => {
38
+ const isCompleted = i < current;
39
+ const isCurrent = i === current;
40
+ const reached = isCompleted || isCurrent;
41
+ const isLast = i === steps.length - 1;
42
+
43
+ return (
44
+ <li
45
+ key={i}
46
+ aria-current={isCurrent ? "step" : undefined}
47
+ className="relative flex-1 min-w-0 flex flex-col items-center text-center"
48
+ >
49
+ {/* One groove per gap, running between the two circles' edges
50
+ (columns are equal, so the next centre is exactly 100% away) */}
51
+ {!isLast && (
52
+ <span
53
+ aria-hidden
54
+ className="absolute top-[15px] h-1 -translate-y-1/2 rounded-full overflow-hidden"
55
+ style={{
56
+ left: "calc(50% + 25px)",
57
+ width: "calc(100% - 50px)",
58
+ background: "var(--well)",
59
+ boxShadow: "var(--well-shadow)",
60
+ }}
61
+ >
62
+ <span
63
+ className="block h-full rounded-full origin-left transition-transform duration-500 ease-[var(--ease-out-soft)]"
64
+ style={{
65
+ background: "var(--face-inverse)",
66
+ transform: isCompleted ? "scaleX(1)" : "scaleX(0)",
67
+ }}
68
+ />
69
+ </span>
70
+ )}
71
+
72
+ <span
73
+ className={[
74
+ "relative w-[30px] h-[30px] rounded-full flex items-center justify-center text-[12.5px] font-semibold tabular-nums shrink-0",
75
+ "transition-[box-shadow,color] duration-300",
76
+ reached ? "text-ink-inverse" : "text-ink-faint",
77
+ ].join(" ")}
78
+ style={{
79
+ background: reached ? "var(--face-inverse)" : "var(--field)",
80
+ boxShadow: isCurrent
81
+ ? "var(--face-inverse-shadow), var(--halo)"
82
+ : reached
83
+ ? "var(--face-inverse-shadow)"
84
+ : "var(--field-shadow)",
85
+ }}
86
+ >
87
+ {isCompleted ? <CheckIcon /> : i + 1}
88
+ <span className="sr-only">{isCompleted ? " (completed)" : ""}</span>
89
+ </span>
90
+
91
+ <span className="mt-3 px-1.5 flex flex-col gap-0.5">
92
+ <span
93
+ className={[
94
+ "text-[13px] font-medium leading-tight transition-colors duration-200",
95
+ reached ? "text-ink" : "text-ink-muted",
96
+ ].join(" ")}
97
+ >
98
+ {step.label}
99
+ </span>
100
+ {step.description && (
101
+ <span className="text-[12px] text-ink-faint leading-snug">{step.description}</span>
102
+ )}
103
+ </span>
104
+ </li>
105
+ );
106
+ })}
107
+ </ol>
108
+ );
109
+ }