diff --git a/web/src/components/ui/toast.tsx b/web/src/components/ui/toast.tsx
index a1810b2..3d41450 100644
--- a/web/src/components/ui/toast.tsx
+++ b/web/src/components/ui/toast.tsx
@@ -29,23 +29,24 @@ export const toast = {
error: (m: string) => useToastStore.getState().push(m, 'error'),
}
-function ToastItem({ toast }: { toast: Toast }) {
+// Param is `item`, not `toast`, so it doesn't shadow the module's `toast` export.
+function ToastItem({ item }: { item: Toast }) {
const dismiss = useToastStore((s) => s.dismiss)
useEffect(() => {
- const t = setTimeout(() => dismiss(toast.id), 4000)
+ const t = setTimeout(() => dismiss(item.id), 4000)
return () => clearTimeout(t)
- }, [toast.id, dismiss])
+ }, [item.id, dismiss])
return (
- {toast.message}
+ {item.message}
)
}
@@ -57,7 +58,7 @@ export function Toaster() {
return (
{toasts.map((t) => (
-
+
))}
)
diff --git a/web/src/editor/GardenCanvas.tsx b/web/src/editor/GardenCanvas.tsx
index 0d0595d..93f1a4d 100644
--- a/web/src/editor/GardenCanvas.tsx
+++ b/web/src/editor/GardenCanvas.tsx
@@ -12,14 +12,6 @@ const GRID_CM = 100 // 1 m grid
const GRID_MIN_CELL_PX = 6
const GRID_MAX_OPACITY = 0.18
-// Kinds that render beneath beds by default; explicit z_index still wins.
-const UNDER_KINDS = new Set(['path', 'structure', 'in_ground'])
-function defaultZForKind(kind: string): number {
- if (UNDER_KINDS.has(kind)) return 0
- if (kind === 'tree') return 2
- return 1
-}
-
/**
* The editor canvas. Pan/zoom/pinch (from useViewport), tap-to-place from the
* armed palette kind, click to select, and — for the selected object —
@@ -78,11 +70,14 @@ export function GardenCanvas({
const cellPx = GRID_CM * viewport.scale
const gridOpacity = Math.max(0, Math.min(GRID_MAX_OPACITY, ((cellPx - GRID_MIN_CELL_PX) / GRID_MIN_CELL_PX) * GRID_MAX_OPACITY))
- // Merge the in-flight live geometry over the server objects, then z-order.
- const rendered = useMemo(() => {
- const merged = liveObject ? objects.map((o) => (o.id === liveObject.id ? liveObject : o)) : objects
- return [...merged].sort((a, b) => a.zIndex - b.zIndex)
- }, [objects, liveObject])
+ // Sort once by z-order; only re-sorts when the server objects change.
+ const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
+ // Merge the in-flight live geometry over the sorted list. A gesture never
+ // changes z, so this stays a cheap O(n) map on every pointermove — no re-sort.
+ const rendered = useMemo(
+ () => (liveObject ? sorted.map((o) => (o.id === liveObject.id ? liveObject : o)) : sorted),
+ [sorted, liveObject],
+ )
const selected = rendered.find((o) => o.id === selectedId) ?? null
// A pointerdown reaching the svg is empty space: place the armed kind there, or
@@ -107,7 +102,7 @@ export function GardenCanvas({
yCm: world.y,
widthCm: def.widthCm,
heightCm: def.heightCm,
- zIndex: defaultZForKind(def.kind),
+ zIndex: def.defaultZ,
},
{ onSuccess: (o) => select(o.id) },
)
diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx
index f1bf99b..7060e69 100644
--- a/web/src/editor/Inspector.tsx
+++ b/web/src/editor/Inspector.tsx
@@ -3,11 +3,19 @@ import { Button } from '@/components/ui/Button'
import { TextField } from '@/components/ui/TextField'
import { TextArea } from '@/components/ui/TextArea'
import { useDeleteObject, useUpdateObject } from '@/lib/objects'
-import { cmFromDisplay, dimensionUnitLabel, displayFromCm, type UnitPref } from '@/lib/units'
+import {
+ cmFromDisplay,
+ dimensionUnitLabel,
+ displayFromCm,
+ MIN_DIMENSION_CM,
+ type UnitPref,
+} from '@/lib/units'
import { kindDef } from './kinds'
import { useEditorStore } from './store'
import type { EditorObject } from './types'
+const DEFAULT_COLOR = '#8a8a8a'
+
/**
* Property panel for the selected object. Each field commits a PATCH on
* blur/change (carrying the object's version); dimensions and position are shown
@@ -35,6 +43,7 @@ export function Inspector({
const [x, setX] = useState(String(displayFromCm(object.xCm, unit)))
const [y, setY] = useState(String(displayFromCm(object.yCm, unit)))
const [rotation, setRotation] = useState(String(Math.round(object.rotationDeg)))
+ const [color, setColor] = useState(object.color ?? DEFAULT_COLOR)
const [confirmingDelete, setConfirmingDelete] = useState(false)
const rootRef = useRef(null)
@@ -50,15 +59,22 @@ export function Inspector({
setX(String(displayFromCm(object.xCm, unit)))
setY(String(displayFromCm(object.yCm, unit)))
setRotation(String(Math.round(object.rotationDeg)))
- }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, unit])
+ setColor(object.color ?? DEFAULT_COLOR)
+ }, [object.version, object.widthCm, object.heightCm, object.xCm, object.yCm, object.rotationDeg, object.name, object.notes, object.color, unit])
const patch = (fields: Partial>) =>
update.mutate({ id: object.id, version: object.version, ...fields })
- const commitDim = (raw: string, current: number, apply: (cm: number) => void) => {
+ // Commit a dimension/position field. `positive` gates width/height (must be
+ // ≥ the server minimum) but not x/y, which may be zero or negative. Compare at
+ // display precision first so a blur without an edit doesn't fire a spurious
+ // PATCH from cm↔ft rounding drift (e.g. 240cm shows 7.9ft → 241cm).
+ const commitDim = (raw: string, current: number, apply: (cm: number) => void, positive = false) => {
const v = parseFloat(raw)
if (!Number.isFinite(v)) return
+ if (v === displayFromCm(current, unit)) return
const cm = cmFromDisplay(v, unit)
+ if (positive && cm < MIN_DIMENSION_CM) return
if (cm !== current) apply(cm)
}
@@ -95,7 +111,7 @@ export function Inspector({
step="any"
value={width}
onChange={(e) => setWidth(e.target.value)}
- onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }))}
+ onBlur={() => commitDim(width, object.widthCm, (cm) => patch({ widthCm: cm }), true)}
/>
setHeight(e.target.value)}
- onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }))}
+ onBlur={() => commitDim(height, object.heightCm, (cm) => patch({ heightCm: cm }), true)}
/>
) => patch({ color: e.target.value })}
+ value={color}
+ // onChange fires continuously while dragging in the native picker;
+ // track it locally for the live swatch and commit one PATCH on blur.
+ onChange={(e: ChangeEvent) => setColor(e.target.value)}
+ onBlur={() => color !== (object.color ?? DEFAULT_COLOR) && patch({ color })}
className="h-9 w-14 cursor-pointer rounded-md border border-border bg-surface"
/>
{object.color && (
-