diff --git a/CLAUDE.md b/CLAUDE.md
index 2223ad3..ff148e5 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -134,7 +134,10 @@ Conventions that follow from it:
a view, `cm` changes only when the person types. Never re-parse the display
string on save — "29′ 6.3″" is the nearest tenth of an inch, and parsing it
back is how a no-change Save turned 900 cm into 899.922 (and bumped the
- version, and wrote a bogus history entry). The inspector guards the same way.
+ version, and wrote a bogus history entry). The inspector still keeps display
+ strings but gets the same result by refusing to commit text that still equals
+ the formatted original (`commitDim`); either way, a no-op save sends exactly
+ what was loaded — or nothing.
- **"Today" is the browser's local day**, from `today()` in
`web/src/lib/dates.ts`, and the UI always sends it: journal `observedAt`,
plop/fill `plantedAt`, `removedAt`. The server's UTC default is only for
diff --git a/web/src/components/gardens/CopyDialog.tsx b/web/src/components/gardens/CopyDialog.tsx
index a741a0b..fdc107d 100644
--- a/web/src/components/gardens/CopyDialog.tsx
+++ b/web/src/components/gardens/CopyDialog.tsx
@@ -1,4 +1,4 @@
-import { useState, type FormEvent } from 'react'
+import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button'
@@ -27,7 +27,13 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
const year = nextPlanYear(base, names, from)
const [name, setName] = useState(() => planNameFor(base, year))
+ const [touched, setTouched] = useState(false)
const [error, setError] = useState(null)
+ // The gardens list can still be loading when this opens; until the person
+ // edits the name, keep the proposal in step with what the list says is free.
+ useEffect(() => {
+ if (!touched) setName(planNameFor(base, year))
+ }, [base, year, touched])
// The API allows duplicate names; say so rather than let two gardens read as
// the same season's plan.
const taken = names.some((n) => n.trim() === name.trim())
@@ -52,7 +58,17 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
A copy of {garden.name} to scheme in — rearrange freely, the
original stays put. Beds and what's planted come along; shares and the public link don't.
Keep the “— {year}” and it shows up as that season's plan in the editor.
{taken && You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.}
{error && {error}}
diff --git a/web/src/components/gardens/GardenCard.tsx b/web/src/components/gardens/GardenCard.tsx
index 61b9c06..63f23f3 100644
--- a/web/src/components/gardens/GardenCard.tsx
+++ b/web/src/components/gardens/GardenCard.tsx
@@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb'
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure']
/**
- * One garden as a card: the plot thumbnail (a link into the editor), name (a
- * plan copy shows its base name and a ` plan` tag), size, a counts line, who it's shared with, and a footer
- * of Open + share / copy / edit / delete. A garden shared WITH you shows its
- * role and a leave action instead of the owner's tools.
+ * One garden as a card: the plot thumbnail (a link into the editor), name, size,
+ * a counts line, who it's shared with, and a footer of Open + share / copy /
+ * edit / delete. A plan copy shows its base name with a ` plan` tag. A
+ * garden shared WITH you shows its role and a leave action instead of the
+ * owner's tools.
*/
export function GardenCard({
garden,
@@ -40,10 +41,11 @@ export function GardenCard({
const owner = currentUserId != null && garden.ownerId === currentUserId
const full = useGardenFull(garden.id)
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
+ const plan = parsePlanName(garden.name)
const planYear = planYearOf(garden.name)
// A plan's year is the point of its name, and the first thing truncation
// would eat ("Back Yard — 20…"); show the base name and put the year on the tag.
- const title = planYear != null ? (parsePlanName(garden.name)?.base ?? garden.name) : garden.name
+ const title = planYear != null && plan ? plan.base : garden.name
const meta = useMemo(() => {
const data = full.data
diff --git a/web/src/components/gardens/GardenThumb.tsx b/web/src/components/gardens/GardenThumb.tsx
index 3c0462d..a6d8c9a 100644
--- a/web/src/components/gardens/GardenThumb.tsx
+++ b/web/src/components/gardens/GardenThumb.tsx
@@ -1,6 +1,7 @@
import { useMemo } from 'react'
import { localToWorld } from '@/lib/geometry'
import type { FullGarden } from '@/lib/objects'
+import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
import { objectStyle, rectRadius } from '@/editor/kinds'
/**
@@ -72,7 +73,7 @@ export function GardenThumb({
const o = byId.get(p.objectId)
if (!o) return null
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
- return
+ return
})}
)
diff --git a/web/src/editor/Canvas.tsx b/web/src/editor/Canvas.tsx
index 3021de2..3ff9058 100644
--- a/web/src/editor/Canvas.tsx
+++ b/web/src/editor/Canvas.tsx
@@ -12,7 +12,7 @@ import {
import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
-import type { Plant } from '@/lib/plants'
+import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds'
@@ -35,10 +35,6 @@ import {
import { useEditorStore, type Viewport } from './store'
import type { EditorGarden, EditorObject } from './types'
-// A plop whose plant is missing from the catalog (a shared garden's private
-// plant) still needs a color to be drawn in.
-const FALLBACK_PLANT_COLOR = '#97a97c'
-
const WHEEL_SENSITIVITY = 0.0016
const ANIM_MS = 520
const REFIT_THRESHOLD_PX = 60
diff --git a/web/src/editor/Inspector.tsx b/web/src/editor/Inspector.tsx
index 1e25c38..b34f21c 100644
--- a/web/src/editor/Inspector.tsx
+++ b/web/src/editor/Inspector.tsx
@@ -8,7 +8,7 @@ import { Tag } from '@/components/ui/Tag'
import { Toggle } from '@/components/ui/Toggle'
import { cn } from '@/lib/cn'
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects'
-import type { Plant } from '@/lib/plants'
+import { FALLBACK_PLANT_COLOR, type Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
import {
cmFromSpacing,
@@ -356,7 +356,7 @@ export function PlopInspector({
return (