@horon-ai/react 0.1.1 → 0.1.3

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/README.md +2 -2
  2. package/package.json +2 -2
  3. package/src/components/agent-activity.tsx +75 -7
  4. package/src/components/agent-lane.tsx +32 -3
  5. package/src/components/agent-lanes-timeline.tsx +85 -7
  6. package/src/components/agent-panel.tsx +46 -4
  7. package/src/components/agent-steering-panel.tsx +53 -3
  8. package/src/components/approval-card.tsx +34 -2
  9. package/src/components/avatar.tsx +20 -4
  10. package/src/components/badge.tsx +24 -3
  11. package/src/components/banner.tsx +19 -3
  12. package/src/components/bot-instructions-panel.tsx +86 -7
  13. package/src/components/bot-list-row.tsx +13 -1
  14. package/src/components/bot-memory-panel.tsx +58 -3
  15. package/src/components/bottom-sheet.tsx +61 -7
  16. package/src/components/breadcrumb-header.tsx +32 -2
  17. package/src/components/breadcrumb.tsx +31 -1
  18. package/src/components/button.tsx +46 -1
  19. package/src/components/calendar.tsx +76 -6
  20. package/src/components/card.tsx +30 -1
  21. package/src/components/chat-bubble.tsx +32 -3
  22. package/src/components/chat-controls.tsx +60 -7
  23. package/src/components/checkbox.tsx +22 -1
  24. package/src/components/chip.tsx +29 -2
  25. package/src/components/choice-card.tsx +25 -1
  26. package/src/components/code-panel.tsx +28 -3
  27. package/src/components/combobox.tsx +52 -2
  28. package/src/components/command-palette.tsx +65 -1
  29. package/src/components/composer.tsx +28 -1
  30. package/src/components/data-table.tsx +198 -16
  31. package/src/components/date-field.tsx +31 -2
  32. package/src/components/dialog.tsx +45 -3
  33. package/src/components/directory-card.tsx +23 -2
  34. package/src/components/document-extract-panel.tsx +83 -5
  35. package/src/components/empty-state.tsx +21 -2
  36. package/src/components/field.tsx +49 -3
  37. package/src/components/file-drop.tsx +39 -4
  38. package/src/components/filter-bar.tsx +38 -1
  39. package/src/components/flue-background.tsx +88 -4
  40. package/src/components/horon-icon.tsx +50 -3
  41. package/src/components/horon-wordmark.tsx +26 -1
  42. package/src/components/icon-button.tsx +38 -1
  43. package/src/components/input-group.tsx +57 -6
  44. package/src/components/input.tsx +48 -2
  45. package/src/components/integration-row.tsx +43 -2
  46. package/src/components/kbd.tsx +28 -2
  47. package/src/components/knowledge-base-panel.tsx +40 -2
  48. package/src/components/liquid-glass.tsx +16 -0
  49. package/src/components/list-detail.tsx +89 -7
  50. package/src/components/marketplace-card.tsx +40 -4
  51. package/src/components/mcp-server-row.tsx +49 -3
  52. package/src/components/menu.tsx +83 -3
  53. package/src/components/message-extras.tsx +50 -4
  54. package/src/components/modal.tsx +71 -4
  55. package/src/components/orchestrator-panel.tsx +48 -4
  56. package/src/components/page-header.tsx +26 -3
  57. package/src/components/plan-card.tsx +28 -1
  58. package/src/components/plan-step.tsx +41 -1
  59. package/src/components/plugin-row.tsx +61 -3
  60. package/src/components/progress.tsx +32 -2
  61. package/src/components/radio-group.tsx +55 -2
  62. package/src/components/research-run-panel.tsx +73 -3
  63. package/src/components/schedule.tsx +163 -12
  64. package/src/components/segmented-control.tsx +57 -2
  65. package/src/components/select.tsx +60 -0
  66. package/src/components/settings-list.tsx +66 -8
  67. package/src/components/sidebar.tsx +214 -23
  68. package/src/components/skeleton.tsx +61 -4
  69. package/src/components/slider.tsx +30 -0
  70. package/src/components/source-row.tsx +43 -4
  71. package/src/components/spinner.tsx +29 -2
  72. package/src/components/split-button.tsx +37 -3
  73. package/src/components/switch.tsx +31 -1
  74. package/src/components/tab-bar.tsx +47 -2
  75. package/src/components/tabs.tsx +59 -5
  76. package/src/components/task-row.tsx +49 -3
  77. package/src/components/textarea-field.tsx +36 -2
  78. package/src/components/textarea.tsx +35 -2
  79. package/src/components/toast.tsx +52 -3
  80. package/src/components/tool-call.tsx +87 -4
  81. package/src/components/tool-calls-header.tsx +32 -1
  82. package/src/components/tool-permissions.tsx +50 -2
  83. package/src/components/tooltip.tsx +33 -1
  84. package/src/components/top-bar.tsx +35 -2
  85. package/src/components/usage-meter.tsx +28 -2
  86. package/src/components/workspace-menu.tsx +46 -1
  87. package/src/hooks/use-active-indicator.ts +44 -1
  88. package/src/hooks/use-color-scheme.ts +24 -1
  89. package/src/hooks/use-copy-to-clipboard.ts +23 -0
  90. package/src/hooks/use-device.ts +29 -1
  91. package/src/hooks/use-hotkey.ts +21 -0
  92. package/src/hooks/use-media-query.ts +18 -0
  93. package/src/hooks/use-platform.ts +30 -1
  94. package/src/hooks/use-reduced-motion.ts +18 -1
  95. package/src/index.ts +31 -0
  96. package/src/lib/cn.ts +13 -1
  97. package/src/lib/cursor-ghost.ts +16 -6
  98. package/src/lib/platform.ts +71 -4
