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:
@@ -0,0 +1,215 @@
|
||||
import { useState } from 'react'
|
||||
import { ColorDot } from '@/components/plants/Monogram'
|
||||
import { Button, IconButton } from '@/components/ui/Button'
|
||||
import { cn } from '@/lib/cn'
|
||||
import type { FillLayout } from '@/lib/objects'
|
||||
import type { Plant } from '@/lib/plants'
|
||||
import type { EditorPlanting } from '@/lib/plantings'
|
||||
import { formatQuantity, type SeedLot } from '@/lib/seedLots'
|
||||
import { formatSize, formatSpacing, type UnitPref } from '@/lib/units'
|
||||
import { lotChoices, orderedPlants, toggleArmedKind, toggleArmedPlant } from './arming'
|
||||
import { KindSwatch } from './KindSwatch'
|
||||
import { OBJECT_KINDS } from './kinds'
|
||||
import { useEditorStore } from './store'
|
||||
import type { EditorObject } from './types'
|
||||
|
||||
/**
|
||||
* The desktop editor's left card. Out of focus: the seven object kinds as pill
|
||||
* rows — click to arm (then click the plan), or drag one straight onto it. In a
|
||||
* focused bed it swaps to the plant list with a search, same arm/drag behavior,
|
||||
* plus the bed's bulk tools (fill, clear, scan a packet).
|
||||
*/
|
||||
export function Toolkit({
|
||||
unit,
|
||||
plants,
|
||||
plantings,
|
||||
lotsByPlant,
|
||||
canEdit,
|
||||
focused,
|
||||
focusedPlopCount,
|
||||
canScan,
|
||||
filling,
|
||||
onBack,
|
||||
onFill,
|
||||
onClear,
|
||||
onScan,
|
||||
}: {
|
||||
unit: UnitPref
|
||||
plants: Plant[]
|
||||
plantings: EditorPlanting[]
|
||||
lotsByPlant: Map<number, SeedLot[]>
|
||||
canEdit: boolean
|
||||
focused: EditorObject | null
|
||||
focusedPlopCount: number
|
||||
canScan: boolean
|
||||
filling: boolean
|
||||
onBack: () => void
|
||||
onFill: (layout: FillLayout) => void
|
||||
onClear: () => void
|
||||
onScan: () => void
|
||||
}) {
|
||||
const armedKind = useEditorStore((s) => s.armedKind)
|
||||
const armedPlant = useEditorStore((s) => s.armedPlant)
|
||||
const armedLotId = useEditorStore((s) => s.armedLotId)
|
||||
const setArmedLotId = useEditorStore((s) => s.setArmedLotId)
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
return (
|
||||
<div className="panel flex min-h-0 flex-col gap-2 overflow-y-auto overflow-x-hidden p-3.5">
|
||||
{!focused ? (
|
||||
<>
|
||||
<h6 className="mx-1 mb-1.5 mt-1">Toolkit</h6>
|
||||
{OBJECT_KINDS.map((k) => {
|
||||
const armed = armedKind === k.kind
|
||||
return (
|
||||
<button
|
||||
key={k.kind}
|
||||
type="button"
|
||||
draggable={canEdit}
|
||||
disabled={!canEdit}
|
||||
onDragStart={(e) => e.dataTransfer.setData('text/plain', `kind:${k.kind}`)}
|
||||
onClick={() => toggleArmedKind(k.kind)}
|
||||
aria-pressed={armed}
|
||||
title="Drag onto the plan — or click to arm, then click the plan"
|
||||
className={cn(
|
||||
'flex items-center gap-2.5 rounded-full border px-2.5 py-[7px] text-left disabled:cursor-default disabled:opacity-60',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-transparent hover:bg-neutral-200',
|
||||
)}
|
||||
>
|
||||
<KindSwatch def={k} />
|
||||
<span className="flex flex-col items-start gap-px">
|
||||
<span className="whitespace-nowrap text-[13px] font-bold">{k.label}</span>
|
||||
<span className="whitespace-nowrap text-[11px] text-ink-mute">{formatSize(k.widthCm, k.heightCm, unit)}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<div className="mt-auto px-1.5 pb-0.5 pt-2 text-xs leading-relaxed text-ink-mute">
|
||||
{canEdit ? 'Drag a shape onto the plan. Double-click any bed to plant it.' : 'You can look but not change this garden.'}
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="mb-1 mt-0.5 flex items-center gap-2">
|
||||
<IconButton label="Back to the plan" icon="chevron-left" size={30} onClick={onBack} />
|
||||
<span className="min-w-0 truncate font-heading text-base leading-[1.15]" title={focused.name}>
|
||||
{focused.name}
|
||||
</span>
|
||||
</div>
|
||||
{canEdit && focused.plantable ? (
|
||||
<>
|
||||
<input
|
||||
className="input"
|
||||
placeholder="Find a plant…"
|
||||
aria-label="Find a plant"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
/>
|
||||
{orderedPlants(plants, plantings, query).map((p) => {
|
||||
const armed = armedPlant?.id === p.id
|
||||
const choices = armed ? lotChoices(armedPlant, lotsByPlant) : []
|
||||
return (
|
||||
<div key={p.id} className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
draggable
|
||||
onDragStart={(e) => {
|
||||
// Dragging arms too, so the drop knows which plant (and lot).
|
||||
if (!armed) toggleArmedPlant(p, lotsByPlant)
|
||||
e.dataTransfer.setData('text/plain', `plant:${p.id}`)
|
||||
}}
|
||||
onClick={() => toggleArmedPlant(p, lotsByPlant)}
|
||||
aria-pressed={armed}
|
||||
title="Drag into the bed — or click to arm, then click the bed"
|
||||
className={cn(
|
||||
'flex items-center gap-[9px] rounded-full border px-3 py-2 text-left',
|
||||
armed ? 'border-accent-400 bg-accent-200' : 'border-divider bg-bg hover:border-neutral-400',
|
||||
)}
|
||||
>
|
||||
<ColorDot color={p.color} size={16} />
|
||||
<span className="min-w-0 truncate text-[13px] font-bold">{p.name}</span>
|
||||
<span className="ml-auto flex-none text-[11px] text-ink-mute">{formatSpacing(p.spacingCm, unit)} apart</span>
|
||||
</button>
|
||||
{choices.length > 0 && <LotChooser lots={choices} value={armedLotId} onChange={setArmedLotId} />}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
{plants.length === 0 && <p className="px-1 text-xs text-ink-mute">No plants in the catalog yet.</p>}
|
||||
<div className="mt-auto flex flex-col gap-1.5 pt-2">
|
||||
{armedPlant && <FillRow plant={armedPlant} busy={filling} onFill={onFill} />}
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{focusedPlopCount > 0 && (
|
||||
<Button variant="ghost" icon="eraser" iconSize={13} className="px-2.5 text-[13px] text-accent-700" onClick={onClear}>
|
||||
Clear the bed ({focusedPlopCount})
|
||||
</Button>
|
||||
)}
|
||||
{canScan && (
|
||||
<Button variant="ghost" icon="camera" iconSize={13} className="px-2.5 text-[13px]" onClick={onScan}>
|
||||
Scan a packet
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="px-1 text-xs leading-relaxed text-ink-mute">
|
||||
{!canEdit ? 'You can look but not change this garden.' : `${focused.name} isn't plantable — turn that on in the inspector's details.`}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Which packet a planting comes out of, when a plant has more than one. */
|
||||
export function LotChooser({
|
||||
lots,
|
||||
value,
|
||||
onChange,
|
||||
compact = false,
|
||||
}: {
|
||||
lots: SeedLot[]
|
||||
value: number | null
|
||||
onChange: (lotId: number | null) => void
|
||||
compact?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-1', compact ? 'items-center' : 'pl-2')}>
|
||||
<span className="w-full text-[11px] font-bold text-ink-mute">Which packet?</span>
|
||||
{lots.map((lot) => (
|
||||
<button
|
||||
key={lot.id}
|
||||
type="button"
|
||||
className="chip px-3 py-1 text-xs"
|
||||
aria-pressed={value === lot.id}
|
||||
onClick={() => onChange(lot.id)}
|
||||
title={`${formatQuantity(lot.remaining)} of ${formatQuantity(lot.quantity)} ${lot.unit} left`}
|
||||
>
|
||||
{lot.vendor || 'Unnamed lot'}
|
||||
{lot.packedForYear != null ? ` · ${lot.packedForYear}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<button type="button" className="chip px-3 py-1 text-xs" aria-pressed={value === null} onClick={() => onChange(null)}>
|
||||
No particular one
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** Fill the whole bed with the armed plant — rows you could plant from, or
|
||||
* clumps for a quick sketch (#77). */
|
||||
export function FillRow({ plant, busy, onFill }: { plant: Plant; busy: boolean; onFill: (layout: FillLayout) => void }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-1 rounded-[18px] border border-divider bg-bg px-2.5 py-2">
|
||||
<span className="text-[11px] font-bold text-ink-mute">Fill the bed with {plant.name.toLowerCase()}:</span>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('grid')} title="Individual plants at true spacing">
|
||||
rows
|
||||
</button>
|
||||
<button type="button" className="chip px-3 py-1 text-xs" disabled={busy} onClick={() => onFill('clump')} title="Fat clumps — a quick sketch">
|
||||
clumps
|
||||
</button>
|
||||
{busy && <span className="text-[11px] text-ink-mute">filling…</span>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user