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
+125 -76
View File
@@ -1,19 +1,22 @@
import { useState } from 'react'
import { PlantIcon } from './PlantIcon'
import { LotStateChip, SeedLotList } from './SeedLotList'
import { SourceLink } from './SourceLink'
import { cardActionClass, cardDangerClass } from '@/components/ui/cardActions'
import { Button } from '@/components/ui/Button'
import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag'
import { cn } from '@/lib/cn'
import { CATEGORY_LABELS, isBuiltin, type Plant } from '@/lib/plants'
import { formatQuantity, summarizeLots, type SeedLot } from '@/lib/seedLots'
import { formatCost, formatQuantity, lotState, safeExternalUrl, type SeedLot } from '@/lib/seedLots'
import { formatSpacing, type UnitPref } from '@/lib/units'
import { Monogram } from './Monogram'
/**
* One catalog plant as a card: icon tile tinted with the plant's color, name,
* category + mature spacing (unit-aware), and actions. Built-ins are badged and
* offer only "Duplicate" (they're read-only); own plants add Edit/Delete.
* One catalog plant as a card: monogram, name, "Category · spacing · days", a
* built-in tag for seeded plants, and a seed-lot summary. Clicking expands it
* (accent border) to the lot cards — vendor, packed-for year, what's left — and
* the plant's own actions. Built-ins are read-only: duplicate to customize.
*/
export function PlantCard({
plant,
letters,
unit,
lots,
onEdit,
@@ -24,9 +27,8 @@ export function PlantCard({
onDeleteLot,
}: {
plant: Plant
letters: string
unit: UnitPref
/** This plant's purchases. A lot may reference a built-in, so even a built-in
* card can carry seed. */
lots: SeedLot[]
onEdit: () => void
onDelete: () => void
@@ -36,80 +38,127 @@ export function PlantCard({
onDeleteLot: (lot: SeedLot) => void
}) {
const builtin = isBuiltin(plant)
const [showLots, setShowLots] = useState(false)
const summary = summarizeLots(lots)
const [open, setOpen] = useState(false)
const sub = [CATEGORY_LABELS[plant.category], `${formatSpacing(plant.spacingCm, unit)} spacing`]
if (plant.daysToMaturity != null) sub.push(`${plant.daysToMaturity} days`)
const lotText =
lots.length === 0
? `No seed lots · click to ${open ? 'close' : 'expand'}`
: `${lots.length} seed ${lots.length === 1 ? 'lot' : 'lots'} · click to ${open ? 'close' : 'see'}`
const source = safeExternalUrl(plant.sourceUrl)
return (
<div className="flex flex-col rounded-xl border border-border bg-surface">
<div className="flex items-start gap-3 p-4">
<PlantIcon color={plant.color} icon={plant.icon} className="h-11 w-11 rounded-lg text-2xl" />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<h3 className="truncate font-semibold text-fg">{plant.name}</h3>
{builtin && (
<span className="shrink-0 rounded bg-border/60 px-1.5 py-0.5 text-[10px] font-medium uppercase tracking-wide text-muted">
Built-in
</span>
)}
</div>
<p className="mt-0.5 text-sm text-muted">
{CATEGORY_LABELS[plant.category]} · {formatSpacing(plant.spacingCm, unit)} spacing
</p>
{(plant.vendor || plant.sourceUrl) && (
<p className="mt-0.5 flex flex-wrap items-center gap-1.5 text-xs text-muted">
{plant.vendor && <span>{plant.vendor}</span>}
<SourceLink url={plant.sourceUrl} />
</p>
)}
{plant.notes && <p className="mt-1 line-clamp-2 text-xs text-muted">{plant.notes}</p>}
</div>
<span
className="mt-1 h-4 w-4 shrink-0 rounded-full border border-black/10 dark:border-white/10"
style={{ backgroundColor: plant.color }}
title={plant.color}
/>
<div
role="button"
tabIndex={0}
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
onKeyDown={(e) => {
if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault()
setOpen((v) => !v)
}
}}
className={cn(
'panel relative cursor-pointer px-[18px] py-4 transition-shadow hover:[box-shadow:var(--shadow-md)]',
open && 'border-accent-400',
)}
>
<div className="flex items-center gap-3">
<Monogram color={plant.color} letters={letters} />
<span className="flex min-w-0 flex-col gap-px">
<span className="truncate font-heading text-[16.5px]" title={plant.name}>
{plant.name}
</span>
<span className="text-xs font-semibold text-ink-mute">{sub.join(' · ')}</span>
</span>
{builtin && <Tag tone="neutral" className="ml-auto flex-none">built-in</Tag>}
</div>
{showLots && (
<div className="border-t border-border px-3 py-2">
<SeedLotList lots={lots} canEdit onAdd={onAddLot} onEdit={onEditLot} onDelete={onDeleteLot} />
<div className="mt-2.5 text-[12.5px] text-ink-soft">{lotText}</div>
{open && (
// Stop clicks inside the expanded area from toggling the card.
<div className="mt-2.5 flex flex-col gap-2" onClick={(e) => e.stopPropagation()} onKeyDown={(e) => e.stopPropagation()}>
{lots.map((lot) => (
<LotCard key={lot.id} lot={lot} onEdit={() => onEditLot(lot)} onDelete={() => onDeleteLot(lot)} />
))}
{lots.length === 0 && (
<div className="text-[12.5px] text-ink-mute">No seed lots yet scan a packet, or record one by hand.</div>
)}
{(plant.vendor || source || plant.notes) && (
<div className="text-xs leading-relaxed text-ink-soft">
{plant.vendor && <span>{plant.vendor}</span>}
{plant.vendor && source && <span> · </span>}
{source && (
<a href={source} target="_blank" rel="noopener noreferrer" className="inline-flex items-center gap-1">
source <Icon name="external-link" size={11} />
</a>
)}
{plant.notes && <p className="mt-1 whitespace-pre-wrap">{plant.notes}</p>}
</div>
)}
<div className="flex flex-wrap gap-1.5 pt-1">
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
Record a lot
</Button>
<span className="ml-auto flex gap-1">
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
Duplicate
</Button>
{!builtin && (
<Button variant="ghost" icon="pencil" iconSize={12} className="px-2.5 text-[13px]" onClick={onEdit}>
Edit
</Button>
)}
{!builtin && (
<Button variant="ghost" icon="trash-2" iconSize={12} className="px-2.5 text-[13px] text-accent-700" onClick={onDelete}>
Delete
</Button>
)}
</span>
</div>
</div>
)}
</div>
)
}
<div className="flex items-center justify-end gap-1 border-t border-border px-2 py-1.5">
{/* The seed count sits with the actions rather than in the body: it's
what you scan for down a list of twenty packets, so it wants a fixed
place on the card. */}
<button
type="button"
onClick={() => setShowLots((v) => !v)}
className={`${cardActionClass} mr-auto gap-1.5`}
aria-expanded={showLots}
>
{lots.length === 0 ? (
<span className="text-muted">No seed</span>
) : (
<>
<span className="tabular-nums">
{formatQuantity(summary.remaining)}
{summary.unit ? ` ${summary.unit}` : ''} left
</span>
<LotStateChip state={summary.state} />
</>
)}
/** What's left of a lot, said plainly: "50 cloves · 14 left" — or how far over
* it was planted, which is a real situation worth showing rather than clamping. */
export function describeLot(lot: SeedLot): string {
const parts = [`${formatQuantity(lot.quantity)} ${lot.unit}`]
const state = lotState(lot)
if (state === 'over') parts.push(`${formatQuantity(-lot.remaining)} over what was bought`)
else if (state === 'empty') parts.push('none left')
else if (state !== 'unknown') parts.push(`${formatQuantity(lot.remaining)} left`)
if (lot.germinationPct != null) parts.push(`${lot.germinationPct}% germination`)
const cost = formatCost(lot.costCents)
if (cost) parts.push(cost)
return parts.join(' · ')
}
function LotCard({ lot, onEdit, onDelete }: { lot: SeedLot; onEdit: () => void; onDelete: () => void }) {
const state = lotState(lot)
return (
<div className="rounded-md border border-divider bg-bg px-[13px] py-2.5">
<div className="flex items-center gap-1.5">
<span className="min-w-0 truncate text-[12.5px] font-bold">{lot.vendor || 'Unnamed lot'}</span>
{state === 'low' && <Tag tone="accent">low</Tag>}
{state === 'empty' && <Tag tone="neutral">empty</Tag>}
{state === 'over' && <Tag tone="accent">over-planted</Tag>}
<span className="ml-auto flex-none text-[11.5px] text-ink-mute">
{lot.packedForYear != null ? `packed for ${lot.packedForYear}` : lot.purchasedAt ? `bought ${lot.purchasedAt}` : ''}
</span>
</div>
<div className="mt-[3px] text-xs text-ink-soft">{describeLot(lot)}</div>
{lot.notes && <div className="mt-1 text-xs text-ink-mute">{lot.notes}</div>}
<div className="mt-1 flex justify-end gap-1">
<button type="button" className="btn btn-ghost px-2 py-1 text-xs" onClick={onEdit}>
Edit
</button>
<button type="button" onClick={onDuplicate} className={cardActionClass}>
Duplicate
<button type="button" className="btn btn-ghost px-2 py-1 text-xs text-accent-700" onClick={onDelete}>
Retire
</button>
{!builtin && (
<button type="button" onClick={onEdit} className={cardActionClass}>
Edit
</button>
)}
{!builtin && (
<button type="button" onClick={onDelete} className={cardDangerClass}>
Delete
</button>
)}
</div>
</div>
)