@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,372 @@
1
+ "use client";
2
+
3
+ import React, { useState, useRef, useCallback } from "react";
4
+
5
+ type ButtonVariant = "primary" | "secondary" | "ghost" | "outline";
6
+ type ButtonSize = "sm" | "md" | "lg" | "icon";
7
+
8
+ interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
9
+ variant?: ButtonVariant;
10
+ size?: ButtonSize;
11
+ children: React.ReactNode;
12
+ fullWidth?: boolean;
13
+ /** Surface the button sits on. On dark cards, secondary/outline switch to a
14
+ dark face so they don't glare against the card. */
15
+ tone?: "light" | "dark";
16
+ /** Equal width and height, for icon-only layered buttons. */
17
+ square?: boolean;
18
+ }
19
+
20
+ /* Layered buttons are a raised face seated in a machined bezel. The bezel
21
+ never moves; the face lifts on hover and sinks flush when pressed. */
22
+ const shellConfig: Record<
23
+ string,
24
+ {
25
+ bezel: string;
26
+ bezelShadow: string;
27
+ face: string;
28
+ faceShadow: string;
29
+ faceHoverShadow: string;
30
+ facePressedShadow: string;
31
+ text: string;
32
+ textShadow: string;
33
+ fill: string;
34
+ }
35
+ > = {
36
+ primary: {
37
+ bezel: "var(--bezel-dark)",
38
+ bezelShadow: "var(--bezel-dark-shadow)",
39
+ face: "var(--face-dark)",
40
+ faceShadow: "var(--face-dark-shadow)",
41
+ faceHoverShadow:
42
+ "0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135), inset 0 -2px 3px -2px rgba(0,0,0,0.25)",
43
+ facePressedShadow: "var(--face-dark-pressed)",
44
+ text: "text-white",
45
+ textShadow: "0 -1px 0 rgba(0,0,0,0.45)",
46
+ fill: "bg-white/[0.05]",
47
+ },
48
+ secondary: {
49
+ bezel: "var(--bezel-light)",
50
+ bezelShadow: "var(--bezel-light-shadow)",
51
+ face: "var(--face-light)",
52
+ faceShadow: "var(--face-light-shadow)",
53
+ faceHoverShadow:
54
+ "0 0 3px rgba(17,17,17,0.136), 0 2px 3px rgba(17,17,17,0.1), 0 4px 8px -2px rgba(17,17,17,0.08), inset 0 2px 3px -2px rgba(255,255,255,1), inset 0 -2px 3px -2px rgba(17,17,17,0.035)",
55
+ facePressedShadow: "var(--face-light-pressed)",
56
+ text: "text-neutral-800",
57
+ textShadow: "0 1px 0 rgba(255,255,255,0.9)",
58
+ fill: "bg-neutral-900/[0.025]",
59
+ },
60
+ outline: {
61
+ bezel: "linear-gradient(180deg, rgba(255,255,255,0.5) 0%, rgba(17,17,17,0.04) 100%)",
62
+ bezelShadow:
63
+ "0 0 0 0.5px rgba(17,17,17,0.048), inset 0 2px 3px -2px rgba(255,255,255,0.45)",
64
+ face: "var(--face-light)",
65
+ faceShadow:
66
+ "0 0 3px rgba(17,17,17,0.102), 0 1px 1px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
67
+ faceHoverShadow:
68
+ "0 0 3px rgba(17,17,17,0.136), 0 1px 2px rgba(17,17,17,0.08), 0 3px 6px -2px rgba(17,17,17,0.06), inset 0 2px 3px -2px rgba(255,255,255,1)",
69
+ facePressedShadow: "var(--face-light-pressed)",
70
+ text: "text-neutral-700",
71
+ textShadow: "0 1px 0 rgba(255,255,255,0.9)",
72
+ fill: "bg-neutral-900/[0.025]",
73
+ },
74
+ };
75
+
76
+ /* Secondary on a dark surface: a raised face a few steps lighter than the
77
+ card — present, but in the same family of tones. */
78
+ const darkSecondary = {
79
+ bezel: "linear-gradient(180deg, #2a2a2a 0%, #161616 100%)",
80
+ bezelShadow:
81
+ "0 0 0 0.5px rgba(0,0,0,0.3), 0 1px 2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.053)",
82
+ face: "linear-gradient(180deg, #353535 0%, #2a2a2a 55%, #242424 100%)",
83
+ faceShadow:
84
+ "0 0 3px rgba(0,0,0,0.35), 0 1px 1.5px rgba(0,0,0,0.4), inset 0 2px 3px -2px rgba(255,255,255,0.09), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
85
+ faceHoverShadow:
86
+ "0 0 3px rgba(0,0,0,0.35), 0 2px 3px rgba(0,0,0,0.45), 0 4px 8px -2px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.12), inset 0 -2px 3px -2px rgba(0,0,0,0.2)",
87
+ facePressedShadow: "var(--face-dark-pressed)",
88
+ text: "text-neutral-100",
89
+ textShadow: "0 -1px 0 rgba(0,0,0,0.4)",
90
+ fill: "bg-white/[0.04]",
91
+ };
92
+
93
+ /* Flat buttons (ghost, icon) are a single face without a bezel. */
94
+ const flatConfig: Record<
95
+ ButtonVariant,
96
+ {
97
+ bg: string;
98
+ background?: string;
99
+ fill: string;
100
+ shadow: string;
101
+ hoverShadow: string;
102
+ pressedShadow: string;
103
+ }
104
+ > = {
105
+ primary: {
106
+ bg: "text-white",
107
+ background: "var(--face-dark)",
108
+ fill: "bg-white/[0.08]",
109
+ shadow: "var(--face-dark-shadow)",
110
+ hoverShadow:
111
+ "0 0 3px rgba(0,0,0,0.35), 0 2px 4px rgba(0,0,0,0.4), 0 6px 14px -4px rgba(0,0,0,0.35), inset 0 2px 3px -2px rgba(255,255,255,0.135)",
112
+ pressedShadow: "var(--face-dark-pressed)",
113
+ },
114
+ secondary: {
115
+ bg: "text-neutral-800",
116
+ background: "var(--face-light)",
117
+ fill: "bg-neutral-900/[0.035]",
118
+ shadow: "var(--face-light-shadow)",
119
+ hoverShadow:
120
+ "0 0 3px rgba(17,17,17,0.136), 0 2px 4px rgba(17,17,17,0.08), 0 6px 14px -4px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",
121
+ pressedShadow: "var(--face-light-pressed)",
122
+ },
123
+ ghost: {
124
+ bg: "bg-transparent text-neutral-500 hover:text-neutral-800",
125
+ fill: "bg-neutral-900/[0.05]",
126
+ shadow: "none",
127
+ hoverShadow: "inset 0 0 2px rgba(17,17,17,0.09)",
128
+ pressedShadow: "inset 0 1px 2px rgba(17,17,17,0.08)",
129
+ },
130
+ outline: {
131
+ bg: "bg-transparent text-neutral-700",
132
+ fill: "bg-neutral-900/[0.035]",
133
+ shadow: "inset 0 0 2px rgba(17,17,17,0.15)",
134
+ hoverShadow:
135
+ "inset 0 0 2px rgba(17,17,17,0.24), 0 1px 2px rgba(17,17,17,0.05)",
136
+ pressedShadow:
137
+ "inset 0 0 2px rgba(17,17,17,0.24), inset 0 1px 2px rgba(17,17,17,0.06)",
138
+ },
139
+ };
140
+
141
+ /* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */
142
+ const shellRadius: Record<ButtonSize, string> = {
143
+ sm: "rounded-[12px] p-[3px]",
144
+ md: "rounded-[13px] p-[3px]",
145
+ lg: "rounded-[15px] p-[3.5px]",
146
+ icon: "",
147
+ };
148
+
149
+ const faceRadius: Record<ButtonSize, string> = {
150
+ sm: "rounded-[9px]",
151
+ md: "rounded-[10px]",
152
+ lg: "rounded-[11.5px]",
153
+ icon: "",
154
+ };
155
+
156
+ const innerPadding: Record<ButtonSize, string> = {
157
+ sm: "px-4 py-2 text-[13px]",
158
+ md: "px-5 py-2.5 text-sm",
159
+ lg: "px-7 py-3 text-[15px]",
160
+ icon: "w-7 h-7 p-0 text-[13px]",
161
+ };
162
+
163
+ const squarePadding: Record<ButtonSize, string> = {
164
+ sm: "w-9 h-9 p-0 text-[13px]",
165
+ md: "w-10 h-10 p-0 text-sm",
166
+ lg: "w-12 h-12 p-0 text-[15px]",
167
+ icon: "",
168
+ };
169
+
170
+ const flatSizeStyles: Record<ButtonSize, string> = {
171
+ sm: "px-4 py-2.5 text-[13px] rounded-[10px]",
172
+ md: "px-5 py-3 text-sm rounded-[10px]",
173
+ lg: "px-7 py-3.5 text-[15px] rounded-xl",
174
+ icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",
175
+ };
176
+
177
+ export function Button({
178
+ variant = "primary",
179
+ size = "md",
180
+ fullWidth = false,
181
+ tone = "light",
182
+ square = false,
183
+ className = "",
184
+ children,
185
+ disabled,
186
+ onClick,
187
+ onPointerDown,
188
+ onPointerUp,
189
+ onPointerLeave,
190
+ onPointerCancel,
191
+ onPointerEnter,
192
+ style,
193
+ ...rest
194
+ }: ButtonProps) {
195
+ const [pressed, setPressed] = useState(false);
196
+ const [hovered, setHovered] = useState(false);
197
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
198
+
199
+ /* The press state is the button's own; handlers passed in run alongside
200
+ it rather than replacing it */
201
+ const handlePointerDown = useCallback(
202
+ (e: React.PointerEvent<HTMLButtonElement>) => {
203
+ if (timerRef.current) clearTimeout(timerRef.current);
204
+ setPressed(true);
205
+ onPointerDown?.(e);
206
+ },
207
+ [onPointerDown],
208
+ );
209
+
210
+ const handlePointerUp = useCallback(
211
+ (e: React.PointerEvent<HTMLButtonElement>) => {
212
+ timerRef.current = setTimeout(() => setPressed(false), 150);
213
+ onPointerUp?.(e);
214
+ },
215
+ [onPointerUp],
216
+ );
217
+
218
+ const handlePointerCancel = useCallback(
219
+ (e: React.PointerEvent<HTMLButtonElement>) => {
220
+ timerRef.current = setTimeout(() => setPressed(false), 150);
221
+ onPointerCancel?.(e);
222
+ },
223
+ [onPointerCancel],
224
+ );
225
+
226
+ const handlePointerLeave = useCallback(
227
+ (e: React.PointerEvent<HTMLButtonElement>) => {
228
+ setHovered(false);
229
+ if (timerRef.current) clearTimeout(timerRef.current);
230
+ timerRef.current = setTimeout(() => setPressed(false), 150);
231
+ onPointerLeave?.(e);
232
+ },
233
+ [onPointerLeave],
234
+ );
235
+
236
+ const layered = variant !== "ghost" && size !== "icon";
237
+
238
+ const handlers = {
239
+ onClick,
240
+ onPointerDown: handlePointerDown,
241
+ onPointerUp: handlePointerUp,
242
+ onPointerLeave: handlePointerLeave,
243
+ onPointerCancel: handlePointerCancel,
244
+ onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {
245
+ setHovered(true);
246
+ onPointerEnter?.(e);
247
+ },
248
+ };
249
+
250
+ if (layered) {
251
+ const cfg =
252
+ tone === "dark" && (variant === "secondary" || variant === "outline")
253
+ ? darkSecondary
254
+ : shellConfig[variant];
255
+ const faceShadow = pressed
256
+ ? cfg.facePressedShadow
257
+ : hovered
258
+ ? cfg.faceHoverShadow
259
+ : cfg.faceShadow;
260
+
261
+ return (
262
+ <button
263
+ data-btn
264
+ className={[
265
+ "group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",
266
+ shellRadius[size],
267
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
268
+ "disabled:opacity-40 disabled:pointer-events-none",
269
+ fullWidth ? "w-full" : "",
270
+ className,
271
+ ]
272
+ .filter(Boolean)
273
+ .join(" ")}
274
+ style={{
275
+ background: cfg.bezel,
276
+ boxShadow: cfg.bezelShadow,
277
+ ...style,
278
+ }}
279
+ disabled={disabled}
280
+ {...handlers}
281
+ {...rest}
282
+ >
283
+ <span
284
+ className={[
285
+ "btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",
286
+ faceRadius[size],
287
+ cfg.text,
288
+ square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),
289
+ ].join(" ")}
290
+ style={{
291
+ background: cfg.face,
292
+ boxShadow: faceShadow,
293
+ textShadow: cfg.textShadow,
294
+ transform: pressed
295
+ ? "translateY(0.5px) scale(0.985)"
296
+ : hovered
297
+ ? "translateY(-0.5px)"
298
+ : "none",
299
+ transition:
300
+ "transform 240ms var(--ease-spring), box-shadow 200ms ease-out",
301
+ }}
302
+ >
303
+ <span
304
+ aria-hidden="true"
305
+ className={[
306
+ "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
307
+ pressed
308
+ ? "scale-y-100"
309
+ : "scale-y-0 group-hover/btn:scale-y-100",
310
+ cfg.fill,
311
+ ].join(" ")}
312
+ />
313
+ <span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">
314
+ {children}
315
+ </span>
316
+ </span>
317
+ </button>
318
+ );
319
+ }
320
+
321
+ const cfg = flatConfig[variant];
322
+ const currentShadow = pressed
323
+ ? cfg.pressedShadow
324
+ : hovered
325
+ ? cfg.hoverShadow
326
+ : cfg.shadow;
327
+
328
+ return (
329
+ <button
330
+ data-btn
331
+ className={[
332
+ "btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",
333
+ "transition-colors duration-200 ease-out",
334
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
335
+ "disabled:opacity-40 disabled:pointer-events-none",
336
+ cfg.bg,
337
+ flatSizeStyles[size],
338
+ fullWidth ? "w-full" : "",
339
+ className,
340
+ ]
341
+ .filter(Boolean)
342
+ .join(" ")}
343
+ style={{
344
+ background: cfg.background,
345
+ boxShadow: currentShadow,
346
+ transform: pressed
347
+ ? "translateY(0.5px) scale(0.97)"
348
+ : hovered && cfg.background
349
+ ? "translateY(-0.5px)"
350
+ : "none",
351
+ transition:
352
+ "transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",
353
+ ...style,
354
+ }}
355
+ disabled={disabled}
356
+ {...handlers}
357
+ {...rest}
358
+ >
359
+ <span
360
+ aria-hidden="true"
361
+ className={[
362
+ "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
363
+ pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",
364
+ cfg.fill,
365
+ ].join(" ")}
366
+ />
367
+ <span className="relative z-10 inline-flex items-center gap-1.5">
368
+ {children}
369
+ </span>
370
+ </button>
371
+ );
372
+ }
@@ -0,0 +1,82 @@
1
+ import React from "react";
2
+
3
+ export interface ButtonGroupItem {
4
+ label: string;
5
+ content: React.ReactNode;
6
+ onClick?: () => void;
7
+ href?: string;
8
+ /** Sized to its content; otherwise a square key for a lone icon */
9
+ wide?: boolean;
10
+ }
11
+
12
+ interface ButtonGroupProps {
13
+ items: ButtonGroupItem[];
14
+ label: string;
15
+ className?: string;
16
+ }
17
+
18
+ /* One bezel, one face, several keys: a linked control in the same material
19
+ as Button (sm), split by grooves rather than gaps. Each key fills from the
20
+ bottom on hover and sinks when pressed, like a layered Button's face. */
21
+ export function ButtonGroup({ items, label, className = "" }: ButtonGroupProps) {
22
+ return (
23
+ <div
24
+ role="group"
25
+ aria-label={label}
26
+ className={["inline-flex w-fit rounded-[12px] p-[3px]", className].join(" ")}
27
+ style={{ background: "var(--bezel-light)", boxShadow: "var(--bezel-light-shadow)" }}
28
+ >
29
+ <div
30
+ className="flex rounded-[9px] overflow-hidden"
31
+ style={{ background: "var(--face-light)", boxShadow: "var(--face-light-shadow)" }}
32
+ >
33
+ {items.map((item, i) => {
34
+ const keyClass = [
35
+ "group/key relative h-9 inline-flex items-center justify-center gap-1.5 cursor-pointer",
36
+ "text-[13px] font-medium text-neutral-800 outline-none",
37
+ "transition-[box-shadow,background] duration-150",
38
+ "active:bg-neutral-900/[0.04] active:[box-shadow:inset_0_1px_2px_rgba(17,17,17,0.08)]",
39
+ "focus-visible:[box-shadow:inset_0_0_0_2px_rgba(17,17,17,0.85)]",
40
+ item.wide ? "px-4" : "w-10",
41
+ ].join(" ");
42
+ const inner = (
43
+ <>
44
+ <span
45
+ aria-hidden
46
+ className="pointer-events-none absolute inset-0 origin-bottom scale-y-0 bg-neutral-900/[0.025] transition-transform duration-300 ease-out group-hover/key:scale-y-100"
47
+ />
48
+ <span className="relative inline-flex items-center gap-1.5">{item.content}</span>
49
+ </>
50
+ );
51
+ const common = {
52
+ "aria-label": item.label,
53
+ title: item.wide ? undefined : item.label,
54
+ className: keyClass,
55
+ style: { textShadow: "0 1px 0 rgba(255,255,255,0.9)" },
56
+ };
57
+
58
+ return (
59
+ <React.Fragment key={item.label}>
60
+ {i > 0 && (
61
+ <span
62
+ aria-hidden
63
+ className="w-px self-stretch my-2 bg-neutral-900/[0.08]"
64
+ style={{ boxShadow: "1px 0 0 rgba(255,255,255,0.9)" }}
65
+ />
66
+ )}
67
+ {item.href ? (
68
+ <a href={item.href} {...common}>
69
+ {inner}
70
+ </a>
71
+ ) : (
72
+ <button type="button" onClick={item.onClick} {...common}>
73
+ {inner}
74
+ </button>
75
+ )}
76
+ </React.Fragment>
77
+ );
78
+ })}
79
+ </div>
80
+ </div>
81
+ );
82
+ }
@@ -0,0 +1,241 @@
1
+ import React from "react";
2
+ import { CardShell } from "./CardShell";
3
+
4
+ type CardTheme = "light" | "dark";
5
+
6
+ interface SplitCardProps {
7
+ title?: string;
8
+ subtitle?: string;
9
+ icon?: React.ReactNode;
10
+ image?: React.ReactNode;
11
+ imageSrc?: string;
12
+ imageAlt?: string;
13
+ actions?: React.ReactNode;
14
+ theme?: CardTheme;
15
+ className?: string;
16
+ children?: React.ReactNode;
17
+ layout?: "split";
18
+ }
19
+
20
+ interface StackCardProps {
21
+ title?: string;
22
+ badge?: React.ReactNode;
23
+ subtitle?: string;
24
+ note?: string;
25
+ image?: React.ReactNode;
26
+ imageSrc?: string;
27
+ imageAlt?: string;
28
+ imageBg?: string;
29
+ actions?: React.ReactNode;
30
+ className?: string;
31
+ children?: React.ReactNode;
32
+ layout: "stack";
33
+ }
34
+
35
+ type CardProps = SplitCardProps | StackCardProps;
36
+
37
+ /* Both layouts use CardShell: content on the face, actions on the rail.
38
+ Media sits flush in the face — the face's own corners frame it, so there
39
+ is no box-inside-a-box. */
40
+
41
+ function MediaPlaceholder({ dark, bg, monogram }: { dark?: boolean; bg?: string; monogram?: string }) {
42
+ return (
43
+ <div
44
+ className={["relative w-full h-full flex items-center justify-center", bg ?? ""].join(" ")}
45
+ style={bg ? undefined : { background: dark ? "var(--media-well-dark)" : "var(--media-well-light)" }}
46
+ >
47
+ {/* A faint dot grid fading out from the centre: a slot, not a hole */}
48
+ <div
49
+ aria-hidden
50
+ className="absolute inset-0"
51
+ style={{
52
+ backgroundImage: `radial-gradient(${
53
+ bg ? "rgba(255,255,255,0.22)" : dark ? "rgba(255,255,255,0.09)" : "rgba(17,17,17,0.09)"
54
+ } 1px, transparent 1px)`,
55
+ backgroundSize: "12px 12px",
56
+ maskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",
57
+ WebkitMaskImage: "radial-gradient(70% 70% at 50% 50%, #000 0%, transparent 100%)",
58
+ }}
59
+ />
60
+ {/* The title's initial, set huge and barely there and cropped by the
61
+ slot's edges: the empty half carries the card's type, not a glyph */}
62
+ {monogram && (
63
+ <span
64
+ aria-hidden
65
+ className={[
66
+ "absolute -right-3 -bottom-[0.32em] text-[220px] font-semibold leading-none tracking-[-0.06em] select-none",
67
+ dark ? "text-white/[0.045]" : "text-neutral-900/[0.045]",
68
+ ].join(" ")}
69
+ >
70
+ {monogram}
71
+ </span>
72
+ )}
73
+ </div>
74
+ );
75
+ }
76
+
77
+ function Media({
78
+ image,
79
+ imageSrc,
80
+ imageAlt,
81
+ dark,
82
+ bg,
83
+ monogram,
84
+ }: {
85
+ image?: React.ReactNode;
86
+ imageSrc?: string;
87
+ imageAlt: string;
88
+ dark?: boolean;
89
+ bg?: string;
90
+ monogram?: string;
91
+ }) {
92
+ if (image) return <>{image}</>;
93
+ if (imageSrc) {
94
+ // eslint-disable-next-line @next/next/no-img-element
95
+ return <img src={imageSrc} alt={imageAlt} loading="lazy" className="w-full h-full object-cover" />;
96
+ }
97
+ return <MediaPlaceholder dark={dark} bg={bg} monogram={monogram} />;
98
+ }
99
+
100
+ function IconTile({ dark, children }: { dark: boolean; children: React.ReactNode }) {
101
+ return (
102
+ <div
103
+ aria-hidden
104
+ className={[
105
+ "w-9 h-9 rounded-[11px] flex items-center justify-center shrink-0",
106
+ dark ? "text-neutral-100" : "text-neutral-700",
107
+ ].join(" ")}
108
+ style={{
109
+ background: dark ? "var(--tile-dark)" : "var(--face-light)",
110
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
111
+ }}
112
+ >
113
+ {children}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ export function Card(props: CardProps) {
119
+ if (props.layout === "stack") {
120
+ const {
121
+ title,
122
+ badge,
123
+ subtitle,
124
+ note,
125
+ image,
126
+ imageSrc,
127
+ imageAlt = "",
128
+ imageBg,
129
+ actions,
130
+ className = "",
131
+ children,
132
+ } = props;
133
+
134
+ return (
135
+ <CardShell
136
+ as="article"
137
+ className={className}
138
+ rail={actions}
139
+ >
140
+ {/* Hairline under the media so it reads as set into the face */}
141
+ <div className="relative h-52 shrink-0 overflow-hidden" style={{ boxShadow: "0 1px 0 rgba(17,17,17,0.07)" }}>
142
+ <Media image={image} imageSrc={imageSrc} imageAlt={imageAlt} bg={imageBg} />
143
+ </div>
144
+
145
+ <div className="flex-1 flex flex-col p-6">
146
+ {(title || badge) && (
147
+ <div className="flex items-center gap-2">
148
+ {title && (
149
+ <h3 className="text-[17px] font-semibold text-neutral-900 tracking-[-0.015em] leading-snug">{title}</h3>
150
+ )}
151
+ {badge}
152
+ </div>
153
+ )}
154
+ {subtitle && (
155
+ // Line breaks in the copy are for centred layouts; let it flow here
156
+ <p className="mt-1.5 text-[14px] text-neutral-600 leading-[1.55] text-pretty">
157
+ {subtitle.replace(/\n/g, " ")}
158
+ </p>
159
+ )}
160
+ {children}
161
+ {note && (
162
+ <p className="mt-auto pt-5 text-[12px] text-neutral-500 leading-relaxed text-pretty">{note}</p>
163
+ )}
164
+ </div>
165
+ </CardShell>
166
+ );
167
+ }
168
+
169
+ const {
170
+ title,
171
+ subtitle,
172
+ icon,
173
+ image,
174
+ imageSrc,
175
+ imageAlt = "",
176
+ actions,
177
+ theme = "light",
178
+ className = "",
179
+ children,
180
+ } = props as SplitCardProps;
181
+ const dark = theme === "dark";
182
+
183
+ return (
184
+ <CardShell
185
+ as="article"
186
+ theme={dark ? "dark" : "light"}
187
+ className={className}
188
+ faceClassName="flex-row! min-h-[220px]"
189
+ rail={actions}
190
+ >
191
+ {/* The title is the hero: display-sized and pinned to the top. The
192
+ subtitle answers it from the bottom edge, so the two frame the
193
+ face between them instead of huddling in one corner. */}
194
+ <div className="relative flex flex-col p-7 flex-1 min-w-0">
195
+ {icon && <IconTile dark={dark}>{icon}</IconTile>}
196
+ {title && (
197
+ <h3
198
+ className={[
199
+ "text-[34px] font-medium leading-[1.02] tracking-[-0.04em] text-balance max-w-[14ch]",
200
+ icon ? "mt-5" : "",
201
+ dark ? "text-white" : "text-neutral-900",
202
+ ].join(" ")}
203
+ >
204
+ {title}
205
+ </h3>
206
+ )}
207
+ <div className="mt-auto pt-8 flex flex-col gap-2">
208
+ {subtitle && (
209
+ <p className="text-[14px] leading-[1.45] tracking-[-0.005em] text-neutral-500">{subtitle}</p>
210
+ )}
211
+ {children}
212
+ </div>
213
+ </div>
214
+ <div
215
+ className="relative w-[40%] shrink-0 overflow-hidden"
216
+ style={{ boxShadow: dark ? "-1px 0 0 rgba(255,255,255,0.07)" : "-1px 0 0 rgba(17,17,17,0.07)" }}
217
+ >
218
+ <Media
219
+ image={image}
220
+ imageSrc={imageSrc}
221
+ imageAlt={imageAlt}
222
+ dark={dark}
223
+ monogram={typeof title === "string" ? title.trim().charAt(0).toLowerCase() : undefined}
224
+ />
225
+ </div>
226
+ </CardShell>
227
+ );
228
+ }
229
+
230
+ interface CardGridProps {
231
+ children: React.ReactNode;
232
+ className?: string;
233
+ }
234
+
235
+ export function CardGrid({ children, className = "" }: CardGridProps) {
236
+ return (
237
+ <div className={["grid grid-cols-1 sm:grid-cols-2 gap-4", className].join(" ")}>
238
+ {children}
239
+ </div>
240
+ );
241
+ }