@horon-ai/react 0.0.0-stage → 0.1.0

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 (93) 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 +202 -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 +121 -0
  8. package/src/components/agent-steering-panel.tsx +274 -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 +365 -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 +191 -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 +402 -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 +35 -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 +156 -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 +224 -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/index.ts +86 -0
  89. package/src/lib/cn.ts +25 -0
  90. package/src/lib/cursor-ghost.ts +244 -0
  91. package/src/lib/size.ts +10 -0
  92. package/src/lib/use-active-indicator.ts +60 -0
  93. package/src/styles/globals.css +564 -0
@@ -0,0 +1,402 @@
1
+ import { type CSSProperties, useEffect, useRef, useSyncExternalStore } from "react";
2
+
3
+ import { cn } from "../lib/cn";
4
+
5
+ export type FlueConfig = {
6
+ bgColor: string;
7
+ colorA: string;
8
+ colorB: string;
9
+ colorC: string;
10
+ colorD: string;
11
+ scale: number;
12
+ speed: number;
13
+ flow: number;
14
+ roughness: number;
15
+ lacunarity: number;
16
+ smokeScale: number;
17
+ curl: number;
18
+ curlScale: number;
19
+ rise: number;
20
+ slender: number;
21
+ width: number;
22
+ spread: number;
23
+ lean: number;
24
+ density: number;
25
+ contrast: number;
26
+ midpoint: number;
27
+ glow: number;
28
+ sink: number;
29
+ grain: number;
30
+ grainAnim: number;
31
+ dither: number;
32
+ vignette: number;
33
+ /** 1 lets the smoke follow the real pointer; 0 (default) ignores it and a slow virtual pointer sways the smoke. */
34
+ cursor: number;
35
+ pointerRadius: number;
36
+ draft: number;
37
+ pointerStrength: number;
38
+ parallax: number;
39
+ maxDpr: number;
40
+ };
41
+
42
+ /** The Horon sky palette for Flue. Recolour through these values only, never the shader. */
43
+ export const FLUE_SKY: FlueConfig = {
44
+ bgColor: "#eef3f8",
45
+ colorA: "#e3ecf6",
46
+ colorB: "#c3d7ea",
47
+ colorC: "#7fa3c6",
48
+ colorD: "#1d3c5e",
49
+ scale: 0.2,
50
+ speed: 0.33,
51
+ flow: 0.04,
52
+ roughness: 0.36,
53
+ lacunarity: 2.55,
54
+ smokeScale: 2.51,
55
+ curl: 0.36,
56
+ curlScale: 0.88,
57
+ rise: 0.03,
58
+ slender: 1.55,
59
+ width: 1.55,
60
+ spread: 1.11,
61
+ lean: 1.25,
62
+ density: 2.15,
63
+ contrast: 1.7,
64
+ midpoint: 0.76,
65
+ glow: 0.53,
66
+ sink: 0.2,
67
+ grain: 0.02,
68
+ grainAnim: 0,
69
+ dither: 1.1,
70
+ vignette: 0.26,
71
+ cursor: 0,
72
+ pointerRadius: 0.84,
73
+ draft: 1.85,
74
+ pointerStrength: 0.16,
75
+ parallax: 0.028,
76
+ maxDpr: 1,
77
+ };
78
+
79
+ /** The night palette for dark mode: near-black ground and blue smoke. Same motion as `FLUE_SKY`. */
80
+ export const FLUE_NIGHT: FlueConfig = {
81
+ ...FLUE_SKY,
82
+ bgColor: "#050608",
83
+ colorA: "#07090d",
84
+ colorB: "#0c1a2c",
85
+ colorC: "#1f4a78",
86
+ colorD: "#4f8fd0",
87
+ glow: 0.35,
88
+ };
89
+
90
+ /** Where the smoke is drawn; each place tunes the motion and colour on top of `FLUE_SKY` / `FLUE_NIGHT`. */
91
+ export type FlueVariant = "default" | "sidebar" | "auth";
92
+
93
+ type FlueTuning = { light: Partial<FlueConfig>; dark: Partial<FlueConfig>; tint: string };
94
+
95
+ /**
96
+ * - `sidebar`: a narrow, tall column under the sidebar's glass. The glass blurs the detail away, so the smoke
97
+ * is calmer and larger, kept low in the column so the nav labels at the top sit on the quiet part. The canvas
98
+ * paints above the sidebar's own fill, so in light mode the variant lays the thick glass (80% white) over the
99
+ * smoke itself: its dense blue band would otherwise drop secondary labels to 2:1. Dark smoke is already dark
100
+ * enough behind light text, so dark lays nothing.
101
+ * - `auth`: the whole sign-in screen behind a centred card. The plume is wider and rises from the bottom
102
+ * centre so it frames the card. The shader's vignette darkens towards black, so light mode keeps none (it
103
+ * would turn the pale sky grey) and stops the plume short of the navy end of the ramp; dark mode uses it
104
+ * to sink the corners into the night ground. No tint.
105
+ */
106
+ const FLUE_VARIANTS: Record<FlueVariant, FlueTuning> = {
107
+ default: { light: {}, dark: {}, tint: "bg-glass-regular dark:bg-transparent" },
108
+ sidebar: {
109
+ light: {
110
+ speed: 0.22,
111
+ smokeScale: 1.9,
112
+ density: 1.85,
113
+ contrast: 1.45,
114
+ midpoint: 0.8,
115
+ glow: 0.3,
116
+ vignette: 0,
117
+ },
118
+ dark: {
119
+ speed: 0.22,
120
+ smokeScale: 1.9,
121
+ density: 1.85,
122
+ contrast: 1.5,
123
+ midpoint: 0.8,
124
+ glow: 0.22,
125
+ vignette: 0,
126
+ },
127
+ tint: "bg-glass-thick dark:bg-transparent",
128
+ },
129
+ auth: {
130
+ light: { width: 1.9, spread: 1.3, density: 1.85, midpoint: 0.8, glow: 0.3, vignette: 0 },
131
+ dark: { width: 1.9, spread: 1.3, density: 2.0, midpoint: 0.76, glow: 0.45, vignette: 0.45 },
132
+ tint: "",
133
+ },
134
+ };
135
+
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
+ const ENGINE_KEYS = new Set(["cursor", "maxDpr"]);
152
+
153
+ const VERT = `#version 300 es
154
+ void main() {
155
+ vec2 p = vec2((gl_VertexID << 1) & 2, gl_VertexID & 2);
156
+ gl_Position = vec4(p * 2.0 - 1.0, 0.0, 1.0);
157
+ }`;
158
+
159
+ const FRAG = `#version 300 es
160
+ precision highp float;
161
+ out vec4 fragColor;
162
+ uniform vec2 iResolution; uniform float iTime; uniform vec2 iMouse; uniform vec2 iMouseVel; uniform vec2 iMouseWake;
163
+ uniform vec3 uBgColor, uColorA, uColorB, uColorC, uColorD;
164
+ uniform float uScale, uSpeed, uFlow, uRoughness, uLacunarity;
165
+ uniform float uSmokeScale, uCurl, uCurlScale, uRise, uSlender;
166
+ uniform float uWidth, uSpread, uLean, uDensity;
167
+ uniform float uContrast, uMidpoint, uGlow, uSink;
168
+ uniform float uGrain, uDither, uVignette;
169
+ uniform float uPointerRadius, uDraft, uPointerStrength, uParallax;
170
+ uniform float uGrainAnim;
171
+ #define OCTAVES 3
172
+ #define TRAIL_TAPS 6
173
+ vec2 hash2(vec2 p){ p=vec2(dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3))); return -1.0+2.0*fract(sin(p)*43758.5453123); }
174
+ float snoise(vec2 p){ const float K1=0.366025404,K2=0.211324865; vec2 i=floor(p+(p.x+p.y)*K1); vec2 a=p-i+(i.x+i.y)*K2; float m=step(a.y,a.x); vec2 o=vec2(m,1.0-m); vec2 b=a-o+K2; vec2 c=a-1.0+2.0*K2; vec3 h=max(0.5-vec3(dot(a,a),dot(b,b),dot(c,c)),0.0); vec3 n=h*h*h*h*vec3(dot(a,hash2(i)),dot(b,hash2(i+o)),dot(c,hash2(i+1.0))); return dot(n,vec3(70.0)); }
175
+ float fbm(vec2 p){ float v=0.0,amp=0.5; for(int i=0; i<OCTAVES; i++){ v+=amp*snoise(p); p*=uLacunarity; amp*=uRoughness; } return v; }
176
+ float trail(vec2 uv,float radius){ vec2 wob=vec2(snoise(uv*1.7+vec2(0.0,iTime*0.05)),snoise(uv*1.7+vec2(4.3,-iTime*0.04))); uv+=wob*radius*0.18; float a=0.0,wsum=0.0; float r2=max(1e-4,radius*radius); for(int i=0; i<TRAIL_TAPS; i++){ float k=float(i)/float(TRAIL_TAPS-1); vec2 c=mix(iMouseWake,iMouse,k); vec2 dd=uv-c; float w=mix(0.30,1.0,k); a+=w*exp(-dot(dd,dd)/r2); wsum+=w; } return a/wsum; }
177
+ float potential(vec2 p){ return snoise(p)+0.5*snoise(p*2.03+vec2(3.7,-1.9)); }
178
+ vec2 curl(vec2 p,float e){ float nx1=potential(p+vec2(0.0,e)); float nx2=potential(p-vec2(0.0,e)); float ny1=potential(p+vec2(e,0.0)); float ny2=potential(p-vec2(e,0.0)); return vec2(nx1-nx2,ny2-ny1)/(2.0*e); }
179
+ vec3 ramp4(float t){ vec3 c=mix(uColorA,uColorB,smoothstep(0.00,0.36,t)); c=mix(c,uColorC,smoothstep(0.32,0.70,t)); c=mix(c,uColorD,smoothstep(0.66,1.00,t)); return c; }
180
+ float triDither(vec2 fc){ float a=fract(sin(dot(fc,vec2(12.9898,78.233)))*43758.5453); float b=fract(sin(dot(fc+17.0,vec2(12.9898,78.233)))*43758.5453); return (a+b-1.0)/255.0; }
181
+ float houseGrain(vec2 fc){ uvec2 q=uvec2(fc)*uvec2(1597334677u,3812015801u)+uint(floor(iTime*24.0*uGrainAnim))*2654435769u; uint n=q.x^q.y; n=n*1664525u+1013904223u; n^=n>>16u; n*=2246822519u; n^=n>>13u; float a=float(n&0xffffu)/65535.0; n*=3266489917u; n^=n>>16u; float b=float(n&0xffffu)/65535.0; return a+b-1.0; }
182
+ void main(){ vec2 uv=(gl_FragCoord.xy-0.5*iResolution)/iResolution.y; float t=iTime*uSpeed; float tr=trail(uv,uPointerRadius); vec2 p=(uv-iMouse*uParallax)*uScale; vec2 sp=p+vec2(0.0,-t*uRise); vec2 c=curl(sp*uCurlScale+vec2(0.0,t*uFlow),0.14)*uCurl; c+=iMouseVel*uDraft*tr; vec2 mp=vec2(sp.x,sp.y*uSlender)*uSmokeScale+c; float dens=fbm(mp)*0.5+0.5; float h=uv.y+0.55; float lean=iMouse.x*uLean*clamp(h,0.0,1.6); float width=max(0.06,uWidth*(0.45+uSpread*clamp(h,0.0,1.8))); float xr=(uv.x-lean)/width; float corridor=exp(-xr*xr); float cool=smoothstep(-0.65,0.85,uv.y); float smoke=dens*corridor*mix(1.0,0.30,cool)*uDensity; float f=clamp((smoke-uMidpoint)*uContrast+0.5+tr*uPointerStrength*0.30,0.0,1.0); vec3 col=ramp4(f); col+=uColorD*uGlow*pow(f,4.0); col=mix(uBgColor,col,smoothstep(0.0,max(0.01,uSink),f)*0.90+0.10); col*=1.0-uVignette*dot(uv,uv); { float hgL=clamp(dot(col,vec3(0.299,0.587,0.114)),0.0,1.0); col+=houseGrain(gl_FragCoord.xy)*uGrain*mix(1.0,4.0*hgL*(1.0-hgL),0.6); } col+=triDither(gl_FragCoord.xy)*uDither; fragColor=vec4(clamp(col,0.0,1.0),1.0); }`;
183
+
184
+ function hexToVec3(hex: string): [number, number, number] {
185
+ const value = Number.parseInt(hex.slice(1), 16);
186
+ return [((value >> 16) & 255) / 255, ((value >> 8) & 255) / 255, (value & 255) / 255];
187
+ }
188
+
189
+ function compile(gl: WebGL2RenderingContext, type: number, source: string): WebGLShader {
190
+ const shader = gl.createShader(type);
191
+ if (!shader) throw new Error("Flue: createShader failed");
192
+ gl.shaderSource(shader, source);
193
+ gl.compileShader(shader);
194
+ if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS))
195
+ throw new Error(gl.getShaderInfoLog(shader) ?? "Flue: compile failed");
196
+ return shader;
197
+ }
198
+
199
+ const USE_PROGRAM = "useProgram";
200
+
201
+ function activate(gl: WebGL2RenderingContext, program: WebGLProgram): void {
202
+ gl[USE_PROGRAM](program);
203
+ }
204
+
205
+ export type FlueBackgroundProps = {
206
+ className?: string;
207
+ style?: CSSProperties;
208
+ /** `sidebar` under the sidebar glass, `auth` behind the sign-in screen, `default` otherwise. */
209
+ variant?: FlueVariant;
210
+ /** Overrides on top of the variant's `FLUE_SKY` (light) or `FLUE_NIGHT` (dark) tuning. */
211
+ config?: Partial<FlueConfig>;
212
+ /** Classes for the tint laid over the smoke; defaults to the variant's tint. */
213
+ tintClassName?: string;
214
+ };
215
+
216
+ /**
217
+ * Flue: an animated WebGL2 smoke gradient, one fullscreen triangle and one fragment shader.
218
+ * It fills its positioned parent behind the content (the parent needs `isolate`) and falls
219
+ * back to the flat `bgColor` when WebGL2 is missing. Reduced motion draws a single still frame.
220
+ * Light mode paints the white-and-sky `FLUE_SKY`; dark mode paints the black-and-blue `FLUE_NIGHT`.
221
+ */
222
+ export function FlueBackground({ className, style, variant = "default", config, tintClassName }: FlueBackgroundProps) {
223
+ const canvasRef = useRef<HTMLCanvasElement>(null);
224
+ const dark = useDarkScheme();
225
+ const tuning = FLUE_VARIANTS[variant];
226
+ const settings = { ...(dark ? FLUE_NIGHT : FLUE_SKY), ...(dark ? tuning.dark : tuning.light), ...config };
227
+ const key = JSON.stringify(settings);
228
+
229
+ useEffect(() => {
230
+ const canvas = canvasRef.current;
231
+ if (!canvas) return undefined;
232
+ const cfg: FlueConfig = JSON.parse(key);
233
+ const gl = canvas.getContext("webgl2", {
234
+ alpha: false,
235
+ antialias: false,
236
+ depth: false,
237
+ stencil: false,
238
+ powerPreference: "high-performance",
239
+ });
240
+ // StrictMode replays the effect on the same canvas. A context that a previous run lost
241
+ // cannot compile again, so fall back to the flat bgColor instead of throwing.
242
+ if (!gl || gl.isContextLost()) return undefined;
243
+
244
+ const program = gl.createProgram();
245
+ if (!program) return undefined;
246
+ const vertexShader = compile(gl, gl.VERTEX_SHADER, VERT);
247
+ const fragmentShader = compile(gl, gl.FRAGMENT_SHADER, FRAG);
248
+ gl.attachShader(program, vertexShader);
249
+ gl.attachShader(program, fragmentShader);
250
+ gl.linkProgram(program);
251
+ if (!gl.getProgramParameter(program, gl.LINK_STATUS))
252
+ throw new Error(gl.getProgramInfoLog(program) ?? "Flue: link failed");
253
+ activate(gl, program);
254
+ const vertexArray = gl.createVertexArray();
255
+ gl.bindVertexArray(vertexArray);
256
+
257
+ const locations = new Map<string, WebGLUniformLocation | null>();
258
+ const loc = (name: string) => {
259
+ if (!locations.has(name)) locations.set(name, gl.getUniformLocation(program, name));
260
+ return locations.get(name) ?? null;
261
+ };
262
+ for (const [name, value] of Object.entries(cfg)) {
263
+ if (ENGINE_KEYS.has(name)) continue;
264
+ const uniform = `u${name.charAt(0).toUpperCase()}${name.slice(1)}`;
265
+ if (typeof value === "string") gl.uniform3f(loc(uniform), ...hexToVec3(value));
266
+ else gl.uniform1f(loc(uniform), value);
267
+ }
268
+
269
+ const resize = () => {
270
+ const dpr = Math.min(window.devicePixelRatio || 1, cfg.maxDpr);
271
+ const width = Math.max(1, Math.round(canvas.clientWidth * dpr));
272
+ const height = Math.max(1, Math.round(canvas.clientHeight * dpr));
273
+ if (canvas.width !== width || canvas.height !== height) {
274
+ canvas.width = width;
275
+ canvas.height = height;
276
+ }
277
+ gl.viewport(0, 0, width, height);
278
+ gl.uniform2f(loc("iResolution"), width, height);
279
+ };
280
+
281
+ const mouse = { x: 0, y: 0, ax: 0, ay: 0, wx: 0, wy: 0, tx: 0, ty: 0 };
282
+ const aim = (event: PointerEvent) => {
283
+ if (!cfg.cursor) return;
284
+ const rect = canvas.getBoundingClientRect();
285
+ if (rect.width === 0 || rect.height === 0) return;
286
+ mouse.tx = ((event.clientX - rect.left) / rect.width - 0.5) * (rect.width / rect.height);
287
+ mouse.ty = 0.5 - (event.clientY - rect.top) / rect.height;
288
+ };
289
+
290
+ let clock = 0;
291
+ const draw = () => {
292
+ gl.uniform1f(loc("iTime"), clock);
293
+ gl.uniform2f(loc("iMouse"), mouse.x, mouse.y);
294
+ gl.uniform2f(loc("iMouseVel"), mouse.ax - mouse.x, mouse.ay - mouse.y);
295
+ gl.uniform2f(loc("iMouseWake"), mouse.wx, mouse.wy);
296
+ gl.drawArrays(gl.TRIANGLES, 0, 3);
297
+ };
298
+
299
+ const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
300
+ let still = motionQuery.matches;
301
+ let resizeQueued = 0;
302
+ const observer = new ResizeObserver(() => {
303
+ cancelAnimationFrame(resizeQueued);
304
+ resizeQueued = requestAnimationFrame(() => {
305
+ resize();
306
+ if (still) draw();
307
+ });
308
+ });
309
+ observer.observe(canvas);
310
+ resize();
311
+ clock = still ? 12 : 0;
312
+ draw();
313
+
314
+ let visible = true;
315
+ const visibility = new IntersectionObserver((entries) => {
316
+ visible = entries.some((entry) => entry.isIntersecting);
317
+ });
318
+ visibility.observe(canvas);
319
+
320
+ let raf = 0;
321
+ let previous = performance.now();
322
+ const frame = (now: number) => {
323
+ raf = requestAnimationFrame(frame);
324
+ const raw = now - previous;
325
+ previous = now;
326
+ if (!visible || document.hidden) return;
327
+ const ms = Math.min(50, Math.max(4.167, raw));
328
+ const step = ms > 36.7 ? 2.2 : ms * 0.06;
329
+ clock += ms * 0.001;
330
+ // Without the real pointer, a slow virtual one wanders across the canvas on two incommensurate
331
+ // sines, so the plume leans, sways and trails on its own the way it does under a moving cursor.
332
+ if (!cfg.cursor) {
333
+ const aspect = canvas.clientWidth / Math.max(1, canvas.clientHeight);
334
+ mouse.tx = aspect * (0.3 * Math.sin(clock * 0.23) + 0.12 * Math.sin(clock * 0.57 + 0.8));
335
+ mouse.ty = 0.22 * Math.sin(clock * 0.19 + 1.3) + 0.08 * Math.sin(clock * 0.41);
336
+ }
337
+ const kLead = 0.105 * step;
338
+ const kBody = 0.043 * step;
339
+ const kWake = 0.017 * step;
340
+ mouse.ax += (mouse.tx - mouse.ax) * kLead;
341
+ mouse.ay += (mouse.ty - mouse.ay) * kLead;
342
+ mouse.x += (mouse.ax - mouse.x) * kBody;
343
+ mouse.y += (mouse.ay - mouse.y) * kBody;
344
+ mouse.wx += (mouse.x - mouse.wx) * kWake;
345
+ mouse.wy += (mouse.y - mouse.wy) * kWake;
346
+ draw();
347
+ };
348
+ // The plume follows the OS switch while the page is open: turning on "reduce motion"
349
+ // freezes the current frame, turning it off resumes from the same clock.
350
+ const listen = () => {
351
+ if (still) {
352
+ window.removeEventListener("pointermove", aim);
353
+ window.removeEventListener("pointerdown", aim);
354
+ return;
355
+ }
356
+ if (cfg.cursor) {
357
+ window.addEventListener("pointermove", aim, { passive: true });
358
+ window.addEventListener("pointerdown", aim, { passive: true });
359
+ }
360
+ previous = performance.now();
361
+ if (!raf) raf = requestAnimationFrame(frame);
362
+ };
363
+ const onMotionChange = (event: MediaQueryListEvent) => {
364
+ still = event.matches;
365
+ if (still) {
366
+ cancelAnimationFrame(raf);
367
+ raf = 0;
368
+ }
369
+ listen();
370
+ };
371
+ motionQuery.addEventListener("change", onMotionChange);
372
+ listen();
373
+
374
+ return () => {
375
+ motionQuery.removeEventListener("change", onMotionChange);
376
+ cancelAnimationFrame(raf);
377
+ cancelAnimationFrame(resizeQueued);
378
+ observer.disconnect();
379
+ visibility.disconnect();
380
+ window.removeEventListener("pointermove", aim);
381
+ window.removeEventListener("pointerdown", aim);
382
+ // Release this run's GL objects without losing the context: the next effect run
383
+ // (StrictMode, config change) reuses the same WebGL2RenderingContext.
384
+ gl.bindVertexArray(null);
385
+ gl.deleteVertexArray(vertexArray);
386
+ gl.deleteProgram(program);
387
+ gl.deleteShader(vertexShader);
388
+ gl.deleteShader(fragmentShader);
389
+ };
390
+ }, [key]);
391
+
392
+ return (
393
+ <div
394
+ aria-hidden
395
+ className={cn("pointer-events-none absolute inset-0 -z-10 overflow-hidden", className)}
396
+ style={{ backgroundColor: settings.bgColor, ...style }}
397
+ >
398
+ <canvas ref={canvasRef} className="block size-full" />
399
+ <div className={cn("absolute inset-0", tintClassName ?? tuning.tint)} />
400
+ </div>
401
+ );
402
+ }
@@ -0,0 +1,280 @@
1
+ import {
2
+ Archive,
3
+ ArrowClockwise,
4
+ ArrowDown,
5
+ ArrowElbowDownLeft,
6
+ ArrowRight,
7
+ ArrowSquareOut,
8
+ ArrowUp,
9
+ Bell,
10
+ BookmarkSimple,
11
+ Brain,
12
+ CalendarBlank,
13
+ CaretDown,
14
+ CaretLeft,
15
+ CaretRight,
16
+ CaretUpDown,
17
+ ChartLineUp,
18
+ ChatCircle,
19
+ Check,
20
+ CheckCircle,
21
+ ClipboardText,
22
+ Clock,
23
+ Code,
24
+ Command,
25
+ Copy,
26
+ Cube,
27
+ DotsThree,
28
+ Drop,
29
+ EnvelopeSimple,
30
+ Eye,
31
+ FilePdf,
32
+ FileText,
33
+ Files,
34
+ Flask,
35
+ Folder,
36
+ Gear,
37
+ GitPullRequest,
38
+ Globe,
39
+ House,
40
+ Image,
41
+ Info,
42
+ Key,
43
+ Lightning,
44
+ List,
45
+ ListChecks,
46
+ MagnifyingGlass,
47
+ Megaphone,
48
+ Microphone,
49
+ Minus,
50
+ PaperPlaneTilt,
51
+ Paperclip,
52
+ Pause,
53
+ PenNib,
54
+ PencilSimple,
55
+ Play,
56
+ Plug,
57
+ Plus,
58
+ PlusCircle,
59
+ Question,
60
+ Repeat,
61
+ Robot,
62
+ Scan,
63
+ SignOut,
64
+ SlidersHorizontal,
65
+ Sparkle,
66
+ SquaresFour,
67
+ Stop,
68
+ ThumbsDown,
69
+ ThumbsUp,
70
+ Trash,
71
+ Tray,
72
+ UploadSimple,
73
+ User,
74
+ UserPlus,
75
+ UsersThree,
76
+ Warning,
77
+ WarningCircle,
78
+ Wrench,
79
+ X,
80
+ type Icon,
81
+ } from "@phosphor-icons/react";
82
+ import type { ComponentProps } from "react";
83
+
84
+ export type HoronIconProps = Omit<ComponentProps<"svg">, "children"> & { size?: number | string };
85
+
86
+ /**
87
+ * Figma `icon/horon` (256:9116): the line falcon eye that stands in for Horon in
88
+ * navigation. Phosphor has no equivalent, so the path is copied from the Tab bar
89
+ * (63:441) export. It takes `currentColor` and sizes like a Phosphor icon.
90
+ */
91
+ export function HoronIcon({ size = "1em", ...props }: HoronIconProps) {
92
+ return (
93
+ <svg viewBox="0 0 22 22" width={size} height={size} fill="currentColor" aria-hidden {...props}>
94
+ <path d="M20.1444 5.12948C19.6411 5.2126 19.307 5.26623 17.0453 5.62287C16.2541 5.74889 15.3486 5.89369 15.0343 5.94464C14.72 5.99559 14.1221 6.09212 13.7045 6.15915C9.0054 6.918 6.10856 7.48379 3.04688 8.23996C1.89737 8.52419 1.98968 8.44911 1.05997 9.83005C0.251141 11.026 0.255537 11.0233 0.240152 11.0984C0.198391 11.2914 0.279714 11.5194 0.415984 11.5998C0.55665 11.6802 0.653358 11.6588 1.1347 11.4255C2.09079 10.9643 2.63807 10.7096 2.65345 10.7203C2.66005 10.7257 2.73038 10.8436 2.80511 10.9804C4.99202 14.9302 7.76138 16.9386 11.0231 16.9359C13.3616 16.9359 15.2892 16.0054 17.397 13.8603C18.1245 13.1229 19.6411 11.337 19.7246 11.1225C19.8191 10.8865 19.8015 10.6157 19.6828 10.4146C19.6059 10.2832 17.7179 8.63145 16.7376 7.84042C16.5992 7.7278 16.5003 7.63127 16.5178 7.62859C16.5354 7.62322 16.8981 7.56155 17.3201 7.48647C17.7443 7.41407 18.4674 7.28804 18.9311 7.20492C19.3949 7.12447 20.1686 6.99845 20.6521 6.92337C21.1356 6.85097 21.5466 6.77857 21.5686 6.76516C21.6851 6.6874 21.7708 6.50774 21.7708 6.34417C21.7708 6.2101 21.7159 6.10016 21.4258 5.64968C21.0191 5.02222 20.951 4.99541 20.1444 5.12948ZM14.9485 8.37403C16.0079 9.18919 18.0564 10.8865 18.0564 10.9482C18.0564 10.9616 17.9047 11.1627 17.7223 11.3933C15.4409 14.2464 13.4034 15.4879 11.0121 15.4825C8.2735 15.4745 5.93053 13.7932 4.1722 10.5701C3.97219 10.2001 3.84911 9.95608 3.8601 9.94267C3.91285 9.881 6.76134 9.25086 8.00096 9.02562C8.20756 8.98808 8.50428 8.93445 8.66033 8.90495C8.81858 8.87814 8.95924 8.85401 8.97683 8.85401C8.99221 8.85401 8.93506 8.94518 8.84935 9.0578C7.69984 10.5326 8.03173 12.911 9.51971 13.8469C11.0846 14.8336 12.966 13.5841 13.1572 11.4282C13.2583 10.2913 12.7396 9.08461 11.9066 8.51883C11.8209 8.45983 11.7484 8.4062 11.7484 8.39816C11.7484 8.38475 14.2606 7.97986 14.3771 7.97181C14.4013 7.96913 14.6584 8.15147 14.9485 8.37403Z" />
95
+ </svg>
96
+ );
97
+ }
98
+
99
+ /**
100
+ * Paper's icon contract: every icon is a component named `icon/<name>`. `iconNames` is that list in
101
+ * Paper order. Paper draws three sizes (16 in small controls and captions, 20 in buttons and rows,
102
+ * 24 in bars and empty states); 12, 14, 18 and 22 elsewhere in the code stay as they are.
103
+ */
104
+ export const iconNames = [
105
+ "plus",
106
+ "x",
107
+ "check",
108
+ "minus",
109
+ "chevron-down",
110
+ "chevron-right",
111
+ "chevron-left",
112
+ "search",
113
+ "send",
114
+ "arrow-up",
115
+ "arrow-right",
116
+ "bot",
117
+ "user",
118
+ "settings",
119
+ "bell",
120
+ "info",
121
+ "circle-alert",
122
+ "circle-check",
123
+ "triangle-alert",
124
+ "inbox",
125
+ "mail",
126
+ "calendar",
127
+ "paperclip",
128
+ "mic",
129
+ "ellipsis",
130
+ "copy",
131
+ "log-out",
132
+ "sparkles",
133
+ "eye",
134
+ "trash",
135
+ "globe",
136
+ "question",
137
+ "drop",
138
+ "list-checks",
139
+ "grid",
140
+ "key",
141
+ "external-link",
142
+ "caret-up-down",
143
+ "user-plus",
144
+ "folder",
145
+ "thumbs-up",
146
+ "arrow-clockwise",
147
+ "image",
148
+ "plug",
149
+ "thumbs-down",
150
+ "chat-circle",
151
+ "command",
152
+ "enter",
153
+ "wrench",
154
+ "clock",
155
+ "repeat",
156
+ "house",
157
+ "file-text",
158
+ "git-pull-request",
159
+ "upload",
160
+ "archive",
161
+ "stop",
162
+ "sliders",
163
+ "lightning",
164
+ "pause",
165
+ "play",
166
+ "arrow-down",
167
+ "scan",
168
+ "file-pdf",
169
+ "brain",
170
+ "bookmark-simple",
171
+ "files",
172
+ "list",
173
+ "horon",
174
+ "cube",
175
+ "code",
176
+ "clipboard-text",
177
+ "megaphone",
178
+ "pen-nib",
179
+ "chart-line-up",
180
+ "flask",
181
+ "users-three",
182
+ "pencil-simple",
183
+ "plus-circle",
184
+ ] as const;
185
+
186
+ export type HoronIconName = (typeof iconNames)[number];
187
+
188
+ const phosphorGlyphs: Record<Exclude<HoronIconName, "horon">, Icon> = {
189
+ plus: Plus,
190
+ x: X,
191
+ check: Check,
192
+ minus: Minus,
193
+ "chevron-down": CaretDown,
194
+ "chevron-right": CaretRight,
195
+ "chevron-left": CaretLeft,
196
+ search: MagnifyingGlass,
197
+ send: PaperPlaneTilt,
198
+ "arrow-up": ArrowUp,
199
+ "arrow-right": ArrowRight,
200
+ bot: Robot,
201
+ user: User,
202
+ settings: Gear,
203
+ bell: Bell,
204
+ info: Info,
205
+ "circle-alert": WarningCircle,
206
+ "circle-check": CheckCircle,
207
+ "triangle-alert": Warning,
208
+ inbox: Tray,
209
+ mail: EnvelopeSimple,
210
+ calendar: CalendarBlank,
211
+ paperclip: Paperclip,
212
+ mic: Microphone,
213
+ ellipsis: DotsThree,
214
+ copy: Copy,
215
+ "log-out": SignOut,
216
+ sparkles: Sparkle,
217
+ eye: Eye,
218
+ trash: Trash,
219
+ globe: Globe,
220
+ question: Question,
221
+ drop: Drop,
222
+ "list-checks": ListChecks,
223
+ grid: SquaresFour,
224
+ key: Key,
225
+ "external-link": ArrowSquareOut,
226
+ "caret-up-down": CaretUpDown,
227
+ "user-plus": UserPlus,
228
+ folder: Folder,
229
+ "thumbs-up": ThumbsUp,
230
+ "arrow-clockwise": ArrowClockwise,
231
+ image: Image,
232
+ plug: Plug,
233
+ "thumbs-down": ThumbsDown,
234
+ "chat-circle": ChatCircle,
235
+ command: Command,
236
+ enter: ArrowElbowDownLeft,
237
+ wrench: Wrench,
238
+ clock: Clock,
239
+ repeat: Repeat,
240
+ house: House,
241
+ "file-text": FileText,
242
+ "git-pull-request": GitPullRequest,
243
+ upload: UploadSimple,
244
+ archive: Archive,
245
+ stop: Stop,
246
+ sliders: SlidersHorizontal,
247
+ lightning: Lightning,
248
+ pause: Pause,
249
+ play: Play,
250
+ "arrow-down": ArrowDown,
251
+ scan: Scan,
252
+ "file-pdf": FilePdf,
253
+ brain: Brain,
254
+ "bookmark-simple": BookmarkSimple,
255
+ files: Files,
256
+ list: List,
257
+ cube: Cube,
258
+ code: Code,
259
+ "clipboard-text": ClipboardText,
260
+ megaphone: Megaphone,
261
+ "pen-nib": PenNib,
262
+ "chart-line-up": ChartLineUp,
263
+ flask: Flask,
264
+ "users-three": UsersThree,
265
+ "pencil-simple": PencilSimple,
266
+ "plus-circle": PlusCircle,
267
+ };
268
+
269
+ export type HoronGlyphProps = Omit<ComponentProps<"svg">, "children" | "ref"> & {
270
+ name: HoronIconName;
271
+ /** Paper sizes are 16, 20 and 24. */
272
+ size?: number | string;
273
+ };
274
+
275
+ /** Figma `icon/<name>`: resolves a registry name to its glyph. Takes `currentColor`, so the parent's text color drives it. */
276
+ export function HoronGlyph({ name, size = 20, ...props }: HoronGlyphProps) {
277
+ if (name === "horon") return <HoronIcon size={size} {...props} />;
278
+ const Glyph = phosphorGlyphs[name];
279
+ return <Glyph aria-hidden size={size} {...props} />;
280
+ }