// Package chrome is the suite's shared page chrome: the top bar // (estilo-bar, with an optional editable title), the account menu, the // app switcher, the error page body and the sign-in pages, plus the // per-app Appearance preference behind the account menu. // // Docs is the first consumer and Sheets the second; until Sheets has // adopted it this is a home, not an agreed interface (AGENTS.md, the one // rule). // // Templates carry no strings: callers supply every label in their own // locale. Parse Templates() together with menu.Templates() and, for the // sign-in form, rastrillo's ui.Templates() with ui.Funcs(). // // Script line: everything works with scripts off. Every trigger is a //
in the one top-level group name="estilo-menus", and every // action is a link or a form POST. With scripts off an open panel closes // only when its own summary is clicked again (the same stated cost as // rastrillo's dropdown); JS() adds Escape and outside-click close, and // Escape in the editable title restores it. package chrome import ( "embed" "net/http" "strings" "time" "unicode" "amadan.net/oficina/estilo.git/menu" "amadan.net/oficina/estilo.git/sky" "github.com/carlosframework/rastrillo/sessions" ) //go:embed chrome.css var css []byte //go:embed chrome.js var js []byte //go:embed templates/*.html var templateFS embed.FS // CSS returns chrome.css. Link it after menu.css. func CSS() []byte { return append([]byte(nil), css...) } // JS returns chrome.js, an optional classic script (defer) that closes an // open bar panel on Escape or an outside click, and restores the // editable title on Escape. Nothing depends on it. func JS() []byte { return append([]byte(nil), js...) } // Templates holds estilo-bar, estilo-bar-title, estilo-bar-actions, // estilo-account, estilo-switcher, estilo-error, estilo-signin, // estilo-signin-sent, estilo-signed-out and the bar's icons. func Templates() embed.FS { return templateFS } // Link is a labelled destination. Current marks the page being shown. type Link struct { Href, Label string Current bool } // App names the app the bar belongs to. Home is the brand's link, the // app root; leave it empty on a public read-only page and the brand is // plain text. Icon is the brand image URL. type App struct { Name, Slug, Home, Icon string } // TitleEdit is the bar's editable title: a form posting Name=Value to // Action. Set it only when the person may rename; leave it nil and the // read-only Context shows instead. type TitleEdit struct { Action, Name, Value string // Label is the input's accessible name ("Document title"); Submit the // button that appears while the field has focus ("Rename"). Label, Submit string } // Action is one page action. Its Item says what activating it does (a // link, a POST or a dialog; see menu.Item), so the bar's button and the // Menu sheet's row are the same element kind and cannot disagree. Primary // marks the page's main action, drawn as the primary button. // // Docs' actions were all links (chrome.Link). Sheets' Create is a POST, // and a link in the sheet would be a GET to the list that creates // nothing, so the action carries the item instead. type Action struct { menu.Item Primary bool } // BarLabels are the bar's own strings: the skip link (rendered by the // app's layout) and the narrow Menu trigger. type BarLabels struct { Skip, Menu string } // Bar is estilo-bar's data. type Bar struct { App App // Context is the open object's title, read-only. ContextEdit, when // set, replaces it with the editable title. Context string ContextEdit *TitleEdit // Nav is the app's section links; NavLabel their accessible name. Nav []Link NavLabel string // Actions are page actions ("Share…", "Create"): buttons at wide // widths, rows in the Menu sheet below 800px. Actions []Action // Switcher is optional; nil drops it with nothing else to change. Switcher *Switcher // Account is set when someone is signed in. Otherwise SignIn is the // corner's "Sign in" link, and nil leaves the corner empty (on the // sign-in pages themselves). Account *Account SignIn *Link // Sticky pins the bar to the top of the viewport. Sticky bool Labels BarLabels } // AccountLabels are the account menu's headings. type AccountLabels struct { SignedInAs, Appearance, Help string } // Account is estilo-account's data. type Account struct { // TriggerLabel is the trigger's accessible name ("Account: // paul@example.com"). Initials fills the avatar; empty shows the // person glyph (a hub that cannot read the address). TriggerLabel, Initials string // Name and Address identify who is signed in; either may be empty. Name, Address string Labels AccountLabels // Items are the app's account-scoped pages, after the identity row. Items []menu.Item // Appearance is the Light / Dark / System group; nil omits it. Appearance *AppearanceMenu // Help items come last before Sign out ("Open source" last of all). Help []menu.Item SignOut SignOut // Rail draws the account at the foot of a sidebar rail rather than at // the bar's inline end, for an app whose chrome is a rail (Fichas). // The trigger becomes a row that says who is signed in beside the // avatar, and the panel hangs from the rail's inline start and opens // upward where the rail is the viewport's height (from 800px, the // shell breakpoint). Below that the rail is an in-flow disclosure at // the top of the page, so the panel opens downward like every other. // The menu, its group and its one Sign out are unchanged. Rail bool } // SignOut is the account menu's one Sign out: a POST to Action. type SignOut struct { Action, Label string } // AppearanceMenu is the account menu's Appearance form, posting mode and // return_to to Action (HandleAppearance). type AppearanceMenu struct { Action, Return string Options []AppearanceOption } // AppearanceOption is one button in the Appearance group. type AppearanceOption struct { Value Mode Label string Current bool } // Switcher is estilo-switcher's data. Apps are the tiles, in the suite's // fixed order; AllApps is the hub directory. Label is the trigger's // accessible name and CurrentLabel the hidden "(this app)" on the // current tile. type Switcher struct { Label, CurrentLabel string Apps []SwitcherApp AllApps menu.Item } // SwitcherApp is one tile. type SwitcherApp struct { Name, Href, Icon string Current bool } // ErrorPage is estilo-error's data. Back is shown only for a Referer the // app has checked is same-origin; Ref only for a 500, prefixed by // RefLabel ("Reference:"). type ErrorPage struct { Status int Title, Body string Home Link Back *Link Ref, RefLabel string } // Hidden is one hidden form field. type Hidden struct { Name, Value string } // SignIn is estilo-signin's data. Error is shown in a negative callout // above the form; AddressError in the field's own error slot. // // The door's words are its own (DoorCopy): the app passes its name, mark, // sky and destinations, and Copy only for a locale other than English. type SignIn struct { Icon, Lede string // App is the product's name, beside its mark at the head of the card // ("Calendar"); Sky is the sky behind it, sky.For(). Both optional. A Sky needs sky.css linked, sky.Handler // mounted at sky.Prefix and img-src data: allowed (the template). App string Sky sky.View Error string Action, Return string Hidden []Hidden Address, AddressError string OpenSource *Link // Copy replaces the door's English (nil) with another locale's set. Copy *DoorCopy } // SignInNotice is the data of estilo-signin-sent and estilo-signed-out, // and of any other notice drawn as the door (the Keymail hand-off, the // hub's account pages). For the door's own two, build it with Sent or // SignedOut, which supply the heading, body and link label; set Heading, // Body and Link directly only for a notice of the app's own. type SignInNotice struct { Icon, Heading, Body string App string Sky sky.View Link *Link // Copy replaces the door's English (nil) with another locale's set. Copy *DoorCopy // page is the tab title's page, set by Sent and SignedOut. page string } // Initials is the avatar's text: the first letter of name, or else of // the address's local part, upper-cased. Empty when neither has a // letter or digit, and the account trigger then shows the person glyph. func Initials(name, address string) string { source := strings.TrimSpace(name) if source == "" { source, _, _ = strings.Cut(strings.TrimSpace(address), "@") } for _, r := range source { if unicode.IsLetter(r) || unicode.IsDigit(r) { return strings.ToUpper(string(r)) } } return "" } // Mode is an Appearance preference. type Mode string const ( Light Mode = "light" Dark Mode = "dark" System Mode = "system" ) // AppearanceCookie is the host-only cookie holding the preference. It is // per app on purpose: each origin remembers its own. const AppearanceCookie = "estilo_appearance" func validMode(v string) (Mode, bool) { switch Mode(v) { case Light, Dark, System: return Mode(v), true } return System, false } // Appearance reads the request's preference. Anything that is not // exactly light, dark or system is System. func Appearance(r *http.Request) Mode { c, err := r.Cookie(AppearanceCookie) if err != nil { return System } m, _ := validMode(c.Value) return m } // ThemeAttr is the value for : "light" or "dark", and // empty for System, when the layout omits the attribute and the theme follows // the operating system. func ThemeAttr(m Mode) string { switch m { case Light, Dark: return string(m) } return "" } // AppearanceOptions builds the three buttons in the fixed order Light, // Dark, System, with current pressed. func AppearanceOptions(current Mode, light, dark, system string) []AppearanceOption { return []AppearanceOption{ {Value: Light, Label: light, Current: current == Light}, {Value: Dark, Label: dark, Current: current == Dark}, {Value: System, Label: system, Current: current == System}, } } // HandleAppearance serves the Appearance form's POST: it validates mode // against the three values, sets AppearanceCookie for a year and answers // 303 to return_to, validated by rastrillo's sessions.SafeReturn (falling // back to "/"). Mount it inside the app's CSRF protection and outside its // session guard, so a session that expired mid-page cannot turn the click // into a sign-in loop. Anything but POST is 405; an unknown mode is 400. func HandleAppearance(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { w.Header().Set("Allow", http.MethodPost) http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) return } r.Body = http.MaxBytesReader(w, r.Body, 4096) mode, ok := validMode(r.PostFormValue("mode")) if !ok { http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest) return } http.SetCookie(w, &http.Cookie{ Name: AppearanceCookie, Value: string(mode), Path: "/", MaxAge: int((365 * 24 * time.Hour).Seconds()), Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) w.Header().Set("Cache-Control", "no-store") http.Redirect(w, r, sessions.SafeReturn(r, "/"), http.StatusSeeOther) }