oficina / home Public

Test: no text control zooms iOS on tap (red: rastrillo v0.23 has no touch rule)

TestNoZoomOnTap (browser tag, make test-browser) emulates a touch screen
and measures every text control on id.*'s sign-in and recovery-code
pages and on the directory's pages: signed out, Open source, the signed-in
directory and the team chooser. iOS zooms into any control under 16px.

Red on purpose at this commit: Home serves the v0.23 tokens.css, which has
no touch rule, so the sign-in address field and the recovery code field
are 14px on a touch screen. Every app's sign-in goes through the first.

test-vet now also vets internal/home with -tags browser.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev e75460d96998462c69de4c16f4fa5e3f181ceff5 parent 2bec942
4 files changed, +157 −1
  • Makefile +10 −1
  • go.mod +9 −0
  • go.sum +4 −0
  • internal/home/zoom_browser_test.go +134 −0
diff --git a/Makefile b/Makefile
index 2f2a794..bcfe109 100644
--- a/Makefile
+++ b/Makefile
@@ -4,7 +4,7 @@ export GOPRIVATE := $(GOPRIVATE),amadan.net/oficina/estilo.git
private_comma := ,
export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git,amadan.net/carlos/platform.git
-.PHONY: build release deps test test-vet test-fmt test-build test-go test-race
+.PHONY: build release deps test test-vet test-fmt test-build test-go test-race test-browser
# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set
# per-target rather than exported globally, because test-race is a
@@ -82,6 +82,7 @@ deps:
test-vet: deps
CGO_ENABLED=0 go vet ./...
+ CGO_ENABLED=0 go vet -tags browser ./internal/home/
cd handoff && CGO_ENABLED=0 go vet ./...
cd roster && CGO_ENABLED=0 go vet ./...
cd connect && CGO_ENABLED=0 go vet ./...
@@ -125,6 +126,14 @@ test-race:
cd roster && CGO_ENABLED=1 go test -race -count=1 ./...
cd connect && CGO_ENABLED=1 go test -race -count=1 ./...
+# The browser checks, in a real Chromium (on PATH, RASTRILLO_CHROME, or 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-vet vets these files with the tag, so they keep compiling.
+test-browser:
+ CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/home/
+
.PHONY: credits
credits:
@set -eu; notices_tmp=$$(mktemp -d); trap 'rm -rf "$$notices_tmp"' EXIT; \
diff --git a/go.mod b/go.mod
index 79ca099..68b8066 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,8 @@ require (
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
filippo.io/age v1.3.2
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/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2
github.com/keymaildev/signin v0.1.1
@@ -21,6 +23,13 @@ require (
modernc.org/sqlite v1.55.0
)
+require (
+ 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/carlos/platform.git/launchctx v0.0.0-20260911135504-fb577f96f7d1
filippo.io/hpke v0.4.0 // indirect
diff --git a/go.sum b/go.sum
index 77a79ac..d6d482a 100644
--- a/go.sum
+++ b/go.sum
@@ -48,12 +48,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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/sqids/sqids-go v0.4.1 h1:eQKYzmAZbLlRwHeHYPF35QhgxwZHLnlmVj9AkIj/rrw=
diff --git a/internal/home/zoom_browser_test.go b/internal/home/zoom_browser_test.go
new file mode 100644
index 0000000..9fb14ad
--- /dev/null
+++ b/internal/home/zoom_browser_test.go
@@ -0,0 +1,134 @@
+//go:build browser
+
+// The browser checks: Home in a real Chromium. Not part of the plain
+// suite, because they need 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 home
+
+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`
+
+// asHost serves h as if every request had arrived for host. The rig's
+// browser talks to http://localhost:<port>, and Home answers only its two
+// configured hosts (serveKnownHost); this is the browser-side half of the
+// production TLS front, which routes by Host.
+func asHost(host string, h http.Handler) func(string) http.Handler {
+ return func(string) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ r.Host = host
+ h.ServeHTTP(w, r)
+ })
+ }
+}
+
+// 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.
+//
+// Every app's sign-in goes through id.*'s field, so that is the one that
+// matters most. The team-home pages (the directory, the team chooser,
+// signed out, Open source) are measured too: they carry no text field
+// today, and this keeps any they gain honest.
+//
+// Emulated as pointer: coarse, which is how the CSS decides.
+func TestNoZoomOnTap(t *testing.T) {
+ f := newMountedChooser(t)
+ cookie := f.session(t, chooserPerson)
+ // Two teams with Docs, so /apps/docs renders the chooser rather than
+ // redirecting to the one team.
+ f.team(t, "A1", "alpha", "Alpha", chooserPerson)
+ f.team(t, "B2", "beta", "Beta", chooserPerson)
+
+ type page struct{ path, ready string }
+ hosts := []struct {
+ name string
+ handler func(string) http.Handler
+ signIn *http.Cookie
+ pages []page
+ }{
+ {"id.*", asHost(credentialHost, f.p.credential.Router()), nil, []page{
+ {"/signin", `input[name="address"]`},
+ {"/passkey/confirm", `input[name="code"]`},
+ }},
+ {"directory, signed out", asHost(directoryHost, f.p.directory.Router()), nil, []page{
+ {"/signed-out", `main`},
+ {"/help/open-source", `main`},
+ }},
+ {"directory, signed in", asHost(directoryHost, f.p.directory.Router()), cookie, []page{
+ {"/", `[home-apps]`},
+ {docsEntryPath, `button[name="team"]`},
+ }},
+ }
+ for _, host := range hosts {
+ rig := harness.New(t, host.handler)
+ 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 %s: %v (the browser is at %s)", host.name, what, err, at)
+ }
+ }
+ // Touch emulation, then a reload on each page so it 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.
+ run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5))
+ if c := host.signIn; c != nil {
+ run("sign in", network.SetCookie(c.Name, c.Value).WithURL(rig.Origin+"/").
+ WithHTTPOnly(c.HttpOnly).WithSecure(c.Secure).WithSameSite(network.CookieSameSiteLax))
+ }
+ for _, p := range host.pages {
+ var coarse bool
+ var small []string
+ run(p.path,
+ chromedp.Navigate(rig.Origin+p.path),
+ chromedp.Reload(),
+ chromedp.WaitVisible(p.ready, chromedp.ByQuery),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(smallControls(textControls), &small),
+ )
+ if !coarse {
+ t.Fatalf("%s %s: the touch-screen emulation did not apply; this would measure desktop styles", host.name, p.path)
+ }
+ if len(small) > 0 {
+ t.Errorf("%s %s: iOS zooms when these are tapped (under 16px on a touch screen): %s", host.name, p.path, strings.Join(small, "; "))
+ }
+ }
+ }
+}
+
+// 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 + ']' : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
+}