oficina / calendar Public

TestNoZoomOnTap: Calendar'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 Calendar 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, the four views with
quick add opened, the event form and page, settings, sharing, import,
the booking-page form and a stranger's booking form. Red on purpose:
every one of them is 13.3 to 14px today. Output in
polish-audit/r3/scripts/migrate-calendar-zoom-red.txt.

make vet now vets the browser-tagged tests, and make browser runs this
one, finding the Playwright cache's Chromium on a Mac.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev d7ac53b3d59c568efbbae7e29243dffe9cf2288d parent baa9e70
3 files changed, +216 −2
  • Makefile +10 −1
  • go.mod +1 −1
  • internal/calendartest/zoom_browser_test.go +205 −0
diff --git a/Makefile b/Makefile
index cd8844f..915854c 100644
--- a/Makefile
+++ b/Makefile
@@ -84,8 +84,11 @@ version-check:
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/calendartest/
fmt-check:
@out=$$(gofmt -l .); if [ -n "$$out" ]; then echo "gofmt needed:"; echo "$$out"; exit 1; fi
@@ -118,9 +121,15 @@ ci: vet fmt-check test migration-check
# the one gate the team runs on every push must not depend on a
# toolchain the app itself does not use. Run it before shipping a
# change to the box.
+#
+# TestNoZoomOnTap (the iOS zoom-on-tap check) drives Chromium through
+# chromedp instead, and finds it through RASTRILLO_CHROME; on a Mac the
+# Playwright cache's Chromium is used when that is unset.
browser:
npm install --no-save playwright
- go test -tags browser -run InARealBrowser ./internal/calendartest/
+ RASTRILLO_CHROME="$(RASTRILLO_CHROME)" go test -tags browser -run 'InARealBrowser|OnTap' ./internal/calendartest/
+
+RASTRILLO_CHROME ?= $(firstword $(wildcard $(HOME)/Library/Caches/ms-playwright/chromium-*/chrome-mac*/Chromium.app/Contents/MacOS/Chromium))
.PHONY: credits
credits:
diff --git a/go.mod b/go.mod
index 6e79be3..3bcbec9 100644
--- a/go.mod
+++ b/go.mod
@@ -18,7 +18,7 @@ require amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
require (
github.com/BurntSushi/toml v1.6.0 // indirect
- github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 // indirect
+ github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 // indirect
diff --git a/internal/calendartest/zoom_browser_test.go b/internal/calendartest/zoom_browser_test.go
new file mode 100644
index 0000000..39c7a1e
--- /dev/null
+++ b/internal/calendartest/zoom_browser_test.go
@@ -0,0 +1,205 @@
+//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, via
+// RASTRILLO_CHROME, or in a Playwright cache):
+//
+// make browser
+//
+// `make vet` vets this package with the tag, so it cannot stop
+// compiling unnoticed.
+package calendartest
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/rastrillo/harness"
+ "github.com/chromedp/cdproto/emulation"
+ "github.com/chromedp/cdproto/network"
+ "github.com/chromedp/chromedp"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// 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. Every page
+// with a field is measured: the door, the views (and the quick-add box,
+// which only shows once its disclosure is opened), the event form with
+// its enhanced date fields, the event page's reminders, settings,
+// sharing, import, the booking-page form, and a stranger's booking
+// form, which is the one most often opened on a phone.
+func TestNoZoomOnTap(t *testing.T) {
+ var app *calendar.App
+ rig := harness.New(t, func(origin string) http.Handler {
+ a, err := calendar.New(calendar.Config{
+ DBPath: filepath.Join(t.TempDir(), "calendar.db"),
+ Env: "development",
+ Origin: origin,
+ Logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
+ })
+ if err != nil {
+ t.Fatalf("calendar.New: %v", err)
+ }
+ t.Cleanup(func() { a.Close() })
+ app = a
+ return a.Handler()
+ })
+ session := signInOverHTTP(t, app, rig.Origin, "host@example.com")
+
+ // A published booking page, through the store, so the stranger's
+ // form has something to book.
+ ctx := context.Background()
+ st := app.StoreForTest()
+ host := app.PersonByAddressForTest(t, "host@example.com")
+ if err := st.SaveHandle(ctx, host.ID, "paul"); err != nil {
+ t.Fatal(err)
+ }
+ ws, _ := calendar.ParseAvailability("every day 9-5")
+ page, err := st.CreateBookingPage(ctx, host.ID, calendar.BookingPageInput{
+ Title: "Quick chat", Description: "Half an hour.", Availability: "every day 9-5",
+ DurationMin: 30, HorizonDays: 30}, ws)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := st.PublishBookingPage(ctx, host.ID, page.ID, true); err != nil {
+ t.Fatal(err)
+ }
+ public := "/book/paul/" + page.Code
+
+ 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 loads path on a touch screen and lists its small controls,
+ // after the optional actions (which open what only interaction shows).
+ measure := func(path, ready string, then ...chromedp.Action) {
+ t.Helper()
+ var coarse bool
+ var small []string
+ actions := []chromedp.Action{
+ chromedp.Navigate(rig.Origin + path),
+ chromedp.Reload(),
+ chromedp.WaitVisible(ready, chromedp.ByQuery),
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ }
+ actions = append(actions, then...)
+ 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, "; "))
+ }
+ }
+
+ run("touch emulation",
+ // Touch emulation, then a reload per page so it re-evaluates its
+ // media queries. SetEmulatedMedia's "pointer" feature is not
+ // emulated by this Chromium; the coarse check in measure is what
+ // proves the emulation took.
+ emulation.SetTouchEmulationEnabled(true).WithMaxTouchPoints(5),
+ )
+
+ // Signed out first: the door is the field every visit starts on.
+ measure("/signin", `input[name="address"]`)
+
+ // The session the real magic-link flow minted, handed to the browser.
+ run("sign in", network.SetCookie(session.Name, session.Value).WithURL(rig.Origin+"/").
+ WithHTTPOnly(session.HttpOnly).WithSameSite(network.CookieSameSiteLax))
+
+ // An event, made through the form as a person makes one.
+ var at string
+ run("create an event",
+ chromedp.Navigate(rig.Origin+"/events/new?start=2026-09-02T09:30&end=2026-09-02T10:00"),
+ chromedp.WaitVisible(`input[name="title"]`, chromedp.ByQuery),
+ chromedp.SendKeys(`input[name="title"]`, "Standup", chromedp.ByQuery),
+ chromedp.Click(`form[action="/events"] button[type="submit"]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`form[action$="/reminders"]`, chromedp.ByQuery),
+ chromedp.Location(&at),
+ )
+ event := regexp.MustCompile(`/events/\d+`).FindString(at)
+ if event == "" {
+ t.Fatalf("creating an event landed on %s", at)
+ }
+
+ // The quick-add box is a closed <details> until it is tapped.
+ openQuick := chromedp.Evaluate(`document.querySelectorAll('details[cal-quick]').forEach(d => d.open = true)`, nil)
+ for _, view := range []string{"/day/2026-09-02", "/week/2026-09-02", "/month/2026-09", "/agenda?from=2026-09-02"} {
+ measure(view, `main`, openQuick)
+ }
+ measure("/events/new", `input[name="title"]`)
+ measure(event+"/edit", `input[name="title"]`)
+ measure(event, `form[action$="/reminders"]`)
+ measure("/settings", `main form`)
+ measure("/calendars", `main`)
+ measure("/calendars/1/share", `main`)
+ measure("/import", `main form`)
+ measure("/settings/booking", `main`)
+ measure("/settings/booking/new", `main form`)
+
+ // The stranger's side: the zone picker, then a day, then a slot,
+ // which is where the name, address and note fields are.
+ measure(public, `select[name="zone"]`)
+ var day string
+ run("a booking day",
+ chromedp.Navigate(rig.Origin+public),
+ chromedp.WaitVisible(`select[name="zone"]`, chromedp.ByQuery),
+ chromedp.Evaluate(`(document.querySelector('a[href*="day="]') || {}).href || ''`, &day),
+ )
+ if day == "" {
+ t.Fatal("the booking page offers no day to pick")
+ }
+ measure(strings.TrimPrefix(day, rig.Origin), `[cal-book-slot]`)
+ run("a booking slot",
+ chromedp.Click(`[cal-book-slot]`, chromedp.ByQuery),
+ chromedp.WaitVisible(`[cal-book-details] input[name="name"]`, chromedp.ByQuery),
+ )
+ var coarse bool
+ var small []string
+ run("the booker's details",
+ chromedp.Evaluate(`matchMedia('(pointer: coarse)').matches`, &coarse),
+ chromedp.Evaluate(smallControls(textControls), &small),
+ )
+ if !coarse {
+ t.Fatal("the booker's details: the touch-screen emulation did not apply")
+ }
+ if len(small) > 0 {
+ t.Errorf("the booker's details: iOS zooms when these are tapped (under 16px on a touch screen): %s", 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 + ']' : '') + (e.id ? '#' + e.id : '') + ' ' + getComputedStyle(e).fontSize)`, sel)
+}