oficina / sheets Public

Merge origin/main (rastrillo-path migration) into chore/door-copy; pin estilo main 530cb93

Keeps main's amadan.net/rastrillo/rastrillo migration (29e4491). Not bumped
to 272ca02 for auth.Config.Body: that pulls in ~33 other rastrillo merges.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 073f9df8123440a3e47932e35a0943f9aff1b448 parent 171225aparent a5c17ab
41 files changed, +907 −67
  • AGENTS.md +13 −0
  • Makefile +20 −3
  • cmd/sheets/main.go +3 −3
  • go.mod +13 −1
  • go.sum +8 −2
  • internal/guardstest/forbidden_imports_test.go +28 −0
  • internal/guardstest/userid_test.go +1 −1
  • internal/sheets/app.go +4 −4
  • internal/sheets/auth.go +2 −2
  • internal/sheets/begin_test.go +2 −2
  • internal/sheets/errors.go +1 −1
  • internal/sheets/handlers_import.go +2 −2
  • internal/sheets/helpers_test.go +2 −2
  • internal/sheets/home.go +2 −2
  • internal/sheets/home_navigation_test.go +1 −1
  • internal/sheets/home_test.go +1 −1
  • internal/sheets/jobpages.go +1 −1
  • internal/sheets/migrations.go +3 −3
  • internal/sheets/native.go +10 −2
  • internal/sheets/native_exchange_test.go +245 −0
  • internal/sheets/render.go +2 −2
  • internal/sheets/signin.go +1 −1
  • internal/sheets/signin_test.go +2 −2
  • internal/sheets/static/sheets.css +25 −0
  • internal/sheets/static/tokens.css +303 −6
  • internal/sheets/store/handle_blank_cap_test.go +2 −2
  • internal/sheets/store/handle_blank_crash_test.go +2 −2
  • internal/sheets/store/handle_blank_recovery_bench_test.go +2 −2
  • internal/sheets/store/handle_version_migration_test.go +2 −2
  • internal/sheets/store/helpers_test.go +2 −2
  • internal/sheets/theme_assets_test.go +11 −1
  • internal/sheets/theme_test.go +1 −1
  • internal/sheets/xlsx/blob.go +1 −1
  • internal/sheets/xlsx/blob_test.go +1 −1
  • internal/sheets/xlsx/helpers_test.go +2 −2
  • internal/sheets/xlsx/plaintext_test.go +2 −2
  • internal/sheets/zoom_browser_test.go +179 −0
  • tools/drive/chrome.mjs +2 −2
  • tools/drive/clipboard.mjs +1 −1
  • tools/drive/menus.mjs +1 −1
  • tools/drive/tableformat.mjs +1 −1
