@horon-ai/react 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/package.json +2 -2
  2. package/src/components/agent-activity.tsx +81 -11
  3. package/src/components/agent-lane.tsx +32 -3
  4. package/src/components/agent-lanes-timeline.tsx +85 -7
  5. package/src/components/agent-panel.tsx +53 -7
  6. package/src/components/agent-steering-panel.tsx +73 -21
  7. package/src/components/approval-card.tsx +34 -2
  8. package/src/components/avatar.tsx +20 -4
  9. package/src/components/badge.tsx +24 -3
  10. package/src/components/banner.tsx +19 -3
  11. package/src/components/bot-instructions-panel.tsx +92 -9
  12. package/src/components/bot-list-row.tsx +13 -1
  13. package/src/components/bot-memory-panel.tsx +58 -3
  14. package/src/components/bottom-sheet.tsx +61 -7
  15. package/src/components/breadcrumb-header.tsx +32 -2
  16. package/src/components/breadcrumb.tsx +31 -1
  17. package/src/components/button.tsx +46 -1
  18. package/src/components/calendar.tsx +76 -6
  19. package/src/components/card.tsx +30 -1
  20. package/src/components/chat-bubble.tsx +32 -3
  21. package/src/components/chat-controls.tsx +60 -7
  22. package/src/components/checkbox.tsx +22 -1
  23. package/src/components/chip.tsx +29 -2
  24. package/src/components/choice-card.tsx +25 -1
  25. package/src/components/code-panel.tsx +28 -3
  26. package/src/components/combobox.tsx +52 -2
  27. package/src/components/command-palette.tsx +89 -10
  28. package/src/components/composer.tsx +28 -1
  29. package/src/components/data-table.tsx +198 -16
  30. package/src/components/date-field.tsx +31 -2
  31. package/src/components/dialog.tsx +45 -3
  32. package/src/components/directory-card.tsx +23 -2
  33. package/src/components/document-extract-panel.tsx +83 -5
  34. package/src/components/empty-state.tsx +21 -2
  35. package/src/components/field.tsx +49 -3
  36. package/src/components/file-drop.tsx +39 -4
  37. package/src/components/filter-bar.tsx +39 -2
  38. package/src/components/flue-background.tsx +91 -21
  39. package/src/components/horon-icon.tsx +50 -3
  40. package/src/components/horon-wordmark.tsx +26 -1
  41. package/src/components/icon-button.tsx +38 -1
  42. package/src/components/input-group.tsx +57 -6
  43. package/src/components/input.tsx +48 -2
  44. package/src/components/integration-row.tsx +43 -2
  45. package/src/components/kbd.tsx +37 -4
  46. package/src/components/knowledge-base-panel.tsx +40 -2
  47. package/src/components/liquid-glass.tsx +16 -0
  48. package/src/components/list-detail.tsx +89 -7
  49. package/src/components/marketplace-card.tsx +40 -4
  50. package/src/components/mcp-server-row.tsx +49 -3
  51. package/src/components/menu.tsx +83 -3
  52. package/src/components/message-extras.tsx +54 -18
  53. package/src/components/modal.tsx +71 -4
  54. package/src/components/orchestrator-panel.tsx +48 -4
  55. package/src/components/page-header.tsx +26 -3
  56. package/src/components/plan-card.tsx +28 -1
  57. package/src/components/plan-step.tsx +41 -1
  58. package/src/components/plugin-row.tsx +61 -3
  59. package/src/components/progress.tsx +32 -2
  60. package/src/components/radio-group.tsx +55 -2
  61. package/src/components/research-run-panel.tsx +73 -3
  62. package/src/components/schedule.tsx +163 -12
  63. package/src/components/segmented-control.tsx +58 -3
  64. package/src/components/select.tsx +60 -0
  65. package/src/components/settings-list.tsx +66 -8
  66. package/src/components/sidebar.tsx +214 -23
  67. package/src/components/skeleton.tsx +61 -4
  68. package/src/components/slider.tsx +30 -0
  69. package/src/components/source-row.tsx +43 -4
  70. package/src/components/spinner.tsx +29 -2
  71. package/src/components/split-button.tsx +37 -3
  72. package/src/components/switch.tsx +31 -1
  73. package/src/components/tab-bar.tsx +48 -3
  74. package/src/components/tabs.tsx +59 -5
  75. package/src/components/task-row.tsx +49 -3
  76. package/src/components/textarea-field.tsx +36 -2
  77. package/src/components/textarea.tsx +35 -2
  78. package/src/components/toast.tsx +52 -3
  79. package/src/components/tool-call.tsx +91 -17
  80. package/src/components/tool-calls-header.tsx +32 -1
  81. package/src/components/tool-permissions.tsx +50 -2
  82. package/src/components/tooltip.tsx +33 -1
  83. package/src/components/top-bar.tsx +35 -2
  84. package/src/components/usage-meter.tsx +28 -2
  85. package/src/components/workspace-menu.tsx +46 -1
  86. package/src/hooks/index.ts +8 -0
  87. package/src/{lib → hooks}/use-active-indicator.ts +44 -1
  88. package/src/hooks/use-color-scheme.ts +50 -0
  89. package/src/hooks/use-copy-to-clipboard.ts +55 -0
  90. package/src/hooks/use-device.ts +69 -0
  91. package/src/hooks/use-hotkey.ts +56 -0
  92. package/src/hooks/use-media-query.ts +43 -0
  93. package/src/hooks/use-platform.ts +68 -0
  94. package/src/hooks/use-reduced-motion.ts +25 -0
  95. package/src/index.ts +44 -0
  96. package/src/lib/cn.ts +13 -1
  97. package/src/lib/cursor-ghost.ts +16 -6
  98. package/src/lib/platform.ts +236 -0
