merge: the account switcher becomes a dropdown; New account gets its own screen
Replaces the CARLOS console wordmark with a <details> disclosure naming the current account — no JS framework, no build step, keyboard-operable, and it degrades to a plain open list without JavaScript at all. New account moves out of the header into its own gated screen.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+390
−31
README.md+5 −1internal/console/server.go+75 −14internal/console/server_account_test.go+208 −0internal/console/templates/layout.html+84 −16internal/console/templates/newaccount.html+18 −0
diff --git a/README.md b/README.md| index c73305cec..72dd3b053 100644 |
| --- a/README.md |
| +++ b/README.md |
| @@ -57,7 +57,11 @@ console-less self-hoster is unaffected by any of this. |
| Every account has at least one owner; the last one can be neither removed nor |
| demoted (transfer first). |
| -**In the console.** The account switcher sits top left on every account page. |
| +**In the console.** The account switcher sits top left on every account page, |
| +where the wordmark used to be: a dropdown whose trigger names the account you |
| +are in, listing the accounts you are a member of and linking to the |
| +**New account** screen at `/accounts/new`. It is a `<details>` disclosure, so |
| +it is keyboard-operable and works with scripting off. |
| URLs are explicit and shareable — `/a/<sqid>/apps/<app>` — so a link a |
| teammate opens lands where you meant; `/` redirects to the account you were |
| last in (or your first membership, or one minted on the spot). A signed-in |
diff --git a/internal/console/server.go b/internal/console/server.go| index cd252ba3f..712ef50d3 100644 |
| --- a/internal/console/server.go |
| +++ b/internal/console/server.go |
| @@ -150,6 +150,7 @@ func NewServer(cfg Config, st *Store) (*Server, error) { |
| s.mux.HandleFunc("POST /a/{sqid}/invite", s.handleInvite) |
| s.mux.HandleFunc("POST /a/{sqid}/invite/revoke", s.handleRevokeInvite) |
| s.mux.HandleFunc("GET /invite/{token}", s.handleAcceptInvite) |
| + s.mux.HandleFunc("GET /accounts/new", s.handleNewAccountForm) |
| s.mux.HandleFunc("POST /accounts/new", s.handleNewAccount) |
| // The pre-accounts URLs, kept as redirects so old links and bookmarks |
| // still land somewhere sensible rather than 404ing. |
| @@ -341,31 +342,50 @@ func (s *Server) redirectToCurrentAccount(w http.ResponseWriter, r *http.Request |
| // if still a member — an account you were removed from must not strand you), |
| // first membership, or mint. |
| func (s *Server) currentAccount(ctx context.Context, addr string) (string, error) { |
| - if id, err := s.store.LastAccount(addr); err != nil { |
| + a, err := s.memberAccount(addr) |
| + if err != nil { |
| + return "", err |
| + } |
| + if a != nil { |
| + return a.Sqid, nil |
| + } |
| + m, err := s.mintAccount(ctx, addr, addr) |
| + if err != nil { |
| return "", err |
| + } |
| + return m.Sqid, nil |
| +} |
| + |
| +// memberAccount is currentAccount's first two cases WITHOUT the mint: the MRU |
| +// account if the viewer is still a member of it, else their first membership |
| +// by creation order, else nil. Split out because pages that merely want the |
| +// header's account context — /accounts/new — must not create an account as a |
| +// side effect of being looked at. Only landing mints. |
| +func (s *Server) memberAccount(addr string) (*Account, error) { |
| + if id, err := s.store.LastAccount(addr); err != nil { |
| + return nil, err |
| } else if id != 0 { |
| if role, err := s.store.Role(id, addr); err != nil { |
| - return "", err |
| + return nil, err |
| } else if role != "" { |
| - if a, err := s.store.AccountByID(id); err != nil { |
| - return "", err |
| - } else if a != nil { |
| - return a.Sqid, nil |
| + a, err := s.store.AccountByID(id) |
| + if err != nil { |
| + return nil, err |
| + } |
| + if a != nil { |
| + a.Role = role |
| + return a, nil |
| } |
| } |
| } |
| accts, err := s.store.AccountsFor(addr) |
| if err != nil { |
| - return "", err |
| + return nil, err |
| } |
| if len(accts) > 0 { |
| - return accts[0].Sqid, nil |
| - } |
| - a, err := s.mintAccount(ctx, addr, addr) |
| - if err != nil { |
| - return "", err |
| + return &accts[0], nil |
| } |
| - return a.Sqid, nil |
| + return nil, nil |
| } |
| // mintAccount creates an account owned by addr: the next free id allocated in |
| @@ -476,6 +496,38 @@ func (s *Server) handleLegacyApp(w http.ResponseWriter, r *http.Request) { |
| http.Redirect(w, r, "/a/"+url.PathEscape(sqid)+"/apps/"+url.PathEscape(r.PathValue("app")), http.StatusFound) |
| } |
| +// handleNewAccountForm is the "New account" screen: its own gated page at its |
| +// own URL, linked from the switcher, rather than a text box wedged into the |
| +// header. It takes no account parameter of any kind — there is nothing here |
| +// to point at another account, so it cannot become a way to probe one — and |
| +// it deliberately does NOT record an MRU: you are not "in" an account here, |
| +// and moving the pointer would send the next sign-in somewhere never chosen. |
| +func (s *Server) handleNewAccountForm(w http.ResponseWriter, r *http.Request) { |
| + addr, raw, ok := s.session(r) |
| + if !ok { |
| + http.Redirect(w, r, "/", http.StatusFound) |
| + return |
| + } |
| + p := page{Title: "New account", Address: addr, CSRF: raw} |
| + p.Flash, p.FlashErr = s.takeFlash(w, r) |
| + if accts, err := s.store.AccountsFor(addr); err != nil { |
| + log.Printf("console: switcher: %v", err) |
| + } else { |
| + p.Accounts = accts |
| + } |
| + // The header chrome wants an account to name in the switcher's trigger. |
| + // memberAccount, not currentAccount: looking at this page must never mint. |
| + // A user with no memberships at all simply gets the wordmark header. |
| + if a, err := s.memberAccount(addr); err != nil { |
| + log.Printf("console: new-account chrome: %v", err) |
| + } else if a != nil { |
| + p.Account = a |
| + p.Role = a.Role |
| + p.Base = "/a/" + url.PathEscape(a.Sqid) |
| + } |
| + s.render(w, http.StatusOK, "newaccount", p) |
| +} |
| + |
| // handleNewAccount creates an account for any signed-in user. |
| func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| addr, raw, ok := s.session(r) |
| @@ -489,8 +541,10 @@ func (s *Server) handleNewAccount(w http.ResponseWriter, r *http.Request) { |
| } |
| name := strings.TrimSpace(r.FormValue("name")) |
| if name == "" || len(name) > 64 { |
| + // Back to the screen the mistake was made on, not off to some other |
| + // account: the form is a page now, and a page can show its own error. |
| s.setFlash(w, "err", "An account needs a name (up to 64 characters).") |
| - s.redirectToCurrentAccount(w, r, addr, http.StatusSeeOther) |
| + http.Redirect(w, r, "/accounts/new", http.StatusSeeOther) |
| return |
| } |
| a, err := s.mintAccount(r.Context(), name, addr) |
| @@ -895,6 +949,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..f0969ea6c 100644 |
| --- a/internal/console/server_account_test.go |
| +++ b/internal/console/server_account_test.go |
| @@ -197,6 +197,214 @@ 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) |
| + } |
| +} |
| + |
| +// "New account" is a screen of its own at its own URL: gated on a session, |
| +// CSRF on its POST, and creating from it lands you in the new account. |
| +func TestNewAccountScreen(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + |
| + // Signed out, it is not a page you can see. |
| + if st, loc, _ := get(t, newBrowser(t), fx.ts.URL+"/accounts/new"); st != http.StatusFound || loc != "/" { |
| + t.Fatalf("signed-out /accounts/new = %d -> %q, want 302 -> /", st, loc) |
| + } |
| + |
| + c := signInPaul(t, fx) |
| + st, _, body := get(t, c, fx.ts.URL+"/accounts/new") |
| + if st != http.StatusOK { |
| + t.Fatalf("/accounts/new = %d, want 200", st) |
| + } |
| + if !strings.Contains(body, `action="/accounts/new"`) || !strings.Contains(body, `name="csrf"`) { |
| + t.Fatalf("the new-account screen has no CSRF-bearing form:\n%s", body) |
| + } |
| + // It is a page, not a header ornament: the chrome is still there. |
| + if !strings.Contains(header(t, body), "<details class=\"switcher\"") { |
| + t.Errorf("the new-account screen lost the header switcher:\n%s", body) |
| + } |
| + |
| + // The POST still refuses a forged CSRF token. |
| + if st, _, _ := postForm(t, c, fx.ts.URL+"/accounts/new", |
| + url.Values{"csrf": {"wrong"}, "name": {"Nope"}}); st != http.StatusForbidden { |
| + t.Errorf("new account with a bad csrf = %d, want 403", st) |
| + } |
| + |
| + // Creating from the screen lands you in the account it made. |
| + csrf := sessionToken(t, fx, c) |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", |
| + url.Values{"csrf": {csrf}, "name": {"Woodstar"}}) |
| + if st != http.StatusSeeOther { |
| + t.Fatalf("create = %d -> %q, want 303", st, loc) |
| + } |
| + accts, _ := fx.st.AccountsFor("paul@keymail.dev") |
| + var made *Account |
| + for i := range accts { |
| + if accts[i].Name == "Woodstar" { |
| + made = &accts[i] |
| + } |
| + } |
| + if made == nil { |
| + t.Fatalf("AccountsFor = %+v, want a Woodstar", accts) |
| + } |
| + if loc != "/a/"+made.Sqid+"/" { |
| + t.Fatalf("landed at %q, want /a/%s/", loc, made.Sqid) |
| + } |
| + // And the account you landed in is the one the header now names. |
| + _, _, body = get(t, c, fx.ts.URL+loc) |
| + if h := header(t, body); !strings.Contains(h, "Woodstar") { |
| + t.Errorf("the header does not name the account just created:\n%s", h) |
| + } |
| +} |
| + |
| +// A nameless submission is a mistake to correct on the screen you are on, |
| +// not a bounce back to some other account. |
| +func TestNewAccountScreenRejectsAnEmptyNameInPlace(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + csrf := sessionToken(t, fx, c) |
| + |
| + st, loc, _ := postForm(t, c, fx.ts.URL+"/accounts/new", url.Values{"csrf": {csrf}, "name": {" "}}) |
| + if st != http.StatusSeeOther || loc != "/accounts/new" { |
| + t.Fatalf("empty name = %d -> %q, want 303 -> /accounts/new", st, loc) |
| + } |
| + if _, _, body := get(t, c, fx.ts.URL+"/accounts/new"); !strings.Contains(body, "needs a name") { |
| + t.Errorf("the screen does not say why it refused:\n%s", body) |
| + } |
| +} |
| + |
| +// Visiting an account page updates the MRU; visiting /accounts/new does not |
| +// — it is not an account you are "in", and moving the MRU there would send |
| +// the next sign-in somewhere the user never chose. |
| +func TestNewAccountScreenLeavesTheMRUAlone(t *testing.T) { |
| + fx := newConsoleFixture(t) |
| + c := signInPaul(t, fx) |
| + |
| + id := testAccountID() |
| + sqid := accountSqid(id) |
| + if err := fx.st.CreateAccount(id, sqid, "Second", "paul@keymail.dev", time.Now()); err != nil { |
| + t.Fatal(err) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/a/"+sqid+"/"); st != http.StatusOK { |
| + t.Fatalf("second account home = %d", st) |
| + } |
| + if st, _, _ := get(t, c, fx.ts.URL+"/accounts/new"); st != http.StatusOK { |
| + t.Fatalf("/accounts/new = %d", st) |
| + } |
| + if st, loc, _ := get(t, c, fx.ts.URL+"/"); st != http.StatusFound || loc != "/a/"+sqid+"/" { |
| + t.Fatalf("landing = %d -> %q, want the MRU account /a/%s/", st, loc, sqid) |
| + } |
| +} |
| + |
| // 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..7d2975305 100644 |
| --- a/internal/console/templates/layout.html |
| +++ b/internal/console/templates/layout.html |
| @@ -51,18 +51,61 @@ |
| 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); } |
| + /* Names are free text up to 64 characters, which unbounded would shove the |
| + nav off the header — truncate in the trigger, wrap in the menu. */ |
| + header .switcher .current-name { |
| + font-weight: 700; min-width: 0; max-width: 14rem; |
| + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; |
| + } |
| + 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; overflow-wrap: anywhere; |
| + } |
| + 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; } |
| p { max-width: 40rem; margin: 0 0 1rem; } |
| .lede { font-size: 1.15rem; color: var(--muted); } |
| .small { font-size: 0.9rem; color: var(--muted); } |
| + /* Present to a screen reader, absent to the eye — for labels the visual |
| + design carries in placement or placeholder text alone. */ |
| + .visually-hidden { |
| + position: absolute; width: 1px; height: 1px; overflow: hidden; |
| + clip-path: inset(50%); white-space: nowrap; |
| + } |
| .error { color: var(--bad); } |
| .banner { |
| background: var(--accent-soft); border-radius: 0.5rem; |
| @@ -117,19 +160,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 +198,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}} |
diff --git a/internal/console/templates/newaccount.html b/internal/console/templates/newaccount.html| new file mode 100644 |
| index 000000000..8bcfe1d3a |
| --- /dev/null |
| +++ b/internal/console/templates/newaccount.html |
| @@ -0,0 +1,18 @@ |
| +{{define "newaccount"}}{{template "top" .}} |
| +<h1>New account</h1> |
| +<p class="lede">An account is a project or a team: it owns apps, has members, |
| +and deploys independently of your other accounts.</p> |
| +{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| +{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}} |
| +<form class="signin" method="post" action="/accounts/new"> |
| + <input type="hidden" name="csrf" value="{{.CSRF}}"> |
| + <label class="visually-hidden" for="account-name">Account name</label> |
| + <input id="account-name" type="text" name="name" placeholder="Kass" autofocus> |
| + <button type="submit">Create account</button> |
| +</form> |
| +<p class="small">Names are free text, up to 64 characters, and deliberately |
| +not unique — two accounts may both be called “Blog”. Identity is the short |
| +id in the URL, so there is nothing to squat. You will be its first owner, |
| +and you can invite teammates from the account's page.</p> |
| +{{if .Account}}<p class="small"><a href="{{.Base}}/">Back to {{.Account.Name}}</a></p>{{end}} |
| +{{template "bottom" .}}{{end}} |