Skip to content

Commit 1e70fb5

Browse files
bloveclaude
andcommitted
fix(website): bridge the dead zone between a nav trigger and its panel
The desktop nav's panel shell hung off `top: 100%` of the nav row, so the row's own `py-4 md:py-5` padding sat between the trigger's bottom edge and the panel's top edge belonging to neither element. Crossing it fired mouseleave on .nav-desktop and scheduled a close; only arriving at the panel could cancel it. A traverse slower than the 150ms grace therefore dismissed the panel mid-move and left the pointer over empty space with nothing left to fire mouseenter on — the panel was gone and did not come back. That is a real defect for anyone moving their mouse deliberately, and it turned main red: nav-panels.spec.ts failed all three attempts on CI while passing on faster local hardware. The fix removes the gap rather than lengthening the timeout. A longer grace would only move the threshold and stay broken for a slower movement. Instead .nav-panel-shell starts its box at the trigger row's bottom edge and pays the difference back as transparent top padding, so the band is part of the shell and mouseenter fires the instant the pointer leaves the trigger. The visible .nav-panel does not move: measured at y=80 on marketing and y=57 on docs both before and after. The new --nav-row-pad tracks the row's real padding (16px, 20px from md, and a flat 16px inside docs, which flattens the step). .nav-desktop also gains `align-self: stretch`. The row is items-center, so on docs — where the 25px logo sets the content height — that box floated 2.5px above where the bridge begins, leaving a smaller copy of the same dead zone. Stretching pins the two hit areas together and moves nothing, since the links stay centred by the element's own items-center. The test now crosses the band deliberately slowly — three stops of 120ms, past CLOSE_DELAY_MS — so it asserts the bridge rather than the speed of the machine it runs on. Verified by reverting the CSS: the strengthened test fails against the old geometry and passes against the new. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 03b5796 commit 1e70fb5

2 files changed

Lines changed: 70 additions & 5 deletions

File tree

