@hyperframes/studio 0.8.8 → 0.8.10

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": "@hyperframes/studio",
3
- "version": "0.8.8",
3
+ "version": "0.8.10",
4
4
  "description": "",
5
5
  "repository": {
6
6
  "type": "git",
@@ -47,11 +47,11 @@
47
47
  "gsap": "^3.13.0",
48
48
  "marked": "^14.1.4",
49
49
  "mediabunny": "^1.45.3",
50
- "@hyperframes/core": "0.8.8",
51
- "@hyperframes/parsers": "0.8.8",
52
- "@hyperframes/player": "0.8.8",
53
- "@hyperframes/studio-server": "0.8.8",
54
- "@hyperframes/sdk": "0.8.8"
50
+ "@hyperframes/core": "0.8.10",
51
+ "@hyperframes/parsers": "0.8.10",
52
+ "@hyperframes/player": "0.8.10",
53
+ "@hyperframes/sdk": "0.8.10",
54
+ "@hyperframes/studio-server": "0.8.10"
55
55
  },
56
56
  "devDependencies": {
57
57
  "@types/react": "19",
@@ -68,7 +68,7 @@
68
68
  "vite": "^6.4.2",
69
69
  "vitest": "^3.2.4",
70
70
  "zustand": "^5.0.0",
71
- "@hyperframes/producer": "0.8.8"
71
+ "@hyperframes/producer": "0.8.10"
72
72
  },