diff --git a/AGENTS.md b/AGENTS.md
index 09c946d..107a769 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -195,6 +195,19 @@ relying on remembering one, make it a test instead.
- `rst-tone` pairs with `data-tone` rather than with a class.
- Adopt per screen, never per element — mixed spellings inside one
component match neither.
+- **rastrillo is `amadan.net/rastrillo/rastrillo`.** The old path,
+ `github.com/carlosframework/rastrillo`, stops at v0.23.0: code importing
+ it gets no fix, and its own checks cannot tell (Sheets zoomed iOS on
+ every tap for a month that way). `TestNoPreMoveRastrillo` allows one
+ import of it, native.go's bridge to rastrillo/native's server, until
+ that server moves. The CLI is pinned as a `tool` in go.mod: run it as
+ `go tool rastrillo …`, never a `rastrillo` from PATH.
+- **After `go tool rastrillo doctor --fix`, delete the scripts it adds**
+ (`static/rastrillo.js`, `select.js`, `datetime.js`, `calendar.js`).
+ Sheets serves estilo's shim and busy.js from memory and uses no other
+ rastrillo script; the shim doctor adds at this pin still carries the
+ old busy rule, which fights estilo's. `TestBusyRuleIsLinkedAndServed`
+ fails if one is left behind.
- **Run `rastrillo doctor` on every rastrillo upgrade**, and
`rastrillo doctor --fix` to re-copy what drifted. `static/tokens.css` is
frozen at scaffold time while the partials it styles keep upgrading with
diff --git a/Makefile b/Makefile
index d02bb33..b55bd98 100644
--- a/Makefile
+++ b/Makefile
@@ -1,7 +1,7 @@
comma := ,
export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(comma))amadan.net/oficina/estilo.git
-.PHONY: build release test test-vet test-fmt test-build test-migrations test-go test-race
+.PHONY: build release test test-vet test-fmt test-build test-migrations test-go test-race test-browser
# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set
# per-target below rather than exported globally, because test-race is
@@ -41,7 +41,7 @@ release:
{ echo "make release: no version to stamp; commit, or name it: make release VERSION=v0.1.0"; exit 1; }
@mkdir -p releases
CGO_ENABLED=0 GOOS=$(RELEASE_GOOS) GOARCH=$(RELEASE_GOARCH) go build -trimpath \
- -ldflags "-s -w -X github.com/carlosframework/rastrillo.BuildVersion=$(VERSION)" \
+ -ldflags "-s -w -X amadan.net/rastrillo/rastrillo.BuildVersion=$(VERSION)" \
-o $(RELEASE_BIN) ./cmd/$(APP)
@echo "built $(RELEASE_BIN) reporting version $(VERSION)"
@echo "publish it with:"
@@ -60,8 +60,11 @@ release:
# package) comes next, and -race — by far the slowest step — runs last.
test: test-vet test-fmt test-build test-migrations test-go test-race
+# Also vets the browser-tagged tests: they are outside `make test`, and a
+# test behind a tag that nothing compiles rots silently.
test-vet:
CGO_ENABLED=0 go vet ./...
+ CGO_ENABLED=0 go vet -tags browser ./internal/sheets/
# Fails on unformatted files rather than rewriting them. CI must never
# silently "fix" a diff out from under a branch — a red test-fmt tells
@@ -85,8 +88,14 @@ test-build:
# app is known to compile and well before test-go pays for real SQLite
# files. A model changed without `rastrillo migration generate` run
# after it fails here, not in production at the next boot.
+#
+# `go tool`, not a bare `rastrillo` on PATH: go.mod pins the CLI as a tool
+# at the same version as the module, so the check always runs the
+# migrate the app is built against — never whatever a machine happened
+# to have installed (a v0.25.0 CLI answering for a v0.23.0 app, until
+# the move to amadan.net/rastrillo/rastrillo).
test-migrations:
- rastrillo migration check
+ go tool rastrillo migration check
# Includes internal/guardstest: the mechanical guards for the line cap,
# forbidden imports, excelize confinement, calc isolation and sealtest
@@ -113,3 +122,11 @@ test-go:
# gets deployed.
test-race:
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
+
+# The browser checks (internal/sheets/*_browser_test.go), in a real
+# Chromium: on PATH, via RASTRILLO_CHROME, or in a Playwright cache.
+# Deliberately NOT part of `test`: the gate CI runs on every push must
+# not need a browser. Run it before shipping a change to the CSS or to
+# rastrillo's pin — TestNoZoomOnTap is the iOS zoom-on-tap check.
+test-browser:
+ CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/sheets/
diff --git a/cmd/sheets/main.go b/cmd/sheets/main.go
index d050593..68e781d 100644
--- a/cmd/sheets/main.go
+++ b/cmd/sheets/main.go
@@ -28,9 +28,9 @@ import (
"net/http"
"os"
- "github.com/carlosframework/rastrillo"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets"
)
diff --git a/go.mod b/go.mod
index c3a5baa..dc1f2a8 100644
--- a/go.mod
+++ b/go.mod
@@ -3,17 +3,27 @@ module amadan.net/oficina/sheets
go 1.25.0
require (
- amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
+ amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/carlosframework/rastrillo v0.23.0
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
+ github.com/chromedp/chromedp v0.14.2
github.com/go-chi/chi/v5 v5.3.2
github.com/xuri/excelize/v2 v2.11.0
golang.org/x/crypto v0.55.0
gorm.io/gorm v1.31.2
)
+require (
+ github.com/BurntSushi/toml v1.6.0 // indirect
+ github.com/chromedp/sysutil v1.1.0 // indirect
+ github.com/gobwas/httphead v0.1.0 // indirect
+ github.com/gobwas/pool v0.2.1 // indirect
+ github.com/gobwas/ws v1.4.0 // indirect
+)
+
require (
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3
github.com/dustin/go-humanize v1.0.1 // indirect
@@ -39,3 +49,5 @@ require (
modernc.org/memory v1.11.0 // indirect
modernc.org/sqlite v1.55.0 // indirect
)
+
+tool amadan.net/rastrillo/rastrillo/cmd/rastrillo
diff --git a/go.sum b/go.sum
index f96f174..4dbeefc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8=
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 h1:bVscoa9fH5LdLDjlyecZhrX7JzWPT0blb57MH0FIm2U=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM=
@@ -8,6 +8,8 @@ amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BL
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3/go.mod h1:ZaaxeE278dJltdrLwmwpxUjQ5wuKzqDAVSNRet+WupY=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
+github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
+github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
@@ -44,12 +46,16 @@ github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8=
github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q=
github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
+github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
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/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
+github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
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=
diff --git a/internal/guardstest/forbidden_imports_test.go b/internal/guardstest/forbidden_imports_test.go
index ebb6af6..b0b8d80 100644
--- a/internal/guardstest/forbidden_imports_test.go
+++ b/internal/guardstest/forbidden_imports_test.go
@@ -27,3 +27,31 @@ func TestNoCgoSQLiteDriver(t *testing.T) {
}
}
}
+
+// preMoveRastrillo is rastrillo's module path before it moved to
+// amadan.net/rastrillo/rastrillo (v0.25.0). Nothing after v0.23.0 was
+// released under it, so code importing it gets no rastrillo fix — which
+// is how Sheets kept zooming iOS on every tap for a month after rastrillo
+// fixed it (polish audit r3, item 1).
+const preMoveRastrillo = "github.com/carlosframework/rastrillo"
+
+// preMoveAllowed is the one file that may still import it, and why:
+// rastrillo/native's server takes the old path's *sessions.Sessions.
+// Delete the entry when native/server moves.
+var preMoveAllowed = map[string]string{
+ "internal/sheets/native.go": preMoveRastrillo + "/sessions",
+}
+
+// TestNoPreMoveRastrillo fails on any import of rastrillo's old module
+// path outside preMoveAllowed.
+func TestNoPreMoveRastrillo(t *testing.T) {
+ root := repoRoot(t)
+ for _, path := range goFiles(t, root) {
+ rel := relPath(t, root, path)
+ for _, imp := range fileImports(t, path) {
+ if importUnder(imp, preMoveRastrillo) && preMoveAllowed[rel] != imp {
+ t.Errorf("%s: imports %q, rastrillo's frozen pre-move path; use amadan.net/rastrillo/rastrillo", rel, imp)
+ }
+ }
+ }
+}
diff --git a/internal/guardstest/userid_test.go b/internal/guardstest/userid_test.go
index b00c7ba..993ac78 100644
--- a/internal/guardstest/userid_test.go
+++ b/internal/guardstest/userid_test.go
@@ -33,7 +33,7 @@ import (
// it covers the violation it was meant to catch. Docs hit exactly that
// (four warning comments, no call sites) and made the same change.
const (
- sessionsPkg = "github.com/carlosframework/rastrillo/sessions"
+ sessionsPkg = "amadan.net/rastrillo/rastrillo/sessions"
forbiddenIdent = "UserID"
)
diff --git a/internal/sheets/app.go b/internal/sheets/app.go
index 01f106d..0dc93c3 100644
--- a/internal/sheets/app.go
+++ b/internal/sheets/app.go
@@ -8,11 +8,11 @@ import (
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/home/connect"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/sessions"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/csrf"
- "github.com/carlosframework/rastrillo/jobs"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/jobs"
"github.com/go-chi/chi/v5"
"gorm.io/gorm"
diff --git a/internal/sheets/auth.go b/internal/sheets/auth.go
index ac16baa..a0f7a2a 100644
--- a/internal/sheets/auth.go
+++ b/internal/sheets/auth.go
@@ -11,8 +11,8 @@ import (
"os"
"strings"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/mail"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/mail"
)
// AuthEnv is everything sign-in needs from the environment, already
diff --git a/internal/sheets/begin_test.go b/internal/sheets/begin_test.go
index 71aacab..f5c4bad 100644
--- a/internal/sheets/begin_test.go
+++ b/internal/sheets/begin_test.go
@@ -10,8 +10,8 @@ import (
"sync"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
diff --git a/internal/sheets/errors.go b/internal/sheets/errors.go
index de31d96..a900dd3 100644
--- a/internal/sheets/errors.go
+++ b/internal/sheets/errors.go
@@ -27,7 +27,7 @@ import (
"strings"
"amadan.net/oficina/estilo.git/chrome"
- "github.com/carlosframework/rastrillo"
+ "amadan.net/rastrillo/rastrillo"
"github.com/go-chi/chi/v5"
)
diff --git a/internal/sheets/handlers_import.go b/internal/sheets/handlers_import.go
index cf36210..a8e973e 100644
--- a/internal/sheets/handlers_import.go
+++ b/internal/sheets/handlers_import.go
@@ -7,8 +7,8 @@ import (
"net/http"
"strconv"
- "github.com/carlosframework/rastrillo/jobs"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/jobs"
+ "amadan.net/rastrillo/rastrillo/sessions"
"amadan.net/oficina/sheets/internal/sheets/xlsx"
)
diff --git a/internal/sheets/helpers_test.go b/internal/sheets/helpers_test.go
index 4f4e797..9821d68 100644
--- a/internal/sheets/helpers_test.go
+++ b/internal/sheets/helpers_test.go
@@ -9,8 +9,8 @@ import (
"sync"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets/seal"
)
diff --git a/internal/sheets/home.go b/internal/sheets/home.go
index 32cea90..a1261f2 100644
--- a/internal/sheets/home.go
+++ b/internal/sheets/home.go
@@ -14,8 +14,8 @@ import (
"amadan.net/oficina/home/connect"
"amadan.net/oficina/home/connect/linkstore"
- "github.com/carlosframework/rastrillo/csrf"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/csrf"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
var errHome = errors.New("sheets: Home connection unavailable")
diff --git a/internal/sheets/home_navigation_test.go b/internal/sheets/home_navigation_test.go
index 9f36663..f78233c 100644
--- a/internal/sheets/home_navigation_test.go
+++ b/internal/sheets/home_navigation_test.go
@@ -2,8 +2,8 @@ package sheets
import (
"amadan.net/oficina/home/connect"
+ "amadan.net/rastrillo/rastrillo"
"context"
- "github.com/carlosframework/rastrillo"
"net/http"
"net/http/httptest"
"net/url"
diff --git a/internal/sheets/home_test.go b/internal/sheets/home_test.go
index 8526e34..aa3dff7 100644
--- a/internal/sheets/home_test.go
+++ b/internal/sheets/home_test.go
@@ -14,7 +14,7 @@ import (
"amadan.net/oficina/home/connect"
"amadan.net/rastrillo/rastrillo/assertion"
assertioncrypto "amadan.net/rastrillo/rastrillo/crypto"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
const homeTestIssuer = "https://id.example.com"
diff --git a/internal/sheets/jobpages.go b/internal/sheets/jobpages.go
index 9fb3ea0..713f84e 100644
--- a/internal/sheets/jobpages.go
+++ b/internal/sheets/jobpages.go
@@ -4,7 +4,7 @@ import (
"bytes"
"net/http"
- "github.com/carlosframework/rastrillo/jobs"
+ "amadan.net/rastrillo/rastrillo/jobs"
)
// jobPageView is the job status page's payload.
diff --git a/internal/sheets/migrations.go b/internal/sheets/migrations.go
index da34efc..d66f731 100644
--- a/internal/sheets/migrations.go
+++ b/internal/sheets/migrations.go
@@ -5,9 +5,9 @@ import (
"amadan.net/oficina/home/handoff"
"embed"
- "github.com/carlosframework/rastrillo/auth"
- "github.com/carlosframework/rastrillo/migrate"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/auth"
+ "amadan.net/rastrillo/rastrillo/migrate"
+ "amadan.net/rastrillo/rastrillo/sessions"
"amadan.net/oficina/sheets/internal/sheets/store"
)
diff --git a/internal/sheets/native.go b/internal/sheets/native.go
index b903d81..d6a271a 100644
--- a/internal/sheets/native.go
+++ b/internal/sheets/native.go
@@ -6,7 +6,15 @@ import (
"strings"
nativeauth "amadan.net/rastrillo/native/server"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/sessions"
+
+ // The one import left on rastrillo's pre-move path, on purpose:
+ // rastrillo/native's server (2a5d07b, and its main) still takes a
+ // github.com/carlosframework/rastrillo *sessions.Sessions. Both
+ // sessions packages read and write the same frozen sessions table, so
+ // a session native sign-in mints is an ordinary session to the app. Move this to
+ // amadan.net/rastrillo/rastrillo/sessions when native/server moves.
+ presessions "github.com/carlosframework/rastrillo/sessions"
"github.com/go-chi/chi/v5"
)
@@ -16,7 +24,7 @@ func (a *App) ConfigureNative() error {
if err != nil {
return err
}
- s, err := sessions.New(sessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"})
+ s, err := presessions.New(presessions.Config{DB: d, Origin: a.origin, SigninPath: "/signin"})
if err != nil {
return err
}
diff --git a/internal/sheets/native_exchange_test.go b/internal/sheets/native_exchange_test.go
new file mode 100644
index 0000000..0083188
--- /dev/null
+++ b/internal/sheets/native_exchange_test.go
@@ -0,0 +1,245 @@
+package sheets
+
+import (
+ "crypto/rand"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// The native sign-in crosses two rastrillo module paths. The app's
+// sessions are amadan.net/rastrillo/rastrillo's; rastrillo/native's
+// server only takes github.com/carlosframework/rastrillo's, which
+// native.go builds over the same database (the one import
+// TestNoPreMoveRastrillo allows). These tests drive the whole exchange
+// through the router, so a change to either package's sessions table,
+// cookie name, token hash or time layout fails here rather than in the
+// Mac app: approval of a browser session, the exchange minting a new
+// one, that one being an ordinary session to the app, and expiry and
+// revocation holding on both sides.
+
+// nativeApp is signInWithOrigin's app with the native routes mounted, as
+// main mounts them, and the browser session's cookies.
+func nativeApp(t *testing.T) (*App, []*http.Cookie) {
+ t.Helper()
+ app, cookies := signInWithOrigin(t)
+ if err := app.ConfigureNative(); err != nil {
+ t.Fatalf("ConfigureNative: %v", err)
+ }
+ return app, cookies
+}
+
+func randomProof(t *testing.T) string {
+ t.Helper()
+ b := make([]byte, 32)
+ if _, err := rand.Read(b); err != nil {
+ t.Fatal(err)
+ }
+ return base64.RawURLEncoding.EncodeToString(b)
+}
+
+// nativeJSONPost is a native client's POST: JSON, from the app's origin.
+func nativeJSONPost(t *testing.T, app *App, path string, body any) *httptest.ResponseRecorder {
+ t.Helper()
+ data, err := json.Marshal(body)
+ if err != nil {
+ t.Fatal(err)
+ }
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(string(data)))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Origin", testOrigin)
+ return do(t, app, req)
+}
+
+// nativeRequest starts a request and returns its id and verifier.
+func nativeRequest(t *testing.T, app *App) (id, verifier string) {
+ t.Helper()
+ verifier = randomProof(t)
+ sum := sha256.Sum256([]byte(verifier))
+ rec := nativeJSONPost(t, app, "/native/request", map[string]string{"challenge": base64.RawURLEncoding.EncodeToString(sum[:])})
+ if rec.Code != http.StatusOK {
+ t.Fatalf("POST /native/request = %d: %s", rec.Code, rec.Body.String())
+ }
+ var out struct{ ID string }
+ if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil || out.ID == "" {
+ t.Fatalf("POST /native/request answered %q", rec.Body.String())
+ }
+ return out.ID, verifier
+}
+
+// nativeApprove is the signed-in browser pressing "Sign in to Sheets app".
+func nativeApprove(t *testing.T, app *App, cookies []*http.Cookie, id string) *httptest.ResponseRecorder {
+ t.Helper()
+ req := httptest.NewRequest(http.MethodPost, "/native/authorize?id="+id, nil)
+ req.Header.Set("Origin", testOrigin)
+ return do(t, app, as(req, cookies))
+}
+
+func nativeExchange(t *testing.T, app *App, id, verifier string) *httptest.ResponseRecorder {
+ t.Helper()
+ return nativeJSONPost(t, app, "/native/exchange", map[string]string{"id": id, "verifier": verifier})
+}
+
+// sessionCookie picks the session cookie out of cookies.
+func sessionCookie(t *testing.T, cookies []*http.Cookie) *http.Cookie {
+ t.Helper()
+ for _, c := range cookies {
+ if strings.Contains(c.Name, "session") {
+ return c
+ }
+ }
+ t.Fatalf("no session cookie in %v", cookies)
+ return nil
+}
+
+// listWith is GET /sheets with one session cookie: 200 signed in.
+func listWith(t *testing.T, app *App, c *http.Cookie) int {
+ t.Helper()
+ return do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{c})).Code
+}
+
+// setSessionExpiry rewrites one session row's expiry, by its token.
+func setSessionExpiry(t *testing.T, app *App, token string, at time.Time) {
+ t.Helper()
+ res := app.db.Exec(`UPDATE sessions SET expires_at = ? WHERE token_hash = ?`,
+ at.UTC().Format(time.RFC3339), sessions.HashToken(token))
+ if res.Error != nil || res.RowsAffected != 1 {
+ t.Fatalf("expire session: %v, %d rows", res.Error, res.RowsAffected)
+ }
+}
+
+func TestNativeExchangeMintsASessionTheAppAccepts(t *testing.T) {
+ app, cookies := nativeApp(t)
+ browser := sessionCookie(t, cookies)
+ create := postForm("/sheets", url.Values{"title": {"Native probe"}})
+ create.Header.Set("Origin", testOrigin)
+ if rec := do(t, app, as(create, cookies)); rec.Code != http.StatusSeeOther {
+ t.Fatalf("creating a sheet = %d: %s", rec.Code, rec.Body.String())
+ }
+ id, verifier := nativeRequest(t, app)
+
+ if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusAccepted {
+ t.Fatalf("exchange before approval = %d, want 202", rec.Code)
+ }
+ if rec := nativeApprove(t, app, cookies, id); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "is signed in") {
+ t.Fatalf("approval = %d: %s", rec.Code, rec.Body.String())
+ }
+ if rec := nativeExchange(t, app, id, randomProof(t)); rec.Code != http.StatusNotFound {
+ t.Fatalf("exchange with the wrong verifier = %d, want 404", rec.Code)
+ }
+ rec := nativeExchange(t, app, id, verifier)
+ if rec.Code != http.StatusOK {
+ t.Fatalf("exchange = %d: %s", rec.Code, rec.Body.String())
+ }
+ var minted struct{ Name, Value string }
+ if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil {
+ t.Fatalf("exchange answered %q", rec.Body.String())
+ }
+ // The pre-move package names the cookie the app reads, and mints a
+ // new token rather than handing back the browser's.
+ if minted.Name != browser.Name {
+ t.Fatalf("exchange minted cookie %q; the app reads %q", minted.Name, browser.Name)
+ }
+ if minted.Value == "" || minted.Value == browser.Value {
+ t.Fatal("exchange did not mint a session of its own")
+ }
+ native := &http.Cookie{Name: minted.Name, Value: minted.Value}
+ if code := listWith(t, app, native); code != http.StatusOK {
+ t.Fatalf("the app refuses the session native minted: GET /sheets = %d", code)
+ }
+ // And it is the same person: their sheet is in the list.
+ list := do(t, app, as(httptest.NewRequest(http.MethodGet, "/sheets", nil), []*http.Cookie{native}))
+ if !strings.Contains(list.Body.String(), "Native probe") {
+ t.Fatal("the minted session is somebody else: the browser's sheet is not in its list")
+ }
+ // Spent: the same id never exchanges twice.
+ if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusNotFound {
+ t.Fatalf("second exchange = %d, want 404", rec.Code)
+ }
+}
+
+func TestNativeSessionExpiryHolds(t *testing.T) {
+ app, cookies := nativeApp(t)
+ browser := sessionCookie(t, cookies)
+
+ // A browser session that has expired cannot approve.
+ id, _ := nativeRequest(t, app)
+ setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute))
+ if rec := nativeApprove(t, app, cookies, id); rec.Code == http.StatusOK && strings.Contains(rec.Body.String(), "is signed in") {
+ t.Fatal("an expired browser session approved a native sign-in")
+ }
+
+ // Approved while live, expired before the exchange: refused.
+ setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour))
+ id, verifier := nativeRequest(t, app)
+ nativeApprove(t, app, cookies, id)
+ setSessionExpiry(t, app, browser.Value, time.Now().Add(-time.Minute))
+ if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("exchange of an expired approval = %d, want 401", rec.Code)
+ }
+
+ // A minted session expires like any other.
+ setSessionExpiry(t, app, browser.Value, time.Now().Add(time.Hour))
+ id, verifier = nativeRequest(t, app)
+ nativeApprove(t, app, cookies, id)
+ rec := nativeExchange(t, app, id, verifier)
+ var minted struct{ Name, Value string }
+ if err := json.Unmarshal(rec.Body.Bytes(), &minted); err != nil {
+ t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String())
+ }
+ native := &http.Cookie{Name: minted.Name, Value: minted.Value}
+ if code := listWith(t, app, native); code != http.StatusOK {
+ t.Fatalf("live minted session: GET /sheets = %d", code)
+ }
+ setSessionExpiry(t, app, minted.Value, time.Now().Add(-time.Minute))
+ if code := listWith(t, app, native); code == http.StatusOK {
+ t.Fatal("the app still accepts a minted session past its expiry")
+ }
+}
+
+func TestNativeSessionRevocationHolds(t *testing.T) {
+ app, cookies := nativeApp(t)
+ browser := sessionCookie(t, cookies)
+
+ // Signing the browser out between approval and exchange refuses it.
+ id, verifier := nativeRequest(t, app)
+ nativeApprove(t, app, cookies, id)
+ if res := app.db.Exec(`DELETE FROM sessions WHERE token_hash = ?`, sessions.HashToken(browser.Value)); res.Error != nil || res.RowsAffected != 1 {
+ t.Fatalf("revoke browser session: %v", res.Error)
+ }
+ if rec := nativeExchange(t, app, id, verifier); rec.Code != http.StatusUnauthorized {
+ t.Fatalf("exchange after the browser signed out = %d, want 401", rec.Code)
+ }
+
+ // The app signing out a minted session (the app's own sessions
+ // package, deleting a row the pre-move package wrote) ends it, and
+ // leaves the browser's alone.
+ app, cookies = nativeApp(t)
+ browser = sessionCookie(t, cookies)
+ id, verifier = nativeRequest(t, app)
+ nativeApprove(t, app, cookies, id)
+ var minted struct{ Name, Value string }
+ if rec := nativeExchange(t, app, id, verifier); json.Unmarshal(rec.Body.Bytes(), &minted) != nil {
+ t.Fatalf("exchange = %d %q", rec.Code, rec.Body.String())
+ }
+ native := &http.Cookie{Name: minted.Name, Value: minted.Value}
+ out := httptest.NewRequest(http.MethodPost, "/signout", nil)
+ out.Header.Set("Origin", testOrigin)
+ if rec := do(t, app, as(out, []*http.Cookie{native})); rec.Code >= 400 {
+ t.Fatalf("POST /signout with the minted session = %d", rec.Code)
+ }
+ if code := listWith(t, app, native); code == http.StatusOK {
+ t.Fatal("a signed-out minted session still reaches /sheets")
+ }
+ if code := listWith(t, app, browser); code != http.StatusOK {
+ t.Fatalf("signing native out ended the browser's session too: GET /sheets = %d", code)
+ }
+}
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index 9470a6e..e7de2f8 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -18,8 +18,8 @@ import (
estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/theme"
- "github.com/carlosframework/rastrillo"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/ui"
)
// templatesFS holds this app's own pages. rastrillo's partials come
diff --git a/internal/sheets/signin.go b/internal/sheets/signin.go
index c87f1fe..199fe1c 100644
--- a/internal/sheets/signin.go
+++ b/internal/sheets/signin.go
@@ -7,7 +7,7 @@ import (
"amadan.net/oficina/estilo.git/brand"
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
- "github.com/carlosframework/rastrillo/sessions"
+ "amadan.net/rastrillo/rastrillo/sessions"
)
// The sign-in pages are estilo's door (estilo-signin, estilo-signin-sent,
diff --git a/internal/sheets/signin_test.go b/internal/sheets/signin_test.go
index 5daf4a8..77a0993 100644
--- a/internal/sheets/signin_test.go
+++ b/internal/sheets/signin_test.go
@@ -8,8 +8,8 @@ import (
"strings"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
)
// TestSigninErrorNeverFallsThroughSilently is the reason signinError
diff --git a/internal/sheets/static/sheets.css b/internal/sheets/static/sheets.css
index b4b7693..05fca0f 100644
--- a/internal/sheets/static/sheets.css
+++ b/internal/sheets/static/sheets.css
@@ -676,6 +676,20 @@
color: inherit;
}
+/* iOS zooms the page into any text control under 16px, and tokens.css
+ * brings controls up to 16px where the pointer is coarse. Two of ours
+ * set `font: inherit` with a selector that outweighs tokens.css's bare
+ * `input`, and fell back to 14px: the cell editor above, and estilo's
+ * title field in the bar. Same rule, same max(), restated here at their
+ * weight. TestNoZoomOnTap (make test-browser) measures both. */
+@media (pointer: coarse) {
+ [data-sheet-grid] .jss_worksheet td input,
+ [data-sheet-grid] .jss_worksheet td textarea,
+ [estilo-bar-title-input] {
+ font-size: max(1rem, 1em);
+ }
+}
+
[data-sheet-grid] .jss_container,
[data-sheet-grid] .jss_content {
background-color: var(--rst-bg);
@@ -1266,6 +1280,17 @@
padding-inline: var(--rst-sp-2);
}
+/* On a touch screen the field is 16px type (no zoom on tap) and the
+ * button is padded for a finger, and the row no longer fits: Set hung
+ * 11-21px out of the panel's right edge. The label column gives the room
+ * back; "Column width" still sets on one line in 5.5rem, and both rows
+ * keep the same column, so they stay in step. */
+@media (pointer: coarse) {
+ [data-size-form] label {
+ flex-basis: 5.5rem;
+ }
+}
+
/* The referenced range is separate from the cell receiving the formula. */
[data-sheet-grid] .jss_worksheet > tbody > tr > td[data-formula-reference] {
outline: 2px solid var(--rst-accent);
diff --git a/internal/sheets/static/tokens.css b/internal/sheets/static/tokens.css
index 16a8809..10f6da5 100644
--- a/internal/sheets/static/tokens.css
+++ b/internal/sheets/static/tokens.css
@@ -62,8 +62,10 @@
Padding values are load-bearing for SC 2.5.8 Target Size (AA, new in
WCAG 2.2): pagination chips measure about 30px, row action pills
- about 27px and buttons about 34px on their smaller axis, all clear of
- the 24px minimum. Do not shrink them without re-measuring.
+ about 27px, and the button sizes about 28px (sm), 34px (default) and
+ 44px (lg) on their smaller axis, all clear of the 24px minimum. Do
+ not shrink them without re-measuring; the button block shows the
+ arithmetic.
Type sizes are rem, not px, so the whole scale tracks a reader who has
raised their browser's default font size. Spacing is rem for the same
@@ -73,6 +75,7 @@
/* ── Scale: type and spacing. Theme-independent. Radius is not here —
it is part of the theme's shape axis; see themes/. ────────────── */
:root {
+ --rst-fs-lg: 1.0625rem; /* 17px — the one step above base */
--rst-fs-base: 0.875rem; /* 14px at the browser default */
--rst-fs-sm: 0.78125rem; /* 12.5px */
--rst-fs-xs: 0.71875rem; /* 11.5px */
@@ -244,6 +247,56 @@ body {
filter: brightness(0.9);
}
+/* ── Button sizes ─────────────────────────────────────────────────────
+ Three steps, and they are a size scale rather than a set of variants:
+ size composes with primary/ghost/danger, so `rst-btn="primary lg"` is
+ the ordinary spelling of a form's submit.
+
+ The default step is the one the page-header action and the row pill
+ want — small, dense, a control beside other controls. It was the ONLY
+ step for a long time, and that is how a form's submit ended up
+ wearing it: a 34px, 12.5px-labelled button stretched across a 44rem
+ column, which reads as a skinny bar rather than the thing the whole
+ screen is asking you to press. A CTA is not a toolbar button; give it
+ `lg`.
+
+ Measured on the smaller axis, against SC 2.5.8's 24px floor — padding
+ both sides, plus the label's line box, plus the 1px border top and
+ bottom (the border is why the default step is 34px and not 32px). The
+ header comment carries these too; keep the two in step:
+ sm 0.3rem × 2 + 11.5px × 1.4 + 2 = 9.6 + 16.1 + 2 ≈ 28px
+ default 0.45rem × 2 + 12.5px × 1.4 + 2 = 14.4 + 17.5 + 2 ≈ 34px
+ lg 0.7rem × 2 + 14px × 1.4 + 2 = 22.4 + 19.6 + 2 ≈ 44px
+ sm is the tightest thing in the file that is still a button. It is
+ for a dense row of them, not for making an ordinary button smaller,
+ and there is deliberately nothing below it. */
+.rst-btn--sm, [rst-btn~="sm"] {
+ font-size: var(--rst-fs-xs);
+ padding: 0.3rem 0.55rem;
+}
+.rst-btn--lg, [rst-btn~="lg"] {
+ font-size: var(--rst-fs-base);
+ padding: 0.7rem 1.15rem;
+}
+
+/* block — a button that fills its container, for the one-action form
+ (sign in, sign up, a confirm page) where the submit IS the screen.
+ It centres its own label: .rst-btn sets no justify-content, so a
+ stretched button's text sits at the row's flex-start and reads as a
+ mistake rather than a choice.
+
+ Pair it with lg. A block button at the default size is precisely the
+ skinny bar described above — the width comes from the container and
+ the height does not follow, so the ratio goes wrong in proportion to
+ how wide the column is. Nothing here enforces that pairing, because a
+ narrow container is a legitimate place for a block button at the
+ default size; the wide ones are where it fails. */
+.rst-btn--block, [rst-btn~="block"] {
+ display: flex;
+ justify-content: center;
+ width: 100%;
+}
+
/* busy — the loading state rastrillo.js gives every submit button on
its way out, and the one state of a button that is not a variant:
any .rst-btn can wear it. The spinner is a real child element
@@ -656,7 +709,7 @@ body {
document outline, not a paragraph dressed up as one. */
.rst-empty__title, [rst-empty-title] {
color: var(--rst-text);
- font-size: 1.0625rem;
+ font-size: var(--rst-fs-lg);
font-weight: 600;
margin: 0 0 var(--rst-sp-2);
}
@@ -733,12 +786,132 @@ body {
.rst-badge--negative, [rst-badge~="negative"] { background: var(--rst-tone-negative-bg); border-color: var(--rst-tone-negative-fg); color: var(--rst-tone-negative-fg); }
.rst-badge--neutral, [rst-badge~="neutral"] { background: var(--rst-tone-neutral-bg); border-color: var(--rst-tone-neutral-fg); color: var(--rst-tone-neutral-fg); }
-/* meter — 4px capacity bar; the number rides beside it as text. */
+/* meter — 4px capacity bar; the number rides beside it as text.
+
+ The bar is a native <meter> (spec §6-v2.4), and styling one is the
+ one place in this file where the same declaration has to be written
+ three times: once on the element, once for the WebKit/Blink
+ pseudo-elements, once for the Firefox one. They cannot share a rule.
+ A selector list containing a pseudo-element an engine does not know
+ is invalid AS A WHOLE in that engine, so ::-webkit-meter-bar and
+ ::-moz-meter-bar in one comma-separated rule would style nothing
+ anywhere. That is why the repetition below is not tidied up.
+
+ The two engines also disagree about which pseudo-element is which.
+ In WebKit/Blink ::-webkit-meter-bar is the TRACK and
+ ::-webkit-meter-optimum-value is the FILL; in Firefox the element
+ itself is the track and ::-moz-meter-bar is the FILL. Read the three
+ blocks with that in mind or they look contradictory.
+
+ HONESTLY FLAGGED: the -moz- rule is unverified. This project's
+ browser drive is Chromium, so the WebKit/Blink path below is measured
+ and the Firefox path is written from the spec and reasoned about. It
+ was shipped that way knowingly (Paul's call). If a Firefox drive ever
+ lands, this is the first rule to point it at.
+
+ The old markup was a <span> with an <i> in it sized by
+ --rst-meter-fill. That custom property is gone; an app that set it is
+ setting nothing now, which is why it is called out in the changelog
+ rather than only here. */
.rst-meter, [rst-meter] { align-items: center; color: var(--rst-text-muted); display: inline-flex; font-size: var(--rst-fs-xs); gap: var(--rst-sp-2); }
-.rst-meter__bar, [rst-meter-bar] { background: var(--rst-accent-soft); border-radius: 2px; flex: 1; height: 4px; min-width: 34px; overflow: hidden; }
-.rst-meter__bar i, [rst-meter-bar] i { background: var(--rst-accent); border-radius: 2px; display: block; height: 100%; width: var(--rst-meter-fill, 0%); }
+.rst-meter__bar, [rst-meter-bar] { appearance: none; background: var(--rst-accent-soft); block-size: 4px; border: 0; border-radius: 2px; flex: 1; min-inline-size: 34px; }
+.rst-meter__bar::-webkit-meter-inner-element, [rst-meter-bar]::-webkit-meter-inner-element { block-size: 100%; }
+.rst-meter__bar::-webkit-meter-bar, [rst-meter-bar]::-webkit-meter-bar { background: var(--rst-accent-soft); block-size: 100%; border: 0; border-radius: 2px; }
+.rst-meter__bar::-webkit-meter-optimum-value, [rst-meter-bar]::-webkit-meter-optimum-value { background: var(--rst-accent); border-radius: 2px; }
+.rst-meter__bar::-moz-meter-bar, [rst-meter-bar]::-moz-meter-bar { background: var(--rst-accent); border-radius: 2px; }
.rst-meter__num, [rst-meter-num] { font-variant-numeric: tabular-nums; white-space: nowrap; }
+/* stats — the instrument strip a dashboard or a report opens with: a
+ lead reading at headline size, companion readings beside it, divided
+ by hairlines inside one card.
+
+ One band, one cell, one lead variant. The headline stat and the
+ counts next to it are the same component at two sizes (spec
+ §6-v2.4), so there is no second kind to keep in step with this one.
+
+ flex-wrap and a flex-basis rather than a grid with a column count.
+ Any number of cells has to work — three on one dashboard, five on
+ the next — and a grid would need the caller to say how many. The
+ basis is what decides when the strip becomes two rows, and the lead
+ cell's is larger so it keeps the line to itself when the others
+ wrap under it.
+
+ The hairline is border-inline-start on every cell but the first,
+ which is what makes the strip read as ONE instrument rather than as
+ a row of cards. Logical, so it lands on the right in Arabic. A
+ wrapped cell keeps its line, and that is the one visual compromise
+ here: at two rows the first cell of the second row draws a leading
+ hairline against the card's edge. It is a hairline, and the
+ alternative — :nth-child arithmetic against a column count nobody
+ declared — is the grid this deliberately is not. */
+.rst-stats, [rst-stats] {
+ background: var(--rst-surface);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--rst-radius);
+ display: flex;
+ flex-wrap: wrap;
+}
+.rst-stat, [rst-stat] {
+ border-inline-start: 1px solid var(--rst-line);
+ display: flex;
+ flex: 1 1 9rem;
+ flex-direction: column;
+ gap: 0.15rem;
+ min-inline-size: 0;
+ padding: var(--rst-sp-4);
+}
+.rst-stat:first-child, [rst-stat]:first-child {
+ border-inline-start: 0;
+}
+.rst-stat--lead, [rst-stat~="lead"] {
+ flex-basis: 14rem;
+}
+/* The eyebrow. Uppercase and tracked, the same grammar every label in
+ this system uses above a value, so a band of cells reads as one row
+ of labels rather than as four headings. */
+.rst-stat__label, [rst-stat-label] {
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ font-weight: 650;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+/* tabular-nums so a column of readings lines up on its digits, and so
+ a number that changes on a poll does not jitter its neighbours. */
+.rst-stat__num, [rst-stat-num] {
+ font-size: 1.5rem;
+ font-variant-numeric: tabular-nums;
+ font-weight: 650;
+ line-height: 1.1;
+}
+.rst-stat--lead .rst-stat__num, [rst-stat~="lead"] [rst-stat-num] {
+ font-size: 2.1rem;
+}
+/* The delta is TEXT in a colour, not a colour. Its sign is written by
+ the caller and is the signal a reader who cannot tell the two hues
+ apart is reading; the colour is the second one (WCAG 2.2 AA 1.4.1).
+ Both hues are held at 4.5:1 against this card by the contrast gate
+ in the same commit that added them — they are text, so the floor
+ applies whether or not the colour carries meaning of its own. */
+.rst-stat__delta, [rst-stat-delta] {
+ font-size: var(--rst-fs-sm);
+ font-variant-numeric: tabular-nums;
+ font-weight: 600;
+}
+.rst-stat__delta[data-tone="positive"], [rst-stat-delta][rst-tone~="positive"] {
+ color: var(--rst-tone-positive-fg);
+}
+.rst-stat__delta[data-tone="negative"], [rst-stat-delta][rst-tone~="negative"] {
+ color: var(--rst-tone-negative-fg);
+}
+.rst-stat__delta[data-tone="neutral"], [rst-stat-delta][rst-tone~="neutral"] {
+ color: var(--rst-text-muted);
+}
+.rst-stat__note, [rst-stat-note] {
+ color: var(--rst-text-faint);
+ font-size: var(--rst-fs-xs);
+}
+
/* person — avatar + name + email; avatar is decoration, text is identity. */
.rst-person, [rst-person] { align-items: center; color: inherit; display: inline-flex; gap: 0.7rem; min-width: 0; text-decoration: none; }
.rst-person__av, [rst-person-av] { align-items: center; background: var(--rst-accent); border-radius: 50%; color: var(--rst-on-accent); display: flex; flex: none; font-size: 0.6875rem; font-weight: 600; height: 28px; justify-content: center; width: 28px; }
@@ -1039,6 +1212,17 @@ details[open] > summary > .rst-caret, details[open] > summary > [rst-caret] { tr
and it is wide enough for the postcodes and codes it is for. The
max keeps it from overflowing a column narrower than itself. */
.rst-input--short, [rst-input~="short"] { inline-size: 8rem; max-inline-size: 100%; }
+/* primary is the form's headline field — the title or name the record is
+ known by — and the size is the whole signal, because the label above it
+ stays the same quiet line every other field gets. font-size has to be
+ restated rather than inherited: .rst-input sets `font: inherit`, so the
+ shorthand would otherwise reset it back to the body's 14px. 550 is the
+ weight .rst-nm and .rst-person__name already give a name; 600 reads as a
+ heading, which this is not. The padding grows with the type so the box
+ keeps its proportions instead of looking like a squeezed plain input.
+ 17px is also where iOS Safari stops zooming a focused input, which the
+ 14px base does not clear — a real bonus, not the reason. */
+.rst-input--primary, [rst-input~="primary"] { font-size: var(--rst-fs-lg); font-weight: 550; padding: 0.6rem 0.7rem; }
textarea.rst-input, textarea[rst-input] { min-height: 5rem; resize: vertical; }
/* field-text/field-textarea (the interim pair the manifest generator
emits) mark a required label with * and put the textarea on its own
@@ -1288,6 +1472,19 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
animation: rst-spin 0.8s linear infinite;
}
@keyframes rst-spin { to { transform: rotate(1turn); } }
+
+/* A running job's determinate bar: a native <progress>, styled the same
+ way and for the same reasons as [rst-meter-bar] — see that block for
+ why the three engines need three rules that cannot be merged, and for
+ the same honest flag about the -moz- one being unverified here.
+ <progress>'s pseudo-elements are its own: ::-webkit-progress-bar is
+ the track, ::-webkit-progress-value the fill, ::-moz-progress-bar the
+ fill. It is a block on its own line under the sentence, because it
+ measures the job the sentence is about. */
+.rst-job__bar, [rst-job-bar] { appearance: none; background: var(--rst-accent-soft); block-size: 4px; border: 0; border-radius: 2px; display: block; inline-size: 100%; margin-block-start: var(--rst-sp-2); }
+.rst-job__bar::-webkit-progress-bar, [rst-job-bar]::-webkit-progress-bar { background: var(--rst-accent-soft); border-radius: 2px; }
+.rst-job__bar::-webkit-progress-value, [rst-job-bar]::-webkit-progress-value { background: var(--rst-accent); border-radius: 2px; }
+.rst-job__bar::-moz-progress-bar, [rst-job-bar]::-moz-progress-bar { background: var(--rst-accent); border-radius: 2px; }
@media (prefers-reduced-motion: reduce) {
.rst-spin, [rst-spin] { animation: none; opacity: 0.5; }
}
@@ -1343,6 +1540,72 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-dtp__set, [rst-dtp-set] { flex: none; font-size: var(--rst-fs-xs); color: var(--rst-muted); }
.rst-dtp__hint, [rst-dtp-hint] { color: var(--rst-muted); font-size: var(--rst-fs-xs); padding: 0.35rem 0.65rem 0.15rem; }
+/* The popup opens above the field where there is no room below it —
+ datetime.js measures and sets .is-above, because a panel hanging off
+ the bottom of the window is a panel whose last week nobody can click.
+ One rule for both popups: only ever one of them is open. */
+.rst-dtp.is-above .rst-dtp__list, [rst-dtp].is-above [rst-dtp-list], .rst-dtp.is-above .rst-cal, [rst-dtp].is-above [rst-cal] { inset-block-end: 100%; margin-block-end: 4px; margin-block-start: 0; top: auto; }
+/* A completion offered rather than chosen — the other months a
+ half-typed word could still have meant. Lighter than the reading
+ above it, because it is a second-best by construction. */
+.rst-dtp__row--alt .rst-dtp__label, [rst-dtp-row~="alt"] [rst-dtp-label] { color: var(--rst-text-muted); }
+
+/* ── The calendar overlay (ui/calendar.js): the month grid a date
+ field's button opens. A real <table> with real column headers,
+ because that is what a calendar is, with role="grid" over the top
+ and a roving tabindex so exactly one day sits in the tab order.
+
+ Sized in rem so the grid keeps its shape when a page is zoomed:
+ seven columns of text, not seven columns of pixels. A fixed inline
+ size rather than the field's, because a calendar is a calendar
+ whatever it hangs under — a month stretched across a wide input is
+ a grid nobody can aim at.
+
+ Everything here is logical (inline/block, never left/right), so a
+ right-to-left page mirrors the panel and the grid together. The
+ chevrons are the one thing that needs saying twice, below.
+ ───────────────────────────────────────────────────────────────── */
+.rst-cal, [rst-cal] { background: var(--rst-surface); border: 1px solid var(--rst-line); border-radius: 9px; box-shadow: var(--rst-shadow-pop); inline-size: 18rem; inset-inline-start: 0; margin-block-start: 4px; padding: var(--rst-sp-2); position: absolute; top: 100%; z-index: 40; }
+.rst-cal__head, [rst-cal-head] { align-items: center; display: flex; gap: var(--rst-sp-1); justify-content: space-between; padding-block-end: var(--rst-sp-2); }
+.rst-cal__title, [rst-cal-title] { flex: 1; font-size: var(--rst-fs-sm); font-weight: 600; text-align: center; }
+.rst-cal__nav, [rst-cal-nav] { align-items: center; background: none; block-size: 1.75rem; border: 0; border-radius: var(--rst-radius-sm); color: var(--rst-muted); cursor: pointer; display: flex; inline-size: 1.75rem; justify-content: center; padding: 0; }
+.rst-cal__nav:hover:not(:disabled), [rst-cal-nav]:hover:not(:disabled) { background: var(--rst-accent-soft); color: var(--rst-text); }
+/* A month with nothing pickable in it — past the field's min, or past
+ its max. Dimmed and inert rather than removed, so the header keeps
+ its shape and the title stays where the eye left it. */
+.rst-cal__nav:disabled, [rst-cal-nav]:disabled { cursor: default; opacity: 0.35; }
+.rst-cal__nav svg, [rst-cal-nav] svg { block-size: 1.1rem; inline-size: 1.1rem; }
+/* One chevron, drawn once and turned round for the other direction, so
+ "back" always points at the start of the line — which is the other
+ end of the box in a right-to-left page, and handled there by the
+ panel's own direction rather than a second rule. */
+[rst-cal-nav~="next"] svg, .rst-cal__nav--next svg { transform: scaleX(-1); }
+.rst-cal__grid, [rst-cal-grid] { border-collapse: collapse; inline-size: 100%; table-layout: fixed; }
+.rst-cal__grid th, [rst-cal-grid] th { color: var(--rst-muted); font-size: var(--rst-fs-xs); font-weight: 500; padding-block: var(--rst-sp-1); text-align: center; }
+.rst-cal__day, [rst-cal-day] { border-radius: var(--rst-radius-sm); cursor: pointer; font-size: var(--rst-fs-sm); font-variant-numeric: tabular-nums; line-height: 2.15; text-align: center; }
+.rst-cal__day:hover, [rst-cal-day]:hover { background: var(--rst-accent-soft); }
+/* The days either side of the month. Still pickable — reaching for the
+ 1st from the row above it is how people use a calendar — but plainly
+ not part of the month on screen. */
+.rst-cal__day.is-outside, [rst-cal-day].is-outside { color: var(--rst-text-faint); }
+/* Today is a ring rather than a fill, so it can still be read when the
+ selection is sitting on top of it. */
+.rst-cal__day.is-today, [rst-cal-day].is-today { box-shadow: inset 0 0 0 1px var(--rst-line-strong); font-weight: 600; }
+/* What the text in the box currently reads as: an outline that follows
+ the words as they are typed. Deliberately quieter than the selection
+ — it is a preview, and nothing has been chosen yet. */
+.rst-cal__day.is-preview, [rst-cal-day].is-preview { box-shadow: inset 0 0 0 2px var(--rst-accent); }
+/* The committed value. aria-selected says so in the accessibility tree;
+ this is the matching visual, never the only signal. */
+.rst-cal__day.is-selected, [rst-cal-day].is-selected { background: var(--rst-accent); color: var(--rst-on-accent); font-weight: 600; }
+.rst-cal__day.is-selected.is-outside, [rst-cal-day].is-selected.is-outside { color: var(--rst-on-accent); }
+/* Outside the field's own min/max. aria-disabled carries it to a screen
+ reader and calendar.js refuses the pick; this says the same to the
+ eye. */
+.rst-cal__day.is-disabled, [rst-cal-day].is-disabled { color: var(--rst-text-faint); cursor: default; text-decoration: line-through; }
+.rst-cal__day.is-disabled:hover, [rst-cal-day].is-disabled:hover { background: none; }
+.rst-cal__day:focus-visible, [rst-cal-day]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+
/* ── Shells: the page frames an app's layout template wraps .rst-page
in — topbar and sidebar. Written in logical properties throughout,
so a right-to-left locale gets a mirrored shell for free: the rail
@@ -1618,6 +1881,40 @@ dialog.rst-modal-panel, dialog[rst-modal-panel] { block-size: auto; color: inher
.rst-error__cta, [rst-error-cta] { display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); justify-content: center; margin-block: var(--rst-sp-5) 0; }
.rst-error__ref, [rst-error-ref] { color: var(--rst-text-faint); margin-block: 2rem 0; }
+/* ---------------------------------------------------------------------
+ Touch: the two zooms nobody asked for.
+
+ Mobile Safari zooms the whole page when it focuses a form control
+ whose text is under 16px, and this file's base type is 14px on
+ purpose. So every text-entry control comes back up to 16px where the
+ pointer is coarse, and stays 14px under a mouse. max() rather than a
+ flat 1rem, so a control deliberately set larger keeps its size; rem
+ rather than px, so a reader who raised the browser default keeps the
+ increase. This is the fix. The other one — user-scalable=no in the
+ viewport tag — buys the same quiet by taking pinch-zoom away from
+ everyone, which is a WCAG 1.4.4 failure and not on offer here.
+
+ Double-tap is the second zoom: tap a button or a row twice in quick
+ succession and the second tap lands as a zoom gesture rather than a
+ click. touch-action: manipulation drops that gesture on interactive
+ elements only, so panning and pinch-zoom are untouched, and so is
+ double-tapping prose or an image, which is a real way to read a
+ narrow column. :where() weighs nothing, so any rule can override it.
+ --------------------------------------------------------------------- */
+
+@media (pointer: coarse) {
+ input, select, textarea,
+ .rst-input, [rst-input],
+ .rst-textarea, [rst-textarea],
+ .rst-search input[type="search"], [rst-search] input[type="search"] {
+ font-size: max(1rem, 1em);
+ }
+}
+
+:where(a[href], button, input, select, textarea, label, summary, [role="button"], [role="tab"], [role="option"]) {
+ touch-action: manipulation;
+}
+
/* ---------------------------------------------------------------------
Utilities, last on purpose.
diff --git a/internal/sheets/store/handle_blank_cap_test.go b/internal/sheets/store/handle_blank_cap_test.go
index f06ce08..fa46609 100644
--- a/internal/sheets/store/handle_blank_cap_test.go
+++ b/internal/sheets/store/handle_blank_cap_test.go
@@ -5,8 +5,8 @@ import (
"path/filepath"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
diff --git a/internal/sheets/store/handle_blank_crash_test.go b/internal/sheets/store/handle_blank_crash_test.go
index a52b072..a339e2c 100644
--- a/internal/sheets/store/handle_blank_crash_test.go
+++ b/internal/sheets/store/handle_blank_crash_test.go
@@ -6,8 +6,8 @@ import (
"path/filepath"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/calc"
diff --git a/internal/sheets/store/handle_blank_recovery_bench_test.go b/internal/sheets/store/handle_blank_recovery_bench_test.go
index 66e03f7..ff2c4a0 100644
--- a/internal/sheets/store/handle_blank_recovery_bench_test.go
+++ b/internal/sheets/store/handle_blank_recovery_bench_test.go
@@ -8,8 +8,8 @@ import (
"testing"
"time"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"gorm.io/gorm"
"amadan.net/oficina/sheets/internal/sheets"
diff --git a/internal/sheets/store/handle_version_migration_test.go b/internal/sheets/store/handle_version_migration_test.go
index 9887a6e..f191a70 100644
--- a/internal/sheets/store/handle_version_migration_test.go
+++ b/internal/sheets/store/handle_version_migration_test.go
@@ -11,8 +11,8 @@ import (
"amadan.net/oficina/sheets/internal/sheets/calc"
"amadan.net/oficina/sheets/internal/sheets/seal"
"amadan.net/oficina/sheets/internal/sheets/store"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
)
func TestVersionWatermarkMigrationPreservesExistingContent(t *testing.T) {
diff --git a/internal/sheets/store/helpers_test.go b/internal/sheets/store/helpers_test.go
index 410d445..65e2208 100644
--- a/internal/sheets/store/helpers_test.go
+++ b/internal/sheets/store/helpers_test.go
@@ -4,8 +4,8 @@ import (
"context"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"gorm.io/gorm"
"amadan.net/oficina/sheets/internal/sheets"
diff --git a/internal/sheets/theme_assets_test.go b/internal/sheets/theme_assets_test.go
index c2bcfd4..b103d65 100644
--- a/internal/sheets/theme_assets_test.go
+++ b/internal/sheets/theme_assets_test.go
@@ -1,6 +1,7 @@
package sheets
import (
+ "io/fs"
"path"
"amadan.net/oficina/estilo.git/busy"
@@ -13,7 +14,7 @@ import (
"testing"
"amadan.net/oficina/estilo.git/theme"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo/ui"
)
func TestSharedThemeAssetFingerprintAndCache(t *testing.T) {
@@ -95,4 +96,13 @@ func TestBusyRuleIsLinkedAndServed(t *testing.T) {
if i, j := strings.Index(page, "static/rastrillo."), strings.Index(page, "static/estilo/busy."); i < 0 || j < 0 {
t.Error("the shim or busy.js is missing")
}
+ // No rastrillo script on disk. `rastrillo doctor --fix` adds the ones
+ // the library ships and the app lacks, and the shim it adds at this
+ // pin is the old one, with the busy rule inside: loaded beside
+ // estilo's busy.js, the two rules fight. Delete what --fix adds.
+ for _, name := range []string{"static/rastrillo.js", "static/busy.js", "static/select.js", "static/datetime.js", "static/calendar.js"} {
+ if _, err := fs.Stat(staticFS, name); err == nil {
+ t.Errorf("%s is on disk; Sheets serves estilo's shim and busy.js, and no other rastrillo script", name)
+ }
+ }
}
diff --git a/internal/sheets/theme_test.go b/internal/sheets/theme_test.go
index 7772174..1af20df 100644
--- a/internal/sheets/theme_test.go
+++ b/internal/sheets/theme_test.go
@@ -16,7 +16,7 @@ import (
"testing"
"amadan.net/oficina/estilo.git/chrome"
- "github.com/carlosframework/rastrillo/ui"
+ "amadan.net/rastrillo/rastrillo/ui"
)
// The bug theme.go was written to stop coming back: data-theme takes the
diff --git a/internal/sheets/xlsx/blob.go b/internal/sheets/xlsx/blob.go
index 8871115..7911f77 100644
--- a/internal/sheets/xlsx/blob.go
+++ b/internal/sheets/xlsx/blob.go
@@ -5,7 +5,7 @@ import (
"context"
"fmt"
- "github.com/carlosframework/rastrillo/blobs"
+ "amadan.net/rastrillo/rastrillo/blobs"
"amadan.net/oficina/sheets/internal/sheets/store"
)
diff --git a/internal/sheets/xlsx/blob_test.go b/internal/sheets/xlsx/blob_test.go
index 2dfacab..5e59a43 100644
--- a/internal/sheets/xlsx/blob_test.go
+++ b/internal/sheets/xlsx/blob_test.go
@@ -8,7 +8,7 @@ import (
"strings"
"testing"
- "github.com/carlosframework/rastrillo/blobs"
+ "amadan.net/rastrillo/rastrillo/blobs"
"amadan.net/oficina/sheets/internal/sheets/xlsx"
)
diff --git a/internal/sheets/xlsx/helpers_test.go b/internal/sheets/xlsx/helpers_test.go
index 0ebb55c..c3cc600 100644
--- a/internal/sheets/xlsx/helpers_test.go
+++ b/internal/sheets/xlsx/helpers_test.go
@@ -9,8 +9,8 @@ import (
"strconv"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"github.com/xuri/excelize/v2"
"gorm.io/gorm"
diff --git a/internal/sheets/xlsx/plaintext_test.go b/internal/sheets/xlsx/plaintext_test.go
index 33560bf..d670de2 100644
--- a/internal/sheets/xlsx/plaintext_test.go
+++ b/internal/sheets/xlsx/plaintext_test.go
@@ -6,8 +6,8 @@ import (
"path/filepath"
"testing"
- rdb "github.com/carlosframework/rastrillo/db"
- "github.com/carlosframework/rastrillo/migrate"
+ rdb "amadan.net/rastrillo/rastrillo/db"
+ "amadan.net/rastrillo/rastrillo/migrate"
"amadan.net/oficina/sheets/internal/sheets"
"amadan.net/oficina/sheets/internal/sheets/sealtest"
diff --git a/internal/sheets/zoom_browser_test.go b/internal/sheets/zoom_browser_test.go
new file mode 100644
index 0000000..8ed823f
--- /dev/null
+++ b/internal/sheets/zoom_browser_test.go
@@ -0,0 +1,179 @@
+//go:build browser
+
+// The browser checks: Sheets in a real Chromium. Not part of the plain
+// suite, because it needs a Chromium (on PATH, via RASTRILLO_CHROME, or in
+// a Playwright cache):
+//
+// make test-browser
+//
+// `make test-vet` vets this file with the tag, so it cannot stop
+// compiling unnoticed.
+package sheets
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/harness"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/cdproto/network"
+ "github.com/chromedp/chromedp"
+)
+
+// textControls is every control a tap puts a caret in. A checkbox, a
+// radio, a file picker or a button raises no keyboard, so iOS never
+// zooms for them.
+const textControls = `input:not([type=checkbox]):not([type=radio]):not([type=hidden]):not([type=file]):not([type=submit]):not([type=button]):not([type=color]):not([type=range]), select, textarea`
+
+// TestNoZoomOnTap: iOS Safari zooms the whole page into any text control
+// whose type is under 16px, and rastrillo's base type is 14px. Its
+// tokens.css brings text controls up to 16px where the pointer is coarse
+// ("Touch: the two zooms nobody asked for"); an app on a tokens.css
+// without that block, or with a rule of its own that sets a control
+// smaller, zooms on the first tap of every field.
+//
+// Emulated as pointer: coarse, which is how the CSS decides. The grid's
+// cell editor is measured too: it only exists once a cell is being
+// edited, and it is the field someone taps most.
+func TestNoZoomOnTap(t *testing.T) {
+ app, mail := appWithRecorder(t)
+ cookies := sessionFor(t, app, mail)
+ sheet, _ := newSheet(t, app, cookies, "Zoom")
+
+ rig := harness.New(t, func(string) http.Handler { return app.Router() })
+ // Signed out first: the door is the field every visit starts on.
+ pages := []struct{ path, ready string }{
+ {"/signin", `input[name="address"]`},
+ {"", ""}, // sign in, below
+ {"/sheets", `main`},
+ {fmt.Sprintf("/sheets/%d", sheet.ID), `td[data-x="0"][data-y="0"]`},
+ {fmt.Sprintf("/sheets/%d/share", sheet.ID), `input[type=email], input[name=address]`},
+ {fmt.Sprintf("/sheets/%d/comments", sheet.ID), `textarea, input[type=text]`},
+ {fmt.Sprintf("/sheets/%d/table", sheet.ID), `main`},
+ }
+ run := func(what string, actions ...chromedp.Action) {
+ t.Helper()
+ ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
+ defer cancel()
+ if err := chromedp.Run(ctx, actions...); err != nil {
+ var at string
+ _ = chromedp.Run(rig.Context(), chromedp.Location(&at))
+ t.Fatalf("%s: %v (the browser is at %s)", what, err, at)
+ }
+ }
+ run("touch emulation",
+ // Touch emulation, then a reload so the page re-evaluates its
+ // media queries. SetEmulatedMedia's "pointer" feature is not
+ // emulated by this Chromium; the coarse check below is what
+ // proves the emulation took.
+ emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
+ )
+ for _, page := range pages {
+ if page.path == "" {
+ // The session signIn's real magic-link flow minted, handed
+ // to the browser.
+ var set []chromedp.Action
+ for _, c := range cookies {
+ set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/").
+ WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax))
+ }
+ run("sign in", set...)
+ continue
+ }
+ var coarse bool
+ var small []string
+ run(page.path,
+ chromedp.Navigate(rig.Origin+page.path),
+ chromedp.Reload(),
+ chromedp.WaitVisible(page.ready),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(smallControls(textControls), &small),
+ )
+ if !coarse {
+ t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", page.path)
+ }
+ if len(small) > 0 {
+ t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", page.path, strings.Join(small, "; "))
+ }
+ }
+
+ // The cell editor, opened the way a double-tap opens it.
+ var small []string
+ run("the cell editor",
+ chromedp.Navigate(fmt.Sprintf("%s/sheets/%d", rig.Origin, sheet.ID)),
+ chromedp.WaitVisible(`td[data-x="0"][data-y="0"]`),
+ chromedp.DoubleClick(`td[data-x="0"][data-y="0"]`),
+ chromedp.WaitVisible(`td[data-x="0"][data-y="0"] input, td[data-x="0"][data-y="0"] textarea, td[data-x="0"][data-y="0"] [contenteditable]`),
+ chromedp.Evaluate(smallControls(`td[data-x="0"][data-y="0"] input, td[data-x="0"][data-y="0"] textarea, td[data-x="0"][data-y="0"] [contenteditable]`), &small),
+ )
+ if len(small) > 0 {
+ t.Errorf("the cell editor zooms when a cell is tapped: %s", strings.Join(small, "; "))
+ }
+}
+
+// TestFormatMenuFitsOnTap: the Format menu's two size rows (label, a
+// number field, Set) are sized to the panel's 15rem. On a touch screen
+// the field is 16px type and the button is padded for a finger, and Set
+// hung 11-21px past the panel's edge, half of it off the menu, at every
+// width. sheets.css narrows the label column under a coarse pointer;
+// this measures the result at 320 and 390 wide: each Set button inside
+// its panel, and its centre landing on the button, not on whatever is
+// behind the menu.
+func TestFormatMenuFitsOnTap(t *testing.T) {
+ app, cookies := signIn(t)
+ sheet, _ := newSheet(t, app, cookies, "Format")
+ rig := harness.New(t, func(string) http.Handler { return app.Router() })
+ var set []chromedp.Action
+ for _, c := range cookies {
+ set = append(set, network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/").
+ WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax))
+ }
+ set = append(set, emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5))
+ rig.Run(set...)
+ for _, width := range []int64{320, 390} {
+ var coarse bool
+ var problems []string
+ ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
+ err := chromedp.Run(ctx,
+ emulation.SetDeviceMetricsOverride(width, 844, 1, true),
+ chromedp.Navigate(fmt.Sprintf("%s/sheets/%d", rig.Origin, sheet.ID)),
+ chromedp.Reload(),
+ chromedp.WaitVisible(`td[data-x="0"][data-y="0"]`),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(`[...document.querySelectorAll('[estilo-menubar-title]')].find(e => e.textContent.trim() === 'Format').click()`, nil),
+ chromedp.WaitVisible(`[data-size-form] button`),
+ chromedp.Evaluate(`[...document.querySelectorAll('[data-size-form] button')].flatMap(b => {
+ const r = b.getBoundingClientRect(), m = b.closest('[estilo-menu]').getBoundingClientRect()
+ const out = []
+ if (r.left < m.left || r.right > m.right || r.top < m.top || r.bottom > m.bottom)
+ out.push(b.closest('form').dataset.axis + ': Set spans ' + Math.round(r.left) + '-' + Math.round(r.right) + ', its panel ' + Math.round(m.left) + '-' + Math.round(m.right))
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2)
+ if (!hit || !b.contains(hit)) out.push(b.closest('form').dataset.axis + ': a tap on Set lands on ' + (hit ? hit.tagName.toLowerCase() : 'nothing'))
+ return out
+ })`, &problems),
+ )
+ cancel()
+ if err != nil {
+ t.Fatalf("%dpx: %v", width, err)
+ }
+ if !coarse {
+ t.Fatalf("%dpx: the touch-screen emulation did not apply", width)
+ }
+ for _, p := range problems {
+ t.Errorf("%dpx: %s", width, p)
+ }
+ }
+}
+
+// smallControls is a script listing each visible control matching sel
+// whose computed font-size is under 16px, described well enough to find.
+func smallControls(sel string) string {
+ return fmt.Sprintf(`[...document.querySelectorAll(%q)]
+ .filter(e => e.getClientRects().length > 0)
+ .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16)
+ .map(e => e.tagName.toLowerCase() + (e.name ? '[name=' + e.name + ']' : '') + (e.className ? '.' + String(e.className).trim().split(/\s+/).join('.') : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
+}
diff --git a/tools/drive/chrome.mjs b/tools/drive/chrome.mjs
index 62ccdf9..7e0db03 100644
--- a/tools/drive/chrome.mjs
+++ b/tools/drive/chrome.mjs
@@ -56,7 +56,7 @@ page.on('pageerror', (e) => errors.push(String(e)));
* "\n\nIt..." and the link comes back expired. */
await page.goto(`${base}/signin`);
await page.fill('input[type=email]', 'paul@example.com');
-await Promise.all([page.waitForLoadState('networkidle'), page.click('button[type=submit]')]);
+await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
const log = fs.readFileSync(LOG, 'utf8');
const m = [...log.matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
if (!m) { console.error('no magic link in the log'); process.exit(1); }
@@ -283,7 +283,7 @@ const vErrors = [];
vp.on('pageerror', (e) => vErrors.push(String(e)));
await vp.goto(`${base}/signin`);
await vp.fill('input[type=email]', 'ada@elsewhere.example');
-await Promise.all([vp.waitForLoadState('networkidle'), vp.click('button[type=submit]')]);
+await Promise.all([vp.waitForURL(/signin\?sent=1/), vp.click('form[action="/signin"] button[type=submit]')]);
const vm = [...fs.readFileSync(LOG, 'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await vp.goto(vm[0]);
await vp.goto(url);
diff --git a/tools/drive/clipboard.mjs b/tools/drive/clipboard.mjs
index a240fdd..66f3af5 100644
--- a/tools/drive/clipboard.mjs
+++ b/tools/drive/clipboard.mjs
@@ -31,7 +31,7 @@ const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('consol
const fails=[]; const ok=n=>console.log(' ok '+n);
const check=(n,c,d)=>c?ok(n):(fails.push(n),console.log(' FAIL '+n+' — '+d));
await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
-await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// Create from the top bar, then name it by renaming: the list's #title
diff --git a/tools/drive/menus.mjs b/tools/drive/menus.mjs
index 2d077f3..8ecc9ae 100644
--- a/tools/drive/menus.mjs
+++ b/tools/drive/menus.mjs
@@ -45,7 +45,7 @@ const errors=[]; page.on('pageerror',e=>errors.push(String(e))); page.on('consol
const reqs=[]; page.on('request',r=>{if(r.method()!=='GET')reqs.push(r.method()+' '+new URL(r.url()).pathname);});
await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
-await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
const mm=[...fs.readFileSync(LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// No sheets yet: Create from the top bar, then name it by renaming.
diff --git a/tools/drive/tableformat.mjs b/tools/drive/tableformat.mjs
index 535bf8e..74d2ae4 100644
--- a/tools/drive/tableformat.mjs
+++ b/tools/drive/tableformat.mjs
@@ -23,7 +23,7 @@ const browser=await chromium.launch();
const page=await (await browser.newContext({viewport:{width:1280,height:900},deviceScaleFactor:2})).newPage();
const errs=[]; page.on('pageerror',e=>errs.push(String(e)));
await page.goto(`${base}/signin`); await page.fill('input[type=email]','paul@example.com');
-await Promise.all([page.waitForLoadState('networkidle'),page.click('button[type=submit]')]);
+await Promise.all([page.waitForURL(/signin\?sent=1/), page.click('form[action="/signin"] button[type=submit]')]);
const mm=[...fs.readFileSync(process.env.SHEETS_LOG,'utf8').matchAll(/http:\/\/127\.0\.0\.1:8799\/auth\/verify\?[^\s\\"]+/g)].pop();
await page.goto(mm[0]); await page.waitForURL(/\/sheets/);
// Create from the top bar, then name it by renaming: the list's #title