@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
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
import { type RefObject, useEffect, useLayoutEffect, useState } from "react";
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
/**
|
|
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
|
}
|
package/src/lib/cursor-ghost.ts
CHANGED
|
@@ -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 & {
|
|
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;
|