73
73
  "peerDependencies": {
74
74
  "react": "19",
@@ -92,7 +92,7 @@ describe("CanvasContextMenu — handler gating", () => {
92
92
 
93
93
  // No menu opened at all — no buttons, no dead-end items, no DOM mutation.
94
94
  expect(menuButtons()).toHaveLength(0);
95
- expect(document.body.querySelector(".fixed.z-50")).toBeNull();
95
+ expect(document.body.querySelector(".fixed.z-\\[200\\]")).toBeNull();
96
96
  expect(el.style.zIndex).toBe("3");
97
97
  });
98
98
 
@@ -212,7 +212,7 @@ export const CanvasContextMenu = memo(function CanvasContextMenu({
212
212
  return createPortal(
213
213
  <div
214
214
  ref={menuRef}
215
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
215
+ className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
216
216
  style={{ left: adjustedX, top: adjustedY }}
217
217
  onPointerDown={stopBubble}
218
218
  onMouseDown={stopBubble}
@@ -10,6 +10,28 @@ import { TimelineFxPopover } from "./TimelineFxPopover.js";
10
10
  const EMPTY_CHAIN: HfAudioFxChain = { version: 1, nodes: [] };
11
11
  const RECT = { left: 0, top: 0, right: 0, bottom: 0 } as DOMRect;
12
12
 
13
+ function rect(top: number, bottom: number): DOMRect {
14
+ return { left: 0, top, right: 0, bottom } as DOMRect;
15
+ }
16
+
17
+ /** Pin the viewport height: every expected number below is derived from it, and
18
+ * happy-dom's 768 default is not something this file should silently inherit. */
19
+ function withViewportHeight<T>(value: number, run: () => T): T {
20
+ const previous = window.innerHeight;
21
+ Object.defineProperty(window, "innerHeight", { value, configurable: true });
22
+ try {
23
+ return run();
24
+ } finally {
25
+ Object.defineProperty(window, "innerHeight", { value: previous, configurable: true });
26
+ }
27
+ }
28
+
29
+ function dialogOf(host: HTMLElement): HTMLElement {
30
+ const el = host.querySelector('[role="dialog"]');
31
+ if (!el) throw new Error("no dialog");
32
+ return el as HTMLElement;
33
+ }
34
+
13
35
  function byTextButton(host: HTMLElement, text: string): HTMLButtonElement | undefined {
14
36
  return Array.from(host.querySelectorAll("button")).find((b) => b.textContent?.includes(text));
15
37
  }
@@ -102,6 +124,73 @@ describe("TimelineFxPopover", () => {
102
124
  expect(onClose).not.toHaveBeenCalled();
103
125
  });
104
126
 
127
+ // These guard the regression that shipped once already: an uncapped
128
+ // popover grew past the gap it opened into, ran under the timeline chrome and
129
+ // took its footer with it, and nothing scrolled.
130
+ it("caps its height to the space below when it opens downward", () => {
131
+ // spaceBelow 738 > spaceAbove 10, so it opens down: 738 - margin(8) - gap(4).
132
+ const dialog = withViewportHeight(768, () =>
133
+ dialogOf(mount({ anchorRect: rect(10, 30) }).host),
134
+ );
135
+ expect(dialog.style.top).toBe("34px");
136
+ expect(dialog.style.maxHeight).toBe("726px");
137
+ });
138
+
139
+ it("caps its height to the space above when it flips upward", () => {
140
+ // spaceBelow 48 < 260 and spaceAbove 700 is larger, so it flips up.
141
+ const dialog = withViewportHeight(768, () =>
142
+ dialogOf(mount({ anchorRect: rect(700, 720) }).host),
143
+ );
144
+ expect(dialog.style.bottom).toBe("72px");
145
+ expect(dialog.style.maxHeight).toBe("688px");
146
+ });
147
+
148
+ it("never caps below a usable minimum, however tight the gap", () => {
149
+ // Both gaps are tiny (80 below / 100 above); the cap must not collapse.
150
+ const dialog = withViewportHeight(200, () =>
151
+ dialogOf(mount({ anchorRect: rect(100, 120) }).host),
152
+ );
153
+ expect(dialog.style.maxHeight).toBe("160px");
154
+ });
155
+
156
+ it("keeps both edges in the viewport when the minimum exceeds the gap", () => {
157
+ // The case the minimum used to lose: at 200px of viewport (roughly 400% zoom
158
+ // on a laptop) neither gap can hold 160px, so honouring the floor has to
159
+ // slide the box in rather than hang its top edge off-screen.
160
+ const dialog = withViewportHeight(200, () =>
161
+ dialogOf(mount({ anchorRect: rect(100, 120) }).host),
162
+ );
163
+ // bottom:32 + maxHeight:160 puts the box at y = 8..168 — a margin on each side.
164
+ expect(dialog.style.bottom).toBe("32px");
165
+ const bottom = Number.parseFloat(dialog.style.bottom);
166
+ const height = Number.parseFloat(dialog.style.maxHeight);
167
+ expect(bottom).toBeGreaterThanOrEqual(8);
168
+ expect(200 - bottom - height).toBeGreaterThanOrEqual(8);
169
+ });
170
+
171
+ it("shrinks below the minimum only when the whole window is shorter", () => {
172
+ // A floor against a tight gap is not a floor against a tight window: 120px of
173
+ // viewport cannot hold 160px, and hanging off the edge is worse than short.
174
+ const dialog = withViewportHeight(120, () =>
175
+ dialogOf(mount({ anchorRect: rect(60, 80) }).host),
176
+ );
177
+ expect(dialog.style.maxHeight).toBe("104px");
178
+ expect(dialog.style.bottom).toBe("8px");
179
+ });
180
+
181
+ it("scrolls the preset list and leaves the footer outside the scroller", () => {
182
+ const { host } = mount();
183
+ const dialog = dialogOf(host);
184
+ const scroller = dialog.querySelector(".overflow-y-auto");
185
+ expect(scroller).toBeTruthy();
186
+ // The footer must be a SIBLING of the scroller, not inside it — otherwise it
187
+ // scrolls away instead of staying put, which is the original bug.
188
+ const footer = byTextButton(host, "Open rack");
189
+ expect(footer).toBeDefined();
190
+ expect(scroller?.contains(footer as Node)).toBe(false);
191
+ expect(dialog.contains(footer as Node)).toBe(true);
192
+ });
193
+
105
194
  it("the footer opens the rack and closes the popover", () => {
106
195
  const { host, onOpenRack, onClose } = mount();
107
196
  const openRack = byTextButton(host, "Open rack");
@@ -17,6 +17,8 @@ import { useFxAudition } from "./useFxAudition.js";
17
17
 
18
18
  const POPOVER_WIDTH = 260;
19
19
  const VIEWPORT_MARGIN = 8;
20
+ /** Below this the popover is useless anyway; it scrolls instead of vanishing. */
21
+ const MIN_POPOVER_HEIGHT = 160;
20
22
 
21
23
  function clampedStyle(anchorRect: DOMRect): CSSProperties {
22
24
  const left = Math.min(
@@ -24,14 +26,41 @@ function clampedStyle(anchorRect: DOMRect): CSSProperties {
24
26
  Math.max(VIEWPORT_MARGIN, window.innerWidth - POPOVER_WIDTH - VIEWPORT_MARGIN),
25
27
  );
26
28
  const spaceBelow = window.innerHeight - anchorRect.bottom;
27
- const openUpward = spaceBelow < 260 && anchorRect.top > spaceBelow;
29
+ // Named, because the height cap below needs the same quantity the flip does.
30
+ // (`spaceAbove === anchorRect.top` for a viewport-relative rect, so the flip
31
+ // condition itself is unchanged — this is a rename, not a behaviour fix.)
32
+ const spaceAbove = anchorRect.top;
33
+ const openUpward = spaceBelow < 260 && spaceAbove > spaceBelow;
34
+ // Flipping direction alone is not enough: the preset list is taller than either
35
+ // gap on a short window, so the popover ran off the top or the bottom and its
36
+ // footer ("+ effect" / "Open rack") went with it. Cap to whatever the chosen
37
+ // side actually has and let the list scroll inside that.
38
+ const available = (openUpward ? spaceAbove : spaceBelow) - VIEWPORT_MARGIN - 4;
39
+ // The minimum is a floor against a tight GAP, not against a tight window: keep
40
+ // a usable list when the gap is smaller than 160px, but never ask for more
41
+ // height than the viewport itself can hold.
42
+ const height = Math.min(
43
+ Math.max(MIN_POPOVER_HEIGHT, available),
44
+ window.innerHeight - VIEWPORT_MARGIN * 2,
45
+ );
46
+ // A floor larger than the gap would hang the box off the edge it opened away
47
+ // from — reachable at high browser zoom, where both gaps fall under ~172px.
48
+ // Slide it back in-bounds the way `left` is already clamped, rather than
49
+ // shrinking below the floor: the offset that keeps BOTH edges inside is
50
+ // `innerHeight - height - VIEWPORT_MARGIN`, from either side.
51
+ const inset = (desired: number) =>
52
+ Math.max(
53
+ VIEWPORT_MARGIN,
54
+ Math.min(desired, Math.max(VIEWPORT_MARGIN, window.innerHeight - height - VIEWPORT_MARGIN)),
55
+ );
28
56
  return {
29
57
  position: "fixed",
30
58
  left,
31
59
  width: POPOVER_WIDTH,
60
+ maxHeight: height,
32
61
  ...(openUpward
33
- ? { bottom: window.innerHeight - anchorRect.top + 4 }
34
- : { top: anchorRect.bottom + 4 }),
62
+ ? { bottom: inset(window.innerHeight - anchorRect.top + 4) }
63
+ : { top: inset(anchorRect.bottom + 4) }),
35
64
  };
36
65
  }
37
66
 
@@ -96,22 +125,27 @@ export function TimelineFxPopover({
96
125
  ref={rootRef}
97
126
  role="dialog"
98
127
  aria-label="Effects"
99
- className="z-50 rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
128
+ className="z-[200] flex flex-col overflow-hidden rounded-md border border-white/10 bg-[#1b1b1f] p-2 shadow-xl"
100
129
  style={clampedStyle(anchorRect)}
101
130
  onKeyDown={onKeyDown}
102
131
  onPointerDown={(event) => event.stopPropagation()}
103
132
  >
104
- <FxPresetMenu
105
- trackKind={trackKind}
106
- onPick={applyPreset}
107
- onAudition={
108
- onChainPreview
109
- ? (id) =>
110
- audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
111
- : undefined
112
- }
113
- />
114
- <div className="mt-2 flex items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
133
+ {/* The list scrolls; the footer below stays put. `min-h-0` is load-bearing
134
+ — a flex child defaults to min-height:auto and would refuse to shrink,
135
+ pushing the footer out of the popover instead of scrolling. */}
136
+ <div className="min-h-0 flex-1 overflow-y-auto">
137
+ <FxPresetMenu
138
+ trackKind={trackKind}
139
+ onPick={applyPreset}
140
+ onAudition={
141
+ onChainPreview
142
+ ? (id) =>
143
+ audition(id ? (base) => applyPresetToChain(base, id, trackKind) ?? base : null)
144
+ : undefined
145
+ }
146
+ />
147
+ </div>
148
+ <div className="mt-2 flex shrink-0 items-center justify-between border-t border-white/10 pt-2 text-[10px] text-white/55">
115
149
  <button
116
150
  type="button"
117
151
  className="hover:text-white"
@@ -250,12 +250,19 @@ export function useRenderQueue(projectId: string | null) {
250
250
  headers: { "Content-Type": "application/json" },
251
251
  body: JSON.stringify(body),
252
252
  });
253
- } catch {
253
+ } catch (err) {
254
+ // The cause used to be discarded. Every failure — a dead server, an
255
+ // aborted request, a DNS error, a mid-render crash — surfaced as the
256
+ // same sentence, and this string is what travels into the feedback
257
+ // report too, so field reports of a render that fails *every time*
258
+ // still carried nothing to act on. Keep the CLI guidance, name the
259
+ // cause after it.
260
+ const cause = err instanceof Error ? err.message : String(err);
254
261
  const failedJob: RenderJob = {
255
262
  id: generateId(),
256
263
  status: "failed",
257
264
  progress: 0,
258
- error: "Could not reach render server. Use `hyperframes render` from the CLI instead.",
265
+ error: `Could not reach render server: ${cause}. Use \`hyperframes render\` from the CLI instead.`,
259
266
  filename: "Export failed",
260
267
  createdAt: startTime,
261
268
  };
@@ -0,0 +1,49 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ // The render POST's catch used to discard its error, so a dead server, a DNS
4
+ // failure and a mid-render crash all produced one sentence. That string is also
5
+ // what travels into the feedback report, so three field reports — one of them a
6
+ // render that failed EVERY time — arrived with nothing to act on.
7
+
8
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9
+ import { mountRenderQueue } from "./renderQueueTestHarness";
10
+
11
+ vi.mock("./useFfmpegStatus", async (importOriginal) => ({
12
+ ...(await importOriginal<typeof import("./useFfmpegStatus")>()),
13
+ useFfmpegStatus: () => ({ status: { ok: true }, checking: false, recheck: vi.fn() }),
14
+ }));
15
+ vi.mock("../../telemetry/events", () => ({ trackStudioRenderStart: vi.fn() }));
16
+
17
+ const { useRenderQueue } = await import("./useRenderQueue");
18
+
19
+ let queue: ReturnType<typeof mountRenderQueue> | null = null;
20
+
21
+ beforeEach(() => {
22
+ vi.stubGlobal(
23
+ "fetch",
24
+ vi.fn(() => Promise.reject(new TypeError("Failed to fetch"))),
25
+ );
26
+ });
27
+
28
+ afterEach(() => {
29
+ queue?.unmount();
30
+ queue = null;
31
+ document.body.innerHTML = "";
32
+ vi.unstubAllGlobals();
33
+ });
34
+
35
+ describe("useRenderQueue transport failure", () => {
36
+ it("names the cause instead of collapsing every failure into one sentence", async () => {
37
+ queue = mountRenderQueue(useRenderQueue);
38
+ const { act } = await import("react");
39
+ await act(async () => {
40
+ await queue?.api().startRender({});
41
+ });
42
+
43
+ const job = queue.api().jobs.at(-1);
44
+ expect(job?.status).toBe("failed");
45
+ expect(job?.error).toContain("Failed to fetch");
46
+ // The CLI guidance still earns its place; it just no longer stands alone.
47
+ expect(job?.error).toContain("hyperframes render");
48
+ });
49
+ });
@@ -41,7 +41,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
41
41
  return createPortal(
42
42
  <div
43
43
  ref={menuRef}
44
- className="hf-automation-menu fixed z-50 min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
44
+ className="hf-automation-menu fixed z-[200] min-w-[140px] rounded border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
45
45
  style={{ left: adjustedX, top: adjustedY }}
46
46
  >
47
47
  {AUTOMATION_SHAPES.map((shape) => (
@@ -48,7 +48,7 @@ export const ClipContextMenu = memo(function ClipContextMenu({
48
48
  ref={menuRef}
49
49
  role="menu"
50
50
  aria-label="Clip actions"
51
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
51
+ className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
52
52
  style={{ left: adjustedX, top: adjustedY }}
53
53
  >
54
54
  {splitLabel && (
@@ -96,7 +96,7 @@ export function KeyframeDiamondContextMenu({
96
96
  ref={menuRef}
97
97
  role="menu"
98
98
  aria-label="Keyframe actions"
99
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
99
+ className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px] overflow-y-auto"
100
100
  style={{ left: adjustedX, top: adjustedY, maxHeight: `calc(100vh - ${adjustedY + 8}px)` }}
101
101
  >
102
102
  {onMoveToPlayhead && (
@@ -79,7 +79,7 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
79
79
  <div
80
80
  role="dialog"
81
81
  aria-label="Group these clips to add effects"
82
- className="z-50 w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
82
+ className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
83
83
  style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
84
84
  onPointerDown={(event) => event.stopPropagation()}
85
85
  >
@@ -0,0 +1,76 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import React, { act } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { afterEach, describe, expect, it, vi } from "vitest";
6
+ import { TimelineGroupHeader } from "./TimelineGroupHeader";
7
+ import { defaultTimelineTheme } from "./timelineTheme";
8
+
9
+ (globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
10
+
11
+ function renderHeader(
12
+ overrides: Partial<React.ComponentProps<typeof TimelineGroupHeader>> = {},
13
+ ): HTMLElement {
14
+ const host = document.createElement("div");
15
+ document.body.append(host);
16
+ act(() => {
17
+ createRoot(host).render(
18
+ <TimelineGroupHeader
19
+ label="Voiceover"
20
+ memberCount={5}
21
+ isExpanded={false}
22
+ onToggleExpanded={vi.fn()}
23
+ laneCount={0}
24
+ isLaneOpen={false}
25
+ onToggleLanes={vi.fn()}
26
+ hidden={false}
27
+ onToggleHidden={vi.fn()}
28
+ isSoloed={false}
29
+ isHalfLitSolo={false}
30
+ onToggleSolo={vi.fn()}
31
+ onFxChainChange={vi.fn()}
32
+ onOpenFxRack={vi.fn()}
33
+ columnWidth={220}
34
+ theme={defaultTimelineTheme}
35
+ {...overrides}
36
+ />,
37
+ );
38
+ });
39
+ return host;
40
+ }
41
+
42
+ /** The structural disclosure — the caret, not the `∿` lane toggle. */
43
+ function caret(host: HTMLElement): HTMLButtonElement {
44
+ const el = Array.from(host.querySelectorAll("button")).find((b) =>
45
+ b.getAttribute("aria-label")?.includes("tracks"),
46
+ );
47
+ if (!el) throw new Error("no disclosure caret");
48
+ return el as HTMLButtonElement;
49
+ }
50
+
51
+ afterEach(() => {
52
+ document.body.innerHTML = "";
53
+ });
54
+
55
+ describe("TimelineGroupHeader disclosure caret", () => {
56
+ // Guards a regression that shipped once: the caret silently reverted to a
57
+ // smaller, non-mono, rotated glyph unlike the property panel's carets.
58
+ it("swaps the glyph rather than rotating one", () => {
59
+ expect(caret(renderHeader({ isExpanded: false })).textContent).toContain("▸");
60
+ expect(caret(renderHeader({ isExpanded: true })).textContent).toContain("▾");
61
+ });
62
+
63
+ it("does not rotate the glyph", () => {
64
+ const glyph = caret(renderHeader({ isExpanded: true })).querySelector("span");
65
+ expect(glyph?.getAttribute("style") ?? "").not.toContain("rotate");
66
+ });
67
+
68
+ it("keeps the caret mono at 13px, not back down at the 11px the row inherits", () => {
69
+ // Only this component's own className — the panel carets are not read here,
70
+ // and this does not claim to pin a match against them.
71
+ const className = caret(renderHeader()).className;
72
+ expect(className).toContain("font-mono");
73
+ expect(className).toContain("text-[13px]");
74
+ expect(className).not.toContain("text-[11px]");
75
+ });
76
+ });
@@ -77,7 +77,11 @@ export function TimelineGroupHeader({
77
77
  aria-expanded={isExpanded}
78
78
  aria-label={`${isExpanded ? "Hide" : "Show"} ${label} tracks`}
79
79
  title={`${isExpanded ? "Hide" : "Show"} tracks`}
80
- className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 text-[11px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
80
+ // Mono, like both of the property panel's disclosure carets. The size is
81
+ // this row's own call and not a match to either of them: the node-body
82
+ // caret sits under `text-[9px]`, and `hf-fx-preset-run-caret` has no
83
+ // size rule at all, so there is no measured target to match.
84
+ className={`flex h-6 w-6 shrink-0 items-center justify-center rounded border-0 bg-transparent p-0 font-mono text-[13px] focus-visible:outline focus-visible:outline-1 focus-visible:outline-[#3CE6AC] ${
81
85
  isExpanded ? "text-white" : "text-white/55 hover:text-white"
82
86
  }`}
83
87
  onPointerDown={(event) => event.stopPropagation()}
@@ -86,9 +90,9 @@ export function TimelineGroupHeader({
86
90
  onToggleExpanded();
87
91
  }}
88
92
  >
89
- <span aria-hidden="true" style={{ transform: isExpanded ? "rotate(90deg)" : undefined }}>
90
-
91
- </span>
93
+ {/* Swapped, not rotated: both panel carets swap, and a rotated ▸ sits
94
+ off-centre in its box because the glyph is not square. */}
95
+ <span aria-hidden="true">{isExpanded ? "▾" : "▸"}</span>
92
96
  </button>
93
97
  <span aria-hidden="true" className="shrink-0 text-[12px] leading-none text-white/50">
94
98
 
@@ -75,7 +75,7 @@ export const TrackGapContextMenu = memo(function TrackGapContextMenu({
75
75
  return createPortal(
76
76
  <div
77
77
  ref={menuRef}
78
- className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
78
+ className="fixed z-[200] bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
79
79
  style={{ left: adjustedX, top: adjustedY }}
80
80
  onPointerLeave={() => onHoverAction(null)}
81
81
  >