@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.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +202 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +121 -0
- package/src/components/agent-steering-panel.tsx +274 -0
- package/src/components/approval-card.tsx +89 -0
- package/src/components/avatar.tsx +104 -0
- package/src/components/badge.tsx +63 -0
- package/src/components/banner.tsx +103 -0
- package/src/components/bot-instructions-panel.tsx +365 -0
- package/src/components/bot-list-row.tsx +42 -0
- package/src/components/bot-memory-panel.tsx +229 -0
- package/src/components/bottom-sheet.tsx +140 -0
- package/src/components/breadcrumb-header.tsx +87 -0
- package/src/components/breadcrumb.tsx +86 -0
- package/src/components/button.tsx +146 -0
- package/src/components/calendar.tsx +410 -0
- package/src/components/card.tsx +79 -0
- package/src/components/chat-bubble.tsx +36 -0
- package/src/components/chat-controls.tsx +118 -0
- package/src/components/checkbox.tsx +73 -0
- package/src/components/chip.tsx +81 -0
- package/src/components/choice-card.tsx +55 -0
- package/src/components/code-panel.tsx +27 -0
- package/src/components/combobox.tsx +312 -0
- package/src/components/command-palette.tsx +191 -0
- package/src/components/composer.tsx +124 -0
- package/src/components/data-table.tsx +429 -0
- package/src/components/date-field.tsx +100 -0
- package/src/components/dialog.tsx +93 -0
- package/src/components/directory-card.tsx +59 -0
- package/src/components/document-extract-panel.tsx +398 -0
- package/src/components/empty-state.tsx +29 -0
- package/src/components/field.tsx +78 -0
- package/src/components/file-drop.tsx +89 -0
- package/src/components/filter-bar.tsx +83 -0
- package/src/components/flue-background.tsx +402 -0
- package/src/components/horon-icon.tsx +280 -0
- package/src/components/horon-wordmark.tsx +26 -0
- package/src/components/icon-button.tsx +61 -0
- package/src/components/input-group.tsx +112 -0
- package/src/components/input.tsx +72 -0
- package/src/components/integration-row.tsx +103 -0
- package/src/components/kbd.tsx +35 -0
- package/src/components/knowledge-base-panel.tsx +185 -0
- package/src/components/liquid-glass.tsx +24 -0
- package/src/components/list-detail.tsx +275 -0
- package/src/components/marketplace-card.tsx +97 -0
- package/src/components/mcp-server-row.tsx +122 -0
- package/src/components/menu.tsx +165 -0
- package/src/components/message-extras.tsx +156 -0
- package/src/components/modal.tsx +108 -0
- package/src/components/orchestrator-panel.tsx +175 -0
- package/src/components/page-header.tsx +48 -0
- package/src/components/plan-card.tsx +70 -0
- package/src/components/plan-step.tsx +111 -0
- package/src/components/plugin-row.tsx +128 -0
- package/src/components/progress.tsx +21 -0
- package/src/components/radio-group.tsx +84 -0
- package/src/components/research-run-panel.tsx +232 -0
- package/src/components/schedule.tsx +332 -0
- package/src/components/segmented-control.tsx +136 -0
- package/src/components/select.tsx +154 -0
- package/src/components/settings-list.tsx +99 -0
- package/src/components/sidebar.tsx +542 -0
- package/src/components/skeleton.tsx +75 -0
- package/src/components/slider.tsx +29 -0
- package/src/components/source-row.tsx +112 -0
- package/src/components/spinner.tsx +29 -0
- package/src/components/split-button.tsx +72 -0
- package/src/components/switch.tsx +79 -0
- package/src/components/tab-bar.tsx +90 -0
- package/src/components/tabs.tsx +162 -0
- package/src/components/task-row.tsx +159 -0
- package/src/components/textarea-field.tsx +127 -0
- package/src/components/textarea.tsx +47 -0
- package/src/components/toast.tsx +128 -0
- package/src/components/tool-call.tsx +224 -0
- package/src/components/tool-calls-header.tsx +65 -0
- package/src/components/tool-permissions.tsx +105 -0
- package/src/components/tooltip.tsx +33 -0
- package/src/components/top-bar.tsx +65 -0
- package/src/components/usage-meter.tsx +47 -0
- package/src/components/workspace-menu.tsx +77 -0
- package/src/index.ts +86 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/size.ts +10 -0
- package/src/lib/use-active-indicator.ts +60 -0
- 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
|
+
}
|