e.stopPropagation()}
>
@@ -147,7 +147,7 @@ export function ContextMenu({
diff --git a/packages/studio/src/components/ui/menuStyle.test.ts b/packages/studio/src/components/ui/menuStyle.test.ts
new file mode 100644
index 0000000000..8e762be259
--- /dev/null
+++ b/packages/studio/src/components/ui/menuStyle.test.ts
@@ -0,0 +1,61 @@
+import { readdirSync, readFileSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+import { describe, expect, it } from "vitest";
+import { menuClasses } from "./menuStyle";
+
+const spacing = /(^| )-?(p|py|pt|pb|m|my|mt|mb)-\d/;
+const root = join(__dirname, "../..");
+
+/** Panels that are not a column of rows: a dialog, a crop toolbar, a preset card and a bare speed list. */
+const NOT_ROW_MENUS = new Set([
+ "player/components/AudioGainDialog.tsx",
+ "components/editor/CropPresetBar.tsx",
+ "components/editor/EaseCurveSection.tsx",
+ "player/components/SpeedMenu.tsx",
+]);
+
+function sources(dir: string): string[] {
+ return readdirSync(dir).flatMap((name) => {
+ const path = join(dir, name);
+ if (statSync(path).isDirectory()) return sources(path);
+ return /\.tsx$/.test(name) && !/\.test\./.test(name) ? [path] : [];
+ });
+}
+
+const files = sources(root).map((path) => ({
+ name: relative(root, path).replaceAll("\\", "/"),
+ text: readFileSync(path, "utf8"),
+}));
+
+describe("menu style", () => {
+ it("adds no padding or margin around a menu's rows", () => {
+ for (const key of ["panel", "group", "divider"] as const)
+ expect(menuClasses[key], key).not.toMatch(spacing);
+ });
+
+ it("clips the panel to its own rounded corners, so a highlight never pokes out of them", () => {
+ expect(menuClasses.panel).toContain("overflow-hidden");
+ expect(menuClasses.panel).toMatch(/(^| )rounded-/);
+ });
+
+ it("is the only place a menu panel's fill, border and shadow are written", () => {
+ const handBuilt = files
+ .filter(({ name }) => !NOT_ROW_MENUS.has(name))
+ .filter(({ text }) =>
+ [...text.matchAll(/["'`]([^"'`]*)["'`]/g)].some(
+ ([, literal]) =>
+ /shadow-(lg|xl)/.test(literal!) && /bg-(neutral-900|panel-bg-2)\b/.test(literal!),
+ ),
+ )
+ .map(({ name }) => name);
+ expect(handBuilt).toEqual([]);
+ });
+
+ it("is the panel of every role=menu, wherever its element is written", () => {
+ const unowned = files
+ .filter(({ text }) => text.includes('role="menu"'))
+ .filter(({ name, text }) => !NOT_ROW_MENUS.has(name) && !text.includes("menuClasses.panel"))
+ .map(({ name }) => name);
+ expect(unowned).toEqual([]);
+ });
+});
diff --git a/packages/studio/src/components/ui/menuStyle.ts b/packages/studio/src/components/ui/menuStyle.ts
new file mode 100644
index 0000000000..2ab1f607f4
--- /dev/null
+++ b/packages/studio/src/components/ui/menuStyle.ts
@@ -0,0 +1,10 @@
+/** The one owner of context menu spacing: rows touch the panel, which clips to its rounded corners. */
+export const menuClasses = {
+ panel: "overflow-hidden rounded-md border border-neutral-700 bg-neutral-900 shadow-lg",
+ group: "empty:hidden border-b border-neutral-700/60",
+ divider: "border-t border-neutral-700/60",
+ row: "w-full px-3 py-1.5 text-left text-xs outline-hidden",
+ rowEnabled: "text-neutral-300 hover:bg-neutral-800 focus-visible:bg-neutral-800 cursor-pointer",
+ rowDanger: "text-danger-ink hover:bg-danger/25 focus-visible:bg-danger/25 cursor-pointer",
+ rowDisabled: "text-neutral-600 cursor-not-allowed",
+} as const;
diff --git a/packages/studio/src/contexts/TimelineEditContext.tsx b/packages/studio/src/contexts/TimelineEditContext.tsx
index 48d7989111..bdc54003f9 100644
--- a/packages/studio/src/contexts/TimelineEditContext.tsx
+++ b/packages/studio/src/contexts/TimelineEditContext.tsx
@@ -46,6 +46,7 @@ const EDIT_CALLBACK_KEY_SET: Record
= {
onRazorSplit: true,
onRazorSplitAll: true,
onFreezeFrame: true,
+ clipMenuTools: true,
onNotice: true,
onDeleteKeyframe: true,
onDeleteAllKeyframes: true,
diff --git a/packages/studio/src/player/components/AutomationSelectionMenu.tsx b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
index f55cd2fb9e..fe79a28312 100644
--- a/packages/studio/src/player/components/AutomationSelectionMenu.tsx
+++ b/packages/studio/src/player/components/AutomationSelectionMenu.tsx
@@ -8,6 +8,7 @@ import { memo } from "react";
import { createPortal } from "react-dom";
import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
import { AUTOMATION_SHAPES, type AutomationShapeId } from "./automationShapes";
+import { menuClasses } from "../../components/ui/menuStyle";
interface AutomationSelectionMenuProps {
x: number;
@@ -45,7 +46,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
// portaled to `document.body`, but the ruler's sticky header sits at z-70
// in the SAME root stacking context, so a z-50 menu opened near the top of
// the timeline is painted through by the ruler and the playhead.
- className="hf-automation-menu fixed z-200 min-w-[140px] rounded-sm border border-panel-border-input bg-panel-bg-2 py-1 shadow-lg"
+ className={`${menuClasses.panel} hf-automation-menu fixed z-200 min-w-[140px]`}
style={{ left: adjustedX, top: adjustedY }}
>
{AUTOMATION_SHAPES.map((shape) => (
@@ -61,7 +62,7 @@ export const AutomationSelectionMenu = memo(function AutomationSelectionMenu({
{shape.label}
))}
-
+