@horon-ai/react 0.0.0-stage → 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.
Files changed (102) hide show
  1. package/README.md +168 -2
  2. package/package.json +50 -5
  3. package/src/assets/sidebar-photo.jpg +0 -0
  4. package/src/components/agent-activity.tsx +204 -0
  5. package/src/components/agent-lane.tsx +80 -0
  6. package/src/components/agent-lanes-timeline.tsx +312 -0
  7. package/src/components/agent-panel.tsx +125 -0
  8. package/src/components/agent-steering-panel.tsx +276 -0
  9. package/src/components/approval-card.tsx +89 -0
  10. package/src/components/avatar.tsx +104 -0
  11. package/src/components/badge.tsx +63 -0
  12. package/src/components/banner.tsx +103 -0
  13. package/src/components/bot-instructions-panel.tsx +369 -0
  14. package/src/components/bot-list-row.tsx +42 -0
  15. package/src/components/bot-memory-panel.tsx +229 -0
  16. package/src/components/bottom-sheet.tsx +140 -0
  17. package/src/components/breadcrumb-header.tsx +87 -0
  18. package/src/components/breadcrumb.tsx +86 -0
  19. package/src/components/button.tsx +146 -0
  20. package/src/components/calendar.tsx +410 -0
  21. package/src/components/card.tsx +79 -0
  22. package/src/components/chat-bubble.tsx +36 -0
  23. package/src/components/chat-controls.tsx +118 -0
  24. package/src/components/checkbox.tsx +73 -0
  25. package/src/components/chip.tsx +81 -0
  26. package/src/components/choice-card.tsx +55 -0
  27. package/src/components/code-panel.tsx +27 -0
  28. package/src/components/combobox.tsx +312 -0
  29. package/src/components/command-palette.tsx +206 -0
  30. package/src/components/composer.tsx +124 -0
  31. package/src/components/data-table.tsx +429 -0
  32. package/src/components/date-field.tsx +100 -0
  33. package/src/components/dialog.tsx +93 -0
  34. package/src/components/directory-card.tsx +59 -0
  35. package/src/components/document-extract-panel.tsx +398 -0
  36. package/src/components/empty-state.tsx +29 -0
  37. package/src/components/field.tsx +78 -0
  38. package/src/components/file-drop.tsx +89 -0
  39. package/src/components/filter-bar.tsx +83 -0
  40. package/src/components/flue-background.tsx +388 -0
  41. package/src/components/horon-icon.tsx +280 -0
  42. package/src/components/horon-wordmark.tsx +26 -0
  43. package/src/components/icon-button.tsx +61 -0
  44. package/src/components/input-group.tsx +112 -0
  45. package/src/components/input.tsx +72 -0
  46. package/src/components/integration-row.tsx +103 -0
  47. package/src/components/kbd.tsx +42 -0
  48. package/src/components/knowledge-base-panel.tsx +185 -0
  49. package/src/components/liquid-glass.tsx +24 -0
  50. package/src/components/list-detail.tsx +275 -0
  51. package/src/components/marketplace-card.tsx +97 -0
  52. package/src/components/mcp-server-row.tsx +122 -0
  53. package/src/components/menu.tsx +165 -0
  54. package/src/components/message-extras.tsx +146 -0
  55. package/src/components/modal.tsx +108 -0
  56. package/src/components/orchestrator-panel.tsx +175 -0
  57. package/src/components/page-header.tsx +48 -0
  58. package/src/components/plan-card.tsx +70 -0
  59. package/src/components/plan-step.tsx +111 -0
  60. package/src/components/plugin-row.tsx +128 -0
  61. package/src/components/progress.tsx +21 -0
  62. package/src/components/radio-group.tsx +84 -0
  63. package/src/components/research-run-panel.tsx +232 -0
  64. package/src/components/schedule.tsx +332 -0
  65. package/src/components/segmented-control.tsx +136 -0
  66. package/src/components/select.tsx +154 -0
  67. package/src/components/settings-list.tsx +99 -0
  68. package/src/components/sidebar.tsx +542 -0
  69. package/src/components/skeleton.tsx +75 -0
  70. package/src/components/slider.tsx +29 -0
  71. package/src/components/source-row.tsx +112 -0
  72. package/src/components/spinner.tsx +29 -0
  73. package/src/components/split-button.tsx +72 -0
  74. package/src/components/switch.tsx +79 -0
  75. package/src/components/tab-bar.tsx +90 -0
  76. package/src/components/tabs.tsx +162 -0
  77. package/src/components/task-row.tsx +159 -0
  78. package/src/components/textarea-field.tsx +127 -0
  79. package/src/components/textarea.tsx +47 -0
  80. package/src/components/toast.tsx +128 -0
  81. package/src/components/tool-call.tsx +215 -0
  82. package/src/components/tool-calls-header.tsx +65 -0
  83. package/src/components/tool-permissions.tsx +105 -0
  84. package/src/components/tooltip.tsx +33 -0
  85. package/src/components/top-bar.tsx +65 -0
  86. package/src/components/usage-meter.tsx +47 -0
  87. package/src/components/workspace-menu.tsx +77 -0
  88. package/src/hooks/index.ts +8 -0
  89. package/src/hooks/use-active-indicator.ts +60 -0
  90. package/src/hooks/use-color-scheme.ts +27 -0
  91. package/src/hooks/use-copy-to-clipboard.ts +32 -0
  92. package/src/hooks/use-device.ts +41 -0
  93. package/src/hooks/use-hotkey.ts +35 -0
  94. package/src/hooks/use-media-query.ts +25 -0
  95. package/src/hooks/use-platform.ts +39 -0
  96. package/src/hooks/use-reduced-motion.ts +8 -0
  97. package/src/index.ts +99 -0
  98. package/src/lib/cn.ts +25 -0
  99. package/src/lib/cursor-ghost.ts +244 -0
  100. package/src/lib/platform.ts +169 -0
  101. package/src/lib/size.ts +10 -0
  102. package/src/styles/globals.css +564 -0
