oficina / meet Public

Adopt the shared chrome in Calls

The 52px estilo bar replaces the 81px shell (Meetings, Settings, the
meeting title as context, 'All apps'); the account menu shows who is
signed in, Appearance, Help > Open source, one Sign out, and 'Connect
your Oficina account' for local sessions. Meetings and agents are real
list rows with one menu each; 'End meeting for everyone' is the only
wording and reads as dangerous. Errors are pages split by Accept, so
room.js still sees plain text; /signed-out is a page standalone too;
titles read '<Page> · Calls'; warnings are warnings. The room lays out
before its first frame (CLS was up to 0.45, now 0). A revoked member is
signed out cleanly; return_to survives sign-in in a sealed cookie;
pages that show Sign out send strict-origin. The guest /m/ path, the
URL names and callscore.SignOut's 307 are unchanged.
Reviewed by GPT-6-Astra via Codex (#24): approve.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/r2-shared-chrome into main.
claude agent, operated by paul@keymail.dev f86d4da7ac5e44767bbcebaee1b97e4f36ba7210 parent f5a1254
58 files changed, +2035 −394
  • cmd/calls/main.go +4 −0
  • go.mod +3 −3
  • go.sum +4 −4
  • internal/calls/app.go +17 −2
  • internal/calls/auth.go +134 −19
  • internal/calls/chrome.go +263 −0
  • internal/calls/credits.go +5 −2
  • internal/calls/credits_test.go +5 −2
  • internal/calls/errors.go +214 −0
  • internal/calls/errors_test.go +80 −0
  • internal/calls/guest.go +7 −22
  • internal/calls/handlers_join.go +3 −2
  • internal/calls/handlers_meetings.go +39 −15
  • internal/calls/handlers_meetings_test.go +20 −1
  • internal/calls/handlers_room.go +8 −5
  • internal/calls/handlers_settings.go +5 −4
  • internal/calls/home.go +47 −11
  • internal/calls/home_router_test.go +48 −3
  • internal/calls/native_auth.go +8 −5
  • internal/calls/render.go +33 −69
  • internal/calls/signin_return.go +209 −0
  • internal/calls/static/call-layout.js +20 −5
  • internal/calls/static/calls-shell.css +35 −25
  • internal/calls/static/calls.css +36 −7
  • internal/calls/static/devices.js +4 −0
  • internal/calls/static/noscript.css +4 −0
  • internal/calls/static/room.js +2 −1
  • internal/calls/static/zone.js +14 −0
  • internal/calls/templates/agent_once.html +4 −5
  • internal/calls/templates/confirm.html +0 −1
  • internal/calls/templates/credits.html +0 −1
  • internal/calls/templates/errors.html +3 −4
  • internal/calls/templates/guest.html +2 −5
  • internal/calls/templates/home.html +0 −1
  • internal/calls/templates/index.html +33 −20
  • internal/calls/templates/join.html +35 −24
  • internal/calls/templates/layout.html +37 −36
  • internal/calls/templates/link_once.html +4 −5
  • internal/calls/templates/meeting_settings.html +2 −4
  • internal/calls/templates/native.html +0 −1
  • internal/calls/templates/partials/meeting-options.html +2 −2
  • internal/calls/templates/room.html +15 −13
  • internal/calls/templates/settings.html +7 −14
  • internal/calls/templates/share.html +3 −5
  • internal/calls/templates/signedout.html +3 −4
  • internal/calls/templates/signin.html +0 −1
  • internal/calls/templates/welcome.html +2 −3
  • internal/callstest/agents_test.go +2 −2
  • internal/callstest/api_test.go +8 −4
  • internal/callstest/browser_test.go +1 −0
  • internal/callstest/chrome_test.go +450 −0
  • internal/callstest/holding_test.go +20 −11
  • internal/callstest/join_test.go +1 −1
  • internal/callstest/meetings_test.go +1 −1
  • internal/callstest/relay_floor_test.go +3 −3
  • internal/callstest/room_test.go +6 −6
  • internal/callstest/signin_test.go +2 −2
  • test/browser/call.mjs +118 −8

CI passed — run details

CI log
=== vet ===
go vet ./...
=== fmt ===
=== test ===
go test ./...
ok  	amadan.net/oficina/calls/callscore	(cached)
?   	amadan.net/oficina/calls/cmd/calls	[no test files]
ok  	amadan.net/oficina/calls/internal/calls	1.783s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	9.758s
?   	amadan.net/oficina/calls/protocol	[no test files]
?   	amadan.net/oficina/calls/scripts/writeestilo	[no test files]
=== migration-check ===
go run amadan.net/rastrillo/rastrillo/cmd/rastrillo migration check
rastrillo migration check: models and migrations agree
=== race ===
CGO_ENABLED=1 go test -race ./internal/...
ok  	amadan.net/oficina/calls/internal/calls	15.848s
ok  	amadan.net/oficina/calls/internal/calls/apns	(cached)
ok  	amadan.net/oficina/calls/internal/calls/hub	(cached)
?   	amadan.net/oficina/calls/internal/calls/icons	[no test files]
ok  	amadan.net/oficina/calls/internal/calls/relay	(cached)
ok  	amadan.net/oficina/calls/internal/calls/seal	(cached)
ok  	amadan.net/oficina/calls/internal/callstest	31.537s
diff --git a/cmd/calls/main.go b/cmd/calls/main.go
index 864e85c..085cf42 100644
--- a/cmd/calls/main.go
+++ b/cmd/calls/main.go
@@ -8,6 +8,7 @@ import (
"os/signal"
"strings"
"syscall"
+ _ "time/tzdata" // the browser's zone for a call's title, wherever the host has no zoneinfo
"amadan.net/rastrillo/rastrillo"
"amadan.net/rastrillo/rastrillo/db"
@@ -88,6 +89,9 @@ func main() {
// without this the platform has no way to know it should wake the
// app to clear one (review C2-2).
opts.NextDue = rt.NextDue
+ // A panic outside the router gets the app's own error page, not
+ // rastrillo's plain "Something went wrong." (shared chrome §3.3).
+ opts.ErrorPage = rt.ErrorPage
if err := rastrillo.Serve(opts); err != nil {
logger.Error("serve failed", "err", err)
os.Exit(1)
diff --git a/go.mod b/go.mod
index 5cc6c80..1cd97b6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,11 +3,12 @@ module amadan.net/oficina/calls
go 1.26.0
require (
- amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
- amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9
+ amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768
+ amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c
amadan.net/oficina/home/handoff v0.0.0-20260908102137-481d2d57f489
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/go-chi/chi/v5 v5.3.2
+ github.com/keymaildev/signin v0.1.1
golang.org/x/crypto v0.56.0
gorm.io/gorm v1.31.2
)
@@ -20,7 +21,6 @@ require (
github.com/google/uuid v1.6.0 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
- github.com/keymaildev/signin v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
diff --git a/go.sum b/go.sum
index 3542d64..da3dcea 100644
--- a/go.sum
+++ b/go.sum
@@ -1,7 +1,7 @@
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8=
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
-amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9 h1:K/PaOVdQqRMwifPD6ipiwLeGR5nbFSxm9FxjrDJlZcg=
-amadan.net/oficina/home/connect v0.0.0-20260923190447-01166fcfc0a9/go.mod h1:YroavXe43Wietec+po8mtSk4a9Bir2h31Q3iAf4MWxI=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768 h1:bVscoa9fH5LdLDjlyecZhrX7JzWPT0blb57MH0FIm2U=
+amadan.net/oficina/estilo.git v0.0.0-20261001093750-530cb932e768/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c h1:3yxO2D8BxDLMGfFV2o6Sp9Z91yLnjDCIuRKHVWORa8A=
+amadan.net/oficina/home/connect v0.0.0-20261001093757-2bec942ddd0c/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/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
diff --git a/internal/calls/app.go b/internal/calls/app.go
index ed1cefe..816ce16 100644
--- a/internal/calls/app.go
+++ b/internal/calls/app.go
@@ -1,6 +1,7 @@
package calls
import (
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"context"
@@ -436,6 +437,12 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
cancel()
r := chi.NewRouter()
+ // Every panic inside the router is the 500 page with a reference and
+ // the viewer's own chrome (errors.go); an unknown route is the 404
+ // page, not chi's plain text (shared chrome spec §3.3).
+ r.Use(a.recoverToErrorPage)
+ r.NotFound(a.notFound)
+ r.MethodNotAllowed(a.methodNotAllowed(r))
// The drive surface (spec §8.2) is mounted at the router root,
// outside csrf.Protect and outside the session middlewares: an
@@ -457,12 +464,17 @@ func build(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *app, err
r.Use(a.nativeResume)
r.Get("/native/start", a.nativeStart)
r.Get("/help/open-source", a.credits)
+ // Appearance is inside the CSRF check and outside the session
+ // group: a session that expired mid-page must not turn the click
+ // into a sign-in loop (spec §8.3).
+ r.Post("/appearance", chrome.HandleAppearance)
r.Get(signinPath, a.signin)
if a.home == nil {
r.Post(signinPath, a.gateBegin(authPlugin.Begin))
r.Get("/auth/callback", authPlugin.Callback)
r.Get("/auth/verify", authPlugin.Verify)
- r.Post("/signout", authPlugin.Signout)
+ r.Post("/signout", signedOutAfter(authPlugin.Signout))
+ r.Get("/signed-out", a.signedOut)
} else {
r.Post(signinPath, a.home.routes.LocalGuard(a.gateBegin(authPlugin.Begin)))
r.Get("/auth/callback", a.home.routes.LocalGuard(authPlugin.Callback))
@@ -546,6 +558,9 @@ type Runtime struct {
// NextDue answers rastrillo.Options.NextDue: the next moment this
// app has work, or the zero time when nothing is scheduled.
NextDue func() time.Time
+ // ErrorPage answers rastrillo.Options.ErrorPage: the app's own error
+ // page for a panic outside the router.
+ ErrorPage func(w http.ResponseWriter, r *http.Request, status int, ref string)
}
// nextDue is Runtime.NextDue's implementation: the earliest of the next
@@ -598,7 +613,7 @@ func App(ctx context.Context, d *db.DB, cfg Config) (*http.ServeMux, *Runtime, e
}
go a.sweepForever(ctx)
go a.tickForever(ctx)
- return mux, &Runtime{NextDue: a.nextDue}, nil
+ return mux, &Runtime{NextDue: a.nextDue, ErrorPage: a.ErrorPage}, nil
}
// NewForTest builds the app and returns the handler and its test
diff --git a/internal/calls/auth.go b/internal/calls/auth.go
index cffd033..8a29ba1 100644
--- a/internal/calls/auth.go
+++ b/internal/calls/auth.go
@@ -12,6 +12,7 @@ import (
"amadan.net/oficina/estilo.git/sky"
rauth "amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/sessions"
+ "github.com/keymaildev/signin"
)
const signinPath = "/signin"
@@ -62,6 +63,16 @@ func (a *app) gateBegin(next http.HandlerFunc) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
address := r.FormValue("address")
+ // Syntax first. The gate below answers every refusal as "sent", so
+ // a malformed address used to go straight to "Check your email"
+ // and the field's own error could never show. Whether a string is
+ // an email address says nothing about who is on the team, so this
+ // answer is no oracle. It is keymail's own check, the one Begin
+ // would make.
+ if _, _, err := signin.SplitAddress(strings.TrimSpace(address)); err != nil {
+ http.Redirect(w, r, a.signinAgain(r, "err=address"), http.StatusSeeOther)
+ return
+ }
if !a.mayRequestLink(w, r, address) {
http.Redirect(w, r, sentLocation, http.StatusSeeOther)
return
@@ -161,8 +172,16 @@ func (a *app) requirePerson(next http.Handler) http.Handler {
return
}
p, err := a.personForSession(r.Context(), s)
+ if errors.Is(err, errHomeRemoved) {
+ // A Home identity that is definitely no longer admitted: end
+ // the session and say so, rather than a 404 whose "Sign in"
+ // would pass straight back through the same live session
+ // (the loop Astra found in Docs #65 and Sheets #39).
+ a.endRemovedSession(w, r)
+ return
+ }
if errors.Is(err, errHomeIdentity) {
- a.ErrorPage(w, r, http.StatusNotFound, "")
+ a.notFound(w, r)
return
}
if err != nil {
@@ -184,38 +203,109 @@ func (a *app) requireMember(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, ok := currentPerson(r)
if !ok || !a.isMember(p) {
- a.ErrorPage(w, r, http.StatusNotFound, "")
+ a.notFound(w, r)
return
}
next.ServeHTTP(w, r)
})
}
+// localSignin serves the sign-in form (GET /signin standalone,
+// /signin/local with Home) and the landing spot for every outcome
+// rastrillo redirects back to (?sent=1, ?err=rate|address|expired|
+// removed|1), in the suite's words (spec §9.2).
+//
+// Already signed in, it skips the form: 303 to return_to or the meeting
+// list. A guest is not signed in for this purpose — signing in is exactly
+// what a guest here is asking to do — and a session that no longer
+// resolves to a person has been ended by signin before this runs.
+//
+// return_to is remembered in a sealed cookie (signin_return.go), and a
+// plain /signin forgets one left by an abandoned attempt.
func (a *app) localSignin(w http.ResponseWriter, r *http.Request) {
+ q := r.URL.Query()
+ ret, hasReturn := safeReturn(q)
+ if v := a.viewerOf(r); v.signedIn() && q.Get("sent") == "" && q.Get("err") == "" && r.URL.Path == signinPath {
+ dest := "/"
+ if hasReturn {
+ dest = ret
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, dest, http.StatusSeeOther)
+ return
+ }
+ switch {
+ case hasReturn:
+ a.rememberReturn(w, ret)
+ case len(q) == 0:
+ a.forgetReturn(w)
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ if q.Get("sent") == "1" {
+ // The door's own check-your-email state (estilo DoorCopy). "Use a
+ // different address" goes back to the form keeping the return
+ // (spec §9.2): the remembered page, not a bare /signin, which would
+ // forget it (Astra on Docs #65, finding 2). rastrillo's Begin
+ // redirects to ?sent=1 with no address, so the body cannot echo it.
+ notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.Sent(a.signinAgain(r, ""))
+ render(w, "signin", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Sent": true, "Notice": notice}))
+ return
+ }
+ // The door's words (heading, label, help, the "Sign in" submit) are
+ // estilo's one copy set; Calls passes its name, mark, sky and
+ // destinations.
door := chrome.SignIn{
- Icon: assets.Path("static/brand/icon.svg"),
- App: "Calls",
- Sky: sky.For(r.Host),
- Action: signinPath,
+ Icon: assets.Path("static/brand/icon.svg"),
+ App: appName,
+ Sky: sky.For(r.Host),
+ Action: signinPath,
+ OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"},
+ }
+ if hasReturn {
+ door.Return = ret
}
- switch r.URL.Query().Get("err") {
+ switch q.Get("err") {
case "address":
- door.AddressError = "That does not look like an email address."
+ // An address error belongs to the field, not a banner.
+ door.AddressError = "That doesn’t look like an email address."
case "rate":
- door.Error = "Too many sign-in attempts just now. Wait a few minutes and try again."
+ door.Error = "Too many attempts. Wait a few minutes, then try again."
case "expired":
- door.Error = "That sign-in link has expired. Ask for a new one."
+ door.Error = "That link has expired or was already used. Enter your email to get a new one."
+ case "removed":
+ door.Error = "Your access has been removed, so you’ve been signed out. Ask whoever runs this team if that’s wrong."
case "":
default:
- door.Error = "Sign-in did not work. Try again."
+ door.Error = "We couldn’t sign you in. Enter your email to get a new link."
}
- render(w, "signin", map[string]any{
- "Sent": r.URL.Query().Get("sent") == "1",
- "Door": door,
- "Notice": chrome.SignInNotice{
- Icon: door.Icon, App: door.App, Sky: door.Sky,
- }.Sent(signinPath),
- })
+ render(w, "signin", a.page(r, frame{parts: []string{door.Page()}, signinPage: true}, map[string]any{"Door": door}))
+}
+
+// signinAgain is the form's own address, keeping whatever page the
+// person was on their way to: /signin/local with Home (a local sign-in
+// the person chose on purpose, which /signin would turn into the Home
+// exchange), /signin otherwise. extra is one more query parameter.
+func (a *app) signinAgain(r *http.Request, extra string) string {
+ path := signinPath
+ if a.home != nil {
+ path = "/signin/local"
+ }
+ q := url.Values{}
+ if ret, ok := a.rememberedReturn(r); ok {
+ q.Set("return_to", ret)
+ }
+ out := path
+ if enc := q.Encode(); enc != "" {
+ out += "?" + enc
+ }
+ if extra != "" {
+ if strings.Contains(out, "?") {
+ out += "&" + extra
+ } else {
+ out += "?" + extra
+ }
+ }
+ return out
}
// continueSignedIn is SignedInPath. Task 6 adds the pending-join branch
@@ -231,6 +321,14 @@ func (a *app) continueSignedIn(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, dest, http.StatusSeeOther)
return
}
+ // Where the person was going when they were asked to sign in
+ // (signin_return.go), spent either way.
+ ret, remembered := a.rememberedReturn(r)
+ a.forgetReturn(w)
+ if remembered {
+ http.Redirect(w, r, ret, http.StatusSeeOther)
+ return
+ }
if a.isMember(p) {
http.Redirect(w, r, "/", http.StatusSeeOther)
return
@@ -261,5 +359,22 @@ func (a *app) continuePending(w http.ResponseWriter, r *http.Request, p Person)
func (a *app) welcome(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
- render(w, "welcome", a.page(r, map[string]any{"Email": p.Email}))
+ render(w, "welcome", a.page(r, titled("You’re signed in"), map[string]any{"Email": p.Email}))
+}
+
+// signedOut serves GET /signed-out standalone: where Sign out lands.
+// With Home configured connect serves it, through renderHome.
+func (a *app) signedOut(w http.ResponseWriter, r *http.Request) {
+ a.renderSignedOut(w, r, signinPath, http.StatusOK)
+}
+
+// renderSignedOut is the signed-out page: the door's own notice
+// (estilo's SignedOut, the same words in every app), whose primary Sign in
+// goes to signin — /signin standalone, connect's action with Home.
+func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, signin string, status int) {
+ notice := chrome.SignInNotice{Icon: assets.Path("static/brand/icon.svg"), App: appName, Sky: sky.For(r.Host)}.SignedOut(signin)
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.Header().Set("Cache-Control", "no-store")
+ w.WriteHeader(status)
+ render(w, "signedout", a.page(r, frame{parts: []string{notice.Page()}, signinPage: true}, map[string]any{"Notice": notice}))
}
diff --git a/internal/calls/chrome.go b/internal/calls/chrome.go
new file mode 100644
index 0000000..5f1169f
--- /dev/null
+++ b/internal/calls/chrome.go
@@ -0,0 +1,263 @@
+package calls
+
+// The shared chrome (estilo chrome, round 2): every page's bar, account
+// menu and <title> are built here, once, so no page can drift from
+// another. Before this the shell was rastrillo's topbar at 81px: a raw
+// address, a blue underlined "Settings" and a Sign out button that
+// wrapped under it, a separate one-item Help dropdown, no nav and no
+// current-page marker, and a <title> that named the app on one page out
+// of fifteen.
+//
+// Spec: estilo spec/shared-chrome, docs/superpowers/specs/
+// 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account),
+// §10 (titles). Calls adopts after Docs, Sheets and Slides.
+//
+// The call room keeps no bar: since 2026-09-28 a call is one window in
+// the viewport (room.html). Its title menu carries the same items, in
+// the same order and words, as the account menu built here (roomMenu).
+
+import (
+ "net/http"
+ "net/url"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
+ "amadan.net/rastrillo/rastrillo/sessions"
+)
+
+// appName is the brand and the last part of every <title>.
+const appName = "Calls"
+
+// Frame is what layout.html reads from every page: the bar, the parts of
+// the <title> before the app's name, and the Appearance preference for
+// <html data-theme>.
+type Frame struct {
+ Chrome chrome.Bar
+ TitleParts []string
+ Theme string
+}
+
+// frame describes one page's chrome. The zero value is a page whose
+// <title> is just "Calls" (the meeting list, the app root).
+type frame struct {
+ // parts precede "Calls" in <title>, most specific first. The first
+ // is the page's h1 (spec §10).
+ parts []string
+ // context is the open meeting's title, shown in the bar.
+ context string
+ // signinPage is one of the sign-in, sent and signed-out pages, or a
+ // native app hand-off page: the corner is empty and the brand is
+ // plain text, since everything it could link to is behind the session
+ // gate (and a hand-off page keeps no-referrer, which a chained
+ // sign-out could not pass through).
+ signinPage bool
+ // public is a page reached through a capability (the invitation
+ // link /j/{token}): "Sign in" carries no return path, because the
+ // path is the capability and must not be copied into another URL.
+ public bool
+}
+
+// titled is the frame for a page whose <title> is these parts.
+func titled(parts ...string) frame { return frame{parts: parts} }
+
+// onMeeting is the frame for one of a meeting's pages: the meeting's
+// title is the bar's context, and it follows page in the <title>. With
+// page empty the meeting is the page (the holding screen, the room).
+func onMeeting(page, meeting string) frame {
+ if page == "" {
+ return frame{parts: []string{meeting}, context: meeting}
+ }
+ return frame{parts: []string{page, meeting}, context: meeting}
+}
+
+// viewer is who the chrome is for: nobody, a guest, or a signed-in
+// person, and how they signed in.
+type viewer struct {
+ person Person
+ method string
+ ok bool
+}
+
+// signedIn is a person who signed in, as opposed to a per-meeting guest.
+func (v viewer) signedIn() bool { return v.ok && !v.person.Anonymous }
+
+// viewerOf resolves whoever the request is for without requiring
+// anybody. Behind requireParticipant that is the person already in the
+// context. Anywhere else (an error page, Open source, the invitation
+// page) the session is read optionally and without side effects: an
+// error page must never create a persons row or end a session, so a
+// session that no longer resolves is simply signed out here, and the
+// next guarded page is where it is dealt with.
+func (a *app) viewerOf(r *http.Request) viewer {
+ if p, ok := currentPerson(r); ok {
+ s, _ := sessions.Current(r)
+ return viewer{person: p, method: s.Method, ok: true}
+ }
+ id, ok := a.auth.SessionFrom(r)
+ if !ok {
+ return viewer{}
+ }
+ p, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)})
+ if err != nil {
+ return viewer{}
+ }
+ return viewer{person: p, method: string(id.Method), ok: true}
+}
+
+// lookupPerson is personForSession without its one write: a first
+// sign-in's persons row is created by the guarded pages, never by chrome.
+func (a *app) lookupPerson(r *http.Request, s sessions.Session) (Person, error) {
+ switch s.Method {
+ case "magiclink", "keymail":
+ return a.store.existingHomePerson(r.Context(), s.Subject)
+ }
+ return a.personForSession(r.Context(), s)
+}
+
+// page builds render's data for a page: data plus "Frame", the chrome,
+// and "Viewer", which the room's own menu reads.
+func (a *app) page(r *http.Request, f frame, data map[string]any) map[string]any {
+ if data == nil {
+ data = map[string]any{}
+ }
+ var v viewer
+ if !f.signinPage {
+ v = a.viewerOf(r)
+ }
+ if v.signedIn() {
+ data["Viewer"] = Viewer{Email: v.person.Email, Member: a.isMember(v.person)}
+ }
+ data["Frame"] = a.frameFor(r, v, f)
+ return data
+}
+
+// Viewer is the signed-in person, for the room's title menu: who they
+// are, and whether the team's pages (Meetings, Settings) are theirs.
+type Viewer struct {
+ Email string
+ Member bool
+}
+
+// frameFor builds a page's Frame for viewer v.
+func (a *app) frameFor(r *http.Request, v viewer, f frame) Frame {
+ bar := chrome.Bar{
+ App: chrome.App{
+ Name: appName,
+ Slug: "calls",
+ Home: "/",
+ Icon: assets.Path("static/brand/icon.svg"),
+ },
+ Context: f.context,
+ Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"},
+ }
+ switch {
+ case f.signinPage:
+ bar.App.Home = ""
+ case v.signedIn():
+ bar.Account = a.account(r, v)
+ bar.Switcher = a.switcher()
+ if a.isMember(v.person) {
+ bar.Nav = a.nav(r)
+ bar.NavLabel = "Calls"
+ }
+ case f.public:
+ // A guest, or nobody, on the invitation page.
+ bar.App.Home = ""
+ bar.SignIn = &chrome.Link{Href: signinPath, Label: "Sign in"}
+ default:
+ // Nobody, or a guest (spec §8.4: guests get the same corner).
+ bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"}
+ }
+ return Frame{
+ Chrome: bar,
+ TitleParts: f.parts,
+ Theme: chrome.ThemeAttr(chrome.Appearance(r)),
+ }
+}
+
+// nav is the team's two pages, with the one being shown marked.
+func (a *app) nav(r *http.Request) []chrome.Link {
+ return []chrome.Link{
+ {Href: "/", Label: "Meetings", Current: r.URL.Path == "/"},
+ {Href: "/settings", Label: "Settings", Current: r.URL.Path == "/settings"},
+ }
+}
+
+// returnTarget is where a person should come back to: this page, if it
+// is a page (a GET), otherwise the meeting list.
+func returnTarget(r *http.Request) string {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ return "/"
+ }
+ return r.URL.RequestURI()
+}
+
+// account is the account menu (spec §8.2): who is signed in, "Connect
+// your Oficina account" when that means something, Appearance, Help
+// (Open source, last), and the one Sign out in the app. Settings is in
+// the bar's nav rather than here: it is the team's meeting defaults and
+// agents, not this person's account, and a second copy of the link would
+// be a second label to keep in step.
+func (a *app) account(r *http.Request, v viewer) *chrome.Account {
+ acc := &chrome.Account{
+ TriggerLabel: "Account: " + v.person.Email,
+ Initials: chrome.Initials("", v.person.Email),
+ Address: v.person.Email,
+ Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ Return: returnTarget(r),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}},
+ SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"},
+ }
+ if a.offersHomeLink(v) {
+ acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}}
+ }
+ return acc
+}
+
+// offersHomeLink is whether the account menu offers to connect the
+// person's Oficina account: only with Home configured, only to a member
+// (the page is behind requireMember), and never to somebody who signed
+// in through Home, whose account already is the connection.
+func (a *app) offersHomeLink(v viewer) bool {
+ return a.home != nil && v.signedIn() && a.isMember(v.person) && (v.method == "magiclink" || v.method == "keymail")
+}
+
+// switcher is the app switcher, or nil when no hub directory is
+// configured. It shows "All apps" alone and no tiles (Paul's §13 answer
+// 2), as Docs, Sheets and Slides do.
+func (a *app) switcher() *chrome.Switcher {
+ if a.cfg.home == nil || a.cfg.home.Directory == "" {
+ return nil
+ }
+ return &chrome.Switcher{
+ Label: "Oficina apps",
+ CurrentLabel: "(this app)",
+ AllApps: estilomenu.Item{Href: a.cfg.home.Directory + "/", Label: "All apps"},
+ }
+}
+
+// roomMenu is the call window's title menu: the account menu's items in
+// its order and its words, for a page that has no bar. Standing is the
+// one line only the room has.
+type roomMenu struct {
+ Identity string
+ Nav []chrome.Link
+ Help []estilomenu.Item
+ SignOut *chrome.SignOut
+}
+
+func (a *app) roomMenuFor(fr Frame) roomMenu {
+ m := roomMenu{Nav: fr.Chrome.Nav}
+ if acc := fr.Chrome.Account; acc != nil {
+ m.Identity = acc.Address
+ m.Help = acc.Help
+ m.SignOut = &acc.SignOut
+ } else {
+ m.Help = []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}
+ }
+ return m
+}
diff --git a/internal/calls/credits.go b/internal/calls/credits.go
index 46c9760..2d3170c 100644
--- a/internal/calls/credits.go
+++ b/internal/calls/credits.go
@@ -28,8 +28,11 @@ func (a *app) credits(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Referrer-Policy", "no-referrer")
- render(w, "credits", a.page(r, map[string]any{"Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}))
+ // strict-origin, not no-referrer: the page shows the account menu, and
+ // a chained sign-out from it is admitted by id.* on the Referer's
+ // origin (spec §9.5). The credit links carry their own no-referrer.
+ w.Header().Set("Referrer-Policy", "strict-origin")
+ render(w, "credits", a.page(r, titled("Open source"), map[string]any{"Data": credits.PageData{Title: "Open source", MissingNoticeLabel: "No notice file is included in this release.", Components: embeddedCredits.Components}}))
}
func serveCreditsCSS(w http.ResponseWriter, r *http.Request) {
diff --git a/internal/calls/credits_test.go b/internal/calls/credits_test.go
index bcacf3a..f153166 100644
--- a/internal/calls/credits_test.go
+++ b/internal/calls/credits_test.go
@@ -19,8 +19,11 @@ func TestCreditsArePublicAndContainDependencyNotices(t *testing.T) {
if response.Code != http.StatusOK || response.Header().Get("Location") != "" || len(response.Result().Cookies()) != 0 {
t.Fatal("credits required authentication", response.Code)
}
- if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "no-referrer" {
- t.Fatal("credits shell may retain account context")
+ if response.Header().Get("Cache-Control") != "no-store" || response.Header().Get("Referrer-Policy") != "strict-origin" {
+ // strict-origin, not no-referrer: the page shows the account menu,
+ // whose chained sign-out id.* admits by the Referer's origin (spec
+ // shared-chrome §9.5). It still sets no cookie (above).
+ t.Fatal("credits must send strict-origin", response.Header().Get("Referrer-Policy"))
}
for _, wanted := range []string{"No notice file is included in this release.", "estilo-credits", "Open source", "Lucide and Feather", "gorm.io/gorm", "gormlite/LICENSE", "/static/credits.css"} {
if !strings.Contains(response.Body.String(), wanted) {
diff --git a/internal/calls/errors.go b/internal/calls/errors.go
new file mode 100644
index 0000000..a3c778f
--- /dev/null
+++ b/internal/calls/errors.go
@@ -0,0 +1,214 @@
+package calls
+
+// HTML error pages (shared chrome spec §3). Before this an unknown URL
+// answered chi's plain-text "404 page not found", with or without
+// Accept: text/html, and the handled error pages were titled "Something
+// went wrong" whatever had happened, a 404 included.
+//
+// Which answers are pages is decided by the request, not the route: a
+// page is rendered only when Accept names text/html, as every browser
+// navigation and form post does. The room's own fetches (the stream,
+// signal, ICE and info), the native app's JSON and the share form's JSON
+// answer never ask for HTML, so they keep plain answers. room.js reads
+// those by status (readRefusal), and the statuses are unchanged.
+//
+// A meeting that is missing, ended, or not yours is the same 404
+// (AGENTS.md: a row that isn't yours 404s, never 403s).
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "runtime/debug"
+ "strings"
+
+ "amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/rastrillo/rastrillo"
+ "github.com/go-chi/chi/v5"
+)
+
+// errorKind says which row of spec §3.2 a failure is.
+type errorKind int
+
+const (
+ // kindRoute: nothing here for you — an unknown address, or a page
+ // this session may not open (a guest outside /m/, a non-member on
+ // Settings). Both are "we can't find that page": which of the two it
+ // is must not be readable from outside.
+ kindRoute errorKind = iota
+ // kindMeeting: the meeting (or its invitation, or a peer in it) is
+ // missing, ended, or not shared with you.
+ kindMeeting
+ // kindStatus: the page for the status itself (5xx).
+ kindStatus
+)
+
+// errorWords is one row of §3.2: the title part, the heading, the body.
+type errorWords struct {
+ title, heading, body string
+}
+
+func errorCopy(status int, kind errorKind) errorWords {
+ switch {
+ case status == http.StatusNotFound && kind == kindMeeting:
+ return errorWords{"Meeting not available", "This meeting isn’t available", "It may have ended, or it hasn’t been shared with you."}
+ case status == http.StatusNotFound:
+ return errorWords{"Page not found", "We can’t find that page", "The link may be out of date, or the page may have moved."}
+ case status == http.StatusForbidden:
+ return errorWords{"No access", "You don’t have access to this page", "Ask whoever runs this team if you think you should."}
+ case status == http.StatusServiceUnavailable:
+ return errorWords{"Calls is unavailable", "Calls is unavailable right now", "Try again in a few minutes."}
+ }
+ return errorWords{"Something went wrong", "Something went wrong", "Try again in a moment. If it keeps happening, quote the reference below."}
+}
+
+// wantsHTML reports whether the request asked for a page.
+func wantsHTML(r *http.Request) bool {
+ return strings.Contains(r.Header.Get("Accept"), "text/html")
+}
+
+// plainError is the answer to a request that did not ask for a page.
+func plainError(w http.ResponseWriter, status int) {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Error(w, http.StatusText(status), status)
+}
+
+// ErrorPage answers status for a handler: a meeting-shaped 404 (almost
+// every handled 404 in Calls is about a meeting or its invitation), the
+// 5xx page with the reference rastrillo minted for the log line, or
+// plain text for a request that did not ask for a page. It is also
+// rastrillo.Options.ErrorPage, for panics outside the router.
+func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
+ a.errorPage(w, r, status, kindMeeting, ref)
+}
+
+// notFound is the route-shaped 404: the router's own, and the guards'.
+func (a *app) notFound(w http.ResponseWriter, r *http.Request) {
+ a.errorPage(w, r, http.StatusNotFound, kindRoute, "")
+}
+
+// errorPage renders the page inside the normal layout, with the
+// viewer's own chrome (read optionally: an error page never signs
+// anybody out or creates anything).
+func (a *app) errorPage(w http.ResponseWriter, r *http.Request, status int, kind errorKind, ref string) {
+ if !wantsHTML(r) {
+ plainError(w, status)
+ return
+ }
+ c := errorCopy(status, kind)
+ page := chrome.ErrorPage{
+ Status: status,
+ Title: c.heading,
+ Body: c.body,
+ Home: chrome.Link{Href: "/", Label: "Go to Calls"},
+ }
+ // Try again is the same URL, and only for a GET: re-sending a POST
+ // from an error page is the one thing it must never do.
+ if status >= 500 && r.Method == http.MethodGet {
+ page.Home = chrome.Link{Href: r.URL.RequestURI(), Label: "Try again"}
+ }
+ if ref != "" {
+ page.Ref, page.RefLabel = ref, "Reference:"
+ }
+ if back := sameOriginReferer(r, a.cfg.Origin); back != "" && status < 500 {
+ page.Back = &chrome.Link{Href: back, Label: "Back"}
+ }
+ var buf strings.Builder
+ data := a.page(r, titled(c.title), map[string]any{"Error": page})
+ if err := pages["errors"].ExecuteTemplate(&buf, "layout", data); err != nil {
+ a.logger.Error("calls: render error page", "err", err)
+ plainError(w, status)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(status)
+ _, _ = w.Write([]byte(buf.String()))
+}
+
+// sameOriginReferer is the page a person came from, when it is this app
+// and not this page: rastrillo's rule for the error page's Back link. An
+// unchecked Referer is an open redirect with better manners.
+func sameOriginReferer(r *http.Request, origin string) string {
+ raw := r.Header.Get("Referer")
+ if raw == "" || origin == "" {
+ return ""
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.User != nil || u.Scheme+"://"+u.Host != origin {
+ return ""
+ }
+ back := u.EscapedPath()
+ if u.RawQuery != "" {
+ back += "?" + u.RawQuery
+ }
+ // Never an invitation path: the Back link would copy the capability
+ // into this page.
+ if strings.HasPrefix(back, "/j/") {
+ return ""
+ }
+ if !strings.HasPrefix(back, "/") || strings.HasPrefix(back, "//") || back == r.URL.RequestURI() {
+ return ""
+ }
+ return back
+}
+
+// methodNotAllowed is the router's MethodNotAllowed. A GET or HEAD to a
+// path that only takes a POST (somebody opening /signout from history)
+// is the same 404 page as an unknown route: to a person there is no
+// page there. Any other method gets 405 in plain text, with the Allow
+// header set here — chi's default sets it, a custom handler does not.
+func (a *app) methodNotAllowed(routes chi.Routes) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ var allowed []string
+ for _, m := range []string{http.MethodGet, http.MethodHead, http.MethodPost, http.MethodPut, http.MethodPatch, http.MethodDelete} {
+ if routes.Match(chi.NewRouteContext(), m, r.URL.Path) {
+ allowed = append(allowed, m)
+ }
+ }
+ get := false
+ for _, m := range allowed {
+ get = get || m == http.MethodGet
+ }
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && !get {
+ a.notFound(w, r)
+ return
+ }
+ w.Header().Set("Allow", strings.Join(allowed, ", "))
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ }
+}
+
+// routePattern is the matched route, "/m/{sqid}" rather than the path it
+// matched: safe to log whatever the path held (/j/ is a capability).
+func routePattern(r *http.Request) string {
+ if rc := chi.RouteContext(r.Context()); rc != nil && rc.RoutePattern() != "" {
+ return rc.RoutePattern()
+ }
+ return "(unrouted)"
+}
+
+// recoverToErrorPage turns a panic in a handler into the 500 page with a
+// reference, logged with the stack under the same reference. The page
+// reads the session itself, so it shows the viewer's own chrome
+// wherever in the router the panic happened. Panics outside the router
+// reach rastrillo.Serve's own recoverer through Options.ErrorPage.
+func (a *app) recoverToErrorPage(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ defer func() {
+ v := recover()
+ if v == nil {
+ return
+ }
+ if v == http.ErrAbortHandler {
+ panic(v)
+ }
+ ref := rastrillo.NewRef()
+ // The value is logged as its type only: a panic raised with a
+ // meeting title in it would be a leak of its own.
+ a.logger.Error("calls: panic", "ref", ref, "route", routePattern(r), "panic", fmt.Sprintf("%T", v), "stack", string(debug.Stack()))
+ a.errorPage(w, r, http.StatusInternalServerError, kindStatus, ref)
+ }()
+ next.ServeHTTP(w, r)
+ })
+}
diff --git a/internal/calls/errors_test.go b/internal/calls/errors_test.go
new file mode 100644
index 0000000..7159919
--- /dev/null
+++ b/internal/calls/errors_test.go
@@ -0,0 +1,80 @@
+package calls
+
+import (
+ "bytes"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+)
+
+// A panic inside the router is the 500 page with a reference and the
+// viewer's chrome for a browser, and plain text for a fetch (spec §3.3).
+func TestAPanicIsTheErrorPageWithAReference(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ boom := f.app.recoverToErrorPage(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { panic("boom") }))
+ r := httptest.NewRequest("GET", homeTestOrigin+"/m/x", nil)
+ r.Header.Set("Accept", "text/html")
+ w := httptest.NewRecorder()
+ boom.ServeHTTP(w, r)
+ body := w.Body.String()
+ if w.Code != 500 || !strings.Contains(body, "<title>Something went wrong · Calls</title>") || !strings.Contains(body, "Reference:") ||
+ !strings.Contains(body, "<header estilo-bar>") || !strings.Contains(body, `href="/m/x">Try again</a>`) {
+ t.Fatalf("panic page: %d\n%s", w.Code, body)
+ }
+ r = httptest.NewRequest("GET", homeTestOrigin+"/m/x/info", nil)
+ w = httptest.NewRecorder()
+ boom.ServeHTTP(w, r)
+ if w.Code != 500 || !strings.HasPrefix(w.Header().Get("Content-Type"), "text/plain") {
+ t.Fatalf("panic for a fetch: %d %q", w.Code, w.Header().Get("Content-Type"))
+ }
+}
+
+// Astra on #24, finding 3 (Memoria #21's shape): the test above builds
+// recoverToErrorPage by hand, so taking it out of the router left it
+// green. This one panics inside a REAL route — the return cookie's clock,
+// read by /signin/local?return_to= — through the production router,
+// signed in and out, and checks the 500 page with a reference, the
+// viewer's own chrome, and that the panic's value reaches neither the
+// page nor the log.
+func TestARoutePanicIsTheErrorPageThroughTheRouter(t *testing.T) {
+ const sentinel = "secret-sentinel-title"
+ for _, signed := range []bool{true, false} {
+ f := newHomeRouterFixture(t)
+ if signed {
+ f.local(t)
+ }
+ var logs bytes.Buffer
+ f.app.logger = slog.New(slog.NewTextHandler(&logs, nil))
+ f.app.store.clk.set(func() time.Time { panic(sentinel) })
+ r := httptest.NewRequest("GET", homeTestOrigin+"/signin/local?return_to=%2Fsettings", nil)
+ r.Header.Set("Accept", "text/html")
+ for _, c := range f.cookies {
+ r.AddCookie(c)
+ }
+ w := httptest.NewRecorder()
+ func() {
+ defer func() {
+ if v := recover(); v != nil {
+ t.Fatalf("signed in=%v: the panic escaped the router: %v", signed, v)
+ }
+ }()
+ f.handler.ServeHTTP(w, r)
+ }()
+ b := w.Body.String()
+ if w.Code != http.StatusInternalServerError || !strings.Contains(b, "<title>Something went wrong · Calls</title>") || !strings.Contains(b, "rst-error-ref") {
+ t.Errorf("signed in=%v: %d, want the 500 page with a reference:\n%s", signed, w.Code, b)
+ }
+ if strings.Contains(b, "estilo-account") != signed {
+ t.Errorf("signed in=%v: the 500 page's chrome is not the viewer's", signed)
+ }
+ if strings.Contains(b, sentinel) || strings.Contains(logs.String(), sentinel) {
+ t.Errorf("signed in=%v: the panic's value reached the page or the log", signed)
+ }
+ if !strings.Contains(logs.String(), "ref=") {
+ t.Errorf("signed in=%v: no reference in the log: %s", signed, logs.String())
+ }
+ }
+}
diff --git a/internal/calls/guest.go b/internal/calls/guest.go
index 0fd355b..cdd4f6b 100644
--- a/internal/calls/guest.go
+++ b/internal/calls/guest.go
@@ -2,7 +2,6 @@ package calls
import (
"net/http"
- "net/url"
"strconv"
"strings"
"unicode/utf8"
@@ -14,36 +13,22 @@ import (
// Guest sessions use the ordinary revocable session store, with a distinct
// identity method. They can never resolve to a verified address or team member.
func (a *app) requireParticipant(next http.Handler) http.Handler {
- return a.homeReturnTo(a.auth.RequireSession(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ return a.requireSignedInPage(a.requirePerson(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
path := r.URL.Path
if p.Anonymous && !strings.HasPrefix(path, "/m/") && path != "/native/authorize" && path != "/native/complete" {
- a.ErrorPage(w, r, http.StatusNotFound, "")
+ a.notFound(w, r)
return
}
next.ServeHTTP(w, r)
- }))))
-}
-
-// homeReturnTo: in Home mode a signed-out page request carries the page to
-// sign-in, so Home sign-in comes back to it rather than to the root;
-// connect's Signin keeps a safe return_to through the exchange. The local
-// plugin's own refusal drops the path. Same cookie, same table: wireHome
-// checked the names agree. Standalone is unchanged.
-func (a *app) homeReturnTo(next http.Handler) http.Handler {
- return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
- if a.home != nil && (r.Method == http.MethodGet || r.Method == http.MethodHead) {
- if _, ok := a.home.sessions.From(r); !ok {
- http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
- return
- }
- }
- next.ServeHTTP(w, r)
- })
+ })))
}
func (a *app) guestPage(w http.ResponseWriter, r *http.Request, m Meeting, token, name, message string) {
nativeHeaders(w)
- render(w, "guest", map[string]any{"Title": a.store.title(m), "Token": token, "Name": name, "NameError": message, "NativeURL": nativeJoinURL(a.cfg.Origin + "/j/" + token)})
+ title := a.store.title(m)
+ f := onMeeting("", title)
+ f.public = true
+ render(w, "guest", a.page(r, f, map[string]any{"Title": title, "Token": token, "Name": name, "NameError": message, "NativeURL": nativeJoinURL(a.cfg.Origin + "/j/" + token)}))
}
func (a *app) joinGuest(w http.ResponseWriter, r *http.Request) {
nativeHeaders(w)
diff --git a/internal/calls/handlers_join.go b/internal/calls/handlers_join.go
index 22365e2..45bce80 100644
--- a/internal/calls/handlers_join.go
+++ b/internal/calls/handlers_join.go
@@ -84,8 +84,9 @@ func (a *app) holding(w http.ResponseWriter, r *http.Request) {
if session, ok := a.sessionKey(r); ok {
_, openElsewhere = a.hub.LiveForSession(m.Sqid, session)
}
- render(w, "join", a.page(r, map[string]any{
- "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
+ title := a.store.title(m)
+ render(w, "join", a.page(r, onMeeting("", title), map[string]any{
+ "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
"Floor": !a.relayConfigured(), "OpenElsewhere": openElsewhere,
"NativeURL": nativeJoinURL(a.cfg.Origin + "/m/" + m.Sqid),
diff --git a/internal/calls/handlers_meetings.go b/internal/calls/handlers_meetings.go
index c97576e..d9d7e53 100644
--- a/internal/calls/handlers_meetings.go
+++ b/internal/calls/handlers_meetings.go
@@ -92,7 +92,7 @@ func (a *app) renderIndex(w http.ResponseWriter, r *http.Request, titleErr, star
upcoming = append(upcoming, rw)
}
}
- render(w, "index", a.page(r, map[string]any{
+ render(w, "index", a.page(r, frame{}, map[string]any{
"Upcoming": upcoming, "Ended": ended, "Zone": a.cfg.Zone,
"TitleError": titleErr, "StartError": startErr, "HomeEnabled": a.showHomeConnection(r),
}))
@@ -124,11 +124,30 @@ func (a *app) createMeeting(w http.ResponseWriter, r *http.Request) {
a.renderLinkOnce(w, r, m, token, false)
}
-// callTitle is the title a one-click "Start a call" meeting gets, in
-// the instance zone: "Call, 10 Sep 09:15". now is passed in rather
-// than read off the clock here so a test can fix it.
-func callTitle(now time.Time, zone *time.Location) string {
- return "Call, " + now.In(zone).Format("2 Jan 15:04")
+// callTitle is the title a one-click "Start a call" meeting gets:
+// "Call, 10 Sep 09:15", in the starter's own time zone when the browser
+// said which (zone.js), so it reads as the time they started it. Without
+// one it is the instance zone, and says so ("Call, 10 Sep 09:15 UTC"):
+// a bare time in a zone the reader cannot see is a wrong time for most
+// of the people reading it. now is passed in so a test can fix it.
+func callTitle(now time.Time, local *time.Location, instance *time.Location, instanceName string) string {
+ if local != nil {
+ return "Call, " + now.In(local).Format("2 Jan 15:04")
+ }
+ return "Call, " + now.In(instance).Format("2 Jan 15:04") + " " + instanceName
+}
+
+// callZone is the browser's IANA zone from the Start a call form, or nil
+// for an empty, unknown or implausible one.
+func callZone(name string) *time.Location {
+ if name == "" || len(name) > 64 || name == "Local" {
+ return nil
+ }
+ loc, err := time.LoadLocation(name)
+ if err != nil {
+ return nil
+ }
+ return loc
}
// startCall is POST /meetings/start: the list's one-click button. It
@@ -137,7 +156,7 @@ func callTitle(now time.Time, zone *time.Location) string {
// and answers with the same link-once page.
func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
p, _ := currentPerson(r)
- title := callTitle(a.store.now(), a.cfg.zone)
+ title := callTitle(a.store.now(), callZone(r.PostFormValue("zone")), a.cfg.zone, a.cfg.Zone)
m, token, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
if err != nil {
a.logger.Error("calls: start call", "err", err)
@@ -151,8 +170,9 @@ func (a *app) startCall(w http.ResponseWriter, r *http.Request) {
// query string lands in the edge's access logs (Sheets' ruling).
func (a *app) renderLinkOnce(w http.ResponseWriter, r *http.Request, m Meeting, token string, replaced bool) {
w.Header().Set("Cache-Control", "no-store")
- render(w, "link_once", a.page(r, map[string]any{
- "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
+ title := a.store.title(m)
+ render(w, "link_once", a.page(r, onMeeting("Your meeting link", title), map[string]any{
+ "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt),
"Link": a.linkURL(token), "Replaced": replaced,
}))
}
@@ -174,13 +194,17 @@ func (a *app) shareMeeting(w http.ResponseWriter, r *http.Request) {
return
}
w.Header().Set("Cache-Control", "no-store")
- w.Header().Set("Referrer-Policy", "no-referrer")
+ // strict-origin rather than no-referrer: the page shows the account
+ // menu, and a chained sign-out is admitted by id.* on the Referer's
+ // origin (spec §9.5). The path, which names the meeting, is never sent.
+ w.Header().Set("Referrer-Policy", "strict-origin")
link := a.linkURL(token)
if r.Header.Get("Accept") == "application/json" {
streamJSON(w, http.StatusOK, map[string]any{"url": link})
return
}
- render(w, "share", a.page(r, map[string]any{"Title": a.store.title(m), "Link": link}))
+ title := a.store.title(m)
+ render(w, "share", a.page(r, onMeeting("Share meeting", title), map[string]any{"Title": title, "Link": link}))
}
// meetingForMember loads the meeting in the URL or answers 404.
@@ -203,7 +227,7 @@ func (a *app) confirmReplaceLink(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", a.page(r, map[string]any{
+ render(w, "confirm", a.page(r, onMeeting("Replace the link?", a.store.title(m)), map[string]any{
"Title": "Replace the link?",
"Body": "Everyone holding a current link for \"" + a.store.title(m) + "\" will get a not-found page. You can share the replacement from the meeting.",
"Action": "/meetings/" + m.Sqid + "/links", "Label": "Replace the link",
@@ -243,10 +267,10 @@ func (a *app) confirmEnd(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", a.page(r, map[string]any{
- "Title": "End the meeting?",
+ render(w, "confirm", a.page(r, onMeeting("End meeting for everyone?", a.store.title(m)), map[string]any{
+ "Title": "End meeting for everyone?",
"Body": "\"" + a.store.title(m) + "\" will close for everyone and its link will stop working.",
- "Action": "/meetings/" + m.Sqid + "/end", "Label": "End the meeting",
+ "Action": "/meetings/" + m.Sqid + "/end", "Label": "End meeting for everyone",
"CancelHref": "/", "Sqid": m.Sqid,
}))
}
diff --git a/internal/calls/handlers_meetings_test.go b/internal/calls/handlers_meetings_test.go
index 0b7df08..52cb714 100644
--- a/internal/calls/handlers_meetings_test.go
+++ b/internal/calls/handlers_meetings_test.go
@@ -24,6 +24,12 @@ func TestCallTitle(t *testing.T) {
zone *time.Location
want string
}{
+ {
+ name: "no browser zone: the instance zone, named",
+ now: time.Date(2026, 9, 30, 20, 47, 0, 0, time.UTC),
+ zone: nil,
+ want: "Call, 30 Sep 20:47 UTC",
+ },
{
name: "UTC",
now: time.Date(2026, 9, 10, 9, 5, 0, 0, time.UTC),
@@ -51,9 +57,22 @@ func TestCallTitle(t *testing.T) {
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
- if got := callTitle(c.now, c.zone); got != c.want {
+ if got := callTitle(c.now, c.zone, time.UTC, "UTC"); got != c.want {
t.Errorf("callTitle(%s, %s) = %q, want %q", c.now, c.zone, got, c.want)
}
})
}
}
+
+// TestCallZone: the browser's zone is used only when it is a real IANA
+// zone; anything else falls back to the instance zone (and its name).
+func TestCallZone(t *testing.T) {
+ if callZone("Europe/Dublin") == nil {
+ t.Error("a real zone was refused")
+ }
+ for _, bad := range []string{"", "Local", "Mars/Olympus", "../../etc/passwd", "Europe/Dublin\n"} {
+ if callZone(bad) != nil {
+ t.Errorf("callZone(%q) accepted", bad)
+ }
+ }
+}
diff --git a/internal/calls/handlers_room.go b/internal/calls/handlers_room.go
index 45d8f68..bc06f8a 100644
--- a/internal/calls/handlers_room.go
+++ b/internal/calls/handlers_room.go
@@ -68,8 +68,9 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "room", a.page(r, map[string]any{
- "Sqid": m.Sqid, "Title": a.store.title(m), "Starts": a.fmtStart(m.StartsAt),
+ title := a.store.title(m)
+ data := a.page(r, onMeeting("", title), map[string]any{
+ "Sqid": m.Sqid, "Title": title, "Starts": a.fmtStart(m.StartsAt),
"Name": p.Name, "Standing": st.String(), "IsMember": st == StandingMember,
// Floor and Relay are the same fact read twice, and both
// are rendering hints: Floor is §5.5's sentence for a
@@ -77,7 +78,9 @@ func (a *app) room(w http.ResponseWriter, r *http.Request) {
// between the two failed-tile sentences. The list the
// server actually mints at /ice is the authority.
"Floor": !a.relayConfigured(), "Relay": a.relayConfigured(), "Boot": a.hub.Boot(),
- }))
+ })
+ data["Menu"] = a.roomMenuFor(data["Frame"].(Frame))
+ render(w, "room", data)
}
// hubRefusal is the four hub sentinels that all mean the same thing to
@@ -127,7 +130,7 @@ func (a *app) confirmRemove(w http.ResponseWriter, r *http.Request) {
a.ErrorPage(w, r, http.StatusNotFound, "")
return
}
- render(w, "confirm", a.page(r, map[string]any{
+ render(w, "confirm", a.page(r, onMeeting("Remove "+name+" from the call?", a.store.title(m)), map[string]any{
"Title": "Remove " + name + " from the call?",
// True for a guest, who has only the link, and for a member, who
// can also come back from the meeting list — one sentence rather
@@ -202,7 +205,7 @@ func (a *app) confirmTakeover(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, "/m/"+m.Sqid+"/join", http.StatusSeeOther)
return
}
- render(w, "confirm", a.page(r, map[string]any{
+ render(w, "confirm", a.page(r, onMeeting("This meeting is open in another tab", a.store.title(m)), map[string]any{
"Title": "This meeting is open in another tab",
"Body": "Use this tab instead? The other tab will leave the call.",
"Action": "/m/" + m.Sqid + "/takeover", "Label": "Use this tab",
diff --git a/internal/calls/handlers_settings.go b/internal/calls/handlers_settings.go
index 0270f25..5d22145 100644
--- a/internal/calls/handlers_settings.go
+++ b/internal/calls/handlers_settings.go
@@ -56,7 +56,7 @@ func (a *app) renderSettings(w http.ResponseWriter, r *http.Request, status int,
return
}
w.WriteHeader(status)
- render(w, "settings", a.page(r, map[string]any{
+ render(w, "settings", a.page(r, titled("Settings"), map[string]any{
"Defaults": defaults, "Saved": r.URL.Query().Get("saved") == "1", "Agents": rows, "Name": name, "NameError": nameErr,
}))
}
@@ -90,7 +90,7 @@ func (a *app) createAgentHandler(w http.ResponseWriter, r *http.Request) {
// a cache must never keep a copy of it around (Task 2 review, same
// property renderLinkOnce already holds for a meeting link).
w.Header().Set("Cache-Control", "no-store")
- render(w, "agent_once", a.page(r, map[string]any{
+ render(w, "agent_once", a.page(r, titled("Your agent’s token"), map[string]any{
"Name": ag.Name, "Principal": principal(ag, p), "Token": token,
}))
}
@@ -125,7 +125,7 @@ func (a *app) confirmRevokeAgent(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
- render(w, "confirm", a.page(r, map[string]any{
+ render(w, "confirm", a.page(r, titled("Revoke "+ag.Name+"?"), map[string]any{
"Title": "Revoke " + ag.Name + "?",
"Body": "Anything using its token stops working at once.",
"Action": "/settings/agents/" + strconv.FormatInt(ag.ID, 10) + "/revoke",
@@ -168,7 +168,8 @@ func (a *app) meetingSettings(w http.ResponseWriter, r *http.Request) {
a.ErrorPage(w, r, 404, "")
return
}
- render(w, "meeting_settings", a.page(r, map[string]any{"Meeting": m, "Title": a.store.title(m), "Saved": r.URL.Query().Get("saved") == "1"}))
+ title := a.store.title(m)
+ render(w, "meeting_settings", a.page(r, onMeeting("Meeting settings", title), map[string]any{"Meeting": m, "Title": title, "Saved": r.URL.Query().Get("saved") == "1"}))
}
func (a *app) saveSettings(w http.ResponseWriter, r *http.Request) {
m, ok := a.meetingForMember(w, r)
diff --git a/internal/calls/home.go b/internal/calls/home.go
index 8744887..38cf510 100644
--- a/internal/calls/home.go
+++ b/internal/calls/home.go
@@ -5,11 +5,10 @@ import (
"context"
"database/sql"
"errors"
+ "fmt"
"net/http"
"strconv"
- "amadan.net/oficina/estilo.git/chrome"
- "amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/home/connect"
"amadan.net/oficina/home/connect/linkstore"
"amadan.net/oficina/home/handoff"
@@ -20,6 +19,12 @@ import (
var errHomeIdentity = errors.New("calls: Home identity refused")
+// errHomeRemoved is the definite subset of errHomeIdentity: the Home
+// link is refused, the person row is gone, or the person is no longer a
+// member. A database that could not be read is errHomeIdentity alone,
+// and never ends a session.
+var errHomeRemoved = fmt.Errorf("%w: removed", errHomeIdentity)
+
type homeIntegration struct {
bridge *connect.Bridge
routes *connect.Routes
@@ -97,13 +102,22 @@ func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person,
return Person{}, errHomeIdentity
}
linked, err := a.home.bridge.Resolve(ctx, s.Subject)
+ if errors.Is(err, linkstore.ErrRefused) {
+ return Person{}, errHomeRemoved
+ }
if err != nil {
return Person{}, errHomeIdentity
}
p, err := a.store.homePersonByID(ctx, a.db.Writer(), linked.ID)
- if err != nil || !a.isMember(p) {
+ if errors.Is(err, sql.ErrNoRows) {
+ return Person{}, errHomeRemoved
+ }
+ if err != nil {
return Person{}, errHomeIdentity
}
+ if !a.isMember(p) {
+ return Person{}, errHomeRemoved
+ }
return p, nil
}
if s.Method != "magiclink" && s.Method != "keymail" {
@@ -111,7 +125,19 @@ func (a *app) personForSession(ctx context.Context, s sessions.Session) (Person,
}
return a.store.ensurePerson(ctx, s.Subject)
}
+
+// signin is GET /signin. A session that no longer resolves to a person
+// who may be here is ended first and told so: with Home configured,
+// connect's Signin skips straight to the destination for any live
+// session cookie, and the destination would refuse it again — the loop
+// Astra found in Docs (#65, finding 1) and Sheets (#39, finding 2).
func (a *app) signin(w http.ResponseWriter, r *http.Request) {
+ if id, ok := a.auth.SessionFrom(r); ok {
+ if _, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}); errors.Is(err, errHomeRemoved) {
+ a.endRemovedSession(w, r)
+ return
+ }
+ }
if a.home != nil {
if _, pending := a.readJoinCookie(r); !pending {
a.home.sessions.Middleware(http.HandlerFunc(a.home.routes.Signin)).ServeHTTP(w, r)
@@ -120,20 +146,30 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
}
a.localSignin(w, r)
}
+
+// endRemovedSession ends this request's session and sends a page request
+// to the sign-in form with the reason; anything else gets the 403 a
+// fetch reads as signed out (room.js's readRefusal).
+func (a *app) endRemovedSession(w http.ResponseWriter, r *http.Request) {
+ a.nativeAuth.sessions.SignOut(w, r)
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ plainError(w, http.StatusForbidden)
+ return
+ }
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, a.signinAgain(r, "err=removed"), http.StatusSeeOther)
+}
+
func (a *app) renderHome(w http.ResponseWriter, r *http.Request, state, label string, status int) {
- name, data := "home", a.page(r, map[string]any{"Home": connect.Page(state, label)})
+ home := connect.Page(state, label)
if state == "signedout" {
// Where Sign out lands: the Oficina door, in the door's words
// (connect's name the hub); its Sign in goes to connect's action.
- home := connect.Page(state, label)
- name, data = "signedout", map[string]any{"Notice": chrome.SignInNotice{
- Icon: assets.Path("static/brand/icon.svg"),
- App: "Calls",
- Sky: sky.For(r.Host),
- }.SignedOut(home.Action)}
+ a.renderSignedOut(w, r, home.Action, status)
+ return
}
var body bytes.Buffer
- if err := pages[name].ExecuteTemplate(&body, "layout", data); err != nil {
+ if err := pages["home"].ExecuteTemplate(&body, "layout", a.page(r, titled(home.Title), map[string]any{"Home": home})); err != nil {
a.ErrorPage(w, r, 500, "")
return
}
diff --git a/internal/calls/home_router_test.go b/internal/calls/home_router_test.go
index 10ac041..c1eb08f 100644
--- a/internal/calls/home_router_test.go
+++ b/internal/calls/home_router_test.go
@@ -107,6 +107,7 @@ func homeRedirect(t *testing.T, w *httptest.ResponseRecorder) string {
}
return w.Header().Get("Location")
}
+
func (f *homeRouterFixture) local(t *testing.T) {
t.Helper()
homeRedirect(t, f.do("POST", "/signin", "address=alice%40example.test&force=1"))
@@ -188,8 +189,15 @@ func TestHomeLinksExistingMemberAndKeepsMeetings(t *testing.T) {
t.Fatal("Home created person", count)
}
f.app.cfg.allow = allowlist{}
- if w = f.do("GET", "/", ""); w.Code != 404 {
- t.Fatal("removed member accepted", w.Code)
+ // A removed member's page request ends the session and says why, on
+ // the local form: a 404 there would offer "Sign in", which connect
+ // passes straight back through the same live cookie (Docs #65).
+ w = f.do("GET", "/", "")
+ if w.Code != 303 || w.Header().Get("Location") != "/signin/local?err=removed" {
+ t.Fatal("removed member accepted", w.Code, w.Header().Get("Location"))
+ }
+ if w = f.do("GET", "/settings", ""); w.Code != 303 || !strings.HasPrefix(w.Header().Get("Location"), "/signin") {
+ t.Fatal("the removed member's session survived", w.Code, w.Header().Get("Location"))
}
}
func TestUnknownHomeDoesNotCreatePerson(t *testing.T) {
@@ -364,9 +372,46 @@ func TestHomeSignedOutIsTheOficinaDoor(t *testing.T) {
t.Errorf("signed-out page is missing %q:\n%s", want, body)
}
}
- for _, gone := range []string{connect.English["signedout_body"], connect.English["signedout_action"], "Home"} {
+ // "Sign in with Home" is the retired label: connect.English now says
+ // "Sign in" itself (Home #79), so asserting its absence by key would
+ // contradict the "Sign in" button required above.
+ for _, gone := range []string{connect.English["signedout_body"], "Sign in with Home", "Home"} {
if strings.Contains(body, gone) {
t.Errorf("signed-out page still carries %q:\n%s", gone, body)
}
}
}
+
+// The revoked-user loop Astra found in Docs (#65, 1) and Sheets (#39, 2):
+// connect's Signin skips the exchange for any live session cookie and
+// sends it back to the page, which refuses it again. A removed member's
+// GET /signin must end that session and say why, and the next /signin
+// must start a real sign-in.
+func TestARemovedHomeMemberIsNotBouncedBetweenSigninAndThePage(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.link(t)
+ f.app.cfg.allow = allowlist{}
+ w := f.do("GET", "/signin?return_to=%2Fsettings", "")
+ if w.Code != 303 || !strings.Contains(w.Header().Get("Location"), "err=removed") {
+ t.Fatalf("a removed member's /signin: %d %q, want the removed notice", w.Code, w.Header().Get("Location"))
+ }
+ if next := homeRedirect(t, f.do("GET", "/signin", "")); !strings.HasPrefix(next, "/auth/start") {
+ t.Fatalf("after the notice, /signin did not start a sign-in: %q", next)
+ }
+}
+
+// The account link is "Connect your Oficina account" (spec §13 Q3: the
+// hub is never named), in the account menu, offered to a local session
+// only: "Connect Home account" on the list page is retired.
+func TestTheAccountMenuOffersToConnectYourOficinaAccount(t *testing.T) {
+ f := newHomeRouterFixture(t)
+ f.local(t)
+ body := f.do("GET", "/", "").Body.String()
+ if !strings.Contains(body, `<a estilo-menu-item href="/account/home"><span estilo-menu-label>Connect your Oficina account</span></a>`) || strings.Contains(body, "Connect Home account") {
+ t.Fatalf("the list page's account link:\n%s", body)
+ }
+ f.link(t)
+ if body := f.do("GET", "/", "").Body.String(); strings.Contains(body, "/account/home") {
+ t.Fatal("a Home session is offered to connect the account it already is")
+ }
+}
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go
index 12c11c6..be19dc7 100644
--- a/internal/calls/native_auth.go
+++ b/internal/calls/native_auth.go
@@ -242,7 +242,10 @@ func (a *app) nativeAuthorize(w http.ResponseWriter, r *http.Request) {
if person.Anonymous {
title = "Continue to the Calls app"
}
- render(w, "native", a.page(r, map[string]any{"CancelHref": cancelHref, "Guest": person.Anonymous, "Title": title, "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)}))
+ // The hand-off pages keep no-referrer (nativeHeaders) and so show no
+ // account menu: an empty corner, as the sign-in pages have (spec §9.5,
+ // "not a chrome page").
+ render(w, "native", a.page(r, frame{parts: []string{title}, signinPage: true}, map[string]any{"CancelHref": cancelHref, "Guest": person.Anonymous, "Title": title, "Action": "/native/authorize?id=" + id, "Done": false, "Code": protocol.AuthenticationCode(id)}))
}
func (a *app) nativeApprove(w http.ResponseWriter, r *http.Request) {
nativeHeaders(w)
@@ -271,7 +274,7 @@ func (a *app) nativeComplete(w http.ResponseWriter, r *http.Request) {
if person.Anonymous {
title = "Calls is ready"
}
- render(w, "native", a.page(r, map[string]any{"Title": title, "Done": true}))
+ render(w, "native", a.page(r, frame{parts: []string{title}, signinPage: true}, map[string]any{"Title": title, "Done": true}))
}
// nativeStartMeeting is POST /native/meetings/start: the app's half of
@@ -296,7 +299,7 @@ func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) {
return
}
p, _ := currentPerson(r)
- title := callTitle(a.store.now(), a.cfg.zone)
+ title := callTitle(a.store.now(), nil, a.cfg.zone, a.cfg.Zone)
m, _, err := a.store.createMeeting(r.Context(), title, nil, actorFor(p))
if err != nil {
a.logger.Error("calls: native start meeting", "err", err)
@@ -305,7 +308,7 @@ func (a *app) nativeStartMeeting(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "application/json")
// The title is the server's, not the app's: callTitle writes it in
- // the instance zone, and an app in another one would otherwise name
- // the meeting an hour it is not.
+ // the instance zone and names that zone, so an app in another one
+ // does not name the meeting an hour it is not.
json.NewEncoder(w).Encode(map[string]string{"sqid": m.Sqid, "title": title})
}
diff --git a/internal/calls/render.go b/internal/calls/render.go
index 400efd3..2c625a2 100644
--- a/internal/calls/render.go
+++ b/internal/calls/render.go
@@ -7,9 +7,11 @@ import (
"io/fs"
"testing/fstest"
+ "amadan.net/oficina/estilo.git/base"
"amadan.net/oficina/estilo.git/callui"
"amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/credits"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"html/template"
"net/http"
@@ -26,13 +28,18 @@ var appFS embed.FS
var assets = rastrillo.NewAssets(callAssets())
func callAssets() fs.FS {
- // callui.css, chrome.css (the sign-in door) and sky.css (the sky
- // behind it) come straight from the estilo this module pins, so they
- // cannot drift from it.
+ // estilo's stylesheets and chrome.js come straight from the estilo
+ // this module pins, so they cannot drift from it: base.css (links),
+ // menu.css (every floating panel), chrome.css (the bar, the account
+ // menu and the sign-in door), sky.css (the sky behind the door),
+ // callui.css (the call's own pieces).
files := fstest.MapFS{
- "static/callui.css": &fstest.MapFile{Data: callui.CSS()},
- "static/chrome.css": &fstest.MapFile{Data: chrome.CSS()},
- "static/sky.css": &fstest.MapFile{Data: sky.CSS()},
+ "static/estilo/base.css": &fstest.MapFile{Data: base.CSS()},
+ "static/estilo/menu.css": &fstest.MapFile{Data: estilomenu.CSS()},
+ "static/estilo/chrome.css": &fstest.MapFile{Data: chrome.CSS()},
+ "static/estilo/chrome.js": &fstest.MapFile{Data: chrome.JS()},
+ "static/estilo/sky.css": &fstest.MapFile{Data: sky.CSS()},
+ "static/callui.css": &fstest.MapFile{Data: callui.CSS()},
}
if err := fs.WalkDir(appFS, "static", func(path string, entry fs.DirEntry, err error) error {
if err != nil {
@@ -57,91 +64,48 @@ var pageNames = []string{"index", "signin", "signedout", "errors", "welcome", "l
var pages = map[string]*template.Template{}
-// doorPages are the pages drawn as the Oficina door: estilo's chrome
-// sign-in templates, parsed alongside rastrillo's field and callout
-// partials that they call.
-var doorPages = map[string]bool{"signin": true, "signedout": true}
-
func init() {
for _, name := range pageNames {
pages[name] = template.Must(buildPage(name).ParseFS(credits.Templates(), "templates/credits.html"))
- if doorPages[name] {
- pages[name] = template.Must(pages[name].ParseFS(chrome.Templates(), "templates/signin.html"))
- }
if name == "home" {
pages[name] = template.Must(pages[name].ParseFS(connect.Templates(), "templates/home_link.html"))
}
}
}
-// buildPage parses rastrillo's ui partials first, then this app's own
-// partials, then the layout and the page, so a page can call
-// confirm-form, field, empty-state and the rest by name with a dict —
-// and chat-panel, the one partial Calls owns, the same way.
+// buildPage parses rastrillo's ui partials first, then estilo's chrome
+// and menu templates (the bar, the account menu, the error page body and
+// the sign-in door, which call rastrillo's field and callout partials),
+// then this app's own partials, then the layout and the page, so a page
+// can call confirm-form, field, empty-state and the rest by name with a
+// dict — and chat-panel, the one partial Calls owns, the same way.
//
// templates/partials/ is parsed for every page rather than per page
// that uses it: a partial is defined-but-unused for free, and the
// alternative is a second list beside pageNames that has to be kept in
// step with the first.
func buildPage(name string) *template.Template {
- return template.Must(
- template.Must(
- template.Must(template.New("layout").
- Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
- Funcs(template.FuncMap{"asset": assets.Path}).
- ParseFS(ui.Templates(), "*.html")).
- ParseFS(appFS, "templates/partials/*.html")).
- ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
+ t := template.New("layout").
+ Funcs(ui.Funcs(ui.WithIcons(icons.Icon, icons.Assets))).
+ Funcs(template.FuncMap{"asset": assets.Path})
+ t = template.Must(t.ParseFS(ui.Templates(), "*.html"))
+ t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(estilomenu.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(appFS, "templates/partials/*.html"))
+ return template.Must(t.ParseFS(appFS, "templates/layout.html", "templates/"+name+".html"))
}
+// render writes a page. A template failure is logged by nobody and
+// answered in the plainest way left: nothing has been written yet, so
+// the status can still say so.
func render(w http.ResponseWriter, name string, data any) {
var buf bytes.Buffer
if err := pages[name].ExecuteTemplate(&buf, "layout", data); err != nil {
http.Error(w, "something went wrong", http.StatusInternalServerError)
return
}
- buf.WriteTo(w)
-}
-
-// Viewer is the signed-in person the shell's account corner names, plus
-// whether they currently hold member standing — the layout's only use
-// for it is deciding whether the Settings link belongs beside sign-out
-// (spec's team-only surface: a guest has no agents to manage).
-type Viewer struct {
- Email string
- Member bool
- // Directory is the hub directory origin (HOME_DIRECTORY_ORIGIN) the
- // account corner's "All apps" links to: set only in Home mode, and
- // empty means no link.
- Directory string
-}
-
-// page builds render's data map for a request that may be signed in:
-// it adds "Viewer" when the request carries a signed-in person
-// (currentPerson, auth.go), so layout.html's account corner and
-// sign-out form appear; a request with no person in context (signin,
-// followLink before a session exists) gets data back unchanged, and
-// the layout's {{if .Viewer}} renders nothing. A page reached only
-// while signed out (templates/signin.html) has no need to call this
-// and renders with the plain render(w, name, data) instead.
-//
-// A method on *app, not a free function, because Viewer.Member needs
-// a.isMember — the team as it stands right now (standing.go) — which
-// a bare *http.Request cannot answer on its own.
-func (a *app) page(r *http.Request, data map[string]any) map[string]any {
- if p, ok := currentPerson(r); ok && !p.Anonymous {
- v := Viewer{Email: p.Email, Member: a.isMember(p)}
- if a.home != nil {
- v.Directory = a.cfg.home.Directory
- }
- data["Viewer"] = v
+ if w.Header().Get("Content-Type") == "" {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
}
- return data
-}
-
-// ErrorPage renders the one error template with the status and the
-// reference rastrillo minted for the log line.
-func (a *app) ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
- w.WriteHeader(status)
- render(w, "errors", a.page(r, map[string]any{"Status": status, "Ref": ref}))
+ buf.WriteTo(w)
}
diff --git a/internal/calls/signin_return.go b/internal/calls/signin_return.go
new file mode 100644
index 0000000..a0faa0c
--- /dev/null
+++ b/internal/calls/signin_return.go
@@ -0,0 +1,209 @@
+package calls
+
+// Back to where you were (shared chrome spec §9.4). Before this a
+// signed-out visit to /settings, a meeting's holding screen or any other
+// guarded page went to a bare /signin, and after the emailed link the
+// person landed on the meeting list: rastrillo/auth's RequireSession
+// drops the path, and its Verify always lands on a fixed SignedInPath.
+//
+// So the path rides a cookie instead of the link. GET /signin?return_to=
+// remembers it, sealed and short-lived, and /continue (SignedInPath)
+// goes there once a pending meeting join — which takes precedence, being
+// the reason the person signed in at all — has had its turn. With Home
+// configured, connect's own Signin remembers return_to for the Home
+// exchange; this cookie serves the local link there (/signin/local).
+//
+// This is a port of Slides' cookie (b19fbda), which Astra reviewed on
+// oficina/slides #14: sealed with a key of its own, bound to this origin
+// by the purpose, with an issued and an expiry time checked on read, one
+// cookie only, strict base64, and handoff.SafePath on the way in and the
+// way out, so it can never name another site or loop into /signin.
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "math"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "amadan.net/oficina/home/handoff"
+ "amadan.net/oficina/home/handoff/seal"
+)
+
+// returnCookieAge is how long a remembered page lasts: a link opened
+// after ten minutes, or in another browser, lands on the meeting list.
+const returnCookieAge int64 = 600
+const returnCookieLimit = 4096
+
+type returnIntent struct {
+ Path string `json:"path"`
+ Issued int64 `json:"issued"`
+ Expires int64 `json:"expires"`
+}
+
+// returnCookieName is host-only, and __Host- prefixed wherever the origin
+// is https.
+func (a *app) returnCookieName() string {
+ if a.secure() {
+ return "__Host-calls_return"
+ }
+ return "calls_return"
+}
+
+func (a *app) secure() bool { return strings.HasPrefix(a.cfg.Origin, "https://") }
+
+// returnSealer keeps the cookie independent of every other sealed value:
+// its key is a domain-bound hash of the instance key. A changed key
+// invalidates old return cookies, which is safe.
+func (a *app) returnSealer() *seal.Sealer {
+ key := sha256.Sum256([]byte("oficina/calls return v1\x00" + a.cfg.instanceKey))
+ s, err := seal.New(key[:], "oficina/calls")
+ if err != nil {
+ return nil
+ }
+ return s
+}
+
+func (a *app) returnPurpose() string { return "calls.return.v1:" + a.cfg.Origin }
+
+// safeReturn is the request's return_to, when there is exactly one and
+// it is a same-origin path (handoff.SafePath: no scheme, no host, no
+// "//", no control characters, not /signin or /auth/*) and not an
+// invitation, whose path is the capability itself.
+func safeReturn(q url.Values) (string, bool) {
+ values, ok := q["return_to"]
+ if !ok || len(values) != 1 || !safeReturnPath(values[0]) {
+ return "", false
+ }
+ return values[0], true
+}
+
+func safeReturnPath(p string) bool {
+ return handoff.SafePath(p) && !strings.HasPrefix(p, "/j/")
+}
+
+func (a *app) rememberReturn(w http.ResponseWriter, path string) {
+ s := a.returnSealer()
+ if !safeReturnPath(path) || s == nil {
+ return
+ }
+ now := a.store.now().Unix()
+ if now <= 0 || now > math.MaxInt64-returnCookieAge {
+ return
+ }
+ raw, err := json.Marshal(returnIntent{Path: path, Issued: now, Expires: now + returnCookieAge})
+ if err != nil {
+ return
+ }
+ sealed, err := s.Seal(a.returnPurpose(), raw)
+ if err != nil {
+ return
+ }
+ value := base64.RawURLEncoding.EncodeToString(sealed)
+ if len(value) > returnCookieLimit {
+ return
+ }
+ http.SetCookie(w, &http.Cookie{
+ Name: a.returnCookieName(), Value: value, Path: "/",
+ MaxAge: int(returnCookieAge), HttpOnly: true, Secure: a.secure(),
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+func (a *app) forgetReturn(w http.ResponseWriter) {
+ http.SetCookie(w, &http.Cookie{
+ Name: a.returnCookieName(), Value: "", Path: "/",
+ MaxAge: -1, HttpOnly: true, Secure: a.secure(),
+ SameSite: http.SameSiteLaxMode,
+ })
+}
+
+// rememberedReturn reads the remembered page back, validated again: the
+// cookie is a request header like any other.
+func (a *app) rememberedReturn(r *http.Request) (string, bool) {
+ s := a.returnSealer()
+ if s == nil {
+ return "", false
+ }
+ count := 0
+ for _, c := range r.Cookies() {
+ if c.Name == a.returnCookieName() {
+ count++
+ }
+ }
+ if count != 1 {
+ return "", false
+ }
+ c, err := r.Cookie(a.returnCookieName())
+ if err != nil || c.Value == "" || len(c.Value) > returnCookieLimit {
+ return "", false
+ }
+ sealed, err := base64.RawURLEncoding.Strict().DecodeString(c.Value)
+ if err != nil || base64.RawURLEncoding.EncodeToString(sealed) != c.Value {
+ return "", false
+ }
+ raw, err := s.Open(a.returnPurpose(), sealed)
+ if err != nil || len(raw) > returnCookieLimit {
+ return "", false
+ }
+ var intent returnIntent
+ if json.Unmarshal(raw, &intent) != nil {
+ return "", false
+ }
+ canonical, _ := json.Marshal(intent)
+ now := a.store.now().Unix()
+ if !bytes.Equal(raw, canonical) || intent.Issued <= 0 || intent.Issued > now || intent.Expires <= now ||
+ intent.Expires <= intent.Issued || intent.Expires-intent.Issued > returnCookieAge || !safeReturnPath(intent.Path) {
+ return "", false
+ }
+ return intent.Path, true
+}
+
+// requireSignedInPage is rastrillo/auth's RequireSession with return_to
+// for a page: a signed-out GET or HEAD that asked for HTML is answered
+// 303 /signin?return_to=<the page>. Everything else — a POST, and the
+// room's stream, signal, ICE and info fetches, which never ask for HTML —
+// goes to the guard and gets exactly today's answer, which is what
+// room.js's readRefusal reads a sign-out from (spec §9.4).
+//
+// With Home configured every signed-out GET carries return_to, as
+// homeReturnTo on main (bf4e24d, #22) already did: a fetch redirected to
+// /signin?return_to=… has still gone somewhere else, which is what
+// readRefusal reads, so the room's answer is unchanged there too.
+func (a *app) requireSignedInPage(next http.Handler) http.Handler {
+ guarded := a.auth.RequireSession(next)
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if (r.Method == http.MethodGet || r.Method == http.MethodHead) && (wantsHTML(r) || a.home != nil) {
+ if _, ok := a.auth.SessionFrom(r); !ok {
+ w.Header().Set("Cache-Control", "no-store")
+ http.Redirect(w, r, signinPath+"?return_to="+url.QueryEscape(r.URL.RequestURI()), http.StatusSeeOther)
+ return
+ }
+ }
+ guarded.ServeHTTP(w, r)
+ })
+}
+
+// signedOutAfter wraps rastrillo/auth's Signout, which ends the session
+// and redirects to the sign-in form: a page that, with no word about
+// what had just happened, read like an error. It goes to /signed-out,
+// which says so.
+func signedOutAfter(signout http.HandlerFunc) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ signout(&toSignedOut{ResponseWriter: w}, r)
+ }
+}
+
+// toSignedOut rewrites Signout's Location, at the last moment a header is
+// still settable (sameAnswer's technique).
+type toSignedOut struct{ http.ResponseWriter }
+
+func (t *toSignedOut) WriteHeader(status int) {
+ if t.Header().Get("Location") == signinPath {
+ t.Header().Set("Location", "/signed-out")
+ }
+ t.ResponseWriter.WriteHeader(status)
+}
diff --git a/internal/calls/static/call-layout.js b/internal/calls/static/call-layout.js
index 4966c29..ca1411c 100644
--- a/internal/calls/static/call-layout.js
+++ b/internal/calls/static/call-layout.js
@@ -698,13 +698,28 @@
// A task later rather than at once: room.js takes the tiles down inside
// its own pagehide listener, and the observer would run between that
// and ours; by the time a timer fires, `leaving` has been set.
- let pending = 0;
+ //
+ // And never later than the next frame: a task alone let the browser
+ // paint the tiles once in their unplaced places first — a lone self
+ // tile drawn at filmstrip size, then the whole stage — which was a
+ // layout shift of 0.43 to 0.80 on joining (shared chrome R2, "the room
+ // layout reserved up front"). Whichever of the timer and the frame
+ // comes first lays out; both still come after the pagehide listeners.
+ let pending = false;
+ let pendingTimer = 0;
+ let pendingFrame = 0;
+ const settle = () => {
+ if (!pending) return;
+ pending = false;
+ clearTimeout(pendingTimer);
+ cancelAnimationFrame(pendingFrame);
+ if (!drag || !drag.moving) layout();
+ };
new MutationObserver(() => {
if (pending || (drag && drag.moving)) return;
- pending = setTimeout(() => {
- pending = 0;
- if (!drag || !drag.moving) layout();
- }, 0);
+ pending = true;
+ pendingTimer = setTimeout(settle, 0);
+ pendingFrame = requestAnimationFrame(settle);
})
.observe(tiles, {childList: true, subtree: true, attributes: true, attributeFilter: ['hidden', 'data-speaker']});
new MutationObserver(chatState).observe(chat, {attributes: true, attributeFilter: ['hidden']});
diff --git a/internal/calls/static/calls-shell.css b/internal/calls/static/calls-shell.css
index 764a5d4..b68de91 100644
--- a/internal/calls/static/calls-shell.css
+++ b/internal/calls/static/calls-shell.css
@@ -1,28 +1,38 @@
-/* Shared day tokens keep the browser's OS theme and suite preferences. */
+/* calls-shell.css: Calls' own rules for the pages under the estilo bar
+ (the bar itself is estilo's chrome.css). */
body { background: var(--rst-bg); }
-[rst-shell-topbar] { --rst-page-max: 80rem; }
-[rst-shell-bar] { min-block-size: 4rem; }
-[rst-shell-brand] { min-block-size: 44px; }
[rst-page] { max-inline-size: 80rem; margin-inline: auto; }
-.calls-lobby { max-inline-size: 32rem; margin: clamp(1rem, 8vh, 5rem) auto; }
-.calls-lobby [estilo-call-panel] { margin-block-start: 1.5rem; padding: clamp(1rem, 4vw, 1.5rem); }
-.calls-lobby h1 { font-size: 2rem; letter-spacing: -0.025em; }
.calls-lobby-heading h1 { font-size: 2rem; }
-[estilo-call-header] h1 { font-size: 1.5rem; }
-[estilo-call-header] [rst-page-header] { margin: 0; }
-/* At 800px and up the framework makes [rst-shell-tail] display:contents,
- so its children are laid out by the bar itself and the tail stops
- grouping them. [rst-shell-account] carries margin-inline-start:auto and
- the Help dropdown does not, so Help — which comes first in the tail —
- was pushed hard against the brand while the account went right. Move
- the push onto whichever dropdown leads the tail, and give the account
- an ordinary gap after it instead of a second auto margin, which would
- only split the two apart again. Below 800px the tail is a column and
- an auto inline-start margin would right-align Help in the open menu,
- so this stays inside the same breakpoint the framework used. */
-@media (min-width: 800px) {
- [rst-shell-tail] > [rst-dropdown] { margin-inline-start: auto; }
- [rst-shell-tail] > [rst-dropdown] ~ [rst-shell-account] {
- margin-inline-start: var(--rst-sp-3);
- }
-}
+
+/* The meeting and agent lists: rastrillo's list rows (rst-list >
+ rst-row), with the row's ⋯ menu drawn as estilo's floating menu, the
+ one menu style in the suite (spec §7.1). The ⋯ is an in-flow control,
+ so its hover is square (§11), and it sits above the row's
+ whole-row link overlay. */
+[rst-row] > [rst-row-menu] { position: relative; z-index: 1; flex: none; }
+/* The open one above the rows after it, whose own ⋯ would otherwise
+ paint through its panel (each ⋯ is a stacking context at 1). */
+[rst-row] > [rst-row-menu][open] { z-index: 2; }
+[rst-row] > [rst-row-menu] > summary { inline-size: 2rem; block-size: 2rem; border-radius: 0; color: var(--rst-text-muted); }
+.calls-row-menu { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); }
+.calls-row-title { color: var(--rst-text); font-weight: 600; }
+.calls-row-badge { display: inline-flex; align-items: center; gap: 0.25rem; flex: none; position: relative; z-index: 1; color: var(--rst-text-muted); }
+.calls-ended { margin-block-start: var(--rst-sp-5); }
+.calls-ended > summary { cursor: pointer; font-weight: 600; margin-block-end: var(--rst-sp-3); }
+.calls-option-help { margin-block: 0.25rem var(--rst-sp-3); color: var(--rst-text-muted); font-size: var(--rst-fs-sm); }
+.calls-share-link a { overflow-wrap: anywhere; }
+.calls-share-actions { display: flex; flex-wrap: wrap; gap: var(--rst-sp-2); }
+/* The row is script's (share-link.js reveals it): the display above
+ would otherwise beat the hidden attribute and show two buttons that do
+ nothing without script (Astra on #24). */
+.calls-share-actions[hidden], .calls-share-actions > [hidden] { display: none; }
+/* …and Share… inside it, which share-link.js reveals only where the
+ browser can share: rst-btn's display beats hidden the same way. */
+.calls-link-input { inline-size: 100%; max-inline-size: 44rem; }
+
+/* The holding screen: who you'll join as, then the device panel, then
+ the other ways in and the meeting's own pages. */
+.calls-standing { color: var(--rst-text-muted); }
+.calls-holding-more { display: grid; gap: var(--rst-sp-3); justify-items: start; margin-block-start: var(--rst-sp-5); }
+.calls-holding-more p { margin: 0; }
+.calls-holding-more form { margin: 0; }
diff --git a/internal/calls/static/calls.css b/internal/calls/static/calls.css
index 4679721..a3a16b1 100644
--- a/internal/calls/static/calls.css
+++ b/internal/calls/static/calls.css
@@ -150,6 +150,15 @@
margin-block: var(--rst-sp-3);
}
.calls-audio-test[hidden] { display: none; }
+
+/* The holding screen keeps the speaker row's place while it is hidden:
+ devices.js reveals it only after the devices are enumerated, and it is
+ the last thing in the panel, so a row appearing there would still
+ push the page below the panel down (r3 audit F2: the controls dropped
+ ~180px, CLS 0.0116). A browser that cannot choose an output keeps a
+ row's worth of empty space at the panel's foot instead. Only on the
+ holding screen: the room's popover has no page below it to move. */
+#devices #speaker-row[hidden] { display: block; visibility: hidden; }
.calls-audio-test summary {
cursor: pointer;
font-weight: 600;
@@ -380,22 +389,42 @@ body:has(> .calls-window) { background: var(--rst-bg); }
.calls-menu a:hover { background: rgb(255 255 255 / 0.1); }
.calls-nav-menu form { margin: 0; }
.calls-nav-standing {
- margin: 0 0 6px;
- padding: 6px 12px 10px;
- border-block-end: 1px solid rgb(255 255 255 / 0.12);
+ margin: 0;
+ padding: 6px 12px 8px;
color: rgb(255 255 255 / 0.85);
font-size: 0.8125rem;
line-height: 1.4;
}
-.calls-nav-who {
- margin: 6px 0 0;
- padding: 8px 12px 2px;
+/* The account menu's separators, headings and identity row (estilo
+ menu.css), drawn for the dark glass: same order, same words. */
+.calls-nav-menu hr {
+ inline-size: 100%;
+ margin: 4px 0;
+ border: 0;
border-block-start: 1px solid rgb(255 255 255 / 0.12);
- color: rgb(255 255 255 / 0.75);
+}
+.calls-nav-heading {
+ display: block;
+ margin: 0;
+ padding: 6px 12px 2px;
+ color: rgb(255 255 255 / 0.6);
+ font-size: 0.75rem;
+ font-weight: 600;
+}
+.calls-nav-who { margin: 0; padding-block-end: 6px; }
+.calls-nav-address {
+ display: block;
+ padding: 0 12px;
+ color: #fff;
font-size: 0.8125rem;
overflow-wrap: anywhere;
}
+/* The title's menu opens over the notices column. .calls-top is a
+ stacking context at 4 and the notices sit at 5, so without this the
+ open menu drew under the relay notice on a phone (r3 audit C3). */
+.calls-top:has(#call-nav[open]) { z-index: 9; }
+
/* ── With no script: the page's sentence where the picture would be ── */
.calls-scriptless {
position: absolute;
diff --git a/internal/calls/static/devices.js b/internal/calls/static/devices.js
index 3771e9b..938974d 100644
--- a/internal/calls/static/devices.js
+++ b/internal/calls/static/devices.js
@@ -788,6 +788,10 @@
loopResult.textContent = 'Playing it back through ' + label + '.';
}
+ // The test is in the page from the first paint (join.html), so that
+ // nothing moves when this script arrives; it goes only where there is
+ // no Web Audio to run it with.
+ if (testBox && !(AudioCtx && testMic && testSpeaker && testLoop && testAudio)) testBox.hidden = true;
if (testBox && AudioCtx && testMic && testSpeaker && testLoop && testAudio) {
testBox.hidden = false;
const go = (fn, what) => () => {
diff --git a/internal/calls/static/noscript.css b/internal/calls/static/noscript.css
new file mode 100644
index 0000000..d11630f
--- /dev/null
+++ b/internal/calls/static/noscript.css
@@ -0,0 +1,4 @@
+/* Loaded only inside <noscript>: the holding screen's device block, which
+ is in the layout from the first paint for script, has nothing to offer
+ without it (AGENTS.md: browser media is one of the two exceptions). */
+#devices { display: none; }
diff --git a/internal/calls/static/room.js b/internal/calls/static/room.js
index 54a16d4..ad248c5 100644
--- a/internal/calls/static/room.js
+++ b/internal/calls/static/room.js
@@ -5635,7 +5635,8 @@
// and the holding screen as the fallback.
function wayOut(reason) {
if (reason === 'signedout') {
- link('/signin', 'Sign in again');
+ // Back to this meeting's holding screen once signed in (spec §9.4).
+ link('/signin?return_to=' + encodeURIComponent('/m/' + sqid + '/join'), 'Sign in again');
return;
}
if (reason === 'replaced') link('/m/' + encodeURIComponent(sqid) + '/takeover', 'Use this tab instead');
diff --git a/internal/calls/static/zone.js b/internal/calls/static/zone.js
new file mode 100644
index 0000000..ba97709
--- /dev/null
+++ b/internal/calls/static/zone.js
@@ -0,0 +1,14 @@
+// zone.js: the browser's own time zone into "Start a call"'s hidden zone
+// field, so the call's automatic title is in the starter's time rather
+// than the server's (handlers_meetings.go, callZone). Nothing else reads
+// it; without script the field stays empty and the title names its zone.
+(function () {
+ var zone = '';
+ try { zone = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) { zone = ''; }
+ function fill() {
+ var fields = document.querySelectorAll('input[data-calls-zone]');
+ for (var i = 0; i < fields.length; i++) fields[i].value = zone;
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', fill);
+ else fill();
+})();
diff --git a/internal/calls/templates/agent_once.html b/internal/calls/templates/agent_once.html
index 3425450..275c591 100644
--- a/internal/calls/templates/agent_once.html
+++ b/internal/calls/templates/agent_once.html
@@ -1,8 +1,7 @@
-{{define "title"}}Your agent's token{{end}}
{{define "content"}}
{{template "back-nav" dict "Href" "/settings" "Label" "Settings"}}
-{{template "page-header" dict "Title" .Name}}
-<p>An agent acts on your behalf with exactly your reach, as {{.Principal}}.</p>
-<p>This is the only time this token is shown. Copy it now.</p>
-<p><input rst-input readonly value="{{.Token}}" size="80" aria-label="Agent token"></p>
+{{template "page-header" dict "Title" "Your agent’s token"}}
+<p><strong>{{.Name}}</strong> acts on your behalf with exactly your reach, as {{.Principal}}.</p>
+{{template "callout" dict "Body" "This is the only time this token is shown. Copy it now." "Tone" "warning"}}
+<p><input rst-input readonly value="{{.Token}}" aria-label="Agent token" class="calls-link-input"></p>
{{end}}
diff --git a/internal/calls/templates/confirm.html b/internal/calls/templates/confirm.html
index 9f53fec..fc9f518 100644
--- a/internal/calls/templates/confirm.html
+++ b/internal/calls/templates/confirm.html
@@ -1,4 +1,3 @@
-{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "back-nav" dict "Href" .CancelHref "Label" (or .BackLabel "Meetings")}}
{{template "page-header" dict "Title" .Title}}
diff --git a/internal/calls/templates/credits.html b/internal/calls/templates/credits.html
index 6859fa3..29c8a97 100644
--- a/internal/calls/templates/credits.html
+++ b/internal/calls/templates/credits.html
@@ -1,3 +1,2 @@
-{{define "title"}}Open source · Calls{{end}}
{{define "head"}}<link rel="stylesheet" href="/static/credits.css">{{end}}
{{define "content"}}{{template "estilo/credits" .Data}}{{end}}
diff --git a/internal/calls/templates/errors.html b/internal/calls/templates/errors.html
index 5c0345d..e533ab2 100644
--- a/internal/calls/templates/errors.html
+++ b/internal/calls/templates/errors.html
@@ -1,4 +1,3 @@
-{{define "title"}}Something went wrong{{end}}
-{{define "content"}}
-{{template "error-page" dict "Status" .Status "Ref" .Ref}}
-{{end}}
+{{/* The error page's body (estilo-error), inside the normal layout and
+ bar, which show whoever is signed in. errors.go builds it. */}}
+{{define "content"}}{{template "estilo-error" .Error}}{{end}}
diff --git a/internal/calls/templates/guest.html b/internal/calls/templates/guest.html
index 1fa99ae..cc3ca96 100644
--- a/internal/calls/templates/guest.html
+++ b/internal/calls/templates/guest.html
@@ -1,11 +1,8 @@
-{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "page-header" dict "Title" .Title}}
-<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p>
-<p>If the app isn’t installed, you can join in your browser below.</p>
<form rst-form method="post" action="/j/{{.Token}}/guest">
-{{template "field" dict "ID" "name" "Name" "name" "Label" "Your name" "Required" true "Maxlength" "80" "Value" .Name "Error" .NameError}}
+{{template "field" dict "ID" "name" "Name" "name" "Label" "Your name" "Help" "Everyone in the call will see this name." "Required" true "Maxlength" "80" "Value" .Name "Error" .NameError}}
<div rst-form-actions><button type="submit" rst-btn="primary lg">Continue in browser</button></div>
</form>
-<p>No account needed. You can also <a href="/signin">sign in</a>.</p>
+<p>No account needed. Have the Calls app? <a href="{{.NativeURL}}">Join in the app</a></p>
{{end}}
diff --git a/internal/calls/templates/home.html b/internal/calls/templates/home.html
index 3acfe36..a5069b7 100644
--- a/internal/calls/templates/home.html
+++ b/internal/calls/templates/home.html
@@ -1,2 +1 @@
-{{define "title"}}{{.Home.Title}} · Calls{{end}}
{{define "content"}}{{template "home-connect" .Home}}{{end}}
diff --git a/internal/calls/templates/index.html b/internal/calls/templates/index.html
index d6259d4..ec405a8 100644
--- a/internal/calls/templates/index.html
+++ b/internal/calls/templates/index.html
@@ -1,40 +1,53 @@
-{{define "title"}}Meetings{{end}}
+{{define "head"}}<script defer src="{{asset "static/zone.js"}}"></script>{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Meetings"}}
-{{if .HomeEnabled}}<p><a href="/account/home" rst-btn="ghost">Connect Home account</a></p>{{end}}
<section>
-<form method="post" action="/meetings/start"><button type="submit" rst-btn="primary lg">Start a call</button></form>
-</section>
-<section>
-<h2>New meeting</h2>
-<form rst-form method="post" action="/meetings">
- {{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError "Autofocus" true}}
- {{template "field" dict "ID" "starts_at" "Name" "starts_at" "Label" (printf "Starts (%s), or leave blank to start now" .Zone) "Type" "datetime-local" "Error" .StartError}}
- <div rst-form-actions><button type="submit" rst-btn="primary lg">Create meeting</button></div>
+<form method="post" action="/meetings/start">
+ {{/* zone.js fills this with the browser's own time zone, so a call's
+ automatic title ("Call, 30 Sep 21:47") is in the starter's time,
+ not the server's. Without script it stays empty, and the title
+ names the zone it was written in. */}}
+ <input type="hidden" name="zone" value="" data-calls-zone>
+ <button type="submit" rst-btn="primary lg">Start a call</button>
</form>
</section>
<section>
<h2>Upcoming</h2>
{{if .Upcoming}}
-<div rst-list>
+<div rst-list class="calls-meetings">
{{range .Upcoming}}
-<article>
- <h3><a href="/m/{{.Sqid}}/join">{{.Title}}</a>{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3>
- {{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}}
- <p>{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · no live link{{end}}</p>
- <p><a href="/meetings/{{.Sqid}}/settings">Settings</a> · <a href="/meetings/{{.Sqid}}/links/replace">Replace the link</a> · <a href="/meetings/{{.Sqid}}/end">End</a></p>
-</article>
+<div rst-row>
+ <span rst-row-main><a href="/m/{{.Sqid}}/join">{{.Title}}</a><small rst-row-sub>{{if .ByAgent}}By {{.CreatedByPrincipal}} · {{end}}{{if .Starts}}Starts {{.Starts}}{{else}}Starts now{{end}}{{if not .LinkPresent}} · No live link{{end}}</small></span>
+ {{if .ByAgent}}<span class="calls-row-badge">{{icon "bot"}}{{template "badge" dict "Label" "Agent"}}</span>{{end}}
+ <details rst-row-menu name="estilo-menus">
+ <summary aria-label="Actions for {{.Title}}">{{icon "ellipsis"}}</summary>
+ <div estilo-menu class="calls-row-menu">
+ <a estilo-menu-item href="/meetings/{{.Sqid}}/settings"><span estilo-menu-label>Meeting settings</span></a>
+ <a estilo-menu-item href="/meetings/{{.Sqid}}/links/replace"><span estilo-menu-label>Replace the link</span></a>
+ <hr>
+ <a estilo-menu-item="danger" href="/meetings/{{.Sqid}}/end"><span estilo-menu-label>End meeting for everyone</span></a>
+ </div>
+ </details>
+</div>
{{end}}
</div>
{{else}}
-{{template "empty-state" dict "Body" "No meetings yet. Make one above and share its link."}}
+{{template "empty-state" dict "Body" "No meetings yet. Start a call now, or make one below and share its link."}}
{{end}}
</section>
+<section>
+<h2>New meeting</h2>
+<form rst-form method="post" action="/meetings">
+ {{template "field" dict "ID" "title" "Name" "title" "Label" "Title" "Required" true "Maxlength" "200" "Error" .TitleError}}
+ {{template "field" dict "ID" "starts_at" "Name" "starts_at" "Label" "Starts" "Help" (printf "In %s. Leave it blank to start now." .Zone) "Type" "datetime-local" "Error" .StartError}}
+ <div rst-form-actions><button type="submit" rst-btn="primary lg">Create meeting</button></div>
+</form>
+</section>
{{if .Ended}}
-<details>
+<details class="calls-ended">
<summary>Ended meetings ({{len .Ended}})</summary>
<div rst-list>
-{{range .Ended}}<article><h3>{{.Title}}{{if .ByAgent}} {{icon "bot"}}{{template "badge" dict "Label" "Agent"}}{{end}}</h3>{{if .ByAgent}}<p>by {{.CreatedByPrincipal}}</p>{{end}}<p>Ended</p></article>{{end}}
+{{range .Ended}}<div rst-row><span rst-row-main><span class="calls-row-title">{{.Title}}</span><small rst-row-sub>{{if .ByAgent}}By {{.CreatedByPrincipal}} · {{end}}Ended</small></span>{{if .ByAgent}}<span class="calls-row-badge">{{icon "bot"}}{{template "badge" dict "Label" "Agent"}}</span>{{end}}</div>{{end}}
</div>
</details>
{{end}}
diff --git a/internal/calls/templates/join.html b/internal/calls/templates/join.html
index 491ff7a..820f588 100644
--- a/internal/calls/templates/join.html
+++ b/internal/calls/templates/join.html
@@ -1,44 +1,57 @@
-{{define "title"}}{{.Title}}{{end}}
{{define "head"}}<link rel="stylesheet" href="{{asset "static/calls.css"}}">
+{{/* #devices is in the layout from the first paint, so its arrival does
+ not push the page down when devices.js runs; without script this
+ sheet hides it, as the hidden attribute used to. */}}
+<noscript><link rel="stylesheet" href="{{asset "static/noscript.css"}}"></noscript>
<script defer src="{{asset "static/devices.js"}}"></script>
{{end}}
+{{/* The holding screen. Its layout is fixed at the first paint (spec
+ §2's "reserved up front"): the controls and the status line come
+ first in the device panel, and the rows devices.js may reveal later
+ come after them, the speaker last of all, so nothing that is
+ already on screen moves when they arrive. */}}
{{define "content"}}
{{if .IsMember}}{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}{{end}}
<div class="calls-lobby-heading">{{template "page-header" dict "Title" .Title}}</div>
-{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
-<p><a href="{{.NativeURL}}" rst-btn>Join in the app</a></p>
-<p>Or use your browser below. The app must be installed to open it.</p>
-<p>You are {{.Name}}, joining as a {{.Standing}}.</p>
-{{if .Floor}}<p rst-notice>This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}
-{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false" estilo-call-actions><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>{{end}}
-{{if .OpenElsewhere}}<p rst-notice>This meeting is already open in another tab. <a href="/m/{{.Sqid}}/takeover">Use this tab instead</a>.</p>{{end}}
+<p class="calls-standing">{{if .Starts}}Starts {{.Starts}}. {{end}}You’ll join as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.</p>
+{{if .Floor}}{{template "callout" dict "Body" "Calls outside your network may not connect yet." "Tone" "warning"}}{{end}}
+{{if .OpenElsewhere}}<div rst-callout rst-tone="warning"><span rst-callout-ic aria-hidden="true">{{icon "alert-triangle"}}</span><div rst-callout-body><p>This meeting is already open in another tab. <a href="/m/{{.Sqid}}/takeover">Use this tab instead</a>.</p></div></div>{{end}}
<noscript><p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it.</p></noscript>
-<div id="devices" data-sqid="{{.Sqid}}" data-join-href="{{if .OpenElsewhere}}/m/{{.Sqid}}/takeover{{else}}/m/{{.Sqid}}{{end}}" hidden>
+<div id="devices" data-sqid="{{.Sqid}}" data-join-href="{{if .OpenElsewhere}}/m/{{.Sqid}}/takeover{{else}}/m/{{.Sqid}}{{end}}">
<div class="calls-device-layout">
<div class="calls-preview-wrap"><video id="preview" muted autoplay playsinline aria-label="Your camera preview" class="calls-preview"></video></div>
<div estilo-call-panel>
+ <div class="calls-controls" estilo-call-actions>
+ <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
+ <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
+ <button type="button" id="join" rst-btn="primary lg" disabled>Join in browser</button>
+ </div>
+ <p id="status" class="calls-status" role="status"></p>
<div id="pickers" class="calls-pickers">
<div id="camera-row">{{template "field-select" dict "ID" "pick-camera" "Name" "camera" "Label" "Camera"}}</div>
<div id="mic-row">{{template "field-select" dict "ID" "pick-mic" "Name" "mic" "Label" "Microphone"}}</div>
- <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
{{/* Studio audio: the unprocessed capture option. field-check is the
vendored toggle (AGENTS.md's field rule), and the sentence under
it is wired to the input with aria-describedby by devices.js,
- because field-check gives its checkbox no id to write here. The
- row starts hidden like the speaker row: devices.js reveals it
- once it knows there is a microphone to apply it to. */}}
- <div id="raw-row" class="calls-raw-row" hidden>{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
+ because field-check gives its checkbox no id to write here. It
+ is shown from the first paint, so the controls do not move when
+ devices.js decides; devices.js hides it only where there is no
+ microphone to apply it to. */}}
+ <div id="raw-row" class="calls-raw-row">{{template "field-check" dict "Name" "raw-audio" "Label" "Studio audio"}}
<p id="raw-help" class="calls-raw-help">Sends your microphone unprocessed and at a higher bitrate, for an audio interface in a room you have treated. It turns off echo cancellation, noise suppression and automatic gain, so leave it off on a laptop.</p></div>
+ {{/* Last, so that revealing it (only where the browser can choose an
+ output, and once it knows there is one) moves nothing below it. */}}
+ <div id="speaker-row" hidden>{{template "field-select" dict "ID" "pick-speaker" "Name" "speaker" "Label" "Speaker"}}</div>
</div>
{{/* Spec §15.20: the audio test, beside the choices it tests. A
native disclosure, so it opens and closes with no script of its
- own; devices.js reveals it only where there is Web Audio to run
- it with, like the rest of #devices it is never seen without
+ own; devices.js removes it where there is no Web Audio to run it
+ with, and like the rest of #devices it is never seen without
script. Each result is said in words in its own status line, and
the meters are native <meter>s with names of their own. Nothing
here is sent anywhere: the recording is played back from memory
and dropped. */}}
- <details id="audio-test" class="calls-audio-test" hidden>
+ <details id="audio-test" class="calls-audio-test">
<summary>Test audio</summary>
<p class="calls-raw-help">Everything here stays in this browser. Nothing you say is sent anywhere.</p>
<div class="calls-audio-row">
@@ -59,14 +72,12 @@
</div>
<audio id="test-audio" hidden></audio>
</details>
- <div class="calls-controls" estilo-call-actions>
- <button type="button" id="toggle-mute" rst-btn aria-label="Microphone" aria-pressed="false">{{icon "mic"}}Microphone<span id="toggle-mute-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
- <button type="button" id="toggle-camera" rst-btn aria-label="Camera" aria-pressed="false">{{icon "video"}}Camera<span id="toggle-camera-state" class="calls-toggle-state" aria-hidden="true">On</span></button>
- <button type="button" id="join" rst-btn="primary lg" disabled>Join in browser</button>
- </div>
- <p id="status" class="calls-status" role="status"></p>
</div>
</div>
</div>
-{{if .IsMember}}<p><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a></p><p><a href="/meetings/{{.Sqid}}/end">End the meeting</a></p>{{end}}
+<div class="calls-holding-more">
+ <p><a href="{{.NativeURL}}" rst-btn>Join in the app</a> <span class="calls-option-help">The Calls app must be installed to open it.</span></p>
+ {{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">{{icon "share"}}Share link</button></form>
+ <p class="calls-holding-links"><a href="/meetings/{{.Sqid}}/settings">Meeting settings</a> · <a href="/meetings/{{.Sqid}}/end">End meeting for everyone</a></p>{{end}}
+</div>
{{end}}
diff --git a/internal/calls/templates/layout.html b/internal/calls/templates/layout.html
index d912c4f..b979309 100644
--- a/internal/calls/templates/layout.html
+++ b/internal/calls/templates/layout.html
@@ -1,59 +1,60 @@
{{define "layout"}}<!doctype html>
-<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}">
+{{/* layout — every page's document: the head, the estilo bar and <main>.
+
+ The bar is estilo's (chrome.Bar, built once per request in
+ chrome.go), in place of rastrillo's topbar shell: that was 81px, with
+ a raw address, a blue "Settings" link and a Sign out button wrapping
+ under it. estilo's is 52px at every width, signed in or out, with the
+ account always last in the same corner.
+
+ data-theme is written into the first bytes of the response, from the
+ Appearance cookie read server-side (chrome.Appearance), so there is
+ no flash. It is ABSENT for "system": theme.css's toggle is
+ :root[data-theme="light"|"dark"], and with no attribute the browser
+ follows the operating system.
+
+ The <title> is built here and only here, "{part} · {part} · Calls",
+ so no handler can drift from the suite's separator (spec §10).
+
+ <meta charset> comes first. The stylesheets are in the suite's fixed
+ order: tokens, theme, brand, then estilo's base, menu and chrome,
+ then the app's own; a page adds its own in "head", last. */}}
+<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{with .Frame.Theme}} data-theme="{{.}}"{{end}}>
<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>{{range .Frame.TitleParts}}{{.}} · {{end}}{{.Frame.Chrome.App.Name}}</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" .}}Calls{{end}}</title>
<link rel="stylesheet" href="/static/estilo/fonts/v1/fonts.css">
<link rel="stylesheet" href="{{asset "static/tokens.css"}}">
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
+<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/estilo/sky.css"}}">
<link rel="stylesheet" href="{{asset "static/callui.css"}}">
-<link rel="stylesheet" href="{{asset "static/sky.css"}}">
-<link rel="stylesheet" href="{{asset "static/chrome.css"}}">
<link rel="stylesheet" href="{{asset "static/calls-shell.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
<script defer src="{{asset "static/busy.js"}}"></script>
+<script defer src="{{asset "static/estilo/chrome.js"}}"></script>
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
{{block "head" .}}{{end}}
</head>
<body>
-{{/* The page shell. The call room replaces it (room.html's "body"):
- a call is one rounded window in the viewport, and its navigation —
- Meetings, Settings, Help, Sign out — moves into the title's menu. */}}
+{{/* The page shell. The call room replaces it (room.html's "body"): a
+ call is one rounded window in the viewport, and its navigation moves
+ into the title's menu, in the account menu's words and order. */}}
{{block "body" .}}
-<div rst-shell-topbar>
-<a rst-skip href="#main">{{T "rastrillo.ui.shell_skip"}}</a>
-<header rst-shell-bar>
-<a rst-shell-brand estilo-brand href="/"><img estilo-brand-icon src="{{asset "static/brand/icon.svg"}}" alt="" width="28" height="28">Calls</a>
-{{/* The account corner is the only thing this shell's tail ever
- carries — Calls has no nav and no locale menu yet — so the whole
- menu+tail pair is skipped outright for a signed-out request rather
- than rendered empty behind a hamburger nobody needs. .Viewer is
- set only by the page helper (render.go), and only when the request
- carries a signed-in person. */}}
-{{if .Viewer}}
-<details rst-shell-menu name="rst-shell-menu"><summary>{{icon "menu"}}{{T "rastrillo.ui.shell_menu"}}</summary></details>
-<div rst-shell-tail>
-<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>
-<div rst-shell-account>
-<span>{{.Viewer.Email}}</span>
-{{if .Viewer.Directory}}<a href="{{.Viewer.Directory}}/">All apps</a>{{end}}
-{{if .Viewer.Member}}<a href="/settings">Settings</a>{{end}}
-<form method="post" action="/signout"><button type="submit" rst-btn="ghost">Sign out</button></form>
-</div>
-</div>
-{{end}}
-</header>
-<main id="main">
-<div rst-page>
+<div estilo-shell>
+<a rst-skip href="#main">{{.Frame.Chrome.Labels.Skip}}</a>
+{{template "estilo-bar" .Frame.Chrome}}
+<main rst-page id="main">
{{template "content" .}}
-</div>
</main>
</div>
{{end}}
diff --git a/internal/calls/templates/link_once.html b/internal/calls/templates/link_once.html
index 9f7c1f3..ebe1195 100644
--- a/internal/calls/templates/link_once.html
+++ b/internal/calls/templates/link_once.html
@@ -1,9 +1,8 @@
-{{define "title"}}Your meeting link{{end}}
{{define "content"}}
{{template "back-nav" dict "Href" "/" "Label" "Meetings"}}
-{{template "page-header" dict "Title" .Title}}
-{{if .Starts}}<p>Starts {{.Starts}}.</p>{{end}}
-<p>{{if .Replaced}}The old links have stopped working. {{end}}Copy this link and send it to the people you are meeting. You can find it again using Share in the meeting.</p>
-<p><input rst-input readonly value="{{.Link}}" size="80" aria-label="Meeting link"></p>
+{{template "page-header" dict "Title" "Your meeting link"}}
+<p><strong>{{.Title}}</strong>{{if .Starts}} · Starts {{.Starts}}{{end}}</p>
+<p>{{if .Replaced}}The old links have stopped working. {{end}}Copy this link and send it to the people you’re meeting. You can find it again with Share link in the meeting.</p>
+<p><input rst-input readonly value="{{.Link}}" aria-label="Meeting link" class="calls-link-input"></p>
<p><a href="/m/{{.Sqid}}/join" rst-btn="primary">Go to the meeting</a></p>
{{end}}
diff --git a/internal/calls/templates/meeting_settings.html b/internal/calls/templates/meeting_settings.html
index 6dadd24..a215ebc 100644
--- a/internal/calls/templates/meeting_settings.html
+++ b/internal/calls/templates/meeting_settings.html
@@ -1,9 +1,7 @@
-{{define "title"}}Meeting settings{{end}}
{{define "content"}}
-{{template "back-nav" dict "Href" (printf "/m/%s/join" .Meeting.Sqid) "Label" "Meeting"}}
+{{template "back-nav" dict "Href" (printf "/m/%s/join" .Meeting.Sqid) "Label" .Title}}
{{template "page-header" dict "Title" "Meeting settings"}}
-<p>{{.Title}}</p>
-{{if .Saved}}<p rst-notice role="status">Meeting settings saved.</p>{{end}}
+{{if .Saved}}{{template "callout" dict "Body" "Meeting settings saved." "Tone" "positive"}}{{end}}
<p>Changes apply when people join. People already admitted can stay in the call.</p>
<form rst-form method="post" action="/meetings/{{.Meeting.Sqid}}/settings">
{{template "meeting-options" .Meeting}}
diff --git a/internal/calls/templates/native.html b/internal/calls/templates/native.html
index a35e03f..da04f0a 100644
--- a/internal/calls/templates/native.html
+++ b/internal/calls/templates/native.html
@@ -1,4 +1,3 @@
-{{define "title"}}{{.Title}}{{end}}
{{define "content"}}
{{template "page-header" dict "Title" .Title}}
<div rst-box>
diff --git a/internal/calls/templates/partials/meeting-options.html b/internal/calls/templates/partials/meeting-options.html
index 95b456e..899ce07 100644
--- a/internal/calls/templates/partials/meeting-options.html
+++ b/internal/calls/templates/partials/meeting-options.html
@@ -1,6 +1,6 @@
{{define "meeting-options"}}
{{template "field-check" dict "Name" "require_signin" "Label" "Require sign-in to join" "Checked" .RequireSignin}}
-<p>Off: anyone with the invitation link can join using their name.</p>
+<p class="calls-option-help">When this is off, anyone with the invitation link can join using just their name.</p>
{{template "field-check" dict "Name" "waiting_room" "Label" "Enable waiting room" "Checked" .WaitingRoom}}
-<p>On: guests wait for a team member to admit them. Off: guests join directly.</p>
+<p class="calls-option-help">Guests wait until someone on the team lets them in. When this is off, guests join directly.</p>
{{end}}
diff --git a/internal/calls/templates/room.html b/internal/calls/templates/room.html
index 3bf2521..415200a 100644
--- a/internal/calls/templates/room.html
+++ b/internal/calls/templates/room.html
@@ -1,4 +1,3 @@
-{{define "title"}}{{.Title}}{{end}}
{{define "head"}}<link rel="stylesheet" href="{{asset "static/calls.css"}}">
<link rel="stylesheet" href="{{asset "static/chat.css"}}">
<script defer src="{{asset "static/chat.js"}}"></script>
@@ -26,13 +25,16 @@
<header id="call-top" class="calls-top calls-glass calls-fades">
<details id="call-nav" class="calls-nav">
<summary aria-label="Menu" title="Meetings, help and your account">{{icon "menu"}}</summary>
+ {{/* The account menu's items, in its order and its words (chrome.go,
+ roomMenu): who is signed in, the team's pages, Help → Open
+ source, the one Sign out. Dark, because it is over the picture. */}}
<nav class="calls-nav-menu" aria-label="Calls">
- <p class="calls-nav-standing">You are {{.Name}}, joining as a {{.Standing}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p>
- {{if .IsMember}}<a href="/">Meetings</a>{{end}}
- {{if .Viewer}}{{if .Viewer.Member}}<a href="/settings">Settings</a>{{end}}{{end}}
- <a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Help: open source</a>
- {{if .Viewer}}<p class="calls-nav-who">{{.Viewer.Email}}</p>
- <form method="post" action="/signout"><button type="submit">Sign out</button></form>{{end}}
+ <p class="calls-nav-standing">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p>
+ {{with .Menu.Identity}}<hr><p class="calls-nav-who"><span class="calls-nav-heading">Signed in as</span><span class="calls-nav-address">{{.}}</span></p>{{end}}
+ {{if .Menu.Nav}}<hr>{{range .Menu.Nav}}<a href="{{.Href}}">{{.Label}}</a>{{end}}{{end}}
+ <hr><p class="calls-nav-heading">Help</p>
+ {{range .Menu.Help}}<a href="{{.Href}}">{{.Label}}</a>{{end}}
+ {{with .Menu.SignOut}}<hr><form method="post" action="{{.Action}}"><button type="submit">{{.Label}}</button></form>{{end}}
</nav>
</details>
<div class="calls-top-text">
@@ -42,8 +44,8 @@
and the relay notice is one of the notices; with no script
neither has anywhere else to be, so both are here in full. */}}
<p id="call-state" class="calls-top-line" hidden></p>
- <noscript><p class="calls-top-line">You are {{.Name}}, joining as a {{.Standing}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p>
- {{if .Floor}}<p rst-notice class="calls-top-line calls-floor">This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p>{{end}}</noscript>
+ <noscript><p class="calls-top-line">You’re in this call as {{.Name}}{{if eq .Standing "guest"}}, a guest{{end}}.{{if .Starts}} Starts {{.Starts}}.{{end}}</p>
+ {{if .Floor}}<p class="calls-top-line calls-floor">Calls outside your network may not connect yet.</p>{{end}}</noscript>
</div>
</header>
@@ -53,13 +55,13 @@
copy is the scriptless one: <noscript> is parsed as markup only when
scripting is off, so exactly one of the two is ever real. Without it
the sentence below is false, which is how it stood between c80a506
- and a6c2ac8. "End the meeting" is here for the same reason: with
+ and a6c2ac8. "End meeting for everyone" is here for the same reason: with
script it is in the bar's More menu, inside #room. */}}
<noscript><div class="calls-scriptless">
<p>The call needs JavaScript to reach your camera and the other participants; everything else on this page works without it. <a href="/m/{{.Sqid}}/join">Back to the holding screen</a>.</p>
- <p class="calls-scriptless-links">{{if .IsMember}}<a href="/">Meetings</a> {{end}}<a href="/help/open-source" referrerpolicy="no-referrer" rel="noreferrer">Help</a></p>
+ <p class="calls-scriptless-links">{{if .IsMember}}<a href="/">Meetings</a> {{end}}<a href="/help/open-source">Open source</a></p>
{{if .IsMember}}<form method="post" action="/m/{{.Sqid}}/share" target="_blank" rel="noopener" data-busy="false"><button type="submit" rst-btn aria-label="Share meeting link">Share link</button></form>
- <p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End the meeting</a></p>{{end}}
+ <p><a href="/meetings/{{.Sqid}}/end" target="_blank" rel="noopener">End meeting for everyone</a></p>{{end}}
</div></noscript>
<div id="room" class="calls-room" data-sqid="{{.Sqid}}" data-boot="{{.Boot}}" data-self-name="{{.Name}}" data-is-member="{{if .IsMember}}1{{end}}" data-relay="{{if .Relay}}1{{end}}" data-peer="" hidden>
@@ -81,7 +83,7 @@
{{/* §5.5's relay notice, once a session: call-window.js shows it on
the first visit of a tab's session and it stays until it is put
away. */}}
- {{if .Floor}}<div id="floor-notice" class="calls-floor-notice" hidden><p>This team's Calls has no relay yet. Calls usually connect only between people on the same network.</p><button type="button" id="floor-dismiss" aria-label="Dismiss the relay notice" title="Dismiss">{{icon "x"}}</button></div>{{end}}
+ {{if .Floor}}<div id="floor-notice" class="calls-floor-notice" hidden><p>Calls outside your network may not connect yet.</p><button type="button" id="floor-dismiss" aria-label="Dismiss the relay notice" title="Dismiss">{{icon "x"}}</button></div>{{end}}
<div id="queue" class="calls-queue" hidden><h2>Waiting to join</h2><ul id="knocks"></ul></div>
</div>
diff --git a/internal/calls/templates/settings.html b/internal/calls/templates/settings.html
index e0a7cb2..00992b8 100644
--- a/internal/calls/templates/settings.html
+++ b/internal/calls/templates/settings.html
@@ -1,10 +1,9 @@
-{{define "title"}}Settings{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Settings"}}
<section>
<h2>Meeting defaults</h2>
<p>Applies to new meetings created by anyone on this team. Existing meetings keep their own settings.</p>
-{{if .Saved}}<p rst-notice role="status">Meeting defaults saved.</p>{{end}}
+{{if .Saved}}{{template "callout" dict "Body" "Meeting defaults saved." "Tone" "positive"}}{{end}}
<form rst-form method="post" action="/settings/defaults">
{{template "meeting-options" .Defaults}}
<div rst-form-actions><button type="submit" rst-btn="primary lg">Save defaults</button></div>
@@ -13,22 +12,16 @@
<section>
<h2>Agents</h2>
{{if .Agents}}
-<div rst-list>
+<div rst-list class="calls-agents">
{{range .Agents}}
-<article>
- <h3>{{.Name}}</h3>
- <p>Created {{.Created}}</p>
- {{if .Revoked}}
- <p>{{template "status-pill" dict "Label" "Revoked"}}</p>
- {{else}}
- <p>{{.Principal}}</p>
- <p><a href="/settings/agents/{{.ID}}/revoke">Revoke</a></p>
- {{end}}
-</article>
+<div rst-row>
+ <span rst-row-main><span class="calls-row-title">{{.Name}}</span><small rst-row-sub>{{if not .Revoked}}{{.Principal}} · {{end}}Created {{.Created}}</small></span>
+ {{if .Revoked}}{{template "status-pill" dict "Label" "Revoked"}}{{else}}<a rst-row-action href="/settings/agents/{{.ID}}/revoke" aria-label="Revoke {{.Name}}">Revoke</a>{{end}}
+</div>
{{end}}
</div>
{{else}}
-{{template "empty-state" dict "Body" "No agents yet."}}
+{{template "empty-state" dict "Body" "No agents yet. An agent is a token a program uses to make and end meetings for you."}}
{{end}}
</section>
<section>
diff --git a/internal/calls/templates/share.html b/internal/calls/templates/share.html
index 45b5465..e3ff6c1 100644
--- a/internal/calls/templates/share.html
+++ b/internal/calls/templates/share.html
@@ -1,11 +1,9 @@
-{{define "title"}}Share meeting{{end}}
{{define "head"}}<script defer src="{{asset "static/share-link.js"}}"></script>{{end}}
{{define "content"}}
{{template "page-header" dict "Title" "Share meeting"}}
-<p>{{.Title}}</p>
-<p>Send this invitation to the people you want to join. Guests will wait to be let in.</p>
-<p><a id="invitation-link" href="{{.Link}}" style="overflow-wrap: anywhere">{{.Link}}</a></p>
-<div id="share-actions" hidden>
+<p>Send this invitation to the people you want to join <strong>{{.Title}}</strong>. Guests wait to be let in.</p>
+<p class="calls-share-link"><a id="invitation-link" href="{{.Link}}">{{.Link}}</a></p>
+<div id="share-actions" class="calls-share-actions" hidden>
<button type="button" id="copy-invitation" rst-btn>Copy link</button>
<button type="button" id="share-invitation" rst-btn hidden>Share…</button>
</div>
diff --git a/internal/calls/templates/signedout.html b/internal/calls/templates/signedout.html
index f967279..abad39f 100644
--- a/internal/calls/templates/signedout.html
+++ b/internal/calls/templates/signedout.html
@@ -1,5 +1,4 @@
-{{define "title"}}{{.Notice.Title}}{{end}}
-{{/* Where Sign out lands with Home connected: the Oficina door's
- signed-out page, in the door's own words. home.go's renderHome
- builds it. */}}
+{{/* Where Sign out lands: the Oficina door's signed-out page, in the
+ door's own words. auth.go's renderSignedOut builds it, standalone
+ and with Home. */}}
{{define "content"}}{{template "estilo-signed-out" .Notice}}{{end}}
diff --git a/internal/calls/templates/signin.html b/internal/calls/templates/signin.html
index e45f2a2..c5f7a3b 100644
--- a/internal/calls/templates/signin.html
+++ b/internal/calls/templates/signin.html
@@ -1,4 +1,3 @@
-{{define "title"}}{{if .Sent}}{{.Notice.Title}}{{else}}{{.Door.Title}}{{end}}{{end}}
{{/* The Oficina door (estilo chrome): one card on the suite's sky, Calls'
mark and name at its head. The form, or once a link has been asked
for, the check-your-email notice. auth.go's localSignin builds both. */}}
diff --git a/internal/calls/templates/welcome.html b/internal/calls/templates/welcome.html
index 7679b9b..8771c8c 100644
--- a/internal/calls/templates/welcome.html
+++ b/internal/calls/templates/welcome.html
@@ -1,5 +1,4 @@
-{{define "title"}}Signed in{{end}}
{{define "content"}}
-{{template "page-header" dict "Title" "You're signed in"}}
-<p>You're signed in as {{.Email}}. This team's Calls has no meetings for you. If someone sent you a meeting link, open it again now.</p>
+{{template "page-header" dict "Title" "You’re signed in"}}
+<p>You’re signed in as {{.Email}}. There are no meetings here for you yet. If someone sent you a meeting link, open it again now.</p>
{{end}}
diff --git a/internal/callstest/agents_test.go b/internal/callstest/agents_test.go
index 332e853..a5670e0 100644
--- a/internal/callstest/agents_test.go
+++ b/internal/callstest/agents_test.go
@@ -307,7 +307,7 @@ func TestCreateIsRefusedAtTwentyLiveAgents(t *testing.T) {
if strings.Contains(list, "one-too-many") {
t.Fatal("the refused 21st agent was created anyway")
}
- if got := strings.Count(list, "Revoke"); got != 20 {
+ if got := strings.Count(list, ">Revoke</a>"); got != 20 {
t.Fatalf("expected exactly 20 live agents, found %d Revoke links:\n%s", got, list)
}
}
@@ -331,7 +331,7 @@ func TestCreateIsRefusedForADuplicateLiveNameButFreedByRevoke(t *testing.T) {
t.Fatalf("no duplicate-name sentence on the refusal:\n%s", rec.Body.String())
}
list := c.get("/settings").Body.String()
- if got := strings.Count(list, "Revoke"); got != 1 {
+ if got := strings.Count(list, ">Revoke</a>"); got != 1 {
t.Fatalf("expected exactly 1 live agent, found %d Revoke links:\n%s", got, list)
}
diff --git a/internal/callstest/api_test.go b/internal/callstest/api_test.go
index cc976e2..bf365dc 100644
--- a/internal/callstest/api_test.go
+++ b/internal/callstest/api_test.go
@@ -1183,16 +1183,20 @@ func TestListShowsEachAgentsOwnPrincipal(t *testing.T) {
}
}
-// rowFor is one meeting's <article> on the list page, so a badge can be
-// attributed to the meeting it is actually on rather than to the page.
+// rowFor is one meeting's list row (rst-row) on the list page, so a
+// badge can be attributed to the meeting it is actually on rather than
+// to the page.
func rowFor(t *testing.T, body, title string) string {
t.Helper()
i := strings.Index(body, title)
if i < 0 {
t.Fatalf("%q is not on the list:\n%s", title, body)
}
- start := strings.LastIndex(body[:i], "<article")
- end := strings.Index(body[i:], "</article>")
+ start := strings.LastIndex(body[:i], "<div rst-row>")
+ end := strings.Index(body[i:], "<div rst-row>")
+ if end < 0 {
+ end = strings.Index(body[i:], "</section>")
+ }
if start < 0 || end < 0 {
t.Fatalf("%q is not inside a list row:\n%s", title, body)
}
diff --git a/internal/callstest/browser_test.go b/internal/callstest/browser_test.go
index 7a85ffd..e3ca30c 100644
--- a/internal/callstest/browser_test.go
+++ b/internal/callstest/browser_test.go
@@ -84,6 +84,7 @@ var scenarios = []string{
"layout",
"glyphs",
"window",
+ "chrome",
"devices",
"audiotest",
"call",
diff --git a/internal/callstest/chrome_test.go b/internal/callstest/chrome_test.go
new file mode 100644
index 0000000..d54c50b
--- /dev/null
+++ b/internal/callstest/chrome_test.go
@@ -0,0 +1,450 @@
+package callstest
+
+// The shared chrome (estilo spec/shared-chrome, round 2), black-box:
+// every page wears the 52px estilo bar with one account menu and one
+// Sign out, is titled "<Page> · Calls", answers errors as pages for a
+// browser and as text for a fetch, and sends a signed-out person back to
+// the page they asked for once they have signed in.
+
+import (
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "os"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+ "time"
+
+ "amadan.net/oficina/estilo.git/base"
+ "amadan.net/oficina/estilo.git/chrome"
+ estilomenu "amadan.net/oficina/estilo.git/menu"
+)
+
+var titleRe = regexp.MustCompile(`<title>([^<]*)</title>`)
+
+func pageTitle(t *testing.T, body string) string {
+ t.Helper()
+ m := titleRe.FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("no <title>:\n%s", body)
+ }
+ return m[1]
+}
+
+// Spec §10: every page's <title> is "<Page> · … · Calls", the page's h1
+// first, and the app root is "Calls" alone.
+func TestEveryPageIsTitledPageDotCalls(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ for path, want := range map[string]string{
+ "/": "Calls",
+ "/settings": "Settings · Calls",
+ "/m/" + sqid + "/join": "Board meeting · Calls",
+ "/m/" + sqid: "Board meeting · Calls",
+ "/meetings/" + sqid + "/settings": "Meeting settings · Board meeting · Calls",
+ "/meetings/" + sqid + "/end": "End meeting for everyone? · Board meeting · Calls",
+ "/help/open-source": "Open source · Calls",
+ "/no/such/page": "Page not found · Calls",
+ "/m/zzzzzzzzzz/join": "Meeting not available · Calls",
+ } {
+ if got := pageTitle(t, c.getNav(path).Body.String()); got != want {
+ t.Errorf("GET %s: title %q, want %q", path, got, want)
+ }
+ }
+ out := x.browser(t)
+ for path, want := range map[string]string{
+ "/signin": "Sign in · Calls",
+ "/signin?sent=1": "Check your email · Calls",
+ "/signed-out": "Signed out · Calls",
+ "/no/such/page": "Page not found · Calls",
+ } {
+ if got := pageTitle(t, out.getNav(path).Body.String()); got != want {
+ t.Errorf("signed out GET %s: title %q, want %q", path, got, want)
+ }
+ }
+}
+
+// Spec §5 and §8: the estilo bar, not rastrillo's topbar; signed in, one
+// account menu holding the only Sign out; signed out, a "Sign in" that
+// comes back to this page; on the sign-in pages themselves, nothing.
+func TestPagesWearTheEstiloBarWithOneAccountMenu(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ for _, path := range []string{"/", "/settings", "/m/" + sqid + "/join", "/meetings/" + sqid + "/settings", "/help/open-source", "/no/such/page"} {
+ body := c.getNav(path).Body.String()
+ if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "rst-shell-bar") {
+ t.Errorf("GET %s does not wear the estilo bar", path)
+ }
+ if n := strings.Count(body, ">Sign out<"); n != 1 {
+ t.Errorf("GET %s has %d Sign out, want exactly 1", path, n)
+ }
+ if !strings.Contains(body, `<summary estilo-bar-trigger aria-label="Account: paul@example.com">`) {
+ t.Errorf("GET %s has no account menu", path)
+ }
+ for _, want := range []string{`<span estilo-menu-identity-label>Signed in as</span>`, `<p estilo-menu-heading id="estilo-appearance">Appearance</p>`, `<a estilo-menu-item href="/help/open-source"><span estilo-menu-label>Open source</span></a>`} {
+ if !strings.Contains(body, want) {
+ t.Errorf("GET %s: the account menu lacks %q", path, want)
+ }
+ }
+ for _, gone := range []string{"Help: open source", "rst-shell-account", ">Help</summary>"} {
+ if strings.Contains(body, gone) {
+ t.Errorf("GET %s still carries %q", path, gone)
+ }
+ }
+ }
+ if body := c.getNav("/settings").Body.String(); !strings.Contains(body, `<a href="/settings" aria-current="page">Settings</a>`) {
+ t.Error("the bar's nav does not mark Settings as the current page")
+ }
+ out := x.browser(t)
+ body := out.getNav("/no/such/page").Body.String()
+ if !strings.Contains(body, `<a rst-btn href="/signin?return_to=%2Fno%2Fsuch%2Fpage" estilo-bar-signin>Sign in</a>`) || strings.Contains(body, "estilo-account") {
+ t.Errorf("a signed-out page's corner is not Sign in, back to it:\n%s", body)
+ }
+ for _, path := range []string{"/signin", "/signin?sent=1", "/signed-out"} {
+ body := out.getNav(path).Body.String()
+ if !strings.Contains(body, "<header estilo-bar>") || strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") {
+ t.Errorf("GET %s: the sign-in pages' corner must be empty", path)
+ }
+ }
+}
+
+// Spec §8.4: a guest gets the same "Sign in" corner, never an account.
+func TestAGuestGetsTheSignInCorner(t *testing.T) {
+ x, sqid, token := memberWithMeeting(t)
+ g := x.browser(t)
+ invite := g.getNav("/j/" + token).Body.String()
+ if !strings.Contains(invite, `<a rst-btn href="/signin" estilo-bar-signin>Sign in</a>`) {
+ t.Errorf("the invitation page's corner must be a bare Sign in (its path is the capability):\n%s", invite)
+ }
+ if rec := g.post("/j/"+token+"/guest", url.Values{"name": {"Visitor"}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("guest join: %d", rec.Code)
+ }
+ body := g.getNav("/m/" + sqid + "/join").Body.String()
+ if !strings.Contains(body, "estilo-bar-signin") || strings.Contains(body, "estilo-account") || strings.Contains(body, ">Sign out<") {
+ t.Errorf("a guest's holding page corner is not Sign in:\n%s", body)
+ }
+ if !strings.Contains(body, "You’ll join as Visitor, a guest.") {
+ t.Errorf("the holding page does not say who the guest joins as:\n%s", body)
+ }
+}
+
+// Spec §3: an error is a page for a browser and plain text for anything
+// else, so the room's own fetches keep the answers room.js reads.
+func TestErrorPagesAreSplitByAccept(t *testing.T) {
+ x, _, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ page := c.getNav("/no/such/page")
+ if page.Code != http.StatusNotFound || !strings.HasPrefix(page.Header().Get("Content-Type"), "text/html") ||
+ !strings.Contains(page.Body.String(), "We can’t find that page") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/">Go to Calls</a>`) {
+ t.Errorf("unknown route for a browser: %d %q\n%s", page.Code, page.Header().Get("Content-Type"), page.Body.String())
+ }
+ plain := c.get("/no/such/page")
+ if plain.Code != http.StatusNotFound || !strings.HasPrefix(plain.Header().Get("Content-Type"), "text/plain") {
+ t.Errorf("unknown route for a fetch: %d %q", plain.Code, plain.Header().Get("Content-Type"))
+ }
+ meeting := c.getNav("/m/zzzzzzzzzz/join")
+ if meeting.Code != http.StatusNotFound || !strings.Contains(meeting.Body.String(), "This meeting isn’t available") {
+ t.Errorf("missing meeting for a browser: %d\n%s", meeting.Code, meeting.Body.String())
+ }
+ if fetch := c.get("/m/zzzzzzzzzz/info"); fetch.Code != http.StatusNotFound || !strings.HasPrefix(fetch.Header().Get("Content-Type"), "text/plain") {
+ t.Errorf("missing meeting for a fetch: %d %q", fetch.Code, fetch.Header().Get("Content-Type"))
+ }
+ // A POST-only path opened from history is no page, not a 405.
+ if rec := c.getNav("/signout"); rec.Code != http.StatusNotFound || !strings.Contains(rec.Body.String(), "We can’t find that page") {
+ t.Errorf("GET /signout: %d", rec.Code)
+ }
+ del := c.do(httptest.NewRequest(http.MethodDelete, "/signin", nil))
+ if del.Code != http.StatusMethodNotAllowed || !strings.Contains(del.Header().Get("Allow"), "GET") {
+ t.Errorf("DELETE /signin: %d Allow %q", del.Code, del.Header().Get("Allow"))
+ }
+}
+
+// Spec §9.4: a signed-out page request carries the page to sign-in, and
+// the emailed link lands back on it. The room's fetches keep today's
+// bare redirect, which room.js reads as "signed out".
+func TestSignInComesBackToTheDeepLink(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ rec := c.getNav("/meetings/" + sqid + "/settings")
+ want := "/signin?return_to=" + url.QueryEscape("/meetings/"+sqid+"/settings")
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != want {
+ t.Fatalf("signed-out deep link: %d %q, want %q", rec.Code, rec.Header().Get("Location"), want)
+ }
+ if fetch := c.getStream("/m/" + sqid + "/stream"); fetch.Code != http.StatusSeeOther || fetch.Header().Get("Location") != "/signin" {
+ t.Errorf("the stream's signed-out answer changed: %d %q", fetch.Code, fetch.Header().Get("Location"))
+ }
+ door := c.getNav(want).Body.String()
+ if !strings.Contains(door, `name="return_to" value="/meetings/`+sqid+`/settings"`) {
+ t.Errorf("the door does not carry return_to:\n%s", door)
+ }
+ if rec := c.post("/signin", url.Values{"address": {"paul@example.com"}}); rec.Code != http.StatusSeeOther {
+ t.Fatalf("POST /signin: %d", rec.Code)
+ }
+ // "Use a different address" keeps the destination (Docs #65, 2).
+ sent := c.getNav("/signin?sent=1").Body.String()
+ if !strings.Contains(sent, `href="/signin?return_to=`+url.QueryEscape("/meetings/"+sqid+"/settings")+`">Use a different address</a>`) {
+ t.Errorf("Use a different address loses the destination:\n%s", sent)
+ }
+ _, link := x.mail.last()
+ if dest := c.follow(link); dest != "/meetings/"+sqid+"/settings" {
+ t.Fatalf("after the link: %q, want the deep link", dest)
+ }
+ // Signed in, /signin skips the form and goes where it was asked to.
+ if rec := c.getNav("/signin?return_to=%2Fsettings"); rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" {
+ t.Errorf("signed in, GET /signin: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ // And a destination off this origin is never followed.
+ if rec := c.getNav("/signin?return_to=%2F%2Fevil.example"); rec.Header().Get("Location") != "/" {
+ t.Errorf("signed in, GET /signin?return_to=//evil.example: %q", rec.Header().Get("Location"))
+ }
+}
+
+// The remembered page is sealed: a hand-written cookie is ignored.
+func TestAForgedReturnCookieIsIgnored(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.cookies["calls_return"] = &http.Cookie{Name: "calls_return", Value: "%2F%2Fevil.example"}
+ if dest := c.signIn("paul@example.com"); dest != "/" {
+ t.Fatalf("a forged return cookie was followed: %q", dest)
+ }
+}
+
+// r3 audit C6: a malformed address is the field's own error, not
+// "Check your email" — and nothing is sent.
+func TestAMalformedAddressIsTheFieldsError(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ rec := c.post("/signin", url.Values{"address": {"not-an-email@"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signin?err=address" {
+ t.Fatalf("malformed address: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ if x.mail.count() != 0 {
+ t.Fatal("a malformed address was mailed")
+ }
+}
+
+// Paul, 2026-10-01: the door's button says "Sign in"; the sent page
+// says what happened in the suite's words (spec §9.2).
+func TestTheDoorSpeaksTheSuitesWords(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ door := c.getNav("/signin").Body.String()
+ for _, want := range []string{`type="submit">Sign in</button>`, "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", `<a href="/help/open-source">Open source</a>`} {
+ if !strings.Contains(door, want) {
+ t.Errorf("the door lacks %q", want)
+ }
+ }
+ if strings.Contains(door, "Email me a link") {
+ t.Error("the door still says Email me a link")
+ }
+ sent := c.getNav("/signin?sent=1").Body.String()
+ if !strings.Contains(sent, "If that address can sign in here, we’ve sent it a link. It works once and expires in 15 minutes.") || strings.Contains(sent, "should be on its way") {
+ t.Errorf("the sent page's copy:\n%s", sent)
+ }
+}
+
+// Sign out lands on a page that says so (spec §9.2), not on a bare form.
+func TestSignOutLandsOnTheSignedOutPage(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.post("/signout", nil)
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/signed-out" {
+ t.Fatalf("POST /signout: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ page := c.getNav("/signed-out")
+ if page.Code != http.StatusOK || !strings.Contains(page.Body.String(), "You’re signed out") || !strings.Contains(page.Body.String(), `<a rst-btn="primary" href="/signin">Sign in</a>`) {
+ t.Errorf("GET /signed-out: %d\n%s", page.Code, page.Body.String())
+ }
+}
+
+// Spec §8.3: Appearance is a cookie the layout writes into <html>.
+func TestAppearanceIsRememberedPerApp(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ rec := c.post("/appearance", url.Values{"mode": {"dark"}, "return_to": {"/settings"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/settings" {
+ t.Fatalf("POST /appearance: %d %q", rec.Code, rec.Header().Get("Location"))
+ }
+ body := c.getNav("/settings").Body.String()
+ if !strings.Contains(body, `<html lang="en" dir="ltr" data-theme="dark">`) || !strings.Contains(body, `value="dark" aria-pressed="true"`) {
+ t.Errorf("dark was not remembered:\n%s", body[:min(len(body), 600)])
+ }
+}
+
+// Real list rows (spec §2 of the audit): each meeting is an rst-row whose
+// actions are one ⋯ menu in estilo's floating menu, with one label for
+// ending a meeting.
+func TestMeetingsAreListRowsWithOneMenu(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ body := c.getNav("/").Body.String()
+ for _, want := range []string{
+ `<span rst-row-main><a href="/m/` + sqid + `/join">Board meeting</a>`,
+ `<details rst-row-menu name="estilo-menus">`,
+ `<summary aria-label="Actions for Board meeting">`,
+ `<a estilo-menu-item="danger" href="/meetings/` + sqid + `/end"><span estilo-menu-label>End meeting for everyone</span></a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Errorf("the meeting list lacks %q", want)
+ }
+ }
+ if strings.Contains(body, "<article>") {
+ t.Error("the meeting list still renders bare articles")
+ }
+}
+
+// r3 audit C4 / spec S10: Start a call names the call in the starter's
+// own time, and without script names the zone it used.
+func TestStartACallTitlesInTheStartersZone(t *testing.T) {
+ x := newHarness(t, "paul@example.com")
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ tokyo, _ := time.LoadLocation("Asia/Tokyo")
+ rec := c.post("/meetings/start", url.Values{"zone": {"Asia/Tokyo"}})
+ if want := "Call, " + x.now.In(tokyo).Format("2 Jan 15:04"); !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("with the browser's zone the title is not %q:\n%s", want, rec.Body.String())
+ }
+ rec = c.post("/meetings/start", nil)
+ if want := "Call, " + x.now.UTC().Format("2 Jan 15:04") + " UTC"; !strings.Contains(rec.Body.String(), want) {
+ t.Errorf("without one the title is not %q", want)
+ }
+}
+
+// Spec §4's grep gate: one notice vocabulary, rastrillo's callout in a
+// tone. rst-notice is always success green, and carried the relay
+// warning and "open in another tab".
+func TestTemplatesUseCalloutTonesOnly(t *testing.T) {
+ bad := regexp.MustCompile(`rst-notice|rst-callout="|rst-tone="warn"`)
+ n := 0
+ err := filepath.WalkDir(filepath.Join("..", "calls", "templates"), func(path string, d os.DirEntry, err error) error {
+ if err != nil || d.IsDir() || !strings.HasSuffix(path, ".html") {
+ return err
+ }
+ n++
+ b, err := os.ReadFile(path)
+ if err != nil {
+ return err
+ }
+ if m := bad.Find(b); m != nil {
+ t.Errorf("%s uses %q", path, m)
+ }
+ return nil
+ })
+ if err != nil || n < 10 {
+ t.Fatalf("walked %d templates: %v", n, err)
+ }
+}
+
+// Spec §3.3's second gate: every remaining plain-text http.Error is
+// listed here with why it is not a page. A new one fails until it is
+// either a page (errorPage) or classified.
+func TestEveryPlainTextErrorIsClassified(t *testing.T) {
+ classified := map[string]int{
+ // The native app's JSON endpoints (/native/*): a Swift client
+ // reads the status, and a page is the wrong answer to it.
+ "native_auth.go": 12,
+ // POST /native/devices: JSON from the native app.
+ "knockpush.go": 7,
+ // methodNotAllowed's 405 (no person navigates with DELETE) and
+ // plainError, the answer to a request that did not ask for a page.
+ "errors.go": 2,
+ // render's last resort when a template itself fails.
+ "render.go": 1,
+ }
+ files, err := filepath.Glob(filepath.Join("..", "calls", "*.go"))
+ if err != nil || len(files) < 20 {
+ t.Fatalf("glob: %d files, %v", len(files), err)
+ }
+ got := map[string]int{}
+ for _, f := range files {
+ if strings.HasSuffix(f, "_test.go") {
+ continue
+ }
+ b, err := os.ReadFile(f)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if n := strings.Count(string(b), "http.Error("); n > 0 {
+ got[filepath.Base(f)] = n
+ }
+ }
+ for f, n := range got {
+ if classified[f] != n {
+ t.Errorf("%s has %d http.Error calls, %d classified: make it a page, or classify it here with the reason", f, n, classified[f])
+ }
+ }
+ for f, n := range classified {
+ if got[f] != n {
+ t.Errorf("%s: %d classified, %d found", f, n, got[f])
+ }
+ }
+}
+
+// The two-pin check (estilo AGENTS.md; spec §12.1 item 1): Calls pins
+// rastrillo v0.26, estilo's own token tests run against v0.23, so every
+// var(--rst-*) estilo's base, menu and chrome sheets use must be defined
+// in the tokens.css and theme.css Calls actually serves.
+func TestEstilosChromeUsesTokensCallsDefines(t *testing.T) {
+ var defined strings.Builder
+ for _, f := range []string{"tokens.css", "theme.css"} {
+ b, err := os.ReadFile(filepath.Join("..", "calls", "static", f))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defined.Write(b)
+ }
+ defs := map[string]bool{}
+ for _, m := range regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`).FindAllStringSubmatch(defined.String(), -1) {
+ defs[m[1]] = true
+ }
+ use := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)`)
+ for name, css := range map[string][]byte{"base.css": base.CSS(), "menu.css": estilomenu.CSS(), "chrome.css": chrome.CSS()} {
+ uses := use.FindAllSubmatch(css, -1)
+ if len(uses) == 0 && name != "base.css" {
+ t.Errorf("%s uses no tokens: this check proves nothing", name)
+ }
+ for _, m := range uses {
+ if !defs[string(m[1])] {
+ t.Errorf("estilo %s uses %s, which Calls' tokens.css and theme.css do not define", name, m[1])
+ }
+ }
+ }
+}
+
+// Spec §9.5, and Astra's P3 on meet #22: a page that shows Sign out sends
+// Referrer-Policy strict-origin, never no-referrer — in Home mode the
+// chained sign-out is admitted by id.* on the Referer's origin, and
+// no-referrer strands it on "Finish signing out". Open source and Share
+// meeting show the account menu, so they are strict-origin; the native
+// hand-off pages keep no-referrer and so show no Sign out at all.
+func TestPagesWithSignOutSendStrictOrigin(t *testing.T) {
+ x, sqid, _ := memberWithMeeting(t)
+ c := x.browser(t)
+ c.signIn("paul@example.com")
+ for name, rec := range map[string]*httptest.ResponseRecorder{
+ "Open source": c.getNav("/help/open-source"),
+ "Share meeting": c.post("/m/"+sqid+"/share", nil),
+ } {
+ if !strings.Contains(rec.Body.String(), ">Sign out<") {
+ t.Errorf("%s: no Sign out, so this check proves nothing", name)
+ }
+ if got := rec.Header().Get("Referrer-Policy"); got != "strict-origin" {
+ t.Errorf("%s shows Sign out with Referrer-Policy %q, want strict-origin", name, got)
+ }
+ }
+ // Any page with Sign out and no-referrer is the bug, wherever it is.
+ for _, path := range []string{"/", "/settings", "/m/" + sqid + "/join", "/meetings/" + sqid + "/settings", "/no/such/page"} {
+ rec := c.getNav(path)
+ if strings.Contains(rec.Body.String(), ">Sign out<") && rec.Header().Get("Referrer-Policy") == "no-referrer" {
+ t.Errorf("GET %s shows Sign out under no-referrer", path)
+ }
+ }
+}
diff --git a/internal/callstest/holding_test.go b/internal/callstest/holding_test.go
index e2f9f56..d5de3c0 100644
--- a/internal/callstest/holding_test.go
+++ b/internal/callstest/holding_test.go
@@ -2,6 +2,7 @@ package callstest
import (
"net/http"
+ "regexp"
"strings"
"testing"
"time"
@@ -13,7 +14,7 @@ func TestHoldingPageShowsTitleStandingAndFloor(t *testing.T) {
c.signIn("paul@example.com")
rec := c.get("/m/" + sqid + "/join")
body := rec.Body.String()
- for _, want := range []string{"Board meeting", "joining as a member", "no relay yet", "End the meeting"} {
+ for _, want := range []string{"Board meeting", "You’ll join as paul.", "Calls outside your network may not connect yet.", `rst-tone="warning"`, "End meeting for everyone"} {
if !strings.Contains(body, want) {
t.Errorf("holding page lacks %q", want)
}
@@ -113,20 +114,28 @@ func TestHoldingPageHasDevicesScriptAndNoLegacyPlaceholder(t *testing.T) {
}
}
-// Fix round 1, Important 5: the scriptless floor depends on #devices
-// starting hidden — devices.js's first job is un-hiding it — so a
-// regression that drops the attribute would show a bare, unstyled
-// device block to every browser with script disabled. Matched right
-// after data-join-href so this is unambiguously the #devices element's
-// own hidden attribute, not some other tag's.
-func TestHoldingPageDevicesBlockStartsHidden(t *testing.T) {
+// Fix round 1, Important 5, restated for the reserved layout (shared
+// chrome R2): #devices is in the layout from the first paint, so that
+// devices.js revealing it does not push the page down, and without
+// script a <noscript> stylesheet hides it instead — a regression that
+// dropped either would show a bare, unstyled device block to every
+// browser with script disabled, or move the page under one with it.
+func TestHoldingPageDevicesBlockIsHiddenOnlyWithoutScript(t *testing.T) {
x, sqid, _ := memberWithMeeting(t)
c := x.browser(t)
c.signIn("paul@example.com")
rec := c.get("/m/" + sqid + "/join")
body := rec.Body.String()
- if !strings.Contains(body, `data-join-href="/m/`+sqid+`" hidden>`) {
- t.Fatalf("holding page's #devices block should render hidden: %s", body)
+ if !strings.Contains(body, `data-join-href="/m/`+sqid+`">`) {
+ t.Fatalf("holding page's #devices block should be in the layout from the first paint: %s", body)
+ }
+ m := regexp.MustCompile(`<noscript><link rel="stylesheet" href="(/static/noscript\.[0-9a-f]+\.css)"></noscript>`).FindStringSubmatch(body)
+ if m == nil {
+ t.Fatalf("holding page has no <noscript> stylesheet to hide #devices: %s", body)
+ }
+ css := c.get(m[1]).Body.String()
+ if !strings.Contains(css, "#devices { display: none; }") {
+ t.Fatalf("the noscript sheet does not hide #devices: %s", css)
}
}
@@ -185,7 +194,7 @@ func TestHoldingPageOffersStudioAudioAsAVendoredField(t *testing.T) {
c.signIn("paul@example.com")
body := c.get("/m/" + sqid + "/join").Body.String()
for _, want := range []string{
- `<div id="raw-row" class="calls-raw-row" hidden>`,
+ `<div id="raw-row" class="calls-raw-row">`,
`<label rst-switch>`,
`name="raw-audio"`,
`id="raw-help"`,
diff --git a/internal/callstest/join_test.go b/internal/callstest/join_test.go
index 8fc8822..a129127 100644
--- a/internal/callstest/join_test.go
+++ b/internal/callstest/join_test.go
@@ -177,7 +177,7 @@ func TestMemberAddressExhaustsTheSharedLimiterWithTheSameAnswer(t *testing.T) {
if got := x.mail.count(); got != 5 {
t.Fatalf("mail count: %d, want 5", got)
}
- if body := g.get("/signin?err=rate").Body.String(); !strings.Contains(body, "Too many sign-in attempts") {
+ if body := g.get("/signin?err=rate").Body.String(); !strings.Contains(body, "Too many attempts. Wait a few minutes, then try again.") {
t.Fatal("?err=rate does not render rastrillo's rate-limit message")
}
}
diff --git a/internal/callstest/meetings_test.go b/internal/callstest/meetings_test.go
index 57ed8d7..edb44b1 100644
--- a/internal/callstest/meetings_test.go
+++ b/internal/callstest/meetings_test.go
@@ -98,7 +98,7 @@ func TestEndMeetingNeedsItsConfirmPageAndRefusesJoinsAfter(t *testing.T) {
c := x.browser(t)
c.signIn("paul@example.com")
sqid, tok := createMeeting(t, c, "x", "")
- if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End the meeting") {
+ if rec := c.get("/meetings/" + sqid + "/end"); rec.Code != http.StatusOK || !strings.Contains(rec.Body.String(), "End meeting for everyone") {
t.Fatalf("confirm page: %d", rec.Code)
}
if rec := c.post("/meetings/"+sqid+"/end", nil); rec.Code != http.StatusSeeOther {
diff --git a/internal/callstest/relay_floor_test.go b/internal/callstest/relay_floor_test.go
index 5935382..c08273e 100644
--- a/internal/callstest/relay_floor_test.go
+++ b/internal/callstest/relay_floor_test.go
@@ -36,7 +36,7 @@ func TestRoomPageShowsFloorWithNoRelay(t *testing.T) {
c := x.browser(t)
c.signIn("paul@example.com")
body := c.get("/m/" + sqid).Body.String()
- if !strings.Contains(body, "no relay yet") {
+ if !strings.Contains(body, "Calls outside your network may not connect yet.") {
t.Fatalf("room page lacks the floor sentence with no relay configured:\n%s", body)
}
// The same fact room.js reads, and the only thing that chooses
@@ -58,11 +58,11 @@ func TestSetRelayHidesTheFloorSentenceOnBothPages(t *testing.T) {
}
c := x.browser(t)
c.signIn("paul@example.com")
- if body := c.get("/m/" + sqid + "/join").Body.String(); strings.Contains(body, "no relay yet") {
+ if body := c.get("/m/" + sqid + "/join").Body.String(); strings.Contains(body, "Calls outside your network may not connect yet.") {
t.Fatalf("holding page still shows the floor sentence with a relay configured:\n%s", body)
}
body := c.get("/m/" + sqid).Body.String()
- if strings.Contains(body, "no relay yet") {
+ if strings.Contains(body, "Calls outside your network may not connect yet.") {
t.Fatalf("room page still shows the floor sentence with a relay configured:\n%s", body)
}
// §5.5's other half: room.js says "even through the relay" on a
diff --git a/internal/callstest/room_test.go b/internal/callstest/room_test.go
index ca0ca34..180eda6 100644
--- a/internal/callstest/room_test.go
+++ b/internal/callstest/room_test.go
@@ -29,7 +29,7 @@ func TestRoomPageRendersItsHeaderStandingAndScriptlessFallback(t *testing.T) {
}
// True before any participation exists: the room page renders
// server-side, ahead of anything room.js does (Important 14).
- if !strings.Contains(body, "joining as a member") {
+ if !strings.Contains(body, "You’re in this call as paul.") {
t.Fatalf("room page's own-standing sentence: %s", body)
}
// calls.css and room.js move into layout.html's new head block
@@ -730,7 +730,7 @@ func TestRoomIsOneWindowWhoseNavigationWorksWithoutScript(t *testing.T) {
t.Fatal("the room still renders the page shell's header")
}
title := strings.Index(body, `<h1 id="call-title">Board meeting</h1>`)
- standing := strings.Index(body, "joining as a member")
+ standing := strings.Index(body, "You’re in this call as paul.")
if title < win || title > room || standing < win || standing > room {
t.Fatalf("the title and the standing line must be server-rendered in the window, outside #room: title %d standing %d room %d", title, standing, room)
}
@@ -770,7 +770,7 @@ func TestGuestRoomWindowCarriesNoMemberActions(t *testing.T) {
guest.signIn("guest@example.com")
rec := guest.get("/m/" + sqid)
body := rec.Body.String()
- if rec.Code != http.StatusOK || !strings.Contains(body, "joining as a guest") {
+ if rec.Code != http.StatusOK || !strings.Contains(body, ", a guest.") {
t.Fatalf("guest room: %d\n%s", rec.Code, body)
}
for _, not := range []string{`href="/">Meetings</a>`, `href="/settings"`, `/end"`, `action="/m/` + sqid + `/share"`, `id="call-more"`} {
@@ -795,17 +795,17 @@ func TestRoomTitlePillMovesStandingAndRelayNoticeOut(t *testing.T) {
body := c.get("/m/" + sqid).Body.String()
nav := strings.Index(body, `<nav class="calls-nav-menu"`)
- standing := strings.Index(body, `<p class="calls-nav-standing">You are paul, joining as a member.`)
+ standing := strings.Index(body, `<p class="calls-nav-standing">You’re in this call as paul.`)
meetings := strings.Index(body, `href="/">Meetings</a>`)
if nav < 0 || standing < nav || meetings < standing {
t.Fatalf("the standing line must open the title's menu, above Meetings: nav %d standing %d meetings %d", nav, standing, meetings)
}
top := body[strings.Index(body, `<div class="calls-top-text">`):strings.Index(body, `</header>`)]
ns := strings.Index(top, "<noscript>")
- if ns < 0 || !strings.Contains(top[ns:], "joining as a member") || !strings.Contains(top[ns:], "no relay yet") {
+ if ns < 0 || !strings.Contains(top[ns:], "You’re in this call as paul.") || !strings.Contains(top[ns:], "Calls outside your network may not connect yet.") {
t.Fatalf("with no script the pill must still say the standing and the relay notice:\n%s", top)
}
- if strings.Contains(top[:ns], "joining as a member") || strings.Contains(top[:ns], "no relay yet") {
+ if strings.Contains(top[:ns], "You’re in this call as paul.") || strings.Contains(top[:ns], "Calls outside your network may not connect yet.") {
t.Fatalf("with script the pill is the title and its status only:\n%s", top[:ns])
}
room := strings.Index(body, `<div id="room"`)
diff --git a/internal/callstest/signin_test.go b/internal/callstest/signin_test.go
index 38c5951..9d67806 100644
--- a/internal/callstest/signin_test.go
+++ b/internal/callstest/signin_test.go
@@ -189,10 +189,10 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) {
// A malformed address is the field's own error; other outcomes are
// the page's.
- if page := c.get("/signin?err=address").Body.String(); !strings.Contains(page, `aria-invalid="true"`) || !strings.Contains(page, "That does not look like an email address.") {
+ if page := c.get("/signin?err=address").Body.String(); !strings.Contains(page, `aria-invalid="true"`) || !strings.Contains(page, "That doesn’t look like an email address.") {
t.Errorf("err=address is not a field error:\n%s", page)
}
- if page := c.get("/signin?err=rate").Body.String(); !strings.Contains(page, `rst-tone="negative" role="alert"`) || !strings.Contains(page, "Too many sign-in attempts") {
+ if page := c.get("/signin?err=rate").Body.String(); !strings.Contains(page, `rst-tone="negative" role="alert"`) || !strings.Contains(page, "Too many attempts. Wait a few minutes, then try again.") {
t.Errorf("err=rate is not a page error:\n%s", page)
}
}
diff --git a/test/browser/call.mjs b/test/browser/call.mjs
index 59058f8..00fe3c8 100644
--- a/test/browser/call.mjs
+++ b/test/browser/call.mjs
@@ -1040,7 +1040,9 @@ async function signInAsGuest(ctx, address = GUEST) {
await member.close();
const page = await ctx.newPage();
await page.goto(origin + '/j/' + token, { waitUntil: 'domcontentloaded' });
- if (page.url().includes('/j/')) await page.getByRole('link', { name: 'sign in', exact: true }).click();
+ // The invitation page's way to sign in is the bar's corner (shared
+ // chrome R2: guests get the same "Sign in" as anyone signed out).
+ if (page.url().includes('/j/')) await page.locator('[estilo-bar-signin]').click();
check(page.url().startsWith(origin + '/signin'),
'the link sends a signed-out outsider to sign in (at ' + where(page) + ')');
@@ -3259,7 +3261,13 @@ const run = {
check(heard.self === false, "the line B sees is not drawn as B's own");
}
- const echo = (await chatLog(pageA)).find((l) => l.text === HELLO);
+ // A's echo is appended when A's own POST is answered, and B's line
+ // when the hub's broadcast reaches B's stream: two different events
+ // with no order between them. Reading A's log the instant B had the
+ // line raced that, and lost about one run in six on main f5a1254 as
+ // on this branch (#24). The claim is unchanged — A's log shows what A
+ // said — and it gets the same two seconds B's does.
+ const echo = await until(async () => (await chatLog(pageA)).find((l) => l.text === HELLO) || false, 2000);
check(echo, "A's own log shows what A said (" + JSON.stringify(await chatLog(pageA)) + ')');
if (echo) check(echo.self === true, "A's echo is drawn as A's own line");
@@ -4330,7 +4338,8 @@ const run = {
check(await waitStatus(page, 'You have been signed out.', 10000),
'the signed-out room says so — status: ' + (await statusText(page)));
const href = await page.locator('#status a').first().getAttribute('href');
- check(href === '/signin', 'the signed-out room offers the way back in (' + href + ')');
+ // Back in, and back to this meeting's holding screen (spec §9.4).
+ check(href === '/signin?return_to=' + encodeURIComponent('/m/' + sqid + '/join'), 'the signed-out room offers the way back in, to this meeting (' + href + ')');
await ctx.close();
@@ -4974,6 +4983,89 @@ const run = {
// white at 4.5:1 or better over white, black and busy video, with and
// without its blur; and with no script the room still reads sensibly
// inside the window. CALLS_WINDOW_SNAPSHOTS=<dir> keeps pictures.
+ // Shared chrome R2 (estilo spec/shared-chrome): the holding screen and
+ // the room hold still while they settle, and the room's title menu
+ // speaks the account menu's words. The room used to paint a lone self
+ // tile at filmstrip size and then fill the stage with it, a layout
+ // shift of 0.43–0.80 on some joins, so the room is loaded eight times.
+ async chrome() {
+ // The audit's own viewport, where both shifts were measured.
+ const ctx = await newContext('member A', { cookie: cookieA, options: { viewport: { width: 1440, height: 900 } } });
+ await ctx.addInitScript(() => {
+ window.__cls = 0;
+ window.__shifts = [];
+ new PerformanceObserver((l) => {
+ for (const e of l.getEntries()) {
+ if (e.hadRecentInput) continue;
+ window.__cls += e.value;
+ window.__shifts.push({ v: +e.value.toFixed(4), t: Math.round(e.startTime), src: (e.sources || []).map((s) => (s.node && (s.node.id || s.node.className)) || '?').join(',') });
+ }
+ }).observe({ type: 'layout-shift', buffered: true });
+ });
+ const page = await ctx.newPage();
+ const cls = () => page.evaluate(() => ({ cls: window.__cls, shifts: window.__shifts }));
+
+ await page.goto(origin + '/m/' + sqid + '/join');
+ const ready = await until(async () => await page.locator('#join').isEnabled(), 15000);
+ check(ready, 'the holding screen is ready — status: ' + (await page.locator('#status').textContent()));
+ await new Promise((r) => setTimeout(r, 1000));
+ const holding = await cls();
+ check(holding.cls < 0.01, 'the holding screen holds still while its devices arrive (CLS ' + holding.cls.toFixed(4) + ' ' + JSON.stringify(holding.shifts) + ')');
+ check(await page.locator('header[estilo-bar]').count() === 1, 'the holding screen wears the estilo bar');
+
+ // With script, Copy link shows, and Share… only where the browser
+ // can share: rst-btn's display used to beat its hidden attribute.
+ const [share] = await Promise.all([ctx.waitForEvent('page'), page.click('form[action$="/share"] button')]);
+ await share.waitForLoadState('load');
+ const canShare = await share.evaluate(() => !!navigator.share);
+ check(await share.locator('#copy-invitation').isVisible() && (await share.locator('#share-invitation').isVisible()) === canShare,
+ 'the Share page shows Copy link, and Share… only where the browser can share (navigator.share ' + canShare + ')');
+ await share.close();
+
+ await Promise.all([page.waitForURL('**/m/' + sqid), page.click('#join')]);
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ check(await until(async () => (await state(page)).admitted, 20000), 'joined from the holding screen');
+ await new Promise((r) => setTimeout(r, 1500));
+ const joined = await cls();
+ check(joined.cls < 0.01, 'holding screen → room holds still (CLS ' + joined.cls.toFixed(4) + ' ' + JSON.stringify(joined.shifts) + ')');
+
+
+ // The title menu: the account menu's order and words.
+ await page.mouse.move(200, 200);
+ await page.click('#call-nav > summary');
+ const menu = (await page.locator('.calls-nav-menu').innerText()).split('\n').map((l) => l.trim()).filter(Boolean);
+ const want = ['Signed in as', 'Meetings', 'Settings', 'Help', 'Open source', 'Sign out'];
+ const order = want.map((w) => menu.indexOf(w));
+ check(order.every((i, n) => i >= 0 && (n === 0 || i > order[n - 1])), 'the room menu reads ' + want.join(' / ') + ' in that order (' + JSON.stringify(menu) + ')');
+ check(await page.getByText('Sign out', { exact: true }).count() === 1, 'the room has exactly one Sign out');
+ const onTop = await page.evaluate(() => {
+ const m = document.querySelector('.calls-nav-menu');
+ const r = m.getBoundingClientRect();
+ const hit = document.elementFromPoint(r.left + r.width / 2, r.top + 12);
+ return !!hit && m.contains(hit);
+ });
+ check(onTop, 'the open title menu is above the notices (r3 audit C3)');
+ await page.click('#call-nav > summary');
+ await leaveRoom(page);
+
+ // The room loaded straight from the list, eight times: the shift
+ // came on some joins and not others.
+ let worst = { cls: 0, shifts: [] };
+ for (let i = 0; i < 8; i++) {
+ await page.goto(origin + '/');
+ await page.goto(origin + '/m/' + sqid);
+ await page.waitForFunction(() => !!window.meet, null, { timeout: 20000 });
+ await until(async () => (await state(page)).admitted, 20000);
+ await new Promise((r) => setTimeout(r, 1200));
+ const c = await cls();
+ if (c.cls >= worst.cls) worst = c;
+ await leaveRoom(page);
+ }
+ check(worst.cls < 0.01, 'the room holds still as its tiles arrive, eight loads from the list (worst CLS ' + worst.cls.toFixed(4) + ' ' + JSON.stringify(worst.shifts) + ')');
+ check(errorsFor('member A') === '', 'no console errors (' + errorsFor('member A') + ')');
+ await ctx.close();
+ },
+
async window() {
const out = process.env.CALLS_WINDOW_SNAPSHOTS;
if (out) mkdirSync(out, { recursive: true });
@@ -5006,7 +5098,7 @@ const run = {
'no script: the room is still the inset, rounded window, with no page scroll (' + JSON.stringify(gs) + ')');
const bareText = await still.locator('.calls-window').innerText();
check(bareText.includes('The call needs JavaScript') && bareText.includes('Back to the holding screen') &&
- bareText.includes('joining as a member'),
+ bareText.includes('You’re in this call as paul.'),
'no script: the title, the standing, the sentence and the way back are inside the window');
await still.locator('#call-nav summary').click();
const menu = await still.locator('.calls-nav-menu').innerText();
@@ -5056,15 +5148,18 @@ const run = {
'the title pill says the title and "In the meeting", as the Mac\'s does (' + JSON.stringify(pill) + ')');
await pageA.locator('#call-nav summary').click();
const standing = (await pageA.locator('.calls-nav-standing').innerText()).trim();
- check(standing === 'You are paul, joining as a member.' &&
+ // The standing line opens the menu; then the account menu's order
+ // (shared chrome R2): who is signed in, Meetings, Settings, Help.
+ check(standing === 'You’re in this call as paul.' &&
await pageA.evaluate(() => {
const p = document.querySelector('.calls-nav-standing');
- return !!p.nextElementSibling && p.nextElementSibling.textContent === 'Meetings';
- }), 'the standing line is in the title\'s menu, above Meetings (' + standing + ')');
+ const meetings = [...document.querySelectorAll('.calls-nav-menu a')].find((a) => a.textContent === 'Meetings');
+ return p === p.parentElement.firstElementChild && !!meetings && !!(p.compareDocumentPosition(meetings) & Node.DOCUMENT_POSITION_FOLLOWING);
+ }), 'the standing line opens the title\'s menu, above Meetings (' + standing + ')');
await pageA.keyboard.press('Escape');
check(!(await pageA.locator('#call-nav').evaluate((d) => d.open)), 'Escape closes the menu');
const floorText = await pageA.locator('#notices #floor-notice').innerText().catch(() => '');
- check(await pageA.locator('#floor-notice').isVisible() && floorText.includes('no relay yet'),
+ check(await pageA.locator('#floor-notice').isVisible() && floorText.includes('Calls outside your network may not connect yet.'),
'the relay notice is a notice of its own under the title, not in it');
await shot(pageA, '1440-relay-notice');
await pageA.locator('#floor-dismiss').click();
@@ -5442,6 +5537,21 @@ const run = {
check(await holding.locator('#devices').isHidden(),
'the holding screen keeps its device controls hidden with no script');
+ // Share link works with no script (a POST into a new tab), and the
+ // page it opens shows the link and nothing that needs script: Copy
+ // link and Share… are script's, hidden until share-link.js reveals
+ // them. A display rule on their row used to beat the hidden
+ // attribute, leaving two buttons that did nothing (Astra on #24).
+ const [share] = await Promise.all([
+ ctx.waitForEvent('page'),
+ holding.click('form[action$="/share"] button'),
+ ]);
+ await share.waitForLoadState('domcontentloaded');
+ check(await share.locator('#invitation-link').isVisible(), 'no script: the Share page shows the invitation link (at ' + where(share) + ')');
+ check(await share.locator('#share-actions').isHidden() && await share.locator('#copy-invitation').isHidden(),
+ 'no script: the Share page shows no Copy link or Share… buttons');
+ await share.close();
+
const room = await ctx.newPage();
await room.goto(origin + '/m/' + sqid, { waitUntil: 'domcontentloaded' });
const roomText = await room.locator('body').innerText();