oficina / meet Public

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>
claude agent, operated by paul@keymail.dev pushed by paul@keymail.dev aa24edbdaeeb2828baeca880f3708395da4c9990 parent 6d30ba6
50 files changed, +460 −269
  • callscore/session.go +5 −2
  • callscore/session_test.go +20 −0
  • cmd/calls/main.go +1 −0
  • internal/calls/auth.go +2 −1
  • internal/calls/chrome.go +0 −1
  • internal/calls/credits.go +5 −2
  • internal/calls/credits_test.go +5 −2
  • internal/calls/guest.go +6 −3
  • internal/calls/handlers_join.go +3 −2
  • internal/calls/handlers_meetings.go +39 −15
  • internal/calls/handlers_meetings_test.go +20 −1
  • internal/calls/handlers_room.go +8 −5
  • internal/calls/handlers_settings.go +5 −4
  • internal/calls/home.go +47 −14
  • internal/calls/home_router_test.go +9 −2
  • internal/calls/native_auth.go +5 −5
  • internal/calls/render.go +1 −1
  • internal/calls/static/calls-shell.css +26 −25
  • internal/calls/static/calls.css +36 −7
  • internal/calls/static/devices.js +4 −0
  • internal/calls/static/noscript.css +4 −0
  • internal/calls/static/room.js +2 −1
  • internal/calls/static/zone.js +14 −0
  • internal/calls/templates/agent_once.html +4 −5
  • internal/calls/templates/confirm.html +0 −1
  • internal/calls/templates/credits.html +0 −1
  • internal/calls/templates/errors.html +3 −4
  • internal/calls/templates/guest.html +2 −5
  • internal/calls/templates/home.html +0 −1
  • internal/calls/templates/index.html +33 −20
  • internal/calls/templates/join.html +35 −24
  • internal/calls/templates/layout.html +37 −35
  • internal/calls/templates/link_once.html +4 −5
  • internal/calls/templates/meeting_settings.html +2 −4
  • internal/calls/templates/native.html +0 −1
  • internal/calls/templates/partials/meeting-options.html +2 −2
  • internal/calls/templates/room.html +15 −13
  • internal/calls/templates/settings.html +7 −14
  • internal/calls/templates/share.html +3 −5
  • internal/calls/templates/signedout.html +0 −1
  • internal/calls/templates/signin.html +0 −1
  • internal/calls/templates/welcome.html +2 −3
  • internal/callstest/agents_test.go +2 −2
  • internal/callstest/api_test.go +8 −4
  • internal/callstest/holding_test.go +20 −11
  • internal/callstest/join_test.go +1 −1
  • internal/callstest/meetings_test.go +1 −1
  • internal/callstest/relay_floor_test.go +3 −3
  • internal/callstest/room_test.go +6 −6
  • internal/callstest/signin_test.go +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)
}
}