@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,275 @@
1
+ import { Collapsible } from "@base-ui/react/collapsible";
2
+ import { type ComponentProps, type ReactNode, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Button } from "./button";
6
+ import { CodePanel } from "./code-panel";
7
+ import { LiquidGlass } from "./liquid-glass";
8
+
9
+ export type ListDetailItem = {
10
+ id: string;
11
+ title: ReactNode;
12
+ meta?: ReactNode;
13
+ };
14
+
15
+ export type ListDetailGroup = {
16
+ id: string;
17
+ label: ReactNode;
18
+ items: ListDetailItem[];
19
+ };
20
+
21
+ export type ListDetailProps = Omit<ComponentProps<"div">, "className" | "children" | "onChange"> & {
22
+ groups: ListDetailGroup[];
23
+ value?: string;
24
+ defaultValue?: string;
25
+ onValueChange?: (id: string) => void;
26
+ /** Renders the detail pane for the selected item. */
27
+ children: (item: ListDetailItem) => ReactNode;
28
+ /** `solid` (default) is an opaque surface shell with a default border; `glass` is the thick glass shell. */
29
+ surface?: "solid" | "glass";
30
+ className?: string;
31
+ };
32
+
33
+ /**
34
+ * Paper `List and detail` (1W6P-0): a 16px-radius shell, solid or thick glass, with a 250px list on an inset
35
+ * surface and a detail pane. Selecting a row fills it in 150ms and swaps the detail with a short rise.
36
+ */
37
+ export function ListDetail({
38
+ groups,
39
+ value,
40
+ defaultValue,
41
+ onValueChange,
42
+ children,
43
+ surface = "solid",
44
+ className,
45
+ ...props
46
+ }: ListDetailProps) {
47
+ const all = groups.flatMap((group) => group.items);
48
+ const [inner, setInner] = useState(defaultValue ?? all[0]?.id);
49
+ const selectedId = value ?? inner;
50
+ const selected = all.find((item) => item.id === selectedId) ?? all[0];
51
+ const mutedClass = "text-text-secondary";
52
+ return (
53
+ <div
54
+ className={cn(
55
+ // Below `sm` the two panes stack: the list would otherwise hold its 250px
56
+ // and leave the detail pane nothing to render in.
57
+ "relative flex w-full max-w-201.5 min-w-0 flex-col overflow-clip rounded-2xl sm:flex-row",
58
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ {surface === "glass" && <LiquidGlass />}
64
+ <div
65
+ role="listbox"
66
+ aria-label="Items"
67
+ className={cn(
68
+ "relative flex w-full shrink-0 flex-col gap-3 border-b border-border-default p-2.5 sm:w-62.5 sm:border-r sm:border-b-0",
69
+ surface === "glass" ? "bg-transparent" : "bg-bg-inset",
70
+ )}
71
+ >
72
+ {groups.map((group) => (
73
+ <div key={group.id} className="flex flex-col gap-0.5">
74
+ <span className={cn("px-3 py-1 text-label-sm font-semibold tracking-[0.04em]", mutedClass)}>
75
+ {group.label}
76
+ </span>
77
+ {group.items.map((item) => (
78
+ <button
79
+ key={item.id}
80
+ type="button"
81
+ role="option"
82
+ aria-selected={item.id === selected?.id}
83
+ data-selected={item.id === selected?.id ? "" : undefined}
84
+ className="flex cursor-pointer flex-col gap-1 rounded-[10px] px-3 py-2.5 text-left motion-colors hover:bg-overlay-ink-4 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand data-selected:bg-overlay-ink-6 data-selected:hover:bg-overlay-ink-8"
85
+ onClick={() => {
86
+ setInner(item.id);
87
+ onValueChange?.(item.id);
88
+ }}
89
+ >
90
+ <span className="flex items-center gap-2">
91
+ <span className="min-w-0 truncate font-mono text-label-md text-text-primary">{item.title}</span>
92
+ </span>
93
+ {item.meta && <span className={cn("text-label-sm", mutedClass)}>{item.meta}</span>}
94
+ </button>
95
+ ))}
96
+ </div>
97
+ ))}
98
+ </div>
99
+ <div key={selected?.id} className="relative flex min-w-0 flex-1 motion-rise flex-col gap-5 px-6 py-5.5">
100
+ {selected && children(selected)}
101
+ </div>
102
+ </div>
103
+ );
104
+ }
105
+
106
+ export type ListDetailHeaderProps = Omit<ComponentProps<"div">, "className" | "title"> & {
107
+ icon?: ReactNode;
108
+ title: ReactNode;
109
+ description?: ReactNode;
110
+ /** Right-hand cluster: a state label, a `Switch`, an icon button. */
111
+ actions?: ReactNode;
112
+ className?: string;
113
+ };
114
+
115
+ /** Detail header: a 40px sky tile with an 18px icon, title and description, actions on the right. */
116
+ export function ListDetailHeader({ icon, title, description, actions, className, ...props }: ListDetailHeaderProps) {
117
+ return (
118
+ <div className={cn("flex flex-wrap items-center gap-3", className)} {...props}>
119
+ {icon && (
120
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-sky-100 text-sky-700 dark:bg-sky-900 dark:text-sky-300 [&_svg]:size-5">
121
+ {icon}
122
+ </span>
123
+ )}
124
+ <div className="flex min-w-0 flex-1 flex-col">
125
+ <span className="font-mono text-label-lg font-semibold text-text-primary">{title}</span>
126
+ {description && <span className="text-body-sm text-text-secondary">{description}</span>}
127
+ </div>
128
+ {actions && <div className="flex shrink-0 items-center gap-3">{actions}</div>}
129
+ </div>
130
+ );
131
+ }
132
+
133
+ export type ListDetailMetaProps = Omit<ComponentProps<"dl">, "className"> & {
134
+ items: { id: string; label: ReactNode; value: ReactNode }[];
135
+ className?: string;
136
+ };
137
+
138
+ /** Meta box: a subtle-fill 12px-radius box with equal columns of a label over a 14/20 value. */
139
+ export function ListDetailMeta({ items, className, ...props }: ListDetailMetaProps) {
140
+ return (
141
+ <dl
142
+ className={cn("m-0 grid grid-cols-2 gap-x-4 gap-y-3 rounded-md bg-bg-subtle px-4 py-3.5", className)}
143
+ {...props}
144
+ >
145
+ {items.map((item) => (
146
+ <div key={item.id} className="flex min-w-0 flex-1 flex-col gap-0.5">
147
+ <dt className="text-label-sm text-text-secondary">{item.label}</dt>
148
+ <dd className="m-0 truncate text-label-md text-text-primary">{item.value}</dd>
149
+ </div>
150
+ ))}
151
+ </dl>
152
+ );
153
+ }
154
+
155
+ export type ListDetailInstructionsProps = Omit<ComponentProps<"div">, "className" | "onChange"> & {
156
+ title?: ReactNode;
157
+ /** Small chip beside the title, such as the file name. */
158
+ tag?: ReactNode;
159
+ value?: string;
160
+ defaultValue?: string;
161
+ onValueChange?: (value: string) => void;
162
+ className?: string;
163
+ };
164
+
165
+ function renderInstructions(text: string) {
166
+ const lines = text.split("\n");
167
+ let fences = 0;
168
+ let offset = 0;
169
+ return lines.map((line, index) => {
170
+ const start = offset;
171
+ offset += line.length + 1;
172
+ if (line.trim() === "---") fences += 1;
173
+ const key = fences === 1 ? /^([\w-]+:)(.*)$/.exec(line) : null;
174
+ return (
175
+ <span key={start}>
176
+ {key ? (
177
+ <>
178
+ <span className="text-syntax-key">{key[1]}</span>
179
+ {key[2]}
180
+ </>
181
+ ) : (
182
+ line
183
+ )}
184
+ {index < lines.length - 1 && "\n"}
185
+ </span>
186
+ );
187
+ });
188
+ }
189
+
190
+ /**
191
+ * Instructions block: header with an Edit button and a code panel. Edit opens a textarea under the panel with
192
+ * the 240ms collapse transition.
193
+ */
194
+ export function ListDetailInstructions({
195
+ title = "Instructions",
196
+ tag,
197
+ value,
198
+ defaultValue = "",
199
+ onValueChange,
200
+ className,
201
+ ...props
202
+ }: ListDetailInstructionsProps) {
203
+ const [inner, setInner] = useState(defaultValue);
204
+ const [editing, setEditing] = useState(false);
205
+ const text = value ?? inner;
206
+ return (
207
+ <div className={cn("flex flex-col gap-2", className)} {...props}>
208
+ <div className="flex flex-wrap items-center gap-2">
209
+ <span className="text-label-md font-semibold text-text-primary">{title}</span>
210
+ {tag && (
211
+ <span className="inline-flex h-5.5 items-center rounded-full bg-overlay-ink-6 px-2 text-label-sm text-text-secondary">
212
+ {tag}
213
+ </span>
214
+ )}
215
+ <span className="flex-1" />
216
+ <Button
217
+ variant="solid"
218
+ size="sm"
219
+ aria-pressed={editing}
220
+ onClick={() => setEditing((open) => !open)}
221
+ className="w-16 justify-center px-0"
222
+ >
223
+ <span key={editing ? "done" : "edit"} className="animate-fade-in">
224
+ {editing ? "Done" : "Edit"}
225
+ </span>
226
+ </Button>
227
+ </div>
228
+ <CodePanel>{renderInstructions(text)}</CodePanel>
229
+ <Collapsible.Root open={editing} onOpenChange={setEditing}>
230
+ <Collapsible.Panel className="motion-collapse">
231
+ <textarea
232
+ aria-label="Edit instructions"
233
+ value={text}
234
+ rows={6}
235
+ className="block w-full resize-y rounded-[10px] border border-border-default bg-bg-inset px-4 py-3.5 font-mono text-[12px]/[21px] text-text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
236
+ onChange={(event) => {
237
+ setInner(event.target.value);
238
+ onValueChange?.(event.target.value);
239
+ }}
240
+ />
241
+ </Collapsible.Panel>
242
+ </Collapsible.Root>
243
+ </div>
244
+ );
245
+ }
246
+
247
+ export type ListDetailFilesProps = Omit<ComponentProps<"div">, "className"> & {
248
+ label?: ReactNode;
249
+ /** Chips, usually a small `Button` with an icon. */
250
+ children: ReactNode;
251
+ className?: string;
252
+ };
253
+
254
+ /** Files row: a 13/18 label over a wrap of 32px glass chips, 6px apart. */
255
+ export function ListDetailFiles({ label = "Files", children, className, ...props }: ListDetailFilesProps) {
256
+ return (
257
+ <div className={cn("flex flex-col gap-2", className)} {...props}>
258
+ <span className="text-label-md font-semibold text-text-primary">{label}</span>
259
+ <div className="flex flex-wrap gap-1.5">{children}</div>
260
+ </div>
261
+ );
262
+ }
263
+
264
+ /** Footer: a 1px top rule and 8px-spaced glass buttons; use `<span className="flex-1" />` as the spacer. */
265
+ export function ListDetailFooter({
266
+ className,
267
+ ...props
268
+ }: Omit<ComponentProps<"div">, "className"> & { className?: string }) {
269
+ return (
270
+ <div
271
+ className={cn("flex flex-wrap items-center gap-2 border-t border-border-default pt-3.5", className)}
272
+ {...props}
273
+ />
274
+ );
275
+ }
@@ -0,0 +1,97 @@
1
+ import { Star } from "@phosphor-icons/react";
2
+ import type { ComponentProps, 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 MarketplaceCardProps = Omit<ComponentProps<"div">, "children"> & {
9
+ /** Two-letter initials shown on the 40px ink tile. */
10
+ initials: string;
11
+ name: ReactNode;
12
+ author?: ReactNode;
13
+ description?: ReactNode;
14
+ /** Capability chips, each with an optional 11px icon. */
15
+ chips?: { id: string; label: ReactNode; icon?: ReactNode }[];
16
+ score?: ReactNode;
17
+ installs?: ReactNode;
18
+ onInstall?: () => void;
19
+ installLabel?: string;
20
+ /** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
21
+ surface?: "solid" | "glass";
22
+ };
23
+
24
+ /** Paper `Marketplace card` (Settings 1VMY-0): solid or thick glass column with tile header, description, chips and a footer with rating and Install. */
25
+ export function MarketplaceCard({
26
+ initials,
27
+ name,
28
+ author,
29
+ description,
30
+ chips,
31
+ score,
32
+ installs,
33
+ onInstall,
34
+ installLabel = "Install",
35
+ surface = "solid",
36
+ className,
37
+ ...props
38
+ }: MarketplaceCardProps) {
39
+ return (
40
+ <div
41
+ className={cn(
42
+ "relative flex flex-col gap-3 overflow-clip rounded-xl p-4",
43
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
44
+ className,
45
+ )}
46
+ {...props}
47
+ >
48
+ {surface === "glass" && <LiquidGlass />}
49
+ <div className="relative flex items-center gap-3">
50
+ <span
51
+ aria-hidden
52
+ className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-neutral-900 text-label-md text-neutral-50"
53
+ >
54
+ {initials}
55
+ </span>
56
+ <span className="flex min-w-0 flex-col gap-0.5">
57
+ <span className="truncate text-label-md text-text-primary">{name}</span>
58
+ {author && <span className="truncate text-caption text-text-secondary">{author}</span>}
59
+ </span>
60
+ </div>
61
+ {description && <p className="relative text-body-sm text-text-secondary">{description}</p>}
62
+ {chips && chips.length > 0 && (
63
+ <div className="relative flex flex-wrap gap-1.5">
64
+ {chips.map((chip) => (
65
+ <span
66
+ key={chip.id}
67
+ className="inline-flex h-5.5 items-center gap-1 rounded-full bg-overlay-ink-6 px-2 text-label-sm text-text-secondary [&_svg]:size-3"
68
+ >
69
+ {chip.icon}
70
+ {chip.label}
71
+ </span>
72
+ ))}
73
+ </div>
74
+ )}
75
+ <div className="relative flex items-center gap-1.5 border-t border-border-default pt-3">
76
+ {score && (
77
+ <>
78
+ <Star aria-hidden weight="fill" className="size-3.5 text-mascot-gold" />
79
+ <span className="text-label-sm text-text-primary">{score}</span>
80
+ </>
81
+ )}
82
+ {score && installs && (
83
+ <span aria-hidden className="text-caption text-text-placeholder">
84
+ ·
85
+ </span>
86
+ )}
87
+ {installs && <span className="text-caption text-text-secondary">{installs}</span>}
88
+ <span className="flex-1" />
89
+ {onInstall && (
90
+ <Button variant="solid" size="sm" onClick={onInstall}>
91
+ {installLabel}
92
+ </Button>
93
+ )}
94
+ </div>
95
+ </div>
96
+ );
97
+ }
@@ -0,0 +1,122 @@
1
+ import { DotsThree, GearSix } from "@phosphor-icons/react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Button } from "./button";
6
+ import { IconButton } from "./icon-button";
7
+ import { Menu, MenuContent, MenuTrigger } from "./menu";
8
+
9
+ const pillClass = "inline-flex h-5.5 items-center rounded-full px-2 text-label-sm";
10
+
11
+ export type McpServerStatusTone = "connected" | "warning" | "neutral";
12
+
13
+ const statusToneClass: Record<McpServerStatusTone, { pill: string; dot: string }> = {
14
+ connected: { pill: "bg-overlay-ink-6 text-text-primary", dot: "bg-status-live" },
15
+ warning: { pill: "bg-accent-red-500-14 text-text-danger", dot: "bg-text-danger" },
16
+ neutral: { pill: "bg-overlay-ink-6 text-text-secondary", dot: "bg-text-placeholder" },
17
+ };
18
+
19
+ /** Suffixes repeated values with their occurrence so duplicate tags never share a React key. */
20
+ const withKeys = (values: string[] | undefined) => {
21
+ const seen = new Map<string, number>();
22
+ return (values ?? []).map((value) => {
23
+ const count = seen.get(value) ?? 0;
24
+ seen.set(value, count + 1);
25
+ return { value, key: count === 0 ? value : `${value}#${count}` };
26
+ });
27
+ };
28
+
29
+ export type McpServerRowProps = Omit<ComponentProps<"div">, "className" | "title"> & {
30
+ /** Server name; its first two letters fill the 40px tile unless `initials` is set. */
31
+ name: string;
32
+ initials?: string;
33
+ /** Tags next to the name, such as "Official". */
34
+ tags?: string[];
35
+ /** Tags tinted brand, such as "All devices". */
36
+ brandTags?: string[];
37
+ description?: ReactNode;
38
+ /** Meta line, joined by "·". */
39
+ meta?: string[];
40
+ statusLabel?: ReactNode;
41
+ /** Colour of the status pill: a live dot by default, a danger tint for a warning, a muted dot for a neutral state. */
42
+ statusTone?: McpServerStatusTone;
43
+ configureLabel?: ReactNode;
44
+ onConfigure?: () => void;
45
+ /** Overflow menu items (`MenuItem`). Hides the overflow button when absent. */
46
+ menu?: ReactNode;
47
+ menuLabel?: string;
48
+ className?: string;
49
+ };
50
+
51
+ /** Paper `MCP server row` (1VHS-0): a divided row with a 40px tile, tags, a status pill and two actions. */
52
+ export function McpServerRow({
53
+ name,
54
+ initials,
55
+ tags,
56
+ brandTags,
57
+ description,
58
+ meta,
59
+ statusLabel = "Connected",
60
+ statusTone = "connected",
61
+ configureLabel = "Configure",
62
+ onConfigure,
63
+ menu,
64
+ menuLabel = "More",
65
+ className,
66
+ ...props
67
+ }: McpServerRowProps) {
68
+ return (
69
+ <div
70
+ className={cn(
71
+ "flex min-w-0 flex-wrap items-center gap-x-3.5 gap-y-3 border-b border-border-default py-4 last:border-b-0",
72
+ className,
73
+ )}
74
+ {...props}
75
+ >
76
+ <span className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-sky-500 text-label-md text-neutral-50">
77
+ {initials ?? name.slice(0, 2).toUpperCase()}
78
+ </span>
79
+ <div className="flex min-w-0 grow basis-40 flex-col gap-1">
80
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
81
+ <span className="max-w-full min-w-0 truncate text-label-md text-text-primary">{name}</span>
82
+ {withKeys(tags).map(({ value, key }) => (
83
+ <span key={key} className={cn(pillClass, "bg-overlay-ink-6 text-text-secondary")}>
84
+ {value}
85
+ </span>
86
+ ))}
87
+ {withKeys(brandTags).map(({ value, key }) => (
88
+ <span key={key} className={cn(pillClass, "bg-accent-sky-500-18 text-text-brand")}>
89
+ {value}
90
+ </span>
91
+ ))}
92
+ </div>
93
+ {description && <span className="text-body-sm wrap-break-word text-text-secondary">{description}</span>}
94
+ {meta && meta.length > 0 && (
95
+ <div className="flex flex-wrap items-center gap-1.5 text-label-sm text-text-secondary">
96
+ {withKeys(meta).map(({ value, key }, i) => (
97
+ <span key={key} className="inline-flex items-center gap-1.5">
98
+ {i > 0 && <span aria-hidden>·</span>}
99
+ {value}
100
+ </span>
101
+ ))}
102
+ </div>
103
+ )}
104
+ </div>
105
+ <span className={cn(pillClass, "shrink-0 gap-1.5", statusToneClass[statusTone].pill)}>
106
+ <span aria-hidden className={cn("size-1.5 rounded-full", statusToneClass[statusTone].dot)} />
107
+ {statusLabel}
108
+ </span>
109
+ <Button variant="solid" size="sm" icon={<GearSix aria-hidden className="size-3.5" />} onClick={onConfigure}>
110
+ {configureLabel}
111
+ </Button>
112
+ {menu && (
113
+ <Menu>
114
+ <MenuTrigger
115
+ render={<IconButton label={menuLabel} variant="ghost" size="sm" icon={<DotsThree aria-hidden />} />}
116
+ />
117
+ <MenuContent align="end">{menu}</MenuContent>
118
+ </Menu>
119
+ )}
120
+ </div>
121
+ );
122
+ }
@@ -0,0 +1,165 @@
1
+ import { Menu as BaseMenu } from "@base-ui/react/menu";
2
+ import { createContext, useContext, type ComponentProps, type ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+
6
+ type WithClass<T> = Omit<T, "className"> & { className?: string };
7
+
8
+ export type MenuSurface = "glass" | "solid" | "paper";
9
+
10
+ const SurfaceContext = createContext<MenuSurface>("glass");
11
+
12
+ export const Menu = BaseMenu.Root;
13
+ export const MenuTrigger = BaseMenu.Trigger;
14
+
15
+ export type MenuContentProps = WithClass<ComponentProps<typeof BaseMenu.Popup>> & {
16
+ /**
17
+ * `glass` is the Figma `Menu` on the veil (20px radius, 40px rows).
18
+ * `solid` is the Account / Workspace menu that opens over glass chrome
19
+ * (opaque fill, 18px radius, soft shadow, 36px rows).
20
+ * `paper` is the Paper workspace switcher: default fill, 1px default border, 14px radius,
21
+ * 0 12 32 shadow, 304px wide, 36px rows.
22
+ */
23
+ surface?: MenuSurface;
24
+ side?: ComponentProps<typeof BaseMenu.Positioner>["side"];
25
+ align?: ComponentProps<typeof BaseMenu.Positioner>["align"];
26
+ sideOffset?: number;
27
+ alignOffset?: number;
28
+ };
29
+
30
+ export const menuPopupClass = {
31
+ glass: "rounded-xl veil-overlay",
32
+ solid: "rounded-[18px] bg-glass-fill-solid shadow-popover",
33
+ paper: "rounded-[14px] border border-border-default bg-bg-default shadow-[0_12px_32px_#0A142424]",
34
+ } as const;
35
+
36
+ /** Figma `Menus and popovers` (88:495): `Menu`, `Account menu` and `Workspace menu`. */
37
+ export function MenuContent({
38
+ surface = "solid",
39
+ side,
40
+ align = "start",
41
+ sideOffset = 8,
42
+ alignOffset,
43
+ className,
44
+ children,
45
+ ...props
46
+ }: MenuContentProps) {
47
+ return (
48
+ <BaseMenu.Portal>
49
+ <BaseMenu.Positioner
50
+ side={side}
51
+ align={align}
52
+ sideOffset={sideOffset}
53
+ alignOffset={alignOffset}
54
+ className="z-50 outline-none"
55
+ >
56
+ <BaseMenu.Popup
57
+ className={cn(
58
+ "relative flex motion-popup flex-col gap-0.5 overflow-clip p-1.5 outline-none",
59
+ surface === "glass" ? "w-55" : surface === "paper" ? "w-76" : "w-64",
60
+ // The 304px paper menu is wider than a 320px viewport allows once the
61
+ // positioner offsets it; this cap keeps the popup on screen.
62
+ "max-w-[calc(100vw-2rem)]",
63
+ menuPopupClass[surface],
64
+ className,
65
+ )}
66
+ {...props}
67
+ >
68
+ <SurfaceContext value={surface}>{children}</SurfaceContext>
69
+ </BaseMenu.Popup>
70
+ </BaseMenu.Positioner>
71
+ </BaseMenu.Portal>
72
+ );
73
+ }
74
+
75
+ export type MenuItemProps = WithClass<ComponentProps<typeof BaseMenu.Item>> & {
76
+ /** 18px leading icon, or a 28px workspace tile. */
77
+ icon?: ReactNode;
78
+ /** Caption under the label, such as "3 members". */
79
+ description?: ReactNode;
80
+ /** Right side: a shortcut such as "⌘C", or a check. */
81
+ trailing?: ReactNode;
82
+ danger?: boolean;
83
+ /** Current choice drawn on the brand tint (Paper workspace switcher). */
84
+ selected?: boolean;
85
+ };
86
+
87
+ export function MenuItem({
88
+ icon,
89
+ description,
90
+ trailing,
91
+ danger,
92
+ selected,
93
+ className,
94
+ children,
95
+ ...props
96
+ }: MenuItemProps) {
97
+ const surface = useContext(SurfaceContext);
98
+ return (
99
+ <BaseMenu.Item
100
+ className={cn(
101
+ "relative flex w-full cursor-pointer items-center gap-2.5 motion-colors outline-none select-none data-disabled:cursor-not-allowed data-disabled:opacity-40 data-highlighted:bg-overlay-ink-6",
102
+ surface === "glass" ? "rounded-md px-3" : "rounded-[10px] px-2.5",
103
+ description ? "h-12" : surface === "glass" ? "h-10" : "h-9",
104
+ selected && "bg-bg-brand data-highlighted:bg-bg-brand",
105
+ className,
106
+ )}
107
+ {...props}
108
+ >
109
+ {icon && (
110
+ <span
111
+ className={cn(
112
+ "flex shrink-0 [&>svg]:size-4.5",
113
+ danger ? "text-text-danger" : surface === "glass" ? "text-text-secondary" : "text-text-primary",
114
+ )}
115
+ >
116
+ {icon}
117
+ </span>
118
+ )}
119
+ <span className="flex min-w-0 flex-1 flex-col">
120
+ <span className={cn("truncate text-label-md", danger ? "text-text-danger" : "text-text-primary")}>
121
+ {children}
122
+ </span>
123
+ {description && <span className="truncate text-caption text-text-secondary">{description}</span>}
124
+ </span>
125
+ {trailing && (
126
+ <span className="flex shrink-0 text-caption text-text-secondary [&>svg]:size-4 [&>svg]:text-text-primary">
127
+ {trailing}
128
+ </span>
129
+ )}
130
+ </BaseMenu.Item>
131
+ );
132
+ }
133
+
134
+ export function MenuSeparator({ className, ...props }: WithClass<ComponentProps<typeof BaseMenu.Separator>>) {
135
+ const surface = useContext(SurfaceContext);
136
+ return (
137
+ <BaseMenu.Separator
138
+ className={cn("relative flex py-1", surface === "glass" ? "px-1.5" : "px-2.5", className)}
139
+ {...props}
140
+ render={(separatorProps) => (
141
+ <div {...separatorProps}>
142
+ <span className="h-px flex-1 bg-overlay-ink-8" />
143
+ </div>
144
+ )}
145
+ />
146
+ );
147
+ }
148
+
149
+ /** A caption heading for a group of items, such as "Workspaces" or the account email. */
150
+ export function MenuLabel({ className, ...props }: WithClass<ComponentProps<"div">>) {
151
+ return <div className={cn("relative px-2.5 pt-2 pb-1 text-caption text-text-secondary", className)} {...props} />;
152
+ }
153
+
154
+ /** A 28px rounded tile for a workspace initial, used as a `MenuItem` icon. */
155
+ export function MenuTile({ className, ...props }: WithClass<ComponentProps<"span">>) {
156
+ return (
157
+ <span
158
+ className={cn(
159
+ "flex size-7 shrink-0 items-center justify-center rounded-sm bg-interactive-primary text-label-sm text-text-inverse",
160
+ className,
161
+ )}
162
+ {...props}
163
+ />
164
+ );
165
+ }