Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
Build image / build-and-push (push) Successful in 31s
Gadfly review (reusable) / review (pull_request) Failing after 1s
Adversarial Review (Gadfly) / review (pull_request) Failing after 1s

The frontend is rebuilt screen by screen from the handoff: warm cream ground,
terracotta + sage accents, Caprasimo over Figtree, every control a pill. Same
React/Vite/TanStack stack and the same lib/ data layer; the presentation is new.

- Tokens: web/src/styles/index.css declares the handoff's styles.css variables
  through Tailwind's @theme under the same names; dark mode is those variables
  overridden on <html> by the handoff's pansy-theme.js, inlined in index.html
  so it runs before first paint. Lucide glyphs at stroke 2.75; a small pill kit
  (Button, Dialog, Field, Seg, Toggle, Tag, toast).
- Login / Register: the centered column over soft accent circles; OIDC button
  and signup footer still follow /auth/providers.
- Gardens: cards with a real SVG plot thumbnail (objects + plant-colored dots
  from /full), a `plan` tag for "<name> — <year>" copies, shares line, Open +
  share/copy/edit/delete; New garden / Share / Plan-a-season dialogs.
- Plants: monogram markers derived from the name (collision-resolved across the
  catalog — replaces emoji icons), category chips, expandable lot cards, the
  scan-packet flow as a two-step dialog that never auto-creates.
- Settings: Appearance (theme seg), Who gets in (read-only sign-in config),
  Garden assistant (self-saving toggle + chat/vision model fields), You.
- Editor: a new canvas with the prototype's pointer model (wheel-to-cursor,
  pinch about the centroid, 3″ snap, one PATCH per drop, semantic-zoom
  monograms/labels), plus corner resize handles; desktop three-card workspace
  (toolkit | plan | rail with Plot/Journal/History/Assistant) and, below 760px
  of container width, the phone chrome (header, peek panel, tool strip, mode
  bar). Seasons as a segmented control over the years with data plus plan
  copies; Undo re-reads history before reverting the newest step.
- Public read-only view and the register page restyled to match.
- GET /settings gains a read-only `auth` view (registration mode, local auth,
  OIDC issuer) so the Settings page can show what's in force.
