Adopt estilo shared chrome in Calendar
15 files changed,
+216
−182
go.mod+2 −2go.sum+4 −4internal/calendar/handlers_event_form.go+26 −0internal/calendar/handlers_event_form_test.go+12 −0internal/calendar/handlers_views.go+1 −1internal/calendar/render.go+115 −27internal/calendar/routes.go+2 −1internal/calendar/static/calendar.css+4 −2internal/calendar/static/calendar.js+10 −0internal/calendar/templates/agenda.html+1 −0internal/calendar/templates/day.html+2 −5internal/calendar/templates/layout.html+23 −131internal/calendar/templates/month.html+3 −4internal/calendar/templates/week.html+3 −5internal/calendar/theme.go+8 −0
CI failed — run details
CI log
=== vet ===
go vet ./...
go: downloading amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
go: downloading amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9
=== fmt ===
=== test ===
go test ./...
? amadan.net/oficina/calendar/cmd/calendar [no test files]
--- FAIL: TestHomeRouterRetainsExistingCalendarIdentity (0.02s)
home_router_test.go:148: redirect status 307:
FAIL
FAIL amadan.net/oficina/calendar/internal/calendar 4.504s
? amadan.net/oficina/calendar/internal/calendar/icons [no test files]
? amadan.net/oficina/calendar/internal/calendar/seal [no test files]
--- FAIL: TestABookedEventsFormLocksItsTimeButSavesItsTitle (0.02s)
booking_settings_test.go:150: reschedule: changed=false err=calendar: slot taken
2026/09/24 14:26:36 INFO migrate applied="[sessions/0001_init auth/0001_init auth/0002_sessions_backfill calendar/0001_create_people calendar/0002_alter_people calendar/0003_alter_reminders calendar/0004_create_attendees calendar/0005_alter_people calendar/0006_alter_reminder_queues]" skipped=0 adopted=false
2026/09/24 14:26:36 ERROR calendar: scheduled work registration failed stage=schedule.register err="calendar: this platform does not support safe scheduled work; update the platform before starting Calendar"
FAIL
FAIL amadan.net/oficina/calendar/internal/calendartest 1.979s
--- FAIL: TestOurCSSDoesNotClaimAnotherPrefix (0.00s)
guards_test.go:226: calendar.css defines a selector for another namespace: "[rst-" — this app's prefix is cal-
FAIL
FAIL amadan.net/oficina/calendar/internal/guardstest 0.036s
FAIL
make: *** [Makefile:85: test] Error 1
amadan: step test failed: exit status 2
amadan: job failed at step test — its output is above, not at the tail of this log
diff --git a/go.mod b/go.mod| index a019e36..2170f17 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-20260911182719-0bcb9287988d |
| +require amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9 |
| require ( |
| github.com/BurntSushi/toml v1.6.0 // indirect |
diff --git a/go.sum b/go.sum| index 24f044d..9964860 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,7 +1,7 @@ |
| -amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d h1:3HsB96tGs+P0tGf2VzqpqA8yqYBhJ+apBh2txAoz9qI= |
| -amadan.net/oficina/estilo.git v0.0.0-20260911182719-0bcb9287988d/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY= |
| -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-20260924071126-894afdcb71a9 h1:vHOkWeJ7mRwLjPbMihf0f7aqz4CLSe0/NseQsHcJUYQ= |
| +amadan.net/oficina/estilo.git v0.0.0-20260924071126-894afdcb71a9/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/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_views.go b/internal/calendar/handlers_views.go| index 7268fb8..671c5cc 100644 |
| --- a/internal/calendar/handlers_views.go |
| +++ b/internal/calendar/handlers_views.go |
| @@ -158,7 +158,7 @@ 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})) |
| + render(w, "month", h.page(r, page.Title, map[string]any{"Month": page, "TodayMonth": time.Now().In(month.Location()).Format("2006-01")})) |
| } |
| // agenda lists the next fortnight. |
diff --git a/internal/calendar/render.go b/internal/calendar/render.go| index 13482fe..75895b1 100644 |
| --- a/internal/calendar/render.go |
| +++ b/internal/calendar/render.go |
| @@ -6,8 +6,13 @@ import ( |
| "bytes" |
| "embed" |
| "html/template" |
| + "io/fs" |
| "net/http" |
| + "testing/fstest" |
| + "amadan.net/oficina/estilo.git/base" |
| + "amadan.net/oficina/estilo.git/chrome" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/rastrillo/ui" |
| @@ -26,7 +31,33 @@ var appFS embed.FS |
| // assets fingerprints static/: {{asset "static/tokens.css"}} in a |
| // template returns a content-hashed URL, served immutable-cacheable by |
| // the handler New mounts. |
| -var assets = rastrillo.NewAssets(appFS) |
| +var assets = rastrillo.NewAssets(staticFiles()) |
| + |
| +// staticFiles adds estilo's versioned shared chrome to Calendar's asset |
| +// tree. Keeping the bytes behind the normal asset handler fingerprints them |
| +// with the rest of the page, rather than making the browser cache a stale |
| +// suite bar across an adoption. |
| +func staticFiles() fs.FS { |
| + files := fstest.MapFS{} |
| + if err := fs.WalkDir(appFS, "static", func(name string, entry fs.DirEntry, err error) error { |
| + if err != nil || entry.IsDir() { |
| + return err |
| + } |
| + data, err := appFS.ReadFile(name) |
| + if err != nil { |
| + return err |
| + } |
| + files[name] = &fstest.MapFile{Data: data} |
| + return nil |
| + }); err != nil { |
| + panic(err) |
| + } |
| + files["static/estilo/base.css"] = &fstest.MapFile{Data: base.CSS()} |
| + files["static/estilo/menu.css"] = &fstest.MapFile{Data: estilomenu.CSS()} |
| + files["static/estilo/chrome.css"] = &fstest.MapFile{Data: chrome.CSS()} |
| + files["static/estilo/chrome.js"] = &fstest.MapFile{Data: chrome.JS()} |
| + return files |
| +} |
| // pageNames is every page this app renders. Kept as a list rather than |
| // discovered from the filesystem so a template that is never rendered |
| @@ -47,26 +78,23 @@ 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")) |
| + t = template.Must(t.ParseFS(estilomenu.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")) |
| } |
| } |
| @@ -112,6 +140,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 |
| @@ -135,12 +167,12 @@ 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, |
| @@ -149,9 +181,43 @@ func (h *handlers) page(r *http.Request, title string, data any) PageData { |
| pd.SignedIn = true |
| pd.Address = id.Address |
| } |
| + pd.Chrome = calendarChrome(r, pd.SignedIn, pd.Address, mode, title) |
| + 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 |
| } |
| +func calendarChrome(r *http.Request, signedIn bool, address string, mode chrome.Mode, title 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) |
| + } |
| + bar := chrome.Bar{ |
| + App: chrome.App{Name: "Calendar", Slug: "calendar", Home: "/", Icon: assets.Path("static/brand/icon.svg")}, |
| + Sticky: true, Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| + NavLabel: "Calendar", Nav: []chrome.Link{ |
| + {Href: "/today", Label: "Today", Current: current("/today") || current("/day/") || current("/week/") || current("/month/")}, |
| + {Href: "/agenda", Label: "Agenda", Current: current("/agenda")}, |
| + {Href: "/calendars", Label: "Calendars", Current: current("/calendars")}, |
| + }, |
| + Actions: []chrome.Action{{Item: estilomenu.Item{Href: "/events/new", Label: "New event"}, Primary: true}}, |
| + } |
| + if !signedIn { |
| + bar.Nav, bar.Actions = nil, nil |
| + bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + template.URLQueryEscaper(r.URL.RequestURI()), Label: "Sign in"} |
| + return bar |
| + } |
| + bar.Account = &chrome.Account{ |
| + TriggerLabel: "Account: " + address, Initials: chrome.Initials("", address), Address: address, |
| + Labels: chrome.AccountLabels{SignedInAs: "Signed in as", 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 |
| +} |
| + |
| // render executes a page into a buffer before anything touches the |
| // wire: a template error becomes a clean 500 instead of garbage |
| // appended to a half-written page, and headers stay settable until the |
| @@ -176,13 +242,35 @@ 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) |
| + mode := chrome.Appearance(r) |
| w.WriteHeader(status) |
| render(w, "errors", PageData{ |
| Title: "Something went wrong", |
| - ThemeAttr: themeAttr(mode), |
| - Mode: mode, |
| + ThemeAttr: chrome.ThemeAttr(mode), |
| + Mode: string(mode), |
| Path: r.URL.Path, |
| + Chrome: calendarChrome(r, false, "", mode, "Something went wrong"), |
| Data: map[string]any{"Status": status, "Ref": ref}, |
| }) |
| } |
| + |
| +// 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) |
| + render(w, "errors", h.page(r, "Page not found", map[string]any{"Status": http.StatusNotFound})) |
| +} |
| + |
| +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 21187e8..5a4fa20 100644 |
| --- a/internal/calendar/routes.go |
| +++ b/internal/calendar/routes.go |
| @@ -37,6 +37,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| r.Handle("/native/*", a.native) |
| r.Get("/help/open-source", h.credits) |
| + r.NotFound(h.notFound) |
| // Signed-out. |
| if a.home != nil { |
| @@ -79,7 +80,7 @@ func (a *App) routes(h *handlers, authPlugin *rauth.Auth, list allowlist, origin |
| // The light/dark switcher, outside the session on purpose: the |
| // sign-in page is a page too, and somebody who needs a |
| // high-contrast theme should not have to sign in to get one. |
| - r.Post("/mode", h.mode) |
| + r.Post("/appearance", h.appearance) |
| // A subscription feed is a capability: the token in the URL is the |
| // whole authorisation, because Google and Apple will not sign in. |
diff --git a/internal/calendar/static/calendar.css b/internal/calendar/static/calendar.css| index ce9efcb..b4e934a 100644 |
| --- a/internal/calendar/static/calendar.css |
| +++ b/internal/calendar/static/calendar.css |
| @@ -25,6 +25,8 @@ |
| margin-block-end: var(--rst-sp-4); |
| } |
| +[cal-bar] + [rst-seg-tabs] { margin-block: 0 var(--rst-sp-4); } |
| + |
| [cal-bar-spacer] { flex: 1 1 auto; } |
| /* The day's sentence. It is the page's opening line, so it reads at |
| @@ -115,7 +117,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); |
| @@ -210,7 +212,7 @@ |
| padding: var(--rst-sp-2); |
| position: sticky; |
| text-align: center; |
| - top: 0; |
| + top: var(--estilo-bar-block-size); |
| z-index: 2; |
| background: var(--rst-bg); |
| } |
diff --git a/internal/calendar/static/calendar.js b/internal/calendar/static/calendar.js| new file mode 100644 |
| index 0000000..679c5ba |
| --- /dev/null |
| +++ b/internal/calendar/static/calendar.js |
| @@ -0,0 +1,10 @@ |
| +// 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]"); |
| + if (!hour) return; |
| + requestAnimationFrame(() => scrollTo(0, Math.max(0, hour.getBoundingClientRect().top + scrollY - 72))); |
| +}); |
diff --git a/internal/calendar/templates/agenda.html b/internal/calendar/templates/agenda.html| index 0b68ce5..47ef653 100644 |
| --- a/internal/calendar/templates/agenda.html |
| +++ b/internal/calendar/templates/agenda.html |
| @@ -19,6 +19,7 @@ |
| <a rst-btn href="/agenda">From today</a> |
| <a rst-btn href="/agenda?from={{.Data.NextISO}}">Next fortnight</a> |
| </div> |
| +{{template "calendar-view-tabs" (dict "View" "agenda" "Day" (printf "/day/%s" .Data.Agenda.FromISO) "Week" (printf "/week/%s" .Data.Agenda.FromISO) "Month" (printf "/month/%s" (slice .Data.Agenda.FromISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Agenda.FromISO))}} |
| {{if .Data.Agenda.Days}} |
| {{range .Data.Agenda.Days}} |
diff --git a/internal/calendar/templates/day.html b/internal/calendar/templates/day.html| index 9510748..2306a6f 100644 |
| --- a/internal/calendar/templates/day.html |
| +++ b/internal/calendar/templates/day.html |
| @@ -22,11 +22,8 @@ |
| <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> |
| +{{template "calendar-view-tabs" (dict "View" "day" "Day" (printf "/day/%s" .Data.Day.ISO) "Week" (printf "/week/%s" .Data.Day.ISO) "Month" (printf "/month/%s" (slice .Data.Day.ISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.Day.ISO))}} |
| {{if .Data.Day.AllDay}} |
| <div cal-allday> |
| @@ -36,7 +33,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/layout.html b/internal/calendar/templates/layout.html| index 771b97e..f30d90b 100644 |
| --- a/internal/calendar/templates/layout.html |
| +++ b/internal/calendar/templates/layout.html |
| @@ -1,150 +1,42 @@ |
| {{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}}> |
| <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> |
| <link rel="stylesheet" href="{{asset "static/tokens.css"}}"> |
| <link rel="stylesheet" href="{{asset "static/theme.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/base.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/menu.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/estilo/chrome.css"}}"> |
| +<link rel="stylesheet" href="{{asset "static/calendar.css"}}"> |
| <script defer src="{{asset "static/rastrillo.js"}}"></script> |
| +<script defer src="{{asset "static/estilo/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}} |
| - |
| -{{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}} |
| +</html>{{end}} |
| + |
| +{{define "calendar-view-tabs"}}<nav rst-seg-tabs aria-label="Calendar view"> |
| + <a href="{{.Day}}"{{if eq .View "day"}} aria-current="page"{{end}}>Day</a> |
| + <a href="{{.Week}}"{{if eq .View "week"}} aria-current="page"{{end}}>Week</a> |
| + <a href="{{.Month}}"{{if eq .View "month"}} aria-current="page"{{end}}>Month</a> |
| + <a href="{{.Agenda}}"{{if eq .View "agenda"}} aria-current="page"{{end}}>Agenda</a> |
| +</nav>{{end}} |
diff --git a/internal/calendar/templates/month.html b/internal/calendar/templates/month.html| index 860cc74..42e6c63 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 id="main"> |
| <header rst-page-header> |
| <div rst-page-header-titles> |
| <h1>{{.Data.Month.Title}}</h1> |
| @@ -16,11 +16,10 @@ |
| <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.TodayMonth}}">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-view-tabs" (dict "View" "month" "Day" (printf "/day/%s-01" .Data.Month.ISO) "Week" (printf "/week/%s-01" .Data.Month.ISO) "Month" (printf "/month/%s" .Data.Month.ISO) "Agenda" (printf "/agenda?from=%s-01" .Data.Month.ISO))}} |
| {{/* 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..352a3bf 100644 |
| --- a/internal/calendar/templates/week.html |
| +++ b/internal/calendar/templates/week.html |
| @@ -2,7 +2,7 @@ |
| {{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)"> |
| +<main rst-page id="main"> |
| <header rst-page-header> |
| <div rst-page-header-titles> |
| <h1>{{.Title}}</h1> |
| @@ -20,10 +20,8 @@ |
| <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-view-tabs" (dict "View" "week" "Day" (printf "/day/%s" .Data.StartISO) "Week" (printf "/week/%s" .Data.StartISO) "Month" (printf "/month/%s" (slice .Data.StartISO 0 7)) "Agenda" (printf "/agenda?from=%s" .Data.StartISO))}} |
| {{/* 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 +50,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..5789b64 100644 |
| --- a/internal/calendar/theme.go |
| +++ b/internal/calendar/theme.go |
| @@ -1,6 +1,7 @@ |
| package calendar |
| import ( |
| + "amadan.net/oficina/estilo.git/chrome" |
| "net/http" |
| ) |
| @@ -100,6 +101,13 @@ func (h *handlers) mode(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, safeReturn(r, "/"), http.StatusSeeOther) |
| } |
| +// appearance is the shared account menu's three-state preference. The old |
| +// Calendar-only /mode and calendar_mode cookie deliberately do not survive: |
| +// the suite cookie gives every adopter the same validated return behaviour. |
| +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. |