package calls // The shared chrome (estilo chrome, round 2): every page's bar, account // menu and are built here, once, so no page can drift from // another. Before this the shell was rastrillo's topbar at 81px: a raw // address, a blue underlined "Settings" and a Sign out button that // wrapped under it, a separate one-item Help dropdown, no nav and no // current-page marker, and a <title> that named the app on one page out // of fifteen. // // Spec: estilo spec/shared-chrome, docs/superpowers/specs/ // 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), // §10 (titles). Calls adopts after Docs, Sheets and Slides. // // The call room keeps no bar: since 2026-09-28 a call is one window in // the viewport (room.html). Its title menu carries the same items, in // the same order and words, as the account menu built here (roomMenu). import ( "net/http" "net/url" "amadan.net/oficina/estilo.git/chrome" estilomenu "amadan.net/oficina/estilo.git/menu" "amadan.net/rastrillo/rastrillo/sessions" ) // appName is the brand and the last part of every <title>. const appName = "Calls" // Frame is what layout.html reads from every page: the bar, the parts of // the <title> before the app's name, and the Appearance preference for // <html data-theme>. type Frame struct { Chrome chrome.Bar TitleParts []string Theme string } // frame describes one page's chrome. The zero value is a page whose // <title> is just "Calls" (the meeting list, the app root). type frame struct { // parts precede "Calls" in <title>, most specific first. The first // is the page's h1 (spec §10). parts []string // context is the open meeting's title, shown in the bar. context string // signinPage is one of the sign-in, sent and signed-out pages, or a // native app hand-off page: the corner is empty and the brand is // plain text, since everything it could link to is behind the session // gate (and a hand-off page keeps no-referrer, which a chained // sign-out could not pass through). signinPage bool // public is a page reached through a capability (the invitation // link /j/{token}): "Sign in" carries no return path, because the // path is the capability and must not be copied into another URL. public bool } // titled is the frame for a page whose <title> is these parts. func titled(parts ...string) frame { return frame{parts: parts} } // onMeeting is the frame for one of a meeting's pages: the meeting's // title is the bar's context, and it follows page in the <title>. With // page empty the meeting is the page (the holding screen, the room). func onMeeting(page, meeting string) frame { if page == "" { return frame{parts: []string{meeting}, context: meeting} } return frame{parts: []string{page, meeting}, context: meeting} } // viewer is who the chrome is for: nobody, a guest, or a signed-in // person, and how they signed in. type viewer struct { person Person method string ok bool } // signedIn is a person who signed in, as opposed to a per-meeting guest. func (v viewer) signedIn() bool { return v.ok && !v.person.Anonymous } // viewerOf resolves whoever the request is for without requiring // anybody. Behind requireParticipant that is the person already in the // context. Anywhere else (an error page, Open source, the invitation // page) the session is read optionally and without side effects: an // error page must never create a persons row or end a session, so a // session that no longer resolves is simply signed out here, and the // next guarded page is where it is dealt with. func (a *app) viewerOf(r *http.Request) viewer { if p, ok := currentPerson(r); ok { s, _ := sessions.Current(r) return viewer{person: p, method: s.Method, ok: true} } id, ok := a.auth.SessionFrom(r) if !ok { return viewer{} } p, err := a.lookupPerson(r, sessions.Session{Subject: id.Address, Method: string(id.Method)}) if err != nil { return viewer{} } return viewer{person: p, method: string(id.Method), ok: true} } // lookupPerson is personForSession without its one write: a first // sign-in's persons row is created by the guarded pages, never by chrome. func (a *app) lookupPerson(r *http.Request, s sessions.Session) (Person, error) { switch s.Method { case "magiclink", "keymail": return a.store.existingHomePerson(r.Context(), s.Subject) } return a.personForSession(r.Context(), s) } // page builds render's data for a page: data plus "Frame", the chrome, // and "Viewer", which the room's own menu reads. func (a *app) page(r *http.Request, f frame, data map[string]any) map[string]any { if data == nil { data = map[string]any{} } var v viewer if !f.signinPage { v = a.viewerOf(r) } if v.signedIn() { data["Viewer"] = Viewer{Email: v.person.Email, Member: a.isMember(v.person)} } data["Frame"] = a.frameFor(r, v, f) return data } // Viewer is the signed-in person, for the room's title menu: who they // are, and whether the team's pages (Meetings, Settings) are theirs. type Viewer struct { Email string Member bool } // frameFor builds a page's Frame for viewer v. func (a *app) frameFor(r *http.Request, v viewer, f frame) Frame { bar := chrome.Bar{ App: chrome.App{ Name: appName, Slug: "calls", Home: "/", Icon: assets.Path("static/brand/icon.svg"), }, Context: f.context, Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, } switch { case f.signinPage: bar.App.Home = "" case v.signedIn(): bar.Account = a.account(r, v) bar.Switcher = a.switcher() if a.isMember(v.person) { bar.Nav = a.nav(r) bar.NavLabel = "Calls" } case f.public: // A guest, or nobody, on the invitation page. bar.App.Home = "" bar.SignIn = &chrome.Link{Href: signinPath, Label: "Sign in"} default: // Nobody, or a guest (spec §8.4: guests get the same corner). bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} } return Frame{ Chrome: bar, TitleParts: f.parts, Theme: chrome.ThemeAttr(chrome.Appearance(r)), } } // nav is the team's two pages, with the one being shown marked. func (a *app) nav(r *http.Request) []chrome.Link { return []chrome.Link{ {Href: "/", Label: "Meetings", Current: r.URL.Path == "/"}, {Href: "/settings", Label: "Settings", Current: r.URL.Path == "/settings"}, } } // returnTarget is where a person should come back to: this page, if it // is a page (a GET), otherwise the meeting list. func returnTarget(r *http.Request) string { if r.Method != http.MethodGet && r.Method != http.MethodHead { return "/" } return r.URL.RequestURI() } // account is the account menu (spec §8.2): who is signed in, "Connect // your Oficina account" when that means something, Appearance, Help // (Open source, last), and the one Sign out in the app. Settings is in // the bar's nav rather than here: it is the team's meeting defaults and // agents, not this person's account, and a second copy of the link would // be a second label to keep in step. func (a *app) account(r *http.Request, v viewer) *chrome.Account { acc := &chrome.Account{ TriggerLabel: "Account: " + v.person.Email, Initials: chrome.Initials("", v.person.Email), Address: v.person.Email, Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, Appearance: &chrome.AppearanceMenu{ Action: "/appearance", Return: returnTarget(r), Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), }, Help: []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}}, SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, } if a.offersHomeLink(v) { acc.Items = []estilomenu.Item{{Href: "/account/home", Label: "Connect your Oficina account"}} } return acc } // offersHomeLink is whether the account menu offers to connect the // person's Oficina account: only with Home configured, only to a member // (the page is behind requireMember), and never to somebody who signed // in through Home, whose account already is the connection. func (a *app) offersHomeLink(v viewer) bool { return a.home != nil && v.signedIn() && a.isMember(v.person) && (v.method == "magiclink" || v.method == "keymail") } // switcher is the app switcher, or nil when no hub directory is // configured. It shows "All apps" alone and no tiles (Paul's §13 answer // 2), as Docs, Sheets and Slides do. func (a *app) switcher() *chrome.Switcher { if a.cfg.home == nil || a.cfg.home.Directory == "" { return nil } return &chrome.Switcher{ Label: "Oficina apps", CurrentLabel: "(this app)", AllApps: estilomenu.Item{Href: a.cfg.home.Directory + "/", Label: "All apps"}, } } // roomMenu is the call window's title menu: the account menu's items in // its order and its words, for a page that has no bar. Standing is the // one line only the room has. type roomMenu struct { Identity string Nav []chrome.Link Help []estilomenu.Item SignOut *chrome.SignOut } func (a *app) roomMenuFor(fr Frame) roomMenu { m := roomMenu{Nav: fr.Chrome.Nav} if acc := fr.Chrome.Account; acc != nil { m.Identity = acc.Address m.Help = acc.Help m.SignOut = &acc.SignOut } else { m.Help = []estilomenu.Item{{Href: "/help/open-source", Label: "Open source"}} } return m }