From 92acd23379be349e42a718197c860cf99260b037 Mon Sep 17 00:00:00 2001 From: obasilakis Date: Mon, 27 Jul 2026 15:23:44 +0300 Subject: [PATCH 01/25] fix(ui): stop the nav controls overlapping the Enterprise link (#1789) (#1802) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NavBar is one `flex justify-between` row with two children. Neither set `min-w-0`, and a flex item defaults to `min-width: auto` — so once logo + links + controls exceeded the `max-w-7xl` cap the clusters stopped compressing and overflowed into each other, which `justify-between` parked mid-bar: the connection indicator landed on top of `Enterprise PRO`, and the PRO badge and version chip wrapped inside a fixed 64px bar. Because the container is capped at 1280px, a wider monitor never helped — measured 28px of overlap at every width from 1280 to 1920. Below ~1180px it was worse than cosmetic: the controls ran to x≈1170 while `documentElement.scrollWidth` stayed at the viewport width, so at 1024px and narrower the theme toggle and the user menu — the only route to Sign out — were unreachable (`elementFromPoint` at the avatar returned null). The trigger is link count: entitled builds render `Sessions` and `Enterprise` on top of the five OSS links, pushing the row past the cap. - `min-w-0` on the link cluster so it can actually shrink, and `overflow-x-auto` so an over-wide link set scrolls instead of spilling over the controls; links get `flex-shrink-0 whitespace-nowrap` so they keep their natural width and the PRO badge can't wrap. - `flex-shrink-0` on the control cluster — docs, theme and the user menu are never compressed or pushed off-screen; the link row absorbs the pressure instead. - Reclaim the ~150px that keeps a 7-link entitled build scroll-free at the capped width: link gaps tighten below `xl`, the version chip hides below `xl` (same data is in the Build Info modal and Settings), and the connection status becomes dot-only with a tooltip + sr-only label, pulsing when disconnected. - `gap-4` between clusters so a clipped link never butts the status dot. Verified against a live stack at 1920/1600/1440/1366/1280/1180/1024/900/ 768/640: zero link-vs-control overlap, controls fully on-screen and hit-testable, bar stays 64px, and no scrolling at 1024 and above. Closes #1789 --- src/frontend/e2e/navbar-overflow.spec.js | 150 +++++++++++++++++++++++ src/frontend/src/components/NavBar.vue | 84 ++++++++++--- 2 files changed, 217 insertions(+), 17 deletions(-) create mode 100644 src/frontend/e2e/navbar-overflow.spec.js diff --git a/src/frontend/e2e/navbar-overflow.spec.js b/src/frontend/e2e/navbar-overflow.spec.js new file mode 100644 index 000000000..57d7efaa6 --- /dev/null +++ b/src/frontend/e2e/navbar-overflow.spec.js @@ -0,0 +1,150 @@ +import { test, expect } from '@playwright/test' + +/** + * Top nav overflow (#1789). + * + * NavBar.vue is one `flex justify-between` row with two children: the logo + + * router-link row on the left, the controls (connection dot, version chip, + * docs, theme toggle, user menu) on the right. Neither child used to set + * `min-w-0`, and a flex item defaults to `min-width: auto` — so once the two + * clusters' natural widths exceeded the `max-w-7xl` cap they stopped + * compressing and overflowed INTO each other, which `justify-between` parked + * in the middle of the bar. On an entitled build (7 links, `Sessions` + + * `Enterprise` both present) that put the connection indicator on top of the + * `Enterprise PRO` link at EVERY viewport width, and below ~1180px it pushed + * the theme toggle and the user menu — the only route to Sign out — past the + * viewport edge with no horizontal scroll to recover them. + * + * These tests pin the three properties the fix guarantees, none of which + * depend on how many links the build renders: + * 1. no link ever visually collides with a control, + * 2. the controls stay fully on-screen and hit-testable, + * 3. links that don't fit stay reachable via a scroll container rather than + * being clipped dead or overlapping. + * + * Widths ≥1280 are the entitled-build regression (the container is capped at + * `max-w-7xl`, so "just use a wider monitor" never fixed it); the narrow + * widths keep the spec meaningful on an OSS build too, where 5 links only + * exhaust the bar once the viewport is small. + */ + +// Viewports: the two laptop widths where the bug was worst, the cap boundary, +// and down to the `sm` breakpoint where the link row first appears. +const WIDTHS = [1920, 1600, 1440, 1366, 1280, 1180, 1024, 900, 768, 640] + +// Geometry of the nav, measured the way a user perceives it: link boxes are +// clipped to the scroll row's visible box first, because a link scrolled past +// the boundary still reports full un-clipped `getBoundingClientRect()` +// geometry while being invisible on screen. +async function navGeometry(page) { + return page.evaluate(() => { + const row = document.querySelector('nav .flex.justify-between') + const [left, right] = row.children + const linkRow = left.children[1] + const clip = linkRow.getBoundingClientRect() + + const controls = [...right.children].map((c) => c.getBoundingClientRect()) + let worstCollision = 0 + for (const raw of left.querySelectorAll('a')) { + const b = raw.getBoundingClientRect() + const l = Math.max(b.left, clip.left) + const r = Math.min(b.right, clip.right) + if (r <= l) continue // fully scrolled out of view + for (const c of controls) { + worstCollision = Math.max(worstCollision, Math.min(r, c.right) - Math.max(l, c.left)) + } + } + + const menu = right.lastElementChild + const menuBox = menu.getBoundingClientRect() + const hit = document.elementFromPoint( + menuBox.left + menuBox.width / 2, + menuBox.top + menuBox.height / 2 + ) + + return { + worstCollision: Math.max(0, worstCollision), + controlsRightEdge: Math.max(...controls.map((c) => c.right)), + controlsLeftEdge: Math.min(...controls.map((c) => c.left)), + userMenuHitTestable: !!(hit && menu.contains(hit)), + navHeight: row.getBoundingClientRect().height, + linkRowOverflowX: getComputedStyle(linkRow).overflowX, + linkRowScrollWidth: linkRow.scrollWidth, + linkRowClientWidth: linkRow.clientWidth, + linkCount: left.querySelectorAll('a').length, + } + }) +} + +test.describe('NavBar overflow (#1789)', () => { + test('@smoke nav links never collide with the right-hand controls', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible({ + timeout: 15000, + }) + + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + const g = await navGeometry(page) + + // The regression itself: a visible link box overlapping a control box. + expect(g.worstCollision, `link/control overlap at ${width}px`).toBe(0) + + // The bar must not grow a second line — the pre-fix symptom was the + // `PRO` badge and version chip wrapping inside a fixed 64px bar. + expect(g.navHeight, `nav height at ${width}px`).toBe(64) + } + }) + + test('@smoke controls stay on-screen and clickable at every width', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible({ + timeout: 15000, + }) + + for (const width of WIDTHS) { + await page.setViewportSize({ width, height: 900 }) + const g = await navGeometry(page) + + // Pre-fix the cluster ran to x≈1170 regardless of viewport, and + // documentElement.scrollWidth stayed at the viewport width — so the + // overhang was unreachable rather than merely off to the side. + expect(g.controlsRightEdge, `controls right edge at ${width}px`).toBeLessThanOrEqual(width) + expect(g.controlsLeftEdge, `controls left edge at ${width}px`).toBeGreaterThanOrEqual(0) + + // The user menu owns Sign out. `elementFromPoint` at its centre is the + // honest check — a box inside the viewport that something else covers + // is still not clickable. + expect(g.userMenuHitTestable, `user menu hit-testable at ${width}px`).toBe(true) + } + + // And it genuinely opens at the width where it used to be unreachable. + await page.setViewportSize({ width: 900, height: 900 }) + await page.locator('nav .flex.justify-between > div:last-child > div:last-child button').click() + await expect(page.getByRole('button', { name: /sign out/i })).toBeVisible({ timeout: 5000 }) + }) + + test('@smoke overflowing links stay reachable via the scroll row', async ({ page }) => { + await page.goto('/') + await expect(page.getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible({ + timeout: 15000, + }) + + // The mechanism that replaces overlap: the link row is a scroll container, + // so a link set too wide for the bar scrolls instead of spilling over the + // controls. Guards against a future refactor dropping `overflow-x-auto`. + await page.setViewportSize({ width: 1440, height: 900 }) + const wide = await navGeometry(page) + expect(wide.linkRowOverflowX).toBe('auto') + + // Squeeze until the row must overflow, then prove the last link can be + // brought into view — clipped-but-scrollable, not clipped-dead. + await page.setViewportSize({ width: 640, height: 900 }) + const narrow = await navGeometry(page) + expect(narrow.linkRowScrollWidth).toBeGreaterThan(narrow.linkRowClientWidth) + + const lastLink = page.locator('nav .flex.justify-between > div:first-child > div:last-child a').last() + await lastLink.scrollIntoViewIfNeeded() + await expect(lastLink).toBeInViewport() + }) +}) diff --git a/src/frontend/src/components/NavBar.vue b/src/frontend/src/components/NavBar.vue index cbd781d67..07bd4f88d 100644 --- a/src/frontend/src/components/NavBar.vue +++ b/src/frontend/src/components/NavBar.vue @@ -1,31 +1,45 @@