oficina / estilo Public

Merge branch 'feat/menu-dropup' into main

Merge feat/menu-dropup: the account menu can sit in a sidebar rail

Account.Rail (off by default) draws a square, rail-wide trigger whose
panel opens upward from 800px and down inside the narrow Menu below it.
For Fichas' sidebar-first layout (Paul, 2026-10-01). Reviewed by
GPT-6-Astra via Codex (#23): approve.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
claude agent, operated by paul@keymail.dev a264aa5b9540467707a1081d5310a459b7f68297 parent 530cb93parent fd9e235
4 files changed, +88 −2
  • chrome/chrome.css +30 −0
  • chrome/chrome.go +9 −0
  • chrome/chrome_test.go +42 −0
  • chrome/templates/account.html +7 −2

CI passed — run details

CI log
=== modcache ===
all modules verified
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build -buildvcs=false ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.005s
ok  	amadan.net/oficina/estilo.git/brand	0.005s
ok  	amadan.net/oficina/estilo.git/busy	0.003s
ok  	amadan.net/oficina/estilo.git/callui	0.006s
ok  	amadan.net/oficina/estilo.git/chrome	0.268s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.014s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.012s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.017s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.012s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.016s
ok  	amadan.net/oficina/estilo.git/savestatus	0.095s
ok  	amadan.net/oficina/estilo.git/sky	0.029s
ok  	amadan.net/oficina/estilo.git/theme	0.008s
diff --git a/chrome/chrome.css b/chrome/chrome.css
index 74bd3d7..71c0490 100644
--- a/chrome/chrome.css
+++ b/chrome/chrome.css
@@ -208,6 +208,36 @@
[estilo-menu-identity-name] + [estilo-menu-identity-address] { font-size: var(--rst-fs-xs); }
[estilo-bar-signin] { flex: none; }
+/* The account at a sidebar rail's foot (Account.Rail), for an app whose
+ chrome is a rail rather than the bar. The trigger is a row the rail's
+ width: the avatar, then who is signed in, cut short with an ellipsis.
+ It stays in the page flow, so its highlight stays square. The panel is
+ the foot's own width, edge to edge with the trigger: hung from the
+ inline end of a foot narrower than the bar's 15rem panel it ran off
+ the window's start edge, and from the inline start it ran past the
+ rail, whose overflow-y: auto clips it (Fichas' first screenshot). */
+[estilo-account~="rail"] > [estilo-bar-trigger] {
+ justify-content: flex-start;
+ gap: var(--rst-sp-2);
+ inline-size: 100%;
+ block-size: auto;
+ min-block-size: 2.5rem;
+ color: var(--rst-text);
+}
+[estilo-account-who] { min-inline-size: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+[estilo-account~="rail"] > [estilo-account-panel] { inset-inline: 0; inline-size: auto; min-inline-size: 0; max-inline-size: none; }
+/* A drop-up where the rail is the viewport's height (rastrillo's shell
+ breakpoint): below the foot there is only the window's edge. Below
+ 800px the rail is a disclosure at the top of the page, with the page
+ under it and too little above, so the panel opens downward there. */
+@media (min-width: 800px) {
+ [estilo-account~="rail"] > [estilo-account-panel] {
+ inset-block-start: auto;
+ inset-block-end: calc(100% + 4px);
+ max-block-size: max(calc(100dvh - 5rem), 8rem);
+ }
+}
+
/* Visually hidden, still announced. */
[estilo-visually-hidden] { position: absolute; clip-path: inset(50%); inline-size: 1px; block-size: 1px; overflow: hidden; white-space: nowrap; }
diff --git a/chrome/chrome.go b/chrome/chrome.go
index 4253bdc..a51b5b4 100644
--- a/chrome/chrome.go
+++ b/chrome/chrome.go
@@ -141,6 +141,15 @@ type Account struct {
// Help items come last before Sign out ("Open source" last of all).
Help []menu.Item
SignOut SignOut
+ // Rail draws the account at the foot of a sidebar rail rather than at
+ // the bar's inline end, for an app whose chrome is a rail (Fichas).
+ // The trigger becomes a row that says who is signed in beside the
+ // avatar, and the panel hangs from the rail's inline start and opens
+ // upward where the rail is the viewport's height (from 800px, the
+ // shell breakpoint). Below that the rail is an in-flow disclosure at
+ // the top of the page, so the panel opens downward like every other.
+ // The menu, its group and its one Sign out are unchanged.
+ Rail bool
}
// SignOut is the account menu's one Sign out: a POST to Action.
diff --git a/chrome/chrome_test.go b/chrome/chrome_test.go
index 40cc07d..fd3a4b2 100644
--- a/chrome/chrome_test.go
+++ b/chrome/chrome_test.go
@@ -513,3 +513,45 @@ func TestTitleFieldDoesNotZoomOnTap(t *testing.T) {
t.Error("the coarse-pointer size comes before the title field's own rule, whose font: inherit then wins")
}
}
+
+// The account at a sidebar's foot (Account.Rail), for an app whose chrome
+// is a rail, not the bar (Fichas, Paul 2026-10-01: "put any shared stuff in
+// the bottom left and maybe roll it into a dropup"). The same menu, the
+// same group and the same one Sign out; only where it sits changes.
+func TestTheAccountCanSitAtARailsFoot(t *testing.T) {
+ acc := signedIn("x").Account
+ bar := render(t, "estilo-account", acc)
+ if strings.Contains(bar, `estilo-account="rail"`) || strings.Contains(bar, "estilo-account-who") {
+ t.Error("the bar's account changed: Rail is off by default")
+ }
+ acc.Rail = true
+ html := render(t, "estilo-account", acc)
+ if !strings.Contains(html, `<details estilo-account="rail" name="estilo-menus">`) {
+ t.Errorf("a rail account is not marked, or left the one menu group:\n%s", html)
+ }
+ // In a rail the trigger is a row: who is signed in, beside the avatar.
+ summary := html[strings.Index(html, "<summary"):strings.Index(html, "</summary>")]
+ if !strings.Contains(summary, `<span estilo-account-who dir="auto">paul@example.test</span>`) {
+ t.Errorf("the rail trigger does not say who is signed in:\n%s", summary)
+ }
+ if n := strings.Count(html, "x-signout"); n != 1 {
+ t.Errorf("Sign out rendered %d times", n)
+ }
+
+ src := string(CSS())
+ // It opens upward where the rail is the viewport's height, and it is
+ // the foot's own width: hung from the inline end of a foot narrower
+ // than the bar's panel it ran off the window's start edge, and from the
+ // inline start it ran past the rail, which scrolls and so clips.
+ wide := regexp.MustCompile(`@media \(min-width: 800px\) \{\s*\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-block-start: auto;[^}]*inset-block-end: calc\(100% \+ 4px\);`)
+ if !wide.MatchString(src) {
+ t.Error("chrome.css does not open the rail account upward from 800px")
+ }
+ if !regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-account-panel\] \{[^}]*inset-inline: 0;[^}]*inline-size: auto;[^}]*min-inline-size: 0;`).MatchString(src) {
+ t.Error("chrome.css does not size the rail account's panel to the rail's foot")
+ }
+ // The trigger is in the page flow: square, like every bar trigger.
+ if regexp.MustCompile(`\[estilo-account~="rail"\] > \[estilo-bar-trigger\] \{[^}]*border-radius: (?:[1-9]|var)`).MatchString(src) {
+ t.Error("the rail trigger is rounded; an in-flow highlight is square")
+ }
+}
diff --git a/chrome/templates/account.html b/chrome/templates/account.html
index ce0f461..0b5b20d 100644
--- a/chrome/templates/account.html
+++ b/chrome/templates/account.html
@@ -5,10 +5,15 @@
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">
+ colour, shows which.
+
+ Rail (Account.Rail) puts it at a sidebar rail's foot instead: the
+ trigger is a row naming who is signed in, and the panel opens upward
+ where the rail is the viewport's height. */}}
+{{define "estilo-account"}}<details estilo-account{{if .Rail}}="rail"{{end}} 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 -}}
+ {{- if .Rail}}{{with or .Name .Address}}<span estilo-account-who dir="auto">{{.}}</span>{{end}}{{end -}}
</summary>
<div estilo-menu estilo-account-panel>
<div estilo-menu-identity>