Replace the UI with the Organic design handoff (docs/design_handoff_pansy_ui)
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:
@@ -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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user