@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,99 @@
1
+ import type { ComponentProps, ReactNode } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { LiquidGlass } from "./liquid-glass";
5
+ import { Switch, type SwitchProps } from "./switch";
6
+
7
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
8
+
9
+ export type SettingsListProps = WithClass<ComponentProps<"div">> & {
10
+ /** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
11
+ surface?: "solid" | "glass";
12
+ };
13
+
14
+ /** Paper `Settings list` (1VD8-0): a 16px-radius card of divided rows, 20px inline padding, solid or thick glass. */
15
+ export function SettingsList({ surface = "solid", className, children, ...props }: SettingsListProps) {
16
+ return (
17
+ <div
18
+ className={cn(
19
+ "relative flex min-w-0 flex-col overflow-clip rounded-2xl px-5 [&>:not(.liquid-layers)]:relative",
20
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
21
+ className,
22
+ )}
23
+ {...props}
24
+ >
25
+ {surface === "glass" && <LiquidGlass />}
26
+ {children}
27
+ </div>
28
+ );
29
+ }
30
+
31
+ export type SettingsRowProps = Omit<ComponentProps<"div">, "className" | "title"> & {
32
+ title: ReactNode;
33
+ description?: ReactNode;
34
+ /** Leading slot before the title, usually a `SettingsRowIcon`; the row tightens to 12px gap and 13px block padding. */
35
+ leading?: ReactNode;
36
+ /** Trailing control slot: a `Switch`, a `SettingsKeys` pair, a button. */
37
+ control?: ReactNode;
38
+ className?: string;
39
+ };
40
+
41
+ /** One row: title and subtitle on the left, control on the right, 1px divider except on the last row. */
42
+ export function SettingsRow({ title, description, leading, control, className, ...props }: SettingsRowProps) {
43
+ return (
44
+ <div
45
+ className={cn(
46
+ "flex min-w-0 flex-wrap items-center border-b border-border-default last:border-b-0",
47
+ leading ? "gap-x-3 gap-y-2 py-[13px]" : "gap-x-4 gap-y-2 py-4",
48
+ className,
49
+ )}
50
+ {...props}
51
+ >
52
+ {leading}
53
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
54
+ <span className="text-label-md wrap-break-word text-text-primary">{title}</span>
55
+ {description && <span className="text-body-sm wrap-break-word text-text-secondary">{description}</span>}
56
+ </div>
57
+ {control && <div className="flex shrink-0 items-center">{control}</div>}
58
+ </div>
59
+ );
60
+ }
61
+
62
+ /** Paper settings row icon tile: 32px, 9px radius, subtle fill, 16px glyph centered. */
63
+ export function SettingsRowIcon({ className, ...props }: WithClass<ComponentProps<"span">>) {
64
+ return (
65
+ <span
66
+ aria-hidden
67
+ className={cn(
68
+ "flex size-8 shrink-0 items-center justify-center rounded-[9px] bg-bg-subtle text-text-primary [&_svg]:size-4",
69
+ className,
70
+ )}
71
+ {...props}
72
+ />
73
+ );
74
+ }
75
+
76
+ /** A `Switch` pre-wired for a row's control slot (md, 44x26). */
77
+ export function SettingsSwitch(props: SwitchProps) {
78
+ return <Switch size="md" {...props} />;
79
+ }
80
+
81
+ /** One keycap: 26px high, 6px radius, subtle fill with a 1px hairline (replaces `Kbd` in settings rows). */
82
+ export function SettingsKey({ className, children, ...props }: WithClass<ComponentProps<"kbd">>) {
83
+ return (
84
+ <kbd
85
+ className={cn(
86
+ "inline-flex h-6.5 items-center rounded-xs border border-border-default bg-bg-subtle px-2 font-sans text-label-sm text-text-primary",
87
+ className,
88
+ )}
89
+ {...props}
90
+ >
91
+ <span className="text-trim">{children}</span>
92
+ </kbd>
93
+ );
94
+ }
95
+
96
+ /** A keycap pair or chord, 4px apart. */
97
+ export function SettingsKeys({ className, ...props }: WithClass<ComponentProps<"span">>) {
98
+ return <span className={cn("inline-flex items-center gap-1", className)} {...props} />;
99
+ }
@@ -0,0 +1,542 @@
1
+ import { Collapsible } from "@base-ui/react/collapsible";
2
+ import { CaretDown, CaretLeft, CaretRight, CaretUpDown, DotsThree, PlusCircle } from "@phosphor-icons/react";
3
+ import {
4
+ Children,
5
+ type ComponentProps,
6
+ type CSSProperties,
7
+ type KeyboardEvent,
8
+ type PointerEvent,
9
+ type ReactNode,
10
+ useEffect,
11
+ useRef,
12
+ useState,
13
+ } from "react";
14
+
15
+ import { cn } from "../lib/cn";
16
+ import { LiquidGlass } from "./liquid-glass";
17
+
18
+ type DivProps = Omit<ComponentProps<"div">, "className"> & { className?: string };
19
+ type ButtonProps = Omit<ComponentProps<"button">, "className"> & { className?: string };
20
+
21
+ export type SidebarProps = Omit<ComponentProps<"nav">, "className"> & {
22
+ className?: string;
23
+ /**
24
+ * `glass` (default) is the thick-glass column. `photo` is the Paper app sidebar: the Horon photo
25
+ * on #EEF3F8 under a 55% white veil, with the thick glass kept for dark mode. `plain` paints no
26
+ * fill or glass, so the column takes the color of the layout behind it, divided by a 1px border.
27
+ */
28
+ surface?: "glass" | "photo" | "plain";
29
+ /**
30
+ * Drag the right edge to change the width, from the `md` breakpoint (768px) up.
31
+ * Below `md` the handle is hidden and the sidebar keeps its fixed 280px width.
32
+ */
33
+ resizable?: boolean;
34
+ /** Starting width in px when `resizable`. Double-clicking the handle returns to it. */
35
+ defaultWidth?: number;
36
+ minWidth?: number;
37
+ maxWidth?: number;
38
+ /** Called with the clamped width as it changes, during a drag too. */
39
+ onWidthChange?: (width: number) => void;
40
+ resizeLabel?: string;
41
+ };
42
+
43
+ const RESIZE_STEP = 16;
44
+
45
+ function clamp(value: number, min: number, max: number): number {
46
+ return Math.round(Math.min(max, Math.max(min, value)));
47
+ }
48
+
49
+ /**
50
+ * Figma `Sidebar` (88:848): a 280px thick-glass column with a 1px right border, 16/12/12px padding.
51
+ * With `resizable`, an 8px handle on the right edge drags the width between `minWidth` and `maxWidth`.
52
+ */
53
+ export function Sidebar({
54
+ className,
55
+ style,
56
+ children,
57
+ surface = "glass",
58
+ resizable = false,
59
+ defaultWidth = 280,
60
+ minWidth = 240,
61
+ maxWidth = 400,
62
+ onWidthChange,
63
+ resizeLabel = "Resize sidebar",
64
+ ...props
65
+ }: SidebarProps) {
66
+ const [width, setWidth] = useState(() => clamp(defaultWidth, minWidth, maxWidth));
67
+ const [dragging, setDragging] = useState(false);
68
+ const drag = useRef<{ x: number; width: number } | null>(null);
69
+
70
+ const resize = (next: number) => {
71
+ const clamped = clamp(next, minWidth, maxWidth);
72
+ if (clamped === width) return;
73
+ setWidth(clamped);
74
+ onWidthChange?.(clamped);
75
+ };
76
+
77
+ // Keep the col-resize cursor and stop text selection while the pointer is off the handle mid-drag.
78
+ useEffect(() => {
79
+ if (!dragging) return undefined;
80
+ const root = document.documentElement.style;
81
+ const previous = { cursor: root.cursor, userSelect: root.userSelect };
82
+ root.cursor = "col-resize";
83
+ root.userSelect = "none";
84
+ return () => {
85
+ root.cursor = previous.cursor;
86
+ root.userSelect = previous.userSelect;
87
+ };
88
+ }, [dragging]);
89
+
90
+ const onPointerDown = (event: PointerEvent<HTMLDivElement>) => {
91
+ if (event.button !== 0) return;
92
+ event.preventDefault();
93
+ event.currentTarget.setPointerCapture(event.pointerId);
94
+ drag.current = { x: event.clientX, width };
95
+ setDragging(true);
96
+ };
97
+
98
+ const onPointerMove = (event: PointerEvent<HTMLDivElement>) => {
99
+ if (!drag.current) return;
100
+ resize(drag.current.width + event.clientX - drag.current.x);
101
+ };
102
+
103
+ const endDrag = () => {
104
+ drag.current = null;
105
+ setDragging(false);
106
+ };
107
+
108
+ const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
109
+ const next = {
110
+ ArrowLeft: width - RESIZE_STEP,
111
+ ArrowRight: width + RESIZE_STEP,
112
+ Home: minWidth,
113
+ End: maxWidth,
114
+ }[event.key];
115
+ if (next === undefined) return;
116
+ event.preventDefault();
117
+ resize(next);
118
+ };
119
+
120
+ return (
121
+ <nav
122
+ className={cn(
123
+ // The column keeps its 280px design width where there is room; a definite-width
124
+ // parent below that caps it, so it never runs past the container.
125
+ "relative flex w-70 max-w-full min-w-0 flex-col items-stretch gap-0.5 overflow-clip px-3 pt-4 pb-3",
126
+ resizable && "transition-[width] duration-200 ease-out-quint data-resizing:transition-none",
127
+ surface === "glass" && "bg-glass-thick",
128
+ surface === "photo" && "bg-sidebar-photo dark:bg-glass-thick dark:bg-none",
129
+ surface === "plain" && "border-r border-border-default",
130
+ resizable && "md:w-(--sidebar-width)",
131
+ className,
132
+ )}
133
+ style={resizable ? ({ ...style, "--sidebar-width": `${width}px` } as CSSProperties) : style}
134
+ data-resizing={(resizable && dragging) || undefined}
135
+ {...props}
136
+ >
137
+ {surface === "glass" && <LiquidGlass />}
138
+ {children}
139
+ {resizable && (
140
+ <div
141
+ role="separator"
142
+ aria-orientation="vertical"
143
+ aria-label={resizeLabel}
144
+ aria-valuenow={width}
145
+ aria-valuemin={minWidth}
146
+ aria-valuemax={maxWidth}
147
+ tabIndex={0}
148
+ data-dragging={dragging || undefined}
149
+ onPointerDown={onPointerDown}
150
+ onPointerMove={onPointerMove}
151
+ onPointerUp={endDrag}
152
+ onPointerCancel={endDrag}
153
+ onLostPointerCapture={endDrag}
154
+ onKeyDown={onKeyDown}
155
+ onDoubleClick={() => resize(defaultWidth)}
156
+ className="group/resize absolute inset-y-0 right-0 z-10 hidden w-2 cursor-col-resize touch-none outline-none md:block"
157
+ >
158
+ <span
159
+ aria-hidden
160
+ className="absolute inset-y-0 right-0 w-0.5 transition-colors duration-150 group-hover/resize:bg-overlay-ink-16 group-focus-visible/resize:bg-text-brand group-data-dragging/resize:bg-text-brand"
161
+ />
162
+ </div>
163
+ )}
164
+ </nav>
165
+ );
166
+ }
167
+
168
+ export type SidebarHeaderProps = {
169
+ /** Workspace initial shown on the 28px ink tile. */
170
+ initial: ReactNode;
171
+ name: ReactNode;
172
+ /** Props for the workspace switch button, such as a `MenuTrigger` render target. */
173
+ switchProps?: ButtonProps;
174
+ /** Replaces the default switch, such as a `WorkspaceMenu` whose trigger is a `SidebarWorkspaceSwitch`. */
175
+ switchSlot?: ReactNode;
176
+ onCollapse?: () => void;
177
+ collapseLabel?: string;
178
+ className?: string;
179
+ };
180
+
181
+ export type SidebarWorkspaceSwitchProps = Omit<ButtonProps, "name"> & { initial: ReactNode; name: ReactNode };
182
+
183
+ /** The workspace switch button: 28px ink tile, name and up-down caret. Highlights while its menu is open. */
184
+ export function SidebarWorkspaceSwitch({ initial, name, className, ...props }: SidebarWorkspaceSwitchProps) {
185
+ return (
186
+ <button
187
+ type="button"
188
+ className={cn(
189
+ "flex h-9 min-w-0 flex-1 cursor-pointer items-center gap-1.5 rounded-[10px] pr-1 pl-2 text-left motion-colors hover:bg-overlay-ink-6 data-popup-open:bg-overlay-ink-6",
190
+ "focus-visible:outline-2 focus-visible:outline-text-brand",
191
+ className,
192
+ )}
193
+ {...props}
194
+ >
195
+ <span className="flex size-7 shrink-0 items-center justify-center rounded-sm bg-interactive-primary text-label-md text-text-inverse">
196
+ {initial}
197
+ </span>
198
+ <span className="min-w-0 flex-1 truncate text-label-lg text-text-primary">{name}</span>
199
+ <CaretUpDown aria-hidden className="size-3.5 shrink-0 text-text-secondary" />
200
+ </button>
201
+ );
202
+ }
203
+
204
+ /** Header: workspace switch (tile, name, up-down caret) and an optional 28px collapse button. 10px gap below. */
205
+ export function SidebarHeader({
206
+ initial,
207
+ name,
208
+ switchProps,
209
+ switchSlot,
210
+ onCollapse,
211
+ collapseLabel = "Collapse sidebar",
212
+ className,
213
+ }: SidebarHeaderProps) {
214
+ return (
215
+ <div className={cn("relative mb-2.5 flex h-9 items-center gap-1.5", className)}>
216
+ {switchSlot ?? <SidebarWorkspaceSwitch {...switchProps} name={name} initial={initial} />}
217
+ {onCollapse && (
218
+ <button
219
+ type="button"
220
+ aria-label={collapseLabel}
221
+ onClick={onCollapse}
222
+ className="relative flex size-7 shrink-0 cursor-pointer items-center justify-center rounded-sm text-text-secondary motion-colors after:absolute after:top-1/2 after:left-1/2 after:min-h-11 after:min-w-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-[''] hover:bg-overlay-ink-6 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand active:bg-overlay-ink-10"
223
+ >
224
+ <CaretLeft aria-hidden className="size-4" />
225
+ </button>
226
+ )}
227
+ </div>
228
+ );
229
+ }
230
+
231
+ type SidebarItemCommonProps = {
232
+ /** 18px icon, or a `SidebarProjectTile`. */
233
+ icon?: ReactNode;
234
+ /** Count on the right, such as unread items. */
235
+ count?: ReactNode;
236
+ /** Trailing 7px red dot for attention. */
237
+ attention?: boolean;
238
+ current?: boolean;
239
+ className?: string;
240
+ };
241
+
242
+ /** With `href`, the row is a real link and takes anchor props; without it, a `type="button"` button. */
243
+ export type SidebarItemProps =
244
+ | (SidebarItemCommonProps & { href: string } & Omit<ComponentProps<"a">, "className" | "href" | "type">)
245
+ | (SidebarItemCommonProps & { href?: undefined } & ButtonProps);
246
+
247
+ /**
248
+ * Figma sidebar `Item`: 32px row, 12px radius, 10px gap; the current item is 6% ink.
249
+ * Pass `href` to render an `<a>` with the same look.
250
+ */
251
+ export function SidebarItem({ icon, count, attention, current, className, children, ...props }: SidebarItemProps) {
252
+ const classes = cn(
253
+ "relative flex h-8 w-full cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-label-md text-text-primary",
254
+ "motion-colors",
255
+ current ? "bg-overlay-ink-6" : "hover:bg-overlay-ink-4",
256
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
257
+ className,
258
+ );
259
+
260
+ const content = (
261
+ <>
262
+ {icon && (
263
+ <span data-slot="icon" className="flex shrink-0 [&>svg]:size-4.5">
264
+ {icon}
265
+ </span>
266
+ )}
267
+ <span className="min-w-0 flex-1 truncate">{children}</span>
268
+ {count !== undefined && <span className="shrink-0 text-body-sm text-text-secondary">{count}</span>}
269
+ {attention && <span aria-label="Needs attention" className="size-[7px] shrink-0 rounded-full bg-text-danger" />}
270
+ </>
271
+ );
272
+
273
+ if (props.href !== undefined) {
274
+ const { href, ...anchorProps } = props;
275
+ return (
276
+ <a href={href} aria-current={current ? "page" : undefined} className={classes} {...anchorProps}>
277
+ {content}
278
+ </a>
279
+ );
280
+ }
281
+
282
+ return (
283
+ <button type="button" aria-current={current ? "page" : undefined} className={classes} {...props}>
284
+ {content}
285
+ </button>
286
+ );
287
+ }
288
+
289
+ /** A 22px rounded tile with a 14px inverse icon, for project rows. Set the fill with `className`. */
290
+ export function SidebarProjectTile({
291
+ className,
292
+ ...props
293
+ }: Omit<ComponentProps<"span">, "className"> & { className?: string }) {
294
+ return (
295
+ <span
296
+ className={cn(
297
+ "flex size-5.5 items-center justify-center rounded-xs bg-interactive-primary text-text-inverse [&>svg]:size-3.5",
298
+ className,
299
+ )}
300
+ {...props}
301
+ />
302
+ );
303
+ }
304
+
305
+ export type SidebarGroupProps = {
306
+ label: ReactNode;
307
+ defaultOpen?: boolean;
308
+ children: ReactNode;
309
+ className?: string;
310
+ };
311
+
312
+ /** A collapsible group: 28px label in secondary text with a 14px caret, 14px above. */
313
+ export function SidebarGroup({ label, defaultOpen = true, children, className }: SidebarGroupProps) {
314
+ return (
315
+ <Collapsible.Root defaultOpen={defaultOpen} className={cn("relative mt-3.5 flex flex-col gap-0.5", className)}>
316
+ <Collapsible.Trigger className="group flex h-7 w-fit cursor-pointer items-center gap-1 px-2.5 text-label-md text-text-secondary motion-colors hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand">
317
+ {label}
318
+ <CaretDown
319
+ aria-hidden
320
+ className="size-3.5 transition-transform duration-200 ease-out-quint group-not-data-panel-open:-rotate-90 group-data-panel-open:rotate-0"
321
+ />
322
+ </Collapsible.Trigger>
323
+ <Collapsible.Panel className="flex motion-collapse flex-col gap-0.5">{children}</Collapsible.Panel>
324
+ </Collapsible.Root>
325
+ );
326
+ }
327
+
328
+ const ROW = 32;
329
+ const GAP = 2;
330
+
331
+ /** Connector path of the Figma `Tree`: one 1.5px line with a 9px curve into the middle of each child row. */
332
+ function branchesPath(count: number): string {
333
+ const centers = Array.from({ length: count }, (_, index) => ROW / 2 + index * (ROW + GAP));
334
+ const last = centers.at(-1) ?? 0;
335
+ const trunk = `M0.75 0V${last - 9}`;
336
+ const branches = centers.map((center) => {
337
+ const start = center - 9;
338
+ return `M0.75 ${start}C0.75 ${start + 4.909} 4.841 ${center} 9.75 ${center}H13.75`;
339
+ });
340
+ return [trunk, ...branches].join("");
341
+ }
342
+
343
+ /** Figma `Tree`: nested sidebar items with 16px icons, 20px in, joined to the parent by curved connectors. */
344
+ export function SidebarTree({ children, className }: { children: ReactNode; className?: string }) {
345
+ const count = Children.count(children);
346
+ const height = ROW / 2 + (count - 1) * (ROW + GAP) + 0.75;
347
+ return (
348
+ <div className={cn("relative flex items-start pl-5 [&_[data-slot=icon]>svg]:size-4", className)}>
349
+ <svg
350
+ aria-hidden
351
+ width="16"
352
+ height={height}
353
+ viewBox={`0 0 16 ${height}`}
354
+ fill="none"
355
+ className="shrink-0 overflow-visible"
356
+ >
357
+ <path d={branchesPath(count)} strokeWidth="1.5" className="stroke-overlay-ink-16" />
358
+ </svg>
359
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">{children}</div>
360
+ </div>
361
+ );
362
+ }
363
+
364
+ /** Pushes the account card to the bottom. */
365
+ export function SidebarSpacer() {
366
+ return <div aria-hidden className="min-h-px flex-1" />;
367
+ }
368
+
369
+ export type SidebarAccountProps = ButtonProps & {
370
+ initials: ReactNode;
371
+ name: ReactNode;
372
+ email?: ReactNode;
373
+ };
374
+
375
+ /** Figma sidebar `Account`: a 54px row with a 36px sky avatar tile and an up-down caret. */
376
+ export function SidebarAccount({ initials, name, email, className, ...props }: SidebarAccountProps) {
377
+ return (
378
+ <button
379
+ type="button"
380
+ className={cn(
381
+ "relative flex w-full cursor-pointer items-center gap-2.5 rounded-lg py-2 pr-2 pl-1.5 text-left motion-colors hover:bg-overlay-ink-4 data-popup-open:bg-overlay-ink-6",
382
+ "focus-visible:outline-2 focus-visible:outline-text-brand",
383
+ className,
384
+ )}
385
+ {...props}
386
+ >
387
+ <span className="flex size-9 shrink-0 items-center justify-center rounded-[10px] bg-sky-200 text-label-md text-surface-ink">
388
+ {initials}
389
+ </span>
390
+ <span className="flex min-w-0 flex-1 flex-col">
391
+ <span className="truncate text-label-md text-text-primary">{name}</span>
392
+ {email && <span className="truncate text-caption text-text-secondary">{email}</span>}
393
+ </span>
394
+ <CaretUpDown aria-hidden className="size-4 shrink-0 text-text-primary" />
395
+ </button>
396
+ );
397
+ }
398
+
399
+ export type SidebarProjectProps = {
400
+ /** A `SidebarProjectTile`. */
401
+ icon: ReactNode;
402
+ label: ReactNode;
403
+ current?: boolean;
404
+ defaultOpen?: boolean;
405
+ open?: boolean;
406
+ onOpenChange?: (open: boolean) => void;
407
+ /**
408
+ * The project's "More" control, such as a `MenuTrigger` whose render target is
409
+ * `<SidebarProjectMore />`. It replaces the chevron while the project is open.
410
+ */
411
+ actions?: ReactNode;
412
+ /** Chats and documents in the project, shown in a `SidebarTree` when open. */
413
+ children?: ReactNode;
414
+ className?: string;
415
+ };
416
+
417
+ /**
418
+ * Figma sidebar project row (88:848, Menu=Project 314:12407): tile, name and a
419
+ * 14px chevron (down when open). An open project shows the 16px "More" button in
420
+ * place of the chevron, on a 6% ink row; a collapsed project keeps the chevron.
421
+ */
422
+ export function SidebarProject({
423
+ icon,
424
+ label,
425
+ current,
426
+ defaultOpen = false,
427
+ open,
428
+ onOpenChange,
429
+ actions,
430
+ children,
431
+ className,
432
+ }: SidebarProjectProps) {
433
+ const hasChildren = Children.count(children) > 0;
434
+ const [uncontrolledOpen, setUncontrolledOpen] = useState(defaultOpen);
435
+ const isOpen = open ?? uncontrolledOpen;
436
+ const handleOpenChange = (next: boolean) => {
437
+ setUncontrolledOpen(next);
438
+ onOpenChange?.(next);
439
+ };
440
+ return (
441
+ <Collapsible.Root open={isOpen} onOpenChange={handleOpenChange} className={cn("flex flex-col gap-0.5", className)}>
442
+ <div
443
+ className={cn(
444
+ "group/project relative flex h-8 items-center rounded-md has-data-popup-open:bg-overlay-ink-6",
445
+ "motion-colors",
446
+ current ? "bg-overlay-ink-6" : "hover:bg-overlay-ink-4",
447
+ )}
448
+ >
449
+ <Collapsible.Trigger
450
+ aria-current={current ? "page" : undefined}
451
+ className={cn(
452
+ "group/trigger flex h-8 min-w-0 flex-1 cursor-pointer items-center gap-2.5 rounded-md px-2.5 text-left text-label-md text-text-primary",
453
+ "focus-visible:outline-2 focus-visible:-outline-offset-2 focus-visible:outline-text-brand",
454
+ )}
455
+ >
456
+ <span data-slot="icon" className="flex shrink-0">
457
+ {icon}
458
+ </span>
459
+ <span className="min-w-0 flex-1 truncate">{label}</span>
460
+ {hasChildren && (
461
+ <span
462
+ aria-hidden
463
+ className={cn("flex size-3.5 shrink-0 [&>svg]:size-3.5", actions !== undefined && isOpen && "invisible")}
464
+ >
465
+ <CaretRight className="transition-transform duration-200 ease-out-quint group-data-panel-open/trigger:rotate-90" />
466
+ </span>
467
+ )}
468
+ </Collapsible.Trigger>
469
+ {actions !== undefined && (
470
+ <span className={cn("absolute right-2.5 flex", !hasChildren || isOpen ? "visible" : "invisible")}>
471
+ {actions}
472
+ </span>
473
+ )}
474
+ </div>
475
+ {hasChildren && (
476
+ <Collapsible.Panel className="motion-collapse">
477
+ <SidebarTree>{children}</SidebarTree>
478
+ </Collapsible.Panel>
479
+ )}
480
+ </Collapsible.Root>
481
+ );
482
+ }
483
+
484
+ export type SidebarProjectMoreProps = ButtonProps & { label?: string };
485
+
486
+ /** The 16px "More" button of a project row. */
487
+ export function SidebarProjectMore({ label = "Project actions", className, ...props }: SidebarProjectMoreProps) {
488
+ return (
489
+ <button
490
+ type="button"
491
+ aria-label={label}
492
+ className={cn(
493
+ "relative flex size-4 cursor-pointer items-center justify-center rounded-xs text-text-primary motion-colors after:absolute after:top-1/2 after:left-1/2 after:min-h-11 after:min-w-11 after:-translate-x-1/2 after:-translate-y-1/2 after:content-[''] hover:bg-overlay-ink-6 focus-visible:outline-2 focus-visible:outline-text-brand",
494
+ className,
495
+ )}
496
+ {...props}
497
+ >
498
+ <DotsThree aria-hidden className="size-4" />
499
+ </button>
500
+ );
501
+ }
502
+
503
+ export type SidebarAddButtonProps = ButtonProps & { icon?: ReactNode };
504
+
505
+ /**
506
+ * Figma `Add project`: a 36px button with a 1px inside dashed border (4/3 dash,
507
+ * 20% ink), label then a 16px plus-circle, 12px above. The border is an SVG rect
508
+ * so the dash matches; its 2px stroke is clipped to the inner 1px.
509
+ */
510
+ const addIcon = <PlusCircle />;
511
+
512
+ export function SidebarAddButton({ icon = addIcon, className, children, ...props }: SidebarAddButtonProps) {
513
+ return (
514
+ <button
515
+ type="button"
516
+ className={cn(
517
+ "relative mt-2 flex h-9 w-full cursor-pointer items-center justify-center gap-1.5 rounded-[10px] text-label-md text-text-secondary motion-colors hover:bg-overlay-ink-4",
518
+ "focus-visible:outline-2 focus-visible:outline-text-brand",
519
+ className,
520
+ )}
521
+ {...props}
522
+ >
523
+ <svg aria-hidden className="pointer-events-none absolute inset-0 size-full text-overlay-ink-20">
524
+ <rect
525
+ width="100%"
526
+ height="100%"
527
+ rx="10"
528
+ fill="none"
529
+ stroke="currentColor"
530
+ strokeWidth="2"
531
+ strokeDasharray="4 3"
532
+ />
533
+ </svg>
534
+ {children}
535
+ <span data-slot="icon" className="flex [&>svg]:size-4">
536
+ {icon}
537
+ </span>
538
+ </button>
539
+ );
540
+ }
541
+
542
+ export type { DivProps as SidebarDivProps };