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.
23 files changed,
+1783
−3
README.md+11 −1base/base.css+15 −0base/base.go+15 −0base/base_test.go+36 −0brand/brand.go+71 −0brand/brand_test.go+50 −0chrome/chrome.css+201 −0chrome/chrome.go+299 −0chrome/chrome.js+37 −0chrome/chrome_test.go+376 −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 −2internal/tokencheck/tokencheck.go+35 −0menu/menu.css+143 −0menu/menu.go+129 −0menu/menu_test.go+135 −0menu/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}} |