From ef1976832b7625909944216953fa92ead0c12b23 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Bombeck?= Date: Thu, 8 Oct 2026 21:41:37 +0200 Subject: [PATCH] refactor(css): move the shell and sheet rules into the components layer Unlayered, the safe-area, keyboard and sheet-clearance rules outranked every utility on the same element, so a later bg-* on the shell or a bottom-* on a sheet would have been ignored without a trace. They now sit under the utilities. The bottom sheet drops its bottom-0, which the layered keyboard rule replaces with the same zero until the keyboard opens; the keyboard rules that must win keep their !important. The toast lift stays unlayered to outrank the toaster's own rules. --- src/__tests__/pwa-mobile-shell-guard.test.ts | 31 +++++ src/app/globals.css | 127 ++++++++++--------- src/components/ui/sheet.tsx | 5 +- 3 files changed, 104 insertions(+), 59 deletions(-) diff --git a/src/__tests__/pwa-mobile-shell-guard.test.ts b/src/__tests__/pwa-mobile-shell-guard.test.ts index 964f7df3e..d8edd594a 100644 --- a/src/__tests__/pwa-mobile-shell-guard.test.ts +++ b/src/__tests__/pwa-mobile-shell-guard.test.ts @@ -92,6 +92,37 @@ describe("the shell owns the safe area", () => { for (const cls of padded) expect(cls).toMatch(/\bkeyboard-open:pb-4\b/); }); + it("those rules sit under the utilities, and the bottom sheet sets no bottom utility", () => { + // Unlayered, the rules outranked every utility on the same element: a + // `bg-*` on the shell or a `bottom-*` on a sheet would have been ignored + // without a trace. In the components layer a utility wins, so the bottom + // sheet must not carry one that would pin it under the keyboard. + const css = code(read("src/app/globals.css")); + const start = css.indexOf("@layer components {"); + expect(start).toBeGreaterThan(-1); + let depth = 0; + let end = start; + for (let i = css.indexOf("{", start); i < css.length; i += 1) { + if (css[i] === "{") depth += 1; + else if (css[i] === "}" && --depth === 0) { + end = i; + break; + } + } + const layer = css.slice(start, end); + expect(layer).toMatch(/\.shell-safe-area\s*\{/); + expect(layer).toMatch(/\[data-sheet-side="bottom"\]\s*\{\s*bottom:/); + expect(layer).toMatch(/\[data-keyboard-hide\]\s*\{\s*display:\s*none/); + expect(layer).toMatch(/\[data-sheet-side="left"\]\s*\{\s*border-left:/); + // Sonner's own rules are unlayered; the toast lift must stay so. + expect(layer).not.toMatch(/data-sonner-toaster/); + + const sheet = code(read("src/components/ui/sheet.tsx")); + const bottomBranch = sheet.match(/side === "bottom" &&\s*"([^"]*)"/)?.[1]; + expect(bottomBranch).toBeDefined(); + expect(bottomBranch).not.toMatch(/(? { expect(read("src/components/ui/sheet.tsx")).toMatch( /data-sheet-side=\{side\}/, diff --git a/src/app/globals.css b/src/app/globals.css index fcb04514a..8006ffce5 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -594,45 +594,77 @@ } /* - * `.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. + * The shell's safe area, the on-screen keyboard and the sheet clearances sit + * in the components layer, under the utilities, so a utility on the same + * element still wins and these rules cannot silently override one. The + * bottom sheet takes its `bottom` from here alone for that reason (the + * sheet primitive sets no `bottom-*`); the keyboard rules that must beat a + * utility say `!important`, which in a layer outranks any utility. */ -.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); - background: linear-gradient(var(--card), var(--card)) top / 100% - env(safe-area-inset-top, 0px) no-repeat; -} +@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. + */ + .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); + background: linear-gradient(var(--card), var(--card)) top / 100% + env(safe-area-inset-top, 0px) no-repeat; + } -/* - * The on-screen keyboard. iOS Safari, and Chrome on Android by default, - * shrink only the visual viewport when the keyboard opens: everything - * `position: fixed` stays anchored to the layout viewport, so a bottom sheet - * keeps its footer (Save, the Coach composer) behind the keyboard and the - * bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once - * in the providers) publishes the covered height as `--keyboard-inset` and - * sets `data-keyboard` on `` while it is open. - * - * Bottom sheets then stand on the keyboard and give up the height it takes, - * so their own scroll body (every bottom sheet is a flex column with a - * scrolling middle) carries the form, and the bottom bar and the Coach - * button step aside until the keyboard is gone. - */ -[data-sheet-side="bottom"] { - bottom: var(--keyboard-inset, 0px); -} -html[data-keyboard="open"] [data-sheet-side="bottom"] { - max-height: calc( - 100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - 0.5rem - ) !important; -} -html[data-keyboard="open"] [data-keyboard-hide] { - display: none !important; + /* + * The on-screen keyboard. iOS Safari, and Chrome on Android by default, + * shrink only the visual viewport when the keyboard opens: everything + * `position: fixed` stays anchored to the layout viewport, so a bottom sheet + * keeps its footer (Save, the Coach composer) behind the keyboard and the + * bottom bar floats somewhere under it. `useKeyboardInset()` (mounted once + * in the providers) publishes the covered height as `--keyboard-inset` and + * sets `data-keyboard` on `` while it is open. + * + * Bottom sheets then stand on the keyboard and give up the height it takes, + * so their own scroll body (every bottom sheet is a flex column with a + * scrolling middle) carries the form, and the bottom bar and the Coach + * button step aside until the keyboard is gone. + */ + [data-sheet-side="bottom"] { + bottom: var(--keyboard-inset, 0px); + } + html[data-keyboard="open"] [data-sheet-side="bottom"] { + max-height: calc( + 100dvh - var(--keyboard-inset, 0px) - env(safe-area-inset-top, 0px) - + 0.5rem + ) !important; + } + html[data-keyboard="open"] [data-keyboard-hide] { + display: none !important; + } + + /* + * Side and top sheets run the full height of the window. In an installed + * app on a tablet the status bar sits over their first 24 px, and a phone + * held sideways has its notch over one side, so the panel keeps its header + * clear of both. A transparent border rather than padding: every consumer + * sets its own padding (often `p-0`), which would override a padding here, + * while the border adds the clearance under any of them and lets the + * panel's own background show through it. + */ + [data-sheet-side="right"], + [data-sheet-side="left"], + [data-sheet-side="top"] { + border-top: env(safe-area-inset-top, 0px) solid transparent; + } + [data-sheet-side="right"] { + border-right: env(safe-area-inset-right, 0px) solid transparent; + } + [data-sheet-side="left"] { + border-left: env(safe-area-inset-left, 0px) solid transparent; + } } /* @@ -650,27 +682,6 @@ html [data-sonner-toaster][data-y-position="bottom"] { } } -/* - * Side and top sheets run the full height of the window. In an installed - * app on a tablet the status bar sits over their first 24 px, and a phone - * held sideways has its notch over one side, so the panel keeps its header - * clear of both. A transparent border rather than padding: every consumer - * sets its own padding (often `p-0`), which would override a padding here, - * while the border adds the clearance under any of them and lets the - * panel's own background show through it. - */ -[data-sheet-side="right"], -[data-sheet-side="left"], -[data-sheet-side="top"] { - border-top: env(safe-area-inset-top, 0px) solid transparent; -} -[data-sheet-side="right"] { - border-right: env(safe-area-inset-right, 0px) solid transparent; -} -[data-sheet-side="left"] { - border-left: env(safe-area-inset-left, 0px) solid transparent; -} - /* * `.no-scrollbar` — keeps a container scrollable but hides the visible * bar. Used by the mobile section strips in `` and diff --git a/src/components/ui/sheet.tsx b/src/components/ui/sheet.tsx index c3816a37a..a58b0a5db 100644 --- a/src/components/ui/sheet.tsx +++ b/src/components/ui/sheet.tsx @@ -66,6 +66,9 @@ function SheetContent({ // full-height sides keep clear of the status bar and the notch. // Its own attribute rather than `data-slot`, which consumers // replace with theirs (`responsive-sheet-content`, `day-panel`). + // A bottom sheet's `bottom` comes from that rule (0 until the + // keyboard opens), so its branch below carries no `bottom-*`: a + // utility would outrank the layered rule and pin it to the edge. data-sheet-side={side} className={cn( "bg-background data-[state=open]:animate-in data-[state=closed]:animate-out fixed z-50 flex flex-col gap-4 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500", @@ -76,7 +79,7 @@ function SheetContent({ side === "top" && "data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top inset-x-0 top-0 h-auto border-b", side === "bottom" && - "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 bottom-0 h-auto border-t", + "data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom inset-x-0 h-auto border-t", className, )} {...props}