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.
58 files changed,
+2035
−394
cmd/calls/main.go+4 −0go.mod+3 −3go.sum+4 −4internal/calls/app.go+17 −2internal/calls/auth.go+134 −19internal/calls/chrome.go+263 −0internal/calls/credits.go+5 −2internal/calls/credits_test.go+5 −2internal/calls/errors.go+214 −0internal/calls/errors_test.go+80 −0internal/calls/guest.go+7 −22internal/calls/handlers_join.go+3 −2internal/calls/handlers_meetings.go+39 −15internal/calls/handlers_meetings_test.go+20 −1internal/calls/handlers_room.go+8 −5internal/calls/handlers_settings.go+5 −4internal/calls/home.go+47 −11internal/calls/home_router_test.go+48 −3internal/calls/native_auth.go+8 −5internal/calls/render.go+33 −69internal/calls/signin_return.go+209 −0internal/calls/static/call-layout.js+20 −5internal/calls/static/calls-shell.css+35 −25internal/calls/static/calls.css+36 −7internal/calls/static/devices.js+4 −0internal/calls/static/noscript.css+4 −0internal/calls/static/room.js+2 −1internal/calls/static/zone.js+14 −0internal/calls/templates/agent_once.html+4 −5internal/calls/templates/confirm.html+0 −1internal/calls/templates/credits.html+0 −1internal/calls/templates/errors.html+3 −4internal/calls/templates/guest.html+2 −5internal/calls/templates/home.html+0 −1internal/calls/templates/index.html+33 −20internal/calls/templates/join.html+35 −24internal/calls/templates/layout.html+37 −36internal/calls/templates/link_once.html+4 −5internal/calls/templates/meeting_settings.html+2 −4internal/calls/templates/native.html+0 −1internal/calls/templates/partials/meeting-options.html+2 −2internal/calls/templates/room.html+15 −13internal/calls/templates/settings.html+7 −14internal/calls/templates/share.html+3 −5internal/calls/templates/signedout.html+3 −4internal/calls/templates/signin.html+0 −1internal/calls/templates/welcome.html+2 −3internal/callstest/agents_test.go+2 −2internal/callstest/api_test.go+8 −4internal/callstest/browser_test.go+1 −0internal/callstest/chrome_test.go+450 −0internal/callstest/holding_test.go+20 −11internal/callstest/join_test.go+1 −1internal/callstest/meetings_test.go+1 −1internal/callstest/relay_floor_test.go+3 −3internal/callstest/room_test.go+6 −6internal/callstest/signin_test.go+2 −2test/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(); |