diff --git a/packages/studio/src/player/components/TimelineFxButton.test.tsx b/packages/studio/src/player/components/TimelineFxButton.test.tsx
index 077f33dbf3..2d037ceb98 100644
--- a/packages/studio/src/player/components/TimelineFxButton.test.tsx
+++ b/packages/studio/src/player/components/TimelineFxButton.test.tsx
@@ -68,6 +68,37 @@ describe("TimelineFxButton", () => {
expect(document.querySelector('[role="dialog"]')).toBeTruthy();
});
+ // The reported symptom was "the grouping button did nothing". The dialog WAS
+ // opening — it positioned at `anchorRect.bottom + 4` with no flip and no
+ // clamp, and this button lives in a track header at the bottom of the studio
+ // window, so it opened past the viewport edge. The test below it passed
+ // throughout: happy-dom reports an all-zero rect for an unlaid-out button,
+ // which lands the dialog at top:4 — on screen, and nothing like the app.
+ it("flips the group dialog above the anchor when there is no room below", () => {
+ const host = mount();
+ const fx = byTextButton(host, "FX");
+ // A track header near the bottom edge of the (1024x768) window.
+ fx!.getBoundingClientRect = () =>
+ ({ left: 300, top: 760, right: 320, bottom: 776, width: 20, height: 16 }) as DOMRect;
+ act(() => fx?.click());
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
+ expect(dialog).toBeTruthy();
+ // Above the anchor, and fully inside the viewport: 644 + 112 === 756.
+ expect(dialog.style.top).toBe("644px");
+ });
+
+ it("keeps the group dialog inside the right edge of the window", () => {
+ const host = mount();
+ const fx = byTextButton(host, "FX");
+ // Hard against the right edge: 224px wide + a 12px margin has to fit.
+ fx!.getBoundingClientRect = () =>
+ ({ left: 1010, top: 100, right: 1024, bottom: 116, width: 14, height: 16 }) as DOMRect;
+ act(() => fx?.click());
+ const dialog = document.querySelector('[role="dialog"]') as HTMLElement;
+ expect(dialog.style.left).toBe("788px");
+ expect(dialog.style.top).toBe("120px");
+ });
+
it("group-pointer variant offers Group instead of a popover", () => {
const onGroupClips = vi.fn();
const host = mount();
diff --git a/packages/studio/src/player/components/TimelineFxButton.tsx b/packages/studio/src/player/components/TimelineFxButton.tsx
index b874ecb18c..65070df804 100644
--- a/packages/studio/src/player/components/TimelineFxButton.tsx
+++ b/packages/studio/src/player/components/TimelineFxButton.tsx
@@ -18,6 +18,24 @@ import {
} from "@hyperframes/core/audio-fx";
import type { HfAudioNameKind } from "@hyperframes/core/audio-carve";
import { TimelineFxPopover } from "../../components/editor/TimelineFxPopover.js";
+import { resolveFloatingPanelPosition } from "../../components/editor/floatingPanel.js";
+
+// Estimated, like FORMAT_PANEL_SIZE in RenderQueue: `w-56` is exact, and only
+// the flip decision uses the height — the clamp keeps the dialog on screen
+// either way.
+const GROUP_DIALOG_SIZE = { width: 224, height: 112 };
+
+/** Where the grouping dialog goes: flipped above the anchor when there is no
+ * room below, and clamped so neither edge leaves the viewport. */
+function groupDialogPosition(anchorRect: DOMRect): { left: number; top: number } {
+ const { left, top } = resolveFloatingPanelPosition(
+ anchorRect,
+ { width: window.innerWidth, height: window.innerHeight },
+ GROUP_DIALOG_SIZE,
+ { offset: 4 },
+ );
+ return { left, top };
+}
function parseFxChainOrEmpty(raw: string | undefined): HfAudioFxChain {
if (!raw) return { version: 1, nodes: [] };
@@ -80,7 +98,12 @@ export function TimelineFxButton(props: TimelineFxButtonProps) {
role="dialog"
aria-label="Group these clips to add effects"
className="z-[200] w-56 rounded-md border border-white/10 bg-[#1b1b1f] p-2.5 text-[11px] text-white/75 shadow-xl"
- style={{ position: "fixed", left: anchorRect.left, top: anchorRect.bottom + 4 }}
+ // Was `top: anchorRect.bottom + 4` with no flip and no clamp. This
+ // button lives in a track header at the BOTTOM of the studio
+ // window, so the dialog opened past the viewport edge and the
+ // click read as doing nothing at all. Same helper the other body
+ // portals position with.
+ style={{ position: "fixed", ...groupDialogPosition(anchorRect) }}
onPointerDown={(event) => event.stopPropagation()}
>
Group these clips to add effects to all of them.