oficina / sheets Public

Move Sheets off the frozen rastrillo path; stop iOS zooming on tap

Sheets imported rastrillo's ui from github.com/carlosframework/rastrillo,
frozen at v0.23.0, so it had missed every ui fix since 2026-08-31,
including the one that stops iOS zooming on tap. It now imports
amadan.net/rastrillo/rastrillo 29e4491 (the version home/connect already
requires), with the CLI pinned as a go.mod tool and the release stamp
fixed. Text controls are 16px on touch, the cell editor and title field
included, and the Format menu fits at 320 and 390. One old-path sessions
import remains for native/server, guarded and tested. Estilo's shim and
busy.js stay the only scripts served.
Reviewed by GPT-6-Astra via Codex (#44).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from chore/rastrillo-path into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev a5c17abca7667cfdd4295d9f2568a5b8a7770ec2 parent 2335fe1
41 files changed, +904 −76
  • AGENTS.md +13 −0
  • Makefile +20 −3
  • cmd/sheets/main.go +3 −3
  • go.mod +12 −0
  • go.sum +6 −12
  • 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

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go vet -tags browser ./internal/sheets/
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
=== migrations ===
go tool rastrillo migration check
rastrillo migration check: models and migrations agree
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/sheets/cmd/sheets	[no test files]
ok  	amadan.net/oficina/sheets/internal/guardstest	0.086s
ok  	amadan.net/oficina/sheets/internal/sheets	10.890s
ok  	amadan.net/oficina/sheets/internal/sheets/calc	0.008s
ok  	amadan.net/oficina/sheets/internal/sheets/format	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/live	0.053s
ok  	amadan.net/oficina/sheets/internal/sheets/seal	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/sealtest	0.003s
ok  	amadan.net/oficina/sheets/internal/sheets/store	2.797s
ok  	amadan.net/oficina/sheets/internal/sheets/xlsx	0.289s
?   	amadan.net/oficina/sheets/tools/gencorpus	[no test files]
=== race ===
go test -race -count=1 ./internal/sheets/store/... ./internal/sheets/live/...
ok  	amadan.net/oficina/sheets/internal/sheets/store	49.783s
ok  	amadan.net/oficina/sheets/internal/sheets/live	1.059s
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 8c52105..a2004bd 100644
--- a/go.mod
+++ b/go.mod
@@ -8,12 +8,22 @@ require (
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 5683e5c..6cb589e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,15 +1,3 @@
-amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwLjPbMihf0f7aqz4CLSe0/NseQsHcJUYQ=
-amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773 h1:9DIAhG3TaGG+LE2+8LvU73ZEhfK+oNn2y2p4kFjAAi0=
-amadan.net/oficina/estilo.git v0.0.0-20260929070530-5b2ce835e773/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914 h1:V0AyxXYCxqGZClOLqvppw+cCTXsn3b9NmRQ8CV5ZlXY=
-amadan.net/oficina/estilo.git v0.0.0-20260929095046-d0664ee49914/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e h1:LTSmIWUS8RjPb6m94Ynf7moyFYeBXr08rdOr3nmNqvg=
-amadan.net/oficina/estilo.git v0.0.0-20260929095838-0994675f496e/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c h1:YYocIz3r3b4oIA0K/58JkSsmyTOCzMT/2GaW/MFbbPo=
-amadan.net/oficina/estilo.git v0.0.0-20260929165053-846b39772f3c/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b h1:UOxYBKsOSRHcHHYNKt0i6cB1Z4yT2soywOTWURYvBWo=
-amadan.net/oficina/estilo.git v0.0.0-20260929183653-cc2c1e56fb4b/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22 h1:RR9qP85FU1cZdn788v1zSLgZbKMGBmSk6Q7Rezj5JYI=
amadan.net/oficina/estilo.git v0.0.0-20260929185827-41201439fd22/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
@@ -20,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=
@@ -56,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 3a28d68..cb9f668 100644
--- a/internal/sheets/auth.go
+++ b/internal/sheets/auth.go
@@ -10,8 +10,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 c59e14a..9759ccb 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 (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