@blurrow/ui 0.2.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 (271) hide show
  1. package/README.md +48 -36
  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 +172 -5
  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 +6 -6
  199. package/dist/registry/Alert.tsx +13 -10
  200. package/dist/registry/AuthorCard.tsx +7 -7
  201. package/dist/registry/Avatar.tsx +5 -5
  202. package/dist/registry/Badge.tsx +12 -15
  203. package/dist/registry/Banner.tsx +15 -18
  204. package/dist/registry/Breadcrumb.tsx +3 -3
  205. package/dist/registry/Button.tsx +58 -69
  206. package/dist/registry/ButtonGroup.tsx +9 -9
  207. package/dist/registry/Card.tsx +15 -15
  208. package/dist/registry/CardShell.tsx +6 -6
  209. package/dist/registry/Carousel.tsx +19 -15
  210. package/dist/registry/Checkbox.tsx +14 -9
  211. package/dist/registry/CloseButton.tsx +2 -2
  212. package/dist/registry/CodeBlock.tsx +15 -15
  213. package/dist/registry/ColorPicker.tsx +404 -0
  214. package/dist/registry/Combobox.tsx +318 -0
  215. package/dist/registry/CommandPalette.tsx +31 -24
  216. package/dist/registry/ContextMenu.tsx +241 -0
  217. package/dist/registry/DataList.tsx +4 -4
  218. package/dist/registry/DatePicker.tsx +494 -0
  219. package/dist/registry/DateRangePicker.tsx +232 -0
  220. package/dist/registry/Drawer.tsx +17 -15
  221. package/dist/registry/DropdownMenu.tsx +39 -77
  222. package/dist/registry/EmptyState.tsx +8 -8
  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 +54 -43
  228. package/dist/registry/Kbd.tsx +4 -5
  229. package/dist/registry/Modal.tsx +17 -15
  230. package/dist/registry/NumberInput.tsx +183 -0
  231. package/dist/registry/OtpInput.tsx +219 -0
  232. package/dist/registry/Pagination.tsx +15 -18
  233. package/dist/registry/Popover.tsx +40 -45
  234. package/dist/registry/Progress.tsx +4 -4
  235. package/dist/registry/QuoteCard.tsx +7 -7
  236. package/dist/registry/Radio.tsx +24 -21
  237. package/dist/registry/RadioCards.tsx +247 -0
  238. package/dist/registry/ReactionBar.tsx +5 -5
  239. package/dist/registry/ReadingProgress.tsx +2 -2
  240. package/dist/registry/ReadingTime.tsx +2 -2
  241. package/dist/registry/Select.tsx +28 -53
  242. package/dist/registry/Separator.tsx +52 -0
  243. package/dist/registry/ShareButtons.tsx +2 -2
  244. package/dist/registry/Sidebar.tsx +18 -18
  245. package/dist/registry/Skeleton.tsx +1 -1
  246. package/dist/registry/Slider.tsx +229 -0
  247. package/dist/registry/Spinner.tsx +32 -0
  248. package/dist/registry/SpotlightCard.tsx +6 -6
  249. package/dist/registry/Stat.tsx +4 -4
  250. package/dist/registry/StatCard.tsx +19 -19
  251. package/dist/registry/Steps.tsx +7 -7
  252. package/dist/registry/Table.tsx +12 -10
  253. package/dist/registry/TableOfContents.tsx +8 -8
  254. package/dist/registry/Tabs.tsx +6 -6
  255. package/dist/registry/TagInput.tsx +9 -9
  256. package/dist/registry/Textarea.tsx +5 -5
  257. package/dist/registry/TimePicker.tsx +315 -0
  258. package/dist/registry/Timeline.tsx +14 -14
  259. package/dist/registry/Toast.tsx +15 -12
  260. package/dist/registry/Toggle.tsx +7 -10
  261. package/dist/registry/Tooltip.tsx +21 -73
  262. package/dist/registry/styles.css +868 -62
  263. package/dist/registry/useFloatingPanel.ts +112 -0
  264. package/dist/styles.css +868 -62
  265. package/dist/useFloatingPanel.d.ts +24 -0
  266. package/dist/useFloatingPanel.js +89 -0
  267. package/dist/useFloatingPanel.js.map +1 -0
  268. package/dist/useModalBehavior.d.ts +15 -0
  269. package/dist/useModalBehavior.js +57 -0
  270. package/dist/useModalBehavior.js.map +1 -0
  271. package/package.json +1 -1
