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>
17 files changed,
+627
−17
go.mod+1 −1go.sum+4 −0internal/fichas/app.go+6 −0internal/fichas/auth_navigation.go+1 −1internal/fichas/config.go+25 −0internal/fichas/config_test.go+18 −0internal/fichas/handlers.go+46 −3internal/fichas/render.go+14 −8internal/fichas/static/chrome.css+38 −0internal/fichas/static/chrome.js+82 −0internal/fichas/static/menu.css+147 −0internal/fichas/templates/layout.html+1 −1internal/fichas/templates/shell.html+8 −2internal/fichastest/account_test.go+129 −0internal/fichastest/browser_test.go+97 −0internal/fichastest/vendored_test.go+5 −0scripts/writeestilo/main.go+5 −1
CI passed — run details
CI log
=== vet === go vet ./... go: downloading amadan.net/oficina/estilo.git v0.0.0-20261001111451-fd9e235901e0 === fmt === === test === go test ./... ? amadan.net/oficina/fichas/cmd/fichas [no test files] ok amadan.net/oficina/fichas/internal/fichas 1.383s ? amadan.net/oficina/fichas/internal/fichas/icons [no test files] ok amadan.net/oficina/fichas/internal/fichas/seal 0.009s ok amadan.net/oficina/fichas/internal/fichastest 0.783s ? amadan.net/oficina/fichas/scripts/mockups [no test files] ? amadan.net/oficina/fichas/scripts/preview [no test files] ? amadan.net/oficina/fichas/scripts/writeestilo [no test files] === migration-check === go run github.com/carlosframework/rastrillo/cmd/rastrillo migration check rastrillo migration check: models and migrations agree
diff --git a/go.mod b/go.mod| index 9cbc513..e6dc64e 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-20261001111451-fd9e235901e0 |
| github.com/carlosframework/rastrillo v0.23.0 |
| 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 acce90b..fe59d2c 100644 |
| --- a/go.sum |
| +++ b/go.sum |
| @@ -1,5 +1,9 @@ |
| 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-20261001110804-bb94a4d98eeb h1:hBuqJyIrUbO3H6CgnNF7s6sPadAyNbQjRdOyQBCM+0Q= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001110804-bb94a4d98eeb/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001111451-fd9e235901e0 h1:/6Or16D7/oNMAKFt9bV+elZSY1yRBjilpAbPNtMLaog= |
| +amadan.net/oficina/estilo.git v0.0.0-20261001111451-fd9e235901e0/go.mod h1:4u0jNBtIdMLOsJyrScfqoEuYDJF7BBM3qM9YWbzt6aw= |
| github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= |
| github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= |
| github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w= |
diff --git a/internal/fichas/app.go b/internal/fichas/app.go| index daabd02..cc01670 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 c527e8a..fd80c71 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" |
| "github.com/carlosframework/rastrillo/auth" |
| "github.com/carlosframework/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 8f78862..84a4179 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" |
| "github.com/carlosframework/rastrillo" |
| "github.com/carlosframework/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 8e641bb..0fe04f9 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..edd1101 |
| --- /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" |
| + "github.com/carlosframework/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 a732076..7b73df8 100644 |
| --- a/internal/fichastest/browser_test.go |
| +++ b/internal/fichastest/browser_test.go |
| @@ -223,3 +223,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 792bc19..bd6c64e 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" |
| @@ -108,6 +109,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) |