Add the menu panel, the menubar and platform shortcut labels
menu.css is the one floating panel every menu in the suite uses (9px float radius, inset rounded rows, check column, key column, separators, headings) plus the document menubar, whose titles are square because they are in the page flow. menu.Shortcut formats a chord server-side for the request's platform (⇧⌘K on Apple, Ctrl+Shift+K elsewhere), so nothing flips after paint.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
4 files changed,
+406
−0
menu/menu.css+136 −0menu/menu.go+129 −0menu/menu_test.go+133 −0menu/templates/menu.html+8 −0
diff --git a/menu/menu.css b/menu/menu.css| new file mode 100644 |
| index 0000000..8c86a05 |
| --- /dev/null |
| +++ b/menu/menu.css |
| @@ -0,0 +1,136 @@ |
| +/* 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; |
| + 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); |
| + 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; 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..dc92040 |
| --- /dev/null |
| +++ b/menu/menu_test.go |
| @@ -0,0 +1,133 @@ |
| +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 */", |
| + } { |
| + 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}} |