@horon-ai/react 0.1.0 → 0.1.2

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 (98) hide show
  1. package/package.json +2 -2
  2. package/src/components/agent-activity.tsx +81 -11
  3. package/src/components/agent-lane.tsx +32 -3
  4. package/src/components/agent-lanes-timeline.tsx +85 -7
  5. package/src/components/agent-panel.tsx +53 -7
  6. package/src/components/agent-steering-panel.tsx +73 -21
  7. package/src/components/approval-card.tsx +34 -2
  8. package/src/components/avatar.tsx +20 -4
  9. package/src/components/badge.tsx +24 -3
  10. package/src/components/banner.tsx +19 -3
  11. package/src/components/bot-instructions-panel.tsx +92 -9
  12. package/src/components/bot-list-row.tsx +13 -1
  13. package/src/components/bot-memory-panel.tsx +58 -3
  14. package/src/components/bottom-sheet.tsx +61 -7
  15. package/src/components/breadcrumb-header.tsx +32 -2
  16. package/src/components/breadcrumb.tsx +31 -1
  17. package/src/components/button.tsx +46 -1
  18. package/src/components/calendar.tsx +76 -6
  19. package/src/components/card.tsx +30 -1
  20. package/src/components/chat-bubble.tsx +32 -3
  21. package/src/components/chat-controls.tsx +60 -7
  22. package/src/components/checkbox.tsx +22 -1
  23. package/src/components/chip.tsx +29 -2
  24. package/src/components/choice-card.tsx +25 -1
  25. package/src/components/code-panel.tsx +28 -3
  26. package/src/components/combobox.tsx +52 -2
  27. package/src/components/command-palette.tsx +89 -10
  28. package/src/components/composer.tsx +28 -1
  29. package/src/components/data-table.tsx +198 -16
  30. package/src/components/date-field.tsx +31 -2
  31. package/src/components/dialog.tsx +45 -3
  32. package/src/components/directory-card.tsx +23 -2
  33. package/src/components/document-extract-panel.tsx +83 -5
  34. package/src/components/empty-state.tsx +21 -2
  35. package/src/components/field.tsx +49 -3
  36. package/src/components/file-drop.tsx +39 -4
  37. package/src/components/filter-bar.tsx +39 -2
  38. package/src/components/flue-background.tsx +91 -21
  39. package/src/components/horon-icon.tsx +50 -3
  40. package/src/components/horon-wordmark.tsx +26 -1
  41. package/src/components/icon-button.tsx +38 -1
  42. package/src/components/input-group.tsx +57 -6
  43. package/src/components/input.tsx +48 -2
  44. package/src/components/integration-row.tsx +43 -2
  45. package/src/components/kbd.tsx +37 -4
  46. package/src/components/knowledge-base-panel.tsx +40 -2
  47. package/src/components/liquid-glass.tsx +16 -0
  48. package/src/components/list-detail.tsx +89 -7
  49. package/src/components/marketplace-card.tsx +40 -4
  50. package/src/components/mcp-server-row.tsx +49 -3
  51. package/src/components/menu.tsx +83 -3
  52. package/src/components/message-extras.tsx +54 -18
  53. package/src/components/modal.tsx +71 -4
  54. package/src/components/orchestrator-panel.tsx +48 -4
  55. package/src/components/page-header.tsx +26 -3
  56. package/src/components/plan-card.tsx +28 -1
  57. package/src/components/plan-step.tsx +41 -1
  58. package/src/components/plugin-row.tsx +61 -3
  59. package/src/components/progress.tsx +32 -2
  60. package/src/components/radio-group.tsx +55 -2
  61. package/src/components/research-run-panel.tsx +73 -3
  62. package/src/components/schedule.tsx +163 -12
  63. package/src/components/segmented-control.tsx +58 -3
  64. package/src/components/select.tsx +60 -0
  65. package/src/components/settings-list.tsx +66 -8
  66. package/src/components/sidebar.tsx +214 -23
  67. package/src/components/skeleton.tsx +61 -4
  68. package/src/components/slider.tsx +30 -0
  69. package/src/components/source-row.tsx +43 -4
  70. package/src/components/spinner.tsx +29 -2
  71. package/src/components/split-button.tsx +37 -3
  72. package/src/components/switch.tsx +31 -1
  73. package/src/components/tab-bar.tsx +48 -3
  74. package/src/components/tabs.tsx +59 -5
  75. package/src/components/task-row.tsx +49 -3
  76. package/src/components/textarea-field.tsx +36 -2
  77. package/src/components/textarea.tsx +35 -2
  78. package/src/components/toast.tsx +52 -3
  79. package/src/components/tool-call.tsx +91 -17
  80. package/src/components/tool-calls-header.tsx +32 -1
  81. package/src/components/tool-permissions.tsx +50 -2
  82. package/src/components/tooltip.tsx +33 -1
  83. package/src/components/top-bar.tsx +35 -2
  84. package/src/components/usage-meter.tsx +28 -2
  85. package/src/components/workspace-menu.tsx +46 -1
  86. package/src/hooks/index.ts +8 -0
  87. package/src/{lib → hooks}/use-active-indicator.ts +44 -1
  88. package/src/hooks/use-color-scheme.ts +50 -0
  89. package/src/hooks/use-copy-to-clipboard.ts +55 -0
  90. package/src/hooks/use-device.ts +69 -0
  91. package/src/hooks/use-hotkey.ts +56 -0
  92. package/src/hooks/use-media-query.ts +43 -0
  93. package/src/hooks/use-platform.ts +68 -0
  94. package/src/hooks/use-reduced-motion.ts +25 -0
  95. package/src/index.ts +44 -0
  96. package/src/lib/cn.ts +13 -1
  97. package/src/lib/cursor-ghost.ts +16 -6
  98. package/src/lib/platform.ts +236 -0
