@horon-ai/react 0.0.0-stage → 0.1.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.
- package/README.md +168 -2
- package/package.json +50 -5
- package/src/assets/sidebar-photo.jpg +0 -0
- package/src/components/agent-activity.tsx +204 -0
- package/src/components/agent-lane.tsx +80 -0
- package/src/components/agent-lanes-timeline.tsx +312 -0
- package/src/components/agent-panel.tsx +125 -0
- package/src/components/agent-steering-panel.tsx +276 -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 +369 -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 +206 -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 +388 -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 +42 -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 +146 -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 +215 -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/hooks/index.ts +8 -0
- package/src/hooks/use-active-indicator.ts +60 -0
- package/src/hooks/use-color-scheme.ts +27 -0
- package/src/hooks/use-copy-to-clipboard.ts +32 -0
- package/src/hooks/use-device.ts +41 -0
- package/src/hooks/use-hotkey.ts +35 -0
- package/src/hooks/use-media-query.ts +25 -0
- package/src/hooks/use-platform.ts +39 -0
- package/src/hooks/use-reduced-motion.ts +8 -0
- package/src/index.ts +99 -0
- package/src/lib/cn.ts +25 -0
- package/src/lib/cursor-ghost.ts +244 -0
- package/src/lib/platform.ts +169 -0
- package/src/lib/size.ts +10 -0
- package/src/styles/globals.css +564 -0
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { ArrowUp, Check, Lightning, Path, Pause, Play, X } from "@phosphor-icons/react";
|
|
2
|
+
import { type ComponentProps, type CSSProperties, type ReactNode, useState } from "react";
|
|
3
|
+
|
|
4
|
+
import { useDevice } from "../hooks/use-device";
|
|
5
|
+
import { usePlatform } from "../hooks/use-platform";
|
|
6
|
+
import { cn } from "../lib/cn";
|
|
7
|
+
import { isModKey } from "../lib/platform";
|
|
8
|
+
import { Kbd } from "./kbd";
|
|
9
|
+
import { Spinner } from "./spinner";
|
|
10
|
+
|
|
11
|
+
export type SteeringStepStatus = "done" | "active" | "todo";
|
|
12
|
+
|
|
13
|
+
export type SteeringStep = {
|
|
14
|
+
id: string;
|
|
15
|
+
label: ReactNode;
|
|
16
|
+
status: SteeringStepStatus;
|
|
17
|
+
/** Duration of a finished or running step, such as "1.4s". */
|
|
18
|
+
time?: ReactNode;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type SteeringNote = {
|
|
22
|
+
id: string;
|
|
23
|
+
text: ReactNode;
|
|
24
|
+
/** An applied note has left the queue and carries the "Now" pill. */
|
|
25
|
+
applied?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type AgentSteeringPanelProps = Omit<ComponentProps<"section">, "className" | "title"> & {
|
|
29
|
+
title: ReactNode;
|
|
30
|
+
subtitle?: ReactNode;
|
|
31
|
+
/** The status text beside the live dot, such as "Running · 2m 10s". */
|
|
32
|
+
status?: ReactNode;
|
|
33
|
+
paused?: boolean;
|
|
34
|
+
onPausedChange?: (paused: boolean) => void;
|
|
35
|
+
steps: SteeringStep[];
|
|
36
|
+
notes?: SteeringNote[];
|
|
37
|
+
onApplyNow?: (id: string) => void;
|
|
38
|
+
onRemoveNote?: (id: string) => void;
|
|
39
|
+
/** Called with the text typed in the steer box: Enter queues it for the next step, Mod+Enter interrupts now. */
|
|
40
|
+
onSteer?: (text: string, interrupt: boolean) => void;
|
|
41
|
+
placeholder?: string;
|
|
42
|
+
className?: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
const noNotes: SteeringNote[] = [];
|
|
46
|
+
type IndexStyle = CSSProperties & { "--i": number };
|
|
47
|
+
const indexStyle = (i: number): IndexStyle => ({ "--i": i });
|
|
48
|
+
|
|
49
|
+
function Marker({ status }: { status: SteeringStepStatus }) {
|
|
50
|
+
if (status === "done") {
|
|
51
|
+
return (
|
|
52
|
+
<span className="flex size-4.5 animate-pop-in items-center justify-center rounded-full bg-status-live text-text-on-ink">
|
|
53
|
+
<Check aria-label="Done" weight="bold" className="size-3" />
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
57
|
+
if (status === "active") return <Spinner size={18} label="Running" />;
|
|
58
|
+
return <span aria-label="Waiting" className="size-4.5 rounded-full border border-dashed border-border-dashed" />;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function SteeringNoteRow({
|
|
62
|
+
note,
|
|
63
|
+
onApplyNow,
|
|
64
|
+
onRemoveNote,
|
|
65
|
+
}: {
|
|
66
|
+
note: SteeringNote;
|
|
67
|
+
onApplyNow?: (id: string) => void;
|
|
68
|
+
onRemoveNote?: (id: string) => void;
|
|
69
|
+
}) {
|
|
70
|
+
const [leaving, setLeaving] = useState(false);
|
|
71
|
+
return (
|
|
72
|
+
<li
|
|
73
|
+
className={cn(
|
|
74
|
+
"flex items-center gap-2.5 rounded-[10px] bg-overlay-ink-4 py-2 pr-2.5 pl-3.5",
|
|
75
|
+
leaving ? "animate-pop-out" : "motion-rise",
|
|
76
|
+
)}
|
|
77
|
+
onAnimationEnd={(event) => {
|
|
78
|
+
if (leaving && event.target === event.currentTarget) onRemoveNote?.(note.id);
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<span className="min-w-0 flex-1 text-body-sm text-text-primary">{note.text}</span>
|
|
82
|
+
<span className="motion-swap shrink-0 justify-items-end">
|
|
83
|
+
<span
|
|
84
|
+
className="flex items-center gap-1.5"
|
|
85
|
+
data-hidden={note.applied || undefined}
|
|
86
|
+
aria-hidden={note.applied || undefined}
|
|
87
|
+
>
|
|
88
|
+
<button
|
|
89
|
+
type="button"
|
|
90
|
+
tabIndex={note.applied ? -1 : undefined}
|
|
91
|
+
onClick={() => onApplyNow?.(note.id)}
|
|
92
|
+
className="flex h-6.5 shrink-0 motion-press cursor-pointer items-center gap-1 rounded-full bg-overlay-ink-6 px-2 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
|
|
93
|
+
>
|
|
94
|
+
<Lightning aria-hidden className="size-3" />
|
|
95
|
+
Now
|
|
96
|
+
</button>
|
|
97
|
+
<button
|
|
98
|
+
type="button"
|
|
99
|
+
aria-label="Remove note"
|
|
100
|
+
tabIndex={note.applied ? -1 : undefined}
|
|
101
|
+
onClick={() => setLeaving(true)}
|
|
102
|
+
className="flex size-6 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand"
|
|
103
|
+
>
|
|
104
|
+
<X aria-hidden className="size-3.5" />
|
|
105
|
+
</button>
|
|
106
|
+
</span>
|
|
107
|
+
<span
|
|
108
|
+
className="flex h-6.5 items-center gap-1 rounded-full bg-interactive-primary px-2 text-label-sm text-text-inverse"
|
|
109
|
+
data-hidden={!note.applied || undefined}
|
|
110
|
+
aria-hidden={!note.applied || undefined}
|
|
111
|
+
>
|
|
112
|
+
<Lightning aria-hidden weight="fill" className="size-3" />
|
|
113
|
+
Now
|
|
114
|
+
</span>
|
|
115
|
+
</span>
|
|
116
|
+
</li>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Paper `Agent steering` (Y51-1): a 24px-radius surface panel with the running task header, a step rail, the notes
|
|
122
|
+
* queued for the next step, and a steer box. The live dot pulses every 2s, a step marker swaps with a short pop,
|
|
123
|
+
* new steps rise in 220ms, and send and pause press in 150ms.
|
|
124
|
+
*/
|
|
125
|
+
export function AgentSteeringPanel({
|
|
126
|
+
title,
|
|
127
|
+
subtitle,
|
|
128
|
+
status,
|
|
129
|
+
paused = false,
|
|
130
|
+
onPausedChange,
|
|
131
|
+
steps,
|
|
132
|
+
notes = noNotes,
|
|
133
|
+
onApplyNow,
|
|
134
|
+
onRemoveNote,
|
|
135
|
+
onSteer,
|
|
136
|
+
placeholder = "Steer the bot…",
|
|
137
|
+
className,
|
|
138
|
+
...props
|
|
139
|
+
}: AgentSteeringPanelProps) {
|
|
140
|
+
const [draft, setDraft] = useState("");
|
|
141
|
+
const { os } = usePlatform();
|
|
142
|
+
const { keyboardHints } = useDevice();
|
|
143
|
+
const queued = notes.filter((note) => !note.applied);
|
|
144
|
+
const submit = (interrupt: boolean) => {
|
|
145
|
+
const text = draft.trim();
|
|
146
|
+
if (!text) return;
|
|
147
|
+
onSteer?.(text, interrupt);
|
|
148
|
+
setDraft("");
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<section
|
|
153
|
+
className={cn(
|
|
154
|
+
"flex w-full max-w-130 min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
|
|
155
|
+
className,
|
|
156
|
+
)}
|
|
157
|
+
{...props}
|
|
158
|
+
>
|
|
159
|
+
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-2 px-5 pt-4 pb-3.5">
|
|
160
|
+
<Path aria-hidden className="size-4.5 shrink-0 text-text-primary" />
|
|
161
|
+
<div className="flex min-w-0 flex-1 flex-col">
|
|
162
|
+
<h3 className="truncate text-label-lg text-text-primary">{title}</h3>
|
|
163
|
+
{subtitle && <p className="truncate text-caption text-text-secondary">{subtitle}</p>}
|
|
164
|
+
</div>
|
|
165
|
+
<span
|
|
166
|
+
aria-hidden
|
|
167
|
+
className={cn(
|
|
168
|
+
"size-1.5 shrink-0 rounded-full bg-status-live text-status-live",
|
|
169
|
+
!paused && "animate-live-pulse",
|
|
170
|
+
)}
|
|
171
|
+
/>
|
|
172
|
+
{status && <span className="min-w-0 truncate text-body-sm text-text-secondary tabular-nums">{status}</span>}
|
|
173
|
+
<button
|
|
174
|
+
type="button"
|
|
175
|
+
aria-label={paused ? "Resume" : "Pause"}
|
|
176
|
+
aria-pressed={paused}
|
|
177
|
+
onClick={() => onPausedChange?.(!paused)}
|
|
178
|
+
className="flex size-7.5 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-overlay-ink-6 text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
|
|
179
|
+
>
|
|
180
|
+
<span className="motion-swap size-4">
|
|
181
|
+
<Play aria-hidden weight="fill" data-hidden={!paused || undefined} className="size-4" />
|
|
182
|
+
<Pause aria-hidden weight="fill" data-hidden={paused || undefined} className="size-4" />
|
|
183
|
+
</span>
|
|
184
|
+
</button>
|
|
185
|
+
</div>
|
|
186
|
+
<div className="h-px bg-overlay-ink-8" />
|
|
187
|
+
|
|
188
|
+
<ol className="flex flex-col gap-3 px-5 py-3">
|
|
189
|
+
{steps.map((step, index) => (
|
|
190
|
+
<li
|
|
191
|
+
key={step.id}
|
|
192
|
+
style={indexStyle(index)}
|
|
193
|
+
className="relative flex min-h-6.5 motion-rise items-center gap-3"
|
|
194
|
+
>
|
|
195
|
+
<span className="flex h-6.5 w-5 shrink-0 items-center justify-center">
|
|
196
|
+
<Marker key={step.status} status={step.status} />
|
|
197
|
+
</span>
|
|
198
|
+
{index < steps.length - 1 && (
|
|
199
|
+
<span
|
|
200
|
+
aria-hidden
|
|
201
|
+
className={cn(
|
|
202
|
+
"absolute top-[calc(50%+12.5px)] bottom-[-18px] left-2.5 w-px",
|
|
203
|
+
step.status === "done" ? "bg-status-live" : "bg-overlay-ink-10",
|
|
204
|
+
)}
|
|
205
|
+
/>
|
|
206
|
+
)}
|
|
207
|
+
<span
|
|
208
|
+
className={cn(
|
|
209
|
+
"min-w-0 flex-1 text-body-sm motion-colors",
|
|
210
|
+
step.status === "todo" ? "text-text-secondary" : "text-text-primary",
|
|
211
|
+
)}
|
|
212
|
+
>
|
|
213
|
+
{step.label}
|
|
214
|
+
</span>
|
|
215
|
+
{step.time && <span className="shrink-0 text-caption text-text-secondary tabular-nums">{step.time}</span>}
|
|
216
|
+
</li>
|
|
217
|
+
))}
|
|
218
|
+
</ol>
|
|
219
|
+
<div className="h-px bg-overlay-ink-8" />
|
|
220
|
+
|
|
221
|
+
<div className="flex flex-col gap-2.5 px-5 py-3.5">
|
|
222
|
+
<div className="motion-reveal" data-collapsed={notes.length === 0 || undefined}>
|
|
223
|
+
<div className="flex flex-col gap-2.5">
|
|
224
|
+
<p className="flex items-center gap-2 text-caption text-text-secondary">
|
|
225
|
+
Queued for the next step
|
|
226
|
+
<Kbd>{queued.length}</Kbd>
|
|
227
|
+
</p>
|
|
228
|
+
<ul className="flex flex-col gap-2">
|
|
229
|
+
{notes.map((note) => (
|
|
230
|
+
<SteeringNoteRow key={note.id} note={note} onApplyNow={onApplyNow} onRemoveNote={onRemoveNote} />
|
|
231
|
+
))}
|
|
232
|
+
</ul>
|
|
233
|
+
</div>
|
|
234
|
+
</div>
|
|
235
|
+
|
|
236
|
+
<div className="flex h-11 items-center gap-2 rounded-full bg-overlay-ink-5 pr-1.5 pl-4">
|
|
237
|
+
<input
|
|
238
|
+
value={draft}
|
|
239
|
+
onChange={(event) => setDraft(event.target.value)}
|
|
240
|
+
onKeyDown={(event) => {
|
|
241
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
|
|
242
|
+
if (event.key === "Enter") {
|
|
243
|
+
event.preventDefault();
|
|
244
|
+
submit(isModKey(event, os));
|
|
245
|
+
}
|
|
246
|
+
}}
|
|
247
|
+
placeholder={placeholder}
|
|
248
|
+
aria-label={placeholder}
|
|
249
|
+
className="min-w-0 flex-1 bg-transparent text-body-sm text-text-primary outline-none placeholder:text-text-secondary"
|
|
250
|
+
/>
|
|
251
|
+
<button
|
|
252
|
+
type="button"
|
|
253
|
+
aria-label="Send"
|
|
254
|
+
onClick={() => submit(false)}
|
|
255
|
+
className="flex size-8 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-interactive-primary text-text-inverse focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
|
|
256
|
+
>
|
|
257
|
+
<ArrowUp aria-hidden className="size-4" />
|
|
258
|
+
</button>
|
|
259
|
+
</div>
|
|
260
|
+
|
|
261
|
+
{keyboardHints && (
|
|
262
|
+
<div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-caption text-text-secondary">
|
|
263
|
+
<span className="flex items-center gap-1.25">
|
|
264
|
+
<Kbd keys="Enter" />
|
|
265
|
+
next step
|
|
266
|
+
</span>
|
|
267
|
+
<span className="flex items-center gap-1.25">
|
|
268
|
+
<Kbd keys="Mod+Enter" />
|
|
269
|
+
interrupt now
|
|
270
|
+
</span>
|
|
271
|
+
</div>
|
|
272
|
+
)}
|
|
273
|
+
</div>
|
|
274
|
+
</section>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { Paperclip } from "@phosphor-icons/react";
|
|
2
|
+
import type { ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import { Button } from "./button";
|
|
6
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
7
|
+
|
|
8
|
+
export type ApprovalState = "pending" | "approved" | "declined";
|
|
9
|
+
export type ApprovalCardSurface = "solid" | "glass";
|
|
10
|
+
|
|
11
|
+
export type ApprovalCardProps = {
|
|
12
|
+
state?: ApprovalState;
|
|
13
|
+
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
|
|
14
|
+
surface?: ApprovalCardSurface;
|
|
15
|
+
/** Status line, defaults to "Needs your yes", "Approved" or "Declined". */
|
|
16
|
+
status?: ReactNode;
|
|
17
|
+
/** The question, such as "Send the Q3 board deck to the team?". */
|
|
18
|
+
title: ReactNode;
|
|
19
|
+
/** Name of the attached item. */
|
|
20
|
+
attachment?: ReactNode;
|
|
21
|
+
onApprove?: () => void;
|
|
22
|
+
onLater?: () => void;
|
|
23
|
+
approveLabel?: string;
|
|
24
|
+
laterLabel?: string;
|
|
25
|
+
className?: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const statusClass: Record<ApprovalState, { dot: string; text: string; label: string }> = {
|
|
29
|
+
pending: { dot: "bg-status-live", text: "text-text-brand", label: "Needs your yes" },
|
|
30
|
+
approved: { dot: "bg-text-secondary", text: "text-text-secondary", label: "Approved" },
|
|
31
|
+
declined: { dot: "bg-accent-red-500-90", text: "text-text-danger", label: "Declined" },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Figma `Approval card` (64:398): the conversation part that asks for an action.
|
|
36
|
+
* Pending keeps the brand rim and the actions.
|
|
37
|
+
*/
|
|
38
|
+
export function ApprovalCard({
|
|
39
|
+
state = "pending",
|
|
40
|
+
surface = "solid",
|
|
41
|
+
status,
|
|
42
|
+
title,
|
|
43
|
+
attachment,
|
|
44
|
+
onApprove,
|
|
45
|
+
onLater,
|
|
46
|
+
approveLabel = "Approve",
|
|
47
|
+
laterLabel = "Later",
|
|
48
|
+
className,
|
|
49
|
+
}: ApprovalCardProps) {
|
|
50
|
+
const tone = statusClass[state];
|
|
51
|
+
return (
|
|
52
|
+
<div
|
|
53
|
+
className={cn(
|
|
54
|
+
"relative flex w-full max-w-90 min-w-0 flex-col items-start overflow-clip rounded-xl p-4 motion-colors md:w-90",
|
|
55
|
+
surface === "glass"
|
|
56
|
+
? cn("bg-glass-thick", state === "pending" && "[--glass-edge:var(--color-border-brand)]")
|
|
57
|
+
: cn("border border-border-default bg-bg-surface", state === "pending" && "border-border-brand"),
|
|
58
|
+
className,
|
|
59
|
+
)}
|
|
60
|
+
>
|
|
61
|
+
{surface === "glass" && <LiquidGlass />}
|
|
62
|
+
<div className={cn("relative flex items-center gap-1.5 text-label-sm motion-colors", tone.text)}>
|
|
63
|
+
<span aria-hidden className={cn("size-2 rounded-full motion-colors", tone.dot)} />
|
|
64
|
+
<span key={state} className="animate-fade-in">
|
|
65
|
+
{status ?? tone.label}
|
|
66
|
+
</span>
|
|
67
|
+
</div>
|
|
68
|
+
<p className="relative mt-3 text-label-lg text-text-primary">{title}</p>
|
|
69
|
+
{attachment && (
|
|
70
|
+
<div className="relative mt-3 flex max-w-full min-w-0 items-center gap-2 rounded-md bg-overlay-ink-5 px-3 py-2 text-body-sm text-text-primary">
|
|
71
|
+
<Paperclip aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
|
|
72
|
+
<span className="min-w-0 truncate">{attachment}</span>
|
|
73
|
+
</div>
|
|
74
|
+
)}
|
|
75
|
+
<div className="motion-reveal w-full" data-collapsed={state !== "pending" || undefined}>
|
|
76
|
+
<div>
|
|
77
|
+
<div className="flex w-full justify-end gap-2 pt-3" inert={state !== "pending"}>
|
|
78
|
+
<Button size="sm" variant={surface === "glass" ? "glass" : "solid"} onClick={onLater}>
|
|
79
|
+
{laterLabel}
|
|
80
|
+
</Button>
|
|
81
|
+
<Button size="sm" onClick={onApprove}>
|
|
82
|
+
{approveLabel}
|
|
83
|
+
</Button>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
);
|
|
89
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Avatar as BaseAvatar } from "@base-ui/react/avatar";
|
|
2
|
+
import { Robot } from "@phosphor-icons/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
import { cn } from "../lib/cn";
|
|
6
|
+
|
|
7
|
+
export type AvatarSize = "sm" | "md" | "lg" | "xl";
|
|
8
|
+
|
|
9
|
+
export type AvatarProps = {
|
|
10
|
+
/** `initials` (default), `bot` (robot on the brand tint) or `image`. */
|
|
11
|
+
type?: "initials" | "bot" | "image";
|
|
12
|
+
size?: AvatarSize;
|
|
13
|
+
/** Image URL for `type="image"`; initials show while it loads or if it fails. */
|
|
14
|
+
src?: string;
|
|
15
|
+
alt?: string;
|
|
16
|
+
/** Initials, or any fallback content. */
|
|
17
|
+
children?: ReactNode;
|
|
18
|
+
/** Shows the live status dot at the bottom right. */
|
|
19
|
+
live?: boolean;
|
|
20
|
+
/** Initials colour: `neutral` (default) or `sky`, the Paper account profile avatar (sky-100 fill, sky-700 text). */
|
|
21
|
+
tone?: "neutral" | "sky";
|
|
22
|
+
/**
|
|
23
|
+
* Border colour of the live dot, so it matches the surface behind it (glass panels, `bg-subtle`, brand tints).
|
|
24
|
+
* Defaults to `border-bg-surface`.
|
|
25
|
+
*/
|
|
26
|
+
liveRingClassName?: string;
|
|
27
|
+
className?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const sizeClass: Record<AvatarSize, string> = {
|
|
31
|
+
sm: "size-6 text-label-sm",
|
|
32
|
+
md: "size-8 text-label-md",
|
|
33
|
+
lg: "size-10 text-label-lg",
|
|
34
|
+
xl: "size-14 text-title-sm",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const botIconClass: Record<AvatarSize, string> = {
|
|
38
|
+
sm: "size-[13px]",
|
|
39
|
+
md: "size-[18px]",
|
|
40
|
+
lg: "size-[22px]",
|
|
41
|
+
xl: "size-[31px]",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Live dot outer size: Figma dot diameter (10, 11, 13, 18) plus its 2px centered white stroke. */
|
|
45
|
+
const liveClass: Record<AvatarSize, string> = {
|
|
46
|
+
sm: "size-2.5",
|
|
47
|
+
md: "size-[11px]",
|
|
48
|
+
lg: "size-[13px]",
|
|
49
|
+
xl: "size-[18px]",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const typeClass = {
|
|
53
|
+
initials: "bg-bg-subtle text-text-secondary",
|
|
54
|
+
bot: "bg-bg-brand text-text-brand",
|
|
55
|
+
image: "bg-sky-200 text-text-secondary",
|
|
56
|
+
} as const;
|
|
57
|
+
|
|
58
|
+
/** Figma `Avatar` (61:347): Initials, Bot and Image in four sizes, with an optional live dot. */
|
|
59
|
+
export function Avatar({
|
|
60
|
+
type = "initials",
|
|
61
|
+
size = "md",
|
|
62
|
+
src,
|
|
63
|
+
alt = "",
|
|
64
|
+
children,
|
|
65
|
+
live,
|
|
66
|
+
tone = "neutral",
|
|
67
|
+
liveRingClassName = "border-bg-surface",
|
|
68
|
+
className,
|
|
69
|
+
}: AvatarProps) {
|
|
70
|
+
return (
|
|
71
|
+
<span className={cn("relative inline-flex shrink-0", className)}>
|
|
72
|
+
<BaseAvatar.Root
|
|
73
|
+
className={cn(
|
|
74
|
+
"inline-flex items-center justify-center overflow-clip rounded-full inset-ring-1 inset-ring-overlay-ink-6 select-none",
|
|
75
|
+
sizeClass[size],
|
|
76
|
+
typeClass[type],
|
|
77
|
+
type === "initials" && tone === "sky" && "bg-sky-100 text-sky-700",
|
|
78
|
+
)}
|
|
79
|
+
>
|
|
80
|
+
{type === "bot" ? (
|
|
81
|
+
<Robot aria-hidden className={botIconClass[size]} />
|
|
82
|
+
) : (
|
|
83
|
+
<>
|
|
84
|
+
{type === "image" && src && (
|
|
85
|
+
<BaseAvatar.Image src={src} alt={alt} className="size-full object-cover motion-fade" />
|
|
86
|
+
)}
|
|
87
|
+
<BaseAvatar.Fallback>{children}</BaseAvatar.Fallback>
|
|
88
|
+
</>
|
|
89
|
+
)}
|
|
90
|
+
</BaseAvatar.Root>
|
|
91
|
+
{live && (
|
|
92
|
+
<span aria-hidden className="absolute -right-px -bottom-px flex animate-pop-in">
|
|
93
|
+
<span
|
|
94
|
+
className={cn(
|
|
95
|
+
"animate-live-pulse rounded-full border-2 bg-status-live text-status-live",
|
|
96
|
+
liveRingClassName,
|
|
97
|
+
liveClass[size],
|
|
98
|
+
)}
|
|
99
|
+
/>
|
|
100
|
+
</span>
|
|
101
|
+
)}
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ComponentProps } from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "../lib/cn";
|
|
4
|
+
import type { ControlSize } from "../lib/size";
|
|
5
|
+
|
|
6
|
+
/** `surface` is the white (raised surface) pill with a 1px default border and primary text. */
|
|
7
|
+
export type BadgeTone = "neutral" | "brand" | "danger" | "solid" | "surface";
|
|
8
|
+
export type BadgeSize = ControlSize;
|
|
9
|
+
|
|
10
|
+
export type BadgeProps = Omit<ComponentProps<"span">, "className"> & {
|
|
11
|
+
tone?: BadgeTone;
|
|
12
|
+
/** Leading status dot; 4px (sm), 6px (md) or 8px (lg). */
|
|
13
|
+
dot?: boolean;
|
|
14
|
+
/** `md` is the 22px Figma Badge; `sm` 20px and `lg` 26px. */
|
|
15
|
+
size?: BadgeSize;
|
|
16
|
+
className?: string;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
const sizeClass: Record<BadgeSize, string> = {
|
|
20
|
+
sm: "h-5 gap-1 px-1.5 text-caption",
|
|
21
|
+
md: "h-5.5 gap-1.5 px-2 text-label-sm",
|
|
22
|
+
lg: "h-6.5 gap-1.5 px-2.5 text-label-md",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const dotSizeClass: Record<BadgeSize, string> = {
|
|
26
|
+
sm: "size-1",
|
|
27
|
+
md: "size-1.5",
|
|
28
|
+
lg: "size-2",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const toneClass: Record<BadgeTone, string> = {
|
|
32
|
+
neutral: "bg-overlay-ink-6 text-text-secondary",
|
|
33
|
+
brand: "bg-accent-sky-500-18 text-text-brand",
|
|
34
|
+
danger: "bg-accent-red-500-14 text-text-danger",
|
|
35
|
+
solid: "bg-interactive-primary text-interactive-on-primary",
|
|
36
|
+
surface: "border border-border-default bg-bg-surface text-text-primary",
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const dotClass: Record<BadgeTone, string> = {
|
|
40
|
+
neutral: "bg-text-secondary",
|
|
41
|
+
brand: "bg-status-live",
|
|
42
|
+
danger: "bg-text-danger",
|
|
43
|
+
solid: "bg-interactive-on-primary",
|
|
44
|
+
surface: "bg-text-primary",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
/** Figma `Badge` (61:283): a 22px translucent pill with label-sm text. */
|
|
48
|
+
export function Badge({ tone = "neutral", dot, size = "md", className, children, ...props }: BadgeProps) {
|
|
49
|
+
return (
|
|
50
|
+
<span
|
|
51
|
+
className={cn(
|
|
52
|
+
"inline-flex shrink-0 items-center justify-center rounded-full whitespace-nowrap",
|
|
53
|
+
sizeClass[size],
|
|
54
|
+
toneClass[tone],
|
|
55
|
+
className,
|
|
56
|
+
)}
|
|
57
|
+
{...props}
|
|
58
|
+
>
|
|
59
|
+
{dot && <span aria-hidden className={cn("shrink-0 rounded-full", dotSizeClass[size], dotClass[tone])} />}
|
|
60
|
+
<span className="text-trim">{children}</span>
|
|
61
|
+
</span>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Info, WarningCircle } from "@phosphor-icons/react";
|
|
2
|
+
import type { ComponentProps, ReactNode } from "react";
|
|
3
|
+
|
|
4
|
+
import { cn } from "../lib/cn";
|
|
5
|
+
import type { ControlSize } from "../lib/size";
|
|
6
|
+
import { LiquidGlass } from "./liquid-glass";
|
|
7
|
+
|
|
8
|
+
export type BannerTone = "info" | "danger";
|
|
9
|
+
export type BannerSize = ControlSize;
|
|
10
|
+
export type BannerSurface = "solid" | "glass";
|
|
11
|
+
|
|
12
|
+
export type BannerProps = Omit<ComponentProps<"div">, "className"> & {
|
|
13
|
+
tone?: BannerTone;
|
|
14
|
+
/** Trailing underlined action, such as a link or button. */
|
|
15
|
+
action?: ReactNode;
|
|
16
|
+
/** Banner size: `sm` 32px minimum, `md` the Figma 48px strip (default), `lg` 56px; scales icon, text and padding. */
|
|
17
|
+
size?: BannerSize;
|
|
18
|
+
/** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
|
|
19
|
+
surface?: BannerSurface;
|
|
20
|
+
className?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
const sizeClass: Record<BannerSize, string> = {
|
|
24
|
+
sm: "gap-2 px-3 py-2",
|
|
25
|
+
md: "gap-3 px-4 py-2.5",
|
|
26
|
+
lg: "gap-3 px-5 py-3",
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const iconSizeClass: Record<BannerSize, string> = {
|
|
30
|
+
sm: "[&_svg]:size-3.5",
|
|
31
|
+
md: "[&_svg]:size-4",
|
|
32
|
+
lg: "[&_svg]:size-5",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const textSizeClass: Record<BannerSize, string> = {
|
|
36
|
+
sm: "text-body-sm",
|
|
37
|
+
md: "text-body-md",
|
|
38
|
+
lg: "text-body-lg",
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const actionSizeClass: Record<BannerSize, string> = {
|
|
42
|
+
sm: "text-label-md",
|
|
43
|
+
md: "text-label-lg",
|
|
44
|
+
lg: "text-label-lg",
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const toneClass: Record<BannerTone, string> = {
|
|
48
|
+
info: "text-text-brand",
|
|
49
|
+
danger: "text-text-danger",
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
const glassEdgeClass: Record<BannerTone, string> = {
|
|
53
|
+
info: "[--glass-edge:var(--color-border-brand)]",
|
|
54
|
+
danger: "[--glass-edge:var(--color-border-danger)]",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const solidEdgeClass: Record<BannerTone, string> = {
|
|
58
|
+
info: "border-border-brand",
|
|
59
|
+
danger: "border-border-danger",
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Figma `Banner` (62:318): a strip with a tinted rim, 16px icon and an underlined action. */
|
|
63
|
+
export function Banner({
|
|
64
|
+
tone = "info",
|
|
65
|
+
action,
|
|
66
|
+
size = "md",
|
|
67
|
+
surface = "solid",
|
|
68
|
+
className,
|
|
69
|
+
children,
|
|
70
|
+
...props
|
|
71
|
+
}: BannerProps) {
|
|
72
|
+
return (
|
|
73
|
+
<div
|
|
74
|
+
role={tone === "danger" ? "alert" : "status"}
|
|
75
|
+
className={cn(
|
|
76
|
+
"relative flex motion-rise items-center overflow-clip rounded-md",
|
|
77
|
+
sizeClass[size],
|
|
78
|
+
toneClass[tone],
|
|
79
|
+
surface === "glass"
|
|
80
|
+
? cn("border border-transparent bg-glass-regular", glassEdgeClass[tone])
|
|
81
|
+
: cn("border border-border-default bg-bg-surface", solidEdgeClass[tone]),
|
|
82
|
+
className,
|
|
83
|
+
)}
|
|
84
|
+
{...props}
|
|
85
|
+
>
|
|
86
|
+
{surface === "glass" && <LiquidGlass />}
|
|
87
|
+
<span className={cn("relative flex shrink-0", iconSizeClass[size])}>
|
|
88
|
+
{tone === "danger" ? <WarningCircle /> : <Info />}
|
|
89
|
+
</span>
|
|
90
|
+
<p className={cn("relative min-w-0 flex-1 text-text-primary", textSizeClass[size])}>{children}</p>
|
|
91
|
+
{action && (
|
|
92
|
+
<span
|
|
93
|
+
className={cn(
|
|
94
|
+
"relative shrink-0 whitespace-nowrap underline [&_a]:cursor-pointer [&_button]:cursor-pointer",
|
|
95
|
+
actionSizeClass[size],
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
{action}
|
|
99
|
+
</span>
|
|
100
|
+
)}
|
|
101
|
+
</div>
|
|
102
|
+
);
|
|
103
|
+
}
|