@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,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)",
47
+ boxShadow: dark ? "var(--card-dark-shadow)" : "var(--card-shadow)",
48
+ ["--rim" as string]: dark ? "var(--card-dark-rim)" : "var(--card-rim)",
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)",
55
+ boxShadow: dark ? "var(--card-face-dark-shadow)" : "var(--card-face-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-ink-muted",
70
+ ].join(" ")}
71
+ >
72
+ {rail}
73
+ </div>
74
+ </div>
75
+ )}
76
+ </Tag>
77
+ );
78
+ }
@@ -0,0 +1,296 @@
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
+ // Kept current after each render so the autoplay timer never goes stale
68
+ const goNextRef = useRef(goNext);
69
+ useEffect(() => {
70
+ goNextRef.current = goNext;
71
+ });
72
+
73
+ useEffect(() => {
74
+ if (!autoPlay || paused) return;
75
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
76
+ const id = setInterval(() => goNextRef.current(), interval);
77
+ return () => clearInterval(id);
78
+ }, [autoPlay, interval, paused]);
79
+
80
+ const onKeyDown = (e: React.KeyboardEvent) => {
81
+ if (e.key === "ArrowRight") { e.preventDefault(); goNextRef.current(); }
82
+ if (e.key === "ArrowLeft") { e.preventDefault(); goPrev(); }
83
+ };
84
+
85
+ useEffect(() => () => { if (cleanupRef.current) clearTimeout(cleanupRef.current); }, []);
86
+
87
+ // ── Swipe / pointer drag ──────────────────────────────────────────
88
+ const dragRef = useRef<{ x: number; y: number; active: boolean }>({ x: 0, y: 0, active: false });
89
+ const THRESHOLD = 48;
90
+
91
+ const onPointerDown = (e: React.PointerEvent) => {
92
+ dragRef.current = { x: e.clientX, y: e.clientY, active: true };
93
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
94
+ };
95
+
96
+ const onPointerUp = (e: React.PointerEvent) => {
97
+ if (!dragRef.current.active) return;
98
+ const dx = e.clientX - dragRef.current.x;
99
+ const dy = e.clientY - dragRef.current.y;
100
+ dragRef.current.active = false;
101
+ if (Math.abs(dx) > THRESHOLD && Math.abs(dx) > Math.abs(dy) * 1.5) {
102
+ if (dx < 0) goNextRef.current();
103
+ else goPrev();
104
+ }
105
+ };
106
+
107
+ const onPointerCancel = () => { dragRef.current.active = false; };
108
+
109
+ /* A plain slide: the incoming and outgoing slides travel together as
110
+ one strip, a full width each. The curve is a strong ease-out, so the
111
+ strip moves the instant you click and then settles — an ease-in-out
112
+ hesitates first, which reads as lag. Driven by the Web Animations API
113
+ on mount, so it never depends on a stylesheet. */
114
+ const slide = (role: Slot["role"]) => (el: HTMLDivElement | null) => {
115
+ if (!el || el.dataset.animated || slots.length < 2) return;
116
+ el.dataset.animated = "1";
117
+ if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
118
+ const sign = direction === "right" ? 1 : -1;
119
+ const frames =
120
+ role === "current"
121
+ ? [{ transform: `translateX(${sign * 100}%)` }, { transform: "translateX(0)" }]
122
+ : [{ transform: "translateX(0)" }, { transform: `translateX(${-sign * 100}%)` }];
123
+ el.animate(frames, { duration: SLIDE_MS, easing: "cubic-bezier(0.22, 1, 0.36, 1)", fill: "both" });
124
+ };
125
+
126
+ const controls = (
127
+ <div className="w-full flex items-center justify-between">
128
+ <div className="flex items-center gap-3 pl-1.5">
129
+ {/* Dots sit in a well; the active one fills over the autoplay
130
+ interval, and the fill holds while autoplay is paused */}
131
+ <div
132
+ className="flex items-center gap-1 rounded-full p-[5px]"
133
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
134
+ >
135
+ {items.map((_, i) => {
136
+ const active = i === currentIdx;
137
+ return (
138
+ <button
139
+ key={i}
140
+ onClick={() => go(i, i > currentIdxRef.current ? "right" : "left")}
141
+ className={[
142
+ "relative h-1.5 rounded-full overflow-hidden cursor-pointer",
143
+ "transition-[width,background] duration-300 ease-[var(--ease-out-soft)]",
144
+ "after:absolute after:-inset-2 after:content-['']",
145
+ active ? "w-6 bg-ink/15" : "w-1.5 bg-ink/20 hover:bg-ink/40",
146
+ ].join(" ")}
147
+ aria-label={`Go to slide ${i + 1}`}
148
+ aria-current={active ? "true" : undefined}
149
+ >
150
+ {active && (
151
+ <span
152
+ key={`${currentIdx}-${autoPlay}`}
153
+ aria-hidden
154
+ className={["absolute inset-0 rounded-full origin-left", autoPlay ? "carousel-progress" : ""].join(" ")}
155
+ style={{
156
+ background: "var(--face-inverse)",
157
+ animationDuration: `${interval}ms`,
158
+ animationPlayState: paused ? "paused" : "running",
159
+ }}
160
+ />
161
+ )}
162
+ </button>
163
+ );
164
+ })}
165
+ </div>
166
+ <span className="text-[12px] font-medium text-ink-body tabular-nums">
167
+ {currentIdx + 1}
168
+ <span className="text-ink-faint"> / {items.length}</span>
169
+ </span>
170
+ </div>
171
+
172
+ <div className="flex gap-2">
173
+ <Button variant="secondary" size="sm" square onClick={goPrev} aria-label="Previous slide">
174
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
175
+ <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
176
+ </svg>
177
+ </Button>
178
+ <Button variant="secondary" size="sm" square onClick={goNext} aria-label="Next slide">
179
+ <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
180
+ <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
181
+ </svg>
182
+ </Button>
183
+ </div>
184
+ </div>
185
+ );
186
+
187
+ /* Built on the card chassis like every other surface: slides are the
188
+ face, controls live on the rail below it. */
189
+ return (
190
+ <section
191
+ aria-roledescription="carousel"
192
+ aria-label="Carousel"
193
+ onKeyDown={onKeyDown}
194
+ onMouseEnter={() => setPaused(true)}
195
+ onMouseLeave={() => setPaused(false)}
196
+ onFocus={() => setPaused(true)}
197
+ onBlur={(e) => {
198
+ if (!e.currentTarget.contains(e.relatedTarget as Node)) setPaused(false);
199
+ }}
200
+ className={className}
201
+ >
202
+ <CardShell rail={controls}>
203
+ <div
204
+ className="relative w-full overflow-hidden cursor-grab active:cursor-grabbing touch-pan-y"
205
+ style={{ minHeight: 300 }}
206
+ aria-live={autoPlay && !paused ? "off" : "polite"}
207
+ onPointerDown={onPointerDown}
208
+ onPointerUp={onPointerUp}
209
+ onPointerCancel={onPointerCancel}
210
+ >
211
+ {slots.map((slot) => (
212
+ <div
213
+ key={slot.key}
214
+ role="group"
215
+ aria-roledescription="slide"
216
+ aria-label={`${slot.itemIndex + 1} of ${items.length}`}
217
+ aria-hidden={slot.role === "prev"}
218
+ ref={slide(slot.role)}
219
+ className={["absolute inset-0 will-change-transform [backface-visibility:hidden]", slot.role === "prev" ? "pointer-events-none" : ""].join(" ")}
220
+ >
221
+ {items[slot.itemIndex]}
222
+ </div>
223
+ ))}
224
+ </div>
225
+ </CardShell>
226
+ </section>
227
+ );
228
+ }
229
+
230
+ type SlideTheme = "dark" | "light" | "tint";
231
+
232
+ interface CarouselSlideProps {
233
+ theme?: SlideTheme;
234
+ /** Background for the "tint" theme */
235
+ tint?: string;
236
+ icon?: React.ReactNode;
237
+ title: React.ReactNode;
238
+ meta?: React.ReactNode;
239
+ }
240
+
241
+ /* A ready-made slide in the cards' vocabulary: the card's own material,
242
+ a raised icon tile, and the same fading dot grid as empty media. */
243
+ export function CarouselSlide({ theme = "dark", tint = "#8A8E7A", icon, title, meta }: CarouselSlideProps) {
244
+ const dark = theme !== "light";
245
+ return (
246
+ <div
247
+ className="relative w-full h-full flex flex-col justify-end gap-6 p-8 overflow-hidden select-none"
248
+ style={{
249
+ background: theme === "dark" ? "var(--card-dark)" : theme === "light" ? "var(--card)" : tint,
250
+ }}
251
+ >
252
+ <div
253
+ aria-hidden
254
+ className="absolute inset-y-0 right-0 w-3/5"
255
+ style={{
256
+ backgroundImage: `radial-gradient(${
257
+ theme === "tint" ? "var(--media-dots-on-colour)" : dark ? "var(--media-dots-dark)" : "var(--slide-dots)"
258
+ } 1px, transparent 1px)`,
259
+ backgroundSize: "12px 12px",
260
+ maskImage: "radial-gradient(60% 70% at 70% 50%, black 0%, transparent 100%)",
261
+ WebkitMaskImage: "radial-gradient(60% 70% at 70% 50%, black 0%, transparent 100%)",
262
+ }}
263
+ />
264
+ {icon && (
265
+ <div
266
+ aria-hidden
267
+ className={[
268
+ "relative w-9 h-9 rounded-[11px] flex items-center justify-center mb-auto",
269
+ dark ? "text-neutral-100" : "text-ink-label",
270
+ ].join(" ")}
271
+ style={{
272
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
273
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
274
+ }}
275
+ >
276
+ {icon}
277
+ </div>
278
+ )}
279
+ <div className="relative">
280
+ <p
281
+ className={[
282
+ "text-[22px] font-medium leading-[1.2] tracking-[-0.015em] mb-2",
283
+ dark ? "text-white" : "text-ink",
284
+ ].join(" ")}
285
+ >
286
+ {title}
287
+ </p>
288
+ {meta && (
289
+ <p className={["text-[13px]", theme === "tint" ? "text-white/65" : dark ? "text-neutral-400" : "text-ink-muted"].join(" ")}>
290
+ {meta}
291
+ </p>
292
+ )}
293
+ </div>
294
+ </div>
295
+ );
296
+ }
@@ -0,0 +1,104 @@
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 group/checkbox",
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-ink leading-snug">{label}</span>
52
+ )}
53
+ {description && (
54
+ <span id={`${uid}-desc`} className="text-[12px] text-ink-muted 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 text-ink-inverse",
69
+ "transition-[box-shadow,transform,scale] duration-200 ease-out active:scale-90",
70
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
71
+ // an empty box answers the pointer like any other field
72
+ active ? "" : "group-hover/checkbox:[box-shadow:var(--field-shadow-hover)]",
73
+ ].join(" ")}
74
+ style={{
75
+ background: active ? "var(--face-inverse)" : "var(--field)",
76
+ boxShadow: active ? "var(--face-inverse-shadow)" : "var(--field-shadow)",
77
+ }}
78
+ >
79
+ {active && (
80
+ indeterminate ? (
81
+ <span className="animate-checkbox-in">
82
+ <svg width="10" height="2" viewBox="0 0 10 2" fill="none">
83
+ <rect width="10" height="2" rx="1" fill="currentColor" />
84
+ </svg>
85
+ </span>
86
+ ) : (
87
+ /* The mark is pressed into the face: it takes the inverse ink, so it
88
+ reads on the dark face in light mode and the pale one in dark */
89
+ <svg width="13" height="10" viewBox="0 0 13 10" fill="none" style={{ filter: "drop-shadow(var(--emboss-inverse))" }}>
90
+ <path
91
+ d="M1.5 5l3.5 3.5L11.5 1"
92
+ stroke="currentColor"
93
+ strokeWidth="2"
94
+ strokeLinecap="round"
95
+ strokeLinejoin="round"
96
+ className="animate-draw-check"
97
+ />
98
+ </svg>
99
+ )
100
+ )}
101
+ </button>
102
+ </div>
103
+ );
104
+ }
@@ -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-ink-faint hover:text-ink focus-visible:outline-ink",
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)] [box-shadow:var(--face-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
+ }