@@ -1,49 +1,79 @@
1
- import { ArrowElbowDownLeft, ArrowUp, Check, Lightning, Path, Pause, Play, X } from "@phosphor-icons/react";
1
+ import { ArrowUp, Check, Lightning, Path, Pause, Play, X } from "@phosphor-icons/react";
2
2
  import { type ComponentProps, type CSSProperties, type ReactNode, useState } from "react";
3
3
 
4
+ import { useDevice } from "../hooks/use-device";
5
+ import { usePlatform } from "../hooks/use-platform";
4
6
  import { cn } from "../lib/cn";
7
+ import { isModKey } from "../lib/platform";
5
8
  import { Kbd } from "./kbd";
6
9
  import { Spinner } from "./spinner";
7
10
 
11
+ /** Progress of one step: `done` fills the marker green, `active` shows a spinner, `todo` a dashed ring. */
8
12
  export type SteeringStepStatus = "done" | "active" | "todo";
9
13
 
14
+ /** One step in the steering rail: its label, progress status and optional duration. */
10
15
  export type SteeringStep = {
16
+ /** Stable key for the step. */
11
17
  id: string;
18
+ /** Step text; secondary colour while the step is still `todo`. */
12
19
  label: ReactNode;
20
+ /** Progress marker to draw. */
13
21
  status: SteeringStepStatus;
14
22
  /** Duration of a finished or running step, such as "1.4s". */
15
23
  time?: ReactNode;
16
24
  };
17
25
 
26
+ /** One steer note: the text and whether it has already been applied. */
18
27
  export type SteeringNote = {
28
+ /** Stable key for the note. */
19
29
  id: string;
30
+ /** The steer text, shown on one row and wrapping as needed. */
20
31
  text: ReactNode;
21
- /** An applied note has left the queue and carries the "Now" pill. */
32
+ /** An applied note has left the queue and carries the "Now" pill. @default false */
22
33
  applied?: boolean;
23
34
  };
24
35
 
36
+ /** Props for {@link AgentSteeringPanel}: the task header, the steps, queued notes and the steer handlers. */
25
37
  export type AgentSteeringPanelProps = Omit<ComponentProps<"section">, "className" | "title"> & {
38
+ /** Task name in the header, truncated to one line. */
26
39
  title: ReactNode;
40
+ /** Second header line, such as the bot or workspace, truncated to one line. */
27
41
  subtitle?: ReactNode;
28
42
  /** The status text beside the live dot, such as "Running · 2m 10s". */
29
43
  status?: ReactNode;
44
+ /** Pauses the live dot pulse and swaps the button to Play; the panel does not stop work on its own. @default false */
30
45
  paused?: boolean;
46
+ /** Fires when the pause/resume button is pressed, with the next paused value. */
31
47
  onPausedChange?: (paused: boolean) => void;
48
+ /** Ordered steps of the running task; the connector under a done step turns live green. */
32
49
  steps: SteeringStep[];
50
+ /** Notes queued for the next step, plus any already applied. @default [] */
33
51
  notes?: SteeringNote[];
52
+ /** Fires with the note id when its "Now" button is pressed, to apply it immediately. */
34
53
  onApplyNow?: (id: string) => void;
54
+ /** Fires with the note id after its pop-out animation ends; remove it from `notes` here. */
35
55
  onRemoveNote?: (id: string) => void;
36
- /** Called with the text typed in the steer box: Enter queues it for the next step, ⌘Enter interrupts now. */
56
+ /** Called with the text typed in the steer box: Enter queues it for the next step, Mod+Enter interrupts now. */
37
57
  onSteer?: (text: string, interrupt: boolean) => void;
58
+ /** Placeholder of the steer input; also its accessible name. @default "Steer the bot…" */
38
59
  placeholder?: string;
60
+ /** Extra classes merged onto the panel, for margins or a width override. */
39
61
  className?: string;
40
62
  };
