diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
index 4fe42f1..e5dd25f 100644
--- a/web/src/components/layout/AppShell.tsx
+++ b/web/src/components/layout/AppShell.tsx
@@ -1,7 +1,8 @@
-import { useState } from 'react'
-import { Link, Outlet, useMatchRoute, useNavigate } from '@tanstack/react-router'
+import { useEffect, useState } from 'react'
+import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Toaster } from '@/components/ui/toast'
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 bar shows labels alone. Settings is filtered to admins at render.
@@ -11,6 +12,8 @@ const sections = [
{ to: '/settings', label: 'Settings', icon: '⚙️', adminOnly: true },
] as const
+type Section = (typeof sections)[number]
+
// TanStack Router concatenates the base className with activeProps/inactiveProps,
// so state-specific and conflicting utilities (text-muted vs text-fg) live in the
// state props — never in the base — to avoid ambiguous overrides.
@@ -30,10 +33,13 @@ export function AppShell() {
const user = me.data
const matchRoute = useMatchRoute()
- // The garden editor route ('/gardens/$gardenId') runs full-screen. Fuzzy:false
- // so the '/gardens' list itself still shows the bottom bar.
+ // Full-screen canvas contexts own the bottom of the screen: the garden editor
+ // 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 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)
@@ -79,11 +85,13 @@ export function AppShell() {
- {/* Bottom bar clearance on mobile so content isn't hidden behind it. */}
@@ -101,16 +109,21 @@ function AccountMenu({ displayName }: { displayName: string }) {
const logout = useLogout()
const navigate = useNavigate()
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() {
try {
await logout.mutateAsync()
await navigate({ to: '/login' })
} catch {
- // 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
- // than pretending they're signed out. logout.isError drives the label.
- setOpen(false)
+ // The logout request failed, so the session is still valid server-side.
+ // Keep the popover OPEN so the button (now "Retry sign out", driven by
+ // logout.isError) stays on screen — closing it would hide the only retry
+ // 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). */
-function BottomNav({
- sections,
-}: {
- sections: ReadonlyArray<{ to: string; label: string; icon: string }>
-}) {
+function BottomNav({ sections }: { sections: ReadonlyArray }) {
return (