@blurrow/ui 0.0.0-stage → 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 (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +93 -2
  3. package/dist/cli.js +1477 -0
  4. package/dist/index.d.ts +631 -0
  5. package/dist/index.js +5559 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/registry/Accordion.tsx +140 -0
  8. package/dist/registry/Alert.tsx +92 -0
  9. package/dist/registry/AuthorCard.tsx +67 -0
  10. package/dist/registry/Avatar.tsx +116 -0
  11. package/dist/registry/Badge.tsx +58 -0
  12. package/dist/registry/Banner.tsx +89 -0
  13. package/dist/registry/Breadcrumb.tsx +60 -0
  14. package/dist/registry/Button.tsx +372 -0
  15. package/dist/registry/ButtonGroup.tsx +82 -0
  16. package/dist/registry/Card.tsx +241 -0
  17. package/dist/registry/CardShell.tsx +78 -0
  18. package/dist/registry/Carousel.tsx +292 -0
  19. package/dist/registry/Checkbox.tsx +99 -0
  20. package/dist/registry/CloseButton.tsx +68 -0
  21. package/dist/registry/CodeBlock.tsx +202 -0
  22. package/dist/registry/CommandPalette.tsx +349 -0
  23. package/dist/registry/DataList.tsx +49 -0
  24. package/dist/registry/Drawer.tsx +164 -0
  25. package/dist/registry/DropdownMenu.tsx +257 -0
  26. package/dist/registry/EmptyState.tsx +55 -0
  27. package/dist/registry/Input.tsx +97 -0
  28. package/dist/registry/Kbd.tsx +24 -0
  29. package/dist/registry/Modal.tsx +128 -0
  30. package/dist/registry/Pagination.tsx +163 -0
  31. package/dist/registry/Popover.tsx +121 -0
  32. package/dist/registry/Progress.tsx +66 -0
  33. package/dist/registry/QuoteCard.tsx +93 -0
  34. package/dist/registry/Radio.tsx +202 -0
  35. package/dist/registry/ReactionBar.tsx +101 -0
  36. package/dist/registry/ReadingProgress.tsx +51 -0
  37. package/dist/registry/ReadingTime.tsx +35 -0
  38. package/dist/registry/Select.tsx +290 -0
  39. package/dist/registry/ShareButtons.tsx +134 -0
  40. package/dist/registry/Sidebar.tsx +182 -0
  41. package/dist/registry/Skeleton.tsx +83 -0
  42. package/dist/registry/SpotlightCard.tsx +106 -0
  43. package/dist/registry/Stat.tsx +82 -0
  44. package/dist/registry/StatCard.tsx +256 -0
  45. package/dist/registry/Steps.tsx +109 -0
  46. package/dist/registry/Table.tsx +121 -0
  47. package/dist/registry/TableOfContents.tsx +116 -0
  48. package/dist/registry/Tabs.tsx +136 -0
  49. package/dist/registry/TagInput.tsx +191 -0
  50. package/dist/registry/Textarea.tsx +84 -0
  51. package/dist/registry/Timeline.tsx +102 -0
  52. package/dist/registry/Toast.tsx +385 -0
  53. package/dist/registry/Toggle.tsx +99 -0
  54. package/dist/registry/Tooltip.tsx +212 -0
  55. package/dist/registry/styles.css +401 -0
  56. package/dist/registry/useModalBehavior.ts +77 -0
  57. package/dist/styles.css +401 -0
  58. package/package.json +56 -4
@@ -0,0 +1,128 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+ import { CloseButton } from "./CloseButton";
5
+ import { useModalBehavior } from "./useModalBehavior";
6
+
7
+ interface ModalProps {
8
+ open: boolean;
9
+ onClose: () => void;
10
+ title?: string;
11
+ description?: string;
12
+ children?: React.ReactNode;
13
+ actions?: React.ReactNode;
14
+ className?: string;
15
+ }
16
+
17
+ const CLOSE_MS = 240;
18
+
19
+ export function Modal({
20
+ open,
21
+ onClose,
22
+ title,
23
+ description,
24
+ children,
25
+ actions,
26
+ className = "",
27
+ }: ModalProps) {
28
+ const [mounted, setMounted] = useState(false);
29
+ const [visible, setVisible] = useState(false);
30
+ const dialogRef = useRef<HTMLDivElement>(null);
31
+ const uid = useId();
32
+
33
+ useEffect(() => {
34
+ if (open) {
35
+ setMounted(true);
36
+ return;
37
+ }
38
+ if (mounted) {
39
+ setVisible(false);
40
+ const t = setTimeout(() => setMounted(false), CLOSE_MS);
41
+ return () => clearTimeout(t);
42
+ }
43
+ }, [open, mounted]);
44
+
45
+ useLayoutEffect(() => {
46
+ if (!mounted || !open || visible) return;
47
+ void dialogRef.current?.getBoundingClientRect();
48
+ const id = requestAnimationFrame(() => setVisible(true));
49
+ return () => cancelAnimationFrame(id);
50
+ }, [mounted, open, visible]);
51
+
52
+ // Escape, focus trap + restore, scroll lock
53
+ useModalBehavior({ open, ready: visible, ref: dialogRef, onClose });
54
+
55
+ if (!mounted) return null;
56
+
57
+ return (
58
+ <div className="fixed inset-0 z-90 flex items-center justify-center p-4">
59
+ <div
60
+ aria-hidden
61
+ className={[
62
+ "absolute inset-0 backdrop-blur-[6px]",
63
+ "transition-opacity ease-out",
64
+ visible ? "opacity-100 duration-300" : "opacity-0 duration-200",
65
+ ].join(" ")}
66
+ style={{ background: "var(--scrim)" }}
67
+ onClick={onClose}
68
+ />
69
+ <div
70
+ ref={dialogRef}
71
+ role="dialog"
72
+ aria-modal="true"
73
+ aria-labelledby={title ? `${uid}-title` : undefined}
74
+ aria-describedby={description ? `${uid}-desc` : undefined}
75
+ className={[
76
+ "relative z-10 w-full max-w-lg rounded-[22px] p-1.5",
77
+ "transition-[opacity,transform,scale,translate] ease-[var(--ease-spring)]",
78
+ visible
79
+ ? "opacity-100 scale-100 translate-y-0 duration-300"
80
+ : "opacity-0 scale-[0.94] translate-y-3 duration-200",
81
+ className,
82
+ ]
83
+ .filter(Boolean)
84
+ .join(" ")}
85
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--modal-shadow)" }}
86
+ >
87
+ <div
88
+ className="bg-white rounded-[16px] overflow-hidden"
89
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
90
+ >
91
+ <div className="pl-6 pr-4 pt-5 pb-4 flex items-start gap-4">
92
+ <div className="flex-1 min-w-0 flex flex-col gap-1.5 pt-1">
93
+ {title && (
94
+ <h2
95
+ id={`${uid}-title`}
96
+ className="text-[16px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug"
97
+ >
98
+ {title}
99
+ </h2>
100
+ )}
101
+ {description && (
102
+ <p id={`${uid}-desc`} className="text-[13px] text-neutral-500 leading-relaxed">
103
+ {description}
104
+ </p>
105
+ )}
106
+ </div>
107
+ <CloseButton label="Close dialog" onClick={onClose} />
108
+ </div>
109
+
110
+ {children && <div className="px-6 pb-5 text-[13.5px] text-neutral-600 leading-relaxed">{children}</div>}
111
+
112
+ {/* Footer sits in the bezel tone so actions read as a separate tray */}
113
+ {actions && (
114
+ <div
115
+ className="px-5 py-3.5 flex items-center justify-end gap-2"
116
+ style={{
117
+ background: "linear-gradient(180deg, #fafafa 0%, #f6f6f6 100%)",
118
+ boxShadow: "inset 0 1px 0 rgba(17,17,17,0.06)",
119
+ }}
120
+ >
121
+ {actions}
122
+ </div>
123
+ )}
124
+ </div>
125
+ </div>
126
+ </div>
127
+ );
128
+ }
@@ -0,0 +1,163 @@
1
+ "use client";
2
+
3
+ import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
4
+
5
+ interface PaginationProps {
6
+ currentPage: number;
7
+ totalPages: number;
8
+ onPageChange: (page: number) => void;
9
+ className?: string;
10
+ }
11
+
12
+ export function Pagination({
13
+ currentPage,
14
+ totalPages,
15
+ onPageChange,
16
+ className = "",
17
+ }: PaginationProps) {
18
+ const slots = buildSlots(currentPage, totalPages);
19
+ const [pill, setPill] = useState({ left: 0, width: 0, ready: false });
20
+ const containerRef = useRef<HTMLDivElement>(null);
21
+ const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
22
+
23
+ const measurePill = () => {
24
+ const btn = btnRefs.current[String(currentPage)];
25
+ const container = containerRef.current;
26
+ if (!btn || !container) return;
27
+ const cr = container.getBoundingClientRect();
28
+ const br = btn.getBoundingClientRect();
29
+ setPill({ left: br.left - cr.left, width: br.width, ready: true });
30
+ };
31
+
32
+ useLayoutEffect(() => {
33
+ measurePill();
34
+ }, []);
35
+
36
+ /* Only the thumb moves: it glides to the new page without overshoot,
37
+ and the numbers themselves stay still. */
38
+ useEffect(() => {
39
+ measurePill();
40
+ }, [currentPage]);
41
+
42
+ useEffect(() => {
43
+ const ro = new ResizeObserver(() => measurePill());
44
+ if (containerRef.current) ro.observe(containerRef.current);
45
+ return () => ro.disconnect();
46
+ }, [currentPage]);
47
+
48
+ return (
49
+ <nav
50
+ aria-label="Pagination"
51
+ className={[
52
+ "inline-flex items-center gap-0.5 rounded-[13px] p-1",
53
+ className,
54
+ ].join(" ")}
55
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
56
+ >
57
+ <button
58
+ onClick={() => onPageChange(currentPage - 1)}
59
+ disabled={currentPage <= 1}
60
+ aria-label="Previous page"
61
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
62
+ >
63
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />
64
+ <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
65
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
66
+ </svg>
67
+ <span className="relative z-10 hidden sm:inline">prev</span>
68
+ </button>
69
+
70
+ <div ref={containerRef} className="relative flex items-center">
71
+ {pill.ready && (
72
+ <span
73
+ aria-hidden
74
+ className="absolute top-0 bottom-0 rounded-[9px] pointer-events-none z-0"
75
+ style={{
76
+ left: pill.left,
77
+ width: pill.width,
78
+ background: "var(--face-light)",
79
+ boxShadow: "var(--thumb-shadow)",
80
+ transition: "left 340ms var(--ease-out-soft), width 340ms var(--ease-out-soft)",
81
+ }}
82
+ />
83
+ )}
84
+
85
+ {slots.map((slot, i) =>
86
+ slot === "..." ? (
87
+ <span
88
+ key={`dot-${i}`}
89
+ aria-hidden
90
+ className="w-8 h-8 flex items-center justify-center text-[11px] text-neutral-400 select-none relative z-10"
91
+ >
92
+ ···
93
+ </span>
94
+ ) : (
95
+ <button
96
+ key={`slot-${i}`}
97
+ ref={(el) => {
98
+ btnRefs.current[String(slot)] = el;
99
+ }}
100
+ onClick={() => slot !== currentPage && onPageChange(slot as number)}
101
+ aria-label={`Page ${slot}`}
102
+ aria-current={slot === currentPage ? "page" : undefined}
103
+ className={[
104
+ "relative z-10 min-w-8 h-8 px-1 rounded-[9px] flex items-center justify-center text-[13px] font-medium transition-colors duration-200 cursor-pointer tabular-nums",
105
+ slot === currentPage
106
+ ? "text-neutral-900"
107
+ : "text-neutral-500 hover:text-neutral-900",
108
+ ].join(" ")}
109
+ >
110
+ {slot}
111
+ </button>
112
+ ),
113
+ )}
114
+ </div>
115
+
116
+ <button
117
+ onClick={() => onPageChange(currentPage + 1)}
118
+ disabled={currentPage >= totalPages}
119
+ aria-label="Next page"
120
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
121
+ >
122
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />
123
+ <span className="relative z-10 hidden sm:inline">next</span>
124
+ <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
125
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
126
+ </svg>
127
+ </button>
128
+ </nav>
129
+ );
130
+ }
131
+
132
+ function buildSlots(current: number, total: number): (number | "...")[] {
133
+ if (total <= 7) {
134
+ return Array.from({ length: total }, (_, i) => i + 1);
135
+ }
136
+
137
+ const slots: (number | "...")[] = new Array(7);
138
+
139
+ slots[0] = 1;
140
+ slots[6] = total;
141
+
142
+ if (current <= 3) {
143
+ slots[1] = 2;
144
+ slots[2] = 3;
145
+ slots[3] = 4;
146
+ slots[4] = 5;
147
+ slots[5] = "...";
148
+ } else if (current >= total - 2) {
149
+ slots[1] = "...";
150
+ slots[2] = total - 4;
151
+ slots[3] = total - 3;
152
+ slots[4] = total - 2;
153
+ slots[5] = total - 1;
154
+ } else {
155
+ slots[1] = "...";
156
+ slots[2] = current - 1;
157
+ slots[3] = current;
158
+ slots[4] = current + 1;
159
+ slots[5] = "...";
160
+ }
161
+
162
+ return slots;
163
+ }
@@ -0,0 +1,121 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useEffect, useId } from "react";
4
+
5
+ type PopoverPosition = "top" | "bottom" | "left" | "right";
6
+
7
+ interface PopoverProps {
8
+ trigger: React.ReactNode;
9
+ children: React.ReactNode;
10
+ position?: PopoverPosition;
11
+ className?: string;
12
+ }
13
+
14
+ /* Anchored with margins on a wrapper so the panel's own transform is free
15
+ for the enter motion, which scales out from the trigger side. */
16
+ const positionStyles: Record<PopoverPosition, string> = {
17
+ top: "bottom-full left-1/2 -translate-x-1/2 pb-2",
18
+ bottom: "top-full left-1/2 -translate-x-1/2 pt-2",
19
+ left: "right-full top-1/2 -translate-y-1/2 pr-2",
20
+ right: "left-full top-1/2 -translate-y-1/2 pl-2",
21
+ };
22
+
23
+ const origin: Record<PopoverPosition, string> = {
24
+ top: "origin-bottom",
25
+ bottom: "origin-top",
26
+ left: "origin-right",
27
+ right: "origin-left",
28
+ };
29
+
30
+ const enterFrom: Record<PopoverPosition, string> = {
31
+ top: "translate-y-1.5",
32
+ bottom: "-translate-y-1.5",
33
+ left: "translate-x-1.5",
34
+ right: "-translate-x-1.5",
35
+ };
36
+
37
+ export function Popover({
38
+ trigger,
39
+ children,
40
+ position = "bottom",
41
+ className = "",
42
+ }: PopoverProps) {
43
+ const [open, setOpen] = useState(false);
44
+ const [visible, setVisible] = useState(false);
45
+ const [mounted, setMounted] = useState(false);
46
+ const ref = useRef<HTMLDivElement>(null);
47
+ const panelRef = useRef<HTMLDivElement>(null);
48
+ const uid = useId();
49
+
50
+ useEffect(() => {
51
+ if (open) {
52
+ setMounted(true);
53
+ requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
54
+ } else {
55
+ setVisible(false);
56
+ const t = setTimeout(() => setMounted(false), 180);
57
+ return () => clearTimeout(t);
58
+ }
59
+ }, [open]);
60
+
61
+ useEffect(() => {
62
+ function onOutside(e: MouseEvent) {
63
+ if (ref.current && !ref.current.contains(e.target as Node)) {
64
+ setOpen(false);
65
+ }
66
+ }
67
+ document.addEventListener("mousedown", onOutside);
68
+ return () => document.removeEventListener("mousedown", onOutside);
69
+ }, []);
70
+
71
+ useEffect(() => {
72
+ if (!open) return;
73
+ const onKey = (e: KeyboardEvent) => {
74
+ if (e.key === "Escape") {
75
+ setOpen(false);
76
+ ref.current?.querySelector<HTMLElement>("[data-popover-trigger] button, [data-popover-trigger] a")?.focus();
77
+ }
78
+ };
79
+ document.addEventListener("keydown", onKey);
80
+ return () => document.removeEventListener("keydown", onKey);
81
+ }, [open]);
82
+
83
+ return (
84
+ <div ref={ref} className={["relative inline-flex", className].join(" ")}>
85
+ <div data-popover-trigger onClick={() => setOpen((v) => !v)} className="inline-flex cursor-pointer">
86
+ {React.isValidElement(trigger)
87
+ ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {
88
+ "aria-haspopup": "dialog",
89
+ "aria-expanded": open,
90
+ "aria-controls": open ? `${uid}-popover` : undefined,
91
+ })
92
+ : trigger}
93
+ </div>
94
+ {mounted && (
95
+ <div className={["absolute z-50", positionStyles[position]].join(" ")}>
96
+ <div
97
+ ref={panelRef}
98
+ id={`${uid}-popover`}
99
+ role="dialog"
100
+ className={[
101
+ "min-w-[220px] rounded-[16px] p-1.5",
102
+ "transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",
103
+ origin[position],
104
+ visible
105
+ ? "opacity-100 scale-100 translate-x-0 translate-y-0"
106
+ : ["opacity-0 scale-[0.96]", enterFrom[position]].join(" "),
107
+ ].join(" ")}
108
+ style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
109
+ >
110
+ <div
111
+ className="bg-white rounded-[11px] p-3.5 text-[13px] text-neutral-600 leading-relaxed"
112
+ style={{ boxShadow: "var(--panel-face-shadow)" }}
113
+ >
114
+ {children}
115
+ </div>
116
+ </div>
117
+ </div>
118
+ )}
119
+ </div>
120
+ );
121
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useRef, useState } from "react";
4
+
5
+ interface ProgressProps {
6
+ value: number;
7
+ max?: number;
8
+ label?: string;
9
+ showValue?: boolean;
10
+ className?: string;
11
+ }
12
+
13
+ export function Progress({
14
+ value,
15
+ max = 100,
16
+ label,
17
+ showValue = false,
18
+ className = "",
19
+ }: ProgressProps) {
20
+ const pct = Math.min(Math.max((value / max) * 100, 0), 100);
21
+ const [displayed, setDisplayed] = useState(0);
22
+ const t = useRef<ReturnType<typeof setTimeout> | null>(null);
23
+
24
+ useEffect(() => {
25
+ t.current = setTimeout(() => setDisplayed(pct), 80);
26
+ return () => { if (t.current) clearTimeout(t.current); };
27
+ }, [pct]);
28
+
29
+ return (
30
+ <div className={["flex flex-col gap-2", className].join(" ")}>
31
+ {(label || showValue) && (
32
+ <div className="flex items-center justify-between gap-4">
33
+ {label && (
34
+ <span className="text-[13px] font-medium text-neutral-700 truncate">{label}</span>
35
+ )}
36
+ {showValue && (
37
+ <span aria-hidden className="text-[12px] tabular-nums text-neutral-500 shrink-0">
38
+ {Math.round(pct)}%
39
+ </span>
40
+ )}
41
+ </div>
42
+ )}
43
+ <div
44
+ className="w-full h-2 rounded-full p-px overflow-hidden"
45
+ role="progressbar"
46
+ aria-label={label}
47
+ aria-valuenow={value}
48
+ aria-valuemin={0}
49
+ aria-valuemax={max}
50
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
51
+ >
52
+ <div
53
+ className="h-full rounded-full relative overflow-hidden"
54
+ style={{
55
+ width: `${displayed}%`,
56
+ /* Never collapses below a pill, so 1% still reads as started */
57
+ minWidth: displayed > 0 ? 6 : 0,
58
+ background: "var(--face-dark)",
59
+ boxShadow: "inset 0 2px 3px -2px rgba(255,255,255,0.135), 0 0 2px rgba(0,0,0,0.2)",
60
+ transition: "width 700ms var(--ease-out-soft)",
61
+ }}
62
+ />
63
+ </div>
64
+ </div>
65
+ );
66
+ }
@@ -0,0 +1,93 @@
1
+ import React from "react";
2
+ import { CardShell } from "./CardShell";
3
+
4
+ interface QuoteCardProps {
5
+ quote: string;
6
+ author: string;
7
+ role?: string;
8
+ avatarSrc?: string;
9
+ theme?: "light" | "dark";
10
+ className?: string;
11
+ }
12
+
13
+ /* Built on CardShell: the quote owns the face; who said it sits on the rail,
14
+ so a stretched card grows the face instead of opening a gap mid-card. */
15
+ export function QuoteCard({
16
+ quote,
17
+ author,
18
+ role,
19
+ avatarSrc,
20
+ theme = "light",
21
+ className = "",
22
+ }: QuoteCardProps) {
23
+ const dark = theme === "dark";
24
+ // Handles like "@name" shouldn't render an "@" monogram
25
+ const initial = (author.match(/[\p{L}\p{N}]/u)?.[0] ?? "?").toLowerCase();
26
+
27
+ const rail = (
28
+ <figcaption className="flex items-center gap-2.5 min-w-0">
29
+ {avatarSrc ? (
30
+ // eslint-disable-next-line @next/next/no-img-element
31
+ <img
32
+ src={avatarSrc}
33
+ alt=""
34
+ loading="lazy"
35
+ className="w-7 h-7 rounded-full object-cover shrink-0"
36
+ style={{ boxShadow: dark ? "0 0 0 0.5px rgba(255,255,255,0.084)" : "0 0 0 0.5px rgba(17,17,17,0.048)" }}
37
+ />
38
+ ) : (
39
+ <span
40
+ aria-hidden
41
+ className={[
42
+ "w-7 h-7 rounded-full flex items-center justify-center text-[11.5px] font-semibold shrink-0",
43
+ dark ? "text-white" : "text-neutral-700",
44
+ ].join(" ")}
45
+ style={{
46
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
47
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
48
+ }}
49
+ >
50
+ {initial}
51
+ </span>
52
+ )}
53
+ <span className="min-w-0 truncate">
54
+ <cite
55
+ className={[
56
+ "not-italic font-semibold tracking-[-0.008em]",
57
+ dark ? "text-white" : "text-neutral-900",
58
+ ].join(" ")}
59
+ >
60
+ {author}
61
+ </cite>
62
+ {role && <span> · {role}</span>}
63
+ </span>
64
+ </figcaption>
65
+ );
66
+
67
+ return (
68
+ <CardShell as="figure" theme={theme} className={className} rail={rail} railKind="text">
69
+ <div className="flex-1 flex flex-col p-8">
70
+ <svg
71
+ aria-hidden
72
+ width="22"
73
+ height="17"
74
+ viewBox="0 0 56 44"
75
+ className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-neutral-300"].join(" ")}
76
+ >
77
+ <path
78
+ fill="currentColor"
79
+ d="M0 44V27.5C0 12.6 7.4 3.4 22.2 0l2.6 6.2C16.9 8.7 13 13.7 12.6 21H24v23H0zm32 0V27.5C32 12.6 39.4 3.4 54.2 0l2.6 6.2C48.9 8.7 45 13.7 44.6 21H56v23H32z"
80
+ />
81
+ </svg>
82
+ <blockquote
83
+ className={[
84
+ "m-0 pt-8 text-[19px] leading-[1.55] tracking-[-0.015em] text-pretty",
85
+ dark ? "text-neutral-50" : "text-neutral-900",
86
+ ].join(" ")}
87
+ >
88
+ {quote}
89
+ </blockquote>
90
+ </div>
91
+ </CardShell>
92
+ );
93
+ }