@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,78 @@
1
+ import React from "react";
2
+
3
+ type Theme = "light" | "dark";
4
+
5
+ interface CardShellProps {
6
+ theme?: Theme;
7
+ /** Controls and context: actions, attribution, deltas. Lives on the chassis. */
8
+ rail?: React.ReactNode;
9
+ /** "actions" for buttons; "text" for plain context, indented to the face's text edge */
10
+ railKind?: "actions" | "text";
11
+ as?: "div" | "figure" | "article";
12
+ className?: string;
13
+ faceClassName?: string;
14
+ children: React.ReactNode;
15
+ }
16
+
17
+ /* Every card is built like every other object in the kit: a chassis (bezel)
18
+ holding a face. Content goes on the face; controls and context go on the
19
+ rail, the strip of chassis below it. The two are nearly the same tone —
20
+ the hairline around the face is what separates them.
21
+
22
+ Geometry, measured from the card's outer edge:
23
+ - chassis 24px radius, 6px bezel → face 18px radius (concentric)
24
+ - rail content sits 12px in from the left and bottom, 10px below the face
25
+ - a button's label then starts 30px in — the same edge as the face's text,
26
+ and plain-text rails are indented to that edge too */
27
+ export function CardShell({
28
+ theme = "light",
29
+ rail,
30
+ railKind = "actions",
31
+ as: Tag = "div",
32
+ className = "",
33
+ faceClassName = "",
34
+ children,
35
+ }: CardShellProps) {
36
+ const dark = theme === "dark";
37
+
38
+ return (
39
+ <Tag
40
+ className={[
41
+ "rim relative m-0 rounded-[24px] p-1.5 flex flex-col h-full",
42
+ rail ? "pb-0" : "",
43
+ className,
44
+ ].join(" ")}
45
+ style={{
46
+ background: dark ? "var(--card-bezel-dark)" : "var(--card-bezel-light)",
47
+ boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-light-shadow)",
48
+ ["--rim" as string]: dark ? "rgba(255,255,255,0.1)" : "rgba(255,255,255,1)",
49
+ } as React.CSSProperties}
50
+ >
51
+ <div
52
+ className={["relative flex-1 min-h-0 rounded-[18px] overflow-hidden flex flex-col", faceClassName].join(" ")}
53
+ style={{
54
+ background: dark ? "var(--card-dark)" : "var(--card-light)",
55
+ boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-light-shadow)",
56
+ }}
57
+ >
58
+ {children}
59
+ </div>
60
+
61
+ {rail && (
62
+ <div className="relative shrink-0 px-1.5 pt-2.5 pb-3">
63
+ <div
64
+ className={[
65
+ // 40px = a small button's full height, so text rails and button
66
+ // rails come out the same height and line up across a row
67
+ "min-h-10 flex items-center gap-2 text-[12.5px]",
68
+ railKind === "text" ? "pl-[18px] pr-3 gap-2.5" : "",
69
+ dark ? "text-neutral-400" : "text-neutral-500",
70
+ ].join(" ")}
71
+ >
72
+ {rail}
73
+ </div>
74
+ </div>
75
+ )}
76
+ </Tag>
77
+ );
78
+ }
@@ -0,0 +1,292 @@
1
+ "use client";
2
+
3
+ import React, { useState, useEffect, useRef } from "react";
4
+ import { Button } from "./Button";
5
+ import { CardShell } from "./CardShell";
6
+
7
+ interface CarouselProps {
8
+ items: React.ReactNode[];
9
+ autoPlay?: boolean;
10
+ interval?: number;
11
+ className?: string;
12
+ }
13
+
14
+ const SLIDE_MS = 680;
15
+
16
+ interface Slot {
17
+ itemIndex: number;
18
+ key: string;
19
+ role: "current" | "prev";
20
+ }
21
+
22
+ export function Carousel({
23
+ items,
24
+ autoPlay = false,
25
+ interval = 4000,
26
+ className = "",
27
+ }: CarouselProps) {
28
+ const [slots, setSlots] = useState<Slot[]>([
29
+ { itemIndex: 0, key: "s-0-0", role: "current" },
30
+ ]);
31
+ const [direction, setDirection] = useState<"left" | "right">("right");
32
+ const [currentIdx, setCurrentIdx] = useState(0);
33
+ // Autoplay never moves content out from under someone looking at it
34
+ const [paused, setPaused] = useState(false);
35
+
36
+ const currentIdxRef = useRef(0);
37
+ const lockedRef = useRef(false);
38
+ const tcRef = useRef(0);
39
+ const cleanupRef = useRef<ReturnType<typeof setTimeout> | null>(null);
40
+
41
+ const go = (nextIdx: number, dir: "left" | "right") => {
42
+ if (lockedRef.current || nextIdx === currentIdxRef.current) return;
43
+
44
+ const prevIdx = currentIdxRef.current;
45
+ const tc = ++tcRef.current;
46
+
47
+ lockedRef.current = true;
48
+ currentIdxRef.current = nextIdx;
49
+
50
+ setDirection(dir);
51
+ setCurrentIdx(nextIdx);
52
+ setSlots([
53
+ { itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" },
54
+ { itemIndex: prevIdx, key: `p-${prevIdx}-${tc}`, role: "prev" },
55
+ ]);
56
+
57
+ if (cleanupRef.current) clearTimeout(cleanupRef.current);
58
+ cleanupRef.current = setTimeout(() => {
59
+ setSlots([{ itemIndex: nextIdx, key: `c-${nextIdx}-${tc}`, role: "current" }]);
60
+ lockedRef.current = false;
61
+ }, SLIDE_MS + 20);
62
+ };
63
+
64
+ const goNext = () => go((currentIdxRef.current + 1) % items.length, "right");
65
+ const goPrev = () => go((currentIdxRef.current - 1 + items.length) % items.length, "left");
66
+
67
+ const goNextRef = useRef(goNext);
68
+ goNextRef.current = goNext;
69
+
70
+ useEffect(() => {
71
+ if (!autoPlay || paused) return;
72
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
73
+ const id = setInterval(() => goNextRef.current(), interval);
74
+ return () => clearInterval(id);
75
+ }, [autoPlay, interval, paused]);
76
+
77
+ const onKeyDown = (e: React.KeyboardEvent) => {
78
+ if (e.key === "ArrowRight") { e.preventDefault(); goNextRef.current(); }
79
+ if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }
80
+ };
81
+
82
+ useEffect(() => () => { if (cleanupRef.current) clearTimeout(cleanupRef.current); }, []);
83
+
84
+ // ── Swipe / pointer drag ──────────────────────────────────────────
85
+ const dragRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });
86
+ const THRESHOLD = 48;
87
+
88
+ const onPointerDown = (e: React.PointerEvent) => {
89
+ dragRef.current = { x: e.clientX, y: e.clientY, active: true };
90
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
91
+ };
92
+
93
+ const onPointerUp = (e: React.PointerEvent) => {
94
+ if (!dragRef.current.active) return;
95
+ const dx = e.clientX - dragRef.current.x;
96
+ const dy = e.clientY - dragRef.current.y;
97
+ dragRef.current.active = false;
98
+ if (Math.abs(dx) > THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {
99
+ dx < 0 ? goNextRef.current() : goPrev();
100
+ }
101
+ };
102
+
103
+ const onPointerCancel = () => { dragRef.current.active = false; };
104
+
105
+ /* A plain slide: the incoming and outgoing slides travel together as
106
+ one strip, a full width each. The curve is a strong ease-out, so the
107
+ strip moves the instant you click and then settles — an ease-in-out
108
+ hesitates first, which reads as lag. Driven by the Web Animations API
109
+ on mount, so it never depends on a stylesheet. */
110
+ const slide = (role: Slot["role"]) => (el: HTMLDivElement | null) => {
111
+ if (!el || el.dataset.animated || slots.length < 2) return;
112
+ el.dataset.animated = "1";
113
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
114
+ const sign = direction === "right" ? 1 : -1;
115
+ const frames =
116
+ role === "current"
117
+ ? [{ transform: `translateX(${sign * 100}%)` }, { transform: "translateX(0)" }]
118
+ : [{ transform: "translateX(0)" }, { transform: `translateX(${-sign * 100}%)` }];
119
+ el.animate(frames, { duration: SLIDE_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" });
120
+ };
121
+
122
+ const controls = (
123
+ <div className="w-full flex items-center justify-between">
124
+ <div className="flex items-center gap-3 pl-1.5">
125
+ {/* Dots sit in a well; the active one fills over the autoplay
126
+ interval, and the fill holds while autoplay is paused */}
127
+ <div
128
+ className="flex items-center gap-1 rounded-full p-[5px]"
129
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
130
+ >
131
+ {items.map((_, i) => {
132
+ const active = i === currentIdx;
133
+ return (
134
+ <button
135
+ key={i}
136
+ onClick={() => go(i, i > currentIdxRef.current ? "right" : "left")}
137
+ className={[
138
+ "relative h-1.5 rounded-full overflow-hidden cursor-pointer",
139
+ "transition-[width,background] duration-300 ease-[var(--ease-out-soft)]",
140
+ "after:absolute after:-inset-2 after:content-['']",
141
+ active ? "w-6 bg-neutral-900/15" : "w-1.5 bg-neutral-900/20 hover:bg-neutral-900/40",
142
+ ].join(" ")}
143
+ aria-label={`Go to slide ${i + 1}`}
144
+ aria-current={active ? "true" : undefined}
145
+ >
146
+ {active && (
147
+ <span
148
+ key={`${currentIdx}-${autoPlay}`}
149
+ aria-hidden
150
+ className={["absolute inset-0 rounded-full origin-left", autoPlay ? "carousel-progress" : ""].join(" ")}
151
+ style={{
152
+ background: "var(--face-dark)",
153
+ animationDuration: `${interval}ms`,
154
+ animationPlayState: paused ? "paused" : "running",
155
+ }}
156
+ />
157
+ )}
158
+ </button>
159
+ );
160
+ })}
161
+ </div>
162
+ <span className="text-[12px] font-medium text-neutral-600 tabular-nums">
163
+ {currentIdx + 1}
164
+ <span className="text-neutral-400"> / {items.length}</span>
165
+ </span>
166
+ </div>
167
+
168
+ <div className="flex gap-2">
169
+ <Button variant="secondary" size="sm" square onClick={goPrev} aria-label="Previous slide">
170
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
171
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
172
+ </svg>
173
+ </Button>
174
+ <Button variant="secondary" size="sm" square onClick={goNext} aria-label="Next slide">
175
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
176
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
177
+ </svg>
178
+ </Button>
179
+ </div>
180
+ </div>
181
+ );
182
+
183
+ /* Built on the card chassis like every other surface: slides are the
184
+ face, controls live on the rail below it. */
185
+ return (
186
+ <section
187
+ aria-roledescription="carousel"
188
+ aria-label="Carousel"
189
+ onKeyDown={onKeyDown}
190
+ onMouseEnter={() => setPaused(true)}
191
+ onMouseLeave={() => setPaused(false)}
192
+ onFocus={() => setPaused(true)}
193
+ onBlur={(e) => {
194
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);
195
+ }}
196
+ className={className}
197
+ >
198
+ <CardShell rail={controls}>
199
+ <div
200
+ className="relative w-full overflow-hidden cursor-grab active:cursor-grabbing touch-pan-y"
201
+ style={{ minHeight: 300 }}
202
+ aria-live={autoPlay && !paused ? "off" : "polite"}
203
+ onPointerDown={onPointerDown}
204
+ onPointerUp={onPointerUp}
205
+ onPointerCancel={onPointerCancel}
206
+ >
207
+ {slots.map((slot) => (
208
+ <div
209
+ key={slot.key}
210
+ role="group"
211
+ aria-roledescription="slide"
212
+ aria-label={`${slot.itemIndex + 1} of ${items.length}`}
213
+ aria-hidden={slot.role === "prev"}
214
+ ref={slide(slot.role)}
215
+ className={["absolute inset-0 will-change-transform [backface-visibility:hidden]", slot.role === "prev" ? "pointer-events-none" : ""].join(" ")}
216
+ >
217
+ {items[slot.itemIndex]}
218
+ </div>
219
+ ))}
220
+ </div>
221
+ </CardShell>
222
+ </section>
223
+ );
224
+ }
225
+
226
+ type SlideTheme = "dark" | "light" | "tint";
227
+
228
+ interface CarouselSlideProps {
229
+ theme?: SlideTheme;
230
+ /** Background for the "tint" theme */
231
+ tint?: string;
232
+ icon?: React.ReactNode;
233
+ title: React.ReactNode;
234
+ meta?: React.ReactNode;
235
+ }
236
+
237
+ /* A ready-made slide in the cards' vocabulary: the card's own material,
238
+ a raised icon tile, and the same fading dot grid as empty media. */
239
+ export function CarouselSlide({ theme = "dark", tint = "#8A8E7A", icon, title, meta }: CarouselSlideProps) {
240
+ const dark = theme !== "light";
241
+ return (
242
+ <div
243
+ className="relative w-full h-full flex flex-col justify-end gap-6 p-8 overflow-hidden select-none"
244
+ style={{
245
+ background: theme === "dark" ? "var(--card-dark)" : theme === "light" ? "var(--card-light)" : tint,
246
+ }}
247
+ >
248
+ <div
249
+ aria-hidden
250
+ className="absolute inset-y-0 right-0 w-3/5"
251
+ style={{
252
+ backgroundImage: `radial-gradient(${
253
+ theme === "tint" ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.08)"
254
+ } 1px, transparent 1px)`,
255
+ backgroundSize: "12px 12px",
256
+ maskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
257
+ WebkitMaskImage: "radial-gradient(60% 70% at 70% 50%, #000 0%, transparent 100%)",
258
+ }}
259
+ />
260
+ {icon && (
261
+ <div
262
+ aria-hidden
263
+ className={[
264
+ "relative w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
265
+ dark ? "text-neutral-100" : "text-neutral-700",
266
+ ].join(" ")}
267
+ style={{
268
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
269
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
270
+ }}
271
+ >
272
+ {icon}
273
+ </div>
274
+ )}
275
+ <div className="relative">
276
+ <p
277
+ className={[
278
+ "text-[22px] font-medium leading-[1.2] tracking-[-0.015em] mb-2",
279
+ dark ? "text-white" : "text-neutral-900",
280
+ ].join(" ")}
281
+ >
282
+ {title}
283
+ </p>
284
+ {meta && (
285
+ <p className={["text-[13px]", theme === "tint" ? "text-white/65" : dark ? "text-neutral-400" : "text-neutral-500"].join(" ")}>
286
+ {meta}
287
+ </p>
288
+ )}
289
+ </div>
290
+ </div>
291
+ );
292
+ }
@@ -0,0 +1,99 @@
1
+ "use client";
2
+
3
+ import React, { useState, useId } from "react";
4
+
5
+ interface CheckboxProps {
6
+ checked?: boolean;
7
+ defaultChecked?: boolean;
8
+ onChange?: (checked: boolean) => void;
9
+ label?: string;
10
+ description?: string;
11
+ disabled?: boolean;
12
+ indeterminate?: boolean;
13
+ className?: string;
14
+ }
15
+
16
+ export function Checkbox({
17
+ checked,
18
+ defaultChecked = false,
19
+ onChange,
20
+ label,
21
+ description,
22
+ disabled = false,
23
+ indeterminate = false,
24
+ className = "",
25
+ }: CheckboxProps) {
26
+ const [internal, setInternal] = useState(defaultChecked);
27
+ const uid = useId();
28
+ const isChecked = checked !== undefined ? checked : internal;
29
+
30
+ const toggle = () => {
31
+ if (disabled) return;
32
+ const next = !isChecked;
33
+ setInternal(next);
34
+ onChange?.(next);
35
+ };
36
+
37
+ const active = isChecked || indeterminate;
38
+
39
+ return (
40
+ <div
41
+ className={[
42
+ "flex items-center justify-between gap-4 select-none",
43
+ disabled ? "opacity-40 pointer-events-none" : "cursor-pointer",
44
+ className,
45
+ ].join(" ")}
46
+ onClick={toggle}
47
+ >
48
+ {(label || description) && (
49
+ <div className="flex flex-col gap-0.5 min-w-0">
50
+ {label && (
51
+ <span id={`${uid}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">{label}</span>
52
+ )}
53
+ {description && (
54
+ <span id={`${uid}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">{description}</span>
55
+ )}
56
+ </div>
57
+ )}
58
+
59
+ {/* Clicks bubble to the row, so the control itself has no onClick */}
60
+ <button
61
+ type="button"
62
+ role="checkbox"
63
+ aria-checked={indeterminate ? "mixed" : isChecked}
64
+ aria-labelledby={label ? `${uid}-label` : undefined}
65
+ aria-describedby={description ? `${uid}-desc` : undefined}
66
+ disabled={disabled}
67
+ className={[
68
+ "shrink-0 w-[22px] h-[22px] rounded-[7px] flex items-center justify-center cursor-pointer",
69
+ "transition-[box-shadow,transform,scale] duration-200 ease-out active:scale-90",
70
+ ].join(" ")}
71
+ style={{
72
+ background: active ? "var(--face-dark)" : "var(--field)",
73
+ boxShadow: active ? "var(--face-dark-shadow)" : "var(--field-shadow)",
74
+ }}
75
+ >
76
+ {active && (
77
+ indeterminate ? (
78
+ <span className="animate-checkbox-in">
79
+ <svg width="10" height="2" viewBox="0 0 10 2" fill="none">
80
+ <rect width="10" height="2" rx="1" fill="white" />
81
+ </svg>
82
+ </span>
83
+ ) : (
84
+ <svg width="13" height="10" viewBox="0 0 13 10" fill="none">
85
+ <path
86
+ d="M1.5 5l3.5 3.5L11.5 1"
87
+ stroke="white"
88
+ strokeWidth="2"
89
+ strokeLinecap="round"
90
+ strokeLinejoin="round"
91
+ className="animate-draw-check"
92
+ />
93
+ </svg>
94
+ )
95
+ )}
96
+ </button>
97
+ </div>
98
+ );
99
+ }
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+
5
+ interface CloseButtonProps {
6
+ onClick?: () => void;
7
+ label?: string;
8
+ size?: "sm" | "md";
9
+ tone?: "light" | "dark";
10
+ className?: string;
11
+ }
12
+
13
+ const sizes = {
14
+ sm: { box: "w-6 h-6 rounded-[7px]", icon: 9 },
15
+ md: { box: "w-7 h-7 rounded-[8px]", icon: 10 },
16
+ };
17
+
18
+ /* Quiet until hovered, then it rises into a raised face like every other
19
+ pressable thing — so it reads as a control, not decoration. */
20
+ export function CloseButton({
21
+ onClick,
22
+ label = "Close",
23
+ size = "md",
24
+ tone = "light",
25
+ className = "",
26
+ }: CloseButtonProps) {
27
+ const dark = tone === "dark";
28
+ const s = sizes[size];
29
+
30
+ return (
31
+ <button
32
+ type="button"
33
+ onClick={onClick}
34
+ aria-label={label}
35
+ className={[
36
+ "group/close relative shrink-0 flex items-center justify-center cursor-pointer",
37
+ "transition-[color,background,scale] duration-150 active:scale-95",
38
+ "focus-visible:outline-2 focus-visible:outline-offset-2",
39
+ dark
40
+ ? "text-white/45 hover:text-white focus-visible:outline-white"
41
+ : "text-neutral-400 hover:text-neutral-900 focus-visible:outline-neutral-900",
42
+ s.box,
43
+ className,
44
+ ].join(" ")}
45
+ >
46
+ <span
47
+ aria-hidden
48
+ className={[
49
+ "absolute inset-0 rounded-[inherit] opacity-0 group-hover/close:opacity-100 transition-opacity duration-150",
50
+ dark
51
+ ? "[background:var(--tile-dark)] [box-shadow:var(--tile-dark-shadow)]"
52
+ : "[background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
53
+ ].join(" ")}
54
+ />
55
+ <svg
56
+ className="relative"
57
+ width={s.icon}
58
+ height={s.icon}
59
+ fill="none"
60
+ stroke="currentColor"
61
+ strokeWidth="2.5"
62
+ viewBox="0 0 24 24"
63
+ >
64
+ <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
65
+ </svg>
66
+ </button>
67
+ );
68
+ }