oficina / home Public

Home's own pages on the shared chrome

Every page Home draws for a person now sits under estilo's 52px bar,
branded Oficina (estilo spec shared-chrome, §12.1 row 8), except the
public front page, which keeps its own design.

- Directory (oficina.works): one account menu, from estilo: "Signed in"
  (the directory never holds an address), Passkeys and Recovery codes
  (id.*'s pages, linked from nowhere until now), Appearance (Light, Dark,
  System, a /appearance POST, per host), Help > Open source, and the one
  Sign out. The lone Help dropdown and the hub's Sign out button go.
  Signed out, the corner is "Sign in", back to the page you were on.
- id.oficina.works: every page is estilo's door (card on the sky) under a
  brand-only bar linking back to the directory; nothing account-shaped
  renders there.
- The team home: "Your apps" on the sky, in a header band under the bar.
  The sky goes only where Calma puts it: the door, this header, the front
  page's hero. The chooser, Open source and error pages are plain pages.
- estilo's error page on both origins for a browser (Accept: text/html),
  with the account menu when signed in; plain text otherwise, and a
  non-browser's wrong method keeps its 405. A page that fails to render,
  and the passkey pages' failures, get the 500 page with a reference.
- Titles are "<Page> · Oficina"; the directory and the front page are
  "Oficina" (not "Oficina — open your new office suite"); the Docs
  continuation gains " · Oficina".
- "Use a different address" and "Start again" keep return_to (r3 C1).
- The hand-off hop keeps the bar and the sky's inline placeholder; only
  its card waits 1.5s (r3 C2). It still holds one link and no busy.js.
- Onest and the Oficina mark are preloaded on every page, and the six app
  marks on the front page (r3 C4): the front page's cold-load CLS goes
  from 0.0028 to 0.
- No chrome.js: Home prefers no script (AGENTS.md, which now says so).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev b8894b7f3724dc9ce990e6f7ad736893352f3ef6 parent 535d41c
39 files changed, +501 −215
  • AGENTS.md +12 −0
  • internal/home/app.go +5 −0
  • internal/home/calma.go +40 −11
  • internal/home/chrome.go +191 −0
  • internal/home/continuation.go +2 −2
  • internal/home/credits.go +1 −1
  • internal/home/directory.go +12 −2
  • internal/home/directory_catalog_test.go +3 −3
  • internal/home/docs_chooser.go +13 −13
  • internal/home/docs_continue_test.go +3 −3
  • internal/home/exchange.go +12 −12
  • internal/home/exchange_test.go +1 −1
  • internal/home/handoff_failure.go +5 −5
  • internal/home/handoff_reveal_test.go +8 −5
  • internal/home/origins_test.go +2 −0
  • internal/home/passkey.go +6 −6
  • internal/home/render.go +36 −10
  • internal/home/shared_chrome_test.go +2 −2
  • internal/home/signin.go +27 −15
  • internal/home/signout.go +3 −3
  • internal/home/static/calma.css +32 −57
  • internal/home/static/site/index.html +9 −1
  • internal/home/templates/assertion.html +7 −10
  • internal/home/templates/check_your_mail.html +4 −4
  • internal/home/templates/credits.html +1 −3
  • internal/home/templates/directory.html +8 −11
  • internal/home/templates/directory_signed_out.html +2 −2
  • internal/home/templates/docs_chooser.html +3 −3
  • internal/home/templates/docs_continue.html +4 −4
  • internal/home/templates/docs_empty.html +1 −1
  • internal/home/templates/error.html +2 −0
  • internal/home/templates/handoff_failed.html +2 −2
  • internal/home/templates/layout.html +31 −12
  • internal/home/templates/passkey_confirm.html +2 −2
  • internal/home/templates/passkey_enrol.html +1 −1
  • internal/home/templates/recovery_codes.html +3 −3
  • internal/home/templates/signin.html +2 −2
  • internal/home/templates/signin_confirm.html +1 −1
  • internal/home/templates/signin_failed.html +2 −2

CI passed — run details

