Skip to content

Commit 6fa8dc9

Browse files
committed
fix(web): lift handoff menu above the sketch canvas toolbar
1 parent 6ce4344 commit 6fa8dc9

2 files changed

Lines changed: 124 additions & 1 deletion

File tree

apps/web/src/styles/workspace/drawer.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1384,7 +1384,13 @@
13841384
top: 0;
13851385
z-index: 4;
13861386
}
1387-
.ws-tabs-shell:has(.entry-settings-menu__popover) {
1387+
/* The tab shell's own z-index:4 makes it a stacking context, so any popover
1388+
opened from the right-side action cluster is capped at 4 — the same layer
1389+
Excalidraw uses for its toolbar (`--zIndex-layerUI: 4`), which paints later
1390+
in DOM order and therefore wins. Lift the whole shell while a popover is
1391+
open so these menus clear the sketch canvas chrome. */
1392+
.ws-tabs-shell:has(.entry-settings-menu__popover),
1393+
.ws-tabs-shell:has(.handoff-menu) {
13881394
z-index: 1900;
13891395
}
13901396
.ws-tabs-bar {
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import { expect, test } from '@/playwright/suite';
2+
import { routeAgents } from '@/playwright/mock-factory';
3+
import { openAllProjectFiles } from '@/playwright/workspace';
4+
import type { Page } from '@playwright/test';
5+
import { T } from '@/timeouts';
6+
7+
// Regression: with a sketch tab open, the header "open in editor" dropdown
8+
// painted UNDER the Excalidraw toolbar island. `.ws-tabs-shell` carries
9+
// `z-index: 4`, which makes it a stacking context, so `.handoff-menu`'s own
10+
// `z-index: 50` is trapped inside it — and Excalidraw's toolbar layer also
11+
// sits at 4 (`--zIndex-layerUI`) while painting later in DOM order, so it
12+
// wins. The fix lifts the tab shell while the handoff menu is open.
13+
14+
const STORAGE_KEY = 'open-design:config';
15+
16+
test.describe.configure({ timeout: T.xlong });
17+
18+
test.beforeEach(async ({ page }) => {
19+
await page.addInitScript((key) => {
20+
window.localStorage.setItem(
21+
key,
22+
JSON.stringify({
23+
mode: 'daemon',
24+
apiKey: '',
25+
baseUrl: 'https://api.anthropic.com',
26+
model: 'claude-sonnet-4-5',
27+
agentId: 'mock',
28+
skillId: null,
29+
designSystemId: null,
30+
onboardingCompleted: true,
31+
agentModels: {},
32+
privacyDecisionAt: 1,
33+
telemetry: { metrics: false, content: false, artifactManifest: false },
34+
}),
35+
);
36+
}, STORAGE_KEY);
37+
await routeAgents(page, [
38+
{
39+
id: 'mock',
40+
name: 'Mock Agent',
41+
bin: 'mock-agent',
42+
available: true,
43+
version: 'test',
44+
models: [{ id: 'default', label: 'Default' }],
45+
},
46+
]);
47+
});
48+
49+
test('[P1] handoff dropdown paints above the sketch canvas toolbar', async ({ page }) => {
50+
await page.setViewportSize({ width: 1440, height: 900 });
51+
52+
const projectId = `handoff-sketch-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
53+
const created = await page.request.post('/api/projects', {
54+
data: {
55+
id: projectId,
56+
name: 'Handoff over sketch',
57+
skillId: null,
58+
designSystemId: null,
59+
pendingPrompt: null,
60+
metadata: { kind: 'prototype' },
61+
},
62+
});
63+
expect(created.ok(), `create project: ${await created.text()}`).toBeTruthy();
64+
65+
await page.goto(`/projects/${projectId}`, { waitUntil: 'domcontentloaded' });
66+
await expect(page.getByTestId('file-workspace')).toBeVisible();
67+
await openAllProjectFiles(page);
68+
await page.getByTestId('design-files-empty-new-sketch').click();
69+
await expect(page.getByTestId('sketch-excalidraw-editor')).toBeVisible();
70+
71+
await page.getByTestId('handoff-caret').click();
72+
const menu = page.getByTestId('handoff-menu');
73+
await expect(menu).toBeVisible();
74+
75+
const probe = await probeMenuOverToolbar(page);
76+
expect(probe.overlapped, 'handoff menu and sketch toolbar do not overlap — probe is vacuous').toBe(true);
77+
expect(
78+
probe.leaks,
79+
`Sketch toolbar paints over the open handoff menu at: ${JSON.stringify(probe.leaks)}`,
80+
).toEqual([]);
81+
});
82+
83+
async function probeMenuOverToolbar(page: Page) {
84+
return page.evaluate(() => {
85+
const menu = document.querySelector('[data-testid="handoff-menu"]');
86+
const toolbar =
87+
document.querySelector('.excalidraw .App-toolbar') ??
88+
document.querySelector('.excalidraw .App-toolbar-content');
89+
if (!menu || !toolbar) throw new Error('menu or excalidraw toolbar not found');
90+
const m = menu.getBoundingClientRect();
91+
const t = toolbar.getBoundingClientRect();
92+
const left = Math.max(m.left, t.left);
93+
const right = Math.min(m.right, t.right);
94+
const top = Math.max(m.top, t.top);
95+
const bottom = Math.min(m.bottom, t.bottom);
96+
if (right <= left || bottom <= top) return { overlapped: false, leaks: [] };
97+
const leaks: Array<{ x: number; y: number; hit: string }> = [];
98+
for (const fx of [0.25, 0.5, 0.75]) {
99+
for (const fy of [0.35, 0.65]) {
100+
const x = Math.round(left + (right - left) * fx);
101+
const y = Math.round(top + (bottom - top) * fy);
102+
const hit = document.elementFromPoint(x, y);
103+
if (!hit?.closest('[data-testid="handoff-menu"]')) {
104+
leaks.push({
105+
x,
106+
y,
107+
hit:
108+
hit instanceof HTMLElement
109+
? hit.className.toString().slice(0, 60) || hit.tagName
110+
: (hit?.tagName ?? 'null'),
111+
});
112+
}
113+
}
114+
}
115+
return { overlapped: true, leaks };
116+
});
117+
}

0 commit comments

Comments
 (0)