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
+199
View File
@@ -0,0 +1,199 @@
import { useState, type FormEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
import { Dialog } from '@/components/ui/Dialog'
import { Field, SelectField, TextAreaField, TextField } from '@/components/ui/Field'
import { errorMessage } from '@/lib/api'
import {
CATEGORY_LABELS,
PLANT_CATEGORIES,
conflictPlant,
useCreatePlant,
useUpdatePlant,
type Plant,
type PlantCategory,
type PlantInput,
} from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units'
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
// Markers are monograms now, but the API still carries an icon per plant; a
// plant made here gets the neutral sprout so nothing downstream sees an empty one.
const DEFAULT_ICON = '🌱'
const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY_LABELS[c] }))
/**
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another
* (`template`, the Duplicate action; the only way to customize a built-in).
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases
* onto the server's current row.
*/
export function PlantDialog({
plant,
template,
unit,
onClose,
}: {
plant?: Plant
template?: Plant
unit: UnitPref
onClose: () => void
}) {
const isEdit = !!plant
const source = plant ?? template
const create = useCreatePlant()
const update = useUpdatePlant()
const pending = create.isPending || update.isPending
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit)))
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [vendor, setVendor] = useState(source?.vendor ?? '')
const [sourceUrl, setSourceUrl] = useState(source?.sourceUrl ?? '')
const [notes, setNotes] = useState(source?.notes ?? '')
const [more, setMore] = useState(!!(source?.vendor || source?.sourceUrl || source?.notes))
const [version, setVersion] = useState(plant?.version ?? 0)
const [conflict, setConflict] = useState<string | null>(null)
const [formError, setFormError] = useState<string | null>(null)
const unitLabel = spacingUnitLabel(unit)
async function onSubmit(e: FormEvent) {
e.preventDefault()
setFormError(null)
setConflict(null)
if (!name.trim()) {
setFormError('Give the plant a name.')
return
}
const spacingCm = cmFromSpacing(parseFloat(spacing), unit)
if (!Number.isFinite(spacingCm) || spacingCm < 1) {
setFormError(`Spacing must be at least 1 ${unitLabel}.`)
return
}
let daysToMaturity: number | null = null
if (days.trim()) {
const d = Number(days)
if (!Number.isInteger(d) || d < 1) {
setFormError('Days to maturity is a whole number of days, or blank.')
return
}
daysToMaturity = d
}
if (sourceUrl.trim() && !safeExternalUrl(sourceUrl.trim())) {
setFormError('The source link needs to be a full http:// or https:// address.')
return
}
const input: PlantInput = {
name: name.trim(),
category,
spacingCm,
color,
icon: source?.icon || DEFAULT_ICON,
daysToMaturity,
sourceUrl: sourceUrl.trim(),
vendor: vendor.trim(),
notes: notes.trim(),
}
try {
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
else await create.mutateAsync(input)
onClose()
} catch (err) {
const current = conflictPlant(err)
if (current) {
setVersion(current.version)
setName(current.name)
setCategory(current.category)
setSpacing(String(spacingFromCm(current.spacingCm, unit)))
setColor(expandHex(current.color))
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
setVendor(current.vendor)
setSourceUrl(current.sourceUrl)
setNotes(current.notes)
setConflict('This plant changed elsewhere. The latest values are shown — look them over and save again.')
return
}
setFormError(errorMessage(err, isEdit ? 'Could not save the plant.' : 'Could not add the plant.'))
}
}
return (
<Dialog title={isEdit ? `Edit ${plant.name}` : 'A new plant'} onClose={onClose} busy={pending}>
<form onSubmit={onSubmit} className="flex flex-col gap-3.5">
{conflict && <Alert tone="info">{conflict}</Alert>}
<TextField label="Name" name="name" required autoFocus placeholder="Delicata squash" value={name} onChange={(e) => setName(e.target.value)} />
<div className="flex gap-2.5">
<SelectField
label="Category"
name="category"
value={category}
onChange={(e) => setCategory(e.target.value as PlantCategory)}
options={categoryOptions}
wrapperClassName="flex-1"
/>
<TextField
label={`Spacing (${unitLabel})`}
name="spacing"
type="number"
inputMode="decimal"
step="any"
min="1"
required
value={spacing}
onChange={(e) => setSpacing(e.target.value)}
wrapperClassName="flex-1"
/>
</div>
<Field label="Marker color">
<ColorSwatches value={color} onChange={setColor} />
</Field>
<TextField
label="Days to maturity (optional)"
name="days"
type="number"
inputMode="numeric"
step="1"
min="1"
value={days}
onChange={(e) => setDays(e.target.value)}
/>
{!more ? (
<button type="button" className="btn btn-ghost self-start text-[13px]" onClick={() => setMore(true)}>
Vendor, source link &amp; notes
</button>
) : (
<div className="flex flex-col gap-3.5 rounded-md border border-divider bg-bg p-3.5">
<div className="flex gap-2.5">
<TextField label="Vendor" name="vendor" placeholder="Johnny's" value={vendor} onChange={(e) => setVendor(e.target.value)} wrapperClassName="flex-1" />
<TextField
label="Source link"
name="sourceUrl"
type="url"
inputMode="url"
placeholder="https://…"
value={sourceUrl}
onChange={(e) => setSourceUrl(e.target.value)}
wrapperClassName="flex-1"
/>
</div>
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
)}
{formError && <Alert>{formError}</Alert>}
<div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={pending}>
Never mind
</Button>
<Button type="submit" variant="primary" disabled={pending}>
{pending ? 'Saving…' : isEdit ? 'Save' : 'Add it'}
</Button>
</div>
</form>
</Dialog>
)
}