CI log
amadan: .amadan/timeout: this job's budget is 15m0s (this runner allows up to 30m0s)
=== vet ===
CGO_ENABLED=0 go vet ./...
CGO_ENABLED=0 go vet -tags browser ./internal/home/
cd handoff && CGO_ENABLED=0 go vet ./...
cd roster && CGO_ENABLED=0 go vet ./...
cd connect && CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build ./...
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build ./cmd/home ./cmd/oficina ./internal/operatorlaunch
cd handoff && CGO_ENABLED=0 go build ./...
cd roster && CGO_ENABLED=0 go build ./...
cd connect && CGO_ENABLED=0 go build ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
?   	amadan.net/oficina/home/cmd/home	[no test files]
ok  	amadan.net/oficina/home/cmd/home-placeholder	0.583s
?   	amadan.net/oficina/home/cmd/oficina	[no test files]
ok  	amadan.net/oficina/home/internal/guardstest	0.037s
ok  	amadan.net/oficina/home/internal/home	10.623s
ok  	amadan.net/oficina/home/internal/home/directorydata	0.954s
ok  	amadan.net/oficina/home/internal/home/logtest	0.004s
ok  	amadan.net/oficina/home/internal/home/seal	0.004s
ok  	amadan.net/oficina/home/internal/home/sealtest	0.003s
ok  	amadan.net/oficina/home/internal/operator	5.035s
ok  	amadan.net/oficina/home/internal/operatorlaunch	4.133s
ok  	amadan.net/oficina/home/internal/provisioner	36.983s
cd handoff && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/handoff	0.315s
ok  	amadan.net/oficina/home/handoff/seal	0.002s
cd roster && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/roster	5.080s
cd connect && CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/home/connect	0.057s
ok  	amadan.net/oficina/home/connect/linkstore	0.198s
=== race ===
go test -race -count=1 ./internal/home/ ./internal/operator/ ./internal/operatorlaunch/ ./internal/provisioner/ ./internal/home/directorydata/
ok  	amadan.net/oficina/home/internal/home	117.705s
ok  	amadan.net/oficina/home/internal/operator	65.558s
ok  	amadan.net/oficina/home/internal/operatorlaunch	22.664s
ok  	amadan.net/oficina/home/internal/provisioner	306.207s
ok  	amadan.net/oficina/home/internal/home/directorydata	18.644s
cd handoff && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/handoff	9.489s
ok  	amadan.net/oficina/home/handoff/seal	1.013s
cd roster && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/roster	6.609s
cd connect && CGO_ENABLED=1 go test -race -count=1 ./...
ok  	amadan.net/oficina/home/connect	1.656s
ok  	amadan.net/oficina/home/connect/linkstore	3.869s
diff --git a/AGENTS.md b/AGENTS.md
index 94d0e57..14aeabb 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -334,6 +334,18 @@ quietly ignored).
Scripts are **static same-origin files, never inline `<script>`**, and
every one Home serves is named in `noscript_test.go`'s closed list —
a rule with a list is checkable, a rule with an escape hatch is not.
+- **The shared chrome** (estilo spec shared-chrome, §12.1 row 8;
+ `internal/home/chrome.go`). Every page Home draws for a person sits under
+ estilo's 52px bar, branded Oficina — except the public front page,
+ which keeps its own design. On the directory a signed-in person has one
+ account menu (Passkeys and Recovery codes on id.*, Appearance, Help →
+ Open source, the one Sign out); id.*'s pages are the door, with a
+ brand-only bar. Unknown routes get estilo's error page for a browser
+ (`Accept: text/html`) and plain text otherwise. Calma's sky goes only on
+ the door, the team home's header and the front page's hero. Home does
+ not load estilo's `chrome.js`: its menus work without it, and the
+ script rule above prefers none — the stated cost is that an open menu
+ closes from its own trigger, not on Escape or a click outside.
**Deploys**
- One binary, `CGO_ENABLED=0`, cgo-free SQLite.
diff --git a/internal/home/app.go b/internal/home/app.go
index 3d8c01f..acf81fb 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -153,6 +153,11 @@ func (a *App) Router() http.Handler {
r := chi.NewRouter()
r.Use(a.serveKnownHost)
+ // estilo's error page for a browser, plain text for anything else
+ // (chrome.go). Unknown hosts never reach these: serveKnownHost answers
+ // them first, with an empty 404.
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed)
a.mountCredits(r)
r.Get("/", func(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/home/calma.go b/internal/home/calma.go
index 92557f3..8ea1a3c 100644
--- a/internal/home/calma.go
+++ b/internal/home/calma.go
@@ -31,11 +31,39 @@ var homeFuncs = map[string]any{
// drift from theirs. Home draws its own templates, so it reads the
// strings from estilo's types rather than rendering estilo-signin.
"signinDoor": func() chrome.SignIn { return chrome.SignIn{App: suiteName} },
- "sentDoor": func() chrome.SignInNotice { return chrome.SignInNotice{App: suiteName}.Sent("/signin") },
+ "sentDoor": func(again string) chrome.SignInNotice {
+ return chrome.SignInNotice{App: suiteName}.Sent(again)
+ },
"signedOutDoor": func() chrome.SignInNotice {
return chrome.SignInNotice{App: suiteName}.SignedOut("/auth/start")
},
"linkLifetime": func() int { return int(chrome.LinkLifetime.Minutes()) },
+ // doorView is estilo-door-open's data: the suite's sky behind one
+ // card, the Oficina mark and name at its head.
+ "doorView": func() doorView {
+ return doorView{Sky: sky.For(sky.SuiteSeed), Icon: versioned("/static/brand/icon.svg"), App: suiteName}
+ },
+ // hopSky is the sky a hop page keeps: the inline placeholder alone, a
+ // data: URI, so the moment between two skies is not a blank page and
+ // still fetches nothing it will not show (r3 C2).
+ "hopSky": func() sky.View {
+ var v sky.View
+ for _, l := range sky.For(sky.SuiteSeed).Layers {
+ if l.Inline != "" {
+ v.Layers = append(v.Layers, l)
+ }
+ }
+ return v
+ },
+ // Onest's file, preloaded so a cold first load paints in it rather
+ // than swapping in after (r3 C4).
+ "fontPreload": func() string { return theme.FontPrefix + "onest-latin.woff2" },
+}
+
+// doorView is estilo-door-open's data (chrome/templates/signin.html).
+type doorView struct {
+ Sky sky.View
+ Icon, App string
}
// suiteName is what the hub calls itself to people: the suite, never
@@ -43,11 +71,12 @@ var homeFuncs = map[string]any{
const suiteName = "Oficina"
// doorPages are the pages at the door — signing in, and everything on the
-// way in or out. The layout draws them as one card on the sky with the
-// Oficina mark, as Calma's sign-in does; every other page keeps the full
-// width (the directory lists apps; credits is a long page of notices).
-// Not assertion.html or docs_continue.html: they are hops, on screen for
-// a moment, so they draw neither the card nor the sky (hopPages).
+// way in or out, and id.*'s account pages. The layout draws them as
+// estilo's door: one card on the sky with the Oficina mark, under the bar.
+// The sky goes only where Calma puts it — the door, the team home's header
+// and the front page's hero — so the team chooser, Open source and the
+// error pages are plain pages under the bar.
+// Not assertion.html or docs_continue.html: they are hops (hopPages).
var doorPages = map[string]bool{
"signin.html": true,
"check_your_mail.html": true,
@@ -59,13 +88,12 @@ var doorPages = map[string]bool{
"handoff_failed.html": true,
"signout_confirm.html": true,
"directory_signed_out.html": true,
- "docs_chooser.html": true,
- "docs_empty.html": true,
- "docs_unavailable.html": true,
}
-// hopPages move on by themselves (an auto-submit, a 0 s refresh): no sky,
-// so the slowest-feeling moment of sign-in fetches nothing it won't show.
+// hopPages move on by themselves (an auto-submit, a 0 s refresh). They
+// keep the bar and the sky's inline placeholder, so the moment between the
+// door and the directory is not a blank page (r3 C2), but load no script,
+// no sky layer and no card: the card shows only if the hop stalls.
var hopPages = map[string]bool{
"assertion.html": true,
"docs_continue.html": true,
@@ -78,6 +106,7 @@ func mountCalma(r chi.Router) {
r.Handle(theme.FontPrefix+"*", theme.FontHandler())
r.Handle(sky.Prefix+"*", sky.Handler())
mountCSS(r, "/static/estilo/sky.css", sky.CSS())
+ mountChrome(r)
mountCSS(r, "/static/calma.css", calmaCSS)
// estilo's busy rule: a submit button's label gives way to a spinner
// for at least 650ms, the button disables, the form refuses a second
diff --git a/internal/home/chrome.go b/internal/home/chrome.go
new file mode 100644
index 0000000..beb34b0
--- /dev/null
+++ b/internal/home/chrome.go
@@ -0,0 +1,191 @@
+package home
+
+import (
+ "net/http"
+ "net/url"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/rastrillo/rastrillo"
+ "amadan.net/rastrillo/rastrillo/sessions"
+ "github.com/go-chi/chi/v5"
+)
+
+// The shared chrome (estilo spec shared-chrome, §12.1 row 8). Every page
+// Home draws for a person, other than the public front page, sits under
+// estilo's 52px bar with the brand "Oficina":
+//
+// - on the directory, a signed-in person gets the account menu: who is
+// signed in, Passkeys and Recovery codes (pages on id.*), Appearance,
+// Help → Open source, and the one Sign out. Signed out, the corner is
+// "Sign in", except on the signed-out page itself.
+// - on id.*, the bar is the brand alone, linking back to the directory:
+// every page there is the door, and the credential origin renders
+// nothing account-shaped (AGENTS.md, Identity).
+//
+// No chrome.js: Home prefers no script (AGENTS.md), and every menu works
+// without it. The stated cost is estilo's: with scripts off, an open menu
+// closes when its own trigger is pressed again, not on Escape or a click
+// outside.
+
+// page is what every template is executed with: the handler's own data,
+// and the request's chrome.
+type page struct {
+ Data any
+ Chrome chrome.Bar
+ // Theme is <html data-theme>: "light", "dark", or empty to follow the
+ // system (the Appearance preference, per host).
+ Theme string
+}
+
+// The account menu's and the bar's words. Home speaks English only today;
+// estilo's templates carry none of their own.
+var (
+ barLabels = chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}
+ accountLabels = chrome.AccountLabels{SignedInAs: "Signed in", Appearance: "Appearance", Help: "Help"}
+)
+
+// onCredential reports whether r arrived for id.* rather than the
+// directory.
+func (a *App) onCredential(r *http.Request) bool {
+ return r != nil && strings.ToLower(r.Host) == a.credentialHost
+}
+
+// pageFor wraps a handler's data in the request's chrome. door marks the
+// sign-in, sent and signed-out pages, whose corner stays empty. hop marks
+// a page that moves on by itself: its bar is the brand alone, as plain
+// text, so the page still holds exactly one link, the way on.
+func (a *App) pageFor(r *http.Request, data any, door, hop bool) page {
+ p := page{Data: data}
+ brand := chrome.App{Name: suiteName, Slug: "oficina", Home: "/", Icon: versioned("/static/brand/icon.svg")}
+ if hop {
+ brand.Home = ""
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ return p
+ }
+ if a.onCredential(r) || r == nil {
+ brand.Home = a.origins.Directory + "/"
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ return p
+ }
+ p.Theme = chrome.ThemeAttr(chrome.Appearance(r))
+ p.Chrome = chrome.Bar{App: brand, Labels: barLabels}
+ if _, ok := sessions.Current(r); ok {
+ p.Chrome.Account = a.account(r)
+ } else if !door {
+ p.Chrome.SignIn = &chrome.Link{Href: signInHref(r), Label: "Sign in"}
+ }
+ return p
+}
+
+// account is the directory's account menu. The directory cannot read the
+// address (it holds a person ref, never an address: the one rule), so the
+// identity row says only "Signed in" and the trigger is the person glyph.
+func (a *App) account(r *http.Request) *chrome.Account {
+ return &chrome.Account{
+ TriggerLabel: "Account",
+ Labels: accountLabels,
+ Items: []menu.Item{
+ {Href: a.origins.Credential + "/passkey/enrol", Label: "Passkeys"},
+ {Href: a.origins.Credential + "/passkey/codes", Label: "Recovery codes"},
+ },
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ // The path alone: no directory page reads its query, and a
+ // query is where a crafted link would carry its text.
+ Return: r.URL.EscapedPath(),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ Help: []menu.Item{{Href: "/help/open-source", Label: "Open source"}},
+ SignOut: chrome.SignOut{Action: signoutPath, Label: "Sign out"},
+ }
+}
+
+// signInHref starts the directory's sign-in and comes back to this page.
+// An error page comes back to the directory instead: its own URL is the
+// one thing known not to exist.
+func signInHref(r *http.Request) string {
+ back := "/"
+ if r.Method == http.MethodGet && r.URL.Path == "/help/open-source" {
+ back = r.URL.Path
+ }
+ return handoff.StartPath + "?return_to=" + url.QueryEscape(back)
+}
+
+// mountChrome serves estilo's base, menu and chrome stylesheets, on both
+// origins.
+func mountChrome(r chi.Router) {
+ mountCSS(r, "/static/estilo/base.css", base.CSS())
+ mountCSS(r, "/static/estilo/menu.css", menu.CSS())
+ mountCSS(r, "/static/estilo/chrome.css", chrome.CSS())
+}
+
+// wantsHTML reports whether the request is a browser's page load: its
+// Accept names text/html. Everything else (a fetch, curl, a monitor) gets
+// plain text, as it always has (spec §3.3).
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// errorCopy is estilo spec §3.2's copy for the statuses Home answers.
+var errorCopy = map[int]struct{ page, heading, body string }{
+ http.StatusNotFound: {"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."},
+ http.StatusInternalServerError: {"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."},
+}
+
+// errorPage answers status as estilo's error page, under the bar, for a
+// browser, and as plain text for anything else. A 500 carries a reference
+// to quote, which is also logged, so the two can be matched.
+func (a *App) errorPage(w http.ResponseWriter, r *http.Request, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ if !wantsHTML(r) {
+ http.Error(w, strings.ToLower(http.StatusText(status)), status)
+ return
+ }
+ c := errorCopy[status]
+ home := chrome.Link{Href: "/", Label: "Go to Oficina"}
+ if a.onCredential(r) {
+ home.Href = a.origins.Directory + "/"
+ }
+ data := chrome.ErrorPage{Status: status, Title: c.heading, Body: c.body, Home: home}
+ if status >= 500 {
+ data.Ref, data.RefLabel = rastrillo.NewRef(), "Reference:"
+ a.logger.Error("error page", "ref", data.Ref, "path", r.URL.Path)
+ if r.Method == http.MethodGet {
+ data.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
+ }
+ }
+ a.renderPage(w, r, status, "error.html", errorView{chrome.Title(c.page, suiteName), data})
+}
+
+// errorView is error.html's data: the tab title and estilo-error's.
+type errorView struct {
+ Title string
+ Error chrome.ErrorPage
+}
+
+// notFound is the 404 for an unknown route, on either origin.
+func (a *App) notFound(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusNotFound)
+}
+
+// methodNotAllowed: a browser's GET or HEAD for a path that only takes a
+// POST is, to the person who typed it, a page that is not there; anything
+// else is told 405 in plain text, as before.
+func (a *App) methodNotAllowed(w http.ResponseWriter, r *http.Request) {
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && wantsHTML(r) {
+ a.notFound(w, r)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+}
+
+// serverError is the 500 for a handler that failed after deciding to
+// answer: the error page with a reference.
+func (a *App) serverError(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusInternalServerError)
+}
diff --git a/internal/home/continuation.go b/internal/home/continuation.go
index 358ac78..80c63d3 100644
--- a/internal/home/continuation.go
+++ b/internal/home/continuation.go
@@ -4,8 +4,8 @@ import "net/http"
// renderContinuation ends the submitted form response before external navigation.
// Callers supply a validated app destination or the sign-in flow's provider URL.
-func (a *App) renderContinuation(w http.ResponseWriter, destination, title string) {
+func (a *App) renderContinuation(w http.ResponseWriter, r *http.Request, destination, title string) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Referrer-Policy", "no-referrer")
- a.render(w, http.StatusOK, "docs_continue.html", struct{ Destination, Title string }{destination, title})
+ a.render(w, r, http.StatusOK, "docs_continue.html", struct{ Destination, Title string }{destination, title})
}
diff --git a/internal/home/credits.go b/internal/home/credits.go
index 07dded1..6ab24aa 100644
--- a/internal/home/credits.go
+++ b/internal/home/credits.go
@@ -28,7 +28,7 @@ var embeddedCredits = func() creditInventory {
func (a *App) mountCredits(r chi.Router) {
r.Get("/help/open-source", func(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})
+ a.render(w, r, http.StatusOK, "credits.html", credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components})
})
mountCSS(r, "/static/credits.css", credits.CSS())
}
diff --git a/internal/home/directory.go b/internal/home/directory.go
index fad60da..3c06ed7 100644
--- a/internal/home/directory.go
+++ b/internal/home/directory.go
@@ -7,6 +7,7 @@ import (
"strings"
"time"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/theme"
"amadan.net/oficina/home/handoff"
"amadan.net/rastrillo/rastrillo/assertion"
@@ -65,6 +66,10 @@ func (a *App) directoryRouter() http.Handler {
r.Use(a.serveKnownHost)
r.Use(a.chooserCookies)
r.Use(a.sessions.Middleware)
+ // After the session middleware, so a signed-in person's 404 shows their
+ // account menu (spec §3.3).
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed)
a.mountCredits(r)
// This callback alone accepts a cross-origin POST, with its own exact issuer
// check and browser-bound finish. Every other mutation retains CSRF protection.
@@ -103,8 +108,13 @@ func (a *App) directoryRouter() http.Handler {
a.handleIndex(w, r)
return
}
- a.render(w, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
+ a.render(w, r, 200, "directory.html", map[string]any{"Apps": directoryCatalog(*a.runtime)})
})
+ // The account menu's Appearance (spec §8.3): inside Protect, outside
+ // any session requirement, so a session that expired mid-page does
+ // not turn the click into a sign-in loop. A host-only cookie, so the
+ // choice is the directory's own.
+ r.Post("/appearance", chrome.HandleAppearance)
// Sign out ends both sessions: this one here, then the id.*
// session by handing the POST on. See signoutPath.
r.Post(signoutPath, func(w http.ResponseWriter, r *http.Request) {
@@ -114,7 +124,7 @@ func (a *App) directoryRouter() http.Handler {
})
r.Get("/signed-out", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, 200, "directory_signed_out.html", map[string]any{})
+ a.render(w, r, 200, "directory_signed_out.html", map[string]any{})
})
r.Get("/healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(200) })
mountCSS(r, "/static/tokens.css", staticAsset("tokens.css"))
diff --git a/internal/home/directory_catalog_test.go b/internal/home/directory_catalog_test.go
index 58db5be..7c53711 100644
--- a/internal/home/directory_catalog_test.go
+++ b/internal/home/directory_catalog_test.go
@@ -50,7 +50,7 @@ func TestDirectoryCatalogRequiresSigninAndDoesNotInventLaunchDestinations(t *tes
t.Fatal("missing app", name)
}
}
- if strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || hasOtherScript(body) {
+ if strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="/apps/docs"`) || !strings.Contains(body, "Choose a team") || hasOtherScript(body) {
t.Fatal("catalog advertised unsupported launch", body)
}
if strings.Count(body, "Not connected to Oficina yet") != 0 || strings.Count(body, "Coming soon") != 0 || strings.Count(body, "Separate sign-in") != 2 {
@@ -74,7 +74,7 @@ func TestDirectoryCatalogPreviewKeepsDocsChooserPolicy(t *testing.T) {
cookie := f.session(t, chooserPerson)
page := f.request("GET", "/?return_to=https://evil.example", "", "", cookie)
body := page.Body.String()
- if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 {
+ if page.Code != 200 || !strings.Contains(body, `href="https://overnight-docs.bds.oncarlos.com/signin"`) || !strings.Contains(body, "Open preview") || strings.Count(body, "Choose a team") != 5 || strings.Contains(body, "evil.example") || strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 {
t.Fatal("preview navigation incorrect", page.Code, body)
}
noStore(t, page)
@@ -171,7 +171,7 @@ func TestConfiguredAppsLaunchOnlyOperatorDestinations(t *testing.T) {
f.handler = app.Router()
page := f.request("GET", "/?app=slides&origin=https://attacker.example", "", "", f.session(t, chooserPerson))
body := page.Body.String()
- if page.Code != 200 || strings.Count(body, "<a ") != 7 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
+ if page.Code != 200 || strings.Count(body, "<a home-app ") != 6 || strings.Count(body, `href="/help/open-source"`) != 1 || !strings.Contains(body, `href="https://sheets.example/signin"`) || !strings.Contains(body, `href="https://memoria.example/signin"`) || !strings.Contains(body, `href="/apps/docs"`) || strings.Contains(body, "attacker.example") {
t.Fatal(page.Code, body)
}
noStore(t, page)
diff --git a/internal/home/docs_chooser.go b/internal/home/docs_chooser.go
index 828baa7..d71c1bb 100644
--- a/internal/home/docs_chooser.go
+++ b/internal/home/docs_chooser.go
@@ -80,16 +80,16 @@ func (c *docsChooser) Entry(w http.ResponseWriter, r *http.Request) {
}
choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
switch len(choices) {
case 0:
- c.app.render(w, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
+ c.app.render(w, r, http.StatusOK, "docs_empty.html", map[string]any{"AppName": c.appName})
case 1:
c.redirect(w, r, choices[0])
default:
- c.app.render(w, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
+ c.app.render(w, r, http.StatusOK, "docs_chooser.html", map[string]any{"Choices": choices, "AppName": c.appName, "ChoicePath": c.choicePath})
}
}
func (c *docsChooser) Select(w http.ResponseWriter, r *http.Request) {
@@ -127,12 +127,12 @@ func (c *docsChooser) choose(w http.ResponseWriter, r *http.Request) {
}
choices, err := c.store.Choices(r.Context(), subject, c.appID)
if err != nil {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
for _, choice := range choices {
if choice.SQID == form.Get("team") {
- c.continueToDocs(w, choice)
+ c.continueToDocs(w, r, choice)
return
}
}
@@ -145,7 +145,7 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
if r.Method == http.MethodPost {
// Start a new GET navigation after the form response. The submitted
// selection is discarded; sign-in returns to current team choices.
- c.renderContinuation(w, c.app.origins.Directory+target)
+ c.renderContinuation(w, r, c.app.origins.Directory+target)
} else {
http.Redirect(w, r, target, http.StatusSeeOther)
}
@@ -157,8 +157,8 @@ func (c *docsChooser) subject(w http.ResponseWriter, r *http.Request) (string, b
}
return session.Subject, true
}
-func (c *docsChooser) unavailable(w http.ResponseWriter) {
- c.app.render(w, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
+func (c *docsChooser) unavailable(w http.ResponseWriter, r *http.Request) {
+ c.app.render(w, r, http.StatusServiceUnavailable, "docs_unavailable.html", nil)
}
// previewOrigin is the operator's preview deployment for this app, or "".
@@ -181,16 +181,16 @@ func (c *docsChooser) redirect(w http.ResponseWriter, r *http.Request, choice di
http.Redirect(w, r, choice.Origin+"/", http.StatusSeeOther)
}
-func (c *docsChooser) continueToDocs(w http.ResponseWriter, choice directorydata.Choice) {
+func (c *docsChooser) continueToDocs(w http.ResponseWriter, r *http.Request, choice directorydata.Choice) {
if !directorydata.ValidOrigin(choice.Origin) {
- c.unavailable(w)
+ c.unavailable(w, r)
return
}
- c.renderContinuation(w, choice.Origin+"/")
+ c.renderContinuation(w, r, choice.Origin+"/")
}
// Callers supply either a validated stored app origin plus the app root,
// or the validated directory origin plus the fixed sign-in continuation path.
-func (c *docsChooser) renderContinuation(w http.ResponseWriter, destination string) {
- c.app.renderContinuation(w, destination, "Continue to "+c.appName)
+func (c *docsChooser) renderContinuation(w http.ResponseWriter, r *http.Request, destination string) {
+ c.app.renderContinuation(w, r, destination, "Continue to "+c.appName)
}
diff --git a/internal/home/docs_continue_test.go b/internal/home/docs_continue_test.go
index e8f5589..947deb7 100644
--- a/internal/home/docs_continue_test.go
+++ b/internal/home/docs_continue_test.go
@@ -73,12 +73,12 @@ func TestDocsContinueCanonicalResponseBoundary(t *testing.T) {
}
for _, origin := range []string{"https://alpha.docs.oficina.works", "https://approved.fixture.example"} {
r := httptest.NewRecorder()
- c.continueToDocs(r, directorydata.Choice{Origin: origin})
+ c.continueToDocs(r, httptest.NewRequest("POST", directoryOrigin+docsChoicePath, nil), directorydata.Choice{Origin: origin})
assertDocsContinuation(t, r, origin+"/")
}
for _, origin := range []string{"", "http://alpha.example", "https://Alpha.example", "https://alpha.example/", "https://alpha.example:443", "https://user@alpha.example", "https://alpha.example?x=1", "https://alpha.example#f", "https://alpha.example?", "https://alpha.example\n", " https://alpha.example", "https://alpha.example\";url=https://bad.example", "javascript:alert(1)", "//alpha.example", "https://[::1]", "https://alpha.example/%22"} {
r := httptest.NewRecorder()
- c.continueToDocs(r, directorydata.Choice{Origin: origin})
+ c.continueToDocs(r, httptest.NewRequest("POST", directoryOrigin+docsChoicePath, nil), directorydata.Choice{Origin: origin})
if r.Code != 503 || strings.Contains(r.Body.String(), "http-equiv") || r.Header().Get("Location") != "" {
t.Fatal(origin, r.Code, r.Body.String())
}
@@ -89,7 +89,7 @@ func TestDocsContinueRendererEscapesIdenticalTargets(t *testing.T) {
// Exercise real template contexts independently of the fixed runtime URL.
want := "https://alpha.docs.oficina.works/auth/start?return_to=%2F&probe=%22%3C"
r := httptest.NewRecorder()
- f.p.directory.render(r, 200, "docs_continue.html", struct{ Destination, Title string }{want, "Continue to Docs"})
+ f.p.directory.render(r, httptest.NewRequest("GET", directoryOrigin+"/", nil), 200, "docs_continue.html", struct{ Destination, Title string }{want, "Continue to Docs"})
refresh, links := continuationTargets(t, r.Body.String())
if len(refresh) != 1 || refresh[0] != "0;url="+want || len(links) != 1 || links[0] != want || !strings.Contains(r.Body.String(), "&amp;probe=") {
t.Fatal(refresh, links, r.Body.String())
diff --git a/internal/home/exchange.go b/internal/home/exchange.go
index 8de9f78..bf87bb1 100644
--- a/internal/home/exchange.go
+++ b/internal/home/exchange.go
@@ -28,7 +28,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
maxAge := 0
if values, present := q["max_age"]; present {
if len(values) != 1 || values[0] != "300" {
- a.failAssertion(w, authorizeAge)
+ a.failAssertion(w, r, authorizeAge)
return
}
maxAge = 300
@@ -38,7 +38,7 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
wantFields++
}
if err != nil || len(q) != wantFields || len(q["consumer"]) != 1 || len(q["nonce"]) != 1 || !handoff.ValidNonce(q.Get("nonce")) || a.runtime.consumers[q.Get("consumer")] == "" {
- a.failAssertion(w, authorizeRequest)
+ a.failAssertion(w, r, authorizeRequest)
return
}
intent := assertionIntent{Consumer: q.Get("consumer"), Nonce: q.Get("nonce"), Expires: time.Now().Unix() + 600, MaxAge: maxAge}
@@ -49,12 +49,12 @@ func (a *App) handleAuthorize(w http.ResponseWriter, r *http.Request) {
}
b, err := json.Marshal(intent)
if err != nil {
- a.failAssertion(w, authorizeEncode)
+ a.failAssertion(w, r, authorizeEncode)
return
}
sealed, err := a.secrets.Sealer.Seal("assertion.intent", b)
if err != nil {
- a.failAssertion(w, authorizeSeal)
+ a.failAssertion(w, r, authorizeSeal)
return
}
http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: base64.RawURLEncoding.EncodeToString(sealed), Path: "/", MaxAge: 600, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
@@ -65,23 +65,23 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
// no-referrer would turn the cross-origin form POST's Origin into null.
w.Header().Set("Referrer-Policy", "strict-origin-when-cross-origin")
if r.URL.RawQuery != "" {
- a.failAssertion(w, assertionQuery)
+ a.failAssertion(w, r, assertionQuery)
return
}
now := time.Now()
intent, ok := a.readAssertionIntent(r, now)
if !ok {
- a.failAssertion(w, assertionIntentInvalid)
+ a.failAssertion(w, r, assertionIntentInvalid)
return
}
target, ok := a.runtime.consumers[intent.Consumer]
if !ok {
- a.failAssertion(w, assertionConsumer)
+ a.failAssertion(w, r, assertionConsumer)
return
}
session, ok := sessions.Current(r)
if !ok {
- a.failAssertion(w, assertionSession)
+ a.failAssertion(w, r, assertionSession)
return
}
authTime := session.AuthTime
@@ -89,7 +89,7 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
authTime = session.At
}
if intent.Subject != "" && intent.Subject != session.Subject {
- a.failAssertion(w, assertionSubject)
+ a.failAssertion(w, r, assertionSubject)
return
}
if intent.MaxAge != 0 && (!sessions.Fresh(session, time.Duration(intent.MaxAge)*time.Second, now) || authTime.After(now)) {
@@ -98,14 +98,14 @@ func (a *App) handleAssertion(w http.ResponseWriter, r *http.Request) {
}
id, err := assertion.RandomID()
if err != nil {
- a.failAssertion(w, assertionRandom)
+ a.failAssertion(w, r, assertionRandom)
return
}
token, err := assertion.Sign(a.runtime.signer, assertion.Claims{Version: 1, Issuer: a.origins.Credential, Audience: target, Subject: session.Subject, KeyID: a.runtime.keyID, ID: id, Nonce: intent.Nonce, IssuedAt: now.Unix(), ExpiresAt: now.Unix() + 120, AuthTime: authTime.Unix()})
if err != nil {
- a.failAssertion(w, assertionSign)
+ a.failAssertion(w, r, assertionSign)
return
}
http.SetCookie(w, &http.Cookie{Name: assertionIntentCookie, Value: "", Path: "/", MaxAge: -1, Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode})
- a.render(w, http.StatusOK, "assertion.html", map[string]any{"Action": target + handoff.CallbackPath, "Token": token})
+ a.render(w, r, http.StatusOK, "assertion.html", map[string]any{"Action": target + handoff.CallbackPath, "Token": token})
}
diff --git a/internal/home/exchange_test.go b/internal/home/exchange_test.go
index 2cfe33f..46119f8 100644
--- a/internal/home/exchange_test.go
+++ b/internal/home/exchange_test.go
@@ -154,7 +154,7 @@ func TestHomeDirectoryExchangeAndPrivacy(t *testing.T) {
token, _ := p.mint(t, intent, credentialSession)
directorySession := p.finish(t, token, pending)
page := exchangeRequest(p.directory, "GET", directoryOrigin+"/", "", nil, directorySession)
- if page.Code != 200 || !strings.Contains(page.Body.String(), "You are signed in.") {
+ if page.Code != 200 || !strings.Contains(page.Body.String(), "<details estilo-account") {
t.Fatal("directory session missing")
}
var subject, authTime string
diff --git a/internal/home/handoff_failure.go b/internal/home/handoff_failure.go
index 555eea6..1aa45da 100644
--- a/internal/home/handoff_failure.go
+++ b/internal/home/handoff_failure.go
@@ -23,7 +23,7 @@ const (
)
// Only this fixed vocabulary reaches logs; no request or error is accepted.
-func (a *App) failAssertion(w http.ResponseWriter, code handoffFailureCode) {
+func (a *App) failAssertion(w http.ResponseWriter, r *http.Request, code handoffFailureCode) {
stage, reason := "assertion", "unknown"
switch code {
case authorizeAge:
@@ -50,7 +50,7 @@ func (a *App) failAssertion(w http.ResponseWriter, code handoffFailureCode) {
reason = "signing"
}
a.logger.Warn("home handoff failed", "stage", stage, "reason", reason)
- a.renderHandoffFailure(w, http.StatusBadRequest)
+ a.renderHandoffFailure(w, r, http.StatusBadRequest)
}
func (a *App) failDirectoryHandoff(w http.ResponseWriter, r *http.Request, status int) {
@@ -68,10 +68,10 @@ func (a *App) failDirectoryHandoff(w http.ResponseWriter, r *http.Request, statu
reason = "unavailable"
}
a.logger.Warn("home handoff failed", "stage", stage, "reason", reason)
- a.renderHandoffFailure(w, status)
+ a.renderHandoffFailure(w, r, status)
}
-func (a *App) renderHandoffFailure(w http.ResponseWriter, status int) {
+func (a *App) renderHandoffFailure(w http.ResponseWriter, r *http.Request, status int) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, status, "handoff_failed.html", map[string]any{"HomeURL": a.origins.Directory})
+ a.render(w, r, status, "handoff_failed.html", map[string]any{"HomeURL": a.origins.Directory})
}
diff --git a/internal/home/handoff_reveal_test.go b/internal/home/handoff_reveal_test.go
index 28bf21c..3577e79 100644
--- a/internal/home/handoff_reveal_test.go
+++ b/internal/home/handoff_reveal_test.go
@@ -16,7 +16,7 @@ import (
func TestHandoffInterstitialStaysHiddenWhileItAutoSubmits(t *testing.T) {
app := newTestApp(t)
var buf bytes.Buffer
- if err := app.renderer.pages["assertion.html"].ExecuteTemplate(&buf, "layout", map[string]any{"Action": "https://blue.docs.oficina.works/auth/assertion", "Token": "sample-token"}); err != nil {
+ if err := app.renderer.pages["assertion.html"].ExecuteTemplate(&buf, "layout", page{Data: map[string]any{"Action": "https://blue.docs.oficina.works/auth/assertion", "Token": "sample-token"}}); err != nil {
t.Fatal(err)
}
page := buf.String()
@@ -35,12 +35,15 @@ func TestHandoffInterstitialStaysHiddenWhileItAutoSubmits(t *testing.T) {
}
}
// Everything the person could read or press is inside the hidden
- // wrapper, and the form the script submits is still the one there.
+ // card, and the form the script submits is still the one there. The
+ // bar and the sky's placeholder sit outside it, so the hop is never a
+ // blank page (r3 C2).
main := body[strings.Index(body, "<main"):strings.Index(body, "</main>")]
- inner := strings.TrimSpace(main[strings.Index(main, ">")+1:])
- if !strings.HasPrefix(inner, "<div estilo-handoff>") || !strings.HasSuffix(inner, "</div>") {
- t.Fatalf("content not wrapped:\n%s", main)
+ start := strings.Index(main, "<section estilo-signin estilo-handoff>")
+ if start < 0 || !strings.Contains(main[:start], `estilo-sky-layer="placeholder"`) {
+ t.Fatalf("content not wrapped in the hidden card on the sky:\n%s", main)
}
+ inner := main[start:strings.Index(main, "</section>")]
if !strings.Contains(inner, `data-assertion-handoff`) || !strings.Contains(inner, `<button type="submit" rst-btn="primary">Continue</button>`) {
t.Fatalf("hand-off form changed:\n%s", inner)
}
diff --git a/internal/home/origins_test.go b/internal/home/origins_test.go
index 7563a87..c5631bf 100644
--- a/internal/home/origins_test.go
+++ b/internal/home/origins_test.go
@@ -23,6 +23,8 @@ var sharedPublicPaths = []string{
"/static/estilo/fonts/v1/*", "/static/estilo/sky/v1/*", "/static/estilo/sky.css", "/static/calma.css",
"/static/busy.css", "/static/busy.js",
"/static/site/home.css", "/static/site/apps.css",
+ // estilo's shared chrome: base links, menus, the bar and the door.
+ "/static/estilo/base.css", "/static/estilo/menu.css", "/static/estilo/chrome.css",
}
// examplePath is a real file under a wildcard route, so fetching it
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index 1a026ad..87a084c 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -135,7 +135,7 @@ func (a *App) passkeyPageData(subject string, extra map[string]any) map[string]a
// The recovery form is a plain POST and works with script off, which is
// the point — recovery is exactly the moment WebAuthn is not working.
func (a *App) handlePasskeyConfirm(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{
+ a.render(w, r, http.StatusOK, "passkey_confirm.html", a.passkeyPageData("", map[string]any{
"RecoveryFailed": r.URL.Query().Get("recovery") == "failed",
}))
}
@@ -148,7 +148,7 @@ func (a *App) handlePasskeyEnrol(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/signin", http.StatusSeeOther)
return
}
- a.render(w, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil))
+ a.render(w, r, http.StatusOK, "passkey_enrol.html", a.passkeyPageData(sess.Subject, nil))
}
// handleRecoveryCodesPage shows how many codes are left, and the form
@@ -168,10 +168,10 @@ func (a *App) handleRecoveryCodesPage(w http.ResponseWriter, r *http.Request) {
remaining, err := a.passkeys.RecoveryCodesRemaining(sess.Subject)
if err != nil {
a.logger.Error("count recovery codes", "person", sess.Subject, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.serverError(w, r)
return
}
- a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining})
+ a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Remaining": remaining})
}
// handleRegenerateRecoveryCodes mints a fresh set and shows it once.
@@ -184,10 +184,10 @@ func (a *App) handleRegenerateRecoveryCodes(w http.ResponseWriter, r *http.Reque
codes, err := a.passkeys.RegenerateRecoveryCodes(sess.Subject)
if err != nil {
a.logger.Error("regenerate recovery codes", "person", sess.Subject, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.serverError(w, r)
return
}
- a.render(w, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes})
+ a.render(w, r, http.StatusOK, "recovery_codes.html", map[string]any{"Codes": codes})
}
// serveAsset serves one of the two script files.
diff --git a/internal/home/render.go b/internal/home/render.go
index 0a6fac2..f63ed7f 100644
--- a/internal/home/render.go
+++ b/internal/home/render.go
@@ -9,7 +9,9 @@ import (
"net/http"
"path"
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/credits"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/rastrillo/rastrillo/ui"
)
@@ -67,6 +69,14 @@ func newRenderer() (*renderer, error) {
if t, err = t.ParseFS(sky.Templates(), "templates/*.html"); err != nil {
return nil, fmt.Errorf("parse sky partials: %w", err)
}
+ // The shared chrome: the bar, the account menu, the door and the
+ // error page (chrome.go).
+ if t, err = t.ParseFS(menu.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("parse menu partials: %w", err)
+ }
+ if t, err = t.ParseFS(chrome.Templates(), "templates/*.html"); err != nil {
+ return nil, fmt.Errorf("parse chrome partials: %w", err)
+ }
if t, err = t.ParseFS(templatesFS, f); err != nil {
return nil, fmt.Errorf("parse %s: %w", f, err)
}
@@ -78,28 +88,44 @@ func newRenderer() (*renderer, error) {
return r, nil
}
-// render writes one page.
+// render writes one page in the request's chrome (chrome.go).
+func (a *App) render(w http.ResponseWriter, r *http.Request, status int, page string, data any) {
+ a.renderPage(w, r, status, page, data)
+}
+
+// renderPage writes one page.
//
// It renders to a buffer first. A template that fails halfway through
// would otherwise have already written a 200 and half a page, and the
// error handler could only append to it — so the status code would say
-// the opposite of what happened.
-func (a *App) render(w http.ResponseWriter, status int, page string, data any) {
- t, ok := a.renderer.pages[page]
+// the opposite of what happened. A page that fails is answered with the
+// 500 page; if that one fails too, with plain text.
+func (a *App) renderPage(w http.ResponseWriter, r *http.Request, status int, name string, data any) {
+ t, ok := a.renderer.pages[name]
if !ok {
- a.logger.Error("no such page template", "page", page)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ a.logger.Error("no such page template", "page", name)
+ a.renderFailed(w, r, name)
return
}
var buf bytes.Buffer
- if err := t.ExecuteTemplate(&buf, "layout", data); err != nil {
- a.logger.Error("render", "page", page, "err", err)
- http.Error(w, "internal error", http.StatusInternalServerError)
+ if err := t.ExecuteTemplate(&buf, "layout", a.pageFor(r, data, doorPages[name], hopPages[name])); err != nil {
+ a.logger.Error("render", "page", name, "err", err)
+ a.renderFailed(w, r, name)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(status)
if _, err := buf.WriteTo(w); err != nil {
- a.logger.Warn("write response", "page", page, "err", err)
+ a.logger.Warn("write response", "page", name, "err", err)
+ }
+}
+
+// renderFailed answers a page that could not be drawn: the 500 page,
+// unless the page that failed was the error page itself.
+func (a *App) renderFailed(w http.ResponseWriter, r *http.Request, name string) {
+ if name == "error.html" || r == nil {
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
}
+ a.serverError(w, r)
}
diff --git a/internal/home/shared_chrome_test.go b/internal/home/shared_chrome_test.go
index 644f54f..95feba1 100644
--- a/internal/home/shared_chrome_test.go
+++ b/internal/home/shared_chrome_test.go
@@ -207,8 +207,8 @@ func TestErrorPagesSplitByAccept(t *testing.T) {
}
for _, accept := range []string{"", "*/*", "application/json"} {
rec := chromeGet(c.h, c.target, accept, c.cookie)
- if rec.Code != http.StatusNotFound || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
- t.Errorf("Accept %q: %d %q, want a plain 404", accept, rec.Code, rec.Header().Get("Content-Type"))
+ if (rec.Code != http.StatusNotFound && rec.Code != http.StatusMethodNotAllowed) || !strings.HasPrefix(rec.Header().Get("Content-Type"), "text/plain") {
+ t.Errorf("Accept %q: %d %q, want a plain 404 or 405", accept, rec.Code, rec.Header().Get("Content-Type"))
}
}
})
diff --git a/internal/home/signin.go b/internal/home/signin.go
index 806797f..adee4bc 100644
--- a/internal/home/signin.go
+++ b/internal/home/signin.go
@@ -4,6 +4,7 @@ import (
"errors"
"net"
"net/http"
+ "net/url"
"strings"
"time"
@@ -161,14 +162,25 @@ func clientIP(r *http.Request) string {
// handleSigninForm renders the address form.
func (a *App) handleSigninForm(w http.ResponseWriter, r *http.Request) {
rememberReturn(w, r)
- a.render(w, http.StatusOK, "signin.html", map[string]any{})
+ a.render(w, r, http.StatusOK, "signin.html", map[string]any{})
}
// handleSigninSent is the check-your-mail page. It is reached by
// redirect and asserts nothing about the address, so it is the same page
// whether or not anybody has an account here.
func (a *App) handleSigninSent(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "check_your_mail.html", map[string]any{})
+ a.render(w, r, http.StatusOK, "check_your_mail.html", map[string]any{"Again": againHref(r)})
+}
+
+// againHref is the way back to the form that keeps where the sign-in was
+// going: "Use a different address" and "Start again" (r3 C1). A bare
+// /signin forgets return_to (rememberReturn clears it), so the next link
+// ended at id.* "/" and the person on the signed-out front page.
+func againHref(r *http.Request) string {
+ if target := readReturn(r); target != "/" {
+ return "/signin?return_to=" + url.QueryEscape(target)
+ }
+ return "/signin"
}
// handleSigninBegin starts a sign-in.
@@ -180,7 +192,7 @@ func (a *App) handleSigninSent(w http.ResponseWriter, r *http.Request) {
// neither discloses whether that person has an account here.
func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- a.render(w, http.StatusBadRequest, "signin.html",
+ a.render(w, r, http.StatusBadRequest, "signin.html",
map[string]any{"Error": "That did not look like a form."})
return
}
@@ -197,25 +209,25 @@ func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
next, err := a.flow.Begin(r.Context(), address, clientIP(r), force)
switch {
case errors.Is(err, signin.ErrBadAddress):
- a.render(w, http.StatusBadRequest, "signin.html",
+ a.render(w, r, http.StatusBadRequest, "signin.html",
map[string]any{"Error": "That does not look like an email address."})
return
case errors.Is(err, signin.ErrRateLimited):
// 429 without saying which budget: which one was hit is itself
// information about whether that address has been tried.
- a.render(w, http.StatusTooManyRequests, "signin.html",
+ a.render(w, r, http.StatusTooManyRequests, "signin.html",
map[string]any{"Error": "Too many attempts. Try again shortly."})
return
case err != nil:
a.logger.Error("signin begin", "err", err)
- a.render(w, http.StatusInternalServerError, "signin.html",
+ a.render(w, r, http.StatusInternalServerError, "signin.html",
map[string]any{"Error": "Something went wrong. Try again."})
return
}
if next.Kind == signin.NextKeymail {
a.setPending(w, next.Pending)
- a.renderContinuation(w, next.Redirect, "Continue to Keymail")
+ a.renderContinuation(w, r, next.Redirect, "Continue to Keymail")
return
}
http.Redirect(w, r, "/signin/sent", http.StatusSeeOther)
@@ -236,14 +248,14 @@ func (a *App) handleSigninBegin(w http.ResponseWriter, r *http.Request) {
// from an invalid one, which is exactly the difference this page exists
// not to have.
func (a *App) handleVerifyForm(w http.ResponseWriter, r *http.Request) {
- a.render(w, http.StatusOK, "signin_confirm.html",
+ a.render(w, r, http.StatusOK, "signin_confirm.html",
map[string]any{"Token": r.URL.Query().Get("token")})
}
// handleVerify redeems a magic link. Behind csrf.Protect, on POST.
func (a *App) handleVerify(w http.ResponseWriter, r *http.Request) {
if err := r.ParseForm(); err != nil {
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
id, err := a.flow.CompleteMagicLink(r.Context(), r.PostFormValue("token"))
@@ -251,7 +263,7 @@ func (a *App) handleVerify(w http.ResponseWriter, r *http.Request) {
// Unknown, expired and already-used are one answer. TakeLink
// makes them one answer at the store; this keeps them one
// answer at the door.
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
a.completeSignIn(w, r, id)
@@ -305,7 +317,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
ref, err := a.findOrMintPerson(r.Context(), id.Address)
if err != nil {
a.logger.Error("find or mint person", "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
sess := sessions.Session{
@@ -331,7 +343,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
gated, err := a.passkeys.Gate(w, requestWithReturn(r, target), sess)
if err != nil {
a.logger.Error("passkey gate", "person", ref, "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
if gated {
@@ -340,7 +352,7 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
if err := a.sessions.SignIn(w, r, sess); err != nil {
a.logger.Error("mint session", "person", ref, "err", err)
- a.signinFailed(w)
+ a.signinFailed(w, r)
return
}
http.Redirect(w, r, target, http.StatusSeeOther)
@@ -349,8 +361,8 @@ func (a *App) completeSignIn(w http.ResponseWriter, r *http.Request, id signin.I
// signinFailed is the one answer every failed redemption gets. One page,
// one status, no detail: unknown, expired, already used and
// wrong-purpose must be indistinguishable from outside.
-func (a *App) signinFailed(w http.ResponseWriter) {
- a.render(w, http.StatusBadRequest, "signin_failed.html", map[string]any{})
+func (a *App) signinFailed(w http.ResponseWriter, r *http.Request) {
+ a.render(w, r, http.StatusBadRequest, "signin_failed.html", map[string]any{"Again": againHref(r)})
}
func (a *App) setPending(w http.ResponseWriter, blob string) {
diff --git a/internal/home/signout.go b/internal/home/signout.go
index 246e22f..c7da8e2 100644
--- a/internal/home/signout.go
+++ b/internal/home/signout.go
@@ -54,14 +54,14 @@ func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) {
back, verdict := a.signoutReturn(r)
switch verdict {
case signoutRefused:
- a.render(w, http.StatusForbidden, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusForbidden, "signout_confirm.html", nil)
return
case signoutConfirm:
// Admitted, but the id.* cookie did not ride this hop, so there is
// no row to revoke yet. Clearing the cookie now would leave the
// row alive behind it; keep it, and let the same-origin button
// (which does carry the cookie) end the session.
- a.render(w, http.StatusOK, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
return
}
a.sessions.SignOut(w, r)
@@ -73,7 +73,7 @@ func (a *App) handleCredentialSignout(w http.ResponseWriter, r *http.Request) {
// a GET is one any link or <img> can trigger.
func (a *App) handleCredentialSignoutPage(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
- a.render(w, http.StatusOK, "signout_confirm.html", nil)
+ a.render(w, r, http.StatusOK, "signout_confirm.html", nil)
}
// signoutReturn admits a sign-out POST from this origin's own pages or
diff --git a/internal/home/static/calma.css b/internal/home/static/calma.css
index be2365a..0e54152 100644
--- a/internal/home/static/calma.css
+++ b/internal/home/static/calma.css
@@ -1,6 +1,6 @@
-/* Home's own Calma rules: where the sky sits on Home's pages, and the
- card the pages at the door are drawn as. Everything else is estilo's
- theme (theme.css) and rastrillo's tokens.css. */
+/* Home's own Calma rules: the team home's header on the sky, and its
+ grid of apps. The bar, the menus and the door are estilo's (chrome.css),
+ the rest estilo's theme (theme.css) and rastrillo's tokens.css. */
/* tokens.css reserves the scrollbar's gutter on every page, so short and
long pages are the same width. Under the sky that reserved strip is a
@@ -10,67 +10,38 @@
--rst-scrollbar-gutter: auto;
}
-/* The sky hangs behind the top of every Home page, fading out before the
- content gets busy. The host is at least as tall as the part of the sky
- that shows, so a short page does not clip it. */
-[home-sky] {
- --estilo-sky-top: -140px;
- --estilo-sky-height: 560px;
- min-height: 420px;
-}
+/* The door (sign-in, id.*'s account pages, signed out) is estilo's:
+ chrome.css draws the card on the sky under the bar. */
-/* At the door: one card on the sky, the Oficina mark at its head. */
-main[home-door] {
- box-sizing: border-box;
- max-width: 460px;
- margin: max(40px, 11vh) auto 48px;
- padding: 34px 40px 32px;
- background: var(--rst-surface);
- border-radius: 20px;
- box-shadow: 0 0 0 1px light-dark(transparent, var(--rst-line)), var(--rst-shadow-lift);
-}
-main[home-door] > [estilo-brand] {
- margin: 0 0 22px;
- font-size: 1.2rem;
- font-weight: 650;
- letter-spacing: -0.01em;
-}
-main[home-door] h1 {
- letter-spacing: -0.02em;
-}
-@media (max-width: 520px) {
- main[home-door] {
- margin: 28px 14px 40px;
- padding: 26px 22px 24px;
- }
-}
+/* Your home: the team home's header on the sky, then a grid of every
+ app, each one tile. An app you can open is a link across its whole
+ tile; one you cannot yet is shown quieter, with its status saying why.
-/* Your home: the Oficina mark and a grid of every app, each one tile. An
- app you can open is a link across its whole tile; one you cannot yet
- is shown quieter, with its status saying why. */
+ The header spans the page under the bar, as the door does, so the sky
+ is never boxed in; its padding puts the words back on the page's
+ column. The sky fades in and out inside the band (sky.css's mask), so
+ neither edge cuts it. */
+:root:has([home-hub-head]) { overflow-x: clip; }
[home-hub-head] {
- display: flex;
- align-items: center;
- justify-content: space-between;
- gap: 16px;
- margin: 28px 0 8px;
+ box-sizing: border-box;
+ margin: calc(-1 * var(--rst-sp-5)) calc(50% - 50vw) var(--rst-sp-5);
+ padding: 64px calc(50vw - 50%) 48px;
+ min-block-size: 240px;
+ /* A sky's layers overhang its box by 12% above and below and fade out
+ from 58% to 92% (sky.css): 260px placed 50px up has faded out by
+ about 220px, inside the band's 240. */
+ --estilo-sky-top: -50px;
+ --estilo-sky-height: 260px;
}
[home-hub-head] h1 {
- margin: 0;
+ margin: 0 0 8px;
font-size: clamp(1.9rem, 4vw, 2.5rem);
letter-spacing: -0.03em;
}
-[home-hub-head] form { margin: 0; }
[home-hub-lede] {
- margin: 0 0 40px;
+ margin: 0;
color: var(--rst-text-muted);
}
-[home-hub-h] {
- margin: 0 0 12px;
- color: var(--rst-text-faint);
- font-size: 0.95rem;
- font-weight: 600;
-}
[home-apps] {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
@@ -101,11 +72,15 @@ a[home-app]:hover { box-shadow: 0 0 0 1px var(--rst-line), var(--rst-shadow-lift
div[home-app] img { filter: saturate(0.6); opacity: 0.8; }
/* In dark, the sky runs brighter (estilo's gain) and muted text on its
- brightest band falls under 4.5:1 (review, discussion 75). So the sky
- is quieter here, and text that sits on it is full-strength. */
+ brightest band falls under 4.5:1 (review, discussion 75). So the team
+ home's sky is quieter here, and text that sits on it is full-strength.
+ The door's text sits on its card, so the door keeps estilo's sky. */
@media (prefers-color-scheme: dark) {
- :root:not([data-theme="light"]) [home-sky] { --estilo-sky-strength: 0.32; }
+ :root:not([data-theme="light"]) [home-hub-head] { --estilo-sky-strength: 0.32; }
:root:not([data-theme="light"]) [home-hub-lede] { color: var(--rst-text); }
}
-:root[data-theme="dark"] [home-sky] { --estilo-sky-strength: 0.32; }
+:root[data-theme="dark"] [home-hub-head] { --estilo-sky-strength: 0.32; }
:root[data-theme="dark"] [home-hub-lede] { color: var(--rst-text); }
+
+/* The team chooser: one button per team, stacked. */
+[home-teams] { display: grid; justify-items: start; gap: var(--rst-sp-2); }
diff --git a/internal/home/static/site/index.html b/internal/home/static/site/index.html
index 59719dd..1f6695d 100644
--- a/internal/home/static/site/index.html
+++ b/internal/home/static/site/index.html
@@ -3,8 +3,16 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
-<title>Oficina — open your new office suite</title>
+<title>Oficina</title>
<meta name="description" content="Oficina is a fast, lightweight office suite that values your privacy. Docs, Sheets, Slides, Calendar, Calls and Memoria, in the browser and as native Mac apps.">
+<link rel="preload" href="/static/estilo/fonts/v1/onest-latin.woff2" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="/static/brand/icon.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/docs.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/sheets.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/slides.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/calendar.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/calls.svg" as="image" type="image/svg+xml">
+<link rel="preload" href="/static/brand/memoria.svg" as="image" type="image/svg+xml">
<link rel="icon" type="image/png" sizes="32x32" href="/static/brand/icon-32.png">
<link rel="icon" type="image/svg+xml" href="/static/brand/icon.svg">
<link rel="apple-touch-icon" sizes="180x180" href="/static/brand/apple-touch-icon.png">
diff --git a/internal/home/templates/assertion.html b/internal/home/templates/assertion.html
index 6b1e5e6..3c7887a 100644
--- a/internal/home/templates/assertion.html
+++ b/internal/home/templates/assertion.html
@@ -1,10 +1,9 @@
{{define "title"}}Continue to Oficina · Oficina{{end}}
{{define "head"}}
-{{/* The page submits itself (static/assertion.js), so its fallback copy
- stays hidden for 1.5 s and shows only if the submit stalls. With
- scripts off nothing submits, so noscript shows it at once. Spec
- shared-chrome §9.3; the rule moves to estilo chrome.css when the
- id.* pages adopt the shared chrome. */}}
+{{/* The page submits itself (static/assertion.js), so its card stays
+ hidden for 1.5 s and shows only if the submit stalls; the bar and the
+ sky's placeholder show at once (layout.html, hop). With scripts off nothing submits, so noscript shows it
+ at once. Spec shared-chrome §9.3. */}}
<style>
[estilo-handoff] { animation: estilo-reveal 0s 1.5s both; }
@keyframes estilo-reveal { from { visibility: hidden } to { visibility: visible } }
@@ -12,13 +11,11 @@
<noscript><style>[estilo-handoff] { animation: none; }</style></noscript>
{{end}}
{{define "content"}}
-<div estilo-handoff>
<h1>Continue to Oficina</h1>
-<p>Select Continue if this page does not move on.</p>
-<form method="post" action="{{.Action}}" rst-form data-assertion-handoff>
-<input type="hidden" name="assertion" value="{{.Token}}">
+<p estilo-signin-body>Select Continue if this page doesn’t move on.</p>
+<form method="post" action="{{.Data.Action}}" rst-form data-assertion-handoff>
+<input type="hidden" name="assertion" value="{{.Data.Token}}">
<button type="submit" rst-btn="primary">Continue</button>
</form>
-</div>
{{end}}
{{define "scripts"}}<script src="/static/assertion.js" defer></script>{{end}}
diff --git a/internal/home/templates/check_your_mail.html b/internal/home/templates/check_your_mail.html
index 517f714..740f16a 100644
--- a/internal/home/templates/check_your_mail.html
+++ b/internal/home/templates/check_your_mail.html
@@ -1,6 +1,6 @@
-{{define "title"}}{{sentDoor.Title}}{{end}}
-{{define "content"}}{{with sentDoor}}
+{{define "title"}}{{(sentDoor .Data.Again).Title}}{{end}}
+{{define "content"}}{{with sentDoor .Data.Again}}
<h1>{{.Heading}}</h1>
-<p>{{.Body}}</p>
-<p><a href="{{.Link.Href}}">{{.Link.Label}}</a></p>
+<p estilo-signin-body role="status">{{.Body}}</p>
+{{with .Link}}<p><a href="{{.Href}}">{{.Label}}</a></p>{{end}}
{{end}}{{end}}
diff --git a/internal/home/templates/credits.html b/internal/home/templates/credits.html
index 596f7dd..11b27e4 100644
--- a/internal/home/templates/credits.html
+++ b/internal/home/templates/credits.html
@@ -1,5 +1,3 @@
{{define "title"}}Open source · Oficina{{end}}
{{define "head"}}<link rel="stylesheet" href="{{v "/static/credits.css"}}">{{end}}
-{{define "content"}}{{template "estilo/credits" .}}{{end}}
-
-{{define "help"}}{{template "home/help" .}}{{end}}
+{{define "content"}}{{template "estilo/credits" .Data}}{{end}}
diff --git a/internal/home/templates/directory.html b/internal/home/templates/directory.html
index a085be3..6ea2d73 100644
--- a/internal/home/templates/directory.html
+++ b/internal/home/templates/directory.html
@@ -1,14 +1,14 @@
{{define "title"}}Oficina{{end}}
{{define "content"}}
-<header home-hub-head>
-<h1 estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="40" height="40">Oficina</h1>
-<form method="post" action="/signout"><button type="submit" rst-btn>Sign out</button></form>
+{{/* The team home's header: the one place inside the hub, beside the
+ door and the front page's hero, where Calma's sky goes. */}}
+<header home-hub-head estilo-sky-host>
+{{template "estilo-sky" suiteSky}}
+<h1>Your apps</h1>
+<p home-hub-lede>Every app here opens with the account you’re signed in with.</p>
</header>
-<p home-hub-lede>You are signed in. Every app below opens with this one account.</p>
-<section aria-labelledby="apps-heading">
-<h2 id="apps-heading" home-hub-h>Apps</h2>
-<ul home-apps>
-{{range .Apps}}
+<ul home-apps aria-label="Apps">
+{{range .Data.Apps}}
<li>{{if .Href}}<a home-app href="{{.Href}}">{{else}}<div home-app>{{end}}
{{if .Icon}}<img src="{{.Icon}}" alt="" width="56" height="56">{{end}}
<b>{{.Name}}</b>
@@ -16,7 +16,4 @@
{{if .Href}}</a>{{else}}</div>{{end}}</li>
{{end}}
</ul>
-</section>
{{end}}
-
-{{define "help"}}{{template "home/help" .}}{{end}}
diff --git a/internal/home/templates/directory_signed_out.html b/internal/home/templates/directory_signed_out.html
index 19d79fe..d427a1a 100644
--- a/internal/home/templates/directory_signed_out.html
+++ b/internal/home/templates/directory_signed_out.html
@@ -1,6 +1,6 @@
{{define "title"}}{{signedOutDoor.Title}}{{end}}
{{define "content"}}{{with signedOutDoor}}
<h1>{{.Heading}}</h1>
-<p>{{.Body}}</p>
-<a href="{{.Link.Href}}" rst-btn="primary">{{.Link.Label}}</a>
+<p estilo-signin-body>{{.Body}}</p>
+<p><a href="{{.Link.Href}}" rst-btn="primary">{{.Link.Label}}</a></p>
{{end}}{{end}}
diff --git a/internal/home/templates/docs_chooser.html b/internal/home/templates/docs_chooser.html
index eec274c..6d708fe 100644
--- a/internal/home/templates/docs_chooser.html
+++ b/internal/home/templates/docs_chooser.html
@@ -1,9 +1,9 @@
{{define "title"}}Choose a team · Oficina{{end}}
{{define "content"}}
<h1>Choose a team</h1>
-<p>Open {{.AppName}} for one of your teams.</p>
-<form method="post" action="{{.ChoicePath}}" rst-form>
-{{range .Choices}}
+<p>Open {{.Data.AppName}} for one of your teams.</p>
+<form method="post" action="{{.Data.ChoicePath}}" rst-form home-teams>
+{{range .Data.Choices}}
<button type="submit" name="team" value="{{.SQID}}" rst-btn>{{.Name}}</button>
{{end}}
</form>
diff --git a/internal/home/templates/docs_continue.html b/internal/home/templates/docs_continue.html
index 89695a2..255e33b 100644
--- a/internal/home/templates/docs_continue.html
+++ b/internal/home/templates/docs_continue.html
@@ -1,6 +1,6 @@
-{{define "title"}}{{.Title}}{{end}}
-{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Destination}}">{{end}}
+{{define "title"}}{{.Data.Title}} · Oficina{{end}}
+{{define "head"}}<meta http-equiv="refresh" content="0;url={{.Data.Destination}}">{{end}}
{{define "content"}}
-<h1>{{.Title}}</h1>
-<p><a href="{{.Destination}}" referrerpolicy="no-referrer">{{.Title}}</a></p>
+<h1>{{.Data.Title}}</h1>
+<p estilo-signin-body><a href="{{.Data.Destination}}" referrerpolicy="no-referrer">{{.Data.Title}}</a></p>
{{end}}
diff --git a/internal/home/templates/docs_empty.html b/internal/home/templates/docs_empty.html
index d3df80d..dea27b9 100644
--- a/internal/home/templates/docs_empty.html
+++ b/internal/home/templates/docs_empty.html
@@ -1,6 +1,6 @@
{{define "title"}}No teams available · Oficina{{end}}
{{define "content"}}
<h1>No teams available</h1>
-<p>This account has no team with {{.AppName}} available.</p>
+<p>This account has no team with {{.Data.AppName}} available.</p>
<p><a href="/">Back to Oficina</a></p>
{{end}}
diff --git a/internal/home/templates/error.html b/internal/home/templates/error.html
new file mode 100644
index 0000000..1f20e63
--- /dev/null
+++ b/internal/home/templates/error.html
@@ -0,0 +1,2 @@
+{{define "title"}}{{.Data.Title}}{{end}}
+{{define "content"}}{{template "estilo-error" .Data.Error}}{{end}}
diff --git a/internal/home/templates/handoff_failed.html b/internal/home/templates/handoff_failed.html
index fa7ddcd..a920954 100644
--- a/internal/home/templates/handoff_failed.html
+++ b/internal/home/templates/handoff_failed.html
@@ -1,6 +1,6 @@
{{define "title"}}Could not finish signing in · Oficina{{end}}
{{define "content"}}
<h1>Could not finish signing in</h1>
-<p>Return to Oficina and open the app again.</p>
-<p><a href="{{.HomeURL}}">Back to Oficina</a></p>
+<p estilo-signin-body>Return to Oficina and open the app again.</p>
+<p><a href="{{.Data.HomeURL}}">Back to Oficina</a></p>
{{end}}
diff --git a/internal/home/templates/layout.html b/internal/home/templates/layout.html
index 273382f..a3493f5 100644
--- a/internal/home/templates/layout.html
+++ b/internal/home/templates/layout.html
@@ -1,28 +1,49 @@
{{define "layout"}}<!doctype html>
-<html lang="{{block "lang" .}}en{{end}}">
+<html lang="en"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
-<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}">
-<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}">
-<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}">
-<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{block "title" .}}Oficina{{end}}</title>
+{{/* A cold first load paints in Onest and with the mark (r3 C4): both are
+ fetched before the stylesheets that name them are parsed. */}}
+<link rel="preload" href="{{fontPreload}}" as="font" type="font/woff2" crossorigin>
+<link rel="preload" href="{{v "/static/brand/icon.svg"}}" as="image" type="image/svg+xml">
+<link rel="icon" type="image/png" sizes="32x32" href="{{v "/static/brand/icon-32.png"}}">
+<link rel="icon" type="image/svg+xml" href="{{v "/static/brand/icon.svg"}}">
+<link rel="apple-touch-icon" sizes="180x180" href="{{v "/static/brand/apple-touch-icon.png"}}">
+{{/* The spec's order (shared-chrome §0.5): tokens, theme, brand, estilo's
+ base, menu and chrome, then the sky and Home's own rules last. */}}
<link rel="stylesheet" href="{{fontsCSS}}">
<link rel="stylesheet" href="{{v "/static/tokens.css"}}">
<link rel="stylesheet" href="{{v "/static/theme.css"}}">
+<link rel="stylesheet" href="{{v "/static/brand/brand.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/base.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/menu.css"}}">
+<link rel="stylesheet" href="{{v "/static/estilo/chrome.css"}}">
<link rel="stylesheet" href="{{v "/static/busy.css"}}">
<link rel="stylesheet" href="{{v "/static/estilo/sky.css"}}">
<link rel="stylesheet" href="{{v "/static/calma.css"}}">
{{block "head" .}}{{end}}
</head>
<body{{block "bodyattrs" .}}{{end}}>
-<div estilo-sky-host home-sky>
-{{if not hop}}{{template "estilo-sky" suiteSky}}{{end}}
-{{block "help" .}}{{end}}
-<main rst-page{{if door}} home-door{{end}}>
-{{if door}}<p estilo-brand><img estilo-brand-icon src="{{v "/static/brand/icon.svg"}}" alt="" width="36" height="36">Oficina</p>{{end}}
+<div estilo-shell>
+{{if not hop}}<a rst-skip href="#main">{{.Chrome.Labels.Skip}}</a>{{end}}
+{{template "estilo-bar" .Chrome}}
+<main rst-page id="main">
+{{- if door}}
+{{template "estilo-door-open" doorView}}
+{{template "content" .}}
+{{template "estilo-door-close"}}
+{{- else if hop}}
+<div estilo-door estilo-sky-host>
+{{template "estilo-sky" hopSky}}
+<section estilo-signin estilo-handoff>
+{{template "content" .}}
+</section>
+</div>
+{{- else}}
{{template "content" .}}
+{{- end}}
</main>
</div>
{{if not hop}}<script defer src='{{v "/static/busy.js"}}'></script>{{end}}
@@ -30,5 +51,3 @@
</body>
</html>
{{end}}
-
-{{define "home/help"}}<header rst-page><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></header>{{end}}
diff --git a/internal/home/templates/passkey_confirm.html b/internal/home/templates/passkey_confirm.html
index e6de3f2..5eb7ffa 100644
--- a/internal/home/templates/passkey_confirm.html
+++ b/internal/home/templates/passkey_confirm.html
@@ -1,9 +1,9 @@
{{define "title"}}Confirm it is you · Oficina{{end}}
-{{define "bodyattrs"}} data-rpid="{{.RPID}}" data-legacyrpid="{{.LegacyRPID}}"{{end}}
+{{define "bodyattrs"}} data-rpid="{{.Data.RPID}}" data-legacyrpid="{{.Data.LegacyRPID}}"{{end}}
{{define "content"}}
<h1>Confirm it is you</h1>
-{{if .RecoveryFailed}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>That code did not work.</span></p>{{end}}
+{{if .Data.RecoveryFailed}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>That code did not work.</span></p>{{end}}
<button id="passkey-confirm" type="button" rst-btn="primary" data-passkey hidden>Use your passkey</button>
<p id="passkey-status" role="status"></p>
diff --git a/internal/home/templates/passkey_enrol.html b/internal/home/templates/passkey_enrol.html
index a2edcb5..09a06d3 100644
--- a/internal/home/templates/passkey_enrol.html
+++ b/internal/home/templates/passkey_enrol.html
@@ -1,5 +1,5 @@
{{define "title"}}Add a passkey · Oficina{{end}}
-{{define "bodyattrs"}} data-rpid="{{.RPID}}" data-userid="{{.UserID}}" data-username="{{.UserName}}"{{end}}
+{{define "bodyattrs"}} data-rpid="{{.Data.RPID}}" data-userid="{{.Data.UserID}}" data-username="{{.Data.UserName}}"{{end}}
{{define "content"}}
<h1>Add a passkey</h1>
<p>A passkey is an optional second step. Without one, your email link is
diff --git a/internal/home/templates/recovery_codes.html b/internal/home/templates/recovery_codes.html
index d88ca24..36c0bc0 100644
--- a/internal/home/templates/recovery_codes.html
+++ b/internal/home/templates/recovery_codes.html
@@ -1,14 +1,14 @@
{{define "title"}}Recovery codes · Oficina{{end}}
{{define "content"}}
<h1>Recovery codes</h1>
-{{if .Codes}}
+{{if .Data.Codes}}
<p rst-callout rst-tone="warning"><span rst-callout-body><strong>These are shown once.</strong> Keep them somewhere your
passkey is not. Each one works a single time.</span></p>
-<ul>{{range .Codes}}<li><code>{{.}}</code></li>{{end}}</ul>
+<ul>{{range .Data.Codes}}<li><code>{{.}}</code></li>{{end}}</ul>
<p>Losing your passkey and these codes together locks you out permanently.
There is no operator who can undo that, by design.</p>
{{else}}
-<p>You have {{.Remaining}} unused recovery codes.</p>
+<p>You have {{.Data.Remaining}} unused recovery codes.</p>
<p>Generating a new set <strong>invalidates every code you have now</strong>.</p>
<form method="post" action="/passkey/codes">
<button type="submit" rst-btn="danger">Generate a new set</button>
diff --git a/internal/home/templates/signin.html b/internal/home/templates/signin.html
index 373cdff..b871679 100644
--- a/internal/home/templates/signin.html
+++ b/internal/home/templates/signin.html
@@ -1,8 +1,8 @@
{{define "title"}}{{signinDoor.Title}}{{end}}
{{define "content"}}{{with signinDoor}}
<h1>{{.Heading}}</h1>
-{{if $.Error}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>{{$.Error}}</span></p>{{end}}
-<form method="post" action="/signin" rst-form>
+{{if $.Data.Error}}<p role="alert" rst-callout rst-tone="negative"><span rst-callout-body>{{$.Data.Error}}</span></p>{{end}}
+<form method="post" action="/signin" rst-form estilo-signin-form>
<div rst-field>
<label rst-field-label for="address">{{.AddressLabel}}</label>
<input id="address" name="address" type="email" autocomplete="email" required autofocus rst-input aria-describedby="address-help">
diff --git a/internal/home/templates/signin_confirm.html b/internal/home/templates/signin_confirm.html
index b27fe86..c70466e 100644
--- a/internal/home/templates/signin_confirm.html
+++ b/internal/home/templates/signin_confirm.html
@@ -3,7 +3,7 @@
<h1>Finish signing in</h1>
<p>Press the button to sign in on this device.</p>
<form method="post" action="/signin/verify" rst-form>
- <input type="hidden" name="token" value="{{.Token}}">
+ <input type="hidden" name="token" value="{{.Data.Token}}">
<div rst-form-foot>
<button type="submit" rst-btn="primary">Sign in</button>
</div>
diff --git a/internal/home/templates/signin_failed.html b/internal/home/templates/signin_failed.html
index d8494b5..3da5292 100644
--- a/internal/home/templates/signin_failed.html
+++ b/internal/home/templates/signin_failed.html
@@ -1,6 +1,6 @@
{{define "title"}}That link did not work · Oficina{{end}}
{{define "content"}}
<h1>That link did not work</h1>
-<p>Sign-in links work once and expire after {{linkLifetime}} minutes. Ask for a new one.</p>
-<p><a href="/signin">Start again</a></p>
+<p estilo-signin-body>Sign-in links work once and expire after {{linkLifetime}} minutes. Ask for a new one.</p>
+<p><a href="{{.Data.Again}}">Start again</a></p>
{{end}}