@@ -1,5 +1,6 @@
1
1
  import { useSyncExternalStore } from "react";
2
2
 
3
+ /** The two schemes the `.dark` class switch can report. */
3
4
  export type ColorScheme = "light" | "dark";
4
5
 
5
6
  const noop = () => {};
@@ -21,7 +22,29 @@ function readIsDark(): boolean {
21
22
  return document.documentElement.classList.contains("dark");
22
23
  }
23
24
 
24
- /** The active color scheme; `light` on the server and before hydration. */
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
+ */
25
48
  export function useColorScheme(): ColorScheme {
26
49
  return useSyncExternalStore(subscribe, readIsDark, () => false) ? "dark" : "light";
27
50
  }
@@ -1,6 +1,8 @@
1
1
  import { useCallback, useEffect, useState } from "react";
2
2
 
3
+ /** What {@link useCopyToClipboard} returns: the transient flag and the write function. */
3
4
  export type CopyToClipboard = {
5
+ /** True from a successful write until `resetMs` has passed; stays false when the write fails. */
4
6
  copied: boolean;
5
7
  /** Writes `text`; a failed write leaves `copied` false instead of throwing. */
6
8
  copy: (text: string) => void;
@@ -10,6 +12,27 @@ export type CopyToClipboard = {
10
12
  * Clipboard writes with a self-clearing confirmation: `copied` turns true when
11
13
  * the write resolves and back to false after `resetMs`, so a button needs no
12
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
+ * ```
13
36
  */
14
37
  export function useCopyToClipboard(resetMs = 1500): CopyToClipboard {
15
38
  const [copied, setCopied] = useState(false);
@@ -1,12 +1,16 @@
1
1
  import { useMediaQuery } from "./use-media-query";
2
2
 
3
+ /** The three layout classes: `mobile` under 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
3
4
  export type DeviceType = "mobile" | "tablet" | "desktop";
4
5
 
6
+ /** The primary input behind the pointer: `touch` for a coarse pointer, `pointer` for a fine one. */
5
7
  export type DeviceInput = "touch" | "pointer";
6
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. */
7
10
  export type Device = {
8
11
  /** `mobile` below 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
9
12
  type: DeviceType;
13
+ /** `touch` when the pointer is coarse, `pointer` when it is fine. */
10
14
  input: DeviceInput;
11
15
  /** True only for a hovering fine pointer, the input that can actually reach a key chip. */
12
16
  keyboardHints: boolean;
@@ -31,7 +35,31 @@ function deviceSnapshot(type: DeviceType, input: DeviceInput, keyboardHints: boo
31
35
  return next;
32
36
  }
33
37
 
34
- /** The device class and its primary input, as the layout and key hints need them. */
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
+ */
35
63
  export function useDevice(): Device {
36
64
  const mobile = useMediaQuery(MOBILE_QUERY);
37
65
  const coarse = useMediaQuery(COARSE_QUERY);
@@ -3,6 +3,7 @@ import { useEffect, useRef } from "react";
3
3
  import { matchesShortcut } from "../lib/platform";
4
4
  import { usePlatform } from "./use-platform";
5
5
 
6
+ /** Options of {@link useHotkey}: the only switch is whether the listener is attached. */
6
7
  export type HotkeyOptions = {
7
8
  /** When false the listener is detached and the hotkey does nothing (default true). */
8
9
  enabled?: boolean;
@@ -12,6 +13,26 @@ export type HotkeyOptions = {
12
13
  * Runs `handler` on a `document` keydown that matches `keys`, such as `Mod+K`,
13
14
  * and prevents the browser default. Text inputs keep their focus, so a palette
14
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
+ * ```
15
36
  */
16
37
  export function useHotkey(keys: string, handler: (event: KeyboardEvent) => void, options: HotkeyOptions = {}) {
17
38
  const { enabled = true } = options;
@@ -6,6 +6,24 @@ const noop = () => {};
6
6
  * Tracks one `matchMedia` query. The server render and the first client render
7
7
  * use `serverValue`, so static thumbnails and hydration agree before the browser
8
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
+ * ```
9
27
  */
10
28
  export function useMediaQuery(query: string, serverValue = false): boolean {
11
29
  const subscribe = useCallback(
@@ -2,10 +2,14 @@ import { useSyncExternalStore } from "react";
2
2
 
3
3
  import { detectOs, detectRuntime, readNavigator, type Os, type Runtime } from "../lib/platform";
4
4
 
5
+ /** Which physical key carries `Mod` on this OS: `meta` is ⌘, `ctrl` is Ctrl. */
5
6
  export type ModKey = "meta" | "ctrl";
6
7
 
8
+ /** What {@link usePlatform} reports: the OS, the shell the page runs in and the key behind `Mod`. */
7
9
  export type Platform = {
10
+ /** The detected OS, `unknown` on the server and when no hint names one. */
8
11
  os: Os;
12
+ /** `desktop` inside the Tauri shell or Electron, `web` everywhere else including the server. */
9
13
  runtime: Runtime;
10
14
  /** The physical key behind `Mod` on this OS, ready for a keydown check. */
11
15
  modKey: ModKey;
@@ -33,7 +37,32 @@ function readPlatform(): Platform {
33
37
 
34
38
  const subscribe = (_onChange: () => void) => noop;
35
39
 
36
- /** The current OS, browser or desktop shell, and the key that means `Mod`. */
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
+ */
37
66
  export function usePlatform(): Platform {
38
67
  return useSyncExternalStore(subscribe, readPlatform, () => SERVER_PLATFORM);
39
68
  }
@@ -2,7 +2,24 @@ import { useMediaQuery } from "./use-media-query";
2
2
 
3
3
  const QUERY = "(prefers-reduced-motion: reduce)";
4
4
 
5
- /** True while the OS asks for reduced motion; false on the server. */
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
+ */
6
23
  export function useReducedMotion(): boolean {
7
24
  return useMediaQuery(QUERY);
8
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";
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;
@@ -1,41 +1,69 @@
1
+ /** The operating systems the shortcut helpers know; `unknown` is what a server render and an empty hint report. */
1
2
  export type Os = "macos" | "windows" | "linux" | "ios" | "android" | "unknown";
2
3
 
4
+ /** Where the page runs: `desktop` inside the Tauri shell or Electron, `web` everywhere else including the server. */
3
5
  export type Runtime = "web" | "desktop";
4
6
 
5
7
  /** The parts of `navigator` that identify the OS; plain data so detection stays testable. */
6
8
  export type PlatformInput = {
9
+ /** Full user agent string, the last and coarsest hint. */
7
10
  userAgent?: string;
11
+ /** `navigator.platform`, still the most reliable hint in Safari and Firefox. */
8
12
  platform?: string;
13
+ /** `navigator.userAgentData.platform`, the first hint when the browser exposes it. */
9
14
  uaPlatform?: string;
15
+ /** Touch points, which is what separates iPadOS (a Mac hint) from a Mac. */
10
16
  maxTouchPoints?: number;
11
17
  };
12
18
 
13
19
  /** The parts of `window` that tell a page from the desktop shell; plain data for the same reason. */
14
20
  export type RuntimeWindow = {
21
+ /** Set by Tauri in the desktop shell. */
15
22
  __TAURI_INTERNALS__?: unknown;
16
- navigator?: { userAgent?: string };
23
+ /** `window.navigator`, read for the Electron fragment in its user agent. */
24
+ navigator?: {
25
+ /** User agent string, tested for `electron`. */
26
+ userAgent?: string;
27
+ };
17
28
  };
18
29
 
19
30
  /** The parts of a keyboard event a shortcut reads; `KeyboardEvent` satisfies it as-is. */
20
31
  export type ShortcutEvent = {
32
+ /** `KeyboardEvent.key`, for example `k`, `Enter` or `ArrowUp`. */
21
33
  key?: string;
34
+ /** ⌘ on macOS and iOS. */
22
35
  metaKey?: boolean;
36
+ /** Ctrl on Windows, Linux and Android. */
23
37
  ctrlKey?: boolean;
38
+ /** ⌥ or Alt. */
24
39
  altKey?: boolean;
40
+ /** ⇧ or Shift. */
25
41
  shiftKey?: boolean;
26
42
  };
27
43
 
28
44
  /**
29
45
  * Reads the platform hints from the live `navigator`. `userAgentData` is not in
30
46
  * the DOM typings yet, so the shape is spelled out here instead of cast away.
47
+ *
48
+ * Every field is copied out, and a missing `navigator` (a server render) yields an empty object, so
49
+ * {@link detectOs} falls through to `unknown` instead of throwing.
50
+ *
51
+ * @returns The four hints as {@link PlatformInput}; fields the browser does not expose stay `undefined`.
31
52
  */
32
53
  export function readNavigator(): PlatformInput {
33
54
  if (typeof navigator === "undefined") return {};
34
55
  const nav = navigator as {
56
+ /** Full user agent string. */
35
57
  userAgent?: string;
58
+ /** `navigator.platform`, such as `MacIntel`. */
36
59
  platform?: string;
60
+ /** Number of touch points, which tells iPadOS from a Mac. */
37
61
  maxTouchPoints?: number;
38
- userAgentData?: { platform?: string };
62
+ /** Chromium's hint object, absent in Safari and Firefox. */
63
+ userAgentData?: {
64
+ /** The OS hint, such as `macOS` or `Windows`. */
65
+ platform?: string;
66
+ };
39
67
  };
40
68
  return {
41
69
  userAgent: nav.userAgent,
@@ -62,6 +90,13 @@ function osFromHint(hint: string, maxTouchPoints: number): Os | undefined {
62
90
  * Finds the OS from the first hint that names one: `userAgentData.platform`,
63
91
  * then `navigator.platform`, then the user agent string. Empty input is
64
92
  * `unknown`, which the shortcuts treat as the macOS/⌘ rendering.
93
+ *
94
+ * A hint containing `mac` is `ios` when `maxTouchPoints` is above 1, since iPadOS reports a Mac platform and
95
+ * user agent. A hint that matches nothing is skipped, so a browser that names no known OS still reports
96
+ * `unknown` rather than a wrong guess.
97
+ *
98
+ * @param input - The hints to read, usually from {@link readNavigator}; omitted means empty. @default {}
99
+ * @returns The first `Os` a hint names, or `unknown` when none does.
65
100
  */
66
101
  export function detectOs(input: PlatformInput = {}): Os {
67
102
  const maxTouchPoints = input.maxTouchPoints ?? 0;
@@ -74,7 +109,14 @@ export function detectOs(input: PlatformInput = {}): Os {
74
109
  return "unknown";
75
110
  }
76
111
 
77
- /** `desktop` for the Tauri shell and Electron; `web` everywhere else, including the server. */
112
+ /**
113
+ * `desktop` for the Tauri shell and Electron; `web` everywhere else, including the server.
114
+ *
115
+ * The Tauri test wins over the user agent, so a shell that also advertises Electron still reports `desktop`.
116
+ *
117
+ * @param win - The window to inspect, usually `window`; `null` or omitted reports `web`. @default null
118
+ * @returns `desktop` inside either shell, `web` otherwise.
119
+ */
78
120
  export function detectRuntime(win: RuntimeWindow | null = null): Runtime {
79
121
  if (!win) return "web";
80
122
  if ("__TAURI_INTERNALS__" in win) return "desktop";
@@ -110,6 +152,13 @@ const OTHER_LABELS: Record<string, string> = {
110
152
  /**
111
153
  * Draws a `+`-joined shortcut for one OS: `Mod+K` is `⌘K` on macOS and iOS,
112
154
  * `Ctrl+K` on Windows and Linux. Mac families glue the parts, the rest keep `+`.
155
+ *
156
+ * `mod`, `shift`, `alt`, `enter`, `escape`/`esc` and the four arrows have labels; any other part is kept as
157
+ * written, so a bare `K` stays `K`. An empty string, or one made only of `+` and spaces, returns `""`.
158
+ *
159
+ * @param keys - Shortcut in the same syntax `Kbd` and {@link matchesShortcut} take, such as `Mod+Shift+Enter`.
160
+ * @param os - The OS to draw for; `unknown` draws the Mac symbols, the server default.
161
+ * @returns The label to render, for example `⌘⇧↵` or `Ctrl+Shift+Enter`.
113
162
  */
114
163
  export function formatShortcut(keys: string, os: Os): string {
115
164
  const parts = keys
@@ -130,7 +179,16 @@ function modifierSlot(os: Os): "meta" | "ctrl" | "either" {
130
179
  return "either";
131
180
  }
132
181
 
133
- /** True when the platform's `Mod` key is down; the other side's key is not `Mod`. */
182
+ /**
183
+ * True when the platform's `Mod` key is down; the other side's key is not `Mod`.
184
+ *
185
+ * On macOS and iOS that means `metaKey` without `ctrlKey`; on Windows, Linux and Android `ctrlKey` without
186
+ * `metaKey`, so Ctrl+⌘ on a Mac is not `Mod`. With an `unknown` OS either key counts.
187
+ *
188
+ * @param event - The keyboard event or anything shaped like {@link ShortcutEvent}.
189
+ * @param os - The OS whose `Mod` to test, usually from {@link detectOs} or `usePlatform().os`.
190
+ * @returns Whether this event carries the platform's modifier.
191
+ */
134
192
  export function isModKey(event: ShortcutEvent, os: Os): boolean {
135
193
  const slot = modifierSlot(os);
136
194
  const meta = event.metaKey === true;
@@ -154,6 +212,15 @@ function canonicalKey(value: string): string {
154
212
  * Tests a keyboard event against a shortcut string such as `Mod+K` or
155
213
  * `Mod+Shift+Enter`. `Mod` is the platform key (⌘ on macOS/iOS, Ctrl
156
214
  * elsewhere, either one when the OS is unknown).
215
+ *
216
+ * Matching is exact on the modifier set: Alt and Shift must be present or absent exactly as the shortcut says,
217
+ * and a missing base key (a string of modifiers only) never matches. `esc` and `escape` are the same key, and
218
+ * the comparison is case-insensitive.
219
+ *
220
+ * @param event - The keydown event or anything shaped like {@link ShortcutEvent}.
221
+ * @param keys - Shortcut string, with `mod` standing for the platform key, such as `Mod+K`.
222
+ * @param os - The OS whose `Mod` to test.
223
+ * @returns Whether the event is exactly this shortcut on this OS.
157
224
  */
158
225
  export function matchesShortcut(event: ShortcutEvent, keys: string, os: Os): boolean {
159
226
  const parts = keys