@blurrow/ui 0.1.0 → 0.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 (53) hide show
  1. package/README.md +28 -0
  2. package/dist/cli.js +1477 -0
  3. package/dist/registry/Accordion.tsx +140 -0
  4. package/dist/registry/Alert.tsx +92 -0
  5. package/dist/registry/AuthorCard.tsx +67 -0
  6. package/dist/registry/Avatar.tsx +116 -0
  7. package/dist/registry/Badge.tsx +58 -0
  8. package/dist/registry/Banner.tsx +89 -0
  9. package/dist/registry/Breadcrumb.tsx +60 -0
  10. package/dist/registry/Button.tsx +372 -0
  11. package/dist/registry/ButtonGroup.tsx +82 -0
  12. package/dist/registry/Card.tsx +241 -0
  13. package/dist/registry/CardShell.tsx +78 -0
  14. package/dist/registry/Carousel.tsx +292 -0
  15. package/dist/registry/Checkbox.tsx +99 -0
  16. package/dist/registry/CloseButton.tsx +68 -0
  17. package/dist/registry/CodeBlock.tsx +202 -0
  18. package/dist/registry/CommandPalette.tsx +349 -0
  19. package/dist/registry/DataList.tsx +49 -0
  20. package/dist/registry/Drawer.tsx +164 -0
  21. package/dist/registry/DropdownMenu.tsx +257 -0
  22. package/dist/registry/EmptyState.tsx +55 -0
  23. package/dist/registry/Input.tsx +97 -0
  24. package/dist/registry/Kbd.tsx +24 -0
  25. package/dist/registry/Modal.tsx +128 -0
  26. package/dist/registry/Pagination.tsx +163 -0
  27. package/dist/registry/Popover.tsx +121 -0
  28. package/dist/registry/Progress.tsx +66 -0
  29. package/dist/registry/QuoteCard.tsx +93 -0
  30. package/dist/registry/Radio.tsx +202 -0
  31. package/dist/registry/ReactionBar.tsx +101 -0
  32. package/dist/registry/ReadingProgress.tsx +51 -0
  33. package/dist/registry/ReadingTime.tsx +35 -0
  34. package/dist/registry/Select.tsx +290 -0
  35. package/dist/registry/ShareButtons.tsx +134 -0
  36. package/dist/registry/Sidebar.tsx +182 -0
  37. package/dist/registry/Skeleton.tsx +83 -0
  38. package/dist/registry/SpotlightCard.tsx +106 -0
  39. package/dist/registry/Stat.tsx +82 -0
  40. package/dist/registry/StatCard.tsx +256 -0
  41. package/dist/registry/Steps.tsx +109 -0
  42. package/dist/registry/Table.tsx +121 -0
  43. package/dist/registry/TableOfContents.tsx +116 -0
  44. package/dist/registry/Tabs.tsx +136 -0
  45. package/dist/registry/TagInput.tsx +191 -0
  46. package/dist/registry/Textarea.tsx +84 -0
  47. package/dist/registry/Timeline.tsx +102 -0
  48. package/dist/registry/Toast.tsx +385 -0
  49. package/dist/registry/Toggle.tsx +99 -0
  50. package/dist/registry/Tooltip.tsx +212 -0
  51. package/dist/registry/styles.css +401 -0
  52. package/dist/registry/useModalBehavior.ts +77 -0
  53. package/package.json +23 -6
