@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,361 @@
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-inverse)",
38
+ bezelShadow: "var(--bezel-inverse-shadow)",
39
+ face: "var(--face-inverse)",
40
+ faceShadow: "var(--face-inverse-shadow)",
41
+ faceHoverShadow: "var(--face-inverse-shadow-hover)",
42
+ facePressedShadow: "var(--face-inverse-pressed)",
43
+ text: "text-ink-inverse",
44
+ textShadow: "var(--emboss-inverse-deep)",
45
+ fill: "bg-ink-inverse/[0.05]",
46
+ },
47
+ secondary: {
48
+ bezel: "var(--bezel)",
49
+ bezelShadow: "var(--bezel-shadow)",
50
+ face: "var(--face)",
51
+ faceShadow: "var(--face-shadow)",
52
+ faceHoverShadow: "var(--face-shadow-hover)",
53
+ facePressedShadow: "var(--face-pressed)",
54
+ text: "text-ink-soft",
55
+ textShadow: "var(--emboss)",
56
+ fill: "bg-ink/[0.025]",
57
+ },
58
+ outline: {
59
+ bezel: "var(--outline-bezel)",
60
+ bezelShadow: "var(--outline-bezel-shadow)",
61
+ face: "var(--face)",
62
+ faceShadow: "var(--outline-face-shadow)",
63
+ faceHoverShadow: "var(--outline-face-shadow-hover)",
64
+ facePressedShadow: "var(--face-pressed)",
65
+ text: "text-ink-label",
66
+ textShadow: "var(--emboss)",
67
+ fill: "bg-ink/[0.025]",
68
+ },
69
+ };
70
+
71
+ /* Secondary on a dark surface: a raised face a few steps lighter than the
72
+ card — present, but in the same family of tones. It's the mid material,
73
+ the same one secondary keys wear everywhere in the dark theme. */
74
+ const darkSecondary = {
75
+ bezel: "var(--bezel-mid)",
76
+ bezelShadow: "var(--bezel-mid-shadow)",
77
+ face: "var(--face-mid)",
78
+ faceShadow: "var(--face-mid-shadow)",
79
+ faceHoverShadow: "var(--face-mid-shadow-hover)",
80
+ facePressedShadow: "var(--face-dark-pressed)",
81
+ text: "text-neutral-100",
82
+ textShadow: "var(--emboss-inverse)",
83
+ fill: "bg-white/[0.04]",
84
+ };
85
+
86
+ /* Flat buttons (ghost, icon) are a single face without a bezel. */
87
+ const flatConfig: Record<
88
+ ButtonVariant,
89
+ {
90
+ bg: string;
91
+ background?: string;
92
+ fill: string;
93
+ shadow: string;
94
+ hoverShadow: string;
95
+ pressedShadow: string;
96
+ }
97
+ > = {
98
+ primary: {
99
+ bg: "text-ink-inverse",
100
+ background: "var(--face-inverse)",
101
+ fill: "bg-ink-inverse/[0.08]",
102
+ shadow: "var(--face-inverse-shadow)",
103
+ hoverShadow: "var(--face-inverse-float-shadow)",
104
+ pressedShadow: "var(--face-inverse-pressed)",
105
+ },
106
+ secondary: {
107
+ bg: "text-ink-soft",
108
+ background: "var(--face)",
109
+ fill: "bg-ink/[0.035]",
110
+ shadow: "var(--face-shadow)",
111
+ hoverShadow: "var(--face-float-shadow)",
112
+ pressedShadow: "var(--face-pressed)",
113
+ },
114
+ ghost: {
115
+ bg: "bg-transparent text-ink-muted hover:text-ink-soft",
116
+ fill: "bg-ink/[0.05]",
117
+ shadow: "none",
118
+ hoverShadow: "var(--ghost-shadow-hover)",
119
+ pressedShadow: "var(--ghost-shadow-pressed)",
120
+ },
121
+ outline: {
122
+ bg: "bg-transparent text-ink-label",
123
+ fill: "bg-ink/[0.035]",
124
+ shadow: "var(--outline-shadow)",
125
+ hoverShadow: "var(--outline-shadow-hover)",
126
+ pressedShadow: "var(--outline-shadow-pressed)",
127
+ },
128
+ };
129
+
130
+ /* Bezel radius minus bezel padding = face radius, so the curves stay concentric. */
131
+ const shellRadius: Record<ButtonSize, string> = {
132
+ sm: "rounded-[12px] p-[3px]",
133
+ md: "rounded-[13px] p-[3px]",
134
+ lg: "rounded-[15px] p-[3.5px]",
135
+ icon: "",
136
+ };
137
+
138
+ const faceRadius: Record<ButtonSize, string> = {
139
+ sm: "rounded-[9px]",
140
+ md: "rounded-[10px]",
141
+ lg: "rounded-[11.5px]",
142
+ icon: "",
143
+ };
144
+
145
+ const innerPadding: Record<ButtonSize, string> = {
146
+ sm: "px-4 py-2 text-[13px]",
147
+ md: "px-5 py-2.5 text-sm",
148
+ lg: "px-7 py-3 text-[15px]",
149
+ icon: "w-7 h-7 p-0 text-[13px]",
150
+ };
151
+
152
+ const squarePadding: Record<ButtonSize, string> = {
153
+ sm: "w-9 h-9 p-0 text-[13px]",
154
+ md: "w-10 h-10 p-0 text-sm",
155
+ lg: "w-12 h-12 p-0 text-[15px]",
156
+ icon: "",
157
+ };
158
+
159
+ const flatSizeStyles: Record<ButtonSize, string> = {
160
+ sm: "px-4 py-2.5 text-[13px] rounded-[10px]",
161
+ md: "px-5 py-3 text-sm rounded-[10px]",
162
+ lg: "px-7 py-3.5 text-[15px] rounded-xl",
163
+ icon: "w-8 h-8 p-0 rounded-[10px] text-[13px]",
164
+ };
165
+
166
+ export function Button({
167
+ variant = "primary",
168
+ size = "md",
169
+ fullWidth = false,
170
+ tone = "light",
171
+ square = false,
172
+ className = "",
173
+ children,
174
+ disabled,
175
+ onClick,
176
+ onPointerDown,
177
+ onPointerUp,
178
+ onPointerLeave,
179
+ onPointerCancel,
180
+ onPointerEnter,
181
+ style,
182
+ ...rest
183
+ }: ButtonProps) {
184
+ const [pressed, setPressed] = useState(false);
185
+ const [hovered, setHovered] = useState(false);
186
+ const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
187
+
188
+ /* The press state is the button's own; handlers passed in run alongside
189
+ it rather than replacing it */
190
+ const handlePointerDown = useCallback(
191
+ (e: React.PointerEvent<HTMLButtonElement>) => {
192
+ if (timerRef.current) clearTimeout(timerRef.current);
193
+ setPressed(true);
194
+ onPointerDown?.(e);
195
+ },
196
+ [onPointerDown],
197
+ );
198
+
199
+ const handlePointerUp = useCallback(
200
+ (e: React.PointerEvent<HTMLButtonElement>) => {
201
+ timerRef.current = setTimeout(() => setPressed(false), 150);
202
+ onPointerUp?.(e);
203
+ },
204
+ [onPointerUp],
205
+ );
206
+
207
+ const handlePointerCancel = useCallback(
208
+ (e: React.PointerEvent<HTMLButtonElement>) => {
209
+ timerRef.current = setTimeout(() => setPressed(false), 150);
210
+ onPointerCancel?.(e);
211
+ },
212
+ [onPointerCancel],
213
+ );
214
+
215
+ const handlePointerLeave = useCallback(
216
+ (e: React.PointerEvent<HTMLButtonElement>) => {
217
+ setHovered(false);
218
+ if (timerRef.current) clearTimeout(timerRef.current);
219
+ timerRef.current = setTimeout(() => setPressed(false), 150);
220
+ onPointerLeave?.(e);
221
+ },
222
+ [onPointerLeave],
223
+ );
224
+
225
+ const layered = variant !== "ghost" && size !== "icon";
226
+
227
+ const handlers = {
228
+ onClick,
229
+ onPointerDown: handlePointerDown,
230
+ onPointerUp: handlePointerUp,
231
+ onPointerLeave: handlePointerLeave,
232
+ onPointerCancel: handlePointerCancel,
233
+ onPointerEnter: (e: React.PointerEvent<HTMLButtonElement>) => {
234
+ setHovered(true);
235
+ onPointerEnter?.(e);
236
+ },
237
+ };
238
+
239
+ if (layered) {
240
+ const cfg =
241
+ tone === "dark" && (variant === "secondary" || variant === "outline")
242
+ ? darkSecondary
243
+ : shellConfig[variant];
244
+ const faceShadow = pressed
245
+ ? cfg.facePressedShadow
246
+ : hovered
247
+ ? cfg.faceHoverShadow
248
+ : cfg.faceShadow;
249
+
250
+ return (
251
+ <button
252
+ data-btn
253
+ className={[
254
+ "group/btn relative inline-flex cursor-pointer select-none whitespace-nowrap",
255
+ shellRadius[size],
256
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
257
+ "disabled:opacity-40 disabled:pointer-events-none",
258
+ fullWidth ? "w-full" : "",
259
+ className,
260
+ ]
261
+ .filter(Boolean)
262
+ .join(" ")}
263
+ style={{
264
+ background: cfg.bezel,
265
+ boxShadow: cfg.bezelShadow,
266
+ ...style,
267
+ }}
268
+ disabled={disabled}
269
+ {...handlers}
270
+ {...rest}
271
+ >
272
+ <span
273
+ className={[
274
+ "btn-face relative overflow-hidden flex items-center justify-center gap-1.5 font-medium",
275
+ faceRadius[size],
276
+ cfg.text,
277
+ square ? squarePadding[size] : ["w-full", innerPadding[size]].join(" "),
278
+ ].join(" ")}
279
+ style={{
280
+ background: cfg.face,
281
+ boxShadow: faceShadow,
282
+ textShadow: cfg.textShadow,
283
+ transform: pressed
284
+ ? "translateY(0.5px) scale(0.985)"
285
+ : hovered
286
+ ? "translateY(-0.5px)"
287
+ : "none",
288
+ transition:
289
+ "transform 240ms var(--ease-spring), box-shadow 200ms ease-out",
290
+ }}
291
+ >
292
+ <span
293
+ aria-hidden="true"
294
+ className={[
295
+ "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
296
+ pressed
297
+ ? "scale-y-100"
298
+ : "scale-y-0 group-hover/btn:scale-y-100",
299
+ cfg.fill,
300
+ ].join(" ")}
301
+ />
302
+ <span className="btn-icon-nudge relative z-10 inline-flex items-center gap-1.5">
303
+ {children}
304
+ </span>
305
+ </span>
306
+ </button>
307
+ );
308
+ }
309
+
310
+ const cfg = flatConfig[variant];
311
+ const currentShadow = pressed
312
+ ? cfg.pressedShadow
313
+ : hovered
314
+ ? cfg.hoverShadow
315
+ : cfg.shadow;
316
+
317
+ return (
318
+ <button
319
+ data-btn
320
+ className={[
321
+ "btn-face group/btn relative overflow-hidden inline-flex items-center justify-center gap-1.5 font-medium cursor-pointer select-none whitespace-nowrap",
322
+ "transition-colors duration-200 ease-out",
323
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
324
+ "disabled:opacity-40 disabled:pointer-events-none",
325
+ cfg.bg,
326
+ flatSizeStyles[size],
327
+ fullWidth ? "w-full" : "",
328
+ className,
329
+ ]
330
+ .filter(Boolean)
331
+ .join(" ")}
332
+ style={{
333
+ background: cfg.background,
334
+ boxShadow: currentShadow,
335
+ transform: pressed
336
+ ? "translateY(0.5px) scale(0.97)"
337
+ : hovered && cfg.background
338
+ ? "translateY(-0.5px)"
339
+ : "none",
340
+ transition:
341
+ "transform 240ms var(--ease-spring), box-shadow 200ms ease-out, color 200ms ease-out",
342
+ ...style,
343
+ }}
344
+ disabled={disabled}
345
+ {...handlers}
346
+ {...rest}
347
+ >
348
+ <span
349
+ aria-hidden="true"
350
+ className={[
351
+ "pointer-events-none absolute inset-0 origin-bottom transition-transform duration-300 ease-out",
352
+ pressed ? "scale-y-100" : "scale-y-0 group-hover/btn:scale-y-100",
353
+ cfg.fill,
354
+ ].join(" ")}
355
+ />
356
+ <span className="relative z-10 inline-flex items-center gap-1.5">
357
+ {children}
358
+ </span>
359
+ </button>
360
+ );
361
+ }
@@ -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)", boxShadow: "var(--bezel-shadow)" }}
28
+ >
29
+ <div
30
+ className="flex rounded-[9px] overflow-hidden"
31
+ style={{ background: "var(--face)", boxShadow: "var(--face-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-ink-soft outline-none",
37
+ "transition-[box-shadow,background] duration-150",
38
+ "active:bg-ink/[0.04] active:[box-shadow:var(--ghost-shadow-pressed)]",
39
+ "focus-visible:[box-shadow:var(--focus-ring-inset)]",
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-ink/[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: "var(--emboss)" },
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-groove"
64
+ style={{ boxShadow: "var(--groove-lip)" }}
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)" }}
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 ? "var(--media-dots-on-colour)" : dark ? "var(--media-dots-dark)" : "var(--media-dots)"
54
+ } 1px, transparent 1px)`,
55
+ backgroundSize: "12px 12px",
56
+ maskImage: "radial-gradient(70% 70% at 50% 50%, black 0%, transparent 100%)",
57
+ WebkitMaskImage: "radial-gradient(70% 70% at 50% 50%, black 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-ink/[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-ink-label",
107
+ ].join(" ")}
108
+ style={{
109
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
110
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-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 var(--divider)" }}>
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-ink 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-ink-body 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-ink-muted 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-ink",
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]", dark ? "text-neutral-500" : "text-ink-muted"].join(" ")}>{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 var(--divider-dark)" : "-1px 0 0 var(--divider)" }}
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
+ }