Add the shared chrome: bar, account menu, switcher, error and sign-in pages
estilo-bar is one 52px row at every width, with an optional editable title, section nav, page actions (rows in a Menu sheet below 800px), the optional switcher and the account corner, in a fixed order. The account menu shows who is signed in, app items, Appearance (Light, Dark, System, pressed state on every option), Help and one Sign out. HandleAppearance validates return_to with rastrillo's sessions.SafeReturn rather than a re-implementation, which pulls rastrillo's sessions (and gorm) into go.sum. chrome.js is an optional classic script for Escape and outside-click close.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
12 files changed,
+1107
−2
chrome/chrome.css+182 −0chrome/chrome.go+299 −0chrome/chrome.js+37 −0chrome/chrome_test.go+367 −0chrome/templates/account.html+40 −0chrome/templates/bar.html+66 −0chrome/templates/error.html+14 −0chrome/templates/icons.html+6 −0chrome/templates/signin.html+53 −0chrome/templates/switcher.html+18 −0go.mod+5 −0go.sum+20 −2
diff --git a/chrome/chrome.css b/chrome/chrome.css| new file mode 100644 |
| index 0000000..0a1bbd4 |
| --- /dev/null |
| +++ b/chrome/chrome.css |
| @@ -0,0 +1,182 @@ |
| +/* chrome/chrome.css: the suite's top bar, account menu, app switcher and |
| + sign-in pages. Link it after menu.css (its panels are [estilo-menu]). |
| + Two custom properties are PUBLIC contract, read by apps and never |
| + copied as numbers: the bar's block size and its stacking level. */ |
| +:root { |
| + --estilo-bar-block-size: 3.25rem; /* 52px. PUBLIC */ |
| + --estilo-z-bar: 45; /* PUBLIC: the bar's stacking level, and the ceiling for in-page layers */ |
| + --estilo-trigger-size: 2rem; /* 32px targets, above the 24px floor */ |
| +} |
| +[estilo-bar] { |
| + position: relative; |
| + z-index: var(--estilo-z-bar); |
| + box-sizing: border-box; |
| + display: flex; |
| + flex-wrap: nowrap; |
| + align-items: center; |
| + gap: var(--rst-sp-3); |
| + block-size: var(--estilo-bar-block-size); /* fixed, not min: nothing may grow it */ |
| + padding-inline: var(--rst-sp-4); |
| + background: var(--rst-surface); |
| + color: var(--rst-text); |
| + border-block-end: 1px solid var(--rst-line); |
| +} |
| +[estilo-bar~="sticky"] { position: sticky; inset-block-start: 0; } |
| +[estilo-bar-brand] { |
| + display: inline-flex; |
| + align-items: center; |
| + gap: 0.5rem; |
| + flex: none; |
| + color: var(--rst-text); |
| + font-weight: 650; |
| + text-decoration: none; |
| + white-space: nowrap; |
| +} |
| +[estilo-bar-brand] img { flex: none; inline-size: 1.75rem; block-size: 1.75rem; border-radius: 21%; } |
| +[estilo-bar-context] { |
| + flex: 0 1 auto; |
| + min-inline-size: 0; |
| + overflow: hidden; |
| + text-overflow: ellipsis; |
| + white-space: nowrap; |
| + padding-inline-start: var(--rst-sp-3); |
| + border-inline-start: 1px solid var(--rst-line); |
| + color: var(--rst-text); |
| + font-weight: 550; |
| +} |
| +[estilo-bar-nav] { display: flex; align-self: stretch; gap: var(--rst-sp-4); } |
| +[estilo-bar-nav] a { |
| + display: flex; |
| + align-items: center; |
| + border-block: 2px solid transparent; /* square current marker, in-flow */ |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + text-decoration: none; |
| + white-space: nowrap; |
| +} |
| +[estilo-bar-nav] a:hover { color: var(--rst-text); } |
| +[estilo-bar-nav] a[aria-current] { border-block-end-color: var(--rst-accent); color: var(--rst-text); font-weight: 550; } |
| +[estilo-bar-end] { display: flex; align-items: center; gap: var(--rst-sp-2); flex: none; margin-inline-start: auto; } |
| +[estilo-bar-actions] { display: flex; align-items: center; gap: var(--rst-sp-2); } |
| +[estilo-bar-trigger] { |
| + display: inline-flex; |
| + align-items: center; |
| + justify-content: center; |
| + gap: 0.3rem; |
| + box-sizing: border-box; |
| + min-inline-size: var(--estilo-trigger-size); |
| + block-size: var(--estilo-trigger-size); |
| + padding-inline: 0.4rem; |
| + border-radius: 0; /* in-flow: square */ |
| + color: var(--rst-text-muted); |
| + font-size: var(--rst-fs-sm); |
| + cursor: pointer; |
| + list-style: none; |
| + user-select: none; |
| +} |
| +[estilo-bar-trigger]::-webkit-details-marker { display: none; } |
| +[estilo-bar-trigger]::marker { content: ""; } |
| +[estilo-bar-trigger] svg { flex: none; inline-size: 1.125rem; block-size: 1.125rem; } |
| +[estilo-bar-trigger]:hover, |
| +[open] > [estilo-bar-trigger] { color: var(--rst-text); background: var(--rst-surface-2); } |
| +[estilo-bar-trigger]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; } |
| +[estilo-bar-more] { display: none; } |
| +@media (max-width: 799.98px) { /* rastrillo's shell breakpoint */ |
| + [estilo-bar-nav], [estilo-bar-actions] { display: none; } |
| + [estilo-bar-more] { display: block; } |
| + [estilo-bar]:has([estilo-bar-context], [estilo-bar-title]) [estilo-bar-name] { display: none; } /* the icon stays; the title gets the room */ |
| +} |
| + |
| +/* The editable title slot: the open object's title, renamed in place. The |
| + input is a bordered control, so it keeps day's radius; its block size is |
| + fixed so an input in the bar can never grow the bar. */ |
| +[estilo-bar-title] { |
| + display: flex; |
| + align-items: center; |
| + gap: var(--rst-sp-2); |
| + flex: 0 1 22rem; |
| + min-inline-size: 0; |
| + margin: 0; |
| + padding-inline-start: var(--rst-sp-3); |
| + border-inline-start: 1px solid var(--rst-line); |
| +} |
| +[estilo-bar-title-input] { |
| + box-sizing: border-box; |
| + block-size: 2rem; |
| + inline-size: 100%; |
| + min-inline-size: 6rem; |
| + padding-inline: 0.4rem; |
| + border: 1px solid transparent; |
| + border-radius: var(--rst-radius-sm); |
| + background: transparent; |
| + color: var(--rst-text); |
| + font: inherit; |
| + font-weight: 550; |
| + text-overflow: ellipsis; |
| +} |
| +[estilo-bar-title-input]:hover { border-color: var(--rst-line); } |
| +[estilo-bar-title-input]:focus { border-color: var(--rst-accent); outline: none; background: var(--rst-surface); } |
| +[estilo-bar-title-submit] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; white-space: nowrap; } |
| +[estilo-bar-title]:focus-within [estilo-bar-title-submit] { position: static; clip-path: none; inline-size: auto; block-size: 2rem; overflow: visible; flex: none; } |
| + |
| +/* The app switcher: a float, so its tiles may be rounded. */ |
| +[estilo-switcher] { position: relative; } |
| +[estilo-switcher-panel] { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); } |
| +[estilo-switcher-grid] { display: grid; grid-template-columns: repeat(3, 5.5rem); gap: 0.25rem; } |
| +[estilo-switcher-app] { |
| + display: flex; |
| + flex-direction: column; |
| + align-items: center; |
| + gap: 0.35rem; |
| + padding: 0.6rem 0.25rem; |
| + border-radius: var(--rst-radius-sm); /* inside a float: rounded is allowed */ |
| + color: var(--rst-text); |
| + font-size: var(--rst-fs-xs); |
| + text-decoration: none; |
| +} |
| +[estilo-switcher-app]:hover, |
| +[estilo-switcher-app]:focus-visible { background: var(--rst-accent-soft); } |
| +[estilo-switcher-app][aria-current] { background: var(--rst-accent-soft); font-weight: 600; } |
| +[estilo-switcher-app] img { border-radius: 21%; } |
| + |
| +/* The account menu. The account is always the last thing at the inline |
| + end of the bar: the "same corner" rule. */ |
| +[estilo-account] { position: relative; } |
| +[estilo-account-panel] { position: absolute; inset-inline-end: 0; inset-block-start: calc(100% + 4px); min-inline-size: 15rem; } |
| +[estilo-avatar] { |
| + display: inline-flex; |
| + align-items: center; |
| + justify-content: center; |
| + inline-size: 1.75rem; |
| + block-size: 1.75rem; |
| + border-radius: 50%; /* an identity mark, not a highlight */ |
| + background: var(--rst-accent-soft); |
| + color: var(--rst-accent); |
| + font-size: var(--rst-fs-xs); |
| + font-weight: 650; |
| +} |
| +[estilo-menu-identity] { display: grid; gap: 0.1rem; padding: 0.5rem var(--estilo-item-padding-inline, 0.65rem); } |
| +[estilo-menu-identity-label] { color: var(--rst-text-muted); font-size: var(--rst-fs-xs); } |
| +[estilo-menu-identity-name] { font-weight: 600; overflow-wrap: anywhere; } |
| +[estilo-menu-identity-address] { color: var(--rst-text-muted); overflow-wrap: anywhere; } |
| +[estilo-menu-identity-name] + [estilo-menu-identity-address] { font-size: var(--rst-fs-xs); } |
| +[estilo-bar-signin] { flex: none; } |
| + |
| +/* Visually hidden, still announced. */ |
| +[estilo-visually-hidden] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; white-space: nowrap; } |
| + |
| +/* Sign-in, check-your-email and signed-out pages: one narrow, |
| + left-aligned column, not a card, so nothing in the flow is framed. */ |
| +[estilo-signin] { |
| + box-sizing: border-box; |
| + max-inline-size: 26rem; |
| + margin: 4rem auto var(--rst-sp-6); |
| + padding-inline: var(--rst-sp-4); |
| +} |
| +[estilo-signin] > [estilo-brand-icon] { display: block; } |
| +[estilo-signin] h1 { margin: var(--rst-sp-3) 0 0; font-size: 1.375rem; font-weight: 600; } |
| +[estilo-signin-lede] { margin: 0.25rem 0 var(--rst-sp-4); color: var(--rst-text-muted); } |
| +[estilo-signin] > h1 + :not([estilo-signin-lede]) { margin-block-start: var(--rst-sp-4); } |
| +[estilo-signin-form] { max-inline-size: none; margin-block-start: var(--rst-sp-4); } |
| +[estilo-signin-body] { margin: var(--rst-sp-4) 0; } |
| +[estilo-signin-foot] { margin-block-start: var(--rst-sp-6); font-size: var(--rst-fs-xs); color: var(--rst-text-muted); } |
diff --git a/chrome/chrome.go b/chrome/chrome.go| new file mode 100644 |
| index 0000000..c72c159 |
| --- /dev/null |
| +++ b/chrome/chrome.go |
| @@ -0,0 +1,299 @@ |
| +// 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 |
| +// <details> 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" |
| + "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 |
| +} |
| + |
| +// 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…"): buttons at wide widths, rows |
| + // in the Menu sheet below 800px. |
| + Actions []Link |
| + // 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 |
| +} |
| + |
| +// 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. |
| +type SignIn struct { |
| + Icon, Heading, Lede string |
| + Error string |
| + Action, Return string |
| + Hidden []Hidden |
| + AddressLabel, Address, Help, AddressError string |
| + Submit string |
| + OpenSource *Link |
| +} |
| + |
| +// SignInNotice is the data of estilo-signin-sent and estilo-signed-out. |
| +type SignInNotice struct { |
| + Icon, Heading, Body string |
| + Link *Link |
| +} |
| + |
| +// 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 <html data-theme>: "light" or "dark", and |
| +// empty for System, when the layout omits the attribute and day 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) |
| +} |
diff --git a/chrome/chrome.js b/chrome/chrome.js| new file mode 100644 |
| index 0000000..f0de97f |
| --- /dev/null |
| +++ b/chrome/chrome.js |
| @@ -0,0 +1,37 @@ |
| +// chrome/chrome.js: optional behaviour for estilo-bar. Load it with |
| +// <script defer>. Everything in the bar already works without it; this |
| +// only adds what <details> cannot do for itself: |
| +// |
| +// - Escape closes the open bar panel (Menu, switcher, account) and |
| +// returns focus to its trigger; |
| +// - a click outside the open bar panel closes it; |
| +// - Escape in the editable title restores the saved value and leaves |
| +// the field. |
| +// |
| +// Menubar menus belong to the app that renders them and are not touched. |
| +;(() => { |
| + const panels = '[estilo-bar] details[estilo-account], [estilo-bar] details[estilo-switcher], [estilo-bar] details[estilo-bar-more]' |
| + const openPanel = () => document.querySelector(panels.split(', ').map((s) => s + '[open]').join(', ')) |
| + |
| + document.addEventListener('keydown', (e) => { |
| + if (e.key !== 'Escape' || e.defaultPrevented) return |
| + const input = e.target.closest && e.target.closest('[estilo-bar-title-input]') |
| + if (input) { |
| + input.value = input.defaultValue |
| + input.blur() |
| + e.preventDefault() |
| + return |
| + } |
| + const open = openPanel() |
| + if (!open) return |
| + open.open = false |
| + const summary = open.querySelector('summary') |
| + if (summary) summary.focus() |
| + e.preventDefault() |
| + }) |
| + |
| + document.addEventListener('click', (e) => { |
| + const open = openPanel() |
| + if (open && !open.contains(e.target)) open.open = false |
| + }, true) |
| +})() |
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go| new file mode 100644 |
| index 0000000..48baf53 |
| --- /dev/null |
| +++ b/chrome/chrome_test.go |
| @@ -0,0 +1,367 @@ |
| +package chrome |
| + |
| +import ( |
| + "bytes" |
| + "html/template" |
| + "net/http" |
| + "net/http/httptest" |
| + "net/url" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/oficina/estilo.git/internal/tokencheck" |
| + "amadan.net/oficina/estilo.git/menu" |
| + "github.com/carlosframework/rastrillo/ui" |
| +) |
| + |
| +func TestChromeUsesDefinedTokens(t *testing.T) { |
| + missing, n := tokencheck.Undefined(CSS()) |
| + if n == 0 { |
| + t.Fatal("chrome.css reads no rastrillo token; the check would pass vacuously") |
| + } |
| + if len(missing) > 0 { |
| + t.Fatal("chrome.css reads undefined tokens:", missing) |
| + } |
| +} |
| + |
| +// The bar's height is public contract and must be fixed, never a minimum: |
| +// nothing inside it may grow it. |
| +func TestBarHeightIsFixedAndPublic(t *testing.T) { |
| + src := string(CSS()) |
| + for _, want := range []string{ |
| + "--estilo-bar-block-size: 3.25rem;", |
| + "--estilo-z-bar: 45;", |
| + "block-size: var(--estilo-bar-block-size);", |
| + "flex-wrap: nowrap;", |
| + } { |
| + if !strings.Contains(src, want) { |
| + t.Errorf("chrome.css lost %q", want) |
| + } |
| + } |
| + if regexp.MustCompile(`\[estilo-bar\]\s*\{[^}]*min-block-size`).MatchString(src) { |
| + t.Error("the bar declares a min-block-size: it could grow") |
| + } |
| +} |
| + |
| +func parse(t *testing.T) *template.Template { |
| + t.Helper() |
| + view := template.New("").Funcs(ui.Funcs()) |
| + view, err := view.ParseFS(ui.Templates(), "*.html") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if view, err = view.ParseFS(menu.Templates(), "templates/*.html"); err != nil { |
| + t.Fatal(err) |
| + } |
| + if view, err = view.ParseFS(Templates(), "templates/*.html"); err != nil { |
| + t.Fatal(err) |
| + } |
| + return view |
| +} |
| + |
| +func render(t *testing.T, name string, data any) string { |
| + t.Helper() |
| + var out bytes.Buffer |
| + if err := parse(t).ExecuteTemplate(&out, name, data); err != nil { |
| + t.Fatal(err) |
| + } |
| + return out.String() |
| +} |
| + |
| +// One word per locale, used for every caller-supplied label, so a label |
| +// the template dropped or hard-coded shows up as a missing string. |
| +var locales = map[string]string{"en": "Account", "de": "Konto", "es": "Cuenta", "fr": "Compte", "it": "Account", "pt": "Conta", "nl": "Account", "pl": "Konto", "ar": "الحساب", "ja": "アカウント", "zh": "账户", "ko": "계정"} |
| + |
| +func signedIn(label string) Bar { |
| + return Bar{ |
| + App: App{Name: label + "-app", Slug: "docs", Home: "/", Icon: "/static/estilo/brand/docs.svg"}, |
| + ContextEdit: &TitleEdit{Action: "/d/abc/title", Name: "title", Value: label + "-title", Label: label + "-title-label", Submit: label + "-rename"}, |
| + Nav: []Link{{Href: "/", Label: label + "-nav", Current: true}}, |
| + NavLabel: label + "-navlabel", |
| + Actions: []Link{{Href: "/d/abc/share", Label: label + "-share"}}, |
| + Switcher: &Switcher{Label: label + "-apps", AllApps: menu.Item{Href: "https://oficina.example/", Label: label + "-allapps"}}, |
| + Account: &Account{ |
| + TriggerLabel: label + "-trigger", Initials: "P", Address: "paul@example.test", |
| + Labels: AccountLabels{SignedInAs: label + "-signedinas", Appearance: label + "-appearance", Help: label + "-help"}, |
| + Items: []menu.Item{{Href: "/account/home", Label: label + "-connect"}}, |
| + Appearance: &AppearanceMenu{Action: "/appearance", Return: "/d/abc", Options: AppearanceOptions(Dark, label+"-light", label+"-dark", label+"-system")}, |
| + Help: []menu.Item{{Href: "/credits", Label: label + "-opensource"}}, |
| + SignOut: SignOut{Action: "/signout", Label: label + "-signout"}, |
| + }, |
| + Labels: BarLabels{Skip: label + "-skip", Menu: label + "-menu"}, |
| + } |
| +} |
| + |
| +func TestBarRendersCallerLabelsInEveryLocale(t *testing.T) { |
| + for locale, label := range locales { |
| + t.Run(locale, func(t *testing.T) { |
| + html := render(t, "estilo-bar", signedIn(label)) |
| + esc := template.HTMLEscapeString |
| + for _, want := range []string{ |
| + `<a estilo-bar-brand href="/">`, esc(label) + "-app", |
| + `<form estilo-bar-title method="post" action="/d/abc/title">`, `value="` + esc(label) + `-title"`, `aria-label="` + esc(label) + `-title-label"`, esc(label) + "-rename", |
| + `aria-label="` + esc(label) + `-navlabel"`, `aria-current="page"`, |
| + esc(label) + "-share", esc(label) + "-menu", |
| + `aria-label="` + esc(label) + `-apps"`, esc(label) + "-allapps", |
| + `aria-label="` + esc(label) + `-trigger"`, esc(label) + "-signedinas", "paul@example.test", |
| + esc(label) + "-connect", esc(label) + "-appearance", esc(label) + "-light", esc(label) + "-dark", esc(label) + "-system", |
| + esc(label) + "-help", esc(label) + "-opensource", esc(label) + "-signout", |
| + } { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("missing %q", want) |
| + } |
| + } |
| + if strings.Contains(html, "<script") { |
| + t.Fatal("the bar requires no script") |
| + } |
| + }) |
| + } |
| +} |
| + |
| +// The "same corner" rule: the account is the last element in the end |
| +// group, after actions, Menu and the switcher, in that order. |
| +func TestEndGroupOrderIsFixed(t *testing.T) { |
| + html := render(t, "estilo-bar", signedIn("x")) |
| + order := []string{"<div estilo-bar-actions>", "<details estilo-bar-more", "<details estilo-switcher", "<details estilo-account"} |
| + last := -1 |
| + for _, marker := range order { |
| + i := strings.Index(html, marker) |
| + if i < 0 || i < last { |
| + t.Fatalf("%q is missing or out of order", marker) |
| + } |
| + last = i |
| + } |
| + tail := html[strings.LastIndex(html, "</details>"):] |
| + if strings.Count(tail, "<") != 3 { // </details> </div> </header> |
| + t.Fatalf("something follows the account in the end group: %q", tail) |
| + } |
| +} |
| + |
| +// Every top-level trigger shares one exclusivity group, so opening one |
| +// closes the others; nothing nested inside a panel carries the name. |
| +func TestEveryBarTriggerJoinsTheOneMenuGroup(t *testing.T) { |
| + html := render(t, "estilo-bar", signedIn("x")) |
| + details := regexp.MustCompile(`<details [^>]*>`).FindAllString(html, -1) |
| + if len(details) != 3 { |
| + t.Fatalf("want three bar disclosures, got %d", len(details)) |
| + } |
| + for _, d := range details { |
| + if !strings.Contains(d, `name="estilo-menus"`) { |
| + t.Errorf("%s is not in the estilo-menus group", d) |
| + } |
| + } |
| +} |
| + |
| +func TestSignOutAppearsExactlyOnce(t *testing.T) { |
| + html := render(t, "estilo-bar", signedIn("x")) |
| + if n := strings.Count(html, "x-signout"); n != 1 { |
| + t.Fatalf("Sign out rendered %d times", n) |
| + } |
| +} |
| + |
| +func TestAppearancePressedStateOnEveryOption(t *testing.T) { |
| + html := render(t, "estilo-bar", signedIn("x")) |
| + pressed := regexp.MustCompile(`value="(light|dark|system)" aria-pressed="(true|false)"`).FindAllStringSubmatch(html, -1) |
| + if len(pressed) != 3 { |
| + t.Fatalf("want three appearance options with aria-pressed, got %v", pressed) |
| + } |
| + for _, p := range pressed { |
| + if (p[1] == "dark") != (p[2] == "true") { |
| + t.Errorf("%s pressed=%s, want only dark pressed", p[1], p[2]) |
| + } |
| + } |
| + if !strings.Contains(html, `role="group" aria-labelledby="estilo-appearance"`) { |
| + t.Error("the appearance options are not a labelled group") |
| + } |
| +} |
| + |
| +func TestSignedOutCornerAndEmptyCorner(t *testing.T) { |
| + b := Bar{App: App{Name: "Docs", Home: "/", Icon: "/i.svg"}, SignIn: &Link{Href: "/signin?return_to=%2Fd%2Fabc", Label: "Sign in"}} |
| + html := render(t, "estilo-bar", b) |
| + if !strings.Contains(html, `<a rst-btn href="/signin?return_to=%2Fd%2Fabc" estilo-bar-signin>Sign in</a>`) { |
| + t.Fatal("signed-out corner missing", html) |
| + } |
| + if strings.Contains(html, "estilo-account") || strings.Contains(html, "estilo-bar-more") { |
| + t.Fatal("signed-out bar has an account or Menu", html) |
| + } |
| + b.SignIn = nil |
| + if html = render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-signin") { |
| + t.Fatal("sign-in page bar shows Sign in", html) |
| + } |
| +} |
| + |
| +func TestPublicBrandIsPlainText(t *testing.T) { |
| + html := render(t, "estilo-bar", Bar{App: App{Name: "Docs", Icon: "/i.svg"}, Context: "A document"}) |
| + if strings.Contains(html, "<a estilo-bar-brand") || !strings.Contains(html, "<span estilo-bar-brand>") { |
| + t.Fatal("public brand is a link", html) |
| + } |
| + if !strings.Contains(html, `<span estilo-bar-context dir="auto" title="A document">A document</span>`) { |
| + t.Fatal("read-only context missing", html) |
| + } |
| +} |
| + |
| +func TestSwitcherWithoutTilesIsAllAppsOnly(t *testing.T) { |
| + b := signedIn("x") |
| + html := render(t, "estilo-bar", b) |
| + if strings.Contains(html, "estilo-switcher-grid") || strings.Contains(html, "estilo-switcher-app") { |
| + t.Fatal("a switcher with no tiles renders a grid") |
| + } |
| + b.Switcher.Apps = []SwitcherApp{{Name: "Docs", Href: "https://dir.example/apps/docs", Icon: "/d.svg", Current: true}, {Name: "Sheets", Href: "https://dir.example/apps/sheets", Icon: "/s.svg"}} |
| + b.Switcher.CurrentLabel = "(this app)" |
| + html = render(t, "estilo-bar", b) |
| + if !strings.Contains(html, `aria-current="true"`) || !strings.Contains(html, "(this app)") { |
| + t.Fatal("the current tile is not marked in words", html) |
| + } |
| + if strings.Count(html, "(this app)") != 1 { |
| + t.Fatal("more than one tile is current") |
| + } |
| +} |
| + |
| +func TestNoMenuTriggerWithoutNavOrActions(t *testing.T) { |
| + b := signedIn("x") |
| + b.Nav, b.Actions = nil, nil |
| + if html := render(t, "estilo-bar", b); strings.Contains(html, "estilo-bar-more") || strings.Contains(html, "estilo-bar-actions") { |
| + t.Fatal("an empty Menu is rendered", html) |
| + } |
| +} |
| + |
| +func TestUntrustedValuesAreEscaped(t *testing.T) { |
| + b := signedIn("x") |
| + b.ContextEdit.Value = `"><script>bad()</script>` |
| + b.Account.Address = `<img src=x onerror=bad>` |
| + b.Actions[0].Href = "javascript:bad()" |
| + html := render(t, "estilo-bar", b) |
| + if strings.Contains(html, "<script>") || strings.Contains(html, "<img src=x") || !strings.Contains(html, "#ZgotmplZ") { |
| + t.Fatal("unsafe bar markup", html) |
| + } |
| +} |
| + |
| +func TestErrorPage(t *testing.T) { |
| + for locale, label := range locales { |
| + t.Run(locale, func(t *testing.T) { |
| + html := render(t, "estilo-error", ErrorPage{Status: 500, Title: label + "-t", Body: label + "-b", Home: Link{Href: "/", Label: label + "-home"}, Back: &Link{Href: "/d/x", Label: label + "-back"}, Ref: "k3f9tq", RefLabel: label + "-ref"}) |
| + esc := template.HTMLEscapeString |
| + for _, want := range []string{"<p rst-error-status>500</p>", esc(label) + "-t", esc(label) + "-b", `<a rst-btn="primary" href="/">` + esc(label) + "-home", `<a rst-btn href="/d/x">` + esc(label) + "-back", esc(label) + "-ref k3f9tq"} { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("missing %q in %s", want, html) |
| + } |
| + } |
| + }) |
| + } |
| + if html := render(t, "estilo-error", ErrorPage{Status: 404, Title: "t", Home: Link{Href: "/", Label: "h"}}); strings.Contains(html, "rst-error-ref") || strings.Contains(html, "rst-btn href") { |
| + t.Fatal("a 404 with no ref and no checked referer shows either", html) |
| + } |
| +} |
| + |
| +func TestSignInPages(t *testing.T) { |
| + for locale, label := range locales { |
| + t.Run(locale, func(t *testing.T) { |
| + esc := template.HTMLEscapeString |
| + html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: label + "-h", Lede: label + "-lede", Error: label + "-err", Action: "/signin", Return: "/d/abc", Hidden: []Hidden{{"force", "1"}}, AddressLabel: label + "-addr", Help: label + "-help", Submit: label + "-submit", OpenSource: &Link{Href: "/credits", Label: label + "-os"}}) |
| + for _, want := range []string{"<h1>" + esc(label) + "-h</h1>", esc(label) + "-lede", `rst-tone="negative" role="alert"`, esc(label) + "-err", `action="/signin" novalidate`, `name="return_to" value="/d/abc"`, `name="force" value="1"`, `type="email"`, `autocomplete="email"`, esc(label) + "-addr", esc(label) + "-help", esc(label) + "-submit", esc(label) + "-os"} { |
| + if !strings.Contains(html, want) { |
| + t.Fatalf("missing %q in %s", want, html) |
| + } |
| + } |
| + html = render(t, "estilo-signin-sent", SignInNotice{Icon: "/i.svg", Heading: label + "-sent", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-again"}}) |
| + if !strings.Contains(html, esc(label)+"-sent") || !strings.Contains(html, `<a href="/signin">`+esc(label)+"-again") { |
| + t.Fatal("sent page", html) |
| + } |
| + html = render(t, "estilo-signed-out", SignInNotice{Icon: "/i.svg", Heading: label + "-out", Body: label + "-body", Link: &Link{Href: "/signin", Label: label + "-in"}}) |
| + if !strings.Contains(html, `<a rst-btn="primary" href="/signin">`+esc(label)+"-in") { |
| + t.Fatal("signed-out page", html) |
| + } |
| + }) |
| + } |
| + // An address error belongs in the field, not a callout above it. |
| + html := render(t, "estilo-signin", SignIn{Icon: "/i.svg", Heading: "h", Action: "/signin", AddressLabel: "a", Submit: "s", AddressError: "bad address"}) |
| + if strings.Contains(html, "rst-callout") || !strings.Contains(html, `rst-field-error id="estilo-signin-address-error" role="alert">bad address`) || !strings.Contains(html, `aria-invalid="true"`) { |
| + t.Fatal("address error not in the field slot", html) |
| + } |
| +} |
| + |
| +func TestInitials(t *testing.T) { |
| + cases := []struct{ name, address, want string }{ |
| + {"Paul Campbell", "paul@tito.io", "P"}, |
| + {"", "paul@tito.io", "P"}, |
| + {"", " émile@example.test", "É"}, |
| + {" ", "", ""}, |
| + {"", "@example.test", ""}, |
| + {"", "._@example.test", ""}, |
| + {"李小龙", "", "李"}, |
| + } |
| + for _, c := range cases { |
| + if got := Initials(c.name, c.address); got != c.want { |
| + t.Errorf("Initials(%q, %q) = %q, want %q", c.name, c.address, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +func TestAppearanceAndThemeAttr(t *testing.T) { |
| + for value, want := range map[string]Mode{"light": Light, "dark": Dark, "system": System, "": System, "auto": System, "Dark": System, "<x>": System} { |
| + r := httptest.NewRequest("GET", "/", nil) |
| + r.AddCookie(&http.Cookie{Name: AppearanceCookie, Value: value}) |
| + if got := Appearance(r); got != want { |
| + t.Errorf("cookie %q: Appearance = %q, want %q", value, got, want) |
| + } |
| + } |
| + if Appearance(httptest.NewRequest("GET", "/", nil)) != System { |
| + t.Error("no cookie is not System") |
| + } |
| + for m, want := range map[Mode]string{Light: "light", Dark: "dark", System: "", Mode("x"): ""} { |
| + if got := ThemeAttr(m); got != want { |
| + t.Errorf("ThemeAttr(%q) = %q, want %q", m, got, want) |
| + } |
| + } |
| +} |
| + |
| +func post(form url.Values) *httptest.ResponseRecorder { |
| + r := httptest.NewRequest("POST", "/appearance", strings.NewReader(form.Encode())) |
| + r.Header.Set("Content-Type", "application/x-www-form-urlencoded") |
| + w := httptest.NewRecorder() |
| + HandleAppearance(w, r) |
| + return w |
| +} |
| + |
| +func TestHandleAppearance(t *testing.T) { |
| + w := post(url.Values{"mode": {"dark"}, "return_to": {"/d/abc?x=1"}}) |
| + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/d/abc?x=1" { |
| + t.Fatalf("got %d %q", w.Code, w.Header().Get("Location")) |
| + } |
| + c := w.Result().Cookies() |
| + if len(c) != 1 || c[0].Name != AppearanceCookie || c[0].Value != "dark" || !c[0].Secure || !c[0].HttpOnly || c[0].SameSite != http.SameSiteLaxMode || c[0].Path != "/" || c[0].MaxAge < 364*24*3600 || c[0].Domain != "" { |
| + t.Fatalf("cookie %+v", c) |
| + } |
| + // return_to that leaves the site falls back to the root; SafeReturn's |
| + // control-character case is the one a prefix test misses. |
| + for _, bad := range []string{"https://evil.example/", "//evil.example/", "/\\evil.example", "/\t/evil.example", "evil", ""} { |
| + w := post(url.Values{"mode": {"light"}, "return_to": {bad}}) |
| + if w.Code != http.StatusSeeOther || w.Header().Get("Location") != "/" { |
| + t.Errorf("return_to %q: got %d %q", bad, w.Code, w.Header().Get("Location")) |
| + } |
| + } |
| + for _, bad := range []string{"auto", "", "DARK", "sepia"} { |
| + if w := post(url.Values{"mode": {bad}, "return_to": {"/"}}); w.Code != http.StatusBadRequest || len(w.Result().Cookies()) != 0 { |
| + t.Errorf("mode %q: got %d with cookies %v", bad, w.Code, w.Result().Cookies()) |
| + } |
| + } |
| + g := httptest.NewRecorder() |
| + HandleAppearance(g, httptest.NewRequest("GET", "/appearance?mode=dark", nil)) |
| + if g.Code != http.StatusMethodNotAllowed || g.Header().Get("Allow") != "POST" || len(g.Result().Cookies()) != 0 { |
| + t.Fatalf("GET: %d", g.Code) |
| + } |
| +} |
| + |
| +// chrome.js must stay a classic, dependency-free script. |
| +func TestScriptIsClassicAndSelfContained(t *testing.T) { |
| + src := string(JS()) |
| + if strings.Contains(src, "import ") || strings.Contains(src, "export ") { |
| + t.Fatal("chrome.js must be a classic script") |
| + } |
| + for _, want := range []string{"Escape", "estilo-bar-title-input", "defaultValue"} { |
| + if !strings.Contains(src, want) { |
| + t.Errorf("chrome.js lost %q", want) |
| + } |
| + } |
| +} |
diff --git a/chrome/templates/account.html b/chrome/templates/account.html| new file mode 100644 |
| index 0000000..ce0f461 |
| --- /dev/null |
| +++ b/chrome/templates/account.html |
| @@ -0,0 +1,40 @@ |
| +{{/* estilo-account: the account menu, the same in every app and always |
| + the last thing in the bar. Data: chrome.Account. |
| + |
| + Order: who is signed in (not interactive), the app's account-scoped |
| + items, Appearance, Help, then Sign out, which appears exactly once in |
| + the app. Appearance is a form of three buttons; the current one is |
| + aria-pressed="true" and every other "false", and the check glyph, not |
| + colour, shows which. */}} |
| +{{define "estilo-account"}}<details estilo-account name="estilo-menus"> |
| + <summary estilo-bar-trigger aria-label="{{.TriggerLabel}}"> |
| + {{- if .Initials}}<span estilo-avatar aria-hidden="true">{{.Initials}}</span>{{else}}{{template "estilo-icon-person"}}{{end -}} |
| + </summary> |
| + <div estilo-menu estilo-account-panel> |
| + <div estilo-menu-identity> |
| + <span estilo-menu-identity-label>{{.Labels.SignedInAs}}</span> |
| + {{- with .Name}}<span estilo-menu-identity-name dir="auto">{{.}}</span>{{end}} |
| + {{- with .Address}}<span estilo-menu-identity-address dir="auto">{{.}}</span>{{end}} |
| + </div> |
| + {{- if .Items}}<hr>{{range .Items}}{{template "estilo-menu-item" .}}{{end}}{{end}} |
| + {{- with .Appearance}} |
| + <hr> |
| + <p estilo-menu-heading id="estilo-appearance">{{$.Labels.Appearance}}</p> |
| + <form method="post" action="{{.Action}}" role="group" aria-labelledby="estilo-appearance"> |
| + <input type="hidden" name="return_to" value="{{.Return}}"> |
| + {{- range .Options}} |
| + <button estilo-menu-item type="submit" name="mode" value="{{.Value}}" aria-pressed="{{if .Current}}true{{else}}false{{end}}"><span estilo-menu-check aria-hidden="true">{{template "estilo-icon-check"}}</span><span estilo-menu-label>{{.Label}}</span></button> |
| + {{- end}} |
| + </form> |
| + {{- end}} |
| + {{- if .Help}} |
| + <hr> |
| + <p estilo-menu-heading>{{.Labels.Help}}</p> |
| + {{- range .Help}}{{template "estilo-menu-item" .}}{{end}} |
| + {{- end}} |
| + <hr> |
| + <form method="post" action="{{.SignOut.Action}}"> |
| + <button estilo-menu-item type="submit"><span estilo-menu-label>{{.SignOut.Label}}</span></button> |
| + </form> |
| + </div> |
| + </details>{{end}} |
diff --git a/chrome/templates/bar.html b/chrome/templates/bar.html| new file mode 100644 |
| index 0000000..bd90c76 |
| --- /dev/null |
| +++ b/chrome/templates/bar.html |
| @@ -0,0 +1,66 @@ |
| +{{/* estilo-bar: the whole app header, one row at every width. |
| + |
| + Data: chrome.Bar. Parse this package's templates together with |
| + menu.Templates() and the app's own layout. The skip link and <main> |
| + are the layout's; this is only the <header>. |
| + |
| + Order in the end group is fixed in every app: actions, then Menu |
| + (narrow only), then the switcher, then the account. The account is |
| + always the last thing at the inline end. |
| + |
| + The brand is a link to the app root, except on public read-only |
| + pages, where App.Home is empty and it is plain text. */}} |
| +{{define "estilo-bar"}}<header estilo-bar{{if .Sticky}}="sticky"{{end}}> |
| + {{- if .App.Home}} |
| + <a estilo-bar-brand href="{{.App.Home}}"><img estilo-brand-icon src="{{.App.Icon}}" alt="" width="28" height="28"><span estilo-bar-name>{{.App.Name}}</span></a> |
| + {{- else}} |
| + <span estilo-bar-brand><img estilo-brand-icon src="{{.App.Icon}}" alt="" width="28" height="28"><span estilo-bar-name>{{.App.Name}}</span></span> |
| + {{- end}} |
| + {{- with .ContextEdit}} |
| + {{template "estilo-bar-title" .}} |
| + {{- else}}{{with .Context}} |
| + <span estilo-bar-context dir="auto" title="{{.}}">{{.}}</span> |
| + {{- end}}{{end}} |
| + {{- if .Nav}} |
| + <nav estilo-bar-nav aria-label="{{.NavLabel}}"> |
| + {{- range .Nav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{.Label}}</a>{{end}} |
| + </nav> |
| + {{- end}} |
| + <div estilo-bar-end> |
| + {{- if .Actions}} |
| + <div estilo-bar-actions>{{template "estilo-bar-actions" .}}</div> |
| + {{- end}} |
| + {{- if or .Nav .Actions}} |
| + <details estilo-bar-more name="estilo-menus"> |
| + <summary estilo-bar-trigger>{{template "estilo-icon-menu"}}<span>{{.Labels.Menu}}</span></summary> |
| + <div estilo-menu="sheet"> |
| + {{- range .Nav}}<a estilo-menu-item href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}><span estilo-menu-label>{{.Label}}</span></a>{{end}} |
| + {{- if and .Nav .Actions}}<hr>{{end}} |
| + {{- range .Actions}}<a estilo-menu-item href="{{.Href}}"><span estilo-menu-label>{{.Label}}</span></a>{{end}} |
| + </div> |
| + </details> |
| + {{- end}} |
| + {{- with .Switcher}} |
| + {{template "estilo-switcher" .}} |
| + {{- end}} |
| + {{- if .Account}} |
| + {{template "estilo-account" .Account}} |
| + {{- else}}{{with .SignIn}} |
| + <a rst-btn href="{{.Href}}" estilo-bar-signin>{{.Label}}</a> |
| + {{- end}}{{end}} |
| + </div> |
| +</header>{{end}} |
| + |
| +{{/* estilo-bar-actions: the wide-screen actions, from Bar.Actions. The |
| + same links appear as rows in the narrow Menu sheet. An app may |
| + redefine this after parsing to change how they are drawn, but the |
| + sheet always renders Bar.Actions, so both stay one list. */}} |
| +{{define "estilo-bar-actions"}}{{range .Actions}}<a rst-btn href="{{.Href}}">{{.Label}}</a>{{end}}{{end}} |
| + |
| +{{/* estilo-bar-title: the open object's title, editable in place. A real |
| + form, so Enter renames with scripts off. The submit button is visually |
| + hidden until the form has focus. Data: chrome.TitleEdit. */}} |
| +{{define "estilo-bar-title"}}<form estilo-bar-title method="post" action="{{.Action}}"> |
| + <input estilo-bar-title-input name="{{.Name}}" value="{{.Value}}" aria-label="{{.Label}}" autocomplete="off" spellcheck="false" dir="auto" maxlength="1024"> |
| + <button rst-btn="ghost" type="submit" estilo-bar-title-submit>{{.Submit}}</button> |
| + </form>{{end}} |
diff --git a/chrome/templates/error.html b/chrome/templates/error.html| new file mode 100644 |
| index 0000000..8c684f6 |
| --- /dev/null |
| +++ b/chrome/templates/error.html |
| @@ -0,0 +1,14 @@ |
| +{{/* estilo-error: the body of an error page, inside the app's normal |
| + layout and bar. It emits rastrillo's rst-error markup, which is already |
| + styled; it exists because the caller must name the way back. |
| + Data: chrome.ErrorPage. */}} |
| +{{define "estilo-error"}}<div rst-error> |
| + <p rst-error-status>{{.Status}}</p> |
| + <h1 rst-error-title>{{.Title}}</h1> |
| + <p rst-error-body>{{.Body}}</p> |
| + <p rst-error-cta><a rst-btn="primary" href="{{.Home.Href}}">{{.Home.Label}}</a> |
| + {{- with .Back}} <a rst-btn href="{{.Href}}">{{.Label}}</a>{{end}}</p> |
| + {{- with .Ref}} |
| + <p class="rst-mono" rst-error-ref dir="auto">{{$.RefLabel}} {{.}}</p> |
| + {{- end}} |
| +</div>{{end}} |
diff --git a/chrome/templates/icons.html b/chrome/templates/icons.html| new file mode 100644 |
| index 0000000..c2a8762 |
| --- /dev/null |
| +++ b/chrome/templates/icons.html |
| @@ -0,0 +1,6 @@ |
| +{{/* The bar's glyphs. Lucide "menu", "user" and "layout-grid" (ISC, |
| + brand/LICENSE.lucide). Decorative: every trigger that carries one has |
| + a text label or an aria-label of its own. */}} |
| +{{define "estilo-icon-menu"}}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M4 12h16M4 6h16M4 18h16"/></svg>{{end}} |
| +{{define "estilo-icon-person"}}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>{{end}} |
| +{{define "estilo-icon-grid"}}<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>{{end}} |
diff --git a/chrome/templates/signin.html b/chrome/templates/signin.html| new file mode 100644 |
| index 0000000..689cab0 |
| --- /dev/null |
| +++ b/chrome/templates/signin.html |
| @@ -0,0 +1,53 @@ |
| +{{/* The sign-in pages: the form, the check-your-email state and the |
| + signed-out page. Each is the body of a page in the app's normal |
| + layout, whose bar shows the brand only (no account, no Sign in). |
| + |
| + estilo-signin uses rastrillo's "field" and "callout" partials, so parse |
| + ui.Templates() with ui.Funcs() alongside this package. The form sets |
| + novalidate: an address error goes in the field's own error slot, never |
| + the browser's bubble. Data: chrome.SignIn. */}} |
| +{{define "estilo-signin"}}<section estilo-signin> |
| + <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40"> |
| + <h1>{{.Heading}}</h1> |
| + {{- with .Lede}} |
| + <p estilo-signin-lede>{{.}}</p> |
| + {{- end}} |
| + {{- with .Error}} |
| + {{template "callout" (dict "Body" . "Tone" "negative" "Alert" true)}} |
| + {{- end}} |
| + <form rst-form estilo-signin-form method="post" action="{{.Action}}" novalidate> |
| + {{- with .Return}} |
| + <input type="hidden" name="return_to" value="{{.}}"> |
| + {{- end}} |
| + {{- range .Hidden}} |
| + <input type="hidden" name="{{.Name}}" value="{{.Value}}"> |
| + {{- end}} |
| + {{template "field" (dict "ID" "estilo-signin-address" "Name" "address" "Type" "email" "Label" .AddressLabel "Autocomplete" "email" "Required" true "Value" .Address "Help" .Help "Error" .AddressError)}} |
| + <div rst-form-foot><button rst-btn="primary" type="submit">{{.Submit}}</button></div> |
| + </form> |
| + {{- with .OpenSource}} |
| + <p estilo-signin-foot><a href="{{.Href}}">{{.Label}}</a></p> |
| + {{- end}} |
| +</section>{{end}} |
| + |
| +{{/* estilo-signin-sent: replaces the form once a link has been sent. Link |
| + goes back to the form, keeping the return. Data: chrome.SignInNotice. */}} |
| +{{define "estilo-signin-sent"}}<section estilo-signin> |
| + <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40"> |
| + <h1>{{.Heading}}</h1> |
| + <p estilo-signin-body role="status">{{.Body}}</p> |
| + {{- with .Link}} |
| + <p><a href="{{.Href}}">{{.Label}}</a></p> |
| + {{- end}} |
| +</section>{{end}} |
| + |
| +{{/* estilo-signed-out: where Sign out lands. Link is the primary |
| + "Sign in". Data: chrome.SignInNotice. */}} |
| +{{define "estilo-signed-out"}}<section estilo-signin> |
| + <img estilo-brand-icon="listing" src="{{.Icon}}" alt="" width="40" height="40"> |
| + <h1>{{.Heading}}</h1> |
| + <p estilo-signin-body>{{.Body}}</p> |
| + {{- with .Link}} |
| + <p><a rst-btn="primary" href="{{.Href}}">{{.Label}}</a></p> |
| + {{- end}} |
| +</section>{{end}} |
diff --git a/chrome/templates/switcher.html b/chrome/templates/switcher.html| new file mode 100644 |
| index 0000000..9ffce27 |
| --- /dev/null |
| +++ b/chrome/templates/switcher.html |
| @@ -0,0 +1,18 @@ |
| +{{/* estilo-switcher: the Oficina apps, then "All apps". Optional: it is |
| + rendered only when Bar.Switcher is set, and an app with no hub |
| + directory leaves it nil. Tiles are optional too; with none, the panel |
| + is the "All apps" link alone. Data: chrome.Switcher. */}} |
| +{{define "estilo-switcher"}}<details estilo-switcher name="estilo-menus"> |
| + <summary estilo-bar-trigger aria-label="{{.Label}}">{{template "estilo-icon-grid"}}</summary> |
| + <div estilo-menu estilo-switcher-panel> |
| + {{- if .Apps}} |
| + <div estilo-switcher-grid> |
| + {{- range .Apps}} |
| + <a estilo-switcher-app href="{{.Href}}"{{if .Current}} aria-current="true"{{end}}><img src="{{.Icon}}" alt="" width="32" height="32"><span>{{.Name}}{{if .Current}}<span estilo-visually-hidden> {{$.CurrentLabel}}</span>{{end}}</span></a> |
| + {{- end}} |
| + </div> |
| + <hr> |
| + {{- end}} |
| + {{template "estilo-menu-item" .AllApps}} |
| + </div> |
| + </details>{{end}} |
diff --git a/go.mod b/go.mod| index e351b3f..3896316 100644 |
| --- a/go.mod |
| +++ b/go.mod |
| @@ -7,10 +7,15 @@ require github.com/carlosframework/rastrillo v0.23.0 |
| require ( |
| github.com/dustin/go-humanize v1.0.1 // indirect |
| github.com/google/uuid v1.6.0 // indirect |
| + github.com/jinzhu/inflection v1.0.0 // indirect |
| + github.com/jinzhu/now v1.1.5 // indirect |
| github.com/mattn/go-isatty v0.0.20 // indirect |
| github.com/ncruces/go-strftime v1.0.0 // indirect |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect |
| golang.org/x/sys v0.46.0 // indirect |
| + golang.org/x/text v0.20.0 // indirect |
| + gorm.io/gorm v1.31.2 // indirect |
| + gorm.io/plugin/dbresolver v1.6.2 // indirect |
| modernc.org/libc v1.74.1 // indirect |
| modernc.org/mathutil v1.7.1 // indirect |
| modernc.org/memory v1.11.0 // indirect |
diff --git a/go.sum b/go.sum| index 83f37f8..be1533c 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,3 @@ |
| -github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8= |
| -github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
| github.com/carlosframework/rastrillo v0.23.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw= |
| github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E= |
| @@ -12,6 +10,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp |
| github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs= |
| github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M= |
| +github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc= |
| +github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= |
| github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= |
| github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= |
| github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= |
| @@ -24,8 +24,16 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= |
| github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= |
| github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= |
| github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= |
| +github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= |
| +github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= |
| +github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= |
| +github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= |
| +github.com/keymaildev/signin v0.1.1 h1:gO+1IAM99sqUkBtCezkgGxab+/DOrqwDxd5H32N1s9Q= |
| +github.com/keymaildev/signin v0.1.1/go.mod h1:Eb/sCmEel1jlcdkgPOrNeMn5jvxzoFvJrdjDUxOBHls= |
| github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= |
| github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= |
| +github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= |
| +github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= |
| github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= |
| github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= |
| github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= |
| @@ -37,8 +45,18 @@ golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= |
| golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= |
| golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= |
| golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= |
| +golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= |
| +golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= |
| golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= |
| golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= |
| +gorm.io/driver/mysql v1.5.7 h1:MndhOPYOfEp2rHKgkZIhJ16eVUIRf2HmzgoPmh7FCWo= |
| +gorm.io/driver/mysql v1.5.7/go.mod h1:sEtPWMiqiN1N1cMXoXmBbd8C6/l+TESwriotuRRpkDM= |
| +gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ= |
| +gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8= |
| +gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo= |
| +gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= |
| +gorm.io/plugin/dbresolver v1.6.2 h1:F4b85TenghUeITqe3+epPSUtHH7RIk3fXr5l83DF8Pc= |
| +gorm.io/plugin/dbresolver v1.6.2/go.mod h1:tctw63jdrOezFR9HmrKnPkmig3m5Edem9fdxk9bQSzM= |
| modernc.org/cc/v4 v4.29.0 h1:CXgwL8cvxmyzBQZzbSl/6xFtMCryb6u8IOqDci39cgc= |
| modernc.org/cc/v4 v4.29.0/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI= |
| modernc.org/ccgo/v4 v4.34.6 h1:sBgfIwyN0TQ9C5hwIeuqyeAKyMWnbvj2fvpF4L11uzU= |