diff --git a/.fallowrc.jsonc b/.fallowrc.jsonc
index 32a6d5bc85..7fbf76be91 100644
--- a/.fallowrc.jsonc
+++ b/.fallowrc.jsonc
@@ -53,6 +53,9 @@
"packages/studio/src/hooks/gsapTargetCache.ts",
// Preview helper consumed dynamically from the studio iframe bridge.
"packages/studio/src/hooks/gsapRuntimePreview.ts",
+ // TEMP(studio-dnd): shipped unwired ahead of the NLE integration;
+ // the app-shell swap PR (studio-dnd/pr22) wires the consumers and removes this block.
+ "packages/studio/src/components/editor/CanvasContextMenu.tsx",
],
"ignorePatterns": [
"docs/**",
@@ -90,6 +93,11 @@
"packages/cli/src/cloud/_gen/**",
],
"ignoreExports": [
+ // TEMP(studio-dnd): consumers land later in the stack; removed by studio-dnd/pr22.
+ {
+ "file": "packages/studio/src/components/editor/canvasContextMenuZOrder.ts",
+ "exports": ["readEffectiveZIndex"],
+ },
// drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as
@@ -433,6 +441,13 @@
// complexity pre-dates the computed-timeline work. Exempted at file level
// rather than refactored as scope creep.
"ignore": [
+ // TEMP(studio-dnd): coexistence-window complexity flare (inherited/CRAP-no-coverage);
+ // removed by studio-dnd/pr22 when the final config lands.
+ "packages/studio/src/player/hooks/useTimelineSyncCallbacks.ts",
+ "packages/studio/src/components/editor/CanvasContextMenu.tsx",
+ "packages/studio/src/components/editor/canvasContextMenuZOrder.test.ts",
+ "packages/studio/src/player/components/timelineCollision.test.ts",
+ "packages/studio/src/player/components/timelineStackingSync.test.ts",
// sourcePatcher.ts: resolveSourceFile / splitInlineStyleDeclarations /
// patch*InTag pre-date this PR; only the PatchOperation type gained two
// optional fields, but the line-shift fingerprint re-flags the inherited
diff --git a/packages/studio/src/components/editor/CanvasContextMenu.test.tsx b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx
new file mode 100644
index 0000000000..9039863213
--- /dev/null
+++ b/packages/studio/src/components/editor/CanvasContextMenu.test.tsx
@@ -0,0 +1,115 @@
+// @vitest-environment happy-dom
+import React, { act } from "react";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { installReactActEnvironment, makeSelection } from "../../hooks/domSelectionTestHarness";
+import { CanvasContextMenu } from "./CanvasContextMenu";
+import type { DomEditSelection } from "./domEditing";
+
+installReactActEnvironment();
+
+let host: HTMLDivElement;
+let root: Root | null = null;
+
+beforeEach(() => {
+ host = document.createElement("div");
+ document.body.append(host);
+});
+
+afterEach(() => {
+ act(() => root?.unmount());
+ root = null;
+ document.body.innerHTML = "";
+});
+
+function renderMenu(props: {
+ selection: DomEditSelection;
+ onApplyZIndex?: () => void;
+ onDelete?: (selection: DomEditSelection) => void;
+}) {
+ root = createRoot(host);
+ act(() => {
+ root!.render(
+ React.createElement(CanvasContextMenu, {
+ x: 10,
+ y: 10,
+ selection: props.selection,
+ onClose: () => {},
+ onApplyZIndex: props.onApplyZIndex,
+ onDelete: props.onDelete,
+ }),
+ );
+ });
+}
+
+/** All menu buttons live in the portal under document.body. */
+function menuButtons(): HTMLButtonElement[] {
+ return [...document.body.querySelectorAll("button")];
+}
+
+function hasDeleteItem(): boolean {
+ return menuButtons().some((b) => b.textContent?.includes("Delete"));
+}
+
+function zOrderButtons(): HTMLButtonElement[] {
+ return menuButtons().filter((b) => !b.textContent?.includes("Delete"));
+}
+
+describe("CanvasContextMenu — handler gating", () => {
+ it("renders all four z-order items, a divider, and Delete when both handlers are present", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({
+ selection: makeSelection("Target", el),
+ onApplyZIndex: vi.fn(),
+ onDelete: vi.fn(),
+ });
+
+ expect(zOrderButtons()).toHaveLength(4);
+ expect(hasDeleteItem()).toBe(true);
+ // The divider only appears between the two groups.
+ expect(document.body.querySelector(".border-t")).not.toBeNull();
+ });
+
+ it("hides every item and does NOT render the menu when no handlers are present", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ // A z-index that a stray optimistic write would clobber — assert it is
+ // untouched, since the menu must not mutate the DOM without a persist path.
+ el.style.zIndex = "3";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el) });
+
+ // No menu opened at all — no buttons, no dead-end items, no DOM mutation.
+ expect(menuButtons()).toHaveLength(0);
+ expect(document.body.querySelector(".fixed.z-50")).toBeNull();
+ expect(el.style.zIndex).toBe("3");
+ });
+
+ it("shows only the z-order items (no Delete, no divider) when onDelete is absent", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el), onApplyZIndex: vi.fn() });
+
+ expect(zOrderButtons()).toHaveLength(4);
+ expect(hasDeleteItem()).toBe(false);
+ expect(document.body.querySelector(".border-t")).toBeNull();
+ });
+
+ it("shows only Delete (no z-order items, no divider) when onApplyZIndex is absent", () => {
+ const el = document.createElement("div");
+ el.id = "target";
+ document.body.append(el);
+
+ renderMenu({ selection: makeSelection("Target", el), onDelete: vi.fn() });
+
+ expect(zOrderButtons()).toHaveLength(0);
+ expect(hasDeleteItem()).toBe(true);
+ expect(document.body.querySelector(".border-t")).toBeNull();
+ });
+});
diff --git a/packages/studio/src/components/editor/CanvasContextMenu.tsx b/packages/studio/src/components/editor/CanvasContextMenu.tsx
new file mode 100644
index 0000000000..a7a124128f
--- /dev/null
+++ b/packages/studio/src/components/editor/CanvasContextMenu.tsx
@@ -0,0 +1,198 @@
+/**
+ * Right-click context menu for a selected canvas element.
+ *
+ * Mirrors the look, positioning, and dismiss behavior of
+ * player/components/ClipContextMenu.tsx — portaled to document.body,
+ * overflow-adjusted, dismissed on outside-click or Escape via
+ * useContextMenuDismiss.
+ *
+ * ── Wiring (z-order persistence) ─────────────────────────────────────────────
+ * Z-index changes are applied optimistically to the live iframe element(s) via
+ * `resolveZOrderChange`, which returns a MULTI-element patch list (tie-aware:
+ * moving a target past an equal-z sibling can require renumbering the affected
+ * set). The patches are surfaced through the `onApplyZIndex` prop.
+ *
+ * The prop MUST be wired at the call site to route through the full persist
+ * path. PreviewOverlays.tsx builds the per-patch PatchTargets (the selected
+ * element carries its full selection identity; sibling elements are iframe DOM
+ * nodes, so their id / selector are derived from the node and they share the
+ * selection's sourceFile) and forwards them to handleDomZIndexReorderCommit.
+ * ─────────────────────────────────────────────────────────────────────────────
+ */
+
+import { memo } from "react";
+import { createPortal } from "react-dom";
+import type { DomEditSelection } from "./domEditing";
+import { useContextMenuDismiss } from "../../hooks/useContextMenuDismiss";
+import {
+ isZOrderActionEnabled,
+ resolveZOrderChange,
+ type ZOrderPatch,
+} from "./canvasContextMenuZOrder";
+
+interface CanvasContextMenuProps {
+ /** Viewport x of the right-click event. */
+ x: number;
+ /** Viewport y of the right-click event. */
+ y: number;
+ selection: DomEditSelection;
+ onClose: () => void;
+ /**
+ * Called with the resolved z-order patch list after an optimistic DOM update.
+ * Each patch is an { element, zIndex } pair (the target and, when a renumber
+ * is needed, affected siblings). Wire to handleDomZIndexReorderCommit (see
+ * module-level wiring comment).
+ */
+ onApplyZIndex?: (patches: ZOrderPatch[]) => void;
+ /**
+ * Delete the selected element. Wire to handleDomEditElementDelete from
+ * useDomEditActionsContext — same path as the Delete/Backspace hotkey.
+ * Absent when the caller wires no delete persist path (e.g. a legacy mount):
+ * the Delete item is then hidden rather than shown as a silent no-op.
+ */
+ onDelete?: (selection: DomEditSelection) => void;
+}
+
+type ZAction = "bring-forward" | "send-backward" | "bring-to-front" | "send-to-back";
+
+const Z_ACTIONS: Array<{ action: ZAction; label: string }> = [
+ { action: "bring-forward", label: "Bring forward" },
+ { action: "send-backward", label: "Send backward" },
+ { action: "bring-to-front", label: "Bring to front" },
+ { action: "send-to-back", label: "Send to back" },
+];
+
+export const CanvasContextMenu = memo(function CanvasContextMenu({
+ x,
+ y,
+ selection,
+ onClose,
+ onApplyZIndex,
+ onDelete,
+}: CanvasContextMenuProps) {
+ const menuRef = useContextMenuDismiss(onClose);
+
+ // Gate each item group on the presence of its persist handler. Without the
+ // handler the action can't be persisted, so showing it would be a dead-end:
+ // a z-write reverts on reload and Delete silently no-ops. Hide the group
+ // instead. If nothing is actionable (a legacy mount with no handlers at all),
+ // don't render the menu — an empty menu is itself a dead-end.
+ const hasZActions = Boolean(onApplyZIndex);
+ const hasDelete = Boolean(onDelete);
+ const hasDivider = hasZActions && hasDelete;
+
+ // Overflow correction — match ClipContextMenu approach. Only the rendered
+ // groups contribute height (keeps positioning correct when a group is hidden).
+ const menuWidth = 200;
+ const menuHeight =
+ 8 + (hasZActions ? Z_ACTIONS.length * 28 : 0) + (hasDivider ? 1 : 0) + (hasDelete ? 28 : 0) + 8; // padding + items + divider + delete + padding
+ const overflowY = y + menuHeight - window.innerHeight;
+ const adjustedX = x + menuWidth > window.innerWidth ? x - menuWidth : x;
+ const adjustedY = overflowY > 0 ? y - overflowY - 8 : y;
+
+ const el = selection.element;
+
+ function handleZAction(action: ZAction) {
+ // No persist handler → do NOT touch the live iframe DOM. An optimistic
+ // write with nothing to persist just reverts on the next reload.
+ if (!onApplyZIndex) return;
+ const patches = resolveZOrderChange(el, action);
+ if (patches === null) return;
+ // Optimistic update — visible immediately even before persist completes.
+ for (const patch of patches) {
+ patch.element.style.zIndex = String(patch.zIndex);
+ const view = patch.element.ownerDocument?.defaultView;
+ if (view && view.getComputedStyle(patch.element).position === "static") {
+ patch.element.style.position = "relative";
+ }
+ }
+ onApplyZIndex(patches);
+ onClose();
+ }
+
+ function handleDelete() {
+ if (!onDelete) return;
+ onDelete(selection);
+ onClose();
+ }
+
+ if (!hasZActions && !hasDelete) return null;
+
+ // The menu is portaled to document.body, but in the React tree it is still a
+ // child of the DomEditOverlay
. React synthetic events bubble through the
+ // REACT tree (not the DOM tree), so a click on any menu control would otherwise
+ // bubble into the overlay's onPointerDown / onMouseDown handlers — which
+ // preventDefault() to start a marquee and re-resolve the selection. That
+ // preventDefault cancels the button's own click and the item action never runs.
+ //
+ // Stop pointer/mouse propagation at the menu root so overlay gesture handlers
+ // never see these events, and drive the item actions on pointerDown (which
+ // fires before any outside-click / dismiss logic can unmount the menu).
+ const stopBubble = (e: React.SyntheticEvent) => {
+ e.stopPropagation();
+ };
+
+ return createPortal(
+