carlos / platform Public

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>
Paul Campbell 2cc963e1dd37a5f31e39ae37f4265451f4d7ff1c parent fc94c67
3 files changed, +193 −16
  • internal/console/server.go +7 −0
  • internal/console/server_account_test.go +113 −0
  • internal/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}}