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
+191
View File
@@ -0,0 +1,191 @@
import { Component, Suspense, useEffect, useRef, useState, type ReactNode } from 'react'
import { Alert } from '@/components/ui/Alert'
import { IconButton } from '@/components/ui/Button'
import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn'
import { describeStep, streamChat, useAgentHistory, useAgentRefresh, useClearAgentHistory, type AgentStep, type AgentTurn } from '@/lib/agent'
import type { useUndo } from '@/lib/history'
import { lazyPage } from '@/lib/lazyPage'
// Lazy so the markdown renderer loads only when an assistant message renders.
const MarkdownMessage = lazyPage(() => import('./MarkdownMessage'), 'MarkdownMessage')
/** Falls back to the raw text if the markdown chunk can't load or throws. */
class MarkdownBoundary extends Component<{ fallback: ReactNode; children: ReactNode }, { failed: boolean }> {
state = { failed: false }
static getDerivedStateFromError() {
return { failed: true }
}
render() {
return this.state.failed ? this.props.fallback : this.props.children
}
}
/**
* Talk to the garden assistant beside the canvas — watching the plan change as
* it works IS the confirmation. Every turn lands as one undoable step, so each
* assistant reply that changed something carries its own Undo.
*/
export function AssistantTab({ gardenId, canEdit, undo, large = false }: { gardenId: number; canEdit: boolean; undo: ReturnType<typeof useUndo>; large?: boolean }) {
const history = useAgentHistory(gardenId, true)
const clear = useClearAgentHistory(gardenId)
const refresh = useAgentRefresh(gardenId)
const [input, setInput] = useState('')
const [pending, setPending] = useState<{ message: string; steps: AgentStep[] } | null>(null)
const [error, setError] = useState<string | null>(null)
const [warning, setWarning] = useState<string | null>(null)
const abort = useRef<AbortController | null>(null)
const bottom = useRef<HTMLDivElement>(null)
// Deliberately NOT aborted on unmount: selecting a bed switches the rail to
// the inspector, and that must not kill a turn mid-flight. The request runs
// on; the exchange is persisted server-side; coming back shows it.
useEffect(() => {
bottom.current?.scrollIntoView({ behavior: 'smooth', block: 'end' })
}, [history.data, pending])
const send = () => {
const message = input.trim()
if (!message || pending) return
setInput('')
setError(null)
setWarning(null)
setPending({ message, steps: [] })
const controller = new AbortController()
abort.current = controller
void streamChat(
gardenId,
message,
{
onStep: (step) => {
setPending((p) => (p ? { ...p, steps: [...p.steps, step] } : p))
refresh.canvas()
},
onDone: (turn: AgentTurn) => {
setPending(null)
refresh.everything()
if (turn.truncated) setError('That turned into more steps than I should take at once — check what changed before continuing.')
},
onWarning: setWarning,
onError: (m) => {
setPending(null)
setError(m)
refresh.everything()
},
},
controller.signal,
)
}
const stop = () => {
abort.current?.abort()
setPending(null)
refresh.everything()
}
const messages = history.data ?? []
const bubbleText = large ? 'text-[13.5px]' : 'text-[13px]'
return (
<div className="flex min-h-0 flex-1 flex-col gap-2.5">
{!canEdit && <Alert tone="info">You can only view this garden, so the assistant can't change anything in it.</Alert>}
{history.isPending && <p className="text-[13px] text-ink-mute">Loading the conversation…</p>}
{history.isError && <Alert>{errorMessage(history.error, "Couldn't load the conversation.")}</Alert>}
{history.isSuccess && messages.length === 0 && !pending && (
<p className="text-[13px] leading-relaxed text-ink-mute">
Ask for what you want and it'll do it — “change the garlic bed to cucumbers this year”, “fill the north half of the
west bed with beans”, “what's in here?”. Everything it does lands as one change you can undo.
</p>
)}
{messages.map((m) =>
m.role === 'user' ? (
<div key={m.id} className={cn('self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText, 'max-w-[85%]')}>
{m.body}
</div>
) : (
<div key={m.id} className="flex max-w-[90%] flex-col items-start gap-1 self-start">
<div className={cn('rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
<MarkdownBoundary fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
<Suspense fallback={<span className="whitespace-pre-wrap">{m.body}</span>}>
<MarkdownMessage>{m.body}</MarkdownMessage>
</Suspense>
</MarkdownBoundary>
</div>
{m.changeSetId != null && canEdit && <TurnUndo changeSetId={m.changeSetId} undo={undo} />}
</div>
),
)}
{pending && (
<>
<div className={cn('max-w-[85%] self-end whitespace-pre-wrap rounded-[18px_18px_4px_18px] bg-accent-200 px-[13px] py-[9px] leading-[1.45] text-accent-900', bubbleText)}>
{pending.message}
</div>
<div className={cn('max-w-[90%] self-start rounded-[18px_18px_18px_4px] border border-divider bg-bg px-[13px] py-[9px] leading-[1.45]', bubbleText)}>
{pending.steps.map((s) => (
<div key={s.index} className="text-xs text-ink-mute">
{describeStep(s)}…
</div>
))}
<p className="flex items-center gap-1.5 text-xs text-ink-mute">
<span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-accent" />
{pending.steps.length === 0 ? 'Thinking…' : 'Working…'}
</p>
</div>
</>
)}
{warning && <Alert tone="info">{warning}</Alert>}
{error && <Alert>{error}</Alert>}
<div ref={bottom} />
<div className="mt-auto flex flex-col gap-1.5 pt-1.5">
{messages.length > 0 && !pending && (
<button
type="button"
className="btn btn-ghost self-end px-2 py-0.5 text-[11.5px]"
disabled={clear.isPending}
onClick={() => clear.mutate(undefined, { onError: (err) => setError(errorMessage(err, "Couldn't clear the conversation.")) })}
>
Start over
</button>
)}
<div className="flex gap-1.5">
<input
className={cn('input', large && 'input-lg')}
placeholder="Ask about your garden…"
aria-label="Message the assistant"
value={input}
disabled={!!pending}
onChange={(e) => setInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault()
send()
}
}}
/>
{pending ? (
<IconButton label="Stop" icon="square" iconSize={large ? 16 : 15} size={large ? 44 : 36} onClick={stop} />
) : (
<IconButton label="Send" icon="send" iconSize={large ? 16 : 15} variant="primary" size={large ? 44 : 36} disabled={!input.trim()} onClick={send} />
)}
</div>
</div>
</div>
)
}
/** Undo on the turn itself, so the common case never involves the History tab. */
function TurnUndo({ changeSetId, undo }: { changeSetId: number; undo: ReturnType<typeof useUndo> }) {
const outcome = undo.outcomeFor(changeSetId)
return (
<div className="flex flex-col items-start gap-0.5 pl-1">
<button type="button" className="btn btn-ghost px-2 py-0.5 text-[11.5px]" disabled={outcome?.tone === 'pending'} onClick={() => undo.undo({ id: changeSetId })}>
{outcome?.tone === 'pending' ? 'Undoing' : 'Undo this'}
</button>
{outcome && outcome.tone !== 'pending' && (
<p role="status" className={cn('text-[11.5px]', outcome.tone === 'ok' ? 'text-ink-mute' : 'font-semibold text-accent-700')}>
{outcome.message}
</p>
)}
</div>
)
}