oficina / fichas Public

The rail's foot is the suite's account menu, a drop-up

Paul, 2026-10-01: "Since Fichas is sidebar-first, let's put any shared
stuff in the bottom left and maybe roll it into a dropup". The foot of
the rail was the bare address and a bordered Sign out button. It is now
estilo's account menu, placed for a rail (estilo feat/menu-dropup,
Account.Rail): a square, rail-wide trigger with the avatar and who is
signed in, opening upward from 800px (where the rail is the viewport's
height) and downward inside the narrow Menu disclosure. It carries:

- who you are signed in as;
- All apps, to HOME_DIRECTORY_ORIGIN when it is set (validated at boot:
  an https origin, or loopback http);
- Appearance: Light, Dark, System, the current one checked, a form to
  POST /appearance (estilo's handler, inside the CSRF check, outside the
  session guard); every page, door and error page included, draws
  <html data-theme> from the first byte;
- the one Sign out.

Not carried, because Fichas lacks them: Connect your Oficina account (no
Home connect) and Help -> Open source (no credits page).

estilo's menu.css and chrome.js join the files writeestilo copies and the
pin test checks; chrome.js closes the menu on Escape (focus back to the
trigger) or an outside click.

Tests, each red on 83f6d62 (polish-audit/r3/scripts/r2-fichas-dropup-red.txt):
TestTheRailFootIsTheAccountMenu, TestAllAppsWhenThereIsAHubDirectory,
TestAppearanceIsChosenInTheAccountMenu, TestLoadConfigTakesTheDirectoryOrigin,
TestDoorFilesAreEstilos (menu.css, chrome.js), and the browser test
TestTheAccountMenuOpensUpward at 1440, 390 and 320 (upward/downward, on
screen and unclipped, Sign out reachable, 9px panel, square trigger,
Escape and outside click).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 2ca84dc1d3e305767370a006fe34f62e8b1f47f2 parent e93afe3
17 files changed, +625 −19
  • go.mod +1 −1
  • go.sum +2 −2
  • internal/fichas/app.go +6 −0
  • internal/fichas/auth_navigation.go +1 −1
  • internal/fichas/config.go +25 −0
  • internal/fichas/config_test.go +18 −0
  • internal/fichas/handlers.go +46 −3
  • internal/fichas/render.go +14 −8
  • internal/fichas/static/chrome.css +38 −0
  • internal/fichas/static/chrome.js +82 −0
  • internal/fichas/static/menu.css +147 −0
  • internal/fichas/templates/layout.html +1 −1
  • internal/fichas/templates/shell.html +8 −2
  • internal/fichastest/account_test.go +129 −0
  • internal/fichastest/browser_test.go +97 −0
  • internal/fichastest/vendored_test.go +5 −0
  • scripts/writeestilo/main.go +5 −1
