@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,274 @@
1
+ import { ArrowElbowDownLeft, 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 { cn } from "../lib/cn";
5
+ import { Kbd } from "./kbd";
6
+ import { Spinner } from "./spinner";
7
+
8
+ export type SteeringStepStatus = "done" | "active" | "todo";
9
+
10
+ export type SteeringStep = {
11
+ id: string;
12
+ label: ReactNode;
13
+ status: SteeringStepStatus;
14
+ /** Duration of a finished or running step, such as "1.4s". */
15
+ time?: ReactNode;
16
+ };
17
+
18
+ export type SteeringNote = {
19
+ id: string;
20
+ text: ReactNode;
21
+ /** An applied note has left the queue and carries the "Now" pill. */
22
+ applied?: boolean;
23
+ };
24
+
25
+ export type AgentSteeringPanelProps = Omit<ComponentProps<"section">, "className" | "title"> & {
26
+ title: ReactNode;
27
+ subtitle?: ReactNode;
28
+ /** The status text beside the live dot, such as "Running · 2m 10s". */
29
+ status?: ReactNode;
30
+ paused?: boolean;
31
+ onPausedChange?: (paused: boolean) => void;
32
+ steps: SteeringStep[];
33
+ notes?: SteeringNote[];
34
+ onApplyNow?: (id: string) => void;
35
+ onRemoveNote?: (id: string) => void;
36
+ /** Called with the text typed in the steer box: Enter queues it for the next step, ⌘Enter interrupts now. */
37
+ onSteer?: (text: string, interrupt: boolean) => void;
38
+ placeholder?: string;
39
+ className?: string;
40
+ };
41
+
42
+ const noNotes: SteeringNote[] = [];
43
+ type IndexStyle = CSSProperties & { "--i": number };
44
+ const indexStyle = (i: number): IndexStyle => ({ "--i": i });
45
+
46
+ function Marker({ status }: { status: SteeringStepStatus }) {
47
+ if (status === "done") {
48
+ return (
49
+ <span className="flex size-4.5 animate-pop-in items-center justify-center rounded-full bg-status-live text-text-on-ink">
50
+ <Check aria-label="Done" weight="bold" className="size-3" />
51
+ </span>
52
+ );
53
+ }
54
+ if (status === "active") return <Spinner size={18} label="Running" />;
55
+ return <span aria-label="Waiting" className="size-4.5 rounded-full border border-dashed border-border-dashed" />;
56
+ }
57
+
58
+ function SteeringNoteRow({
59
+ note,
60
+ onApplyNow,
61
+ onRemoveNote,
62
+ }: {
63
+ note: SteeringNote;
64
+ onApplyNow?: (id: string) => void;
65
+ onRemoveNote?: (id: string) => void;
66
+ }) {
67
+ const [leaving, setLeaving] = useState(false);
68
+ return (
69
+ <li
70
+ className={cn(
71
+ "flex items-center gap-2.5 rounded-[10px] bg-overlay-ink-4 py-2 pr-2.5 pl-3.5",
72
+ leaving ? "animate-pop-out" : "motion-rise",
73
+ )}
74
+ onAnimationEnd={(event) => {
75
+ if (leaving && event.target === event.currentTarget) onRemoveNote?.(note.id);
76
+ }}
77
+ >
78
+ <span className="min-w-0 flex-1 text-body-sm text-text-primary">{note.text}</span>
79
+ <span className="motion-swap shrink-0 justify-items-end">
80
+ <span
81
+ className="flex items-center gap-1.5"
82
+ data-hidden={note.applied || undefined}
83
+ aria-hidden={note.applied || undefined}
84
+ >
85
+ <button
86
+ type="button"
87
+ tabIndex={note.applied ? -1 : undefined}
88
+ onClick={() => onApplyNow?.(note.id)}
89
+ 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"
90
+ >
91
+ <Lightning aria-hidden className="size-3" />
92
+ Now
93
+ </button>
94
+ <button
95
+ type="button"
96
+ aria-label="Remove note"
97
+ tabIndex={note.applied ? -1 : undefined}
98
+ onClick={() => setLeaving(true)}
99
+ 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"
100
+ >
101
+ <X aria-hidden className="size-3.5" />
102
+ </button>
103
+ </span>
104
+ <span
105
+ className="flex h-6.5 items-center gap-1 rounded-full bg-interactive-primary px-2 text-label-sm text-text-inverse"
106
+ data-hidden={!note.applied || undefined}
107
+ aria-hidden={!note.applied || undefined}
108
+ >
109
+ <Lightning aria-hidden weight="fill" className="size-3" />
110
+ Now
111
+ </span>
112
+ </span>
113
+ </li>
114
+ );
115
+ }
116
+
117
+ /**
118
+ * Paper `Agent steering` (Y51-1): a 24px-radius surface panel with the running task header, a step rail, the notes
119
+ * queued for the next step, and a steer box. The live dot pulses every 2s, a step marker swaps with a short pop,
120
+ * new steps rise in 220ms, and send and pause press in 150ms.
121
+ */
122
+ export function AgentSteeringPanel({
123
+ title,
124
+ subtitle,
125
+ status,
126
+ paused = false,
127
+ onPausedChange,
128
+ steps,
129
+ notes = noNotes,
130
+ onApplyNow,
131
+ onRemoveNote,
132
+ onSteer,
133
+ placeholder = "Steer the bot…",
134
+ className,
135
+ ...props
136
+ }: AgentSteeringPanelProps) {
137
+ const [draft, setDraft] = useState("");
138
+ const queued = notes.filter((note) => !note.applied);
139
+ const submit = (interrupt: boolean) => {
140
+ const text = draft.trim();
141
+ if (!text) return;
142
+ onSteer?.(text, interrupt);
143
+ setDraft("");
144
+ };
145
+
146
+ return (
147
+ <section
148
+ className={cn(
149
+ "flex w-full max-w-130 min-w-0 flex-col overflow-clip rounded-[24px] border border-border-default bg-bg-surface",
150
+ className,
151
+ )}
152
+ {...props}
153
+ >
154
+ <div className="flex flex-wrap items-center gap-x-2.5 gap-y-2 px-5 pt-4 pb-3.5">
155
+ <Path aria-hidden className="size-4.5 shrink-0 text-text-primary" />
156
+ <div className="flex min-w-0 flex-1 flex-col">
157
+ <h3 className="truncate text-label-lg text-text-primary">{title}</h3>
158
+ {subtitle && <p className="truncate text-caption text-text-secondary">{subtitle}</p>}
159
+ </div>
160
+ <span
161
+ aria-hidden
162
+ className={cn(
163
+ "size-1.5 shrink-0 rounded-full bg-status-live text-status-live",
164
+ !paused && "animate-live-pulse",
165
+ )}
166
+ />
167
+ {status && <span className="min-w-0 truncate text-body-sm text-text-secondary tabular-nums">{status}</span>}
168
+ <button
169
+ type="button"
170
+ aria-label={paused ? "Resume" : "Pause"}
171
+ aria-pressed={paused}
172
+ onClick={() => onPausedChange?.(!paused)}
173
+ 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"
174
+ >
175
+ <span className="motion-swap size-4">
176
+ <Play aria-hidden weight="fill" data-hidden={!paused || undefined} className="size-4" />
177
+ <Pause aria-hidden weight="fill" data-hidden={paused || undefined} className="size-4" />
178
+ </span>
179
+ </button>
180
+ </div>
181
+ <div className="h-px bg-overlay-ink-8" />
182
+
183
+ <ol className="flex flex-col gap-3 px-5 py-3">
184
+ {steps.map((step, index) => (
185
+ <li
186
+ key={step.id}
187
+ style={indexStyle(index)}
188
+ className="relative flex min-h-6.5 motion-rise items-center gap-3"
189
+ >
190
+ <span className="flex h-6.5 w-5 shrink-0 items-center justify-center">
191
+ <Marker key={step.status} status={step.status} />
192
+ </span>
193
+ {index < steps.length - 1 && (
194
+ <span
195
+ aria-hidden
196
+ className={cn(
197
+ "absolute top-[calc(50%+12.5px)] bottom-[-18px] left-2.5 w-px",
198
+ step.status === "done" ? "bg-status-live" : "bg-overlay-ink-10",
199
+ )}
200
+ />
201
+ )}
202
+ <span
203
+ className={cn(
204
+ "min-w-0 flex-1 text-body-sm motion-colors",
205
+ step.status === "todo" ? "text-text-secondary" : "text-text-primary",
206
+ )}
207
+ >
208
+ {step.label}
209
+ </span>
210
+ {step.time && <span className="shrink-0 text-caption text-text-secondary tabular-nums">{step.time}</span>}
211
+ </li>
212
+ ))}
213
+ </ol>
214
+ <div className="h-px bg-overlay-ink-8" />
215
+
216
+ <div className="flex flex-col gap-2.5 px-5 py-3.5">
217
+ <div className="motion-reveal" data-collapsed={notes.length === 0 || undefined}>
218
+ <div className="flex flex-col gap-2.5">
219
+ <p className="flex items-center gap-2 text-caption text-text-secondary">
220
+ Queued for the next step
221
+ <Kbd>{queued.length}</Kbd>
222
+ </p>
223
+ <ul className="flex flex-col gap-2">
224
+ {notes.map((note) => (
225
+ <SteeringNoteRow key={note.id} note={note} onApplyNow={onApplyNow} onRemoveNote={onRemoveNote} />
226
+ ))}
227
+ </ul>
228
+ </div>
229
+ </div>
230
+
231
+ <div className="flex h-11 items-center gap-2 rounded-full bg-overlay-ink-5 pr-1.5 pl-4">
232
+ <input
233
+ value={draft}
234
+ onChange={(event) => setDraft(event.target.value)}
235
+ onKeyDown={(event) => {
236
+ if (event.nativeEvent.isComposing || event.keyCode === 229) return;
237
+ if (event.key === "Enter") {
238
+ event.preventDefault();
239
+ submit(event.metaKey || event.ctrlKey);
240
+ }
241
+ }}
242
+ placeholder={placeholder}
243
+ aria-label={placeholder}
244
+ className="min-w-0 flex-1 bg-transparent text-body-sm text-text-primary outline-none placeholder:text-text-secondary"
245
+ />
246
+ <button
247
+ type="button"
248
+ aria-label="Send"
249
+ onClick={() => submit(false)}
250
+ 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"
251
+ >
252
+ <ArrowUp aria-hidden className="size-4" />
253
+ </button>
254
+ </div>
255
+
256
+ <div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-caption text-text-secondary">
257
+ <span className="flex items-center gap-1.25">
258
+ <Kbd>
259
+ <ArrowElbowDownLeft aria-hidden />
260
+ </Kbd>
261
+ next step
262
+ </span>
263
+ <span className="flex items-center gap-1.25">
264
+ <Kbd>⌘</Kbd>
265
+ <Kbd>
266
+ <ArrowElbowDownLeft aria-hidden />
267
+ </Kbd>
268
+ interrupt now
269
+ </span>
270
+ </div>
271
+ </div>
272
+ </section>
273
+ );
274
+ }
@@ -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
+ }