@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,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 ADDED
@@ -0,0 +1,99 @@
1
+ export * from "./components/agent-activity";
2
+ export * from "./components/agent-lane";
3
+ export * from "./components/agent-lanes-timeline";
4
+ export * from "./components/agent-panel";
5
+ export * from "./components/agent-steering-panel";
6
+ export * from "./components/approval-card";
7
+ export * from "./components/avatar";
8
+ export * from "./components/badge";
9
+ export * from "./components/banner";
10
+ export * from "./components/bot-instructions-panel";
11
+ export * from "./components/bot-list-row";
12
+ export * from "./components/bot-memory-panel";
13
+ export * from "./components/bottom-sheet";
14
+ export * from "./components/breadcrumb";
15
+ export * from "./components/breadcrumb-header";
16
+ export * from "./components/button";
17
+ export * from "./components/calendar";
18
+ export * from "./components/card";
19
+ export * from "./components/chat-bubble";
20
+ export * from "./components/chat-controls";
21
+ export * from "./components/checkbox";
22
+ export * from "./components/chip";
23
+ export * from "./components/choice-card";
24
+ export * from "./components/code-panel";
25
+ export * from "./components/command-palette";
26
+ export * from "./components/combobox";
27
+ export * from "./components/composer";
28
+ export * from "./components/data-table";
29
+ export * from "./components/date-field";
30
+ export * from "./components/dialog";
31
+ export * from "./components/directory-card";
32
+ export * from "./components/document-extract-panel";
33
+ export * from "./components/empty-state";
34
+ export * from "./components/field";
35
+ export * from "./components/file-drop";
36
+ export * from "./components/filter-bar";
37
+ export * from "./components/flue-background";
38
+ export * from "./components/horon-icon";
39
+ export * from "./components/horon-wordmark";
40
+ export * from "./components/icon-button";
41
+ export * from "./components/input";
42
+ export * from "./components/input-group";
43
+ export * from "./components/integration-row";
44
+ export * from "./components/kbd";
45
+ export * from "./components/knowledge-base-panel";
46
+ export * from "./components/liquid-glass";
47
+ export * from "./components/list-detail";
48
+ export * from "./components/marketplace-card";
49
+ export * from "./components/mcp-server-row";
50
+ export * from "./components/menu";
51
+ export * from "./components/modal";
52
+ export * from "./components/message-extras";
53
+ export * from "./components/orchestrator-panel";
54
+ export * from "./components/page-header";
55
+ export * from "./components/plan-card";
56
+ export * from "./components/plan-step";
57
+ export * from "./components/plugin-row";
58
+ export * from "./components/progress";
59
+ export * from "./components/radio-group";
60
+ export * from "./components/research-run-panel";
61
+ export * from "./components/schedule";
62
+ export * from "./components/segmented-control";
63
+ export * from "./components/select";
64
+ export * from "./components/settings-list";
65
+ export * from "./components/slider";
66
+ export * from "./components/sidebar";
67
+ export * from "./components/skeleton";
68
+ export * from "./components/source-row";
69
+ export * from "./components/spinner";
70
+ export * from "./components/split-button";
71
+ export * from "./components/switch";
72
+ export * from "./components/tab-bar";
73
+ export * from "./components/tabs";
74
+ export * from "./components/task-row";
75
+ export * from "./components/textarea";
76
+ export * from "./components/textarea-field";
77
+ export * from "./components/toast";
78
+ export * from "./components/tool-call";
79
+ export * from "./components/tool-calls-header";
80
+ export * from "./components/tool-permissions";
81
+ export * from "./components/tooltip";
82
+ export * from "./components/top-bar";
83
+ export * from "./components/usage-meter";
84
+ export * from "./components/workspace-menu";
85
+ export * from "./hooks";
86
+ export { cn, type ClassValue } from "./lib/cn";
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";
package/src/lib/cn.ts ADDED
@@ -0,0 +1,25 @@
1
+ import { lightColors, primitives, radius, shadow, textStyles } from "@horon-ai/tokens";
2
+ import { extendTailwindMerge } from "tailwind-merge";
3
+
4
+ export type ClassValue = string | false | null | undefined;
5
+
6
+ /**
7
+ * tailwind-merge that knows the Horon theme, so `text-label-md` (font size) and
8
+ * `text-text-primary` (color) are not mistaken for each other, and a caller's
9
+ * `w-full` or `rounded-full` replaces the component default instead of fighting it.
10
+ */
11
+ const merge = extendTailwindMerge({
12
+ extend: {
13
+ theme: {
14
+ color: [...Object.keys(lightColors), ...Object.keys(primitives)],
15
+ text: Object.keys(textStyles),
16
+ radius: Object.keys(radius),
17
+ shadow: Object.keys(shadow),
18
+ },
19
+ },
20
+ });
21
+
22
+ /** Joins class names, skipping empty values; later classes win over conflicting earlier ones. */
23
+ export function cn(...values: ClassValue[]): string {
24
+ return merge(values.filter(Boolean).join(" "));
25
+ }
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Cursor Ghost: copies of the arrow trail behind the real one as a motion smear.
3
+ *
4
+ * The native cursor is never hidden or replaced. The ghosts live in one fixed,
5
+ * `pointer-events: none`, `aria-hidden` layer on top of the page, so they never
6
+ * take clicks, hovers or focus. The effect only mounts on a fine hover pointer
7
+ * without `prefers-reduced-motion: reduce`, and re-checks both on change.
8
+ *
9
+ * Importing `@horon-ai/react` (or `@horon-ai/react/cursor-ghost`) mounts it once
10
+ * per page. Put `data-horon-cursor="off"` on `<html>` before that import to opt
11
+ * out, then call `mountCursorGhost()` yourself for custom options.
12
+ */
13
+
14
+ export type CursorGhostOptions = {
15
+ /** Number of arrow copies. */
16
+ ghosts?: number;
17
+ /** Arrow height in CSS px. */
18
+ size?: number;
19
+ /** Lerp factor of the first copy toward the pointer, per frame. */
20
+ headFollow?: number;
21
+ /** Blur spread of the last copy in CSS px. */
22
+ smear?: number;
23
+ /** Arrow fill. Blended with `difference`, so white reads dark on light pages. */
24
+ color?: string;
25
+ };
26
+
27
+ const DEFAULTS: Required<CursorGhostOptions> = { ghosts: 5, size: 26, headFollow: 0.16, smear: 14, color: "#FFFFFF" };
28
+
29
+ const ENABLE_QUERY = "(hover: hover) and (pointer: fine)";
30
+ const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
31
+ const INTERACTIVE = "a, button, input";
32
+ const OPT_OUT_ATTRIBUTE = "data-horon-cursor";
33
+ const AUTO_MOUNT_KEY = Symbol.for("horon.cursorGhost");
34
+
35
+ /** Arrow drawn in a 24-unit box; its tip sits at (3, 2). */
36
+ const ARROW_PATH = "M3 2v18.2l5-4.7 3.4 7.6 3.2-1.4-3.3-7.5H18Z";
37
+ const ARROW_TIP = { x: 3, y: 2 };
38
+ const ARROW_BOX = 24;
39
+
40
+ /** Each copy follows the one ahead of it more loosely than the last. */
41
+ const FOLLOW_DECAY = 0.78;
42
+ const HEAD_OPACITY = 0.55;
43
+ /** Trail strength while the pointer is over `a, button, input`. */
44
+ const INTERACTIVE_INTENSITY = 0.5;
45
+ const SETTLE_EPSILON = 0.05;
46
+
47
+ const SVG_NS = "http://www.w3.org/2000/svg";
48
+
49
+ function startEffect({ ghosts, size, headFollow, smear, color }: Required<CursorGhostOptions>): () => void {
50
+ const count = Math.max(1, Math.round(ghosts));
51
+ const scale = size / ARROW_BOX;
52
+ const tipX = ARROW_TIP.x * scale;
53
+ const tipY = ARROW_TIP.y * scale;
54
+ const fadeDistance = size * 0.5;
55
+
56
+ const layer = document.createElement("div");
57
+ layer.setAttribute("aria-hidden", "true");
58
+ layer.setAttribute("data-horon-cursor-ghost", "");
59
+ Object.assign(layer.style, {
60
+ position: "fixed",
61
+ inset: "0",
62
+ pointerEvents: "none",
63
+ zIndex: "2147483647",
64
+ overflow: "hidden",
65
+ mixBlendMode: "difference",
66
+ contain: "strict",
67
+ visibility: "hidden",
68
+ });
69
+
70
+ const nodes: SVGSVGElement[] = [];
71
+ const follow = new Float32Array(count);
72
+ const baseOpacity = new Float32Array(count);
73
+ const xs = new Float32Array(count);
74
+ const ys = new Float32Array(count);
75
+
76
+ for (let i = 0; i < count; i += 1) {
77
+ const progress = (i + 1) / count;
78
+ follow[i] = headFollow * FOLLOW_DECAY ** i;
79
+ baseOpacity[i] = HEAD_OPACITY * (1 - i / count);
80
+
81
+ const svg = document.createElementNS(SVG_NS, "svg");
82
+ svg.setAttribute("viewBox", `0 0 ${ARROW_BOX} ${ARROW_BOX}`);
83
+ svg.setAttribute("width", String(size));
84
+ svg.setAttribute("height", String(size));
85
+ const path = document.createElementNS(SVG_NS, "path");
86
+ path.setAttribute("d", ARROW_PATH);
87
+ path.setAttribute("fill", color);
88
+ svg.append(path);
89
+ Object.assign(svg.style, {
90
+ position: "absolute",
91
+ left: "0",
92
+ top: "0",
93
+ overflow: "visible",
94
+ opacity: "0",
95
+ // CSS blur takes a standard deviation; half the smear spreads it about `smear` px.
96
+ filter: `blur(${((smear * progress) / 2).toFixed(2)}px)`,
97
+ willChange: "transform, opacity",
98
+ });
99
+ nodes.push(svg);
100
+ }
101
+ // Paint the most blurred copy first so the sharpest one sits on top.
102
+ layer.append(...nodes.toReversed());
103
+ document.body.append(layer);
104
+
105
+ let pointerX = 0;
106
+ let pointerY = 0;
107
+ let present = false;
108
+ let intensity = 1;
109
+ let targetIntensity = 1;
110
+ let frame = 0;
111
+
112
+ const tick = () => {
113
+ frame = 0;
114
+ intensity += (targetIntensity - intensity) * 0.2;
115
+ let leaderX = pointerX;
116
+ let leaderY = pointerY;
117
+ let moving = Math.abs(targetIntensity - intensity) > 0.01;
118
+
119
+ for (let i = 0; i < count; i += 1) {
120
+ // The weight, position and node of one copy, read once per frame. Every
121
+ // array here is allocated at `count`, so the fallbacks are dead: they let
122
+ // the reads type under `noUncheckedIndexedAccess`.
123
+ const rate = follow[i] ?? 0;
124
+ const node = nodes[i];
125
+ let x = xs[i] ?? leaderX;
126
+ let y = ys[i] ?? leaderY;
127
+ const dx = leaderX - x;
128
+ const dy = leaderY - y;
129
+ if (Math.abs(dx) > SETTLE_EPSILON || Math.abs(dy) > SETTLE_EPSILON) {
130
+ x += dx * rate;
131
+ y += dy * rate;
132
+ moving = true;
133
+ } else {
134
+ x = leaderX;
135
+ y = leaderY;
136
+ }
137
+ xs[i] = x;
138
+ ys[i] = y;
139
+ if (!node) continue;
140
+ const distance = Math.hypot(pointerX - x, pointerY - y);
141
+ node.style.transform = `translate3d(${(x - tipX).toFixed(2)}px, ${(y - tipY).toFixed(2)}px, 0)`;
142
+ node.style.opacity = ((baseOpacity[i] ?? 0) * Math.min(1, distance / fadeDistance) * intensity).toFixed(3);
143
+ leaderX = x;
144
+ leaderY = y;
145
+ }
146
+
147
+ if (moving) frame = requestAnimationFrame(tick);
148
+ };
149
+
150
+ const wake = () => {
151
+ if (!frame) frame = requestAnimationFrame(tick);
152
+ };
153
+
154
+ const onPointerMove = (event: PointerEvent) => {
155
+ if (event.pointerType === "touch") return;
156
+ pointerX = event.clientX;
157
+ pointerY = event.clientY;
158
+ if (!present) {
159
+ // Start every copy at the pointer instead of sweeping in from the corner.
160
+ xs.fill(pointerX);
161
+ ys.fill(pointerY);
162
+ present = true;
163
+ layer.style.visibility = "visible";
164
+ }
165
+ const target = event.target;
166
+ targetIntensity = target instanceof Element && target.closest(INTERACTIVE) ? INTERACTIVE_INTENSITY : 1;
167
+ wake();
168
+ };
169
+
170
+ const onPointerOut = (event: PointerEvent) => {
171
+ if (event.relatedTarget) return;
172
+ present = false;
173
+ layer.style.visibility = "hidden";
174
+ };
175
+
176
+ const onBlur = () => {
177
+ present = false;
178
+ layer.style.visibility = "hidden";
179
+ };
180
+
181
+ window.addEventListener("pointermove", onPointerMove, { passive: true });
182
+ document.addEventListener("pointerout", onPointerOut, { passive: true });
183
+ window.addEventListener("blur", onBlur);
184
+
185
+ return () => {
186
+ if (frame) cancelAnimationFrame(frame);
187
+ frame = 0;
188
+ window.removeEventListener("pointermove", onPointerMove);
189
+ document.removeEventListener("pointerout", onPointerOut);
190
+ window.removeEventListener("blur", onBlur);
191
+ layer.remove();
192
+ };
193
+ }
194
+
195
+ /**
196
+ * Mounts the Cursor Ghost trail and returns its teardown. The trail appears and
197
+ * disappears on its own as the pointer type or the reduced-motion setting changes.
198
+ */
199
+ export function mountCursorGhost(options: CursorGhostOptions = {}): () => void {
200
+ if (typeof window === "undefined" || typeof document === "undefined" || !window.matchMedia) return () => {};
201
+ const config = { ...DEFAULTS, ...options };
202
+ const enable = window.matchMedia(ENABLE_QUERY);
203
+ const reducedMotion = window.matchMedia(REDUCED_MOTION_QUERY);
204
+ let stop: (() => void) | null = null;
205
+
206
+ const sync = () => {
207
+ const wanted = enable.matches && !reducedMotion.matches;
208
+ if (wanted && !stop) stop = startEffect(config);
209
+ else if (!wanted && stop) {
210
+ stop();
211
+ stop = null;
212
+ }
213
+ };
214
+
215
+ let started = false;
216
+ const begin = () => {
217
+ started = true;
218
+ sync();
219
+ enable.addEventListener("change", sync);
220
+ reducedMotion.addEventListener("change", sync);
221
+ };
222
+ if (document.body) begin();
223
+ else document.addEventListener("DOMContentLoaded", begin, { once: true });
224
+
225
+ return () => {
226
+ document.removeEventListener("DOMContentLoaded", begin);
227
+ if (!started) return;
228
+ enable.removeEventListener("change", sync);
229
+ reducedMotion.removeEventListener("change", sync);
230
+ stop?.();
231
+ stop = null;
232
+ };
233
+ }
234
+
235
+ function autoMount() {
236
+ if (typeof window === "undefined" || typeof document === "undefined") return;
237
+ const registry = globalThis as typeof globalThis & { [AUTO_MOUNT_KEY]?: true };
238
+ if (registry[AUTO_MOUNT_KEY]) return;
239
+ if (document.documentElement.getAttribute(OPT_OUT_ATTRIBUTE) === "off") return;
240
+ registry[AUTO_MOUNT_KEY] = true;
241
+ mountCursorGhost();
242
+ }
243
+
244
+ autoMount();
@@ -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
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The size scale every sized control shares. `md` is the default and the
3
+ * original Figma/Paper size; `sm` and `lg` step one rung down and up:
4
+ *
5
+ * - control height: sm 32px, md 40px, lg 48px (h-8 / h-10 / h-12)
6
+ * - control text: sm `label-md` / `body-sm`, md `label-lg` / `body-md`, lg `label-lg` / `body-lg`
7
+ * - icon: sm 16px, md 16-20px, lg 20-24px
8
+ * - selection marks (checkbox, radio): sm 16px, md 20px, lg 24px
9
+ */
10
+ export type ControlSize = "sm" | "md" | "lg";