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
+251
View File
@@ -0,0 +1,251 @@
import { useState, type KeyboardEvent } from 'react'
import { Alert } from '@/components/ui/Alert'
import { Button, IconButton } from '@/components/ui/Button'
import { TextAreaField, TextField } from '@/components/ui/Field'
import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn'
import {
formatObservedAt,
today,
useCreateJournalEntry,
useDeleteJournalEntry,
useJournal,
useUpdateJournalEntry,
type JournalEntry,
type JournalFilter,
} from '@/lib/journal'
import type { EditorPlanting } from '@/lib/plantings'
import { objectDisplayName } from './kinds'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
/** What a new note attaches to: the selection, else the focused bed, else the
* garden — and how to say it. */
export interface JournalAttach {
objectId?: number
plantingId?: number
label: string | null
}
/**
* The grow journal: entries newest first as cards (what it's about, when, the
* note), a one-line composer that attaches to whatever's selected — Enter
* submits — and, when the rail was opened from a bed's "N notes", a filter chip
* narrowing the list to that thing. Notes get written standing in the garden
* holding a phone, so the composer is never more than a tap away.
*/
export function JournalTab({
gardenId,
canEdit,
currentUserId,
isOwner,
objects,
plantings,
attach,
composerFirst = false,
large = false,
}: {
gardenId: number
canEdit: boolean
currentUserId?: number
isOwner: boolean
objects: EditorObject[]
plantings: EditorPlanting[]
attach: JournalAttach
/** Phone: the input above the entries (the thumb is at the bottom anyway). */
composerFirst?: boolean
large?: boolean
}) {
const scope = useEditorStore((s) => s.journalScope)
const setScope = useEditorStore((s) => s.setJournalScope)
const filter: JournalFilter = scope?.type === 'object' ? { objectId: scope.id } : scope?.type === 'plop' ? { plantingId: scope.id } : {}
const journal = useJournal(gardenId, filter)
const entries = journal.data?.pages.flatMap((p) => p.entries) ?? []
const scopeName =
scope?.type === 'object'
? objectDisplayName(objects.find((o) => o.id === scope.id) ?? { name: '', kind: 'object' })
: scope?.type === 'plop'
? 'one planting'
: null
const composer = canEdit && (
<Composer gardenId={gardenId} attach={attach} large={large} />
)
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
{scopeName && (
<div className="flex items-center gap-2 rounded-full bg-accent-2-200 py-1 pl-3.5 pr-1 text-xs font-semibold text-accent-2-800">
Notes about {scopeName}
<IconButton label="Show the whole journal" icon="x" iconSize={12} variant="plain" size={26} className="ml-auto" onClick={() => setScope(null)} />
</div>
)}
{composerFirst && composer}
{journal.isPending && <p className="text-[13px] text-ink-mute">Loading</p>}
{journal.isError && entries.length === 0 && <Alert>{errorMessage(journal.error, 'Could not load the journal.')}</Alert>}
{journal.isSuccess && entries.length === 0 && (
<p className="text-[13px] leading-relaxed text-ink-mute">
{scopeName
? `Nothing written about ${scopeName} yet.`
: 'Nothing written yet. This is where what happened goes — when something went in, what came up, what the frost got. Next year you get to read it back.'}
</p>
)}
{entries.map((e) => (
<Entry
key={e.id}
entry={e}
gardenId={gardenId}
objects={objects}
plantings={plantings}
canDelete={canEdit && (e.authorId === currentUserId || isOwner)}
canRewrite={canEdit && e.authorId === currentUserId}
large={large}
/>
))}
{journal.hasNextPage && (
<Button variant="ghost" className="self-start text-[13px]" disabled={journal.isFetchingNextPage} onClick={() => void journal.fetchNextPage()}>
{journal.isFetchingNextPage ? 'Loading…' : 'Older notes'}
</Button>
)}
{!composerFirst && <div className="mt-auto pt-1.5">{composer}</div>}
</div>
)
}
function Composer({ gardenId, attach, large }: { gardenId: number; attach: JournalAttach; large: boolean }) {
const create = useCreateJournalEntry(gardenId)
const [body, setBody] = useState('')
const [error, setError] = useState<string | null>(null)
const submit = () => {
const text = body.trim()
if (!text || create.isPending) return
setError(null)
create.mutate(
{ body: text, observedAt: today(), objectId: attach.objectId, plantingId: attach.plantingId },
{ onSuccess: () => setBody(''), onError: (err) => setError(errorMessage(err, "Couldn't save that note.")) },
)
}
const onKey = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
e.preventDefault()
submit()
}
}
return (
<div className="flex flex-col gap-1.5">
<div className="flex gap-1.5">
<input
className={cn('input', large && 'input-lg')}
placeholder={attach.label ? `Note something about ${attach.label}` : 'Note something…'}
aria-label="New journal note"
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={onKey}
/>
<IconButton label="Log it" icon="plus" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={create.isPending || !body.trim()} onClick={submit} />
</div>
{error && <Alert>{error}</Alert>}
</div>
)
}
function Entry({
entry,
gardenId,
objects,
plantings,
canDelete,
canRewrite,
large,
}: {
entry: JournalEntry
gardenId: number
objects: EditorObject[]
plantings: EditorPlanting[]
canDelete: boolean
canRewrite: boolean
large: boolean
}) {
const update = useUpdateJournalEntry(gardenId)
const del = useDeleteJournalEntry(gardenId)
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(entry.body)
const [observedAt, setObservedAt] = useState(entry.observedAt)
const [error, setError] = useState<string | null>(null)
// A plop-level note names its bed; an object-level note names the object.
const objectId = entry.objectId ?? (entry.plantingId != null ? plantings.find((p) => p.id === entry.plantingId)?.objectId : undefined)
const about = objects.find((o) => o.id === objectId)
const aboutLabel = about ? objectDisplayName(about) + (entry.plantingId != null ? ' · planting' : '') : entry.plantingId != null ? 'a planting' : 'Garden'
const save = () => {
const text = draft.trim()
if (!text) return setError("An entry needs some text — delete it instead if that's what you meant.")
setError(null)
update.mutate(
{ id: entry.id, version: entry.version, body: text, observedAt },
{ onSuccess: () => setEditing(false), onError: (err) => setError(errorMessage(err, "Couldn't save that edit.")) },
)
}
return (
<div className="rounded-md border border-divider bg-bg px-3.5 py-3">
<div className="mb-[5px] flex items-center gap-1.5">
<span className="text-[11.5px] font-bold text-accent-2-700">{aboutLabel}</span>
<span className="ml-auto text-[11.5px] text-ink-mute" title={`by ${entry.authorName}`}>
{formatObservedAt(entry.observedAt)}
</span>
</div>
{editing ? (
<div className="flex flex-col gap-2">
<TextAreaField label="Note" name={`entry-${entry.id}`} rows={3} className={cn(large && 'text-base')} value={draft} onChange={(e) => setDraft(e.target.value)} />
<TextField label="Observed on" name={`observed-${entry.id}`} type="date" value={observedAt} onChange={(e) => setObservedAt(e.target.value)} />
<div className="flex justify-end gap-1">
<Button
variant="ghost"
className="px-2.5 text-[13px]"
onClick={() => {
setDraft(entry.body)
setObservedAt(entry.observedAt)
setEditing(false)
setError(null)
}}
>
Cancel
</Button>
<Button variant="primary" className="px-3.5 text-[13px]" disabled={update.isPending} onClick={save}>
{update.isPending ? 'Saving…' : 'Save'}
</Button>
</div>
</div>
) : (
<div className={cn('whitespace-pre-wrap leading-relaxed', large ? 'text-[13.5px]' : 'text-[13px]')}>{entry.body}</div>
)}
{(canRewrite || canDelete) && !editing && (
<div className="-mb-1 mt-1 flex justify-end gap-0.5">
{canRewrite && (
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" onClick={() => setEditing(true)}>
Edit
</button>
)}
{canDelete && (
<button
type="button"
className="btn btn-ghost px-2 py-0.5 text-[11.5px] text-accent-700"
disabled={del.isPending}
onClick={() => {
setError(null)
del.mutate(entry.id, { onError: (err) => setError(errorMessage(err, "Couldn't delete that note.")) })
}}
>
Delete
</button>
)}
</div>
)}
{error && <p className="mt-1 text-xs font-semibold text-accent-700">{error}</p>}
</div>
)
}