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>
41 files changed,
+907
−67
AGENTS.md+13 −0Makefile+20 −3cmd/sheets/main.go+3 −3go.mod+13 −1go.sum+8 −2internal/guardstest/forbidden_imports_test.go+28 −0internal/guardstest/userid_test.go+1 −1internal/sheets/app.go+4 −4internal/sheets/auth.go+2 −2internal/sheets/begin_test.go+2 −2internal/sheets/errors.go+1 −1internal/sheets/handlers_import.go+2 −2internal/sheets/helpers_test.go+2 −2internal/sheets/home.go+2 −2internal/sheets/home_navigation_test.go+1 −1internal/sheets/home_test.go+1 −1internal/sheets/jobpages.go+1 −1internal/sheets/migrations.go+3 −3internal/sheets/native.go+10 −2internal/sheets/native_exchange_test.go+245 −0internal/sheets/render.go+2 −2internal/sheets/signin.go+1 −1internal/sheets/signin_test.go+2 −2internal/sheets/static/sheets.css+25 −0internal/sheets/static/tokens.css+303 −6internal/sheets/store/handle_blank_cap_test.go+2 −2internal/sheets/store/handle_blank_crash_test.go+2 −2internal/sheets/store/handle_blank_recovery_bench_test.go+2 −2internal/sheets/store/handle_version_migration_test.go+2 −2internal/sheets/store/helpers_test.go+2 −2internal/sheets/theme_assets_test.go+11 −1internal/sheets/theme_test.go+1 −1internal/sheets/xlsx/blob.go+1 −1internal/sheets/xlsx/blob_test.go+1 −1internal/sheets/xlsx/helpers_test.go+2 −2internal/sheets/xlsx/plaintext_test.go+2 −2internal/sheets/zoom_browser_test.go+179 −0tools/drive/chrome.mjs+2 −2tools/drive/clipboard.mjs+1 −1tools/drive/menus.mjs+1 −1tools/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 |