@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
@@ -4,52 +4,97 @@ import { type ComponentProps, type ReactNode, useMemo, useState } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import { PanelPillTabs } from "./bot-instructions-panel";
6
6
 
7
- type WithClass<T> = Omit<T, "className"> & { className?: string };
7
+ /** Strips a section's own `className` so the panel can merge its classes with the caller's. */
8
+ type WithClass<T> = Omit<T, "className"> & {
9
+ /** Extra classes merged last onto the element. */
10
+ className?: string;
11
+ };
8
12
 
13
+ /** Row state of a plain field: `filled` reads fine, `check` marks a value the extractor is unsure about. */
9
14
  export type ExtractFieldStatus = "filled" | "check";
10
15
 
16
+ /** One extracted scalar value: one row in the Fields view and one entry in the JSON view. */
11
17
  export type ExtractField = {
18
+ /** Stable identity; confirming a row records this id and drives the footer counters. */
12
19
  id: string;
20
+ /** Row label on the left, 100px wide; also the key in the JSON view. */
13
21
  label: string;
22
+ /** The read value, shown truncated in a button-sized pill. */
14
23
  value: string;
15
24
  /** Field type shown at the row end, for example `Text` or `Date`. */
16
25
  type?: string;
26
+ /** `check` marks the value for confirmation: the row takes the danger border and a "Check" caption. */
17
27
  status?: ExtractFieldStatus;
18
28
  };
19
29
 
30
+ /** One extracted table: a labelled block with a header row and body rows instead of a single value. */
20
31
  export type ExtractTable = {
32
+ /** Stable identity, counted like a field in the footer. */
21
33
  id: string;
34
+ /** Row label above the table. */
22
35
  label: string;
36
+ /** Field type shown at the row end, such as `Table`. */
23
37
  type?: string;
24
- columns: { label: string; align?: "start" | "end"; width?: number }[];
38
+ /** Column definitions, in order; a cell reads its column by index. */
39
+ columns: {
40
+ /** Column heading, `caption` secondary text. */
41
+ label: string;
42
+ /** Cell alignment; `end` right-aligns numbers. */
43
+ align?: "start" | "end";
44
+ /** Fixed column width in px; a column without it shares the free width. */
45
+ width?: number;
46
+ }[];
47
+ /** Body rows; each inner array is one row, matched to `columns` by index. */
25
48
  rows: string[][];
26
49
  };
27
50
 
51
+ /** A labelled section of the Fields view, grouped under a dot and a heading. */
28
52
  export type ExtractGroup = {
53
+ /** Stable identity for the React key. */
29
54
  id: string;
55
+ /** Section heading, `label-md` primary text. */
30
56
  label: string;
57
+ /** Fields and tables of this section, in display order. */
31
58
  fields: (ExtractField | ExtractTable)[];
32
59
  };
33
60
 
61
+ /** Props for {@link DocumentExtractPanel}: the sheet to view, the extracted groups and the footer actions. */
34
62
  export type DocumentExtractPanelProps = WithClass<Omit<ComponentProps<"section">, "title">> & {
63
+ /** Document name in the viewer toolbar; `label-md` primary text, truncated. */
35
64
  fileName: string;
65
+ /** Page indicator at the right of the toolbar. @default "1 / 1" */
36
66
  pageLabel?: string;
37
67
  /** The paper sheet content. Wrap fields in `ExtractHighlight` to outline what was read. */
38
68
  sheet: ReactNode;
69
+ /** Extracted sections, in display order; the same data feeds the Fields, JSON and Schema views. */
39
70
  groups: ExtractGroup[];
71
+ /** Starting zoom the toolbar shows; 70 draws the sheet at its design size (scale 1) and the range is 30–150. @default 70 */
40
72
  defaultZoom?: number;
41
- bot?: { initial: string; name: string; elapsed?: string };
73
+ /** Bot credited in the footer; without it the footer shows no attribution. */
74
+ bot?: {
75
+ /** One-letter mark on the 20px brand square. */
76
+ initial: string;
77
+ /** Bot name, `caption` secondary text. */
78
+ name: string;
79
+ /** Optional run time appended as "· 12s". */
80
+ elapsed?: string;
81
+ };
82
+ /** Fires when the Extract button is pressed; the panel also bumps its run counter, which remounts the active view and replays its entry animation. */
42
83
  onExtract?: () => void;
84
+ /** Fires after Accept all has queued the confirmation of every row, so the confirmed marks land on the next render. */
43
85
  onAcceptAll?: () => void;
44
86
  /** Makes the file name actionable; without it the name is a plain label. */
45
87
  onOpenFile?: () => void;
46
88
  };
