mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-12 14:16:12 +00:00
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:
1 parent
57423d9f03
commit
57fbbcdef8
5 files changed
+308
-5
No files matched your search
@@ -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());
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -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";
|
||||
@@ -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",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
Reference in new issue
Block a user