From 9f434a801a7e3124b9ac8a454a2931d725a0acc3 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 00:51:23 -0400 Subject: [PATCH 1/2] Mobile-first app shell: bottom tab nav + slim top bar (#98) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top bar was desktop-shaped โ€” logo + Gardens/Plants/Settings + name + Sign out crammed in one row, wrapping "Sign out" to two lines at 390px, with no thumb-reachable navigation. Mobile-first now: - Slim top bar: brand (left, still the way back to /gardens) + a compact account control (right). - Section nav (Gardens/Plants/Settings) moves to a bottom tab bar in the thumb zone, safe-area-aware, โ‰ฅ52px targets, shown only when signed in. - Account/sign-out is a small top-right popover (Signed in as โ€ฆ / Sign out), reachable in 2 taps. Close-on-outside-tap via a backdrop button, no document listener. - Desktop (md:+) keeps the inline top nav; the bottom bar is md:hidden. - The editor is a full-screen context, so it owns the bottom of the screen โ€” the app bottom bar hides on /gardens/$id (via useMatchRoute), leaving no competing bars there; the editor's own mode bar arrives in the mode-model issue. Verified live at 390px and 1280px: bottom bar on the list, hidden in the editor, account menu opens, desktop unchanged. tsc + vitest green. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/components/layout/AppShell.tsx | 197 +++++++++++++++++++------ 1 file changed, 148 insertions(+), 49 deletions(-) diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx index 88442b2..4fe42f1 100644 --- a/web/src/components/layout/AppShell.tsx +++ b/web/src/components/layout/AppShell.tsx @@ -1,10 +1,14 @@ -import { Link, Outlet, useNavigate } from '@tanstack/react-router' +import { useState } from 'react' +import { Link, Outlet, useMatchRoute, useNavigate } from '@tanstack/react-router' import { Toaster } from '@/components/ui/toast' import { useLogout, useMe } from '@/lib/auth' -const navLinks = [ - { to: '/gardens', label: 'Gardens' }, - { to: '/plants', label: 'Plants' }, +// 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. +const sections = [ + { to: '/gardens', label: 'Gardens', icon: '๐Ÿก', adminOnly: false }, + { to: '/plants', label: 'Plants', icon: '๐ŸŒฟ', adminOnly: false }, + { to: '/settings', label: 'Settings', icon: 'โš™๏ธ', adminOnly: true }, ] as const // TanStack Router concatenates the base className with activeProps/inactiveProps, @@ -14,73 +18,56 @@ const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-color const navLinkActive = 'bg-border/60 text-fg' const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg' -/** Top-level chrome: a sticky nav bar plus the routed page in an . */ +/** + * Top-level chrome. Mobile-first: a slim top bar (brand + account) with the + * section nav moved to a thumb-reachable bottom tab bar; the desktop breakpoint + * (`md:`) restores the inline top nav. The editor is a full-screen context, so it + * owns the bottom of the screen โ€” the app bottom bar hides there (the brand link + * is the way back to the gardens list), leaving exactly one bottom bar per route. + */ export function AppShell() { const me = useMe() - const logout = useLogout() - const navigate = useNavigate() const user = me.data + const matchRoute = useMatchRoute() - 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 title below. - } - } + // The garden editor route ('/gardens/$gardenId') runs full-screen. Fuzzy:false + // so the '/gardens' list itself still shows the bottom bar. + const inEditor = !!matchRoute({ to: '/gardens/$gardenId', fuzzy: false }) + const showBottomNav = !!user && !inEditor + + const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin) return (
-
+
-
+ {/* Bottom bar clearance on mobile so content isn't hidden behind it. */} +
+ {showBottomNav && } +
) } + +/** Account control: a compact button that toggles a small sign-out popover. On + * desktop the display name shows inline; on mobile it lives inside the popover. */ +function AccountMenu({ displayName }: { displayName: string }) { + const logout = useLogout() + const navigate = useNavigate() + const [open, setOpen] = useState(false) + + 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) + } + } + + const initial = displayName.trim().charAt(0).toUpperCase() || 'ยท' + + return ( +
+ + + {open && ( + <> + {/* Full-screen click-away backdrop; reliably closes on an outside tap + without a document listener. */} + +
+ + )} + + ) +} + +/** 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 }> +}) { + return ( + + ) +} From 79f03acea8cd8f08ce2fa992731c7740e52bb5d6 Mon Sep 17 00:00:00 2001 From: Steve Dudenhoeffer Date: Wed, 22 Jul 2026 01:02:06 -0400 Subject: [PATCH 2/2] Address mobile-shell review: logout retry, public-garden bar, class hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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
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) Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ --- web/src/components/layout/AppShell.tsx | 50 ++++++++++++++++---------- 1 file changed, 31 insertions(+), 19 deletions(-) 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 (