Adopt the shared chrome in Calendar; views hold still
The 52px estilo bar replaces the old shell, with an account menu (Appearance via /appearance, one Sign out, 'All apps' when Home names a directory) and estilo error pages split by Accept. Day, Week, Month and Agenda now share one 90rem layout and one toolbar (Previous, Today, Next, then the switcher), and the switcher carries the selected date. Across all 12 view hops, at 1440 and at 390, nothing moves and CLS is 0. On main, 6 hops moved by up to 287px between loads. Week headings and the all-day row stick; day and week grids open at working hours; event chips are square. The booked-event test no longer fails 14:00-15:00 UTC. The sign-in button reads 'Sign in'. Reviewed by Claude Fable 5.1 (#17). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Squashed from feat/r2-shared-chrome into main.
43 files changed,
+1086
−397
go.mod+1 −1go.sum+2 −2internal/calendar/app.go+19 −1internal/calendar/chrome_calendar.go+52 −0internal/calendar/credits.go+3 −1internal/calendar/credits_test.go+5 −2internal/calendar/handlers.go+4 −33internal/calendar/handlers_booking_form.go+2 −2internal/calendar/handlers_booking_settings.go+1 −1internal/calendar/handlers_event_delete.go+5 −5internal/calendar/handlers_event_form.go+26 −0internal/calendar/handlers_event_form_test.go+12 −0internal/calendar/handlers_events.go+10 −9internal/calendar/handlers_misc.go+3 −3internal/calendar/handlers_quick.go+1 −1internal/calendar/handlers_views.go+18 −5internal/calendar/home.go+1 −1internal/calendar/home_router_test.go+20 −1internal/calendar/person.go+5 −5internal/calendar/render.go+111 −35internal/calendar/routes.go+2 −1internal/calendar/signin.go+1 −1internal/calendar/static/base.css+15 −0internal/calendar/static/calendar.css+78 −4internal/calendar/static/calendar.js+14 −0internal/calendar/static/chrome.js+82 −0internal/calendar/static/menu.css+143 −0internal/calendar/templates/agenda.html+8 −7internal/calendar/templates/day.html+6 −13internal/calendar/templates/errors.html+1 −1internal/calendar/templates/event.html+1 −3internal/calendar/templates/layout.html+40 −133internal/calendar/templates/month.html+2 −8internal/calendar/templates/week.html+4 −12internal/calendar/theme.go+9 −99internal/calendartest/booking_settings_test.go+29 −3internal/calendartest/index_test.go+38 −2internal/calendartest/playwright_views_test.go+104 −0internal/calendartest/signin_door_test.go+2 −1internal/calendartest/vendored_test.go+23 −0internal/calendartest/view_tabs_test.go+42 −0scripts/writeestilo/main.go+6 −1test/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.502s ? 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 2.215s ok amadan.net/oficina/calendar/internal/guardstest 0.043s ? 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/go.mod b/go.mod| index 5188792..da777bc 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -5,7 +5,7 @@ go 1.25.0 |
| toolchain go1.26.5 |
| require ( |
| - amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f |
| + amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 |
| amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd |
| github.com/carlosframework/rastrillo v0.23.0 |
diff --git a/go.sum b/go.sum| index 1b25fc6..2f15ceb 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0 h1:Qww+xGIYJaj+6TIX4Knr71ghxeYJjtgFm6WukX6NkGg= |
| amadan.net/oficina/estilo.git v0.0.0-20260929231226-cce11010ece0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f h1:lkw3tjzHfZxe74pNfo+tGDz4mkZLg+D6R+FTkmsym6M= |
| -amadan.net/oficina/home/connect v0.0.0-20260910000424-5b8d8410775f/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg= |
| +amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489 h1:RPKtyry/71i06o8L+gjAaUyexbmG56kO1YPs601URAM= |
| amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489/go.mod h1:YISpzFJ+iMgrM0rUL032OSzsAQbP4/nozvXLCCOcn1k= |
| amadan.net/rastrillo/native/server v0.0.0-20260923173546-2a5d07b2c8d3 h1:5EFx7BLJj/+G/rt8bTmeyMWVfF5s5Sns7ajTvX4w/I8= |
diff --git a/internal/calendar/app.go b/internal/calendar/app.go| index 9a5d607..6fcdc9f 100644 |
| --- a/internal/calendar/app.go |
| +++ b/internal/calendar/app.go |
| @@ -15,6 +15,7 @@ import ( |
| rauth "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/db" |
| "github.com/carlosframework/rastrillo/migrate" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "amadan.net/oficina/calendar/internal/calendar/seal" |
| ) |
| @@ -154,7 +155,7 @@ func New(cfg Config) (*App, error) { |
| mailRecorder: recorder, |
| auth: authPlugin, |
| } |
| - h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate()} |
| + h := &handlers{store: store, logger: logger, origin: origin, mailer: mailer, gate: newGate(), viewerFrom: authPlugin.SessionFrom} |
| a.h = h |
| if homeSettings != nil { |
| a.home, err = a.newHome(homeSettings, list) |
| @@ -163,6 +164,23 @@ func New(cfg Config) (*App, error) { |
| return nil, err |
| } |
| h.homeEnabled = true |
| + h.directory = homeSettings.Directory |
| + h.viewerFrom = func(r *http.Request) (rauth.Identity, bool) { |
| + // requirePerson has already resolved the person on every page |
| + // behind it; resolve again only where it has not run (public |
| + // pages, errors raised before it). |
| + if p, ok := CurrentPerson(r); ok { |
| + return rauth.Identity{Address: p.Address}, true |
| + } |
| + if a.home == nil { |
| + return viewer(r) |
| + } |
| + if s, ok := sessions.Current(r); !ok || s.Method != connect.HomeMethod { |
| + return viewer(r) |
| + } |
| + p, err := a.homePerson(r) |
| + return rauth.Identity{Address: p.Address}, err == nil |
| + } |
| } |
| // Native wiring comes before the scheduler starts, so a native |
| // failure leaves no timer goroutine behind to leak. |
diff --git a/internal/calendar/chrome_calendar.go b/internal/calendar/chrome_calendar.go| new file mode 100644 |
| index 0000000..ef7a1b8 |
| --- /dev/null |
| +++ b/internal/calendar/chrome_calendar.go |
| @@ -0,0 +1,52 @@ |
| +package calendar |
| + |
| +import ( |
| + "html/template" |
| + "net/http" |
| + |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| +) |
| + |
| +func (h *handlers) viewer(r *http.Request) (rauth.Identity, bool) { |
| + if h.viewerFrom != nil { |
| + return h.viewerFrom(r) |
| + } |
| + return viewer(r) |
| +} |
| + |
| +func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.Mode, title, directory string) chrome.Bar { |
| + 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")}}, |
| + } |
| + if !signedIn { |
| + bar.Nav = nil |
| + bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + template.URLQueryEscaper(r.URL.RequestURI()), Label: "Sign in"} |
| + return bar |
| + } |
| + if directory != "" { |
| + bar.Switcher = &chrome.Switcher{Label: "Oficina apps", AllApps: estilomenu.Item{Href: directory, Label: "All apps"}} |
| + } |
| + // A live session whose person could not be resolved (a removed Home |
| + // person on an error page) still gets the menu, to sign out from, but |
| + // names nobody: "Signed in", the person glyph, no address (spec §8.2, |
| + // as the hub directory does). |
| + trigger, signedInAs := "Account: "+address, "Signed in as" |
| + if address == "" { |
| + trigger, signedInAs = "Account", "Signed in" |
| + } |
| + bar.Account = &chrome.Account{TriggerLabel: trigger, Initials: chrome.Initials("", address), Address: address, |
| + Labels: chrome.AccountLabels{SignedInAs: signedInAs, Appearance: "Appearance", Help: "Help"}, |
| + Items: []estilomenu.Item{{Href: "/settings", Label: "Settings"}, {Href: "/settings/booking", Label: "Booking pages"}, {Href: "/import", Label: "Import a calendar"}}, |
| + Appearance: &chrome.AppearanceMenu{Action: "/appearance", Return: r.URL.RequestURI(), Options: chrome.AppearanceOptions(mode, "Light", "Dark", "System")}, Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}} |
| + return bar |
| +} |
diff --git a/internal/calendar/credits.go b/internal/calendar/credits.go| index 60999fd..3c12d63 100644 |
| --- a/internal/calendar/credits.go |
| +++ b/internal/calendar/credits.go |
| @@ -28,7 +28,9 @@ func (h *handlers) credits(w http.ResponseWriter, r *http.Request) { |
| w.Header().Set("Content-Type", "text/html; charset=utf-8") |
| w.Header().Set("Cache-Control", "no-store") |
| - w.Header().Set("Referrer-Policy", "no-referrer") |
| + // strict-origin (spec §9.5): this page shows the account menu, and Home's |
| + // sign-out chain needs an origin; no path or query is ever sent. |
| + w.Header().Set("Referrer-Policy", "strict-origin") |
| render(w, "credits", h.page(r, "Open source", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})) |
| } |
diff --git a/internal/calendar/credits_test.go b/internal/calendar/credits_test.go| index ba2a304..f7b78cf 100644 |
| --- a/internal/calendar/credits_test.go |
| +++ b/internal/calendar/credits_test.go |
| @@ -18,8 +18,11 @@ func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) { |
| if response.Code != http.StatusOK || response.Header().Get("Location") != "" || len(response.Result().Cookies()) != 0 { |
| t.Fatal("credits required authentication", response.Code) |
| } |
| - if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "no-referrer" { |
| - t.Fatal("credits shell may retain account context") |
| + if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "strict-origin" { |
| + // strict-origin, not no-referrer (shared-chrome spec §9.5): the page |
| + // shows the account menu, and Home's sign-out chain refuses a POST |
| + // with no Origin/Referer. strict-origin still never sends a path. |
| + t.Fatal("credits must send Referrer-Policy: strict-origin, got", response.Header().Get("Referrer-Policy")) |
| } |
| for _, wanted := range []string{"No notice file is included in this release.", "estilo-credits", "Open source", "Lucide and Feather", "gorm.io/gorm", "gormlite/LICENSE", "/static/credits.css"} { |
| if !strings.Contains(response.Body.String(), wanted) { |
diff --git a/internal/calendar/handlers.go b/internal/calendar/handlers.go| index 6d56c2e..7758479 100644 |
| --- a/internal/calendar/handlers.go |
| +++ b/internal/calendar/handlers.go |
| @@ -3,8 +3,8 @@ package calendar |
| import ( |
| "log/slog" |
| "net/http" |
| - "strings" |
| + rauth "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/rastrillo/mail" |
| ) |
| @@ -12,6 +12,7 @@ import ( |
| type handlers struct { |
| scheduled *scheduledWork |
| homeEnabled bool |
| + directory string |
| store *Store |
| logger *slog.Logger |
| @@ -26,38 +27,8 @@ type handlers struct { |
| // gate is the per-IP token bucket every public booking route |
| // passes through. One per app; memory only. |
| - gate *gate |
| -} |
| - |
| -// secure reports whether cookies this app sets should carry Secure. It |
| -// is derived from the origin rather than from the request, so a |
| -// misconfigured proxy cannot talk the app into dropping the attribute. |
| -func (h *handlers) secure() bool { |
| - return strings.HasPrefix(h.origin, "https://") |
| -} |
| - |
| -// safeReturn returns a same-site path to redirect back to after a form |
| -// post, or fallback when there is nothing safe to use. |
| -// |
| -// Same-site means starting with exactly one "/" — never "//host", |
| -// which browsers read as a protocol-relative URL to another origin and |
| -// which is the whole open-redirect bug in one extra character. A |
| -// backslash is refused for the same reason: some browsers normalise it |
| -// to a slash, so "/\evil.com" would otherwise pass this check and |
| -// leave anyway. |
| -func safeReturn(r *http.Request, fallback string) string { |
| - _ = r.ParseForm() |
| - to := r.FormValue("return_to") |
| - if to == "" { |
| - to = r.URL.Query().Get("return_to") |
| - } |
| - if len(to) < 1 || to[0] != '/' { |
| - return fallback |
| - } |
| - if strings.HasPrefix(to, "//") || strings.HasPrefix(to, "/\\") { |
| - return fallback |
| - } |
| - return to |
| + gate *gate |
| + viewerFrom func(*http.Request) (rauth.Identity, bool) |
| } |
| // index serves GET / — the calendar itself. |
diff --git a/internal/calendar/handlers_booking_form.go b/internal/calendar/handlers_booking_form.go| index e900119..0138e37 100644 |
| --- a/internal/calendar/handlers_booking_form.go |
| +++ b/internal/calendar/handlers_booking_form.go |
| @@ -88,7 +88,7 @@ func (h *handlers) editBookingPage(w http.ResponseWriter, r *http.Request) { |
| id, _ := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| page, err := h.store.BookingPage(r.Context(), p.ID, id) |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if err != nil { |
| @@ -130,7 +130,7 @@ func (h *handlers) saveBookingPage(w http.ResponseWriter, r *http.Request) { |
| err = h.store.UpdateBookingPage(r.Context(), p.ID, f.ID, in, windows) |
| } |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if ve, ok := Invalid(err); ok { |
diff --git a/internal/calendar/handlers_booking_settings.go b/internal/calendar/handlers_booking_settings.go| index 2203b05..05c029e 100644 |
| --- a/internal/calendar/handlers_booking_settings.go |
| +++ b/internal/calendar/handlers_booking_settings.go |
| @@ -54,7 +54,7 @@ func (h *handlers) publishBookingPage(w http.ResponseWriter, r *http.Request) { |
| _ = r.ParseForm() |
| err := h.store.PublishBookingPage(r.Context(), p.ID, id, r.FormValue("publish") == "1") |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if err != nil { |
diff --git a/internal/calendar/handlers_event_delete.go b/internal/calendar/handlers_event_delete.go| index 6931676..1ac09b3 100644 |
| --- a/internal/calendar/handlers_event_delete.go |
| +++ b/internal/calendar/handlers_event_delete.go |
| @@ -12,7 +12,7 @@ func (h *handlers) deleteEvent(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| _ = r.ParseForm() |
| @@ -41,9 +41,9 @@ func (h *handlers) deleteEvent(w http.ResponseWriter, r *http.Request) { |
| func (h *handlers) deleteFailed(w http.ResponseWriter, r *http.Request, err error) { |
| switch { |
| case NotFound(err): |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| case err == ErrReadOnly: |
| - ErrorPage(w, r, http.StatusForbidden, "") |
| + h.errorPage(w, r, http.StatusForbidden, "") |
| default: |
| h.fail(w, r, "delete event", err) |
| } |
| @@ -56,12 +56,12 @@ func (h *handlers) confirmDelete(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| e, err := h.store.Event(r.Context(), p.ID, id) |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if err != nil { |
diff --git a/internal/calendar/handlers_event_form.go b/internal/calendar/handlers_event_form.go| index 5912106..f1e27a0 100644 |
| --- a/internal/calendar/handlers_event_form.go |
| +++ b/internal/calendar/handlers_event_form.go |
| @@ -4,6 +4,7 @@ import ( |
| "net/http" |
| "strconv" |
| "strings" |
| + "time" |
| ) |
| // eventForm is the create/edit form's view data. It carries the |
| @@ -60,6 +61,12 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo |
| allDay := r.FormValue("all_day") == "1" |
| start := formTime(r.FormValue("start"), allDay) |
| end := formTime(r.FormValue("end"), allDay) |
| + // Turning a same-day timed event into an all-day event must preserve a |
| + // non-empty half-open range. A date-only end on the same day is empty, |
| + // so a non-midnight submitted end rounds *up* to the following day. |
| + if allDay { |
| + end = roundedAllDayEnd(start, end, r.FormValue("end")) |
| + } |
| // The zone is the person's, except on a booked event's form, which |
| // carries the EVENT's own zone as a hidden field: after the host |
| // changes their profile zone, a title-only save must still match |
| @@ -109,6 +116,25 @@ func (h *handlers) readEventForm(r *http.Request, p Person) (EventInput, eventFo |
| return in, f, true |
| } |
| +func hasClock(raw string) bool { |
| + v := strings.TrimSpace(raw) |
| + if len(v) <= len(LayoutDate) || v[len(LayoutDate)] != 'T' { |
| + return false |
| + } |
| + return !strings.HasPrefix(v[len(LayoutDate)+1:], "00:00") |
| +} |
| + |
| +func roundedAllDayEnd(start, end, raw string) string { |
| + if !hasClock(raw) || end != start { |
| + return end |
| + } |
| + d, err := time.Parse(LayoutDate, end) |
| + if err != nil { |
| + return end |
| + } |
| + return d.AddDate(0, 0, 1).Format(LayoutDate) |
| +} |
| + |
| // formTime reads a Starts/Ends value in the shape the All day box |
| // asks for. The form renders date inputs for an all-day event and |
| // datetime inputs otherwise, but without script ticking or unticking |
diff --git a/internal/calendar/handlers_event_form_test.go b/internal/calendar/handlers_event_form_test.go| new file mode 100644 |
| index 0000000..64a656b |
| --- /dev/null |
| +++ b/internal/calendar/handlers_event_form_test.go |
| @@ -0,0 +1,12 @@ |
| +package calendar |
| + |
| +import "testing" |
| + |
| +func TestAllDayConversionRoundsANonMidnightEndUp(t *testing.T) { |
| + if got := roundedAllDayEnd("2026-09-23", "2026-09-23", "2026-09-23T13:00"); got != "2026-09-24" { |
| + t.Fatalf("end = %q, want next day", got) |
| + } |
| + if got := roundedAllDayEnd("2026-09-23", "2026-09-24", "2026-09-24T00:00"); got != "2026-09-24" { |
| + t.Fatalf("midnight end = %q", got) |
| + } |
| +} |
diff --git a/internal/calendar/handlers_events.go b/internal/calendar/handlers_events.go| index a924d5b..f773d76 100644 |
| --- a/internal/calendar/handlers_events.go |
| +++ b/internal/calendar/handlers_events.go |
| @@ -73,7 +73,7 @@ func (h *handlers) createEvent(w http.ResponseWriter, r *http.Request) { |
| e, err := h.store.CreateEvent(r.Context(), p.ID, in) |
| switch { |
| case NotFound(err): |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| case err != nil: |
| if ve, isVE := Invalid(err); isVE { |
| @@ -101,12 +101,12 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| e, err := h.store.Event(r.Context(), p.ID, id) |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if err != nil { |
| @@ -120,7 +120,7 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) { |
| if raw := chi.URLParam(r, "occ"); raw != "" { |
| local, ok := expandCompact(raw) |
| if !ok { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| occurrence = local |
| @@ -151,6 +151,7 @@ func (h *handlers) showEvent(w http.ResponseWriter, r *http.Request) { |
| "Attendees": attendees, |
| "Zone": p.Zone, |
| "RuleText": DescribeRule(e.RRule), |
| + "When": spanWords(e.StartUTC, e.EndUTC, p.Zone), |
| } |
| render(w, "event", h.page(r, e.Title, data)) |
| } |
| @@ -160,12 +161,12 @@ func (h *handlers) editEvent(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| e, err := h.store.Event(r.Context(), p.ID, id) |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| if err != nil { |
| @@ -208,7 +209,7 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| in, f, ok := h.readEventForm(r, p) |
| @@ -246,9 +247,9 @@ func (h *handlers) updateEvent(w http.ResponseWriter, r *http.Request) { |
| } |
| switch { |
| case NotFound(err): |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| case err == ErrReadOnly: |
| - ErrorPage(w, r, http.StatusForbidden, "") |
| + h.errorPage(w, r, http.StatusForbidden, "") |
| case err != nil: |
| h.fail(w, r, "update event", err) |
| default: |
diff --git a/internal/calendar/handlers_misc.go b/internal/calendar/handlers_misc.go| index a85ea46..9ead269 100644 |
| --- a/internal/calendar/handlers_misc.go |
| +++ b/internal/calendar/handlers_misc.go |
| @@ -174,7 +174,7 @@ func (h *handlers) importICS(w http.ResponseWriter, r *http.Request) { |
| } |
| calID, _ := strconv.ParseInt(r.FormValue("calendar_id"), 10, 64) |
| if _, err := h.store.Calendar(r.Context(), p.ID, calID); err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| file, _, err := r.FormFile("file") |
| @@ -242,12 +242,12 @@ func (h *handlers) calendarParam(w http.ResponseWriter, r *http.Request) (Person |
| p, _ := CurrentPerson(r) |
| id, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return p, CalendarView{}, false |
| } |
| cal, err := h.store.Calendar(r.Context(), p.ID, id) |
| if NotFound(err) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return p, CalendarView{}, false |
| } |
| if err != nil { |
diff --git a/internal/calendar/handlers_quick.go b/internal/calendar/handlers_quick.go| index 1655ae6..800e832 100644 |
| --- a/internal/calendar/handlers_quick.go |
| +++ b/internal/calendar/handlers_quick.go |
| @@ -115,7 +115,7 @@ func (h *handlers) quickParse(w http.ResponseWriter, r *http.Request) { |
| func (h *handlers) quickAdd(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| if err := r.ParseForm(); err != nil { |
| - ErrorPage(w, r, http.StatusBadRequest, "") |
| + h.errorPage(w, r, http.StatusBadRequest, "") |
| return |
| } |
| text := strings.TrimSpace(r.FormValue("text")) |
diff --git a/internal/calendar/handlers_views.go b/internal/calendar/handlers_views.go| index 7268fb8..624e71a 100644 |
| --- a/internal/calendar/handlers_views.go |
| +++ b/internal/calendar/handlers_views.go |
| @@ -29,7 +29,7 @@ func (h *handlers) day(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| day, err := ParseDate(chi.URLParam(r, "date"), p.Zone) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| from, to, err := DayBounds(day, p.Zone) |
| @@ -71,7 +71,7 @@ func (h *handlers) week(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| anyDay, err := ParseDate(chi.URLParam(r, "date"), p.Zone) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| start, err := WeekStartFor(anyDay, p.WeekStart, p.Zone) |
| @@ -140,7 +140,7 @@ func (h *handlers) month(w http.ResponseWriter, r *http.Request) { |
| p, _ := CurrentPerson(r) |
| month, err := ParseMonth(chi.URLParam(r, "month"), p.Zone) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| from, to, err := MonthBounds(month, p.WeekStart, p.Zone) |
| @@ -158,7 +158,20 @@ func (h *handlers) month(w http.ResponseWriter, r *http.Request) { |
| h.fail(w, r, "build month", err) |
| return |
| } |
| - render(w, "month", h.page(r, page.Title, map[string]any{"Month": page})) |
| + // The day the view switcher carries (?date=, from Day, Week or Agenda), |
| + // so Month's Day and Week tabs go back to it. Outside this month, or |
| + // absent: today if this is the current month, else the 1st. |
| + now := time.Now().In(month.Location()) |
| + selected := month.Format("2006-01") + "-01" |
| + if now.Format("2006-01") == month.Format("2006-01") { |
| + selected = now.Format("2006-01-02") |
| + } |
| + if s := r.URL.Query().Get("date"); s != "" { |
| + if d, err := ParseDate(s, p.Zone); err == nil && d.Format("2006-01") == month.Format("2006-01") { |
| + selected = d.Format("2006-01-02") |
| + } |
| + } |
| + render(w, "month", h.page(r, page.Title, map[string]any{"Month": page, "TodayMonth": now.Format("2006-01"), "SelectedISO": selected})) |
| } |
| // agenda lists the next fortnight. |
| @@ -202,5 +215,5 @@ func (h *handlers) agenda(w http.ResponseWriter, r *http.Request) { |
| // person or their content goes into it. |
| func (h *handlers) fail(w http.ResponseWriter, r *http.Request, stage string, err error) { |
| h.logger.Error("calendar: "+stage, "err", err) |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| + h.errorPage(w, r, http.StatusInternalServerError, "") |
| } |
diff --git a/internal/calendar/home.go b/internal/calendar/home.go| index 70e4c90..b60a4c1 100644 |
| --- a/internal/calendar/home.go |
| +++ b/internal/calendar/home.go |
| @@ -66,7 +66,7 @@ func (a *App) newHome(settings *connect.DecodedCredentials, list allowlist) (*ho |
| } |
| var buf bytes.Buffer |
| if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, data.Title, view)); err != nil { |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| + a.h.errorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| w.Header().Set("Cache-Control", "no-store") |
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go| index a18ab48..e1469bf 100644 |
| --- a/internal/calendar/home_router_test.go |
| +++ b/internal/calendar/home_router_test.go |
| @@ -114,6 +114,10 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| f.local(t) |
| if w := f.do("GET", "/day/2026-09-10", ""); w.Code != 200 { |
| t.Fatal("local calendar", w.Code, w.Body.String()) |
| + } else if !strings.Contains(w.Body.String(), `aria-label="Account: alice@example.test"`) { |
| + // With Home on, a LOCAL session's chrome still names the person |
| + // (viewerFrom only takes the Home path for a Home session). |
| + t.Fatal("local session in Home mode drew no account menu") |
| } |
| var person Person |
| if err := f.app.db.G.Where("address=?", "alice@example.test").First(&person).Error; err != nil { |
| @@ -145,7 +149,11 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| t.Fatal("proof replaced session") |
| } |
| homeRedirect(t, f.do("POST", "/account/home/confirm", "")) |
| - homeRedirect(t, f.do("POST", "/signout", "")) |
| + // connect now preserves this POST across the app → id.* sign-out hop; |
| + // a 303 would turn it into a GET and leave the directory session alive. |
| + if out := f.do("POST", "/signout", ""); out.Code != http.StatusTemporaryRedirect || out.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("sign-out = %d %q, want 307 to issuer", out.Code, out.Header().Get("Location")) |
| + } |
| start = homeRedirect(t, f.do("GET", "/signin", "")) |
| target := homeRedirect(t, f.exchange(t, start, "opaque-home-alice")) |
| if target != "/" { |
| @@ -192,6 +200,17 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| } |
| if w := f.do("GET", "/", ""); w.Code != 404 { |
| t.Fatal("deleted account admitted", w.Code) |
| + } else { |
| + // The 404 still has a session to sign out of, but no identity: |
| + // the account menu says "Signed in" and names nobody, rather than |
| + // "Account: " and "Signed in as" over an empty address. |
| + body := w.Body.String() |
| + if strings.Contains(body, `aria-label="Account: "`) || strings.Contains(body, "Signed in as") { |
| + t.Fatal("error page drew an empty identity in the account menu") |
| + } |
| + if !strings.Contains(body, "estilo-account") || !strings.Contains(body, ">Signed in<") || !strings.Contains(body, "Sign out") { |
| + t.Fatal("error page lost the account menu for a live session") |
| + } |
| } |
| replacement, err := f.app.store.EnsurePerson(t.Context(), "replacement@example.test") |
| if err != nil || replacement.ID == person.ID { |
diff --git a/internal/calendar/person.go b/internal/calendar/person.go| index be8e2b9..b35370c 100644 |
| --- a/internal/calendar/person.go |
| +++ b/internal/calendar/person.go |
| @@ -30,12 +30,12 @@ func (a *App) requirePerson(next http.Handler) http.Handler { |
| return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| if session, ok := sessions.Current(r); ok && session.Method == connect.HomeMethod { |
| if a.home == nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + a.h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| p, err := a.homePerson(r) |
| if err != nil { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + a.h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p))) |
| @@ -48,11 +48,11 @@ func (a *App) requirePerson(next http.Handler) http.Handler { |
| // reaching here means the two disagree. Refuse rather than |
| // continue with no viewer. |
| a.logger.Error("calendar: session with no identity reached requirePerson", "path", r.URL.Path) |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| + a.h.errorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| if a.home != nil && !a.home.admitted.allows(id.Address) { |
| - ErrorPage(w, r, http.StatusNotFound, "") |
| + a.h.errorPage(w, r, http.StatusNotFound, "") |
| return |
| } |
| p, err := a.store.EnsurePersonSetup(r.Context(), id.Address) |
| @@ -61,7 +61,7 @@ func (a *App) requirePerson(next http.Handler) http.Handler { |
| // request, and `carlos logs` is platform-held storage we |
| // cannot grep afterwards. |
| a.logger.Error("calendar: ensure person", "err", err) |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| + a.h.errorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), personCtxKey{}, p))) |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index 66b3418..da8593d 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -11,6 +11,7 @@ import ( |
| "net/http" |
| "github.com/carlosframework/rastrillo" |
| + "github.com/carlosframework/rastrillo/sessions" |
| "github.com/carlosframework/rastrillo/ui" |
| "amadan.net/oficina/calendar/internal/calendar/icons" |
| @@ -49,33 +50,27 @@ var pages = map[string]*template.Template{} |
| func init() { |
| for _, name := range pageNames { |
| - pages[name] = template.Must( |
| - // ui's partials and their helpers first, so every page can |
| - // call one without registering anything. WithIcons points |
| - // ui's two icon seams at the app's own set — both of them, |
| - // always: passing only icon would leave iconAssets on the |
| - // framework default and silently break cdn and js delivery. |
| - template.Must(template.New("layout"). |
| - Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| - Funcs(template.FuncMap{"asset": assets.Path}). |
| - Funcs(appFuncs). |
| - ParseFS(ui.Templates(), "*.html")). |
| - // The shell, this app's own shared partials, and the |
| - // page's body. _chip.html is shared by every view, so |
| - // it is parsed into every tree rather than duplicated. |
| - ParseFS(appFS, "templates/layout.html", "templates/_chip.html", |
| - "templates/_quickadd.html", "templates/_quickpane.html", |
| - "templates/_bookingpane.html", |
| - "templates/"+name+".html")) |
| - pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html")) |
| - pages[name] = template.Must(pages[name].ParseFS(credits.Templates(), "templates/*.html")) |
| + // ui's partials and their helpers first, so every page can |
| + // call one without registering anything. WithIcons points |
| + // ui's two icon seams at the app's own set — both of them, |
| + // always: passing only icon would leave iconAssets on the |
| + // framework default and silently break cdn and js delivery. |
| + t := template.Must(template.New("layout"). |
| + Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))). |
| + Funcs(template.FuncMap{"asset": assets.Path}). |
| + Funcs(appFuncs). |
| + ParseFS(ui.Templates(), "*.html")) |
| + // estilo's menu and chrome: the bar, the account menu, the error |
| + // page and the sign-in door. chrome's templates call ui's field and |
| + // callout partials, already in the tree, and name menu's. |
| + t = template.Must(t.ParseFS(menu.Templates(), "templates/*.html")) |
| + t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html")) |
| + t = template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/_chip.html", |
| + "templates/_quickadd.html", "templates/_quickpane.html", "templates/_bookingpane.html", |
| + "templates/"+name+".html")) |
| + t = template.Must(t.ParseFS(connect.Templates(), "templates/home_link.html")) |
| + pages[name] = template.Must(t.ParseFS(credits.Templates(), "templates/*.html")) |
| } |
| - // The sign-in pages are estilo's door. chrome's templates use ui's |
| - // field and callout partials, already in the tree, and name menu's |
| - // templates from the bar, so menu is parsed alongside. |
| - pages["signin"] = template.Must(template.Must(pages["signin"]. |
| - ParseFS(menu.Templates(), "templates/*.html")). |
| - ParseFS(chrome.Templates(), "templates/*.html")) |
| } |
| // fragments is the partials on their own, with no layout around them. |
| @@ -120,6 +115,10 @@ type PageData struct { |
| // "dark"), so the switcher can mark its current choice. |
| Mode string |
| + // Chrome is the suite-owned 52px bar. It is built in page so error |
| + // pages and ordinary pages cannot accidentally grow different chrome. |
| + Chrome chrome.Bar |
| + |
| // Path is the current request path, so a form that posts elsewhere |
| // and redirects back — the mode switcher — can name where "back" is. |
| Path string |
| @@ -143,20 +142,24 @@ type PageData struct { |
| // the viewer and the return path are read, so no handler can forget one |
| // and render a page whose switcher silently does nothing. |
| func (h *handlers) page(r *http.Request, title string, data any) PageData { |
| - mode := readMode(r) |
| + mode := chrome.Appearance(r) |
| pd := PageData{ |
| Title: title, |
| HomeEnabled: h.homeEnabled, |
| - ThemeAttr: themeAttr(mode), |
| - Mode: mode, |
| + ThemeAttr: chrome.ThemeAttr(mode), |
| + Mode: string(mode), |
| Path: r.URL.Path, |
| QuickPane: emptyPane(), |
| Data: data, |
| } |
| - if id, ok := viewer(r); ok { |
| + if id, ok := h.viewer(r); ok { |
| pd.SignedIn = true |
| pd.Address = id.Address |
| } |
| + pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title, h.directory) |
| + if r.URL.Path == "/signin" || r.URL.Path == "/signin/local" || r.URL.Path == "/signed-out" { |
| + pd.Chrome.Account, pd.Chrome.SignIn = nil, nil |
| + } |
| return pd |
| } |
| @@ -184,13 +187,86 @@ func render(w http.ResponseWriter, name string, data any) { |
| // Options.ErrorPage at, so a panic gets the same page a handled failure |
| // would. |
| func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| - mode := readMode(r) |
| + id, ok := viewer(r) |
| + _, session := sessions.Current(r) |
| + renderError(w, r, status, ref, id.Address, ok || session, "") |
| +} |
| + |
| +// errorPage is ErrorPage for a request the app is serving: the viewer is |
| +// resolved the way every other page resolves it (h.viewer, which knows a |
| +// Home session's person), so the error page carries the same chrome. A |
| +// live session whose person cannot be resolved still gets the account |
| +// menu, to sign out from, with no identity in it. |
| +func (h *handlers) errorPage(w http.ResponseWriter, r *http.Request, status int, ref string) { |
| + id, ok := h.viewer(r) |
| + _, session := sessions.Current(r) |
| + if !ok { |
| + id.Address = "" |
| + } |
| + renderError(w, r, status, ref, id.Address, ok || session, h.directory) |
| +} |
| + |
| +func renderError(w http.ResponseWriter, r *http.Request, status int, ref, address string, signed bool, directory string) { |
| + mode := chrome.Appearance(r) |
| + title, errPage := errorData(r, status, ref) |
| w.WriteHeader(status) |
| render(w, "errors", PageData{ |
| - Title: "Something went wrong", |
| - ThemeAttr: themeAttr(mode), |
| - Mode: mode, |
| + Title: title, |
| + ThemeAttr: chrome.ThemeAttr(mode), |
| + Mode: string(mode), |
| Path: r.URL.Path, |
| - Data: map[string]any{"Status": status, "Ref": ref}, |
| + SignedIn: signed, |
| + Address: address, |
| + Chrome: calendarChrome(r, signed, address, mode, title, directory), |
| + Data: errPage, |
| }) |
| } |
| + |
| +// errorData is the shared-chrome spec's §3.2 table: the tab title, then the |
| +// page's h1, body and primary way back. A 404 never says whether the thing |
| +// exists (not shared and deleted read the same). A 500 or 503 on a GET |
| +// offers the same URL again; any other method goes to Calendar, because |
| +// re-sending a POST from a link is not possible and not wanted. |
| +func errorData(r *http.Request, status int, ref string) (string, chrome.ErrorPage) { |
| + home := chrome.Link{Href: "/", Label: "Go to Calendar"} |
| + again := home |
| + if r.Method == http.MethodGet { |
| + again = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"} |
| + } |
| + title, h1, body, cta := "Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below.", again |
| + switch status { |
| + case http.StatusNotFound: |
| + title, h1, body, cta = "Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved.", home |
| + case http.StatusForbidden: |
| + title, h1, body, cta = "No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should.", home |
| + case http.StatusServiceUnavailable: |
| + title, h1, body, cta = "Calendar is unavailable", "Calendar is unavailable right now", "Try again in a few minutes.", again |
| + case http.StatusBadRequest: |
| + // Not in the table: a malformed request is the person's link or |
| + // form, not our failure, so it reads as the 404's way back. |
| + title, h1, body, cta = "Something went wrong", "That request didn’t work", "The link or form may be out of date. Go back and try again.", home |
| + } |
| + return title, chrome.ErrorPage{Status: status, Title: h1, Body: body, Home: cta, Ref: ref, RefLabel: "Reference:"} |
| +} |
| + |
| +// notFound has an HTML page only for navigation. API/feed clients retain the |
| +// ordinary compact 404, which is both the useful representation and prevents |
| +// an HTML error page from being mistaken for a successful feed response. |
| +func (h *handlers) notFound(w http.ResponseWriter, r *http.Request) { |
| + if !containsHTML(r.Header.Get("Accept")) { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + w.WriteHeader(http.StatusNotFound) |
| + title, page := errorData(r, http.StatusNotFound, "") |
| + render(w, "errors", h.page(r, title, page)) |
| +} |
| + |
| +func containsHTML(accept string) bool { |
| + for i := 0; i+9 <= len(accept); i++ { |
| + if accept[i:i+9] == "text/html" { |
| + return true |
| + } |
| + } |
| + return false |
| +} |
diff --git a/internal/calendar/routes.go b/internal/calendar/routes.go| index 23ab309..275c52c 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -39,6 +39,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Handle("/native/*", a.native) |
| r.Get("/help/open-source", h.credits) |
| + r.NotFound(h.notFound) |
| // Signed-out. |
| if a.home != nil { |
| @@ -81,7 +82,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| // The light/dark switcher, outside the session on purpose: the |
| // sign-in page is a page too, and somebody who needs a |
| // high-contrast theme should not have to sign in to get one. |
| - r.Post("/mode", h.mode) |
| + r.Post("/appearance", h.appearance) |
| // A subscription feed is a capability: the token in the URL is the |
| // whole authorisation, because Google and Apple will not sign in. |
diff --git a/internal/calendar/signin.go b/internal/calendar/signin.go| index 5645d76..c34437c 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -39,7 +39,7 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| Action: "/signin", |
| AddressLabel: "Email address", |
| Help: "We email you a link. There is no password to forget.", |
| - Submit: "Email me a link", |
| + Submit: "Sign in", // Paul, 2026-10-01 |
| OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| } |
| switch q.Get("err") { |
diff --git a/internal/calendar/static/base.css b/internal/calendar/static/base.css| new file mode 100644 |
| index 0000000..9873e71 |
| --- /dev/null |
| +++ b/internal/calendar/static/base.css |
| @@ -0,0 +1,15 @@ |
| +/* base/base.css: links. Zero specificity on purpose: any component rule wins. |
| + Prose links keep their underline, because colour must never carry meaning |
| + alone. Chrome links (bar, nav, menus, rows, buttons) set their own colour |
| + and text-decoration, and every one of them outranks :where(). */ |
| +:where(a:any-link) { |
| + color: var(--rst-accent); |
| + text-decoration-line: underline; |
| + text-decoration-thickness: 1px; |
| + text-underline-offset: 0.15em; |
| +} |
| +:where(a:any-link:hover) { text-decoration-thickness: 2px; } |
| +:where(a:any-link:focus-visible) { |
| + outline: 2px solid var(--rst-accent); |
| + outline-offset: 2px; |
| +} |
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css| index ce9efcb..bd12658 100644 |
| --- a/internal/calendar/static/calendar.css |
| +++ b/internal/calendar/static/calendar.css |
| @@ -17,14 +17,50 @@ |
| /* ── 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 bar is sticky, so an anchor or :target lands below it, not under |
| + * it (shared-chrome spec §5.3). */ |
| +html { scroll-padding-block-start: var(--estilo-bar-block-size); } |
| + |
| +/* 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-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; } |
| /* The day's sentence. It is the page's opening line, so it reads at |
| @@ -36,6 +72,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 |
| @@ -115,7 +154,7 @@ |
| align-items: baseline; |
| background: color-mix(in oklab, hsl(var(--cal-hue, 220) 60% 50%) 14%, var(--rst-surface)); |
| border-inline-start: 3px solid hsl(var(--cal-hue, 220) 55% 45%); |
| - border-radius: var(--rst-radius-sm); |
| + border-radius: 0; |
| color: var(--rst-text); |
| display: flex; |
| font-size: var(--rst-fs-sm); |
| @@ -173,6 +212,10 @@ |
| display: grid; |
| gap: 0; |
| grid-template-columns: 3.5rem 1fr; |
| + /* The grid scrolls inside the first screen, but never below 24rem: on |
| + * a short window (or a phone on its side) 100vh - 20rem is a sliver. */ |
| + max-block-size: max(24rem, calc(100vh - 20rem)); |
| + overflow: auto; |
| } |
| [cal-week-grid] { |
| @@ -181,6 +224,13 @@ |
| grid-template-columns: 3.5rem repeat(7, minmax(6rem, 1fr)); |
| } |
| +[cal-week-scroll] { |
| + /* The grid scrolls inside the first screen, but never below 24rem: on |
| + * a short window (or a phone on its side) 100vh - 20rem is a sliver. */ |
| + max-block-size: max(24rem, calc(100vh - 20rem)); |
| + overflow: auto; |
| +} |
| + |
| [cal-hours] { |
| border-inline-end: 1px solid var(--rst-line); |
| position: relative; |
| @@ -206,10 +256,20 @@ |
| } |
| [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; |
| + /* 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); |
| @@ -217,6 +277,8 @@ |
| [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] { |
| @@ -300,6 +362,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); |
| @@ -396,8 +468,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/static/calendar.js b/internal/calendar/static/calendar.js| new file mode 100644 |
| index 0000000..a9395b2 |
| --- /dev/null |
| +++ b/internal/calendar/static/calendar.js |
| @@ -0,0 +1,14 @@ |
| +// Calendar's hour grids begin at midnight in the DOM so their geometry and |
| +// keyboard targets remain honest. On a fresh page load, bring the working |
| +// day into view without animation; browser history restoration remains in |
| +// charge on Back/Forward. |
| +addEventListener("DOMContentLoaded", () => { |
| + if (performance.getEntriesByType("navigation")[0]?.type === "back_forward") return; |
| + const hour = document.querySelector("[cal-working-hour]"); |
| + const scroller = hour && hour.closest("[cal-grid], [cal-week-scroll]"); |
| + if (!scroller) return; |
| + requestAnimationFrame(() => { |
| + const top = hour.getBoundingClientRect().top - scroller.getBoundingClientRect().top; |
| + scroller.scrollTop = Math.max(0, scroller.scrollTop + top - 72); |
| + }); |
| +}); |
diff --git a/internal/calendar/static/chrome.js b/internal/calendar/static/chrome.js| new file mode 100644 |
| index 0000000..1f583c6 |
| --- /dev/null |
| +++ b/internal/calendar/static/chrome.js |
| @@ -0,0 +1,82 @@ |
| +// chrome/chrome.js: optional behaviour for estilo-bar and the one top-level |
| +// menu group. Load it with <script defer>. Everything already works |
| +// without it; this only adds what <details> and the platform cannot do for |
| +// themselves: |
| +// |
| +// - Escape closes the open member of the group (name="estilo-menus": the |
| +// bar's Menu, switcher and account, and every menubar menu) and returns |
| +// focus to its title; |
| +// - a click outside the open member closes it; |
| +// - Escape in the editable title restores the saved value and leaves |
| +// the field; |
| +// - a row or button that opens a dialog (command="show-modal") closes |
| +// the menu it sat in, returns focus to that menu's title when the |
| +// dialog closes, and opens the dialog itself on an engine without |
| +// invoker commands. |
| +// |
| +// The group is one group in every app, so closing it is estilo's job, not |
| +// each app's: Sheets' menubar lost Escape and outside-click entirely when |
| +// it left rastrillo's dropdown. |
| +// |
| +// Both listen in the capture phase. A document app has to stop key |
| +// events from propagating out of its menus — Sheets' grid library acts on |
| +// every key it hears on document, so an arrow in an open menu would walk |
| +// the cell cursor too — and a bubble-phase listener on document never |
| +// hears the Escape such an app stops. Capture hears it first. An app that |
| +// closes its own menu on Escape (Docs' menubar) finds nothing open and |
| +// does nothing, so nothing is handled twice. |
| +;(() => { |
| + const group = 'details[name="estilo-menus"][open]' |
| + const openPanel = () => document.querySelector(group) |
| + |
| + // Focus returns to a menu's title; in a menubar with a roving tab stop |
| + // (one title tabbable, the rest tabindex=-1) the stop moves with it, so |
| + // Tab then Shift+Tab comes back to the menu you were in, not to File |
| + // (Astra, estilo #11). A menubar without one is left without one. |
| + const returnTo = (title) => { |
| + title.focus() |
| + const bar = title.closest('[estilo-menubar]') |
| + if (!bar) return |
| + const titles = bar.querySelectorAll('[estilo-menubar-title]') |
| + if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return |
| + Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 }) |
| + } |
| + |
| + document.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || e.defaultPrevented) return |
| + const input = e.target.closest && e.target.closest('[estilo-bar-title-input]') |
| + if (input) { |
| + input.value = input.defaultValue |
| + input.blur() |
| + e.preventDefault() |
| + return |
| + } |
| + const open = openPanel() |
| + if (!open) return |
| + open.open = false |
| + const summary = open.querySelector('summary') |
| + if (summary) returnTo(summary) |
| + e.preventDefault() |
| + }, true) |
| + |
| + document.addEventListener('click', (e) => { |
| + const open = openPanel() |
| + if (open && !open.contains(e.target)) open.open = false |
| + }, true) |
| + |
| + const invokers = typeof HTMLButtonElement !== 'undefined' && 'command' in HTMLButtonElement.prototype |
| + document.addEventListener('click', (e) => { |
| + const button = e.target.closest && e.target.closest('button[commandfor][command="show-modal"]') |
| + if (!button || button.disabled) return |
| + const dialog = document.getElementById(button.getAttribute('commandfor')) |
| + const holder = button.closest('details[name="estilo-menus"]') |
| + if (holder) { |
| + holder.open = false |
| + const summary = holder.querySelector('summary') |
| + if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true }) |
| + } |
| + if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return |
| + dialog.showModal() |
| + e.preventDefault() |
| + }) |
| +})() |
diff --git a/internal/calendar/static/menu.css b/internal/calendar/static/menu.css| new file mode 100644 |
| index 0000000..2ddf029 |
| --- /dev/null |
| +++ b/internal/calendar/static/menu.css |
| @@ -0,0 +1,143 @@ |
| +/* menu/menu.css: the one floating panel, and the menu bar it hangs from. |
| + Every floating surface in the suite uses [estilo-menu]: the account |
| + menu, the switcher, the narrow Menu sheet and every menubar menu. |
| + A float is rounded and so is a highlight inside it; a menubar title is |
| + in the page flow, so it is square (the suite's square rule). */ |
| +:root { |
| + --estilo-float-radius: 9px; /* rastrillo's hard-coded dropdown value, named. PUBLIC */ |
| + --estilo-float-padding: 0.25rem; |
| + --estilo-item-padding-block: 0.4rem; |
| + --estilo-item-padding-inline: 0.65rem; |
| +} |
| +[estilo-menu] { |
| + box-sizing: border-box; |
| + /* Sized to its rows, not to what it hangs from. Every panel is absolutely |
| + positioned inside a <details> no wider than its title, and an absolute |
| + box's shrink-to-fit width is capped by that, so without this a row with |
| + a shortcut wrapped ("Decrease / indent") inside a menu with room to |
| + spare. Docs found it on first adoption. */ |
| + inline-size: max-content; |
| + min-inline-size: 12rem; |
| + max-inline-size: min(22rem, calc(100vw - 1rem)); |
| + /* The 8rem floor is for short frames, where the viewport maths alone can |
| + leave a panel a few pixels tall: an overflowing menu can be scrolled, |
| + an invisible one cannot. */ |
| + max-block-size: max(calc(100dvh - var(--estilo-bar-block-size, 3.25rem) - 1rem), 8rem); |
| + overflow-y: auto; |
| + overscroll-behavior: contain; |
| + padding: var(--estilo-float-padding); |
| + background: var(--rst-surface); |
| + color: var(--rst-text); |
| + border: 1px solid var(--rst-line); |
| + border-radius: var(--estilo-float-radius); |
| + box-shadow: var(--rst-shadow-pop); |
| + font-size: var(--rst-fs-sm); |
| + text-align: start; |
| + z-index: 30; |
| +} |
| +[estilo-menu~="sheet"] { |
| + position: absolute; |
| + inset-inline: 0.5rem; |
| + inset-block-start: calc(100% + 4px); |
| + inline-size: auto; |
| + max-inline-size: none; |
| +} |
| +[estilo-menu] form { margin: 0; } |
| +[estilo-menu-item] { |
| + display: flex; |
| + align-items: center; |
| + gap: 0.5rem; |
| + box-sizing: border-box; |
| + inline-size: 100%; |
| + min-block-size: 2rem; |
| + padding: var(--estilo-item-padding-block) var(--estilo-item-padding-inline); |
| + border: 0; |
| + border-radius: var(--rst-radius-sm); /* inset and rounded: inside a float */ |
| + background: none; |
| + color: var(--rst-text); |
| + font: inherit; |
| + text-align: start; |
| + text-decoration: none; |
| + cursor: pointer; |
| +} |
| +[estilo-menu-item]:hover, |
| +[estilo-menu-item]:focus-visible { background: var(--rst-accent-soft); } |
| +[estilo-menu-item]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +[estilo-menu-item][aria-disabled="true"], |
| +[estilo-menu-item]:disabled { |
| + color: var(--rst-text-faint); |
| + background: none; |
| + cursor: default; |
| +} |
| +[estilo-menu-check] { |
| + display: inline-flex; |
| + flex: none; |
| + inline-size: 1rem; |
| + block-size: 1rem; |
| + visibility: hidden; |
| + color: var(--rst-accent); |
| +} |
| +[estilo-menu-check] svg { inline-size: 100%; block-size: 100%; } |
| +[estilo-menu-item][aria-pressed="true"] [estilo-menu-check], |
| +[estilo-menu-item][aria-current] [estilo-menu-check] { visibility: visible; } |
| +[estilo-menu-label] { flex: 1 1 auto; min-inline-size: 0; } |
| +[estilo-menu-key] { |
| + flex: none; |
| + margin-inline-start: auto; |
| + padding-inline-start: 1.5rem; |
| + color: var(--rst-text-muted); |
| + font: inherit; |
| + font-size: var(--rst-fs-xs); |
| + font-variant-numeric: tabular-nums; |
| + white-space: nowrap; |
| +} |
| +[estilo-menu] hr { |
| + border: 0; |
| + border-block-start: 1px solid var(--rst-line); |
| + margin: 0.25rem 0; |
| +} |
| +[estilo-menu-heading] { |
| + margin: 0; |
| + padding: 0.5rem var(--estilo-item-padding-inline) 0.25rem; |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 600; /* no text-transform */ |
| +} |
| + |
| +/* The document menu bar: File, Edit, View... Titles are in the page flow, |
| + so their highlight is square. Every title joins the one top-level |
| + exclusivity group, name="estilo-menus", with the bar's own triggers. */ |
| +[estilo-menubar] { display: flex; flex-wrap: wrap; align-items: stretch; min-block-size: 2rem; } |
| +[estilo-menubar-menu] { position: relative; } |
| +[estilo-menubar-menu] > [estilo-menu] { |
| + position: absolute; |
| + inset-inline-start: 0; |
| + inset-block-start: calc(100% + 2px); |
| +} |
| +[estilo-menubar-title] { |
| + display: flex; |
| + align-items: center; |
| + box-sizing: border-box; |
| + block-size: 100%; |
| + min-block-size: 2rem; |
| + padding-inline: 0.6rem; |
| + border-radius: 0; /* square: in-flow */ |
| + color: var(--rst-text); |
| + font-size: var(--rst-fs-sm); |
| + white-space: nowrap; |
| + list-style: none; |
| + cursor: default; |
| + user-select: none; |
| +} |
| +[estilo-menubar-title]::-webkit-details-marker { display: none; } |
| +[estilo-menubar-title]::marker { content: ""; } |
| +[estilo-menubar-title]:hover { background: var(--rst-surface-2); } |
| +[open] > [estilo-menubar-title] { background: var(--rst-accent-soft); } |
| +[estilo-menubar-title]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +/* A panel hung from a title near the inline end overflows a phone, so |
| + below 30rem every menubar panel spans the viewport instead. */ |
| +@media (max-width: 30rem) { |
| + [estilo-menubar-menu] { position: static; } |
| + [estilo-menubar] { position: relative; } |
| + [estilo-menubar-menu] > [estilo-menu] { inset-inline: 0.5rem; inline-size: auto; min-inline-size: 0; max-inline-size: none; } |
| +} |
diff --git a/internal/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html| index 0b68ce5..f81e9b7 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,11 +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-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?date=%s" (slice .Data.Agenda.FromISO 0 7) .Data.Agenda.FromISO) "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}} |
| @@ -39,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 9510748..3184cb5 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,20 +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?date=%s" (slice .Data.Day.ISO 0 7) .Data.Day.ISO) "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> |
| - <span cal-bar-spacer></span> |
| - <a rst-btn="ghost" href="/week/{{.Data.Day.ISO}}">Week</a> |
| - <a rst-btn="ghost" href="/month/{{slice .Data.Day.ISO 0 7}}">Month</a> |
| - <a rst-btn="ghost" href="/agenda?from={{.Data.Day.ISO}}">Agenda</a> |
| -</div> |
| - |
| {{if .Data.Day.AllDay}} |
| <div cal-allday> |
| {{range .Data.Day.AllDay}}{{template "chip" .}}{{end}} |
| @@ -36,7 +29,7 @@ |
| <div cal-grid> |
| <div cal-hours> |
| - {{range .Data.Day.Hours}}<div cal-hour>{{.}}</div>{{end}} |
| + {{range .Data.Day.Hours}}<div cal-hour{{if eq . "08:00"}} cal-working-hour{{end}}>{{.}}</div>{{end}} |
| </div> |
| <div cal-column> |
| <div cal-column-rules aria-hidden="true"></div> |
diff --git a/internal/calendar/templates/errors.html b/internal/calendar/templates/errors.html| index 7fa0354..b83354b 100644 |
| --- a/internal/calendar/templates/errors.html |
| +++ b/internal/calendar/templates/errors.html |
| @@ -7,4 +7,4 @@ |
| override the partial's own catalog defaults; see |
| ui/partials/error-page.html for what it accepts. */}} |
| {{define "title"}}{{.Title}}{{end}} |
| -{{define "content"}}{{template "error-page" dict "Status" .Data.Status "Ref" .Data.Ref}}{{end}} |
| +{{define "content"}}{{template "estilo-error" .Data}}{{end}} |
diff --git a/internal/calendar/templates/event.html b/internal/calendar/templates/event.html| index 00bb875..cfdde13 100644 |
| --- a/internal/calendar/templates/event.html |
| +++ b/internal/calendar/templates/event.html |
| @@ -26,9 +26,7 @@ |
| {{end}} |
| {{$items := list |
| - (dict "Label" "When" "Value" .Data.Chip.TimeLabel) |
| - (dict "Label" "Starts" "Value" .Data.Event.StartLocal) |
| - (dict "Label" "Ends" "Value" .Data.Event.EndLocal)}} |
| + (dict "Label" "When" "Value" .Data.When)}} |
| {{template "detail-list" dict "Items" $items}} |
| {{/* The authored zone is a whisper, never the headline: the time above |
diff --git a/internal/calendar/templates/layout.html b/internal/calendar/templates/layout.html| index 9ee2e14..883fe49 100644 |
| --- a/internal/calendar/templates/layout.html |
| +++ b/internal/calendar/templates/layout.html |
| @@ -1,157 +1,64 @@ |
| {{define "layout"}}<!doctype html> |
| -{{/* data-theme is ABSENT for auto, and that absence is the mechanism, |
| - not an omission: with no attribute the browser resolves against the |
| - operating system through the theme's own `color-scheme: light dark`. |
| - The value is the light/dark SCHEME — never rastrillo's theme NAME. |
| - Writing "day" here would match neither toggle rule in theme.css, and |
| - the switcher would silently do nothing. See theme.go. */}} |
| -<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}> |
| +<html lang="{{block "lang" .}}en-GB{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{if .ThemeAttr}} data-theme="{{.ThemeAttr}}"{{end}}> |
| <head> |
| +<meta charset="utf-8"> |
| +<meta name="viewport" content="width=device-width, initial-scale=1"> |
| +<title>{{if ne .Title "Calendar"}}{{.Title}} · {{end}}Calendar</title> |
| <link rel="icon" type="image/png" sizes="32x32" href="{{asset "static/brand/icon-32.png"}}"> |
| <link rel="icon" type="image/svg+xml" href="{{asset "static/brand/icon.svg"}}"> |
| <link rel="apple-touch-icon" sizes="180x180" href="{{asset "static/brand/apple-touch-icon.png"}}"> |
| -<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| -<meta charset="utf-8"> |
| -<meta name="viewport" content="width=device-width, initial-scale=1"> |
| -<title>{{block "title" .}}Hello{{end}}</title> |
| +{{/* Onest, Calma's typeface, served by estilo (routes.go). Then the |
| + spec's order: tokens, theme, brand, estilo base, menu, chrome, and the |
| + app's own CSS last. busy.css is the 650ms busy rule; sky.css is the |
| + sign-in door's sky. */}} |
| <link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css"> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/busy.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| -{{/* estilo's page chrome (the sign-in door) and the suite's sky behind |
| - it. Both only style [estilo-*] markup. */}} |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/base.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/menu.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/chrome.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/sky.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/calendar.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| <script defer src="{{asset "static/busy.js"}}"></script> |
| +<script defer src="{{asset "static/chrome.js"}}"></script> |
| {{iconAssets}} |
| <script defer src="{{asset "static/select.js"}}"></script> |
| <script defer src="{{asset "static/datetime.js"}}"></script> |
| -{{/* Quick add's live pane. The box works without it. */}} |
| <script defer src="{{asset "static/quickadd.js"}}"></script> |
| -{{/* The booking availability pane and the public page's zone guess. |
| - Both forms work without it. */}} |
| <script defer src="{{asset "static/booking.js"}}"></script> |
| -{{/* head is the app's own slot in <head>: a favicon, a meta tag, one |
| - more stylesheet. Empty by default, last in the head so an app's |
| - own CSS wins over the framework's, and the only block in the |
| - three shells that is not chrome. */}} |
| -{{/* Last in the head, so this app's own CSS wins the ties it should |
| - against tokens.css and the theme. */}} |
| -<link rel="stylesheet" href="{{asset "static/calendar.css"}}"> |
| +<script defer src="{{asset "static/calendar.js"}}"></script> |
| {{block "head" .}}{{end}} |
| </head> |
| <body> |
| -<div rst-shell-topbar> |
| -<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a> |
| -<header rst-shell-bar> |
| -{{block "brand" .}}<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Calendar</a>{{end}} |
| -{{/* The narrow layout. Below 800px the bar's tail — nav, account, |
| - locale — hides behind this disclosure; above it, tokens.css hides |
| - the summary and gives [rst-shell-tail] display: contents, so the |
| - three blocks generate no box of their own and lay out as flex |
| - items of the bar, exactly as they did before this element existed. |
| - Laid out as direct children, not made into them: see the depth |
| - note at the foot of this comment. The sidebar collapses at the |
| - same width behind the same icon: two shells, one idiom. |
| - |
| - name="rst-shell-menu" — a group of its own, and NOT rst-menus, |
| - which is exactly the group this must not join. <details name> |
| - exclusivity is document-wide, so a disclosure in the menus' group |
| - would be closed by the account menu it just revealed: on a phone, |
| - opening the account would take the navigation away with it. Two |
| - gates hold the name (TestEveryMenuDefaultsToTheSharedExclusivityGroup |
| - reads it off the layout, and the browser drive opens the account |
| - menu inside the collapsed tail and asserts this is still open), so |
| - read this paragraph as the reason for them rather than as advice |
| - to change the attribute below. |
| - |
| - The tail is also a SIBLING rather than this element's content, |
| - which is how [rst-shell-chrome] does it: a closed <details> hides |
| - its own content, and the wide layout cannot be recovered from |
| - inside one. |
| - |
| - The three block names below are unchanged — nav, account, locale. |
| - Apps override them, and that contract is gated. What DID change is |
| - depth: nav, account and locale are now grandchildren of |
| - [rst-shell-bar] through [rst-shell-tail]. display: contents gives |
| - their boxes back above 800px; it does not give the selector back, |
| - so an app stylesheet or script written as |
| - `[rst-shell-bar] > [rst-shell-nav]` stops matching at every width. |
| - Use a descendant selector, or target [rst-shell-nav] directly. |
| - docs/site/templates.md says the same thing where an upgrader will |
| - meet it. */}} |
| -<details rst-shell-menu name="rst-shell-menu"><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details> |
| -<div rst-shell-tail> |
| -<nav rst-shell-nav>{{block "nav" .}} |
| -{{if .SignedIn}} |
| -<a href="/today">Today</a> |
| -<a href="/agenda">Agenda</a> |
| -<a href="/calendars">Calendars</a> |
| -<a href="/events/new">New event</a> |
| -{{end}} |
| -{{end}}</nav> |
| -<details rst-dropdown rst-shell-account name="rst-menus"> |
| -<summary>{{T "rastrillo.ui.shell_account"}}<span rst-caret aria-hidden="true">{{icon "chevron-down"}}</span></summary> |
| -{{/* account here is the dropdown's MENU BODY, not the whole menu: the |
| - layout owns the details/summary, so an override supplies only the |
| - links that sit inside [rst-dropdown-menu]. Sidebar's account block |
| - is the other shape — a bare slot in the rail — so moving a block |
| - between the two shells needs an edit. */}} |
| -<div rst-dropdown-menu>{{block "account" .}} |
| -{{if .SignedIn}} |
| -{{/* The address is shown here and nowhere else on any page: it is how |
| - you check which account you are looking at, and it is the one piece |
| - of personal data this chrome carries. */}} |
| -<p rst-menu-group>{{.Address}}</p> |
| -{{end}} |
| - |
| -{{/* The whole light/dark switcher, as a plain form. No script, |
| - deliberately: somebody who needs a high-contrast theme is not well |
| - served by being told to enable JavaScript first. |
| - |
| - Three buttons, not a toggle, because there are three states and the |
| - third — auto — is not the midpoint of the other two. return_to |
| - carries the current path so the choice takes effect on the page |
| - being read rather than dropping the reader at the root. |
| - |
| - aria-pressed, not colour alone, marks the current choice. */}} |
| -<form rst-menu-group method="post" action="/mode"> |
| - <input type="hidden" name="return_to" value="{{.Path}}"> |
| - <button rst-btn="ghost compact" type="submit" name="mode" value="light" aria-pressed="{{if eq .Mode "light"}}true{{else}}false{{end}}">Light</button> |
| - <button rst-btn="ghost compact" type="submit" name="mode" value="dark" aria-pressed="{{if eq .Mode "dark"}}true{{else}}false{{end}}">Dark</button> |
| - <button rst-btn="ghost compact" type="submit" name="mode" value="auto" aria-pressed="{{if eq .Mode "auto"}}true{{else}}false{{end}}">Auto</button> |
| -</form> |
| - |
| -{{if .SignedIn}} |
| -<p rst-menu-group><a href="/settings">Settings</a></p> |
| -{{if .HomeEnabled}}<p rst-menu-group><a href="/account/home">Connect Home account</a></p>{{end}} |
| -<p rst-menu-group><a href="/import">Import a calendar</a></p> |
| -<form rst-menu-group method="post" action="/signout"> |
| - <button rst-btn="ghost" type="submit">Sign out</button> |
| -</form> |
| -{{end}} |
| -{{end}}</div> |
| -</details> |
| -{{block "help" .}}{{if .SignedIn}}{{template "calendar/help" .}}{{end}}{{end}} |
| -{{block "locale" .}}{{end}} |
| -</div> |
| -</header> |
| -{{/* Two extension points, not one. Most pages define "content" and sit |
| - inside rst-page's 64rem reading column, which is right for a form |
| - or a list. |
| - |
| - A week grid is not a reading column: it is seven columns that need |
| - the viewport. Those pages override "main" and supply their own |
| - <main>, which is why this is a block rather than a bare template |
| - call. Sheets' layout does the same thing for the same reason. */}} |
| -{{block "main" .}}<main rst-page id="main"> |
| -{{template "content" .}} |
| -</main>{{end}} |
| -<footer rst-shell-foot>{{block "foot" .}}{{end}}</footer> |
| +<div estilo-shell> |
| + <a rst-skip href="#main">{{.Chrome.Labels.Skip}}</a> |
| + {{template "estilo-bar" .Chrome}} |
| + {{block "main" .}}<main rst-page id="main">{{template "content" .}}</main>{{end}} |
| + <footer rst-shell-foot>{{block "foot" .}}{{end}}</footer> |
| </div> |
| </body> |
| -</html> |
| -{{end}} |
| +</html>{{end}} |
| -{{define "calendar/help"}}<details rst-dropdown name="rst-menus"><summary>Help</summary><div rst-dropdown-menu><a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Open source</a></div></details>{{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 860cc74..d21a2e0 100644 |
| --- a/internal/calendar/templates/month.html |
| +++ b/internal/calendar/templates/month.html |
| @@ -1,6 +1,6 @@ |
| {{define "title"}}{{.Title}}{{end}} |
| {{define "main"}} |
| -<main id="main" style="padding: var(--rst-sp-4)"> |
| +<main rst-page cal-view id="main"> |
| <header rst-page-header> |
| <div rst-page-header-titles> |
| <h1>{{.Data.Month.Title}}</h1> |
| @@ -14,13 +14,7 @@ |
| </div> |
| </header> |
| -<div cal-bar> |
| - <a rst-btn href="/month/{{.Data.Month.PrevISO}}">Previous month</a> |
| - <a rst-btn href="/today">Today</a> |
| - <a rst-btn href="/month/{{.Data.Month.NextISO}}">Next month</a> |
| - <span cal-bar-spacer></span> |
| - <a rst-btn="ghost" href="/agenda">Agenda</a> |
| -</div> |
| +{{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" .Data.SelectedISO) "Week" (printf "/week/%s" .Data.SelectedISO) "Month" (printf "/month/%s?date=%s" .Data.Month.ISO .Data.SelectedISO) "Agenda" (printf "/agenda?from=%s" .Data.SelectedISO))}} |
| {{/* 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 179d806..eabef19 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 id="main" style="padding: var(--rst-sp-4)"> |
| +{{/* 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,14 +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> |
| - <span cal-bar-spacer></span> |
| - <a rst-btn="ghost" href="/day/{{.Data.TodayISO}}">Day</a> |
| - <a rst-btn="ghost" href="/agenda">Agenda</a> |
| -</div> |
| +{{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?date=%s" (slice .Data.StartISO 0 7) .Data.StartISO) "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 |
| @@ -52,7 +44,7 @@ |
| {{end}} |
| <div cal-hours> |
| - {{range (index .Data.Days 0).Hours}}<div cal-hour>{{.}}</div>{{end}} |
| + {{range (index .Data.Days 0).Hours}}<div cal-hour{{if eq . "08:00"}} cal-working-hour{{end}}>{{.}}</div>{{end}} |
| </div> |
| {{range .Data.Days}} |
| <div cal-column> |
diff --git a/internal/calendar/theme.go b/internal/calendar/theme.go| index 5d87469..7fa054b 100644 |
| --- a/internal/calendar/theme.go |
| +++ b/internal/calendar/theme.go |
| @@ -1,106 +1,16 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "net/http" |
| ) |
| -// modeCookie carries the reader's light/dark choice. A cookie, not a |
| -// row: the choice belongs to a browser rather than to an account, it |
| -// must be readable server-side before the first byte of HTML is |
| -// written, and a signed-out visitor on the sign-in page has no row to |
| -// read it from. |
| -const modeCookie = "calendar_mode" |
| - |
| -// Three states, not two. "auto" is the absence of a stored choice and |
| -// renders as the ABSENCE of the data-theme attribute — that is the |
| -// mechanism, not an omission. With no attribute the browser resolves |
| -// against the operating system through the theme's own |
| -// `color-scheme: light dark`; with one, the two rules at the foot of |
| -// theme.css re-resolve every light-dark() in the file and the explicit |
| -// choice beats the OS in both directions. |
| -// |
| -// The bug this names: data-theme takes the light/dark SCHEME, never |
| -// rastrillo's theme NAME. "day", "plain" and "signal" are palette |
| -// files picked once at scaffold time; feeding one of those to the |
| -// attribute matches neither toggle rule, so the switcher silently does |
| -// nothing. Sheets shipped exactly that and wrote a test to stop it |
| -// coming back. |
| -const ( |
| - modeAuto = "auto" |
| - modeLight = "light" |
| - modeDark = "dark" |
| -) |
| - |
| -// themeAttr maps a stored mode onto the value of <html data-theme>. |
| -// Auto — and anything unrecognised, including a hand-edited cookie — |
| -// maps to the empty string, which the layout renders as no attribute |
| -// at all. |
| -func themeAttr(mode string) string { |
| - switch mode { |
| - case modeLight: |
| - return modeLight |
| - case modeDark: |
| - return modeDark |
| - default: |
| - return "" |
| - } |
| -} |
| - |
| -// readMode returns the mode this request should render in. A missing or |
| -// unreadable cookie is auto, which is the correct answer for a first |
| -// visit and for a hostile value alike. |
| -func readMode(r *http.Request) string { |
| - c, err := r.Cookie(modeCookie) |
| - if err != nil { |
| - return modeAuto |
| - } |
| - switch c.Value { |
| - case modeLight, modeDark: |
| - return c.Value |
| - default: |
| - return modeAuto |
| - } |
| -} |
| - |
| -// mode handles POST /mode: the whole switcher, as a plain form. No |
| -// script, deliberately — somebody who needs a high-contrast theme is |
| -// not well served by being told to enable JavaScript first. |
| -// |
| -// It answers 303 back to where the person was, so the choice takes |
| -// effect on the page they were reading rather than dropping them at |
| -// the root. |
| -func (h *handlers) mode(w http.ResponseWriter, r *http.Request) { |
| - _ = r.ParseForm() |
| - mode := r.FormValue("mode") |
| - switch mode { |
| - case modeLight, modeDark: |
| - http.SetCookie(w, &http.Cookie{ |
| - Name: modeCookie, |
| - Value: mode, |
| - Path: "/", |
| - MaxAge: int(oneYearSeconds), |
| - HttpOnly: true, |
| - SameSite: http.SameSiteLaxMode, |
| - Secure: h.secure(), |
| - }) |
| - default: |
| - // Auto is stored as the absence of the cookie, so choosing it |
| - // deletes rather than writes. One representation of "no |
| - // choice", so a cookie can never disagree with its own absence. |
| - http.SetCookie(w, &http.Cookie{ |
| - Name: modeCookie, |
| - Value: "", |
| - Path: "/", |
| - MaxAge: -1, |
| - HttpOnly: true, |
| - SameSite: http.SameSiteLaxMode, |
| - Secure: h.secure(), |
| - }) |
| - } |
| - http.Redirect(w, r, safeReturn(r, "/"), http.StatusSeeOther) |
| +// appearance handles POST /appearance, the account menu's Light / Dark / |
| +// System group (shared-chrome spec §8.3). estilo validates the mode and the |
| +// return path and sets the suite's estilo_appearance cookie; the layout |
| +// writes <html data-theme> from it with chrome.ThemeAttr, so "System" is |
| +// the ABSENCE of the attribute. Calendar's own /mode, calendar_mode cookie |
| +// and "Auto" label are retired. |
| +func (h *handlers) appearance(w http.ResponseWriter, r *http.Request) { |
| + chrome.HandleAppearance(w, r) |
| } |
| - |
| -// oneYearSeconds is the mode cookie's lifetime. A display preference |
| -// with no expiry to speak of; a year is simply longer than anyone |
| -// leaves a browser profile alone. |
| -const oneYearSeconds = 365 * 24 * 60 * 60 |
diff --git a/internal/calendartest/booking_settings_test.go b/internal/calendartest/booking_settings_test.go| index 9eeec3b..5073841 100644 |
| --- a/internal/calendartest/booking_settings_test.go |
| +++ b/internal/calendartest/booking_settings_test.go |
| @@ -116,11 +116,31 @@ func TestATakenHandleIsRefusedOnSettings(t *testing.T) { |
| // works — and still works after the host changes their profile zone, |
| // because the hidden tz is the event's own (B25). |
| func TestABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T) { |
| + // Keep this scenario independent of the machine's wall clock. With |
| + // time.Now() it failed only when run between 14:00 and 15:00 UTC in |
| + // summer time, on any date (a sweep of every hour, 20-30 September, |
| + // failed at 14:17 and nowhere else): the stale-host bug the reread below |
| + // fixes. The first case is that hour, and fails without the reread; |
| + // the other two are either side of Dublin's DST boundaries. |
| + for _, now := range []struct { |
| + name string |
| + now time.Time |
| + }{ |
| + {"ci-failure-2026-09-24-1417Z", time.Date(2026, time.September, 24, 14, 17, 0, 0, time.UTC)}, |
| + {"dublin-dst-start", time.Date(2026, time.March, 29, 13, 0, 0, 0, time.UTC)}, |
| + {"dublin-dst-end", time.Date(2026, time.October, 25, 13, 0, 0, 0, time.UTC)}, |
| + } { |
| + t.Run(now.name, func(t *testing.T) { |
| + testABookedEventsFormLocksItsTimeButSavesItsTitle(t, now.now) |
| + }) |
| + } |
| +} |
| + |
| +func testABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T, now time.Time) { |
| app, host, page := hostWithPage(t) |
| h := app.Handler() |
| session := app.SignInForTest(t, host.Address) |
| st := app.StoreForTest() |
| - now := time.Now() |
| slot := slotsFor(t, app, page, host, now, 3)[0] |
| b, err := st.HoldBooking(context.Background(), holdInput(page, host, slot, "doc@tito.io", "ip"), now) |
| if err != nil { |
| @@ -144,8 +164,14 @@ func TestABookedEventsFormLocksItsTimeButSavesItsTitle(t *testing.T) { |
| t.Fatalf("title-only save: %d\n%s", rec.Code, rec.Body.String()) |
| } |
| // And a stale form with the old time after a public reschedule is refused. |
| - slots := slotsFor(t, app, page, host, now, 3) |
| - v, _ := st.BookingByID(context.Background(), b.ID, false) |
| + // Settings above changed the host's zone. Read the booking again before |
| + // selecting the new public slot, so FreeSlots and RescheduleBooking use |
| + // the same host-zone record. |
| + v, err := st.BookingByID(context.Background(), b.ID, false) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + slots := slotsFor(t, app, v.Page, v.Host, now, 3) |
| if changed, err := st.RescheduleBooking(context.Background(), b.ID, slots[3].StartUTC, slots[3].EndUTC, v.Event.Sequence, now); err != nil || !changed { |
| t.Fatalf("reschedule: changed=%v err=%v", changed, err) |
| } |
diff --git a/internal/calendartest/index_test.go b/internal/calendartest/index_test.go| index 9385699..44c822a 100644 |
| --- a/internal/calendartest/index_test.go |
| +++ b/internal/calendartest/index_test.go |
| @@ -67,10 +67,46 @@ func TestErrorPageRendersFrameworkPartial(t *testing.T) { |
| t.Fatalf("ErrorPage: status %d, want %d", rec.Code, http.StatusInternalServerError) |
| } |
| body := rec.Body.String() |
| - if !strings.Contains(body, "Something went wrong on our side") { |
| - t.Errorf("ErrorPage body missing the 500 title:\n%s", body) |
| + if !strings.Contains(body, "If it keeps happening, quote the reference below.") { |
| + t.Errorf("ErrorPage body missing the 500 body (spec §3.2):\n%s", body) |
| } |
| if !strings.Contains(body, "abc123") { |
| t.Errorf("ErrorPage body missing the ref:\n%s", body) |
| } |
| } |
| + |
| +// The error pages say what the shared-chrome spec's §3.2 table says (estilo |
| +// docs/superpowers/specs/2026-09-23-shared-chrome.md): the tab title, the |
| +// h1, and a way back that names the app. A 500 or 503 on a GET offers the |
| +// same URL again; anything else goes to Calendar. |
| +func TestErrorPagesUseTheSuiteCopy(t *testing.T) { |
| + for _, c := range []struct { |
| + status int |
| + method string |
| + title, h1, cta string |
| + ctaHref string |
| + }{ |
| + {http.StatusNotFound, http.MethodGet, "Page not found · Calendar", "We can’t find that page", "Go to Calendar", "/"}, |
| + {http.StatusForbidden, http.MethodGet, "No access · Calendar", "You don’t have access to this page", "Go to Calendar", "/"}, |
| + {http.StatusInternalServerError, http.MethodGet, "Something went wrong · Calendar", "Something went wrong", "Try again", "/week/2026-09-24?x=1"}, |
| + {http.StatusInternalServerError, http.MethodPost, "Something went wrong · Calendar", "Something went wrong", "Go to Calendar", "/"}, |
| + {http.StatusServiceUnavailable, http.MethodGet, "Calendar is unavailable · Calendar", "Calendar is unavailable right now", "Try again", "/week/2026-09-24?x=1"}, |
| + } { |
| + rec := httptest.NewRecorder() |
| + calendar.ErrorPage(rec, httptest.NewRequest(c.method, "/week/2026-09-24?x=1", nil), c.status, "") |
| + body := rec.Body.String() |
| + if rec.Code != c.status { |
| + t.Errorf("%d %s: status %d", c.status, c.method, rec.Code) |
| + } |
| + if !strings.Contains(body, "<title>"+c.title+"</title>") { |
| + t.Errorf("%d %s: no <title>%s</title>", c.status, c.method, c.title) |
| + } |
| + if !regexp.MustCompile(`<h1 rst-error-title>\s*` + regexp.QuoteMeta(c.h1) + `\s*</h1>`).MatchString(body) { |
| + t.Errorf("%d %s: no h1 %q", c.status, c.method, c.h1) |
| + } |
| + cta := regexp.MustCompile(`<a rst-btn="primary" href="([^"]*)">([^<]*)</a>`).FindStringSubmatch(body) |
| + if cta == nil || cta[2] != c.cta || strings.ReplaceAll(cta[1], "&", "&") != c.ctaHref { |
| + t.Errorf("%d %s: primary way back = %q, want %q → %s", c.status, c.method, cta, c.cta, c.ctaHref) |
| + } |
| + } |
| +} |
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/internal/calendartest/signin_door_test.go b/internal/calendartest/signin_door_test.go| index b61861c..4186933 100644 |
| --- a/internal/calendartest/signin_door_test.go |
| +++ b/internal/calendartest/signin_door_test.go |
| @@ -26,7 +26,8 @@ func TestSignInPageIsTheDoor(t *testing.T) { |
| "<span>Calendar</span>", |
| `<form rst-form estilo-signin-form method="post" action="/signin"`, |
| `name="address"`, |
| - "Email me a link", |
| + // Paul, 2026-10-01: the door's button says "Sign in". |
| + `<button rst-btn="primary" type="submit">Sign in</button>`, |
| "We email you a link. There is no password to forget.", |
| } { |
| if !strings.Contains(body, want) { |
diff --git a/internal/calendartest/vendored_test.go b/internal/calendartest/vendored_test.go| index 7c3bc22..4169ad9 100644 |
| --- a/internal/calendartest/vendored_test.go |
| +++ b/internal/calendartest/vendored_test.go |
| @@ -1,8 +1,10 @@ |
| package calendartest |
| import ( |
| + "amadan.net/oficina/estilo.git/base" |
| estilobusy "amadan.net/oficina/estilo.git/busy" |
| "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| "bytes" |
| @@ -120,3 +122,24 @@ func TestDoorFilesAreEstilos(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// The shared chrome's other files (estilo spec 2026-09-23 §1): base.css |
| +// (the link rule), menu.css (every floating panel) and chrome.js (Esc and |
| +// outside-click close). The layout links them in the spec's order after |
| +// theme.css. Pinned like the door's; after an estilo upgrade: |
| +// go run ./scripts/writeestilo. |
| +func TestSharedChromeFilesAreEstilos(t *testing.T) { |
| + for name, want := range map[string][]byte{ |
| + "base.css": base.CSS(), |
| + "menu.css": menu.CSS(), |
| + "chrome.js": chrome.JS(), |
| + } { |
| + got, err := os.ReadFile(filepath.Join("..", "calendar", "static", name)) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if !bytes.Equal(got, want) { |
| + t.Errorf("static/%s differs from estilo's; run go run ./scripts/writeestilo", name) |
| + } |
| + } |
| +} |
diff --git a/internal/calendartest/view_tabs_test.go b/internal/calendartest/view_tabs_test.go| new file mode 100644 |
| index 0000000..4530574 |
| --- /dev/null |
| +++ b/internal/calendartest/view_tabs_test.go |
| @@ -0,0 +1,42 @@ |
| +package calendartest |
| + |
| +import ( |
| + "regexp" |
| + "testing" |
| +) |
| + |
| +// The view switcher keeps the date you were looking at, through Month too. |
| +// A month URL names only the month, so the switcher carries the day as |
| +// ?date= and Month's Day and Week tabs go back to it, not to the 1st. |
| +func TestViewTabsKeepTheSelectedDateThroughMonth(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + tab := func(path, label string) string { |
| + t.Helper() |
| + rec := getAs(t, h, session, path) |
| + if rec.Code != 200 { |
| + t.Fatalf("GET %s: %d", path, rec.Code) |
| + } |
| + m := regexp.MustCompile(`<a cal-tab="` + label + `" href="([^"]*)"`).FindStringSubmatch(rec.Body.String()) |
| + if m == nil { |
| + t.Fatalf("GET %s: no %s tab", path, label) |
| + } |
| + return m[1] |
| + } |
| + for _, c := range []struct{ path, label, want string }{ |
| + {"/day/2026-09-24", "Month", "/month/2026-09?date=2026-09-24"}, |
| + {"/week/2026-09-24", "Month", "/month/2026-09?date=2026-09-21"}, |
| + {"/agenda?from=2026-09-24", "Month", "/month/2026-09?date=2026-09-24"}, |
| + {"/month/2026-09?date=2026-09-24", "Day", "/day/2026-09-24"}, |
| + {"/month/2026-09?date=2026-09-24", "Week", "/week/2026-09-24"}, |
| + {"/month/2026-09?date=2026-09-24", "Agenda", "/agenda?from=2026-09-24"}, |
| + // A date outside the month, or none, falls back to the 1st (this |
| + // month is not the current one, where it would be today). |
| + {"/month/2026-09?date=2026-10-02", "Day", "/day/2026-09-01"}, |
| + {"/month/2020-02", "Week", "/week/2020-02-01"}, |
| + } { |
| + if got := tab(c.path, c.label); got != c.want { |
| + t.Errorf("%s: %s tab = %s, want %s", c.path, c.label, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go| index a12f7f5..e5520ec 100644 |
| --- a/scripts/writeestilo/main.go |
| +++ b/scripts/writeestilo/main.go |
| @@ -2,15 +2,17 @@ |
| // the estilo this module pins: theme.css (Calma, with Calendar's accent), |
| // and rastrillo's busy rule as estilo carries it — rastrillo.js without |
| // the old rule, busy.js and busy.css — and the sign-in door's chrome.css |
| -// and sky.css. Run it from the repository root |
| +// and sky.css, and the shared chrome's base.css, menu.css and chrome.js. Run it from the repository root |
| // after upgrading estilo; the pin tests in internal/calendartest fail until you do. |
| package main |
| import ( |
| "os" |
| + "amadan.net/oficina/estilo.git/base" |
| "amadan.net/oficina/estilo.git/busy" |
| "amadan.net/oficina/estilo.git/chrome" |
| + "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/sky" |
| "amadan.net/oficina/estilo.git/theme" |
| ) |
| @@ -27,6 +29,9 @@ func main() { |
| "busy.css": busy.CSS(), |
| "chrome.css": chrome.CSS(), |
| "sky.css": sky.CSS(), |
| + "base.css": base.CSS(), |
| + "menu.css": menu.CSS(), |
| + "chrome.js": chrome.JS(), |
| } { |
| if err := os.WriteFile("internal/calendar/static/"+name, body, 0o644); err != nil { |
| panic(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); |
| +} |