| 1 | package docs |
| 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 template can drift |
| 5 | // from another. Before this, each page defined its own rastrillo shell |
| 6 | // blocks, and the share page fell back to rastrillo's default "Home" |
| 7 | // brand because it forgot one. |
| 8 | // |
| 9 | // Spec: estilo spec/shared-chrome, docs/superpowers/specs/ |
| 10 | // 2026-09-23-shared-chrome.md, §5 (bar), §6 (switcher), §8 (account), |
| 11 | // §10 (titles). |
| 12 | |
| 13 | import ( |
| 14 | "net/http" |
| 15 | "net/url" |
| 16 | |
| 17 | "amadan.net/oficina/estilo.git/brand" |
| 18 | "amadan.net/oficina/estilo.git/chrome" |
| 19 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 20 | "amadan.net/rastrillo/rastrillo" |
| 21 | "amadan.net/rastrillo/rastrillo/sessions" |
| 22 | ) |
| 23 | |
| 24 | // Frame is what views/layout.html reads from every page: the bar, the |
| 25 | // parts of the <title> before the app's name, and the Appearance |
| 26 | // preference for <html data-theme>. |
| 27 | type Frame struct { |
| 28 | Chrome chrome.Bar |
| 29 | TitleParts []string |
| 30 | Theme string |
| 31 | } |
| 32 | |
| 33 | // frameOptions describes one page's chrome. The zero value is a plain |
| 34 | // signed-in-or-out page with no context. |
| 35 | type frameOptions struct { |
| 36 | // titleParts precede "Docs" in <title>, most specific first. |
| 37 | titleParts []string |
| 38 | // context is the open object's title; edit replaces it with the |
| 39 | // editable title when the person may rename. |
| 40 | context string |
| 41 | edit *chrome.TitleEdit |
| 42 | actions []chrome.Action |
| 43 | // public is a read-only page a stranger reaches: the brand is plain |
| 44 | // text, and "Sign in" carries no return path (the path is a |
| 45 | // capability and must not be copied into another URL). |
| 46 | public bool |
| 47 | // signinPage is one of the sign-in, sent and signed-out pages: the |
| 48 | // corner is empty. |
| 49 | signinPage bool |
| 50 | } |
| 51 | |
| 52 | // frame builds a page's Frame. The viewer is read optionally: an error |
| 53 | // page, the public page and a sign-in page all show whatever the |
| 54 | // request's session says, without requiring one. |
| 55 | func (h *handlers) frame(r *http.Request, o frameOptions) Frame { |
| 56 | bar := chrome.Bar{ |
| 57 | App: chrome.App{ |
| 58 | Name: rastrillo.T(r, "docs.app.name"), |
| 59 | Slug: "docs", |
| 60 | Home: "/", |
| 61 | Icon: brand.Path("docs"), |
| 62 | }, |
| 63 | Context: o.context, |
| 64 | ContextEdit: o.edit, |
| 65 | Actions: o.actions, |
| 66 | Sticky: true, |
| 67 | Labels: chrome.BarLabels{Skip: rastrillo.T(r, "docs.chrome.skip"), Menu: rastrillo.T(r, "docs.chrome.menu")}, |
| 68 | } |
| 69 | if o.public { |
| 70 | bar.App.Home = "" |
| 71 | } |
| 72 | address, signedIn := "", false |
| 73 | if !o.signinPage { |
| 74 | address, signedIn = h.viewer(r) |
| 75 | } |
| 76 | switch { |
| 77 | case o.signinPage: |
| 78 | // The corner is empty on the pages that are themselves sign-in. |
| 79 | case signedIn: |
| 80 | bar.Account = h.account(r, address) |
| 81 | bar.Switcher = h.switcher(r) |
| 82 | case o.public: |
| 83 | bar.SignIn = &chrome.Link{Href: "/signin", Label: rastrillo.T(r, "docs.chrome.signin")} |
| 84 | default: |
| 85 | bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + url.QueryEscape(returnTarget(r)), Label: rastrillo.T(r, "docs.chrome.signin")} |
| 86 | } |
| 87 | return Frame{Chrome: bar, TitleParts: o.titleParts, Theme: chrome.ThemeAttr(chrome.Appearance(r))} |
| 88 | } |
| 89 | |
| 90 | // returnTarget is where a person should come back to: this page, if it |
| 91 | // is a page (a GET), otherwise the list. |
| 92 | func returnTarget(r *http.Request) string { |
| 93 | if r.Method != http.MethodGet { |
| 94 | return "/" |
| 95 | } |
| 96 | return r.URL.RequestURI() |
| 97 | } |
| 98 | |
| 99 | func (h *handlers) account(r *http.Request, address string) *chrome.Account { |
| 100 | a := &chrome.Account{ |
| 101 | TriggerLabel: rastrillo.T(r, "docs.chrome.account.anonymous"), |
| 102 | Initials: chrome.Initials("", address), |
| 103 | Address: address, |
| 104 | Labels: chrome.AccountLabels{ |
| 105 | SignedInAs: rastrillo.T(r, "docs.chrome.signedinas"), |
| 106 | Appearance: rastrillo.T(r, "docs.chrome.appearance"), |
| 107 | Help: rastrillo.T(r, "docs.chrome.help"), |
| 108 | }, |
| 109 | Appearance: &chrome.AppearanceMenu{ |
| 110 | Action: "/appearance", |
| 111 | Return: returnTarget(r), |
| 112 | Options: chrome.AppearanceOptions(chrome.Appearance(r), |
| 113 | rastrillo.T(r, "docs.chrome.appearance.light"), |
| 114 | rastrillo.T(r, "docs.chrome.appearance.dark"), |
| 115 | rastrillo.T(r, "docs.chrome.appearance.system")), |
| 116 | }, |
| 117 | SignOut: chrome.SignOut{Action: "/signout", Label: rastrillo.T(r, "docs.signout.label")}, |
| 118 | } |
| 119 | if address != "" { |
| 120 | a.TriggerLabel = rastrillo.Tf(r, "docs.chrome.account", "address", address) |
| 121 | } |
| 122 | if label := h.homeLinkLabel(r); label != "" { |
| 123 | a.Items = []estilomenu.Item{{Href: "/account/home", Label: label}} |
| 124 | } |
| 125 | return a |
| 126 | } |
| 127 | |
| 128 | // switcher is the app switcher, or nil when no hub directory is |
| 129 | // configured. Until Home's /apps/<slug> changes and the hidden id.* |
| 130 | // interstitial have merged (spec §6.2 a, b and the §9.3 hide), it shows |
| 131 | // "All apps" alone and no tiles: a tile today costs two assertion |
| 132 | // exchanges and flashes an interstitial mid-switch. |
| 133 | func (h *handlers) switcher(r *http.Request) *chrome.Switcher { |
| 134 | if h.directory == "" { |
| 135 | return nil |
| 136 | } |
| 137 | return &chrome.Switcher{ |
| 138 | Label: rastrillo.T(r, "docs.chrome.apps"), |
| 139 | CurrentLabel: rastrillo.T(r, "docs.chrome.thisapp"), |
| 140 | AllApps: estilomenu.Item{Href: h.directory + "/", Label: rastrillo.T(r, "docs.chrome.allapps")}, |
| 141 | } |
| 142 | } |
| 143 | |
| 144 | // viewer is who the request is signed in as, read optionally. Inside a |
| 145 | // signed-in route the session middleware has already resolved it; on a |
| 146 | // route no session guards (an error page, the public page) peek reads |
| 147 | // the cookie without requiring it and without writing anything. |
| 148 | // |
| 149 | // The address is empty for a session whose person has no verified |
| 150 | // address; the account menu then shows the person glyph. |
| 151 | func (h *handlers) viewer(r *http.Request) (string, bool) { |
| 152 | if id, ok := currentIdentity(r); ok { |
| 153 | return actorAddress(id.Actor), true |
| 154 | } |
| 155 | if s, ok := sessions.Current(r); ok && s.Subject != "" { |
| 156 | if s.Method == "magiclink" || s.Method == "keymail" { |
| 157 | return s.Subject, true |
| 158 | } |
| 159 | } |
| 160 | if h.peek != nil { |
| 161 | return h.peek(r) |
| 162 | } |
| 163 | return "", false |
| 164 | } |
| 165 | |
| 166 | func actorAddress(a Actor) string { |
| 167 | if a.VerifiedAddress == nil { |
| 168 | return "" |
| 169 | } |
| 170 | return *a.VerifiedAddress |
| 171 | } |
| 172 | |
| 173 | // pageHeaders marks a rendered page as depending on the platform its |
| 174 | // shortcut labels were formatted for: menu.Shortcut reads the |
| 175 | // User-Agent, so a shared cache must not hand a Mac's page to Windows. |
| 176 | func pageHeaders(w http.ResponseWriter) { |
| 177 | w.Header().Add("Vary", "User-Agent") |
| 178 | } |
| 179 | |