Address Gadfly review on the units fix
Build image / build-and-push (push) Successful in 8s

Five findings, all fair:

The soft-minimum hint contradicted itself. "did you mean ft?" made sense when
the grid field was labelled inches sitting under a feet field — which is exactly
what this PR removed. The remaining failure mode is different: someone reaching
for plant spacing, which belongs to the bed, not the garden. The hint now says
that, and points at where the bed grid actually lives. The stale "1 typed
meaning one foot" comments went with it.

gridNote was a three-level nested ternary; it is now gridNoteFor, with the same
logic in explicit branches and the reasoning in one doc comment rather than
threaded between the arms.

cmFromDisplay(parseFloat(gridSize), unit) was computed once for the hint and
again in the submit handler. Hoisted to a single per-render value so the two
can't disagree about what was entered.

roundCm's comment called the 1e-6 cm grain "1 nanometer"; it is 10 nm. Corrected
— the constant was always fine, only the comment was wrong.

Extracted the duplicated overlay-badge class string.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01H3zbym8Doka2d7D48maSgZ
This commit is contained in:
2026-07-21 00:49:23 -04:00
co-authored by Claude Opus 4.8
parent 782f2394b2
commit 0169e32584
4 changed files with 45 additions and 29 deletions
+5
View File
@@ -0,0 +1,5 @@
{
"enabledPlugins": {
"frontend-design@claude-plugins-official": true
}
}
+10 -6
View File
@@ -68,6 +68,14 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setUnit(next)
}
// Converted once per render and used by both the submit handler and the
// too-fine hint, so the two can never disagree about what was entered.
const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit)
// Soft floor: hint, don't refuse. A garden-scale grid this fine is usually
// someone reaching for plant spacing, which lives on the bed instead — but it
// is a legitimate choice for a very small garden, so the save still goes through.
const gridTooFine = Number.isFinite(gridSizeCm) && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) {
e.preventDefault()
setFormError(null)
@@ -86,7 +94,6 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
setFormError('Width and height must be between 1 cm and 100 m.')
return
}
const gridSizeCm = cmFromDisplay(parseFloat(gridSize), unit)
if (!isValidDimensionCm(gridSizeCm)) {
setFormError('Grid size must be between 1 cm and 100 m.')
return
@@ -130,10 +137,6 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
}
const unitLabel = dimensionUnitLabel(unit)
// Soft floor: warn, don't refuse. A sub-4″ garden grid is almost always "1"
// typed meaning one foot, but it's a legitimate choice for a tiny garden.
const gridEntered = cmFromDisplay(parseFloat(gridSize), unit)
const gridTooFine = Number.isFinite(gridEntered) && gridEntered > 0 && gridEntered < MIN_GARDEN_GRID_CM
return (
<Modal title={isEdit ? 'Edit garden' : 'New garden'} onClose={onClose} busy={pending}>
@@ -201,7 +204,8 @@ export function GardenFormModal({ garden, onClose }: { garden?: Garden; onClose:
</div>
{gridTooFine && (
<p className="mt-1 text-xs text-muted">
That's a {formatCm(gridEntered, unit)} grid for the whole garden — did you mean {unitLabel}?
{formatCm(gridSizeCm, unit)} is very fine for a whole-garden grid. Plant spacing lives on each bed
(Bed grid in the inspector), not here.
</p>
)}
</div>
+24 -17
View File
@@ -8,7 +8,7 @@ import {
type Rect,
type Size,
} from '@/lib/geometry'
import { formatCm } from '@/lib/units'
import { formatCm, type UnitPref } from '@/lib/units'
import { useCreateObject, useCreatePlanting } from '@/lib/objects'
import type { Plant } from '@/lib/plants'
import type { EditorPlanting } from '@/lib/plantings'
@@ -24,8 +24,28 @@ import type { EditorGarden, EditorObject } from './types'
const GRID_MIN_CELL_PX = 6
const GRID_OPACITY = 0.18
const OVERLAY_BADGE_CLASS = 'rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur'
const BED_GRID_MAX_LINES = 200 // safety cap on lines drawn inside one bed
/**
* What to tell the user about the grid they're looking at, or null when the
* drawn lines are simply the garden's own grid and need no explanation. Two
* states are worth naming rather than leaving them to infer: lines drawn every
* Nth grid cell (the grid was too fine to draw at this zoom), and — degenerate —
* a grid so fine no multiple of it is drawable, which matters most when snapping
* is on and would otherwise be invisibly active (#47).
*/
function gridNoteFor(
drawnCm: number | null,
gridCm: number,
snapToGrid: boolean,
unit: UnitPref,
): string | null {
if (drawnCm == null) return snapToGrid ? 'grid too fine to draw — zoom in' : null
if (drawnCm === gridCm) return null
return `grid every ${formatCm(drawnCm, unit)}`
}
/** The world-space bounds rect of an object (center + size → top-left rect). */
function objectRect(o: EditorObject): Rect {
return { x: o.xCm - o.widthCm / 2, y: o.yCm - o.heightCm / 2, w: o.widthCm, h: o.heightCm }
@@ -113,16 +133,7 @@ export function GardenCanvas({
// the drawn lines are a subset of the snap positions, never a different grid.
const gridCm = garden.gridSizeCm
const drawnGridCm = visibleGridStepCm(gridCm, viewport.scale, GRID_MIN_CELL_PX)
// Two states worth explaining rather than leaving the user to infer: lines that
// are every Nth grid cell, and (degenerate) a grid too fine to draw at all.
const gridNote =
drawnGridCm == null
? garden.snapToGrid
? 'grid too fine to draw — zoom in'
: null
: drawnGridCm !== gridCm
? `grid every ${formatCm(drawnGridCm, garden.unitPref)}`
: null
const gridNote = gridNoteFor(drawnGridCm, gridCm, garden.snapToGrid, garden.unitPref)
const sorted = useMemo(() => [...objects].sort((a, b) => a.zIndex - b.zIndex), [objects])
const rendered = useMemo(
@@ -300,12 +311,8 @@ export function GardenCanvas({
</svg>
<div className="pointer-events-none absolute left-3 top-3 flex flex-col items-start gap-1">
<span className="rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">
{viewport.scale.toFixed(2)} px/cm
</span>
{gridNote && (
<span className="rounded-md bg-surface/80 px-2 py-1 text-xs text-muted backdrop-blur">{gridNote}</span>
)}
<span className={OVERLAY_BADGE_CLASS}>{viewport.scale.toFixed(2)} px/cm</span>
{gridNote && <span className={OVERLAY_BADGE_CLASS}>{gridNote}</span>}
</div>
<button
type="button"
+6 -6
View File
@@ -12,9 +12,9 @@ const INCHES_PER_FOOT = 12
export const MIN_DIMENSION_CM = 1
export const MAX_DIMENSION_CM = 10_000
// A garden grid finer than this at *dimension* scale is a mis-entry, not a
// preference (typing "1" in a feet field meaning one foot, and landing on a 1 cm
// grid). Soft: the form warns, it doesn't refuse.
// Below this, a garden-scale grid is fine enough to be worth questioning — it
// usually means someone reached for plant spacing, which belongs to the bed, not
// the garden. Soft: the form hints, it doesn't refuse.
export const MIN_GARDEN_GRID_CM = 10
/** Whether a centimeter dimension is within the server's accepted range. */
@@ -23,9 +23,9 @@ export function isValidDimensionCm(cm: number): boolean {
}
/** Round away IEEE-754 dust without rounding away precision: 12 × 2.54 is
* 30.479999999999997 in binary floating point, and 1 nanometer is far below
* anything a garden cares about. Every cm column is REAL, so entry stays exact
* — this only stops 30.48 from being stored as 30.479999999999997. */
* 30.479999999999997 in binary floating point, and the 1e-6 cm (10 nm) grain
* here is far below anything a garden cares about. Every cm column is REAL, so
* entry stays exact — this only stops 30.48 being stored as 30.479999999999997. */
function roundCm(cm: number): number {
return Math.round(cm * 1e6) / 1e6
}