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
@@ -0,0 +1,292 @@
import { useRef, useState, type ChangeEvent, type DragEvent, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { SelectField, TextField } from '@/components/ui/Field'
import { Icon } from '@/components/ui/Icon'
import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn'
import { CATEGORY_LABELS, PLANT_CATEGORIES, isBuiltin, type PlantCategory, type PlantInput } from '@/lib/plants'
import { lotDefaults, newPlantDefaults, useCreateFromPacket, useScanPacket, type PacketProposal } from '@/lib/seedPacket'
import { LOT_UNITS, type LotUnit } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
const unitOptions = LOT_UNITS.map((u) => ({ value: u.value, label: u.label }))
// 'new' is "create a new plant"; a number selects that existing candidate.
type Selection = number | 'new'
/**
* Photograph a seed packet → the vision model reads it into fields → the user
* matches it to the catalog and confirms (#81/#102). Two steps in one dialog.
* The read never writes; a misread can't add anything on its own, and a wrong
* auto-match would split a variety's history across duplicate rows — so the
* match is always a human choice. Only offered where `capabilities.vision` is
* on; a 503 is still handled in case the model is torn down in between.
*/
export function ScanPacketDialog({ unit, onClose }: { unit: UnitPref; onClose: () => void }) {
const scan = useScanPacket()
const create = useCreateFromPacket()
const fileInput = useRef<HTMLInputElement>(null)
const scanAbort = useRef<AbortController | null>(null)
const [proposal, setProposal] = useState<PacketProposal | null>(null)
const [error, setError] = useState<string | null>(null)
const [dragOver, setDragOver] = useState(false)
const [selection, setSelection] = useState<Selection>('new')
const [name, setName] = useState('')
const [category, setCategory] = useState<PlantCategory>('vegetable')
const [spacing, setSpacing] = useState('')
const [days, setDays] = useState('')
const [vendor, setVendor] = useState('')
const [quantity, setQuantity] = useState('')
const [lotUnit, setLotUnit] = useState<LotUnit>('packets')
const [packedForYear, setPackedForYear] = useState('')
const unitLabel = spacingUnitLabel(unit)
const busy = scan.isPending || create.isPending
function readFile(file: File | undefined) {
if (!file) return
setError(null)
const controller = new AbortController()
scanAbort.current = controller
scan.mutate(
{ file, signal: controller.signal },
{
onSuccess: (p) => {
const plant = newPlantDefaults(p)
const lot = lotDefaults(p.packet)
setProposal(p)
setSelection(p.candidates[0]?.plant.id ?? 'new')
setName(plant.name)
setCategory(plant.category)
setSpacing(String(spacingFromCm(plant.spacingCm, unit)))
setDays(plant.daysToMaturity != null ? String(plant.daysToMaturity) : '')
setVendor(lot.vendor)
setQuantity(String(lot.quantity))
setLotUnit(lot.unit)
setPackedForYear(lot.packedForYear != null ? String(lot.packedForYear) : '')
},
onError: (err) => {
if ((err as Error)?.name === 'AbortError') return
setError(errorMessage(err, "Couldn't read that photo. Try a clearer, well-lit shot of the packet."))
},
},
)
}
function onFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
e.target.value = '' // so re-picking the same file fires change again
readFile(file)
}
function onDrop(e: DragEvent) {
e.preventDefault()
setDragOver(false)
readFile(e.dataTransfer.files?.[0])
}
async function onConfirm(e: FormEvent) {
e.preventDefault()
if (!proposal) return
setError(null)
const qty = quantity.trim() === '' ? 0 : Number(quantity)
if (!Number.isFinite(qty) || qty < 0) return setError('Quantity must be a number, or blank.')
let year: number | null = null
if (packedForYear.trim()) {
const y = Number(packedForYear)
if (!Number.isInteger(y) || y < 1900 || y > 2200) return setError('Packed-for should be a four-digit year.')
year = y
}
const lot = {
vendor: vendor.trim(),
sourceUrl: '',
sku: proposal.packet.sku,
lotCode: proposal.packet.lotCode,
purchasedAt: null,
packedForYear: year,
quantity: qty,
unit: lotUnit,
costCents: null,
germinationPct: null,
notes: '',
}
let newPlant: PlantInput | undefined
let plantId: number | undefined
if (selection === 'new') {
if (!name.trim()) return setError('Name the new plant, or pick an existing one above.')
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) return setError(`Spacing must be at least 1 ${unitLabel}.`)
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) return setError('Days to maturity is a whole number of days, or blank.')
daysToMaturity = d
}
newPlant = { ...newPlantDefaults(proposal), name: name.trim(), category, spacingCm, daysToMaturity, vendor: vendor.trim() }
} else {
plantId = selection
}
try {
const res = await create.mutateAsync({ plantId, newPlant, lot })
toast.info(res.plantIsNew ? `Added ${res.plant.name} and its seed lot.` : `Recorded a seed lot for ${res.plant.name}.`)
onClose()
} catch (err) {
setError(errorMessage(err, 'Could not save the packet.'))
}
}
const variety = proposal ? [proposal.packet.species, proposal.packet.variety].filter(Boolean).join(' — ') : ''
return (
<Dialog title="Scan a seed packet" onClose={onClose} busy={create.isPending} width={560}>
{!proposal ? (
<>
<input
ref={fileInput}
type="file"
accept="image/*"
capture="environment"
onChange={onFile}
className="hidden"
aria-hidden
tabIndex={-1}
/>
<div
onDragOver={(e) => {
e.preventDefault()
setDragOver(true)
}}
onDragLeave={() => setDragOver(false)}
onDrop={onDrop}
className={cn(
'flex flex-col items-center gap-2.5 rounded-lg border-2 border-dashed px-5 py-9 text-center',
dragOver ? 'border-accent-400 bg-accent-100' : 'border-neutral-400',
)}
>
<Icon name="camera" size={30} className="text-accent-2-600" />
<div className="text-sm font-semibold">Photograph the packet front is enough</div>
<div className="max-w-[36ch] text-[12.5px] text-ink-mute">
The vision model reads it into fields. It only reads; nothing is saved until you confirm.
</div>
{scan.isPending ? (
<p className="mt-1 flex items-center gap-2 text-[13px] font-semibold text-ink-soft">
<span className="inline-block h-2 w-2 animate-pulse rounded-full bg-accent" />
Reading the packet this can take a few seconds.
</p>
) : (
<Button variant="primary" icon="camera" className="mt-1" onClick={() => fileInput.current?.click()}>
Take or choose a photo
</Button>
)}
</div>
{error && <Alert>{error}</Alert>}
<div className="flex justify-end">
{/* Never disabled — this is the way out of a slow scan. */}
<Button
onClick={() => {
scanAbort.current?.abort()
onClose()
}}
>
Cancel
</Button>
</div>
</>
) : (
<form onSubmit={onConfirm} className="flex flex-col gap-3.5">
<div className="grid grid-cols-2 gap-2.5">
<TextField label="Variety" name="variety" value={selection === 'new' ? name : variety} readOnly={selection !== 'new'} onChange={(e) => setName(e.target.value)} />
<TextField label="Vendor" name="vendor" value={vendor} onChange={(e) => setVendor(e.target.value)} />
<TextField label="Packed for" name="packedForYear" type="number" inputMode="numeric" value={packedForYear} onChange={(e) => setPackedForYear(e.target.value)} />
<div className="field">
<label htmlFor="scan-quantity">Quantity</label>
<div className="flex gap-1.5">
<input id="scan-quantity" className="input min-w-0" type="number" inputMode="decimal" step="any" min="0" value={quantity} onChange={(e) => setQuantity(e.target.value)} />
<select className="input w-auto flex-none" aria-label="Unit" value={lotUnit} onChange={(e) => setLotUnit(e.target.value as LotUnit)}>
{unitOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
</div>
</div>
<div className="mt-0.5 text-[12.5px] font-bold text-ink-soft">Match it to your catalog nothing is auto-created:</div>
<div role="radiogroup" aria-label="Which plant this packet is" className="flex flex-col gap-2">
{proposal.candidates.map((c, i) => (
<MatchOption
key={c.plant.id}
selected={selection === c.plant.id}
onSelect={() => setSelection(c.plant.id)}
label={`${c.plant.name} (${isBuiltin(c.plant) ? 'built-in' : 'yours'})`}
sub={i === 0 ? `best match · ${c.reason}` : c.reason}
/>
))}
<MatchOption
selected={selection === 'new'}
onSelect={() => setSelection('new')}
label="Create a new plant"
sub="from the extracted fields"
/>
</div>
{selection === 'new' && (
<div className="grid grid-cols-2 gap-2.5 rounded-md border border-divider bg-bg p-3.5">
<SelectField label="Category" name="category" value={category} onChange={(e) => setCategory(e.target.value as PlantCategory)} options={categoryOptions} />
<TextField label={`Spacing (${unitLabel})`} name="spacing" type="number" inputMode="decimal" step="any" min="1" required value={spacing} onChange={(e) => setSpacing(e.target.value)} />
<TextField label="Days to maturity" name="days" type="number" inputMode="numeric" step="1" min="1" value={days} onChange={(e) => setDays(e.target.value)} wrapperClassName="col-span-2" />
</div>
)}
{error && <Alert>{error}</Alert>}
<div className="mt-0.5 flex justify-between gap-2">
<Button
variant="ghost"
onClick={() => {
setError(null)
setProposal(null)
}}
disabled={busy}
>
Rescan
</Button>
<span className="flex gap-2">
<Button onClick={onClose} disabled={busy}>
Cancel
</Button>
<Button type="submit" variant="primary" disabled={busy}>
{create.isPending ? 'Saving…' : selection === 'new' ? 'Create plant + lot' : 'Add the lot'}
</Button>
</span>
</div>
</form>
)}
</Dialog>
)
}
function MatchOption({ selected, onSelect, label, sub }: { selected: boolean; onSelect: () => void; label: string; sub: string }) {
return (
<button
type="button"
role="radio"
aria-checked={selected}
onClick={onSelect}
className={cn(
'flex cursor-pointer items-center gap-2 rounded-full border px-4 py-2.5 text-left',
selected ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg',
)}
>
<span className="text-[13.5px] font-bold">{label}</span>
<span className="ml-auto text-right text-xs text-ink-mute">{sub}</span>
</button>
)
}