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.
This commit is contained in:
rcourtman
2026-08-19 19:53:39 +01:00
parent 66c87fb6e8
commit 54f623cffb
11 changed files with 238 additions and 40 deletions
@@ -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`.
@@ -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.
+32 -32
View File
@@ -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"
]
}
@@ -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 = /(?<![\d.])5rem\s*\+\s*env\(\s*safe-area-inset-bottom/;
function collectFrontendSources(dir: string): Array<{ path: string; source: string }> {
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([]);
});
});
@@ -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();
@@ -2265,7 +2265,7 @@ export const AIChat: Component<AIChatProps> = (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<AIChatProps> = (props) => {
<Show when={isOpen() && isOverlayLayout()}>
<button
type="button"
class="fixed inset-x-0 top-0 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-40 bg-slate-950/45 backdrop-blur-[1px]"
class="fixed inset-x-0 top-0 bottom-[var(--pulse-mobile-nav-height)] z-40 bg-slate-950/45 backdrop-blur-[1px]"
onClick={props.onClose}
aria-label="Close Pulse Assistant backdrop"
/>
@@ -116,7 +116,7 @@ export function GitHubStarBanner() {
return (
<Show when={showModal()}>
<section
class="fixed left-4 right-20 bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
class="fixed left-4 right-20 bottom-[var(--pulse-mobile-nav-height)] z-30 max-w-md overflow-hidden rounded-lg border border-border bg-surface text-base-content shadow-xl md:right-auto md:bottom-4"
aria-labelledby="github-star-title"
aria-live="polite"
>
@@ -360,3 +360,19 @@ describe('GitHubStarBanner', () => {
});
});
});
describe('GitHubStarBanner mobile navigation clearance', () => {
it('reads the published bottom navigation height', () => {
expect(gitHubStarBannerSource).toContain('bottom-[var(--pulse-mobile-nav-height)]');
});
it('does not keep its own copy of the bar height', () => {
// This banner was one of five sites hardcoding 5rem for a bar that
// measures ~45px. The bar publishes its measured height instead.
expect(gitHubStarBannerSource).not.toMatch(/5rem\s*\+\s*env\(safe-area-inset-bottom/);
});
it('keeps its own desktop placement once the bar is gone', () => {
expect(gitHubStarBannerSource).toContain('md:bottom-4');
});
});
@@ -1,4 +1,4 @@
import { For, Show } from 'solid-js';
import { For, Show, onCleanup, onMount } from 'solid-js';
import ChevronsUpDownIcon from 'lucide-solid/icons/chevrons-up-down';
import EllipsisIcon from 'lucide-solid/icons/ellipsis';
import { type MobileNavBarProps, useMobileNavBarState } from './useMobileNavBarState';
@@ -98,6 +98,45 @@ function MobileNavDestinationContent(props: {
export function MobileNavBar(props: MobileNavBarProps) {
const mobileNav = useMobileNavBarState(props);
const tabIconClass = 'h-3.5 w-3.5 shrink-0';
// This bar's height is content-driven and includes the safe-area inset via
// pb-safe, so anything that has to sit on top of it cannot hardcode a value.
// Five call sites did, all at 5rem against a bar that measures ~45px, which
// left a band below the Assistant backdrop that was neither dimmed nor
// click-blocked. Publish the measured height instead and let them read it.
// Reports 0 when the bar is hidden at xl, which is the correct clearance
// there.
let navElement: HTMLElement | undefined;
let navObserver: ResizeObserver | undefined;
const publishNavHeight = () => {
if (!navElement) return;
document.documentElement.style.setProperty(
'--pulse-mobile-nav-height',
`${navElement.offsetHeight}px`,
);
};
// Publish from onMount, not from the ref: Solid runs refs before the node is
// in the document, where offsetHeight is 0. Reading it after mount forces
// layout and yields the real height, so the first paint is already correct
// without waiting for an observer callback.
onMount(() => {
publishNavHeight();
if (typeof ResizeObserver !== 'undefined' && navElement) {
navObserver = new ResizeObserver(publishNavHeight);
navObserver.observe(navElement);
}
// The observer covers content-driven height changes, but the bar is
// xl:hidden, so crossing that breakpoint swaps between its real height and
// 0 via display alone. Observing a display:none element is the least
// consistent case across engines, so listen for resize too rather than
// leaving a stale clearance behind after a rotation or window drag.
window.addEventListener('resize', publishNavHeight);
});
onCleanup(() => {
navObserver?.disconnect();
window.removeEventListener('resize', publishNavHeight);
document.documentElement.style.removeProperty('--pulse-mobile-nav-height');
});
const overflowHasBadge = () =>
mobileNav.overflowDestinations().some(mobileNavDestinationHasBadge);
@@ -148,6 +187,7 @@ export function MobileNavBar(props: MobileNavBarProps) {
return (
<nav
ref={(element) => (navElement = element)}
class="fixed inset-x-0 bottom-0 z-40 border-t border-border bg-surface pb-safe xl:hidden"
aria-label="Mobile navigation"
>
@@ -291,3 +291,48 @@ describe('MobileNavBar', () => {
});
});
});
describe('MobileNavBar clearance publishing', () => {
const NAV_HEIGHT_VARIABLE = '--pulse-mobile-nav-height';
const renderBar = () =>
render(() => (
<MobileNavBar
activeTab={() => 'proxmox'}
primaryTabs={() => [makePrimary('proxmox', 'Proxmox')]}
utilityTabs={() => [makeUtility('alerts', 'Alerts')]}
/>
));
it('publishes its measured height so overlays can clear it', () => {
const { unmount } = renderBar();
// Anything sitting on top of this bar reads the published value rather
// than hardcoding one. Five call sites once hardcoded 5rem against a bar
// that measures ~45px, leaving a band below the Assistant backdrop that
// was neither dimmed nor click-blocked.
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).toMatch(
/^\d+(\.\d+)?px$/,
);
unmount();
});
it('stops publishing once the bar is gone', () => {
const { unmount } = renderBar();
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).not.toBe('');
unmount();
// A stale height would keep reserving space for a bar that is no longer
// rendered, so consumers must fall back to the declared default.
expect(document.documentElement.style.getPropertyValue(NAV_HEIGHT_VARIABLE)).toBe('');
});
it('measures after mount rather than from the ref', () => {
// Solid runs refs before the node is in the document, where offsetHeight
// is 0, so publishing from the ref reports a bar of no height.
expect(mobileNavBarSource).toContain('onMount(');
expect(mobileNavBarSource).not.toMatch(/ref=\{setNavRef\}/);
});
});
+17 -2
View File
@@ -428,8 +428,8 @@
@media (max-width: 79.999rem) {
.filter-bottom-nav-aware-panel {
bottom: calc(5rem + env(safe-area-inset-bottom)) !important;
max-height: calc(100dvh - 6rem - env(safe-area-inset-bottom));
bottom: var(--pulse-mobile-nav-height) !important;
max-height: calc(100dvh - var(--pulse-mobile-nav-height) - 1rem);
}
}
@@ -1159,6 +1159,21 @@ body,
display: none;
}
/*
Height of the mobile bottom navigation bar. MobileNavBar overwrites this at
runtime with its measured height, which already includes the safe-area inset
via pb-safe - consumers must NOT add env(safe-area-inset-bottom) on top.
The declared value is only a pre-measurement fallback, and it 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, which is the defect this variable exists to prevent; reserving
slightly less is harmless because the opaque bar paints over it.
*/
:root {
--pulse-mobile-nav-height: calc(2.5rem + env(safe-area-inset-bottom, 0px));
}
.pb-safe {
padding-bottom: env(safe-area-inset-bottom, 0px);
}