oficina / calendar Public

Adopt estilo shared chrome in Calendar

Paul Campbell pushed by paul@keymail.dev 16c5197a6fc31b556b160bf667010b59518e640c parent 4d2975d
15 files changed, +216 −182
  • go.mod +2 −2
  • go.sum +4 −4
  • internal/calendar/handlers_event_form.go +26 −0
  • internal/calendar/handlers_event_form_test.go +12 −0
  • internal/calendar/handlers_views.go +1 −1
  • internal/calendar/render.go +115 −27
  • internal/calendar/routes.go +2 −1
  • internal/calendar/static/calendar.css +4 −2
  • internal/calendar/static/calendar.js +10 −0
  • internal/calendar/templates/agenda.html +1 −0
  • internal/calendar/templates/day.html +2 −5
  • internal/calendar/templates/layout.html +23 −131
  • internal/calendar/templates/month.html +3 −4
  • internal/calendar/templates/week.html +3 −5
  • internal/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.