From 54f623cffb12aa676c1d2821b7cd4a3dbe890431 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Wed, 19 Aug 2026 19:53:39 +0100 Subject: [PATCH] fix(ui): measure the mobile nav instead of guessing its height The Assistant overlay and its backdrop reserved a hardcoded calc(5rem + env(safe-area-inset-bottom)) for a bar that measures 45px. At 375x812 the backdrop ended at y=732 while the bar starts at 767, leaving a 35px band that was neither dimmed nor click-blocked: page content showed through and stayed interactive outside an open modal. The bar's height is content-driven and already includes the safe-area inset via pb-safe, so no caller can hardcode it correctly. Five sites had tried: the Assistant panel, its backdrop, the star banner, and both halves of the .filter-bottom-nav-aware-panel rule, which also carried a derived 6rem. MobileNavBar now publishes its measured height as --pulse-mobile-nav-height and those five sites read it. Publishing happens in onMount rather than the ref, because Solid runs refs before the node is in the document where offsetHeight is 0; reading after mount forces layout and gets the real value on first paint. A ResizeObserver covers content-driven changes and a resize listener covers the xl breakpoint, where the bar swaps between its height and 0 by display alone. The declared :root value is only a pre-measurement fallback and deliberately under-estimates. Over-reserving is what produces the un-dimmed band; reserving slightly less is harmless because the opaque bar paints over it. Verified at 375x812 and 1280x800: backdrop and panel now both end at 767 against a bar top of 767, gap 0; the point 5px above the bar hit-tests to the Assistant panel rather than page content; the filter panel resolves to 45px on mobile; and the star banner's md:bottom-4 still wins at 16px. Adds the regression proofs the shape guard asks for, including an architecture test that fails if any runtime source reintroduces a literal bar height, and records the ownership boundary in the frontend-primitives and ai-runtime contracts. --- .../v6/internal/subsystems/ai-runtime.md | 10 +++ .../subsystems/frontend-primitives.md | 29 +++++++++ frontend-modern/browser-verification.json | 64 +++++++++---------- .../src/__tests__/App.architecture.test.ts | 43 +++++++++++++ .../AI/Chat/__tests__/AIChat.test.tsx | 4 +- .../src/components/AI/Chat/index.tsx | 4 +- .../src/components/GitHubStarBanner.tsx | 2 +- .../__tests__/GitHubStarBanner.test.tsx | 16 +++++ .../src/components/shared/MobileNavBar.tsx | 42 +++++++++++- .../shared/__tests__/MobileNavBar.test.tsx | 45 +++++++++++++ frontend-modern/src/index.css | 19 +++++- 11 files changed, 238 insertions(+), 40 deletions(-) diff --git a/docs/release-control/v6/internal/subsystems/ai-runtime.md b/docs/release-control/v6/internal/subsystems/ai-runtime.md index 26aa45ca0..8bd34369a 100644 --- a/docs/release-control/v6/internal/subsystems/ai-runtime.md +++ b/docs/release-control/v6/internal/subsystems/ai-runtime.md @@ -7659,3 +7659,13 @@ then owns deduplication and persistence. A submitted ID that identifies any real out-of-scope resource, an ambiguous name, a type mismatch, or a name that does not resolve inside the lifecycle scope remains rejected. No fuzzy name or ID matching is permitted at this boundary. + +The Assistant overlay clears the mobile navigation bar from the published bar +height rather than a literal of its own. In overlay layout both the panel in +`frontend-modern/src/components/AI/Chat/index.tsx` and its closing backdrop end +at `--pulse-mobile-nav-height`, so the dimmed, click-blocking backdrop reaches +the top edge of the bar with no gap. Reserving more than the bar's real height +leaves page content visible and interactive below the backdrop while the +Assistant is open, which is a modal-containment defect rather than a cosmetic +one. The bar owns that value; see the frontend-primitives contract. Proofs live +in `frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx`. diff --git a/docs/release-control/v6/internal/subsystems/frontend-primitives.md b/docs/release-control/v6/internal/subsystems/frontend-primitives.md index 5f559d56d..8e5881e4e 100644 --- a/docs/release-control/v6/internal/subsystems/frontend-primitives.md +++ b/docs/release-control/v6/internal/subsystems/frontend-primitives.md @@ -6185,3 +6185,32 @@ alert surfaces use the same typed monitoring and lifecycle vocabulary and provider-ownership presentation helper. They must not create local alert mute, archive, or removal state. The menu remains keyboard reachable, uses ordinary shared surface and border tokens, and retains usable controls at phone width. + +### The mobile navigation bar publishes its own height + +`frontend-modern/src/components/shared/MobileNavBar.tsx` is the only owner of +the bottom navigation bar's height. That height is content-driven and already +includes the safe-area inset through `pb-safe`, so no consumer may derive it. +The bar measures itself after mount, publishes the result as the +`--pulse-mobile-nav-height` custom property on the document element, keeps it +current through a resize observer and a window resize listener, and removes it +when the bar unmounts. Measurement happens in `onMount` rather than in the +element ref, because a ref runs before the node is in the document, where the +measured height is zero. + +Every surface that must sit on top of the bar reads that property: the +Assistant overlay panel and its backdrop, the global GitHub star banner, and +the `.filter-bottom-nav-aware-panel` rule in +`frontend-modern/src/index.css`. Consumers must not add +`env(safe-area-inset-bottom)` on top of the published value, and must not +reintroduce a literal bar height. The declared `:root` value is a +pre-measurement fallback only and deliberately under-estimates: reserving more +than the bar's real height leaves a band between an overlay's backdrop and the +bar that is neither dimmed nor click-blocked, while reserving slightly less is +covered by the opaque bar. Surfaces with their own placement at wider +viewports, such as the star banner's `md:bottom-4`, keep it. + +Proofs live in `frontend-modern/src/components/shared/__tests__/MobileNavBar.test.tsx`, +`frontend-modern/src/components/__tests__/GitHubStarBanner.test.tsx`, and +`frontend-modern/src/__tests__/App.architecture.test.ts`, which fails if any +runtime source reintroduces a literal bar height. diff --git a/frontend-modern/browser-verification.json b/frontend-modern/browser-verification.json index 9172d2a72..3ba3687b1 100644 --- a/frontend-modern/browser-verification.json +++ b/frontend-modern/browser-verification.json @@ -1,46 +1,46 @@ { "version": 1, - "base_sha": "4de1ede6030836d09c1e0a118c26c6413fd9f112", - "verified_at": "2026-08-19T18:37:46Z", + "base_sha": "66c87fb6e8595af47c360573bb8efa51d46492d1", + "verified_at": "2026-08-19T18:57:18Z", "result": "passed", "changed_paths": [ - "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx", - "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx", - "frontend-modern/src/components/Workloads/workloadsFilterModel.ts", - "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx", - "frontend-modern/src/components/shared/FilterBar/filterCatalog.ts", - "frontend-modern/src/components/shared/FilterButtonGroup.tsx", - "frontend-modern/src/components/shared/filterButtonGroupModel.ts", - "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx" + "frontend-modern/src/components/AI/Chat/index.tsx", + "frontend-modern/src/components/GitHubStarBanner.tsx", + "frontend-modern/src/components/shared/MobileNavBar.tsx", + "frontend-modern/src/index.css" ], "content_sha256": { - "frontend-modern/src/components/Workloads/WorkloadsFilter.tsx": "05dc8ff16997ab1f7663a13b9df71ab8219607466933a46e5f2009f96ec80562", - "frontend-modern/src/components/Workloads/WorkloadsInventorySummary.tsx": "deleted", - "frontend-modern/src/components/Workloads/workloadsFilterModel.ts": "d2b4e7e74c81ba69e7f9c4003568108f02c0dc8bc9071555a1a46bddb4af3908", - "frontend-modern/src/components/shared/FilterBar/FilterBar.tsx": "98f8573ace4b7bf5a8ad5bdf6b33e0b6a75ebaabff83f6cd947c233b5a8b9843", - "frontend-modern/src/components/shared/FilterBar/filterCatalog.ts": "b3267ab57bcdf6ba7735168608772785a8445e54ccc2c6746c5ea9ff011f98c8", - "frontend-modern/src/components/shared/FilterButtonGroup.tsx": "7661c28b8abbc2c01c37a3e96175753df7276e8d3b67a1ae8f283f8563dc787d", - "frontend-modern/src/components/shared/filterButtonGroupModel.ts": "06576097404a56f66d6ae0ef23fc913dbff11ceb5833140d00df58ad9711fe41", - "frontend-modern/src/features/proxmox/ProxmoxNodesTable.tsx": "29d2892a9b16e926cdafc35372b2ba7f7f8fe38b8099f7bb51d92319a4995f09", - "frontend-modern/src/features/proxmox/ProxmoxPageSurface.tsx": "2fd5c8176ea82f7c39dfe1fab8c8500001fd48c37b0fa17118d9febf3853ff0f" + "frontend-modern/src/components/AI/Chat/index.tsx": "67d3dc20ea1100c3047f27052c35aaa4b1e15a8d2ebeda1022fecae9c772835d", + "frontend-modern/src/components/GitHubStarBanner.tsx": "ea65775ad40dc08d54d6e08f6f3c736c545917c11e8e7fbddb71d856afc11d79", + "frontend-modern/src/components/shared/MobileNavBar.tsx": "16a6917b7fdc25d77bd9cd6760f132f08a0f13f833ede6d278237e37378549e6", + "frontend-modern/src/index.css": "1b195e474bdf5cfa33af971d1a01ed6b7e7e58eb998d823616a292c0ecc6fb1f" }, - "routes": ["/proxmox/overview", "/vmware/overview"], + "routes": [ + "/", + "/proxmox" + ], "viewports": [ - { "width": 1440, "height": 900 }, - { "width": 390, "height": 844 } + { + "width": 375, + "height": 812 + }, + { + "width": 1280, + "height": 800 + } ], "states": [ - "Proxmox type and status totals shown as centred 11px tabular numbers beside their 12px labels", - "Proxmox Nodes total and cluster/standalone topology attached to the existing table heading", - "Inventory totals hidden from both filter controls and the Nodes heading through one shared preference, then restored", - "Selected Attention filter with inline count and clear-filter action", - "Phone filter collapsed and expanded at 390px with inline totals and no horizontal page overflow", - "vSphere workload status totals rendered through the same shared filter option-count contract" + "authenticated mobile 375x812, bottom navigation mounted and measuring 45px, --pulse-mobile-nav-height published as 45px", + "authenticated mobile with the Assistant overlay open, backdrop and panel both ending at the bar's top edge", + "authenticated desktop 1280x800, bar display:none and the variable published as 0px", + "re-verified after rebase onto 66c87fb6e, which reworked the FilterBar and Proxmox estate surfaces on the same route" ], "interactions": [ - "Opened View, selected Hide for Inventory totals, confirmed workload and node/topology totals disappeared immediately, then restored Show", - "Expanded the phone Filters control and inspected both count-bearing segmented controls", - "Navigated from Proxmox to vSphere and confirmed shared workload totals beside provider-specific status labels" + "opened the Assistant overlay and measured backdrop bottom 767 against bar top 767, gap 0px, previously a 35px un-dimmed clickable band", + "hit-tested 5px above the bar and found the Assistant panel covering it rather than page content", + "probed .filter-bottom-nav-aware-panel computed bottom, resolving to 45px on mobile from the shared variable", + "probed the star banner class combination at 800px and confirmed md:bottom-4 still wins at 16px", + "dispatched a resize event and confirmed the published value republishes from 0px to 45px", + "reloaded at 1280x800 and confirmed the bar is display:none with the variable published as 0px" ] } diff --git a/frontend-modern/src/__tests__/App.architecture.test.ts b/frontend-modern/src/__tests__/App.architecture.test.ts index bae7415bb..12112db69 100644 --- a/frontend-modern/src/__tests__/App.architecture.test.ts +++ b/frontend-modern/src/__tests__/App.architecture.test.ts @@ -845,3 +845,46 @@ describe('App architecture', () => { expect(appRuntimeStateSource).not.toContain('/api/license/entitlements'); }); }); + +describe('mobile bottom navigation clearance', () => { + const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height'; + // Matches the shape every drifted copy used, spaced or not. The lookbehind + // keeps the declared 2.5rem fallback from matching its own guard. + const HARDCODED_BAR_HEIGHT = /(? { + return readdirSync(dir, { withFileTypes: true }).flatMap((entry) => { + const entryPath = join(dir, entry.name); + if (entry.isDirectory()) { + return collectFrontendSources(entryPath); + } + // Only runtime sources matter; a test may quote the old shape in order + // to assert against it, as this one does. + if (!/\.(css|ts|tsx)$/.test(entry.name)) { + return []; + } + if (entryPath.includes('__tests__') || /\.(test|spec)\./.test(entry.name)) { + return []; + } + return [{ path: entryPath, source: readFileSync(entryPath, 'utf8') }]; + }); + } + + it('declares the bar height as a single custom property', () => { + expect(appStylesSource).toContain(NAV_HEIGHT_VARIABLE); + }); + + it('keeps every consumer reading that property instead of its own copy', () => { + // The Assistant panel, its backdrop, the star banner and both halves of + // the .filter-bottom-nav-aware-panel rule each carried their own + // calc(5rem + env(safe-area-inset-bottom)) for a bar that measures ~45px. + // The overshoot left a band below the Assistant backdrop that was neither + // dimmed nor click-blocked, so page content stayed interactive outside an + // open modal. Read the published height rather than adding a sixth copy. + const offenders = collectFrontendSources(join(process.cwd(), 'src')) + .filter(({ source }) => HARDCODED_BAR_HEIGHT.test(source)) + .map(({ path }) => path.slice(process.cwd().length + 1)); + + expect(offenders).toEqual([]); + }); +}); diff --git a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx index 186dc1eec..f44c9bda1 100644 --- a/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx +++ b/frontend-modern/src/components/AI/Chat/__tests__/AIChat.test.tsx @@ -2003,12 +2003,12 @@ describe('AIChat', () => { const panel = screen.getByText('Pulse Assistant').closest('[data-layout-mode]'); expect(panel).toHaveAttribute('data-layout-mode', 'overlay'); expect(panel).toHaveClass('top-0'); - expect(panel).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]'); + expect(panel).toHaveClass('bottom-[var(--pulse-mobile-nav-height)]'); expect(panel).toHaveClass('h-auto'); expect(panel).not.toHaveClass('inset-y-0'); expect(panel).not.toHaveClass('h-full'); const backdrop = screen.getByRole('button', { name: 'Close Pulse Assistant backdrop' }); - expect(backdrop).toHaveClass('bottom-[calc(5rem+env(safe-area-inset-bottom,0px))]'); + expect(backdrop).toHaveClass('bottom-[var(--pulse-mobile-nav-height)]'); expect(backdrop).not.toHaveClass('inset-0'); expect(screen.queryByTitle('Collapse Pulse Assistant')).not.toBeInTheDocument(); expect(screen.getByLabelText('Close Pulse Assistant')).toBeInTheDocument(); diff --git a/frontend-modern/src/components/AI/Chat/index.tsx b/frontend-modern/src/components/AI/Chat/index.tsx index f37906410..5da9af3ee 100644 --- a/frontend-modern/src/components/AI/Chat/index.tsx +++ b/frontend-modern/src/components/AI/Chat/index.tsx @@ -2265,7 +2265,7 @@ export const AIChat: Component = (props) => { onCleanup(stopPanelResize); const rootClassName = createMemo(() => { if (isOverlayLayout()) { - return `fixed top-0 right-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-50 flex h-auto w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${ + return `fixed top-0 right-0 bottom-[var(--pulse-mobile-nav-height)] z-50 flex h-auto w-full flex-col bg-surface transition-transform duration-300 sm:w-[560px] sm:max-w-[calc(100vw-1rem)] ${ isOpen() ? 'translate-x-0 overflow-visible border-l border-border shadow-2xl' : 'translate-x-full overflow-hidden border-l-0' @@ -4248,7 +4248,7 @@ export const AIChat: Component = (props) => {