- README / DESIGN.md / CLAUDE.md updated; @use-gesture/react dropped.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-08-22 19:12:29 -04:00
co-authored by Claude Fable 5
parent 18b36870d4
commit 52b2c09a9e
111 changed files with 6621 additions and 7215 deletions
+13 -225
View File
@@ -1,236 +1,24 @@
import { Suspense, useEffect, useState } from 'react'
import { Link, Outlet, useMatchRoute, useNavigate, useRouterState } from '@tanstack/react-router'
import { Suspense } from 'react'
import { Outlet } 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.
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
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.
const navLinkBase = 'rounded-md px-3 py-1.5 text-sm font-medium transition-colors'
const navLinkActive = 'bg-border/60 text-fg'
const navLinkInactive = 'text-muted hover:bg-border/60 hover:text-fg'
/**
* 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.
* The root layout is deliberately empty chrome: every page renders its own nav
* (the editor wants a different one on a phone than Gardens does), so the shell
* only provides the Suspense boundary for the code-split routes and the toast
* stack. The theme is applied to <html> by the bootstrap in index.html.
*/
export function AppShell() {
const me = useMe()
const user = me.data
const matchRoute = useMatchRoute()
// 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 inPublicGarden = !!matchRoute({ to: '/g/$token', fuzzy: false })
// A canvas route wants the whole width — the garden editor is squeezed by the
// max-w-5xl reading measure the other pages use (#107).
const canvasRoute = inEditor || inPublicGarden
const showBottomNav = !!user && !canvasRoute
// On a phone the editor is a full-screen canvas, so the global top bar is pure
// chrome above the garden — hide it and let the editor's own strip carry the
// back link AND the account menu (so sign-out isn't lost). Editor only, not the
// public view, which has no strip of its own to fall back on.
const hideHeaderOnMobile = inEditor
const visibleSections = sections.filter((s) => !s.adminOnly || user?.isAdmin)
return (
<div className="flex min-h-full flex-col">
<header
className={cn(
'sticky top-0 z-20 border-b border-border bg-surface/90 backdrop-blur',
hideHeaderOnMobile && 'hidden md:block',
)}
>
{/* The bar matches the content width below: constrained on reading pages,
edge-to-edge on the canvas routes so the brand aligns with the editor. */}
<nav className={cn('flex items-center gap-4 px-4 py-3', canvasRoute ? '' : 'mx-auto max-w-5xl')}>
<Link to="/gardens" className="text-lg font-semibold text-accent-strong">
🌱 pansy
</Link>
{/* Desktop inline section links. Hidden on mobile, where the bottom bar
carries them. */}
<div className="hidden flex-1 items-center gap-1 md:flex">
{user &&
visibleSections.map((s) => (
<Link
key={s.to}
to={s.to}
className={navLinkBase}
activeProps={{ className: navLinkActive }}
inactiveProps={{ className: navLinkInactive }}
>
{s.label}
</Link>
))}
</div>
{/* Spacer so account/sign-out sits right on mobile (the desktop links
own flex-1 above). */}
<div className="flex-1 md:hidden" />
{user ? (
<AccountMenu displayName={user.displayName} />
) : (
<Link
to="/login"
className="rounded-md px-3 py-1.5 text-sm font-medium text-muted transition-colors hover:text-fg"
>
Sign in
</Link>
)}
</nav>
</header>
<main
className={cn(
'w-full flex-1 px-4 py-6',
// Constrain the reading pages to a comfortable measure; the canvas
// routes go edge-to-edge so the garden gets the whole screen.
!canvasRoute && 'mx-auto max-w-5xl',
// 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',
)}
>
{/* Boundary for the lazily-loaded routes (see router.tsx). */}
<Suspense fallback={<p className="p-6 text-sm text-muted">Loading</p>}>
<Outlet />
</Suspense>
</main>
{showBottomNav && <BottomNav sections={visibleSections} />}
<>
<Suspense fallback={<PageFallback />}>
<Outlet />
</Suspense>
<Toaster />
</div>
</>
)
}
/** 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.
* Exported so the editor's mobile strip can carry it — the global header that
* normally hosts it is hidden there (see hideHeaderOnMobile). */
export 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.
// 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.
}
}
const initial = displayName.trim().charAt(0).toUpperCase() || '·'
return (
<div className="relative">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-haspopup="menu"
aria-expanded={open}
className="flex items-center gap-2 rounded-md px-2 py-1.5 text-sm text-muted transition-colors hover:text-fg"
>
<span className="hidden sm:inline">{displayName}</span>
<span
aria-hidden
className="flex size-8 items-center justify-center rounded-full bg-border/60 text-sm font-semibold text-fg"
>
{initial}
</span>
</button>
{open && (
<>
{/* Full-screen click-away backdrop; reliably closes on an outside tap
without a document listener. */}
<button
type="button"
aria-label="Close menu"
className="fixed inset-0 z-30 cursor-default"
onClick={() => setOpen(false)}
/>
<div
role="menu"
className="absolute right-0 z-40 mt-2 w-48 rounded-lg border border-border bg-surface p-1 shadow-lg"
>
<p className="truncate px-3 py-2 text-xs text-muted">
Signed in as <span className="text-fg">{displayName}</span>
</p>
<button
type="button"
role="menuitem"
onClick={onLogout}
disabled={logout.isPending}
title={logout.isError ? 'Sign out failed — try again' : undefined}
className="w-full rounded-md px-3 py-2 text-left text-sm font-medium text-muted transition-colors hover:bg-border/60 hover:text-fg disabled:opacity-60"
>
{logout.isPending ? 'Signing out…' : logout.isError ? 'Retry sign out' : 'Sign out'}
</button>
</div>
</>
)}
</div>
)
}
/** Mobile bottom tab bar for the top-level sections (thumb zone, safe-area aware). */
function BottomNav({ sections }: { sections: ReadonlyArray<Section> }) {
return (
<nav
aria-label="Sections"
className="fixed inset-x-0 bottom-0 z-20 border-t border-border bg-surface/95 pb-[env(safe-area-inset-bottom)] backdrop-blur md:hidden"
>
<ul className="mx-auto flex max-w-5xl items-stretch justify-around">
{sections.map((s) => (
<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
to={s.to}
className="flex h-14 flex-col items-center justify-center gap-0.5 text-xs font-medium transition-colors"
activeProps={{ className: 'text-accent-strong' }}
inactiveProps={{ className: 'text-muted hover:text-fg' }}
>
<span aria-hidden className="text-lg leading-none">
{s.icon}
</span>
{s.label}
</Link>
</li>
))}
</ul>
</nav>
)
export function PageFallback({ label = 'Loading…' }: { label?: string }) {
return <p className="p-7 text-[13px] font-semibold text-ink-mute">{label}</p>
}