oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

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.
20package chrome
21
22import (
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
35var css []byte
36
37//go:embed chrome.js
38var js []byte
39
40//go:embed templates/*.html
41var templateFS embed.FS
42
43// CSS returns chrome.css. Link it after menu.css.
44func 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.
49func 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.
54func Templates() embed.FS { return templateFS }
55
56// Link is a labelled destination. Current marks the page being shown.
57type 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.
65type 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.
72type 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.
87type 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.
94type BarLabels struct {
95 Skip, Menu string
96}
97
98// Bar is estilo-bar's data.
99type 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.
124type AccountLabels struct {
125 SignedInAs, Appearance, Help string
126}
127
128// Account is estilo-account's data.
129type 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.
156type 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).
162type AppearanceMenu struct {
163 Action, Return string
164 Options []AppearanceOption
165}
166
167// AppearanceOption is one button in the Appearance group.
168type 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.
178type Switcher struct {
179 Label, CurrentLabel string
180 Apps []SwitcherApp
181 AllApps menu.Item
182}
183
184// SwitcherApp is one tile.
185type 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:").
193type 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.
202type 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.
211type 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.
233type 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.
247func 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.
261type Mode string
262
263const (
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.
271const AppearanceCookie = "estilo_appearance"
272
273func 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.
283func 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.
295func 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.
305func 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.
319func 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