@horon-ai/react 0.1.0 → 0.1.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@horon-ai/react",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/horon-ai/design.git",
@@ -30,7 +30,7 @@
30
30
  "dependencies": {
31
31
  "@base-ui/react": "^1.9.0",
32
32
  "@fontsource-variable/geist-mono": "^5.3.0",
33
- "@horon-ai/tokens": "0.1.0",
33
+ "@horon-ai/tokens": "0.1.1",
34
34
  "@phosphor-icons/react": "^2.1.10",
35
35
  "@tanstack/react-table": "^9.3.1",
36
36
  "pretendard": "^1.3.9",
@@ -1,6 +1,7 @@
1
1
  import { CaretDown, CaretRight, CheckCircle, WarningCircle, Wrench } from "@phosphor-icons/react";
2
2
  import { type ComponentProps, type ReactNode, useEffect, useRef, useState } from "react";
3
3
 
4
+ import { useReducedMotion } from "../hooks/use-reduced-motion";
4
5
  import { cn } from "../lib/cn";
5
6
  import { Spinner } from "./spinner";
6
7
 
@@ -37,7 +38,7 @@ function codePointEnd(text: string, end: number): number {
37
38
  * bursty chunks read as steady typing; otherwise, and under reduced motion, everything shows at once.
38
39
  * Text that no longer extends what was shown (a rewrite) restarts from the shared prefix.
39
40
  */
40
- function useRevealedLength(text: string, smooth: boolean): number {
41
+ function useRevealedLength(text: string, smooth: boolean, reducedMotion: boolean): number {
41
42
  const [shown, setShown] = useState(text.length);
42
43
  const shownRef = useRef(text.length);
43
44
  const previous = useRef(text);
@@ -54,7 +55,7 @@ function useRevealedLength(text: string, smooth: boolean): number {
54
55
  while (shared < start && text[shared] === before[shared]) shared++;
55
56
  start = shared;
56
57
 
57
- if (!smooth || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
58
+ if (!smooth || reducedMotion) {
58
59
  set(text.length);
59
60
  return undefined;
60
61
  }
@@ -73,7 +74,7 @@ function useRevealedLength(text: string, smooth: boolean): number {
73
74
  if (length < text.length) frame = requestAnimationFrame(tick);
74
75
  });
75
76
  return () => cancelAnimationFrame(frame);
76
- }, [text, smooth]);
77
+ }, [text, smooth, reducedMotion]);
77
78
 
78
79
  return Math.min(shown, text.length);
79
80
  }
@@ -95,7 +96,8 @@ export type StreamingTextProps = Omit<ComponentProps<"div">, "className" | "chil
95
96
  * when it is complete, instead of every chunk.
96
97
  */
