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.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 22:57:47 +02:00
1 parent 938b5151de
commit ef1976832b
3 files changed
+104 -59

No files matched your search

@@ -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(/(?<![\w-])bottom-/);
});
it("the sheet primitive names its side for those rules", () => {
expect(read("src/components/ui/sheet.tsx")).toMatch(
/data-sheet-side=\{side\}/,
+69 -58
View File
@@ -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 `<html>` 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 `<html>` 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 `<AdminShell>` and
+4 -1
View File
@@ -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}