41
63
 
42
64
  const noNotes: SteeringNote[] = [];
43
- type IndexStyle = CSSProperties & { "--i": number };
65
+ type IndexStyle = CSSProperties & {
66
+ /** Step index, consumed by the staggered `motion-rise` delay. */
67
+ "--i": number;
68
+ };
44
69
  const indexStyle = (i: number): IndexStyle => ({ "--i": i });
45
70
 
46
- function Marker({ status }: { status: SteeringStepStatus }) {
71
+ /** Marker for one step: green check when done, spinner when active, dashed ring while waiting. */
72
+ // prettier-ignore
73
+ function Marker({ status }: {
74
+ /** Progress state, also used as the marker's React key so the pop animation replays on change. */
75
+ status: SteeringStepStatus;
76
+ }) {
47
77
  if (status === "done") {
48
78
  return (
49
79
  <span className="flex size-4.5 animate-pop-in items-center justify-center rounded-full bg-status-live text-text-on-ink">
@@ -55,13 +85,17 @@ function Marker({ status }: { status: SteeringStepStatus }) {
55
85
  return <span aria-label="Waiting" className="size-4.5 rounded-full border border-dashed border-border-dashed" />;
56
86
  }
57
87
 
88
+ /** One queued note: the text, a "Now" action and a remove button, or a filled "Now" pill once applied. */
58
89
  function SteeringNoteRow({
59
90
  note,
60
91
  onApplyNow,
61
92
  onRemoveNote,
62
93
  }: {
94
+ /** The note to render; `applied` swaps the actions for the filled pill. */
63
95
  note: SteeringNote;
96
+ /** Called with the note id when "Now" is pressed. */
64
97
  onApplyNow?: (id: string) => void;
98
+ /** Called with the note id when the pop-out animation finishes, so the parent can drop it. */
65
99
  onRemoveNote?: (id: string) => void;
66
100
  }) {
67
101
  const [leaving, setLeaving] = useState(false);
@@ -118,6 +152,25 @@ function SteeringNoteRow({
118
152
  * Paper `Agent steering` (Y51-1): a 24px-radius surface panel with the running task header, a step rail, the notes
119
153
  * queued for the next step, and a steer box. The live dot pulses every 2s, a step marker swaps with a short pop,
120
154
  * new steps rise in 220ms, and send and pause press in 150ms.
155
+ *
156
+ * The steer draft is local state: it clears after `onSteer` fires, and the panel never queues a note itself.
157
+ * Enter queues the text for the next step and Mod+Enter sends it as an interrupt — ⌘Enter on macOS and iOS,
158
+ * Ctrl+Enter on Windows, Linux and Android. The two hint chips draw the same OS keys through `Kbd` and are
159
+ * hidden on touch devices that show no keyboard hints. IME composition keystrokes are ignored so Korean and
160
+ * Japanese input do not submit mid-character.
161
+ *
162
+ * @example
163
+ * ```tsx
164
+ * <AgentSteeringPanel
165
+ * title="Quarterly revenue review"
166
+ * status="Running · 2m 10s"
167
+ * steps={[
168
+ * { id: "read", label: "Read the sheet", status: "done", time: "1.4s" },
169
+ * { id: "summarise", label: "Summarise the rows", status: "active" },
170
+ * ]}
171
+ * onSteer={(text, interrupt) => steer(text, interrupt)}
172
+ * />
173
+ * ```
121
174
  */
122
175
  export function AgentSteeringPanel({
123
176
  title,
@@ -135,6 +188,8 @@ export function AgentSteeringPanel({
135
188
  ...props
136
189
  }: AgentSteeringPanelProps) {
137
190
  const [draft, setDraft] = useState("");
191
+ const { os } = usePlatform();
192
+ const { keyboardHints } = useDevice();
138
193
  const queued = notes.filter((note) => !note.applied);
139
194
  const submit = (interrupt: boolean) => {
140
195
  const text = draft.trim();
@@ -236,7 +291,7 @@ export function AgentSteeringPanel({
236
291
  if (event.nativeEvent.isComposing || event.keyCode === 229) return;
237
292
  if (event.key === "Enter") {
238
293
  event.preventDefault();
239
- submit(event.metaKey || event.ctrlKey);
294
+ submit(isModKey(event, os));
240
295
  }
241
296
  }}
242
297
  placeholder={placeholder}
@@ -253,21 +308,18 @@ export function AgentSteeringPanel({
253
308
  </button>
254
309
  </div>
255
310
 
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>
311
+ {keyboardHints && (
312
+ <div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-caption text-text-secondary">
313
+ <span className="flex items-center gap-1.25">
314
+ <Kbd keys="Enter" />
315
+ next step
316
+ </span>
317
+ <span className="flex items-center gap-1.25">
318
+ <Kbd keys="Mod+Enter" />
319
+ interrupt now
320
+ </span>
321
+ </div>
322
+ )}
271
323
  </div>
272
324
  </section>
273
325
  );
@@ -5,12 +5,16 @@ import { cn } from "../lib/cn";
5
5
  import { Button } from "./button";
6
6
  import { LiquidGlass } from "./liquid-glass";
7
7
 
8
+ /** Lifecycle of the request: `pending` keeps the brand rim and both actions, the other states hide the actions. */
8
9
  export type ApprovalState = "pending" | "approved" | "declined";
10
+ /** `solid` paints the raised surface, `glass` layers the glass material for use over imagery. */
9
11
  export type ApprovalCardSurface = "solid" | "glass";
10
12
 
13
+ /** Props for {@link ApprovalCard}: the state, the question, the optional attachment and the action handlers. */
11
14
  export type ApprovalCardProps = {
15
+ /** Lifecycle state; drives the status dot, status text and whether the actions stay visible. @default "pending" */
12
16
  state?: ApprovalState;
13
- /** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
17
+ /** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. @default "solid" */
14
18
  surface?: ApprovalCardSurface;
15
19
  /** Status line, defaults to "Needs your yes", "Approved" or "Declined". */
16
20
  status?: ReactNode;
@@ -18,14 +22,29 @@ export type ApprovalCardProps = {
18
22
  title: ReactNode;
19
23
  /** Name of the attached item. */
20
24
  attachment?: ReactNode;
25
+ /** Fires when the primary action is pressed; the card does not change its own state. */
21
26
  onApprove?: () => void;
27
+ /** Fires when the secondary action is pressed, for deferring without deciding. */
22
28
  onLater?: () => void;
29
+ /** Label of the primary button. @default "Approve" */
23
30
  approveLabel?: string;
31
+ /** Label of the secondary button. @default "Later" */
24
32
  laterLabel?: string;
33
+ /** Extra classes merged onto the card, for margins or a width override. */
25
34
  className?: string;
26
35
  };
27
36
 
28
- const statusClass: Record<ApprovalState, { dot: string; text: string; label: string }> = {
37
+ const statusClass: Record<
38
+ ApprovalState,
39
+ {
40
+ /** Background of the status dot. */
41
+ dot: string;
42
+ /** Text colour of the status line. */
43
+ text: string;
44
+ /** Default status line when `status` is omitted. */
45
+ label: string;
46
+ }
47
+ > = {
29
48
  pending: { dot: "bg-status-live", text: "text-text-brand", label: "Needs your yes" },
30
49
  approved: { dot: "bg-text-secondary", text: "text-text-secondary", label: "Approved" },
31
50
  declined: { dot: "bg-accent-red-500-90", text: "text-text-danger", label: "Declined" },
@@ -34,6 +53,19 @@ const statusClass: Record<ApprovalState, { dot: string; text: string; label: str
34
53
  /**
35
54
  * Figma `Approval card` (64:398): the conversation part that asks for an action.
36
55
  * Pending keeps the brand rim and the actions.
56
+ *
57
+ * Controlled by `state`: the card never flips itself, and once it leaves `pending` the actions collapse and
58
+ * become inert, so a settled request cannot be answered twice.
59
+ *
60
+ * @example
61
+ * ```tsx
62
+ * <ApprovalCard
63
+ * title="Send the Q3 board deck to the team?"
64
+ * attachment="Q3-board-deck.pdf"
65
+ * onApprove={send}
66
+ * onLater={dismiss}
67
+ * />
68
+ * ```
37
69
  */
38
70
  export function ApprovalCard({
39
71
  state = "pending",
@@ -4,26 +4,32 @@ import type { ReactNode } from "react";
4
4
 
5
5
  import { cn } from "../lib/cn";
6
6
 
7
+ /** Diameter of the avatar: `sm` 24px, `md` 32px, `lg` 40px, `xl` 56px. */
7
8
  export type AvatarSize = "sm" | "md" | "lg" | "xl";
8
9
 
10
+ /** Props for {@link Avatar}: the avatar type and size, the image source, fallback content and the live dot. */
9
11
  export type AvatarProps = {
10
- /** `initials` (default), `bot` (robot on the brand tint) or `image`. */
12
+ /** `initials` (default), `bot` (robot on the brand tint) or `image`. @default "initials" */
11
13
  type?: "initials" | "bot" | "image";
14
+ /** Diameter token; also scales the initials, the bot icon and the live dot. @default "md" */
12
15
  size?: AvatarSize;
13
16
  /** Image URL for `type="image"`; initials show while it loads or if it fails. */
14
17
  src?: string;
18
+ /** Alternative text for `type="image"`; pass `""` when the name is already shown next to the avatar. @default "" */
15
19
  alt?: string;
16
20
  /** Initials, or any fallback content. */
17
21
  children?: ReactNode;
18
- /** Shows the live status dot at the bottom right. */
22
+ /** Shows the live status dot at the bottom right. @default false */
19
23
  live?: boolean;
20
- /** Initials colour: `neutral` (default) or `sky`, the Paper account profile avatar (sky-100 fill, sky-700 text). */
24
+ /** Initials colour: `neutral` (default) or `sky`, the Paper account profile avatar (sky-100 fill, sky-700 text). @default "neutral" */
21
25
  tone?: "neutral" | "sky";
22
26
  /**
23
27
  * Border colour of the live dot, so it matches the surface behind it (glass panels, `bg-subtle`, brand tints).
24
28
  * Defaults to `border-bg-surface`.
29
+ * @default "border-bg-surface"
25
30
  */
26
31
  liveRingClassName?: string;
32
+ /** Extra classes merged onto the wrapper, for positioning or a custom size override. */
27
33
  className?: string;
28
34
  };
29
35
 
@@ -55,7 +61,17 @@ const typeClass = {
55
61
  image: "bg-sky-200 text-text-secondary",
56
62
  } as const;
57
63
 
58
- /** Figma `Avatar` (61:347): Initials, Bot and Image in four sizes, with an optional live dot. */
64
+ /**
65
+ * Figma `Avatar` (61:347): Initials, Bot and Image in four sizes, with an optional live dot. Built on Base UI's
66
+ * avatar primitive, so an image that fails to load falls back to the children instead of a broken icon.
67
+ *
68
+ * @example
69
+ * ```tsx
70
+ * <Avatar type="image" size="lg" src={user.photoUrl} alt="" live>
71
+ * {initials}
72
+ * </Avatar>
73
+ * ```
74
+ */
59
75
  export function Avatar({
60
76
  type = "initials",
61
77
  size = "md",
@@ -3,16 +3,27 @@ import type { ComponentProps } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import type { ControlSize } from "../lib/size";
5
5
 
6
- /** `surface` is the white (raised surface) pill with a 1px default border and primary text. */
6
+ /** Tint of the pill and its dot; `neutral` is the default.
7
+ *
8
+ * - `neutral`: 6% ink pill with secondary text, the default.
9
+ * - `brand`: sky-tinted pill with brand text, for counts and states tied to the brand.
10
+ * - `danger`: red-tinted pill with danger text, for errors and destructive states.
11
+ * - `solid`: filled brand pill with on-primary text, for the strongest emphasis.
12
+ * - `surface`: the white (raised surface) pill with a 1px default border and primary text.
13
+ */
7
14
  export type BadgeTone = "neutral" | "brand" | "danger" | "solid" | "surface";
15
+ /** Size token shared with the form controls (`sm`, `md`, `lg`); the rendered heights are documented on {@link Badge}. */
8
16
  export type BadgeSize = ControlSize;
9
17
 
18
+ /** Props for {@link Badge}: the tone, size, optional status dot and extra span props. */
10
19
  export type BadgeProps = Omit<ComponentProps<"span">, "className"> & {
20
+ /** Colour treatment of the pill and its dot. @default "neutral" */
11
21
  tone?: BadgeTone;
12
22
  /** Leading status dot; 4px (sm), 6px (md) or 8px (lg). */
13
23
  dot?: boolean;
14
- /** `md` is the 22px Figma Badge; `sm` 20px and `lg` 26px. */
24
+ /** `md` is the 22px Figma Badge; `sm` 20px and `lg` 26px. @default "md" */
15
25
  size?: BadgeSize;
26
+ /** Extra classes merged onto the pill, for margins or alignment. */
16
27
  className?: string;
17
28
  };
18
29
 
@@ -44,7 +55,17 @@ const dotClass: Record<BadgeTone, string> = {
44
55
  surface: "bg-text-primary",
45
56
  };
46
57
 
47
- /** Figma `Badge` (61:283): a 22px translucent pill with label-sm text. */
58
+ /**
59
+ * Figma `Badge` (61:283): a 22px translucent pill with label-sm text. Renders a non-interactive `span`, so it can
60
+ * sit inside buttons, list rows and headings without changing their layout height.
61
+ *
62
+ * @example
63
+ * ```tsx
64
+ * <Badge tone="brand" dot>
65
+ * Running
66
+ * </Badge>
67
+ * ```
68
+ */
48
69
  export function Badge({ tone = "neutral", dot, size = "md", className, children, ...props }: BadgeProps) {
49
70
  return (
50
71
  <span
@@ -5,18 +5,24 @@ import { cn } from "../lib/cn";
5
5
  import type { ControlSize } from "../lib/size";
6
6
  import { LiquidGlass } from "./liquid-glass";
7
7
 
8
+ /** Severity of the strip: `info` tints the rim and icon brand, `danger` uses the danger rim and role="alert". */
8
9
  export type BannerTone = "info" | "danger";
10
+ /** Size token shared with the form controls; the rendered heights are documented on {@link Banner}. */
9
11
  export type BannerSize = ControlSize;
12
+ /** `solid` paints the raised surface, `glass` layers the glass material for use over imagery. */
10
13
  export type BannerSurface = "solid" | "glass";
11
14
 
15
+ /** Props for {@link Banner}: the tone, size and surface of the strip plus its optional trailing action. */
12
16
  export type BannerProps = Omit<ComponentProps<"div">, "className"> & {
17
+ /** Severity; also picks the icon and the ARIA role. @default "info" */
13
18
  tone?: BannerTone;
14
19
  /** Trailing underlined action, such as a link or button. */
15
20
  action?: ReactNode;
16
- /** Banner size: `sm` 32px minimum, `md` the Figma 48px strip (default), `lg` 56px; scales icon, text and padding. */
21
+ /** Banner size: `sm` 32px minimum, `md` the Figma 48px strip (default), `lg` 56px; scales icon, text and padding. @default "md" */
17
22
  size?: BannerSize;
18
- /** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. */
23
+ /** `solid` (default) on raised surfaces, `glass` over imagery and glass panels. @default "solid" */
19
24
  surface?: BannerSurface;
25
+ /** Extra classes merged onto the strip, for margins or width overrides. */
20
26
  className?: string;
21
27
  };
22
28
 
@@ -59,7 +65,17 @@ const solidEdgeClass: Record<BannerTone, string> = {
59
65
  danger: "border-border-danger",
60
66
  };
61
67
 
62
- /** Figma `Banner` (62:318): a strip with a tinted rim, 16px icon and an underlined action. */
68
+ /**
69
+ * Figma `Banner` (62:318): a strip with a tinted rim, 16px icon and an underlined action. The tone sets the ARIA
70
+ * role — `danger` renders `role="alert"` (announced immediately), `info` renders `role="status"`.
71
+ *
72
+ * @example
73
+ * ```tsx
74
+ * <Banner tone="danger" action={<a href="/settings">Review</a>}>
75
+ * Your API key expired.
76
+ * </Banner>
77
+ * ```
78
+ */
63
79
  export function Banner({
64
80
  tone = "info",
65
81
  action,
@@ -2,23 +2,54 @@ import { CaretDown, Play } from "@phosphor-icons/react";
2
2
  import { type ComponentProps, type KeyboardEvent, type ReactNode, useRef, useState } from "react";
3
3
 
4
4
  import { cn } from "../lib/cn";
5
- import { useActiveIndicator } from "../lib/use-active-indicator";
5
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
6
+ import { useDevice } from "../hooks/use-device";
6
7
  import { Kbd } from "./kbd";
7
8
 
8
- type WithClass<T> = Omit<T, "className"> & { className?: string };
9
+ /** Drops the base type's `className` so the panel can own it. */
10
+ type WithClass<T> = Omit<T, "className"> & {
11
+ /** Extra classes merged onto the root element. */
12
+ className?: string;
13
+ };
9
14
 
15
+ /** Props for {@link PanelPillTabs}: the tab items, the controlled or default selection and the change handler. */
10
16
  export type PanelPillTabsProps = {
11
- items: { value: string; label: ReactNode }[];
17
+ /** Tabs in display order; the first one is the default selection. */
18
+ items: {
19
+ /** Value handed to `onValueChange` and compared against `value`. */
20
+ value: string;
21
+ /** Tab label. */
22
+ label: ReactNode;
23
+ }[];
24
+ /** Controlled selected value; omit it to let the track keep its own selection. */
12
25
  value?: string;
26
+ /** Selection used before the first click, falling back to the first item. */
13
27
  defaultValue?: string;
28
+ /** Fires with the next value on click or arrow-key navigation. */
14
29
  onValueChange?: (value: string) => void;
30
+ /** Accessible name of the tab list. */
15
31
  "aria-label"?: string;
32
+ /** Extra classes merged onto the track, for width overrides. */
16
33
  className?: string;
17
34
  };
18
35
 
19
36
  /**
20
- * The 5% ink pill track shared by the Bot panels (Paper `YNQ-1`): 3px padding, 2px gap, 28px options with
37
+ * The 5% ink pill track shared by the Bot panels (Paper `YNQ-1`): 3px padding, 2px gap, 30px options with
21
38
  * 12px inset and a 10% ink selection that slides between options in 280ms.
39
+ *
40
+ * Works controlled (`value`) or uncontrolled (`defaultValue`); the arrow keys wrap around, move the selection
41
+ * and focus the newly selected tab, while only the selected tab stays in the tab order.
42
+ *
43
+ * @example
44
+ * ```tsx
45
+ * <PanelPillTabs
46
+ * aria-label="Instructions view"
47
+ * items={[
48
+ * { value: "instructions", label: "Instructions" },
49
+ * { value: "example", label: "Example" },
50
+ * ]}
51
+ * />
52
+ * ```
22
53
  */
23
54
  export function PanelPillTabs({ items, value, defaultValue, onValueChange, className, ...props }: PanelPillTabsProps) {
24
55
  const [inner, setInner] = useState(defaultValue ?? items[0]?.value ?? "");
@@ -81,6 +112,7 @@ export function PanelPillTabs({ items, value, defaultValue, onValueChange, class
81
112
  );
82
113
  }
83
114
 
115
+ /** Chip tint: `sky` the default light blue, `brand` the deeper sky-700, `neutral` grey. */
84
116
  export type InstructionVariableTone = "sky" | "brand" | "neutral";
85
117
 
86
118
  const variableToneClass: Record<InstructionVariableTone, string> = {
@@ -96,26 +128,53 @@ const variableDotClass: Record<InstructionVariableTone, string> = {
96
128
  };
97
129
 
98
130
  /** One piece of an instruction line: plain text, or a `{{variable}}` chip. */
99
- export type InstructionPart = string | { variable: string; tone?: InstructionVariableTone };
131
+ export type InstructionPart =
132
+ | string
133
+ | {
134
+ /** Variable name, rendered inside the chip without the braces. */
135
+ variable: string;
136
+ /** Chip tint. @default "sky" */
137
+ tone?: InstructionVariableTone;
138
+ };
100
139
 
101
- export type InstructionVariable = { name: string; tone?: InstructionVariableTone; value: string };
140
+ /** A test value for one `{{variable}}` in the prompt, shown under the instructions. */
141
+ export type InstructionVariable = {
142
+ /** Variable name shown above the test value. */
143
+ name: string;
144
+ /** Dot tint matching the chip in the prompt. @default "sky" */
145
+ tone?: InstructionVariableTone;
146
+ /** Test value used when the instruction is run. */
147
+ value: string;
148
+ };
102
149
 
150
+ /** Props for {@link BotInstructionsPanel}: the bot, the prompt lines, the optional example, variables and scenarios, and the run handler. */
103
151
  export type BotInstructionsPanelProps = WithClass<Omit<ComponentProps<"section">, "title">> & {
152
+ /** Panel heading. @default "Instructions" */
104
153
  title?: ReactNode;
105
154
  /** Version label in the header pill, for example `v4`. */
106
155
  version?: string;
107
156
  /** Status next to the version, rendered with a live dot. */
108
157
  versionStatus?: string;
109
- bot: { initial: string; name: string };
110
- /** The editable prompt, one array of parts per line. */
158
+ /** Bot whose prompt is shown; the initial fills the 18px tile. */
159
+ bot: {
160
+ /** One-character tile label. */
161
+ initial: string;
162
+ /** Bot name next to the tile. */
163
+ name: string;
164
+ };
165
+ /** The prompt text, one array of parts per line, rendered read-only. */
111
166
  instructions: InstructionPart[][];
112
167
  /** Shown under the Example tab instead of the prompt. */
113
168
  example?: ReactNode;
169
+ /** Prompt length shown at the right of the tab row; hidden when undefined. */
114
170
  tokenCount?: number;
115
171
  /** Test values for each variable in the prompt. */
116
172
  variables?: InstructionVariable[];
173
+ /** Test scenarios offered as pills under the prompt. */
117
174
  scenarios?: string[];
175
+ /** Scenario selected on first render, falling back to the first scenario. */
118
176
  defaultScenario?: string;
177
+ /** Fires with the selected scenario (or undefined) when Run is pressed; the panel does not run anything itself. */
119
178
  onRun?: (scenario: string | undefined) => void;
120
179
  /** The last run's output, or a hint until there is one. */
121
180
  output?: ReactNode;
@@ -129,13 +188,17 @@ function partKey(part: InstructionPart) {
129
188
  return typeof part === "string" ? part : part.variable;
130
189
  }
131
190
 
191
+ /** Inline rounded chip that stands in for a `{{variable}}` inside a prompt line. */
132
192
  function VariableChip({
133
193
  tone = "sky",
134
194
  className,
135
195
  children,
136
196
  }: {
197
+ /** Chip tint. @default "sky" */
137
198
  tone?: InstructionVariableTone;
199
+ /** Extra classes merged onto the chip. */
138
200
  className?: string;
201
+ /** Variable name shown without braces. */
139
202
  children: ReactNode;
140
203
  }) {
141
204
  return (
@@ -152,6 +215,23 @@ function Divider() {
152
215
  /**
153
216
  * Bot instructions panel (Paper `YN3-1` / `GOZ-0`): a 24px-radius surface with the bot header, an
154
217
  * Instructions / Example tab pair over the prompt text with `{{variable}}` chips, and a test-run block.
218
+ *
219
+ * The tab and the selected scenario are local state; the panel only reports a run through `onRun`. Output is
220
+ * re-keyed per run so it animates in every time. A caret blinks at the end of the prompt while the prompt area
221
+ * has focus. The Run button carries a `Mod+Enter` chip — ⌘↵ on macOS and iOS, Ctrl+Enter elsewhere — which is
222
+ * hidden on touch devices that show no keyboard hints.
223
+ *
224
+ * @example
225
+ * ```tsx
226
+ * <BotInstructionsPanel
227
+ * version="v4"
228
+ * bot={{ initial: "R", name: "Researcher" }}
229
+ * instructions={[["Summarise ", { variable: "sheet" }, " for the team."]]}
230
+ * variables={[{ name: "sheet", value: "Q3 metrics" }]}
231
+ * scenarios={["Weekly digest", "Board update"]}
232
+ * onRun={(scenario) => run(scenario)}
233
+ * />
234
+ * ```
155
235
  */
156
236
  export function BotInstructionsPanel({
157
237
  title = "Instructions",
@@ -174,6 +254,7 @@ export function BotInstructionsPanel({
174
254
  const [tab, setTab] = useState("instructions");
175
255
  const [scenario, setScenario] = useState(defaultScenario ?? scenarios?.[0]);
176
256
  const [runCount, setRunCount] = useState(0);
257
+ const { keyboardHints } = useDevice();
177
258
 
178
259
  return (
179
260
  <section
@@ -342,7 +423,9 @@ export function BotInstructionsPanel({
342
423
  >
343
424
  <Play aria-hidden className="size-3.5" />
344
425
  Run
345
- <Kbd className="bg-bg-surface-6 text-text-inverse/80">⌘↵</Kbd>
426
+ {keyboardHints && (
427
+ <Kbd keys="Mod+Enter" className="bg-bg-surface-6 text-text-inverse/80" />
428
+ )}
346
429
  </button>
347
430
  </div>
348
431
  </div>
@@ -3,7 +3,9 @@ import type { ComponentProps, ReactNode } from "react";
3
3
  import { cn } from "../lib/cn";
4
4
  import { Avatar } from "./avatar";
5
5
 
6
+ /** Props for {@link BotListRow}: the bot name, last-activity time and preview, the live and selected states. */
6
7
  export type BotListRowProps = Omit<ComponentProps<"button">, "className"> & {
8
+ /** Bot display name, truncated with an ellipsis when it does not fit. */
7
9
  name: ReactNode;
8
10
  /** Last activity time, such as "8:41 AM". */
9
11
  time?: ReactNode;
@@ -11,11 +13,21 @@ export type BotListRowProps = Omit<ComponentProps<"button">, "className"> & {
11
13
  preview?: ReactNode;
12
14
  /** Running bots show the live dot on the avatar. */
13
15
  live?: boolean;
16
+ /** The row for the bot currently open in the panel; paints the 10% ink selected fill and sets `aria-pressed`. */
14
17
  selected?: boolean;
18
+ /** Extra classes merged onto the button, for margins or width overrides. */
15
19
  className?: string;
16
20
  };
17
21
 
18
- /** Figma `Bot list row` (64:456): a flat 12px-padded row; hover 5% ink, selected 10% ink. */
22
+ /**
23
+ * Figma `Bot list row` (64:456): a flat 12px-padded row; hover 5% ink, selected 10% ink. The whole row is the
24
+ * button, so the accessible name comes from its text content (name, time and preview).
25
+ *
26
+ * @example
27
+ * ```tsx
28
+ * <BotListRow name="Researcher" time="8:41 AM" preview="Pulled the Q3 numbers" live selected />
29
+ * ```
30
+ */
19
31
  export function BotListRow({ name, time, preview, live, selected, className, ...props }: BotListRowProps) {
20
32
  return (
21
33
  <button