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>
9 files changed,
+344
−47
internal/calendar/chrome_calendar.go+5 −3internal/calendar/static/calendar.css+62 −5internal/calendar/templates/agenda.html+8 −8internal/calendar/templates/day.html+5 −9internal/calendar/templates/layout.html+20 −6internal/calendar/templates/month.html+2 −7internal/calendar/templates/week.html+3 −9internal/calendartest/playwright_views_test.go+104 −0test/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); |
| +} |