Calls on estilo's shared chrome: bar, account menu, errors, titles, callouts, rows
- estilo-bar (52px) in place of rastrillo's 81px topbar; nav Meetings · Settings with the current page marked; the account menu (identity, Connect your Oficina account, Appearance, Help → Open source, one Sign out); "All apps" when HOME_DIRECTORY_ORIGIN is set - error pages (estilo-error) for unknown routes, meetings and 5xx, split by Accept so room.js's fetches keep their plain answers; a router recoverer and Options.ErrorPage for panics - <Page> · Calls titles built once in the layout - callouts in rastrillo tones; no rst-notice left - meetings and agents as rst-list rows, the row's actions in an estilo-menu; "End meeting for everyone" everywhere - the room's title menu in the account menu's order and words - sign-in: the door says Sign in; the suite's copy; a malformed address is the field's error; return_to carried in a sealed cookie; a removed Home identity is signed out and told, not looped; /signed-out standalone - the holding screen's layout reserved from the first paint - Start a call titles the call in the starter's zone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
50 files changed,
+460
−269
callscore/session.go+5 −2callscore/session_test.go+20 −0cmd/calls/main.go+1 −0internal/calls/auth.go+2 −1internal/calls/chrome.go+0 −1internal/calls/credits.go+5 −2internal/calls/credits_test.go+5 −2internal/calls/guest.go+6 −3internal/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 −14internal/calls/home_router_test.go+9 −2internal/calls/native_auth.go+5 −5internal/calls/render.go+1 −1internal/calls/static/calls-shell.css+26 −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 −35internal/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+0 −1internal/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/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+3 −3
diff --git a/callscore/session.go b/callscore/session.go| index 6f40203..ecf18b7 100644 |
| --- a/callscore/session.go |
| +++ b/callscore/session.go |
| @@ -144,7 +144,7 @@ func OpenLink(ctx context.Context, origin, address string, client *http.Client) |
| // SignOut revokes the held session on its instance: the same POST |
| // /signout the web's button makes, carrying the Origin csrf.Protect |
| // reads. Either sign-out route answers with a redirect once the row is |
| -// gone. |
| +// gone: 303 standalone, 307 in Home mode. |
| // |
| // Best effort, and the caller forgets its copy whatever this answers: a |
| // person who pressed Sign out is signed out of this app, and a session |
| @@ -171,7 +171,10 @@ func SignOut(ctx context.Context, origin string, client *http.Client) error { |
| return errors.New("cannot reach Calls") |
| } |
| res.Body.Close() |
| - if res.StatusCode != http.StatusSeeOther { |
| + // 303 to /signed-out standalone; in Home mode, 307 on to the issuer's |
| + // /signout for the browser's id.* session, which this client neither |
| + // holds nor follows (safeClient). Both come after the row is gone. |
| + if res.StatusCode != http.StatusSeeOther && res.StatusCode != http.StatusTemporaryRedirect { |
| return errors.New("Calls did not confirm the sign-out") |
| } |
| return nil |
diff --git a/callscore/session_test.go b/callscore/session_test.go| index fb2fe63..80b9450 100644 |
| --- a/callscore/session_test.go |
| +++ b/callscore/session_test.go |
| @@ -177,3 +177,23 @@ func TestSignOutPostsWithTheOriginAndWantsTheRedirect(t *testing.T) { |
| t.Fatal("a refused sign-out was reported as done", err) |
| } |
| } |
| + |
| +// In Home mode the server ends the Calls session and then answers 307 on |
| +// to {issuer}/signout (connect.Routes.Signout), a hop for a browser's |
| +// id.* session that the native client does not follow and has no use for. |
| +// The session is gone either way, so both redirects confirm the sign-out. |
| +func TestSignOutAcceptsTheHomeChain(t *testing.T) { |
| + var followed atomic.Bool |
| + issuer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { followed.Store(true) })) |
| + defer issuer.Close() |
| + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + http.Redirect(w, r, issuer.URL+"/signout", http.StatusTemporaryRedirect) |
| + })) |
| + defer server.Close() |
| + if err := SignOut(t.Context(), server.URL, server.Client()); err != nil { |
| + t.Fatal("a Home-mode sign-out was reported as failed:", err) |
| + } |
| + if followed.Load() { |
| + t.Fatal("the native client followed the hop to the issuer") |
| + } |
| +} |
diff --git a/cmd/calls/main.go b/cmd/calls/main.go| index 7988d12..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" |
diff --git a/internal/calls/auth.go b/internal/calls/auth.go| index dc36afc..ddfdea7 100644 |
| --- a/internal/calls/auth.go |
| +++ b/internal/calls/auth.go |
| @@ -267,7 +267,7 @@ func (a *app) localSignin(w http.ResponseWriter, r *http.Request) { |
| Action: signinPath, |
| AddressLabel: "Email address", |
| Help: "We’ll email you a link that signs you in. It works once and expires in 15 minutes.", |
| - Submit: "Email me a link", |
| + Submit: "Sign in", |
| OpenSource: &chrome.Link{Href: "/help/open-source", Label: "Open source"}, |
| } |
| if hasReturn { |
| @@ -383,6 +383,7 @@ func (a *app) signedOut(w http.ResponseWriter, r *http.Request) { |
| // Home in connect's. |
| func (a *app) renderSignedOut(w http.ResponseWriter, r *http.Request, heading, body, href, label string, status int) { |
| 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{"Signed out"}, signinPage: true}, map[string]any{"Notice": chrome.SignInNotice{ |
| Icon: assets.Path("static/brand/icon.svg"), |
diff --git a/internal/calls/chrome.go b/internal/calls/chrome.go| index 781d314..7019ffc 100644 |
| --- a/internal/calls/chrome.go |
| +++ b/internal/calls/chrome.go |
| @@ -259,4 +259,3 @@ func (a *app) roomMenuFor(fr Frame) roomMenu { |
| } |
| 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/guest.go b/internal/calls/guest.go| index 3fc42b6..cdd4f6b 100644 |
| --- a/internal/calls/guest.go |
| +++ b/internal/calls/guest.go |
| @@ -13,11 +13,11 @@ 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.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) |
| @@ -25,7 +25,10 @@ func (a *app) requireParticipant(next http.Handler) http.Handler { |
| } |
| 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 f5264d6..2501a5f 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,22 +146,29 @@ 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 connect's own words. |
| - 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), |
| - Heading: home.Title, |
| - Body: home.Body, |
| - Link: &chrome.Link{Href: home.Action, Label: home.ActionLabel}, |
| - }} |
| + a.renderSignedOut(w, r, home.Title, home.Body, home.Action, home.ActionLabel, 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 ab308ab..e89ba46 100644 |
| --- a/internal/calls/home_router_test.go |
| +++ b/internal/calls/home_router_test.go |
| @@ -186,8 +186,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) { |
diff --git a/internal/calls/native_auth.go b/internal/calls/native_auth.go| index 12c11c6..1ce7d2f 100644 |
| --- a/internal/calls/native_auth.go |
| +++ b/internal/calls/native_auth.go |
| @@ -242,7 +242,7 @@ 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)})) |
| + render(w, "native", a.page(r, titled(title), 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 +271,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, titled(title), map[string]any{"Title": title, "Done": true})) |
| } |
| // nativeStartMeeting is POST /native/meetings/start: the app's half of |
| @@ -296,7 +296,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 +305,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 6bb220c..2c625a2 100644 |
| --- a/internal/calls/render.go |
| +++ b/internal/calls/render.go |
| @@ -10,8 +10,8 @@ import ( |
| "amadan.net/oficina/estilo.git/base" |
| "amadan.net/oficina/estilo.git/callui" |
| "amadan.net/oficina/estilo.git/chrome" |
| - estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/credits" |
| + estilomenu "amadan.net/oficina/estilo.git/menu" |
| "amadan.net/oficina/estilo.git/sky" |
| "html/template" |
| "net/http" |
diff --git a/internal/calls/static/calls-shell.css b/internal/calls/static/calls-shell.css| index 764a5d4..c5f4e1e 100644 |
| --- a/internal/calls/static/calls-shell.css |
| +++ b/internal/calls/static/calls-shell.css |
| @@ -1,28 +1,29 @@ |
| -/* 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; } |
| +[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); } |
| +.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..e174840 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 class="calls-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 b25a823..b979309 100644 |
| --- a/internal/calls/templates/layout.html |
| +++ b/internal/calls/templates/layout.html |
| @@ -1,58 +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.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 a275f71..7ab02a1 100644 |
| --- a/internal/calls/templates/signedout.html |
| +++ b/internal/calls/templates/signedout.html |
| @@ -1,4 +1,3 @@ |
| -{{define "title"}}Signed out · Calls{{end}} |
| {{/* Where Sign out lands with Home connected: the Oficina door's |
| signed-out page. home.go's renderHome builds it from connect's own |
| signed-out strings. */}} |
diff --git a/internal/calls/templates/signin.html b/internal/calls/templates/signin.html| index 7f49e4c..c5f7a3b 100644 |
| --- a/internal/calls/templates/signin.html |
| +++ b/internal/calls/templates/signin.html |
| @@ -1,4 +1,3 @@ |
| -{{define "title"}}{{if .Sent}}Check your email{{else}}Sign in{{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/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 1f44264..24f5808 100644 |
| --- a/internal/callstest/signin_test.go |
| +++ b/internal/callstest/signin_test.go |
| @@ -160,7 +160,7 @@ func TestSigninPageIsTheOficinaDoor(t *testing.T) { |
| body := rec.Body.String() |
| for _, want := range []string{ |
| "<div estilo-door", "<span>Calls</span>", `<img estilo-sky-layer="placeholder"`, |
| - `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, "Email me a link", |
| + `<img estilo-sky-layer="grain"`, `action="/signin"`, `name="address"`, `type="submit">Sign in</button>`, |
| } { |
| if !strings.Contains(body, want) { |
| t.Errorf("sign-in page is missing %q:\n%s", want, body) |
| @@ -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) |
| } |
| } |