@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
@@ -42,8 +42,8 @@ export function ReadingProgress({ className = "" }: ReadingProgressProps) {
42
42
  style={{
43
43
  // transform instead of width: no layout on every scroll frame
44
44
  transform: `scaleX(${progress / 100})`,
45
- background: "linear-gradient(90deg, #0a0a0a 0%, #262626 100%)",
46
- boxShadow: "0 0 8px rgba(17,17,17,0.3)",
45
+ background: "var(--progress-bar)",
46
+ boxShadow: "var(--progress-bar-glow)",
47
47
  }}
48
48
  />
49
49
  </div>
@@ -9,7 +9,7 @@ export function ReadingTime({ minutes, className = "" }: ReadingTimeProps) {
9
9
  return (
10
10
  <span
11
11
  className={[
12
- "inline-flex items-center gap-1.5 text-[12px] font-medium text-neutral-500",
12
+ "inline-flex items-center gap-1.5 text-[12px] font-medium text-ink-muted",
13
13
  className,
14
14
  ]
15
15
  .filter(Boolean)
@@ -24,7 +24,7 @@ export function ReadingTime({ minutes, className = "" }: ReadingTimeProps) {
24
24
  strokeWidth="2"
25
25
  strokeLinecap="round"
26
26
  strokeLinejoin="round"
27
- className="text-neutral-400"
27
+ className="text-ink-faint"
28
28
  >
29
29
  <circle cx="12" cy="12" r="10" />
30
30
  <polyline points="12 6 12 12 16 14" />
@@ -1,6 +1,8 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useState, useRef, useEffect, useId } from "react";
4
+ import { Portal } from "./Floating";
5
+ import { useFloatingPanel } from "./useFloatingPanel";
4
6
 
5
7
  interface SelectOption {
6
8
  value: string;
@@ -33,41 +35,20 @@ export function Select({
33
35
  fullWidth = false,
34
36
  className = "",
35
37
  }: SelectProps) {
36
- const [open, setOpen] = useState(false);
37
- // The list stays mounted while it folds away, and only unfolds once painted
38
- const [mounted, setMounted] = useState(false);
39
- const [entered, setEntered] = useState(false);
40
- const closeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
41
38
  const [active, setActive] = useState(0);
42
39
  const ref = useRef<HTMLDivElement>(null);
43
40
  const buttonRef = useRef<HTMLButtonElement>(null);
44
41
  const listRef = useRef<HTMLDivElement>(null);
45
42
  const uid = useId();
43
+ // The list is portalled to the body and pinned under the field
44
+ const { open, mounted, close, show, setFloating, panelStyle } = useFloatingPanel(ref, {
45
+ matchWidth: true,
46
+ onEscape: () => buttonRef.current?.focus(),
47
+ });
46
48
 
47
49
  const selectedIndex = options.findIndex((o) => o.value === value);
48
50
  const selected = options[selectedIndex];
49
51
 
50
- const close = () => {
51
- setOpen(false);
52
- setEntered(false);
53
- if (closeTimer.current) clearTimeout(closeTimer.current);
54
- closeTimer.current = setTimeout(() => setMounted(false), 200);
55
- };
56
-
57
- useEffect(() => () => {
58
- if (closeTimer.current) clearTimeout(closeTimer.current);
59
- }, []);
60
-
61
- useEffect(() => {
62
- function onOutside(e: MouseEvent) {
63
- if (ref.current && !ref.current.contains(e.target as Node)) {
64
- close();
65
- }
66
- }
67
- document.addEventListener("mousedown", onOutside);
68
- return () => document.removeEventListener("mousedown", onOutside);
69
- }, []);
70
-
71
52
  useEffect(() => {
72
53
  if (!open) return;
73
54
  listRef.current
@@ -77,10 +58,7 @@ export function Select({
77
58
 
78
59
  const openList = () => {
79
60
  setActive(selectedIndex >= 0 ? selectedIndex : 0);
80
- if (closeTimer.current) clearTimeout(closeTimer.current);
81
- setOpen(true);
82
- setMounted(true);
83
- requestAnimationFrame(() => requestAnimationFrame(() => setEntered(true)));
61
+ show();
84
62
  };
85
63
 
86
64
  const pick = (index: number) => {
@@ -143,7 +121,7 @@ export function Select({
143
121
  {label && (
144
122
  <span
145
123
  id={`${uid}-label`}
146
- className="text-[12.5px] font-medium text-neutral-700 tracking-[-0.005em]"
124
+ className="text-[12.5px] font-medium text-ink-label tracking-[-0.005em]"
147
125
  >
148
126
  {label}
149
127
  </span>
@@ -156,6 +134,9 @@ export function Select({
156
134
  <button
157
135
  ref={buttonRef}
158
136
  type="button"
137
+ /* A select-only combobox: the role is what lets the button point
138
+ at the active option while focus stays on it */
139
+ role="combobox"
159
140
  disabled={disabled}
160
141
  onClick={() => (open ? close() : openList())}
161
142
  onKeyDown={onKeyDown}
@@ -166,7 +147,7 @@ export function Select({
166
147
  aria-activedescendant={open ? `${uid}-opt-${active}` : undefined}
167
148
  className={[
168
149
  "group/select relative isolate inline-flex items-center justify-between gap-3 w-full",
169
- "rounded-xl px-4 py-3 text-[13.5px] font-medium text-neutral-900",
150
+ "rounded-xl px-4 py-3 text-[13.5px] font-medium text-ink",
170
151
  "field cursor-pointer select-none",
171
152
  "focus:outline-none disabled:opacity-40 disabled:pointer-events-none",
172
153
  ].join(" ")}
@@ -176,7 +157,7 @@ export function Select({
176
157
  id={`${uid}-value`}
177
158
  className={[
178
159
  "relative z-10 truncate",
179
- selected ? "text-neutral-900" : "text-neutral-400 font-normal",
160
+ selected ? "text-ink" : "text-ink-faint font-normal",
180
161
  ].join(" ")}
181
162
  >
182
163
  {selected ? selected.label : placeholder}
@@ -191,7 +172,7 @@ export function Select({
191
172
  viewBox="0 0 24 24"
192
173
  className={[
193
174
  "relative z-10 shrink-0 transition-[transform,color] duration-300 ease-[var(--ease-spring)]",
194
- open ? "rotate-180 text-neutral-900" : "text-neutral-400 group-hover/select:text-neutral-700",
175
+ open ? "rotate-180 text-ink" : "text-ink-faint group-hover/select:text-ink-label",
195
176
  ].join(" ")}
196
177
  >
197
178
  <path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
@@ -203,7 +184,7 @@ export function Select({
203
184
  className="block absolute inset-y-0 w-[30%] -left-[30%] animate-input-shimmer"
204
185
  style={{
205
186
  background:
206
- "linear-gradient(90deg, transparent 0%, rgba(255,255,255,0.55) 50%, transparent 100%)",
187
+ "linear-gradient(90deg, transparent 0%, var(--shimmer) 50%, transparent 100%)",
207
188
  }}
208
189
  />
209
190
  </span>
@@ -211,23 +192,14 @@ export function Select({
211
192
  </button>
212
193
 
213
194
  {mounted && (
195
+ <Portal>
214
196
  <div
215
- className="absolute left-0 z-50 mt-2 p-1.5 rounded-[16px] origin-top"
197
+ ref={setFloating}
198
+ className="p-1.5 rounded-[16px]"
216
199
  style={{
217
- minWidth: "100%",
200
+ ...panelStyle,
218
201
  maxWidth: "min(420px, calc(100vw - 2rem))",
219
202
  width: "max-content",
220
- background: "var(--panel-bezel)",
221
- boxShadow: "var(--overlay-shadow)",
222
- /* Unfolds downward out of the field: a clip reveals it top to
223
- bottom while it fades and settles. The clip's sides reach
224
- past the panel so its shadow is never cut off. */
225
- opacity: entered ? 1 : 0,
226
- transform: entered ? "none" : "translateY(-4px) scale(0.98)",
227
- clipPath: entered ? "inset(-48px -48px -48px -48px)" : "inset(-48px -48px 55% -48px)",
228
- transition: entered
229
- ? "opacity 180ms ease-out, transform 360ms var(--ease-out-soft), clip-path 360ms var(--ease-out-soft)"
230
- : "opacity 160ms ease-in, transform 200ms ease-in, clip-path 200ms ease-in",
231
203
  }}
232
204
  >
233
205
  <div
@@ -235,7 +207,7 @@ export function Select({
235
207
  id={`${uid}-list`}
236
208
  role="listbox"
237
209
  aria-labelledby={label ? `${uid}-label` : undefined}
238
- className="bg-white rounded-[11px] p-1 flex flex-col gap-0.5 max-h-72 overflow-y-auto scroll-thin"
210
+ className="bg-panel-face rounded-[11px] p-1 flex flex-col gap-0.5 max-h-72 overflow-y-auto scroll-thin"
239
211
  style={{ boxShadow: "var(--panel-face-shadow)" }}
240
212
  >
241
213
  {options.map((opt, i) => {
@@ -254,8 +226,10 @@ export function Select({
254
226
  className={[
255
227
  "flex items-center justify-between gap-3 w-full px-2.5 py-2 text-[13px] rounded-[8px] cursor-pointer",
256
228
  "transition-colors duration-100",
257
- isSelected ? "font-medium text-neutral-900" : "text-neutral-600",
258
- isActive ? "bg-neutral-100/80 text-neutral-900" : "",
229
+ isSelected ? "font-medium" : "",
230
+ // one ink per state: utilities sort by name, so text-ink-body would beat text-ink
231
+ isSelected || isActive ? "text-ink" : "text-ink-body",
232
+ isActive ? "bg-surface-active/80" : "",
259
233
  ].join(" ")}
260
234
  style={{
261
235
  animation: `select-option-in 300ms var(--ease-out-soft) ${40 + i * 22}ms both`,
@@ -265,7 +239,7 @@ export function Select({
265
239
  {isSelected && (
266
240
  <svg
267
241
  aria-hidden
268
- className="shrink-0 text-neutral-900"
242
+ className="shrink-0 text-ink"
269
243
  width="13"
270
244
  height="13"
271
245
  fill="none"
@@ -281,10 +255,11 @@ export function Select({
281
255
  })}
282
256
  </div>
283
257
  </div>
258
+ </Portal>
284
259
  )}
285
260
  </div>
286
261
 
287
- {hint && <p className="text-[11.5px] text-neutral-500 mt-0.5">{hint}</p>}
262
+ {hint && <p className="text-[11.5px] text-ink-muted mt-0.5">{hint}</p>}
288
263
  </div>
289
264
  );
290
265
  }
@@ -0,0 +1,52 @@
1
+ import React from "react";
2
+
3
+ interface SeparatorProps {
4
+ orientation?: "horizontal" | "vertical";
5
+ /** A few words set into the middle of the line, like "or" */
6
+ label?: React.ReactNode;
7
+ /** Only draws a line; screen readers skip it */
8
+ decorative?: boolean;
9
+ className?: string;
10
+ }
11
+
12
+ /* A groove scored into the surface: a dark line with a lit lip just
13
+ under it, the way a cut catches light from above. */
14
+ function Groove({ vertical }: { vertical: boolean }) {
15
+ return (
16
+ <span
17
+ aria-hidden
18
+ className={vertical ? "block w-px self-stretch" : "block h-px flex-1"}
19
+ style={{
20
+ background: "var(--groove)",
21
+ boxShadow: vertical ? "var(--groove-lip)" : "var(--groove-lip-below)",
22
+ }}
23
+ />
24
+ );
25
+ }
26
+
27
+ export function Separator({ orientation = "horizontal", label, decorative = false, className = "" }: SeparatorProps) {
28
+ const vertical = orientation === "vertical";
29
+ const a11y = decorative
30
+ ? { "aria-hidden": true as const }
31
+ : { role: "separator", "aria-orientation": orientation };
32
+
33
+ if (vertical) {
34
+ return (
35
+ <span {...a11y} className={["inline-flex self-stretch", className].join(" ")}>
36
+ <Groove vertical />
37
+ </span>
38
+ );
39
+ }
40
+
41
+ return (
42
+ <div {...a11y} className={["flex w-full items-center gap-3", className].join(" ")}>
43
+ <Groove vertical={false} />
44
+ {label && (
45
+ <>
46
+ <span className="shrink-0 text-[11.5px] font-medium text-ink-faint">{label}</span>
47
+ <Groove vertical={false} />
48
+ </>
49
+ )}
50
+ </div>
51
+ );
52
+ }
@@ -110,7 +110,7 @@ export function ShareButtons({
110
110
  onClick: handleCopy as () => void,
111
111
  content: (
112
112
  <>
113
- <span key={String(copied)} className={["animate-checkbox-in", copied ? "text-emerald-600" : ""].join(" ")}>
113
+ <span key={String(copied)} className={["animate-checkbox-in", copied ? "text-success-soft" : ""].join(" ")}>
114
114
  {copied ? <CheckIcon /> : <LinkIcon />}
115
115
  </span>
116
116
  <span className="w-[62px] text-left">{copied ? "copied" : "copy link"}</span>
@@ -124,7 +124,7 @@ export function ShareButtons({
124
124
 
125
125
  return (
126
126
  <div className={className}>
127
- <p className="text-[12px] font-medium text-neutral-500 lowercase mb-3">
127
+ <p className="text-[12px] font-medium text-ink-muted lowercase mb-3">
128
128
  Share
129
129
  </p>
130
130
  <ButtonGroup label="Share" items={keys} />
@@ -53,8 +53,8 @@ export function Sidebar({
53
53
  setPill({ top: br.top - nr.top, height: br.height, ready: true });
54
54
  };
55
55
 
56
- useLayoutEffect(() => { measurePill(current); }, []);
57
- useEffect(() => { measurePill(current); }, [current]);
56
+ // Before paint, so the pill is never seen out of place
57
+ useLayoutEffect(() => { measurePill(current); }, [current]);
58
58
 
59
59
  useEffect(() => {
60
60
  const ro = new ResizeObserver(() => measurePill(current));
@@ -73,27 +73,27 @@ export function Sidebar({
73
73
  className={["rounded-[20px] p-1.5", className].join(" ")}
74
74
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
75
75
  >
76
- <div className="bg-white rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
76
+ <div className="bg-panel-face rounded-[15px] overflow-hidden" style={{ boxShadow: "var(--panel-face-shadow)" }}>
77
77
  {header && (
78
- <div className="px-3 py-3 border-b border-neutral-100 flex items-center gap-2.5">
78
+ <div className="px-3 py-3 border-b border-line flex items-center gap-2.5">
79
79
  {header.logo && (
80
80
  <div
81
- className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-white"
82
- style={{ background: "var(--face-dark)", boxShadow: "var(--face-dark-shadow)" }}
81
+ className="w-7 h-7 rounded-[8px] flex items-center justify-center shrink-0 text-ink-inverse"
82
+ style={{ background: "var(--face-inverse)", boxShadow: "var(--face-inverse-shadow)" }}
83
83
  >
84
84
  {header.logo}
85
85
  </div>
86
86
  )}
87
87
  <div className="flex-1 min-w-0">
88
- <p className="text-[13px] font-semibold text-neutral-900 leading-none truncate">{header.title}</p>
88
+ <p className="text-[13px] font-semibold text-ink leading-none truncate">{header.title}</p>
89
89
  {header.subtitle && (
90
- <p className="text-[11px] text-neutral-400 mt-0.5 leading-none truncate">{header.subtitle}</p>
90
+ <p className="text-[11px] text-ink-faint mt-0.5 leading-none truncate">{header.subtitle}</p>
91
91
  )}
92
92
  </div>
93
93
  <button
94
94
  type="button"
95
95
  aria-label="Switch workspace"
96
- className="shrink-0 w-6 h-6 flex items-center justify-center text-neutral-400 hover:text-neutral-900 transition-colors cursor-pointer rounded-[7px] hover:bg-neutral-100"
96
+ className="shrink-0 w-6 h-6 flex items-center justify-center text-ink-faint hover:text-ink transition-colors cursor-pointer rounded-[7px] hover:bg-surface-active"
97
97
  >
98
98
  <svg width="13" height="13" fill="none" stroke="currentColor" strokeWidth="2" viewBox="0 0 24 24">
99
99
  <path strokeLinecap="round" strokeLinejoin="round" d="M8 9l4-4 4 4M8 15l4 4 4-4" />
@@ -110,8 +110,8 @@ export function Sidebar({
110
110
  style={{
111
111
  top: pill.top,
112
112
  height: pill.height,
113
- background: "var(--face-dark)",
114
- boxShadow: "var(--face-dark-shadow)",
113
+ background: "var(--face-inverse)",
114
+ boxShadow: "var(--face-inverse-shadow)",
115
115
  transition: "top 300ms var(--ease-spring), height 300ms var(--ease-spring)",
116
116
  }}
117
117
  />
@@ -120,7 +120,7 @@ export function Sidebar({
120
120
  {sections.map((section, si) => (
121
121
  <div key={si} className="flex flex-col gap-0.5">
122
122
  {section.title && (
123
- <p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-neutral-400 lowercase">
123
+ <p className="relative z-10 px-2.5 pt-1 pb-1.5 text-[11.5px] font-medium text-ink-faint lowercase">
124
124
  {section.title}
125
125
  </p>
126
126
  )}
@@ -139,8 +139,8 @@ export function Sidebar({
139
139
  "transition-colors duration-200 cursor-pointer",
140
140
  "disabled:opacity-35 disabled:pointer-events-none",
141
141
  isActive
142
- ? "text-white"
143
- : "text-neutral-600 hover:text-neutral-900 hover:bg-neutral-100/80",
142
+ ? "text-ink-inverse"
143
+ : "text-ink-body hover:text-ink hover:bg-surface-active/80",
144
144
  ].join(" ")}
145
145
  >
146
146
  {item.icon && (
@@ -148,8 +148,8 @@ export function Sidebar({
148
148
  className={[
149
149
  "shrink-0 w-[22px] h-[22px] rounded-md flex items-center justify-center transition-colors duration-200",
150
150
  isActive
151
- ? "bg-white/12 shadow-[inset_0_1px_0_rgba(255,255,255,0.12)]"
152
- : "text-neutral-500 [background:var(--face-light)] [box-shadow:var(--face-light-shadow)]",
151
+ ? "bg-ink-inverse/12 shadow-[inset_0_1px_0_var(--inverse-lip)]"
152
+ : "text-ink-muted [background:var(--face)] [box-shadow:var(--face-shadow)]",
153
153
  ].join(" ")}
154
154
  >
155
155
  {item.icon}
@@ -163,8 +163,8 @@ export function Sidebar({
163
163
  className={[
164
164
  "shrink-0 text-[11px] font-medium px-1.5 py-0.5 rounded-md tabular-nums leading-none transition-colors duration-200",
165
165
  isActive
166
- ? "bg-white/20 text-white/80"
167
- : "bg-neutral-100 text-neutral-500",
166
+ ? "bg-ink-inverse/20 text-ink-inverse/80"
167
+ : "bg-surface-active text-ink-muted",
168
168
  ].join(" ")}
169
169
  >
170
170
  {item.badge}
@@ -44,7 +44,7 @@ export function Skeleton({
44
44
  className={["rounded-[20px] p-1.5", className].join(" ")}
45
45
  style={{ background: "var(--panel-bezel)", boxShadow: "var(--panel-shadow)" }}
46
46
  >
47
- <div className="bg-white rounded-[15px] p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
47
+ <div className="bg-panel-face rounded-[15px] p-1.5" style={{ boxShadow: "var(--panel-face-shadow)" }}>
48
48
  <div className={[base, "w-full rounded-[11px]"].join(" ")} style={{ ...well, height: height ?? 160 }} />
49
49
  <div className="px-3.5 pt-4 pb-3.5 flex flex-col gap-2.5">
50
50
  <div className={[base, "h-3.5 w-3/5"].join(" ")} style={well} />
@@ -0,0 +1,229 @@
1
+ "use client";
2
+
3
+ import React, { useId, useRef, useState } from "react";
4
+ import { FieldLabel } from "./FieldMessage";
5
+
6
+ type SliderValue = number | [number, number];
7
+
8
+ interface SliderProps<V extends SliderValue> {
9
+ /** One number for a slider, two for a range */
10
+ value?: V;
11
+ defaultValue?: V;
12
+ onChange?: (value: V) => void;
13
+ /** Fires once the knob is let go, for work too heavy to do while dragging */
14
+ onCommit?: (value: V) => void;
15
+ min?: number;
16
+ max?: number;
17
+ step?: number;
18
+ label?: string;
19
+ /** Show the value beside the label; pass a function to format it */
20
+ showValue?: boolean | ((value: V) => React.ReactNode);
21
+ /** Small labels under the track, at these values */
22
+ marks?: number[];
23
+ disabled?: boolean;
24
+ /** Name of a hidden input (two for a range: name[0], name[1]) */
25
+ name?: string;
26
+ className?: string;
27
+ }
28
+
29
+ const clamp = (n: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, n));
30
+ const decimals = (n: number) => (String(n).split(".")[1] ?? "").length;
31
+
32
+ /* A sunken track with the filled part in the inverse material, and a
33
+ raised knob (or two, for a range) that you drag along it. The knob
34
+ swells while held; arrows step, shift steps by ten, page keys by a
35
+ tenth of the range, home and end jump to the ends. */
36
+ export function Slider<V extends SliderValue = number>({
37
+ value,
38
+ defaultValue,
39
+ onChange,
40
+ onCommit,
41
+ min = 0,
42
+ max = 100,
43
+ step = 1,
44
+ label,
45
+ showValue = false,
46
+ marks,
47
+ disabled = false,
48
+ name,
49
+ className = "",
50
+ }: SliderProps<V>) {
51
+ const uid = useId();
52
+ const trackRef = useRef<HTMLDivElement>(null);
53
+ const knobs = useRef<(HTMLSpanElement | null)[]>([]);
54
+ const isRange = Array.isArray(value ?? defaultValue);
55
+ const [internal, setInternal] = useState<V>(() => (defaultValue ?? value ?? (min as V)) as V);
56
+ const current = (value !== undefined ? value : internal) as V;
57
+ const values: number[] = Array.isArray(current) ? [...current] : [current as number];
58
+ const [dragging, setDragging] = useState<number | null>(null);
59
+ const digits = decimals(step);
60
+
61
+ const snap = (n: number) => Number(clamp(Math.round((n - min) / step) * step + min, min, max).toFixed(digits));
62
+ const pct = (n: number) => ((n - min) / (max - min)) * 100;
63
+
64
+ const emit = (next: number[], commit = false) => {
65
+ const v = (isRange ? [next[0], next[1]] : next[0]) as V;
66
+ setInternal(v);
67
+ if (next.some((n, i) => n !== values[i])) onChange?.(v);
68
+ if (commit) onCommit?.(v);
69
+ };
70
+
71
+ /* Each knob is kept on its own side of the other */
72
+ const setAt = (i: number, n: number, commit = false) => {
73
+ const next = [...values];
74
+ next[i] = snap(n);
75
+ if (isRange) next[i] = i === 0 ? Math.min(next[0], values[1]) : Math.max(next[1], values[0]);
76
+ emit(next, commit);
77
+ };
78
+
79
+ const fromPointer = (clientX: number) => {
80
+ const r = trackRef.current!.getBoundingClientRect();
81
+ return min + clamp((clientX - r.left) / r.width, 0, 1) * (max - min);
82
+ };
83
+
84
+ const onPointerDown = (e: React.PointerEvent) => {
85
+ if (disabled || e.button !== 0) return;
86
+ e.preventDefault();
87
+ const n = fromPointer(e.clientX);
88
+ // Grab whichever knob is nearer; on a tie, the one that can move that way
89
+ let i = 0;
90
+ if (isRange) {
91
+ const d0 = Math.abs(n - values[0]);
92
+ const d1 = Math.abs(n - values[1]);
93
+ i = d1 < d0 || (d0 === d1 && n > values[1]) ? 1 : 0;
94
+ }
95
+ (e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
96
+ setDragging(i);
97
+ knobs.current[i]?.focus();
98
+ setAt(i, n);
99
+ };
100
+
101
+ const onKeyDown = (i: number) => (e: React.KeyboardEvent) => {
102
+ const big = Math.max(step, (max - min) / 10);
103
+ const moves: Record<string, number> = {
104
+ ArrowRight: step * (e.shiftKey ? 10 : 1),
105
+ ArrowUp: step * (e.shiftKey ? 10 : 1),
106
+ ArrowLeft: -step * (e.shiftKey ? 10 : 1),
107
+ ArrowDown: -step * (e.shiftKey ? 10 : 1),
108
+ PageUp: big,
109
+ PageDown: -big,
110
+ };
111
+ if (e.key in moves) {
112
+ e.preventDefault();
113
+ setAt(i, values[i] + moves[e.key], true);
114
+ } else if (e.key === "Home") {
115
+ e.preventDefault();
116
+ setAt(i, min, true);
117
+ } else if (e.key === "End") {
118
+ e.preventDefault();
119
+ setAt(i, max, true);
120
+ }
121
+ };
122
+
123
+ const shown =
124
+ typeof showValue === "function"
125
+ ? showValue(current)
126
+ : showValue
127
+ ? values.join(" – ")
128
+ : null;
129
+
130
+ const lo = isRange ? pct(values[0]) : 0;
131
+ const hi = pct(values[isRange ? 1 : 0]);
132
+
133
+ return (
134
+ <div className={["flex w-full flex-col gap-2.5", disabled ? "opacity-40" : "", className].join(" ")}>
135
+ {(label || shown !== null) && (
136
+ <div className="flex items-center justify-between gap-4">
137
+ {label && <FieldLabel id={`${uid}-label`}>{label}</FieldLabel>}
138
+ {shown !== null && <span className="text-[12.5px] tabular-nums text-ink-muted">{shown}</span>}
139
+ </div>
140
+ )}
141
+
142
+ <div
143
+ className={["relative flex h-6 touch-none items-center", disabled ? "pointer-events-none" : "cursor-pointer"].join(" ")}
144
+ onPointerDown={onPointerDown}
145
+ onPointerMove={(e) => dragging !== null && setAt(dragging, fromPointer(e.clientX))}
146
+ onPointerUp={() => {
147
+ if (dragging !== null) onCommit?.(current);
148
+ setDragging(null);
149
+ }}
150
+ onPointerCancel={() => setDragging(null)}
151
+ >
152
+ {/* The track: a groove cut into the surface */}
153
+ <div
154
+ ref={trackRef}
155
+ className="relative h-2 w-full rounded-full"
156
+ style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}
157
+ >
158
+ <div
159
+ className="absolute inset-y-px rounded-full"
160
+ style={{
161
+ left: `calc(${lo}% + ${isRange ? 0 : 1}px)`,
162
+ width: `calc(${hi - lo}% - ${isRange ? 0 : 1}px)`,
163
+ background: "var(--face-inverse)",
164
+ boxShadow: "var(--fill-shadow)",
165
+ transition: dragging === null ? "left 200ms var(--ease-out-soft), width 200ms var(--ease-out-soft)" : "none",
166
+ }}
167
+ />
168
+ </div>
169
+
170
+ {values.map((v, i) => (
171
+ <span
172
+ key={i}
173
+ ref={(el) => {
174
+ knobs.current[i] = el;
175
+ }}
176
+ role="slider"
177
+ tabIndex={disabled ? -1 : 0}
178
+ aria-labelledby={label ? `${uid}-label` : undefined}
179
+ aria-label={isRange ? (i === 0 ? "minimum" : "maximum") : label ? undefined : "value"}
180
+ aria-valuemin={isRange && i === 1 ? values[0] : min}
181
+ aria-valuemax={isRange && i === 0 ? values[1] : max}
182
+ aria-valuenow={v}
183
+ aria-disabled={disabled || undefined}
184
+ onKeyDown={onKeyDown(i)}
185
+ className="absolute top-1/2 h-5 w-5 rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ink focus-visible:ring-offset-2 focus-visible:ring-offset-page"
186
+ style={{
187
+ left: `${pct(v)}%`,
188
+ // the last-dragged knob of a range sits on top, so two at one spot can be pulled apart
189
+ zIndex: dragging === i ? 2 : 1,
190
+ background: "var(--face)",
191
+ boxShadow: "var(--knob-shadow)",
192
+ transform: `translate(-50%, -50%) scale(${dragging === i ? 1.15 : 1})`,
193
+ transition:
194
+ dragging === null
195
+ ? "left 200ms var(--ease-out-soft), transform 220ms var(--ease-spring)"
196
+ : "transform 220ms var(--ease-spring)",
197
+ }}
198
+ >
199
+ {/* A small dimple on the knob's crown */}
200
+ <span
201
+ aria-hidden
202
+ className="absolute left-1/2 top-1/2 h-1.5 w-1.5 -translate-x-1/2 -translate-y-1/2 rounded-full"
203
+ style={{ background: "var(--track)", boxShadow: "var(--track-shadow)" }}
204
+ />
205
+ </span>
206
+ ))}
207
+ </div>
208
+
209
+ {marks && marks.length > 0 && (
210
+ <div aria-hidden className="relative h-4">
211
+ {marks.map((m) => (
212
+ <span
213
+ key={m}
214
+ className="absolute -translate-x-1/2 text-[11px] tabular-nums text-ink-faint"
215
+ style={{ left: `${pct(m)}%` }}
216
+ >
217
+ {m}
218
+ </span>
219
+ ))}
220
+ </div>
221
+ )}
222
+
223
+ {name &&
224
+ values.map((v, i) => (
225
+ <input key={i} type="hidden" name={isRange ? `${name}[${i}]` : name} value={v} />
226
+ ))}
227
+ </div>
228
+ );
229
+ }
@@ -0,0 +1,32 @@
1
+ import React from "react";
2
+
3
+ type SpinnerSize = "sm" | "md" | "lg";
4
+
5
+ interface SpinnerProps {
6
+ size?: SpinnerSize | number;
7
+ /** Read out to screen readers; the spinner is silent without it */
8
+ label?: string;
9
+ className?: string;
10
+ }
11
+
12
+ const SIZES: Record<SpinnerSize, number> = { sm: 14, md: 18, lg: 24 };
13
+
14
+ /* A short arc chasing round a faint ring. It takes the text colour, so
15
+ inside a primary button it's light and on the page it's ink. */
16
+ export function Spinner({ size = "md", label, className = "" }: SpinnerProps) {
17
+ const px = typeof size === "number" ? size : SIZES[size];
18
+ return (
19
+ <span
20
+ role={label ? "status" : undefined}
21
+ aria-label={label}
22
+ aria-hidden={label ? undefined : true}
23
+ className={["inline-flex shrink-0", className].join(" ")}
24
+ style={{ width: px, height: px }}
25
+ >
26
+ <svg viewBox="0 0 24 24" width={px} height={px} fill="none" className="animate-spin">
27
+ <circle cx="12" cy="12" r="9.5" stroke="currentColor" strokeWidth="2.5" opacity="0.18" />
28
+ <path d="M21.5 12a9.5 9.5 0 0 0-9.5-9.5" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" />
29
+ </svg>
30
+ </span>
31
+ );
32
+ }