Merge origin/main (shared chrome, Home consumer) into chore/door-copy
Main's layout now appends " · Calendar" to the title, so the door's
handlers pass .Page() rather than .Title(), and the tab titles stay
"Sign in · Calendar", "Check your email · Calendar" and
"Signed out · Calendar". Main's standalone /signed-out (signedOut) now
draws the door's SignedOut notice ("You’re signed out", "You’ve signed
out of Calendar on this device."), so its test asserts the door's words.
home.go keeps main's a.h.errorPage; signin.go keeps the door's copy, not
main's app-owned Heading/Lede/Help/Submit. Re-pins estilo to main
0b4d92c (feat/door-copy merged; same tree as fbd14e7).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
45 files changed,
+1329
−404
go.mod+2 −2go.sum+4 −4internal/calendar/app.go+19 −1internal/calendar/chrome_calendar.go+53 −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+36 −2internal/calendar/home_consumer_test.go+117 −0internal/calendar/home_router_test.go+28 −1internal/calendar/person.go+5 −5internal/calendar/render.go+111 −35internal/calendar/routes.go+5 −3internal/calendar/signin.go+31 −2internal/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/signed_out_test.go+46 −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 ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 === fmt === === test === go test ./... ? amadan.net/oficina/calendar/cmd/calendar [no test files] ok amadan.net/oficina/calendar/internal/calendar 4.575s ? 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.239s ok amadan.net/oficina/calendar/internal/guardstest 0.042s ? 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 1344ca2..e95c8ff 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 |
| @@ -14,7 +14,7 @@ require ( |
| gorm.io/gorm v1.31.2 |
| ) |
| -require amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 |
| +require amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 |
| require ( |
| github.com/BurntSushi/toml v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index b5f639e..584fdf4 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027 h1:+NFDWW5ROkLaFIsT9flDuAjNeQJJxj0Safk32UcRyn0= |
| -amadan.net/oficina/estilo.git v0.0.0-20261001075355-fbd14e759027/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/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +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..0216ccd |
| --- /dev/null |
| +++ b/internal/calendar/chrome_calendar.go |
| @@ -0,0 +1,53 @@ |
| +package calendar |
| + |
| +import ( |
| + "html/template" |
| + "net/http" |
| + "strings" |
| + |
| + "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: strings.TrimSuffix(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 70cb27c..4f9b8d7 100644 |
| --- a/internal/calendar/home.go |
| +++ b/internal/calendar/home.go |
| @@ -62,11 +62,11 @@ func (a *App) newHome(settings *connect.DecodedCredentials, list allowlist) (*ho |
| tree, title, view := pages["home"], data.Title, any(data) |
| if state == "signedout" { |
| notice := signedOutDoor(r, data.Action) |
| - tree, title, view = pages["signin"], notice.Title(), signinData{SignedOut: ¬ice} |
| + tree, title, view = pages["signin"], notice.Page(), signinData{SignedOut: ¬ice} |
| } |
| var buf bytes.Buffer |
| if err := tree.ExecuteTemplate(&buf, "layout", a.h.page(r, title, view)); err != nil { |
| - ErrorPage(w, r, http.StatusInternalServerError, "") |
| + a.h.errorPage(w, r, http.StatusInternalServerError, "") |
| return |
| } |
| w.Header().Set("Cache-Control", "no-store") |
| @@ -101,3 +101,37 @@ func (a *App) homePerson(r *http.Request) (Person, error) { |
| } |
| return person, nil |
| } |
| + |
| +// homeSignin is GET /signin with Home on. connect's Signin shortcuts a |
| +// signed-in person straight to where they were headed; but a session whose |
| +// person is no longer admitted (removed from the team, or a Home link to a |
| +// deleted person) is not signed in for Calendar, and the shortcut would end |
| +// on requirePerson's 404 with nowhere to go. So /signin first asks the same |
| +// question requirePerson does. Not admitted: the session ends here and |
| +// /signin is asked again without it, which starts a fresh exchange (as |
| +// sheets' homeRequire and docs' admission check do). A storage failure is |
| +// a 503, never a sign-out. |
| +func (a *App) homeSignin(w http.ResponseWriter, r *http.Request) { |
| + s, ok := a.home.sessions.From(r) |
| + if ok && (s.Method == connect.HomeMethod || s.Method == "magiclink" || s.Method == "keymail") { |
| + admitted := false |
| + if s.Method == connect.HomeMethod { |
| + _, err := a.homePerson(r) |
| + if errors.Is(err, linkstore.ErrStorage) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + a.h.errorPage(w, r, http.StatusServiceUnavailable, "") |
| + return |
| + } |
| + admitted = err == nil |
| + } else { |
| + admitted = a.home.admitted.allows(normalizeAddress(s.Subject)) |
| + } |
| + if !admitted { |
| + a.home.sessions.SignOut(w, r) |
| + w.Header().Set("Cache-Control", "no-store") |
| + http.Redirect(w, r, r.URL.RequestURI(), http.StatusSeeOther) |
| + return |
| + } |
| + } |
| + a.home.routes.Signin(w, r) |
| +} |
diff --git a/internal/calendar/home_consumer_test.go b/internal/calendar/home_consumer_test.go| new file mode 100644 |
| index 0000000..ced5f50 |
| --- /dev/null |
| +++ b/internal/calendar/home_consumer_test.go |
| @@ -0,0 +1,117 @@ |
| +package calendar |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "encoding/json" |
| + "io" |
| + "log/slog" |
| + "net/http" |
| + "path/filepath" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/home/connect" |
| + assertioncrypto "amadan.net/rastrillo/rastrillo/crypto" |
| +) |
| + |
| +const homeTestDirectory = "https://directory.example.test" |
| + |
| +// newHomeConsumerFixture configures Home the way production does: from the |
| +// environment, through connect.CredentialsFromEnv, so HOME_DIRECTORY_ORIGIN |
| +// (unprefixed, the name Home itself reads) is read exactly as it is live. |
| +func newHomeConsumerFixture(t *testing.T, directory string) *homeRouterFixture { |
| + t.Helper() |
| + key, err := assertioncrypto.Generate() |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + manifest, _ := json.Marshal(map[string]string{"key": base64.StdEncoding.EncodeToString(key.SignPub())}) |
| + t.Setenv("CALENDAR_HOME_ISSUER", homeTestIssuer) |
| + t.Setenv("CALENDAR_HOME_CONSUMER_ID", "calendar-test") |
| + t.Setenv("CALENDAR_HOME_KEYS_BASE64", base64.StdEncoding.EncodeToString(manifest)) |
| + t.Setenv("CALENDAR_HOME_DEK_BASE64", base64.StdEncoding.EncodeToString(bytes.Repeat([]byte{7}, 32))) |
| + t.Setenv("HOME_DIRECTORY_ORIGIN", directory) |
| + a, err := New(Config{DBPath: filepath.Join(t.TempDir(), "calendar.db"), Env: "development", Origin: homeTestOrigin, AllowedAddresses: "alice@example.test", Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Home: connect.CredentialsFromEnv("CALENDAR")}) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + t.Cleanup(func() { a.Close() }) |
| + return &homeRouterFixture{a, key, map[string]*http.Cookie{}} |
| +} |
| + |
| +// Sign out in Calendar ends the id.* session too: the app session ends and |
| +// the same POST is handed on (307) to {issuer}/signout, which returns the |
| +// browser to /signed-out. Without it the next "Sign in" passed silently |
| +// through the live id.* session (spec shared-chrome §9.5). |
| +func TestHomeSignoutChainsToIssuer(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + w := f.do("POST", "/signout", "") |
| + if w.Code != http.StatusTemporaryRedirect || w.Header().Get("Location") != homeTestIssuer+"/signout" { |
| + t.Fatalf("POST /signout: status %d Location %q, want 307 to %s/signout", w.Code, w.Header().Get("Location"), homeTestIssuer) |
| + } |
| + if got := w.Header().Get("Referrer-Policy"); got != "strict-origin-when-cross-origin" { |
| + t.Fatalf("Referrer-Policy %q: id.* admits the null-Origin hop by Referer", got) |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("sign-out kept the Calendar session cookie") |
| + } |
| +} |
| + |
| +// A signed-in person who follows a "Sign in" link goes straight to where |
| +// they were headed, with no second exchange. |
| +func TestHomeSigninWhenSignedInReturnsToPage(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")); got != "/day/2026-09-10" { |
| + t.Fatalf("signed-in /signin went to %q, want /day/2026-09-10", got) |
| + } |
| +} |
| + |
| +// "All apps" is the estilo switcher's item (shared-chrome spec §6), linking |
| +// to the hub directory's root: HOME_DIRECTORY_ORIGIN (an origin, which |
| +// connect refuses with a path or a trailing slash) plus "/". No directory, |
| +// no switcher. |
| +func TestAllAppsLinksToDirectory(t *testing.T) { |
| + item := regexp.MustCompile(`<details estilo-switcher[\s\S]*?<a estilo-menu-item href="([^"]*)"><span estilo-menu-label>All apps</span></a>`) |
| + for _, configured := range []string{homeTestDirectory} { |
| + f := newHomeConsumerFixture(t, configured) |
| + f.local(t) |
| + m := item.FindStringSubmatch(f.do("GET", "/day/2026-09-10", "").Body.String()) |
| + if m == nil { |
| + t.Fatalf("HOME_DIRECTORY_ORIGIN=%s: no All apps item in the estilo switcher", configured) |
| + } |
| + if m[1] != homeTestDirectory+"/" { |
| + t.Errorf("HOME_DIRECTORY_ORIGIN=%s: All apps links to %q, want %q", configured, m[1], homeTestDirectory+"/") |
| + } |
| + } |
| + |
| + bare := newHomeConsumerFixture(t, "") |
| + bare.local(t) |
| + if body := bare.do("GET", "/day/2026-09-10", "").Body.String(); strings.Contains(body, "All apps") || strings.Contains(body, "estilo-switcher") { |
| + t.Fatal("switcher shown with no directory configured") |
| + } |
| +} |
| + |
| +// A session whose person is no longer admitted is not "already signed in": |
| +// /signin ends it and starts over, rather than shortcutting to the page |
| +// (which requirePerson answers with a terminating 404). Astra P3 on #23; |
| +// the same check docs' admitted and sheets' homeRequire make. |
| +func TestSigninDoesNotShortcutARevokedPerson(t *testing.T) { |
| + f := newHomeConsumerFixture(t, "") |
| + f.local(t) |
| + delete(f.app.home.admitted.addrs, "alice@example.test") |
| + w := f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "") |
| + if w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/day/2026-09-10" { |
| + t.Fatal("/signin shortcut a removed member to the page, which 404s") |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("/signin kept the removed member's session") |
| + } |
| + // Asked again with no session, it starts a fresh exchange. |
| + if got := homeRedirect(t, f.do("GET", "/signin?return_to=%2Fday%2F2026-09-10", "")); !strings.HasPrefix(got, "/auth/start") { |
| + t.Fatalf("second /signin went to %q, want the exchange", got) |
| + } |
| +} |
diff --git a/internal/calendar/home_router_test.go b/internal/calendar/home_router_test.go| index a18ab48..acf56b9 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 { |
| @@ -200,6 +219,14 @@ func TestHomeRouterRetainsExistingCalendarIdentity(t *testing.T) { |
| if w := f.do("GET", "/", ""); w.Code != 404 { |
| t.Fatal("Home attached replacement", w.Code) |
| } |
| + // /signin does not shortcut this session to "/", which would 404 with |
| + // nowhere to go: it ends the session and starts over (Astra P3, #23). |
| + if w := f.do("GET", "/signin", ""); w.Code == http.StatusSeeOther && w.Header().Get("Location") == "/" { |
| + t.Fatal("/signin shortcut a deleted Home person to /, which 404s") |
| + } |
| + if f.cookies[f.app.home.sessions.CookieName()] != nil { |
| + t.Fatal("/signin kept a deleted Home person's session") |
| + } |
| } |
| func TestHomeRouterUnknownSubjectCannotCreateAccount(t *testing.T) { |
| f := newHomeRouterFixture(t) |
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..a5162ee 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -39,10 +39,11 @@ 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 { |
| - r.Get("/signin", a.home.routes.Signin) |
| + r.Get("/signin", a.homeSignin) |
| r.Get("/signin/local", h.signin) |
| r.Get("/signed-out", a.home.routes.SignedOut) |
| r.Get(connect.LocalCompletePath, a.home.routes.LocalComplete) |
| @@ -55,6 +56,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.With(a.home.sessions.Require).Post("/account/home/confirm", a.home.routes.ConfirmPost) |
| } else { |
| r.Get("/signin", h.signin) |
| + r.Get("/signed-out", h.signedOut) |
| } |
| // One counter for the life of the router, so the log line can say |
| // how many refusals this boot has seen rather than only that one |
| @@ -75,13 +77,13 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Post("/signin", begin) |
| r.Get("/auth/callback", authPlugin.Callback) |
| r.Get("/auth/verify", authPlugin.Verify) |
| - r.Post("/signout", authPlugin.Signout) |
| + r.Post("/signout", signOutToSignedOut(authPlugin.Signout)) |
| } |
| // 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 fc115ff..5bee915 100644 |
| --- a/internal/calendar/signin.go |
| +++ b/internal/calendar/signin.go |
| @@ -53,7 +53,7 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| switch { |
| case sent && !failed: |
| notice := chrome.SignInNotice{Icon: form.Icon, App: form.App, Sky: form.Sky}.Sent(r.URL.Path) |
| - render(w, "signin", h.page(r, notice.Title(), signinData{Sent: ¬ice})) |
| + render(w, "signin", h.page(r, notice.Page(), signinData{Sent: ¬ice})) |
| case sent: |
| // Somebody who asks for a second link after a failed one should |
| // see both facts, not whichever the page happened to check first: |
| @@ -62,7 +62,7 @@ func (h *handlers) signin(w http.ResponseWriter, r *http.Request) { |
| form.Lede = chrome.English.SentBody |
| fallthrough |
| default: |
| - render(w, "signin", h.page(r, form.Title(), signinData{Form: form})) |
| + render(w, "signin", h.page(r, form.Page(), signinData{Form: form})) |
| } |
| } |
| @@ -84,3 +84,32 @@ func signedOutDoor(r *http.Request, signin string) chrome.SignInNotice { |
| Sky: sky.For(r.Host), |
| }.SignedOut(signin) |
| } |
| + |
| +// signedOut serves GET /signed-out without Home: the same estilo door Home |
| +// mode's sign-out lands on, in the door's words, so signing out is a |
| +// page that says so rather than the sign-in form again (or, before, a |
| +// 404 for anyone who reached the address). |
| +func (h *handlers) signedOut(w http.ResponseWriter, r *http.Request) { |
| + w.Header().Set("Cache-Control", "no-store") |
| + notice := signedOutDoor(r, "/signin") |
| + render(w, "signin", h.page(r, notice.Page(), signinData{SignedOut: ¬ice})) |
| +} |
| + |
| +// signOutToSignedOut is rastrillo's standalone Sign out with one change: |
| +// where it lands. auth.Signout revokes the session and answers 303 to its |
| +// SigninPath, which every other auth outcome also uses, so the path cannot |
| +// change; this retargets that one redirect to /signed-out. |
| +func signOutToSignedOut(signout http.HandlerFunc) http.HandlerFunc { |
| + return func(w http.ResponseWriter, r *http.Request) { |
| + signout(&signedOutRedirect{ResponseWriter: w}, r) |
| + } |
| +} |
| + |
| +type signedOutRedirect struct{ http.ResponseWriter } |
| + |
| +func (s *signedOutRedirect) WriteHeader(code int) { |
| + if code == http.StatusSeeOther && s.Header().Get("Location") == "/signin" { |
| + s.Header().Set("Location", "/signed-out") |
| + } |
| + s.ResponseWriter.WriteHeader(code) |
| +} |
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/signed_out_test.go b/internal/calendartest/signed_out_test.go| new file mode 100644 |
| index 0000000..6e95ce0 |
| --- /dev/null |
| +++ b/internal/calendartest/signed_out_test.go |
| @@ -0,0 +1,46 @@ |
| +package calendartest |
| + |
| +import ( |
| + "net/http" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// Standalone (no Home), signing out lands on a real page: the estilo |
| +// signed-out door at /signed-out, as Home mode's sign-out does, rather than |
| +// back on the sign-in form as if nothing happened. /signed-out used to 404 |
| +// without Home (Fable 5.1 follow-up finding 4 on #17). |
| +func TestStandaloneSignOutLandsOnTheSignedOutPage(t *testing.T) { |
| + app, session := signedIn(t, "someone@example.com") |
| + h := app.Handler() |
| + |
| + rec := postAs(t, h, session, "/signout", nil) |
| + if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" { |
| + t.Fatalf("POST /signout: %d to %q, want 303 to /signed-out", rec.Code, rec.Header().Get("Location")) |
| + } |
| + if after := getAs(t, h, session, "/day/2026-09-24"); after.Code != http.StatusSeeOther { |
| + t.Fatalf("the session survived sign-out: GET a view answered %d", after.Code) |
| + } |
| + |
| + page := get(t, h, "/signed-out") |
| + body := page.Body.String() |
| + if page.Code != http.StatusOK || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") { |
| + t.Fatalf("GET /signed-out: %d %s", page.Code, page.Header().Get("Content-Type")) |
| + } |
| + for _, want := range []string{ |
| + "<title>Signed out · Calendar</title>", |
| + "estilo-door", |
| + // The door's words (estilo chrome.English), not the app's. |
| + "<h1>You’re signed out</h1>", |
| + "You’ve signed out of Calendar on this device.", |
| + `<a rst-btn="primary" href="/signin">Sign in</a>`, |
| + } { |
| + if !strings.Contains(body, want) { |
| + t.Errorf("/signed-out is missing %q:\n%s", want, body) |
| + } |
| + } |
| + // The page that IS signing out shows nothing in the bar's corner. |
| + if strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") { |
| + t.Error("/signed-out drew a Sign in button or an account menu in the bar") |
| + } |
| +} |
diff --git a/internal/calendartest/signin_door_test.go b/internal/calendartest/signin_door_test.go| index ccaac45..93e984d 100644 |
| --- a/internal/calendartest/signin_door_test.go |
| +++ b/internal/calendartest/signin_door_test.go |
| @@ -27,7 +27,8 @@ func TestSignInPageIsTheDoor(t *testing.T) { |
| `<form rst-form estilo-signin-form method="post" action="/signin"`, |
| `name="address"`, |
| "<title>Sign in · Calendar</title>", |
| - `type="submit">Sign in</button>`, |
| + // Paul, 2026-10-01: the door's button says "Sign in". |
| + `<button rst-btn="primary" type="submit">Sign in</button>`, |
| "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| } { |
| 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); |
| +} |