oficina / calendar Public

Views hold still: one layout, one toolbar, no jumping

Paul's complaint is views jumping. Measured signed in at 1440 and 390,
over Day→Week→Month→Agenda→Day and each view's Previous and Next, the
branch still moved the prev/next row and the view switcher 18–58px on
most hops, and Next sideways with each view's label ("Next day", "Next
fortnight"). Now nothing moves and CLS is 0 on every hop at both widths.

- One toolbar partial (layout.html "calendar-toolbar") in all four views:
  Previous, Today, Next, then the Day/Week/Month/Agenda switcher, in one
  row. The visible labels are the same in every view; the period is in
  the heading and in each link's accessible name ("Previous week").
- The day's sentence and the agenda's scope line move below the toolbar,
  so their length never moves the controls.
- One width for all four views: [cal-view], 90rem with rst-page's 1rem
  gutter, lined up with the bar's brand. At 64rem a week column was
  134px and a month column 142px; now 191 and 199 at 1440.
- A view tab is sized by a hidden copy of its label in the current tab's
  weight, so the bold current tab no longer shifts the switcher 0.1px.
- The week's day headings stick at 0 inside their own scroll box, not at
  the bar's height (which painted hour rows above the day names), and
  the all-day row sticks beneath them: the scroll to working hours had
  carried every all-day event off the first screen.
- The bar's "New event" action goes: every view's header has one.
- Quick add's popover takes the suite float radius and shadow (spec §11).

