Address mobile-shell review: logout retry, public-garden bar, class hygiene
Build image / build-and-push (push) Successful in 19s

Gadfly findings on #98:

- Logout failure (4 models): the catch called setOpen(false), closing the
  popover and hiding the only "Retry sign out" affordance — contradicting
  its own comment. Keep the popover open on failure so the retry button
  (driven by logout.isError) stays on screen.
- Public garden (2 models): the bottom-bar suppression missed /g/$token,
  whose PublicGardenPage also renders a 100dvh-8rem canvas — a signed-in
  viewer of a shared link got the bar overlapping it. Suppress there too.
- BottomNav base className carried text-muted, fighting the active
  text-accent-strong and violating the file's own "color only in state
  props" convention (3 models). Moved color entirely to the state props.
- BottomNav sections prop type was a hand-written partial copy of the
  sections shape; derive `Section = (typeof sections)[number]` (2 models).
- Popover open state now resets on route change, so navigating (bottom nav
  or browser back) can't strand an invisible full-screen backdrop.
- Coupled the <main> bottom clearance to BottomNav's height (both 3.5rem /
  h-14, co-located with a note) and switched the template-string className
  to cn().

Verified live: route-change closes the popover with no lingering backdrop.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-22 01:02:06 -04:00
co-authored by Claude Opus 4.8
parent 9f434a801a
commit 79f03acea8
+31 -19
View File
@@ -1,7 +1,8 @@
import { useState } from 'react' import { useEffect, useState } from 'react'
import { Link, Outlet, useMatchRoute, useNavigate } from '@tanstack/react-router' import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Toaster } from '@/components/ui/toast' import { Toaster } from '@/components/ui/toast'
import { useLogout, useMe } from '@/lib/auth' import { useLogout, useMe } from '@/lib/auth'
import { cn } from '@/lib/cn'
// Top-level sections. `icon` is only used by the mobile bottom bar; the desktop // Top-level sections. `icon` is only used by the mobile bottom bar; the desktop
// top bar shows labels alone. Settings is filtered to admins at render. // top bar shows labels alone. Settings is filtered to admins at render.
@@ -11,6 +12,8 @@ const sections = [
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true }, { to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
] as const ] as const
type Section = (typeof sections)[number]
// TanStack Router concatenates the base className with activeProps/inactiveProps, // TanStack Router concatenates the base className with activeProps/inactiveProps,
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the // so state-specific and conflicting utilities (text-muted vs text-fg) live in the
// state props — never in the base — to avoid ambiguous overrides. // state props — never in the base — to avoid ambiguous overrides.
@@ -30,10 +33,13 @@ export function AppShell() {
const user = me.data const user = me.data
const matchRoute = useMatchRoute() const matchRoute = useMatchRoute()
// The garden editor route ('/gardens/$gardenId') runs full-screen. Fuzzy:false // Full-screen canvas contexts own the bottom of the screen: the garden editor
// so the '/gardens' list itself still shows the bottom bar. // and the public shared-garden view both render a 100dvh-8rem canvas, so a
// signed-in viewer must not get the app bottom bar overlapping it. Fuzzy:false
// so the '/gardens' list itself still shows the bar.
const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false })
const showBottomNav = !!user && !inEditor const inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
const showBottomNav = !!user && !inEditor && !inPublicGarden
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
@@ -79,11 +85,13 @@ export function AppShell() {
</nav> </nav>
</header> </header>
{/* Bottom bar clearance on mobile so content isn't hidden behind it. */}
<main <main
className={`mx-auto w-full max-w-5xl flex-1 px-4 py-6 ${ className={cn(
showBottomNav ? 'pb-[calc(4.5rem+env(safe-area-inset-bottom))] md:pb-6' : '' 'mx-auto w-full max-w-5xl flex-1 px-4 py-6',
}`} // Clear the fixed bottom bar on mobile so content isn't hidden behind
// it. The 3.5rem must match BottomNav's h-14 (kept adjacent below).
showBottomNav && 'pb-[calc(3.5rem+env(safe-area-inset-bottom))] md:pb-6',
)}
> >
<Outlet /> <Outlet />
</main> </main>
@@ -101,16 +109,21 @@ function AccountMenu({ displayName }: { displayName: string }) {
const logout = useLogout() const logout = useLogout()
const navigate = useNavigate() const navigate = useNavigate()
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const pathname = useRouterState({ select: (s) => s.location.pathname })
// Close on any route change, so navigating (bottom nav, browser back) can't
// leave the popover — and its full-screen backdrop — stuck open over the page.
useEffect(() => setOpen(false), [pathname])
async function onLogout() { async function onLogout() {
try { try {
await logout.mutateAsync() await logout.mutateAsync()
await navigate({ to: '/login' }) await navigate({ to: '/login' })
} catch { } catch {
// The logout request failed, so the session is still valid server-side: // The logout request failed, so the session is still valid server-side.
// leave the user where they are (the button re-enables for a retry) rather // Keep the popover OPEN so the button (now "Retry sign out", driven by
// than pretending they're signed out. logout.isError drives the label. // logout.isError) stays on screen — closing it would hide the only retry
setOpen(false) // affordance and pretend nothing went wrong.
} }
} }
@@ -169,11 +182,7 @@ function AccountMenu({ displayName }: { displayName: string }) {
} }
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */ /** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
function BottomNav({ function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
sections,
}: {
sections: ReadonlyArray<{ to: string; label: string; icon: string }>
}) {
return ( return (
<nav <nav
aria-label="Sections" aria-label="Sections"
@@ -182,9 +191,12 @@ function BottomNav({
<ul className="mx-auto flex max-w-5xl items-stretch justify-around"> <ul className="mx-auto flex max-w-5xl items-stretch justify-around">
{sections.map((s) => ( {sections.map((s) => (
<li key={s.to} className="flex-1"> <li key={s.to} className="flex-1">
{/* h-14 matches the clearance reserved on <main> above. Color lives
only in the state props (per the top-bar convention), never the
base, so active/inactive don't fight. */}
<Link <Link
to={s.to} to={s.to}
className="flex min-h-[3.25rem] flex-col items-center justify-center gap-0.5 py-1.5 text-xs font-medium text-muted transition-colors" className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
activeProps={{ className: 'text-accent-strong' }} activeProps={{ className: 'text-accent-strong' }}
inactiveProps={{ className: 'text-muted hover:text-fg' }} inactiveProps={{ className: 'text-muted hover:text-fg' }}
> >