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) => {