@@ -0,0 +1,65 @@
1
+ import { Collapsible } from "@base-ui/react/collapsible";
2
+ import { CaretDown } from "@phosphor-icons/react";
3
+ import { Children, type ReactNode } from "react";
4
+
5
+ import { cn } from "../lib/cn";
6
+
7
+ export type ToolCallsHeaderProps = {
8
+ /** Up to three 14px tool icons, drawn as overlapping 28px chips. */
9
+ icons?: ReactNode;
10
+ title?: ReactNode;
11
+ /** Elapsed time, such as "4.0s". */
12
+ elapsed?: ReactNode;
13
+ /** `ToolCall` cards, stacked 8px apart under the header. */
14
+ children?: ReactNode;
15
+ defaultOpen?: boolean;
16
+ open?: boolean;
17
+ onOpenChange?: (open: boolean) => void;
18
+ className?: string;
19
+ };
20
+
21
+ /** Figma `Tool-calls header` (Agents N11): overlapping tool chips, "Using tools…" and a caret that fold the ToolCall cards below. */
22
+ export function ToolCallsHeader({
23
+ icons,
24
+ title = "Using tools…",
25
+ elapsed,
26
+ children,
27
+ defaultOpen = true,
28
+ open,
29
+ onOpenChange,
30
+ className,
31
+ }: ToolCallsHeaderProps) {
32
+ const chips = Children.toArray(icons).slice(0, 3);
33
+ return (
34
+ <Collapsible.Root
35
+ defaultOpen={defaultOpen}
36
+ open={open}
37
+ onOpenChange={onOpenChange}
38
+ className={cn(
39
+ "flex w-full min-w-0 flex-col rounded-xl border border-border-default bg-bg-surface p-3",
40
+ className,
41
+ )}
42
+ >
43
+ <Collapsible.Trigger className="group flex w-full min-w-0 cursor-pointer items-center gap-2.5 pb-1 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand">
44
+ {chips.length > 0 && (
45
+ <span className="flex shrink-0 items-center">
46
+ {Children.map(chips, (icon) => (
47
+ <span className="-ml-2 flex size-7 shrink-0 items-center justify-center rounded-[10px] bg-bg-subtle text-text-secondary ring-2 ring-bg-surface first:ml-0 [&>svg]:size-3.5">
48
+ {icon}
49
+ </span>
50
+ ))}
51
+ </span>
52
+ )}
53
+ <span className="min-w-0 truncate text-label-md text-text-primary">{title}</span>
54
+ {elapsed && <span className="shrink-0 text-label-md text-text-secondary">· {elapsed}</span>}
55
+ <CaretDown
56
+ aria-hidden
57
+ className="ml-auto size-4 shrink-0 text-text-secondary transition-transform duration-200 ease-out-quint group-data-panel-open:rotate-180"
58
+ />
59
+ </Collapsible.Trigger>
60
+ <Collapsible.Panel className="motion-collapse">
61
+ <div className="flex min-w-0 flex-col gap-2 pt-1">{children}</div>
62
+ </Collapsible.Panel>
63
+ </Collapsible.Root>
64
+ );
65
+ }
@@ -0,0 +1,105 @@
1
+ import { type ComponentProps, type ReactNode, useState } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+ import { SegmentedControl } from "./segmented-control";
5
+
6
+ export type ToolPermission = "allow" | "ask" | "off";
7
+
8
+ export type ToolPermissionItem = {
9
+ name: string;
10
+ description?: ReactNode;
11
+ /** Starting choice for the uncontrolled component; defaults to `ask`. */
12
+ defaultValue?: ToolPermission;
13
+ };
14
+
15
+ export type ToolPermissionsProps = Omit<ComponentProps<"div">, "className" | "title" | "onChange"> & {
16
+ title: ReactNode;
17
+ tools: ToolPermissionItem[];
18
+ /** Controlled choices keyed by tool name. */
19
+ values?: Record<string, ToolPermission>;
20
+ onValuesChange?: (values: Record<string, ToolPermission>) => void;
21
+ /** Label of the text button that sets every tool to Allow. */
22
+ allowAllLabel?: ReactNode;
23
+ className?: string;
24
+ };
25
+
26
+ const options: { value: ToolPermission; label: string }[] = [
27
+ { value: "allow", label: "Allow" },
28
+ { value: "ask", label: "Ask" },
29
+ { value: "off", label: "Off" },
30
+ ];
31
+
32
+ /**
33
+ * Paper `Tool permissions` (1VT5-0): one solid card, 16px radius with a default border. A subtle header
34
+ * carries the group title, a count pill and an "Allow all" text button; below it one divided row per tool
35
+ * with a mono name, a description and a small solid Allow / Ask / Off segmented control. 16px side padding
36
+ * on every row so the names, the header title and the controls line up.
37
+ */
38
+ export function ToolPermissions({
39
+ title,
40
+ tools,
41
+ values,
42
+ onValuesChange,
43
+ allowAllLabel = "Allow all",
44
+ className,
45
+ ...props
46
+ }: ToolPermissionsProps) {
47
+ const [inner, setInner] = useState<Record<string, ToolPermission>>(() =>
48
+ Object.fromEntries(tools.map((tool) => [tool.name, tool.defaultValue ?? "ask"])),
49
+ );
50
+ const current = values ?? inner;
51
+ const update = (next: Record<string, ToolPermission>) => {
52
+ setInner(next);
53
+ onValuesChange?.(next);
54
+ };
55
+ return (
56
+ <div
57
+ className={cn(
58
+ "flex w-full max-w-130 min-w-0 flex-col overflow-clip rounded-2xl border border-border-default bg-bg-surface",
59
+ className,
60
+ )}
61
+ {...props}
62
+ >
63
+ <div className="flex h-11 items-center gap-2 border-b border-border-default bg-bg-subtle px-4">
64
+ <span className="min-w-0 truncate text-label-md font-semibold text-text-primary">{title}</span>
65
+ <span className="inline-flex h-5 shrink-0 items-center rounded-full bg-overlay-ink-6 px-1.5 text-label-sm text-text-secondary">
66
+ <span className="text-trim">{tools.length}</span>
67
+ </span>
68
+ <span className="min-w-0 flex-1" />
69
+ <button
70
+ type="button"
71
+ className="shrink-0 cursor-pointer rounded-sm text-label-sm whitespace-nowrap text-text-secondary motion-colors hover:text-text-primary focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-text-brand"
72
+ onClick={() => update(Object.fromEntries(tools.map((tool) => [tool.name, "allow" as const])))}
73
+ >
74
+ {allowAllLabel}
75
+ </button>
76
+ </div>
77
+ {tools.map((tool) => (
78
+ <div
79
+ key={tool.name}
80
+ className="flex flex-wrap items-center gap-x-4 gap-y-2 border-b border-border-default px-4 py-3 last:border-b-0"
81
+ >
82
+ <div className="flex min-w-0 grow basis-40 flex-col gap-1">
83
+ <span className="truncate font-mono text-code font-medium text-text-primary">{tool.name}</span>
84
+ {tool.description && (
85
+ <span className="text-caption wrap-break-word text-text-secondary">{tool.description}</span>
86
+ )}
87
+ </div>
88
+ <SegmentedControl
89
+ aria-label={`${tool.name} permission`}
90
+ size="sm"
91
+ surface="solid"
92
+ fill={false}
93
+ className="shrink-0"
94
+ items={options}
95
+ value={current[tool.name] ?? "ask"}
96
+ onValueChange={(value) => {
97
+ const next = options.find((option) => option.value === value);
98
+ if (next) update({ ...current, [tool.name]: next.value });
99
+ }}
100
+ />
101
+ </div>
102
+ ))}
103
+ </div>
104
+ );
105
+ }
@@ -0,0 +1,33 @@
1
+ import { Tooltip as BaseTooltip } from "@base-ui/react/tooltip";
2
+ import type { ReactElement, ReactNode } from "react";
3
+
4
+ export type TooltipProps = {
5
+ /** The element that shows the tooltip on hover and focus. */
6
+ children: ReactElement;
7
+ label: ReactNode;
8
+ side?: "top" | "bottom" | "left" | "right";
9
+ delay?: number;
10
+ };
11
+
12
+ /** Figma `Tooltip` (62:282): an ink bubble, 10/6px padding, 8px radius, with a 10x6 arrow. */
13
+ export function Tooltip({ children, label, side = "top", delay }: TooltipProps) {
14
+ return (
15
+ <BaseTooltip.Root>
16
+ <BaseTooltip.Trigger delay={delay} render={children} />
17
+ <BaseTooltip.Portal>
18
+ <BaseTooltip.Positioner side={side} sideOffset={6} className="z-50">
19
+ <BaseTooltip.Popup className="flex max-w-64 motion-popup rounded-sm bg-interactive-primary px-2.5 py-1.5 text-label-sm text-balance text-interactive-on-primary duration-150">
20
+ {label}
21
+ <BaseTooltip.Arrow className="flex data-[side=bottom]:-top-1.5 data-[side=bottom]:rotate-180 data-[side=left]:-right-2 data-[side=left]:-rotate-90 data-[side=right]:-left-2 data-[side=right]:rotate-90 data-[side=top]:-bottom-1.5">
22
+ <svg width="10" height="6" viewBox="0 0 10 6" aria-hidden className="fill-interactive-primary">
23
+ <path d="M0.67 1.5h8.66L5 6z" />
24
+ </svg>
25
+ </BaseTooltip.Arrow>
26
+ </BaseTooltip.Popup>
27
+ </BaseTooltip.Positioner>
28
+ </BaseTooltip.Portal>
29
+ </BaseTooltip.Root>
30
+ );
31
+ }
32
+
33
+ export const TooltipProvider = BaseTooltip.Provider;
@@ -0,0 +1,65 @@
1
+ import { CaretLeft, DotsThree } from "@phosphor-icons/react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Avatar } from "./avatar";
6
+ import { IconButton } from "./icon-button";
7
+ import { LiquidGlass } from "./liquid-glass";
8
+
9
+ export type TopBarProps = Omit<ComponentProps<"header">, "className" | "title"> & {
10
+ title: ReactNode;
11
+ subtitle?: ReactNode;
12
+ /** Shows the live status dot on the avatar. */
13
+ live?: boolean;
14
+ onBack?: () => void;
15
+ onMore?: () => void;
16
+ backLabel?: string;
17
+ moreLabel?: string;
18
+ /** `solid` (default) is an opaque surface bar with a default border; `glass` is the regular glass bar. */
19
+ surface?: "solid" | "glass";
20
+ className?: string;
21
+ };
22
+
23
+ /** Paper `Top bar`: back button, bot avatar with a flush live dot, title and subtitle, on solid surface or regular glass. */
24
+ export function TopBar({
25
+ title,
26
+ subtitle,
27
+ live,
28
+ onBack,
29
+ onMore,
30
+ backLabel = "Back",
31
+ moreLabel = "More",
32
+ surface = "solid",
33
+ className,
34
+ ...props
35
+ }: TopBarProps) {
36
+ return (
37
+ <header
38
+ className={cn(
39
+ "relative z-10 flex items-center gap-2 overflow-clip rounded-full p-2",
40
+ surface === "glass" ? "bg-glass-regular" : "border border-border-default bg-bg-surface",
41
+ className,
42
+ )}
43
+ {...props}
44
+ >
45
+ {surface === "glass" && <LiquidGlass />}
46
+ {onBack && <IconButton icon={<CaretLeft />} label={backLabel} onClick={onBack} />}
47
+ <span className="relative inline-flex shrink-0">
48
+ <Avatar type="bot" size="md" />
49
+ {live && (
50
+ <span aria-hidden className="absolute right-0 bottom-0 flex animate-pop-in">
51
+ <span className="size-[9px] animate-live-pulse rounded-full bg-status-live text-status-live" />
52
+ </span>
53
+ )}
54
+ </span>
55
+ <div
56
+ key={typeof title === "string" ? `${title}|${typeof subtitle === "string" ? subtitle : ""}` : undefined}
57
+ className="relative flex min-w-0 flex-1 animate-fade-in flex-col overflow-clip"
58
+ >
59
+ <span className="truncate text-label-lg text-text-primary">{title}</span>
60
+ {subtitle && <span className="truncate text-caption text-text-secondary">{subtitle}</span>}
61
+ </div>
62
+ {onMore && <IconButton icon={<DotsThree weight="bold" />} label={moreLabel} onClick={onMore} />}
63
+ </header>
64
+ );
65
+ }
@@ -0,0 +1,47 @@
1
+ import { Meter } from "@base-ui/react/meter";
2
+ import type { ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { LiquidGlass } from "./liquid-glass";
6
+
7
+ export type UsageMeterProps = {
8
+ label: ReactNode;
9
+ /** 0-100. */
10
+ value: number;
11
+ /** Left caption, such as "6,420 of 10,000 tasks". */
12
+ detail?: ReactNode;
13
+ /** Right caption, such as "Resets Nov 1". */
14
+ note?: ReactNode;
15
+ /** `solid` (default) is an opaque surface card with a default border; `glass` is the thick glass card. */
16
+ surface?: "solid" | "glass";
17
+ className?: string;
18
+ };
19
+
20
+ /** Figma `Usage meter` (in Loading 109:733): a card with a 6px sky fill, solid or thick glass. */
21
+ export function UsageMeter({ label, value, detail, note, surface = "solid", className }: UsageMeterProps) {
22
+ return (
23
+ <Meter.Root
24
+ value={value}
25
+ className={cn(
26
+ "relative flex w-full max-w-75 min-w-0 flex-col gap-2 overflow-clip rounded-xl p-4",
27
+ surface === "glass" ? "bg-glass-thick" : "border border-border-default bg-bg-surface",
28
+ className,
29
+ )}
30
+ >
31
+ {surface === "glass" && <LiquidGlass />}
32
+ <div className="relative flex w-full items-center gap-2 text-label-md">
33
+ <Meter.Label className="min-w-0 flex-1 text-text-primary">{label}</Meter.Label>
34
+ <Meter.Value className="shrink-0 text-text-secondary tabular-nums" />
35
+ </div>
36
+ <Meter.Track className="relative h-1.5 w-full overflow-clip rounded-full bg-overlay-ink-8">
37
+ <Meter.Indicator className="h-full rounded-full bg-sky-500 transition-[width] duration-400 ease-out-quint" />
38
+ </Meter.Track>
39
+ {(detail || note) && (
40
+ <div className="relative flex w-full items-center gap-2 text-caption text-text-secondary">
41
+ <span className="min-w-0 flex-1">{detail}</span>
42
+ {note && <span className="shrink-0 whitespace-nowrap">{note}</span>}
43
+ </div>
44
+ )}
45
+ </Meter.Root>
46
+ );
47
+ }
@@ -0,0 +1,77 @@
1
+ import { Check, GearSix, PlusCircle } from "@phosphor-icons/react";
2
+ import type { ComponentProps, ReactNode } from "react";
3
+
4
+ import { cn } from "../lib/cn";
5
+ import { Menu, MenuContent, MenuItem, MenuLabel, MenuSeparator, MenuTile, MenuTrigger } from "./menu";
6
+
7
+ export type WorkspaceMenuItem = {
8
+ id: string;
9
+ name: string;
10
+ /** Caption under the name, such as "12 members". */
11
+ meta?: ReactNode;
12
+ /** Tile initial; defaults to the first letter of the name. */
13
+ initial?: string;
14
+ };
15
+
16
+ export type WorkspaceMenuProps = {
17
+ workspaces: WorkspaceMenuItem[];
18
+ value: string;
19
+ onValueChange?: (id: string) => void;
20
+ onCreate?: () => void;
21
+ onSettings?: () => void;
22
+ /** The element that opens the menu, such as the sidebar workspace button. */
23
+ trigger: ComponentProps<typeof MenuTrigger>["render"];
24
+ triggerChildren?: ReactNode;
25
+ triggerClassName?: string;
26
+ label?: ReactNode;
27
+ className?: string;
28
+ };
29
+
30
+ /** Paper `Workspace menu`: solid 256px menu listing workspaces with a check on the current one. */
31
+ export function WorkspaceMenu({
32
+ workspaces,
33
+ value,
34
+ onValueChange,
35
+ onCreate,
36
+ onSettings,
37
+ trigger,
38
+ triggerChildren,
39
+ triggerClassName,
40
+ label = "Workspaces",
41
+ className,
42
+ }: WorkspaceMenuProps) {
43
+ return (
44
+ <Menu>
45
+ <MenuTrigger render={trigger} className={triggerClassName}>
46
+ {triggerChildren}
47
+ </MenuTrigger>
48
+ <MenuContent surface="solid" side="bottom" align="start" sideOffset={7} className={className}>
49
+ <MenuLabel>{label}</MenuLabel>
50
+ {workspaces.map((workspace) => (
51
+ <MenuItem
52
+ key={workspace.id}
53
+ icon={<MenuTile>{workspace.initial ?? workspace.name.charAt(0).toUpperCase()}</MenuTile>}
54
+ description={workspace.meta}
55
+ selected={workspace.id === value}
56
+ trailing={
57
+ <Check
58
+ weight="bold"
59
+ className={cn("transition-opacity duration-150", workspace.id === value ? "opacity-100" : "opacity-0")}
60
+ />
61
+ }
62
+ onClick={() => onValueChange?.(workspace.id)}
63
+ >
64
+ {workspace.name}
65
+ </MenuItem>
66
+ ))}
67
+ <MenuSeparator />
68
+ <MenuItem icon={<PlusCircle />} onClick={onCreate}>
69
+ New workspace
70
+ </MenuItem>
71
+ <MenuItem icon={<GearSix />} onClick={onSettings}>
72
+ Workspace settings
73
+ </MenuItem>
74
+ </MenuContent>
75
+ </Menu>
76
+ );
77
+ }
@@ -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,60 @@
1
+ import { type RefObject, useEffect, useLayoutEffect, useState } from "react";
2
+
3
+ export type IndicatorRect = { left: number; top: number; width: number; height: number };
4
+
5
+ /**
6
+ * Measures the active item inside a positioned track so one indicator can slide
7
+ * between items. `selector` finds the active item (for example `[data-pressed]`
8
+ * or `[aria-current="page"]`); the rect follows attribute changes and resizes.
9
+ * `animate` turns true one frame after the first placement, so the indicator
10
+ * lands in place instead of sliding in from 0,0.
11
+ */
12
+ export function useActiveIndicator(track: RefObject<HTMLElement | null>, selector: string) {
13
+ const [rect, setRect] = useState<IndicatorRect | null>(null);
14
+ const [animate, setAnimate] = useState(false);
15
+
16
+ useLayoutEffect(() => {
17
+ const element = track.current;
18
+ if (!element) return undefined;
19
+ const measure = () => {
20
+ const active = element.querySelector<HTMLElement>(selector);
21
+ if (!active) {
22
+ setRect(null);
23
+ return;
24
+ }
25
+ const next = {
26
+ left: active.offsetLeft,
27
+ top: active.offsetTop,
28
+ width: active.offsetWidth,
29
+ height: active.offsetHeight,
30
+ };
31
+ setRect((previous) =>
32
+ previous &&
33
+ previous.left === next.left &&
34
+ previous.top === next.top &&
35
+ previous.width === next.width &&
36
+ previous.height === next.height
37
+ ? previous
38
+ : next,
39
+ );
40
+ };
41
+ measure();
42
+ const resize = new ResizeObserver(measure);
43
+ resize.observe(element);
44
+ for (const child of Array.from(element.children)) resize.observe(child);
45
+ const mutation = new MutationObserver(measure);
46
+ mutation.observe(element, { attributes: true, subtree: true, childList: true });
47
+ return () => {
48
+ resize.disconnect();
49
+ mutation.disconnect();
50
+ };
51
+ }, [track, selector]);
52
+
53
+ useEffect(() => {
54
+ if (!rect || animate) return undefined;
55
+ const frame = requestAnimationFrame(() => setAnimate(true));
56
+ return () => cancelAnimationFrame(frame);
57
+ }, [rect, animate]);
58
+
59
+ return { rect, animate };
60
+ }
@@ -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
+ }