@@ -1,6 +1,18 @@
1
1
  import { type RefObject, useEffect, useLayoutEffect, useState } from "react";
2
2
 
3
- export type IndicatorRect = { left: number; top: number; width: number; height: number };
3
+ /**
4
+ * Position of the active item inside its track, in CSS px, taken from the item's `offset*` readings.
5
+ */
6
+ export type IndicatorRect = {
7
+ /** Left offset measured from the track, as `offsetLeft` reports it. */
8
+ left: number;
9
+ /** Top offset measured from the track, as `offsetTop` reports it. */
10
+ top: number;
11
+ /** Width of the active item. */
12
+ width: number;
13
+ /** Height of the active item. */
14
+ height: number;
15
+ };
4
16
 
5
17
  /**
6
18
  * Measures the active item inside a positioned track so one indicator can slide
@@ -8,6 +20,37 @@ export type IndicatorRect = { left: number; top: number; width: number; height:
8
20
  * or `[aria-current="page"]`); the rect follows attribute changes and resizes.
9
21
  * `animate` turns true one frame after the first placement, so the indicator
10
22
  * lands in place instead of sliding in from 0,0.
23
+ *
24
+ * Measuring happens in a layout effect, so a server render draws no indicator: `rect` starts `null`, and the
25
+ * first client pass places the item before paint while `animate` stays false.
26
+ *
27
+ * @param track - Ref to the positioned track element that holds the items.
28
+ * @param selector - CSS selector for the active item, queried inside the track; a missing item yields a null rect.
29
+ * @returns The measured rect (or `null` before the first measurement) and an `animate` flag for the first frame.
30
+ *
31
+ * @example
32
+ * ```tsx
33
+ * import { useActiveIndicator } from "@horon-ai/react";
34
+ *
35
+ * function Tabs({ items, value }: { items: string[]; value: string }) {
36
+ * const track = useRef<HTMLDivElement>(null);
37
+ * const { rect, animate } = useActiveIndicator(track, '[aria-current="page"]');
38
+ * return (
39
+ * <div ref={track} className="relative flex gap-1">
40
+ * {items.map((item) => (
41
+ * <button key={item} aria-current={item === value ? "page" : undefined}>
42
+ * {item}
43
+ * </button>
44
+ * ))}
45
+ * <span
46
+ * aria-hidden
47
+ * className={animate ? "pointer-events-none absolute rounded-full motion-indicator" : "pointer-events-none absolute rounded-full"}
48
+ * style={{ ...rect, opacity: rect ? 1 : 0 }}
49
+ * />
50
+ * </div>
51
+ * );
52
+ * }
53
+ * ```
11
54
  */
