console: the account switcher becomes a dropdown in the wordmark's place — a row of tabs does not scale past three accounts
The header's top left was a static "CARLOS console" wordmark with the account switcher strung out beside it as a row of links, one per account, plus an inline new-account form. It reads as chrome rather than as the control it is, and it grows sideways with every account you join. Now the top left IS the switcher: a <details>/<summary> disclosure whose trigger names the account you are in, opening onto the accounts you are a member of. <details> was chosen over a scripted menu because the platform forbids a build step and a JS framework here, and the element is already a button to a screen reader, already opens on Enter/Space, and already works with scripting off. The only script is an enhancement — Escape and click-away — and removing it changes nothing else. The current account is marked with aria-current and a tick; the others are plain links to /a/<sqid>/, so membership is still the only thing that puts an account in the list. Signed-out and account-less pages keep the wordmark: a dropdown with nothing to drop is worse than no dropdown.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
3 files changed,
+193
−16
internal/console/server.go+7 −0internal/console/server_account_test.go+113 −0internal/console/templates/layout.html+73 −16
diff --git a/internal/console/server.go b/internal/console/server.go| index 011662a43..3f5a6be0c 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -891,6 +891,13 @@ func (s *Server) accountPage(w http.ResponseWriter, r *http.Request, addr, raw s |
| } else { |
| p.Accounts = accts |
| } |
| + // The dropdown's trigger names the current account, so the menu must |
| + // contain it — a list that lost it (a failed query) would open onto a |
| + // menu with no "you are here" row at all. The gate already proved the |
| + // membership, so this is a display fallback, not a permission decision. |
| + if len(p.Accounts) == 0 { |
| + p.Accounts = []Account{*acct} |
| + } |
| return p |
| } |
diff --git a/internal/console/server_account_test.go b/internal/console/server_account_test.go| index d94213795..bbe0dc851 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -197,6 +197,119 @@ func TestSwitcherListsOnlyMyAccounts(t *testing.T) { |
| } |
| } |
| +// header returns everything between <header> and </header> — the chrome, on |
| +// its own, so a switcher assertion cannot be satisfied by the page body |
| +// happening to contain an account name. |
| +func header(t *testing.T, body string) string { |
| + t.Helper() |
| + _, rest, ok := strings.Cut(body, "<header>") |
| + if !ok { |
| + t.Fatalf("no <header> in:\n%s", body) |
| + } |
| + head, _, ok := strings.Cut(rest, "</header>") |
| + if !ok { |
| + t.Fatalf("unterminated <header> in:\n%s", body) |
| + } |
| + return head |
| +} |
| + |
| +// The switcher IS the top-left of the header: a <details>/<summary> |
| +// disclosure naming the current account, in place of the old wordmark. The |
| +// disclosure element matters — it is what makes the dropdown keyboard |
| +// operable and screen-reader sane with no JavaScript at all. |
| +func TestHeaderSwitcherIsADropdownNamingTheCurrentAccount(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + otherID := testAccountID() |
| + if err := fx.st.CreateAccount(otherID, accountSqid(otherID), "Kass", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/")) |
| + h := header(t, body) |
| + |
| + if !strings.Contains(h, "<details class=\"switcher\"") { |
| + t.Errorf("the header switcher is not a <details> disclosure:\n%s", h) |
| + } |
| + if !strings.Contains(h, "<summary") { |
| + t.Errorf("the switcher has no <summary> trigger:\n%s", h) |
| + } |
| + // The trigger says what it is — the current account's name, not an icon. |
| + summary, _, _ := strings.Cut(h, "</summary>") |
| + if !strings.Contains(summary, "ops") { |
| + t.Errorf("the switcher trigger does not name the current account:\n%s", summary) |
| + } |
| + // The wordmark it replaces is gone from an account page's header. |
| + if strings.Contains(h, "CARLOS console") { |
| + t.Errorf("the wordmark is still in the account page header:\n%s", h) |
| + } |
| + // The current account is marked as such, and the others are plain links. |
| + if !strings.Contains(h, `aria-current="true"`) { |
| + t.Errorf("the current account is not marked in the menu:\n%s", h) |
| + } |
| + if !strings.Contains(h, `href="/a/`+accountSqid(otherID)+`/"`) { |
| + t.Errorf("the menu has no link to the user's other account:\n%s", h) |
| + } |
| + // And "New account" is a link to its own screen, not an inline form. |
| + if !strings.Contains(h, `href="/accounts/new"`) { |
| + t.Errorf("the menu has no link to the new-account screen:\n%s", h) |
| + } |
| + if strings.Contains(h, `action="/accounts/new"`) { |
| + t.Errorf("the header still carries the inline new-account form:\n%s", h) |
| + } |
| +} |
| + |
| +// A user with exactly one account still gets a coherent header: the |
| +// dropdown names it, marks it current, and still offers a new account. |
| +func TestSingleAccountHeaderIsCoherent(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/")) |
| + h := header(t, body) |
| + |
| + if accts, _ := fx.st.AccountsFor("paul@keymail.dev"); len(accts) != 1 { |
| + t.Fatalf("fixture user has %d accounts, want exactly 1", len(accts)) |
| + } |
| + if !strings.Contains(h, "<summary") || !strings.Contains(h, `aria-current="true"`) { |
| + t.Errorf("single-account header is not a coherent switcher:\n%s", h) |
| + } |
| + if strings.Count(h, `aria-current="true"`) != 1 { |
| + t.Errorf("single-account menu marks something other than one current account:\n%s", h) |
| + } |
| + if !strings.Contains(h, `href="/accounts/new"`) { |
| + t.Errorf("single-account header lost the new-account link:\n%s", h) |
| + } |
| +} |
| + |
| +// Keyless (read-only) deployments degrade MUTATIONS, never the chrome: the |
| +// switcher is navigation, and losing it would strand a read-only operator in |
| +// whichever account they happened to land in. |
| +func TestKeylessStillRendersTheSwitcher(t *testing.T) { |
| + fx := newConsoleFixtureKeyed(t, false) |
| + c := signInPaul(t, fx) |
| + _, _, body := get(t, c, fx.u("/")) |
| + h := header(t, body) |
| + if !strings.Contains(h, "<details class=\"switcher\"") || !strings.Contains(h, "ops") { |
| + t.Errorf("keyless mode dropped the account switcher:\n%s", h) |
| + } |
| + if !strings.Contains(h, `href="/accounts/new"`) { |
| + t.Errorf("keyless mode dropped the new-account link:\n%s", h) |
| + } |
| +} |
| + |
| +// Signed-out pages have no account to name, so they keep the wordmark — |
| +// a header with an empty dropdown in it would be worse than no dropdown. |
| +func TestSignedOutHeaderKeepsTheWordmark(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + _, _, body := get(t, newBrowser(t), fx.ts.URL+"/") |
| + h := header(t, body) |
| + if !strings.Contains(h, "CARLOS console") { |
| + t.Errorf("signed-out header lost the wordmark:\n%s", h) |
| + } |
| + if strings.Contains(h, "<details class=\"switcher\"") { |
| + t.Errorf("signed-out header renders an accountless switcher:\n%s", h) |
| + } |
| +} |
| + |
| // The audit trail is per (account, app), not per app name. Two accounts can |
| // both own a "blog"; sharing one activity stream would show each account the |
| // other's actions — including the other account's member addresses, which is |
diff --git a/internal/console/templates/layout.html b/internal/console/templates/layout.html| index ec1fc570a..b6954b769 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -51,12 +51,44 @@ |
| font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem; |
| } |
| header .addr { color: var(--muted); } |
| + /* The account switcher sits where the wordmark used to: a <details> |
| + disclosure, so it is a real button to a screen reader, opens on |
| + Enter/Space, and needs no JavaScript to work at all. The inline script |
| + at the foot only adds Escape and click-away, which are enhancements. */ |
| header .switcher { |
| - display: flex; align-items: baseline; gap: 0.5rem; flex-wrap: wrap; |
| - font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; font-size: 0.9rem; |
| + position: relative; |
| + font-family: ui-sans-serif, system-ui, -apple-system, sans-serif; |
| } |
| - header .switcher form { display: flex; gap: 0.4rem; align-items: baseline; margin: 0; } |
| - header .switcher input[type=text] { font-size: 0.8rem; padding: 0.2rem 0.4rem; width: 8rem; } |
| + header .switcher > summary { |
| + display: inline-flex; align-items: baseline; gap: 0.4rem; |
| + list-style: none; cursor: pointer; |
| + padding: 0.25rem 0.5rem; margin: -0.25rem -0.5rem; border-radius: 0.5rem; |
| + } |
| + header .switcher > summary::-webkit-details-marker { display: none; } |
| + header .switcher > summary:hover { background: var(--accent-soft); } |
| + header .switcher .current-name { font-weight: 700; } |
| + header .switcher .caret { font-size: 0.7rem; color: var(--muted); } |
| + header .switcher[open] > summary .caret { transform: rotate(180deg); display: inline-block; } |
| + header .switcher-menu { |
| + position: absolute; z-index: 20; top: calc(100% + 0.6rem); left: -0.5rem; |
| + min-width: 13rem; max-width: 20rem; |
| + background: var(--bg); border: 1px solid var(--rule); border-radius: 0.6rem; |
| + padding: 0.35rem; font-size: 0.9rem; |
| + box-shadow: 0 0.4rem 1.5rem rgba(0, 0, 0, 0.14); |
| + } |
| + header .switcher-menu ul { list-style: none; margin: 0; padding: 0; } |
| + header .switcher-menu a, header .switcher-menu .current { |
| + display: flex; align-items: baseline; gap: 0.5rem; |
| + padding: 0.35rem 0.55rem; border-radius: 0.4rem; |
| + color: var(--ink); text-decoration: none; |
| + } |
| + header .switcher-menu a:hover, header .switcher-menu a:focus { background: var(--accent-soft); } |
| + header .switcher-menu .current { font-weight: 600; background: var(--accent-soft); } |
| + /* The tick reserves its width on every row so the names stay in one column. */ |
| + header .switcher-menu .tick { color: var(--accent); width: 0.9rem; flex: none; } |
| + header .switcher-menu a .tick { visibility: hidden; } |
| + header .switcher-menu hr { border: 0; border-top: 1px solid var(--rule); margin: 0.35rem 0.2rem; } |
| + header .switcher-menu .new a { color: var(--accent); } |
| main { max-width: 56rem; margin: 0 auto; padding: 2.5rem 0 3rem; min-height: 55vh; } |
| h1 { font-size: 1.9rem; line-height: 1.15; margin: 0 0 1rem; } |
| h2 { font-size: 1.25rem; margin: 2rem 0 0.75rem; } |
| @@ -117,19 +149,28 @@ |
| </head> |
| <body> |
| <header> |
| - <div class="brand"><a href="/">CARLOS console</a></div> |
| {{if .Account}} |
| - <div class="switcher"> |
| - <span class="small">Account</span> |
| - <strong>{{.Account.Name}}</strong> |
| - <span class="small">·</span> |
| - {{range .Accounts}}{{if ne .Sqid $.Account.Sqid}}<a href="/a/{{.Sqid}}/">{{.Name}}</a> {{end}}{{end}} |
| - <form method="post" action="/accounts/new"> |
| - <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| - <input type="text" name="name" placeholder="New account"> |
| - <button class="linkish" type="submit">New account</button> |
| - </form> |
| - </div> |
| + <details class="switcher" id="switcher"> |
| + <summary> |
| + <span class="small">Account</span> |
| + <span class="current-name">{{.Account.Name}}</span> |
| + <span class="caret" aria-hidden="true">▾</span> |
| + </summary> |
| + <div class="switcher-menu"> |
| + <ul> |
| + {{range .Accounts}}<li>{{if eq .Sqid $.Account.Sqid}} |
| + <span class="current" aria-current="true"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</span> |
| + {{else}} |
| + <a href="/a/{{.Sqid}}/"><span class="tick" aria-hidden="true">✓</span>{{.Name}}</a> |
| + {{end}}</li> |
| + {{end}} |
| + </ul> |
| + <hr> |
| + <div class="new"><a href="/accounts/new"><span class="tick" aria-hidden="true">✓</span>New account</a></div> |
| + </div> |
| + </details> |
| + {{else}} |
| + <div class="brand"><a href="/">CARLOS console</a></div> |
| {{end}} |
| {{if .Address}}<nav> |
| {{if .Account}}<a href="{{.Base}}/">Apps</a>{{end}} |
| @@ -146,6 +187,22 @@ |
| {{define "bottom"}} |
| </main> |
| <footer>🤖 Console text written by an LLM, per <a href="https://11factor.org/#x">factor X</a>.</footer> |
| +<script> |
| +// Enhancement only: <details> already opens, closes and focuses on its own. |
| +// This adds the two behaviours a menu is expected to have and the element |
| +// does not — Escape closes it (returning focus to the trigger), and a click |
| +// anywhere else closes it. With JS off, the dropdown is unchanged. |
| +(function () { |
| + var d = document.getElementById("switcher"); |
| + if (!d) return; |
| + document.addEventListener("keydown", function (e) { |
| + if (e.key === "Escape" && d.open) { d.open = false; d.firstElementChild.focus(); } |
| + }); |
| + document.addEventListener("click", function (e) { |
| + if (d.open && !d.contains(e.target)) d.open = false; |
| + }); |
| +})(); |
| +</script> |
| </body> |
| </html> |
| {{end}} |