harebear-ui 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/AGENTS.md +32 -12
  2. package/README.md +109 -36
  3. package/bin/harebear-ui.mjs +66 -3
  4. package/dist/components/admin/app-nav.d.ts +57 -0
  5. package/dist/components/admin/app-nav.js +172 -0
  6. package/dist/components/admin/focus-layout.d.ts +14 -0
  7. package/dist/components/admin/focus-layout.js +36 -0
  8. package/dist/components/admin/settings.d.ts +31 -0
  9. package/dist/components/admin/settings.js +78 -0
  10. package/dist/components/ai/action-receipt.d.ts +16 -0
  11. package/dist/components/ai/action-receipt.js +47 -0
  12. package/dist/components/ai/ask-box.d.ts +27 -0
  13. package/dist/components/ai/ask-box.js +85 -0
  14. package/dist/components/ai/assistant.d.ts +94 -0
  15. package/dist/components/ai/assistant.js +229 -0
  16. package/dist/components/ai/dictation.d.ts +57 -0
  17. package/dist/components/ai/dictation.js +148 -0
  18. package/dist/components/data/sectioned-list.d.ts +37 -0
  19. package/dist/components/data/sectioned-list.js +139 -0
  20. package/dist/components/data/task-row.d.ts +22 -0
  21. package/dist/components/data/task-row.js +60 -0
  22. package/dist/components/layout/collapse.d.ts +14 -0
  23. package/dist/components/layout/collapse.js +24 -0
  24. package/dist/components/layout/media-item.d.ts +13 -0
  25. package/dist/components/layout/media-item.js +54 -0
  26. package/dist/components/layout/metric.d.ts +15 -0
  27. package/dist/components/layout/metric.js +51 -0
  28. package/dist/components/layout/show-more.d.ts +17 -0
  29. package/dist/components/layout/show-more.js +30 -0
  30. package/dist/components/layout/stack.d.ts +39 -0
  31. package/dist/components/layout/stack.js +114 -0
  32. package/dist/components/layout/text.d.ts +37 -0
  33. package/dist/components/layout/text.js +52 -0
  34. package/dist/components/theme/theme-toggle.d.ts +4 -1
  35. package/dist/components/theme/theme-toggle.js +11 -6
  36. package/dist/components/ui/check-circle.d.ts +16 -0
  37. package/dist/components/ui/check-circle.js +39 -0
  38. package/dist/components/ui/sonner.d.ts +15 -2
  39. package/dist/components/ui/sonner.js +20 -3
  40. package/dist/index.d.ts +18 -2
  41. package/dist/index.js +18 -2
  42. package/dist/styles.css +75 -0
  43. package/docs/COMPONENTS.md +160 -21
  44. package/docs/ROADMAP.md +95 -0
  45. package/docs/guides/agents.md +75 -0
  46. package/docs/guides/getting-started.md +217 -0
  47. package/docs/guides/theming.md +99 -0
  48. package/llms.txt +11 -3
  49. package/package.json +2 -1
  50. package/skills/harebear-ui/SKILL.md +2 -1