47
89
 
90
+ /** Lowest and highest zoom the toolbar allows, and the step of one press. */
48
91
  const ZOOM_MIN = 30;
49
92
  const ZOOM_MAX = 150;
50
93
  const ZOOM_STEP = 10;
94
+ /** The fit zoom: 100% of the sheet box is drawn at this percent, so the default 70 shows it at its design size. */
51
95
  const ZOOM_FIT = 70;
52
96
 
97
+ /** Outline colours for `ExtractHighlight`: brand-family tints for reads, neutral and ink for notes and marks. */
53
98
  const highlightTone = {
54
99
  sky: "border-accent-sky-500-90 bg-accent-sky-500-12",
55
100
  brand: "border-accent-sky-700-90 bg-accent-sky-700-12",
@@ -62,7 +107,10 @@ export function ExtractHighlight({
62
107
  tone = "sky",
63
108
  className,
64
109
  ...props
65
- }: WithClass<ComponentProps<"div">> & { tone?: keyof typeof highlightTone }) {
110
+ }: WithClass<ComponentProps<"div">> & {
111
+ /** Outline and fill pair: `sky` for a normal read, `brand` for a highlighted one, `neutral` and `ink` for notes. @default "sky" */
112
+ tone?: keyof typeof highlightTone;
113
+ }) {
66
114
  return (
67
115
  <div
68
116
  className={cn("flex flex-col gap-px rounded-sm border-[1.2px] px-1 py-0.5", highlightTone[tone], className)}
@@ -79,7 +127,12 @@ function isChecked(field: ExtractField, resolved: Set<string>) {
79
127
  return field.status === "check" && !resolved.has(field.id);
80
128
  }
81
129
 
82
- function Divider({ vertical }: { vertical?: boolean }) {
130
+ /** One hairline between the panel's regions; `vertical` draws it as a column rule for the split layout. */
131
+ // prettier-ignore
132
+ function Divider({ vertical }: {
133
+ /** Draws a column rule instead of a row rule. */
134
+ vertical?: boolean;
135
+ }) {
83
136
  return (
84
137
  <div
85
138
  role="separator"
@@ -95,6 +148,31 @@ function stepClass() {
95
148
  /**
96
149
  * Document extract panel (Paper `YUT-1` / `GWP-0`): a viewer with zoom controls on the left and the extracted
97
150
  * fields on the right, with Fields / JSON / Schema views, a per-row confirmation check and an Accept all footer.
151
+ *
152
+ * Laid out with container queries, not the viewport: the two columns stack below the 4xl container width and
153
+ * split above it, with the viewer column fixed at 540px. Zoom runs 30–150 in steps of 10, "Fit" returns to 70
154
+ * (the sheet's design size), and the sheet is scaled from its top edge, so it never reflows. A field with
155
+ * `status: "check"` renders as a button that confirms that one row; Accept all confirms every row at once.
156
+ * The footer counts filled and unresolved rows, keeps a dot per field and credits the bot when `bot` is set.
157
+ *
158
+ * @example
159
+ * ```tsx
160
+ * <DocumentExtractPanel
161
+ * fileName="invoice-0424.pdf"
162
+ * groups={[
163
+ * {
164
+ * id: "vendor",
165
+ * label: "Vendor",
166
+ * fields: [
167
+ * { id: "name", label: "Name", value: "Northwind Supply", type: "Text" },
168
+ * { id: "due", label: "Due", value: "Oct 24, 2026", type: "Date", status: "check" },
169
+ * ],
170
+ * },
171
+ * ]}
172
+ * sheet={<h1 className="text-title-md">Northwind Supply</h1>}
173
+ * onAcceptAll={() => saveExtraction()}
174
+ * />
175
+ * ```
98
176
  */
99
177
  export function DocumentExtractPanel({
100
178
  fileName,
@@ -2,17 +2,36 @@ import type { ReactNode } from "react";
2
2
 
3
3
  import { cn } from "../lib/cn";
4
4
 
5
+ /** Props for {@link EmptyState}: an optional illustration, the copy and one action. */
5
6
  export type EmptyStateProps = {
6
- /** 140px illustration, such as a mascot pose. */
7
+ /** 140px illustration, such as a mascot pose. The slot is square and the child is stretched to fill it. */
7
8
  illustration?: ReactNode;
9
+ /** Heading, rendered as a `title-md` balanced paragraph. */
8
10
  title: ReactNode;
11
+ /** Supporting copy under the heading, `body-md` secondary text across the full column width. */
9
12
  description?: ReactNode;
10
13
  /** Action under the text, usually a `Button`. */
11
14
  action?: ReactNode;
15
+ /** Extra classes on the column, merged after the width, spacing and entry animation. */
12
16
  className?: string;
13
17
  };
14
18
 
15
- /** Figma `Empty state` (64:485): centered 140px mascot, title-md heading, body-md text, action. */
19
+ /**
20
+ * Figma `Empty state` (64:485): centered 140px mascot, title-md heading, body-md text, action.
21
+ *
22
+ * A 360px-max column with a rise-in animation and no border or fill, so it drops into a card, a panel body
23
+ * or an empty table area without drawing its own surface.
24
+ *
25
+ * @example
26
+ * ```tsx
27
+ * <EmptyState
28
+ * illustration={<img src="/mascots/horon-mascot-welcome.webp" alt="" />}
29
+ * title="Meet your first bot"
30
+ * description="Give it a role and a task. It keeps working after you close the laptop."
31
+ * action={<Button>Create a bot</Button>}
32
+ * />
33
+ * ```
34
+ */
16
35
  export function EmptyState({ illustration, title, description, action, className }: EmptyStateProps) {
17
36
  return (
18
37
  <div
@@ -4,8 +4,12 @@ import { createContext, useContext, type ComponentProps } from "react";
4
4
  import { cn } from "../lib/cn";
5
5
  import type { ControlSize } from "../lib/size";
6
6
 
7
- type WithClass<T> = Omit<T, "className"> & { className?: string };
7
+ type WithClass<T> = Omit<T, "className"> & {
8
+ /** Extra classes appended after the part's own classes. */
9
+ className?: string;
10
+ };
8
11
 
12
+ /** `md` outside a `Field`, so a part rendered on its own keeps the default rung rather than throwing. */
9
13
  const FieldSizeContext = createContext<ControlSize>("md");
10
14
 
11
15
  const gapClass: Record<ControlSize, string> = {
@@ -26,12 +30,38 @@ const metaClass: Record<ControlSize, string> = {
26
30
  lg: "text-body-sm",
27
31
  };
28
32
 
29
- /** Figma `Field examples` (58:163): label, control and help text, 6/8/10px apart. */
33
+ /**
34
+ * Figma `Field examples` (58:163): label, control and help text, 6/8/10px apart.
35
+ *
36
+ * Wraps Base UI's `Field.Root`, a vertical stack (`max-w-full`, left aligned) whose gaps and text
37
+ * rungs follow `size` through context, so `FieldLabel`, `FieldDescription` and `FieldError` match the
38
+ * control without repeating the size. `invalid` (or Base UI's own validation) marks the control inside
39
+ * the field invalid, and the control's box paints its danger border from that.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * import { Field, FieldDescription, FieldError, FieldLabel, Input } from "@horon-ai/react";
44
+ *
45
+ * function EmailField() {
46
+ * return (
47
+ * <Field size="md">
48
+ * <FieldLabel htmlFor="email">Email</FieldLabel>
49
+ * <Input id="email" type="email" placeholder="you@example.com" />
50
+ * <FieldDescription>We only use it to sign you in.</FieldDescription>
51
+ * <FieldError>Enter a valid address.</FieldError>
52
+ * </Field>
53
+ * );
54
+ * }
55
+ * ```
56
+ */
30
57
  export function Field({
31
58
  size = "md",
32
59
  className,
33
60
  ...props
34
- }: WithClass<Omit<ComponentProps<typeof BaseField.Root>, "size">> & { size?: ControlSize }) {
61
+ }: WithClass<Omit<ComponentProps<typeof BaseField.Root>, "size">> & {
62
+ /** Control rung shared by every part; sets the gap (6/8/10px) and the label and meta type scales. @default "md" */
63
+ size?: ControlSize;
64
+ }) {
35
65
  return (
36
66
  <FieldSizeContext.Provider value={size}>
37
67
  <BaseField.Root
@@ -42,6 +72,11 @@ export function Field({
42
72
  );
43
73
  }
44
74
 
75
+ /**
76
+ * Part of {@link Field}. The control's label (`Field.Label`): `label-sm/md/lg` from the field's size,
77
+ * primary text, and the disabled colour when its control is disabled. Give it `htmlFor` matching the
78
+ * control's `id`, which is also what makes clicking the text focus the control.
79
+ */
45
80
  export function FieldLabel({ className, ...props }: WithClass<ComponentProps<typeof BaseField.Label>>) {
46
81
  const size = useContext(FieldSizeContext);
47
82
  return (
@@ -52,11 +87,22 @@ export function FieldLabel({ className, ...props }: WithClass<ComponentProps<typ
52
87
  );
53
88
  }
54
89
 
90
+ /**
91
+ * Part of {@link Field}. The help line under the control (`Field.Description`) in secondary text:
92
+ * `caption` at sm and md, `body-sm` at lg. Always full width so the copy wraps inside the field, and
93
+ * Base UI wires it to the control as the description for assistive tech.
94
+ */
55
95
  export function FieldDescription({ className, ...props }: WithClass<ComponentProps<typeof BaseField.Description>>) {
56
96
  const size = useContext(FieldSizeContext);
57
97
  return <BaseField.Description className={cn(metaClass[size], "w-full text-text-secondary", className)} {...props} />;
58
98
  }
59
99
 
100
+ /**
101
+ * Part of {@link Field}. The validation message (`Field.Error`) in `text-text-danger` at the field's
102
+ * meta rung. It stays mounted only while it should show: Base UI renders it when the enclosing field is
103
+ * invalid, or when `match` is set, and keeps it mounted through its exit — the row collapses from `1fr`
104
+ * to `0fr` as it fades, so the parts below glide up instead of jumping.
105
+ */
60
106
  export function FieldError({ className, children, ...props }: WithClass<ComponentProps<typeof BaseField.Error>>) {
61
107
  const size = useContext(FieldSizeContext);
62
108
  // Base UI keeps the message mounted through its exit (`data-ending-style`), so the row can close
@@ -4,26 +4,61 @@ import { type ComponentProps, type DragEvent, type ReactNode, useId, useState }
4
4
  import { cn } from "../lib/cn";
5
5
  import { Spinner } from "./spinner";
6
6
 
7
+ /**
8
+ * How the drop target is painted: `solid` fills it with the raised surface (`bg-bg-surface`), `glass`
9
+ * leaves it transparent on the veil so imagery behind shows through.
10
+ */
7
11
  export type FileDropSurface = "solid" | "glass";
8
12
 
13
+ /** Props of {@link FileDrop}: the native file input attributes plus the target's own state. */
9
14
  export type FileDropProps = Omit<ComponentProps<"input">, "className" | "type" | "children" | "onDrop"> & {
10
- /** Label next to the icon. */
15
+ /** Label next to the icon. @default "Drop files here or click to upload" */
11
16
  label?: ReactNode;
12
17
  /** Shows a spinner in place of the icon while files upload. */
13
18
  uploading?: boolean;
14
19
  /** Danger rim for a rejected file. */
15
20
  rejected?: boolean;
16
- /** Called with the dropped files. */
21
+ /** Called with the dropped or picked files, in picker order; fires on both drop and native file input change. */
17
22
  onFiles?: (files: File[]) => void;
18
- /** `solid` (default) fills the target with the raised surface; `glass` puts it on the veil over imagery. */
23
+ /** `solid` (default) fills the target with the raised surface; `glass` puts it on the veil over imagery. @default "solid" */
19
24
  surface?: FileDropSurface;
25
+ /** Extra classes for the label that forms the target. */
20
26
  className?: string;
21
27
  };
22
28
 
23
29
  /**
24
30
  * Paper `File drop` (1VWI-0): a 1.5px dashed r10 target, icon 16 and a 13/18 500 label centred with gap 8.
25
- * Dragging swaps the dashes for a 1px brand rim; no shake or bounce. Solid fills with the raised surface,
31
+ * Dragging keeps the dashed edge and repaints it in the brand border, adds a 1px inset brand rim and, on
32
+ * `solid`, shifts the fill to `bg-bg-subtle`; no shake or bounce. Solid fills with the raised surface,
26
33
  * glass sits on the veil (`veil-drop`) and keeps the dashed border as its only outline.
34
+ *
35
+ * The target is a `<label>` around a screen-reader-only `<input type="file">`, so a click opens the native
36
+ * picker and the control stays keyboard reachable; the focus ring is drawn on the label through
37
+ * `has-[:focus-visible]`. Drops land in `onFiles` with `DataTransfer.files`, and the input's change event
38
+ * also calls `onFiles` before forwarding `onChange`. While `uploading`, the icon becomes a spinner, the
39
+ * input is disabled and drops are ignored; `rejected` paints a danger rim. The target is capped at 550px
40
+ * and never wider than its parent.
41
+ *
42
+ * @example
43
+ * ```tsx
44
+ * import { useState } from "react";
45
+ * import { FileDrop } from "@horon-ai/react";
46
+ *
47
+ * function Example() {
48
+ * const [uploading, setUploading] = useState(false);
49
+ * return (
50
+ * <FileDrop
51
+ * accept=".pdf,.png"
52
+ * label="Drop contracts here or click to upload"
53
+ * uploading={uploading}
54
+ * onFiles={(files) => {
55
+ * setUploading(true);
56
+ * void upload(files).finally(() => setUploading(false));
57
+ * }}
58
+ * />
59
+ * );
60
+ * }
61
+ * ```
27
62
  */
28
63
  export function FileDrop({
29
64
  label = "Drop files here or click to upload",
@@ -1,20 +1,29 @@
1
1
  import { type ComponentProps, type ReactNode, useRef, useState } from "react";
2
2
 
3
3
  import { cn } from "../lib/cn";
4
- import { useActiveIndicator } from "../lib/use-active-indicator";
4
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
5
5
 
6
+ /** One option in a `FilterBar`. */
6
7
  export type FilterBarItem = {
8
+ /** Stable value reported through `onValueChange` and compared with `value`. */
7
9
  value: string;
10
+ /** Visible chip text; normally a short noun such as `All` or `Agents`. */
8
11
  label: ReactNode;
9
- /** Count shown after the label, such as 4. */
12
+ /** Count shown after the label, such as 4. Omit to hide the slot. */
10
13
  count?: number;
14
+ /** Disables just this option: 40% opacity, not clickable. */
11
15
  disabled?: boolean;
12
16
  };
13
17
 
18
+ /** Props of {@link FilterBar}: the options and the selection, controlled or uncontrolled. */
14
19
  export type FilterBarProps = Omit<ComponentProps<"div">, "onChange" | "defaultValue"> & {
20
+ /** Options in display order; the first one is the initial selection when `defaultValue` is omitted. */
15
21
  items: FilterBarItem[];
22
+ /** Controlled selection. Pass `onValueChange` to hear about clicks. */
16
23
  value?: string;
24
+ /** Initial selection when uncontrolled. @default items[0].value */
17
25
  defaultValue?: string;
26
+ /** Called with the clicked option's `value`; clicks on the already-selected option still fire. */
18
27
  onValueChange?: (value: string) => void;
19
28
  };
20
29
 
@@ -22,6 +31,34 @@ export type FilterBarProps = Omit<ComponentProps<"div">, "onChange" | "defaultVa
22
31
  * Paper `Filter bar` (Agents N2): a 5% ink pill track with 30px options; the selected option sits on a sliding 10% ink pill.
23
32
  * The pills never wrap, so a screen too narrow for all of them scrolls the track sideways instead of widening the page.
24
33
  * The track keeps its intrinsic width (and so stays centred by its parent) unless that width exceeds the box.
34
+ *
35
+ * Selection is controlled through `value` or uncontrolled from `defaultValue`, which falls back to the first
36
+ * item. The group is a `radiogroup` labelled by `aria-label` (`Filter` when omitted), each option a
37
+ * `<button role="radio" aria-checked>`; the background pill is measured by the active option and hides when
38
+ * none matches, and it lands without animating on the first paint. There is no roving tab index: every enabled
39
+ * option is a tab stop, and Space or Enter selects it.
40
+ *
41
+ * @example
42
+ * ```tsx
43
+ * import { useState } from "react";
44
+ * import { FilterBar } from "@horon-ai/react";
45
+ *
46
+ * function Example() {
47
+ * const [scope, setScope] = useState("all");
48
+ * return (
49
+ * <FilterBar
50
+ * aria-label="Scope"
51
+ * value={scope}
52
+ * onValueChange={setScope}
53
+ * items={[
54
+ * { value: "all", label: "All", count: 12 },
55
+ * { value: "agents", label: "Agents", count: 4 },
56
+ * { value: "archived", label: "Archived", disabled: true },
57
+ * ]}
58
+ * />
59
+ * );
60
+ * }
61
+ * ```
25
62
  */
26
63
  export function FilterBar({
27
64
  items,
@@ -1,45 +1,89 @@
1
- import { type CSSProperties, useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { type CSSProperties, useEffect, useRef } from "react";
2
2
 
3
+ import { useColorScheme } from "../hooks/use-color-scheme";
3
4
  import { cn } from "../lib/cn";
4
5
 
6
+ /**
7
+ * Every knob the fragment shader reads. Each key becomes a uniform named `u` plus the capitalised key
8
+ * (`speed` → `uSpeed`), and a hex string is uploaded as a `vec3`, so add a field here and a matching
9
+ * uniform in the shader or it silently does nothing. `cursor` and `maxDpr` are engine keys instead: they
10
+ * never become uniforms and are skipped when uniforms are bound.
11
+ */
5
12
  export type FlueConfig = {
13
+ /** Flat ground color, hex. Painted on the wrapper, so it is also the fallback when WebGL2 is unavailable. */
6
14
  bgColor: string;
15
+ /** Faintest ramp stop, a near-ground tint that only the thinnest smoke reaches. */
7
16
  colorA: string;
17
+ /** Second ramp stop: the first visible blue of the plume. */
8
18
  colorB: string;
19
+ /** Third ramp stop: the dense body of the plume. */
9
20
  colorC: string;
21
+ /** Densest ramp stop, also the glow color at high density. */
10
22
  colorD: string;
23
+ /** Zoom of the base noise field; larger values make the smoke finer and busier. */
11
24
  scale: number;
25
+ /** Time multiplier, so the drift speed follows it. Larger values scroll faster. */
12
26
  speed: number;
27
+ /** Vertical advection added to the curl field per second, the internal flow of the plume. */
13
28
  flow: number;
29
+ /** Gain of each fbm octave (0..1); lower values keep the coarse shape dominant. */
14
30
  roughness: number;
31
+ /** Frequency step between fbm octaves; larger values add detail at smaller and smaller scale. */
15
32
  lacunarity: number;
33
+ /** Zoom of the density field: larger values make individual wisps smaller. */
16
34
  smokeScale: number;
35
+ /** Amount of curl-noise displacement; higher values swirl the smoke more. */
17
36
  curl: number;
37
+ /** Zoom of the curl potential field, so the swirls themselves change size. */
18
38
  curlScale: number;
39
+ /** Upward drift of the smoke in uv per second. */
19
40
  rise: number;
41
+ /** Vertical frequency multiplier for the density field; larger values compress the wisps vertically. */
20
42
  slender: number;
43
+ /** Half-width (uv) of the vertical corridor the plume is allowed to fill. */
21
44
  width: number;
45
+ /** How much the corridor widens from the bottom to the top of the frame. */
22
46
  spread: number;
47
+ /** How far the pointer's x pushes the top of the plume sideways. */
23
48
  lean: number;
49
+ /** Overall density multiplier before the threshold. */
24
50
  density: number;
51
+ /** Gain around `midpoint`: higher values give a harder edge between ground and smoke. */
25
52
  contrast: number;
53
+ /** Threshold (0..1) where smoke starts to show; higher values leave more of the ground bare. */
26
54
  midpoint: number;
55
+ /** Strength of the `colorD` bloom on the densest part, added as `pow(f, 4)`. */
27
56
  glow: number;
57
+ /** Smoothstep width, over the density value, at which the ground gives way to the ramp; 0 floors at 0.01. */
28
58
  sink: number;
59
+ /** Film-grain strength; 0 turns grain off. */
29
60
  grain: number;
61
+ /** Grain refresh multiplier (24 steps per second at 1); 0 holds one static grain field. */
30
62
  grainAnim: number;
63
+ /** Triangle-dither amplitude added to every channel to stop gradient banding. */
31
64
  dither: number;
65
+ /** Corner darkening; multiplied by the squared uv distance from the centre. */
32
66
  vignette: number;
33
67
  /** 1 lets the smoke follow the real pointer; 0 (default) ignores it and a slow virtual pointer sways the smoke. */
34
68
  cursor: number;
69
+ /** Radius (uv) of the pointer trail's gaussian falloff. */
35
70
  pointerRadius: number;
71
+ /** How strongly the pointer's velocity drags the smoke along, through the trail. */
36
72
  draft: number;
73
+ /** How much the pointer trail brightens the density, added as `trail * 0.30 * this`. */
37
74
  pointerStrength: number;
75
+ /** How far the whole field shifts against the pointer, in uv. */
38
76
  parallax: number;
77
+ /** Device-pixel-ratio cap for the canvas; 1 halves the pixel count on a 2x screen and keeps the fill cheap. */
39
78
  maxDpr: number;
40
79
  };
41
80
 
42
- /** The Horon sky palette for Flue. Recolour through these values only, never the shader. */
81
+ /**
82
+ * The Horon sky palette for Flue. Recolour through these values only, never the shader.
83
+ *
84
+ * It is a complete `FlueConfig`, so it doubles as the base every variant tunes on top of, and its
85
+ * `cursor: 0` / `maxDpr: 1` keep the default cheap: a still-ish plume at 1x pixel density.
86
+ */
43
87
  export const FLUE_SKY: FlueConfig = {
44
88
  bgColor: "#eef3f8",
45
89
  colorA: "#e3ecf6",
@@ -76,7 +120,12 @@ export const FLUE_SKY: FlueConfig = {
76
120
  maxDpr: 1,
77
121
  };
78
122
 
79
- /** The night palette for dark mode: near-black ground and blue smoke. Same motion as `FLUE_SKY`. */
123
+ /**
124
+ * The night palette for dark mode: near-black ground and blue smoke. Same motion as `FLUE_SKY`.
125
+ *
126
+ * Only the five colors and `glow` differ; every other number is inherited, so light and dark read as the
127
+ * same plume in two lights.
128
+ */
80
129
  export const FLUE_NIGHT: FlueConfig = {
81
130
  ...FLUE_SKY,
82
131
  bgColor: "#050608",
@@ -90,9 +139,18 @@ export const FLUE_NIGHT: FlueConfig = {
90
139
  /** Where the smoke is drawn; each place tunes the motion and colour on top of `FLUE_SKY` / `FLUE_NIGHT`. */
91
140
  export type FlueVariant = "default" | "sidebar" | "auth";
92
141
 
93
- type FlueTuning = { light: Partial<FlueConfig>; dark: Partial<FlueConfig>; tint: string };
142
+ /** One variant's overrides: values layered over the base palette per scheme, plus the tint laid on top. */
143
+ type FlueTuning = {
144
+ /** Overrides applied on top of `FLUE_SKY` in light mode. */
145
+ light: Partial<FlueConfig>;
146
+ /** Overrides applied on top of `FLUE_NIGHT` in dark mode. */
147
+ dark: Partial<FlueConfig>;
148
+ /** Classes for the tint div over the smoke; `""` adds none, and it yields to `tintClassName`. */
149
+ tint: string;
150
+ };
94
151
 
95
152
  /**
153
+ * - `default`: no motion overrides, under a regular glass tint in light mode and none in dark.
96
154
  * - `sidebar`: a narrow, tall column under the sidebar's glass. The glass blurs the detail away, so the smoke
97
155
  * is calmer and larger, kept low in the column so the nav labels at the top sit on the quiet part. The canvas
98
156
  * paints above the sidebar's own fill, so in light mode the variant lays the thick glass (80% white) over the
@@ -133,21 +191,6 @@ const FLUE_VARIANTS: Record<FlueVariant, FlueTuning> = {
133
191
  },
134
192
  };
135
193
 
136
- /** Follows the `.dark` class on <html>, the switch the theme uses. */
137
- function subscribeScheme(onChange: () => void): () => void {
138
- const observer = new MutationObserver(onChange);
139
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
140
- return () => observer.disconnect();
141
- }
142
-
143
- function useDarkScheme(): boolean {
144
- return useSyncExternalStore(
145
- subscribeScheme,
146
- () => document.documentElement.classList.contains("dark"),
147
- () => false,
148
- );
149
- }
150
-
151
194
  const ENGINE_KEYS = new Set(["cursor", "maxDpr"]);
152
195
 
153
196
  const VERT = `#version 300 es
@@ -202,10 +245,13 @@ function activate(gl: WebGL2RenderingContext, program: WebGLProgram): void {
202
245
  gl[USE_PROGRAM](program);
203
246
  }
204
247
 
248
+ /** Props of {@link FlueBackground}: placement, which variant, and per-instance shader overrides. */
205
249
  export type FlueBackgroundProps = {
250
+ /** Extra classes for the wrapper, which is already `absolute inset-0 -z-10 overflow-hidden`. */
206
251
  className?: string;
252
+ /** Inline styles for the wrapper; `backgroundColor` is set from the resolved `bgColor` before these apply. */
207
253
  style?: CSSProperties;
208
- /** `sidebar` under the sidebar glass, `auth` behind the sign-in screen, `default` otherwise. */
254
+ /** `sidebar` under the sidebar glass, `auth` behind the sign-in screen, `default` otherwise. @default "default" */
209
255
  variant?: FlueVariant;
210
256
  /** Overrides on top of the variant's `FLUE_SKY` (light) or `FLUE_NIGHT` (dark) tuning. */
211
257
  config?: Partial<FlueConfig>;
@@ -218,10 +264,34 @@ export type FlueBackgroundProps = {
218
264
  * It fills its positioned parent behind the content (the parent needs `isolate`) and falls
219
265
  * back to the flat `bgColor` when WebGL2 is missing. Reduced motion draws a single still frame.
220
266
  * Light mode paints the white-and-sky `FLUE_SKY`; dark mode paints the black-and-blue `FLUE_NIGHT`.
267
+ *
268
+ * The wrapper is `absolute inset-0 -z-10`, `pointer-events-none` and `aria-hidden`, with a tint div over the
269
+ * canvas, so it never intercepts input and the content above it stays readable. Settings resolve as palette +
270
+ * variant tuning + `config`; the effect is keyed by their JSON, so changing `config` rebuilds the program.
271
+ * Drawing pauses while the canvas is off-screen (`IntersectionObserver`) or the tab is hidden, and the
272
+ * device-pixel ratio is capped by `maxDpr`. `cursor: 0` (the default) swaps the real pointer for a slow
273
+ * virtual one that wanders on two incommensurate sines, so a pointerless panel still breathes; `cursor: 1`
274
+ * follows the real pointer. A context lost
275
+ * by a previous run leaves the flat color instead of throwing, and cleanup frees this run's GL objects
276
+ * without dropping the shared context.
277
+ *
278
+ * @example
279
+ * ```tsx
280
+ * import { FlueBackground } from "@horon-ai/react";
281
+ *
282
+ * function Sidebar() {
283
+ * return (
284
+ * <div className="relative isolate min-h-96 overflow-hidden rounded-xl bg-bg-default">
285
+ * <FlueBackground variant="sidebar" config={{ speed: 0.5 }} />
286
+ * <nav className="relative p-6 text-text-primary">Navigation</nav>
287
+ * </div>
288
+ * );
289
+ * }
290
+ * ```
221
291
  */
222
292
  export function FlueBackground({ className, style, variant = "default", config, tintClassName }: FlueBackgroundProps) {
223
293
  const canvasRef = useRef<HTMLCanvasElement>(null);
224
- const dark = useDarkScheme();
294
+ const dark = useColorScheme() === "dark";
225
295
  const tuning = FLUE_VARIANTS[variant];
226
296
  const settings = { ...(dark ? FLUE_NIGHT : FLUE_SKY), ...(dark ? tuning.dark : tuning.light), ...config };
227
297
  const key = JSON.stringify(settings);