From ccd7ee38c9ed53eb3c04dc8608336663a4946b88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Sat, 24 Oct 2026 11:00:56 +0200 Subject: [PATCH 1/2] 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. --- e2e/tablet-shell.spec.ts | 116 ++++++++++++++++++ src/app/globals.css | 35 ++++-- .../layout/__tests__/shell-viewport.test.ts | 54 ++++++++ .../layout/__tests__/sidebar-nav.test.tsx | 3 +- src/components/layout/shell-metrics.ts | 49 ++++++++ src/components/layout/sidebar-nav.tsx | 32 +++-- 6 files changed, 268 insertions(+), 21 deletions(-) diff --git a/e2e/tablet-shell.spec.ts b/e2e/tablet-shell.spec.ts index 66d3fc6fe..905baba36 100644 --- a/e2e/tablet-shell.spec.ts +++ b/e2e/tablet-shell.spec.ts @@ -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 `
`), 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 `
` (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); + }); +} diff --git a/src/app/globals.css b/src/app/globals.css index b8dcc6df5..979d6311e 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -631,10 +631,13 @@ body, * owns the shell's bottom edge (the rail, `
`, 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 `
` 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; + } } /* diff --git a/src/components/layout/__tests__/shell-viewport.test.ts b/src/components/layout/__tests__/shell-viewport.test.ts index 28e5ebfa7..76778853f 100644 --- a/src/components/layout/__tests__/shell-viewport.test.ts +++ b/src/components/layout/__tests__/shell-viewport.test.ts @@ -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(/ { + 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"); + }); +}); diff --git a/src/components/layout/__tests__/sidebar-nav.test.tsx b/src/components/layout/__tests__/sidebar-nav.test.tsx index 6243311c0..c6d519608 100644 --- a/src/components/layout/__tests__/sidebar-nav.test.tsx +++ b/src/components/layout/__tests__/sidebar-nav.test.tsx @@ -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(" 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, ); diff --git a/src/components/layout/shell-metrics.ts b/src/components/layout/shell-metrics.ts index fba668a0e..c415c51d1 100644 --- a/src/components/layout/shell-metrics.ts +++ b/src/components/layout/shell-metrics.ts @@ -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 + * `