| 1 | // Package chrome is the suite's shared page chrome: the top bar |
| 2 | // (estilo-bar, with an optional editable title), the account menu, the |
| 3 | // app switcher, the error page body and the sign-in pages, plus the |
| 4 | // per-app Appearance preference behind the account menu. |
| 5 | // |
| 6 | // Docs is the first consumer and Sheets the second; until Sheets has |
| 7 | // adopted it this is a home, not an agreed interface (AGENTS.md, the one |
| 8 | // rule). |
| 9 | // |
| 10 | // Templates carry no strings: callers supply every label in their own |
| 11 | // locale. Parse Templates() together with menu.Templates() and, for the |
| 12 | // sign-in form, rastrillo's ui.Templates() with ui.Funcs(). |
| 13 | // |
| 14 | // Script line: everything works with scripts off. Every trigger is a |
| 15 | // <details> in the one top-level group name="estilo-menus", and every |
| 16 | // action is a link or a form POST. With scripts off an open panel closes |
| 17 | // only when its own summary is clicked again (the same stated cost as |
| 18 | // rastrillo's dropdown); JS() adds Escape and outside-click close, and |
| 19 | // Escape in the editable title restores it. |
| 20 | package chrome |
| 21 | |
| 22 | import ( |
| 23 | "embed" |
| 24 | "net/http" |
| 25 | "strings" |
| 26 | "time" |
| 27 | "unicode" |
| 28 | |
| 29 | "amadan.net/oficina/estilo.git/menu" |
| 30 | "amadan.net/oficina/estilo.git/sky" |
| 31 | "github.com/carlosframework/rastrillo/sessions" |
| 32 | ) |
| 33 | |
| 34 | //go:embed chrome.css |
| 35 | var css []byte |
| 36 | |
| 37 | //go:embed chrome.js |
| 38 | var js []byte |
| 39 | |
| 40 | //go:embed templates/*.html |
| 41 | var templateFS embed.FS |
| 42 | |
| 43 | // CSS returns chrome.css. Link it after menu.css. |
| 44 | func CSS() []byte { return append([]byte(nil), css...) } |
| 45 | |
| 46 | // JS returns chrome.js, an optional classic script (defer) that closes an |
| 47 | // open bar panel on Escape or an outside click, and restores the |
| 48 | // editable title on Escape. Nothing depends on it. |
| 49 | func JS() []byte { return append([]byte(nil), js...) } |
| 50 | |
| 51 | // Templates holds estilo-bar, estilo-bar-title, estilo-bar-actions, |
| 52 | // estilo-account, estilo-switcher, estilo-error, estilo-signin, |
| 53 | // estilo-signin-sent, estilo-signed-out and the bar's icons. |
| 54 | func Templates() embed.FS { return templateFS } |
| 55 | |
| 56 | // Link is a labelled destination. Current marks the page being shown. |
| 57 | type Link struct { |
| 58 | Href, Label string |
| 59 | Current bool |
| 60 | } |
| 61 | |
| 62 | // App names the app the bar belongs to. Home is the brand's link, the |
| 63 | // app root; leave it empty on a public read-only page and the brand is |
| 64 | // plain text. Icon is the brand image URL. |
| 65 | type App struct { |
| 66 | Name, Slug, Home, Icon string |
| 67 | } |
| 68 | |
| 69 | // TitleEdit is the bar's editable title: a form posting Name=Value to |
| 70 | // Action. Set it only when the person may rename; leave it nil and the |
| 71 | // read-only Context shows instead. |
| 72 | type TitleEdit struct { |
| 73 | Action, Name, Value string |
| 74 | // Label is the input's accessible name ("Document title"); Submit the |
| 75 | // button that appears while the field has focus ("Rename"). |
| 76 | Label, Submit string |
| 77 | } |
| 78 | |
| 79 | // Action is one page action. Its Item says what activating it does (a |
| 80 | // link, a POST or a dialog; see menu.Item), so the bar's button and the |
| 81 | // Menu sheet's row are the same element kind and cannot disagree. Primary |
| 82 | // marks the page's main action, drawn as the primary button. |
| 83 | // |
| 84 | // Docs' actions were all links (chrome.Link). Sheets' Create is a POST, |
| 85 | // and a link in the sheet would be a GET to the list that creates |
| 86 | // nothing, so the action carries the item instead. |
| 87 | type Action struct { |
| 88 | menu.Item |
| 89 | Primary bool |
| 90 | } |
| 91 | |
| 92 | // BarLabels are the bar's own strings: the skip link (rendered by the |
| 93 | // app's layout) and the narrow Menu trigger. |
| 94 | type BarLabels struct { |
| 95 | Skip, Menu string |
| 96 | } |
| 97 | |
| 98 | // Bar is estilo-bar's data. |
| 99 | type Bar struct { |
| 100 | App App |
| 101 | // Context is the open object's title, read-only. ContextEdit, when |
| 102 | // set, replaces it with the editable title. |
| 103 | Context string |
| 104 | ContextEdit *TitleEdit |
| 105 | // Nav is the app's section links; NavLabel their accessible name. |
| 106 | Nav []Link |
| 107 | NavLabel string |
| 108 | // Actions are page actions ("Share…", "Create"): buttons at wide |
| 109 | // widths, rows in the Menu sheet below 800px. |
| 110 | Actions []Action |
| 111 | // Switcher is optional; nil drops it with nothing else to change. |
| 112 | Switcher *Switcher |
| 113 | // Account is set when someone is signed in. Otherwise SignIn is the |
| 114 | // corner's "Sign in" link, and nil leaves the corner empty (on the |
| 115 | // sign-in pages themselves). |
| 116 | Account *Account |
| 117 | SignIn *Link |
| 118 | // Sticky pins the bar to the top of the viewport. |
| 119 | Sticky bool |
| 120 | Labels BarLabels |
| 121 | } |
| 122 | |
| 123 | // AccountLabels are the account menu's headings. |
| 124 | type AccountLabels struct { |
| 125 | SignedInAs, Appearance, Help string |
| 126 | } |
| 127 | |
| 128 | // Account is estilo-account's data. |
| 129 | type Account struct { |
| 130 | // TriggerLabel is the trigger's accessible name ("Account: |
| 131 | // paul@example.com"). Initials fills the avatar; empty shows the |
| 132 | // person glyph (a hub that cannot read the address). |
| 133 | TriggerLabel, Initials string |
| 134 | // Name and Address identify who is signed in; either may be empty. |
| 135 | Name, Address string |
| 136 | Labels AccountLabels |
| 137 | // Items are the app's account-scoped pages, after the identity row. |
| 138 | Items []menu.Item |
| 139 | // Appearance is the Light / Dark / System group; nil omits it. |
| 140 | Appearance *AppearanceMenu |
| 141 | // Help items come last before Sign out ("Open source" last of all). |
| 142 | Help []menu.Item |
| 143 | SignOut SignOut |
| 144 | // Rail draws the account at the foot of a sidebar rail rather than at |
| 145 | // the bar's inline end, for an app whose chrome is a rail (Fichas). |
| 146 | // The trigger becomes a row that says who is signed in beside the |
| 147 | // avatar, and the panel hangs from the rail's inline start and opens |
| 148 | // upward where the rail is the viewport's height (from 800px, the |
| 149 | // shell breakpoint). Below that the rail is an in-flow disclosure at |
| 150 | // the top of the page, so the panel opens downward like every other. |
| 151 | // The menu, its group and its one Sign out are unchanged. |
| 152 | Rail bool |
| 153 | } |
| 154 | |
| 155 | // SignOut is the account menu's one Sign out: a POST to Action. |
| 156 | type SignOut struct { |
| 157 | Action, Label string |
| 158 | } |
| 159 | |
| 160 | // AppearanceMenu is the account menu's Appearance form, posting mode and |
| 161 | // return_to to Action (HandleAppearance). |
| 162 | type AppearanceMenu struct { |
| 163 | Action, Return string |
| 164 | Options []AppearanceOption |
| 165 | } |
| 166 | |
| 167 | // AppearanceOption is one button in the Appearance group. |
| 168 | type AppearanceOption struct { |
| 169 | Value Mode |
| 170 | Label string |
| 171 | Current bool |
| 172 | } |
| 173 | |
| 174 | // Switcher is estilo-switcher's data. Apps are the tiles, in the suite's |
| 175 | // fixed order; AllApps is the hub directory. Label is the trigger's |
| 176 | // accessible name and CurrentLabel the hidden "(this app)" on the |
| 177 | // current tile. |
| 178 | type Switcher struct { |
| 179 | Label, CurrentLabel string |
| 180 | Apps []SwitcherApp |
| 181 | AllApps menu.Item |
| 182 | } |
| 183 | |
| 184 | // SwitcherApp is one tile. |
| 185 | type SwitcherApp struct { |
| 186 | Name, Href, Icon string |
| 187 | Current bool |
| 188 | } |
| 189 | |
| 190 | // ErrorPage is estilo-error's data. Back is shown only for a Referer the |
| 191 | // app has checked is same-origin; Ref only for a 500, prefixed by |
| 192 | // RefLabel ("Reference:"). |
| 193 | type ErrorPage struct { |
| 194 | Status int |
| 195 | Title, Body string |
| 196 | Home Link |
| 197 | Back *Link |
| 198 | Ref, RefLabel string |
| 199 | } |
| 200 | |
| 201 | // Hidden is one hidden form field. |
| 202 | type Hidden struct { |
| 203 | Name, Value string |
| 204 | } |
| 205 | |
| 206 | // SignIn is estilo-signin's data. Error is shown in a negative callout |
| 207 | // above the form; AddressError in the field's own error slot. |
| 208 | // |
| 209 | // The door's words are its own (DoorCopy): the app passes its name, mark, |
| 210 | // sky and destinations, and Copy only for a locale other than English. |
| 211 | type SignIn struct { |
| 212 | Icon, Lede string |
| 213 | // App is the product's name, beside its mark at the head of the card |
| 214 | // ("Calendar"); Sky is the sky behind it, sky.For(<the instance's |
| 215 | // host>). Both optional. A Sky needs sky.css linked, sky.Handler |
| 216 | // mounted at sky.Prefix and img-src data: allowed (the template). |
| 217 | App string |
| 218 | Sky sky.View |
| 219 | Error string |
| 220 | Action, Return string |
| 221 | Hidden []Hidden |
| 222 | Address, AddressError string |
| 223 | OpenSource *Link |
| 224 | // Copy replaces the door's English (nil) with another locale's set. |
| 225 | Copy *DoorCopy |
| 226 | } |
| 227 | |
| 228 | // SignInNotice is the data of estilo-signin-sent and estilo-signed-out, |
| 229 | // and of any other notice drawn as the door (the Keymail hand-off, the |
| 230 | // hub's account pages). For the door's own two, build it with Sent or |
| 231 | // SignedOut, which supply the heading, body and link label; set Heading, |
| 232 | // Body and Link directly only for a notice of the app's own. |
| 233 | type SignInNotice struct { |
| 234 | Icon, Heading, Body string |
| 235 | App string |
| 236 | Sky sky.View |
| 237 | Link *Link |
| 238 | // Copy replaces the door's English (nil) with another locale's set. |
| 239 | Copy *DoorCopy |
| 240 | // page is the tab title's page, set by Sent and SignedOut. |
| 241 | page string |
| 242 | } |
| 243 | |
| 244 | // Initials is the avatar's text: the first letter of name, or else of |
| 245 | // the address's local part, upper-cased. Empty when neither has a |
| 246 | // letter or digit, and the account trigger then shows the person glyph. |
| 247 | func Initials(name, address string) string { |
| 248 | source := strings.TrimSpace(name) |
| 249 | if source == "" { |
| 250 | source, _, _ = strings.Cut(strings.TrimSpace(address), "@") |
| 251 | } |
| 252 | for _, r := range source { |
| 253 | if unicode.IsLetter(r) || unicode.IsDigit(r) { |
| 254 | return strings.ToUpper(string(r)) |
| 255 | } |
| 256 | } |
| 257 | return "" |
| 258 | } |
| 259 | |
| 260 | // Mode is an Appearance preference. |
| 261 | type Mode string |
| 262 | |
| 263 | const ( |
| 264 | Light Mode = "light" |
| 265 | Dark Mode = "dark" |
| 266 | System Mode = "system" |
| 267 | ) |
| 268 | |
| 269 | // AppearanceCookie is the host-only cookie holding the preference. It is |
| 270 | // per app on purpose: each origin remembers its own. |
| 271 | const AppearanceCookie = "estilo_appearance" |
| 272 | |
| 273 | func validMode(v string) (Mode, bool) { |
| 274 | switch Mode(v) { |
| 275 | case Light, Dark, System: |
| 276 | return Mode(v), true |
| 277 | } |
| 278 | return System, false |
| 279 | } |
| 280 | |
| 281 | // Appearance reads the request's preference. Anything that is not |
| 282 | // exactly light, dark or system is System. |
| 283 | func Appearance(r *http.Request) Mode { |
| 284 | c, err := r.Cookie(AppearanceCookie) |
| 285 | if err != nil { |
| 286 | return System |
| 287 | } |
| 288 | m, _ := validMode(c.Value) |
| 289 | return m |
| 290 | } |
| 291 | |
| 292 | // ThemeAttr is the value for <html data-theme>: "light" or "dark", and |
| 293 | // empty for System, when the layout omits the attribute and the theme follows |
| 294 | // the operating system. |
| 295 | func ThemeAttr(m Mode) string { |
| 296 | switch m { |
| 297 | case Light, Dark: |
| 298 | return string(m) |
| 299 | } |
| 300 | return "" |
| 301 | } |
| 302 | |
| 303 | // AppearanceOptions builds the three buttons in the fixed order Light, |
| 304 | // Dark, System, with current pressed. |
| 305 | func AppearanceOptions(current Mode, light, dark, system string) []AppearanceOption { |
| 306 | return []AppearanceOption{ |
| 307 | {Value: Light, Label: light, Current: current == Light}, |
| 308 | {Value: Dark, Label: dark, Current: current == Dark}, |
| 309 | {Value: System, Label: system, Current: current == System}, |
| 310 | } |
| 311 | } |
| 312 | |
| 313 | // HandleAppearance serves the Appearance form's POST: it validates mode |
| 314 | // against the three values, sets AppearanceCookie for a year and answers |
| 315 | // 303 to return_to, validated by rastrillo's sessions.SafeReturn (falling |
| 316 | // back to "/"). Mount it inside the app's CSRF protection and outside its |
| 317 | // session guard, so a session that expired mid-page cannot turn the click |
| 318 | // into a sign-in loop. Anything but POST is 405; an unknown mode is 400. |
| 319 | func HandleAppearance(w http.ResponseWriter, r *http.Request) { |
| 320 | if r.Method != http.MethodPost { |
| 321 | w.Header().Set("Allow", http.MethodPost) |
| 322 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 323 | return |
| 324 | } |
| 325 | r.Body = http.MaxBytesReader(w, r.Body, 4096) |
| 326 | mode, ok := validMode(r.PostFormValue("mode")) |
| 327 | if !ok { |
| 328 | http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) |
| 329 | return |
| 330 | } |
| 331 | http.SetCookie(w, &http.Cookie{ |
| 332 | Name: AppearanceCookie, |
| 333 | Value: string(mode), |
| 334 | Path: "/", |
| 335 | MaxAge: int((365 * 24 * time.Hour).Seconds()), |
| 336 | Secure: true, |
| 337 | HttpOnly: true, |
| 338 | SameSite: http.SameSiteLaxMode, |
| 339 | }) |
| 340 | w.Header().Set("Cache-Control", "no-store") |
| 341 | http.Redirect(w, r, sessions.SafeReturn(r, "/"), http.StatusSeeOther) |
| 342 | } |
| 343 | |