TestViewSwitchingHoldsStillInARealBrowser (make browser;
test/browser/views.mjs) fails on the previous head with 40 checks (the
movement, both column widths, the sticky headings, the all-day row) and
passes here with 198.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev f5ca64cf4984de4f1c47674a59195b1bd2b944c7 parent 43611c6
9 files changed, +344 −47
  • internal/calendar/chrome_calendar.go +5 −3
  • internal/calendar/static/calendar.css +62 −5
  • internal/calendar/templates/agenda.html +8 −8
  • internal/calendar/templates/day.html +5 −9
  • internal/calendar/templates/layout.html +20 −6
  • internal/calendar/templates/month.html +2 −7
  • internal/calendar/templates/week.html +3 −9
  • internal/calendartest/playwright_views_test.go +104 −0
  • test/browser/views.mjs +135 −0

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/calendar/cmd/calendar	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendar	4.689s
?   	amadan.net/oficina/calendar/internal/calendar/icons	[no test files]
?   	amadan.net/oficina/calendar/internal/calendar/seal	[no test files]
ok  	amadan.net/oficina/calendar/internal/calendartest	3.425s
ok  	amadan.net/oficina/calendar/internal/guardstest	0.050s
?   	amadan.net/oficina/calendar/scripts/writeestilo	[no test files]
=== migration-check ===
go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/calendar/chrome_calendar.go b/internal/calendar/chrome_calendar.go
index ee5571e..0e051fe 100644
--- a/internal/calendar/chrome_calendar.go
+++ b/internal/calendar/chrome_calendar.go
@@ -20,14 +20,16 @@ func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.
current := func(prefix string) bool {
return r.URL.Path == prefix || (prefix != "/" && len(r.URL.Path) > len(prefix) && r.URL.Path[:len(prefix)] == prefix)
}
+ // No bar actions: every view's header carries Quick add and New event,
+ // and the same primary button twice on one screen was the audit's
+ // "New event is a nav tab and also a primary button" (calendar.md §B2).
bar := chrome.Bar{
App: chrome.App{Name: "Calendar", Slug: "calendar", Home: "/", Icon: assets.Path("static/brand/icon.svg")}, Sticky: true,
Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, NavLabel: "Calendar",
- Nav: []chrome.Link{{Href: "/today", Label: "Today", Current: current("/today") || current("/day/") || current("/week/") || current("/month/")}, {Href: "/agenda", Label: "Agenda", Current: current("/agenda")}, {Href: "/calendars", Label: "Calendars", Current: current("/calendars")}},
- Actions: []chrome.Action{{Item: estilomenu.Item{Href: "/events/new", Label: "New event"}, Primary: true}},
+ Nav: []chrome.Link{{Href: "/today", Label: "Today", Current: current("/today") || current("/day/") || current("/week/") || current("/month/")}, {Href: "/agenda", Label: "Agenda", Current: current("/agenda")}, {Href: "/calendars", Label: "Calendars", Current: current("/calendars")}},
}
if !signedIn {
- bar.Nav, bar.Actions = nil, nil
+ bar.Nav = nil
bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + template.URLQueryEscaper(r.URL.RequestURI()), Label: "Sign in"}
return bar
}
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css
index fb52a59..2420df4 100644
--- a/internal/calendar/static/calendar.css
+++ b/internal/calendar/static/calendar.css
@@ -17,15 +17,45 @@
/* ── The view switcher ─────────────────────────────────────────── */
+/* One layout for all four views. Day, Week, Month and Agenda share this
+ * main box, so switching views never moves the header, the toolbar or
+ * the grid's edge (test/browser/views.mjs). 90rem, not rst-page's 64rem
+ * reading column: seven day columns at 64rem are ~134px, too narrow for a
+ * chip's time and title. rst-page's own inline padding is the gutter, the
+ * same 1rem as the estilo bar's, so at 1440 the view's edge lines up with
+ * the bar's brand. */
+[cal-view] { max-width: 90rem; } /* after tokens.css, so it beats rst-page's 64rem */
+
+/* The toolbar (layout.html "calendar-toolbar"): the steps at the start,
+ * the view switcher at the end, one row wherever it fits. Every label in
+ * it is the same in every view, so nothing in it moves. */
[cal-bar] {
align-items: center;
display: flex;
flex-wrap: wrap;
gap: var(--rst-sp-3);
+ justify-content: space-between;
margin-block-end: var(--rst-sp-4);
}
-[cal-view-tabs] { margin-block: 0 var(--rst-sp-4); }
+[cal-bar-steps] {
+ display: flex;
+ gap: var(--rst-sp-2);
+}
+
+/* A view tab is as wide as its label in the current tab's weight (600,
+ * against 550), whether or not it is current: a hidden bold copy of the
+ * label (the cal-tab attribute) sizes the column. Without it the switcher
+ * narrowed and widened by a fraction of a pixel with every view, and at
+ * the end of the row that moved it. */
+[cal-tab] { display: inline-grid; text-align: center; }
+[cal-tab]::after {
+ block-size: 0;
+ content: attr(cal-tab);
+ font-weight: 600;
+ overflow: hidden;
+ visibility: hidden;
+}
[cal-bar-spacer] { flex: 1 1 auto; }
@@ -38,6 +68,9 @@
max-width: 52rem;
}
+/* The agenda's scope line: a caption, not an answer, so it is muted. */
+[cal-sentence-muted] { color: var(--rst-text-muted); }
+
/* ── Month ─────────────────────────────────────────────────────── */
/* A real table: a month IS tabular data, so the row and column headers
@@ -215,17 +248,29 @@
}
[cal-column-head] {
+ /* A fixed height, so the all-day row can stick exactly beneath it. */
+ align-items: center;
+ block-size: var(--cal-week-head-size);
border-block-end: 1px solid var(--rst-line);
- padding: var(--rst-sp-2);
+ box-sizing: border-box;
+ display: flex;
+ justify-content: center;
+ padding: 0 var(--rst-sp-2);
position: sticky;
text-align: center;
- top: var(--estilo-bar-block-size);
+ /* 0, not --estilo-bar-block-size: the heads stick inside
+ * [cal-week-scroll], which is their own scroll box, so the sticky bar
+ * above the page never overlaps them. The bar's offset here left a 52px
+ * band of hour rows painted above the day names. */
+ top: 0;
z-index: 2;
background: var(--rst-bg);
}
[cal-column-head~="today"] { font-weight: 700; }
+[cal-week-grid] { --cal-week-head-size: 2.5rem; }
+
/* The hour rules, drawn as a repeating background so 24 of them cost
* one declaration rather than 24 elements. */
[cal-column-rules] {
@@ -309,6 +354,16 @@
padding: var(--rst-sp-1);
}
+/* The week's all-day row sticks under the day headings. The grid opens
+ * scrolled to working hours, and a row that scrolled away with midnight
+ * hid every all-day event from the first screen. */
+[cal-allday="week"], [cal-allday-label] {
+ background: var(--rst-bg);
+ position: sticky;
+ top: var(--cal-week-head-size);
+ z-index: 2;
+}
+
[cal-allday-label] {
border-block-end: 1px solid var(--rst-line);
border-inline-end: 1px solid var(--rst-line);
@@ -405,8 +460,10 @@
[cal-quick-form] {
background: var(--rst-surface);
border: 1px solid var(--rst-line);
- border-radius: var(--rst-radius-sm);
- box-shadow: 0 8px 24px color-mix(in oklab, var(--rst-text) 18%, transparent);
+ /* A float, so the suite's float radius and shadow (estilo menu.css;
+ * shared-chrome spec §11), not a hand-built 6px and shadow of its own. */
+ border-radius: var(--estilo-float-radius);
+ box-shadow: var(--rst-shadow-pop);
/* Anchored to the END edge, so the panel opens back across the page
* instead of off the side of it. The box sits at the far right of the
* header, beside New event: anchored at the start edge a 30rem panel
diff --git a/internal/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html
index 47ef653..5b76339 100644
--- a/internal/calendar/templates/agenda.html
+++ b/internal/calendar/templates/agenda.html
@@ -1,9 +1,9 @@
{{define "title"}}{{.Title}}{{end}}
-{{define "content"}}
+{{define "main"}}
+<main rst-page cal-view id="main">
<header rst-page-header>
<div rst-page-header-titles>
<h1>Agenda</h1>
- <p rst-page-header-sub>The next fortnight, and only the days with something in them.</p>
</div>
{{/* Quick add sits BESIDE New event, not instead of it. The
sentence is the fast path; the form is the one that shows
@@ -14,12 +14,10 @@
</div>
</header>
-<div cal-bar>
- <a rst-btn href="/agenda?from={{.Data.PrevISO}}">Previous fortnight</a>
- <a rst-btn href="/agenda">From today</a>
- <a rst-btn href="/agenda?from={{.Data.NextISO}}">Next fortnight</a>
-</div>
-{{template "calendar-view-tabs" (dict "View" "agenda" "Day" (printf "/day/%s" .Data.Agenda.FromISO) "Week" (printf "/week/%s" .Data.Agenda.FromISO) "Month" (printf "/month/%s" (slice .Data.Agenda.FromISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Agenda.FromISO))}}
+{{template "calendar-toolbar" (dict "View" "agenda" "Noun" "fortnight" "Prev" (printf "/agenda?from=%s" .Data.PrevISO) "Today" "/agenda" "Next" (printf "/agenda?from=%s" .Data.NextISO) "Day" (printf "/day/%s" .Data.Agenda.FromISO) "Week" (printf "/week/%s" .Data.Agenda.FromISO) "Month" (printf "/month/%s" (slice .Data.Agenda.FromISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Agenda.FromISO))}}
+
+{{/* The agenda's scope, below the toolbar like the day's sentence. */}}
+<p cal-sentence cal-sentence-muted>The next fortnight, and only the days with something in them.</p>
{{if .Data.Agenda.Days}}
{{range .Data.Agenda.Days}}
@@ -40,4 +38,6 @@
"ActionHref" "/events/new"
"ActionLabel" "Add an event"}}
{{end}}
+</main>
{{end}}
+{{define "content"}}{{end}}
diff --git a/internal/calendar/templates/day.html b/internal/calendar/templates/day.html
index 2306a6f..28a0651 100644
--- a/internal/calendar/templates/day.html
+++ b/internal/calendar/templates/day.html
@@ -1,6 +1,6 @@
{{define "title"}}{{.Title}}{{end}}
{{define "main"}}
-<main rst-page id="main">
+<main rst-page cal-view id="main">
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Data.Day.Title}}</h1>
@@ -14,17 +14,13 @@
</div>
</header>
+{{template "calendar-toolbar" (dict "View" "day" "Noun" "day" "Prev" (printf "/day/%s" .Data.PrevISO) "Today" (printf "/day/%s" .Data.TodayISO) "Next" (printf "/day/%s" .Data.NextISO) "Day" (printf "/day/%s" .Data.Day.ISO) "Week" (printf "/week/%s" .Data.Day.ISO) "Month" (printf "/month/%s" (slice .Data.Day.ISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Day.ISO))}}
+
{{/* The day's own sentence. Not a caption — it is the answer to the
- question somebody opened this page to ask. */}}
+ question somebody opened this page to ask. Below the toolbar, so its
+ length (and whether it wraps) never moves the controls. */}}
<p cal-sentence>{{.Data.Day.Summary.Sentence}}</p>
-<div cal-bar>
- <a rst-btn href="/day/{{.Data.PrevISO}}">Previous day</a>
- <a rst-btn href="/day/{{.Data.TodayISO}}">Today</a>
- <a rst-btn href="/day/{{.Data.NextISO}}">Next day</a>
-</div>
-{{template "calendar-view-tabs" (dict "View" "day" "Day" (printf "/day/%s" .Data.Day.ISO) "Week" (printf "/week/%s" .Data.Day.ISO) "Month" (printf "/month/%s" (slice .Data.Day.ISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Day.ISO))}}
-
{{if .Data.Day.AllDay}}
<div cal-allday>
{{range .Data.Day.AllDay}}{{template "chip" .}}{{end}}
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html
index 60471a6..883fe49 100644
--- a/internal/calendar/templates/layout.html
+++ b/internal/calendar/templates/layout.html
@@ -42,9 +42,23 @@
</body>
</html>{{end}}
-{{define "calendar-view-tabs"}}<div cal-view-tabs><nav rst-seg-tabs aria-label="Calendar view">
- <a href="{{.Day}}"{{if eq .View "day"}} aria-current="page"{{end}}>Day</a>
- <a href="{{.Week}}"{{if eq .View "week"}} aria-current="page"{{end}}>Week</a>
- <a href="{{.Month}}"{{if eq .View "month"}} aria-current="page"{{end}}>Month</a>
- <a href="{{.Agenda}}"{{if eq .View "agenda"}} aria-current="page"{{end}}>Agenda</a>
-</nav></div>{{end}}
+{{/* calendar-toolbar is the one row under every view's header: step back,
+ Today, step forward, and the view switcher. It is the same partial in
+ Day, Week, Month and Agenda, and its visible labels are the same in all
+ four ("Previous", "Today", "Next"; the period is in the heading and in
+ each link's accessible name), so switching views or stepping through
+ them moves nothing. A view's own lead line (the day's sentence, the
+ agenda's scope) sits BELOW this row, never above it. */}}
+{{define "calendar-toolbar"}}<div cal-bar>
+ <div cal-bar-steps>
+ <a rst-btn href="{{.Prev}}">Previous<span class="rst-sr-only"> {{.Noun}}</span></a>
+ <a rst-btn href="{{.Today}}">Today</a>
+ <a rst-btn href="{{.Next}}">Next<span class="rst-sr-only"> {{.Noun}}</span></a>
+ </div>
+ <nav rst-seg-tabs aria-label="Calendar view">
+ <a cal-tab="Day" href="{{.Day}}"{{if eq .View "day"}} aria-current="page"{{end}}>Day</a>
+ <a cal-tab="Week" href="{{.Week}}"{{if eq .View "week"}} aria-current="page"{{end}}>Week</a>
+ <a cal-tab="Month" href="{{.Month}}"{{if eq .View "month"}} aria-current="page"{{end}}>Month</a>
+ <a cal-tab="Agenda" href="{{.Agenda}}"{{if eq .View "agenda"}} aria-current="page"{{end}}>Agenda</a>
+ </nav>
+</div>{{end}}
diff --git a/internal/calendar/templates/month.html b/internal/calendar/templates/month.html
index 42e6c63..a8f75b8 100644
--- a/internal/calendar/templates/month.html
+++ b/internal/calendar/templates/month.html
@@ -1,6 +1,6 @@
{{define "title"}}{{.Title}}{{end}}
{{define "main"}}
-<main rst-page id="main">
+<main rst-page cal-view id="main">
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Data.Month.Title}}</h1>
@@ -14,12 +14,7 @@
</div>
</header>
-<div cal-bar>
- <a rst-btn href="/month/{{.Data.Month.PrevISO}}">Previous month</a>
- <a rst-btn href="/month/{{.Data.TodayMonth}}">Today</a>
- <a rst-btn href="/month/{{.Data.Month.NextISO}}">Next month</a>
-</div>
-{{template "calendar-view-tabs" (dict "View" "month" "Day" (printf "/day/%s-01" .Data.Month.ISO) "Week" (printf "/week/%s-01" .Data.Month.ISO) "Month" (printf "/month/%s" .Data.Month.ISO) "Agenda" (printf "/agenda?from=%s-01" .Data.Month.ISO))}}
+{{template "calendar-toolbar" (dict "View" "month" "Noun" "month" "Prev" (printf "/month/%s" .Data.Month.PrevISO) "Today" (printf "/month/%s" .Data.TodayMonth) "Next" (printf "/month/%s" .Data.Month.NextISO) "Day" (printf "/day/%s-01" .Data.Month.ISO) "Week" (printf "/week/%s-01" .Data.Month.ISO) "Month" (printf "/month/%s" .Data.Month.ISO) "Agenda" (printf "/agenda?from=%s-01" .Data.Month.ISO))}}
{{/* A real table. A month IS tabular data — seven weekday columns
against week rows — so the headers come free for a screen reader,
diff --git a/internal/calendar/templates/week.html b/internal/calendar/templates/week.html
index 352a3bf..033ff83 100644
--- a/internal/calendar/templates/week.html
+++ b/internal/calendar/templates/week.html
@@ -1,8 +1,7 @@
{{define "title"}}{{.Title}}{{end}}
{{define "main"}}
-{{/* Full width, not the reading column: a week is seven columns that
- need the viewport. */}}
-<main rst-page id="main">
+{{/* cal-view: the one width every view shares (calendar.css). */}}
+<main rst-page cal-view id="main">
<header rst-page-header>
<div rst-page-header-titles>
<h1>{{.Title}}</h1>
@@ -16,12 +15,7 @@
</div>
</header>
-<div cal-bar>
- <a rst-btn href="/week/{{.Data.PrevISO}}">Previous week</a>
- <a rst-btn href="/week/{{.Data.TodayISO}}">This week</a>
- <a rst-btn href="/week/{{.Data.NextISO}}">Next week</a>
-</div>
-{{template "calendar-view-tabs" (dict "View" "week" "Day" (printf "/day/%s" .Data.StartISO) "Week" (printf "/week/%s" .Data.StartISO) "Month" (printf "/month/%s" (slice .Data.StartISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.StartISO))}}
+{{template "calendar-toolbar" (dict "View" "week" "Noun" "week" "Prev" (printf "/week/%s" .Data.PrevISO) "Today" (printf "/week/%s" .Data.TodayISO) "Next" (printf "/week/%s" .Data.NextISO) "Day" (printf "/day/%s" .Data.StartISO) "Week" (printf "/week/%s" .Data.StartISO) "Month" (printf "/month/%s" (slice .Data.StartISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.StartISO))}}
{{/* The week keeps its two dimensions on a phone and scrolls sideways.
Flattening it to a list would make it the agenda, which already
diff --git a/internal/calendartest/playwright_views_test.go b/internal/calendartest/playwright_views_test.go
new file mode 100644
index 0000000..f74b377
--- /dev/null
+++ b/internal/calendartest/playwright_views_test.go
@@ -0,0 +1,104 @@
+//go:build browser
+
+package calendartest
+
+import (
+ "context"
+ "io"
+ "log/slog"
+ "net"
+ "net/http"
+ "net/url"
+ "os/exec"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ calendar "amadan.net/oficina/calendar/internal/calendar"
+)
+
+// TestViewSwitchingHoldsStillInARealBrowser is Paul's "views jumping"
+// complaint as a gate: switching Day, Week, Month and Agenda, and stepping
+// each with Previous and Next, moves none of the page's controls and
+// shifts no layout, at 1440 and 390 wide. Each hop is a full document
+// load, so no single-page CLS figure sees the jump; the node script
+// compares boxes across documents. See test/browser/views.mjs.
+func TestViewSwitchingHoldsStillInARealBrowser(t *testing.T) {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen: %v", err)
+ }
+ origin := "http://" + ln.Addr().String()
+ app, 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() { app.Close() })
+ srv := &http.Server{Handler: app.Handler()}
+ go func() { _ = srv.Serve(ln) }()
+ t.Cleanup(func() { _ = srv.Close() })
+
+ session := signInOverHTTP(t, app, origin, "someone@example.com")
+ client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }}
+ do := func(method, path string, form url.Values) *http.Response {
+ t.Helper()
+ var body io.Reader
+ if form != nil {
+ body = strings.NewReader(form.Encode())
+ }
+ req, _ := http.NewRequest(method, origin+path, body)
+ if form != nil {
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ req.Header.Set("Origin", origin)
+ }
+ req.AddCookie(session)
+ res, err := client.Do(req)
+ if err != nil {
+ t.Fatal(err)
+ }
+ return res
+ }
+ res := do("GET", "/events/new", nil)
+ page, _ := io.ReadAll(res.Body)
+ res.Body.Close()
+ m := regexp.MustCompile(`<option value="(\d+)"[^>]*\bselected\b`).FindSubmatch(page)
+ if m == nil {
+ t.Fatal("no default calendar on the create form")
+ }
+ // Content in every view, including an all-day event (it adds a row
+ // in Day and Week) and a busy day (Month's "+N more").
+ for _, v := range []url.Values{
+ {"title": {"Team offsite"}, "all_day": {"1"}, "start": {"2026-09-23"}, "end": {"2026-09-24"}, "kind": {"commitment"}},
+ {"title": {"Design review"}, "start": {"2026-09-24T10:00"}, "end": {"2026-09-24T11:00"}, "kind": {"meeting"}},
+ {"title": {"Lunch with Ana"}, "start": {"2026-09-24T13:00"}, "end": {"2026-09-24T14:00"}, "kind": {"commitment"}},
+ {"title": {"Planning"}, "start": {"2026-09-22T15:00"}, "end": {"2026-09-22T17:00"}, "kind": {"meeting"}},
+ {"title": {"Standup"}, "start": {"2026-09-24T09:30"}, "end": {"2026-09-24T09:45"}, "kind": {"meeting"}},
+ {"title": {"Dentist"}, "start": {"2026-09-24T16:00"}, "end": {"2026-09-24T16:30"}, "kind": {"commitment"}},
+ } {
+ v.Set("calendar_id", string(m[1]))
+ v.Set("status", "confirmed")
+ v.Set("repeat", "none")
+ res := do("POST", "/events", v)
+ res.Body.Close()
+ if res.StatusCode != http.StatusSeeOther {
+ t.Fatalf("create %q: status %d", v.Get("title"), res.StatusCode)
+ }
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute)
+ defer cancel()
+ cmd := exec.CommandContext(ctx, "node", "test/browser/views.mjs", origin, session.Name, session.Value)
+ cmd.Dir = repoRootForBrowser(t)
+ out, err := cmd.CombinedOutput()
+ t.Logf("node:\n%s", out)
+ if err != nil {
+ t.Fatalf("the browser checks failed: %v", err)
+ }
+}
diff --git a/test/browser/views.mjs b/test/browser/views.mjs
new file mode 100644
index 0000000..af8a39f
--- /dev/null
+++ b/test/browser/views.mjs
@@ -0,0 +1,135 @@
+// views.mjs — switching views holds still (R2, Paul: "views jumping").
+//
+// Driven by TestViewSwitchingHoldsStillInARealBrowser, which seeds the
+// week of 21 September 2026 with an all-day event and timed events.
+//
+// At 1440 and 390 wide, over every hop Day → Week → Month → Agenda → Day
+// and every view's Previous and Next:
+// 1. CLS inside the new document is 0.
+// 2. The page heading, the view switcher, Quick add, New event, and the
+// Previous and Next buttons are at the same x/y as on the page
+// before (within 0.05px: the bold current tab once moved the switcher by 0.1px).
+// 3. Every view's <main> has the same box, so there is one layout.
+// And, at 1440:
+// 4. Week and Month are not squeezed: a day column is at least 160px.
+// 5. The week's sticky day headings sit at the top of the grid's own
+// scroll box once it has scrolled to working hours, painted over
+// the hours scrolled beneath them, with the all-day row still shown.
+import { chromium } from 'playwright';
+
+const [origin, cookieName, cookieValue] = process.argv.slice(2);
+if (!origin || !cookieName || !cookieValue) {
+ console.error('usage: views.mjs <origin> <cookieName> <cookieValue>');
+ process.exit(2);
+}
+
+const failures = [];
+function check(ok, what) {
+ if (ok) { console.log('ok: ' + what); return; }
+ failures.push(what);
+ console.error('FAIL: ' + what);
+}
+
+const views = {
+ Day: '/day/2026-09-24',
+ Week: '/week/2026-09-24',
+ Month: '/month/2026-09',
+ Agenda: '/agenda?from=2026-09-24',
+};
+const fields = ['heading', 'switcher', 'quickAdd', 'newEvent', 'prev', 'next', 'main'];
+
+const browser = await chromium.launch();
+for (const [width, height] of [[1440, 900], [390, 844]]) {
+ const ctx = await browser.newContext({ viewport: { width, height } });
+ await ctx.addCookies([{ name: cookieName, value: cookieValue, url: origin, httpOnly: true }]);
+ await ctx.addInitScript(() => {
+ window.__cls = 0;
+ new PerformanceObserver((list) => {
+ for (const e of list.getEntries()) if (!e.hadRecentInput) window.__cls += e.value;
+ }).observe({ type: 'layout-shift', buffered: true });
+ });
+ const page = await ctx.newPage();
+
+ const facts = async () => {
+ await page.waitForLoadState('networkidle');
+ await page.waitForTimeout(300);
+ return page.evaluate(() => {
+ const box = (e) => {
+ if (!e) return null;
+ const r = e.getBoundingClientRect();
+ return r.width > 0 && r.height > 0 ? { x: r.x, y: r.y, w: r.width } : null;
+ };
+ const bar = [...document.querySelectorAll('[cal-bar] a')];
+ return {
+ cls: window.__cls || 0,
+ heading: box(document.querySelector('main h1')),
+ switcher: box(document.querySelector('[rst-seg-tabs][aria-label="Calendar view"]')),
+ quickAdd: box(document.querySelector('[cal-quick-summary]')),
+ newEvent: box(document.querySelector('[cal-header-actions] a[rst-btn="primary"]')),
+ prev: box(bar.find((a) => /^Previous/.test(a.textContent.trim()))),
+ next: box(bar.find((a) => /^Next/.test(a.textContent.trim()))),
+ main: box(document.querySelector('main')),
+ };
+ });
+ };
+ const hop = async (name, path) => {
+ const before = await facts();
+ await page.goto(origin + path);
+ const after = await facts();
+ check(after.cls === 0, `${width}: ${name}: CLS is 0 (${after.cls})`);
+ for (const f of fields) {
+ const a = before[f], b = after[f];
+ const still = a && b && Math.abs(a.x - b.x) < 0.05 && Math.abs(a.y - b.y) < 0.05 && (f !== 'main' || Math.abs(a.w - b.w) < 0.05);
+ check(still, `${width}: ${name}: ${f} holds still (${a ? `${a.x.toFixed(1)},${a.y.toFixed(1)}` : 'missing'} → ${b ? `${b.x.toFixed(1)},${b.y.toFixed(1)}` : 'missing'})`);
+ }
+ };
+
+ await page.goto(origin + views.Day);
+ await hop('Day → Week', views.Week);
+ await hop('Week → Month', views.Month);
+ await hop('Month → Agenda', views.Agenda);
+ await hop('Agenda → Day', views.Day);
+ for (const [view, path] of Object.entries(views)) {
+ for (const dir of ['Previous', 'Next']) {
+ await page.goto(origin + path);
+ await facts();
+ const href = await page.locator('[cal-bar] a').filter({ hasText: new RegExp('^' + dir) }).first().getAttribute('href');
+ await hop(`${view} ${dir.toLowerCase()}`, href);
+ }
+ }
+
+ if (width === 1440) {
+ await page.goto(origin + views.Week);
+ await facts();
+ const week = await page.evaluate(() => {
+ const heads = [...document.querySelectorAll('[cal-column-head]')].slice(1).map((h) => h.getBoundingClientRect());
+ const scroller = document.querySelector('[cal-week-scroll]');
+ const top = scroller.getBoundingClientRect().top;
+ // What paints at each heading's centre: the heading, not an hour
+ // row scrolled over it.
+ const covered = heads.filter((r) => !document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)?.closest('[cal-column-head]')).length;
+ // The all-day row stays on the first screen once the grid has
+ // scrolled to working hours: the offsite's chip paints at its centre.
+ const chip = [...document.querySelectorAll('[cal-allday] [cal-chip]')].find((c) => c.textContent.includes('Team offsite'));
+ const cr = chip && chip.getBoundingClientRect();
+ const allDay = !!(cr && document.elementFromPoint(cr.x + cr.width / 2, cr.y + cr.height / 2)?.closest('[cal-chip]') === chip);
+ return { col: Math.min(...heads.map((r) => r.width)), headTop: heads[0].top, top, scrolled: scroller.scrollTop, covered, allDay };
+ });
+ check(week.col >= 160, `1440: a week column is at least 160px (${week.col.toFixed(1)})`);
+ check(week.scrolled > 0, `1440: the week opened scrolled to working hours (scrollTop ${week.scrolled})`);
+ check(Math.abs(week.headTop - week.top) < 1, `1440: the day headings stick to the grid's top (${week.headTop.toFixed(1)} vs ${week.top.toFixed(1)})`);
+ check(week.allDay, '1440: the all-day row is still on screen after the scroll to working hours');
+ check(week.covered === 0, `1440: every day heading paints over the scrolled grid (${week.covered} covered)`);
+
+ await page.goto(origin + views.Month);
+ await facts();
+ const cell = await page.evaluate(() => Math.min(...[...document.querySelectorAll('[cal-month] tbody tr:first-child td')].map((td) => td.getBoundingClientRect().width)));
+ check(cell >= 160, `1440: a month column is at least 160px (${cell.toFixed(1)})`);
+ }
+ await ctx.close();
+}
+await browser.close();
+if (failures.length) {
+ console.error(`\n${failures.length} check(s) failed`);
+ process.exit(1);
+}