diff --git a/.gitignore b/.gitignore
index 5b90e79..875b531 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,3 +25,21 @@ go.work.sum
# env file
.env
+# pansy binary + SQLite database files
+/pansy
+*.db
+*.db-wal
+*.db-shm
+
+# Embedded web build: the placeholder index.html is committed so `go build`
+# works on a fresh checkout; `make web` overwrites it and adds hashed assets,
+# which are generated artifacts and must not be committed.
+/internal/webdist/dist/*
+!/internal/webdist/dist/index.html
+
+# macOS
+.DS_Store
+
+# Playwright MCP scratch output
+.playwright-mcp/
+
diff --git a/Makefile b/Makefile
new file mode 100644
index 0000000..0a387f0
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,51 @@
+# pansy — one static binary: Go API + embedded React build.
+#
+# make build # production binary ./pansy with the web build embedded
+# make dev # Go API (:8080) + Vite dev (:5173) together, hot reload
+# make test # go test + web tests
+#
+# pansy is a standalone module; GOWORK=off keeps the parent workspace out of the
+# way, and CGO_ENABLED=0 keeps the binary static (pure-Go SQLite driver).
+
+BINARY := pansy
+WEBDIST := internal/webdist/dist
+GO_ENV := CGO_ENABLED=0 GOWORK=off
+
+.PHONY: all build web dev dev-api dev-web test clean
+
+all: build
+
+## build: bundle the frontend and compile the single static binary.
+build: web
+ $(GO_ENV) go build -o $(BINARY) ./cmd/pansy
+ @echo "built ./$(BINARY)"
+
+## web: produce the production web bundle and sync it into the embed directory.
+web:
+ cd web && npm ci && npm run build
+ rm -rf $(WEBDIST)
+ cp -r web/dist $(WEBDIST)
+
+## dev: run the API and Vite dev server together (Ctrl-C stops both).
+## Prefer two terminals if you want independent restarts:
+## term 1: make dev-api term 2: make dev-web
+dev:
+ @echo "API on :8080, Vite on :5173 (proxies /api → :8080). Ctrl-C stops both."
+ @$(MAKE) -j2 dev-api dev-web
+
+dev-api:
+ $(GO_ENV) go run ./cmd/pansy
+
+dev-web:
+ cd web && npm run dev
+
+## test: run backend and (if present) frontend tests.
+test:
+ $(GO_ENV) go test ./...
+ cd web && npm test --if-present
+
+## clean: remove the binary and the generated embed contents (restore placeholder).
+clean:
+ rm -f $(BINARY)
+ rm -rf $(WEBDIST)
+ git checkout -- $(WEBDIST)/index.html 2>/dev/null || true
diff --git a/README.md b/README.md
index 3205c4e..5711d1c 100644
--- a/README.md
+++ b/README.md
@@ -4,3 +4,57 @@ Self-hostable garden planner: drag beds, bags, and containers onto a real-scale
See [DESIGN.md](DESIGN.md) for the architecture. Work is tracked in this repo's issues — start from the tracking epic.
+## Quickstart
+
+**Prerequisites:** Go 1.26+, Node 20+.
+
+### Develop
+
+Run the Go API and the Vite dev server together (Vite proxies `/api` → the API):
+
+```sh
+make dev
+```
+
+Then open http://localhost:5173. Or run the two halves in separate terminals for independent restarts:
+
+```sh
+make dev-api # Go API on :8080
+make dev-web # Vite dev server on :5173
+```
+
+### Build & run
+
+Produce the single static binary with the web build embedded, then run it:
+
+```sh
+make build
+./pansy
+```
+
+Open http://localhost:8080 — one process serves both the JSON API and the app.
+
+### Test
+
+```sh
+make test
+```
+
+## Configuration
+
+All configuration is via environment variables; every value has a default, so `./pansy` runs with none set.
+
+| Variable | Default | Description |
+| ------------------------- | ------------------ | ------------------------------------------------------------------ |
+| `PANSY_PORT` | `8080` | TCP port the HTTP server listens on. |
+| `PANSY_DB` | `./pansy.db` | SQLite database file path (created if absent). |
+| `PANSY_BASE_URL` | *(empty)* | Externally-visible base URL; used to derive the OIDC redirect URI. |
+| `PANSY_REGISTRATION` | `open` | `open` or `closed` — gates local self-service signup. |
+| `PANSY_LOCAL_AUTH` | `true` | Enable local password auth. Set `false` for pure-OIDC. |
+| `PANSY_OIDC_ISSUER` | *(empty)* | OIDC issuer/discovery URL (Authentik). Enables SSO when set. |
+| `PANSY_OIDC_CLIENT_ID` | *(empty)* | OIDC client ID. |
+| `PANSY_OIDC_CLIENT_SECRET`| *(empty)* | OIDC client secret. |
+| `PANSY_OIDC_BUTTON_LABEL` | `Sign in with SSO` | Label for the OIDC button on the login page. |
+| `PANSY_TRUSTED_PROXIES` | *(none)* | Comma-separated proxy CIDRs/IPs to trust for client-IP resolution. |
+
+Auth variables are read at startup now and consumed as the auth features land (issues #4/#5).
diff --git a/cmd/pansy/main.go b/cmd/pansy/main.go
new file mode 100644
index 0000000..f4f92ac
--- /dev/null
+++ b/cmd/pansy/main.go
@@ -0,0 +1,87 @@
+// Command pansy is the single static binary: it loads configuration, opens and
+// migrates the SQLite database, builds the HTTP server (JSON API + embedded SPA),
+// and serves until interrupted.
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log/slog"
+ "net/http"
+ "os"
+ "os/signal"
+ "syscall"
+ "time"
+
+ "gitea.stevedudenhoeffer.com/steve/pansy/internal/api"
+ "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
+ "gitea.stevedudenhoeffer.com/steve/pansy/internal/store"
+ "gitea.stevedudenhoeffer.com/steve/pansy/internal/webdist"
+)
+
+func main() {
+ slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo})))
+
+ if err := run(); err != nil {
+ slog.Error("pansy: fatal", "error", err)
+ os.Exit(1)
+ }
+}
+
+func run() error {
+ cfg := config.Load()
+
+ db, err := store.Open(cfg.DBPath)
+ if err != nil {
+ return fmt.Errorf("open database: %w", err)
+ }
+ defer db.Close()
+
+ // Migrations run against a short-lived context so a hung DB can't block boot
+ // forever; the schema is tiny so this is generous.
+ migCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if err := db.Migrate(migCtx); err != nil {
+ return fmt.Errorf("migrate database: %w", err)
+ }
+ slog.Info("pansy: database ready", "path", cfg.DBPath)
+
+ r := api.New(cfg)
+ api.RegisterSPA(r, webdist.Dist())
+
+ srv := &http.Server{
+ Addr: fmt.Sprintf(":%d", cfg.Port),
+ Handler: r,
+ ReadTimeout: 15 * time.Second,
+ ReadHeaderTimeout: 5 * time.Second,
+ WriteTimeout: 30 * time.Second,
+ IdleTimeout: 60 * time.Second,
+ }
+
+ // Serve in the background; the main goroutine waits for a signal, then drains.
+ serveErr := make(chan error, 1)
+ go func() {
+ slog.Info("pansy: listening", "addr", srv.Addr)
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ serveErr <- err
+ }
+ }()
+
+ ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
+ defer stop()
+
+ select {
+ case err := <-serveErr:
+ return fmt.Errorf("http server: %w", err)
+ case <-ctx.Done():
+ slog.Info("pansy: shutting down")
+ stop() // restore default signal handling so a second Ctrl-C force-quits
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
+ defer cancel()
+ if err := srv.Shutdown(shutdownCtx); err != nil {
+ return fmt.Errorf("graceful shutdown: %w", err)
+ }
+ }
+ return nil
+}
diff --git a/go.mod b/go.mod
new file mode 100644
index 0000000..c595a2f
--- /dev/null
+++ b/go.mod
@@ -0,0 +1,51 @@
+module gitea.stevedudenhoeffer.com/steve/pansy
+
+go 1.26.2
+
+require (
+ github.com/gin-gonic/gin v1.10.1
+ github.com/samber/slog-gin v1.15.0
+ modernc.org/sqlite v1.34.4
+)
+
+require (
+ github.com/bytedance/sonic v1.11.9 // indirect
+ github.com/bytedance/sonic/loader v0.1.1 // indirect
+ github.com/cloudwego/base64x v0.1.4 // indirect
+ github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.4 // indirect
+ github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/go-playground/locales v0.14.1 // indirect
+ github.com/go-playground/universal-translator v0.18.1 // indirect
+ github.com/go-playground/validator/v10 v10.22.0 // indirect
+ github.com/goccy/go-json v0.10.3 // indirect
+ github.com/google/uuid v1.6.0 // indirect
+ github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
+ github.com/json-iterator/go v1.1.12 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.8 // indirect
+ github.com/leodido/go-urn v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
+ github.com/modern-go/reflect2 v1.0.2 // indirect
+ github.com/ncruces/go-strftime v0.1.9 // indirect
+ github.com/pelletier/go-toml/v2 v2.2.2 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+ github.com/ugorji/go/codec v1.2.12 // indirect
+ go.opentelemetry.io/otel v1.29.0 // indirect
+ go.opentelemetry.io/otel/trace v1.29.0 // indirect
+ golang.org/x/arch v0.8.0 // indirect
+ golang.org/x/crypto v0.31.0 // indirect
+ golang.org/x/net v0.33.0 // indirect
+ golang.org/x/sys v0.28.0 // indirect
+ golang.org/x/text v0.21.0 // indirect
+ google.golang.org/protobuf v1.34.2 // indirect
+ gopkg.in/yaml.v3 v3.0.1 // indirect
+ modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect
+ modernc.org/libc v1.55.3 // indirect
+ modernc.org/mathutil v1.6.0 // indirect
+ modernc.org/memory v1.8.0 // indirect
+ modernc.org/strutil v1.2.0 // indirect
+ modernc.org/token v1.1.0 // indirect
+)
diff --git a/go.sum b/go.sum
new file mode 100644
index 0000000..4c8c120
--- /dev/null
+++ b/go.sum
@@ -0,0 +1,142 @@
+github.com/bytedance/sonic v1.11.9 h1:LFHENlIY/SLzDWverzdOvgMztTxcfcF+cqNsz9pK5zg=
+github.com/bytedance/sonic v1.11.9/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
+github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
+github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
+github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
+github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
+github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
+github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/gabriel-vasile/mimetype v1.4.4 h1:QjV6pZ7/XZ7ryI2KuyeEDE8wnh7fHP9YnQy+R0LnH8I=
+github.com/gabriel-vasile/mimetype v1.4.4/go.mod h1:JwLei5XPtWdGiMFB5Pjle1oEeoSeEuJfJE+TtfvdB/s=
+github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
+github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-gonic/gin v1.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ=
+github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
+github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
+github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
+github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
+github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
+github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
+github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
+github.com/go-playground/validator/v10 v10.22.0 h1:k6HsTZ0sTnROkhS//R0O+55JgM8C4Bx7ia+JlgcnOao=
+github.com/go-playground/validator/v10 v10.22.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
+github.com/goccy/go-json v0.10.3 h1:KZ5WoDbxAIgm2HNbYckL0se1fHD6rz5j4ywS6ebzDqA=
+github.com/goccy/go-json v0.10.3/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
+github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
+github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
+github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo=
+github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
+github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
+github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
+github.com/klauspost/cpuid/v2 v2.2.8 h1:+StwCXwm9PdpiEkPyzBXIy+M9KUb4ODm0Zarf1kS5BM=
+github.com/klauspost/cpuid/v2 v2.2.8/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
+github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
+github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
+github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
+github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
+github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
+github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
+github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
+github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
+github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
+github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
+github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
+github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
+github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/samber/slog-gin v1.15.0 h1:Kqs/ilXd9divtslWjbz5DVptmLlzyntbBiXUAta2SFg=
+github.com/samber/slog-gin v1.15.0/go.mod h1:mPAEinK/g2jPLauuWO11m3Q0Ca7aG4k9XjXjXY8IhMQ=
+github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
+github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
+github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
+github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
+github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
+github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
+github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
+github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
+github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
+github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
+github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
+github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
+go.opentelemetry.io/otel v1.29.0 h1:PdomN/Al4q/lN6iBJEN3AwPvUiHPMlt93c8bqTG5Llw=
+go.opentelemetry.io/otel v1.29.0/go.mod h1:N/WtXPs1CNCUEx+Agz5uouwCba+i+bJGFicT8SR4NP8=
+go.opentelemetry.io/otel/trace v1.29.0 h1:J/8ZNK4XgR7a21DZUAsbF8pZ5Jcw1VhACmnYt39JTi4=
+go.opentelemetry.io/otel/trace v1.29.0/go.mod h1:eHl3w0sp3paPkYstJOmAimxhiFXPg+MMTlEh3nsQgWQ=
+golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
+golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
+golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
+golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
+golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
+golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
+golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
+golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
+golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
+golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
+golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
+golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
+golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
+golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
+google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
+google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
+gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
+gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
+gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ=
+modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ=
+modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y=
+modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s=
+modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE=
+modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ=
+modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw=
+modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI=
+modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4=
+modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U=
+modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w=
+modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4=
+modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo=
+modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E=
+modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU=
+modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4=
+modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0=
+modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc=
+modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss=
+modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8=
+modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk=
+modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA=
+modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
+rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/api/api.go b/internal/api/api.go
new file mode 100644
index 0000000..48a7b3f
--- /dev/null
+++ b/internal/api/api.go
@@ -0,0 +1,44 @@
+// Package api wires pansy's HTTP surface: a gin engine with structured logging
+// and panic recovery, the versioned JSON API under /api/v1, and (via spa.go) the
+// embedded single-page-app fallback. Handlers stay thin — decode, call the
+// service layer, encode — so all business logic and permission checks live in
+// internal/service (added by later issues).
+package api
+
+import (
+ "log/slog"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ sloggin "github.com/samber/slog-gin"
+
+ "gitea.stevedudenhoeffer.com/steve/pansy/internal/config"
+)
+
+func init() {
+ // Quiet gin's default debug output; we log via slog-gin instead.
+ gin.SetMode(gin.ReleaseMode)
+}
+
+// New builds the gin engine with the standard middleware stack and registers the
+// API routes. The embedded SPA fallback is registered separately by the caller
+// via RegisterSPA (see spa.go) so the API can be built and tested without a web
+// build present.
+func New(cfg *config.Config) *gin.Engine {
+ r := gin.New()
+ r.Use(sloggin.New(slog.Default()), gin.Recovery())
+
+ if err := r.SetTrustedProxies(cfg.TrustedProxies); err != nil {
+ slog.Warn("api: failed to set trusted proxies", "error", err)
+ }
+
+ v1 := r.Group("/api/v1")
+ v1.GET("/healthz", healthz)
+
+ return r
+}
+
+// healthz is a liveness probe: always returns {"ok": true} when the server is up.
+func healthz(c *gin.Context) {
+ c.JSON(http.StatusOK, gin.H{"ok": true})
+}
diff --git a/internal/api/spa.go b/internal/api/spa.go
new file mode 100644
index 0000000..d0197f2
--- /dev/null
+++ b/internal/api/spa.go
@@ -0,0 +1,78 @@
+package api
+
+import (
+ "io/fs"
+ "net/http"
+ "path"
+ "strings"
+
+ "github.com/gin-gonic/gin"
+)
+
+// RegisterSPA serves the embedded single-page app for every route gin didn't
+// otherwise match. A request whose path maps to a real file (e.g. a hashed asset
+// bundle) is served directly; any other non-API path falls back to index.html so
+// client-side deep links like /gardens/42 load the app. Unmatched /api paths
+// return a JSON 404 rather than HTML.
+func RegisterSPA(r *gin.Engine, dist fs.FS) {
+ fileServer := http.FileServer(http.FS(dist))
+
+ r.NoRoute(func(c *gin.Context) {
+ reqPath := c.Request.URL.Path
+
+ if strings.HasPrefix(reqPath, "/api/") {
+ c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
+ "code": "NOT_FOUND", "message": "no such endpoint",
+ }})
+ return
+ }
+
+ if c.Request.Method != http.MethodGet && c.Request.Method != http.MethodHead {
+ c.JSON(http.StatusMethodNotAllowed, gin.H{"error": gin.H{
+ "code": "METHOD_NOT_ALLOWED", "message": "method not allowed",
+ }})
+ return
+ }
+
+ name := strings.TrimPrefix(path.Clean(reqPath), "/")
+ if name == "" {
+ name = "index.html"
+ }
+
+ if f, err := dist.Open(name); err == nil {
+ f.Close()
+ // Vite emits content-hashed filenames under assets/, so those are safe
+ // to cache forever; index.html must always revalidate to pick up new builds.
+ if strings.HasPrefix(name, "assets/") {
+ c.Header("Cache-Control", "public, max-age=31536000, immutable")
+ } else {
+ c.Header("Cache-Control", "no-cache")
+ }
+ fileServer.ServeHTTP(c.Writer, c.Request)
+ return
+ }
+
+ // A missing file under assets/ is a genuine 404, not a client route:
+ // falling back to index.html would return HTML with a 200 for a .js/.css
+ // request, which the browser rejects with a confusing MIME-type error.
+ if strings.HasPrefix(name, "assets/") {
+ c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
+ "code": "NOT_FOUND", "message": "asset not found",
+ }})
+ return
+ }
+
+ serveIndex(c, dist)
+ })
+}
+
+// serveIndex writes index.html as the SPA fallback for an unmatched client route.
+func serveIndex(c *gin.Context, dist fs.FS) {
+ data, err := fs.ReadFile(dist, "index.html")
+ if err != nil {
+ c.String(http.StatusInternalServerError, "index.html missing from build")
+ return
+ }
+ c.Header("Cache-Control", "no-cache")
+ c.Data(http.StatusOK, "text/html; charset=utf-8", data)
+}
diff --git a/internal/api/spa_test.go b/internal/api/spa_test.go
new file mode 100644
index 0000000..03e911f
--- /dev/null
+++ b/internal/api/spa_test.go
@@ -0,0 +1,95 @@
+package api
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "testing/fstest"
+
+ "github.com/gin-gonic/gin"
+)
+
+func testEngine() *gin.Engine {
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ // A real API route so unmatched /api/* paths reach NoRoute (as in production).
+ r.GET("/api/v1/healthz", func(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) })
+
+ dist := fstest.MapFS{
+ "index.html": &fstest.MapFile{Data: []byte(`
`)},
+ "assets/app-abc123.js": &fstest.MapFile{Data: []byte(`console.log(1)`)},
+ }
+ RegisterSPA(r, dist)
+ return r
+}
+
+func do(t *testing.T, r *gin.Engine, method, path string) *httptest.ResponseRecorder {
+ t.Helper()
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, httptest.NewRequest(method, path, nil))
+ return w
+}
+
+func TestSPAServesHashedAssetImmutable(t *testing.T) {
+ w := do(t, testEngine(), http.MethodGet, "/assets/app-abc123.js")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ if cc := w.Header().Get("Cache-Control"); !strings.Contains(cc, "immutable") {
+ t.Errorf("Cache-Control = %q, want immutable", cc)
+ }
+ if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "javascript") {
+ t.Errorf("Content-Type = %q, want javascript", ct)
+ }
+}
+
+func TestSPAServesIndexNoCache(t *testing.T) {
+ w := do(t, testEngine(), http.MethodGet, "/")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ if cc := w.Header().Get("Cache-Control"); cc != "no-cache" {
+ t.Errorf("Cache-Control = %q, want no-cache", cc)
+ }
+}
+
+func TestSPAFallbackForClientRoute(t *testing.T) {
+ // A deep link with no matching file returns index.html so client routing works.
+ w := do(t, testEngine(), http.MethodGet, "/gardens/42")
+ if w.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", w.Code)
+ }
+ if !strings.Contains(w.Body.String(), `id="root"`) {
+ t.Errorf("expected index.html body, got %q", w.Body.String())
+ }
+}
+
+func TestSPAMissingAssetIs404(t *testing.T) {
+ // A missing asset must NOT fall back to index.html (which would be a 200 of
+ // HTML for a .js request — a MIME-type error in the browser).
+ w := do(t, testEngine(), http.MethodGet, "/assets/missing-xyz.js")
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+ if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
+ t.Errorf("Content-Type = %q, want json", ct)
+ }
+}
+
+func TestSPAUnmatchedAPIIs404JSON(t *testing.T) {
+ w := do(t, testEngine(), http.MethodGet, "/api/v1/nope")
+ if w.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", w.Code)
+ }
+ if ct := w.Header().Get("Content-Type"); !strings.Contains(ct, "json") {
+ t.Errorf("Content-Type = %q, want json (not the SPA HTML)", ct)
+ }
+}
+
+func TestSPARejectsNonGET(t *testing.T) {
+ w := do(t, testEngine(), http.MethodPost, "/gardens")
+ if w.Code != http.StatusMethodNotAllowed {
+ t.Fatalf("status = %d, want 405", w.Code)
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
new file mode 100644
index 0000000..f07f049
--- /dev/null
+++ b/internal/config/config.go
@@ -0,0 +1,136 @@
+// Package config loads pansy's runtime configuration from the environment.
+//
+// Every value has a sensible default so `pansy` runs with zero configuration
+// for local use; production deployments override via PANSY_* env vars. Auth
+// values (local + OIDC) are plumbed here now and consumed by the auth issues
+// (#4 local, #5 OIDC).
+package config
+
+import (
+ "log/slog"
+ "os"
+ "strconv"
+ "strings"
+)
+
+// Registration gates local self-service signup.
+const (
+ RegistrationOpen = "open"
+ RegistrationClosed = "closed"
+)
+
+// Config is the fully-resolved runtime configuration.
+type Config struct {
+ // Port is the TCP port the HTTP server listens on (PANSY_PORT, default 8080).
+ Port int
+ // DBPath is the SQLite database file path (PANSY_DB, default ./pansy.db).
+ DBPath string
+ // BaseURL is the externally-visible base URL, used to derive the OIDC
+ // redirect URI and absolute links (PANSY_BASE_URL). Empty in bare local dev.
+ BaseURL string
+ // Registration is "open" or "closed"; gates POST /auth/register for local
+ // accounts (PANSY_REGISTRATION, default open). OIDC JIT provisioning ignores
+ // this — the IdP gates access.
+ Registration string
+ // LocalAuth enables argon2id username/password auth (PANSY_LOCAL_AUTH,
+ // default true). Set false for pure-Authentik deployments.
+ LocalAuth bool
+ // OIDC holds the optional OpenID Connect provider settings.
+ OIDC OIDCConfig
+ // TrustedProxies is the set of proxy CIDRs/IPs gin trusts for client IP
+ // resolution (PANSY_TRUSTED_PROXIES, comma-separated). Empty trusts none.
+ TrustedProxies []string
+}
+
+// OIDCConfig holds the OpenID Connect provider settings (Authentik is the
+// primary IdP). Consumed by #5.
+type OIDCConfig struct {
+ Issuer string // PANSY_OIDC_ISSUER — discovery base URL
+ ClientID string // PANSY_OIDC_CLIENT_ID
+ ClientSecret string // PANSY_OIDC_CLIENT_SECRET
+ ButtonLabel string // PANSY_OIDC_BUTTON_LABEL — login-page button text
+}
+
+// Enabled reports whether enough OIDC config is present to attempt discovery.
+func (o OIDCConfig) Enabled() bool {
+ return o.Issuer != "" && o.ClientID != ""
+}
+
+// RegistrationOpen reports whether local self-service signup is allowed.
+func (c *Config) RegistrationOpen() bool {
+ return c.Registration == RegistrationOpen
+}
+
+// Load reads configuration from the environment, applying defaults. It never
+// fails: invalid numeric/boolean values fall back to the default and are logged.
+func Load() *Config {
+ cfg := &Config{
+ Port: envInt("PANSY_PORT", 8080),
+ DBPath: envStr("PANSY_DB", "./pansy.db"),
+ BaseURL: strings.TrimRight(envStr("PANSY_BASE_URL", ""), "/"),
+ Registration: envStr("PANSY_REGISTRATION", RegistrationOpen),
+ LocalAuth: envBool("PANSY_LOCAL_AUTH", true),
+ OIDC: OIDCConfig{
+ Issuer: envStr("PANSY_OIDC_ISSUER", ""),
+ ClientID: envStr("PANSY_OIDC_CLIENT_ID", ""),
+ ClientSecret: envStr("PANSY_OIDC_CLIENT_SECRET", ""),
+ ButtonLabel: envStr("PANSY_OIDC_BUTTON_LABEL", "Sign in with SSO"),
+ },
+ TrustedProxies: envList("PANSY_TRUSTED_PROXIES"),
+ }
+
+ if cfg.Registration != RegistrationOpen && cfg.Registration != RegistrationClosed {
+ slog.Warn("config: invalid PANSY_REGISTRATION, defaulting to open", "value", cfg.Registration)
+ cfg.Registration = RegistrationOpen
+ }
+
+ return cfg
+}
+
+func envStr(key, def string) string {
+ if v, ok := os.LookupEnv(key); ok && v != "" {
+ return v
+ }
+ return def
+}
+
+func envInt(key string, def int) int {
+ v, ok := os.LookupEnv(key)
+ if !ok || v == "" {
+ return def
+ }
+ n, err := strconv.Atoi(v)
+ if err != nil {
+ slog.Warn("config: invalid int env, using default", "key", key, "value", v, "default", def)
+ return def
+ }
+ return n
+}
+
+func envBool(key string, def bool) bool {
+ v, ok := os.LookupEnv(key)
+ if !ok || v == "" {
+ return def
+ }
+ b, err := strconv.ParseBool(v)
+ if err != nil {
+ slog.Warn("config: invalid bool env, using default", "key", key, "value", v, "default", def)
+ return def
+ }
+ return b
+}
+
+func envList(key string) []string {
+ v, ok := os.LookupEnv(key)
+ if !ok || v == "" {
+ return nil
+ }
+ parts := strings.Split(v, ",")
+ out := make([]string, 0, len(parts))
+ for _, p := range parts {
+ if p = strings.TrimSpace(p); p != "" {
+ out = append(out, p)
+ }
+ }
+ return out
+}
diff --git a/internal/domain/domain.go b/internal/domain/domain.go
new file mode 100644
index 0000000..85ce397
--- /dev/null
+++ b/internal/domain/domain.go
@@ -0,0 +1,155 @@
+// Package domain holds pansy's core types: plain structs mirroring the database
+// schema and the sentinel errors the service layer returns. It has no
+// dependencies on store, api, or any framework — every other package depends on
+// it, not the other way around.
+package domain
+
+import "errors"
+
+// Sentinel errors returned by the service layer and mapped to HTTP status codes
+// by the API layer (404, 403, 409 respectively).
+var (
+ // ErrNotFound means the requested entity does not exist (or is invisible to
+ // the actor, when we deliberately mask existence).
+ ErrNotFound = errors.New("not found")
+ // ErrForbidden means the actor is known but lacks permission for the action.
+ ErrForbidden = errors.New("forbidden")
+ // ErrVersionConflict means the row's version did not match the one supplied
+ // with a PATCH/DELETE; the caller should refetch and retry.
+ ErrVersionConflict = errors.New("version conflict")
+)
+
+// Enumerated string values mirrored from the schema CHECK constraints.
+const (
+ UnitMetric = "metric"
+ UnitImperial = "imperial"
+
+ RoleViewer = "viewer"
+ RoleEditor = "editor"
+
+ KindBed = "bed"
+ KindGrowBag = "grow_bag"
+ KindContainer = "container"
+ KindInGround = "in_ground"
+ KindTree = "tree"
+ KindPath = "path"
+ KindStructure = "structure"
+
+ ShapeRect = "rect"
+ ShapeCircle = "circle"
+ ShapePolygon = "polygon" // reserved for post-v1
+
+ CategoryVegetable = "vegetable"
+ CategoryHerb = "herb"
+ CategoryFlower = "flower"
+ CategoryFruit = "fruit"
+ CategoryTreeShrub = "tree_shrub"
+ CategoryCover = "cover"
+)
+
+// User is a pansy account. It may have a local password, OIDC identity, or both.
+type User struct {
+ ID int64 `json:"id"`
+ Email string `json:"email"`
+ DisplayName string `json:"displayName"`
+ PasswordHash *string `json:"-"` // never serialized
+ OIDCIssuer *string `json:"-"`
+ OIDCSubject *string `json:"-"`
+ IsAdmin bool `json:"isAdmin"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// Session is a server-side session record. The raw token is never stored; only
+// its sha256 hash (the primary key) is.
+type Session struct {
+ TokenHash string `json:"-"`
+ UserID int64 `json:"userId"`
+ ExpiresAt string `json:"expiresAt"`
+ CreatedAt string `json:"createdAt"`
+}
+
+// Garden is a planning surface owned by one user, at real-world scale (cm).
+type Garden struct {
+ ID int64 `json:"id"`
+ OwnerID int64 `json:"ownerId"`
+ Name string `json:"name"`
+ WidthCM float64 `json:"widthCm"`
+ HeightCM float64 `json:"heightCm"`
+ UnitPref string `json:"unitPref"`
+ Notes string `json:"notes"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// GardenShare grants a non-owner user viewer or editor access to a garden.
+type GardenShare struct {
+ ID int64 `json:"id"`
+ GardenID int64 `json:"gardenId"`
+ UserID int64 `json:"userId"`
+ Role string `json:"role"`
+ CreatedBy int64 `json:"createdBy"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// GardenObject is any placeable object in a garden (bed, container, tree, path…).
+// Positioned by center point + rotation about center, in garden space.
+type GardenObject struct {
+ ID int64 `json:"id"`
+ GardenID int64 `json:"gardenId"`
+ Kind string `json:"kind"`
+ Name string `json:"name"`
+ Shape string `json:"shape"`
+ Points *string `json:"points,omitempty"` // reserved: JSON for polygons
+ XCM float64 `json:"xCm"`
+ YCM float64 `json:"yCm"`
+ WidthCM float64 `json:"widthCm"`
+ HeightCM float64 `json:"heightCm"`
+ RotationDeg float64 `json:"rotationDeg"`
+ ZIndex int `json:"zIndex"`
+ Plantable bool `json:"plantable"`
+ Color *string `json:"color,omitempty"`
+ Props *string `json:"props,omitempty"` // kind-specific JSON
+ Notes string `json:"notes"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// Plant is a catalog entry. OwnerID nil means a read-only seeded built-in.
+type Plant struct {
+ ID int64 `json:"id"`
+ OwnerID *int64 `json:"ownerId,omitempty"` // nil = built-in
+ Name string `json:"name"`
+ Category string `json:"category"`
+ SpacingCM float64 `json:"spacingCm"`
+ Color string `json:"color"`
+ Icon string `json:"icon"`
+ DaysToMaturity *int `json:"daysToMaturity,omitempty"`
+ Notes string `json:"notes"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
+
+// Planting ("plop") is a circular patch of one plant, positioned in its parent
+// object's local frame. Count nil means it is derived from area / spacing².
+type Planting struct {
+ ID int64 `json:"id"`
+ ObjectID int64 `json:"objectId"`
+ PlantID int64 `json:"plantId"`
+ XCM float64 `json:"xCm"`
+ YCM float64 `json:"yCm"`
+ RadiusCM float64 `json:"radiusCm"`
+ Count *int `json:"count,omitempty"` // nil = derived
+ Label *string `json:"label,omitempty"`
+ PlantedAt *string `json:"plantedAt,omitempty"`
+ RemovedAt *string `json:"removedAt,omitempty"`
+ Version int64 `json:"version"`
+ CreatedAt string `json:"createdAt"`
+ UpdatedAt string `json:"updatedAt"`
+}
diff --git a/internal/store/migrate.go b/internal/store/migrate.go
new file mode 100644
index 0000000..16ff9dc
--- /dev/null
+++ b/internal/store/migrate.go
@@ -0,0 +1,135 @@
+package store
+
+import (
+ "context"
+ "embed"
+ "fmt"
+ "io/fs"
+ "log/slog"
+ "sort"
+ "strconv"
+ "strings"
+)
+
+//go:embed migrations/*.sql
+var migrationsFS embed.FS
+
+// migration is one numbered SQL file: version parsed from the filename prefix.
+type migration struct {
+ version int
+ name string
+ sql string
+}
+
+// Migrate applies every embedded migration whose version has not yet been
+// recorded, in ascending order, each in its own transaction. It is idempotent:
+// a second run with no new files is a no-op. Migration files are named
+// NNNN_description.sql (e.g. 0001_init.sql); NNNN is the version.
+func (d *DB) Migrate(ctx context.Context) error {
+ if _, err := d.sql.ExecContext(ctx,
+ `CREATE TABLE IF NOT EXISTS schema_migrations (version INTEGER PRIMARY KEY)`,
+ ); err != nil {
+ return fmt.Errorf("store: create schema_migrations: %w", err)
+ }
+
+ applied, err := d.appliedVersions(ctx)
+ if err != nil {
+ return err
+ }
+
+ migrations, err := loadMigrations()
+ if err != nil {
+ return err
+ }
+
+ for _, m := range migrations {
+ if applied[m.version] {
+ continue
+ }
+ if err := d.applyMigration(ctx, m); err != nil {
+ return err
+ }
+ slog.Info("store: applied migration", "version", m.version, "name", m.name)
+ }
+ return nil
+}
+
+func (d *DB) appliedVersions(ctx context.Context) (map[int]bool, error) {
+ rows, err := d.sql.QueryContext(ctx, `SELECT version FROM schema_migrations`)
+ if err != nil {
+ return nil, fmt.Errorf("store: read schema_migrations: %w", err)
+ }
+ defer rows.Close()
+
+ applied := map[int]bool{}
+ for rows.Next() {
+ var v int
+ if err := rows.Scan(&v); err != nil {
+ return nil, fmt.Errorf("store: scan schema_migrations: %w", err)
+ }
+ applied[v] = true
+ }
+ return applied, rows.Err()
+}
+
+func (d *DB) applyMigration(ctx context.Context, m migration) error {
+ tx, err := d.sql.BeginTx(ctx, nil)
+ if err != nil {
+ return fmt.Errorf("store: begin migration %d: %w", m.version, err)
+ }
+ defer tx.Rollback() //nolint:errcheck // no-op after a successful commit
+
+ if _, err := tx.ExecContext(ctx, m.sql); err != nil {
+ return fmt.Errorf("store: apply migration %d (%s): %w", m.version, m.name, err)
+ }
+ if _, err := tx.ExecContext(ctx,
+ `INSERT INTO schema_migrations (version) VALUES (?)`, m.version,
+ ); err != nil {
+ return fmt.Errorf("store: record migration %d: %w", m.version, err)
+ }
+ return tx.Commit()
+}
+
+// loadMigrations reads and parses every embedded migration file, sorted by
+// version ascending.
+func loadMigrations() ([]migration, error) {
+ entries, err := fs.ReadDir(migrationsFS, "migrations")
+ if err != nil {
+ return nil, fmt.Errorf("store: read migrations dir: %w", err)
+ }
+
+ var migrations []migration
+ for _, e := range entries {
+ if e.IsDir() || !strings.HasSuffix(e.Name(), ".sql") {
+ continue
+ }
+ version, err := parseVersion(e.Name())
+ if err != nil {
+ return nil, err
+ }
+ body, err := fs.ReadFile(migrationsFS, "migrations/"+e.Name())
+ if err != nil {
+ return nil, fmt.Errorf("store: read migration %s: %w", e.Name(), err)
+ }
+ migrations = append(migrations, migration{version: version, name: e.Name(), sql: string(body)})
+ }
+
+ sort.Slice(migrations, func(i, j int) bool {
+ return migrations[i].version < migrations[j].version
+ })
+ return migrations, nil
+}
+
+// parseVersion extracts the leading integer from a migration filename such as
+// "0001_init.sql".
+func parseVersion(name string) (int, error) {
+ prefix, _, ok := strings.Cut(name, "_")
+ if !ok {
+ return 0, fmt.Errorf("store: migration %q missing NNNN_ prefix", name)
+ }
+ v, err := strconv.Atoi(prefix)
+ if err != nil {
+ return 0, fmt.Errorf("store: migration %q has non-numeric version: %w", name, err)
+ }
+ return v, nil
+}
diff --git a/internal/store/migrations/0001_init.sql b/internal/store/migrations/0001_init.sql
new file mode 100644
index 0000000..87cefc3
--- /dev/null
+++ b/internal/store/migrations/0001_init.sql
@@ -0,0 +1,139 @@
+-- 0001_init.sql — pansy initial schema.
+--
+-- Conventions (see DESIGN.md § Domain model):
+-- * All measurements are centimeters (REAL); imperial is a display concern.
+-- * `version INTEGER NOT NULL DEFAULT 1` on every user-mutable row; PATCH/DELETE
+-- carry it and the service layer increments on write for 409 conflict detection.
+-- * Enumerations are enforced with CHECK constraints.
+-- * Timestamps are ISO-8601 TEXT (UTC); dates are 'YYYY-MM-DD' TEXT.
+-- * Foreign keys cascade on owner/parent deletion; enforcement is on (PRAGMA foreign_keys).
+
+-- users -----------------------------------------------------------------------
+-- A user may authenticate locally (password_hash), via OIDC (oidc_issuer+subject),
+-- or both (linked by email on first OIDC login). First registered user is admin.
+CREATE TABLE users (
+ id INTEGER PRIMARY KEY,
+ email TEXT NOT NULL UNIQUE COLLATE NOCASE,
+ display_name TEXT NOT NULL,
+ password_hash TEXT, -- argon2id; NULL for OIDC-only users
+ oidc_issuer TEXT, -- NULL for local-only users
+ oidc_subject TEXT,
+ is_admin INTEGER NOT NULL DEFAULT 0 CHECK (is_admin IN (0, 1)),
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+-- NULLs compare distinct in SQLite, so this only constrains real (issuer, subject) pairs.
+CREATE UNIQUE INDEX idx_users_oidc ON users (oidc_issuer, oidc_subject);
+
+-- sessions --------------------------------------------------------------------
+-- Opaque bearer token stored only as a sha256 hash; the raw token lives in the
+-- HttpOnly cookie. 30-day sliding expiry (expires_at bumped on use).
+CREATE TABLE sessions (
+ token_hash TEXT PRIMARY KEY, -- sha256 of the 32-byte random token
+ user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ expires_at TEXT NOT NULL,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+CREATE INDEX idx_sessions_user ON sessions (user_id);
+
+-- gardens ---------------------------------------------------------------------
+CREATE TABLE gardens (
+ id INTEGER PRIMARY KEY,
+ owner_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ name TEXT NOT NULL,
+ width_cm REAL NOT NULL,
+ height_cm REAL NOT NULL,
+ unit_pref TEXT NOT NULL DEFAULT 'metric' CHECK (unit_pref IN ('metric', 'imperial')),
+ notes TEXT NOT NULL DEFAULT '',
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+CREATE INDEX idx_gardens_owner ON gardens (owner_id);
+
+-- garden_shares ---------------------------------------------------------------
+-- The owner is implicit via gardens.owner_id and never has a share row.
+CREATE TABLE garden_shares (
+ id INTEGER PRIMARY KEY,
+ garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
+ user_id INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ role TEXT NOT NULL CHECK (role IN ('viewer', 'editor')),
+ created_by INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ UNIQUE (garden_id, user_id)
+);
+CREATE INDEX idx_garden_shares_garden ON garden_shares (garden_id);
+CREATE INDEX idx_garden_shares_user ON garden_shares (user_id);
+
+-- garden_objects --------------------------------------------------------------
+-- One polymorphic table for every placeable object. Positioned by CENTER point +
+-- rotation about center, in garden space (origin top-left, x→right, y→down).
+-- shape='polygon' and the points JSON column are reserved for post-v1; the v1
+-- editor emits only 'rect' and 'circle' (circle: width_cm = diameter).
+CREATE TABLE garden_objects (
+ id INTEGER PRIMARY KEY,
+ garden_id INTEGER NOT NULL REFERENCES gardens (id) ON DELETE CASCADE,
+ kind TEXT NOT NULL CHECK (kind IN ('bed', 'grow_bag', 'container', 'in_ground', 'tree', 'path', 'structure')),
+ name TEXT NOT NULL DEFAULT '',
+ shape TEXT NOT NULL DEFAULT 'rect' CHECK (shape IN ('rect', 'circle', 'polygon')),
+ points TEXT, -- reserved: JSON [[x,y],...] for shape='polygon'
+ x_cm REAL NOT NULL, -- center in garden space
+ y_cm REAL NOT NULL,
+ width_cm REAL NOT NULL,
+ height_cm REAL NOT NULL,
+ rotation_deg REAL NOT NULL DEFAULT 0, -- clockwise about center
+ z_index INTEGER NOT NULL DEFAULT 0,
+ plantable INTEGER NOT NULL DEFAULT 0 CHECK (plantable IN (0, 1)),
+ color TEXT, -- optional hex override
+ props TEXT, -- kind-specific extras (JSON)
+ notes TEXT NOT NULL DEFAULT '',
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+CREATE INDEX idx_garden_objects_garden ON garden_objects (garden_id);
+
+-- plants ----------------------------------------------------------------------
+-- Catalog. owner_id NULL = built-in (seeded, read-only; clone to customize).
+-- Users see built-ins plus their own.
+CREATE TABLE plants (
+ id INTEGER PRIMARY KEY,
+ owner_id INTEGER REFERENCES users (id) ON DELETE CASCADE, -- NULL = built-in
+ name TEXT NOT NULL,
+ category TEXT NOT NULL CHECK (category IN ('vegetable', 'herb', 'flower', 'fruit', 'tree_shrub', 'cover')),
+ spacing_cm REAL NOT NULL, -- mature spacing
+ color TEXT NOT NULL, -- hex
+ icon TEXT NOT NULL DEFAULT '', -- emoji (zero image assets in v1)
+ days_to_maturity INTEGER,
+ notes TEXT NOT NULL DEFAULT '',
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+CREATE INDEX idx_plants_owner ON plants (owner_id);
+
+-- plantings ("plops") ---------------------------------------------------------
+-- A circular patch of one plant, positioned in the PARENT OBJECT's local frame
+-- (origin at object center, unrotated axes) so moving/rotating the bed moves its
+-- plants for free. count NULL = derived max(1, round(pi*r^2 / spacing_cm^2)).
+-- "Clear bed" sets removed_at; v1 lists rows where removed_at IS NULL.
+CREATE TABLE plantings (
+ id INTEGER PRIMARY KEY,
+ object_id INTEGER NOT NULL REFERENCES garden_objects (id) ON DELETE CASCADE,
+ plant_id INTEGER NOT NULL REFERENCES plants (id) ON DELETE RESTRICT,
+ x_cm REAL NOT NULL, -- center in object-local frame
+ y_cm REAL NOT NULL,
+ radius_cm REAL NOT NULL,
+ count INTEGER, -- NULL = derived from area / spacing^2
+ label TEXT,
+ planted_at TEXT, -- 'YYYY-MM-DD'
+ removed_at TEXT, -- 'YYYY-MM-DD'; NULL = currently planted
+ version INTEGER NOT NULL DEFAULT 1,
+ created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')),
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%SZ', 'now'))
+);
+CREATE INDEX idx_plantings_object ON plantings (object_id);
+CREATE INDEX idx_plantings_plant ON plantings (plant_id);
diff --git a/internal/store/sqlite.go b/internal/store/sqlite.go
new file mode 100644
index 0000000..de9505a
--- /dev/null
+++ b/internal/store/sqlite.go
@@ -0,0 +1,68 @@
+// Package store provides pansy's durable persistence layer: a pure-Go SQLite
+// database (modernc.org/sqlite — no cgo, so the binary stays static) with a
+// tiny embedded migration runner. Entity query methods are added per feature
+// issue; this file only handles opening and the connection pragmas.
+package store
+
+import (
+ "database/sql"
+ "fmt"
+ "net/url"
+ "strings"
+
+ _ "modernc.org/sqlite" // pure-Go driver, registered as "sqlite"
+)
+
+// DB is a handle to the SQLite database backing pansy. Safe for concurrent use:
+// WAL mode allows many readers with one writer, and busy_timeout makes a
+// contended writer wait rather than fail with SQLITE_BUSY.
+type DB struct {
+ sql *sql.DB
+}
+
+// Open opens (creating if absent) the SQLite database at path and returns a DB.
+// The pragmas — busy_timeout, WAL journal, and foreign-key enforcement — are
+// applied per connection via the DSN so every pooled connection inherits them.
+// A path of ":memory:" yields an ephemeral in-memory database (useful in tests);
+// the pool is pinned to a single connection so all queries see the same DB.
+// The caller owns the returned DB and must Close it.
+func Open(path string) (*DB, error) {
+ dsn, memory := buildDSN(path)
+ sqldb, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("store: open %q: %w", path, err)
+ }
+ if memory {
+ // Each new connection to a bare ":memory:" DSN gets its OWN empty database,
+ // so a pooled second connection wouldn't see the migrated schema. Pin the
+ // pool to one connection to keep an in-memory DB coherent.
+ sqldb.SetMaxOpenConns(1)
+ }
+ if err := sqldb.Ping(); err != nil {
+ sqldb.Close()
+ return nil, fmt.Errorf("store: ping %q: %w", path, err)
+ }
+ return &DB{sql: sqldb}, nil
+}
+
+// buildDSN turns a filesystem path (or ":memory:") into a modernc DSN carrying
+// the pragmas we want on every connection, and reports whether it is in-memory.
+// A path that already looks like a DSN (has a scheme or query) is passed through
+// untouched.
+func buildDSN(path string) (dsn string, memory bool) {
+ memory = path == ":memory:" || strings.Contains(path, ":memory:") || strings.Contains(path, "mode=memory")
+ if strings.HasPrefix(path, "file:") || strings.Contains(path, "?") {
+ return path, memory
+ }
+ pragmas := url.Values{}
+ pragmas.Add("_pragma", "busy_timeout(5000)")
+ pragmas.Add("_pragma", "journal_mode(WAL)")
+ pragmas.Add("_pragma", "foreign_keys(1)")
+ return "file:" + path + "?" + pragmas.Encode(), memory
+}
+
+// Close closes the underlying database.
+func (d *DB) Close() error { return d.sql.Close() }
+
+// SQL exposes the underlying *sql.DB for the per-entity query files.
+func (d *DB) SQL() *sql.DB { return d.sql }
diff --git a/internal/store/store_test.go b/internal/store/store_test.go
new file mode 100644
index 0000000..10b3fa4
--- /dev/null
+++ b/internal/store/store_test.go
@@ -0,0 +1,122 @@
+package store
+
+import (
+ "context"
+ "path/filepath"
+ "testing"
+)
+
+// openTestDB opens a fresh file-backed DB in a temp dir and migrates it.
+func openTestDB(t *testing.T) *DB {
+ t.Helper()
+ db, err := Open(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("Open: %v", err)
+ }
+ t.Cleanup(func() { db.Close() })
+ if err := db.Migrate(context.Background()); err != nil {
+ t.Fatalf("Migrate: %v", err)
+ }
+ return db
+}
+
+func TestMigrateCreatesSchema(t *testing.T) {
+ db := openTestDB(t)
+
+ want := []string{
+ "users", "sessions", "gardens", "garden_shares",
+ "garden_objects", "plants", "plantings", "schema_migrations",
+ }
+ for _, table := range want {
+ var name string
+ err := db.SQL().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name=?`, table,
+ ).Scan(&name)
+ if err != nil {
+ t.Errorf("expected table %q to exist: %v", table, err)
+ }
+ }
+
+ var version int
+ if err := db.SQL().QueryRow(`SELECT max(version) FROM schema_migrations`).Scan(&version); err != nil {
+ t.Fatalf("read schema_migrations: %v", err)
+ }
+ if version != 1 {
+ t.Errorf("schema version = %d, want 1", version)
+ }
+}
+
+func TestMigrateIsIdempotent(t *testing.T) {
+ db := openTestDB(t)
+
+ // A second Migrate must be a no-op and leave exactly one recorded version.
+ if err := db.Migrate(context.Background()); err != nil {
+ t.Fatalf("second Migrate: %v", err)
+ }
+ var count int
+ if err := db.SQL().QueryRow(`SELECT count(*) FROM schema_migrations`).Scan(&count); err != nil {
+ t.Fatalf("count schema_migrations: %v", err)
+ }
+ if count != 1 {
+ t.Errorf("schema_migrations rows = %d, want 1", count)
+ }
+}
+
+func TestForeignKeysEnforced(t *testing.T) {
+ db := openTestDB(t)
+
+ // The DSN sets foreign_keys(1) per connection; a planting referencing a
+ // nonexistent object must be rejected. This guards the pragma actually
+ // reaching the app's pooled connections, not just the sqlite3 CLI.
+ _, err := db.SQL().Exec(
+ `INSERT INTO plantings (object_id, plant_id, x_cm, y_cm, radius_cm) VALUES (999, 999, 0, 0, 10)`,
+ )
+ if err == nil {
+ t.Fatal("expected foreign-key violation, got nil")
+ }
+}
+
+func TestCheckConstraintRejectsBadEnum(t *testing.T) {
+ db := openTestDB(t)
+
+ // Seed a user + garden so the FK is satisfied and only the CHECK can fail.
+ res, err := db.SQL().Exec(
+ `INSERT INTO users (email, display_name) VALUES ('a@b.c', 'A')`)
+ if err != nil {
+ t.Fatalf("insert user: %v", err)
+ }
+ uid, _ := res.LastInsertId()
+ res, err = db.SQL().Exec(
+ `INSERT INTO gardens (owner_id, name, width_cm, height_cm) VALUES (?, 'G', 100, 100)`, uid)
+ if err != nil {
+ t.Fatalf("insert garden: %v", err)
+ }
+ gid, _ := res.LastInsertId()
+
+ // kind='invalid' violates the CHECK constraint.
+ _, err = db.SQL().Exec(
+ `INSERT INTO garden_objects (garden_id, kind, x_cm, y_cm, width_cm, height_cm)
+ VALUES (?, 'invalid', 0, 0, 10, 10)`, gid)
+ if err == nil {
+ t.Fatal("expected CHECK constraint violation for bad kind, got nil")
+ }
+}
+
+func TestInMemoryDBIsCoherentAcrossQueries(t *testing.T) {
+ // A bare ":memory:" DSN gives each connection its own DB; Open must pin the
+ // pool so the migrated schema is visible to subsequent queries.
+ db, err := Open(":memory:")
+ if err != nil {
+ t.Fatalf("Open(:memory:): %v", err)
+ }
+ defer db.Close()
+ if err := db.Migrate(context.Background()); err != nil {
+ t.Fatalf("Migrate: %v", err)
+ }
+ var name string
+ if err := db.SQL().QueryRow(
+ `SELECT name FROM sqlite_master WHERE type='table' AND name='gardens'`,
+ ).Scan(&name); err != nil {
+ t.Fatalf("in-memory schema not visible: %v", err)
+ }
+}
diff --git a/internal/webdist/dist.go b/internal/webdist/dist.go
new file mode 100644
index 0000000..5bc693c
--- /dev/null
+++ b/internal/webdist/dist.go
@@ -0,0 +1,24 @@
+// Package webdist embeds the production web build so pansy ships as one static
+// binary. The Makefile syncs web/dist into this package's dist/ directory before
+// `go build`; a minimal placeholder dist/index.html is committed so the module
+// builds without a web build present (the Makefile overwrites it).
+package webdist
+
+import (
+ "embed"
+ "io/fs"
+)
+
+//go:embed all:dist
+var embedded embed.FS
+
+// Dist returns the embedded web build rooted at its top level (index.html,
+// assets/…), ready to hand to api.RegisterSPA.
+func Dist() fs.FS {
+ sub, err := fs.Sub(embedded, "dist")
+ if err != nil {
+ // Only possible if the embed directive and this path disagree — a build-time bug.
+ panic("webdist: dist subtree missing: " + err.Error())
+ }
+ return sub
+}
diff --git a/internal/webdist/dist/index.html b/internal/webdist/dist/index.html
new file mode 100644
index 0000000..897acf3
--- /dev/null
+++ b/internal/webdist/dist/index.html
@@ -0,0 +1,17 @@
+
+
+
+
+
+ pansy
+
+
+
+
+ pansy
+ The web build is not embedded in this binary. Run make build to bundle the frontend.
+
+
+
diff --git a/web/.gitignore b/web/.gitignore
new file mode 100644
index 0000000..7452931
--- /dev/null
+++ b/web/.gitignore
@@ -0,0 +1,5 @@
+node_modules/
+dist/
+*.log
+.DS_Store
+.vite/
diff --git a/web/index.html b/web/index.html
new file mode 100644
index 0000000..268c135
--- /dev/null
+++ b/web/index.html
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+ pansy
+
+
+
+
+
+
+
diff --git a/web/package-lock.json b/web/package-lock.json
new file mode 100644
index 0000000..6f0dbd4
--- /dev/null
+++ b/web/package-lock.json
@@ -0,0 +1,2701 @@
+{
+ "name": "pansy-web",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "pansy-web",
+ "version": "0.1.0",
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "@tanstack/react-router": "^1.95.0",
+ "clsx": "^2.1.1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/@babel/code-frame": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
+ "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "js-tokens": "^4.0.0",
+ "picocolors": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/compat-data": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
+ "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/core": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
+ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-compilation-targets": "^7.29.7",
+ "@babel/helper-module-transforms": "^7.29.7",
+ "@babel/helpers": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/remapping": "^2.3.5",
+ "convert-source-map": "^2.0.0",
+ "debug": "^4.1.0",
+ "gensync": "^1.0.0-beta.2",
+ "json5": "^2.2.3",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/babel"
+ }
+ },
+ "node_modules/@babel/generator": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
+ "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "@jridgewell/gen-mapping": "^0.3.12",
+ "@jridgewell/trace-mapping": "^0.3.28",
+ "jsesc": "^3.0.2"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-compilation-targets": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
+ "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/compat-data": "^7.29.7",
+ "@babel/helper-validator-option": "^7.29.7",
+ "browserslist": "^4.24.0",
+ "lru-cache": "^5.1.1",
+ "semver": "^6.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-globals": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
+ "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-imports": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
+ "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/traverse": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-module-transforms": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
+ "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-module-imports": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7",
+ "@babel/traverse": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0"
+ }
+ },
+ "node_modules/@babel/helper-plugin-utils": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
+ "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-string-parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
+ "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-identifier": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
+ "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helper-validator-option": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
+ "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/helpers": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
+ "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/parser": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
+ "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.29.7"
+ },
+ "bin": {
+ "parser": "bin/babel-parser.js"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-self": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
+ "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/plugin-transform-react-jsx-source": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
+ "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-plugin-utils": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ },
+ "peerDependencies": {
+ "@babel/core": "^7.0.0-0"
+ }
+ },
+ "node_modules/@babel/template": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
+ "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/types": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/traverse": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
+ "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/code-frame": "^7.29.7",
+ "@babel/generator": "^7.29.7",
+ "@babel/helper-globals": "^7.29.7",
+ "@babel/parser": "^7.29.7",
+ "@babel/template": "^7.29.7",
+ "@babel/types": "^7.29.7",
+ "debug": "^4.3.1"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@babel/types": {
+ "version": "7.29.7",
+ "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
+ "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/helper-string-parser": "^7.29.7",
+ "@babel/helper-validator-identifier": "^7.29.7"
+ },
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/@esbuild/aix-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
+ "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "aix"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz",
+ "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz",
+ "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/android-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz",
+ "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz",
+ "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/darwin-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz",
+ "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/freebsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz",
+ "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz",
+ "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz",
+ "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz",
+ "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-loong64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz",
+ "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-mips64el": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz",
+ "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==",
+ "cpu": [
+ "mips64el"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-ppc64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz",
+ "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-riscv64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz",
+ "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-s390x": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz",
+ "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/linux-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz",
+ "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/netbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "netbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz",
+ "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openbsd-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz",
+ "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/openharmony-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz",
+ "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/sunos-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz",
+ "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "sunos"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-arm64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz",
+ "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-ia32": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz",
+ "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@esbuild/win32-x64": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz",
+ "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/@jridgewell/gen-mapping": {
+ "version": "0.3.13",
+ "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz",
+ "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.0",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/remapping": {
+ "version": "2.3.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz",
+ "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/gen-mapping": "^0.3.5",
+ "@jridgewell/trace-mapping": "^0.3.24"
+ }
+ },
+ "node_modules/@jridgewell/resolve-uri": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
+ "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/@jridgewell/sourcemap-codec": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@jridgewell/trace-mapping": {
+ "version": "0.3.31",
+ "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
+ "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/resolve-uri": "^3.1.0",
+ "@jridgewell/sourcemap-codec": "^1.4.14"
+ }
+ },
+ "node_modules/@rolldown/pluginutils": {
+ "version": "1.0.0-beta.27",
+ "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz",
+ "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@rollup/rollup-android-arm-eabi": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
+ "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-android-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz",
+ "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz",
+ "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-darwin-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz",
+ "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz",
+ "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-freebsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz",
+ "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz",
+ "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz",
+ "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz",
+ "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-arm64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz",
+ "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz",
+ "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-loong64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz",
+ "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==",
+ "cpu": [
+ "loong64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz",
+ "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-ppc64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz",
+ "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz",
+ "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-riscv64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz",
+ "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-s390x-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz",
+ "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-linux-x64-musl": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz",
+ "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@rollup/rollup-openbsd-x64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz",
+ "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openbsd"
+ ]
+ },
+ "node_modules/@rollup/rollup-openharmony-arm64": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz",
+ "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-arm64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz",
+ "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-ia32-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz",
+ "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-gnu": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz",
+ "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@rollup/rollup-win32-x64-msvc": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz",
+ "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@tailwindcss/node": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz",
+ "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/remapping": "^2.3.5",
+ "enhanced-resolve": "^5.24.1",
+ "jiti": "^2.7.0",
+ "lightningcss": "1.32.0",
+ "magic-string": "^0.30.21",
+ "source-map-js": "^1.2.1",
+ "tailwindcss": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz",
+ "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">= 20"
+ },
+ "optionalDependencies": {
+ "@tailwindcss/oxide-android-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-arm64": "4.3.3",
+ "@tailwindcss/oxide-darwin-x64": "4.3.3",
+ "@tailwindcss/oxide-freebsd-x64": "4.3.3",
+ "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-arm64-musl": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-gnu": "4.3.3",
+ "@tailwindcss/oxide-linux-x64-musl": "4.3.3",
+ "@tailwindcss/oxide-wasm32-wasi": "4.3.3",
+ "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3",
+ "@tailwindcss/oxide-win32-x64-msvc": "4.3.3"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-android-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz",
+ "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-arm64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz",
+ "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-darwin-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz",
+ "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-freebsd-x64": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz",
+ "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz",
+ "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz",
+ "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-arm64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz",
+ "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-gnu": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz",
+ "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-linux-x64-musl": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz",
+ "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-wasm32-wasi": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz",
+ "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==",
+ "bundleDependencies": [
+ "@napi-rs/wasm-runtime",
+ "@emnapi/core",
+ "@emnapi/runtime",
+ "@tybys/wasm-util",
+ "@emnapi/wasi-threads",
+ "tslib"
+ ],
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "^1.11.1",
+ "@emnapi/runtime": "^1.11.1",
+ "@emnapi/wasi-threads": "^1.2.2",
+ "@napi-rs/wasm-runtime": "^1.1.4",
+ "@tybys/wasm-util": "^0.10.2",
+ "tslib": "^2.8.1"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz",
+ "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/oxide-win32-x64-msvc": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz",
+ "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 20"
+ }
+ },
+ "node_modules/@tailwindcss/vite": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz",
+ "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@tailwindcss/node": "4.3.3",
+ "@tailwindcss/oxide": "4.3.3",
+ "tailwindcss": "4.3.3"
+ },
+ "peerDependencies": {
+ "vite": "^5.2.0 || ^6 || ^7 || ^8"
+ }
+ },
+ "node_modules/@tanstack/history": {
+ "version": "1.162.0",
+ "resolved": "https://registry.npmjs.org/@tanstack/history/-/history-1.162.0.tgz",
+ "integrity": "sha512-79pf/RkhteYZTRgcR4F9kbk84P2N8rugQJswxfIqovlbRiT3yI7eBE+5QorIrZaOKktsgzRlXh1l/du/xpl4iA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/query-core": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.2.tgz",
+ "integrity": "sha512-hH5MLoJhF7KaIGd7q3xTXGXvslI+GYlM1Z/35aSHHWaCJWB7XvTSHYuV3eM7tw+aE0mT/xMro4M4Q9rCGHT0lw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/react-query": {
+ "version": "5.101.2",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.2.tgz",
+ "integrity": "sha512-seDkr6kzGzX1okaaTtZPtgA688CDPlXUz1C6xSg0ESqn04Vuc8tlrYms1s3de+znBqhPVxFRfpAfUf+6XvfPWg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/query-core": "5.101.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^18 || ^19"
+ }
+ },
+ "node_modules/@tanstack/react-router": {
+ "version": "1.170.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-router/-/react-router-1.170.18.tgz",
+ "integrity": "sha512-wpbGYZEp/fmz1q4bn7BD8VZ+/VZ7GBqSJv5V969pU+chP8y7dquWDmKTFMohvUegb9lg12m1uPVvD6kB2wORvQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "@tanstack/react-store": "^0.9.3",
+ "@tanstack/router-core": "1.171.15",
+ "isbot": "^5.1.22"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=18.0.0 || >=19.0.0",
+ "react-dom": ">=18.0.0 || >=19.0.0"
+ }
+ },
+ "node_modules/@tanstack/react-store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-store/-/react-store-0.9.3.tgz",
+ "integrity": "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/store": "0.9.3",
+ "use-sync-external-store": "^1.6.0"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
+ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/@tanstack/router-core": {
+ "version": "1.171.15",
+ "resolved": "https://registry.npmjs.org/@tanstack/router-core/-/router-core-1.171.15.tgz",
+ "integrity": "sha512-IILCDcLaItMZQ2jEmCABHY1Nhjjn5XUvwpQp3e4Nmu+vfg0BgYFuu/QASz2SwE2ZNbVMrvt8X/wxa+Gg5aErxA==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/history": "1.162.0",
+ "cookie-es": "^3.0.0",
+ "seroval": "^1.5.4",
+ "seroval-plugins": "^1.5.4"
+ },
+ "engines": {
+ "node": ">=20.19"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/store": {
+ "version": "0.9.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.9.3.tgz",
+ "integrity": "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@types/babel__core": {
+ "version": "7.20.5",
+ "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
+ "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.20.7",
+ "@babel/types": "^7.20.7",
+ "@types/babel__generator": "*",
+ "@types/babel__template": "*",
+ "@types/babel__traverse": "*"
+ }
+ },
+ "node_modules/@types/babel__generator": {
+ "version": "7.27.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz",
+ "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__template": {
+ "version": "7.4.4",
+ "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz",
+ "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/parser": "^7.1.0",
+ "@babel/types": "^7.0.0"
+ }
+ },
+ "node_modules/@types/babel__traverse": {
+ "version": "7.28.0",
+ "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz",
+ "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/types": "^7.28.2"
+ }
+ },
+ "node_modules/@types/estree": {
+ "version": "1.0.9",
+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/@types/node": {
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "undici-types": "~6.21.0"
+ }
+ },
+ "node_modules/@types/react": {
+ "version": "19.2.17",
+ "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz",
+ "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "csstype": "^3.2.2"
+ }
+ },
+ "node_modules/@types/react-dom": {
+ "version": "19.2.3",
+ "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz",
+ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==",
+ "dev": true,
+ "license": "MIT",
+ "peerDependencies": {
+ "@types/react": "^19.2.0"
+ }
+ },
+ "node_modules/@vitejs/plugin-react": {
+ "version": "4.7.0",
+ "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
+ "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@babel/core": "^7.28.0",
+ "@babel/plugin-transform-react-jsx-self": "^7.27.1",
+ "@babel/plugin-transform-react-jsx-source": "^7.27.1",
+ "@rolldown/pluginutils": "1.0.0-beta.27",
+ "@types/babel__core": "^7.20.5",
+ "react-refresh": "^0.17.0"
+ },
+ "engines": {
+ "node": "^14.18.0 || >=16.0.0"
+ },
+ "peerDependencies": {
+ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
+ }
+ },
+ "node_modules/baseline-browser-mapping": {
+ "version": "2.10.43",
+ "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz",
+ "integrity": "sha512-AjYpR78kDWAY3Efj+cDTFH9t9SCoL7OoTp1BOb0mQV7S+6CiLwnWM3FyxhJtdPufDFKzmCSFoUncKjWgJEZTCQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "baseline-browser-mapping": "dist/cli.cjs"
+ },
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
+ "node_modules/browserslist": {
+ "version": "4.28.6",
+ "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.6.tgz",
+ "integrity": "sha512-FQBYNK15VMslhLHpA7+n+n1GOlF1kId2xcCg7/j95f24AOF6VDYMNH4mFxF7KuaTdv627faazpOAjFzMrfJOUw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "baseline-browser-mapping": "^2.10.42",
+ "caniuse-lite": "^1.0.30001803",
+ "electron-to-chromium": "^1.5.389",
+ "node-releases": "^2.0.51",
+ "update-browserslist-db": "^1.2.3"
+ },
+ "bin": {
+ "browserslist": "cli.js"
+ },
+ "engines": {
+ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
+ }
+ },
+ "node_modules/caniuse-lite": {
+ "version": "1.0.30001806",
+ "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001806.tgz",
+ "integrity": "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/caniuse-lite"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "CC-BY-4.0"
+ },
+ "node_modules/clsx": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
+ "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/convert-source-map": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
+ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/cookie-es": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/cookie-es/-/cookie-es-3.1.1.tgz",
+ "integrity": "sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==",
+ "license": "MIT"
+ },
+ "node_modules/csstype": {
+ "version": "3.2.3",
+ "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
+ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/electron-to-chromium": {
+ "version": "1.5.393",
+ "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.393.tgz",
+ "integrity": "sha512-kiDJdIUawuEIcp9XoICKp1iTYDEbgguIPq526N1Q7jIQDeQ3CqoMx71025PI/7E48Ddtw2HuWsVjY7afEgNxmg==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/enhanced-resolve": {
+ "version": "5.24.2",
+ "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.2.tgz",
+ "integrity": "sha512-rpsZEGT1jFuve6QlpyRp9ckQ+kN61hvF9BzCPyMdaKTm8UJce96KBn3sorXOFXlzjPrs3Vc4T1NsSroZ3PxlFw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "graceful-fs": "^4.2.4",
+ "tapable": "^2.3.3"
+ },
+ "engines": {
+ "node": ">=10.13.0"
+ }
+ },
+ "node_modules/esbuild": {
+ "version": "0.25.12",
+ "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
+ "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "bin": {
+ "esbuild": "bin/esbuild"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "@esbuild/aix-ppc64": "0.25.12",
+ "@esbuild/android-arm": "0.25.12",
+ "@esbuild/android-arm64": "0.25.12",
+ "@esbuild/android-x64": "0.25.12",
+ "@esbuild/darwin-arm64": "0.25.12",
+ "@esbuild/darwin-x64": "0.25.12",
+ "@esbuild/freebsd-arm64": "0.25.12",
+ "@esbuild/freebsd-x64": "0.25.12",
+ "@esbuild/linux-arm": "0.25.12",
+ "@esbuild/linux-arm64": "0.25.12",
+ "@esbuild/linux-ia32": "0.25.12",
+ "@esbuild/linux-loong64": "0.25.12",
+ "@esbuild/linux-mips64el": "0.25.12",
+ "@esbuild/linux-ppc64": "0.25.12",
+ "@esbuild/linux-riscv64": "0.25.12",
+ "@esbuild/linux-s390x": "0.25.12",
+ "@esbuild/linux-x64": "0.25.12",
+ "@esbuild/netbsd-arm64": "0.25.12",
+ "@esbuild/netbsd-x64": "0.25.12",
+ "@esbuild/openbsd-arm64": "0.25.12",
+ "@esbuild/openbsd-x64": "0.25.12",
+ "@esbuild/openharmony-arm64": "0.25.12",
+ "@esbuild/sunos-x64": "0.25.12",
+ "@esbuild/win32-arm64": "0.25.12",
+ "@esbuild/win32-ia32": "0.25.12",
+ "@esbuild/win32-x64": "0.25.12"
+ }
+ },
+ "node_modules/escalade": {
+ "version": "3.2.0",
+ "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
+ "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/fdir": {
+ "version": "6.5.0",
+ "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
+ "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "peerDependencies": {
+ "picomatch": "^3 || ^4"
+ },
+ "peerDependenciesMeta": {
+ "picomatch": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/fsevents": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
+ "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+ "dev": true,
+ "hasInstallScript": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+ }
+ },
+ "node_modules/gensync": {
+ "version": "1.0.0-beta.2",
+ "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
+ "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6.9.0"
+ }
+ },
+ "node_modules/graceful-fs": {
+ "version": "4.2.11",
+ "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
+ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/isbot": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.1.tgz",
+ "integrity": "sha512-dJ+LpKyClQZ7NG+j3OensC/mAZkGpukE9YUrgPYvAZj2doVL0edfDgywTUh5CXa0o+nW9a1V9e5+CJTX8+SxRw==",
+ "license": "Unlicense",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
+ "node_modules/js-tokens": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
+ "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/jsesc": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
+ "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jsesc": "bin/jsesc"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/json5": {
+ "version": "2.2.3",
+ "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
+ "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "json5": "lib/cli.js"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/lightningcss": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
+ "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
+ "dev": true,
+ "license": "MPL-2.0",
+ "dependencies": {
+ "detect-libc": "^2.0.3"
+ },
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ },
+ "optionalDependencies": {
+ "lightningcss-android-arm64": "1.32.0",
+ "lightningcss-darwin-arm64": "1.32.0",
+ "lightningcss-darwin-x64": "1.32.0",
+ "lightningcss-freebsd-x64": "1.32.0",
+ "lightningcss-linux-arm-gnueabihf": "1.32.0",
+ "lightningcss-linux-arm64-gnu": "1.32.0",
+ "lightningcss-linux-arm64-musl": "1.32.0",
+ "lightningcss-linux-x64-gnu": "1.32.0",
+ "lightningcss-linux-x64-musl": "1.32.0",
+ "lightningcss-win32-arm64-msvc": "1.32.0",
+ "lightningcss-win32-x64-msvc": "1.32.0"
+ }
+ },
+ "node_modules/lightningcss-android-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
+ "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-arm64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
+ "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-darwin-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
+ "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-freebsd-x64": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
+ "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm-gnueabihf": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
+ "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
+ "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-arm64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
+ "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-gnu": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
+ "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-linux-x64-musl": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
+ "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-arm64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
+ "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lightningcss-win32-x64-msvc": {
+ "version": "1.32.0",
+ "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
+ "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MPL-2.0",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": ">= 12.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/parcel"
+ }
+ },
+ "node_modules/lru-cache": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
+ "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "yallist": "^3.0.2"
+ }
+ },
+ "node_modules/magic-string": {
+ "version": "0.30.21",
+ "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
+ "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@jridgewell/sourcemap-codec": "^1.5.5"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/nanoid": {
+ "version": "3.3.16",
+ "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
+ "integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "bin": {
+ "nanoid": "bin/nanoid.cjs"
+ },
+ "engines": {
+ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+ }
+ },
+ "node_modules/node-releases": {
+ "version": "2.0.51",
+ "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz",
+ "integrity": "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=18"
+ }
+ },
+ "node_modules/picocolors": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
+ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/picomatch": {
+ "version": "4.0.5",
+ "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
+ "integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/jonschlinkert"
+ }
+ },
+ "node_modules/postcss": {
+ "version": "8.5.19",
+ "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz",
+ "integrity": "sha512-Mz8SaolMd8nB+G13WkORcxQKHZ/NE4xXevtkJHVuG+guo9/wYKlIMTKAqGdEmYOXR2ijPjTYNHssizdaVSUNdQ==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/postcss/"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/postcss"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "nanoid": "^3.3.12",
+ "picocolors": "^1.1.1",
+ "source-map-js": "^1.2.1"
+ },
+ "engines": {
+ "node": "^10 || ^12 || >=14"
+ }
+ },
+ "node_modules/react": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
+ "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/react-dom": {
+ "version": "19.2.7",
+ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz",
+ "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==",
+ "license": "MIT",
+ "dependencies": {
+ "scheduler": "^0.27.0"
+ },
+ "peerDependencies": {
+ "react": "^19.2.7"
+ }
+ },
+ "node_modules/react-refresh": {
+ "version": "0.17.0",
+ "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
+ "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/rollup": {
+ "version": "4.62.2",
+ "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
+ "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/estree": "1.0.9"
+ },
+ "bin": {
+ "rollup": "dist/bin/rollup"
+ },
+ "engines": {
+ "node": ">=18.0.0",
+ "npm": ">=8.0.0"
+ },
+ "optionalDependencies": {
+ "@rollup/rollup-android-arm-eabi": "4.62.2",
+ "@rollup/rollup-android-arm64": "4.62.2",
+ "@rollup/rollup-darwin-arm64": "4.62.2",
+ "@rollup/rollup-darwin-x64": "4.62.2",
+ "@rollup/rollup-freebsd-arm64": "4.62.2",
+ "@rollup/rollup-freebsd-x64": "4.62.2",
+ "@rollup/rollup-linux-arm-gnueabihf": "4.62.2",
+ "@rollup/rollup-linux-arm-musleabihf": "4.62.2",
+ "@rollup/rollup-linux-arm64-gnu": "4.62.2",
+ "@rollup/rollup-linux-arm64-musl": "4.62.2",
+ "@rollup/rollup-linux-loong64-gnu": "4.62.2",
+ "@rollup/rollup-linux-loong64-musl": "4.62.2",
+ "@rollup/rollup-linux-ppc64-gnu": "4.62.2",
+ "@rollup/rollup-linux-ppc64-musl": "4.62.2",
+ "@rollup/rollup-linux-riscv64-gnu": "4.62.2",
+ "@rollup/rollup-linux-riscv64-musl": "4.62.2",
+ "@rollup/rollup-linux-s390x-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-gnu": "4.62.2",
+ "@rollup/rollup-linux-x64-musl": "4.62.2",
+ "@rollup/rollup-openbsd-x64": "4.62.2",
+ "@rollup/rollup-openharmony-arm64": "4.62.2",
+ "@rollup/rollup-win32-arm64-msvc": "4.62.2",
+ "@rollup/rollup-win32-ia32-msvc": "4.62.2",
+ "@rollup/rollup-win32-x64-gnu": "4.62.2",
+ "@rollup/rollup-win32-x64-msvc": "4.62.2",
+ "fsevents": "~2.3.2"
+ }
+ },
+ "node_modules/scheduler": {
+ "version": "0.27.0",
+ "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
+ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "6.3.1",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
+ "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ }
+ },
+ "node_modules/seroval": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/seroval/-/seroval-1.5.5.tgz",
+ "integrity": "sha512-bSjOuPcwPKLSJNhr9+bZxA20nQxVle5J5MNsYRVE6cIg7KpRLXGupymePavu0jrxlPiPsr4xGZSB8yUY2sH2sw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/seroval-plugins": {
+ "version": "1.5.5",
+ "resolved": "https://registry.npmjs.org/seroval-plugins/-/seroval-plugins-1.5.5.tgz",
+ "integrity": "sha512-+BDhqYM6CEn3x09v44dpa9p6974FuUB2dxk+Ctn04k0cO1Zt6QODTXfmEZK0eBaTe/fJBvP4NMGuNJ+R8T+QMg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "seroval": "^1.0"
+ }
+ },
+ "node_modules/source-map-js": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
+ "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tailwind-merge": {
+ "version": "2.6.1",
+ "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz",
+ "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/dcastil"
+ }
+ },
+ "node_modules/tailwindcss": {
+ "version": "4.3.3",
+ "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz",
+ "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/tapable": {
+ "version": "2.3.3",
+ "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz",
+ "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": ">=6"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/webpack"
+ }
+ },
+ "node_modules/tinyglobby": {
+ "version": "0.2.17",
+ "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
+ "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "picomatch": "^4.0.4"
+ },
+ "engines": {
+ "node": ">=12.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/SuperchupuDev"
+ }
+ },
+ "node_modules/typescript": {
+ "version": "5.9.3",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
+ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+ "dev": true,
+ "license": "Apache-2.0",
+ "bin": {
+ "tsc": "bin/tsc",
+ "tsserver": "bin/tsserver"
+ },
+ "engines": {
+ "node": ">=14.17"
+ }
+ },
+ "node_modules/undici-types": {
+ "version": "6.21.0",
+ "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
+ "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+ "dev": true,
+ "license": "MIT"
+ },
+ "node_modules/update-browserslist-db": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+ "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/browserslist"
+ },
+ {
+ "type": "tidelift",
+ "url": "https://tidelift.com/funding/github/npm/browserslist"
+ },
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/ai"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "escalade": "^3.2.0",
+ "picocolors": "^1.1.1"
+ },
+ "bin": {
+ "update-browserslist-db": "cli.js"
+ },
+ "peerDependencies": {
+ "browserslist": ">= 4.21.0"
+ }
+ },
+ "node_modules/use-sync-external-store": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
+ "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
+ "node_modules/vite": {
+ "version": "6.4.3",
+ "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz",
+ "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "esbuild": "^0.25.0",
+ "fdir": "^6.4.4",
+ "picomatch": "^4.0.2",
+ "postcss": "^8.5.3",
+ "rollup": "^4.34.9",
+ "tinyglobby": "^0.2.13"
+ },
+ "bin": {
+ "vite": "bin/vite.js"
+ },
+ "engines": {
+ "node": "^18.0.0 || ^20.0.0 || >=22.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/vitejs/vite?sponsor=1"
+ },
+ "optionalDependencies": {
+ "fsevents": "~2.3.3"
+ },
+ "peerDependencies": {
+ "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0",
+ "jiti": ">=1.21.0",
+ "less": "*",
+ "lightningcss": "^1.21.0",
+ "sass": "*",
+ "sass-embedded": "*",
+ "stylus": "*",
+ "sugarss": "*",
+ "terser": "^5.16.0",
+ "tsx": "^4.8.1",
+ "yaml": "^2.4.2"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "jiti": {
+ "optional": true
+ },
+ "less": {
+ "optional": true
+ },
+ "lightningcss": {
+ "optional": true
+ },
+ "sass": {
+ "optional": true
+ },
+ "sass-embedded": {
+ "optional": true
+ },
+ "stylus": {
+ "optional": true
+ },
+ "sugarss": {
+ "optional": true
+ },
+ "terser": {
+ "optional": true
+ },
+ "tsx": {
+ "optional": true
+ },
+ "yaml": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/yallist": {
+ "version": "3.1.1",
+ "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
+ "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
+ "dev": true,
+ "license": "ISC"
+ },
+ "node_modules/zod": {
+ "version": "3.25.76",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
+ "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ }
+ }
+}
diff --git a/web/package.json b/web/package.json
new file mode 100644
index 0000000..0ef2d02
--- /dev/null
+++ b/web/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "pansy-web",
+ "version": "0.1.0",
+ "private": true,
+ "type": "module",
+ "description": "Pansy garden planner — web frontend.",
+ "engines": {
+ "node": ">=20"
+ },
+ "scripts": {
+ "dev": "vite",
+ "build": "tsc --noEmit && vite build",
+ "typecheck": "tsc --noEmit",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@tanstack/react-query": "^5.62.0",
+ "@tanstack/react-router": "^1.95.0",
+ "clsx": "^2.1.1",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0",
+ "tailwind-merge": "^2.6.0",
+ "zod": "^3.24.1"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/node": "^22.10.5",
+ "@types/react": "^19.0.2",
+ "@types/react-dom": "^19.0.2",
+ "@vitejs/plugin-react": "^4.3.4",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.2",
+ "vite": "^6.0.7"
+ }
+}
diff --git a/web/src/components/PageStub.tsx b/web/src/components/PageStub.tsx
new file mode 100644
index 0000000..6ce21ba
--- /dev/null
+++ b/web/src/components/PageStub.tsx
@@ -0,0 +1,13 @@
+import type { ReactNode } from 'react'
+
+/** Placeholder page scaffolding used until each feature issue fills these in. */
+export function PageStub({ title, children }: { title: string; children?: ReactNode }) {
+ return (
+
+ {title}
+
+ {children ?? 'Placeholder — this page arrives in a later issue.'}
+
+
+ )
+}
diff --git a/web/src/components/layout/AppShell.tsx b/web/src/components/layout/AppShell.tsx
new file mode 100644
index 0000000..5fc81bd
--- /dev/null
+++ b/web/src/components/layout/AppShell.tsx
@@ -0,0 +1,46 @@
+import { Link, Outlet } from '@tanstack/react-router'
+import { cn } from '@/lib/cn'
+
+const navLinks = [
+ { to: '/gardens', label: 'Gardens' },
+ { to: '/plants', label: 'Plants' },
+] as const
+
+/** Top-level chrome: a sticky nav bar plus the routed page in an . */
+export function AppShell() {
+ return (
+
+ )
+}
diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts
new file mode 100644
index 0000000..906d37b
--- /dev/null
+++ b/web/src/lib/api.ts
@@ -0,0 +1,121 @@
+// Typed fetch wrapper for the pansy JSON API under /api/v1.
+//
+// Every non-2xx response throws an ApiError carrying the HTTP status and the
+// parsed response body. That contract matters beyond this issue: the optimistic
+// editor (per DESIGN.md § Sync) sends each PATCH/DELETE with a row `version` and,
+// on a 409, reads the *current* row back out of `ApiError.body` to reconcile.
+
+const BASE = '/api/v1'
+
+/** Error thrown for any non-2xx response (or a network failure, status 0). */
+export class ApiError extends Error {
+ readonly status: number
+ /** Parsed response body (JSON object, string, or null). On a 409 this is the current server row. */
+ readonly body: unknown
+
+ constructor(message: string, status: number, body: unknown) {
+ super(message)
+ this.name = 'ApiError'
+ this.status = status
+ this.body = body
+ }
+
+ /** A version conflict (DESIGN.md § Sync); `body` holds the current server row. */
+ get isConflict(): boolean {
+ return this.status === 409
+ }
+
+ /** No authenticated session; callers typically redirect to /login. */
+ get isUnauthorized(): boolean {
+ return this.status === 401
+ }
+}
+
+type ParamValue = string | number | boolean | undefined | null
+export type Params = Record
+
+export interface RequestOptions {
+ method?: 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'
+ /** JSON request body; serialized and sent with a JSON content-type. */
+ body?: unknown
+ /** Query-string parameters; undefined/null/'' entries are omitted. */
+ params?: Params
+ signal?: AbortSignal
+}
+
+function buildUrl(path: string, params?: Params): string {
+ const url = BASE + path
+ if (!params) return url
+ const qs = new URLSearchParams()
+ for (const [k, v] of Object.entries(params)) {
+ if (v === undefined || v === null || v === '') continue
+ qs.set(k, String(v))
+ }
+ const s = qs.toString()
+ return s ? `${url}?${s}` : url
+}
+
+async function parseBody(res: Response): Promise {
+ if (res.status === 204) return null
+ const text = await res.text()
+ if (!text) return null
+ const contentType = res.headers.get('content-type') ?? ''
+ if (contentType.includes('application/json')) {
+ try {
+ return JSON.parse(text)
+ } catch {
+ return text
+ }
+ }
+ return text
+}
+
+function messageFrom(body: unknown, status: number): string {
+ if (body && typeof body === 'object') {
+ const err = (body as { error?: { message?: string } }).error
+ if (err?.message) return err.message
+ const msg = (body as { message?: string }).message
+ if (msg) return msg
+ }
+ return `Request failed (${status})`
+}
+
+/** Perform a request against /api/v1 and return the parsed JSON body as T. */
+export async function apiFetch(path: string, opts: RequestOptions = {}): Promise {
+ const { method = 'GET', body, params, signal } = opts
+
+ let res: Response
+ try {
+ res = await fetch(buildUrl(path, params), {
+ method,
+ signal,
+ credentials: 'same-origin', // send the HttpOnly session cookie
+ headers: {
+ accept: 'application/json',
+ ...(body !== undefined ? { 'content-type': 'application/json' } : {}),
+ },
+ body: body !== undefined ? JSON.stringify(body) : undefined,
+ })
+ } catch (err) {
+ if ((err as Error)?.name === 'AbortError') throw err
+ throw new ApiError('Cannot reach the pansy server.', 0, null)
+ }
+
+ const payload = await parseBody(res)
+ if (!res.ok) {
+ throw new ApiError(messageFrom(payload, res.status), res.status, payload)
+ }
+ return payload as T
+}
+
+/** Convenience verbs over apiFetch. */
+export const api = {
+ get: (path: string, opts?: Omit) =>
+ apiFetch(path, { ...opts, method: 'GET' }),
+ post: (path: string, body?: unknown, opts?: Omit) =>
+ apiFetch(path, { ...opts, method: 'POST', body }),
+ patch: (path: string, body?: unknown, opts?: Omit) =>
+ apiFetch(path, { ...opts, method: 'PATCH', body }),
+ delete: (path: string, opts?: Omit) =>
+ apiFetch(path, { ...opts, method: 'DELETE' }),
+}
diff --git a/web/src/lib/cn.ts b/web/src/lib/cn.ts
new file mode 100644
index 0000000..a22d4d2
--- /dev/null
+++ b/web/src/lib/cn.ts
@@ -0,0 +1,7 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+/** Merge conditional class names, resolving Tailwind conflicts (last wins). */
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/web/src/main.tsx b/web/src/main.tsx
new file mode 100644
index 0000000..4da6841
--- /dev/null
+++ b/web/src/main.tsx
@@ -0,0 +1,23 @@
+import { StrictMode } from 'react'
+import { createRoot } from 'react-dom/client'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { RouterProvider } from '@tanstack/react-router'
+import { router } from './router'
+import './styles/index.css'
+
+const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { refetchOnWindowFocus: false, staleTime: 30_000 },
+ },
+})
+
+const rootEl = document.getElementById('root')
+if (!rootEl) throw new Error('Missing #root element')
+
+createRoot(rootEl).render(
+
+
+
+
+ ,
+)
diff --git a/web/src/pages/GardenEditorPage.tsx b/web/src/pages/GardenEditorPage.tsx
new file mode 100644
index 0000000..e2aabec
--- /dev/null
+++ b/web/src/pages/GardenEditorPage.tsx
@@ -0,0 +1,13 @@
+import { getRouteApi } from '@tanstack/react-router'
+import { PageStub } from '@/components/PageStub'
+
+const routeApi = getRouteApi('/gardens/$gardenId')
+
+export function GardenEditorPage() {
+ const { gardenId } = routeApi.useParams()
+ return (
+
+ The field editor (canvas, palette, plops) arrives across issues #9–#15.
+
+ )
+}
diff --git a/web/src/pages/GardensPage.tsx b/web/src/pages/GardensPage.tsx
new file mode 100644
index 0000000..c5a1877
--- /dev/null
+++ b/web/src/pages/GardensPage.tsx
@@ -0,0 +1,5 @@
+import { PageStub } from '@/components/PageStub'
+
+export function GardensPage() {
+ return The garden list and create flow arrive in issue #8.
+}
diff --git a/web/src/pages/LoginPage.tsx b/web/src/pages/LoginPage.tsx
new file mode 100644
index 0000000..9f43996
--- /dev/null
+++ b/web/src/pages/LoginPage.tsx
@@ -0,0 +1,5 @@
+import { PageStub } from '@/components/PageStub'
+
+export function LoginPage() {
+ return Login form and SSO button arrive in issue #6.
+}
diff --git a/web/src/pages/PlantsPage.tsx b/web/src/pages/PlantsPage.tsx
new file mode 100644
index 0000000..eb6344f
--- /dev/null
+++ b/web/src/pages/PlantsPage.tsx
@@ -0,0 +1,5 @@
+import { PageStub } from '@/components/PageStub'
+
+export function PlantsPage() {
+ return The plant catalog arrives in issue #13.
+}
diff --git a/web/src/pages/RegisterPage.tsx b/web/src/pages/RegisterPage.tsx
new file mode 100644
index 0000000..c38adfb
--- /dev/null
+++ b/web/src/pages/RegisterPage.tsx
@@ -0,0 +1,5 @@
+import { PageStub } from '@/components/PageStub'
+
+export function RegisterPage() {
+ return Registration form arrives in issue #6.
+}
diff --git a/web/src/router.tsx b/web/src/router.tsx
new file mode 100644
index 0000000..f0c478c
--- /dev/null
+++ b/web/src/router.tsx
@@ -0,0 +1,54 @@
+import {
+ createRootRoute,
+ createRoute,
+ createRouter,
+ redirect,
+} from '@tanstack/react-router'
+import { AppShell } from '@/components/layout/AppShell'
+import { LoginPage } from '@/pages/LoginPage'
+import { RegisterPage } from '@/pages/RegisterPage'
+import { GardensPage } from '@/pages/GardensPage'
+import { GardenEditorPage } from '@/pages/GardenEditorPage'
+import { PlantsPage } from '@/pages/PlantsPage'
+
+const rootRoute = createRootRoute({ component: AppShell })
+
+// The root path has no page of its own yet; send it to the gardens list. A real
+// auth-aware landing/redirect lands with the route guard in issue #6.
+const indexRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: '/',
+ beforeLoad: () => {
+ throw redirect({ to: '/gardens' })
+ },
+})
+
+const loginRoute = createRoute({ getParentRoute: () => rootRoute, path: 'login', component: LoginPage })
+const registerRoute = createRoute({ getParentRoute: () => rootRoute, path: 'register', component: RegisterPage })
+const gardensRoute = createRoute({ getParentRoute: () => rootRoute, path: 'gardens', component: GardensPage })
+const gardenEditorRoute = createRoute({
+ getParentRoute: () => rootRoute,
+ path: 'gardens/$gardenId',
+ component: GardenEditorPage,
+})
+const plantsRoute = createRoute({ getParentRoute: () => rootRoute, path: 'plants', component: PlantsPage })
+
+const routeTree = rootRoute.addChildren([
+ indexRoute,
+ loginRoute,
+ registerRoute,
+ gardensRoute,
+ gardenEditorRoute,
+ plantsRoute,
+])
+
+export const router = createRouter({
+ routeTree,
+ defaultPreload: 'intent',
+})
+
+declare module '@tanstack/react-router' {
+ interface Register {
+ router: typeof router
+ }
+}
diff --git a/web/src/styles/index.css b/web/src/styles/index.css
new file mode 100644
index 0000000..9b682ec
--- /dev/null
+++ b/web/src/styles/index.css
@@ -0,0 +1,46 @@
+@import 'tailwindcss';
+
+/* Garden-planner theme tokens. Each --color-* becomes a Tailwind utility
+ (bg-*, text-*, border-*). Light by default; dark via prefers-color-scheme. */
+@theme {
+ --color-bg: #f8faf7;
+ --color-surface: #ffffff;
+ --color-border: #e3e8e0;
+ --color-muted: #61705d;
+ --color-fg: #1c2419;
+
+ --color-accent: #3f8f4f;
+ --color-accent-strong: #2f7a3e;
+ --color-accent-contrast: #ffffff;
+
+ --font-sans: ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif;
+}
+
+@layer base {
+ html,
+ body,
+ #root {
+ height: 100%;
+ }
+ body {
+ background-color: var(--color-bg);
+ color: var(--color-fg);
+ font-family: var(--font-sans);
+ -webkit-font-smoothing: antialiased;
+ }
+}
+
+/* Dark theme: override the same tokens so utilities recolor automatically. */
+@media (prefers-color-scheme: dark) {
+ @theme {
+ --color-bg: #10140f;
+ --color-surface: #171c15;
+ --color-border: #2a3226;
+ --color-muted: #8a967f;
+ --color-fg: #e7ede2;
+
+ --color-accent: #5bb06a;
+ --color-accent-strong: #6fc07d;
+ --color-accent-contrast: #0c1109;
+ }
+}
diff --git a/web/src/vite-env.d.ts b/web/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/web/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/web/tsconfig.json b/web/tsconfig.json
new file mode 100644
index 0000000..65eacec
--- /dev/null
+++ b/web/tsconfig.json
@@ -0,0 +1,29 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "useDefineForClassFields": true,
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "resolveJsonModule": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "verbatimModuleSyntax": true,
+
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["src/*"]
+ }
+ },
+ "include": ["src", "vite.config.ts"]
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
new file mode 100644
index 0000000..b92a007
--- /dev/null
+++ b/web/vite.config.ts
@@ -0,0 +1,34 @@
+import { defineConfig, loadEnv } from 'vite'
+import react from '@vitejs/plugin-react'
+import tailwindcss from '@tailwindcss/vite'
+import { fileURLToPath, URL } from 'node:url'
+
+// In dev, Vite serves the SPA with HMR and proxies /api to the Go backend. The
+// backend listens on PANSY_PORT (default 8080); read it so the proxy target
+// tracks an overridden port. The production build is embedded into the Go binary
+// (see internal/webdist), so no proxy is involved there.
+export default defineConfig(({ mode }) => {
+ const env = loadEnv(mode, process.cwd(), '')
+ const backendPort = env.PANSY_PORT || process.env.PANSY_PORT || '8080'
+ return {
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('./src', import.meta.url)),
+ },
+ },
+ server: {
+ port: 5173,
+ proxy: {
+ '/api': {
+ target: `http://127.0.0.1:${backendPort}`,
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ outDir: 'dist',
+ sourcemap: true,
+ },
+ }
+})