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 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 .
const appName = "Calls"
// Frame is what layout.html reads from every page: the bar, the parts of
// the before the app's name, and the Appearance preference for
// .
type Frame struct {
Chrome chrome.Bar
TitleParts []string
Theme string
}
// frame describes one page's chrome. The zero value is a page whose
// is just "Calls" (the meeting list, the app root).
type frame struct {
// parts precede "Calls" in , 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 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 . 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
}