Polish: imperial, clear-bed, keyboard nudging, empty states (#18)
Build image / build-and-push (push) Successful in 4s

Co-authored-by: Steve Dudenhoeffer <[email protected]>
This commit was merged in pull request #37.
This commit is contained in:
2026-07-19 04:44:26 +00:00
committed by steve
parent c2dd93a93d
commit 245e0cbe71
14 changed files with 268 additions and 11 deletions
+43
View File
@@ -0,0 +1,43 @@
import { Modal } from '@/components/ui/Modal'
import { Button } from '@/components/ui/Button'
import { useClearObject } from '@/lib/objects'
/** Confirm clearing every active plop from a focused bed (soft-remove — the rows
* are kept with removed_at, so history survives). */
export function ClearBedModal({
objectName,
plops,
gardenId,
onClose,
}: {
objectName: string
plops: { id: number; version: number }[]
gardenId: number
onClose: () => void
}) {
const clear = useClearObject(gardenId)
const n = plops.length
return (
<Modal title="Clear bed" onClose={onClose} busy={clear.isPending}>
<div className="flex flex-col gap-4">
<p className="text-sm text-muted">
Remove all <span className="font-medium text-fg">{n}</span> {n === 1 ? 'plant' : 'plants'} from{' '}
<span className="font-medium text-fg">{objectName}</span>? They're marked removed but kept in history.
</p>
<div className="flex justify-end gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={clear.isPending}>
Cancel
</Button>
<Button
type="button"
variant="danger"
disabled={clear.isPending || n === 0}
onClick={() => clear.mutate(plops, { onSuccess: onClose })}
>
{clear.isPending ? 'Clearing' : 'Clear bed'}
</Button>
</div>
</div>
</Modal>
)
}
+18
View File
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react'
import { cn } from '@/lib/cn'
/** A non-interactive hint overlaid on the canvas (empty-state guidance). */
export function EditorHint({ position = 'center', children }: { position?: 'center' | 'top'; children: ReactNode }) {
return (
<div
className={cn(
'pointer-events-none absolute flex p-6 text-center',
position === 'top' ? 'inset-x-0 top-16 justify-center' : 'inset-0 items-center justify-center',
)}
>
<p className="max-w-xs rounded-lg bg-surface/85 px-4 py-3 text-sm text-muted shadow-sm backdrop-blur">
{children}
</p>
</div>
)
}
+4
View File
@@ -37,6 +37,9 @@ export const PlopMarker = memo(function PlopMarker({
const showIcon = scale >= SEMANTIC_FAR && !!plant?.icon
const showText = scale >= SEMANTIC_NEAR && !!plant
const count = plop.count ?? (plant ? computeDerivedCount(plop.radiusCm, plant.spacingCm) : plop.derivedCount)
// Keep the tap target at least ~44px across even when the plop draws tiny at
// low zoom (fill=transparent still receives pointer events, unlike fill=none).
const hitR = Math.max(r, 22 / scale)
function down(e: PointerEvent) {
e.stopPropagation()
@@ -45,6 +48,7 @@ export const PlopMarker = memo(function PlopMarker({
return (
<g transform={`translate(${plop.xCm} ${plop.yCm})`} onPointerDown={down} style={{ cursor: 'pointer' }}>
<circle cx={0} cy={0} r={hitR} fill="transparent" />
<circle
cx={0}
cy={0}
+5
View File
@@ -27,3 +27,8 @@ export const OBJECT_KINDS: KindDef[] = [
export function kindDef(kind: string): KindDef | undefined {
return OBJECT_KINDS.find((k) => k.kind === kind)
}
/** A human label for an object: its name, else its kind's label, else "Object". */
export function objectDisplayName(o: { name: string; kind: string }): string {
return o.name || kindDef(o.kind)?.label || 'Object'
}