Add configurable grid + snap to the layout system
Build image / build-and-push (push) Successful in 15s
Gadfly review (reusable) / review (pull_request) Successful in 10m8s
Adversarial Review (Gadfly) / review (pull_request) Successful in 10m9s

Two independent grids, each with a size and a snap toggle:

- Garden grid (owner-set, in the garden settings form): drives the
  editor's visual grid and, when snapping is on, snaps objects on
  place/move and snaps their dimensions to whole grid steps on resize.
- Bed grid (per plantable object, in the bed Inspector): when snapping
  is on, plants snap to the bed's grid on place/move, and the grid is
  drawn inside the focused bed. Plant radius stays free.

Snapping defaults off on both, so existing gardens keep free placement.
Grid spacing shares the [1cm, 100m] range and defaults to 1m (garden) /
30cm (bed); 0 is treated as unset and re-defaulted.

Backend: migration 0005 adds grid_size_cm/snap_to_grid to gardens and
garden_objects, threaded through domain/store/service/api with service +
api round-trip tests. Frontend: new geometry snap helpers (unit-tested),
schema/type plumbing, canvas + overlay snapping, and the two forms.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01JdQpdYYsTgtkJBxbcpAszi
This commit is contained in:
2026-07-19 02:51:44 -04:00
co-authored by Claude Opus 4.8
parent 9968c06243
commit 3f4d5627d8
23 changed files with 553 additions and 64 deletions
+18 -9
View File
@@ -10,29 +10,37 @@ import (
)
// gardenColumns lists the gardens columns in the fixed order scanGarden expects.
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, version, created_at, updated_at`
const gardenColumns = `id, owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid, version, created_at, updated_at`
func scanGarden(s scanner) (*domain.Garden, error) {
var g domain.Garden
var (
g domain.Garden
snap int64
)
if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt,
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt,
); err != nil {
return nil, err
}
g.SnapToGrid = snap != 0
return &g, nil
}
// scanGardenWithRole reads a garden row plus a trailing my_role column into the
// computed Garden.MyRole field (used by the actor-scoped list).
func scanGardenWithRole(s scanner) (*domain.Garden, error) {
var g domain.Garden
var (
g domain.Garden
snap int64
)
if err := s.Scan(
&g.ID, &g.OwnerID, &g.Name, &g.WidthCM, &g.HeightCM,
&g.UnitPref, &g.Notes, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
&g.UnitPref, &g.Notes, &g.GridSizeCM, &snap, &g.Version, &g.CreatedAt, &g.UpdatedAt, &g.MyRole,
); err != nil {
return nil, err
}
g.SnapToGrid = snap != 0
return &g, nil
}
@@ -45,10 +53,10 @@ const maxGardensListed = 1000
// set and validated by the service) and returns the stored row.
func (d *DB) CreateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden, error) {
created, err := scanGarden(d.sql.QueryRowContext(ctx,
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes)
VALUES (?, ?, ?, ?, ?, ?)
`INSERT INTO gardens (owner_id, name, width_cm, height_cm, unit_pref, notes, grid_size_cm, snap_to_grid)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
RETURNING `+gardenColumns,
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes,
g.OwnerID, g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid),
))
if err != nil {
return nil, fmt.Errorf("store: insert garden: %w", err)
@@ -117,11 +125,12 @@ func (d *DB) UpdateGarden(ctx context.Context, g *domain.Garden) (*domain.Garden
updated, err := scanGarden(d.sql.QueryRowContext(ctx,
`UPDATE gardens
SET name = ?, width_cm = ?, height_cm = ?, unit_pref = ?, notes = ?,
grid_size_cm = ?, snap_to_grid = ?,
version = version + 1,
updated_at = strftime('%Y-%m-%dT%H:%M:%SZ', 'now')
WHERE id = ? AND version = ?
RETURNING `+gardenColumns,
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.ID, g.Version,
g.Name, g.WidthCM, g.HeightCM, g.UnitPref, g.Notes, g.GridSizeCM, boolToInt(g.SnapToGrid), g.ID, g.Version,
))
if errors.Is(err, sql.ErrNoRows) {
// No row matched: distinguish "gone" from "stale version".