oficina / calendar Public

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>
Paul Campbell pushed by paul@keymail.dev 7d137bf3d2ee13b56fad66fc7668c668abd1193c parent 01c34a3parent b3ada57
45 files changed, +1329 −404
  • go.mod +2 −2
  • go.sum +4 −4
  • internal/calendar/app.go +19 −1
  • internal/calendar/chrome_calendar.go +53 −0
  • internal/calendar/credits.go +3 −1
  • internal/calendar/credits_test.go +5 −2
  • internal/calendar/handlers.go +4 −33
  • internal/calendar/handlers_booking_form.go +2 −2
  • internal/calendar/handlers_booking_settings.go +1 −1
  • internal/calendar/handlers_event_delete.go +5 −5
  • internal/calendar/handlers_event_form.go +26 −0
  • internal/calendar/handlers_event_form_test.go +12 −0
  • internal/calendar/handlers_events.go +10 −9
  • internal/calendar/handlers_misc.go +3 −3
  • internal/calendar/handlers_quick.go +1 −1
  • internal/calendar/handlers_views.go +18 −5
  • internal/calendar/home.go +36 −2
  • internal/calendar/home_consumer_test.go +117 −0
  • internal/calendar/home_router_test.go +28 −1
  • internal/calendar/person.go +5 −5
  • internal/calendar/render.go +111 −35
  • internal/calendar/routes.go +5 −3
  • internal/calendar/signin.go +31 −2
  • internal/calendar/static/base.css +15 −0
  • internal/calendar/static/calendar.css +78 −4
  • internal/calendar/static/calendar.js +14 −0
  • internal/calendar/static/chrome.js +82 −0
  • internal/calendar/static/menu.css +143 −0
  • internal/calendar/templates/agenda.html +8 −7
  • internal/calendar/templates/day.html +6 −13
  • internal/calendar/templates/errors.html +1 −1
  • internal/calendar/templates/event.html +1 −3
  • internal/calendar/templates/layout.html +40 −133
  • internal/calendar/templates/month.html +2 −8
  • internal/calendar/templates/week.html +4 −12
  • internal/calendar/theme.go +9 −99
  • internal/calendartest/booking_settings_test.go +29 −3
  • internal/calendartest/index_test.go +38 −2
  • internal/calendartest/playwright_views_test.go +104 −0
  • internal/calendartest/signed_out_test.go +46 −0
  • internal/calendartest/signin_door_test.go +2 −1
  • internal/calendartest/vendored_test.go +23 −0
  • internal/calendartest/view_tabs_test.go +42 −0
  • scripts/writeestilo/main.go +6 −1
  • test/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: &notice}
+ tree, title, view = pages["signin"], notice.Page(), signinData{SignedOut: &notice}
}
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: &notice}))
+ render(w, "signin", h.page(r, notice.Page(), signinData{Sent: &notice}))
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: &notice}))
+}
+
+// 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], "&amp;", "&") != 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);
+}