| 1 | package slides |
| 2 | |
| 3 | // The shared chrome (estilo chrome, round 2): every page's bar, account |
| 4 | // menu and <title> are built here, once, so no page can drift from |
| 5 | // another. Before this the bar was rastrillo's topbar layout: 45px |
| 6 | // signed out and 51px signed in (the Account summary was taller than the |
| 7 | // bar and existed only once signed in), an account menu of an address, |
| 8 | // Agents and Sign out, a separate one-item Help dropdown, and a <title> |
| 9 | // that never named the app. |
| 10 | // |
| 11 | // Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| 12 | // 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), |
| 13 | // §10 (titles). Slides is the third adopter, after Docs and Sheets. |
| 14 | // |
| 15 | // Every label is a catalog key (AGENTS.md: a hardcoded English string is |
| 16 | // a bug); estilo's templates carry no strings of their own. |
| 17 | |
| 18 | import ( |
| 19 | "net/http" |
| 20 | "net/url" |
| 21 | |
| 22 | "amadan.net/oficina/estilo.git/brand" |
| 23 | "amadan.net/oficina/estilo.git/chrome" |
| 24 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 25 | "amadan.net/oficina/home/connect" |
| 26 | "amadan.net/rastrillo/rastrillo" |
| 27 | "amadan.net/rastrillo/rastrillo/sessions" |
| 28 | ) |
| 29 | |
| 30 | // appName is the brand and the last part of every <title>. A proper |
| 31 | // noun, the same in every locale, so not a catalog key. |
| 32 | const appName = "Slides" |
| 33 | |
| 34 | // Frame is what layout.html reads from every page: the bar, the parts of |
| 35 | // the <title> before the app's name, and the Appearance preference for |
| 36 | // <html data-theme>. |
| 37 | type Frame struct { |
| 38 | Chrome chrome.Bar |
| 39 | TitleParts []string |
| 40 | Theme string |
| 41 | // Bare pages have no bar at all: the presenting screens, which are |
| 42 | // capability-addressed and shown on a projector, a lectern or a |
| 43 | // phone in somebody's hand (spec §12.1, Slides). A signed-in |
| 44 | // presenter used to see "Account → Sign in" there. |
| 45 | Bare bool |
| 46 | } |
| 47 | |
| 48 | // shell is embedded in every page's view data, so .Frame resolves on |
| 49 | // every page the layout renders. |
| 50 | type shell struct { |
| 51 | Frame Frame |
| 52 | } |
| 53 | |
| 54 | // frame describes one page's chrome. The zero value is a plain page whose |
| 55 | // <title> is just "Slides". |
| 56 | type frame struct { |
| 57 | // parts precede "Slides" in <title>, most specific first. The first |
| 58 | // is the page's h1 (spec §10). |
| 59 | parts []string |
| 60 | // context is the open deck's title, shown in the bar on the deck's |
| 61 | // sub-pages. |
| 62 | context string |
| 63 | // public is a read-only page a stranger reaches (the share link): |
| 64 | // the brand is plain text, and "Sign in" carries no return path, |
| 65 | // because the path is a capability and must not be copied into |
| 66 | // another URL. |
| 67 | public bool |
| 68 | // signinPage is one of the sign-in, sent and signed-out pages: the |
| 69 | // corner is empty, and the brand is plain text (every page it could |
| 70 | // link to is behind the session gate). |
| 71 | signinPage bool |
| 72 | // bare drops the bar (the presenting screens). |
| 73 | bare bool |
| 74 | // ret, when set, is where Appearance returns to instead of this |
| 75 | // request's own path and query. |
| 76 | ret string |
| 77 | } |
| 78 | |
| 79 | // titled is the frame for a page whose <title> is these parts. |
| 80 | func titled(parts ...string) frame { return frame{parts: parts} } |
| 81 | |
| 82 | // onDeck is the frame for one of a deck's sub-pages: the deck's title is |
| 83 | // the bar's context and the second title part. |
| 84 | func onDeck(page, deck string) frame { |
| 85 | return frame{parts: []string{page, deck}, context: deck} |
| 86 | } |
| 87 | |
| 88 | // viewerState is who the chrome is for: nobody, or a person and how |
| 89 | // they signed in. |
| 90 | type viewerState struct { |
| 91 | user User |
| 92 | method string |
| 93 | ok bool |
| 94 | } |
| 95 | |
| 96 | // optionalViewer resolves whoever is signed in on r without requiring |
| 97 | // anybody, and without side effects: an error page, the Open source page |
| 98 | // and the share-link viewer show the viewer's own chrome, but must never |
| 99 | // sign somebody out or create a row. An address that is no longer |
| 100 | // admitted is treated as signed out here; the next guarded page is where |
| 101 | // currentUser ends that session. |
| 102 | func (a *app) optionalViewer(r *http.Request) viewerState { |
| 103 | s, ok := sessions.Current(r) |
| 104 | if !ok && a.sessions != nil { |
| 105 | s, ok = a.sessions.From(r) |
| 106 | } |
| 107 | if !ok || s.Subject == "" { |
| 108 | return viewerState{} |
| 109 | } |
| 110 | if s.Method == connect.HomeMethod { |
| 111 | u, err := a.homeUser(r, s) |
| 112 | if err != nil { |
| 113 | return viewerState{} |
| 114 | } |
| 115 | return viewerState{user: u, method: s.Method, ok: true} |
| 116 | } |
| 117 | if !a.allowed.allows(s.Subject) { |
| 118 | return viewerState{} |
| 119 | } |
| 120 | var u User |
| 121 | if err := a.db.G.WithContext(r.Context()).Where("email = ?", normalizeAddress(s.Subject)).Take(&u).Error; err != nil { |
| 122 | return viewerState{} |
| 123 | } |
| 124 | return viewerState{user: u, method: s.Method, ok: true} |
| 125 | } |
| 126 | |
| 127 | // shellFor is the shell of a page behind the session gate: u is the |
| 128 | // person currentUser resolved. |
| 129 | func (a *app) shellFor(r *http.Request, u User, f frame) shell { |
| 130 | method := "" |
| 131 | if s, ok := sessions.Current(r); ok { |
| 132 | method = s.Method |
| 133 | } |
| 134 | return shell{Frame: a.frameFor(r, viewerState{user: u, method: method, ok: true}, f)} |
| 135 | } |
| 136 | |
| 137 | // publicShell is the shell of a page reachable signed out: the viewer is |
| 138 | // read optionally. |
| 139 | func (a *app) publicShell(r *http.Request, f frame) shell { |
| 140 | var v viewerState |
| 141 | if !f.signinPage { |
| 142 | v = a.optionalViewer(r) |
| 143 | } |
| 144 | return shell{Frame: a.frameFor(r, v, f)} |
| 145 | } |
| 146 | |
| 147 | // frameFor builds a page's Frame for viewer v. |
| 148 | func (a *app) frameFor(r *http.Request, v viewerState, f frame) Frame { |
| 149 | bar := chrome.Bar{ |
| 150 | App: chrome.App{ |
| 151 | Name: appName, |
| 152 | Slug: "slides", |
| 153 | Home: "/", |
| 154 | Icon: brand.Path("slides"), |
| 155 | }, |
| 156 | Context: f.context, |
| 157 | Labels: chrome.BarLabels{Skip: rastrillo.T(r, "slides.chrome.skip"), Menu: rastrillo.T(r, "slides.chrome.menu")}, |
| 158 | } |
| 159 | switch { |
| 160 | case f.signinPage: |
| 161 | // The corner is empty on the pages that are themselves sign-in, |
| 162 | // and the brand is plain text: everything it could link to is |
| 163 | // behind the session gate and would bounce straight back here. |
| 164 | bar.App.Home = "" |
| 165 | case v.ok: |
| 166 | bar.Account = a.account(r, v, f.ret) |
| 167 | bar.Switcher = a.switcher(r) |
| 168 | case f.public: |
| 169 | bar.SignIn = &chrome.Link{Href: signinPath, Label: rastrillo.T(r, "slides.chrome.signin")} |
| 170 | default: |
| 171 | bar.SignIn = &chrome.Link{Href: signinPath + "?return_to=" + url.QueryEscape(returnTarget(r)), Label: rastrillo.T(r, "slides.chrome.signin")} |
| 172 | } |
| 173 | if f.public { |
| 174 | bar.App.Home = "" |
| 175 | } |
| 176 | return Frame{ |
| 177 | Chrome: bar, |
| 178 | TitleParts: f.parts, |
| 179 | Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| 180 | Bare: f.bare, |
| 181 | } |
| 182 | } |
| 183 | |
| 184 | // returnTarget is where a person should come back to: this page, if it |
| 185 | // is a page (a GET), otherwise the deck list. |
| 186 | func returnTarget(r *http.Request) string { |
| 187 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 188 | return "/" |
| 189 | } |
| 190 | return r.URL.RequestURI() |
| 191 | } |
| 192 | |
| 193 | // account is the account menu (spec §8.2): who is signed in, Agents and |
| 194 | // "Connect your Oficina account" when that means something, Appearance, |
| 195 | // Help (Open source, last), and the one Sign out in the app. |
| 196 | func (a *app) account(r *http.Request, v viewerState, ret string) *chrome.Account { |
| 197 | if ret == "" { |
| 198 | ret = returnTarget(r) |
| 199 | } |
| 200 | u := v.user |
| 201 | acc := &chrome.Account{ |
| 202 | TriggerLabel: rastrillo.T(r, "slides.chrome.account"), |
| 203 | Initials: chrome.Initials(u.Name, u.Email), |
| 204 | Address: u.Email, |
| 205 | Labels: chrome.AccountLabels{ |
| 206 | SignedInAs: rastrillo.T(r, "slides.chrome.signed_in_as"), |
| 207 | Appearance: rastrillo.T(r, "slides.chrome.appearance"), |
| 208 | Help: rastrillo.T(r, "slides.credits.help"), |
| 209 | }, |
| 210 | Appearance: &chrome.AppearanceMenu{ |
| 211 | Action: "/appearance", |
| 212 | Return: ret, |
| 213 | Options: chrome.AppearanceOptions(chrome.Appearance(r), |
| 214 | rastrillo.T(r, "slides.chrome.appearance.light"), |
| 215 | rastrillo.T(r, "slides.chrome.appearance.dark"), |
| 216 | rastrillo.T(r, "slides.chrome.appearance.system")), |
| 217 | }, |
| 218 | Help: []estilomenu.Item{{Href: "/help/open-source", Label: rastrillo.T(r, "slides.credits.title")}}, |
| 219 | SignOut: chrome.SignOut{Action: "/signout", Label: rastrillo.T(r, "slides.account.signout")}, |
| 220 | } |
| 221 | if u.Name != "" { |
| 222 | acc.Name = u.Name |
| 223 | } |
| 224 | if u.Email != "" { |
| 225 | acc.TriggerLabel = rastrillo.Tf(r, "slides.chrome.account.who", "address", u.Email) |
| 226 | } |
| 227 | // Agents (agents.go): the tokens that let a program drive this |
| 228 | // person's decks. A page, so a link and no ellipsis. |
| 229 | acc.Items = []estilomenu.Item{{Href: "/agents", Label: rastrillo.T(r, "slides.account.agents")}} |
| 230 | if a.offersHomeLink(v) { |
| 231 | acc.Items = append(acc.Items, estilomenu.Item{Href: "/account/home", Label: rastrillo.T(r, "slides.home.menu_action")}) |
| 232 | } |
| 233 | return acc |
| 234 | } |
| 235 | |
| 236 | // offersHomeLink is whether the account menu offers to connect the |
| 237 | // person's Oficina account: only when Home is configured, and never to |
| 238 | // somebody who signed in through Home, whose account already is the |
| 239 | // connection (spec §12.1 item 4; before this Slides offered it to Home |
| 240 | // sessions too). |
| 241 | func (a *app) offersHomeLink(v viewerState) bool { |
| 242 | return a.home != nil && v.ok && v.method != connect.HomeMethod |
| 243 | } |
| 244 | |
| 245 | // switcher is the app switcher, or nil when no hub directory is |
| 246 | // configured. It shows "All apps" alone and no tiles (Paul's §13 answer |
| 247 | // 2): a tile today still costs an assertion exchange through the |
| 248 | // directory session. |
| 249 | func (a *app) switcher(r *http.Request) *chrome.Switcher { |
| 250 | if a.directory == "" { |
| 251 | return nil |
| 252 | } |
| 253 | return &chrome.Switcher{ |
| 254 | Label: rastrillo.T(r, "slides.chrome.switcher"), |
| 255 | CurrentLabel: rastrillo.T(r, "slides.chrome.switcher.current"), |
| 256 | AllApps: estilomenu.Item{Href: a.directory + "/", Label: rastrillo.T(r, "slides.chrome.switcher.all")}, |
| 257 | } |
| 258 | } |
| 259 | |