@horon-ai/react 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/README.md +168 -2
  2. package/package.json +50 -5
  3. package/src/assets/sidebar-photo.jpg +0 -0
  4. package/src/components/agent-activity.tsx +202 -0
  5. package/src/components/agent-lane.tsx +80 -0
  6. package/src/components/agent-lanes-timeline.tsx +312 -0
  7. package/src/components/agent-panel.tsx +121 -0
  8. package/src/components/agent-steering-panel.tsx +274 -0
  9. package/src/components/approval-card.tsx +89 -0
  10. package/src/components/avatar.tsx +104 -0
  11. package/src/components/badge.tsx +63 -0
  12. package/src/components/banner.tsx +103 -0
  13. package/src/components/bot-instructions-panel.tsx +365 -0
  14. package/src/components/bot-list-row.tsx +42 -0
  15. package/src/components/bot-memory-panel.tsx +229 -0
  16. package/src/components/bottom-sheet.tsx +140 -0
  17. package/src/components/breadcrumb-header.tsx +87 -0
  18. package/src/components/breadcrumb.tsx +86 -0
  19. package/src/components/button.tsx +146 -0
  20. package/src/components/calendar.tsx +410 -0
  21. package/src/components/card.tsx +79 -0
  22. package/src/components/chat-bubble.tsx +36 -0
  23. package/src/components/chat-controls.tsx +118 -0
  24. package/src/components/checkbox.tsx +73 -0
  25. package/src/components/chip.tsx +81 -0
  26. package/src/components/choice-card.tsx +55 -0
  27. package/src/components/code-panel.tsx +27 -0
  28. package/src/components/combobox.tsx +312 -0
  29. package/src/components/command-palette.tsx +191 -0
  30. package/src/components/composer.tsx +124 -0
  31. package/src/components/data-table.tsx +429 -0
  32. package/src/components/date-field.tsx +100 -0
  33. package/src/components/dialog.tsx +93 -0
  34. package/src/components/directory-card.tsx +59 -0
  35. package/src/components/document-extract-panel.tsx +398 -0
  36. package/src/components/empty-state.tsx +29 -0
  37. package/src/components/field.tsx +78 -0
  38. package/src/components/file-drop.tsx +89 -0
  39. package/src/components/filter-bar.tsx +83 -0
  40. package/src/components/flue-background.tsx +402 -0
  41. package/src/components/horon-icon.tsx +280 -0
  42. package/src/components/horon-wordmark.tsx +26 -0
  43. package/src/components/icon-button.tsx +61 -0
  44. package/src/components/input-group.tsx +112 -0
  45. package/src/components/input.tsx +72 -0
  46. package/src/components/integration-row.tsx +103 -0
  47. package/src/components/kbd.tsx +35 -0
  48. package/src/components/knowledge-base-panel.tsx +185 -0
  49. package/src/components/liquid-glass.tsx +24 -0
  50. package/src/components/list-detail.tsx +275 -0
  51. package/src/components/marketplace-card.tsx +97 -0
  52. package/src/components/mcp-server-row.tsx +122 -0
  53. package/src/components/menu.tsx +165 -0
  54. package/src/components/message-extras.tsx +156 -0
  55. package/src/components/modal.tsx +108 -0
  56. package/src/components/orchestrator-panel.tsx +175 -0
  57. package/src/components/page-header.tsx +48 -0
  58. package/src/components/plan-card.tsx +70 -0
  59. package/src/components/plan-step.tsx +111 -0
  60. package/src/components/plugin-row.tsx +128 -0
  61. package/src/components/progress.tsx +21 -0
  62. package/src/components/radio-group.tsx +84 -0
  63. package/src/components/research-run-panel.tsx +232 -0
  64. package/src/components/schedule.tsx +332 -0
  65. package/src/components/segmented-control.tsx +136 -0
  66. package/src/components/select.tsx +154 -0
  67. package/src/components/settings-list.tsx +99 -0
  68. package/src/components/sidebar.tsx +542 -0
  69. package/src/components/skeleton.tsx +75 -0
  70. package/src/components/slider.tsx +29 -0
  71. package/src/components/source-row.tsx +112 -0
  72. package/src/components/spinner.tsx +29 -0
  73. package/src/components/split-button.tsx +72 -0
  74. package/src/components/switch.tsx +79 -0
  75. package/src/components/tab-bar.tsx +90 -0
  76. package/src/components/tabs.tsx +162 -0
  77. package/src/components/task-row.tsx +159 -0
  78. package/src/components/textarea-field.tsx +127 -0
  79. package/src/components/textarea.tsx +47 -0
  80. package/src/components/toast.tsx +128 -0
  81. package/src/components/tool-call.tsx +224 -0
  82. package/src/components/tool-calls-header.tsx +65 -0
  83. package/src/components/tool-permissions.tsx +105 -0
  84. package/src/components/tooltip.tsx +33 -0
  85. package/src/components/top-bar.tsx +65 -0
  86. package/src/components/usage-meter.tsx +47 -0
  87. package/src/components/workspace-menu.tsx +77 -0
  88. package/src/index.ts +86 -0
  89. package/src/lib/cn.ts +25 -0
  90. package/src/lib/cursor-ghost.ts +244 -0
  91. package/src/lib/size.ts +10 -0
  92. package/src/lib/use-active-indicator.ts +60 -0
  93. package/src/styles/globals.css +564 -0
