Give the app-chrome header band one owner

The top bar's bottom border and the sidebar logo band's bottom border draw
one horizontal line across the top of the app, and they sat a pixel apart.
Both declared h-16, so the number was never the problem. Every box in the
app is border-box: the top bar carried `h-16 border-b` on one element, which
puts the line inside the 4rem at 63 to 64 px, while the sidebar put `border-b`
on the wrapper and `h-16` on the link inside it, which puts the line after the
4rem at 64 to 65 px. Same number, different line.

The height and the border now travel together as SHELL_HEADER_BAND in
shell-metrics.ts, next to the sub-shell scroll floor that exists for the same
reason. The element carrying that class is the band; neither call site
restates a height, and the sidebar link stretches with h-full. Border colour
stays per-surface, because the two chrome surfaces are tinted apart by design
and only the geometry has to match.

Two guards. chrome-header-seam.test.tsx asserts the structural property, that
each band renders every token of the shared constant and declares no height of
its own; a second height class on either element replaces the shared one
through twMerge, so both halves fire on the same edit. The e2e spec measures
the painted result at 768, 900, 1024 and 1280 px in both the expanded sidebar
and the collapsed rail, and checks each band actually paints a border at the
same weight first, so two missing lines cannot pass as two aligned ones.
This commit is contained in:
Marc-André Bombeck committed 2026-08-02 07:12:19 +02:00
1 parent 57423d9f03
commit 57fbbcdef8
5 files changed
+308 -5

No files matched your search