@@ -20,8 +20,8 @@ export function Pagination({
20
20
  const containerRef = useRef<HTMLDivElement>(null);
21
21
  const btnRefs = useRef<Record<string, HTMLButtonElement | null>>({});
22
22
 
23
- const measurePill = () => {
24
- const btn = btnRefs.current[String(currentPage)];
23
+ const measurePill = (page: number) => {
24
+ const btn = btnRefs.current[String(page)];
25
25
  const container = containerRef.current;
26
26
  if (!btn || !container) return;
27
27
  const cr = container.getBoundingClientRect();
@@ -29,18 +29,15 @@ export function Pagination({
29
29
  setPill({ left: br.left - cr.left, width: br.width, ready: true });
30
30
  };
31
31
 
32
- useLayoutEffect(() => {
33
- measurePill();
34
- }, []);
35
-
36
32
  /* Only the thumb moves: it glides to the new page without overshoot,
37
- and the numbers themselves stay still. */
38
- useEffect(() => {
39
- measurePill();
33
+ and the numbers themselves stay still. Measured before paint, so it
34
+ is never seen out of place. */
35
+ useLayoutEffect(() => {
36
+ measurePill(currentPage);
40
37
  }, [currentPage]);
41
38
 
42
39
  useEffect(() => {
43
- const ro = new ResizeObserver(() => measurePill());
40
+ const ro = new ResizeObserver(() => measurePill(currentPage));
44
41
  if (containerRef.current) ro.observe(containerRef.current);
45
42
  return () => ro.disconnect();
46
43
  }, [currentPage]);
@@ -58,9 +55,9 @@ export function Pagination({
58
55
  onClick={() => onPageChange(currentPage - 1)}
59
56
  disabled={currentPage <= 1}
60
57
  aria-label="Previous page"
61
- className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
58
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
62
59
  >
63
- <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />
60
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
64
61
  <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
65
62
  <path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" />
66
63
  </svg>
@@ -75,7 +72,7 @@ export function Pagination({
75
72
  style={{
76
73
  left: pill.left,
77
74
  width: pill.width,
78
- background: "var(--face-light)",
75
+ background: "var(--face)",
79
76
  boxShadow: "var(--thumb-shadow)",
80
77
  transition: "left 340ms var(--ease-out-soft), width 340ms var(--ease-out-soft)",
81
78
  }}
@@ -87,7 +84,7 @@ export function Pagination({
87
84
  <span
88
85
  key={`dot-${i}`}
89
86
  aria-hidden
90
- className="w-8 h-8 flex items-center justify-center text-[11px] text-neutral-400 select-none relative z-10"
87
+ className="w-8 h-8 flex items-center justify-center text-[11px] text-ink-faint select-none relative z-10"
91
88
  >
92
89
  ···
93
90
  </span>
@@ -103,8 +100,8 @@ export function Pagination({
103
100
  className={[
104
101
  "relative z-10 min-w-8 h-8 px-1 rounded-[9px] flex items-center justify-center text-[13px] font-medium transition-colors duration-200 cursor-pointer tabular-nums",
105
102
  slot === currentPage
106
- ? "text-neutral-900"
107
- : "text-neutral-500 hover:text-neutral-900",
103
+ ? "text-ink"
104
+ : "text-ink-muted hover:text-ink",
108
105
  ].join(" ")}
109
106
  >
110
107
  {slot}
@@ -117,9 +114,9 @@ export function Pagination({
117
114
  onClick={() => onPageChange(currentPage + 1)}
118
115
  disabled={currentPage >= totalPages}
119
116
  aria-label="Next page"
120
- className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-neutral-600 hover:text-neutral-900 transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
117
+ className="group/nav relative overflow-hidden h-8 px-3 rounded-[9px] flex items-center gap-1.5 text-[13px] font-medium text-ink-body hover:text-ink transition-colors cursor-pointer disabled:opacity-30 disabled:pointer-events-none"
121
118
  >
122
- <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face-light)] [box-shadow:var(--thumb-shadow)]" />
119
+ <span className="pointer-events-none absolute inset-0 rounded-[9px] opacity-0 group-hover/nav:opacity-100 transition-opacity duration-200 [background:var(--face)] [box-shadow:var(--thumb-shadow)]" />
123
120
  <span className="relative z-10 hidden sm:inline">next</span>
124
121
  <svg className="relative z-10" width="12" height="12" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
125
122
  <path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" />
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState, useRef, useEffect, useId } from "react";
4
+ import { ENTER_FROM, LAYER, ORIGIN, Portal, useAnchoredPosition } from "./Floating";
4
5
 
5
6
  type PopoverPosition = "top" | "bottom" | "left" | "right";
6
7
 
@@ -11,29 +12,6 @@ interface PopoverProps {
11
12
  className?: string;
12
13
  }
13
14
 
14
- /* Anchored with margins on a wrapper so the panel's own transform is free
15
- for the enter motion, which scales out from the trigger side. */
16
- const positionStyles: Record<PopoverPosition, string> = {
17
- top: "bottom-full left-1/2 -translate-x-1/2 pb-2",
18
- bottom: "top-full left-1/2 -translate-x-1/2 pt-2",
19
- left: "right-full top-1/2 -translate-y-1/2 pr-2",
20
- right: "left-full top-1/2 -translate-y-1/2 pl-2",
21
- };
22
-
23
- const origin: Record<PopoverPosition, string> = {
24
- top: "origin-bottom",
25
- bottom: "origin-top",
26
- left: "origin-right",
27
- right: "origin-left",
28
- };
29
-
30
- const enterFrom: Record<PopoverPosition, string> = {
31
- top: "translate-y-1.5",
32
- bottom: "-translate-y-1.5",
33
- left: "translate-x-1.5",
34
- right: "-translate-x-1.5",
35
- };
36
-
37
15
  export function Popover({
38
16
  trigger,
39
17
  children,
@@ -44,29 +22,42 @@ export function Popover({
44
22
  const [visible, setVisible] = useState(false);
45
23
  const [mounted, setMounted] = useState(false);
46
24
  const ref = useRef<HTMLDivElement>(null);
47
- const panelRef = useRef<HTMLDivElement>(null);
48
25
  const uid = useId();
26
+ // Portalled and pinned beside the trigger, flipping if there's no room
27
+ const { setFloating, floatingRef, side } = useAnchoredPosition(ref, { open: mounted, side: position, align: "center" });
28
+
29
+ /* Mounted and faded out during render; the effect only times the rest */
30
+ if (open && !mounted) setMounted(true);
31
+ if (!open && visible) setVisible(false);
49
32
 
50
33
  useEffect(() => {
51
34
  if (open) {
52
- setMounted(true);
53
- requestAnimationFrame(() => requestAnimationFrame(() => setVisible(true)));
35
+ requestAnimationFrame(() =>
36
+ requestAnimationFrame(() => {
37
+ setVisible(true);
38
+ // The panel lives at the end of <body>; bring focus to it so Tab reaches its contents
39
+ const panel = floatingRef.current;
40
+ const first = panel?.querySelector<HTMLElement>(
41
+ 'button:not([disabled]), [href], input:not([disabled]), select, textarea, [tabindex]:not([tabindex="-1"])',
42
+ );
43
+ (first ?? panel)?.focus({ preventScroll: true });
44
+ }),
45
+ );
54
46
  } else {
55
- setVisible(false);
56
47
  const t = setTimeout(() => setMounted(false), 180);
57
48
  return () => clearTimeout(t);
58
49
  }
59
- }, [open]);
50
+ }, [open, floatingRef]);
60
51
 
61
52
  useEffect(() => {
62
53
  function onOutside(e: MouseEvent) {
63
- if (ref.current && !ref.current.contains(e.target as Node)) {
64
- setOpen(false);
65
- }
54
+ const t = e.target as Node;
55
+ if (ref.current?.contains(t) || floatingRef.current?.contains(t)) return;
56
+ setOpen(false);
66
57
  }
67
58
  document.addEventListener("mousedown", onOutside);
68
59
  return () => document.removeEventListener("mousedown", onOutside);
69
- }, []);
60
+ }, [floatingRef]);
70
61
 
71
62
  useEffect(() => {
72
63
  if (!open) return;
@@ -92,29 +83,33 @@ export function Popover({
92
83
  : trigger}
93
84
  </div>
94
85
  {mounted && (
95
- <div className={["absolute z-50", positionStyles[position]].join(" ")}>
86
+ <Portal>
96
87
  <div
97
- ref={panelRef}
88
+ ref={setFloating}
98
89
  id={`${uid}-popover`}
99
90
  role="dialog"
100
- className={[
101
- "min-w-[220px] rounded-[16px] p-1.5",
102
- "transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",
103
- origin[position],
104
- visible
105
- ? "opacity-100 scale-100 translate-x-0 translate-y-0"
106
- : ["opacity-0 scale-[0.96]", enterFrom[position]].join(" "),
107
- ].join(" ")}
108
- style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)" }}
91
+ tabIndex={-1}
92
+ className="outline-none min-w-[220px] max-w-[calc(100vw-16px)] rounded-[16px] p-1.5 transition-[opacity,transform] duration-200 ease-[var(--ease-spring)]"
93
+ style={{
94
+ position: "fixed",
95
+ top: 0,
96
+ left: 0,
97
+ zIndex: LAYER.floating,
98
+ transformOrigin: ORIGIN[side],
99
+ opacity: visible ? 1 : 0,
100
+ transform: visible ? "none" : `${ENTER_FROM[side]} scale(0.96)`,
101
+ background: "var(--panel-bezel)",
102
+ boxShadow: "var(--overlay-shadow)",
103
+ }}
109
104
  >
110
105
  <div
111
- className="bg-white rounded-[11px] p-3.5 text-[13px] text-neutral-600 leading-relaxed"
106
+ className="bg-panel-face rounded-[11px] p-3.5 text-[13px] text-ink-body leading-relaxed"
112
107
  style={{ boxShadow: "var(--panel-face-shadow)" }}
113
108
  >
114
109
  {children}
115
110
  </div>
116
111
  </div>
117
- </div>
112
+ </Portal>
118
113
  )}
119
114
  </div>
120
115
  );
@@ -31,10 +31,10 @@ export function Progress({
31
31
  {(label || showValue) && (
32
32
  <div className="flex items-center justify-between gap-4">
33
33
  {label && (
34
- <span className="text-[13px] font-medium text-neutral-700 truncate">{label}</span>
34
+ <span className="text-[13px] font-medium text-ink-label truncate">{label}</span>
35
35
  )}
36
36
  {showValue && (
37
- <span aria-hidden className="text-[12px] tabular-nums text-neutral-500 shrink-0">
37
+ <span aria-hidden className="text-[12px] tabular-nums text-ink-muted shrink-0">
38
38
  {Math.round(pct)}%
39
39
  </span>
40
40
  )}
@@ -55,8 +55,8 @@ export function Progress({
55
55
  width: `${displayed}%`,
56
56
  /* Never collapses below a pill, so 1% still reads as started */
57
57
  minWidth: displayed > 0 ? 6 : 0,
58
- background: "var(--face-dark)",
59
- boxShadow: "inset 0 2px 3px -2px rgba(255,255,255,0.135), 0 0 2px rgba(0,0,0,0.2)",
58
+ background: "var(--face-inverse)",
59
+ boxShadow: "var(--fill-shadow)",
60
60
  transition: "width 700ms var(--ease-out-soft)",
61
61
  }}
62
62
  />
@@ -33,18 +33,18 @@ export function QuoteCard({
33
33
  alt=""
34
34
  loading="lazy"
35
35
  className="w-7 h-7 rounded-full object-cover shrink-0"
36
- style={{ boxShadow: dark ? "0 0 0 0.5px rgba(255,255,255,0.084)" : "0 0 0 0.5px rgba(17,17,17,0.048)" }}
36
+ style={{ boxShadow: dark ? "0 0 0 0.5px var(--photo-edge-dark)" : "0 0 0 0.5px var(--photo-edge)" }}
37
37
  />
38
38
  ) : (
39
39
  <span
40
40
  aria-hidden
41
41
  className={[
42
42
  "w-7 h-7 rounded-full flex items-center justify-center text-[11.5px] font-semibold shrink-0",
43
- dark ? "text-white" : "text-neutral-700",
43
+ dark ? "text-white" : "text-ink-label",
44
44
  ].join(" ")}
45
45
  style={{
46
- background: dark ? "var(--tile-dark)" : "var(--face-light)",
47
- boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-light-shadow)",
46
+ background: dark ? "var(--tile-dark)" : "var(--tile)",
47
+ boxShadow: dark ? "var(--tile-dark-shadow)" : "var(--tile-shadow)",
48
48
  }}
49
49
  >
50
50
  {initial}
@@ -54,7 +54,7 @@ export function QuoteCard({
54
54
  <cite
55
55
  className={[
56
56
  "not-italic font-semibold tracking-[-0.008em]",
57
- dark ? "text-white" : "text-neutral-900",
57
+ dark ? "text-white" : "text-ink",
58
58
  ].join(" ")}
59
59
  >
60
60
  {author}
@@ -72,7 +72,7 @@ export function QuoteCard({
72
72
  width="22"
73
73
  height="17"
74
74
  viewBox="0 0 56 44"
75
- className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-neutral-300"].join(" ")}
75
+ className={["shrink-0 mb-auto", dark ? "text-white/25" : "text-ink-ghost"].join(" ")}
76
76
  >
77
77
  <path
78
78
  fill="currentColor"
@@ -82,7 +82,7 @@ export function QuoteCard({
82
82
  <blockquote
83
83
  className={[
84
84
  "m-0 pt-8 text-[19px] leading-[1.55] tracking-[-0.015em] text-pretty",
85
- dark ? "text-neutral-50" : "text-neutral-900",
85
+ dark ? "text-neutral-50" : "text-ink",
86
86
  ].join(" ")}
87
87
  >
88
88
  {quote}
@@ -33,6 +33,21 @@ interface Blob {
33
33
 
34
34
  const LEAD = "260ms var(--ease-out-soft)";
35
35
  const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";
36
+
37
+ /* Where the face sits over a well, in the group's own coordinates. Read
38
+ straight off the DOM, so setting it from an effect is a measurement,
39
+ not derived state */
40
+ function wellBox(group: HTMLElement | null, el: HTMLElement | null, dir: Blob["dir"]): Blob | null {
41
+ if (!group || !el) return null;
42
+ return {
43
+ top: el.offsetTop,
44
+ bottom: group.clientHeight - el.offsetTop - el.offsetHeight,
45
+ left: el.offsetLeft,
46
+ size: el.offsetWidth,
47
+ dir,
48
+ };
49
+ }
50
+
36
51
  export function RadioGroup({
37
52
  options,
38
53
  value,
@@ -59,24 +74,13 @@ export function RadioGroup({
59
74
  const selectedIndex = options.findIndex((o) => o.value === selected);
60
75
  const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
61
76
 
62
- const measure = (dir: Blob["dir"]) => {
63
- const group = groupRef.current;
64
- const el = refs.current[prevIndex.current];
65
- if (!group || !el) return setBlob(null);
66
- setBlob({
67
- top: el.offsetTop,
68
- bottom: group.clientHeight - el.offsetTop - el.offsetHeight,
69
- left: el.offsetLeft,
70
- size: el.offsetWidth,
71
- dir,
72
- });
73
- };
77
+ const measure = (dir: Blob["dir"]) =>
78
+ setBlob(wellBox(groupRef.current, refs.current[prevIndex.current], dir));
74
79
 
75
80
  useLayoutEffect(() => {
76
81
  const prev = prevIndex.current;
77
82
  prevIndex.current = selectedIndex;
78
83
  measure(prev < 0 || selectedIndex < 0 ? 0 : selectedIndex > prev ? 1 : selectedIndex < prev ? -1 : 0);
79
- // eslint-disable-next-line react-hooks/exhaustive-deps
80
84
  }, [selectedIndex]);
81
85
 
82
86
  // Rewrapped descriptions move the wells; follow them without animating.
@@ -92,7 +96,6 @@ export function RadioGroup({
92
96
  });
93
97
  ro.observe(group);
94
98
  return () => ro.disconnect();
95
- // eslint-disable-next-line react-hooks/exhaustive-deps
96
99
  }, []);
97
100
 
98
101
  const move = (from: number, step: 1 | -1) => {
@@ -121,11 +124,11 @@ export function RadioGroup({
121
124
  ].join(" ")}
122
125
  >
123
126
  <div className="flex flex-col gap-0.5 min-w-0">
124
- <span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-neutral-900 leading-snug">
127
+ <span id={`${uid}-${i}-label`} className="text-[13px] font-medium text-ink leading-snug">
125
128
  {opt.label}
126
129
  </span>
127
130
  {opt.description && (
128
- <span id={`${uid}-${i}-desc`} className="text-[12px] text-neutral-500 leading-relaxed">
131
+ <span id={`${uid}-${i}-desc`} className="text-[12px] text-ink-muted leading-relaxed">
129
132
  {opt.description}
130
133
  </span>
131
134
  )}
@@ -155,7 +158,7 @@ export function RadioGroup({
155
158
  className={[
156
159
  "shrink-0 w-[22px] h-[22px] rounded-full cursor-pointer",
157
160
  "transition-[box-shadow] duration-200 ease-out",
158
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
161
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
159
162
  active ? "" : "group-hover/radio:[box-shadow:var(--field-shadow-hover)]",
160
163
  ].join(" ")}
161
164
  style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
@@ -174,8 +177,8 @@ export function RadioGroup({
174
177
  bottom: blob.bottom,
175
178
  left: blob.left,
176
179
  width: blob.size,
177
- background: "var(--face-dark)",
178
- boxShadow: "var(--face-dark-shadow)",
180
+ background: "var(--face-inverse)",
181
+ boxShadow: "var(--face-inverse-shadow)",
179
182
  transition:
180
183
  blob.dir === 0
181
184
  ? "none"
@@ -187,11 +190,11 @@ export function RadioGroup({
187
190
  {/* The dot rides the trailing edge's spring, arriving as it settles */}
188
191
  <span
189
192
  aria-hidden
190
- className="pointer-events-none absolute w-2 h-2 rounded-full bg-white"
193
+ className="pointer-events-none absolute w-2 h-2 rounded-full bg-ink-inverse"
191
194
  style={{
192
195
  top: blob.top + (blob.size - 8) / 2,
193
196
  left: blob.left + (blob.size - 8) / 2,
194
- boxShadow: "0 1px 1px rgba(0,0,0,0.3)",
197
+ boxShadow: "var(--dot-shadow)",
195
198
  transition: blob.dir === 0 ? "none" : `top ${TRAIL}`,
196
199
  }}
197
200
  />
@@ -0,0 +1,247 @@
1
+ "use client";
2
+
3
+ import React, { useEffect, useId, useLayoutEffect, useRef, useState } from "react";
4
+
5
+
6
+ export interface RadioCardOption {
7
+ value: string;
8
+ label: string;
9
+ description?: string;
10
+ /** Something on the right of the title, like a price */
11
+ aside?: React.ReactNode;
12
+ icon?: React.ReactNode;
13
+ disabled?: boolean;
14
+ }
15
+
16
+ interface RadioCardsProps {
17
+ options: RadioCardOption[];
18
+ value?: string;
19
+ defaultValue?: string;
20
+ onChange?: (value: string) => void;
21
+ /** Read out as the group's name */
22
+ label?: string;
23
+ /** Options per row from the small breakpoint up; they stack on phones */
24
+ columns?: 1 | 2 | 3;
25
+ name?: string;
26
+ className?: string;
27
+ }
28
+
29
+ const COLS = { 1: "", 2: "sm:grid-cols-2", 3: "sm:grid-cols-3" } as const;
30
+
31
+ // RadioGroup's timings: the leading edge leaves at once, the trailing
32
+ // edge follows a beat later on a spring and settles into place
33
+ const LEAD = "260ms var(--ease-out-soft)";
34
+ const TRAIL = "480ms cubic-bezier(0.34, 1.25, 0.64, 1) 50ms";
35
+
36
+ /* Bigger choices, like a plan or a delivery speed: the segmented control
37
+ from Tabs, grown up. The options sit flat in one sunken well and a
38
+ single raised thumb flows to whichever is chosen, across rows and
39
+ columns: its leading edge goes first and the trailing edge catches
40
+ up, so it stretches toward the new option the way RadioGroup's
41
+ selection does, and you can see which way it went. Each keeps RadioGroup's own control on its right, so the two
42
+ read as one family. One Tab stop, arrows move. */
43
+ export function RadioCards({
44
+ options,
45
+ value,
46
+ defaultValue = "",
47
+ onChange,
48
+ label,
49
+ columns = 1,
50
+ name,
51
+ className = "",
52
+ }: RadioCardsProps) {
53
+ const uid = useId();
54
+ const [internal, setInternal] = useState(defaultValue);
55
+ const selected = value !== undefined ? value : internal;
56
+ const wellRef = useRef<HTMLDivElement>(null);
57
+ const refs = useRef<(HTMLButtonElement | null)[]>([]);
58
+ const thumbRef = useRef<HTMLSpanElement>(null);
59
+ // Where the thumb last landed, to tell which way the next move goes
60
+ const last = useRef<{ top: number; left: number } | null>(null);
61
+
62
+ const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
63
+ const selectedIndex = options.findIndex((o) => o.value === selected);
64
+ const tabStop = selectedIndex >= 0 ? selectedIndex : enabled[0];
65
+
66
+ const selectedRef = useRef(selectedIndex);
67
+
68
+ /* The thumb is held by its four edges, measured from the well's, and
69
+ written straight onto it, so a move never re-renders anything.
70
+ Heading forward on an axis, the far edge leads and the near one
71
+ trails on a spring; heading back, the other way round. */
72
+ const place = (index: number, animate: boolean) => {
73
+ const thumb = thumbRef.current;
74
+ const el = refs.current[index];
75
+ const well = wellRef.current;
76
+ if (!thumb || !well) return;
77
+ if (!el) {
78
+ thumb.style.opacity = "0";
79
+ last.current = null;
80
+ return;
81
+ }
82
+ const top = el.offsetTop;
83
+ const left = el.offsetLeft;
84
+ const from = animate ? last.current : null;
85
+ const edge = (d: number, near: string, far: string) =>
86
+ d === 0 ? [] : d > 0 ? [`${near} ${TRAIL}`, `${far} ${LEAD}`] : [`${near} ${LEAD}`, `${far} ${TRAIL}`];
87
+ const dy = from ? Math.sign(top - from.top) : 0;
88
+ const dx = from ? Math.sign(left - from.left) : 0;
89
+ thumb.style.transition = [...edge(dy, "top", "bottom"), ...edge(dx, "left", "right")].join(", ") || "none";
90
+ thumb.style.top = `${top}px`;
91
+ thumb.style.left = `${left}px`;
92
+ thumb.style.right = `${well.clientWidth - left - el.offsetWidth}px`;
93
+ thumb.style.bottom = `${well.clientHeight - top - el.offsetHeight}px`;
94
+ thumb.style.opacity = "1";
95
+ last.current = { top, left };
96
+ };
97
+
98
+ // Before paint, so the thumb is never seen out of place
99
+ useLayoutEffect(() => {
100
+ selectedRef.current = selectedIndex;
101
+ place(selectedIndex, true);
102
+ }, [selectedIndex]);
103
+
104
+ // Rewrapped text or a new breakpoint moves the options; follow them
105
+ // without animating. An observer reports once as soon as it's attached;
106
+ // that one is skipped, or it would cut short a move already under way.
107
+ useEffect(() => {
108
+ const well = wellRef.current;
109
+ if (!well) return;
110
+ let attached = false;
111
+ const ro = new ResizeObserver(() => {
112
+ if (attached) place(selectedRef.current, false);
113
+ attached = true;
114
+ });
115
+ ro.observe(well);
116
+ return () => ro.disconnect();
117
+ }, []);
118
+
119
+ const select = (i: number) => {
120
+ const o = options[i];
121
+ if (!o || o.disabled) return;
122
+ setInternal(o.value);
123
+ onChange?.(o.value);
124
+ };
125
+
126
+ const move = (from: number, step: 1 | -1) => {
127
+ const at = enabled.indexOf(from);
128
+ const next = enabled[(at + step + enabled.length) % enabled.length];
129
+ refs.current[next]?.focus();
130
+ select(next);
131
+ };
132
+
133
+ return (
134
+ <div
135
+ ref={wellRef}
136
+ role="radiogroup"
137
+ aria-label={label}
138
+ className={["relative grid grid-cols-1 gap-1 rounded-[18px] p-1.5", COLS[columns], className].join(" ")}
139
+ style={{ background: "var(--well)", boxShadow: "var(--well-shadow)" }}
140
+ >
141
+ <span
142
+ ref={thumbRef}
143
+ aria-hidden
144
+ className="pointer-events-none absolute rounded-[13px] opacity-0"
145
+ style={{ background: "var(--face)", boxShadow: "var(--thumb-shadow)" }}
146
+ />
147
+
148
+ {options.map((o, i) => {
149
+ const on = i === selectedIndex;
150
+ return (
151
+ <button
152
+ key={o.value}
153
+ ref={(el) => {
154
+ refs.current[i] = el;
155
+ }}
156
+ type="button"
157
+ role="radio"
158
+ aria-checked={on}
159
+ aria-labelledby={`${uid}-${i}-label`}
160
+ aria-describedby={o.description ? `${uid}-${i}-desc` : undefined}
161
+ disabled={o.disabled}
162
+ tabIndex={i === tabStop ? 0 : -1}
163
+ onClick={() => select(i)}
164
+ onKeyDown={(e) => {
165
+ if (e.key === "ArrowDown" || e.key === "ArrowRight") {
166
+ e.preventDefault();
167
+ move(i, 1);
168
+ } else if (e.key === "ArrowUp" || e.key === "ArrowLeft") {
169
+ e.preventDefault();
170
+ move(i, -1);
171
+ }
172
+ }}
173
+ className={[
174
+ "group/card relative z-10 flex items-start gap-3 rounded-[13px] px-4 py-3.5 text-left",
175
+ "focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-ink",
176
+ "disabled:opacity-40 disabled:cursor-not-allowed",
177
+ on ? "cursor-default" : "cursor-pointer",
178
+ ].join(" ")}
179
+ >
180
+ {o.icon && (
181
+ <span
182
+ aria-hidden
183
+ className={[
184
+ "mt-px flex h-5 w-5 shrink-0 items-center justify-center transition-colors duration-200",
185
+ on ? "text-ink" : "text-ink-faint group-hover/card:text-ink-body",
186
+ ].join(" ")}
187
+ >
188
+ {o.icon}
189
+ </span>
190
+ )}
191
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5">
192
+ <span className="flex items-baseline justify-between gap-3">
193
+ <span
194
+ id={`${uid}-${i}-label`}
195
+ className={[
196
+ "text-[13.5px] font-medium leading-snug transition-colors duration-150",
197
+ on ? "text-ink" : "text-ink-soft group-hover/card:text-ink",
198
+ ].join(" ")}
199
+ >
200
+ {o.label}
201
+ </span>
202
+ {o.aside && (
203
+ <span
204
+ className={[
205
+ "shrink-0 text-[13px] tabular-nums transition-colors duration-150",
206
+ on ? "font-medium text-ink" : "text-ink-muted",
207
+ ].join(" ")}
208
+ >
209
+ {o.aside}
210
+ </span>
211
+ )}
212
+ </span>
213
+ {o.description && (
214
+ <span id={`${uid}-${i}-desc`} className="text-[12px] leading-relaxed text-ink-muted">
215
+ {o.description}
216
+ </span>
217
+ )}
218
+ </span>
219
+ {/* RadioGroup's control: a sunken well, and when chosen a face in
220
+ the inverse material with a dot pressed into it */}
221
+ <span
222
+ aria-hidden
223
+ className={[
224
+ "relative mt-px h-[18px] w-[18px] shrink-0 rounded-full transition-[box-shadow] duration-200",
225
+ on ? "" : "group-hover/card:[box-shadow:var(--field-shadow-hover)]",
226
+ ].join(" ")}
227
+ style={{ background: "var(--field)", boxShadow: "var(--field-shadow)" }}
228
+ >
229
+ <span
230
+ className="absolute inset-0 grid place-items-center rounded-full transition-[opacity,transform] duration-300 ease-[var(--ease-spring)]"
231
+ style={{
232
+ background: "var(--face-inverse)",
233
+ boxShadow: "var(--face-inverse-shadow)",
234
+ opacity: on ? 1 : 0,
235
+ transform: on ? "scale(1)" : "scale(0.4)",
236
+ }}
237
+ >
238
+ <span className="h-1.5 w-1.5 rounded-full bg-ink-inverse" style={{ boxShadow: "var(--dot-shadow)" }} />
239
+ </span>
240
+ </span>
241
+ </button>
242
+ );
243
+ })}
244
+ {name && <input type="hidden" name={name} value={selected} />}
245
+ </div>
246
+ );
247
+ }
@@ -68,14 +68,14 @@ export function ReactionBar({
68
68
  aria-label={`${reaction.label}, ${reaction.count}`}
69
69
  className={[
70
70
  "inline-flex items-center gap-1.5 rounded-[11px] pl-2.5 pr-3 py-[7px] text-[13px] font-medium cursor-pointer",
71
- "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-neutral-900",
72
- reaction.active ? "text-white" : "text-neutral-600 hover:text-neutral-900",
71
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ink",
72
+ reaction.active ? "text-ink-inverse" : "text-ink-body hover:text-ink",
73
73
  ].join(" ")}
74
74
  style={{
75
75
  /* Off is a raised face; on is the dark face — pressed in and lit */
76
- background: reaction.active ? "var(--face-dark)" : "var(--face-light)",
77
- boxShadow: reaction.active ? "var(--face-dark-shadow)" : "var(--face-light-shadow)",
78
- textShadow: reaction.active ? "0 -1px 0 rgba(0,0,0,0.4)" : "0 1px 0 rgba(255,255,255,0.9)",
76
+ background: reaction.active ? "var(--face-inverse)" : "var(--face)",
77
+ boxShadow: reaction.active ? "var(--face-inverse-shadow)" : "var(--face-shadow)",
78
+ textShadow: reaction.active ? "var(--emboss-inverse)" : "var(--emboss)",
79
79
  transform: isAnimating ? "scale(1.08)" : "scale(1)",
80
80
  transition:
81
81
  "transform 250ms var(--ease-spring), color 200ms ease, box-shadow 200ms ease",