Author SHA1 Message Date
steveandClaude Fable 5 0d95578c6a Address #125 review: memoized ink, one fallback color, reactive copy name
Build image / build-and-push (push) Successful in 10s
- monogramInk is memoized by color string; the canvas asks for every
  visible plop on every frame of a pan (Gadfly, 2/4 models).
- FALLBACK_PLANT_COLOR lives in lib/plants and is used by the canvas, the
  inspector and the garden thumbnail instead of three raw '#97a97c's.
- CopyDialog keeps its proposed "<base> — <year>" in step with the gardens
  list until the person edits the name, so a list that loads after the
  dialog opens can't leave a taken year in the field.
- GardenCard: reflowed the summary comment; no dead fallback on a plan
  name that's already known to parse.
- today() has one import path (lib/dates); the journal re-export is gone.
- CLAUDE.md says what the inspector actually does (a text-compare guard)
  rather than claiming it uses LengthField.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:25:02 -04:00
steveandClaude Fable 5 157e04ed24 Skip no-op saves in the edit dialogs; clear a stale model-spec error
Build image / build-and-push (push) Successful in 26s
A Save that changed nothing still sent a PATCH, which bumped the row's
version and landed an "Edited garden settings" step in History that undid
nothing — the drift is gone since the last commit, but the write was still
there. Both dialogs now close without a request when every field matches
the loaded row.

In Settings, a rejected model spec's reason stayed under the field after
the field was blanked back to the saved value; committing an unchanged
value now clears it.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:22:22 -04:00
steveandClaude Fable 5 27f658c1f7 Smoke-sweep fixes: exact saves, local dates, safer remove, readable markers
Build image / build-and-push (push) Successful in 2m55s
Gadfly review (reusable) / review (pull_request) Successful in 8m59s
Adversarial Review (Gadfly) / review (pull_request) Successful in 8m59s
- Garden and plant dialogs keep centimeters as the source of truth
  (LengthField in lib/units.ts): a no-change Save no longer rewrites
  900 cm as 899.922 or a 45 cm spacing as 44.958, bumping versions and
  writing bogus history entries on the way.
- The UI stamps every date with the browser's local day (lib/dates.ts).
  Journal notes already did; plop placement, fill and removal now do too,
  so a 9 pm placement isn't "planted tomorrow". The fill endpoint gained an
  optional plantedAt; API and agent callers still default to UTC today.
- Removing an object that holds plants asks first and says how many go
  with it. An empty one still goes straight away (one Undo restores it).
- The expanded plant card's action row wraps instead of clipping "Delete".
- Monogram lettering switches to a dark ink on pale marker colors (garlic,
  cabbage, marigold) instead of near-white on near-white.
- Copy-as-plan proposes the next free year and warns when the typed name
  already exists, so two gardens can't both read as "the 2027 plan".
- Plan cards show the base name with a "2027 plan" tag, so the year — the
  point of the name — survives truncation.
- A rejected model spec now says which model and why: a wrapped
  ErrInvalidInput's reason reaches the client as the 400's message, and the
  Settings field shows it inline instead of toasting "invalid input".

