Keep the phone shell on a phone held sideways

A phone in landscape is 844 to 932 px wide, past md, so it got the
desktop rail and top bar: a sliver of content and no thumb-reachable
navigation. Two variants now decide the shell: shell-desktop from md up
except on a touch screen at most 520 px tall, shell-mobile otherwise.
The sidebar, top bar, bottom nav, the main column's bottom clearance,
the Coach button, the selection bar and the two full-height Coach pages
all key on them instead of a bare md.
This commit is contained in:
Marc-André Bombeck committed 2026-10-08 18:36:45 +02:00
1 parent a0c5ab6591
commit b53782810f
10 files changed
+42 -13

No files matched your search

+1 -1
View File
@@ -331,7 +331,7 @@ export default function CoachConversationsPage() {
// ~50px on notched devices; restored to match exactly. v1.42 — the
// shell now takes that inset above the top bar at every width, so
// the desktop height subtracts it too, and `/coach/plans` matches.
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
className="bg-background shell-desktop:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))] -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6"
>
<CoachConversationsBody readOnly={!coach.available} />
</div>
+1 -1
View File
@@ -296,7 +296,7 @@ export default function CoachPlansPage() {
// mobile-only BottomNav band). The status-bar inset of an installed
// app comes off as well, at every width: the shell takes it above
// the top bar (`shell-safe-area`).
className="bg-background -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6 md:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))]"
className="bg-background shell-desktop:h-[calc(100dvh-4rem-env(safe-area-inset-top,0px))] -mx-4 -mt-6 -mb-20 flex h-[calc(100dvh-8rem-env(safe-area-inset-top,0px)-env(safe-area-inset-bottom,0px))] min-h-[32rem] flex-col overflow-hidden md:-mx-6"
>
<CoachPlansBody readOnly={!coach.available} />
</div>
+24
View File
@@ -58,6 +58,30 @@
}
}
/*
* `shell-desktop:` / `shell-mobile:` — which app shell the viewport gets.
* The desktop rail (sidebar, no bottom nav) from `md` up, EXCEPT a touch
* screen at most 520 px tall: a phone held sideways is 844–932 px wide but
* only 390–430 px tall, and the rail plus the top bar left it a sliver of
* content and no thumb-reachable navigation. It keeps the phone shell (top
* bar, bottom nav, capture button) instead. Everything that switches
* between the two shells keys on these, never on a bare `md:`, so the bars
* cannot disagree about which shell is showing.
*/
@custom-variant shell-desktop {
@media (min-width: 48rem) and (pointer: fine),
(min-width: 48rem) and (pointer: none),
(min-width: 48rem) and (min-height: 521px) {
@slot;
}
}
@custom-variant shell-mobile {
@media (max-width: 47.98rem), (pointer: coarse) and (max-height: 520px) {
@slot;
}
}
@theme inline {
/* Extra-small breakpoint (30rem / 480px) for the narrowest phones — the
default scale starts at sm (640px), which leaves a gap between an iPhone SE
@@ -82,7 +82,7 @@ export function SelectionActionBar({
// an `sm:` drop sat the bar behind the still-visible nav 640-767px.
// z-30 keeps the bar above page content without needing to beat the
// (now-cleared) nav's z-50.
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg md:bottom-4"
className="bg-card border-border sticky bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] z-30 mt-2 flex items-center justify-between gap-2 rounded-lg border p-2 shadow-lg shell-desktop:bottom-4"
>
<div className="flex items-center gap-1">
<Button
@@ -81,8 +81,10 @@ describe("<LayoutCoachFab>", () => {
expect(html).toContain(
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)]",
);
// The bottom offset follows the shell, not a width: a phone held
// sideways keeps the bottom nav, so the FAB keeps clearing it there.
expect(html).toContain(
"md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
"shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
);
expect(html).toContain('data-keyboard-hide=""');
expect(html).not.toContain("md:bottom-6");
+1 -1
View File
@@ -246,7 +246,7 @@ export function LayoutCoachFab() {
// phone held sideways (844 px, past `md`) has its notch on one
// side, and an installed iPad its home indicator along the bottom.
"fixed right-[calc(env(safe-area-inset-right,0px)+1.5rem)] z-40 size-14 rounded-full shadow-lg md:right-[calc(env(safe-area-inset-right,0px)+2rem)]",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] md:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
"bottom-[calc(env(safe-area-inset-bottom,0px)+5rem)] shell-desktop:bottom-[calc(env(safe-area-inset-bottom,0px)+2rem)]",
// Dark glyph on the purple/pink gradient — white sat at
// ≈2.3:1 against the gradient midpoint; the background token
// reads ≈6.5:1.
+2 -2
View File
@@ -458,7 +458,7 @@ export function AuthShell({
error after the user already filled in a form. */}
{demoMode ? <DemoBanner /> : null}
<MaintainershipBanner />
<div className="flex min-h-0 flex-1 flex-col md:flex-row">
<div className="shell-desktop:flex-row flex min-h-0 flex-1 flex-col">
<SidebarNav />
{/* `min-w-0` lets the content column shrink below its children's
intrinsic min width (e.g. the measurements table); without it
@@ -497,7 +497,7 @@ export function AuthShell({
// for all of them at once instead of asking each new
// absolutely-positioned child to remember its own wrapper.
className={cn(
"relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))] md:pb-0",
"shell-desktop:pb-0 relative flex-1 overflow-y-auto pb-[calc(4rem+env(safe-area-inset-bottom,0px))]",
fullBleed ? "flex flex-col" : "[scrollbar-gutter:stable]",
)}
>
+2 -2
View File
@@ -208,7 +208,7 @@ export function BottomNav() {
aria-label={t("nav.mobileNavigation")}
data-slot="bottom-nav"
data-keyboard-hide=""
className="bg-card border-border fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md md:hidden"
className="bg-card border-border shell-desktop:hidden fixed bottom-0 left-0 z-50 min-h-16 w-full border-t pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)] backdrop-blur-md"
>
<div className="mx-auto flex h-16 max-w-lg items-stretch justify-around px-1">
{primaryLeft.map(renderPrimary)}
@@ -274,7 +274,7 @@ export function BottomNav() {
// The sheet auto-sizes to its content; padding-bottom respects
// the iOS safe area so the inner buttons stay reachable above
// the home-indicator on iPhone-X-class devices.
className="mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)] md:hidden"
className="shell-desktop:hidden mx-auto max-w-md rounded-t-xl pb-[calc(env(safe-area-inset-bottom)+1rem)]"
data-testid="bottom-nav-more-sheet"
>
<SheetHeader>
+1 -1
View File
@@ -681,7 +681,7 @@ export function SidebarNav() {
<aside
aria-label={t("nav.sidebar")}
className={cn(
"bg-sidebar border-sidebar-border relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none md:flex md:flex-col",
"bg-sidebar border-sidebar-border shell-desktop:flex shell-desktop:flex-col relative hidden h-full flex-shrink-0 overflow-hidden border-r transition-[width] duration-200 motion-reduce:transition-none",
collapsed ? "w-16" : "w-64",
)}
>
+6 -3
View File
@@ -97,7 +97,10 @@ export function TopBar() {
// inside its own 4rem, a 59 px inset left the bar a 5 px content box.
>
{/* Mobile logo */}
<Link href="/" className="flex min-h-11 items-center gap-2 md:hidden">
<Link
href="/"
className="shell-desktop:hidden flex min-h-11 items-center gap-2"
>
<Logo className="text-primary" size={20} />
<span className="font-bold tracking-tight">HealthLog</span>
</Link>
@@ -105,7 +108,7 @@ export function TopBar() {
{/* Desktop: the page's context, where a page gives one (the Coach's
trail); otherwise an empty spacer. User controls are in the
sidebar. */}
<TopBarContextOutlet className="hidden min-w-0 flex-1 items-center md:flex" />
<TopBarContextOutlet className="shell-desktop:flex hidden min-w-0 flex-1 items-center" />
{/* Page-owned actions (the Coach's conversations toggle). `ml-auto`
keeps them at the trailing edge, right before the mobile avatar
@@ -117,7 +120,7 @@ export function TopBar() {
<TopBarActionsOutlet className="ml-auto flex shrink-0 items-center gap-1 empty:hidden md:-mr-3" />
{/* Mobile-only auth section (desktop uses sidebar user section) */}
<div className="flex items-center gap-2 md:hidden">
<div className="shell-desktop:hidden flex items-center gap-2">
{isLoading ? (
<Skeleton className="bg-muted h-4 w-20 rounded" />
) : user ? (