@@ -0,0 +1,398 @@
1
+ import { CaretDown, Check, FilePdf, Minus, Plus, Scan } from "@phosphor-icons/react";
2
+ import { type ComponentProps, type ReactNode, useMemo, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { PanelPillTabs } from "./bot-instructions-panel";
6
+
7
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
8
+
9
+ export type ExtractFieldStatus = "filled" | "check";
10
+
11
+ export type ExtractField = {
12
+ id: string;
13
+ label: string;
14
+ value: string;
15
+ /** Field type shown at the row end, for example `Text` or `Date`. */
16
+ type?: string;
17
+ status?: ExtractFieldStatus;
18
+ };
19
+
20
+ export type ExtractTable = {
21
+ id: string;
22
+ label: string;
23
+ type?: string;
24
+ columns: { label: string; align?: "start" | "end"; width?: number }[];
25
+ rows: string[][];
26
+ };
27
+
28
+ export type ExtractGroup = {
29
+ id: string;
30
+ label: string;
31
+ fields: (ExtractField | ExtractTable)[];
32
+ };
33
+
34
+ export type DocumentExtractPanelProps = WithClass<Omit<ComponentProps<"section">, "title">> & {
35
+ fileName: string;
36
+ pageLabel?: string;
37
+ /** The paper sheet content. Wrap fields in `ExtractHighlight` to outline what was read. */
38
+ sheet: ReactNode;
39
+ groups: ExtractGroup[];
40
+ defaultZoom?: number;
41
+ bot?: { initial: string; name: string; elapsed?: string };
42
+ onExtract?: () => void;
43
+ onAcceptAll?: () => void;
44
+ /** Makes the file name actionable; without it the name is a plain label. */
45
+ onOpenFile?: () => void;
46
+ };
47
+
48
+ const ZOOM_MIN = 30;
49
+ const ZOOM_MAX = 150;
50
+ const ZOOM_STEP = 10;
51
+ const ZOOM_FIT = 70;
52
+
53
+ const highlightTone = {
54
+ sky: "border-accent-sky-500-90 bg-accent-sky-500-12",
55
+ brand: "border-accent-sky-700-90 bg-accent-sky-700-12",
56
+ neutral: "border-text-placeholder-90 bg-text-placeholder-12",
57
+ ink: "border-interactive-primary bg-overlay-ink-12",
58
+ } as const;
59
+
60
+ /** An outline over a value on the sheet, the 4px-radius 1.2px box Paper draws where a field was read. */
61
+ export function ExtractHighlight({
62
+ tone = "sky",
63
+ className,
64
+ ...props
65
+ }: WithClass<ComponentProps<"div">> & { tone?: keyof typeof highlightTone }) {
66
+ return (
67
+ <div
68
+ className={cn("flex flex-col gap-px rounded-sm border-[1.2px] px-1 py-0.5", highlightTone[tone], className)}
69
+ {...props}
70
+ />
71
+ );
72
+ }
73
+
74
+ function isTable(field: ExtractField | ExtractTable): field is ExtractTable {
75
+ return "rows" in field;
76
+ }
77
+
78
+ function isChecked(field: ExtractField, resolved: Set<string>) {
79
+ return field.status === "check" && !resolved.has(field.id);
80
+ }
81
+
82
+ function Divider({ vertical }: { vertical?: boolean }) {
83
+ return (
84
+ <div
85
+ role="separator"
86
+ className={cn("shrink-0 bg-overlay-ink-8", vertical ? "w-px self-stretch" : "h-px self-stretch")}
87
+ />
88
+ );
89
+ }
90
+
91
+ function stepClass() {
92
+ return "flex size-6 shrink-0 cursor-pointer items-center justify-center rounded-full text-text-primary motion-press hover:bg-overlay-ink-8 disabled:cursor-not-allowed disabled:opacity-40 focus-visible:outline-2 focus-visible:outline-text-brand";
93
+ }
94
+
95
+ /**
96
+ * Document extract panel (Paper `YUT-1` / `GWP-0`): a viewer with zoom controls on the left and the extracted
97
+ * fields on the right, with Fields / JSON / Schema views, a per-row confirmation check and an Accept all footer.
98
+ */
99
+ export function DocumentExtractPanel({
100
+ fileName,
101
+ pageLabel = "1 / 1",
102
+ sheet,
103
+ groups,
104
+ defaultZoom = ZOOM_FIT,
105
+ bot,
106
+ onExtract,
107
+ onAcceptAll,
108
+ onOpenFile,
109
+ className,
110
+ ...props
111
+ }: DocumentExtractPanelProps) {
112
+ const [zoom, setZoom] = useState(defaultZoom);
113
+ const [view, setView] = useState("fields");
114
+ const [resolved, setResolved] = useState<Set<string>>(() => new Set());
115
+ const [run, setRun] = useState(0);
116
+
117
+ const flat = useMemo(() => groups.flatMap((group) => group.fields), [groups]);
118
+ const total = flat.length;
119
+ const toCheck = flat.filter((field) => !isTable(field) && isChecked(field, resolved)).length;
120
+ const filled = total - toCheck;
121
+
122
+ const resolve = (id: string) => setResolved((previous) => new Set(previous).add(id));
123
+ const acceptAll = () => {
124
+ setResolved(new Set(flat.map((field) => field.id)));
125
+ onAcceptAll?.();
126
+ };
127
+ const json = useMemo(() => {
128
+ const data: Record<string, unknown> = {};
129
+ for (const field of flat) {
130
+ data[field.label] = isTable(field)
131
+ ? field.rows.map((row) => Object.fromEntries(field.columns.map((column, index) => [column.label, row[index]])))
132
+ : field.value;
133
+ }
134
+ return JSON.stringify(data, null, 2);
135
+ }, [flat]);
136
+
137
+ let rowIndex = 0;
138
+
139
+ return (
140
+ <section
141
+ className={cn(
142
+ "@container flex w-full max-w-280 min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
143
+ className,
144
+ )}
145
+ {...props}
146
+ >
147
+ <div className="flex flex-col @4xl:flex-row">
148
+ <div className="flex min-w-0 flex-col @4xl:w-135 @4xl:shrink-0">
149
+ <div className="flex flex-wrap items-center gap-2 px-5 py-3">
150
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-[10px] bg-overlay-ink-5 text-text-danger">
151
+ <FilePdf aria-hidden className="size-4" />
152
+ </span>
153
+ {onOpenFile ? (
154
+ <button
155
+ type="button"
156
+ onClick={onOpenFile}
157
+ className="flex min-w-0 cursor-pointer items-center gap-1 rounded-md text-left focus-visible:outline-2 focus-visible:outline-text-brand"
158
+ >
159
+ <span className="min-w-0 truncate text-label-md text-text-primary">{fileName}</span>
160
+ <CaretDown aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
161
+ </button>
162
+ ) : (
163
+ <span className="min-w-0 truncate text-label-md text-text-primary">{fileName}</span>
164
+ )}
165
+ <span className="min-w-0 flex-1" />
166
+ <span className="shrink-0 text-caption text-text-secondary">{pageLabel}</span>
167
+ <div className="flex h-7.5 shrink-0 items-center gap-0.5 rounded-full bg-overlay-ink-5 p-[3px]">
168
+ <button
169
+ type="button"
170
+ aria-label="Zoom out"
171
+ disabled={zoom <= ZOOM_MIN}
172
+ onClick={() => setZoom((value) => Math.max(ZOOM_MIN, value - ZOOM_STEP))}
173
+ className={stepClass()}
174
+ >
175
+ <Minus aria-hidden className="size-3.5" />
176
+ </button>
177
+ <span className="w-9 text-center text-label-sm text-text-primary tabular-nums">{zoom}%</span>
178
+ <button
179
+ type="button"
180
+ aria-label="Zoom in"
181
+ disabled={zoom >= ZOOM_MAX}
182
+ onClick={() => setZoom((value) => Math.min(ZOOM_MAX, value + ZOOM_STEP))}
183
+ className={stepClass()}
184
+ >
185
+ <Plus aria-hidden className="size-3.5" />
186
+ </button>
187
+ </div>
188
+ <button
189
+ type="button"
190
+ onClick={() => setZoom(ZOOM_FIT)}
191
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-overlay-ink-6 pr-3.5 pl-3 text-label-sm text-text-primary hover:bg-overlay-ink-8 focus-visible:outline-2 focus-visible:outline-text-brand"
192
+ >
193
+ Fit
194
+ </button>
195
+ </div>
196
+ <div className="flex min-h-120 flex-1 justify-center overflow-hidden bg-overlay-ink-7 px-6 py-5">
197
+ <div
198
+ className="flex h-fit w-full max-w-117.5 shrink-0 origin-top flex-col gap-3.5 bg-neutral-50 px-6.5 pt-6.5 pb-5 text-neutral-900 ring-1 ring-overlay-ink-8 [filter:drop-shadow(0_2px_4px_#00000014)] transition-[scale] duration-220 ease-out-quint"
199
+ style={{ scale: zoom / ZOOM_FIT }}
200
+ >
201
+ {sheet}
202
+ </div>
203
+ </div>
204
+ </div>
205
+ <Divider vertical />
206
+ <div className="flex min-w-0 flex-1 flex-col">
207
+ <div className="flex items-center gap-2 px-5 py-3">
208
+ <PanelPillTabs
209
+ aria-label="Extraction view"
210
+ value={view}
211
+ onValueChange={setView}
212
+ items={[
213
+ { value: "fields", label: "Fields" },
214
+ { value: "json", label: "JSON" },
215
+ { value: "schema", label: "Schema" },
216
+ ]}
217
+ />
218
+ <span className="min-w-0 flex-1" />
219
+ <button
220
+ type="button"
221
+ onClick={() => {
222
+ setRun((value) => value + 1);
223
+ onExtract?.();
224
+ }}
225
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center justify-center gap-1.5 rounded-full bg-overlay-ink-6 pr-3 pl-3.5 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
226
+ >
227
+ <Scan aria-hidden className="size-3.5" />
228
+ Extract
229
+ </button>
230
+ </div>
231
+ <Divider />
232
+ <div key={`${view}-${run}`} className="flex flex-col gap-1.5 px-5 pt-3.5 pb-4">
233
+ {view === "fields" ? (
234
+ <>
235
+ <p className="text-body-sm text-text-secondary">{total} fields read from this document</p>
236
+ {groups.map((group) => (
237
+ <div key={group.id} className="flex flex-col gap-1.5">
238
+ <div className="flex items-center gap-2 pt-3 pb-1">
239
+ <span aria-hidden className="size-1.75 rounded-full bg-sky-500" />
240
+ <h4 className="text-label-md text-text-primary">{group.label}</h4>
241
+ </div>
242
+ {group.fields.map((field) => {
243
+ const delay = { animationDelay: `${rowIndex++ * 30}ms` };
244
+ if (isTable(field)) {
245
+ return (
246
+ <div key={field.id} className="flex motion-rise flex-col gap-1.5" style={delay}>
247
+ <div className="flex items-center gap-3">
248
+ <span className="w-25 shrink-0 text-body-sm text-text-secondary">{field.label}</span>
249
+ <span className="min-w-0 flex-1" />
250
+ {field.type ? (
251
+ <span className="w-14 shrink-0 text-end text-caption text-text-secondary">
252
+ {field.type}
253
+ </span>
254
+ ) : null}
255
+ </div>
256
+ <div className="flex flex-col overflow-x-auto rounded-[10px] border border-overlay-ink-10 px-3 py-0.5">
257
+ <div className="flex min-w-52 flex-col">
258
+ <div className="flex items-center gap-2 py-2 text-caption text-text-secondary">
259
+ {field.columns.map((column) => (
260
+ <span
261
+ key={column.label}
262
+ className={cn(
263
+ column.width ? "shrink-0" : "min-w-0 flex-1",
264
+ column.align === "end" && "text-end",
265
+ )}
266
+ style={column.width ? { width: column.width } : undefined}
267
+ >
268
+ {column.label}
269
+ </span>
270
+ ))}
271
+ </div>
272
+ {field.rows.map((row) => (
273
+ <div
274
+ key={row.join("|")}
275
+ className="flex items-center gap-2 py-2 text-caption text-text-primary"
276
+ >
277
+ {row.map((cell, index) => {
278
+ const column = field.columns[index];
279
+ return (
280
+ <span
281
+ key={column?.label ?? cell}
282
+ className={cn(
283
+ column?.width ? "shrink-0" : "min-w-0 flex-1",
284
+ column?.align === "end" && "text-end",
285
+ )}
286
+ style={column?.width ? { width: column.width } : undefined}
287
+ >
288
+ {cell}
289
+ </span>
290
+ );
291
+ })}
292
+ </div>
293
+ ))}
294
+ </div>
295
+ </div>
296
+ </div>
297
+ );
298
+ }
299
+ const needsCheck = isChecked(field, resolved);
300
+ return (
301
+ <div key={field.id} className="flex motion-rise flex-wrap items-center gap-3" style={delay}>
302
+ <span className="w-25 shrink-0 text-body-sm text-text-secondary">{field.label}</span>
303
+ <button
304
+ type="button"
305
+ disabled={!needsCheck}
306
+ onClick={() => resolve(field.id)}
307
+ aria-label={needsCheck ? `Confirm ${field.label}` : undefined}
308
+ className={cn(
309
+ "flex h-9 min-w-0 flex-1 items-center gap-2 rounded-[10px] border px-3 text-start motion-colors",
310
+ needsCheck
311
+ ? "cursor-pointer border-border-danger bg-overlay-ink-12 hover:bg-overlay-ink-14 focus-visible:outline-2 focus-visible:outline-text-brand"
312
+ : "cursor-default border-transparent bg-overlay-ink-4",
313
+ )}
314
+ >
315
+ <span className="min-w-0 flex-1 truncate text-body-sm text-text-primary">
316
+ {field.value}
317
+ </span>
318
+ {needsCheck ? (
319
+ <span className="shrink-0 text-caption text-text-danger">Check</span>
320
+ ) : (
321
+ <Check
322
+ aria-hidden
323
+ weight="bold"
324
+ className="size-3.5 shrink-0 animate-pop-in text-text-secondary"
325
+ />
326
+ )}
327
+ </button>
328
+ {field.type ? (
329
+ <span className="w-14 shrink-0 text-end text-caption text-text-secondary">
330
+ {field.type}
331
+ </span>
332
+ ) : null}
333
+ </div>
334
+ );
335
+ })}
336
+ </div>
337
+ ))}
338
+ </>
339
+ ) : null}
340
+ {view === "json" ? (
341
+ <pre className="animate-slide-shift-next overflow-x-auto rounded-[10px] bg-overlay-ink-4 p-3 font-mono text-caption whitespace-pre text-text-primary">
342
+ {json}
343
+ </pre>
344
+ ) : null}
345
+ {view === "schema" ? (
346
+ <ul className="flex animate-slide-shift-next flex-col gap-1.5">
347
+ {flat.map((field) => (
348
+ <li key={field.id} className="flex items-center gap-3 text-body-sm text-text-primary">
349
+ <span className="w-25 shrink-0 text-text-secondary">{field.label}</span>
350
+ <span className="rounded-md bg-overlay-ink-6 px-1.5 py-px text-caption text-text-secondary">
351
+ {field.type ?? "Text"}
352
+ </span>
353
+ </li>
354
+ ))}
355
+ </ul>
356
+ ) : null}
357
+ </div>
358
+ </div>
359
+ </div>
360
+ <Divider />
361
+ <div className="flex flex-wrap items-center gap-3 px-5 py-3">
362
+ <div className="flex items-center gap-1" aria-hidden>
363
+ {flat.map((field) => (
364
+ <span
365
+ key={field.id}
366
+ className={cn(
367
+ "size-2 rounded-full motion-colors",
368
+ !isTable(field) && isChecked(field, resolved) ? "bg-red-500" : "bg-sky-500",
369
+ )}
370
+ />
371
+ ))}
372
+ </div>
373
+ <span className="text-body-sm text-text-secondary">
374
+ {filled} of {total} filled · {toCheck} to check
375
+ </span>
376
+ <span className="min-w-0 flex-1" />
377
+ {bot ? (
378
+ <>
379
+ <span className="flex size-5 items-center justify-center rounded-[5px] bg-interactive-primary text-label-sm text-text-inverse">
380
+ {bot.initial}
381
+ </span>
382
+ <span className="text-caption text-text-secondary">
383
+ {bot.name}
384
+ {bot.elapsed ? ` · ${bot.elapsed}` : ""}
385
+ </span>
386
+ </>
387
+ ) : null}
388
+ <button
389
+ type="button"
390
+ onClick={acceptAll}
391
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-interactive-primary pr-3 pl-3.5 text-label-sm text-text-inverse hover:bg-interactive-primary-hover focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
392
+ >
393
+ Accept all
394
+ </button>
395
+ </div>
396
+ </section>
397
+ );
398
+ }
@@ -0,0 +1,29 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type EmptyStateProps = {
6
+ /** 140px illustration, such as a mascot pose. */
7
+ illustration?: ReactNode;
8
+ title: ReactNode;
9
+ description?: ReactNode;
10
+ /** Action under the text, usually a `Button`. */
11
+ action?: ReactNode;
12
+ className?: string;
13
+ };
14
+
15
+ /** Figma `Empty state` (64:485): centered 140px mascot, title-md heading, body-md text, action. */
16
+ export function EmptyState({ illustration, title, description, action, className }: EmptyStateProps) {
17
+ return (
18
+ <div
19
+ className={cn("flex w-full max-w-90 motion-rise flex-col items-center gap-3 px-6 py-8 text-center", className)}
20
+ >
21
+ {illustration && (
22
+ <div className="flex size-35 shrink-0 items-center justify-center [&>*]:size-full">{illustration}</div>
23
+ )}
24
+ <p className="text-title-md text-balance text-text-primary">{title}</p>
25
+ {description && <p className="self-stretch text-body-md text-pretty text-text-secondary">{description}</p>}
26
+ {action && <div className="mt-4 flex">{action}</div>}
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,78 @@
1
+ import { Field as BaseField } from "@base-ui/react/field";
2
+ import { createContext, useContext, type ComponentProps } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+
7
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
8
+
9
+ const FieldSizeContext = createContext<ControlSize>("md");
10
+
11
+ const gapClass: Record<ControlSize, string> = {
12
+ sm: "gap-1.5",
13
+ md: "gap-2",
14
+ lg: "gap-2.5",
15
+ };
16
+
17
+ const labelClass: Record<ControlSize, string> = {
18
+ sm: "text-label-sm",
19
+ md: "text-label-md",
20
+ lg: "text-label-lg",
21
+ };
22
+
23
+ const metaClass: Record<ControlSize, string> = {
24
+ sm: "text-caption",
25
+ md: "text-caption",
26
+ lg: "text-body-sm",
27
+ };
28
+
29
+ /** Figma `Field examples` (58:163): label, control and help text, 6/8/10px apart. */
30
+ export function Field({
31
+ size = "md",
32
+ className,
33
+ ...props
34
+ }: WithClass<Omit<ComponentProps<typeof BaseField.Root>, "size">> & { size?: ControlSize }) {
35
+ return (
36
+ <FieldSizeContext.Provider value={size}>
37
+ <BaseField.Root
38
+ className={cn("flex max-w-full min-w-0 flex-col items-start", gapClass[size], className)}
39
+ {...props}
40
+ />
41
+ </FieldSizeContext.Provider>
42
+ );
43
+ }
44
+
45
+ export function FieldLabel({ className, ...props }: WithClass<ComponentProps<typeof BaseField.Label>>) {
46
+ const size = useContext(FieldSizeContext);
47
+ return (
48
+ <BaseField.Label
49
+ className={cn(labelClass[size], "text-text-primary data-disabled:text-text-disabled", className)}
50
+ {...props}
51
+ />
52
+ );
53
+ }
54
+
55
+ export function FieldDescription({ className, ...props }: WithClass<ComponentProps<typeof BaseField.Description>>) {
56
+ const size = useContext(FieldSizeContext);
57
+ return <BaseField.Description className={cn(metaClass[size], "w-full text-text-secondary", className)} {...props} />;
58
+ }
59
+
60
+ export function FieldError({ className, children, ...props }: WithClass<ComponentProps<typeof BaseField.Error>>) {
61
+ const size = useContext(FieldSizeContext);
62
+ // Base UI keeps the message mounted through its exit (`data-ending-style`), so the row can close
63
+ // as it fades: the fields below move with it instead of jumping when the text appears.
64
+ return (
65
+ <BaseField.Error
66
+ className={cn(
67
+ "motion-reveal data-ending-style:grid-rows-[0fr] data-starting-style:grid-rows-[0fr]",
68
+ "data-ending-style:opacity-0 data-starting-style:opacity-0",
69
+ metaClass[size],
70
+ "text-text-danger",
71
+ className,
72
+ )}
73
+ {...props}
74
+ >
75
+ <span className="block">{children}</span>
76
+ </BaseField.Error>
77
+ );
78
+ }
@@ -0,0 +1,89 @@
1
+ import { UploadSimple } from "@phosphor-icons/react";
2
+ import { type ComponentProps, type DragEvent, type ReactNode, useId, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Spinner } from "./spinner";
6
+
7
+ export type FileDropSurface = "solid" | "glass";
8
+
9
+ export type FileDropProps = Omit<ComponentProps<"input">, "className" | "type" | "children" | "onDrop"> & {
10
+ /** Label next to the icon. */
11
+ label?: ReactNode;
12
+ /** Shows a spinner in place of the icon while files upload. */
13
+ uploading?: boolean;
14
+ /** Danger rim for a rejected file. */
15
+ rejected?: boolean;
16
+ /** Called with the dropped files. */
17
+ onFiles?: (files: File[]) => void;
18
+ /** `solid` (default) fills the target with the raised surface; `glass` puts it on the veil over imagery. */
19
+ surface?: FileDropSurface;
20
+ className?: string;
21
+ };
22
+
23
+ /**
24
+ * Paper `File drop` (1VWI-0): a 1.5px dashed r10 target, icon 16 and a 13/18 500 label centred with gap 8.
25
+ * Dragging swaps the dashes for a 1px brand rim; no shake or bounce. Solid fills with the raised surface,
26
+ * glass sits on the veil (`veil-drop`) and keeps the dashed border as its only outline.
27
+ */
28
+ export function FileDrop({
29
+ label = "Drop files here or click to upload",
30
+ uploading,
31
+ rejected,
32
+ onFiles,
33
+ surface = "solid",
34
+ disabled,
35
+ className,
36
+ onChange,
37
+ ...props
38
+ }: FileDropProps) {
39
+ const id = useId();
40
+ const [dragging, setDragging] = useState(false);
41
+
42
+ const onDrop = (event: DragEvent<HTMLLabelElement>) => {
43
+ event.preventDefault();
44
+ setDragging(false);
45
+ if (disabled || uploading) return;
46
+ onFiles?.(Array.from(event.dataTransfer.files));
47
+ };
48
+
49
+ return (
50
+ <label
51
+ htmlFor={id}
52
+ data-dragging={dragging ? "" : undefined}
53
+ data-rejected={rejected ? "" : undefined}
54
+ aria-busy={uploading || undefined}
55
+ onDragOver={(event) => {
56
+ event.preventDefault();
57
+ if (!disabled) setDragging(true);
58
+ }}
59
+ onDragLeave={() => setDragging(false)}
60
+ onDrop={onDrop}
61
+ className={cn(
62
+ "relative flex w-full max-w-[550px] cursor-pointer items-center justify-center gap-2 overflow-clip rounded-[10px] border-[1.5px] border-dashed border-border-dashed px-4 py-3 text-label-md text-text-secondary motion-colors",
63
+ "has-[:focus-visible]:outline-2 has-[:focus-visible]:outline-offset-2 has-[:focus-visible]:outline-text-brand",
64
+ // The dashed edge stays put (border-style cannot transition) and the active state adds an inner rim.
65
+ "data-dragging:border-border-brand data-dragging:shadow-[inset_0_0_0_1px_var(--color-text-brand)]",
66
+ surface === "solid" ? "bg-bg-surface data-dragging:bg-bg-subtle" : "veil-drop",
67
+ "data-rejected:border-border-danger data-rejected:text-text-danger data-rejected:shadow-[inset_0_0_0_1px_var(--color-border-danger)]",
68
+ disabled && "cursor-not-allowed opacity-40",
69
+ className,
70
+ )}
71
+ >
72
+ <input
73
+ id={id}
74
+ type="file"
75
+ className="sr-only"
76
+ disabled={disabled || uploading}
77
+ onChange={(event) => {
78
+ onFiles?.(Array.from(event.target.files ?? []));
79
+ onChange?.(event);
80
+ }}
81
+ {...props}
82
+ />
83
+ <span className="relative flex items-center gap-2">
84
+ {uploading ? <Spinner size={16} label="Uploading" /> : <UploadSimple aria-hidden className="size-4 shrink-0" />}
85
+ <span className={cn(uploading && "opacity-60")}>{label}</span>
86
+ </span>
87
+ </label>
88
+ );
89
+ }
@@ -0,0 +1,83 @@
1
+ import { type ComponentProps, type ReactNode, useRef, useState } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { useActiveIndicator } from "../lib/use-active-indicator";
5
+
6
+ export type FilterBarItem = {
7
+ value: string;
8
+ label: ReactNode;
9
+ /** Count shown after the label, such as 4. */
10
+ count?: number;
11
+ disabled?: boolean;
12
+ };
13
+
14
+ export type FilterBarProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue"> & {
15
+ items: FilterBarItem[];
16
+ value?: string;
17
+ defaultValue?: string;
18
+ onValueChange?: (value: string) => void;
19
+ };
20
+
21
+ /**
22
+ * Paper `Filter bar` (Agents N2): a 5% ink pill track with 30px options; the selected option sits on a sliding 10% ink pill.
23
+ * The pills never wrap, so a screen too narrow for all of them scrolls the track sideways instead of widening the page.
24
+ * The track keeps its intrinsic width (and so stays centred by its parent) unless that width exceeds the box.
25
+ */
26
+ export function FilterBar({
27
+ items,
28
+ value,
29
+ defaultValue,
30
+ onValueChange,
31
+ className,
32
+ "aria-label": ariaLabel = "Filter",
33
+ ...props
34
+ }: FilterBarProps) {
35
+ const [inner, setInner] = useState(defaultValue ?? items[0]?.value);
36
+ const selected = value ?? inner;
37
+ const track = useRef<HTMLDivElement>(null);
38
+ const { rect, animate } = useActiveIndicator(track, '[aria-checked="true"]');
39
+
40
+ return (
41
+ <div
42
+ ref={track}
43
+ role="radiogroup"
44
+ aria-label={ariaLabel}
45
+ className={cn(
46
+ "relative inline-flex max-w-full min-w-0 items-center gap-0.5 overflow-x-auto rounded-full bg-overlay-ink-5 p-[3px]",
47
+ className,
48
+ )}
49
+ {...props}
50
+ >
51
+ {rect && (
52
+ <span
53
+ aria-hidden
54
+ className={cn("absolute rounded-full bg-overlay-ink-10", animate && "motion-indicator")}
55
+ style={{ left: rect.left, top: rect.top, width: rect.width, height: rect.height }}
56
+ />
57
+ )}
58
+ {items.map((item) => {
59
+ const active = item.value === selected;
60
+ return (
61
+ <button
62
+ key={item.value}
63
+ type="button"
64
+ role="radio"
65
+ aria-checked={active}
66
+ disabled={item.disabled}
67
+ onClick={() => {
68
+ setInner(item.value);
69
+ onValueChange?.(item.value);
70
+ }}
71
+ className={cn(
72
+ "relative flex h-7.5 shrink-0 cursor-pointer items-center gap-1.5 rounded-full px-4 whitespace-nowrap motion-colors focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand disabled:cursor-not-allowed disabled:opacity-40",
73
+ active ? "text-text-primary" : "text-text-secondary hover:bg-overlay-ink-5 hover:text-text-primary",
74
+ )}
75
+ >
76
+ <span className="text-label-md">{item.label}</span>
77
+ {item.count !== undefined && <span className="text-caption text-text-secondary">{item.count}</span>}
78
+ </button>
79
+ );
80
+ })}
81
+ </div>
82
+ );
83
+ }