@@ -0,0 +1,31 @@
1
+ import * as React from "react";
2
+ export interface SettingsSectionProps extends Omit<React.ComponentProps<"section">, "title"> {
3
+ title: React.ReactNode;
4
+ description?: React.ReactNode;
5
+ /** SettingsRow children. */
6
+ children?: React.ReactNode;
7
+ }
8
+ /** A card of settings: heading, optional description, then SettingsRows separated by hairlines. */
9
+ declare function SettingsSection({ title, description, className, children, ...props }: SettingsSectionProps): React.JSX.Element;
10
+ export interface SettingsRowProps extends Omit<React.ComponentProps<"div">, "content"> {
11
+ label: React.ReactNode;
12
+ /** One line under the label. */
13
+ description?: React.ReactNode;
14
+ /** id of the control, so the label is a <label> for it (Switch, Select trigger, Input). */
15
+ htmlFor?: string;
16
+ /** The control at the right: Switch, Select, ToggleGroup, Button. */
17
+ control?: React.ReactNode;
18
+ /**
19
+ * Inline content that expands under the row (change password, add a connection). Without a `control`, the row
20
+ * gets a toggle button (`expandLabel` / "Cancel").
21
+ */
22
+ content?: React.ReactNode;
23
+ open?: boolean;
24
+ defaultOpen?: boolean;
25
+ onOpenChange?: (open: boolean) => void;
26
+ /** Label of the default toggle button. Default "Change". */
27
+ expandLabel?: React.ReactNode;
28
+ }
29
+ /** One setting: label and description at the left, a control at the right, optional expandable content. */
30
+ declare function SettingsRow({ label, description, htmlFor, control, content, open: openProp, defaultOpen, onOpenChange, expandLabel, className, ...props }: SettingsRowProps): React.JSX.Element;
31
+ export { SettingsRow, SettingsSection };
@@ -0,0 +1,78 @@
1
+ "use client";
2
+ import { Collapse } from "../layout/collapse.js";
3
+ import { Button } from "../ui/button.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ import * as React from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ // src/components/admin/settings.tsx
8
+ /** A card of settings: heading, optional description, then SettingsRows separated by hairlines. */
9
+ function SettingsSection({ title, description, className, children, ...props }) {
10
+ const id = React.useId();
11
+ return /* @__PURE__ */ jsxs("section", {
12
+ "data-slot": "settings-section",
13
+ "aria-labelledby": id,
14
+ className: cn("flex min-w-0 flex-col rounded-xl bg-card shadow-card", className),
15
+ ...props,
16
+ children: [/* @__PURE__ */ jsxs("div", {
17
+ className: "flex flex-col gap-1 px-5 pt-5 pb-2",
18
+ children: [/* @__PURE__ */ jsx("h2", {
19
+ id,
20
+ className: "text-base font-semibold",
21
+ children: title
22
+ }), description && /* @__PURE__ */ jsx("p", {
23
+ className: "text-[13px] text-muted-foreground",
24
+ children: description
25
+ })]
26
+ }), /* @__PURE__ */ jsx("div", {
27
+ className: "flex flex-col divide-y divide-border px-5 pb-1",
28
+ children
29
+ })]
30
+ });
31
+ }
32
+ /** One setting: label and description at the left, a control at the right, optional expandable content. */
33
+ function SettingsRow({ label, description, htmlFor, control, content, open: openProp, defaultOpen = false, onOpenChange, expandLabel = "Change", className, ...props }) {
34
+ const [openState, setOpenState] = React.useState(defaultOpen);
35
+ const open = openProp ?? openState;
36
+ const setOpen = (next) => {
37
+ if (openProp === void 0) setOpenState(next);
38
+ onOpenChange?.(next);
39
+ };
40
+ const contentId = React.useId();
41
+ const Label = htmlFor ? "label" : "div";
42
+ return /* @__PURE__ */ jsxs("div", {
43
+ "data-slot": "settings-row",
44
+ "data-state": content ? open ? "open" : "closed" : void 0,
45
+ className: cn("py-3.5", className),
46
+ ...props,
47
+ children: [/* @__PURE__ */ jsxs("div", {
48
+ className: "flex flex-wrap items-center gap-x-4 gap-y-2",
49
+ children: [/* @__PURE__ */ jsxs("div", {
50
+ className: "flex min-w-0 flex-1 basis-48 flex-col",
51
+ children: [/* @__PURE__ */ jsx(Label, {
52
+ htmlFor,
53
+ className: "text-sm font-medium",
54
+ children: label
55
+ }), description && /* @__PURE__ */ jsx("span", {
56
+ className: "text-[13px] text-muted-foreground",
57
+ children: description
58
+ })]
59
+ }), control ?? (content ? /* @__PURE__ */ jsx(Button, {
60
+ type: "button",
61
+ variant: "outline",
62
+ size: "sm",
63
+ "aria-expanded": open,
64
+ "aria-controls": contentId,
65
+ onClick: () => setOpen(!open),
66
+ children: open ? "Cancel" : expandLabel
67
+ }) : null)]
68
+ }), content && /* @__PURE__ */ jsx(Collapse, {
69
+ open,
70
+ id: contentId,
71
+ children: /* @__PURE__ */ jsx("div", {
72
+ className: "pt-3.5",
73
+ children: content
74
+ })
75
+ })]
76
+ });
77
+ }
78
+ export { SettingsRow, SettingsSection };
@@ -0,0 +1,16 @@
1
+ import * as React from "react";
2
+ export interface ActionReceiptProps extends Omit<React.ComponentProps<"div">, "title"> {
3
+ /** What was done, past tense: "Added milk to Groceries". */
4
+ title: React.ReactNode;
5
+ /** One line, e.g. "Groceries · just now". */
6
+ meta?: React.ReactNode;
7
+ /** Shows an Undo button. */
8
+ onUndo?: () => void;
9
+ /** Default "Undo". */
10
+ undoLabel?: React.ReactNode;
11
+ /** After undo: the check goes away, the title is struck through and the button is replaced by "Undone". */
12
+ undone?: boolean;
13
+ }
14
+ /** A compact card for one action the assistant took on its own: round check, title, one meta line, Undo. */
15
+ declare function ActionReceipt({ title, meta, onUndo, undoLabel, undone, className, ...props }: ActionReceiptProps): React.JSX.Element;
16
+ export { ActionReceipt };
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { Button } from "../ui/button.js";
3
+ import { cn } from "../../lib/utils.js";
4
+ import { jsx, jsxs } from "react/jsx-runtime";
5
+ import { CheckIcon } from "lucide-react";
6
+ // src/components/ai/action-receipt.tsx
7
+ /** A compact card for one action the assistant took on its own: round check, title, one meta line, Undo. */
8
+ function ActionReceipt({ title, meta, onUndo, undoLabel = "Undo", undone, className, ...props }) {
9
+ return /* @__PURE__ */ jsxs("div", {
10
+ "data-slot": "action-receipt",
11
+ "data-undone": undone || void 0,
12
+ className: cn("flex min-w-0 items-center gap-3 rounded-xl border border-border bg-card px-3.5 py-2.5", className),
13
+ ...props,
14
+ children: [
15
+ /* @__PURE__ */ jsx("span", {
16
+ "aria-hidden": "true",
17
+ className: cn("grid size-6 shrink-0 place-items-center rounded-full", undone ? "border-2 border-muted-foreground/40" : "bg-success text-card"),
18
+ children: !undone && /* @__PURE__ */ jsx(CheckIcon, {
19
+ className: "size-3.5",
20
+ strokeWidth: 3
21
+ })
22
+ }),
23
+ /* @__PURE__ */ jsxs("div", {
24
+ className: "flex min-w-0 flex-1 flex-col leading-snug",
25
+ children: [/* @__PURE__ */ jsx("span", {
26
+ className: cn("text-sm font-medium wrap-anywhere", undone && "text-muted-foreground line-through"),
27
+ children: title
28
+ }), meta && /* @__PURE__ */ jsx("span", {
29
+ className: "truncate text-xs text-muted-foreground",
30
+ children: meta
31
+ })]
32
+ }),
33
+ undone ? /* @__PURE__ */ jsx("span", {
34
+ className: "shrink-0 text-xs font-medium text-muted-foreground",
35
+ children: "Undone"
36
+ }) : onUndo && /* @__PURE__ */ jsx(Button, {
37
+ type: "button",
38
+ variant: "ghost",
39
+ size: "sm",
40
+ className: "shrink-0",
41
+ onClick: onUndo,
42
+ children: undoLabel
43
+ })
44
+ ]
45
+ });
46
+ }
47
+ export { ActionReceipt };
@@ -0,0 +1,27 @@
1
+ import { SpeechSource } from "./dictation.js";
2
+ import * as React from "react";
3
+ export interface AskBoxProps extends Omit<React.ComponentProps<"form">, "onSubmit" | "onChange"> {
4
+ value: string;
5
+ onValueChange: (value: string) => void;
6
+ /** Called with the trimmed text on Enter or the send button. Shift+Enter inserts a newline. */
7
+ onSubmit: (value: string) => void;
8
+ /** One per app, e.g. "Ask HomeBot anything". Also the field's accessible label. Default "Ask anything". */
9
+ placeholder?: string;
10
+ /** Shown under the text while there is text, e.g. chips for the date or list it picked up. */
11
+ chips?: React.ReactNode;
12
+ /** Pills under the box. Clicking one submits it (or calls onSuggestion). */
13
+ suggestions?: string[];
14
+ onSuggestion?: (suggestion: string) => void;
15
+ /** Speech source for the mic. Default the browser's; `false` hides the mic. */
16
+ dictation?: SpeechSource | false;
17
+ /** BCP 47 language for dictation. */
18
+ lang?: string;
19
+ disabled?: boolean;
20
+ autoFocus?: boolean;
21
+ }
22
+ /**
23
+ * The large rounded input an assistant starts from: auto-growing text, chips while typing, suggestion pills and the
24
+ * DictationButton (mic, stop, send). The focus ring is on the whole box.
25
+ */
26
+ declare function AskBox({ value, onValueChange, onSubmit, placeholder, chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, className, ...props }: AskBoxProps): React.JSX.Element;
27
+ export { AskBox };
@@ -0,0 +1,85 @@
1
+ "use client";
2
+ import { DictationButton } from "./dictation.js";
3
+ import { Button } from "../ui/button.js";
4
+ import { cn } from "../../lib/utils.js";
5
+ import * as React from "react";
6
+ import { jsx, jsxs } from "react/jsx-runtime";
7
+ // src/components/ai/ask-box.tsx
8
+ const noSpeech = {
9
+ isSupported: () => false,
10
+ start: () => () => {}
11
+ };
12
+ /**
13
+ * The large rounded input an assistant starts from: auto-growing text, chips while typing, suggestion pills and the
14
+ * DictationButton (mic, stop, send). The focus ring is on the whole box.
15
+ */
16
+ function AskBox({ value, onValueChange, onSubmit, placeholder = "Ask anything", chips, suggestions, onSuggestion, dictation, lang, disabled, autoFocus, className, ...props }) {
17
+ const id = React.useId();
18
+ const submit = () => {
19
+ const text = value.trim();
20
+ if (text && !disabled) onSubmit(text);
21
+ };
22
+ const hasText = value.trim().length > 0;
23
+ return /* @__PURE__ */ jsxs("form", {
24
+ "data-slot": "ask-box",
25
+ className: cn("flex w-full min-w-0 flex-col gap-3", className),
26
+ onSubmit: (e) => {
27
+ e.preventDefault();
28
+ submit();
29
+ },
30
+ ...props,
31
+ children: [/* @__PURE__ */ jsxs("div", {
32
+ className: "focus-box flex flex-col gap-2 rounded-3xl border border-transparent bg-muted py-2.5 pr-2.5 pl-5",
33
+ children: [/* @__PURE__ */ jsxs("div", {
34
+ className: "flex items-end gap-2",
35
+ children: [
36
+ /* @__PURE__ */ jsx("label", {
37
+ htmlFor: id,
38
+ className: "sr-only",
39
+ children: placeholder
40
+ }),
41
+ /* @__PURE__ */ jsx("textarea", {
42
+ id,
43
+ rows: 1,
44
+ value,
45
+ disabled,
46
+ autoFocus,
47
+ placeholder,
48
+ onChange: (e) => onValueChange(e.target.value),
49
+ onKeyDown: (e) => {
50
+ if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
51
+ e.preventDefault();
52
+ submit();
53
+ }
54
+ },
55
+ className: "field-sizing-content max-h-48 min-h-10 min-w-0 flex-1 resize-none border-0 bg-transparent py-2 text-base leading-6 text-foreground outline-none placeholder:text-muted-foreground"
56
+ }),
57
+ /* @__PURE__ */ jsx(DictationButton, {
58
+ value,
59
+ disabled,
60
+ source: dictation === false ? noSpeech : dictation,
61
+ lang,
62
+ onTranscript: (text) => onValueChange(value ? `${value.replace(/\s+$/, "")} ${text}` : text)
63
+ })
64
+ ]
65
+ }), hasText && chips && /* @__PURE__ */ jsx("div", {
66
+ "data-slot": "ask-box-chips",
67
+ className: "flex flex-wrap gap-1.5 pb-1",
68
+ children: chips
69
+ })]
70
+ }), suggestions && suggestions.length > 0 && /* @__PURE__ */ jsx("div", {
71
+ "data-slot": "ask-box-suggestions",
72
+ className: "flex flex-wrap gap-2",
73
+ children: suggestions.map((s) => /* @__PURE__ */ jsx(Button, {
74
+ type: "button",
75
+ variant: "outline",
76
+ size: "sm",
77
+ shape: "pill",
78
+ disabled,
79
+ onClick: () => onSuggestion ? onSuggestion(s) : onSubmit(s),
80
+ children: s
81
+ }, s))
82
+ })]
83
+ });
84
+ }
85
+ export { AskBox };
@@ -0,0 +1,94 @@
1
+ import { SpeechSource } from "./dictation.js";
2
+ import * as React from "react";
3
+ export interface AssistantMessage {
4
+ id: string;
5
+ role: "user" | "assistant";
6
+ /** Text or nodes: Prose, ActionReceipts, ToolSteps. */
7
+ content: React.ReactNode;
8
+ }
9
+ /** What the open page tells the assistant: use it as context, and its suggestions fill the dock's empty state. */
10
+ export interface AssistantPage {
11
+ /** Stable key for the page, e.g. the route. */
12
+ id: string;
13
+ title?: string;
14
+ /** Anything the send handler needs: the open list, the selected record. */
15
+ data?: unknown;
16
+ suggestions?: string[];
17
+ }
18
+ export interface AssistantSendContext {
19
+ page: AssistantPage | null;
20
+ /** The conversation before this message. */
21
+ messages: AssistantMessage[];
22
+ /** Add a message (a streamed reply, a receipt) while the handler runs. Returns its id. */
23
+ append: (message: Omit<AssistantMessage, "id"> & {
24
+ id?: string;
25
+ }) => string;
26
+ /** Replace a message's content, e.g. while a reply streams in. */
27
+ update: (id: string, content: React.ReactNode) => void;
28
+ }
29
+ export interface AssistantContextValue {
30
+ open: boolean;
31
+ setOpen: (open: boolean) => void;
32
+ toggle: () => void;
33
+ messages: AssistantMessage[];
34
+ /** True while the send handler runs. */
35
+ pending: boolean;
36
+ page: AssistantPage | null;
37
+ setPage: (page: AssistantPage | null) => void;
38
+ /** Adds the user message, opens the dock and runs the app's onSend. */
39
+ send: (text: string) => Promise<void>;
40
+ append: AssistantSendContext["append"];
41
+ update: AssistantSendContext["update"];
42
+ clear: () => void;
43
+ }
44
+ export interface AssistantProviderProps {
45
+ /**
46
+ * The app's send handler. Return the reply (text or nodes) to append it, or call `append` / `update` yourself
47
+ * (for streaming or receipts) and return nothing. A thrown error is shown as the reply.
48
+ */
49
+ onSend: (text: string, context: AssistantSendContext) => React.ReactNode | Promise<React.ReactNode>;
50
+ initialMessages?: AssistantMessage[];
51
+ defaultOpen?: boolean;
52
+ children?: React.ReactNode;
53
+ }
54
+ /**
55
+ * Keeps one assistant conversation across pages: open state, messages, the open page as context, and the app's
56
+ * send handler. Mount it above the router outlet; read it with useAssistant().
57
+ */
58
+ declare function AssistantProvider({ onSend, initialMessages, defaultOpen, children }: AssistantProviderProps): React.JSX.Element;
59
+ /** The assistant state from AssistantProvider. Throws outside one. */
60
+ export declare function useAssistant(): AssistantContextValue;
61
+ /**
62
+ * Tells the assistant which page is open, for as long as the calling component is mounted. The page is replaced
63
+ * when `page.id` changes; other fields are read at send time.
64
+ */
65
+ export declare function useAssistantPage(page: AssistantPage): void;
66
+ export interface AssistantDockProps extends Omit<React.ComponentProps<"section">, "title"> {
67
+ /** Assistant name in the header and launcher, e.g. "HomeBot". Default "Assistant". */
68
+ title?: string;
69
+ /** Header and launcher icon. Default a sparkle. */
70
+ icon?: React.ReactNode;
71
+ /** Link to the full-screen chat. Shows an expand button in the header. */
72
+ fullScreenHref?: string;
73
+ /** Empty state heading. Default "How can I help?". */
74
+ emptyTitle?: React.ReactNode;
75
+ /** Empty state line under the heading. */
76
+ emptyDescription?: React.ReactNode;
77
+ /** Suggestion pills in the empty state. Default: the open page's suggestions. */
78
+ suggestions?: string[];
79
+ /** Composer placeholder. Default `Ask ${title}`. */
80
+ placeholder?: string;
81
+ /** Speech source for the mic. Default the browser's; `false` hides the mic. */
82
+ dictation?: SpeechSource | false;
83
+ /** Show the floating launcher button while closed. Default true. Turn off when another control opens it. */
84
+ launcher?: boolean;
85
+ /** Label of the launcher. Default `Ask ${title}`. */
86
+ launcherLabel?: string;
87
+ }
88
+ /**
89
+ * The assistant as a floating panel over the page (it never reflows the page): header, empty state with
90
+ * suggestions, message list, AskBox. A soft launcher button opens it. Escape closes it. Needs AssistantProvider.
91
+ * Sits above AppNav's phone tab bar.
92
+ */
93
+ declare function AssistantDock({ title, icon, fullScreenHref, emptyTitle, emptyDescription, suggestions, placeholder, dictation, launcher, launcherLabel, className, ...props }: AssistantDockProps): React.JSX.Element | null;
94
+ export { AssistantDock, AssistantProvider };
@@ -0,0 +1,229 @@
1
+ "use client";
2
+ import { AskBox } from "./ask-box.js";
3
+ import { AssistantAvatar, ChatMessage, ChatThread, TypingIndicator } from "./chat.js";
4
+ import { Button } from "../ui/button.js";
5
+ import { cn } from "../../lib/utils.js";
6
+ import * as React from "react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import { Maximize2Icon, SparklesIcon, XIcon } from "lucide-react";
9
+ // src/components/ai/assistant.tsx
10
+ const AssistantContext = React.createContext(null);
11
+ let nextId = 0;
12
+ const newId = () => `m${Date.now().toString(36)}${(nextId++).toString(36)}`;
13
+ /**
14
+ * Keeps one assistant conversation across pages: open state, messages, the open page as context, and the app's
15
+ * send handler. Mount it above the router outlet; read it with useAssistant().
16
+ */
17
+ function AssistantProvider({ onSend, initialMessages = [], defaultOpen = false, children }) {
18
+ const [open, setOpen] = React.useState(defaultOpen);
19
+ const [messages, setMessages] = React.useState(initialMessages);
20
+ const [pending, setPending] = React.useState(false);
21
+ const [page, setPage] = React.useState(null);
22
+ const messagesRef = React.useRef(messages);
23
+ messagesRef.current = messages;
24
+ const pageRef = React.useRef(page);
25
+ pageRef.current = page;
26
+ const onSendRef = React.useRef(onSend);
27
+ onSendRef.current = onSend;
28
+ const append = React.useCallback((m) => {
29
+ const id = m.id ?? newId();
30
+ setMessages((list) => [...list, {
31
+ ...m,
32
+ id
33
+ }]);
34
+ return id;
35
+ }, []);
36
+ const update = React.useCallback((id, content) => {
37
+ setMessages((list) => list.map((m) => m.id === id ? {
38
+ ...m,
39
+ content
40
+ } : m));
41
+ }, []);
42
+ const send = React.useCallback(async (text) => {
43
+ const before = messagesRef.current;
44
+ append({
45
+ role: "user",
46
+ content: text
47
+ });
48
+ setOpen(true);
49
+ setPending(true);
50
+ try {
51
+ const reply = await onSendRef.current(text, {
52
+ page: pageRef.current,
53
+ messages: before,
54
+ append,
55
+ update
56
+ });
57
+ if (reply !== void 0 && reply !== null && reply !== false) append({
58
+ role: "assistant",
59
+ content: reply
60
+ });
61
+ } catch (e) {
62
+ append({
63
+ role: "assistant",
64
+ content: e instanceof Error ? e.message : "Something went wrong."
65
+ });
66
+ } finally {
67
+ setPending(false);
68
+ }
69
+ }, [append, update]);
70
+ const value = React.useMemo(() => ({
71
+ open,
72
+ setOpen,
73
+ toggle: () => setOpen((v) => !v),
74
+ messages,
75
+ pending,
76
+ page,
77
+ setPage,
78
+ send,
79
+ append,
80
+ update,
81
+ clear: () => setMessages([])
82
+ }), [
83
+ open,
84
+ messages,
85
+ pending,
86
+ page,
87
+ send,
88
+ append,
89
+ update
90
+ ]);
91
+ return /* @__PURE__ */ jsx(AssistantContext.Provider, {
92
+ value,
93
+ children
94
+ });
95
+ }
96
+ /** The assistant state from AssistantProvider. Throws outside one. */
97
+ function useAssistant() {
98
+ const ctx = React.useContext(AssistantContext);
99
+ if (!ctx) throw new Error("useAssistant must be used inside <AssistantProvider>");
100
+ return ctx;
101
+ }
102
+ /**
103
+ * Tells the assistant which page is open, for as long as the calling component is mounted. The page is replaced
104
+ * when `page.id` changes; other fields are read at send time.
105
+ */
106
+ function useAssistantPage(page) {
107
+ const { setPage } = useAssistant();
108
+ const latest = React.useRef(page);
109
+ latest.current = page;
110
+ React.useEffect(() => {
111
+ setPage(latest.current);
112
+ return () => setPage(null);
113
+ }, [page.id, setPage]);
114
+ }
115
+ /**
116
+ * The assistant as a floating panel over the page (it never reflows the page): header, empty state with
117
+ * suggestions, message list, AskBox. A soft launcher button opens it. Escape closes it. Needs AssistantProvider.
118
+ * Sits above AppNav's phone tab bar.
119
+ */
120
+ function AssistantDock({ title = "Assistant", icon = /* @__PURE__ */ jsx(SparklesIcon, {}), fullScreenHref, emptyTitle = "How can I help?", emptyDescription, suggestions, placeholder, dictation, launcher = true, launcherLabel, className, ...props }) {
121
+ const { open, setOpen, messages, pending, page, send } = useAssistant();
122
+ const [draft, setDraft] = React.useState("");
123
+ const titleId = React.useId();
124
+ const panelRef = React.useRef(null);
125
+ const launcherRef = React.useRef(null);
126
+ const wasOpen = React.useRef(open);
127
+ const pills = suggestions ?? page?.suggestions ?? [];
128
+ React.useEffect(() => {
129
+ if (open && !wasOpen.current) panelRef.current?.querySelector("textarea")?.focus();
130
+ if (!open && wasOpen.current) launcherRef.current?.focus();
131
+ wasOpen.current = open;
132
+ }, [open]);
133
+ const submit = (text) => {
134
+ if (pending) return;
135
+ setDraft("");
136
+ panelRef.current?.querySelector("textarea")?.focus();
137
+ send(text);
138
+ };
139
+ if (!open) {
140
+ if (!launcher) return null;
141
+ return /* @__PURE__ */ jsxs("button", {
142
+ ref: launcherRef,
143
+ type: "button",
144
+ "data-slot": "assistant-launcher",
145
+ "aria-expanded": false,
146
+ onClick: () => setOpen(true),
147
+ className: "fixed right-4 bottom-[calc(var(--hb-bottom-inset,0px)+var(--hb-fab-inset,0px)+1rem)] z-40 flex h-12 items-center gap-2 rounded-full border border-border bg-card pr-5 pl-2 text-sm font-medium text-foreground shadow-card outline-none hover:bg-muted focus-visible:ring-4 focus-visible:ring-ring/20 md:right-6 md:bottom-6",
148
+ children: [/* @__PURE__ */ jsx(AssistantAvatar, { icon }), launcherLabel ?? `Ask ${title}`]
149
+ });
150
+ }
151
+ return /* @__PURE__ */ jsxs("section", {
152
+ ref: panelRef,
153
+ "data-slot": "assistant-dock",
154
+ role: "dialog",
155
+ "aria-modal": "false",
156
+ "aria-labelledby": titleId,
157
+ onKeyDown: (e) => {
158
+ if (e.key === "Escape") {
159
+ e.stopPropagation();
160
+ setOpen(false);
161
+ }
162
+ },
163
+ className: cn("fixed inset-x-2 bottom-[calc(var(--hb-bottom-inset,0px)+0.5rem)] z-40 flex h-[min(40rem,calc(100dvh-var(--hb-bottom-inset,0px)-1rem))] flex-col overflow-hidden rounded-2xl border border-border bg-card shadow-card duration-200 animate-in fade-in slide-in-from-bottom-4 motion-reduce:animate-none sm:inset-x-auto sm:right-4 sm:w-104 md:right-6 md:bottom-6 md:h-[min(40rem,calc(100dvh-3rem))]", className),
164
+ ...props,
165
+ children: [
166
+ /* @__PURE__ */ jsxs("header", {
167
+ className: "flex items-center gap-2.5 py-2.5 pr-2.5 pl-4",
168
+ children: [
169
+ /* @__PURE__ */ jsx(AssistantAvatar, { icon }),
170
+ /* @__PURE__ */ jsx("h2", {
171
+ id: titleId,
172
+ className: "min-w-0 flex-1 truncate text-[15px] font-semibold",
173
+ children: title
174
+ }),
175
+ fullScreenHref && /* @__PURE__ */ jsx(Button, {
176
+ asChild: true,
177
+ variant: "ghost",
178
+ size: "icon",
179
+ shape: "pill",
180
+ children: /* @__PURE__ */ jsx("a", {
181
+ href: fullScreenHref,
182
+ "aria-label": "Open full screen",
183
+ children: /* @__PURE__ */ jsx(Maximize2Icon, {})
184
+ })
185
+ }),
186
+ /* @__PURE__ */ jsx(Button, {
187
+ variant: "ghost",
188
+ size: "icon",
189
+ shape: "pill",
190
+ "aria-label": `Close ${title}`,
191
+ onClick: () => setOpen(false),
192
+ children: /* @__PURE__ */ jsx(XIcon, {})
193
+ })
194
+ ]
195
+ }),
196
+ messages.length === 0 ? /* @__PURE__ */ jsxs("div", {
197
+ "data-slot": "assistant-dock-empty",
198
+ className: "flex flex-1 flex-col justify-end gap-1 overflow-y-auto px-5 pb-3",
199
+ children: [/* @__PURE__ */ jsx("p", {
200
+ className: "text-lg font-semibold",
201
+ children: emptyTitle
202
+ }), emptyDescription && /* @__PURE__ */ jsx("p", {
203
+ className: "text-sm text-muted-foreground",
204
+ children: emptyDescription
205
+ })]
206
+ }) : /* @__PURE__ */ jsxs(ChatThread, {
207
+ className: "px-4 pt-2",
208
+ contentClassName: "gap-5",
209
+ children: [messages.map((m) => /* @__PURE__ */ jsx(ChatMessage, {
210
+ role: m.role,
211
+ avatar: false,
212
+ children: m.content
213
+ }, m.id)), pending && /* @__PURE__ */ jsx(TypingIndicator, { label: "Working" })]
214
+ }),
215
+ /* @__PURE__ */ jsx("div", {
216
+ className: "px-3 pt-2 pb-3",
217
+ children: /* @__PURE__ */ jsx(AskBox, {
218
+ value: draft,
219
+ onValueChange: setDraft,
220
+ onSubmit: submit,
221
+ placeholder: placeholder ?? `Ask ${title}`,
222
+ suggestions: messages.length === 0 ? pills : void 0,
223
+ dictation
224
+ })
225
+ })
226
+ ]
227
+ });
228
+ }
229
+ export { AssistantDock, AssistantProvider, useAssistant, useAssistantPage };
@@ -0,0 +1,57 @@
1
+ import * as React from "react";
2
+ export interface SpeechSourceHandlers {
3
+ /** Recognised text. `final` is false for interim guesses that may still change. */
4
+ onResult: (text: string, final: boolean) => void;
5
+ /** Listening stopped (silence, stop(), or an error). */
6
+ onEnd: () => void;
7
+ onError: (error: string) => void;
8
+ }
9
+ /**
10
+ * Where dictation comes from. The default is `browserSpeechSource` (the Web Speech API); pass your own to use a
11
+ * server model: start recording in `start`, call the handlers, and return a function that stops.
12
+ */
13
+ export interface SpeechSource {
14
+ isSupported: () => boolean;
15
+ start: (handlers: SpeechSourceHandlers, options: {
16
+ lang?: string;
17
+ }) => () => void;
18
+ }
19
+ /** SpeechSource on the browser's SpeechRecognition API. Unsupported (Firefox, most WebViews) means no mic button. */
20
+ export declare const browserSpeechSource: SpeechSource;
21
+ export interface UseDictationOptions {
22
+ /** Default browserSpeechSource. */
23
+ source?: SpeechSource;
24
+ /** BCP 47 language, e.g. "en-CA". Default: the browser's. */
25
+ lang?: string;
26
+ /** Called with each final transcript. */
27
+ onTranscript: (text: string) => void;
28
+ }
29
+ /** Dictation state for a custom control: `{ supported, listening, interim, error, start, stop }`. */
30
+ export declare function useDictation({ source, lang, onTranscript }: UseDictationOptions): {
31
+ supported: boolean;
32
+ listening: boolean;
33
+ interim: string;
34
+ error: string | null;
35
+ start: () => void;
36
+ stop: () => void;
37
+ };
38
+ export interface DictationButtonProps extends Omit<React.ComponentProps<"div">, "onSubmit"> {
39
+ /** The composer's current text. Empty shows the mic; text shows send. */
40
+ value: string;
41
+ /** Final transcripts, to append to the text. */
42
+ onTranscript: (text: string) => void;
43
+ /** Send. Without it, the send button is a submit button for the surrounding form. */
44
+ onSend?: () => void;
45
+ source?: SpeechSource;
46
+ lang?: string;
47
+ disabled?: boolean;
48
+ /** Default "Send message". */
49
+ sendLabel?: string;
50
+ }
51
+ /**
52
+ * One round button for a composer: a mic when empty, stop (with listening bars) while listening, send once there is
53
+ * text. Announces "Listening" in a polite live region. Without a supported speech source the mic is hidden and the
54
+ * button is just send.
55
+ */
56
+ declare function DictationButton({ value, onTranscript, onSend, source, lang, disabled, sendLabel, className, ...props }: DictationButtonProps): React.JSX.Element;
57
+ export { DictationButton };