97
98
  export function StreamingText({ text, streaming = true, smooth = true, className, ...props }: StreamingTextProps) {
98
- const shown = useRevealedLength(text, streaming && smooth);
99
+ const reducedMotion = useReducedMotion();
100
+ const shown = useRevealedLength(text, streaming && smooth, reducedMotion);
99
101
  const typing = shown < text.length;
100
102
  return (
101
103
  <div
@@ -1,7 +1,10 @@
1
1
  import { CaretDown } from "@phosphor-icons/react";
2
2
  import type { ComponentProps, ReactNode } from "react";
3
3
 
4
+ import { useDevice } from "../hooks/use-device";
5
+ import { usePlatform } from "../hooks/use-platform";
4
6
  import { cn } from "../lib/cn";
7
+ import { isModKey } from "../lib/platform";
5
8
  import { Kbd } from "./kbd";
6
9
 
7
10
  type WithClass<T> = Omit<T, "className"> & { className?: string };
@@ -75,7 +78,7 @@ export type TaskComposerProps = {
75
78
  className?: string;
76
79
  };
77
80
 
78
- /** Figma task `Composer` (Agent tasks 112:797): a 4% ink box, 18px radius, with pickers, ⌘↵ hint and Start. */
81
+ /** Figma task `Composer` (Agent tasks 112:797): a 4% ink box, 18px radius, with pickers, a Mod+Enter hint and Start. */
79
82
  export function TaskComposer({
80
83
  placeholder = "Describe a task for a bot",
81
84
  value,
@@ -85,6 +88,8 @@ export function TaskComposer({
85
88
  startLabel = "Start",
86
89
  className,
87
90
  }: TaskComposerProps) {
91
+ const { os } = usePlatform();
92
+ const { keyboardHints } = useDevice();
88
93
  return (
89
94
  <div
90
95
  className={cn(
@@ -97,7 +102,7 @@ export function TaskComposer({
97
102
  value={value}
98
103
  onChange={(event) => onValueChange?.(event.target.value)}
99
104
  onKeyDown={(event) => {
100
- if (event.key === "Enter" && event.metaKey) onStart?.();
105
+ if (event.key === "Enter" && isModKey(event, os)) onStart?.();
101
106
  }}
102
107
  placeholder={placeholder}
103
108
  aria-label={placeholder}
@@ -106,8 +111,7 @@ export function TaskComposer({
106
111
  <div className="flex flex-wrap items-center gap-1.5">
107
112
  {pickers && <span className="flex min-w-0 flex-wrap items-center gap-1.5">{pickers}</span>}
108
113
  <span className="min-w-0 flex-1" />
109
- <Kbd>⌘</Kbd>
110
- <Kbd>↵</Kbd>
114
+ {keyboardHints && <Kbd keys="Mod+Enter" />}
111
115
  <button
112
116
  type="button"
113
117
  onClick={onStart}
@@ -1,7 +1,10 @@
1
- import { ArrowElbowDownLeft, ArrowUp, Check, Lightning, Path, Pause, Play, X } from "@phosphor-icons/react";
1
+ import { ArrowUp, Check, Lightning, Path, Pause, Play, X } from "@phosphor-icons/react";
2
2
  import { type ComponentProps, type CSSProperties, type ReactNode, useState } from "react";
3
3
 
4
+ import { useDevice } from "../hooks/use-device";
5
+ import { usePlatform } from "../hooks/use-platform";
4
6
  import { cn } from "../lib/cn";
7
+ import { isModKey } from "../lib/platform";
5
8
  import { Kbd } from "./kbd";
6
9
  import { Spinner } from "./spinner";
7
10
 
@@ -33,7 +36,7 @@ export type AgentSteeringPanelProps = Omit<ComponentProps<"section">, "className
33
36
  notes?: SteeringNote[];
34
37
  onApplyNow?: (id: string) => void;
35
38
  onRemoveNote?: (id: string) => void;
36
- /** Called with the text typed in the steer box: Enter queues it for the next step, ⌘Enter interrupts now. */
39
+ /** Called with the text typed in the steer box: Enter queues it for the next step, Mod+Enter interrupts now. */
37
40
  onSteer?: (text: string, interrupt: boolean) => void;
38
41
  placeholder?: string;
39
42
  className?: string;
@@ -135,6 +138,8 @@ export function AgentSteeringPanel({
135
138
  ...props
136
139
  }: AgentSteeringPanelProps) {
137
140
  const [draft, setDraft] = useState("");
141
+ const { os } = usePlatform();
142
+ const { keyboardHints } = useDevice();
138
143
  const queued = notes.filter((note) => !note.applied);
139
144
  const submit = (interrupt: boolean) => {
140
145
  const text = draft.trim();
@@ -236,7 +241,7 @@ export function AgentSteeringPanel({
236
241
  if (event.nativeEvent.isComposing || event.keyCode === 229) return;
237
242
  if (event.key === "Enter") {
238
243
  event.preventDefault();
239
- submit(event.metaKey || event.ctrlKey);
244
+ submit(isModKey(event, os));
240
245
  }
241
246
  }}
242
247
  placeholder={placeholder}
@@ -253,21 +258,18 @@ export function AgentSteeringPanel({
253
258
  </button>
254
259
  </div>
255
260
 
256
- <div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-caption text-text-secondary">
257
- <span className="flex items-center gap-1.25">
258
- <Kbd>
259
- <ArrowElbowDownLeft aria-hidden />
260
- </Kbd>
261
- next step
262
- </span>
263
- <span className="flex items-center gap-1.25">
264
- <Kbd>⌘</Kbd>
265
- <Kbd>
266
- <ArrowElbowDownLeft aria-hidden />
267
- </Kbd>
268
- interrupt now
269
- </span>
270
- </div>
261
+ {keyboardHints && (
262
+ <div className="flex flex-wrap items-center gap-x-3.5 gap-y-1.5 text-caption text-text-secondary">
263
+ <span className="flex items-center gap-1.25">
264
+ <Kbd keys="Enter" />
265
+ next step
266
+ </span>
267
+ <span className="flex items-center gap-1.25">
268
+ <Kbd keys="Mod+Enter" />
269
+ interrupt now
270
+ </span>
271
+ </div>
272
+ )}
271
273
  </div>
272
274
  </section>
273
275
  );
@@ -2,7 +2,8 @@ import { CaretDown, Play } from "@phosphor-icons/react";
2
2
  import { type ComponentProps, type KeyboardEvent, type ReactNode, useRef, useState } from "react";
3
3
 
4
4
  import { cn } from "../lib/cn";
5
- import { useActiveIndicator } from "../lib/use-active-indicator";
5
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
6
+ import { useDevice } from "../hooks/use-device";
6
7
  import { Kbd } from "./kbd";
7
8
 
8
9
  type WithClass<T> = Omit<T, "className"> & { className?: string };
@@ -174,6 +175,7 @@ export function BotInstructionsPanel({
174
175
  const [tab, setTab] = useState("instructions");
175
176
  const [scenario, setScenario] = useState(defaultScenario ?? scenarios?.[0]);
176
177
  const [runCount, setRunCount] = useState(0);
178
+ const { keyboardHints } = useDevice();
177
179
 
178
180
  return (
179
181
  <section
@@ -342,7 +344,9 @@ export function BotInstructionsPanel({
342
344
  >
343
345
  <Play aria-hidden className="size-3.5" />
344
346
  Run
345
- <Kbd className="bg-bg-surface-6 text-text-inverse/80">⌘↵</Kbd>
347
+ {keyboardHints && (
348
+ <Kbd keys="Mod+Enter" className="bg-bg-surface-6 text-text-inverse/80" />
349
+ )}
346
350
  </button>
347
351
  </div>
348
352
  </div>
@@ -1,11 +1,16 @@
1
1
  import { Autocomplete } from "@base-ui/react/autocomplete";
2
2
  import { Dialog } from "@base-ui/react/dialog";
3
- import { MagnifyingGlass } from "@phosphor-icons/react";
3
+ import { MagnifyingGlass, X } from "@phosphor-icons/react";
4
4
  import type { ReactNode } from "react";
5
5
 
6
+ import { useDevice } from "../hooks/use-device";
6
7
  import { cn } from "../lib/cn";
7
8
  import { Kbd } from "./kbd";
8
9
 
10
+ /** Close control shape for pointers that never reach a key: a 28px icon tile instead of the esc chip. */
11
+ const touchCloseClass =
12
+ "flex size-7 shrink-0 motion-press cursor-pointer items-center justify-center rounded-full text-text-secondary hover:bg-overlay-ink-6 hover:text-text-primary focus-visible:outline-2 focus-visible:outline-text-brand [&_svg]:size-4";
13
+
9
14
  export type CommandItem = {
10
15
  value: string;
11
16
  label: string;
@@ -16,7 +21,7 @@ export type CommandItem = {
16
21
  tileClassName?: string;
17
22
  /** Caption after the label, such as the workspace. */
18
23
  meta?: ReactNode;
19
- /** Right-side hint, usually `<Kbd>`. */
24
+ /** Right-side hint, usually `<Kbd>`; hidden when the device shows no keyboard hints. */
20
25
  hint?: ReactNode;
21
26
  onSelect?: () => void;
22
27
  };
@@ -30,7 +35,7 @@ export type CommandPaletteProps = {
30
35
  placeholder?: string;
31
36
  /** Shown when the query has no matches; defaults to "No results". */
32
37
  empty?: ReactNode;
33
- /** Footer key hints, such as `[["↑↓", "Move"], ["↵", "Open"]]`. */
38
+ /** Footer key hints, such as `[["↑↓", "Move"], ["↵", "Open"]]`; hidden when the device shows no keyboard hints. */
34
39
  hints?: [ReactNode, string][];
35
40
  trigger?: ReactNode;
36
41
  /**
@@ -55,6 +60,7 @@ export function CommandPalette({
55
60
  surface = "solid",
56
61
  className,
57
62
  }: CommandPaletteProps) {
63
+ const { keyboardHints } = useDevice();
58
64
  return (
59
65
  <Dialog.Root open={open} onOpenChange={onOpenChange}>
60
66
  {trigger}
@@ -76,13 +82,16 @@ export function CommandPalette({
76
82
  placeholder={placeholder}
77
83
  empty={empty}
78
84
  closeControl={
79
- <Dialog.Close aria-label="Close" className="motion-press cursor-pointer">
80
- <Kbd>esc</Kbd>
85
+ <Dialog.Close
86
+ aria-label="Close"
87
+ className={keyboardHints ? "motion-press cursor-pointer" : touchCloseClass}
88
+ >
89
+ {keyboardHints ? <Kbd keys="Escape" /> : <X aria-hidden />}
81
90
  </Dialog.Close>
82
91
  }
83
92
  onSelect={() => onOpenChange?.(false)}
84
93
  />
85
- {hints && hints.length > 0 && (
94
+ {keyboardHints && hints && hints.length > 0 && (
86
95
  <div className="relative flex flex-wrap items-center gap-x-4 gap-y-1 px-3 pt-2 pb-1">
87
96
  {hints.map(([key, label]) => (
88
97
  <span key={label} className="flex items-center gap-1.5 text-caption text-text-secondary">
@@ -119,6 +128,7 @@ export function CommandPaletteBody({
119
128
  onClose,
120
129
  onSelect,
121
130
  }: CommandPaletteBodyProps) {
131
+ const { keyboardHints } = useDevice();
122
132
  return (
123
133
  <Autocomplete.Root
124
134
  open
@@ -138,8 +148,13 @@ export function CommandPaletteBody({
138
148
  />
139
149
  {closeControl ??
140
150
  (onClose === undefined ? null : (
141
- <button type="button" aria-label={closeLabel} className="motion-press cursor-pointer" onClick={onClose}>
142
- <Kbd>esc</Kbd>
151
+ <button
152
+ type="button"
153
+ aria-label={closeLabel}
154
+ className={keyboardHints ? "motion-press cursor-pointer" : touchCloseClass}
155
+ onClick={onClose}
156
+ >
157
+ {keyboardHints ? <Kbd keys="Escape" /> : <X aria-hidden />}
143
158
  </button>
144
159
  ))}
145
160
  </div>
@@ -179,7 +194,7 @@ export function CommandPaletteBody({
179
194
  <span className="truncate text-label-md text-text-primary">{item.label}</span>
180
195
  {item.meta && <span className="shrink-0 text-caption text-text-secondary">{item.meta}</span>}
181
196
  <span className="flex-1" />
182
- {item.hint}
197
+ {keyboardHints && item.hint}
183
198
  </Autocomplete.Item>
184
199
  )}
185
200
  </Autocomplete.Collection>
@@ -1,7 +1,7 @@
1
1
  import { type ComponentProps, type ReactNode, useRef, useState } from "react";
2
2
 
3
3
  import { cn } from "../lib/cn";
4
- import { useActiveIndicator } from "../lib/use-active-indicator";
4
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
5
5
 
6
6
  export type FilterBarItem = {
7
7
  value: string;
@@ -1,5 +1,6 @@
1
- import { type CSSProperties, useEffect, useRef, useSyncExternalStore } from "react";
1
+ import { type CSSProperties, useEffect, useRef } from "react";
2
2
 
3
+ import { useColorScheme } from "../hooks/use-color-scheme";
3
4
  import { cn } from "../lib/cn";
4
5
 
5
6
  export type FlueConfig = {
@@ -133,21 +134,6 @@ const FLUE_VARIANTS: Record<FlueVariant, FlueTuning> = {
133
134
  },
134
135
  };
135
136
 
136
- /** Follows the `.dark` class on <html>, the switch the theme uses. */
137
- function subscribeScheme(onChange: () => void): () => void {
138
- const observer = new MutationObserver(onChange);
139
- observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
140
- return () => observer.disconnect();
141
- }
142
-
143
- function useDarkScheme(): boolean {
144
- return useSyncExternalStore(
145
- subscribeScheme,
146
- () => document.documentElement.classList.contains("dark"),
147
- () => false,
148
- );
149
- }
150
-
151
137
  const ENGINE_KEYS = new Set(["cursor", "maxDpr"]);
152
138
 
153
139
  const VERT = `#version 300 es
@@ -221,7 +207,7 @@ export type FlueBackgroundProps = {
221
207
  */
222
208
  export function FlueBackground({ className, style, variant = "default", config, tintClassName }: FlueBackgroundProps) {
223
209
  const canvasRef = useRef<HTMLCanvasElement>(null);
224
- const dark = useDarkScheme();
210
+ const dark = useColorScheme() === "dark";
225
211
  const tuning = FLUE_VARIANTS[variant];
226
212
  const settings = { ...(dark ? FLUE_NIGHT : FLUE_SKY), ...(dark ? tuning.dark : tuning.light), ...config };
227
213
  const key = JSON.stringify(settings);
@@ -1,6 +1,8 @@
1
1
  import type { ComponentProps } from "react";
2
2
 
3
+ import { usePlatform } from "../hooks/use-platform";
3
4
  import { cn } from "../lib/cn";
5
+ import { formatShortcut } from "../lib/platform";
4
6
  import type { ControlSize } from "../lib/size";
5
7
 
6
8
  export type KbdSize = ControlSize;
@@ -8,6 +10,8 @@ export type KbdSize = ControlSize;
8
10
  export type KbdProps = Omit<ComponentProps<"kbd">, "className"> & {
9
11
  /** Chip size: `sm` tighter caption, `md` the Command palette chip (default), `lg` label-sm with more padding. */
10
12
  size?: KbdSize;
13
+ /** Shortcut such as `Mod+Enter`, drawn with the OS's key labels (`⌘↵` / `Ctrl+Enter`) instead of children. */
14
+ keys?: string;
11
15
  className?: string;
12
16
  };
13
17
 
@@ -19,7 +23,8 @@ const sizeClass: Record<KbdSize, string> = {
19
23
  };
20
24
 
21
25
  /** Figma `Kbd` (Command palette 110:676): a 6% ink chip with caption text. */
22
- export function Kbd({ size = "md", className, children, ...props }: KbdProps) {
26
+ export function Kbd({ size = "md", keys, className, children, ...props }: KbdProps) {
27
+ const { os } = usePlatform();
23
28
  return (
24
29
  <kbd
25
30
  className={cn(
@@ -29,7 +34,9 @@ export function Kbd({ size = "md", className, children, ...props }: KbdProps) {
29
34
  )}
30
35
  {...props}
31
36
  >
32
- <span className="inline-flex items-center gap-[inherit] text-trim">{children}</span>
37
+ <span className="inline-flex items-center gap-[inherit] text-trim">
38
+ {keys === undefined ? children : formatShortcut(keys, os)}
39
+ </span>
33
40
  </kbd>
34
41
  );
35
42
  }
@@ -1,6 +1,7 @@
1
1
  import { Copy, FileText, X } from "@phosphor-icons/react";
2
- import { useEffect, useState, type ComponentProps, type ReactNode } from "react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
3
 
4
+ import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
4
5
  import { cn } from "../lib/cn";
5
6
  import { IconButton } from "./icon-button";
6
7
  import { LiquidGlass } from "./liquid-glass";
@@ -119,25 +120,14 @@ export function CodeBlock({
119
120
  className,
120
121
  ...props
121
122
  }: CodeBlockProps) {
122
- const [copied, setCopied] = useState(false);
123
- useEffect(() => {
124
- if (!copied) return undefined;
125
- const timer = setTimeout(() => setCopied(false), 1500);
126
- return () => clearTimeout(timer);
127
- }, [copied]);
128
- const copy = () => {
129
- navigator.clipboard?.writeText(code).then(
130
- () => setCopied(true),
131
- () => undefined,
132
- );
133
- };
123
+ const { copied, copy } = useCopyToClipboard(1500);
134
124
  return (
135
125
  <div className={cn("flex flex-col overflow-clip rounded-lg bg-surface-ink", className)} {...props}>
136
126
  <div className="flex items-center gap-2 bg-bg-surface-6 py-2 pr-3.5 pl-3.5 text-caption text-text-on-ink">
137
127
  <span className="min-w-0 flex-1 opacity-70">{language}</span>
138
128
  <button
139
129
  type="button"
140
- onClick={copy}
130
+ onClick={() => copy(code)}
141
131
  className="flex shrink-0 motion-press cursor-pointer items-center gap-1 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
142
132
  >
143
133
  <Copy aria-hidden className="size-3.5 shrink-0" />
@@ -4,7 +4,7 @@ import { type ReactNode, useRef } from "react";
4
4
 
5
5
  import { cn } from "../lib/cn";
6
6
  import type { ControlSize } from "../lib/size";
7
- import { useActiveIndicator } from "../lib/use-active-indicator";
7
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
8
8
  import { LiquidGlass } from "./liquid-glass";
9
9
 
10
10
  /** `tab` keeps Paper's page-tab rung beside the three control sizes. */
@@ -2,7 +2,7 @@ import { type ReactNode, useRef } from "react";
2
2
 
3
3
  import { cn } from "../lib/cn";
4
4
  import type { ControlSize } from "../lib/size";
5
- import { useActiveIndicator } from "../lib/use-active-indicator";
5
+ import { useActiveIndicator } from "../hooks/use-active-indicator";
6
6
  import { LiquidGlass } from "./liquid-glass";
7
7
 
8
8
  export type TabBarItem = { value: string; label: string; icon: ReactNode };
@@ -1,7 +1,8 @@
1
1
  import { Collapsible } from "@base-ui/react/collapsible";
2
2
  import { ArrowClockwise, CaretDown, Check, CheckCircle, Copy, WarningCircle } from "@phosphor-icons/react";
3
- import { useEffect, useState, type ReactNode } from "react";
3
+ import type { ReactNode } from "react";
4
4
 
5
+ import { useCopyToClipboard } from "../hooks/use-copy-to-clipboard";
5
6
  import { cn } from "../lib/cn";
6
7
  import { Button } from "./button";
7
8
  import { Spinner } from "./spinner";
@@ -91,12 +92,7 @@ function Section({
91
92
  copyText?: string;
92
93
  footer?: ReactNode;
93
94
  }) {
94
- const [copied, setCopied] = useState(false);
95
- useEffect(() => {
96
- if (!copied) return undefined;
97
- const timer = setTimeout(() => setCopied(false), 1500);
98
- return () => clearTimeout(timer);
99
- }, [copied]);
95
+ const { copied, copy } = useCopyToClipboard(1500);
100
96
  return (
101
97
  <div className="flex min-w-0 flex-col gap-1.5">
102
98
  <div className="flex items-center gap-2 text-label-sm text-text-secondary">
@@ -105,12 +101,7 @@ function Section({
105
101
  <button
106
102
  type="button"
107
103
  aria-label={copied ? "Copied" : `Copy ${label.toLowerCase()}`}
108
- onClick={() => {
109
- navigator.clipboard?.writeText(copyText).then(
110
- () => setCopied(true),
111
- () => undefined,
112
- );
113
- }}
104
+ onClick={() => copy(copyText)}
114
105
  className="flex motion-press cursor-pointer items-center justify-center rounded-sm hover:text-text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
115
106
  >
116
107
  {copied ? (
@@ -0,0 +1,8 @@
1
+ export * from "./use-active-indicator";
2
+ export * from "./use-color-scheme";
3
+ export * from "./use-copy-to-clipboard";
4
+ export * from "./use-device";
5
+ export * from "./use-hotkey";
6
+ export * from "./use-media-query";
7
+ export * from "./use-platform";
8
+ export * from "./use-reduced-motion";
@@ -0,0 +1,27 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ export type ColorScheme = "light" | "dark";
4
+
5
+ const noop = () => {};
6
+
7
+ /**
8
+ * The theme is stored as the `.dark` class on <html> (the site's ThemeToggle
9
+ * writes it), so that class is the only source — `prefers-color-scheme` is not
10
+ * consulted.
11
+ */
12
+ function subscribe(onChange: () => void): () => void {
13
+ if (typeof document === "undefined" || typeof MutationObserver === "undefined") return noop;
14
+ const observer = new MutationObserver(onChange);
15
+ observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] });
16
+ return () => observer.disconnect();
17
+ }
18
+
19
+ function readIsDark(): boolean {
20
+ if (typeof document === "undefined") return false;
21
+ return document.documentElement.classList.contains("dark");
22
+ }
23
+
24
+ /** The active color scheme; `light` on the server and before hydration. */
25
+ export function useColorScheme(): ColorScheme {
26
+ return useSyncExternalStore(subscribe, readIsDark, () => false) ? "dark" : "light";
27
+ }
@@ -0,0 +1,32 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+
3
+ export type CopyToClipboard = {
4
+ copied: boolean;
5
+ /** Writes `text`; a failed write leaves `copied` false instead of throwing. */
6
+ copy: (text: string) => void;
7
+ };
8
+
9
+ /**
10
+ * Clipboard writes with a self-clearing confirmation: `copied` turns true when
11
+ * the write resolves and back to false after `resetMs`, so a button needs no
12
+ * timer of its own.
13
+ */
14
+ export function useCopyToClipboard(resetMs = 1500): CopyToClipboard {
15
+ const [copied, setCopied] = useState(false);
16
+
17
+ useEffect(() => {
18
+ if (!copied) return undefined;
19
+ const timer = setTimeout(() => setCopied(false), resetMs);
20
+ return () => clearTimeout(timer);
21
+ }, [copied, resetMs]);
22
+
23
+ const copy = useCallback((text: string) => {
24
+ if (typeof navigator === "undefined" || !navigator.clipboard) return;
25
+ navigator.clipboard.writeText(text).then(
26
+ () => setCopied(true),
27
+ () => undefined,
28
+ );
29
+ }, []);
30
+
31
+ return { copied, copy };
32
+ }
@@ -0,0 +1,41 @@
1
+ import { useMediaQuery } from "./use-media-query";
2
+
3
+ export type DeviceType = "mobile" | "tablet" | "desktop";
4
+
5
+ export type DeviceInput = "touch" | "pointer";
6
+
7
+ export type Device = {
8
+ /** `mobile` below 768px, `tablet` for a coarse pointer above that, `desktop` otherwise. */
9
+ type: DeviceType;
10
+ input: DeviceInput;
11
+ /** True only for a hovering fine pointer, the input that can actually reach a key chip. */
12
+ keyboardHints: boolean;
13
+ };
14
+
15
+ const MOBILE_QUERY = "(max-width: 767px)";
16
+ const COARSE_QUERY = "(pointer: coarse)";
17
+ const HINTS_QUERY = "(hover: hover) and (pointer: fine)";
18
+
19
+ /**
20
+ * `useSyncExternalStore` compares snapshots by reference, so each result is
21
+ * built once and handed out again.
22
+ */
23
+ const devices = new Map<string, Device>();
24
+
25
+ function deviceSnapshot(type: DeviceType, input: DeviceInput, keyboardHints: boolean): Device {
26
+ const key = `${type}:${input}:${keyboardHints}`;
27
+ const cached = devices.get(key);
28
+ if (cached) return cached;
29
+ const next = { type, input, keyboardHints };
30
+ devices.set(key, next);
31
+ return next;
32
+ }
33
+
34
+ /** The device class and its primary input, as the layout and key hints need them. */
35
+ export function useDevice(): Device {
36
+ const mobile = useMediaQuery(MOBILE_QUERY);
37
+ const coarse = useMediaQuery(COARSE_QUERY);
38
+ const keyboardHints = useMediaQuery(HINTS_QUERY, true);
39
+ const type: DeviceType = mobile ? "mobile" : coarse ? "tablet" : "desktop";
40
+ return deviceSnapshot(type, coarse ? "touch" : "pointer", keyboardHints);
41
+ }
@@ -0,0 +1,35 @@
1
+ import { useEffect, useRef } from "react";
2
+
3
+ import { matchesShortcut } from "../lib/platform";
4
+ import { usePlatform } from "./use-platform";
5
+
6
+ export type HotkeyOptions = {
7
+ /** When false the listener is detached and the hotkey does nothing (default true). */
8
+ enabled?: boolean;
9
+ };
10
+
11
+ /**
12
+ * Runs `handler` on a `document` keydown that matches `keys`, such as `Mod+K`,
13
+ * and prevents the browser default. Text inputs keep their focus, so a palette
14
+ * shortcut still fires mid-typing; the check follows the OS modifier key.
15
+ */
16
+ export function useHotkey(keys: string, handler: (event: KeyboardEvent) => void, options: HotkeyOptions = {}) {
17
+ const { enabled = true } = options;
18
+ const platform = usePlatform();
19
+ const handlerRef = useRef(handler);
20
+
21
+ useEffect(() => {
22
+ handlerRef.current = handler;
23
+ });
24
+
25
+ useEffect(() => {
26
+ if (!enabled) return undefined;
27
+ const onKeyDown = (event: KeyboardEvent) => {
28
+ if (!matchesShortcut(event, keys, platform.os)) return;
29
+ event.preventDefault();
30
+ handlerRef.current(event);
31
+ };
32
+ document.addEventListener("keydown", onKeyDown);
33
+ return () => document.removeEventListener("keydown", onKeyDown);
34
+ }, [keys, enabled, platform.os]);
35
+ }
@@ -0,0 +1,25 @@
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
+ export function useMediaQuery(query: string, serverValue = false): boolean {
11
+ const subscribe = useCallback(
12
+ (onChange: () => void) => {
13
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return noop;
14
+ const media = window.matchMedia(query);
15
+ media.addEventListener("change", onChange);
16
+ return () => media.removeEventListener("change", onChange);
17
+ },
18
+ [query],
19
+ );
20
+ const getSnapshot = useCallback(() => {
21
+ if (typeof window === "undefined" || typeof window.matchMedia !== "function") return serverValue;
22
+ return window.matchMedia(query).matches;
23
+ }, [query, serverValue]);
24
+ return useSyncExternalStore(subscribe, getSnapshot, () => serverValue);
25
+ }
@@ -0,0 +1,39 @@
1
+ import { useSyncExternalStore } from "react";
2
+
3
+ import { detectOs, detectRuntime, readNavigator, type Os, type Runtime } from "../lib/platform";
4
+
5
+ export type ModKey = "meta" | "ctrl";
6
+
7
+ export type Platform = {
8
+ os: Os;
9
+ runtime: Runtime;
10
+ /** The physical key behind `Mod` on this OS, ready for a keydown check. */
11
+ modKey: ModKey;
12
+ };
13
+
14
+ const noop = () => {};
15
+
16
+ /** What a server render and the first hydration pass assume. */
17
+ const SERVER_PLATFORM: Platform = { os: "unknown", runtime: "web", modKey: "meta" };
18
+
19
+ /**
20
+ * The OS and shell do not change while a tab is open, so the platform is read
21
+ * once and every later render reuses the same object.
22
+ */
23
+ let clientPlatform: Platform | null = null;
24
+
25
+ function readPlatform(): Platform {
26
+ if (typeof window === "undefined") return SERVER_PLATFORM;
27
+ if (clientPlatform) return clientPlatform;
28
+ const os = detectOs(readNavigator());
29
+ const modKey: ModKey = os === "windows" || os === "linux" || os === "android" ? "ctrl" : "meta";
30
+ clientPlatform = { os, runtime: detectRuntime(window), modKey };
31
+ return clientPlatform;
32
+ }
33
+
34
+ const subscribe = (_onChange: () => void) => noop;
35
+
36
+ /** The current OS, browser or desktop shell, and the key that means `Mod`. */
37
+ export function usePlatform(): Platform {
38
+ return useSyncExternalStore(subscribe, readPlatform, () => SERVER_PLATFORM);
39
+ }
@@ -0,0 +1,8 @@
1
+ import { useMediaQuery } from "./use-media-query";
2
+
3
+ const QUERY = "(prefers-reduced-motion: reduce)";
4
+
5
+ /** True while the OS asks for reduced motion; false on the server. */
6
+ export function useReducedMotion(): boolean {
7
+ return useMediaQuery(QUERY);
8
+ }
package/src/index.ts CHANGED
@@ -82,5 +82,18 @@ export * from "./components/tooltip";
82
82
  export * from "./components/top-bar";
83
83
  export * from "./components/usage-meter";
84
84
  export * from "./components/workspace-menu";
85
+ export * from "./hooks";
85
86
  export { cn, type ClassValue } from "./lib/cn";
86
87
  export { mountCursorGhost, type CursorGhostOptions } from "./lib/cursor-ghost";
88
+ export {
89
+ detectOs,
90
+ detectRuntime,
91
+ formatShortcut,
92
+ isModKey,
93
+ matchesShortcut,
94
+ type Os,
95
+ type PlatformInput,
96
+ type Runtime,
97
+ type RuntimeWindow,
98
+ type ShortcutEvent,
99
+ } from "./lib/platform";
@@ -0,0 +1,169 @@
1
+ export type Os = "macos" | "windows" | "linux" | "ios" | "android" | "unknown";
2
+
3
+ export type Runtime = "web" | "desktop";
4
+
5
+ /** The parts of `navigator` that identify the OS; plain data so detection stays testable. */
6
+ export type PlatformInput = {
7
+ userAgent?: string;
8
+ platform?: string;
9
+ uaPlatform?: string;
10
+ maxTouchPoints?: number;
11
+ };
12
+
13
+ /** The parts of `window` that tell a page from the desktop shell; plain data for the same reason. */
14
+ export type RuntimeWindow = {
15
+ __TAURI_INTERNALS__?: unknown;
16
+ navigator?: { userAgent?: string };
17
+ };
18
+
19
+ /** The parts of a keyboard event a shortcut reads; `KeyboardEvent` satisfies it as-is. */
20
+ export type ShortcutEvent = {
21
+ key?: string;
22
+ metaKey?: boolean;
23
+ ctrlKey?: boolean;
24
+ altKey?: boolean;
25
+ shiftKey?: boolean;
26
+ };
27
+
28
+ /**
29
+ * Reads the platform hints from the live `navigator`. `userAgentData` is not in
30
+ * the DOM typings yet, so the shape is spelled out here instead of cast away.
31
+ */
32
+ export function readNavigator(): PlatformInput {
33
+ if (typeof navigator === "undefined") return {};
34
+ const nav = navigator as {
35
+ userAgent?: string;
36
+ platform?: string;
37
+ maxTouchPoints?: number;
38
+ userAgentData?: { platform?: string };
39
+ };
40
+ return {
41
+ userAgent: nav.userAgent,
42
+ platform: nav.platform,
43
+ uaPlatform: nav.userAgentData?.platform,
44
+ maxTouchPoints: nav.maxTouchPoints,
45
+ };
46
+ }
47
+
48
+ const OS_HINTS = ["uaPlatform", "platform", "userAgent"] as const;
49
+
50
+ /** Classifies one hint string; `undefined` when the hint names no OS we know. */
51
+ function osFromHint(hint: string, maxTouchPoints: number): Os | undefined {
52
+ if (hint.includes("android")) return "android";
53
+ if (hint.includes("iphone") || hint.includes("ipad") || hint.includes("ipod") || hint.includes("ios")) return "ios";
54
+ // iPadOS reports a Mac platform and user agent, so touch points are what separate it from a Mac.
55
+ if (hint.includes("mac")) return maxTouchPoints > 1 ? "ios" : "macos";
56
+ if (hint.includes("win")) return "windows";
57
+ if (hint.includes("linux") || hint.includes("x11") || hint.includes("cros") || hint.includes("bsd")) return "linux";
58
+ return undefined;
59
+ }
60
+
61
+ /**
62
+ * Finds the OS from the first hint that names one: `userAgentData.platform`,
63
+ * then `navigator.platform`, then the user agent string. Empty input is
64
+ * `unknown`, which the shortcuts treat as the macOS/⌘ rendering.
65
+ */
66
+ export function detectOs(input: PlatformInput = {}): Os {
67
+ const maxTouchPoints = input.maxTouchPoints ?? 0;
68
+ for (const field of OS_HINTS) {
69
+ const hint = (input[field] ?? "").trim().toLowerCase();
70
+ if (!hint) continue;
71
+ const os = osFromHint(hint, maxTouchPoints);
72
+ if (os) return os;
73
+ }
74
+ return "unknown";
75
+ }
76
+
77
+ /** `desktop` for the Tauri shell and Electron; `web` everywhere else, including the server. */
78
+ export function detectRuntime(win: RuntimeWindow | null = null): Runtime {
79
+ if (!win) return "web";
80
+ if ("__TAURI_INTERNALS__" in win) return "desktop";
81
+ return /electron/i.test(win.navigator?.userAgent ?? "") ? "desktop" : "web";
82
+ }
83
+
84
+ const MAC_LABELS: Record<string, string> = {
85
+ mod: "⌘",
86
+ shift: "⇧",
87
+ alt: "⌥",
88
+ enter: "↵",
89
+ escape: "esc",
90
+ esc: "esc",
91
+ arrowup: "↑",
92
+ arrowdown: "↓",
93
+ arrowleft: "←",
94
+ arrowright: "→",
95
+ };
96
+
97
+ const OTHER_LABELS: Record<string, string> = {
98
+ mod: "Ctrl",
99
+ shift: "Shift",
100
+ alt: "Alt",
101
+ enter: "Enter",
102
+ escape: "Esc",
103
+ esc: "Esc",
104
+ arrowup: "↑",
105
+ arrowdown: "↓",
106
+ arrowleft: "←",
107
+ arrowright: "→",
108
+ };
109
+
110
+ /**
111
+ * Draws a `+`-joined shortcut for one OS: `Mod+K` is `⌘K` on macOS and iOS,
112
+ * `Ctrl+K` on Windows and Linux. Mac families glue the parts, the rest keep `+`.
113
+ */
114
+ export function formatShortcut(keys: string, os: Os): string {
115
+ const parts = keys
116
+ .split("+")
117
+ .map((part) => part.trim())
118
+ .filter(Boolean);
119
+ if (parts.length === 0) return "";
120
+ // macOS, iOS and the unknown server default draw Mac symbols; everyone else spells them out.
121
+ const mac = os === "macos" || os === "ios" || os === "unknown";
122
+ const labels = mac ? MAC_LABELS : OTHER_LABELS;
123
+ return parts.map((part) => labels[part.toLowerCase()] ?? part).join(mac ? "" : "+");
124
+ }
125
+
126
+ /** Which physical key carries `Mod` for an OS; `either` is the server/unknown case. */
127
+ function modifierSlot(os: Os): "meta" | "ctrl" | "either" {
128
+ if (os === "macos" || os === "ios") return "meta";
129
+ if (os === "windows" || os === "linux" || os === "android") return "ctrl";
130
+ return "either";
131
+ }
132
+
133
+ /** True when the platform's `Mod` key is down; the other side's key is not `Mod`. */
134
+ export function isModKey(event: ShortcutEvent, os: Os): boolean {
135
+ const slot = modifierSlot(os);
136
+ const meta = event.metaKey === true;
137
+ const ctrl = event.ctrlKey === true;
138
+ if (slot === "meta") return meta && !ctrl;
139
+ if (slot === "ctrl") return ctrl && !meta;
140
+ return meta || ctrl;
141
+ }
142
+
143
+ /** Modifier names that are never the final key of a shortcut. */
144
+ const MODIFIERS: Record<string, true> = { mod: true, shift: true, alt: true, ctrl: true, meta: true };
145
+
146
+ const KEY_ALIASES: Record<string, string> = { esc: "escape" };
147
+
148
+ function canonicalKey(value: string): string {
149
+ const lower = value.trim().toLowerCase();
150
+ return KEY_ALIASES[lower] ?? lower;
151
+ }
152
+
153
+ /**
154
+ * Tests a keyboard event against a shortcut string such as `Mod+K` or
155
+ * `Mod+Shift+Enter`. `Mod` is the platform key (⌘ on macOS/iOS, Ctrl
156
+ * elsewhere, either one when the OS is unknown).
157
+ */
158
+ export function matchesShortcut(event: ShortcutEvent, keys: string, os: Os): boolean {
159
+ const parts = keys
160
+ .split("+")
161
+ .map((part) => part.trim().toLowerCase())
162
+ .filter(Boolean);
163
+ const base = parts.find((part) => !MODIFIERS[part]);
164
+ if (!base) return false;
165
+ if ((event.altKey === true) !== parts.includes("alt")) return false;
166
+ if ((event.shiftKey === true) !== parts.includes("shift")) return false;
167
+ if (isModKey(event, os) !== parts.includes("mod")) return false;
168
+ return canonicalKey(event.key ?? "") === canonicalKey(base);
169
+ }
File without changes