@@ -0,0 +1,134 @@
1
+ "use client";
2
+
3
+ import React, { useState, useCallback } from "react";
4
+ import { ButtonGroup } from "./ButtonGroup";
5
+
6
+ interface ShareButtonsProps {
7
+ url?: string;
8
+ title?: string;
9
+ className?: string;
10
+ }
11
+
12
+
13
+ function LinkIcon() {
14
+ return (
15
+ <svg
16
+ width="14"
17
+ height="14"
18
+ viewBox="0 0 24 24"
19
+ fill="none"
20
+ stroke="currentColor"
21
+ strokeWidth="1.75"
22
+ strokeLinecap="round"
23
+ strokeLinejoin="round"
24
+ >
25
+ <path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
26
+ <path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
27
+ </svg>
28
+ );
29
+ }
30
+
31
+ function CheckIcon() {
32
+ return (
33
+ <svg
34
+ width="14"
35
+ height="14"
36
+ viewBox="0 0 24 24"
37
+ fill="none"
38
+ stroke="currentColor"
39
+ strokeWidth="2"
40
+ strokeLinecap="round"
41
+ strokeLinejoin="round"
42
+ >
43
+ <polyline points="20 6 9 17 4 12" />
44
+ </svg>
45
+ );
46
+ }
47
+
48
+ function XIcon() {
49
+ return (
50
+ <svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
51
+ <path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
52
+ </svg>
53
+ );
54
+ }
55
+
56
+ function LinkedInIcon() {
57
+ return (
58
+ <svg width="13" height="13" fill="currentColor" viewBox="0 0 24 24">
59
+ <path d="M20.447 20.452h-3.554v-5.569c0-1.328-.027-3.037-1.852-3.037-1.853 0-2.136 1.445-2.136 2.939v5.667H9.351V9h3.414v1.561h.046c.477-.9 1.637-1.85 3.37-1.85 3.601 0 4.267 2.37 4.267 5.455v6.286zM5.337 7.433a2.062 2.062 0 01-2.063-2.065 2.063 2.063 0 112.063 2.065zm1.782 13.019H3.555V9h3.564v11.452zM22.225 0H1.771C.792 0 0 .774 0 1.729v20.542C0 23.227.792 24 1.771 24h20.451C23.2 24 24 23.227 24 22.271V1.729C24 .774 23.2 0 22.222 0h.003z" />
60
+ </svg>
61
+ );
62
+ }
63
+
64
+ export function ShareButtons({
65
+ url,
66
+ title,
67
+ className = "",
68
+ }: ShareButtonsProps) {
69
+ const [copied, setCopied] = useState(false);
70
+
71
+ const shareUrl = url ?? (typeof window !== "undefined" ? window.location.href : "");
72
+ const shareTitle = title ?? "";
73
+
74
+ const handleCopy = useCallback(async () => {
75
+ try {
76
+ await navigator.clipboard.writeText(shareUrl);
77
+ setCopied(true);
78
+ setTimeout(() => setCopied(false), 2000);
79
+ } catch {
80
+ // Fallback silently
81
+ }
82
+ }, [shareUrl]);
83
+
84
+ const handleTwitter = useCallback(() => {
85
+ const params = new URLSearchParams({
86
+ text: shareTitle,
87
+ url: shareUrl,
88
+ });
89
+ window.open(
90
+ `https://x.com/intent/tweet?${params.toString()}`,
91
+ "_blank",
92
+ "noopener,noreferrer"
93
+ );
94
+ }, [shareUrl, shareTitle]);
95
+
96
+ const handleLinkedIn = useCallback(() => {
97
+ const params = new URLSearchParams({
98
+ url: shareUrl,
99
+ });
100
+ window.open(
101
+ `https://www.linkedin.com/sharing/share-offsite/?${params.toString()}`,
102
+ "_blank",
103
+ "noopener,noreferrer"
104
+ );
105
+ }, [shareUrl]);
106
+
107
+ const keys = [
108
+ {
109
+ label: copied ? "Link copied" : "Copy link",
110
+ onClick: handleCopy as () => void,
111
+ content: (
112
+ <>
113
+ <span key={String(copied)} className={["animate-checkbox-in", copied ? "text-emerald-600" : ""].join(" ")}>
114
+ {copied ? <CheckIcon /> : <LinkIcon />}
115
+ </span>
116
+ <span className="w-[62px] text-left">{copied ? "copied" : "copy link"}</span>
117
+ </>
118
+ ),
119
+ wide: true,
120
+ },
121
+ { label: "Share on X", onClick: handleTwitter, content: <XIcon />, wide: false },
122
+ { label: "Share on LinkedIn", onClick: handleLinkedIn, content: <LinkedInIcon />, wide: false },
123
+ ];
124
+
125
+ return (
126
+ <div className={className}>
127
+ <p className="text-[12px] font-medium text-neutral-500 lowercase mb-3">
128
+ Share
129
+ </p>
130
+ <ButtonGroup label="Share" items={keys} />
131
+ <span aria-live="polite" className="sr-only">{copied ? "Link copied to clipboard" : ""}</span>
132
+ </div>
133
+ );
134
+ }
@@ -0,0 +1,182 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useLayoutEffect } from "react";
4
+
5
+ interface SidebarItem {
6
+ key: string;
7
+ label: string;
8
+ icon?: React.ReactNode;
9
+ badge?: string | number;
10
+ disabled?: boolean;
11
+ }
12
+
13
+ interface SidebarSection {
14
+ title?: string;
15
+ items: SidebarItem[];
16
+ }
17
+
18
+ interface SidebarHeader {
19
+ logo?: React.ReactNode;
20
+ title: string;
21
+ subtitle?: string;
22
+ }
23
+
24
+ interface SidebarProps {
25
+ sections: SidebarSection[];
26
+ header?: SidebarHeader;
27
+ defaultActive?: string;
28
+ active?: string;
29
+ onSelect?: (key: string) => void;
30
+ className?: string;
31
+ }
32
+
33
+ export function Sidebar({
34
+ sections,
35
+ header,
36
+ defaultActive = "",
37
+ active,
38
+ onSelect,
39
+ className = "",
40
+ }: SidebarProps) {
41
+ const [internal, setInternal] = useState(defaultActive);
42
+ const current = active !== undefined ? active : internal;
43
+ const [pill, setPill] = useState({ top: 0, height: 0, ready: false });
44
+ const navRef = useRef<HTMLElement>(null);
45
+ const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
46
+
47
+ const measurePill = (key: string) => {
48
+ const btn = btnRefs.current[key];
49
+ const nav = navRef.current;
50
+ if (!btn || !nav) return;
51
+ const nr = nav.getBoundingClientRect();
52
+ const br = btn.getBoundingClientRect();
53
+ setPill({ top: br.top - nr.top, height: br.height, ready: true });
54
+ };
55
+
56
+ useLayoutEffect(() => { measurePill(current); }, []);
57
+ useEffect(() => { measurePill(current); }, [current]);
58
+
59
+ useEffect(() => {
60
+ const ro = new ResizeObserver(() => measurePill(current));
61
+ if (navRef.current) ro.observe(navRef.current);
62
+ return () => ro.disconnect();
63
+ }, [current]);
64
+
65
+ const handleSelect = (key: string, disabled?: boolean) => {
66
+ if (disabled) return;
67
+ setInternal(key);
68
+ onSelect?.(key);
69
+ };
70
+
71
+ return (
72
+ <div
73
+ className={["rounded-[20px] p-1.5", className].join(" ")}
74
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
75
+ >
76
+ <div className="bg-white rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
77
+ {header && (
78
+ <div className="px-3 py-3 border-b border-neutral-100 flex items-center gap-2.5">
79
+ {header.logo && (
80
+ <div
81
+ className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-white"
82
+ style={{ background: "var(--face-dark)", boxShadow: "var(--face-dark-shadow)" }}
83
+ >
84
+ {header.logo}
85
+ </div>
86
+ )}
87
+ <div className="flex-1 min-w-0">
88
+ <p className="text-[13px] font-semibold text-neutral-900 leading-none truncate">{header.title}</p>
89
+ {header.subtitle && (
90
+ <p className="text-[11px] text-neutral-400 mt-0.5 leading-none truncate">{header.subtitle}</p>
91
+ )}
92
+ </div>
93
+ <button
94
+ type="button"
95
+ aria-label="Switch workspace"
96
+ className="shrink-0 w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-900 transition-colors cursor-pointer rounded-[7px] hover:bg-neutral-100"
97
+ >
98
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
99
+ <path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
100
+ </svg>
101
+ </button>
102
+ </div>
103
+ )}
104
+
105
+ <nav ref={navRef} aria-label={header?.title ?? "Sidebar"} className="relative p-2 flex flex-col gap-4">
106
+ {pill.ready && (
107
+ <span
108
+ aria-hidden
109
+ className="absolute left-2 right-2 rounded-[11px] pointer-events-none z-0"
110
+ style={{
111
+ top: pill.top,
112
+ height: pill.height,
113
+ background: "var(--face-dark)",
114
+ boxShadow: "var(--face-dark-shadow)",
115
+ transition: "top 300ms var(--ease-spring), height 300ms var(--ease-spring)",
116
+ }}
117
+ />
118
+ )}
119
+
120
+ {sections.map((section, si) => (
121
+ <div key={si} className="flex flex-col gap-0.5">
122
+ {section.title && (
123
+ <p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-neutral-400 lowercase">
124
+ {section.title}
125
+ </p>
126
+ )}
127
+ {section.items.map((item) => {
128
+ const isActive = current === item.key;
129
+ return (
130
+ <button
131
+ key={item.key}
132
+ ref={(el) => { btnRefs.current[item.key] = el; }}
133
+ type="button"
134
+ onClick={() => handleSelect(item.key, item.disabled)}
135
+ disabled={item.disabled}
136
+ aria-current={isActive ? "page" : undefined}
137
+ className={[
138
+ "relative z-10 w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[11px] text-[13px] font-medium text-left",
139
+ "transition-colors duration-200 cursor-pointer",
140
+ "disabled:opacity-35 disabled:pointer-events-none",
141
+ isActive
142
+ ? "text-white"
143
+ : "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100/80",
144
+ ].join(" ")}
145
+ >
146
+ {item.icon && (
147
+ <span
148
+ className={[
149
+ "shrink-0 w-[22px] h-[22px] rounded-md flex items-center justify-center transition-colors duration-200",
150
+ isActive
151
+ ? "bg-white/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"
152
+ : "text-neutral-500 [background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
153
+ ].join(" ")}
154
+ >
155
+ {item.icon}
156
+ </span>
157
+ )}
158
+
159
+ <span className="flex-1 truncate">{item.label}</span>
160
+
161
+ {item.badge !== undefined && (
162
+ <span
163
+ className={[
164
+ "shrink-0 text-[11px] font-medium px-1.5 py-0.5 rounded-md tabular-nums leading-none transition-colors duration-200",
165
+ isActive
166
+ ? "bg-white/20 text-white/80"
167
+ : "bg-neutral-100 text-neutral-500",
168
+ ].join(" ")}
169
+ >
170
+ {item.badge}
171
+ </span>
172
+ )}
173
+ </button>
174
+ );
175
+ })}
176
+ </div>
177
+ ))}
178
+ </nav>
179
+ </div>
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,83 @@
1
+ import React from "react";
2
+
3
+ type SkeletonVariant = "text" | "circle" | "rect" | "card";
4
+
5
+ interface SkeletonProps {
6
+ variant?: SkeletonVariant;
7
+ width?: string | number;
8
+ height?: string | number;
9
+ lines?: number;
10
+ className?: string;
11
+ /** What is loading, for screen readers */
12
+ label?: string;
13
+ }
14
+
15
+ /* Placeholders are empty sunken wells — the shape of what will fill them —
16
+ with a light sweep travelling across, rather than a blink. */
17
+ const base = "skeleton-sheen relative overflow-hidden rounded-[8px]";
18
+ const well: React.CSSProperties = { background: "var(--well)", boxShadow: "var(--well-shadow)" };
19
+
20
+ export function Skeleton({
21
+ variant = "text",
22
+ width,
23
+ height,
24
+ lines = 1,
25
+ className = "",
26
+ label = "Loading",
27
+ }: SkeletonProps) {
28
+ const a11y = { role: "status", "aria-busy": true, "aria-label": label } as const;
29
+
30
+ if (variant === "circle") {
31
+ return (
32
+ <div
33
+ {...a11y}
34
+ className={[base, "rounded-full", className].join(" ")}
35
+ style={{ ...well, width: width ?? 40, height: height ?? 40 }}
36
+ />
37
+ );
38
+ }
39
+
40
+ if (variant === "card") {
41
+ return (
42
+ <div
43
+ {...a11y}
44
+ className={["rounded-[20px] p-1.5", className].join(" ")}
45
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
46
+ >
47
+ <div className="bg-white rounded-[15px] p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
48
+ <div className={[base, "w-full rounded-[11px]"].join(" ")} style={{ ...well, height: height ?? 160 }} />
49
+ <div className="px-3.5 pt-4 pb-3.5 flex flex-col gap-2.5">
50
+ <div className={[base, "h-3.5 w-3/5"].join(" ")} style={well} />
51
+ <div className={[base, "h-2.5 w-full"].join(" ")} style={well} />
52
+ <div className={[base, "h-2.5 w-4/5"].join(" ")} style={well} />
53
+ </div>
54
+ </div>
55
+ </div>
56
+ );
57
+ }
58
+
59
+ if (variant === "rect") {
60
+ return (
61
+ <div
62
+ {...a11y}
63
+ className={[base, "rounded-[12px]", className].join(" ")}
64
+ style={{ ...well, width: width ?? "100%", height: height ?? 80 }}
65
+ />
66
+ );
67
+ }
68
+
69
+ return (
70
+ <div {...a11y} className={["flex flex-col gap-2", className].join(" ")}>
71
+ {Array.from({ length: lines }).map((_, i) => (
72
+ <div
73
+ key={i}
74
+ className={[base, "h-2.5 rounded-full"].join(" ")}
75
+ style={{
76
+ ...well,
77
+ width: width ?? (i === lines - 1 && lines > 1 ? "60%" : "100%"),
78
+ }}
79
+ />
80
+ ))}
81
+ </div>
82
+ );
83
+ }
@@ -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-neutral-800",
56
+ ].join(" ")}
57
+ style={{
58
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
59
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-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-neutral-500",
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-neutral-900",
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-neutral-600",
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-neutral-900 leading-none tabular-nums"
47
+ style={{ letterSpacing: "-0.03em" }}
48
+ >
49
+ {prefix}{displayed}{suffix}
50
+ </span>
51
+ {trend && (
52
+ <span className="text-[12px] text-neutral-500 font-medium">{trend}</span>
53
+ )}
54
+ </div>
55
+ <span className="text-[12px] text-neutral-500">{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-neutral-200 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
+ }