apps/website/e2e/nav-panels.spec.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -102,13 +102,43 @@ test.describe('desktop nav panels', () => {
102102
);
103103
await expect(page.locator('.nav-panel')).toBeVisible({ timeout: 2000 });
104104

105-
// The dead zone between the trigger row and the panel belongs to neither
106-
// element; crossing it schedules a close that entering the panel must cancel.
105+
// Read the resting geometry: the entrance animation translates the panel
106+
// by 4px, so an un-settled box would move the waypoints below.
107+
await page.locator('.nav-panel').evaluate((el) =>
108+
Promise.all(el.getAnimations().map((animation) => animation.finished)),
109+
);
110+
const rowBox = await page.locator('.nav-desktop').boundingBox();
111+
const panelBox = await page.locator('.nav-panel').boundingBox();
107112
const itemBox = await page
108113
.locator('.nav-panel .nav-panel-item')
109114
.first()
110115
.boundingBox();
111-
if (!itemBox) throw new Error('Panel item has no box');
116+
if (!rowBox || !panelBox || !itemBox)
117+
throw new Error('Nav geometry has no box');
118+
119+
// The band between the trigger row's bottom edge and the panel's top edge
120+
// is the row's own `py-4 md:py-5` padding. It is real space the pointer
121+
// has to cross, and it used to belong to neither element: leaving the row
122+
// scheduled a close, and only arriving at the panel could cancel it.
123+
const gapTop = rowBox.y + rowBox.height;
124+
const gapBottom = panelBox.y;
125+
expect(gapBottom).toBeGreaterThan(gapTop);
126+
127+
// Cross that band DELIBERATELY SLOWLY — three stops of 120ms, ~360ms in
128+
// total, comfortably past NavDesktop's CLOSE_DELAY_MS of 150ms. A quick
129+
// traverse merely outruns the close timer, so it passes on fast hardware
130+
// whether or not the gap is bridged (that is how this shipped red on CI
131+
// and green locally). Dwelling longer than the grace asserts the thing
132+
// that actually keeps the panel open: .nav-panel-shell's transparent
133+
// top padding makes the band part of the shell, so the pointer never
134+
// leaves the panel's own subtree and no close is ever scheduled.
135+
const x = triggerBox.x + triggerBox.width / 2;
136+
for (const y of [gapTop + 1, (gapTop + gapBottom) / 2, gapBottom - 1]) {
137+
await page.mouse.move(x, y, { steps: 5 });
138+
await page.waitForTimeout(120);
139+
}
140+
await expect(page.locator('.nav-panel')).toBeVisible();
141+
112142
await page.mouse.move(
113143
itemBox.x + itemBox.width / 2,
114144
itemBox.y + itemBox.height / 2,

apps/website/src/styles/chrome.css

Lines changed: 37 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,22 @@
2525
* the docs column, and the mobile drawer (top: nav-h - 1px) hung 14px below the
2626
* nav it is supposed to be attached to. These are measured, not derived, so
2727
* only a real browser can hold them honest: e2e/nav-height.spec.ts asserts
28-
* nav.height === --nav-h at each of the three steps. */
28+
* nav.height === --nav-h at each of the three steps.
29+
*
30+
* --nav-row-pad is that same row's block padding on its own — the `py-4
31+
* md:py-5` half of `px-6 py-4 md:px-8 md:py-5`. It has only TWO steps, not
32+
* three: padding grows at md and never again, so the lg entry below is
33+
* deliberately absent and 20px carries through. .nav-panel-shell reads it to
34+
* bridge the gap between a trigger and its panel (see the note there), so a
35+
* wrong value here is a dead zone, not a cosmetic drift. */
2936
:root {
3037
--nav-h: 58px;
38+
--nav-row-pad: 16px;
3139
}
3240
@media (min-width: 768px) {
3341
:root {
3442
--nav-h: 66px;
43+
--nav-row-pad: 20px;
3544
}
3645
}
3746
@media (min-width: 1024px) {
@@ -52,6 +61,12 @@
5261
* marketing bar. */
5362
:root:has(.nav-bar[data-route='docs']) {
5463
--nav-h: 58px;
64+
/* Docs pins the row's padding to a flat 16px at every width (see the
65+
* `.nav-bar[data-route='docs'] > div` rule below), so the bridge has to
66+
* shrink with it. Left at the marketing 20px the shell would start 4px
67+
* ABOVE the trigger row's bottom edge and lie over the triggers, swallowing
68+
* the mouseenter that switches from one panel to the next. */
69+
--nav-row-pad: 16px;
5570
}
5671

5772
/* Footer */
@@ -399,7 +414,15 @@
399414
}
400415
.nav-panel-shell {
401416
position: absolute;
402-
top: 100%;
417+
/* Hit area starts at the trigger's bottom edge, not the row's. The row's
418+
* py-4/md:py-5 padding used to sit between trigger and panel belonging to
419+
* neither, so a traverse slower than CLOSE_DELAY_MS closed the panel and
420+
* left the pointer over nothing that could reopen it. The padding below is
421+
* a transparent bridge: it makes the gap part of the shell, so mouseenter
422+
* fires the instant the pointer leaves the trigger. The visible .nav-panel
423+
* does not move. e2e/nav-panels.spec.ts drives this with a stepped move. */
424+
top: calc(100% - var(--nav-row-pad));
425+
padding-top: var(--nav-row-pad);
403426
left: 0;
404427
right: 0;
405428
z-index: 60;
@@ -513,6 +536,18 @@
513536
* here — keeping .nav-panel-shell's containing block on .nav-bar > div below. */
514537
.nav-desktop {
515538
position: static;
539+
/* The row is `items-center`, so this box would otherwise shrink to its own
540+
* tallest child and float in the middle of the row's content. On docs that
541+
* left 2.5px between its bottom edge and where .nav-panel-shell's bridge
542+
* starts — a second, smaller version of the dead zone the bridge exists to
543+
* close, since the row's content height there is set by the 25px logo, not
544+
* by these 20px links. Stretching pins the bottom edge to the content box
545+
* so the two hit areas meet exactly. Children stay centred by this
546+
* element's own `items-center`, so nothing moves: measured on /docs, the
547+
* panel still rests at y=57 and the bar still measures 58px. On marketing
548+
* the 40px CTA already made this box the full content height, so there
549+
* stretching is a no-op. */
550+
align-self: stretch;
516551
}
517552
.nav-bar > div {
518553
position: relative;

0 commit comments

Comments
 (0)