diff --git a/go.mod b/go.mod
index 367c3c0..e49ebea 100644
--- a/go.mod
+++ b/go.mod
@@ -5,7 +5,7 @@ go 1.26
toolchain go1.26.5
require (
- amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16
+ amadan.net/oficina/estilo.git v0.0.0-20261001113457-a264aa5b9540
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd
github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327
github.com/chromedp/chromedp v0.14.2
diff --git a/go.sum b/go.sum
index 2bbffcc..cf6c1dc 100644
--- a/go.sum
+++ b/go.sum
@@ -1,5 +1,5 @@
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16 h1:F5v3x25dEj1rJNnBm71G7shN+fM+T2actUlaGPvoeH8=
-amadan.net/oficina/estilo.git v0.0.0-20261001084202-0b4d92c3dc16/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
+amadan.net/oficina/estilo.git v0.0.0-20261001113457-a264aa5b9540 h1:lG6hyBqz13XGisiyhKMHzPoBK6QP8weHQ02je/2gquk=
+amadan.net/oficina/estilo.git v0.0.0-20261001113457-a264aa5b9540/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd h1:jlFSt95M1ontOg+VePU+kIJpChtWqDQAbnmlxqCnbgc=
amadan.net/rastrillo/rastrillo v0.26.1-0.20260908075707-29e4491631bd/go.mod h1:RpyHVPD0udcSfHJroY0KZE/FUjgw6Tvfo8SJm2qeZGE=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
diff --git a/internal/fichas/app.go b/internal/fichas/app.go
index 175dc6b..3496417 100644
--- a/internal/fichas/app.go
+++ b/internal/fichas/app.go
@@ -1,6 +1,7 @@
package fichas
import (
+ "amadan.net/oficina/estilo.git/chrome"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"context"
@@ -73,6 +74,11 @@ func App(d *db.DB, cfg Config, logger *slog.Logger) (*http.ServeMux, *app, error
r.Get("/auth/callback", a.authCallback)
r.Get("/auth/verify", a.authVerify)
r.Post("/signout", a.authSignout)
+ // Light, dark or system: a per-person preference set by a form in the
+ // account menu, so it works with scripts off. estilo's handler and
+ // cookie; inside the CSRF check above and outside the session guard,
+ // so an expired session cannot turn the click into a sign-in loop.
+ r.Post("/appearance", chrome.HandleAppearance)
// Everything else requires a session AND admission. Grouped rather
// than wrapped per route: a route added below is born guarded, which
diff --git a/internal/fichas/auth_navigation.go b/internal/fichas/auth_navigation.go
index 9ab63ff..31ac666 100644
--- a/internal/fichas/auth_navigation.go
+++ b/internal/fichas/auth_navigation.go
@@ -54,7 +54,7 @@ func (a *app) keymailContinuation(next http.HandlerFunc) http.HandlerFunc {
// The URL carries state and a PKCE challenge. It must not
// leak onward as a Referer.
w.Header().Set("Referrer-Policy", "no-referrer")
- render(w, "signin", signinPage{Continue: &signinContinue{Door: doorHead(r), ContinueURL: destination}})
+ render(w, "signin", signinPage{Continue: &signinContinue{Door: doorHead(r), ContinueURL: destination}, Theme: themeAttr(r)})
return
}
diff --git a/internal/fichas/config.go b/internal/fichas/config.go
index 8916535..aaebf7a 100644
--- a/internal/fichas/config.go
+++ b/internal/fichas/config.go
@@ -4,6 +4,7 @@ import (
"encoding/base64"
"errors"
"fmt"
+ "net/url"
"strings"
"amadan.net/oficina/fichas/internal/fichas/seal"
@@ -42,6 +43,10 @@ type Config struct {
// something we can grep and retract, so that is a credential we
// cannot take back. Opt in explicitly, for local development.
AllowLoggedMail bool
+ // Directory is the hub directory's origin (HOME_DIRECTORY_ORIGIN, the
+ // name Home and the other apps use), the account menu's "All apps".
+ // Empty — a standalone instance — and there is no "All apps".
+ Directory string
}
// ErrMissing is returned when required platform config is absent.
@@ -119,6 +124,9 @@ func LoadConfig(getenv func(string) string) (Config, error) {
"set FICHAS_ALLOW_LOGGED_MAIL=1 to accept that on a loopback origin", ErrMissing)
}
+ if c.Directory, err = directoryOrigin(getenv("HOME_DIRECTORY_ORIGIN")); err != nil {
+ return Config{}, err
+ }
c.Shape = strings.ToLower(strings.TrimSpace(getenv("FICHAS_SHAPE")))
if c.Shape == "" {
c.Shape = ShapeLone
@@ -172,3 +180,20 @@ func isLocal(origin string) bool {
// out loud. A silently defaulted origin is a CSRF yardstick pointing at
// the wrong host.
func (c Config) UsedDefaultOrigin() bool { return c.Origin == defaultOrigin }
+
+// directoryOrigin validates HOME_DIRECTORY_ORIGIN: empty, or an origin
+// and nothing else — https, or http on loopback for local development —
+// returned without a trailing slash. It becomes a link in every page, so
+// anything else is refused at boot rather than drawn.
+func directoryOrigin(v string) (string, error) {
+ v = strings.TrimSuffix(strings.TrimSpace(v), "/")
+ if v == "" {
+ return "", nil
+ }
+ u, err := url.Parse(v)
+ if err != nil || u.Host == "" || u.User != nil || u.Path != "" || u.RawQuery != "" || u.Fragment != "" ||
+ !(u.Scheme == "https" || (u.Scheme == "http" && isLocal(v+"/"))) {
+ return "", fmt.Errorf("fichas: HOME_DIRECTORY_ORIGIN is %q; it is the hub directory's origin, https://host", v)
+ }
+ return u.Scheme + "://" + u.Host, nil
+}
diff --git a/internal/fichas/config_test.go b/internal/fichas/config_test.go
index 266ce1c..9bb365b 100644
--- a/internal/fichas/config_test.go
+++ b/internal/fichas/config_test.go
@@ -198,3 +198,21 @@ func TestLoggedMailCannotBeAskedForOffLoopback(t *testing.T) {
t.Fatal("a logging mailer was accepted on a public origin")
}
}
+
+// HOME_DIRECTORY_ORIGIN is the account menu's "All apps": an origin and
+// nothing else, https unless it is loopback, and absent means none.
+func TestLoadConfigTakesTheDirectoryOrigin(t *testing.T) {
+ c, err := LoadConfig(env(map[string]string{}))
+ if err != nil || c.Directory != "" {
+ t.Fatalf("unset: Directory %q, err %v; want empty", c.Directory, err)
+ }
+ c, err = LoadConfig(env(map[string]string{"HOME_DIRECTORY_ORIGIN": "https://oficina.works/"}))
+ if err != nil || c.Directory != "https://oficina.works" {
+ t.Fatalf("Directory %q, err %v; want https://oficina.works", c.Directory, err)
+ }
+ for _, bad := range []string{"oficina.works", "http://oficina.works", "https://oficina.works/apps", "https://oficina.works?x=1", "javascript:alert(1)", "https://"} {
+ if _, err := LoadConfig(env(map[string]string{"HOME_DIRECTORY_ORIGIN": bad})); err == nil {
+ t.Errorf("HOME_DIRECTORY_ORIGIN=%q accepted; want a refusal", bad)
+ }
+ }
+}
diff --git a/internal/fichas/handlers.go b/internal/fichas/handlers.go
index bd137d1..73f3b05 100644
--- a/internal/fichas/handlers.go
+++ b/internal/fichas/handlers.go
@@ -8,6 +8,7 @@ import (
"strings"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/rastrillo/rastrillo/auth"
"amadan.net/rastrillo/rastrillo/db"
@@ -77,7 +78,7 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
// The same page for an owner and a stranger (beginGuard), so it
// promises nothing about the address. The words are the door's.
sent := head.Sent(signinPath)
- render(w, "signin", signinPage{Sent: &sent})
+ render(w, "signin", signinPage{Sent: &sent, Theme: themeAttr(r)})
return
}
form := chrome.SignIn{
@@ -91,7 +92,7 @@ func (a *app) signin(w http.ResponseWriter, r *http.Request) {
if q.Get("force") == "1" {
form.Hidden = []chrome.Hidden{{Name: "force", Value: "1"}}
}
- render(w, "signin", signinPage{Form: &form})
+ render(w, "signin", signinPage{Form: &form, Theme: themeAttr(r)})
}
// signinPage is templates/signin.html's data: exactly one of its fields
@@ -101,6 +102,8 @@ type signinPage struct {
Form *chrome.SignIn
Sent *chrome.SignInNotice
Continue *signinContinue
+ // Theme is <html data-theme> (themeAttr).
+ Theme string
}
// Title is the tab title, the door's own for each state: "Sign in ·
@@ -187,13 +190,53 @@ func (a *app) pageStatus(w http.ResponseWriter, r *http.Request, status int, nam
}
data["Nav"] = nav
me, _ := a.identity(r)
- data["Me"] = me
+ data["Account"] = a.account(r, me)
+ data["Theme"] = themeAttr(r)
if status != http.StatusOK {
w.WriteHeader(status)
}
render(w, name, data)
}
+// account is the suite's account menu (estilo chrome, spec §8.2), drawn at
+// the rail's foot and opening upward (Paul, 2026-10-01: "put any shared
+// stuff in the bottom left and maybe roll it into a dropup"): who is
+// signed in, "All apps" when there is a hub directory, Appearance, and the
+// one Sign out. Fichas has no Home connect, so no "Connect your Oficina
+// account", and no credits page yet, so no Help → Open source.
+func (a *app) account(r *http.Request, address string) *chrome.Account {
+ acc := &chrome.Account{
+ TriggerLabel: "Account",
+ Initials: chrome.Initials("", address),
+ Address: address,
+ Labels: chrome.AccountLabels{SignedInAs: "Signed in as", Appearance: "Appearance", Help: "Help"},
+ Appearance: &chrome.AppearanceMenu{
+ Action: "/appearance",
+ Return: returnTarget(r),
+ Options: chrome.AppearanceOptions(chrome.Appearance(r), "Light", "Dark", "System"),
+ },
+ SignOut: chrome.SignOut{Action: "/signout", Label: "Sign out"},
+ Rail: true,
+ }
+ if address != "" {
+ acc.TriggerLabel = "Account: " + address
+ }
+ if a.cfg.Directory != "" {
+ acc.Items = []menu.Item{{Href: a.cfg.Directory + "/", Label: "All apps"}}
+ }
+ return acc
+}
+
+// returnTarget is where Appearance brings a person back to: this page if
+// it is one (a GET), otherwise the list — a form re-rendered with errors
+// is a POST's answer, and its URL is not a page.
+func returnTarget(r *http.Request) string {
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ return "/"
+ }
+ return r.URL.RequestURI()
+}
+
// nav lists the card types, from the index rather than the database: the
// labels are sealed and the index has them open already. Scoped to cards —
// an entry type (Call log) is something you add to a card, not somewhere
diff --git a/internal/fichas/render.go b/internal/fichas/render.go
index 0486d8f..06436fc 100644
--- a/internal/fichas/render.go
+++ b/internal/fichas/render.go
@@ -9,6 +9,7 @@ import (
"time"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/rastrillo/rastrillo"
"amadan.net/rastrillo/rastrillo/ui"
@@ -58,13 +59,13 @@ func init() {
"safeHref": safeHref,
}).
ParseFS(ui.Templates(), "*.html"))
- if name == "signin" {
- // The sign-in page is the suite's door, estilo chrome's
- // estilo-signin and friends, which call ui's field and
- // callout: same tree, and before the app's own files so
- // the app's templates are the last word on any name.
- t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
- }
+ // estilo chrome: the sign-in door (estilo-signin and friends,
+ // which call ui's field and callout) and the account menu at the
+ // rail's foot (estilo-account, whose rows are menu's). Same tree,
+ // and before the app's own files so the app's templates are the
+ // last word on any name.
+ t = template.Must(t.ParseFS(menu.Templates(), "templates/*.html"))
+ t = template.Must(t.ParseFS(chrome.Templates(), "templates/*.html"))
pages[name] = template.Must(t.ParseFS(appFS, "templates/"+shell+".html", "templates/partials.html", "templates/"+name+".html"))
}
}
@@ -90,9 +91,14 @@ func render(w http.ResponseWriter, name string, data any) {
// Ctx.ErrorPage too as the app grows handlers of its own.
func ErrorPage(w http.ResponseWriter, r *http.Request, status int, ref string) {
w.WriteHeader(status)
- render(w, "errors", map[string]any{"Status": status, "Ref": ref})
+ render(w, "errors", map[string]any{"Status": status, "Ref": ref, "Theme": themeAttr(r)})
}
+// themeAttr is <html data-theme>: the person's Appearance choice, "light" or
+// "dark", and empty for System, when the page follows the operating
+// system. Every page draws it, so the scheme is right in the first paint.
+func themeAttr(r *http.Request) string { return chrome.ThemeAttr(chrome.Appearance(r)) }
+
// selectOptions turns a field's unsealed enum values into what
// ui's field-select wants, marking the current one selected.
//
diff --git a/internal/fichas/static/chrome.css b/internal/fichas/static/chrome.css
index 6bda384..71c0490 100644
--- a/internal/fichas/static/chrome.css
+++ b/internal/fichas/static/chrome.css
@@ -135,6 +135,14 @@
}
[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); }
+/* iOS zooms the page into a text field under 16px. tokens.css lifts fields
+ to 16px on a coarse pointer, but with a bare `input` selector, which
+ font: inherit above outweighs. Same rule, restated at this weight. */
+@media (pointer: coarse) {
+ [estilo-bar-title-input] {
+ font-size: max(1rem, 1em);
+ }
+}
[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
@@ -200,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/internal/fichas/static/chrome.js b/internal/fichas/static/chrome.js
new file mode 100644
index 0000000..1f583c6
--- /dev/null
+++ b/internal/fichas/static/chrome.js
@@ -0,0 +1,82 @@
+// chrome/chrome.js: optional behaviour for estilo-bar and the one top-level
+// menu group. Load it with <script defer>. Everything already works
+// without it; this only adds what <details> and the platform cannot do for
+// themselves:
+//
+// - Escape closes the open member of the group (name="estilo-menus": the
+// bar's Menu, switcher and account, and every menubar menu) and returns
+// focus to its title;
+// - a click outside the open member closes it;
+// - Escape in the editable title restores the saved value and leaves
+// the field;
+// - a row or button that opens a dialog (command="show-modal") closes
+// the menu it sat in, returns focus to that menu's title when the
+// dialog closes, and opens the dialog itself on an engine without
+// invoker commands.
+//
+// The group is one group in every app, so closing it is estilo's job, not
+// each app's: Sheets' menubar lost Escape and outside-click entirely when
+// it left rastrillo's dropdown.
+//
+// Both listen in the capture phase. A document app has to stop key
+// events from propagating out of its menus — Sheets' grid library acts on
+// every key it hears on document, so an arrow in an open menu would walk
+// the cell cursor too — and a bubble-phase listener on document never
+// hears the Escape such an app stops. Capture hears it first. An app that
+// closes its own menu on Escape (Docs' menubar) finds nothing open and
+// does nothing, so nothing is handled twice.
+;(() => {
+ const group = 'details[name="estilo-menus"][open]'
+ const openPanel = () => document.querySelector(group)
+
+ // Focus returns to a menu's title; in a menubar with a roving tab stop
+ // (one title tabbable, the rest tabindex=-1) the stop moves with it, so
+ // Tab then Shift+Tab comes back to the menu you were in, not to File
+ // (Astra, estilo #11). A menubar without one is left without one.
+ const returnTo = (title) => {
+ title.focus()
+ const bar = title.closest('[estilo-menubar]')
+ if (!bar) return
+ const titles = bar.querySelectorAll('[estilo-menubar-title]')
+ if (!Array.prototype.some.call(titles, (t) => t.hasAttribute('tabindex'))) return
+ Array.prototype.forEach.call(titles, (t) => { t.tabIndex = t === title ? 0 : -1 })
+ }
+
+ 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) returnTo(summary)
+ e.preventDefault()
+ }, true)
+
+ document.addEventListener('click', (e) => {
+ const open = openPanel()
+ if (open && !open.contains(e.target)) open.open = false
+ }, true)
+
+ const invokers = typeof HTMLButtonElement !== 'undefined' && 'command' in HTMLButtonElement.prototype
+ document.addEventListener('click', (e) => {
+ const button = e.target.closest && e.target.closest('button[commandfor][command="show-modal"]')
+ if (!button || button.disabled) return
+ const dialog = document.getElementById(button.getAttribute('commandfor'))
+ const holder = button.closest('details[name="estilo-menus"]')
+ if (holder) {
+ holder.open = false
+ const summary = holder.querySelector('summary')
+ if (dialog && summary) dialog.addEventListener('close', () => returnTo(summary), { once: true })
+ }
+ if (invokers || !dialog || typeof dialog.showModal !== 'function' || dialog.open) return
+ dialog.showModal()
+ e.preventDefault()
+ })
+})()
diff --git a/internal/fichas/static/menu.css b/internal/fichas/static/menu.css
new file mode 100644
index 0000000..9070652
--- /dev/null
+++ b/internal/fichas/static/menu.css
@@ -0,0 +1,147 @@
+/* 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; }
+/* A destructive row: the negative tone, and its own tint on hover. The
+ label says what it does; the tone says it is the one to read twice. */
+[estilo-menu-item~="danger"] { color: var(--rst-tone-negative-fg); }
+[estilo-menu-item~="danger"]:hover, [estilo-menu-item~="danger"]:focus-visible { background: var(--rst-tone-negative-bg); }
+[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/internal/fichas/templates/layout.html b/internal/fichas/templates/layout.html
index 2b86bfd..d86b8bf 100644
--- a/internal/fichas/templates/layout.html
+++ b/internal/fichas/templates/layout.html
@@ -1,5 +1,5 @@
{{define "layout"}}<!doctype html>
-<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}">
+<html lang="{{block "lang" .}}en{{end}}" dir="{{block "dir" .}}ltr{{end}}"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
diff --git a/internal/fichas/templates/shell.html b/internal/fichas/templates/shell.html
index e6aa12d..ae558ea 100644
--- a/internal/fichas/templates/shell.html
+++ b/internal/fichas/templates/shell.html
@@ -1,5 +1,5 @@
{{define "layout"}}<!doctype html>
-<html lang="en" dir="ltr">
+<html lang="en" dir="ltr"{{with .Theme}} data-theme="{{.}}"{{end}}>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -12,11 +12,17 @@
<link rel="stylesheet" href="{{asset "static/busy.css"}}">
<link rel="stylesheet" href="{{asset "static/theme.css"}}">
<link rel="stylesheet" href="{{asset "static/brand/brand.css"}}">
+{{/* The account menu at the rail's foot is estilo's: its panel and rows
+ (menu.css), the account itself (chrome.css), and the script that
+ closes it on Escape or an outside click (chrome.js). */}}
+<link rel="stylesheet" href="{{asset "static/menu.css"}}">
+<link rel="stylesheet" href="{{asset "static/chrome.css"}}">
<link rel="stylesheet" href="{{asset "static/fichas.css"}}">
<script defer src="{{asset "static/rastrillo.js"}}"></script>
<script defer src="{{asset "static/busy.js"}}"></script>
<script defer src="{{asset "static/shortcuts.js"}}"></script>
<script defer src="{{asset "static/live.js"}}"></script>
+<script defer src="{{asset "static/chrome.js"}}"></script>
{{iconAssets}}
<script defer src="{{asset "static/select.js"}}"></script>
<script defer src="{{asset "static/datetime.js"}}"></script>
@@ -34,7 +40,7 @@
{{range .Nav}}<a href="{{.Href}}"{{if .Current}} aria-current="page"{{end}}>{{icon "users"}} {{.Label}}</a>
{{end}}<a href="/types/new">{{icon "plus"}} New type</a>
</nav>
-<div rst-shell-rail-foot><form method="post" action="/signout"><span fichas-meta>{{.Me}}</span> <button rst-btn="ghost" type="submit">Sign out</button></form></div>
+<div rst-shell-rail-foot>{{template "estilo-account" .Account}}</div>
</aside>
<main rst-shell-main id="main">
<div rst-page>
diff --git a/internal/fichastest/account_test.go b/internal/fichastest/account_test.go
new file mode 100644
index 0000000..833aab7
--- /dev/null
+++ b/internal/fichastest/account_test.go
@@ -0,0 +1,129 @@
+package fichastest
+
+import (
+ "io"
+ "log/slog"
+ "net/http"
+ "net/http/httptest"
+ "net/url"
+ "path/filepath"
+ "regexp"
+ "strings"
+ "testing"
+
+ fichas "amadan.net/oficina/fichas/internal/fichas"
+ "amadan.net/rastrillo/rastrillo/db"
+)
+
+// appWith is newApp with the config adjusted first.
+func appWith(t *testing.T, adjust func(*fichas.Config)) http.Handler {
+ t.Helper()
+ logger := slog.New(slog.NewTextHandler(io.Discard, nil))
+ d, err := db.Open(filepath.Join(t.TempDir(), "app.db"), logger)
+ if err != nil {
+ t.Fatalf("db.Open: %v", err)
+ }
+ t.Cleanup(func() { d.Close() })
+ cfg := testConfig(t)
+ adjust(&cfg)
+ mux, err := fichas.AppForTest(d, cfg, logger, TestOwner)
+ if err != nil {
+ t.Fatalf("AppForTest: %v", err)
+ }
+ return mux
+}
+
+// The rail's foot is the suite's account menu (Paul, 2026-10-01: "put any
+// shared stuff in the bottom left and maybe roll it into a dropup"):
+// estilo's, at the rail's foot, carrying what every app's carries. Before,
+// the foot was the bare address and a bordered Sign out button.
+func TestTheRailFootIsTheAccountMenu(t *testing.T) {
+ body := get(t, newApp(t), "/").Body.String()
+ foot := body[strings.Index(body, "<div rst-shell-rail-foot>"):]
+ foot = foot[:strings.Index(foot, "</aside>")]
+ for _, want := range []string{
+ `<details estilo-account="rail" name="estilo-menus">`,
+ `aria-label="Account: ` + TestOwner + `"`,
+ `<span estilo-account-who dir="auto">` + TestOwner + `</span>`,
+ `<span estilo-menu-identity-label>Signed in as</span>`,
+ `<form method="post" action="/appearance" role="group" aria-labelledby="estilo-appearance">`,
+ `<input type="hidden" name="return_to" value="/">`,
+ `<form method="post" action="/signout">`,
+ } {
+ if !strings.Contains(foot, want) {
+ t.Errorf("the rail's foot lacks %s:\n%s", want, foot)
+ }
+ }
+ if n := strings.Count(body, ">Sign out<"); n != 1 {
+ t.Errorf("Sign out appears %d times; once, in the account menu", n)
+ }
+ if strings.Contains(body, `rst-btn="ghost" type="submit">Sign out`) {
+ t.Error("the old bordered Sign out button is still in the rail")
+ }
+ // Nothing Fichas does not have: no Home connect, no credits page, and
+ // no hub directory configured here.
+ for _, absent := range []string{"Connect your Oficina account", "Open source", "All apps", "estilo-menu-heading>Help"} {
+ if strings.Contains(body, absent) {
+ t.Errorf("the account menu offers %q, which Fichas does not have", absent)
+ }
+ }
+ pressed := regexp.MustCompile(`value="(light|dark|system)" aria-pressed="(true|false)"`).FindAllStringSubmatch(foot, -1)
+ if len(pressed) != 3 {
+ t.Fatalf("want Light, Dark and System, got %v", pressed)
+ }
+ for _, p := range pressed {
+ if (p[1] == "system") != (p[2] == "true") {
+ t.Errorf("%s pressed=%s; with no preference only System is checked", p[1], p[2])
+ }
+ }
+}
+
+// "All apps" goes to the hub directory when one is configured.
+func TestAllAppsWhenThereIsAHubDirectory(t *testing.T) {
+ h := appWith(t, func(c *fichas.Config) { c.Directory = "https://oficina.example" })
+ body := get(t, h, "/").Body.String()
+ if !strings.Contains(body, `<a estilo-menu-item href="https://oficina.example/"><span estilo-menu-label>All apps</span></a>`) {
+ t.Errorf("no All apps row to the hub directory:\n%s", body)
+ }
+}
+
+// Appearance is a per-person preference set by a form: estilo's handler,
+// inside the CSRF check, and every page — the shell and the plain layout
+// alike — draws the chosen scheme from the first byte.
+func TestAppearanceIsChosenInTheAccountMenu(t *testing.T) {
+ h := newApp(t)
+
+ // No same-origin evidence: refused by the CSRF check like any POST.
+ req := httptest.NewRequest(http.MethodPost, "/appearance", strings.NewReader("mode=dark&return_to=%2Fcards%2Fnew"))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, req)
+ if rec.Code != http.StatusForbidden {
+ t.Errorf("a cross-site Appearance POST answered %d; want 403", rec.Code)
+ }
+
+ rec = post(t, h, "/appearance", url.Values{"mode": {"dark"}, "return_to": {"/cards/new"}})
+ if rec.Code != http.StatusSeeOther || rec.Header().Get("Location") != "/cards/new" {
+ t.Fatalf("POST /appearance: %d to %q; want 303 back to /cards/new", rec.Code, rec.Header().Get("Location"))
+ }
+ cookies := rec.Result().Cookies()
+ if len(cookies) != 1 || cookies[0].Value != "dark" {
+ t.Fatalf("POST /appearance set %v; want the dark preference", cookies)
+ }
+ for _, path := range []string{"/", "/signin"} {
+ r := httptest.NewRequest(http.MethodGet, path, nil)
+ r.AddCookie(cookies[0])
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, r)
+ if !strings.Contains(rec.Body.String(), `<html lang="en" dir="ltr" data-theme="dark">`) {
+ t.Errorf("%s does not draw the dark scheme the person chose", path)
+ }
+ if path == "/" && !strings.Contains(rec.Body.String(), `value="dark" aria-pressed="true"`) {
+ t.Error("the account menu does not check Dark")
+ }
+ }
+ // No preference, no attribute: the scheme follows the system.
+ if strings.Contains(get(t, h, "/").Body.String(), "data-theme") {
+ t.Error("data-theme drawn without a preference")
+ }
+}
diff --git a/internal/fichastest/browser_test.go b/internal/fichastest/browser_test.go
index 90600ff..04288fe 100644
--- a/internal/fichastest/browser_test.go
+++ b/internal/fichastest/browser_test.go
@@ -240,3 +240,100 @@ func TestNoZoomOnTap(t *testing.T) {
}
}
}
+
+// TestTheAccountMenuOpensUpward: the account at the rail's foot opens
+// upward where the rail is the viewport's height, and inside the narrow
+// Menu disclosure it opens downward; at every width the whole panel is on
+// screen and its Sign out is the thing a tap at its centre reaches. Escape
+// closes it and gives focus back to its trigger; a click outside closes
+// it. The panel floats, so it is rounded; the trigger is in the rail's
+// flow, so it is square.
+func TestTheAccountMenuOpensUpward(t *testing.T) {
+ type facts struct {
+ Open bool
+ PanelTop, PanelBot float64
+ PanelLeft, PanelRite float64
+ TrigTop, TrigBot float64
+ VW float64
+ SignOutHit bool
+ EdgesHit bool
+ PanelRadius string
+ TrigRadius string
+ }
+ const measure = `(() => {
+ const d = document.querySelector('[estilo-account]'), s = d.querySelector('summary'), p = d.querySelector('[estilo-account-panel]')
+ const pr = p.getBoundingClientRect(), tr = s.getBoundingClientRect()
+ const out = d.querySelector('form[action="/signout"] button')
+ out.scrollIntoView({block: 'nearest'})
+ const or = out.getBoundingClientRect(), hit = document.elementFromPoint(or.left + or.width / 2, or.top + or.height / 2)
+ const pr2 = p.getBoundingClientRect(), tr2 = s.getBoundingClientRect()
+ // Not clipped: a point just inside each side edge is the panel's own,
+ // not whatever is beside it (the rail scrolls, so it clips).
+ const mid = (pr2.top + pr2.bottom) / 2, inP = (x) => { const h = document.elementFromPoint(x, mid); return !!h && p.contains(h) }
+ return {Open: d.open, PanelTop: pr2.top, PanelBot: pr2.bottom, PanelLeft: pr2.left, PanelRite: pr2.right,
+ TrigTop: tr2.top, TrigBot: tr2.bottom, VW: document.documentElement.clientWidth,
+ SignOutHit: !!hit && out.contains(hit), EdgesHit: inP(pr2.left + 2) && inP(pr2.right - 2), PanelRadius: getComputedStyle(p).borderRadius, TrigRadius: getComputedStyle(s).borderRadius}
+ })()`
+ for _, w := range []int64{1440, 390, 320} {
+ rig := browserApp(t, nil)
+ var f facts
+ var closedByEscape, focusBack, closedByOutside bool
+ actions := []chromedp.Action{
+ emulation.SetDeviceMetricsOverride(w, 800, 1, false),
+ chromedp.Navigate(rig.Origin + "/"),
+ chromedp.WaitReady(`[rst-shell-rail-foot]`),
+ }
+ if w < 800 {
+ actions = append(actions, chromedp.Click(`[rst-shell-chrome] > summary`), chromedp.Sleep(100*time.Millisecond))
+ }
+ var present bool
+ rig.Run(append(actions, chromedp.Evaluate(`!!document.querySelector('[rst-shell-rail-foot] [estilo-account] > summary')`, &present))...)
+ if !present {
+ t.Fatalf("%dpx: the rail's foot has no account menu", w)
+ }
+ actions = append(actions,
+ chromedp.Click(`[estilo-account] > summary`),
+ chromedp.Sleep(150*time.Millisecond),
+ chromedp.Evaluate(measure, &f),
+ chromedp.KeyEvent("\x1b"),
+ chromedp.Sleep(100*time.Millisecond),
+ chromedp.Evaluate(`!document.querySelector('[estilo-account]').open`, &closedByEscape),
+ chromedp.Evaluate(`document.activeElement === document.querySelector('[estilo-account] > summary')`, &focusBack),
+ chromedp.Click(`[estilo-account] > summary`),
+ chromedp.Sleep(100*time.Millisecond),
+ // The window's top corner, outside the panel at every width (at
+ // 390 the downward panel covers the page's heading).
+ chromedp.MouseClickXY(float64(w)-8, 8),
+ chromedp.Sleep(100*time.Millisecond),
+ chromedp.Evaluate(`!document.querySelector('[estilo-account]').open`, &closedByOutside),
+ )
+ rig.Run(actions...)
+ if !f.Open {
+ t.Fatalf("%dpx: the account menu did not open", w)
+ }
+ if w >= 800 && f.PanelBot > f.TrigTop+0.5 {
+ t.Errorf("%dpx: the panel (%.0f..%.0f) does not open upward from its trigger at %.0f", w, f.PanelTop, f.PanelBot, f.TrigTop)
+ }
+ if w < 800 && f.PanelTop < f.TrigBot-0.5 {
+ t.Errorf("%dpx: inside the Menu disclosure the panel (%.0f..%.0f) should open downward from %.0f", w, f.PanelTop, f.PanelBot, f.TrigBot)
+ }
+ if f.PanelTop < 0 || f.PanelLeft < 0 || f.PanelRite > f.VW+0.5 {
+ t.Errorf("%dpx: the panel is partly off screen: %+v", w, f)
+ }
+ if !f.EdgesHit {
+ t.Errorf("%dpx: the panel's edge is clipped or covered (%.0f..%.0f)", w, f.PanelLeft, f.PanelRite)
+ }
+ if !f.SignOutHit {
+ t.Errorf("%dpx: a tap at Sign out's centre does not reach it", w)
+ }
+ if f.PanelRadius != "9px" || f.TrigRadius != "0px" {
+ t.Errorf("%dpx: panel radius %s (want 9px, it floats), trigger radius %s (want 0px, it is in flow)", w, f.PanelRadius, f.TrigRadius)
+ }
+ if !closedByEscape || !focusBack {
+ t.Errorf("%dpx: Escape closed it: %v; focus back on its trigger: %v", w, closedByEscape, focusBack)
+ }
+ if !closedByOutside {
+ t.Errorf("%dpx: a click outside left it open", w)
+ }
+ }
+}
diff --git a/internal/fichastest/vendored_test.go b/internal/fichastest/vendored_test.go
index 71f4f97..5f0113a 100644
--- a/internal/fichastest/vendored_test.go
+++ b/internal/fichastest/vendored_test.go
@@ -3,6 +3,7 @@ package fichastest
import (
"amadan.net/oficina/estilo.git/busy"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
"bytes"
@@ -115,6 +116,10 @@ func TestDoorFilesAreEstilos(t *testing.T) {
for name, want := range map[string][]byte{
"chrome.css": chrome.CSS(),
"sky.css": sky.CSS(),
+ // The account menu in the rail: its panel and rows, and the script
+ // that closes it on Escape or an outside click.
+ "menu.css": menu.CSS(),
+ "chrome.js": chrome.JS(),
} {
got, err := os.ReadFile(filepath.Join("..", "fichas", "static", name))
if err != nil {
diff --git a/scripts/writeestilo/main.go b/scripts/writeestilo/main.go
index 6f23894..b284d72 100644
--- a/scripts/writeestilo/main.go
+++ b/scripts/writeestilo/main.go
@@ -2,7 +2,8 @@
// the estilo this module pins: theme.css (Calma, with Fichas's accent),
// and rastrillo's busy rule as estilo carries it — rastrillo.js without
// the old rule, busy.js and busy.css — and the sign-in door's two
-// stylesheets, chrome.css and sky.css. Run it from the repository root
+// stylesheets, chrome.css and sky.css; and the account menu in the rail,
+// menu.css and chrome.js. Run it from the repository root
// after upgrading estilo; the pin tests in internal/fichastest fail until you do.
package main
@@ -11,6 +12,7 @@ import (
"amadan.net/oficina/estilo.git/busy"
"amadan.net/oficina/estilo.git/chrome"
+ "amadan.net/oficina/estilo.git/menu"
"amadan.net/oficina/estilo.git/sky"
"amadan.net/oficina/estilo.git/theme"
)
@@ -27,6 +29,8 @@ func main() {
"busy.css": busy.CSS(),
"chrome.css": chrome.CSS(),
"sky.css": sky.CSS(),
+ "menu.css": menu.CSS(),
+ "chrome.js": chrome.JS(),
} {
if err := os.WriteFile("internal/fichas/static/"+name, body, 0o644); err != nil {
panic(err)