+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from "./setup/test";
import { STORAGE_STATE_PATH } from "./setup/global-setup";
/**
* The bottom border of the top bar and the bottom border of the sidebar logo
* band form one horizontal line across the top of the app. The two elements
* sit in different columns of the shell flex row, so nothing in the layout
* makes them agree; both read `SHELL_HEADER_BAND`
* (`src/components/layout/shell-metrics.ts`) for the height AND the border,
* which is what keeps the line continuous.
*
* The structural half of that contract is asserted in
* `src/components/layout/__tests__/chrome-header-seam.test.tsx`. This spec
* asserts the painted result: same border bottom edge, same border width, at
* four widths spanning the sidebar's visible range, in both the expanded
* sidebar and the collapsed icon rail.
*
* The waits below settle on the two bands being VISIBLE and on the viewport
* having actually resized. Nothing here waits on a height or an offset, so a
* regression cannot hide behind the setup.
*/
// 768 px is the `md` breakpoint where the sidebar first appears; 1024 px is
// `lg`, where an unset collapse preference flips the rail to icons. Both
// boundaries and one width either side of them.
const WIDTHS = [768, 900, 1024, 1280] as const;
const SIDEBAR_COLLAPSED_KEY = "healthlog-sidebar-collapsed";
type BandGeometry = {
bottom: number;
height: number;
borderBottomWidth: number;
};
test.describe("app-chrome header seam", () => {
test.use({ storageState: STORAGE_STATE_PATH });
test.beforeEach(({}, testInfo) => {
test.skip(
testInfo.project.name === "chromium-mobile",
"the global sidebar is desktop-only (md+), so below md there is no seam",
);
});
for (const collapsed of [false, true] as const) {
test(`top bar and sidebar borders share one line (${
collapsed ? "collapsed rail" : "expanded sidebar"
})`, async ({ page }) => {
// Pin the rail state instead of letting the viewport default decide, so
// each run covers both shapes at every width.
await page.addInitScript(
([key, value]) => {
window.localStorage.setItem(key, value);
},
[SIDEBAR_COLLAPSED_KEY, String(collapsed)] as const,
);
await page.goto("/", { waitUntil: "domcontentloaded" });
const topBar = page.locator('[data-slot="top-bar"]');
const sidebarHeader = page.locator('[data-slot="sidebar-header"]');
for (const width of WIDTHS) {
await page.setViewportSize({ width, height: 900 });
// Settle on paint, not on geometry: both bands present and visible,
// the logo link inside the sidebar band rendered, and the viewport
// actually at the requested width.
await expect(topBar).toBeVisible();
await expect(sidebarHeader).toBeVisible();
await expect(sidebarHeader.locator("a[href='/']")).toBeVisible();
await expect
.poll(() => page.evaluate(() => window.innerWidth))
.toBe(width);
const seam = await page.evaluate(() => {
const read = (slot: string) => {
const el = document.querySelector(`[data-slot="${slot}"]`);
if (!el) return null;
const rect = el.getBoundingClientRect();
const style = getComputedStyle(el);
return {
bottom: rect.bottom,
height: rect.height,
borderBottomWidth: parseFloat(style.borderBottomWidth),
};
};
return {
topBar: read("top-bar"),
sidebar: read("sidebar-header"),
} as { topBar: BandGeometry | null; sidebar: BandGeometry | null };
});
const at = `at ${width}px (${collapsed ? "collapsed" : "expanded"})`;
expect(seam.topBar, `top bar missing ${at}`).not.toBeNull();
expect(seam.sidebar, `sidebar band missing ${at}`).not.toBeNull();
const top = seam.topBar!;
const side = seam.sidebar!;
// Two borders that do not exist also "line up". Prove each band
// actually paints a line, and paints it at the same weight, before
// comparing where the lines sit.
expect(
top.borderBottomWidth,
`top bar has no bottom border ${at}`,
).toBeGreaterThan(0);
expect(
side.borderBottomWidth,
`sidebar band has no bottom border ${at}`,
).toBeGreaterThan(0);
expect(
side.borderBottomWidth,
`the two borders are drawn at different weights ${at}`,
).toBeCloseTo(top.borderBottomWidth, 1);
// The bands are collapsed neither to zero nor to a sliver.
expect(top.height, `top bar has no height ${at}`).toBeGreaterThan(32);
expect(side.height, `sidebar band has no height ${at}`).toBeGreaterThan(
32,
);
// The seam itself: the bottom border sits on the band's border-box
// bottom edge, so equal bottoms means one continuous line.
expect(
Math.abs(side.bottom - top.bottom),
`header seam steps by ${(side.bottom - top.bottom).toFixed(
2,
)}px ${at}`,
).toBeLessThan(1);
}
});
}
});
@@ -0,0 +1,123 @@
/**
* The top bar and the sidebar logo band draw one continuous line across the
* top of the app. They are siblings in different columns of the shell flex
* row, so nothing in the layout makes them agree: the only thing that does is
* that both read `SHELL_HEADER_BAND` for their height AND their bottom
* border.
*
* This suite asserts the structural property, which is stronger than "the two
* borders happen to land on the same y today": each band carries every token
* of the shared constant, and neither band declares a height of its own. A
* second height class on either element replaces the shared one through
* `twMerge`, so both halves of the assertion fire on the same mutation.
*
* The painted result is measured in `e2e/chrome-header-seam.spec.ts`.
*/
import { describe, expect, it, vi } from "vitest";
import { renderToStaticMarkup } from "react-dom/server";
vi.mock("next/navigation", () => ({
usePathname: () => "/",
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
}));
vi.mock("@/hooks/use-auth", () => ({
useAuth: () => ({
user: {
id: "u1",
username: "testuser",
email: "user@example.com",
role: "USER" as const,
avatarUrl: null,
modules: {} as Record<string, boolean>,
},
isAuthenticated: true,
isLoading: false,
refetch: vi.fn(),
}),
useLogout: () => ({ mutate: vi.fn(), isPending: false }),
}));
vi.mock("@/components/providers", () => ({
useTheme: () => ({
theme: "system" as const,
resolvedTheme: "dark" as const,
setTheme: vi.fn(),
}),
}));
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { I18nProvider } from "@/lib/i18n/context";
import { SHELL_HEADER_BAND } from "../shell-metrics";
import { SidebarNav } from "../sidebar-nav";
import { TopBar } from "../top-bar";
function render(node: React.ReactElement): string {
return renderToStaticMarkup(
<QueryClientProvider client={new QueryClient()}>
<I18nProvider initialLocale="en">{node}</I18nProvider>
</QueryClientProvider>,
);
}
/**
* Pull the class list off the element carrying `data-slot="<slot>"`. The
* pattern spans the whole opening tag, so it holds whichever side of
* `data-slot` React emits the class attribute on.
*/
function classTokens(html: string, slot: string): string[] {
const tag = new RegExp(`<[a-z]+[^>]*\\bdata-slot="${slot}"[^>]*>`, "i").exec(
html,
);
expect(
tag,
`no element with data-slot="${slot}" in the rendered markup`,
).not.toBeNull();
const cls = /\bclass="([^"]*)"/.exec(tag![0]);
expect(
cls,
`the data-slot="${slot}" element carries no class attribute`,
).not.toBeNull();
return cls![1].split(/\s+/).filter(Boolean);
}
const isHeightToken = (t: string) =>
/^(?:(?:sm|md|lg|xl|2xl):)?(?:min-|max-)?h-/.test(t);
const BAND_TOKENS = SHELL_HEADER_BAND.split(/\s+/).filter(Boolean);
const BAND_HEIGHT_TOKENS = BAND_TOKENS.filter(isHeightToken);
const BANDS: ReadonlyArray<[string, string, () => string]> = [
["top bar", "top-bar", () => render(<TopBar />)],
["sidebar logo band", "sidebar-header", () => render(<SidebarNav />)],
];
describe("app-chrome header seam", () => {
it("SHELL_HEADER_BAND declares both a height and a bottom border", () => {
// The whole point of the constant is that the border ships INSIDE the
// 4rem box. A constant that carried only the height would let the two
// call sites put the line on different elements again.
expect(BAND_HEIGHT_TOKENS.length).toBeGreaterThan(0);
expect(BAND_TOKENS).toContain("border-b");
});
for (const [label, slot, renderBand] of BANDS) {
it(`${label} takes its height and border from SHELL_HEADER_BAND`, () => {
const tokens = classTokens(renderBand(), slot);
for (const token of BAND_TOKENS) {
expect(
tokens,
`the ${label} dropped "${token}" from the shared band`,
).toContain(token);
}
});
it(`${label} declares no height of its own`, () => {
const tokens = classTokens(renderBand(), slot);
expect(
tokens.filter(isHeightToken).sort(),
`the ${label} carries a height class that is not the shared band's`,
).toEqual([...BAND_HEIGHT_TOKENS].sort());
});
}
});
+28
View File
@@ -27,3 +27,31 @@
* scope for the shell-only over-scroll fix.)
*/
export const SUB_SHELL_GRID_FLOOR = "md:min-h-[calc(100dvh-10.5rem)]";
/**
* The app-chrome header band: one 4rem strip whose bottom border draws the
* single horizontal line across the top of the app.
*
* Two elements paint that line. The top bar (`top-bar.tsx`) draws it over the
* content column, the sidebar logo band (`sidebar-nav.tsx`) draws it over the
* rail, and the two live in different columns of the shell flex row, so
* nothing in the layout makes them agree. Each used to declare `h-16` on its
* own, and the two lines still landed a pixel apart: every box in the app is
* `border-box`, so `h-16 border-b` on ONE element puts the line inside the
* 4rem (63 to 64 px) while `h-16` on a child of a `border-b` wrapper puts it
* after the 4rem (64 to 65 px). Same number, different line.
*
* The height and the border therefore travel together, from here. The element
* that carries this class IS the band; neither call site restates a height,
* and anything nested inside stretches with `h-full`.
*
* Border COLOUR stays per-surface (`border-border` on the top bar,
* `border-sidebar-border` on the rail): the two chrome surfaces are tinted
* apart by design and only the geometry has to match.
*
* Guards: `chrome-header-seam.test.tsx` asserts both elements render this
* exact class and declare no height of their own;
* `e2e/chrome-header-seam.spec.ts` measures the two painted borders at four
* widths in both the expanded and the collapsed rail.
*/
export const SHELL_HEADER_BAND = "h-16 border-b";
+9 -3
View File
@@ -22,6 +22,7 @@ import {
visibleNavDestinations,
visibleUtilityDestinations,
} from "@/components/layout/nav-model";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { useAuth, useLogout } from "@/hooks/use-auth";
@@ -435,17 +436,22 @@ export function SidebarNav() {
collapsed ? "w-16" : "w-64",
)}
>
{/* Logo */}
{/* Logo band. Height + bottom border come from `SHELL_HEADER_BAND`,
which the top bar reads too, so the two borders draw one
continuous line. The band owns the 4rem; the link inside
stretches with `h-full` and never restates a height. */}
<div
data-slot="sidebar-header"
className={cn(
"border-sidebar-border border-b",
"border-sidebar-border",
SHELL_HEADER_BAND,
collapsed ? "px-3" : "px-6",
)}
>
<Link
href="/"
className={cn(
"flex h-16 items-center",
"flex h-full items-center",
collapsed ? "justify-center px-0" : "gap-2",
)}
>
+13 -2
View File
@@ -11,6 +11,8 @@ import {
Shield,
Sun,
} from "lucide-react";
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
import { cn } from "@/lib/utils";
import { Logo } from "@/components/ui/logo";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Skeleton } from "@/components/ui/skeleton";
@@ -56,12 +58,21 @@ export function TopBar() {
return (
<header
className="bg-card/80 border-border sticky top-0 z-40 flex h-16 items-center justify-between border-b px-4 backdrop-blur-md md:px-6"
data-slot="top-bar"
// Height + bottom border come from `SHELL_HEADER_BAND`, which the
// sidebar logo band reads too, so the two borders draw one
// continuous line. Never restate a height here.
className={cn(
"bg-card/80 border-border sticky top-0 z-40 flex items-center justify-between px-4 backdrop-blur-md md:px-6",
SHELL_HEADER_BAND,
)}
// iOS PWA on notched iPhones overlays the status bar onto the
// sticky header unless we reserve the safe-area inset. The
// inline style adds `safe-area-inset-top` as padding-top on
// devices that report one and is a no-op on every other
// platform; the inner h-16 content area stays nominally 64 px.
// platform. The band stays 4rem tall either way (border-box), so
// on a device that reports an inset the content area inside it
// gives up those pixels.
style={{ paddingTop: "env(safe-area-inset-top, 0px)" }}
>
{/* Mobile logo */}