mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 13:22:14 +00:00
fix(shell): let the rail take the left inset and mirror its account band
An iPad in a Safari tab laid the app shell out 10 px in from the left edge in portrait. The shell padded that inset, so a strip of page colour ran beside the rail from the logo band to the account row, and the rail's band borders stopped short of the edge. The desktop shell no longer pads the sides. The rail runs to the window's edge and takes the left inset inside its own background, widening by it so its content keeps its 4rem / 16rem; the logo band, the footer group and the account band reach back over the inset so their lines meet the edge. On the right the top bar and main already run to the edge. The phone shell keeps padding both sides. The side insets are read through --safe-area-left and --safe-area-right, so a test can set one. The account band takes the logo band's height (4rem) with the avatar centred in it, instead of 61 to 69 px of padding with more room below the avatar than above; the home-indicator inset stays under the band. tablet-shell.spec.ts sets a 10 px side inset and a 20 px home indicator and checks that the rail owns the window's left column of pixels, that its band lines start at x = 0, that nothing in it sits inside the inset, and that the account band mirrors the logo band. shell-viewport.test.ts holds the routing.
This commit is contained in:
1 parent
3d47c1e6b4
commit
ccd7ee38c9
6 files changed
+268
-21
No files matched your search
@@ -610,3 +610,119 @@ for (const vp of [
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A side inset. An iPad in a Safari tab laid the shell out 10 px in from the
|
||||
* left edge in portrait, which no browser under test reports. The shell and
|
||||
* the rail read the side insets through `--safe-area-left` / `-right`
|
||||
* (globals.css), so setting them is the same inset to both.
|
||||
*/
|
||||
const SIDE_INSET = 10;
|
||||
|
||||
async function setSideInsets(page: Page, px: number) {
|
||||
await page.addStyleTag({
|
||||
content: `:root { --safe-area-left: ${px}px !important; --safe-area-right: ${px}px !important; }`,
|
||||
});
|
||||
}
|
||||
|
||||
const SIDE_INSET_VIEWPORTS = [
|
||||
{ name: "iPad Pro 11 portrait", width: 834, height: 1194 },
|
||||
{ name: "iPad Air 11 portrait", width: 820, height: 1180 },
|
||||
{ name: "iPad Air 11 landscape", width: 1180, height: 820 },
|
||||
{ name: "iPad Pro 13 landscape", width: 1366, height: 1024 },
|
||||
] as const;
|
||||
|
||||
for (const vp of SIDE_INSET_VIEWPORTS) {
|
||||
test(`${vp.name}: the rail runs to the left edge over a side inset`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: vp.width, height: vp.height });
|
||||
await openDashboard(page);
|
||||
await setSideInsets(page, SIDE_INSET);
|
||||
await setBottomInset(page, HOME_INDICATOR);
|
||||
await expectShellPinned(page);
|
||||
await expect(page.locator("aside")).toBeVisible();
|
||||
|
||||
const m = await page.evaluate(() => {
|
||||
const aside = document.querySelector("aside")!;
|
||||
const box = (el: Element | null) => {
|
||||
const r = el!.getBoundingClientRect();
|
||||
return { left: r.left, right: r.right, top: r.top, bottom: r.bottom };
|
||||
};
|
||||
// What paints a pixel: the rail, or the page behind the shell (the
|
||||
// root, the body, or one of the shell's own unpainted boxes around the
|
||||
// rail and `<main>`), or some other chrome that meets the edge.
|
||||
const owner = (x: number, y: number) => {
|
||||
const hit = document.elementFromPoint(x, y);
|
||||
if (!hit) return "nothing";
|
||||
if (hit.closest("aside")) return "rail";
|
||||
const unpainted =
|
||||
hit === document.documentElement ||
|
||||
hit === document.body ||
|
||||
(hit.id !== "main-content" &&
|
||||
hit.contains(document.getElementById("main-content")));
|
||||
return unpainted ? `page:${hit.tagName}` : "chrome";
|
||||
};
|
||||
const header = aside.querySelector('[data-slot="sidebar-header"]')!;
|
||||
const account = aside.querySelector(
|
||||
'[data-slot="sidebar-column"]',
|
||||
)!.lastElementChild!;
|
||||
const avatar = account.querySelector('[data-slot="avatar"]')!;
|
||||
const footer = aside.querySelector('[data-slot="sidebar-footer"]')!;
|
||||
const links = [...aside.querySelectorAll("nav a[href]")].map(
|
||||
(a) => a.getBoundingClientRect().left,
|
||||
);
|
||||
const headerBox = box(header);
|
||||
const accountBox = box(account);
|
||||
return {
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
aside: box(aside),
|
||||
header: headerBox,
|
||||
account: accountBox,
|
||||
avatar: box(avatar),
|
||||
logoLeft: box(header.querySelector("svg")).left,
|
||||
footerLeft: box(footer).left,
|
||||
firstLinkLeft: Math.min(...links),
|
||||
// Down the window's first column of pixels, and its last.
|
||||
leftEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
(accountBox.top + accountBox.bottom) / 2,
|
||||
innerHeight - 2,
|
||||
].map((y) => owner(1, y)),
|
||||
rightEdge: [
|
||||
(headerBox.top + headerBox.bottom) / 2,
|
||||
innerHeight / 2,
|
||||
].map((y) => owner(innerWidth - 1, y)),
|
||||
};
|
||||
});
|
||||
|
||||
// No strip: the rail paints the inset from the logo band to the bottom
|
||||
// edge, and the top bar and `<main>` (or a docked panel) meet the right
|
||||
// edge.
|
||||
expect(m.leftEdge).toEqual(["rail", "rail", "rail", "rail"]);
|
||||
expect(m.rightEdge).toEqual(["chrome", "chrome"]);
|
||||
expect(m.aside.left).toBe(0);
|
||||
// The band lines run to the edge: the logo band, the account band and
|
||||
// the footer group (whose scroll edge draws a line) start at x = 0.
|
||||
expect(m.header.left).toBe(0);
|
||||
expect(m.account.left).toBe(0);
|
||||
expect(m.footerLeft).toBe(0);
|
||||
// And everything in the rail stays clear of the inset.
|
||||
expect(m.logoLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.firstLinkLeft).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
expect(m.avatar.left).toBeGreaterThanOrEqual(SIDE_INSET);
|
||||
|
||||
// The account band mirrors the logo band: the same height, the avatar
|
||||
// centred in it, and the home indicator's inset below it.
|
||||
expect(m.account.bottom - m.account.top).toBe(
|
||||
m.header.bottom - m.header.top,
|
||||
);
|
||||
const above = m.avatar.top - m.account.top;
|
||||
const below = m.account.bottom - m.avatar.bottom;
|
||||
expect(Math.abs(above - below)).toBeLessThanOrEqual(1);
|
||||
expect(m.account.bottom).toBe(m.innerHeight - HOME_INDICATOR);
|
||||
expect(m.aside.bottom).toBe(m.innerHeight);
|
||||
});
|
||||
}
|
||||
+27
-8
@@ -631,10 +631,13 @@ body,
|
||||
* owns the shell's bottom edge (the rail, `<main>`, the bottom bar, the
|
||||
* Coach button). It is `env()` everywhere it matters; a test that needs an
|
||||
* inset no desktop browser reports sets it instead
|
||||
* (`e2e/tablet-shell.spec.ts`).
|
||||
* (`e2e/tablet-shell.spec.ts`). The side insets are read the same way, by
|
||||
* the shell and the rail (`.shell-safe-area` below).
|
||||
*/
|
||||
:root {
|
||||
--safe-area-bottom: env(safe-area-inset-bottom, 0px);
|
||||
--safe-area-left: env(safe-area-inset-left, 0px);
|
||||
--safe-area-right: env(safe-area-inset-right, 0px);
|
||||
}
|
||||
html[data-scroll="body"] {
|
||||
scrollbar-gutter: stable;
|
||||
@@ -691,18 +694,34 @@ body,
|
||||
@layer components {
|
||||
/*
|
||||
* `.shell-safe-area` — the authenticated app shell's share of the installed
|
||||
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the top
|
||||
* and side insets as padding, and the strip behind the status bar is painted
|
||||
* in the chrome colour (`--card`, the top bar and the sidebar) rather than
|
||||
* the page colour, so the status bar reads as part of the bar beneath it. A
|
||||
* zero inset makes the gradient zero tall: nothing changes in a browser tab.
|
||||
* app's safe area (see the note in `auth-shell.tsx`). The shell takes the
|
||||
* top inset as padding (and the side insets in the phone shell), and the
|
||||
* strip behind the status bar is painted in the chrome colour (`--card`,
|
||||
* the top bar and the sidebar) rather than the page colour, so the status
|
||||
* bar reads as part of the bar beneath it. A zero inset makes the gradient
|
||||
* zero tall.
|
||||
*
|
||||
* The side insets are not always zero in a browser tab: an iPad in a Safari
|
||||
* tab laid the shell out 10 px in from the left edge in portrait, with
|
||||
* Safari's own toolbar buttons moved in by the same 10 px. Padded here, that
|
||||
* inset showed the page colour in a strip left of the rail, from the logo
|
||||
* band down to the account row, and cut the rail's band borders short of the
|
||||
* edge. So the desktop shell leaves the sides to what meets them: the rail
|
||||
* takes the left inset inside its own background (`sidebar-nav.tsx`), and
|
||||
* on the right the top bar and `<main>` run to the edge with gutters wider
|
||||
* than any tablet's side inset. The phone shell has no rail and keeps them
|
||||
* here.
|
||||
*/
|
||||
.shell-safe-area {
|
||||
padding-top: env(safe-area-inset-top, 0px);
|
||||
padding-right: env(safe-area-inset-right, 0px);
|
||||
padding-left: env(safe-area-inset-left, 0px);
|
||||
padding-right: var(--safe-area-right);
|
||||
padding-left: var(--safe-area-left);
|
||||
background: linear-gradient(var(--card), var(--card)) top / 100%
|
||||
env(safe-area-inset-top, 0px) no-repeat;
|
||||
@variant shell-desktop {
|
||||
padding-right: 0;
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -12,6 +12,10 @@ import { readSource } from "@/__tests__/helpers/source-files";
|
||||
|
||||
import {
|
||||
MAIN_SCROLL_CLASS,
|
||||
RAIL_ACCOUNT_BAND,
|
||||
RAIL_WIDTH_COLLAPSED,
|
||||
RAIL_WIDTH_EXPANDED,
|
||||
SHELL_HEADER_BAND,
|
||||
SHELL_ROOT_CLASS,
|
||||
SUB_SHELL_GRID_FLOOR,
|
||||
} from "../shell-metrics";
|
||||
@@ -137,3 +141,53 @@ describe("the bottom edge is cleared in both shells", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The side insets. An iPad in a Safari tab laid the shell out 10 px in from
|
||||
* the left edge in portrait; the shell padded that inset and its strip showed
|
||||
* the page colour beside the rail, from the logo band to the account row.
|
||||
* Desktop browsers report no side inset, so `e2e/tablet-shell.spec.ts`
|
||||
* simulates one through `--safe-area-left` and measures the result; these
|
||||
* hold the decisions that route the inset to the rail.
|
||||
*/
|
||||
describe("the side insets belong to what meets them", () => {
|
||||
const css = () => readSource("src/app/globals.css", ".shell-safe-area {");
|
||||
const rail = () =>
|
||||
readSource(
|
||||
"src/components/layout/sidebar-nav.tsx",
|
||||
"export function SidebarNav",
|
||||
);
|
||||
|
||||
it("are env() behind one property each, which the shell and the rail read", () => {
|
||||
const root = readSource("src/app/globals.css", "--safe-area-left");
|
||||
expect(root).toMatch(
|
||||
/--safe-area-left:\s*env\(safe-area-inset-left,\s*0px\);/,
|
||||
);
|
||||
expect(root).toMatch(
|
||||
/--safe-area-right:\s*env\(safe-area-inset-right,\s*0px\);/,
|
||||
);
|
||||
expect(css()).not.toMatch(/padding-(?:left|right):\s*env\(/);
|
||||
});
|
||||
|
||||
it("the desktop shell pads no side, the phone shell pads both", () => {
|
||||
const block = /\.shell-safe-area\s*\{([\s\S]*?)\n \}/.exec(css())![1];
|
||||
expect(block).toMatch(/padding-left:\s*var\(--safe-area-left\);/);
|
||||
expect(block).toMatch(/padding-right:\s*var\(--safe-area-right\);/);
|
||||
expect(block).toMatch(
|
||||
/@variant shell-desktop\s*\{\s*padding-right:\s*0;\s*padding-left:\s*0;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("the rail takes the left inset inside its own background", () => {
|
||||
const source = rail();
|
||||
expect(source).toMatch(/<aside[\s\S]*?pl-\(--safe-area-left\)/);
|
||||
expect(RAIL_WIDTH_COLLAPSED).toBe("w-[calc(4rem+var(--safe-area-left))]");
|
||||
expect(RAIL_WIDTH_EXPANDED).toBe("w-[calc(16rem+var(--safe-area-left))]");
|
||||
});
|
||||
|
||||
it("the account band mirrors the logo band", () => {
|
||||
const height = (value: string) => /\bh-(\d+)\b/.exec(value)?.[1];
|
||||
expect(height(RAIL_ACCOUNT_BAND)).toBe(height(SHELL_HEADER_BAND));
|
||||
expect(RAIL_ACCOUNT_BAND).toContain("border-t");
|
||||
});
|
||||
});
|
||||
@@ -56,6 +56,7 @@ vi.mock("@/hooks/use-mounted", () => ({
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { I18nProvider } from "@/lib/i18n/context";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { RAIL_WIDTH_COLLAPSED } from "../shell-metrics";
|
||||
import {
|
||||
SIDEBAR_EXPANDED_CONTENT_WIDTH,
|
||||
SidebarCollapseToggle,
|
||||
@@ -559,7 +560,7 @@ describe("<SidebarNav> width transition never overflows the nav", () => {
|
||||
});
|
||||
mockMountedRef.value = true;
|
||||
const html = render();
|
||||
expect(asideClass(html).split(" ")).toContain("w-16");
|
||||
expect(asideClass(html).split(" ")).toContain(RAIL_WIDTH_COLLAPSED);
|
||||
expect(columnClass(html).split(" ")).not.toContain(
|
||||
SIDEBAR_EXPANDED_CONTENT_WIDTH,
|
||||
);
|
||||
|
||||
@@ -91,3 +91,52 @@ export const MAIN_SCROLL_CLASS =
|
||||
* widths in both the expanded and the collapsed rail.
|
||||
*/
|
||||
export const SHELL_HEADER_BAND = "h-16 border-b";
|
||||
|
||||
/**
|
||||
* The rail's account band, the bottom counterpart of its logo band: the same
|
||||
* height, the border on the other side, the avatar centred in it. Padded on
|
||||
* its own (`p-3`, `p-2` on short windows) it came out 61 to 69 px tall with
|
||||
* 18 px above the avatar and more below, and its divider sat at no height
|
||||
* the logo band shares. The home-indicator inset stays under the band (the
|
||||
* rail's `pb-(--safe-area-bottom)`), so the band itself never shifts.
|
||||
*/
|
||||
export const RAIL_ACCOUNT_BAND = "h-16 shrink-0 border-t";
|
||||
|
||||
/**
|
||||
* The rail's width, collapsed (4rem) and expanded (16rem), plus the window's
|
||||
* left inset. The rail runs to the window's left edge and takes that inset as
|
||||
* padding inside its own background (`pl-(--safe-area-left)` on the
|
||||
* `<aside>`), so its content keeps the 4rem / 16rem it was laid out for and
|
||||
* stays clear of the inset. The shell leaves the left side to the rail
|
||||
* (`.shell-safe-area`, globals.css): an iPad in a Safari tab laid the shell
|
||||
* out 10 px in from the left, and that strip showed the page colour beside
|
||||
* the rail. Zero inset: exactly `w-16` / `w-64`.
|
||||
*/
|
||||
export const RAIL_WIDTH_COLLAPSED = "w-[calc(4rem+var(--safe-area-left))]";
|
||||
export const RAIL_WIDTH_EXPANDED = "w-[calc(16rem+var(--safe-area-left))]";
|
||||
|
||||
/**
|
||||
* A rail band that draws a border across the rail (the logo band, the footer
|
||||
* group's scroll edge, the account band) reaches back over the left inset, so
|
||||
* its line runs to the window's edge like the top bar's does, and adds the
|
||||
* inset to its own left padding, so its content stays where it was.
|
||||
*/
|
||||
const RAIL_BAND_BLEED = "-ml-(--safe-area-left)";
|
||||
|
||||
/** The logo band's and the footer group's horizontal padding, inset included. */
|
||||
export const RAIL_BAND_INSET = {
|
||||
header: {
|
||||
collapsed: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
expanded: `${RAIL_BAND_BLEED} pr-6 pl-[calc(1.5rem+var(--safe-area-left))]`,
|
||||
},
|
||||
footer: {
|
||||
collapsed: `${RAIL_BAND_BLEED} pr-1.5 pl-[calc(0.375rem+var(--safe-area-left))]`,
|
||||
expanded: `${RAIL_BAND_BLEED} pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** The account band, collapsed (the avatar centred) and expanded. */
|
||||
export const RAIL_ACCOUNT_BAND_CLASS = {
|
||||
collapsed: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center justify-center pl-(--safe-area-left)`,
|
||||
expanded: `${RAIL_ACCOUNT_BAND} ${RAIL_BAND_BLEED} border-sidebar-border flex items-center pr-3 pl-[calc(0.75rem+var(--safe-area-left))]`,
|
||||
} as const;
|
||||
@@ -26,7 +26,13 @@ import {
|
||||
visibleNavDestinations,
|
||||
visibleUtilityDestinations,
|
||||
} from "@/components/layout/nav-model";
|
||||
import { SHELL_HEADER_BAND } from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RAIL_ACCOUNT_BAND_CLASS,
|
||||
RAIL_BAND_INSET,
|
||||
RAIL_WIDTH_COLLAPSED,
|
||||
RAIL_WIDTH_EXPANDED,
|
||||
SHELL_HEADER_BAND,
|
||||
} from "@/components/layout/shell-metrics";
|
||||
import {
|
||||
RailNavList,
|
||||
useNavOverflows,
|
||||
@@ -268,10 +274,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
if (collapsed) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"border-sidebar-border border-t p-3",
|
||||
"[@media(max-height:940px)]:p-2",
|
||||
)}
|
||||
data-slot="sidebar-account"
|
||||
className={RAIL_ACCOUNT_BAND_CLASS.collapsed}
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<DropdownMenu>
|
||||
@@ -294,8 +298,11 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="border-sidebar-border border-t p-3">
|
||||
<div className="flex items-center gap-3 px-2 py-1">
|
||||
<div
|
||||
data-slot="sidebar-account"
|
||||
className={RAIL_ACCOUNT_BAND_CLASS.expanded}
|
||||
>
|
||||
<div className="flex w-full min-w-0 items-center gap-3 px-2 py-1">
|
||||
{/* The avatar + name in the expanded footer routes straight
|
||||
into the account settings; the kebab to the right still opens
|
||||
the rest of the user menu. */}
|
||||
@@ -343,7 +350,8 @@ function SidebarUserSection({ collapsed }: { collapsed: boolean }) {
|
||||
*
|
||||
* Steps (rows are full-width, so the click target is the whole row):
|
||||
* - default: 40 px rows, 4 px apart
|
||||
* - max-height 940 px: gaps close (40 px pitch), list and user padding tighten
|
||||
* - max-height 940 px: gaps close (40 px pitch), the list padding tightens
|
||||
* (the account band keeps the logo band's height, `RAIL_ACCOUNT_BAND`)
|
||||
* - max-height 820 px: 36 px rows
|
||||
* - max-height 740 px: 32 px rows — the one step under 40 px, taken only
|
||||
* where the alternative is rows hidden behind a scroll edge
|
||||
@@ -627,8 +635,8 @@ export function SidebarNav() {
|
||||
<aside
|
||||
aria-label={t("nav.sidebar")}
|
||||
className={cn(
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? "w-16" : "w-64",
|
||||
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r pb-(--safe-area-bottom) pl-(--safe-area-left) transition-[width] duration-200 motion-reduce:transition-none",
|
||||
collapsed ? RAIL_WIDTH_COLLAPSED : RAIL_WIDTH_EXPANDED,
|
||||
)}
|
||||
>
|
||||
{/* The rail animates its width; the content does not. Expanded, this
|
||||
@@ -656,7 +664,7 @@ export function SidebarNav() {
|
||||
className={cn(
|
||||
"border-sidebar-border",
|
||||
SHELL_HEADER_BAND,
|
||||
collapsed ? "px-3" : "px-6",
|
||||
RAIL_BAND_INSET.header[collapsed ? "collapsed" : "expanded"],
|
||||
)}
|
||||
>
|
||||
<Link
|
||||
@@ -755,7 +763,7 @@ export function SidebarNav() {
|
||||
className={cn(
|
||||
RAIL_LIST_GAP,
|
||||
"pb-1",
|
||||
collapsed ? "px-1.5" : "px-3",
|
||||
RAIL_BAND_INSET.footer[collapsed ? "collapsed" : "expanded"],
|
||||
navOverflows && "border-sidebar-border border-t pt-1",
|
||||
)}
|
||||
>
|
||||
|
||||
Reference in new issue
Block a user