@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.
- package/package.json +2 -2
- package/src/components/agent-activity.tsx +81 -11
- package/src/components/agent-lane.tsx +32 -3
- package/src/components/agent-lanes-timeline.tsx +85 -7
- package/src/components/agent-panel.tsx +53 -7
- package/src/components/agent-steering-panel.tsx +73 -21
- package/src/components/approval-card.tsx +34 -2
- package/src/components/avatar.tsx +20 -4
- package/src/components/badge.tsx +24 -3
- package/src/components/banner.tsx +19 -3
- package/src/components/bot-instructions-panel.tsx +92 -9
- package/src/components/bot-list-row.tsx +13 -1
- package/src/components/bot-memory-panel.tsx +58 -3
- package/src/components/bottom-sheet.tsx +61 -7
- package/src/components/breadcrumb-header.tsx +32 -2
- package/src/components/breadcrumb.tsx +31 -1
- package/src/components/button.tsx +46 -1
- package/src/components/calendar.tsx +76 -6
- package/src/components/card.tsx +30 -1
- package/src/components/chat-bubble.tsx +32 -3
- package/src/components/chat-controls.tsx +60 -7
- package/src/components/checkbox.tsx +22 -1
- package/src/components/chip.tsx +29 -2
- package/src/components/choice-card.tsx +25 -1
- package/src/components/code-panel.tsx +28 -3
- package/src/components/combobox.tsx +52 -2
- package/src/components/command-palette.tsx +89 -10
- package/src/components/composer.tsx +28 -1
- package/src/components/data-table.tsx +198 -16
- package/src/components/date-field.tsx +31 -2
- package/src/components/dialog.tsx +45 -3
- package/src/components/directory-card.tsx +23 -2
- package/src/components/document-extract-panel.tsx +83 -5
- package/src/components/empty-state.tsx +21 -2
- package/src/components/field.tsx +49 -3
- package/src/components/file-drop.tsx +39 -4
- package/src/components/filter-bar.tsx +39 -2
- package/src/components/flue-background.tsx +91 -21
- package/src/components/horon-icon.tsx +50 -3
- package/src/components/horon-wordmark.tsx +26 -1
- package/src/components/icon-button.tsx +38 -1
- package/src/components/input-group.tsx +57 -6
- package/src/components/input.tsx +48 -2
- package/src/components/integration-row.tsx +43 -2
- package/src/components/kbd.tsx +37 -4
- package/src/components/knowledge-base-panel.tsx +40 -2
- package/src/components/liquid-glass.tsx +16 -0
- package/src/components/list-detail.tsx +89 -7
- package/src/components/marketplace-card.tsx +40 -4
- package/src/components/mcp-server-row.tsx +49 -3
- package/src/components/menu.tsx +83 -3
- package/src/components/message-extras.tsx +54 -18
- package/src/components/modal.tsx +71 -4
- package/src/components/orchestrator-panel.tsx +48 -4
- package/src/components/page-header.tsx +26 -3
- package/src/components/plan-card.tsx +28 -1
- package/src/components/plan-step.tsx +41 -1
- package/src/components/plugin-row.tsx +61 -3
- package/src/components/progress.tsx +32 -2
- package/src/components/radio-group.tsx +55 -2
- package/src/components/research-run-panel.tsx +73 -3
- package/src/components/schedule.tsx +163 -12
- package/src/components/segmented-control.tsx +58 -3
- package/src/components/select.tsx +60 -0
- package/src/components/settings-list.tsx +66 -8
- package/src/components/sidebar.tsx +214 -23
- package/src/components/skeleton.tsx +61 -4
- package/src/components/slider.tsx +30 -0
- package/src/components/source-row.tsx +43 -4
- package/src/components/spinner.tsx +29 -2
- package/src/components/split-button.tsx +37 -3
- package/src/components/switch.tsx +31 -1
- package/src/components/tab-bar.tsx +48 -3
- package/src/components/tabs.tsx +59 -5
- package/src/components/task-row.tsx +49 -3
- package/src/components/textarea-field.tsx +36 -2
- package/src/components/textarea.tsx +35 -2
- package/src/components/toast.tsx +52 -3
- package/src/components/tool-call.tsx +91 -17
- package/src/components/tool-calls-header.tsx +32 -1
- package/src/components/tool-permissions.tsx +50 -2
- package/src/components/tooltip.tsx +33 -1
- package/src/components/top-bar.tsx +35 -2
- package/src/components/usage-meter.tsx +28 -2
- package/src/components/workspace-menu.tsx +46 -1
- package/src/hooks/index.ts +8 -0
- package/src/{lib → hooks}/use-active-indicator.ts +44 -1
- package/src/hooks/use-color-scheme.ts +50 -0
- package/src/hooks/use-copy-to-clipboard.ts +55 -0
- package/src/hooks/use-device.ts +69 -0
- package/src/hooks/use-hotkey.ts +56 -0
- package/src/hooks/use-media-query.ts +43 -0
- package/src/hooks/use-platform.ts +68 -0
- package/src/hooks/use-reduced-motion.ts +25 -0
- package/src/index.ts +44 -0
- package/src/lib/cn.ts +13 -1
- package/src/lib/cursor-ghost.ts +16 -6
- 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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">> & {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
package/src/components/field.tsx
CHANGED
|
@@ -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"> & {
|
|
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
|
-
/**
|
|
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">> & {
|
|
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
|
|
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 "../
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
|
|
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 =
|
|
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);
|