oficina / docs Public

Test that no text control zooms iOS on tap (red on purpose)

TestNoZoomOnTap drives Docs in Chromium with touch emulation, reloads so
the page re-evaluates its media queries, checks matchMedia('(pointer:
coarse)') took, and lists every visible text control under 16px: the
door, the list's search and new-document fields, the document's title
field and toolbar comboboxes, the share page's address and published-link
fields, and the open jump-to palette.

Red here, as it should be: Docs takes rastrillo's ui from the frozen
github.com/carlosframework/rastrillo v0.23.0, whose tokens.css has no
coarse-pointer rule, so every one of them is 12.5 or 14px.

Behind the browser tag (it needs a Chromium); the gate now vets that tag
so the file cannot stop compiling unnoticed.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev fbd51b16db8bcaf27120b7d5f8938d9824f0a21c parent 73a5461
4 files changed, +170 −0
  • .amadan/ci.d/10-gate +4 −0
  • go.mod +6 −0
  • go.sum +4 −0
  • internal/docs/zoom_browser_test.go +156 −0
diff --git a/.amadan/ci.d/10-gate b/.amadan/ci.d/10-gate
index 11b8464..26f2d11 100755
--- a/.amadan/ci.d/10-gate
+++ b/.amadan/ci.d/10-gate
@@ -240,6 +240,10 @@ echo "=== wire compatibility, both directions"
echo "=== go: vet, test, gofmt"
go vet ./...
+# The browser checks (internal/docs/*_browser_test.go) need a Chromium, so
+# the gate does not run them; it vets them, so a test behind a tag that
+# nothing compiles cannot rot silently.
+go vet -tags browser ./internal/docs/
go test ./... -count=1
test -z "$(gofmt -l .)" || { echo "gate: gofmt"; gofmt -l .; exit 1; }
diff --git a/go.mod b/go.mod
index 4ba10b7..93b5520 100644
--- a/go.mod
+++ b/go.mod
@@ -9,6 +9,8 @@ require (
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908132728-d9c28d708556
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/google/uuid v1.6.0
@@ -20,7 +22,11 @@ require (
)
require (
+ github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // 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
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
diff --git a/go.sum b/go.sum
index 1f66d59..e791c77 100644
--- a/go.sum
+++ b/go.sum
@@ -42,12 +42,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/tetratelabs/wazero v1.12.0 h1:DuWcpNu/FzgEXgGBDp8J1Spc+CWOvvtvVyjKlaZopYU=
diff --git a/internal/docs/zoom_browser_test.go b/internal/docs/zoom_browser_test.go
new file mode 100644
index 0000000..2c0eb94
--- /dev/null
+++ b/internal/docs/zoom_browser_test.go
@@ -0,0 +1,156 @@
+//go:build browser
+
+// The browser checks: Docs in a real Chromium. Not part of the plain
+// suite or the gate, because they need a Chromium (on PATH, via
+// RASTRILLO_CHROME, or in a Playwright cache):
+//
+// CGO_ENABLED=0 go test -tags browser -run OnTap ./internal/docs/
+//
+// The gate vets this file with the tag (.amadan/ci.d/10-gate), so it
+// cannot stop compiling unnoticed.
+package docs_test
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/rastrillo/rastrillo/harness"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/cdproto/network"
+ "github.com/chromedp/chromedp"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// 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. The editor's own text (contenteditable) is not here:
+// it is the document, set at the document's own sizes, and the person
+// chose 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 outweighs it, zooms
+// on the first tap of every field.
+//
+// Emulated as pointer: coarse, which is how the CSS decides. The
+// jump-to palette is measured open: it is hidden until Control-K, and a
+// hidden field measures as nothing.
+func TestNoZoomOnTap(t *testing.T) {
+ var app *docs.App
+ rig := harness.New(t, func(origin string) http.Handler {
+ var err error
+ app, err = docs.NewForTestWith(t, docs.Config{Origin: origin})
+ if err != nil {
+ t.Fatalf("NewForTestWith: %v", err)
+ }
+ return app.Handler()
+ })
+ cookies := signInCookies(t, app, "alice@example.com")
+ // Same-origin POSTs: this app's origin is the rig's, not DefaultOrigin.
+ sameOrigin := func(path, body string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(body))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Sec-Fetch-Site", "same-origin")
+ for _, c := range cookies {
+ req.AddCookie(c)
+ }
+ rec := httptest.NewRecorder()
+ app.Handler().ServeHTTP(rec, req)
+ return rec
+ }
+ created := sameOrigin("/documents", "title=Zoom")
+ if created.Code != http.StatusSeeOther {
+ t.Fatalf("POST /documents: %d", created.Code)
+ }
+ sqid := strings.TrimPrefix(created.Header().Get("Location"), "/d/")
+
+ 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)
+ }
+ }
+ // Touch emulation, then a reload on every 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))
+
+ once := false
+ measure := func(path, ready string, open ...chromedp.Action) {
+ t.Helper()
+ var coarse bool
+ var small []string
+ actions := []chromedp.Action{chromedp.Navigate(rig.Origin + path)}
+ if !once {
+ actions = append(actions, chromedp.Reload())
+ }
+ actions = append(actions,
+ chromedp.WaitVisible(ready, chromedp.ByQuery),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ )
+ actions = append(actions, open...)
+ actions = append(actions, chromedp.Evaluate(smallControls(textControls), &small))
+ run(path, actions...)
+ if !coarse {
+ t.Fatalf("%s: the touch-screen emulation did not apply; this would measure desktop styles", path)
+ }
+ if len(small) > 0 {
+ t.Errorf("%s: iOS zooms when these are tapped (under 16px on a touch screen): %s", path, strings.Join(small, "; "))
+ }
+ }
+
+ // Signed out first: the door is the field every visit starts on.
+ measure("/signin", `input[name="address"]`)
+
+ 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...)
+
+ measure("/", `input[name="title"]`)
+ // The toolbar's three selects, once select.js has enhanced them, and
+ // the title field in the bar.
+ measure("/d/"+sqid, `[contenteditable="true"]`,
+ chromedp.WaitVisible(`[estilo-bar-title-input], [docs-toolbar] input, [docs-toolbar] select`, chromedp.ByQuery))
+ // The share page, then published: the link field shows only in the
+ // one render that follows the publish.
+ measure("/d/"+sqid+"/share", `input[name="address"]`)
+ if rec := sameOrigin("/d/"+sqid+"/share/link", "on=1"); rec.Code != http.StatusSeeOther {
+ t.Fatalf("publishing: %d", rec.Code)
+ }
+ // Once only: the reload measure makes would render the page without it.
+ once = true
+ measure("/d/"+sqid+"/share", `[docs-share-url-input]`)
+ once = false
+ measure("/", `input[name="title"]`,
+ chromedp.Evaluate(`document.dispatchEvent(new KeyboardEvent('keydown', {key: 'k', ctrlKey: true, bubbles: true}))`, nil),
+ chromedp.WaitVisible(`[docs-jumpto-input]`, chromedp.ByQuery))
+}
+
+// smallControls is a script listing each visible, tappable 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)
+ // select.js hides the native select it enhances (aria-hidden,
+ // clipped, out of the tab order); its combobox is what is tapped.
+ .filter(e => !e.closest('[aria-hidden=true]'))
+ .filter(e => parseFloat(getComputedStyle(e).fontSize) < 16)
+ .map(e => e.tagName.toLowerCase() + [...e.attributes].filter(a => a.name !== 'class' && a.name !== 'style' && a.name !== 'value' && a.name !== 'placeholder').map(a => '[' + a.name + (a.value && a.value.length < 24 ? '=' + a.value : '') + ']').join('') + ' ' + getComputedStyle(e).fontSize)`, sel)
+}