@horon-ai/react 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (102) 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 +204 -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 +125 -0
  8. package/src/components/agent-steering-panel.tsx +276 -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 +369 -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 +206 -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 +388 -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 +42 -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 +146 -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 +215 -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/hooks/index.ts +8 -0
  89. package/src/hooks/use-active-indicator.ts +60 -0
  90. package/src/hooks/use-color-scheme.ts +27 -0
  91. package/src/hooks/use-copy-to-clipboard.ts +32 -0
  92. package/src/hooks/use-device.ts +41 -0
  93. package/src/hooks/use-hotkey.ts +35 -0
  94. package/src/hooks/use-media-query.ts +25 -0
  95. package/src/hooks/use-platform.ts +39 -0
  96. package/src/hooks/use-reduced-motion.ts +8 -0
  97. package/src/index.ts +99 -0
  98. package/src/lib/cn.ts +25 -0
  99. package/src/lib/cursor-ghost.ts +244 -0
  100. package/src/lib/platform.ts +169 -0
  101. package/src/lib/size.ts +10 -0
  102. package/src/styles/globals.css +564 -0
@@ -0,0 +1,61 @@
1
+ import { Button as BaseButton } from "@base-ui/react/button";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+ import { LiquidGlass } from "./liquid-glass";
7
+
8
+ export type IconButtonVariant = "primary" | "ghost" | "glass" | "solid" | "quiet";
9
+ export type IconButtonSize = ControlSize;
10
+
11
+ export type IconButtonProps = Omit<ComponentProps<typeof BaseButton>, "className" | "children"> & {
12
+ variant?: IconButtonVariant;
13
+ size?: IconButtonSize;
14
+ icon: ReactNode;
15
+ /** Accessible name; the button has no visible text. */
16
+ label: string;
17
+ className?: string;
18
+ };
19
+
20
+ const sizeClass: Record<IconButtonSize, string> = {
21
+ sm: "size-8 [&_svg]:size-4",
22
+ md: "size-10 [&_svg]:size-5",
23
+ lg: "size-12 [&_svg]:size-6",
24
+ };
25
+
26
+ const variantClass: Record<IconButtonVariant, string> = {
27
+ primary:
28
+ "bg-interactive-primary text-interactive-on-primary hover:not-data-disabled:bg-interactive-primary-hover active:not-data-disabled:bg-interactive-primary-pressed",
29
+ ghost: "text-text-primary hover:not-data-disabled:bg-overlay-ink-6 active:not-data-disabled:bg-overlay-ink-10",
30
+ glass:
31
+ "overflow-clip bg-glass-clear text-text-primary hover:not-data-disabled:bg-glass-clear-hover active:not-data-disabled:bg-glass-clear-pressed",
32
+ solid:
33
+ "border border-border-default bg-bg-surface text-text-primary hover:not-data-disabled:bg-bg-subtle active:not-data-disabled:bg-interactive-secondary-hover",
34
+ // No fill and no liquid layer: only the ink wash on hover/press. A glass disc is wrong on a dark
35
+ // shell or title bar, where `glass` reads as a grey bubble; this keeps the icon legible without one.
36
+ quiet:
37
+ "text-text-secondary hover:not-data-disabled:bg-overlay-ink-6 hover:not-data-disabled:text-text-primary active:not-data-disabled:bg-overlay-ink-10",
38
+ };
39
+
40
+ /** Figma `Icon button` (57:167): Primary, Ghost, Glass and Solid in three sizes, plus a fill-free `quiet` variant. */
41
+ export function IconButton({ variant = "ghost", size = "md", icon, label, className, ...props }: IconButtonProps) {
42
+ return (
43
+ <BaseButton
44
+ aria-label={label}
45
+ className={cn(
46
+ "relative inline-flex shrink-0 motion-press cursor-pointer items-center justify-center rounded-full select-none",
47
+ // Paper draws icon buttons down to 32px. The painted hit area keeps the 44px reach
48
+ // without moving a single pixel of the visible control.
49
+ "relative 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-['']",
50
+ "focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand data-disabled:cursor-not-allowed data-disabled:opacity-40",
51
+ sizeClass[size],
52
+ variantClass[variant],
53
+ className,
54
+ )}
55
+ {...props}
56
+ >
57
+ {variant === "glass" && <LiquidGlass />}
58
+ <span className="relative flex">{icon}</span>
59
+ </BaseButton>
60
+ );
61
+ }
@@ -0,0 +1,112 @@
1
+ import { Input as BaseInput } from "@base-ui/react/input";
2
+ import { createContext, useContext, useMemo, type ComponentProps, type ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+ import { fieldBoxClass, fieldTextClass, type InputSurface } from "./input";
7
+
8
+ export type InputGroupSize = ControlSize;
9
+
10
+ export type InputGroupProps = Omit<ComponentProps<typeof BaseInput>, "className" | "prefix" | "size"> & {
11
+ /**
12
+ * Segment before the input: `<Select variant="segment" />` or `<Combobox variant="segment" />`
13
+ * for a choice such as a country code, or `<InputGroupSegment>` for fixed text.
14
+ */
15
+ prefix?: ReactNode;
16
+ /** Segment after the input, such as a currency select. */
17
+ suffix?: ReactNode;
18
+ size?: InputGroupSize;
19
+ invalid?: boolean;
20
+ /** Solid (default) or glass, as on `Input`. */
21
+ surface?: InputSurface;
22
+ className?: string;
23
+ };
24
+
25
+ type InputGroupContextValue = { size: InputGroupSize; disabled: boolean; surface: InputSurface };
26
+
27
+ /** Lets a segment (select, combobox or fixed text) take the group's size, disabled state and surface. */
28
+ const InputGroupContext = createContext<InputGroupContextValue | null>(null);
29
+
30
+ /** The group a segment sits in, or null outside an `InputGroup`. */
31
+ export function useInputGroup() {
32
+ return useContext(InputGroupContext);
33
+ }
34
+
35
+ const sizeClass: Record<InputGroupSize, string> = {
36
+ sm: "h-8",
37
+ md: "h-10",
38
+ lg: "h-12",
39
+ };
40
+
41
+ /** The input's own padding, the same 12/14/20px as a standalone `Input`. */
42
+ const inputPaddingClass: Record<InputGroupSize, string> = {
43
+ sm: "px-3",
44
+ md: "px-3.5",
45
+ lg: "px-5",
46
+ };
47
+
48
+ /** The tinted segment look, shared by `InputGroupSegment` and the `segment` variant of `Select` and `Combobox`. */
49
+ export const inputGroupSegmentClass: Record<InputGroupSize, string> = {
50
+ sm: "relative flex h-full shrink-0 items-center gap-1 bg-overlay-ink-4 px-3 text-body-sm text-text-primary [&_svg]:size-4 [&_svg]:text-text-secondary",
51
+ md: "relative flex h-full shrink-0 items-center gap-1 bg-overlay-ink-4 px-3 text-body-md text-text-primary [&_svg]:size-4 [&_svg]:text-text-secondary",
52
+ lg: "relative flex h-full shrink-0 items-center gap-1.5 bg-overlay-ink-4 px-4 text-body-lg text-text-primary [&_svg]:size-5 [&_svg]:text-text-secondary",
53
+ };
54
+
55
+ const divider =
56
+ "relative h-full w-px shrink-0 bg-border-default group-data-invalid:bg-border-danger group-data-disabled:bg-border-default/60";
57
+
58
+ /** Figma `Input group` (60:429): a glass or solid field split into a select segment and an input segment, in three sizes. */
59
+ export function InputGroup({
60
+ prefix,
61
+ suffix,
62
+ size = "md",
63
+ invalid,
64
+ surface = "solid",
65
+ className,
66
+ disabled = false,
67
+ ...props
68
+ }: InputGroupProps) {
69
+ const context = useMemo(() => ({ size, disabled, surface }), [size, disabled, surface]);
70
+ return (
71
+ <InputGroupContext value={context}>
72
+ <div
73
+ className={cn(fieldBoxClass.base, "group", sizeClass[size], fieldBoxClass[surface], className)}
74
+ data-invalid={invalid ? "" : undefined}
75
+ data-disabled={disabled ? "" : undefined}
76
+ >
77
+ {prefix && (
78
+ <>
79
+ {prefix}
80
+ <span aria-hidden className={divider} />
81
+ </>
82
+ )}
83
+ <div className={cn("relative flex h-full min-w-0 flex-1 items-center", inputPaddingClass[size])}>
84
+ <BaseInput
85
+ disabled={disabled}
86
+ aria-invalid={invalid || undefined}
87
+ className={cn(fieldBoxClass.text, fieldTextClass[size])}
88
+ {...props}
89
+ />
90
+ </div>
91
+ {suffix && (
92
+ <>
93
+ <span aria-hidden className={divider} />
94
+ {suffix}
95
+ </>
96
+ )}
97
+ </div>
98
+ </InputGroupContext>
99
+ );
100
+ }
101
+
102
+ /** A fixed-text segment, such as a unit or a `https://` prefix. Takes the group's size. */
103
+ export function InputGroupSegment({ className, children }: { className?: string; children: ReactNode }) {
104
+ const group = useInputGroup();
105
+ return (
106
+ <div
107
+ className={cn(inputGroupSegmentClass[group?.size ?? "md"], "group-data-disabled:text-text-disabled", className)}
108
+ >
109
+ {children}
110
+ </div>
111
+ );
112
+ }
@@ -0,0 +1,72 @@
1
+ import { Input as BaseInput } from "@base-ui/react/input";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import type { ControlSize } from "../lib/size";
6
+
7
+ export type InputSize = ControlSize;
8
+ export type InputSurface = "glass" | "solid" | "solid-on-page";
9
+
10
+ export type InputProps = Omit<ComponentProps<typeof BaseInput>, "className" | "size"> & {
11
+ size?: InputSize;
12
+ /** `glass` over imagery and glass panels, `solid` on raised surfaces, `solid-on-page` directly on the page background. */
13
+ surface?: InputSurface;
14
+ /** Leading icon, 16px (sm, md) or 20px (lg), in the secondary text color. */
15
+ icon?: ReactNode;
16
+ /** Shows the error border. Inside a `Field`, an invalid field does the same. */
17
+ invalid?: boolean;
18
+ className?: string;
19
+ /** Classes for the native `<input>`. */
20
+ inputClassName?: string;
21
+ };
22
+
23
+ /** Height, horizontal padding and icon size shared by every field-style control. */
24
+ export const fieldSizeClass: Record<ControlSize, string> = {
25
+ sm: "h-8 gap-1.5 px-3 [&_svg]:size-4",
26
+ md: "h-10 gap-2 px-3.5 [&_svg]:size-4",
27
+ lg: "h-12 gap-2.5 px-5 [&_svg]:size-5",
28
+ };
29
+
30
+ /** Body text size that matches each control rung. */
31
+ export const fieldTextClass: Record<ControlSize, string> = {
32
+ sm: "text-body-sm",
33
+ md: "text-body-md",
34
+ lg: "text-body-lg",
35
+ };
36
+
37
+ export const fieldBoxClass = {
38
+ /** The box is the focus target's container, so the ring is drawn on `:focus-within`, not on the inner input. */
39
+ base: "motion-colors relative flex min-w-0 max-w-full items-center overflow-clip rounded-md border focus-within:outline-2 focus-within:outline-offset-2 focus-within:outline-text-brand",
40
+ /** The veil field (globals.css `.veil-field`): no liquid layers, so hosts render no `LiquidGlass`. */
41
+ glass: "veil-field has-[[data-disabled]]:opacity-60 has-[:disabled]:opacity-60",
42
+ solid:
43
+ "border-border-default bg-bg-surface has-[[data-disabled]]:border-border-default has-[:disabled]:border-border-default has-[[data-disabled]]:bg-bg-subtle has-[:disabled]:bg-bg-subtle has-[[data-invalid]]:border-border-danger data-invalid:border-border-danger",
44
+ "solid-on-page":
45
+ "border-border-default bg-bg-default has-[[data-disabled]]:border-border-default has-[:disabled]:border-border-default has-[[data-disabled]]:bg-bg-subtle has-[:disabled]:bg-bg-subtle has-[[data-invalid]]:border-border-danger data-invalid:border-border-danger",
46
+ text: "min-w-0 flex-1 bg-transparent text-text-primary outline-none placeholder:text-text-placeholder disabled:cursor-not-allowed disabled:text-text-disabled",
47
+ } as const;
48
+
49
+ /** Figma `Input` (58:126): SM, MD and LG, on Glass or Solid surface. */
50
+ export function Input({
51
+ size = "md",
52
+ surface = "solid",
53
+ icon,
54
+ invalid,
55
+ className,
56
+ inputClassName,
57
+ ...props
58
+ }: InputProps) {
59
+ return (
60
+ <div
61
+ className={cn(fieldBoxClass.base, fieldBoxClass[surface], fieldSizeClass[size], className)}
62
+ data-invalid={invalid ? "" : undefined}
63
+ >
64
+ {icon && <span className="relative flex shrink-0 text-text-secondary">{icon}</span>}
65
+ <BaseInput
66
+ aria-invalid={invalid || undefined}
67
+ className={cn("relative", fieldBoxClass.text, fieldTextClass[size], inputClassName)}
68
+ {...props}
69
+ />
70
+ </div>
71
+ );
72
+ }
@@ -0,0 +1,103 @@
1
+ import { Plug } from "@phosphor-icons/react";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { LiquidGlass } from "./liquid-glass";
6
+
7
+ export type IntegrationStatus = "connected" | "reconnect" | "not-connected";
8
+
9
+ export type IntegrationRowProps = {
10
+ /** 20px icon shown in inverse on the 40px tile. */
11
+ icon: ReactNode;
12
+ name: ReactNode;
13
+ /** Account and scopes, or the reason a reconnect is needed. */
14
+ detail?: ReactNode;
15
+ status: IntegrationStatus;
16
+ onAction?: () => void;
17
+ connectedLabel?: string;
18
+ reconnectLabel?: string;
19
+ connectLabel?: string;
20
+ /** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass row. */
21
+ surface?: "solid" | "glass";
22
+ className?: string;
23
+ };
24
+
25
+ const tileClass: Record<IntegrationStatus, string> = {
26
+ connected: "bg-sky-500 text-neutral-50",
27
+ reconnect: "bg-sky-700 text-neutral-50",
28
+ "not-connected": "bg-bg-subtle text-text-secondary",
29
+ };
30
+
31
+ /** Figma `Integration row` (Integrations 110:883): 40px tile, status pill on the right, solid or thick glass. */
32
+ export function IntegrationRow({
33
+ icon,
34
+ name,
35
+ detail,
36
+ status,
37
+ onAction,
38
+ connectedLabel = "Connected",
39
+ reconnectLabel = "Reconnect",
40
+ connectLabel = "Connect",
41
+ surface = "solid",
42
+ className,
43
+ }: IntegrationRowProps) {
44
+ return (
45
+ <div
46
+ className={cn(
47
+ "relative flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 overflow-clip rounded-2xl py-3 pr-3.5 pl-3",
48
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
49
+ status === "reconnect" &&
50
+ (surface === "glass" ? "[--glass-edge:var(--color-border-danger)]" : "border-border-danger"),
51
+ className,
52
+ )}
53
+ >
54
+ {surface === "glass" && <LiquidGlass />}
55
+ <span
56
+ className={cn(
57
+ "relative flex size-10 shrink-0 items-center justify-center rounded-[10px] motion-colors [&>svg]:size-5",
58
+ surface === "glass" && status === "not-connected"
59
+ ? "bg-overlay-ink-6 text-text-secondary"
60
+ : tileClass[status],
61
+ )}
62
+ >
63
+ {icon}
64
+ </span>
65
+ <span className="relative flex min-w-0 grow basis-40 flex-col gap-0.5">
66
+ <span className="truncate text-label-md text-text-primary">{name}</span>
67
+ {detail && (
68
+ <span
69
+ className={cn("truncate text-caption", status === "reconnect" ? "text-text-danger" : "text-text-secondary")}
70
+ >
71
+ {detail}
72
+ </span>
73
+ )}
74
+ </span>
75
+ <span className="motion-swap relative shrink-0">
76
+ <span
77
+ data-hidden={status !== "connected" || undefined}
78
+ aria-hidden={status !== "connected" || undefined}
79
+ className="flex h-7.5 items-center justify-center gap-1.5 rounded-full bg-accent-sky-500-18 px-3 text-label-sm whitespace-nowrap text-text-primary"
80
+ >
81
+ <span aria-hidden className="size-1.5 rounded-full bg-status-live" />
82
+ {connectedLabel}
83
+ </span>
84
+ <button
85
+ type="button"
86
+ onClick={onAction}
87
+ data-hidden={status === "connected" || undefined}
88
+ aria-hidden={status === "connected" || undefined}
89
+ tabIndex={status === "connected" ? -1 : undefined}
90
+ className={cn(
91
+ "flex h-7.5 motion-press cursor-pointer items-center justify-center gap-1.5 rounded-full px-3 text-label-sm whitespace-nowrap focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand",
92
+ status === "reconnect"
93
+ ? "bg-accent-red-500-14 text-text-danger"
94
+ : "bg-interactive-primary text-text-inverse",
95
+ )}
96
+ >
97
+ {status === "not-connected" && <Plug aria-hidden className="size-3.5" />}
98
+ {status === "reconnect" ? reconnectLabel : connectLabel}
99
+ </button>
100
+ </span>
101
+ </div>
102
+ );
103
+ }
@@ -0,0 +1,42 @@
1
+ import type { ComponentProps } from "react";
2
+
3
+ import { usePlatform } from "../hooks/use-platform";
4
+ import { cn } from "../lib/cn";
5
+ import { formatShortcut } from "../lib/platform";
6
+ import type { ControlSize } from "../lib/size";
7
+
8
+ export type KbdSize = ControlSize;
9
+
10
+ export type KbdProps = Omit<ComponentProps<"kbd">, "className"> & {
11
+ /** Chip size: `sm` tighter caption, `md` the Command palette chip (default), `lg` label-sm with more padding. */
12
+ size?: KbdSize;
13
+ /** Shortcut such as `Mod+Enter`, drawn with the OS's key labels (`⌘↵` / `Ctrl+Enter`) instead of children. */
14
+ keys?: string;
15
+ className?: string;
16
+ };
17
+
18
+ /** Chip rungs: 16/20/24px high, so the three sizes read apart instead of only wider padding. */
19
+ const sizeClass: Record<KbdSize, string> = {
20
+ sm: "h-4 gap-0.5 px-1 text-caption [&_svg]:size-2.5",
21
+ md: "h-5 gap-1 px-1.5 text-caption [&_svg]:size-3",
22
+ lg: "h-6 gap-1 px-2 text-label-sm [&_svg]:size-3.5",
23
+ };
24
+
25
+ /** Figma `Kbd` (Command palette 110:676): a 6% ink chip with caption text. */
26
+ export function Kbd({ size = "md", keys, className, children, ...props }: KbdProps) {
27
+ const { os } = usePlatform();
28
+ return (
29
+ <kbd
30
+ className={cn(
31
+ "inline-flex shrink-0 items-center rounded-xs bg-overlay-ink-6 font-sans leading-none whitespace-nowrap text-text-secondary",
32
+ sizeClass[size],
33
+ className,
34
+ )}
35
+ {...props}
36
+ >
37
+ <span className="inline-flex items-center gap-[inherit] text-trim">
38
+ {keys === undefined ? children : formatShortcut(keys, os)}
39
+ </span>
40
+ </kbd>
41
+ );
42
+ }
@@ -0,0 +1,185 @@
1
+ import { CaretDown, Plus, SlidersHorizontal, UploadSimple } from "@phosphor-icons/react";
2
+ import { type ComponentProps, type DragEvent, type ReactNode, useState } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { PanelPillTabs } from "./bot-instructions-panel";
6
+ import { Spinner } from "./spinner";
7
+
8
+ export type KnowledgeBaseTab = {
9
+ id: string;
10
+ label: ReactNode;
11
+ count?: number;
12
+ };
13
+
14
+ export type KnowledgeBasePanelProps = Omit<ComponentProps<"section">, "className" | "title" | "onChange"> & {
15
+ title: ReactNode;
16
+ /** The 12/18 line under the title: sources, chunks, bots. */
17
+ meta?: ReactNode;
18
+ tabs: KnowledgeBaseTab[];
19
+ value?: string;
20
+ defaultValue?: string;
21
+ onValueChange?: (id: string) => void;
22
+ /** Shows the spinner and "N indexing" at the right of the tabs when above zero. */
23
+ indexing?: number;
24
+ onSettings?: () => void;
25
+ onAddSource?: () => void;
26
+ /** Called with the files dropped on the dropzone or picked with Browse. Omit to hide the dropzone. */
27
+ onFiles?: (files: File[]) => void;
28
+ dropTitle?: ReactNode;
29
+ dropHint?: ReactNode;
30
+ /** Pipeline column labels shown above the list; hidden on narrow widths. */
31
+ stages?: string[];
32
+ /** The list rows, usually `SourceRow variant="list"`. */
33
+ children?: ReactNode;
34
+ className?: string;
35
+ };
36
+
37
+ const defaultStages = ["Parse", "Chunk", "Embed", "Index"];
38
+
39
+ /**
40
+ * Paper `Knowledge base` (XVM-1): a 24px-radius surface panel with a header, a pill tab tray, a table head, a
41
+ * flat divided source list and a dashed dropzone. The selected tab pill travels in 280ms, the dropzone border and
42
+ * fill change in 150ms while a file is over it, and indexing progress fills are animated by the rows.
43
+ */
44
+ export function KnowledgeBasePanel({
45
+ title,
46
+ meta,
47
+ tabs,
48
+ value,
49
+ defaultValue,
50
+ onValueChange,
51
+ indexing = 0,
52
+ onSettings,
53
+ onAddSource,
54
+ onFiles,
55
+ dropTitle = "Drop files here, or browse",
56
+ dropHint = "PDF, DOCX, Markdown or TXT · Notion, Google Drive and websites from Add source",
57
+ stages = defaultStages,
58
+ children,
59
+ className,
60
+ ...props
61
+ }: KnowledgeBasePanelProps) {
62
+ const [inner, setInner] = useState(defaultValue ?? tabs[0]?.id);
63
+ const selectedId = value ?? inner;
64
+ const [dragging, setDragging] = useState(false);
65
+
66
+ const select = (id: string) => {
67
+ setInner(id);
68
+ onValueChange?.(id);
69
+ };
70
+
71
+ const over = (event: DragEvent) => {
72
+ event.preventDefault();
73
+ setDragging(true);
74
+ };
75
+ const drop = (event: DragEvent) => {
76
+ event.preventDefault();
77
+ setDragging(false);
78
+ const files = Array.from(event.dataTransfer.files);
79
+ if (files.length > 0) onFiles?.(files);
80
+ };
81
+
82
+ return (
83
+ <section
84
+ className={cn(
85
+ "flex w-full max-w-200 min-w-0 flex-col items-stretch gap-3 rounded-[24px] border border-border-default bg-bg-surface px-5 py-4.5",
86
+ className,
87
+ )}
88
+ {...props}
89
+ >
90
+ <div className="flex min-w-0 flex-wrap items-center gap-2">
91
+ <div className="flex min-w-0 flex-1 flex-col gap-0.5">
92
+ <h3 className="truncate text-label-lg text-text-primary">{title}</h3>
93
+ {meta && <p className="truncate text-caption text-text-secondary">{meta}</p>}
94
+ </div>
95
+ <button
96
+ type="button"
97
+ aria-label="Knowledge base settings"
98
+ onClick={onSettings}
99
+ className="flex size-7.5 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full bg-overlay-ink-6 text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand [&>svg]:size-4"
100
+ >
101
+ <SlidersHorizontal aria-hidden />
102
+ </button>
103
+ <button
104
+ type="button"
105
+ onClick={onAddSource}
106
+ className="flex h-7.5 shrink-0 motion-press cursor-pointer items-center gap-1.5 rounded-full bg-overlay-ink-6 pr-2.5 pl-3 text-label-sm text-text-primary hover:bg-overlay-ink-10 focus-visible:outline-2 focus-visible:outline-text-brand"
107
+ >
108
+ <Plus aria-hidden className="size-3.5" />
109
+ Add source
110
+ <CaretDown aria-hidden className="size-3 text-text-secondary" />
111
+ </button>
112
+ </div>
113
+
114
+ <div className="flex flex-wrap items-center gap-2">
115
+ <PanelPillTabs
116
+ aria-label="Sources"
117
+ value={selectedId}
118
+ onValueChange={select}
119
+ items={tabs.map((tab) => ({
120
+ value: tab.id,
121
+ label: (
122
+ <span className="flex items-center gap-1.5">
123
+ {tab.label}
124
+ {tab.count !== undefined && <span className="text-caption text-text-secondary">{tab.count}</span>}
125
+ </span>
126
+ ),
127
+ }))}
128
+ />
129
+ <span className="min-w-0 flex-1" />
130
+ <span className="motion-reveal-x shrink-0" data-collapsed={indexing <= 0 || undefined}>
131
+ <span className="flex items-center gap-2 text-caption text-text-secondary tabular-nums">
132
+ <Spinner size={12} label="Indexing" />
133
+ {indexing} indexing
134
+ </span>
135
+ </span>
136
+ </div>
137
+
138
+ <div aria-hidden className="hidden items-center gap-3 px-2 pt-2 pb-1 text-caption text-text-secondary sm:flex">
139
+ <span className="min-w-0 flex-1">Source</span>
140
+ <span className="flex gap-1">
141
+ {stages.map((stage) => (
142
+ <span key={stage} className="flex h-4 w-11 shrink-0 items-center">
143
+ {stage}
144
+ </span>
145
+ ))}
146
+ </span>
147
+ <span className="w-18 shrink-0" />
148
+ </div>
149
+
150
+ <div className="flex flex-col">{children}</div>
151
+
152
+ {onFiles && (
153
+ <label
154
+ onDragOver={over}
155
+ onDragEnter={over}
156
+ onDragLeave={(event) => {
157
+ const next = event.relatedTarget;
158
+ if (!(next instanceof Node) || !event.currentTarget.contains(next)) setDragging(false);
159
+ }}
160
+ onDrop={drop}
161
+ className={cn(
162
+ "flex cursor-pointer items-center gap-3 rounded-2xl border border-dashed px-4.5 py-4 motion-colors has-focus-visible:outline-2 has-focus-visible:outline-text-brand",
163
+ dragging ? "border-border-focus bg-bg-brand" : "border-overlay-ink-18 bg-transparent",
164
+ )}
165
+ >
166
+ <input
167
+ type="file"
168
+ multiple
169
+ className="sr-only"
170
+ onChange={(event) => {
171
+ const files = Array.from(event.target.files ?? []);
172
+ if (files.length > 0) onFiles(files);
173
+ event.target.value = "";
174
+ }}
175
+ />
176
+ <UploadSimple aria-hidden className="size-4.5 shrink-0 text-text-secondary" />
177
+ <span className="flex min-w-0 flex-col gap-0.5">
178
+ <span className="text-label-md wrap-break-word text-text-primary">{dropTitle}</span>
179
+ <span className="text-caption wrap-break-word text-text-secondary">{dropHint}</span>
180
+ </span>
181
+ </label>
182
+ )}
183
+ </section>
184
+ );
185
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The glass material from `@horon-ai/tokens` `glass.ts`, the same one Flutter's
3
+ * `HoronLiquidGlass` draws: the backdrop blurred and saturated, then a 1px rim
4
+ * that is bright at the top and faint down the sides.
5
+ *
6
+ * Render it as the first child of a `relative isolate` host with a border radius
7
+ * and no border of its own; the rim is the edge. `isolate` keeps the layer's
8
+ * `-z-10` inside the host, so it paints under the body while staying above the
9
+ * host's own fill. A host that cannot isolate keeps its body `relative` instead.
10
+ *
11
+ * The host keeps its fill (`glass-clear`, `glass-regular`, `glass-thick`), which
12
+ * is equivalent to Flutter painting the fill over the filtered backdrop because
13
+ * saturation is linear and keeps white. There is deliberately no `tier` prop:
14
+ * the fill is the host's, and a fill on the layer would have to override the
15
+ * `glass-fill-frost` swap that `globals.css` applies for reduced transparency
16
+ * and for engines without `backdrop-filter`, so the layer would lie about the
17
+ * surface it is drawing. Tiers belong on the host, one class, no prop.
18
+ *
19
+ * Without backdrop-filter support, or with reduced transparency requested, the
20
+ * fill turns to `glass-fill-frost` and the rim stays (see `globals.css`).
21
+ */
22
+ export function LiquidGlass() {
23
+ return <span aria-hidden className="liquid-layers" />;
24
+ }