12
55
  export function useActiveIndicator(track: RefObject<HTMLElement | null>, selector: string) {
13
56
  const [rect, setRect] = useState<IndicatorRect | null>(null);
@@ -0,0 +1,50 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ /** The two schemes the `.dark` class switch can report. */
4
+ export type ColorScheme = "light" | "dark";
5
+
6
+ const noop = () => {};
7
+
8
+ /**
9
+ * The theme is stored as the `.dark` class on <html> (the site's ThemeToggle
10
+ * writes it), so that class is the only source — `prefers-color-scheme` is not
11
+ * consulted.
12
+ */
13
+ function subscribe(onChange: () => void): () => void {
14
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return noop;
15
+ const observer = new MutationObserver(onChange);
16
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
17
+ return () => observer.disconnect();
18
+ }
19
+
20
+ function readIsDark(): boolean {
21
+ if (typeof document === "undefined") return false;
22
+ return document.documentElement.classList.contains("dark");
23
+ }
24
+
25
+ /**
26
+ * The active color scheme; `light` on the server and before hydration.
27
+ *
28
+ * The `.dark` class on <html> is the only source, watched with a `MutationObserver`, so a toggle anywhere on the
29
+ * page re-renders every caller. `prefers-color-scheme` is deliberately ignored: the theme is an explicit switch,
30
+ * not an OS follow. A server render reports `light` until the class is read on the client.
31
+ *
32
+ * @returns `dark` while the root element carries the `.dark` class, `light` otherwise.
33
+ *
34
+ * @example
35
+ * ```tsx
36
+ * import { useColorScheme } from "@horon-ai/react";
37
+ *
38
+ * function ThemeToggle() {
39
+ * const scheme = useColorScheme();
40
+ * return (
41
+ * <Button variant="ghost" onClick={() => document.documentElement.classList.toggle("dark")}>
42
+ * {scheme === "dark" ? "Light" : "Dark"}
43
+ * </Button>
44
+ * );
45
+ * }
46
+ * ```
47
+ */
48
+ export function useColorScheme(): ColorScheme {
49
+ return useSyncExternalStore(subscribe, readIsDark, () => false) ? "dark" : "light";
50
+ }
@@ -0,0 +1,55 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ /** What {@link useCopyToClipboard} returns: the transient flag and the write function. */
4
+ export type CopyToClipboard = {
5
+ /** True from a successful write until `resetMs` has passed; stays false when the write fails. */
6
+ copied: boolean;
7
+ /** Writes `text`; a failed write leaves `copied` false instead of throwing. */
8
+ copy: (text: string) => void;
9
+ };
10
+
11
+ /**
12
+ * Clipboard writes with a self-clearing confirmation: `copied` turns true when
13
+ * the write resolves and back to false after `resetMs`, so a button needs no
14
+ * timer of its own.
15
+ *
16
+ * The write goes through `navigator.clipboard`, so it needs a secure context and a user gesture; where that API
17
+ * is missing the call is a no-op and `copied` never turns true. The timer is cleared when the component unmounts
18
+ * or `resetMs` changes.
19
+ *
20
+ * @param resetMs - How long `copied` stays true after a successful write, in milliseconds. @default 1500
21
+ * @returns A {@link CopyToClipboard}: the current `copied` flag and a stable `copy` function.
22
+ *
23
+ * @example
24
+ * ```tsx
25
+ * import { useCopyToClipboard } from "@horon-ai/react";
26
+ *
27
+ * function CopyRow({ value }: { value: string }) {
28
+ * const { copied, copy } = useCopyToClipboard();
29
+ * return (
30
+ * <Button variant="ghost" onClick={() => copy(value)}>
31
+ * {copied ? "Copied" : "Copy"}
32
+ * </Button>
33
+ * );
34
+ * }
35
+ * ```
36
+ */
37
+ export function useCopyToClipboard(resetMs = 1500): CopyToClipboard {
38
+ const [copied, setCopied] = useState(false);
39
+
40
+ useEffect(() => {
41
+ if (!copied) return undefined;
42
+ const timer = setTimeout(() => setCopied(false), resetMs);
43
+ return () => clearTimeout(timer);
44
+ }, [copied, resetMs]);
45
+
46
+ const copy = useCallback((text: string) => {
47
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
48
+ navigator.clipboard.writeText(text).then(
49
+ () => setCopied(true),
50
+ () => undefined,
51
+ );
52
+ }, []);
53
+
54
+ return { copied, copy };
55
+ }
@@ -0,0 +1,69 @@
1
+ import { useMediaQuery } from "./use-media-query";
2
+
3
+ /** The three layout classes: `mobile` under 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
4
+ export type DeviceType = "mobile" | "tablet" | "desktop";
5
+
6
+ /** The primary input behind the pointer: `touch` for a coarse pointer, `pointer` for a fine one. */
7
+ export type DeviceInput = "touch" | "pointer";
8
+
9
+ /** What the layout and the key hints need: the device class, the input behind it and whether a key chip can be reached. */
10
+ export type Device = {
11
+ /** `mobile` below 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
12
+ type: DeviceType;
13
+ /** `touch` when the pointer is coarse, `pointer` when it is fine. */
14
+ input: DeviceInput;
15
+ /** True only for a hovering fine pointer, the input that can actually reach a key chip. */
16
+ keyboardHints: boolean;
17
+ };
18
+
19
+ const MOBILE_QUERY = "(max-width: 767px)";
20
+ const COARSE_QUERY = "(pointer: coarse)";
21
+ const HINTS_QUERY = "(hover: hover) and (pointer: fine)";
22
+
23
+ /**
24
+ * `useSyncExternalStore` compares snapshots by reference, so each result is
25
+ * built once and handed out again.
26
+ */
27
+ const devices = new Map<string, Device>();
28
+
29
+ function deviceSnapshot(type: DeviceType, input: DeviceInput, keyboardHints: boolean): Device {
30
+ const key = `${type}:${input}:${keyboardHints}`;
31
+ const cached = devices.get(key);
32
+ if (cached) return cached;
33
+ const next = { type, input, keyboardHints };
34
+ devices.set(key, next);
35
+ return next;
36
+ }
37
+
38
+ /**
39
+ * The device class and its primary input, as the layout and key hints need them.
40
+ *
41
+ * The three queries go through {@link useMediaQuery}, so nothing is read during a server render or the first
42
+ * hydration pass: that pass reports `desktop` / `pointer` with `keyboardHints` on, the value the query hooks
43
+ * were given as their server fallback. The returned object is shared, so a re-render with the same device
44
+ * hands back the same reference.
45
+ *
46
+ * @returns The current `Device`: `type`, `input` and `keyboardHints`.
47
+ *
48
+ * @example
49
+ * ```tsx
50
+ * import { useDevice } from "@horon-ai/react";
51
+ *
52
+ * function ComposeFooter() {
53
+ * const { type, keyboardHints } = useDevice();
54
+ * return (
55
+ * <footer className="flex items-center gap-2">
56
+ * <span className="text-caption">{type}</span>
57
+ * {keyboardHints && <Kbd keys="Mod+Enter" />}
58
+ * </footer>
59
+ * );
60
+ * }
61
+ * ```
62
+ */
63
+ export function useDevice(): Device {
64
+ const mobile = useMediaQuery(MOBILE_QUERY);
65
+ const coarse = useMediaQuery(COARSE_QUERY);
66
+ const keyboardHints = useMediaQuery(HINTS_QUERY, true);
67
+ const type: DeviceType = mobile ? "mobile" : coarse ? "tablet" : "desktop";
68
+ return deviceSnapshot(type, coarse ? "touch" : "pointer", keyboardHints);
69
+ }
@@ -0,0 +1,56 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import { matchesShortcut } from "../lib/platform";
4
+ import { usePlatform } from "./use-platform";
5
+
6
+ /** Options of {@link useHotkey}: the only switch is whether the listener is attached. */
7
+ export type HotkeyOptions = {
8
+ /** When false the listener is detached and the hotkey does nothing (default true). */
9
+ enabled?: boolean;
10
+ };
11
+
12
+ /**
13
+ * Runs `handler` on a `document` keydown that matches `keys`, such as `Mod+K`,
14
+ * and prevents the browser default. Text inputs keep their focus, so a palette
15
+ * shortcut still fires mid-typing; the check follows the OS modifier key.
16
+ *
17
+ * The listener is attached in an effect, so a server render binds nothing. The handler is held in a ref, so a
18
+ * caller can pass an inline arrow without re-binding on every render; only `keys`, `enabled` and the OS
19
+ * re-subscribe. `Mod` follows {@link usePlatform}: ⌘ on macOS and iOS, Ctrl on Windows, Linux and Android,
20
+ * and either one while the OS is still unknown.
21
+ *
22
+ * @param keys - Shortcut string such as `Mod+K`, `Mod+Shift+Enter` or `Escape`, in the same syntax `Kbd` draws.
23
+ * @param handler - Called with the matching keydown event after `preventDefault()`.
24
+ * @param options - {@link HotkeyOptions}; `enabled` defaults to `true`.
25
+ * @returns Nothing; the listener is cleaned up when the component unmounts or `keys`/`enabled` change.
26
+ *
27
+ * @example
28
+ * ```tsx
29
+ * import { useHotkey } from "@horon-ai/react";
30
+ *
31
+ * function PaletteShortcut({ open }: { open: () => void }) {
32
+ * useHotkey("Mod+K", open);
33
+ * return <Kbd keys="Mod+K" />;
34
+ * }
35
+ * ```
36
+ */
37
+ export function useHotkey(keys: string, handler: (event: KeyboardEvent) => void, options: HotkeyOptions = {}) {
38
+ const { enabled = true } = options;
39
+ const platform = usePlatform();
40
+ const handlerRef = useRef(handler);
41
+
42
+ useEffect(() => {
43
+ handlerRef.current = handler;
44
+ });
45
+
46
+ useEffect(() => {
47
+ if (!enabled) return undefined;
48
+ const onKeyDown = (event: KeyboardEvent) => {
49
+ if (!matchesShortcut(event, keys, platform.os)) return;
50
+ event.preventDefault();
51
+ handlerRef.current(event);
52
+ };
53
+ document.addEventListener("keydown", onKeyDown);
54
+ return () => document.removeEventListener("keydown", onKeyDown);
55
+ }, [keys, enabled, platform.os]);
56
+ }
@@ -0,0 +1,43 @@
1
+ import { useCallback, useSyncExternalStore } from "react";
2
+
3
+ const noop = () => {};
4
+
5
+ /**
6
+ * Tracks one `matchMedia` query. The server render and the first client render
7
+ * use `serverValue`, so static thumbnails and hydration agree before the browser
8
+ * reports the real state.
9
+ *
10
+ * The subscription is rebuilt when `query` changes, and both the server snapshot and the snapshot taken before
11
+ * `window.matchMedia` exists fall back to `serverValue`. The query string must be a literal the caller keeps
12
+ * stable across renders, as the cache key is the string itself.
13
+ *
14
+ * @param query - A media query such as `(max-width: 767px)` or `(prefers-reduced-motion: reduce)`.
15
+ * @param serverValue - Result to report while rendering on the server or before `matchMedia` is available. @default false
16
+ * @returns Whether the query currently matches, or `serverValue` on the server and in the first pass.
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * import { useMediaQuery } from "@horon-ai/react";
21
+ *
22
+ * function Nav() {
23
+ * const compact = useMediaQuery("(max-width: 767px)");
24
+ * return compact ? <TabBar items={items} value={tab} onValueChange={setTab} /> : <Sidebar items={items} />;
25
+ * }
26
+ * ```
27
+ */
28
+ export function useMediaQuery(query: string, serverValue = false): boolean {
29
+ const subscribe = useCallback(
30
+ (onChange: () => void) => {
31
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return noop;
32
+ const media = window.matchMedia(query);
33
+ media.addEventListener("change", onChange);
34
+ return () => media.removeEventListener("change", onChange);
35
+ },
36
+ [query],
37
+ );
38
+ const getSnapshot = useCallback(() => {
39
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return serverValue;
40
+ return window.matchMedia(query).matches;
41
+ }, [query, serverValue]);
42
+ return useSyncExternalStore(subscribe, getSnapshot, () => serverValue);
43
+ }
@@ -0,0 +1,68 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ import { detectOs, detectRuntime, readNavigator, type Os, type Runtime } from "../lib/platform";
4
+
5
+ /** Which physical key carries `Mod` on this OS: `meta` is ⌘, `ctrl` is Ctrl. */
6
+ export type ModKey = "meta" | "ctrl";
7
+
8
+ /** What {@link usePlatform} reports: the OS, the shell the page runs in and the key behind `Mod`. */
9
+ export type Platform = {
10
+ /** The detected OS, `unknown` on the server and when no hint names one. */
11
+ os: Os;
12
+ /** `desktop` inside the Tauri shell or Electron, `web` everywhere else including the server. */
13
+ runtime: Runtime;
14
+ /** The physical key behind `Mod` on this OS, ready for a keydown check. */
15
+ modKey: ModKey;
16
+ };
17
+
18
+ const noop = () => {};
19
+
20
+ /** What a server render and the first hydration pass assume. */
21
+ const SERVER_PLATFORM: Platform = { os: "unknown", runtime: "web", modKey: "meta" };
22
+
23
+ /**
24
+ * The OS and shell do not change while a tab is open, so the platform is read
25
+ * once and every later render reuses the same object.
26
+ */
27
+ let clientPlatform: Platform | null = null;
28
+
29
+ function readPlatform(): Platform {
30
+ if (typeof window === "undefined") return SERVER_PLATFORM;
31
+ if (clientPlatform) return clientPlatform;
32
+ const os = detectOs(readNavigator());
33
+ const modKey: ModKey = os === "windows" || os === "linux" || os === "android" ? "ctrl" : "meta";
34
+ clientPlatform = { os, runtime: detectRuntime(window), modKey };
35
+ return clientPlatform;
36
+ }
37
+
38
+ const subscribe = (_onChange: () => void) => noop;
39
+
40
+ /**
41
+ * The current OS, browser or desktop shell, and the key that means `Mod`.
42
+ *
43
+ * The value is read from `navigator` and `window` and cached for the life of the page: there is no store to
44
+ * subscribe to, so the hook never re-renders on its own. A server render and the first hydration pass get
45
+ * `{ os: "unknown", runtime: "web", modKey: "meta" }`, which draws the Mac keys; on the client the first render
46
+ * already reports the detected platform.
47
+ *
48
+ * @returns The current {@link Platform}: `os`, `runtime` and `modKey`.
49
+ *
50
+ * @example
51
+ * ```tsx
52
+ * import { isModKey, usePlatform } from "@horon-ai/react";
53
+ *
54
+ * function Composer({ onStart }: { onStart: () => void }) {
55
+ * const { os } = usePlatform();
56
+ * return (
57
+ * <textarea
58
+ * onKeyDown={(event) => {
59
+ * if (event.key === "Enter" && isModKey(event, os)) onStart();
60
+ * }}
61
+ * />
62
+ * );
63
+ * }
64
+ * ```
65
+ */
66
+ export function usePlatform(): Platform {
67
+ return useSyncExternalStore(subscribe, readPlatform, () => SERVER_PLATFORM);
68
+ }
@@ -0,0 +1,25 @@
1
+ import { useMediaQuery } from "./use-media-query";
2
+
3
+ const QUERY = "(prefers-reduced-motion: reduce)";
4
+
5
+ /**
6
+ * True while the OS asks for reduced motion; false on the server.
7
+ *
8
+ * A thin reading of {@link useMediaQuery} over `(prefers-reduced-motion: reduce)`; the CSS side of the same
9
+ * preference is handled by the motion utilities in `globals.css`, which collapse their durations.
10
+ *
11
+ * @returns Whether the user prefers reduced motion; `false` during a server render and the first pass.
12
+ *
13
+ * @example
14
+ * ```tsx
15
+ * import { useReducedMotion } from "@horon-ai/react";
16
+ *
17
+ * function Stream({ text }: { text: string }) {
18
+ * const reducedMotion = useReducedMotion();
19
+ * return <StreamingText text={text} smooth={!reducedMotion} />;
20
+ * }
21
+ * ```
22
+ */
23
+ export function useReducedMotion(): boolean {
24
+ return useMediaQuery(QUERY);
25
+ }
package/src/index.ts CHANGED
@@ -1,3 +1,34 @@
1
+ /**
2
+ * Horon Design System for React.
3
+ *
4
+ * The package holds the 84 Horon components ported from the Figma `Horon` file. Behaviour and
5
+ * accessibility come from Base UI (`@base-ui/react`); layout and colour come from Tailwind CSS v4
6
+ * utilities whose values are the `@horon-ai/tokens` variables, so class names such as `text-label-md`
7
+ * or `bg-glass-regular` are the design tokens themselves. TypeScript source ships as-is, with no
8
+ * build output, so the consuming bundler compiles it.
9
+ *
10
+ * Import everything from the barrel, or one component from its subpath:
11
+ *
12
+ * - `import { Button, Card, Input } from "@horon-ai/react"` re-exports every component and its prop types.
13
+ * - `import { Button } from "@horon-ai/react/components/button"` resolves to the same source file
14
+ * through the `./components/*` export map, which keeps a bundle to the components it uses.
15
+ * - `import { cn } from "@horon-ai/react"` is the tailwind-merge helper that understands the token
16
+ * names, so a caller class overrides a component default.
17
+ * - `import { mountCursorGhost } ...` / `import "@horon-ai/react/cursor-ghost"` adds the desktop
18
+ * cursor trail outside React.
19
+ * - `import { usePlatform, useDevice, useHotkey } from "@horon-ai/react"` reads the OS, the browser or
20
+ * desktop shell and the input device, and binds a `Mod+K`-style shortcut. The hooks are barrel-only,
21
+ * with no subpath, and the same `lib/platform` helpers (`formatShortcut`, `matchesShortcut`, `isModKey`)
22
+ * are exported so a caller's shortcut draws and matches the keys the components use.
23
+ *
24
+ * Import `@horon-ai/react/globals.css` once in the app entry stylesheet: it brings in Tailwind CSS v4,
25
+ * Pretendard, Geist Mono, the generated token theme and the liquid-glass layer styles. Add an `@source`
26
+ * line beside it that points at the package `src` folder so Tailwind scans the component sources (see
27
+ * the README for the exact line). Then set `class="dark"` on an ancestor for the dark palette, and
28
+ * `data-platform="desktop"` or `data-platform="mobile"` to switch the typography board.
29
+ *
30
+ * @packageDocumentation
31
+ */
1
32
  export * from "./components/agent-activity";
2
33
  export * from "./components/agent-lane";
3
34
  export * from "./components/agent-lanes-timeline";
@@ -82,5 +113,18 @@ export * from "./components/tooltip";
82
113
  export * from "./components/top-bar";
83
114
  export * from "./components/usage-meter";
84
115
  export * from "./components/workspace-menu";
116
+ export * from "./hooks";
85
117
  export { cn, type ClassValue } from "./lib/cn";
86
118
  export { mountCursorGhost, type CursorGhostOptions } from "./lib/cursor-ghost";
119
+ export {
120
+ detectOs,
121
+ detectRuntime,
122
+ formatShortcut,
123
+ isModKey,
124
+ matchesShortcut,
125
+ type Os,
126
+ type PlatformInput,
127
+ type Runtime,
128
+ type RuntimeWindow,
129
+ type ShortcutEvent,
130
+ } from "./lib/platform";
package/src/lib/cn.ts CHANGED
@@ -1,6 +1,10 @@
1
1
  import { lightColors, primitives, radius, shadow, textStyles } from "@horon-ai/tokens";
2
2
  import { extendTailwindMerge } from "tailwind-merge";
3
3
 
4
+ /**
5
+ * One class-name argument accepted by {@link cn}: a class string, or a falsy value that is skipped.
6
+ * `false`, `null` and `undefined` are common when a class is applied conditionally.
7
+ */
4
8
  export type ClassValue = string | false | null | undefined;
5
9
 
6
10
  /**
@@ -19,7 +23,15 @@ const merge = extendTailwindMerge({
19
23
  },
20
24
  });
21
25
 
22
- /** Joins class names, skipping empty values; later classes win over conflicting earlier ones. */
26
+ /**
27
+ * Joins class names, skipping empty values; later classes win over conflicting earlier ones.
28
+ *
29
+ * Use it in every component so a caller's class overrides the component default instead of
30
+ * duplicating it; the merge is extended with the Horon token names.
31
+ *
32
+ * @param values - Any number of class strings or falsy values, applied left to right.
33
+ * @returns One space-separated class string with conflicting utilities collapsed.
34
+ */
23
35
  export function cn(...values: ClassValue[]): string {
24
36
  return merge(values.filter(Boolean).join(" "));
25
37
  }
@@ -12,15 +12,15 @@
12
12
  */
13
13
 
14
14
  export type CursorGhostOptions = {
15
- /** Number of arrow copies. */
15
+ /** Number of arrow copies. @default 5 */
16
16
  ghosts?: number;
17
- /** Arrow height in CSS px. */
17
+ /** Arrow height in CSS px. @default 26 */
18
18
  size?: number;
19
- /** Lerp factor of the first copy toward the pointer, per frame. */
19
+ /** Lerp factor of the first copy toward the pointer, per frame. @default 0.16 */
20
20
  headFollow?: number;
21
- /** Blur spread of the last copy in CSS px. */
21
+ /** Blur spread of the last copy in CSS px. @default 14 */
22
22
  smear?: number;
23
- /** Arrow fill. Blended with `difference`, so white reads dark on light pages. */
23
+ /** Arrow fill. Blended with `difference`, so white reads dark on light pages. @default "#FFFFFF" */
24
24
  color?: string;
25
25
  };
26
26
 
@@ -195,6 +195,13 @@ function startEffect({ ghosts, size, headFollow, smear, color }: Required<Cursor
195
195
  /**
196
196
  * Mounts the Cursor Ghost trail and returns its teardown. The trail appears and
197
197
  * disappears on its own as the pointer type or the reduced-motion setting changes.
198
+ *
199
+ * Safe to call during server rendering: without a `window`, `document` or `matchMedia`
200
+ * it returns a no-op teardown. Call it after `<html data-horon-cursor="off">` is set when
201
+ * you want both the automatic mount suppressed and custom options.
202
+ *
203
+ * @param options - Overrides for the trail; omitted keys fall back to the built-in defaults.
204
+ * @returns A teardown that removes the layer, its listeners and any pending animation frame.
198
205
  */
199
206
  export function mountCursorGhost(options: CursorGhostOptions = {}): () => void {
200
207
  if (typeof window === "undefined" || typeof document === "undefined" || !window.matchMedia) return () => {};
@@ -234,7 +241,10 @@ export function mountCursorGhost(options: CursorGhostOptions = {}): () => void {
234
241
 
235
242
  function autoMount() {
236
243
  if (typeof window === "undefined" || typeof document === "undefined") return;
237
- const registry = globalThis as typeof globalThis & { [AUTO_MOUNT_KEY]?: true };
244
+ const registry = globalThis as typeof globalThis & {
245
+ /** Set once the automatic mount has run, so a second import does not add another trail. */
246
+ [AUTO_MOUNT_KEY]?: true;
247
+ };
238
248
  if (registry[AUTO_MOUNT_KEY]) return;
239
249
  if (document.documentElement.getAttribute(OPT_OUT_ATTRIBUTE) === "off") return;
240
250
  registry[AUTO_MOUNT_KEY] = true;