| 1 | package sheets |
| 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 markup written out |
| 6 | // in layout.html, 45px signed out and 51px signed in (the Create and |
| 7 | // Import buttons were taller than the bar), with a flat account list of |
| 8 | // Light / Dark / System / Sign out and a title every handler spelled its |
| 9 | // own way. |
| 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). Sheets is the second adopter; the corrections it needed |
| 14 | // are on estilo feat/r2-sheets-corrections. |
| 15 | |
| 16 | import ( |
| 17 | "net/http" |
| 18 | "net/url" |
| 19 | |
| 20 | "amadan.net/oficina/estilo.git/brand" |
| 21 | "amadan.net/oficina/estilo.git/chrome" |
| 22 | estilomenu "amadan.net/oficina/estilo.git/menu" |
| 23 | "amadan.net/oficina/home/connect" |
| 24 | ) |
| 25 | |
| 26 | // appName is the brand, the last part of every <title>, and the only |
| 27 | // name the app goes by (spec §10: "Sheets", never "Oficina Sheets"). |
| 28 | const appName = "Sheets" |
| 29 | |
| 30 | // shortcutsDialog is the id of the Keyboard shortcuts dialog the layout |
| 31 | // renders on every signed-in page, opened from the account menu's Help. |
| 32 | const shortcutsDialog = "sheets-shortcuts" |
| 33 | |
| 34 | // Frame is what layout.html reads from every page: the bar, the parts of |
| 35 | // the <title> before the app's name, the Appearance preference for |
| 36 | // <html data-theme>, the shell variant, and the shortcut labels for this |
| 37 | // request's platform. |
| 38 | type Frame struct { |
| 39 | Chrome chrome.Bar |
| 40 | TitleParts []string |
| 41 | Theme string |
| 42 | // Shell is estilo-shell's value: "fill" on the grid, the one page |
| 43 | // that fills the viewport; empty everywhere else. |
| 44 | Shell string |
| 45 | // Keys are the shortcut labels, formatted server-side by estilo's |
| 46 | // menu.Shortcut for the request's platform (⌘Z on a Mac, Ctrl+Z |
| 47 | // elsewhere), so nothing flips after paint. |
| 48 | Keys map[string]string |
| 49 | // Shortcuts renders the Keyboard shortcuts dialog. It is set exactly |
| 50 | // when the account menu (which opens it) is. |
| 51 | Shortcuts bool |
| 52 | } |
| 53 | |
| 54 | // frame describes one page's chrome. The zero value is a plain page with |
| 55 | // no context, whose <title> is just "Sheets". |
| 56 | type frame struct { |
| 57 | // parts precede "Sheets" in <title>, most specific first. |
| 58 | parts []string |
| 59 | // context is the open sheet's title; edit replaces it with the |
| 60 | // editable title when the person may rename. |
| 61 | context string |
| 62 | edit *chrome.TitleEdit |
| 63 | actions []chrome.Action |
| 64 | // public is a read-only page a stranger reaches (/l/): the brand is |
| 65 | // plain text, and "Sign in" carries no return path, because the path |
| 66 | // is a capability and must not be copied into another URL. |
| 67 | public bool |
| 68 | // signinPage is one of the sign-in, sent and signed-out pages: the |
| 69 | // corner is empty. |
| 70 | signinPage bool |
| 71 | // fill is the grid: the shell is the viewport and the bar does not |
| 72 | // stick (the document never scrolls). |
| 73 | fill bool |
| 74 | // ret, when set, is where Appearance returns to instead of this |
| 75 | // request's own path and query. The share screen sets it: its query |
| 76 | // is a one-shot notice, and reflecting a query into the page is the |
| 77 | // channel TestTheShareScreenIgnoresATokenInItsQuery keeps closed. |
| 78 | ret string |
| 79 | } |
| 80 | |
| 81 | // titled is the frame for a page whose <title> is these parts. |
| 82 | func titled(parts ...string) frame { return frame{parts: parts} } |
| 83 | |
| 84 | // onSheet is the frame for a page about one sheet: the sheet's title is |
| 85 | // the bar's context, and page (when not empty) names the sub-page. |
| 86 | func onSheet(s SheetSummary, page string) frame { |
| 87 | f := frame{context: s.Title, parts: []string{s.Title}} |
| 88 | if page != "" { |
| 89 | f.parts = []string{page, s.Title} |
| 90 | } |
| 91 | return f |
| 92 | } |
| 93 | |
| 94 | // shortcutKeys are the chords the menus, toolbar tips and the shortcuts |
| 95 | // dialog label. The bindings themselves live in commands.js and |
| 96 | // clipboard.js, which accept either Ctrl or ⌘. |
| 97 | var shortcutKeys = map[string]string{ |
| 98 | "undo": "Mod+Z", |
| 99 | "cut": "Mod+X", |
| 100 | "copy": "Mod+C", |
| 101 | "paste": "Mod+V", |
| 102 | "filldown": "Mod+D", |
| 103 | "selectall": "Mod+A", |
| 104 | "bold": "Mod+B", |
| 105 | "italic": "Mod+I", |
| 106 | "delete": "Delete", |
| 107 | "enter": "Enter", |
| 108 | "tab": "Tab", |
| 109 | "esc": "Esc", |
| 110 | } |
| 111 | |
| 112 | func keysFor(r *http.Request) map[string]string { |
| 113 | out := make(map[string]string, len(shortcutKeys)) |
| 114 | for name, chord := range shortcutKeys { |
| 115 | out[name] = estilomenu.Shortcut(r, chord) |
| 116 | } |
| 117 | return out |
| 118 | } |
| 119 | |
| 120 | // frameFor builds a page's Frame. The viewer is read optionally: an |
| 121 | // error page, the link page and a sign-in page all show whatever the |
| 122 | // request's session says, without requiring one. |
| 123 | func (a *App) frameFor(r *http.Request, o frame) Frame { |
| 124 | bar := chrome.Bar{ |
| 125 | App: chrome.App{ |
| 126 | Name: appName, |
| 127 | Slug: "sheets", |
| 128 | Home: "/", |
| 129 | Icon: brand.Path("sheets"), |
| 130 | }, |
| 131 | Context: o.context, |
| 132 | ContextEdit: o.edit, |
| 133 | Actions: o.actions, |
| 134 | Sticky: !o.fill, |
| 135 | Labels: chrome.BarLabels{Skip: "Skip to content", Menu: "Menu"}, |
| 136 | } |
| 137 | address, signedIn := "", false |
| 138 | if !o.signinPage { |
| 139 | address, signedIn = a.identity(r) |
| 140 | } |
| 141 | switch { |
| 142 | case o.signinPage: |
| 143 | // The corner is empty on the pages that are themselves sign-in. |
| 144 | case signedIn: |
| 145 | // The list, not "/", which only redirects a signed-in person |
| 146 | // there: one hop fewer from the brand. |
| 147 | bar.App.Home = "/sheets" |
| 148 | bar.Account = a.account(r, address, o.ret) |
| 149 | bar.Switcher = a.switcher() |
| 150 | case o.public: |
| 151 | bar.SignIn = &chrome.Link{Href: "/signin", Label: "Sign in"} |
| 152 | default: |
| 153 | bar.SignIn = &chrome.Link{Href: "/signin?return_to=" + url.QueryEscape(returnTarget(r)), Label: "Sign in"} |
| 154 | } |
| 155 | if o.public { |
| 156 | bar.App.Home = "" |
| 157 | } |
| 158 | f := Frame{ |
| 159 | Chrome: bar, |
| 160 | TitleParts: o.parts, |
| 161 | Theme: chrome.ThemeAttr(chrome.Appearance(r)), |
| 162 | Keys: keysFor(r), |
| 163 | Shortcuts: bar.Account != nil, |
| 164 | } |
| 165 | if o.fill { |
| 166 | f.Shell = "fill" |
| 167 | } |
| 168 | return f |
| 169 | } |
| 170 | |
| 171 | // returnTarget is where a person should come back to: this page, if it |
| 172 | // is a page (a GET), otherwise the list. |
| 173 | func returnTarget(r *http.Request) string { |
| 174 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 175 | return "/sheets" |
| 176 | } |
| 177 | return r.URL.RequestURI() |
| 178 | } |
| 179 | |
| 180 | // account is the account menu (spec §8.2): who is signed in, "Connect |
| 181 | // your Oficina account" when that means something, Appearance, Help, and |
| 182 | // the one Sign out in the app. |
| 183 | func (a *App) account(r *http.Request, address, ret string) *chrome.Account { |
| 184 | if ret == "" { |
| 185 | ret = returnTarget(r) |
| 186 | } |
| 187 | acc := &chrome.Account{ |
| 188 | TriggerLabel: "Account", |
| 189 | Initials: chrome.Initials("", address), |
| 190 | Address: address, |
| 191 | Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"}, |
| 192 | Appearance: &chrome.AppearanceMenu{ |
| 193 | Action: "/appearance", |
| 194 | Return: ret, |
| 195 | Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"), |
| 196 | }, |
| 197 | // Sheets has no Open source page yet, so Help holds only the |
| 198 | // shortcuts; the spec's "Open source last" waits for a credits |
| 199 | // page (recorded on the branch discussion). |
| 200 | Help: []estilomenu.Item{{Dialog: shortcutsDialog, Label: "Keyboard shortcuts…"}}, |
| 201 | SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"}, |
| 202 | } |
| 203 | if address != "" { |
| 204 | acc.TriggerLabel = "Account: " + address |
| 205 | } |
| 206 | if a.offersHomeLink(r) { |
| 207 | acc.Items = []estilomenu.Item{{Href: "/account/home", Label: homeCopy["menu_action"]}} |
| 208 | } |
| 209 | return acc |
| 210 | } |
| 211 | |
| 212 | // offersHomeLink is whether the account menu offers to connect the |
| 213 | // person's Oficina account: only when Home is configured, and never to |
| 214 | // somebody who signed in through Home, whose account is already the |
| 215 | // connection (spec §12.1 item 4; before this Sheets offered it to Home |
| 216 | // sessions too). |
| 217 | func (a *App) offersHomeLink(r *http.Request) bool { |
| 218 | if a.home == nil { |
| 219 | return false |
| 220 | } |
| 221 | s, ok := a.homeSessions.From(r) |
| 222 | return ok && s.Method != connect.HomeMethod |
| 223 | } |
| 224 | |
| 225 | // switcher is the app switcher, or nil when no hub directory is |
| 226 | // configured. It shows "All apps" alone and no tiles (Paul's §13 answer |
| 227 | // 2): a tile today still costs an assertion exchange through the |
| 228 | // directory session. |
| 229 | func (a *App) switcher() *chrome.Switcher { |
| 230 | if a.directory == "" { |
| 231 | return nil |
| 232 | } |
| 233 | return &chrome.Switcher{ |
| 234 | Label: "Oficina apps", |
| 235 | CurrentLabel: "(this app)", |
| 236 | AllApps: estilomenu.Item{Href: a.directory + "/", Label: "All apps"}, |
| 237 | } |
| 238 | } |
| 239 | |
| 240 | // homeCopy is connect's account-connection copy (which already leaves |
| 241 | // the hub unnamed — "your Oficina account" — and says Connect, Paul's |
| 242 | // §13 answer 3), with only Sheets' own words on top. |
| 243 | var homeCopy = func() map[string]string { |
| 244 | out := make(map[string]string, len(connect.English)) |
| 245 | for k, v := range connect.English { |
| 246 | out[k] = v |
| 247 | } |
| 248 | out["start_body"] = "Use your Oficina account to sign in to this account. Your sheets and sharing settings stay as they are." |
| 249 | out["documents_action"] = "Back to Sheets" |
| 250 | return out |
| 251 | }() |
| 252 | |
| 253 | // pageHeaders marks a rendered page as depending on the platform its |
| 254 | // shortcut labels were formatted for: menu.Shortcut reads the |
| 255 | // User-Agent, so a shared cache must not hand a Mac's page to Windows. |
| 256 | func pageHeaders(w http.ResponseWriter) { |
| 257 | w.Header().Add("Vary", "User-Agent") |
| 258 | } |
| 259 | |