Also defuses a clock bomb in TestRemainingReturnsWhenAPlantingIsRemoved,
which only passed while the real date was before 2026-08-01.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-08-22 22:11:12 -04:00
steve 05392ee0db Merge pull request 'Replace the UI with the Organic design handoff' (#124) from feat/organic-ui into main
Build image / build-and-push (push) Successful in 6s
2026-08-22 23:40:23 +00:00
39 changed files with 562 additions and 134 deletions
+18
View File
@@ -129,6 +129,24 @@ Conventions that follow from it:
a constraint between neighbouring plants; a bed edge is nobody's neighbour. a constraint between neighbouring plants; a bed edge is nobody's neighbour.
- **Soft removal**: "clear bed" sets `removed_at`; the editor reads - **Soft removal**: "clear bed" sets `removed_at`; the editor reads
`removed_at IS NULL`. Hard delete is a different operation. `removed_at IS NULL`. Hard delete is a different operation.
- **Length fields keep centimeters as the source of truth.** A dialog field
that takes a length is a `LengthField` (`web/src/lib/units.ts`): the text is
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 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
API callers and the agent. A gardener placing at 9 pm in Ohio planted today,
not tomorrow — don't add a UI path that leaves the date to the server.
- **A wrapped `ErrInvalidInput` is shown to the person verbatim.**
`fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)`
reaches the client as the 400's message (minus the sentinel prefix); the bare
sentinel reads "invalid input". Write the reason for the keyboard, not the log.
- **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run - **Migrations** are numbered `.sql` files in `internal/store/migrations/`, run
at startup, embedded. Never edit one that has shipped. at startup, embedded. Never edit one that has shipped.
- **Every service mutation lands in history** (#48). If you add one, record it — - **Every service mutation lands in history** (#48). If you add one, record it —
+1 -1
View File
@@ -64,7 +64,7 @@ POST /change-sets/:id/revert ← undo an operation; 201, or 409 + the conflicts
POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link) POST /gardens/:id/copy ← deep-copy a garden you own (objects + active plops; not shares/link)
POST /gardens/:id/objects PATCH,DELETE /objects/:id POST /gardens/:id/objects PATCH,DELETE /objects/:id
POST /objects/:id/plantings PATCH,DELETE /plantings/:id POST /objects/:id/plantings PATCH,DELETE /plantings/:id
POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect POST /objects/:id/fill ← hex-pack a region with one plant; region by compass name or rect; optional plantedAt (default UTC today)
POST /objects/:id/clear ← soft-remove every active plop, as ONE change set POST /objects/:id/clear ← soft-remove every active plop, as ONE change set
GET,POST /plants PATCH,DELETE /plants/:id (own plants only) GET,POST /plants PATCH,DELETE /plants/:id (own plants only)
GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private) GET,POST /seed-lots GET,PATCH,DELETE /seed-lots/:id (own lots only; private)
+1 -1
View File
@@ -60,7 +60,7 @@ func TestTurnIsOneChangeSet(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed garlic: %v", err) t.Fatalf("seed garlic: %v", err)
} }
+3 -1
View File
@@ -163,7 +163,9 @@ func (a *adapter) fillRegion(ctx context.Context, args struct {
SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"` SpacingOverride *float64 `json:"spacingOverrideCm" description:"optional in-row spacing override in cm; omit to use the plant's spacing"`
Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"` Mode string `json:"mode" enum:"clump,grid" description:"clump (default) drops a few fat clumps for a quick sketch; grid lays out individual plants in rows at true spacing, a layout you could plant from"`
}) (any, error) { }) (any, error) {
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode)) // nil: the agent runs server-side with no local day, so the fill dates
// plops UTC-today like its create_planting does.
return a.svc.FillNamedRegion(ctx, a.actor, args.ObjectID, args.Region, args.PlantID, args.SpacingOverride, service.FillLayout(args.Mode), nil)
} }
func (a *adapter) findPlant(ctx context.Context, args struct { func (a *adapter) findPlant(ctx context.Context, args struct {
+1 -1
View File
@@ -169,7 +169,7 @@ func TestGarlicBedToCucumbers(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("bed: %v", err) t.Fatalf("bed: %v", err)
} }
if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump); err != nil { if _, err := svc.FillNamedRegion(ctx, owner, bed.ID, "all", garlic.ID, nil, service.FillClump, nil); err != nil {
t.Fatalf("seed the garlic: %v", err) t.Fatalf("seed the garlic: %v", err)
} }
+17 -1
View File
@@ -6,6 +6,7 @@ import (
"log/slog" "log/slog"
"net/http" "net/http"
"strconv" "strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -52,7 +53,7 @@ func writeServiceError(c *gin.Context, err error) {
case errors.Is(err, domain.ErrOIDCIdentityConflict): case errors.Is(err, domain.ErrOIDCIdentityConflict):
writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account") writeAPIError(c, http.StatusConflict, "OIDC_IDENTITY_CONFLICT", "this identity conflicts with an existing account")
case errors.Is(err, domain.ErrInvalidInput): case errors.Is(err, domain.ErrInvalidInput):
writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", "invalid input") writeAPIError(c, http.StatusBadRequest, "INVALID_INPUT", inputMessage(err))
default: default:
slog.Error("api: unhandled service error", "error", err) slog.Error("api: unhandled service error", "error", err)
writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error") writeAPIError(c, http.StatusInternalServerError, "INTERNAL", "internal error")
@@ -114,3 +115,18 @@ func parseIDParam(c *gin.Context, name string) (int64, bool) {
} }
return id, true return id, true
} }
// inputMessage is the text a 400 carries for an ErrInvalidInput. The bare
// sentinel reads "invalid input"; a service that wraps it with a reason —
// fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, spec)
// — has that reason shown to the person verbatim, minus the sentinel prefix.
// So anything wrapped this way is written for the keyboard, not the log (see
// the note on domain.ErrInvalidInput).
func inputMessage(err error) string {
msg := err.Error()
base := domain.ErrInvalidInput.Error()
if msg == base {
return msg
}
return strings.TrimPrefix(msg, base+": ")
}
+30
View File
@@ -0,0 +1,30 @@
package api
import (
"errors"
"fmt"
"testing"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/domain"
)
// TestInputMessage: the bare sentinel stays generic; a wrapped reason reaches
// the person without the "invalid input: " prefix in front of it.
func TestInputMessage(t *testing.T) {
cases := []struct {
err error
want string
}{
{domain.ErrInvalidInput, "invalid input"},
{fmt.Errorf("%w: chat model %q: unknown provider", domain.ErrInvalidInput, "nonesuch/model"), `chat model "nonesuch/model": unknown provider`},
{fmt.Errorf("loading: %w", domain.ErrInvalidInput), "loading: invalid input"},
}
for _, c := range cases {
if !errors.Is(c.err, domain.ErrInvalidInput) {
t.Fatalf("%v should still be an ErrInvalidInput", c.err)
}
if got := inputMessage(c.err); got != c.want {
t.Errorf("inputMessage(%v) = %q, want %q", c.err, got, c.want)
}
}
}
+6 -2
View File
@@ -55,6 +55,10 @@ type objectFillRequest struct {
// (individual plants in rows at true spacing). Empty = clump. An unknown value // (individual plants in rows at true spacing). Empty = clump. An unknown value
// is refused by the service (#77). // is refused by the service (#77).
Layout string `json:"layout"` Layout string `json:"layout"`
// PlantedAt dates every plop the fill makes (YYYY-MM-DD). The UI sends its
// local day; omitted, the server uses UTC today — which is tomorrow for an
// evening gardener west of Greenwich, so clients that know better say so.
PlantedAt *string `json:"plantedAt"`
} }
func (h *handlers) fillObject(c *gin.Context) { func (h *handlers) fillObject(c *gin.Context) {
@@ -88,9 +92,9 @@ func (h *handlers) fillObject(c *gin.Context) {
return return
} }
region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY} region := service.Region{MinX: rect.MinX, MinY: rect.MinY, MaxX: rect.MaxX, MaxY: rect.MaxY}
created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillRegion(c.Request.Context(), actor, id, region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} else { } else {
created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout)) created, err = h.svc.FillNamedRegion(c.Request.Context(), actor, id, req.Region, req.PlantID, req.SpacingOverrideCM, service.FillLayout(req.Layout), req.PlantedAt)
} }
if err != nil { if err != nil {
writeServiceError(c, err) writeServiceError(c, err)
+16 -2
View File
@@ -1,7 +1,9 @@
package api package api
import ( import (
"encoding/json"
"net/http" "net/http"
"strings"
"testing" "testing"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -159,10 +161,22 @@ func TestSettingsRejectsBadModel(t *testing.T) {
admin := registerAndCookie(t, r, "[email protected]") admin := registerAndCookie(t, r, "[email protected]")
v := settingsVersion(t, r, admin) v := settingsVersion(t, r, admin)
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "nonesuch/model", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "nonesuch/model", "version": v}, admin)
if w.Code != http.StatusBadRequest {
t.Errorf("bad model: status %d, want 400", w.Code) t.Errorf("bad model: status %d, want 400", w.Code)
} }
// The message says which field and which spec, so the page can show a reason
// rather than a bare "invalid input".
var body struct {
Error struct{ Code, Message string } `json:"error"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatalf("decode bad-model body: %v", err)
}
if body.Error.Code != "INVALID_INPUT" || !strings.Contains(body.Error.Message, "chat model") || !strings.Contains(body.Error.Message, "nonesuch/model") {
t.Errorf("bad model error = %+v, want INVALID_INPUT naming the chat model and spec", body.Error)
}
// agentEnabled must be a bool or null, not a string. // agentEnabled must be a bool or null, not a string.
if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings", if w := doJSON(t, r, http.MethodPatch, "/api/v1/settings",
map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest { map[string]any{"agentModel": "", "agentEnabled": "yes", "version": v}, admin); w.Code != http.StatusBadRequest {
+4 -1
View File
@@ -33,7 +33,10 @@ var (
ErrShareExists = errors.New("garden already shared with that user") ErrShareExists = errors.New("garden already shared with that user")
// ErrInvalidInput means the caller supplied structurally invalid data (empty // ErrInvalidInput means the caller supplied structurally invalid data (empty
// required field, malformed value). Mapped to 400. // required field, malformed value). Mapped to 400. Wrap it with the reason —
// fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", ErrInvalidInput) —
// and the API shows that reason to the person verbatim, so write it for
// them, not for a log; the bare sentinel reads as just "invalid input".
ErrInvalidInput = errors.New("invalid input") ErrInvalidInput = errors.New("invalid input")
// ErrInvalidCredentials means a login attempt failed. It is deliberately // ErrInvalidCredentials means a login attempt failed. It is deliberately
// identical for an unknown email and a wrong password so neither can be // identical for an unknown email and a wrong password so neither can be
+20 -6
View File
@@ -2,6 +2,8 @@ package service
import ( import (
"context" "context"
"errors"
"fmt"
"strings" "strings"
"gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel" "gitea.stevedudenhoeffer.com/steve/pansy/internal/agentmodel"
@@ -61,12 +63,15 @@ func (s *Service) UpdateInstanceSettings(ctx context.Context, actorID int64, pat
model := strings.TrimSpace(patch.AgentModel) model := strings.TrimSpace(patch.AgentModel)
vision := strings.TrimSpace(patch.VisionModel) vision := strings.TrimSpace(patch.VisionModel)
// Validate non-empty specs up front. An empty one is the "inherit env" // Validate non-empty specs up front. An empty one is the "inherit env"
// sentinel and needs no check — the env value was validated at boot. // sentinel and needs no check — the env value was validated at boot. The
for _, spec := range []string{model, vision} { // reason rides on the sentinel so the 400 can show it: "unknown provider"
if spec != "" { // is something a person can act on, "invalid input" is not.
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, spec); err != nil { for _, f := range []struct{ label, spec string }{{"chat model", model}, {"vision model", vision}} {
return nil, domain.ErrInvalidInput if f.spec == "" {
} continue
}
if err := agentmodel.Validate(s.cfg.Agent.OllamaCloudAPIKey, f.spec); err != nil {
return nil, fmt.Errorf("%w: %s %q: %v", domain.ErrInvalidInput, f.label, f.spec, specReason(err))
} }
} }
return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{ return s.store.UpdateInstanceSettings(ctx, &domain.InstanceSettings{
@@ -166,3 +171,12 @@ func (s *Service) EffectiveConfig(ctx context.Context) (EffectiveAgent, Effectiv
} }
return s.agentOver(st), s.visionOver(st), nil return s.agentOver(st), s.visionOver(st), nil
} }
// specReason strips agentmodel's own "resolve %q:" wrapping so the message
// reads "unknown provider …" rather than repeating the spec twice.
func specReason(err error) string {
if u := errors.Unwrap(err); u != nil {
return u.Error()
}
return err.Error()
}
+16 -8
View File
@@ -175,13 +175,15 @@ func validFillLayout(l FillLayout) (FillLayout, bool) {
// in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half- // in from each edge by edgeInset — a half-spacing for grid, radius-less-a-half-
// spacing for a clump (see edgeInset for the why). A candidate is skipped when its // spacing for a clump (see edgeInset for the why). A candidate is skipped when its
// plop would sit entirely inside an existing active plop (so re-filling doesn't // plop would sit entirely inside an existing active plop (so re-filling doesn't
// stack duplicates). Returns the plops it created. // stack duplicates). Every plop is dated plantedAt (YYYY-MM-DD), or UTC today
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { // when nil — the UI always sends its local day, so the default is for API and
// agent callers. Returns the plops it created.
func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout) return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
} }
// fillLoaded is the shared body of FillRegion/FillNamedRegion given an object // fillLoaded is the shared body of FillRegion/FillNamedRegion given an object
@@ -189,10 +191,13 @@ func (s *Service) FillRegion(ctx context.Context, actorID, objectID int64, regio
// non-finite region, clamps the region to the object's bounds, refuses fills over // non-finite region, clamps the region to the object's bounds, refuses fills over
// maxFillPlops, and inserts the whole batch in one transaction rather than one // maxFillPlops, and inserts the whole batch in one transaction rather than one
// round-trip per plop. // round-trip per plop.
func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.GardenObject, region Region, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
if !o.Plantable { if !o.Plantable {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
} }
if !validDatePtr(plantedAt) {
return nil, fmt.Errorf("%w: plantedAt must be a YYYY-MM-DD date", domain.ErrInvalidInput)
}
layout, ok := validFillLayout(layout) layout, ok := validFillLayout(layout)
if !ok { if !ok {
return nil, domain.ErrInvalidInput return nil, domain.ErrInvalidInput
@@ -235,7 +240,10 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if err != nil { if err != nil {
return nil, err return nil, err
} }
today := s.now().UTC().Format(dateLayout) plantedOn := s.now().UTC().Format(dateLayout)
if plantedAt != nil {
plantedOn = *plantedAt
}
batch := make([]*domain.Planting, 0, len(centers)) batch := make([]*domain.Planting, 0, len(centers))
// Only the plops that were ALREADY here can cover a candidate: every plop this // Only the plops that were ALREADY here can cover a candidate: every plop this
// fill makes shares one radius and sits on a distinct lattice point, and a plop // fill makes shares one radius and sits on a distinct lattice point, and a plop
@@ -247,7 +255,7 @@ func (s *Service) fillLoaded(ctx context.Context, actorID int64, o *domain.Garde
if coveredByExisting(c.x, c.y, radius, existing) { if coveredByExisting(c.x, c.y, radius, existing) {
continue continue
} }
batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &today}) batch = append(batch, &domain.Planting{ObjectID: o.ID, PlantID: plantID, XCM: c.x, YCM: c.y, RadiusCM: radius, PlantedAt: &plantedOn})
} }
created, err := s.store.CreatePlantings(ctx, batch) created, err := s.store.CreatePlantings(ctx, batch)
if err != nil { if err != nil {
@@ -376,7 +384,7 @@ func coveredByExisting(x, y, radius float64, existing []domain.Planting) bool {
// FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half") // FillNamedRegion is FillRegion addressed by a compass name ("ne", "south half")
// instead of a resolved Region — the ergonomic form for agent tools, which don't // instead of a resolved Region — the ergonomic form for agent tools, which don't
// hold the object's geometry. It resolves the name against the object, then fills. // hold the object's geometry. It resolves the name against the object, then fills.
func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout) ([]domain.Planting, error) { func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64, regionName string, plantID int64, spacingOverride *float64, layout FillLayout, plantedAt *string) ([]domain.Planting, error) {
o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor) o, _, err := s.objectForRole(ctx, actorID, objectID, roleEditor)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -385,7 +393,7 @@ func (s *Service) FillNamedRegion(ctx context.Context, actorID, objectID int64,
if err != nil { if err != nil {
return nil, err return nil, err
} }
return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout) return s.fillLoaded(ctx, actorID, o, region, plantID, spacingOverride, layout, plantedAt)
} }
// ClearObject soft-removes every active plop in an object the actor can edit (one // ClearObject soft-removes every active plop in an object the actor can edit (one
+43 -12
View File
@@ -59,7 +59,7 @@ func TestFillRegionCappedForHugeArea(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap bed := seedFillBed(t, s, owner, g.ID, 6000, 6000) // ~46k lattice points at radius 15 → over the cap
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err) t.Errorf("oversized fill err = %v, want ErrInvalidInput (over maxFillPlops)", err)
} }
} }
@@ -199,7 +199,7 @@ func TestFillRegionRejectsNonFiniteRegion(t *testing.T) {
{MinX: nan, MinY: -50, MaxX: 50, MaxY: 50}, {MinX: nan, MinY: -50, MaxX: 50, MaxY: 50},
{MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)}, {MinX: -50, MinY: -50, MaxX: 50, MaxY: math.Inf(1)},
} { } {
created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, r, plant.ID, nil, FillClump, nil)
if !errors.Is(err, domain.ErrInvalidInput) { if !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err) t.Errorf("FillRegion(%+v) err = %v, want ErrInvalidInput", r, err)
} }
@@ -223,7 +223,7 @@ func TestFillRegionOutsideObjectPlantsNothing(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
// Wholly east of the bed: clampTo gives MinX=500, MaxX=50. // Wholly east of the bed: clampTo gives MinX=500, MaxX=50.
created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, rect(500, -50, 600, 50), plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -256,7 +256,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15 plant := seedOwnPlant(t, s, owner, 10) // radius = max(15,15) = 15
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -283,7 +283,7 @@ func TestFillRegionDeterministicPacking(t *testing.T) {
// Re-filling the same region skips everything (each candidate sits exactly on // Re-filling the same region skips everything (each candidate sits exactly on
// an existing plop → entirely inside it). // an existing plop → entirely inside it).
again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) again, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("second FillRegion: %v", err) t.Fatalf("second FillRegion: %v", err)
} }
@@ -305,14 +305,14 @@ func TestFillGridLaysOutIndividualPlants(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 10) // spacing 10 plant := seedOwnPlant(t, s, owner, 10) // spacing 10
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) clump, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("clump: %v", err) t.Fatalf("clump: %v", err)
} }
if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil { if _, err := s.ClearObject(ctx, owner, bed.ID); err != nil {
t.Fatalf("clear: %v", err) t.Fatalf("clear: %v", err)
} }
grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid) grid, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillGrid, nil)
if err != nil { if err != nil {
t.Fatalf("grid: %v", err) t.Fatalf("grid: %v", err)
} }
@@ -351,7 +351,7 @@ func TestFillRejectsUnknownLayout(t *testing.T) {
bed := seedFillBed(t, s, owner, g.ID, 60, 60) bed := seedFillBed(t, s, owner, g.ID, 60, 60)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral")); !errors.Is(err, domain.ErrInvalidInput) { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillLayout("spiral"), nil); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("unknown layout err = %v, want ErrInvalidInput", err) t.Errorf("unknown layout err = %v, want ErrInvalidInput", err)
} }
} }
@@ -368,7 +368,7 @@ func TestFillRegionRotatedBedUsesLocalFrame(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 20) plant := seedOwnPlant(t, s, owner, 20)
region, _ := NamedRegion(bed, "ne") region, _ := NamedRegion(bed, "ne")
created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump) created, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillRegion: %v", err) t.Fatalf("FillRegion: %v", err)
} }
@@ -391,7 +391,7 @@ func TestClearObject(t *testing.T) {
bed := seedBed(t, s, owner, g.ID) bed := seedBed(t, s, owner, g.ID)
plant := seedOwnPlant(t, s, owner, 10) plant := seedOwnPlant(t, s, owner, 10)
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
@@ -425,7 +425,7 @@ func TestOpsForbiddenForViewer(t *testing.T) {
} }
region, _ := NamedRegion(bed, "all") region, _ := NamedRegion(bed, "all")
if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump); !errors.Is(err, domain.ErrForbidden) { if _, err := s.FillRegion(ctx, viewer, bed.ID, region, plant.ID, nil, FillClump, nil); !errors.Is(err, domain.ErrForbidden) {
t.Errorf("viewer fill = %v, want ErrForbidden", err) t.Errorf("viewer fill = %v, want ErrForbidden", err)
} }
if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) { if _, err := s.ClearObject(ctx, viewer, bed.ID); !errors.Is(err, domain.ErrForbidden) {
@@ -455,7 +455,7 @@ func TestFillScenario(t *testing.T) {
if err != nil { if err != nil {
t.Fatalf("region %q: %v", name, err) t.Fatalf("region %q: %v", name, err)
} }
if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump); err != nil { if _, err := s.FillRegion(ctx, owner, bed.ID, region, plantID, nil, FillClump, nil); err != nil {
t.Fatalf("fill %q: %v", name, err) t.Fatalf("fill %q: %v", name, err)
} }
} }
@@ -507,3 +507,34 @@ func seedNamedPlant(t *testing.T, s *Service, owner int64, name string, spacingC
} }
return p return p
} }
// TestFillRegionPlantedAt: a fill dates its plops as told and refuses a date
// that isn't one. The UI sends its local day, so an evening fill isn't stamped
// with UTC's tomorrow; API and agent callers that omit it still get UTC today.
func TestFillRegionPlantedAt(t *testing.T) {
ctx := context.Background()
s := newTestService(t, openConfig())
owner := seedUser(t, s, "[email protected]")
g, _ := s.CreateGarden(ctx, owner, GardenInput{Name: "Dated", WidthCM: 2000, HeightCM: 2000})
bed := seedFillBed(t, s, owner, g.ID, 200, 100)
plant := seedOwnPlant(t, s, owner, 30)
day := "2026-04-01"
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &day)
if err != nil {
t.Fatalf("fill: %v", err)
}
if len(created) == 0 {
t.Fatal("fill created nothing")
}
for _, p := range created {
if p.PlantedAt == nil || *p.PlantedAt != day {
t.Errorf("planting %d plantedAt = %v, want %s", p.ID, p.PlantedAt, day)
}
}
bad := "April 1st"
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, &bad); !errors.Is(err, domain.ErrInvalidInput) {
t.Errorf("bad date err = %v, want ErrInvalidInput", err)
}
}
+5 -5
View File
@@ -73,7 +73,7 @@ func TestFillRegionIsOneChangeSet(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump) created, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil)
if err != nil { if err != nil {
t.Fatalf("FillNamedRegion: %v", err) t.Fatalf("FillNamedRegion: %v", err)
} }
@@ -320,7 +320,7 @@ func TestRevertClearObject(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -688,7 +688,7 @@ func TestClearObjectOnlyClearsWhatItSnapshotted(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID) before, _ := s.store.ListActivePlantingsForObject(ctx, bed.ID)
@@ -725,7 +725,7 @@ func TestRevertResultCarriesItsCounts(t *testing.T) {
plant := seedOwnPlant(t, s, owner, 15) plant := seedOwnPlant(t, s, owner, 15)
ctx := context.Background() ctx := context.Background()
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
t.Fatalf("fill: %v", err) t.Fatalf("fill: %v", err)
} }
// A second, different kind of change, so the breakdown has more than one row // A second, different kind of change, so the breakdown has more than one row
@@ -830,7 +830,7 @@ func TestSucceededTurnRecordsEvenIfTheCallerWentAway(t *testing.T) {
cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{ cs, err := s.WithChangeSet(ctx, owner, g.ID, ChangeSetOptions{
Source: domain.SourceAgent, Summary: "plant beans in the second bed", Source: domain.SourceAgent, Summary: "plant beans in the second bed",
}, func(ctx context.Context) error { }, func(ctx context.Context) error {
if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump); err != nil { if _, err := s.FillNamedRegion(ctx, owner, bed.ID, "all", plant.ID, nil, FillClump, nil); err != nil {
return err return err
} }
cancel() // the client disconnects, mid-turn, after the work landed cancel() // the client disconnects, mid-turn, after the work landed
+5 -2
View File
@@ -99,9 +99,12 @@ func TestRemainingReturnsWhenAPlantingIsRemoved(t *testing.T) {
lot := seedLot(t, s, owner, plant.ID, 100, nil) lot := seedLot(t, s, owner, plant.ID, 100, nil)
ctx := context.Background() ctx := context.Background()
ten := 10 // Dated explicitly: left to default, plantedAt is the real UTC day, and the
// removal below has to come after it — a test that only passed before
// 2026-08-01 is the kind of clock bomb this avoids.
ten, planted := 10, "2026-07-01"
pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{ pl, err := s.CreatePlanting(ctx, owner, bed.ID, PlantingInput{
PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantID: plant.ID, XCM: 0, YCM: 0, RadiusCM: 20, Count: &ten, SeedLotID: &lot.ID, PlantedAt: &planted,
}) })
if err != nil { if err != nil {
t.Fatalf("CreatePlanting: %v", err) t.Fatalf("CreatePlanting: %v", err)
+32 -8
View File
@@ -1,4 +1,4 @@
import { useState, type FormEvent } from 'react' import { useEffect, useState, type FormEvent } from 'react'
import { useNavigate } from '@tanstack/react-router' import { useNavigate } from '@tanstack/react-router'
import { Alert } from '@/components/ui/Alert' import { Alert } from '@/components/ui/Alert'
import { Button } from '@/components/ui/Button' import { Button } from '@/components/ui/Button'
@@ -6,24 +6,37 @@ import { Dialog } from '@/components/ui/Dialog'
import { TextField } from '@/components/ui/Field' import { TextField } from '@/components/ui/Field'
import { toast } from '@/components/ui/toast' import { toast } from '@/components/ui/toast'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { useCopyGarden, type Garden } from '@/lib/gardens' import { useCopyGarden, useGardens, type Garden } from '@/lib/gardens'
import { parsePlanName, planNameFor } from '@/lib/plan' import { nextPlanYear, parsePlanName, planNameFor } from '@/lib/plan'
/** /**
* Duplicate a garden — the way to scheme a season: the copy is a separate * Duplicate a garden — the way to scheme a season: the copy is a separate
* garden you rearrange freely while this one stays put. Beds and everything * garden you rearrange freely while this one stays put. Beds and everything
* currently planted come along; the share link and shares don't. The name is * currently planted come along; the share link and shares don't. The name is
* prefilled as "<name> — <next year>", which is what the editor's season * prefilled as "<name> — <year>" for the next year that doesn't already have a
* control and the `plan` tag read back (see lib/plan.ts). On success we land in * plan, which is what the editor's season control and the `plan` tag read back
* the copy, since the point of copying is to start editing it. * (see lib/plan.ts). On success we land in the copy, since the point of copying
* is to start editing it.
*/ */
export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) { export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () => void }) {
const copy = useCopyGarden() const copy = useCopyGarden()
const navigate = useNavigate() const navigate = useNavigate()
const gardens = useGardens()
const names = (gardens.data ?? []).map((g) => g.name)
const base = parsePlanName(garden.name)?.base ?? garden.name const base = parsePlanName(garden.name)?.base ?? garden.name
const year = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1 const from = (parsePlanName(garden.name)?.year ?? new Date().getFullYear()) + 1
const year = nextPlanYear(base, names, from)
const [name, setName] = useState(() => planNameFor(base, year)) const [name, setName] = useState(() => planNameFor(base, year))
const [touched, setTouched] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(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())
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
e.preventDefault() e.preventDefault()
@@ -45,8 +58,19 @@ export function CopyDialog({ garden, onClose }: { garden: Garden; onClose: () =>
A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the A copy of <span className="font-semibold text-text">{garden.name}</span> to scheme in rearrange freely, the
original stays put. Beds and what's planted come along; shares and the public link don't. original stays put. Beds and what's planted come along; shares and the public link don't.
</p> </p>
<TextField label="Name" name="name" required autoFocus value={name} onChange={(e) => setName(e.target.value)} /> <TextField
label="Name"
name="name"
required
autoFocus
value={name}
onChange={(e) => {
setTouched(true)
setName(e.target.value)
}}
/>
<p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p> <p className="text-xs text-ink-mute">Keep the {year} and it shows up as that season's plan in the editor.</p>
{taken && <Alert tone="info">You already have a garden called “{name.trim()}” — pick another name so the two don't read as the same plan.</Alert>}
{error && <Alert>{error}</Alert>} {error && <Alert>{error}</Alert>}
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button type="button" onClick={onClose} disabled={copy.isPending}> <Button type="button" onClick={onClose} disabled={copy.isPending}>
+12 -7
View File
@@ -5,7 +5,7 @@ import { IconButton } from '@/components/ui/Button'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import type { Garden } from '@/lib/gardens' import type { Garden } from '@/lib/gardens'
import { useGardenFull } from '@/lib/objects' import { useGardenFull } from '@/lib/objects'
import { planYearOf } from '@/lib/plan' import { parsePlanName, planYearOf } from '@/lib/plan'
import { sharesQueryOptions } from '@/lib/shares' import { sharesQueryOptions } from '@/lib/shares'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindPlural } from '@/editor/kinds' import { kindPlural } from '@/editor/kinds'
@@ -15,10 +15,11 @@ import { GardenThumb } from './GardenThumb'
const COUNTED_KINDS = ['bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure'] 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 + an * One garden as a card: the plot thumbnail (a link into the editor), name, size,
* optional `plan` tag, size, a counts line, who it's shared with, and a footer * a counts line, who it's shared with, and a footer of Open + share / copy /
* of Open + share / copy / edit / delete. A garden shared WITH you shows its * edit / delete. A plan copy shows its base name with a `<year> plan` tag. A
* role and a leave action instead of the owner's tools. * garden shared WITH you shows its role and a leave action instead of the
* owner's tools.
*/ */
export function GardenCard({ export function GardenCard({
garden, garden,
@@ -40,7 +41,11 @@ export function GardenCard({
const owner = currentUserId != null && garden.ownerId === currentUserId const owner = currentUserId != null && garden.ownerId === currentUserId
const full = useGardenFull(garden.id) const full = useGardenFull(garden.id)
const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner }) const shares = useQuery({ ...sharesQueryOptions(garden.id), enabled: owner })
const plan = parsePlanName(garden.name)
const planYear = planYearOf(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 && plan ? plan.base : garden.name
const meta = useMemo(() => { const meta = useMemo(() => {
const data = full.data const data = full.data
@@ -79,9 +84,9 @@ export function GardenCard({
<div className="flex flex-1 flex-col gap-2 px-[18px] py-4"> <div className="flex flex-1 flex-col gap-2 px-[18px] py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="min-w-0 truncate font-heading text-lg" title={garden.name}> <span className="min-w-0 truncate font-heading text-lg" title={garden.name}>
{garden.name} {title}
</span> </span>
{planYear != null && <Tag tone="accent">plan</Tag>} {planYear != null && <Tag tone="accent">{planYear} plan</Tag>}
<span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute"> <span className="ml-auto flex-none text-[12.5px] font-semibold text-ink-mute">
{formatSize(garden.widthCm, garden.heightCm, garden.unitPref)} {formatSize(garden.widthCm, garden.heightCm, garden.unitPref)}
</span> </span>
+35 -31
View File
@@ -9,13 +9,15 @@ import { errorMessage } from '@/lib/api'
import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens' import { conflictGarden, useCreateGarden, useUpdateGarden, type Garden } from '@/lib/gardens'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
dimensionInputMode, dimensionInputMode,
dimensionUnitLabel, dimensionUnitLabel,
editDimensionField,
formatCm, formatCm,
formatDimensionInput,
isValidDimensionCm, isValidDimensionCm,
MIN_GARDEN_GRID_CM, MIN_GARDEN_GRID_CM,
parseDimension, type LengthField,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
@@ -36,8 +38,11 @@ function entryHint(unit: UnitPref): string {
/** /**
* "A new garden" (no garden) or edit (garden given). Dimensions are typed in the * "A new garden" (no garden) or edit (garden given). Dimensions are typed in the
* chosen unit and stored as centimeters; switching units converts what's typed so * chosen unit and stored as centimeters: each field is a LengthField, so
* the physical size holds. A 409 rebases the form onto the server's fresh row. * switching units re-shows the same centimeters and a Save sends exactly what
* was loaded unless the person typed over it (re-parsing the display string is
* how 900 cm once became 899.922). A 409 rebases the form onto the server's
* fresh row.
*/ */
export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) { export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: () => void }) {
const isEdit = !!garden const isEdit = !!garden
@@ -48,14 +53,10 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const initialUnit: UnitPref = garden?.unitPref ?? 'imperial' const initialUnit: UnitPref = garden?.unitPref ?? 'imperial'
const [name, setName] = useState(garden?.name ?? '') const [name, setName] = useState(garden?.name ?? '')
const [unit, setUnit] = useState<UnitPref>(initialUnit) const [unit, setUnit] = useState<UnitPref>(initialUnit)
const [width, setWidth] = useState(() => const [width, setWidth] = useState<LengthField>(() => dimensionField(garden?.widthCm ?? cmFromFtIn(DEFAULT_W_FT), initialUnit))
garden ? formatDimensionInput(garden.widthCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_W_FT), 'imperial'), const [height, setHeight] = useState<LengthField>(() => dimensionField(garden?.heightCm ?? cmFromFtIn(DEFAULT_H_FT), initialUnit))
) const [gridSize, setGridSize] = useState<LengthField>(() =>
const [height, setHeight] = useState(() => dimensionField(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
garden ? formatDimensionInput(garden.heightCm, initialUnit) : formatDimensionInput(cmFromFtIn(DEFAULT_H_FT), 'imperial'),
)
const [gridSize, setGridSize] = useState(() =>
formatDimensionInput(garden?.gridSizeCm ?? (initialUnit === 'imperial' ? cmFromFtIn(1) : DEFAULT_GRID_CM), initialUnit),
) )
const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false) const [snapToGrid, setSnapToGrid] = useState(garden?.snapToGrid ?? false)
const [notes, setNotes] = useState(garden?.notes ?? '') const [notes, setNotes] = useState(garden?.notes ?? '')
@@ -65,17 +66,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
const [formError, setFormError] = useState<string | null>(null) const [formError, setFormError] = useState<string | null>(null)
function changeUnit(next: UnitPref) { function changeUnit(next: UnitPref) {
const convert = (s: string) => { setWidth((f) => convertDimensionField(f, next))
const cm = parseDimension(s, unit) setHeight((f) => convertDimensionField(f, next))
return cm === null ? s : formatDimensionInput(cm, next) setGridSize((f) => convertDimensionField(f, next))
}
setWidth(convert(width))
setHeight(convert(height))
setGridSize(convert(gridSize))
setUnit(next) setUnit(next)
} }
const gridSizeCm = parseDimension(gridSize, unit) const gridSizeCm = gridSize.cm
const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM const gridTooFine = gridSizeCm !== null && gridSizeCm > 0 && gridSizeCm < MIN_GARDEN_GRID_CM
async function onSubmit(e: FormEvent) { async function onSubmit(e: FormEvent) {
@@ -86,8 +83,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setFormError('Give the garden a name.') setFormError('Give the garden a name.')
return return
} }
const widthCm = parseDimension(width, unit) const widthCm = width.cm
const heightCm = parseDimension(height, unit) const heightCm = height.cm
if (widthCm === null || heightCm === null) { if (widthCm === null || heightCm === null) {
setFormError(entryHint(unit)) setFormError(entryHint(unit))
return return
@@ -101,6 +98,13 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
return return
} }
const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid } const input = { name: name.trim(), widthCm, heightCm, unitPref: unit, notes: notes.trim(), gridSizeCm, snapToGrid }
// Nothing changed: close without a request. A PATCH that writes the same
// row still bumps the version and lands an "Edited garden settings" step
// in History that undoes nothing.
if (isEdit && (Object.keys(input) as (keyof typeof input)[]).every((k) => input[k] === garden[k])) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: garden.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -111,9 +115,9 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setUnit(current.unitPref) setUnit(current.unitPref)
setWidth(formatDimensionInput(current.widthCm, current.unitPref)) setWidth(dimensionField(current.widthCm, current.unitPref))
setHeight(formatDimensionInput(current.heightCm, current.unitPref)) setHeight(dimensionField(current.heightCm, current.unitPref))
setGridSize(formatDimensionInput(current.gridSizeCm, current.unitPref)) setGridSize(dimensionField(current.gridSizeCm, current.unitPref))
setSnapToGrid(current.snapToGrid) setSnapToGrid(current.snapToGrid)
setNotes(current.notes) setNotes(current.notes)
setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.') setConflict('This garden changed elsewhere. The latest values are shown — look them over and save again.')
@@ -138,8 +142,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={width} value={width.text}
onChange={(e) => setWidth(e.target.value)} onChange={(e) => setWidth(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<TextField <TextField
@@ -148,8 +152,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
required required
value={height} value={height.text}
onChange={(e) => setHeight(e.target.value)} onChange={(e) => setHeight(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
<div className="field"> <div className="field">
@@ -171,8 +175,8 @@ export function GardenDialog({ garden, onClose }: { garden?: Garden; onClose: ()
name="gridSize" name="gridSize"
type="text" type="text"
inputMode={inputMode} inputMode={inputMode}
value={gridSize} value={gridSize.text}
onChange={(e) => setGridSize(e.target.value)} onChange={(e) => setGridSize(editDimensionField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
hint={ hint={
gridTooFine && gridSizeCm !== null gridTooFine && gridSizeCm !== null
+2 -1
View File
@@ -1,6 +1,7 @@
import { useMemo } from 'react' import { useMemo } from 'react'
import { localToWorld } from '@/lib/geometry' import { localToWorld } from '@/lib/geometry'
import type { FullGarden } from '@/lib/objects' import type { FullGarden } from '@/lib/objects'
import { FALLBACK_PLANT_COLOR } from '@/lib/plants'
import { objectStyle, rectRadius } from '@/editor/kinds' import { objectStyle, rectRadius } from '@/editor/kinds'
/** /**
@@ -72,7 +73,7 @@ export function GardenThumb({
const o = byId.get(p.objectId) const o = byId.get(p.objectId)
if (!o) return null if (!o) return null
const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg) const w = localToWorld({ x: p.xCm, y: p.yCm }, { x: o.xCm, y: o.yCm }, o.rotationDeg)
return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? '#97a97c'} /> return <circle key={p.id} cx={w.x} cy={w.y} r={p.radiusCm} fill={plantColor.get(p.plantId) ?? FALLBACK_PLANT_COLOR} />
})} })}
</svg> </svg>
) )
+5 -3
View File
@@ -1,7 +1,9 @@
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { monogramInk } from '@/lib/monogram'
/** A plant marker off the canvas: a solid circle in the plant's color with its /** A plant marker off the canvas: a solid circle in the plant's color with its
* 12 letter monogram in the display face. Size in px. */ * 12 letter monogram in the display face, paper or ink by the color's
* lightness (see monogramInk). Size in px. */
export function Monogram({ export function Monogram({
color, color,
letters, letters,
@@ -16,8 +18,8 @@ export function Monogram({
return ( return (
<span <span
aria-hidden aria-hidden
className={cn('grid flex-none place-items-center rounded-full font-heading leading-none text-paper', className)} className={cn('grid flex-none place-items-center rounded-full font-heading leading-none', className)}
style={{ width: size, height: size, background: color, fontSize: Math.round(size * 0.425) }} style={{ width: size, height: size, background: color, color: monogramInk(color), fontSize: Math.round(size * 0.425) }}
> >
{letters} {letters}
</span> </span>
+1 -1
View File
@@ -95,7 +95,7 @@ export function PlantCard({
<Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}> <Button variant="ghost" icon="plus" iconSize={12} className="px-2.5 text-[13px]" onClick={onAddLot}>
Record a lot Record a lot
</Button> </Button>
<span className="ml-auto flex gap-1"> <span className="ml-auto flex flex-wrap justify-end gap-1">
<Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}> <Button variant="ghost" icon="copy" iconSize={12} className="px-2.5 text-[13px]" onClick={onDuplicate}>
Duplicate Duplicate
</Button> </Button>
+17 -9
View File
@@ -15,7 +15,7 @@ import {
type PlantInput, type PlantInput,
} from '@/lib/plants' } from '@/lib/plants'
import { safeExternalUrl } from '@/lib/seedLots' import { safeExternalUrl } from '@/lib/seedLots'
import { cmFromSpacing, spacingFromCm, spacingUnitLabel, type UnitPref } from '@/lib/units' import { editSpacingField, spacingField, spacingUnitLabel, type LengthField, type UnitPref } from '@/lib/units'
import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches' import { ColorSwatches, CURATED_SWATCHES, expandHex } from './ColorSwatches'
// Markers are monograms now, but the API still carries an icon per plant; a // Markers are monograms now, but the API still carries an icon per plant; a
@@ -26,8 +26,10 @@ const categoryOptions = PLANT_CATEGORIES.map((c) => ({ value: c, label: CATEGORY
/** /**
* "A new plant" — or edit (`plant`), or a fresh create prefilled from another * "A new plant" — or edit (`plant`), or a fresh create prefilled from another
* (`template`, the Duplicate action; the only way to customize a built-in). * (`template`, the Duplicate action; the only way to customize a built-in).
* Spacing is typed in the page's unit and stored in centimeters. A 409 rebases * Spacing is typed in the page's unit and stored in centimeters — as a
* onto the server's current row. * LengthField, so a Save that didn't touch it sends the centimeters that were
* loaded rather than re-parsing "17.7 in" into 44.958. A 409 rebases onto the
* server's current row.
*/ */
export function PlantDialog({ export function PlantDialog({
plant, plant,
@@ -48,7 +50,7 @@ export function PlantDialog({
const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '') const [name, setName] = useState(source ? (isEdit ? source.name : `${source.name} (copy)`) : '')
const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable') const [category, setCategory] = useState<PlantCategory>(source?.category ?? 'vegetable')
const [spacing, setSpacing] = useState(String(spacingFromCm(source?.spacingCm ?? 30, unit))) const [spacing, setSpacing] = useState<LengthField>(() => spacingField(source?.spacingCm ?? 30, unit))
const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0])) const [color, setColor] = useState(expandHex(source?.color ?? CURATED_SWATCHES[0]))
const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '') const [days, setDays] = useState(source?.daysToMaturity != null ? String(source.daysToMaturity) : '')
const [vendor, setVendor] = useState(source?.vendor ?? '') const [vendor, setVendor] = useState(source?.vendor ?? '')
@@ -68,8 +70,8 @@ export function PlantDialog({
setFormError('Give the plant a name.') setFormError('Give the plant a name.')
return return
} }
const spacingCm = cmFromSpacing(parseFloat(spacing), unit) const spacingCm = spacing.cm
if (!Number.isFinite(spacingCm) || spacingCm < 1) { if (spacingCm === null || spacingCm < 1) {
setFormError(`Spacing must be at least 1 ${unitLabel}.`) setFormError(`Spacing must be at least 1 ${unitLabel}.`)
return return
} }
@@ -97,6 +99,12 @@ export function PlantDialog({
vendor: vendor.trim(), vendor: vendor.trim(),
notes: notes.trim(), notes: notes.trim(),
} }
// Nothing changed: close without a request, so a look-and-Save doesn't bump
// the version for every garden that shares the plant.
if (isEdit && (Object.keys(input) as (keyof PlantInput)[]).every((k) => input[k] === (k === 'color' ? expandHex(plant.color) : plant[k]))) {
onClose()
return
}
try { try {
if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version }) if (isEdit) await update.mutateAsync({ id: plant.id, ...input, version })
else await create.mutateAsync(input) else await create.mutateAsync(input)
@@ -107,7 +115,7 @@ export function PlantDialog({
setVersion(current.version) setVersion(current.version)
setName(current.name) setName(current.name)
setCategory(current.category) setCategory(current.category)
setSpacing(String(spacingFromCm(current.spacingCm, unit))) setSpacing(spacingField(current.spacingCm, unit))
setColor(expandHex(current.color)) setColor(expandHex(current.color))
setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '') setDays(current.daysToMaturity != null ? String(current.daysToMaturity) : '')
setVendor(current.vendor) setVendor(current.vendor)
@@ -142,8 +150,8 @@ export function PlantDialog({
step="any" step="any"
min="1" min="1"
required required
value={spacing} value={spacing.text}
onChange={(e) => setSpacing(e.target.value)} onChange={(e) => setSpacing(editSpacingField(e.target.value, unit))}
wrapperClassName="flex-1" wrapperClassName="flex-1"
/> />
</div> </div>
+4 -3
View File
@@ -10,8 +10,9 @@ import {
type PointerEvent as ReactPointerEvent, type PointerEvent as ReactPointerEvent,
} from 'react' } from 'react'
import { clampScale, type Point } from '@/lib/geometry' import { clampScale, type Point } from '@/lib/geometry'
import { monogramInk } from '@/lib/monogram'
import { useCreateObject, useCreatePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' 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 type { EditorPlanting } from '@/lib/plantings'
import { formatSize } from '@/lib/units' import { formatSize } from '@/lib/units'
import { kindDef, objectStyle, rectRadius } from './kinds' import { kindDef, objectStyle, rectRadius } from './kinds'
@@ -589,7 +590,7 @@ export const Canvas = forwardRef<
> >
{/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */} {/* A fingertip-sized hit area so a tiny plop at low zoom is still grabbable. */}
<circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" /> <circle r={Math.max(p.radiusCm, 10 / s)} fill="transparent" />
<circle r={p.radiusCm} fill={plant?.color ?? '#97a97c'} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} /> <circle r={p.radiusCm} fill={plant?.color ?? FALLBACK_PLANT_COLOR} stroke={isSel ? 'var(--color-paper)' : 'none'} strokeWidth={2.5 / s} />
</g> </g>
) )
})} })}
@@ -642,7 +643,7 @@ export const Canvas = forwardRef<
textAnchor="middle" textAnchor="middle"
dominantBaseline="central" dominantBaseline="central"
fontSize={r * 1.05} fontSize={r * 1.05}
fill="var(--color-paper)" fill={monogramInk(plant?.color ?? FALLBACK_PLANT_COLOR)}
style={{ fontFamily: 'var(--font-heading)' }} style={{ fontFamily: 'var(--font-heading)' }}
> >
{letters.get(p.plantId) ?? '?'} {letters.get(p.plantId) ?? '?'}
+38 -3
View File
@@ -1,13 +1,14 @@
import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { ColorDot } from '@/components/plants/Monogram' import { ColorDot } from '@/components/plants/Monogram'
import { Button, IconButton } from '@/components/ui/Button' import { Button, IconButton } from '@/components/ui/Button'
import { ConfirmDialog } from '@/components/ui/ConfirmDialog'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { Icon } from '@/components/ui/Icon' import { Icon } from '@/components/ui/Icon'
import { Tag } from '@/components/ui/Tag' import { Tag } from '@/components/ui/Tag'
import { Toggle } from '@/components/ui/Toggle' import { Toggle } from '@/components/ui/Toggle'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { useDeleteObject, useRemovePlanting, useUpdateObject, useUpdatePlanting } from '@/lib/objects' 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 type { EditorPlanting } from '@/lib/plantings'
import { import {
cmFromSpacing, cmFromSpacing,
@@ -22,7 +23,7 @@ import {
spacingUnitLabel, spacingUnitLabel,
type UnitPref, type UnitPref,
} from '@/lib/units' } from '@/lib/units'
import { kindDef, kindPlural } from './kinds' import { kindDef, kindPlural, objectDisplayName } from './kinds'
import { MIN_OBJECT_CM, plopCount } from './shared' import { MIN_OBJECT_CM, plopCount } from './shared'
import { useEditorStore } from './store' import { useEditorStore } from './store'
import type { EditorObject } from './types' import type { EditorObject } from './types'
@@ -40,6 +41,13 @@ export function rosterText(o: EditorObject, plantings: EditorPlanting[], plantsB
return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ') return 'Growing: ' + [...roster].map(([n, c]) => `${c} ${n}`).join(' · ')
} }
/** How many plants an object holds right now (its plops × their counts). */
export function plantCountIn(o: EditorObject, plantings: EditorPlanting[], plantsById: Map<number, Plant>): number {
let n = 0
for (const p of plantings) if (p.objectId === o.id) n += plopCount(p, plantsById.get(p.plantId))
return n
}
/** A collapsible block of the less-often-needed fields. */ /** A collapsible block of the less-often-needed fields. */
function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) { function Details({ open, onToggle, children }: { open: boolean; onToggle: () => void; children: ReactNode }) {
return ( return (
@@ -66,6 +74,7 @@ export function ObjectInspector({
canEdit, canEdit,
focused, focused,
roster, roster,
plantCount,
noteCount, noteCount,
large, large,
onPlantThis, onPlantThis,
@@ -79,6 +88,8 @@ export function ObjectInspector({
/** Already inside this bed (so "Plant this" is redundant). */ /** Already inside this bed (so "Plant this" is redundant). */
focused: boolean focused: boolean
roster: string roster: string
/** Live plants in it (see plantCountIn) — Remove asks first when this is > 0. */
plantCount: number
noteCount: number noteCount: number
/** Phone: 16px inputs, 44px targets. */ /** Phone: 16px inputs, 44px targets. */
large?: boolean large?: boolean
@@ -89,6 +100,7 @@ export function ObjectInspector({
const update = useUpdateObject(gardenId) const update = useUpdateObject(gardenId)
const del = useDeleteObject(gardenId) const del = useDeleteObject(gardenId)
const rootRef = useRef<HTMLDivElement>(null) const rootRef = useRef<HTMLDivElement>(null)
const [confirmRemove, setConfirmRemove] = useState(false)
const [name, setName] = useState(object.name) const [name, setName] = useState(object.name)
const [details, setDetails] = useState(false) const [details, setDetails] = useState(false)
const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit)) const [width, setWidth] = useState(formatDimensionInput(object.widthCm, unit))
@@ -178,6 +190,13 @@ export function ObjectInspector({
iconClassName="text-accent-700" iconClassName="text-accent-700"
disabled={del.isPending} disabled={del.isPending}
onClick={() => { onClick={() => {
// An empty object goes straight away (one Undo brings it back); a
// planted one takes its plants with it, which is worth a question —
// on the phone this button sits right beside "Plant this".
if (plantCount > 0) {
setConfirmRemove(true)
return
}
onDeleted() onDeleted()
del.mutate(object.id) del.mutate(object.id)
}} }}
@@ -259,6 +278,22 @@ export function ObjectInspector({
<TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} /> <TextAreaField label="Notes" name="notes" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} onBlur={() => notes !== object.notes && patch({ notes })} />
</fieldset> </fieldset>
</Details> </Details>
{confirmRemove && (
<ConfirmDialog
title={`Remove ${objectDisplayName(object)}?`}
confirmLabel="Remove it"
busyLabel="Removing…"
errorFallback="Could not remove it."
onConfirm={async () => {
await del.mutateAsync(object.id)
onDeleted()
}}
onClose={() => setConfirmRemove(false)}
>
It has {plantCount === 1 ? 'one plant' : `${plantCount} plants`} in it, and they go with it. One Undo brings
everything back.
</ConfirmDialog>
)}
</div> </div>
) )
} }
@@ -321,7 +356,7 @@ export function PlopInspector({
return ( return (
<div ref={rootRef} className="flex flex-col gap-3"> <div ref={rootRef} className="flex flex-col gap-3">
<div className="flex items-center gap-2.5"> <div className="flex items-center gap-2.5">
<ColorDot color={plant?.color ?? '#97a97c'} size={18} /> <ColorDot color={plant?.color ?? FALLBACK_PLANT_COLOR} size={18} />
<span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span> <span className="min-w-0 truncate font-heading text-[17px]">{plant?.name ?? 'Unknown plant'}</span>
{noteCount > 0 && ( {noteCount > 0 && (
<button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}> <button type="button" className="tag tag-accent-2 ml-auto cursor-pointer border-0" onClick={onNotes}>
+1 -1
View File
@@ -4,9 +4,9 @@ import { Button, IconButton } from '@/components/ui/Button'
import { TextAreaField, TextField } from '@/components/ui/Field' import { TextAreaField, TextField } from '@/components/ui/Field'
import { errorMessage } from '@/lib/api' import { errorMessage } from '@/lib/api'
import { cn } from '@/lib/cn' import { cn } from '@/lib/cn'
import { today } from '@/lib/dates'
import { import {
formatObservedAt, formatObservedAt,
today,
useCreateJournalEntry, useCreateJournalEntry,
useDeleteJournalEntry, useDeleteJournalEntry,
useJournal, useJournal,
+11
View File
@@ -0,0 +1,11 @@
import { describe, expect, it } from 'vitest'
import { today } from './dates'
describe('today', () => {
it('is the local calendar day, zero-padded', () => {
// 21:30 local on Aug 22 is Aug 23 in UTC for anyone west of Greenwich; the
// gardener still planted on the 22nd.
expect(today(new Date(2026, 7, 22, 21, 30))).toBe('2026-08-22')
expect(today(new Date(2026, 0, 5, 0, 1))).toBe('2026-01-05')
})
})
+11
View File
@@ -0,0 +1,11 @@
// The day it is where the person is. Everything the UI stamps with "today" — a
// journal note, a placed or filled plant — uses the browser's local date,
// because a gardener planting at 9 pm in Ohio planted today, not (in UTC)
// tomorrow. The server's own defaults are UTC and only apply when a date is
// omitted, which is for API callers and the agent; the UI never omits one.
/** Today as YYYY-MM-DD in the browser's local time zone. */
export function today(now = new Date()): string {
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
-7
View File
@@ -127,13 +127,6 @@ export function useDeleteJournalEntry(gardenId: number) {
}) })
} }
/** Today as YYYY-MM-DD in the viewer's own timezone — "today" means the day you
* are standing in the garden, not the day it is in UTC. */
export function today(): string {
const now = new Date()
const pad = (n: number) => String(n).padStart(2, '0')
return `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`
}
/** A date-only string as a short human label, without dragging the value /** A date-only string as a short human label, without dragging the value
* through a Date (which would shift it by the timezone offset). */ * through a Date (which would shift it by the timezone offset). */
+20 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { monogramFor, monogramMap, speciesName } from './monogram' import { monogramFor, monogramInk, monogramMap, speciesName } from './monogram'
describe('speciesName', () => { describe('speciesName', () => {
it('drops a variety suffix and a parenthetical', () => { it('drops a variety suffix and a parenthetical', () => {
@@ -65,3 +65,22 @@ describe('monogramMap', () => {
expect(monogramFor('🌱')).toBe('?') expect(monogramFor('🌱')).toBe('?')
}) })
}) })
describe('monogramInk', () => {
it('keeps paper lettering on colors dark enough to carry it', () => {
expect(monogramInk('#c8553d')).toBe('var(--color-paper)') // tomato
expect(monogramInk('#7a8a5e')).toBe('var(--color-paper)') // sage
expect(monogramInk('#5f8f45')).toBe('var(--color-paper)')
})
it('switches to the dark marker ink on pale colors', () => {
expect(monogramInk('#d9d2c5')).toBe('var(--color-marker-ink)') // garlic
expect(monogramInk('#8bc98b')).toBe('var(--color-marker-ink)') // cabbage
expect(monogramInk('#fff')).toBe('var(--color-marker-ink)')
})
it('falls back to paper for anything it cannot read', () => {
expect(monogramInk('tomato')).toBe('var(--color-paper)')
expect(monogramInk('')).toBe('var(--color-paper)')
})
})
+40
View File
@@ -61,3 +61,43 @@ export function monogramFor(name: string): string {
const ls = letters(name) const ls = letters(name)
return ls.length ? ls[0].toUpperCase() : '?' return ls.length ? ls[0].toUpperCase() : '?'
} }
// --- Lettering color --------------------------------------------------------
// Letters are paper on the marker's color — which reads on tomato red and sage,
// and vanishes on garlic's #d9d2c5. Pale markers take the dark marker ink
// instead. Both inks are theme-stable (the marker's own color is), so the
// choice depends only on the color, never on light/dark mode.
const PAPER = 'var(--color-paper)'
const INK = 'var(--color-marker-ink)'
// Below this relative luminance, paper still clears ~2.5:1 against the marker;
// above it, the dark ink does better. Sage (#7a8a5e, 0.23) keeps paper; cabbage
// green (#8bc98b, 0.49), marigold orange and garlic flip to ink.
const PAPER_MAX_LUMINANCE = 0.37
/** WCAG relative luminance of a #rgb / #rrggbb color; null if unparseable. */
function luminance(color: string): number | null {
const m = color.trim().match(/^#?([0-9a-f]{3}|[0-9a-f]{6})$/i)
if (!m) return null
const hex = m[1].length === 3 ? [...m[1]].map((c) => c + c).join('') : m[1]
const channel = (i: number) => {
const v = parseInt(hex.slice(i, i + 2), 16) / 255
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4
}
return 0.2126 * channel(0) + 0.7152 * channel(2) + 0.0722 * channel(4)
}
// Memoized by color string: the canvas asks for every visible plop on every
// frame of a pan, and a catalog has a dozen distinct colors, not thousands.
const inkByColor = new Map<string, string>()
/** The CSS color the monogram letters take on a marker of `color`. */
export function monogramInk(color: string): string {
let ink = inkByColor.get(color)
if (ink === undefined) {
const l = luminance(color)
ink = l !== null && l > PAPER_MAX_LUMINANCE ? INK : PAPER
inkByColor.set(color, ink)
}
return ink
}
+8 -4
View File
@@ -7,6 +7,7 @@ import { useCallback } from 'react'
import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query' import { queryOptions, useMutation, useQuery, useQueryClient, type QueryClient } from '@tanstack/react-query'
import { z } from 'zod' import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
import { today } from './dates'
import { gardenSchema } from './gardens' import { gardenSchema } from './gardens'
import { plantSchema, type Plant } from './plants' import { plantSchema, type Plant } from './plants'
import { serverPlantingSchema, type ServerPlanting } from './plantings' import { serverPlantingSchema, type ServerPlanting } from './plantings'
@@ -260,13 +261,17 @@ export interface PlantingCreate {
label?: string | null label?: string | null
/** Attributes the plop to a purchase, so that lot can report what's left. */ /** Attributes the plop to a purchase, so that lot can report what's left. */
seedLotId?: number seedLotId?: number
/** YYYY-MM-DD; defaults to the browser's local today (never the server's UTC one). */
plantedAt?: string
} }
export function useCreatePlanting(gardenId: number) { export function useCreatePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> => mutationFn: async ({ objectId, ...body }: PlantingCreate): Promise<ServerPlanting> =>
serverPlantingSchema.parse(await api.post(`/objects/${objectId}/plantings`, body)), serverPlantingSchema.parse(
await api.post(`/objects/${objectId}/plantings`, { plantedAt: today(), ...body }),
),
onSuccess: (created) => { onSuccess: (created) => {
patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] })) patchFullCache(qc, gardenId, (full) => ({ ...full, plantings: [...full.plantings, created] }))
}, },
@@ -353,7 +358,7 @@ export function useFillObject(gardenId: number) {
layout: FillLayout layout: FillLayout
}): Promise<number> => { }): Promise<number> => {
const res = fillResultSchema.parse( const res = fillResultSchema.parse(
await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout }), await api.post(`/objects/${objectId}/fill`, { plantId, region: 'all', layout, plantedAt: today() }),
) )
return res.created return res.created
}, },
@@ -393,8 +398,7 @@ export function useRemovePlanting(gardenId: number) {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({
mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => { mutationFn: async ({ id, version }: { id: number; version: number }): Promise<ServerPlanting> => {
const today = new Date().toISOString().slice(0, 10) return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today(), version }))
return serverPlantingSchema.parse(await api.patch(`/plantings/${id}`, { removedAt: today, version }))
}, },
onMutate: async ({ id }) => { onMutate: async ({ id }) => {
await qc.cancelQueries({ queryKey: fullKey(gardenId) }) await qc.cancelQueries({ queryKey: fullKey(gardenId) })
+13 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan' import { nextPlanYear, parsePlanName, planGardensOf, planNameFor, planYearOf } from './plan'
describe('plan names', () => { describe('plan names', () => {
it('round-trips the default copy name', () => { it('round-trips the default copy name', () => {
@@ -35,3 +35,15 @@ describe('plan names', () => {
expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2]) expect(planGardensOf('Home Garden', gardens).map((g) => g.id)).toEqual([3, 2])
}) })
}) })
describe('nextPlanYear', () => {
it('skips years that already have a plan copy, however the dash was typed', () => {
const names = ['Back Yard', 'Back Yard — 2027', 'Back Yard - 2028', 'Front Strip — 2029']
expect(nextPlanYear('Back Yard', names, 2027)).toBe(2029)
expect(nextPlanYear('Front Strip', names, 2027)).toBe(2027)
})
it('is the starting year when nothing is taken', () => {
expect(nextPlanYear('Plot', [], 2030)).toBe(2030)
})
})
+14
View File
@@ -40,3 +40,17 @@ export function planGardensOf<G extends { id: number; name: string }>(base: stri
} }
return out.sort((a, b) => a.year - b.year) return out.sort((a, b) => a.year - b.year)
} }
/** The first year from `from` on that `base` has no plan copy for among
* `names`, so a new copy never proposes a name that is already taken — two
* "Back Yard — 2027"s would both be offered as the 2027 plan. */
export function nextPlanYear(base: string, names: readonly string[], from: number): number {
const taken = new Set<number>()
for (const n of names) {
const p = parsePlanName(n)
if (p && p.base === base.trim()) taken.add(p.year)
}
let year = from
while (taken.has(year)) year += 1
return year
}
+4
View File
@@ -8,6 +8,10 @@ import { z } from 'zod'
import { ApiError, api } from './api' import { ApiError, api } from './api'
export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const export const PLANT_CATEGORIES = ['vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover'] as const
/** The marker color for a plop whose plant isn't in the catalog you can see (a
* shared garden's private plant): the sage the curated swatches start with. */
export const FALLBACK_PLANT_COLOR = '#97a97c'
export type PlantCategory = (typeof PLANT_CATEGORIES)[number] export type PlantCategory = (typeof PLANT_CATEGORIES)[number]
export const CATEGORY_LABELS: Record<PlantCategory, string> = { export const CATEGORY_LABELS: Record<PlantCategory, string> = {
+32
View File
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { import {
cmFromFtIn, cmFromFtIn,
convertDimensionField,
dimensionField,
editDimensionField,
editSpacingField,
spacingField,
cmFromMeters, cmFromMeters,
cmFromSpacing, cmFromSpacing,
dimensionInputMode, dimensionInputMode,
@@ -242,3 +247,30 @@ describe('formatSize / formatLength', () => {
expect(formatLength(1219, 'imperial')).toBe('40') expect(formatLength(1219, 'imperial')).toBe('40')
}) })
}) })
describe('LengthField', () => {
it('keeps the stored centimeters through a unit switch and back', () => {
let f = dimensionField(900, 'imperial')
expect(f.text).toBe('29 6.3″')
f = convertDimensionField(f, 'metric')
expect(f.text).toBe('9')
f = convertDimensionField(f, 'imperial')
expect(f).toEqual({ text: '29 6.3″', cm: 900 })
})
it('moves the centimeters only when the text is edited', () => {
expect(editDimensionField("15' 6\"", 'imperial').cm).toBe(472.44)
const typo = editDimensionField('nope', 'imperial')
expect(typo.cm).toBeNull()
// A typo survives a unit switch as typed rather than turning into a number.
expect(convertDimensionField(typo, 'metric')).toEqual(typo)
})
it('spacing: 45 cm reads 17.7 in and stays 45 cm until typed over', () => {
const f = spacingField(45, 'imperial')
expect(f).toEqual({ text: '17.7', cm: 45 })
expect(editSpacingField('18', 'imperial').cm).toBe(45.72)
expect(editSpacingField('', 'imperial').cm).toBeNull()
expect(editSpacingField('25', 'metric').cm).toBe(25)
})
})
+42
View File
@@ -227,3 +227,45 @@ export function formatLength(cm: number, unit: UnitPref): string {
export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string { export function formatSize(widthCm: number, heightCm: number, unit: UnitPref): string {
return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}` return `${formatLength(widthCm, unit)} × ${formatLength(heightCm, unit)}`
} }
// --- Typed-length fields ----------------------------------------------------
// A dialog field that takes a length holds TWO things: the text the person sees
// and the centimeters it means. The centimeters change only when the person
// types; re-showing the field in another unit, or saving without touching it,
// reuses them as they are. Parsing the displayed text back on save is how a
// no-change Save once turned 900 cm into 899.922 — "29 6.3″" is the nearest
// tenth of an inch, not the number that was loaded.
/** A length as typed and as stored; `cm` is null while the text doesn't parse. */
export interface LengthField {
text: string
cm: number | null
}
/** A dimension field (meters, or feet and inches) showing `cm`. */
export function dimensionField(cm: number, unit: UnitPref): LengthField {
return { text: formatDimensionInput(cm, unit), cm }
}
/** The person typed `text` into a dimension field. */
export function editDimensionField(text: string, unit: UnitPref): LengthField {
return { text, cm: parseDimension(text, unit) }
}
/** Re-show a dimension field in another unit; the centimeters don't move. A
* field that doesn't parse keeps its text, so the typo stays visible. */
export function convertDimensionField(field: LengthField, unit: UnitPref): LengthField {
return field.cm === null ? field : dimensionField(field.cm, unit)
}
/** A spacing field (cm, or inches) showing `cm`. */
export function spacingField(cm: number, unit: UnitPref): LengthField {
return { text: String(spacingFromCm(cm, unit)), cm }
}
/** The person typed `text` into a spacing field. */
export function editSpacingField(text: string, unit: UnitPref): LengthField {
const trimmed = text.trim()
const n = Number(trimmed)
return { text, cm: trimmed !== '' && Number.isFinite(n) ? cmFromSpacing(n, unit) : null }
}
+2 -1
View File
@@ -13,7 +13,7 @@ import { AssistantTab } from '@/editor/AssistantTab'
import { Canvas, type CanvasHandle } from '@/editor/Canvas' import { Canvas, type CanvasHandle } from '@/editor/Canvas'
import { ClearBedDialog } from '@/editor/ClearBedDialog' import { ClearBedDialog } from '@/editor/ClearBedDialog'
import { HistoryTab } from '@/editor/HistoryTab' import { HistoryTab } from '@/editor/HistoryTab'
import { GardenSummary, ObjectInspector, PlopInspector, rosterText } from '@/editor/Inspector' import { GardenSummary, ObjectInspector, PlopInspector, plantCountIn, rosterText } from '@/editor/Inspector'
import { JournalTab, type JournalAttach } from '@/editor/JournalTab' import { JournalTab, type JournalAttach } from '@/editor/JournalTab'
import { KindSwatch } from '@/editor/KindSwatch' import { KindSwatch } from '@/editor/KindSwatch'
import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds' import { OBJECT_KINDS, objectDisplayName } from '@/editor/kinds'
@@ -390,6 +390,7 @@ function Editor({
canEdit={canEdit} canEdit={canEdit}
focused={focusId === selectedObject.id} focused={focusId === selectedObject.id}
roster={rosterText(selectedObject, plantings, plantsById)} roster={rosterText(selectedObject, plantings, plantsById)}
plantCount={plantCountIn(selectedObject, plantings, plantsById)}
noteCount={journalCounts.data?.get(selectedObject.id) ?? 0} noteCount={journalCounts.data?.get(selectedObject.id) ?? 0}
large={isMobile} large={isMobile}
onPlantThis={() => plantThis(selectedObject)} onPlantThis={() => plantThis(selectedObject)}
+28 -9
View File
@@ -113,11 +113,16 @@ function WhoGetsInCard({ data }: { data: SettingsResponse }) {
) )
} }
type ModelField = 'agentModel' | 'visionModel'
function AssistantCard({ data }: { data: SettingsResponse }) { function AssistantCard({ data }: { data: SettingsResponse }) {
const update = useUpdateSettings() const update = useUpdateSettings()
const { settings, effective } = data const { settings, effective } = data
const [model, setModel] = useState(settings.agentModel) const [model, setModel] = useState(settings.agentModel)
const [vModel, setVModel] = useState(settings.visionModel) const [vModel, setVModel] = useState(settings.visionModel)
// A rejected model spec stays in its field with the server's reason under it
// (the field keeps the typo so it can be fixed); everything else toasts.
const [fieldError, setFieldError] = useState<{ field: ModelField; message: string } | null>(null)
// Re-sync the fields when the stored row changes underneath (a save, a // Re-sync the fields when the stored row changes underneath (a save, a
// refetch), so what's shown is what's saved. // refetch), so what's shown is what's saved.
@@ -136,14 +141,20 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
version: settings.version, version: settings.version,
}, },
{ {
onSuccess: () => toast.info('Saved the assistant picked it up.'), onSuccess: () => {
setFieldError(null)
toast.info('Saved — the assistant picked it up.')
},
onError: (err) => { onError: (err) => {
const current = conflictSettings(err) const current = conflictSettings(err)
toast.error( if (current) {
current toast.error('Someone else changed these settings just now — showing their version. Re-apply yours if you still want it.')
? 'Someone else changed these settings just now showing their version. Re-apply yours if you still want it.' return
: errorMessage(err, "Couldn't save settings."), }
) const message = errorMessage(err, "Couldn't save settings.")
const field = (['agentModel', 'visionModel'] as const).find((f) => patch[f] !== undefined)
if (field) setFieldError({ field, message })
else toast.error(message)
}, },
}, },
) )
@@ -157,10 +168,15 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
? 'off — key still in env' ? 'off — key still in env'
: `configured · not running (${shortModel(effective.model)})` : `configured · not running (${shortModel(effective.model)})`
const commit = (field: 'agentModel' | 'visionModel', value: string) => { const commit = (field: ModelField, value: string) => {
const v = value.trim() const v = value.trim()
if (v !== settings[field]) save({ [field]: v }) if (v !== settings[field]) save({ [field]: v })
// Back to what's saved (the typo was cleared): nothing to send, and the
// old reason would be about a value no longer in the field.
else setFieldError((e) => (e?.field === field ? null : e))
} }
const errorFor = (field: ModelField) =>
fieldError?.field === field ? <span className="text-accent-700">{fieldError.message}</span> : null
return ( return (
<Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}> <Card title="Garden assistant" tag={<Tag tone="accent-2">{status}</Tag>}>
@@ -180,7 +196,7 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setModel(e.target.value)} onChange={(e) => setModel(e.target.value)}
onBlur={() => commit('agentModel', model)} onBlur={() => commit('agentModel', model)}
onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)} onKeyDown={(e) => e.key === 'Enter' && commit('agentModel', model)}
hint="A majordomo model spec; a comma-separated list is a failover chain." hint={errorFor('agentModel') ?? 'A majordomo model spec; a comma-separated list is a failover chain.'}
/> />
<TextField <TextField
label="Vision model — reads seed packets; must be vision-capable" label="Vision model — reads seed packets; must be vision-capable"
@@ -190,7 +206,10 @@ function AssistantCard({ data }: { data: SettingsResponse }) {
onChange={(e) => setVModel(e.target.value)} onChange={(e) => setVModel(e.target.value)}
onBlur={() => commit('visionModel', vModel)} onBlur={() => commit('visionModel', vModel)}
onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)} onKeyDown={(e) => e.key === 'Enter' && commit('visionModel', vModel)}
hint={effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.'} hint={
errorFor('visionModel') ??
(effective.visionReady ? `Scanning is on with ${shortModel(effective.visionModel)}.` : 'Scanning is off until a model and a key are both present.')
}
/> />
<div className="text-xs leading-relaxed text-ink-mute"> <div className="text-xs leading-relaxed text-ink-mute">
The API key stays in the environment on purpose a secret in the database would ride along in every backup. The API key stays in the environment on purpose a secret in the database would ride along in every backup.
+4 -1
View File
@@ -21,8 +21,11 @@
--color-accent: #c67139; --color-accent: #c67139;
--color-accent-2: #7a8a5e; --color-accent-2: #7a8a5e;
--color-divider: color-mix(in srgb, #201e1d 16%, transparent); --color-divider: color-mix(in srgb, #201e1d 16%, transparent);
/* The monogram ink on plant markers; does not change between modes. */ /* Monogram lettering on plant markers: paper on dark colors, marker-ink on
pale ones (lib/monogram.ts picks). Neither changes between modes the
marker's own color doesn't either. */
--color-paper: #fffaf1; --color-paper: #fffaf1;
--color-marker-ink: #201e1d;
--color-neutral-100: #f9f4ed; --color-neutral-100: #f9f4ed;
--color-neutral-200: #eee7db; --color-neutral-200: #eee7db;