oficina / memoria Public

TestNoZoomOnTap: Memoria's text controls zoom iOS on tap (red)

iOS Safari zooms the page into any text control under 16px. rastrillo
fixed that in tokens.css on 2026-09-02, but Memoria imports the frozen
github.com/carlosframework/rastrillo path and never received the fix.

The test emulates a touch screen in Chromium (chromedp: touch emulation
and a reload, then asserts matchMedia('(pointer: coarse)')) and lists
every visible text control under 16px: the door (signed out, through
the production App), the search box on the index and on results, Add
content, a document and its delete page, the connectors and the Notion
token. Red on purpose: the door, Add content and the token are 14px and
the search box 12.5px. Output in
polish-audit/r3/scripts/migrate-memoria-zoom-red.txt.

New: make browser runs it (chromedp; the Playwright cache's Chromium on
a Mac), and make vet vets the browser-tagged tests. chromedp joins
go.mod for it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 86642510fe44917d659d945ee9fa274bb841f3cd parent 3203cdb
4 files changed, +202 −1
  • Makefile +14 −1
  • go.mod +6 −0
  • go.sum +4 −0
  • internal/memoriatest/zoom_browser_test.go +178 −0
diff --git a/Makefile b/Makefile
index 924bb13..a3ae7b6 100644
--- a/Makefile
+++ b/Makefile
@@ -12,7 +12,7 @@ RELEASE_GOOS ?= linux
RELEASE_GOARCH ?= arm64
RELEASE_BIN := releases/$(APP)-$(RELEASE_GOOS)-$(RELEASE_GOARCH)
-.PHONY: build release test vet fmt-check migration-check ci
+.PHONY: build release test vet fmt-check migration-check ci browser
# build is the compile check: with more than one package matched, go
# build discards the output, so this catches a broken package without
@@ -61,8 +61,11 @@ release:
test:
go test ./...
+# Also vets the browser-tagged tests: they are outside `make ci`, and a
+# test behind a tag that nothing compiles rots silently.
vet:
go vet ./...
+ go vet -tags browser ./internal/memoriatest/
fmt-check:
@out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@@ -88,6 +91,16 @@ migration-check:
# declares manifest resources, also add: rastrillo generate --check
ci: vet fmt-check test migration-check
+# The browser checks (internal/memoriatest/*_browser_test.go), in a real
+# Chromium through chromedp. Deliberately NOT part of `ci`: the gate
+# every push runs 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. The Chromium comes from RASTRILLO_CHROME, or on a
+# Mac from the Playwright cache.
+RASTRILLO_CHROME ?= $(firstword $(wildcard $(HOME)/Library/Caches/ms-playwright/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium))
+browser:
+ RASTRILLO_CHROME="$(RASTRILLO_CHROME)" CGO_ENABLED=0 go test -tags browser -count=1 -run 'OnTap' ./internal/memoriatest/
+
.PHONY: credits
credits:
@set -eu; notices_tmp=$$(mktemp -d); trap 'rm -rf "$$notices_tmp"' EXIT; \
diff --git a/go.mod b/go.mod
index ccdf8d5..304acc0 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,8 @@ require (
amadan.net/oficina/suite v0.0.0-20260905215529-60b01a8a0d6d
amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3
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/google/uuid v1.6.0
golang.org/x/crypto v0.55.0
@@ -26,7 +28,11 @@ require (
require (
github.com/BurntSushi/toml v1.6.0 // indirect
+ 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/keymaildev/signin v0.1.1 // indirect
diff --git a/go.sum b/go.sum
index f412453..d120a6e 100644
--- a/go.sum
+++ b/go.sum
@@ -46,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/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
diff --git a/internal/memoriatest/zoom_browser_test.go b/internal/memoriatest/zoom_browser_test.go
new file mode 100644
index 0000000..e176197
--- /dev/null
+++ b/internal/memoriatest/zoom_browser_test.go
@@ -0,0 +1,178 @@
+//go:build browser
+
+// The iOS zoom-on-tap check, in a real Chromium through chromedp. Not
+// part of `make ci`, because it needs a Chromium (on PATH, or via
+// RASTRILLO_CHROME; the Makefile finds the Playwright cache's on a Mac):
+//
+// make browser
+//
+// `make vet` vets this package with the tag, so it cannot stop compiling
+// unnoticed.
+package memoriatest
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "mime/multipart"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/db"
+ "github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/chromedp"
+
+ memoria "amadan.net/oficina/memoria/internal/memoria"
+)
+
+// 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 (touch
+// emulation and a reload; the coarse check proves it took). Every page
+// with a field is measured: the door (signed out, through the production
+// App), the search box on the index and on results, Add content, a
+// document, the connectors and the Notion connector's token.
+func TestNoZoomOnTap(t *testing.T) {
+ t.Run("signed out", func(t *testing.T) {
+ rig := harness.New(t, func(origin string) http.Handler {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, _, err := memoria.App(d, origin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("App: %v", err)
+ }
+ return mux
+ })
+ var coarse bool
+ var small []string
+ ctx, cancel := context.WithTimeout(rig.Context(), 20*time.Second)
+ defer cancel()
+ if err := chromedp.Run(ctx,
+ emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
+ chromedp.Navigate(rig.Origin+"/signin"),
+ chromedp.Reload(),
+ chromedp.WaitVisible(`input[name="address"]`, chromedp.ByQuery),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(smallControls(textControls), &small),
+ ); err != nil {
+ t.Fatalf("/signin: %v", err)
+ }
+ if !coarse {
+ t.Fatal("/signin: the touch-screen emulation did not apply")
+ }
+ if len(small) > 0 {
+ t.Errorf("/signin: iOS zooms when these are tapped (under 16px on a touch screen): %s", strings.Join(small, "; "))
+ }
+ })
+ t.Run("signed in", testNoZoomSignedIn)
+}
+
+func testNoZoomSignedIn(t *testing.T) {
+ rig := harness.New(t, func(origin string) http.Handler {
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ mux, _, err := memoria.AppForTest(d, origin, testDEK(t), logger)
+ if err != nil {
+ t.Fatalf("AppForTest: %v", err)
+ }
+ return mux
+ })
+
+ 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)
+ }
+ }
+ measure := func(path, ready string) {
+ t.Helper()
+ var coarse bool
+ var small []string
+ run(path,
+ chromedp.Navigate(rig.Origin+path),
+ chromedp.Reload(),
+ chromedp.WaitVisible(ready, chromedp.ByQuery),
+ 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", 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, "; "))
+ }
+ }
+
+ run("touch emulation", emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5))
+
+ // A document, posted as the Add content form posts one.
+ var buf bytes.Buffer
+ mw := multipart.NewWriter(&buf)
+ _ = mw.WriteField("title", "Office handbook")
+ _ = mw.WriteField("body", "The Lisbon office is at Rua Augusta 100.")
+ mw.Close()
+ req, _ := http.NewRequest(http.MethodPost, rig.Origin+"/documents", &buf)
+ req.Header.Set("Content-Type", mw.FormDataContentType())
+ req.Header.Set("Origin", rig.Origin)
+ res, err := http.DefaultClient.Do(req)
+ if err != nil {
+ t.Fatalf("store a document: %v", err)
+ }
+ res.Body.Close()
+ var at string
+ run("find the document",
+ chromedp.Navigate(rig.Origin+"/"),
+ chromedp.WaitVisible(`a[href^="/documents/"]`, chromedp.ByQuery),
+ chromedp.AttributeValue(`a[href^="/documents/"]`, "href", &at, nil, chromedp.ByQuery),
+ )
+ doc := regexp.MustCompile(`^/documents/\d+$`).FindString(at)
+ if doc == "" {
+ t.Fatalf("the index links no document (got %q)", at)
+ }
+
+ measure("/", `main`)
+ measure("/?q=Lisbon", `main`)
+ measure("/add", `textarea[name="body"]`)
+ measure(doc, `main`)
+ measure(doc+"/delete", `main`)
+ measure("/connectors", `main`)
+ measure("/connectors/notion", `input[name="token"]`)
+}
+
+// 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.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
+}