oficina / estilo Public

Add the shared chrome components (base, menu, chrome, brand)

base: zero-specificity link rule. menu: one floating panel (9px, inset
rows, check and shortcut columns), a square menubar, and platform
shortcut labels. chrome: the 52px bar with an editable-title slot
(Rename floats under the title below 800px), the account menu with
per-app Appearance, the 'All apps' switcher, the error page body, and
the sign-in/sent/signed-out templates. brand: app icons served at
/static/estilo/brand/. Theme unchanged (byte-identical to day).
First adopter: Docs. Reviewed by GPT-6-Astra via Codex (#10); fix delta
checked by the coordinating session, with a model re-check pending.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Squashed from feat/r2-chrome-components into main.
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 76dbe9bcb69cb7dd9fb12c60215a8febe1b109fe parent bd5cd6d
23 files changed, +1783 −3
  • README.md +11 −1
  • base/base.css +15 −0
  • base/base.go +15 −0
  • base/base_test.go +36 −0
  • brand/brand.go +71 −0
  • brand/brand_test.go +50 −0
  • chrome/chrome.css +201 −0
  • chrome/chrome.go +299 −0
  • chrome/chrome.js +37 −0
  • chrome/chrome_test.go +376 −0
  • chrome/templates/account.html +40 −0
  • chrome/templates/bar.html +66 −0
  • chrome/templates/error.html +14 −0
  • chrome/templates/icons.html +6 −0
  • chrome/templates/signin.html +53 −0
  • chrome/templates/switcher.html +18 −0
  • go.mod +5 −0
  • go.sum +20 −2
  • internal/tokencheck/tokencheck.go +35 −0
  • menu/menu.css +143 −0
  • menu/menu.go +129 −0
  • menu/menu_test.go +135 −0
  • menu/templates/menu.html +8 −0

CI failed — run details

CI log
=== vet ===
CGO_ENABLED=0 go vet ./...
../../cache/go-mod/github.com/carlosframework/rastrillo@v0.23.0/sessions/sessions.go:72:12: pattern migrations/*.sql: no matching files found
make: *** [Makefile:22: test-vet] Error 1

amadan: step vet failed: exit status 2

amadan: job failed at step vet — its output is above, not at the tail of this log
diff --git a/README.md b/README.md
index 96db7ba..302947e 100644
--- a/README.md
+++ b/README.md
@@ -18,7 +18,17 @@ apps. It carries no authentication logic — callers validate the
destination and supply translated labels. The screens run without
scripts.
-App shells and permission/session logic stay app-owned. There is no
+`base`, `menu` and `chrome` are the shared chrome from the round-2 spec
+(`docs/superpowers/specs/2026-09-23-shared-chrome.md` on
+`spec/shared-chrome`): link styling; the one floating menu panel and the
+document menu bar, with `menu.Shortcut` for platform-correct shortcut
+labels; and the top bar, account menu, app switcher, error page and
+sign-in pages, with the per-app Appearance preference. Link the three
+stylesheets in that order, after `tokens.css`, the theme and
+`brand.css`. `brand` serves the app icons from Go. Docs adopts first and
+Sheets second; until Sheets has, they are a home, not an agreed interface.
+
+Permission and session logic stay app-owned. There is no
shared component gallery yet.
## Building
diff --git a/base/base.css b/base/base.css
new file mode 100644
index 0000000..9873e71
--- /dev/null
+++ b/base/base.css
@@ -0,0 +1,15 @@
+/* base/base.css: links. Zero specificity on purpose: any component rule wins.
+ Prose links keep their underline, because colour must never carry meaning
+ alone. Chrome links (bar, nav, menus, rows, buttons) set their own colour
+ and text-decoration, and every one of them outranks :where(). */
+:where(a:any-link) {
+ color: var(--rst-accent);
+ text-decoration-line: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.15em;
+}
+:where(a:any-link:hover) { text-decoration-thickness: 2px; }
+:where(a:any-link:focus-visible) {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 2px;
+}
diff --git a/base/base.go b/base/base.go
new file mode 100644
index 0000000..1c58c00
--- /dev/null
+++ b/base/base.go
@@ -0,0 +1,15 @@
+// Package base is the suite's base element styling: today, links. It is
+// the one stylesheet every Oficina app links before any component, and
+// it carries zero specificity so that every rastrillo and estilo
+// component rule still wins without a fight. It contains no strings and
+// no script.
+package base
+
+import _ "embed"
+
+//go:embed base.css
+var css []byte
+
+// CSS returns base.css. Link it after the theme and brand.css and before
+// menu.css and chrome.css.
+func CSS() []byte { return append([]byte(nil), css...) }
diff --git a/base/base_test.go b/base/base_test.go
new file mode 100644
index 0000000..b089153
--- /dev/null
+++ b/base/base_test.go
@@ -0,0 +1,36 @@
+package base
+
+import (
+ "regexp"
+ "testing"
+
+ "amadan.net/oficina/estilo.git/internal/tokencheck"
+)
+
+func TestBaseUsesDefinedTokens(t *testing.T) {
+ missing, n := tokencheck.Undefined(CSS())
+ if n == 0 {
+ t.Fatal("base.css reads no rastrillo token; the check would pass vacuously")
+ }
+ if len(missing) > 0 {
+ t.Fatal("base.css reads undefined tokens:", missing)
+ }
+}
+
+// Every selector in base.css must be wrapped in :where(), so that it has
+// zero specificity and cannot outrank a component's own link colour.
+// A bare "a { color }" here would repaint every menu item, row title and
+// button in the suite.
+func TestBaseRulesHaveZeroSpecificity(t *testing.T) {
+ src := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAll(CSS(), nil)
+ selectors := regexp.MustCompile(`(?m)^([^{}]+)\{`).FindAllSubmatch(src, -1)
+ if len(selectors) == 0 {
+ t.Fatal("no rules found in base.css")
+ }
+ for _, m := range selectors {
+ sel := string(m[1])
+ if !regexp.MustCompile(`^\s*:where\([^)]*\)\s*$`).MatchString(sel) {
+ t.Errorf("selector %q is not a bare :where(...) and has specificity", sel)
+ }
+ }
+}
diff --git a/brand/brand.go b/brand/brand.go
new file mode 100644
index 0000000..211861a
--- /dev/null
+++ b/brand/brand.go
@@ -0,0 +1,71 @@
+// Package brand serves the suite's app icons from Go, so a page can link
+// any sibling app's icon (the app switcher) without vendoring the file.
+// The artwork itself is brand/svg/, unchanged; see README.md.
+package brand
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+)
+
+//go:embed svg/*.svg
+var svgFS embed.FS
+
+// Prefix is the URL path Handler serves under.
+const Prefix = "/static/estilo/brand/"
+
+// files maps a public slug to its master. Calls keeps meet.svg: the
+// rename to Calls left the artwork's file name alone.
+var files = map[string]string{
+ "oficina": "oficina.svg",
+ "docs": "docs.svg",
+ "sheets": "sheets.svg",
+ "slides": "slides.svg",
+ "calendar": "calendar.svg",
+ "calls": "meet.svg",
+ "meet": "meet.svg",
+ "memoria": "memoria.svg",
+}
+
+// SVG returns the icon for slug, or nil for an unknown slug.
+func SVG(slug string) []byte {
+ name, ok := files[slug]
+ if !ok {
+ return nil
+ }
+ body, err := svgFS.ReadFile("svg/" + name)
+ if err != nil {
+ return nil
+ }
+ return body
+}
+
+// Path is the URL Handler serves slug's icon at.
+func Path(slug string) string { return Prefix + slug + ".svg" }
+
+// Handler serves GET and HEAD for Prefix + "<slug>.svg". Mount it at
+// Prefix. Anything else is 404; the icons are public artwork and need no
+// session.
+func Handler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ slug, ok := strings.CutSuffix(strings.TrimPrefix(r.URL.Path, Prefix), ".svg")
+ body := SVG(slug)
+ if !ok || body == nil || !strings.HasPrefix(r.URL.Path, Prefix) {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", "image/svg+xml")
+ w.Header().Set("Cache-Control", "public, max-age=86400")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(body)
+ })
+}
diff --git a/brand/brand_test.go b/brand/brand_test.go
new file mode 100644
index 0000000..d392e9e
--- /dev/null
+++ b/brand/brand_test.go
@@ -0,0 +1,50 @@
+package brand
+
+import (
+ "bytes"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+)
+
+func TestEveryAppHasAnIcon(t *testing.T) {
+ for _, slug := range []string{"oficina", "docs", "sheets", "slides", "calendar", "calls", "memoria"} {
+ body := SVG(slug)
+ if !bytes.HasPrefix(bytes.TrimSpace(body), []byte("<svg")) {
+ t.Errorf("%s: not an svg", slug)
+ }
+ }
+ calls, _ := os.ReadFile("svg/meet.svg")
+ if !bytes.Equal(SVG("calls"), calls) {
+ t.Error("calls is not meet.svg")
+ }
+ if SVG("../go.mod") != nil || SVG("") != nil || SVG("home") != nil {
+ t.Error("an unknown slug returned artwork")
+ }
+}
+
+func TestHandler(t *testing.T) {
+ h := Handler()
+ get := func(method, path string) *httptest.ResponseRecorder {
+ w := httptest.NewRecorder()
+ h.ServeHTTP(w, httptest.NewRequest(method, path, nil))
+ return w
+ }
+ w := get("GET", Path("docs"))
+ docs, _ := os.ReadFile("svg/docs.svg")
+ if w.Code != 200 || w.Header().Get("Content-Type") != "image/svg+xml" || w.Header().Get("X-Content-Type-Options") != "nosniff" || !bytes.Equal(w.Body.Bytes(), docs) {
+ t.Fatalf("GET docs: %d %q", w.Code, w.Header())
+ }
+ if w := get("HEAD", Path("sheets")); w.Code != 200 || w.Body.Len() != 0 {
+ t.Fatalf("HEAD: %d %d", w.Code, w.Body.Len())
+ }
+ for _, p := range []string{Prefix + "docs", Prefix + "nope.svg", Prefix + "../brand.css", "/elsewhere/docs.svg", Prefix} {
+ if w := get("GET", p); w.Code != http.StatusNotFound {
+ t.Errorf("%s: %d", p, w.Code)
+ }
+ }
+ if w := get("POST", Path("docs")); w.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST: %d", w.Code)
+ }
+}
diff --git a/chrome/chrome.css b/chrome/chrome.css
new file mode 100644
index 0000000..d40baff
--- /dev/null
+++ b/chrome/chrome.css
@@ -0,0 +1,201 @@
+/* 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; }
+/* Below the shell breakpoint the row has no room for the button beside the
+ field: it would sit under Menu or the switcher, and a click at its centre
+ would open them instead (found in the first review, at 390px and 320px).
+ So the field takes whatever width is left, down to nothing but its own
+ padding, and on focus the button floats just under it, over the page,
+ where nothing in the bar competes for it. Enter renames at every width. */
+@media (max-width: 799.98px) {
+ [estilo-bar-title] { position: relative; }
+ [estilo-bar-title-input] { min-inline-size: 0; }
+ [estilo-bar-title]:focus-within [estilo-bar-title-submit] {
+ position: absolute;
+ inset-block-start: calc(100% + 0.5rem);
+ inset-inline-start: var(--rst-sp-3);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius, 9px); /* a float */
+ background: var(--rst-surface);
+ box-shadow: var(--rst-shadow-pop);
+ }
+}
+
+/* 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..c78bdd1
--- /dev/null
+++ b/chrome/chrome_test.go
@@ -0,0 +1,376 @@
+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)
+ }
+ }
+ // Narrow: the title field may shrink to nothing and the Rename button
+ // floats under it rather than taking room from Menu and the switcher.
+ // The browser proof is polish-audit/r2-chrome-check.mjs's reach checks.
+ narrow := src[strings.LastIndex(src, "@media (max-width: 799.98px)"):]
+ for _, want := range []string{"[estilo-bar-title-input] { min-inline-size: 0; }", "[estilo-bar-title]:focus-within [estilo-bar-title-submit] {\n position: absolute;"} {
+ if !strings.Contains(narrow, want) {
+ t.Errorf("the narrow title layout 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..4ac79e8
--- /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">
+ <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=
diff --git a/internal/tokencheck/tokencheck.go b/internal/tokencheck/tokencheck.go
new file mode 100644
index 0000000..1ab8de4
--- /dev/null
+++ b/internal/tokencheck/tokencheck.go
@@ -0,0 +1,35 @@
+// Package tokencheck is the shared half of every component's
+// "...UseDefinedTokens" test: each var(--rst-*) a component stylesheet
+// reads must be defined by the pinned rastrillo's tokens.css plus
+// estilo's theme. AGENTS.md's two-pin case, for components.
+package tokencheck
+
+import (
+ "regexp"
+
+ "amadan.net/oficina/estilo.git/theme"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+var (
+ definition = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`)
+ reference = regexp.MustCompile(`var\(\s*(--rst-[a-z0-9-]+)`)
+)
+
+// Undefined returns the rastrillo tokens css reads that neither
+// ui.TokensCSS() nor theme.CSS() defines, and how many references it
+// read in total, so a caller can refuse a stylesheet that reads none
+// (a check over nothing passes vacuously).
+func Undefined(css []byte) (missing []string, references int) {
+ defined := map[string]bool{}
+ for _, m := range definition.FindAllSubmatch(append(append([]byte{}, ui.TokensCSS()...), theme.CSS()...), -1) {
+ defined[string(m[1])] = true
+ }
+ for _, m := range reference.FindAllSubmatch(css, -1) {
+ references++
+ if !defined[string(m[1])] {
+ missing = append(missing, string(m[1]))
+ }
+ }
+ return missing, references
+}
diff --git a/menu/menu.css b/menu/menu.css
new file mode 100644
index 0000000..2ddf029
--- /dev/null
+++ b/menu/menu.css
@@ -0,0 +1,143 @@
+/* menu/menu.css: the one floating panel, and the menu bar it hangs from.
+ Every floating surface in the suite uses [estilo-menu]: the account
+ menu, the switcher, the narrow Menu sheet and every menubar menu.
+ A float is rounded and so is a highlight inside it; a menubar title is
+ in the page flow, so it is square (the suite's square rule). */
+:root {
+ --estilo-float-radius: 9px; /* rastrillo's hard-coded dropdown value, named. PUBLIC */
+ --estilo-float-padding: 0.25rem;
+ --estilo-item-padding-block: 0.4rem;
+ --estilo-item-padding-inline: 0.65rem;
+}
+[estilo-menu] {
+ box-sizing: border-box;
+ /* Sized to its rows, not to what it hangs from. Every panel is absolutely
+ positioned inside a <details> no wider than its title, and an absolute
+ box's shrink-to-fit width is capped by that, so without this a row with
+ a shortcut wrapped ("Decrease / indent") inside a menu with room to
+ spare. Docs found it on first adoption. */
+ inline-size: max-content;
+ min-inline-size: 12rem;
+ max-inline-size: min(22rem, calc(100vw - 1rem));
+ /* The 8rem floor is for short frames, where the viewport maths alone can
+ leave a panel a few pixels tall: an overflowing menu can be scrolled,
+ an invisible one cannot. */
+ max-block-size: max(calc(100dvh - var(--estilo-bar-block-size, 3.25rem) - 1rem), 8rem);
+ overflow-y: auto;
+ overscroll-behavior: contain;
+ padding: var(--estilo-float-padding);
+ background: var(--rst-surface);
+ color: var(--rst-text);
+ border: 1px solid var(--rst-line);
+ border-radius: var(--estilo-float-radius);
+ box-shadow: var(--rst-shadow-pop);
+ font-size: var(--rst-fs-sm);
+ text-align: start;
+ z-index: 30;
+}
+[estilo-menu~="sheet"] {
+ position: absolute;
+ inset-inline: 0.5rem;
+ inset-block-start: calc(100% + 4px);
+ inline-size: auto;
+ max-inline-size: none;
+}
+[estilo-menu] form { margin: 0; }
+[estilo-menu-item] {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ box-sizing: border-box;
+ inline-size: 100%;
+ min-block-size: 2rem;
+ padding: var(--estilo-item-padding-block) var(--estilo-item-padding-inline);
+ border: 0;
+ border-radius: var(--rst-radius-sm); /* inset and rounded: inside a float */
+ background: none;
+ color: var(--rst-text);
+ font: inherit;
+ text-align: start;
+ text-decoration: none;
+ cursor: pointer;
+}
+[estilo-menu-item]:hover,
+[estilo-menu-item]:focus-visible { background: var(--rst-accent-soft); }
+[estilo-menu-item]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+[estilo-menu-item][aria-disabled="true"],
+[estilo-menu-item]:disabled {
+ color: var(--rst-text-faint);
+ background: none;
+ cursor: default;
+}
+[estilo-menu-check] {
+ display: inline-flex;
+ flex: none;
+ inline-size: 1rem;
+ block-size: 1rem;
+ visibility: hidden;
+ color: var(--rst-accent);
+}
+[estilo-menu-check] svg { inline-size: 100%; block-size: 100%; }
+[estilo-menu-item][aria-pressed="true"] [estilo-menu-check],
+[estilo-menu-item][aria-current] [estilo-menu-check] { visibility: visible; }
+[estilo-menu-label] { flex: 1 1 auto; min-inline-size: 0; }
+[estilo-menu-key] {
+ flex: none;
+ margin-inline-start: auto;
+ padding-inline-start: 1.5rem;
+ color: var(--rst-text-muted);
+ font: inherit;
+ font-size: var(--rst-fs-xs);
+ font-variant-numeric: tabular-nums;
+ white-space: nowrap;
+}
+[estilo-menu] hr {
+ border: 0;
+ border-block-start: 1px solid var(--rst-line);
+ margin: 0.25rem 0;
+}
+[estilo-menu-heading] {
+ margin: 0;
+ padding: 0.5rem var(--estilo-item-padding-inline) 0.25rem;
+ color: var(--rst-text-muted);
+ font-size: var(--rst-fs-xs);
+ font-weight: 600; /* no text-transform */
+}
+
+/* The document menu bar: File, Edit, View... Titles are in the page flow,
+ so their highlight is square. Every title joins the one top-level
+ exclusivity group, name="estilo-menus", with the bar's own triggers. */
+[estilo-menubar] { display: flex; flex-wrap: wrap; align-items: stretch; min-block-size: 2rem; }
+[estilo-menubar-menu] { position: relative; }
+[estilo-menubar-menu] > [estilo-menu] {
+ position: absolute;
+ inset-inline-start: 0;
+ inset-block-start: calc(100% + 2px);
+}
+[estilo-menubar-title] {
+ display: flex;
+ align-items: center;
+ box-sizing: border-box;
+ block-size: 100%;
+ min-block-size: 2rem;
+ padding-inline: 0.6rem;
+ border-radius: 0; /* square: in-flow */
+ color: var(--rst-text);
+ font-size: var(--rst-fs-sm);
+ white-space: nowrap;
+ list-style: none;
+ cursor: default;
+ user-select: none;
+}
+[estilo-menubar-title]::-webkit-details-marker { display: none; }
+[estilo-menubar-title]::marker { content: ""; }
+[estilo-menubar-title]:hover { background: var(--rst-surface-2); }
+[open] > [estilo-menubar-title] { background: var(--rst-accent-soft); }
+[estilo-menubar-title]:focus-visible { outline: 2px solid var(--rst-accent); outline-offset: -2px; }
+/* A panel hung from a title near the inline end overflows a phone, so
+ below 30rem every menubar panel spans the viewport instead. */
+@media (max-width: 30rem) {
+ [estilo-menubar-menu] { position: static; }
+ [estilo-menubar] { position: relative; }
+ [estilo-menubar-menu] > [estilo-menu] { inset-inline: 0.5rem; inline-size: auto; min-inline-size: 0; max-inline-size: none; }
+}
diff --git a/menu/menu.go b/menu/menu.go
new file mode 100644
index 0000000..59a78c6
--- /dev/null
+++ b/menu/menu.go
@@ -0,0 +1,129 @@
+// Package menu is the suite's one floating panel ([estilo-menu]), its
+// rows ([estilo-menu-item]) and the document menu bar
+// ([estilo-menubar]). Docs is its first consumer.
+//
+// Scripts off, a menu is a <details>: it opens, and its links and form
+// buttons work; it closes only when its summary is clicked again. Script
+// may add Escape, outside-click close and menubar hover-switching; none
+// of it is required.
+//
+// It carries no strings. Callers supply every label in their locale.
+package menu
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+)
+
+//go:embed menu.css
+var css []byte
+
+//go:embed templates/*.html
+var templateFS embed.FS
+
+// CSS returns menu.css. Link it after base.css and before chrome.css.
+func CSS() []byte { return append([]byte(nil), css...) }
+
+// Templates holds estilo-menu-item and estilo-icon-check. Parse it into
+// the app's page templates with ParseFS(Templates(), "templates/*.html").
+func Templates() embed.FS { return templateFS }
+
+// Item is one link row rendered by estilo-menu-item.
+type Item struct {
+ Href, Label string
+ // Key is an already-formatted shortcut, from Shortcut. Optional.
+ Key string
+}
+
+// Apple reports whether the request comes from an Apple platform, the
+// server-side choice behind Shortcut: Sec-CH-UA-Platform "macOS" or
+// "iOS", or a User-Agent naming a Macintosh, iPhone or iPad. A response
+// whose markup depends on it must be private or no-store, or say
+// Vary: User-Agent.
+func Apple(r *http.Request) bool {
+ switch strings.Trim(r.Header.Get("Sec-CH-UA-Platform"), `"`) {
+ case "macOS", "iOS":
+ return true
+ }
+ ua := r.UserAgent()
+ return strings.Contains(ua, "Macintosh") || strings.Contains(ua, "iPhone") || strings.Contains(ua, "iPad")
+}
+
+// Shortcut formats a key chord for the request's platform, so a label
+// is right in the first paint and nothing flips once script runs.
+//
+// keys is written in one platform-neutral spelling, parts joined by
+// "+": Mod (Command on Apple, Ctrl elsewhere), Shift, Alt, Ctrl (the
+// literal Control key), then one key. Enter, Delete, Backspace, Tab and
+// Esc are named; anything else is printed as given. Apple chords print
+// their modifiers in the order ⌃⌥⇧⌘ with no separator (⇧⌘K); other
+// platforms join words with "+" in the order Ctrl, Alt, Shift
+// (Ctrl+Shift+K).
+func Shortcut(r *http.Request, keys string) string {
+ return format(Apple(r), keys)
+}
+
+var named = map[string][2]string{
+ // name: {apple, other}
+ "Enter": {"↩", "Enter"},
+ "Delete": {"⌫", "Delete"},
+ "Backspace": {"⌫", "Backspace"},
+ "Tab": {"⇥", "Tab"},
+ "Esc": {"⎋", "Esc"},
+}
+
+func format(apple bool, keys string) string {
+ var ctrl, alt, shift, cmd bool
+ key := ""
+ for _, part := range strings.Split(keys, "+") {
+ switch part {
+ case "Mod":
+ if apple {
+ cmd = true
+ } else {
+ ctrl = true
+ }
+ case "Ctrl":
+ ctrl = true
+ case "Alt":
+ alt = true
+ case "Shift":
+ shift = true
+ default:
+ key = part
+ }
+ }
+ if n, ok := named[key]; ok {
+ if apple {
+ key = n[0]
+ } else {
+ key = n[1]
+ }
+ }
+ if apple {
+ var b strings.Builder
+ for _, m := range []struct {
+ on bool
+ glyph string
+ }{{ctrl, "⌃"}, {alt, "⌥"}, {shift, "⇧"}, {cmd, "⌘"}} {
+ if m.on {
+ b.WriteString(m.glyph)
+ }
+ }
+ return b.String() + key
+ }
+ var parts []string
+ for _, m := range []struct {
+ on bool
+ word string
+ }{{ctrl, "Ctrl"}, {alt, "Alt"}, {shift, "Shift"}} {
+ if m.on {
+ parts = append(parts, m.word)
+ }
+ }
+ if key != "" {
+ parts = append(parts, key)
+ }
+ return strings.Join(parts, "+")
+}
diff --git a/menu/menu_test.go b/menu/menu_test.go
new file mode 100644
index 0000000..4af67e2
--- /dev/null
+++ b/menu/menu_test.go
@@ -0,0 +1,135 @@
+package menu
+
+import (
+ "bytes"
+ "html/template"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/estilo.git/internal/tokencheck"
+)
+
+func TestMenuUsesDefinedTokens(t *testing.T) {
+ missing, n := tokencheck.Undefined(CSS())
+ if n == 0 {
+ t.Fatal("menu.css reads no rastrillo token; the check would pass vacuously")
+ }
+ if len(missing) > 0 {
+ t.Fatal("menu.css reads undefined tokens:", missing)
+ }
+}
+
+// The square rule, stated in the stylesheet: a float and the rows inside
+// it are rounded, a menubar title is in the page flow and is square.
+func TestFloatsAreRoundedAndMenubarTitlesSquare(t *testing.T) {
+ src := string(CSS())
+ for _, want := range []string{
+ "--estilo-float-radius: 9px;",
+ "border-radius: var(--estilo-float-radius);",
+ "border-radius: 0; /* square: in-flow */",
+ // Sized to its rows, not to the <details> it hangs from.
+ "inline-size: max-content;",
+ } {
+ if !strings.Contains(src, want) {
+ t.Errorf("menu.css lost %q", want)
+ }
+ }
+}
+
+func render(t *testing.T, name string, data any) string {
+ t.Helper()
+ view, err := template.New("").ParseFS(Templates(), "templates/*.html")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var out bytes.Buffer
+ if err := view.ExecuteTemplate(&out, name, data); err != nil {
+ t.Fatal(err)
+ }
+ return out.String()
+}
+
+var locales = map[string]string{"en": "Open source", "de": "Open Source", "es": "Código abierto", "fr": "Logiciels libres", "it": "Open source", "pt": "Código aberto", "nl": "Open source", "pl": "Otwarte oprogramowanie", "ar": "مفتوح المصدر", "ja": "オープンソース", "zh": "开源", "ko": "오픈 소스"}
+
+func TestItemRendersCallerLabelsInEveryLocale(t *testing.T) {
+ for locale, label := range locales {
+ t.Run(locale, func(t *testing.T) {
+ html := render(t, "estilo-menu-item", Item{Href: "/credits", Label: label, Key: "⌘K"})
+ for _, want := range []string{`<a estilo-menu-item href="/credits">`, "<span estilo-menu-label>" + template.HTMLEscapeString(label) + "</span>", "<kbd estilo-menu-key>⌘K</kbd>"} {
+ if !strings.Contains(html, want) {
+ t.Fatalf("missing %q in %s", want, html)
+ }
+ }
+ if strings.Contains(html, "<script") {
+ t.Fatal("a menu row needs no script")
+ }
+ })
+ }
+ if html := render(t, "estilo-menu-item", Item{Href: "/x", Label: "x"}); strings.Contains(html, "<kbd") {
+ t.Fatal("an item with no shortcut renders an empty key column", html)
+ }
+}
+
+func TestItemEscapesUntrustedHrefAndLabel(t *testing.T) {
+ html := render(t, "estilo-menu-item", Item{Href: "javascript:alert(1)", Label: "<img src=x onerror=bad>"})
+ if !strings.Contains(html, "#ZgotmplZ") || strings.Contains(html, "<img") {
+ t.Fatal("unsafe menu item markup", html)
+ }
+}
+
+func TestShortcutPerPlatform(t *testing.T) {
+ cases := []struct{ keys, apple, other string }{
+ {"Mod+Z", "⌘Z", "Ctrl+Z"},
+ {"Mod+Shift+Z", "⇧⌘Z", "Ctrl+Shift+Z"},
+ {"Mod+Shift+K", "⇧⌘K", "Ctrl+Shift+K"},
+ {"Shift+Mod+K", "⇧⌘K", "Ctrl+Shift+K"},
+ {"Mod+Alt+1", "⌥⌘1", "Ctrl+Alt+1"},
+ {"Ctrl+Alt+Shift+Mod+P", "⌃⌥⇧⌘P", "Ctrl+Alt+Shift+P"},
+ {"Ctrl+Tab", "⌃⇥", "Ctrl+Tab"},
+ {"Mod+Ctrl+F", "⌃⌘F", "Ctrl+F"},
+ {"Alt+Enter", "⌥↩", "Alt+Enter"},
+ {"Shift+Enter", "⇧↩", "Shift+Enter"},
+ {"Shift+Tab", "⇧⇥", "Shift+Tab"},
+ {"Tab", "⇥", "Tab"},
+ {"Esc", "⎋", "Esc"},
+ {"Delete", "⌫", "Delete"},
+ {"Backspace", "⌫", "Backspace"},
+ {"Mod+K", "⌘K", "Ctrl+K"},
+ }
+ mac := httptest.NewRequest("GET", "/", nil)
+ mac.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36")
+ win := httptest.NewRequest("GET", "/", nil)
+ win.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36")
+ for _, c := range cases {
+ if got := Shortcut(mac, c.keys); got != c.apple {
+ t.Errorf("Apple %q = %q, want %q", c.keys, got, c.apple)
+ }
+ if got := Shortcut(win, c.keys); got != c.other {
+ t.Errorf("other %q = %q, want %q", c.keys, got, c.other)
+ }
+ }
+}
+
+func TestApplePlatformDetection(t *testing.T) {
+ cases := []struct {
+ header, value string
+ want bool
+ }{
+ {"Sec-CH-UA-Platform", `"macOS"`, true},
+ {"Sec-CH-UA-Platform", `"iOS"`, true},
+ {"Sec-CH-UA-Platform", `"Windows"`, false},
+ {"Sec-CH-UA-Platform", `"Linux"`, false},
+ {"User-Agent", "Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X)", true},
+ {"User-Agent", "Mozilla/5.0 (iPad; CPU OS 18_0 like Mac OS X)", true},
+ {"User-Agent", "Mozilla/5.0 (X11; Linux x86_64)", false},
+ {"User-Agent", "Mozilla/5.0 (Linux; Android 14)", false},
+ }
+ for _, c := range cases {
+ r := httptest.NewRequest("GET", "/", nil)
+ r.Header.Set(c.header, c.value)
+ if got := Apple(r); got != c.want {
+ t.Errorf("%s %q: Apple = %v, want %v", c.header, c.value, got, c.want)
+ }
+ }
+}
diff --git a/menu/templates/menu.html b/menu/templates/menu.html
new file mode 100644
index 0000000..8eb07c0
--- /dev/null
+++ b/menu/templates/menu.html
@@ -0,0 +1,8 @@
+{{/* estilo-menu-item: one row in any estilo-menu. A link: every action that
+ can be a link or a form works with scripts off. Callers supply the
+ label in their own locale; Key is an already-formatted shortcut
+ (menu.Shortcut). */}}
+{{define "estilo-menu-item"}}<a estilo-menu-item href="{{.Href}}"><span estilo-menu-label>{{.Label}}</span>{{with .Key}}<kbd estilo-menu-key>{{.}}</kbd>{{end}}</a>{{end}}
+{{/* estilo-icon-check: the glyph inside an estilo-menu-check column.
+ Lucide "check" (ISC, brand/LICENSE.lucide). */}}
+{{define "estilo-icon-check"}}<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false"><path d="M20 6 9 17l-5-5"/></svg>{{end}}