carlos / platform Public

plan: console redesign — 14 tasks, additive tabs, suite green per commit

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 2c5a38c56c71cc6556f53ae576494fc13323a6ba parent 8d7089c
1 file changed, +1342 −0
  • docs/superpowers/plans/2026-08-06-console-redesign.md +1342 −0
diff --git a/docs/superpowers/plans/2026-08-06-console-redesign.md b/docs/superpowers/plans/2026-08-06-console-redesign.md
new file mode 100644
index 000000000..ba7f9d291
--- /dev/null
+++ b/docs/superpowers/plans/2026-08-06-console-redesign.md
@@ -0,0 +1,1342 @@
+# Console Redesign Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Reskin the CARLOS console in the carloku.com design language and split its two overloaded pages into a Heroku-style IA: apps / team / settings sections per account, five tabs per app, a create-app page, and a personal settings page.
+
+**Architecture:** Go `html/template`, all CSS inline in `internal/console/templates/layout.html`, zero JS added, zero off-origin resources. GET pages split; **no POST path moves** (step-up stash/resume re-enters the same path). Tabs are built additively (Tasks 8–11) while the old one-page app view keeps working; the old page retires in Task 12 — **every commit keeps the suite green.** Every new account-scoped GET uses the existing `account()`/`owner()`/`accountApp()` gates so non-members keep getting byte-identical 404s.
+
+**Tech Stack:** Go 1.22 `http.ServeMux` method+pattern routing, `html/template`, vendored Lucide SVG icons (`icons.go`), stdlib-only tests via `httptest` fixtures in `internal/console/server_test.go`.
+
+**Spec:** `docs/superpowers/specs/2026-08-06-console-redesign-design.md`
+
+**Delegation (CLAUDE.md policy):** implement tasks with **Sonnet** using this plan as the brief; every task touching handlers/gates (3–13) gets an **Opus review**; name the model explicitly on every dispatch.
+
+## Global Constraints
+
+- Build/test loop, run from the repo root of THIS worktree:
+ `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
+ (may need the sandbox disabled — "read-only file system" from the toolchain is the tell).
+ Final gate: `go build ./... && go vet ./... && go test ./... -count=1` all clean; `gofmt -l` clean on touched files.
+- `icons_test.go` invariants are law: no `<link>` of any kind (no favicon, no webfont), no `<script src`, no off-origin anything, no text-glyph arrows/carets/checks in header chrome (`▾ ✓ × →` etc. — use `{{icon "slug"}}`), the 🤖 factor-X footer keeps its emoji.
+- Gate order in every POST handler: session → membership/ownership (byte-identical 404) → CSRF (`subtle.ConstantTimeCompare`) → `requireFreshAuth` LAST. New GET handlers: gate first, render after; never leak account existence.
+- Links use `{{.Nav}}` (name-or-sqid), forms post under `{{.Base}}` (sqid). Never mix.
+- Templates: a page is `{{define "x"}}{{template "top" .}} … {{template "bottom" .}}{{end}}`. A new page needs: `templates/<name>.html`, any new fields on `page` (server.go:312), a handler, one `mux.HandleFunc` line in `NewServer` (server.go:238-275).
+- Where a test snippet below guesses a fixture accessor wrong, follow the idiom of the named existing test — never weaken what the snippet asserts.
+- Commit after every task. Messages end with
+ `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`.
+
+---
+
+### Task 1: Design tokens + typography reskin (CSS only)
+
+**Files:**
+- Modify: `internal/console/templates/layout.html:7-213` (the `<style>` block only)
+
+**Interfaces:**
+- Produces: CSS classes `.pagehead`, `.eyebrow`, `.tabs`, `button.linkish.danger`, `a.addr`, and CSS vars `--sans`, `--mono`, `--warn`, `--accent-strong` — used by every later task. No template markup changes in this task.
+
+- [ ] **Step 1: Run the console suite to record a green baseline**
+
+Run: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./internal/console/ -count=1`
+Expected: PASS (this task must not change it — no test asserts the serif stack or any color).
+
+- [ ] **Step 2: Replace the token block** (`layout.html:8-19`) with the carloku palette:
+
+```css
+ :root {
+ --bg: #ffffff; --ink: #201d29; --muted: #6f6480;
+ --accent: #5b3e96; --accent-strong: #4c3380;
+ --accent-soft: rgba(91, 62, 150, 0.09);
+ --rule: #eae7f0; --bad: #b3403a; --warn: #a6790f;
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+ --sans: ui-sans-serif, system-ui, -apple-system, sans-serif;
+ }
+ @media (prefers-color-scheme: dark) {
+ :root {
+ --bg: #16141c; --ink: #f0eef6; --muted: #a99ebd;
+ --accent: #b394f2; --accent-strong: #c9b2f7;
+ --accent-soft: rgba(179, 148, 242, 0.12);
+ --rule: #2c2836; --bad: #e07b76; --warn: #d9a94a;
+ }
+ }
+```
+
+- [ ] **Step 3: Retype the page.** Precise edits, all inside the `<style>` block:
+
+1. `body` (`:21-25`): `font: 400 1rem/1.6 var(--sans);` (replaces the Charter/Georgia serif line; keep the rest of the rule).
+2. `code` (`:28`) and `pre` (`:32`): `font-family: var(--mono);`.
+3. `select, input[type=text]` (`:38`): add `font-family: var(--sans);` and change `border-radius` to `0.4rem`.
+4. The six `font-family: ui-sans-serif, system-ui, -apple-system, sans-serif;` lines: **delete** from `header nav` (`:56`), `.switcher` (`:65`), `button, .btn` (`:197`), `footer` (`:210`) — redundant now the body is sans; **replace with** `font-family: var(--mono);` in `.rung-name` (`:171`) and `th` (`:189`) — mono is the working typeface for column headers and rung labels.
+5. `.tile h2` (`:131`): add `font-family: var(--mono);`.
+6. `.chip` (`:139`): add `font-family: var(--mono);` and change `font-weight` to `500`.
+7. Widths: `header` (`:49`), `main` (`:110`), `footer` (`:208`): `max-width: 64rem;`.
+8. `button, .btn` (`:196-202`): `border-radius: 0.4rem;`, then add after `button.linkish` (`:203-206`):
+
+```css
+ button:not(.linkish):hover, .btn:hover { background: var(--accent-strong); }
+ button.linkish.danger { color: var(--bad); }
+```
+
+- [ ] **Step 4: Add the new component CSS** (append at the end of the `<style>` block):
+
+```css
+ /* Page anatomy: every page opens with a mono eyebrow naming the context,
+ an h1, and one human sentence saying what the page is for. */
+ .pagehead { margin: 0 0 1.75rem; }
+ .eyebrow {
+ font-family: var(--mono); font-size: 0.75rem; letter-spacing: 0.08em;
+ text-transform: uppercase; color: var(--muted); margin: 0 0 0.3rem;
+ }
+ .pagehead h1 { margin: 0 0 0.35rem; }
+ .pagehead .lede { margin: 0; font-size: 1.05rem; }
+ /* App tab bar (Heroku-style), and the account nav's active marker.
+ aria-current carries the state; the accent underline draws it. */
+ .tabs {
+ display: flex; flex-wrap: wrap; gap: 1.25rem; margin: 0 0 2rem;
+ border-bottom: 1px solid var(--rule);
+ font-family: var(--mono); font-size: 0.85rem;
+ }
+ .tabs a {
+ color: var(--muted); text-decoration: none;
+ padding: 0 0 0.55rem; border-bottom: 2px solid transparent; margin-bottom: -1px;
+ }
+ .tabs a:hover { color: var(--ink); }
+ .tabs a[aria-current] { color: var(--ink); border-bottom-color: var(--accent); }
+ header nav a[aria-current] { color: var(--ink); font-weight: 600; text-decoration: none; }
+ header nav a.addr { color: var(--muted); text-decoration: none; }
+ header nav a.addr:hover { text-decoration: underline; }
+```
+
+- [ ] **Step 5: Re-run the console suite**
+
+Run: the Step 1 command. Expected: PASS unchanged. If a test fails it was asserting a style detail — fix the assertion to the new value, never revert the design.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/console/templates/layout.html
+git commit -m "console: adopt the carloku design tokens and retype the page
+
+Sans body, mono as the working typeface, purple accent, 64rem measure.
+Defines --warn (was referenced but undefined) and the .pagehead/.tabs
+vocabulary the IA split builds on."
+```
+
+---
+
+### Task 2: Signed-out home hero
+
+**Files:**
+- Modify: `internal/console/templates/home.html`
+- Test: `internal/console/server_test.go` (whichever tests assert home copy — grep `"The CARLOS platform console"`)
+
+- [ ] **Step 1: Rewrite `home.html`** (keep every `{{…}}` action that exists today — Error, the signin form):
+
+```html
+{{define "home"}}{{template "top" .}}
+<div class="pagehead">
+ <p class="eyebrow">🤖 CARLOS console</p>
+ <h1>Ship it. Promote it. Sleep<span style="color: var(--accent)">.</span></h1>
+ <p class="lede">Your apps, their live routes, and their release channels —
+ signed releases in, immutable bytes out.</p>
+</div>
+{{if .Error}}<p class="error">{{.Error}}</p>{{end}}
+<form class="signin" method="get" action="/auth/signin">
+ <button type="submit">Sign in with Keymail</button>
+</form>
+<p class="small">One button, whether it's your first time or your five-hundredth.
+First visits approve the console once in your Keymail — that single approval
+is the whole sign-up. After that, the same button is a one-click sign-in.
+No passwords, ever; revoke the console from Keymail's connected apps at any
+time.</p>
+{{template "bottom" .}}{{end}}
+```
+
+The eyebrow's 🤖 is content (the carloku wordmark line), not header chrome, so `TestSwitcherChromeIsInlineSVGNotTextGlyphs` (header-scoped) is unaffected; an inline `style` attribute breaks no icons_test invariant (those ban `<link>`, `src=`, `url(http…)`).
+
+- [ ] **Step 2: Run the console suite; update any test asserting the old h1**
+
+Run: the console suite. Expected: tests asserting `The CARLOS platform console` fail; change those wants to `Ship it. Promote it. Sleep`.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/console/templates/home.html internal/console/*_test.go
+git commit -m "console: carloku hero on the signed-out home"
+```
+
+---
+
+### Task 3: Create-app page (`GET /a/{sqid}/apps/new`)
+
+**Files:**
+- Create: `internal/console/templates/app_new.html`
+- Modify: `internal/console/server.go` (route, `handleNewAppForm`, `handleNewApp` error-redirect, `claimApp` reserved name), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `account()` (server.go:601), `accountPage()` (server.go:1174).
+- Produces: `page.ActiveNav string` field; `assertNonMember404(t, fx, sub)` test helper — every later task's 404-parity step uses it.
+
+- [ ] **Step 1: Write the failing tests** (append to `server_test.go`):
+
+```go
+// assertNonMember404 signs bob (no membership in the fixture account) in and
+// asserts the account-scoped path answers him byte-identically to an account
+// that does not exist at all.
+func assertNonMember404(t *testing.T, fx *consoleFixture, sub string) {
+ t.Helper()
+ fx.setUser("bob@keymail.dev")
+ c := newBrowser(t)
+ signUp(t, fx, c, "code-nm-"+t.Name())
+ s1, _, b1 := get(t, c, fx.u(sub))
+ s2, _, b2 := get(t, c, fx.ts.URL+"/a/zzzzzzzz"+sub)
+ if s1 != http.StatusNotFound || s2 != http.StatusNotFound || b1 != b2 {
+ t.Fatalf("non-member leak on %s: member-URL=%d unknown-URL=%d identical=%v", sub, s1, s2, b1 == b2)
+ }
+}
+
+func TestCreateAppPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/new"))
+ if status != 200 {
+ t.Fatalf("GET /apps/new = %d", status)
+ }
+ for _, want := range []string{
+ "Create an app", `action="` + fx.path("/apps/new") + `"`,
+ "lowercase letters, digits, and dashes",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("create-app page missing %q", want)
+ }
+ }
+ // The account home now links here instead of embedding the form.
+ _, _, home := get(t, c, fx.u("/"))
+ if !strings.Contains(home, `/apps/new"`) {
+ t.Fatal("account home does not link to /apps/new")
+ }
+ if strings.Contains(home, `action="`+fx.path("/apps/new")+`"`) {
+ t.Fatal("account home still embeds the create form")
+ }
+ assertNonMember404(t, fx, "/apps/new")
+}
+
+func TestAppNamedNewIsRefused(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ csrf := sessionToken(t, fx, c)
+ status, loc, _ := postForm(t, c, fx.u("/apps/new"), url.Values{"csrf": {csrf}, "name": {"new"}})
+ if status != http.StatusSeeOther || loc != fx.path("/apps/new") {
+ t.Fatalf("claiming 'new' = %d -> %q, want 303 back to the form", status, loc)
+ }
+ if apps, _ := fx.st.AppsIn(OpsAccountID); strings.Contains(strings.Join(apps, " "), "new") {
+ t.Fatal("reserved name 'new' was claimed")
+ }
+}
+```
+
+(`fx.u(sub)` and `fx.path(sub)` build the fixture account's URL/path — the `TestCreateApp` idiom at server_test.go:409.)
+
+- [ ] **Step 2: Run them to verify they fail**
+
+Run: `… go test ./internal/console/ -run 'CreateAppPage|AppNamedNew' -count=1`
+Expected: FAIL — `GET /apps/new = 404` (route missing).
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct (server.go:312, with the chrome fields around :334): add
+
+```go
+ ActiveNav string // account nav section this page sits in: "apps" | "team" | "settings"
+```
+
+2. Route (with the other `/a/{sqid}` GETs, server.go:254):
+
+```go
+ s.mux.HandleFunc("GET /a/{sqid}/apps/new", s.handleNewAppForm)
+```
+
+Go 1.22's mux prefers the literal `apps/new` over `apps/{app}` — which is exactly why the name must be reserved (next point): an app named `new` could never be viewed.
+
+3. `claimApp` (server.go:1354): read its invalid-name branch first, then add a reserved-name check beside it, returning the same outcome shape the invalid-name path returns, with the message `That name is reserved.` The check is `name == "new"`, placed before any store write.
+
+4. Handler (next to `handleNewApp`, server.go:1421):
+
+```go
+// handleNewAppForm is the create-app page; the claim itself stays on
+// POST /a/{sqid}/apps/new (handleNewApp).
+func (s *Server) handleNewAppForm(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Create an app"
+ p.ActiveNav = "apps"
+ s.render(w, http.StatusOK, "app_new", p)
+}
+```
+
+5. `handleNewApp` (server.go:1421): every failure path that today redirects to the account root (`303 → base+"/"`) redirects to `base+"/apps/new"` instead. The success path (303 → the new app's page) is unchanged.
+
+6. `templates/app_new.html`:
+
+```html
+{{define "app_new"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Account · {{.Account.Name}}</p>
+ <h1>Create an app</h1>
+ <p class="lede">Claim a name here; ship a release to it from your terminal.</p>
+</div>
+<form class="signin" method="post" action="{{.Base}}/apps/new">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="name" placeholder="my-app" autofocus>
+ <button type="submit">Create app</button>
+</form>
+<p class="small">Names are lowercase letters, digits, and dashes, starting with a letter — unique within this account, not across the platform.</p>
+{{template "bottom" .}}{{end}}
+```
+
+7. `templates/apps.html`: delete the `<h2>Create an app</h2>` block (`:28-34`, heading + form + naming note) and add in its place:
+
+```html
+<p><a class="btn" href="{{.Nav}}/apps/new">Create app</a></p>
+```
+
+- [ ] **Step 4: Run the new tests, then the whole console suite**
+
+Expected: the two new tests PASS. `TestCreateApp` (server_test.go:409) fails on its taken-name legs — it asserts `303 → fx.path("/")` and re-reads `/` for the "That name is taken." flash; update those to `fx.path("/apps/new")` and read the flash from `fx.u("/apps/new")`. `TestEmptyStateWelcomesNewUser` (:376) asserts `action="…/apps/new"` on the home body — change that want to `href` + keep "Create app".
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/templates/ internal/console/server.go internal/console/server_test.go
+git commit -m "console: create-app is its own page
+
+GET /a/{sqid}/apps/new; the claim POST stays put. 'new' becomes a
+reserved app name because the literal route shadows apps/{app}."
+```
+
+---
+
+### Task 4: Team page (`GET /a/{sqid}/team`)
+
+**Files:**
+- Create: `internal/console/templates/team.html`
+- Modify: `internal/console/server.go` (route, `handleTeam`, redirect targets in `handleInvite`/`handleRevokeInvite`, slim `handleAccountHome`), `internal/console/ownership.go` (redirect targets in `handleRemoveMember`/`handleTransferOwnership`), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`, plus whichever tests drive invite/remove/transfer flows (grep `invite\|members/` across `internal/console/*_test.go`)
+
+**Interfaces:**
+- Consumes: `account()`, `accountPage()`, `assertNonMember404` (Task 3), `page.ActiveNav`.
+- Produces: the `/team` page every invite/member POST redirects back to.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestTeamPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/team"))
+ if status != 200 {
+ t.Fatalf("GET /team = %d", status)
+ }
+ for _, want := range []string{
+ "Team", "people who can see and ship",
+ `action="` + fx.path("/invite") + `"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("team page missing %q", want)
+ }
+ }
+ // The account home no longer carries the member machinery.
+ _, _, home := get(t, c, fx.u("/"))
+ for _, gone := range []string{"Invite a teammate", "Hand over ownership"} {
+ if strings.Contains(home, gone) {
+ t.Fatalf("account home still shows %q", gone)
+ }
+ }
+ assertNonMember404(t, fx, "/team")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** — expected: `GET /team = 404`.
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/team", s.handleTeam)`.
+2. Handler (next to `handleAccountHome`, server.go:1121):
+
+```go
+// handleTeam lists the account's members and, for owners, the invite
+// machinery. The mutations it hosts all POST to their existing paths.
+func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Team"
+ p.ActiveNav = "team"
+ if members, err := s.store.Members(acct.ID); err != nil {
+ log.Printf("console: members: %v", err)
+ } else {
+ p.Members = members
+ }
+ if role == RoleOwner {
+ if invites, err := s.store.PendingInvites(acct.ID); err != nil {
+ log.Printf("console: pending invites: %v", err)
+ } else {
+ p.Invites = invites
+ }
+ }
+ s.render(w, http.StatusOK, "team", p)
+}
+```
+
+3. `templates/team.html` — the Members/invites machinery from `apps.html:36-90`, wrapped in the page anatomy; destructive buttons gain `danger`:
+
+```html
+{{define "team"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Account · {{.Account.Name}}</p>
+ <h1>Team</h1>
+ <p class="lede">The people who can see and ship this account's apps.</p>
+</div>
+<div class="tablewrap">
+<table>
+ <tr><th>Address</th><th>Role</th>{{if .IsOwner}}<th></th>{{end}}</tr>
+ {{range .Members}}<tr>
+ <td>{{.Name}}</td>
+ <td>{{.Role}}</td>
+ {{if $.IsOwner}}<td>
+ {{if ne .Name $.Address}}
+ <form method="post" action="{{$.Base}}/members/transfer">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish" type="submit">Hand over ownership</button>
+ </form>
+ <form method="post" action="{{$.Base}}/members/remove">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="address" value="{{.Name}}">
+ <button class="linkish danger" type="submit">Remove</button>
+ </form>
+ {{end}}
+ </td>{{end}}
+ </tr>{{end}}
+</table>
+</div>
+{{if .IsOwner}}
+<p class="small">Removing a member, handing over ownership and deleting the account each ask
+ you to confirm with your passkey — a signed-in browser on its own isn't enough.</p>
+<h2>Invite a teammate</h2>
+<form class="signin" method="post" action="{{.Base}}/invite">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="address" placeholder="them@keymail.dev">
+ <button type="submit">Send invitation</button>
+</form>
+<p class="small">The invitation arrives in their Keymail Apps inbox — the platform never emails anyone. It works once and expires in 7 days.</p>
+{{if .Invites}}
+<div class="tablewrap">
+<table>
+ <tr><th>Invited</th><th>By</th><th>Expires</th><th></th></tr>
+ {{range .Invites}}
+ <tr>
+ <td>{{.Address}}</td>
+ <td>{{.InvitedBy}}</td>
+ <td>{{fmtTime .ExpiresAt}}</td>
+ <td>
+ <form method="post" action="{{$.Base}}/invite/revoke">
+ <input type="hidden" name="csrf" value="{{$.CSRF}}">
+ <input type="hidden" name="token" value="{{.Token}}">
+ <button class="linkish danger" type="submit">Revoke</button>
+ </form>
+ </td>
+ </tr>
+ {{end}}
+</table>
+</div>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}
+```
+
+4. `templates/apps.html`: delete `:36-90` (the `<h2>Members</h2>` heading through the pending-invites table close). Leave the delete-account block — it moves in Task 5.
+5. `handleAccountHome` (server.go:1121): delete the members load (`:1163-1167`) and the owner-invites load (`:1156-1162`).
+6. Redirect targets: where these four handlers `http.Redirect` back to the account root after `setFlash`, point them at `…/team` instead — `handleInvite` (server.go:751), `handleRevokeInvite` (:804), `handleRemoveMember` (ownership.go:33), `handleTransferOwnership` (ownership.go:58). **Only the redirect URL changes; gates, CSRF, and step-up calls do not move.** (Transfer demotes you to member; `/team` renders for members, so the landing is safe. Remove removes *someone else* — you stay a member.)
+
+- [ ] **Step 4: Run the full console suite; update redirect assertions**
+
+Expected: tests driving invite/remove/transfer assert `303 → fx.path("/")` — update to `fx.path("/team")`, and flash re-reads of `/` become `/team`. `stepup_test.go`'s ordering tests (`TestStepUpIsUnskippable`, `TestOwnershipActionsBehindStepUp`, `TestCSRFStillOutranksStepUp`) must pass — if one fails on anything but a landing URL, the implementation reordered a gate: fix the code, not the test.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: team is its own page
+
+Members, invites, transfer and remove live at /a/{sqid}/team; their
+POSTs stay at their existing paths and redirect back to the page."
+```
+
+---
+
+### Task 5: Account settings page (`GET /a/{sqid}/settings`)
+
+**Files:**
+- Create: `internal/console/templates/account_settings.html`
+- Modify: `internal/console/server.go` (route, `handleAccountSettings`, `page.HasApps`), `internal/console/templates/apps.html`
+- Test: `internal/console/server_test.go`, `internal/console/deletion_test.go` (delete-account flow)
+
+**Interfaces:**
+- Consumes: `account()`, `accountPage()`, `assertNonMember404`.
+- Produces: `page.HasApps bool`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestAccountSettingsPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/settings"))
+ if status != 200 {
+ t.Fatalf("GET /settings = %d", status)
+ }
+ for _, want := range []string{"Account settings", "<h2>Identity</h2>", "Your role"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("settings page missing %q", want)
+ }
+ }
+ // The fixture account has apps (kass), so no delete form may render —
+ // but the section explains itself instead of vanishing silently.
+ if strings.Contains(body, `action="`+fx.path("/delete")+`"`) {
+ t.Fatal("delete form rendered for an account with apps")
+ }
+ if !strings.Contains(body, "can't be deleted") {
+ t.Fatal("missing the why-not-deletable explanation")
+ }
+ assertNonMember404(t, fx, "/settings")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** — expected: 404.
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct: add `HasApps bool // account settings: an account with apps cannot be deleted`.
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/settings", s.handleAccountSettings)`.
+3. Handler:
+
+```go
+// handleAccountSettings is the account itself — identity, and its end.
+// Deletion stays owner-only and zero-apps-only; the POST is unchanged.
+func (s *Server) handleAccountSettings(w http.ResponseWriter, r *http.Request) {
+ addr, raw, acct, role, ok := s.account(w, r)
+ if !ok {
+ return
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = "Account settings"
+ p.ActiveNav = "settings"
+ apps, err := s.store.AppsIn(acct.ID)
+ if err != nil {
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your apps. Please try again.")
+ return
+ }
+ p.HasApps = len(apps) > 0
+ s.render(w, http.StatusOK, "account_settings", p)
+}
+```
+
+4. `templates/account_settings.html`:
+
+```html
+{{define "account_settings"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">Account · {{.Account.Name}}</p>
+ <h1>Account settings</h1>
+ <p class="lede">The account itself — what it's called, and how it ends.</p>
+</div>
+<h2>Identity</h2>
+<div class="tablewrap">
+<table>
+ <tr><th>Name</th><th>ID</th><th>Your role</th></tr>
+ <tr><td>{{.Account.Name}}</td><td><code>{{.Account.Sqid}}</code></td><td>{{.Role}}</td></tr>
+</table>
+</div>
+{{if .IsOwner}}
+<h2>Delete this account</h2>
+{{if .HasApps}}
+<p class="small">This account has apps, so it can't be deleted. Delete or move the apps first;
+ an account with nothing in it can be retired from this page.</p>
+{{else}}
+<p class="small">An account with no apps can be retired. Type <strong>{{.Account.Name}}</strong> to
+ confirm, then confirm with your passkey. Members and pending invitations go with it; nothing
+ outside this account is touched.</p>
+<form class="signin" method="post" action="{{.Base}}/delete">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <input type="text" name="confirm" placeholder="{{.Account.Name}}">
+ <button type="submit">Delete account</button>
+</form>
+{{end}}
+{{end}}
+{{template "bottom" .}}{{end}}
+```
+
+Display note: `apps.html` today shows the empty-account delete form to **every** member; the POST is owner-gated, so a member submitting it gets a 404. Showing a member a form that will refuse them is exactly the un-human behavior this redesign removes — hence the `{{if .IsOwner}}` wrap. The template gate is display only; `handleDeleteAccount`'s `owner()` gate (ownership.go:86) remains the enforcement.
+
+5. `templates/apps.html`: delete the `{{if not .Apps}}…Delete this account…{{end}}` block (`:91-101`).
+
+- [ ] **Step 4: Run the full console suite; update delete-flow tests**
+
+Expected: `deletion_test.go`'s browser-driven account-delete tests fetch `/` for the form — point those fetches at `/settings`. POST path, confirm-name check, and step-up assertions stay identical.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: account settings is its own page
+
+Identity plus the danger zone; delete keeps its owner gate, zero-apps
+rule, type-the-name confirm, and step-up, all at the same POST path."
+```
+
+---
+
+### Task 6: Account nav (Apps · Team · Settings) + address → personal settings link
+
+**Files:**
+- Modify: `internal/console/templates/layout.html:240-247`, `internal/console/server.go` (`ActiveNav` in `handleAccountHome` and `handleApp`), `internal/console/ownership.go` (`ActiveNav` in `handleTrash`)
+- Test: `internal/console/server_account_test.go`
+
+- [ ] **Step 1: Write the failing test** (append to server_account_test.go; derive the account-home path the way `TestEmptyStateWelcomesNewUser` does — the Location of `GET /`):
+
+```go
+func TestAccountNavHasSections(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ nav := strings.TrimSuffix(home, "/")
+ _, _, body := get(t, c, fx.ts.URL+home)
+ h := header(t, body)
+ for _, want := range []string{
+ `href="` + nav + `/"`, `href="` + nav + `/team"`, `href="` + nav + `/settings"`,
+ `href="/settings"`, // the address is the door to personal settings
+ } {
+ if !strings.Contains(h, want) {
+ t.Fatalf("header missing %q in:\n%s", want, h)
+ }
+ }
+ if strings.Count(h, `aria-current="page"`) != 1 {
+ t.Fatalf("want exactly one active nav section on the apps page, got:\n%s", h)
+ }
+ _, _, team := get(t, c, fx.ts.URL+nav+"/team")
+ if !strings.Contains(header(t, team), `/team" aria-current="page"`) {
+ t.Fatal("team page does not mark Team active")
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.** The nav block in `layout.html` (`:240-247`) becomes:
+
+```html
+ {{if .Address}}<nav>
+ {{if .Account}}
+ <a href="{{.Nav}}/"{{if eq .ActiveNav "apps"}} aria-current="page"{{end}}>Apps</a>
+ <a href="{{.Nav}}/team"{{if eq .ActiveNav "team"}} aria-current="page"{{end}}>Team</a>
+ <a href="{{.Nav}}/settings"{{if eq .ActiveNav "settings"}} aria-current="page"{{end}}>Settings</a>
+ {{end}}
+ <a class="addr" href="/settings" title="Your settings">{{.Address}}</a>
+ <form method="post" action="/auth/signout">
+ <input type="hidden" name="csrf" value="{{.CSRF}}">
+ <button class="linkish" type="submit">Sign out</button>
+ </form>
+ </nav>{{end}}
+```
+
+(`/settings` 404s until Task 13 registers it — same PR, noted in the commit message.)
+
+Set `ActiveNav = "apps"` in `handleAccountHome` (server.go:1126, after `accountPage`), `handleApp` (:1240), and `handleTrash` (ownership.go:169, where it builds its page).
+
+- [ ] **Step 4: Run the console suite; update the pinned header tests**
+
+Expected: `TestHeaderSwitcherIsADropdownNamingTheCurrentAccount` (:220), `TestSingleAccountHeaderIsCoherent` (:263), `TestKeylessStillRendersTheSwitcher` (:286) pin the old single-link nav / addr-as-text — update to the three links and the addr link. `TestSignedOutHeaderKeepsTheWordmark` (:301) must pass **unmodified**.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: account nav gains Team and Settings; the address opens your settings
+
+/settings itself lands with the personal-settings page later in this branch."
+```
+
+---
+
+### Task 7: `appPage` helper + app tab bar (old app page keeps working)
+
+**Files:**
+- Create: `internal/console/templates/appnav.html`
+- Modify: `internal/console/server.go` (`appPage` helper, `handleApp` prologue, `page.ActiveTab`/`page.AppName`), `internal/console/templates/app.html`
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `accountApp()` (server.go:1207), `ensureAccountManifest`, `Assemble` — currently called inline in `handleApp` (server.go:1225-1242).
+- Produces:
+ - `func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool)` — gates, assembles the single-app view into `p.Detail`, fills chrome, sets `p.AppName`, `p.ActiveTab = tab`, `p.ActiveNav = "apps"`, `p.Title = app`. Tasks 8–12 call it.
+ - `page.ActiveTab string`, `page.AppName string`.
+ - Template partial `appnav` reading `.Account`, `.Nav`, `.AppName`, `.ActiveTab` — eyebrow + h1 + tab bar; the tab pages of Tasks 8–11 all open with it.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestAppTabBar(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, home, _ := get(t, c, fx.ts.URL+"/")
+ base := strings.TrimSuffix(home, "/") + "/apps/kass"
+ _, _, body := get(t, c, fx.ts.URL+base)
+ for _, want := range []string{
+ `class="tabs"`,
+ `href="` + base + `"`, `href="` + base + `/releases"`,
+ `href="` + base + `/instances"`, `href="` + base + `/activity"`,
+ `href="` + base + `/settings"`,
+ `aria-current="page">Overview</a>`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app page missing %q", want)
+ }
+ }
+}
+```
+
+(The fixture's app is `kass` — see `TestCreateApp`'s taken-name list. If the account-home redirect idiom differs, copy `TestEmptyStateWelcomesNewUser`.)
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. `page` struct additions:
+
+```go
+ ActiveTab string // app pages: "overview" | "releases" | "instances" | "activity" | "settings"
+ AppName string // app pages: the {app} segment, for the tab bar and eyebrow
+```
+
+2. `appPage` — extract `handleApp`'s prologue (server.go:1226-1242) **verbatim**, then set the new chrome:
+
+```go
+// appPage gates an app page, assembles its platform view, and fills the
+// chrome every app tab shares. Callers add their own tab's data on top.
+func (s *Server) appPage(w http.ResponseWriter, r *http.Request, tab string) (page, *Account, bool) {
+ addr, raw, acct, role, app, ok := s.accountApp(w, r)
+ if !ok {
+ return page{}, nil, false
+ }
+ // The account manifest is re-attempted on view: minting writes it, but a
+ // bucket hiccup there is deliberately non-fatal, and this is where it gets
+ // another chance. Best-effort — a page view never fails on it.
+ s.ensureAccountManifest(r.Context(), acct)
+ views, err := Assemble(s.cfg.Registry, s.cfg.Releases, s.cfg.ReleasePub, acct.Sqid, []string{app}, s.cfg.EdgeIPs)
+ if err != nil || len(views) != 1 {
+ log.Printf("console: assemble %s: %v", app, err)
+ s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read platform state. Please try again.")
+ return page{}, nil, false
+ }
+ p := s.accountPage(w, r, addr, raw, acct, role)
+ p.Title = app
+ p.AppName = app
+ p.ActiveTab = tab
+ p.ActiveNav = "apps"
+ p.Detail = &views[0]
+ return p, acct, true
+}
+```
+
+3. `handleApp` (server.go:1225): replace its gate+manifest+Assemble prologue with `p, acct, ok := s.appPage(w, r, "overview")`; the rest of its loads (Versions, Instances, metrics sweep, Events) stay for now, assigning into `p`.
+
+4. `templates/appnav.html`:
+
+```html
+{{define "appnav"}}
+<div class="pagehead">
+ <p class="eyebrow">App · {{.Account.Name}}</p>
+ <h1>{{.AppName}}</h1>
+</div>
+<nav class="tabs" aria-label="App sections">
+ <a href="{{.Nav}}/apps/{{.AppName}}"{{if eq .ActiveTab "overview"}} aria-current="page"{{end}}>Overview</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/releases"{{if eq .ActiveTab "releases"}} aria-current="page"{{end}}>Releases</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/instances"{{if eq .ActiveTab "instances"}} aria-current="page"{{end}}>Instances</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/activity"{{if eq .ActiveTab "activity"}} aria-current="page"{{end}}>Activity</a>
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings"{{if eq .ActiveTab "settings"}} aria-current="page"{{end}}>Settings</a>
+</nav>
+{{end}}
+```
+
+5. `templates/app.html:2`: replace `<h1>{{.Detail.App}}</h1>` with `{{template "appnav" .}}`, moving the two flash lines (`:3-4`) **above** it (flashes render first on app pages — the promote flows land here).
+
+(The four tab URLs 404 until Tasks 8–11 land — same PR; the suite has no test clicking them yet.)
+
+- [ ] **Step 4: Run the console suite**
+
+Expected: new test PASSES; existing app-page tests keep passing (the page still renders every section). If a ladder test pins `<h1>` adjacency, update it to the appnav markup.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: app pages get their tab bar and shared appPage chrome
+
+Tabs land page by page next; the one-page view keeps serving until the
+last tab exists."
+```
+
+---
+
+### Task 8: App Releases tab
+
+**Files:**
+- Create: `internal/console/templates/app_releases.html`
+- Modify: `internal/console/server.go` (route, `handleAppReleases`; `back` field in `handlePromote`/`handleRollback`/`handleNewCanary`/`handleRetireCanary`)
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `appPage()` (Task 7), `Versions()` (as called at server.go:1243).
+- Produces: the `back` form-field convention — optional hidden `back=releases`; the four promote-family handlers redirect to `…/apps/{app}/releases` when `back` is exactly the literal `"releases"`, else to the app page as today. The field survives step-up stash/resume (the stash keeps all fields but `csrf`, stepup.go:285-295). Never echo `back` into a URL — the allowlist comparison is the whole parser.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestReleasesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/releases"))
+ if status != 200 {
+ t.Fatalf("GET releases tab = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Releases</a>`,
+ "<h2>Channels</h2>", "<h2>Canaries</h2>", "<h2>Releases</h2>",
+ `name="back" value="releases"`,
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("releases tab missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/releases")
+}
+```
+
+Also extend ONE promote-family flow test (e.g. `TestRollbackFlow`, server_test.go:549) with a `back` leg: repeat its happy-path POST with `"back": {"releases"}` added to the `url.Values` and assert the redirect Location ends `/apps/kass/releases`; keep the original leg asserting the app-page redirect (the default must stay covered).
+
+- [ ] **Step 2: Run to verify both fail.**
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/releases", s.handleAppReleases)`.
+2. Handler:
+
+```go
+// handleAppReleases is the deploy surface: every shipped release, the
+// channels they promote onto, and the canaries riding ahead.
+func (s *Server) handleAppReleases(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "releases")
+ if !ok {
+ return
+ }
+ versions, err := Versions(s.cfg.Releases, acct.Sqid, p.AppName)
+ if err != nil {
+ log.Printf("console: versions %s: %v", p.AppName, err)
+ p.FlashErr = "Could not list this app's releases."
+ }
+ p.Versions = versions
+ s.render(w, http.StatusOK, "app_releases", p)
+}
+```
+
+3. `templates/app_releases.html`: flashes, `{{template "appnav" .}}`, then `app.html`'s Channels (`:89-116`), Canaries (`:118-156`) and Releases (`:158-193`) sections copied **verbatim** (the originals stay in `app.html` until Task 12), with exactly three changes in the copy:
+ - every `<form method="post"` in these sections gains `<input type="hidden" name="back" value="releases">`;
+ - `Roll back` and `Retire` buttons become `class="linkish danger"`;
+ - nothing else — the empty-releases `carlos ship` guidance block (`:188-191`) comes along unchanged.
+4. `back` handling in `handlePromote` (server.go:1458), `handleRollback` (:1591), `handleNewCanary` (:1796), `handleRetireCanary` (:1868) — each computes redirects to the app page today; wrap that destination:
+
+```go
+ dest := base + "/apps/" + url.PathEscape(app)
+ if r.FormValue("back") == "releases" {
+ dest += "/releases"
+ }
+```
+
+using each handler's actual base/app variable names, applied to **every** redirect in the handler (success and flash-error alike) so a releases-page submit always lands back on the releases page. `handleProduction`/`handleFastTrack` keep their current redirects — their forms live in the ladder.
+
+- [ ] **Step 4: Run the console suite** — expected: all green (old page untouched, new legs pass).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: releases tab — channels, canaries, releases, promote flows
+
+Forms carry back=releases (allowlisted literal, never echoed) so a
+promote made on the tab lands back on the tab; ladder flows still land
+on the app page."
+```
+
+---
+
+### Task 9: App Instances tab
+
+**Files:**
+- Create: `internal/console/templates/app_instances.html`
+- Modify: `internal/console/server.go` (route, `handleAppInstances`, extract `appResourceMetrics` helper; `handleApp` calls the helper)
+- Test: `internal/console/server_test.go`
+
+**Interfaces:**
+- Consumes: `appPage()`, `s.instances.Report`, `s.OwnerAppMetrics`.
+- Produces: `func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics` — the tenant-bounded metrics sweep, shared by `handleApp` (until Task 12) and `handleAppInstances`.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestInstancesTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/instances"))
+ if status != 200 {
+ t.Fatalf("GET instances tab = %d", status)
+ }
+ if !strings.Contains(body, `aria-current="page">Instances</a>`) {
+ t.Fatal("tab not active")
+ }
+ assertNonMember404(t, fx, "/apps/kass/instances")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Extract the metrics sweep from `handleApp` (server.go:1249-1281) into a helper, moving the **entire tenant-boundary comment block (server.go:1250-1264) with it, word for word** — it documents why the account comparison is not redundant with the gate:
+
+```go
+func (s *Server) appResourceMetrics(ctx context.Context, view *AppView, acct *Account) []InstanceMetrics {
+ // [the verbatim comment block from server.go:1250-1264 lives here]
+ hosts := make([]string, 0, len(view.Routes))
+ hib := make(map[string]bool, len(view.Routes))
+ for _, rt := range view.Routes {
+ if rt.Account != acct.Sqid {
+ continue
+ }
+ hosts = append(hosts, rt.Host)
+ hib[rt.Host] = rt.Hibernate
+ }
+ am, err := s.OwnerAppMetrics(ctx, hosts)
+ if err != nil {
+ log.Printf("console: app metrics %s: %v", view.App, err)
+ return nil
+ }
+ for i := range am {
+ am[i].Hibernate = hib[am[i].Host]
+ }
+ return am
+}
+```
+
+`handleApp`'s inline sweep becomes `p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)`.
+
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/instances", s.handleAppInstances)`.
+3. Handler:
+
+```go
+// handleAppInstances is what the app is actually running: its self-reported
+// instances, and each one's CPU/memory on the box it runs on.
+func (s *Server) handleAppInstances(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "instances")
+ if !ok {
+ return
+ }
+ p.Instances = s.instances.Report(r.Context(), p.Detail.Routes)
+ p.AppMetrics = s.appResourceMetrics(r.Context(), p.Detail, acct)
+ s.render(w, http.StatusOK, "app_instances", p)
+}
+```
+
+4. `templates/app_instances.html`: flashes + `{{template "appnav" .}}`, then `app.html`'s Instances (`:41-59`) and Resources (`:61-87`) sections copied verbatim, plus an empty state after both:
+
+```html
+{{if and (or (not .Instances) (not .Instances.Reporting)) (not .AppMetrics)}}
+<p class="small">Nothing to show yet — instances appear when the app serves
+<code>/.carlos/instances</code>, and resource readings when it runs on a
+reporting box.</p>
+{{end}}
+```
+
+- [ ] **Step 4: Run the console suite** — expected: green. `TestAppPageOwnerMetricsBoundary` (:1210) still drives the old app page, which now goes through the extracted helper — it passing is the proof the extraction preserved the boundary.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: instances tab; the tenant-bounded metrics sweep becomes a helper
+
+Boundary comment and behavior move intact — the old page and the new
+tab share the one implementation."
+```
+
+---
+
+### Task 10: App Activity tab
+
+**Files:**
+- Create: `internal/console/templates/app_activity.html`
+- Modify: `internal/console/server.go` (route, `handleAppActivity`, `activityCap`)
+- Test: `internal/console/server_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestActivityTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/activity"))
+ if status != 200 {
+ t.Fatalf("GET activity tab = %d", status)
+ }
+ for _, want := range []string{`aria-current="page">Activity</a>`, "CLI promotions don"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("activity tab missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/activity")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Next to `eventsCap` (server.go:1291): `const activityCap = 50 // the activity tab has the room the overview strip never did`.
+2. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/activity", s.handleAppActivity)`.
+3. Handler:
+
+```go
+// handleAppActivity is the console's own audit trail for this app.
+func (s *Server) handleAppActivity(w http.ResponseWriter, r *http.Request) {
+ p, acct, ok := s.appPage(w, r, "activity")
+ if !ok {
+ return
+ }
+ if events, err := s.store.Events(acct.ID, p.AppName, activityCap); err != nil {
+ log.Printf("console: events %s: %v", p.AppName, err)
+ } else {
+ p.Events = events
+ }
+ s.render(w, http.StatusOK, "app_activity", p)
+}
+```
+
+4. `templates/app_activity.html`: flashes + `{{template "appnav" .}}`, then the activity table from `app.html:195-206` with the `{{if .Events}}` gate inverted into an empty state:
+
+```html
+{{if .Events}}
+<div class="tablewrap">
+<table>
+ <tr><th>When</th><th>Who</th><th>What</th></tr>
+ {{range .Events}}
+ <tr><td>{{fmtTime .CreatedAt}}</td><td>{{.Address}}</td><td>{{.Detail}}</td></tr>
+ {{end}}
+</table>
+</div>
+{{else}}<p class="small">Nothing yet — promotes, rollbacks and canaries made in this console will show here.</p>{{end}}
+<p class="small">Actions taken through this console only — CLI promotions don't appear here.</p>
+```
+
+- [ ] **Step 4: Run the console suite** — expected: green (`eventsCap` and the old page untouched).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: activity tab — the audit trail gets a page and a 50-row cap"
+```
+
+---
+
+### Task 11: App Settings tab (identity + domains)
+
+**Files:**
+- Create: `internal/console/templates/app_settings.html`
+- Modify: `internal/console/server.go` (route, `handleAppSettings`)
+- Test: `internal/console/server_test.go`
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestAppSettingsTab(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.u("/apps/kass/settings"))
+ if status != 200 {
+ t.Fatalf("GET app settings = %d", status)
+ }
+ for _, want := range []string{
+ `aria-current="page">Settings</a>`, "<h2>Identity</h2>", "Domains &amp; TLS",
+ } {
+ if !strings.Contains(body, want) {
+ t.Fatalf("app settings missing %q", want)
+ }
+ }
+ assertNonMember404(t, fx, "/apps/kass/settings")
+}
+```
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. Route: `s.mux.HandleFunc("GET /a/{sqid}/apps/{app}/settings", s.handleAppSettings)`.
+2. Handler:
+
+```go
+// handleAppSettings is the app's own record: identity, and its domains —
+// the two things about an app that aren't a deploy.
+func (s *Server) handleAppSettings(w http.ResponseWriter, r *http.Request) {
+ p, _, ok := s.appPage(w, r, "settings")
+ if !ok {
+ return
+ }
+ s.render(w, http.StatusOK, "app_settings", p)
+}
+```
+
+3. `templates/app_settings.html`: flashes + `{{template "appnav" .}}`, then:
+
+```html
+<h2>Identity</h2>
+<div class="tablewrap">
+<table>
+ <tr><th>App</th><th>Account</th></tr>
+ <tr><td><code>{{.Detail.App}}</code></td><td>{{.Account.Name}} <code>{{.Account.Sqid}}</code></td></tr>
+</table>
+</div>
+<h2>Domains &amp; TLS</h2>
+```
+
+followed by the body of `app.html`'s Domains section (`:22-39` — the table/empty-state, not its `<h2>` line) copied verbatim.
+
+- [ ] **Step 4: Run the console suite** — expected: green.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/console/ && git commit -m "console: app settings tab — identity, domains and TLS (Heroku's answer to where domains live)"
+```
+
+---
+
+### Task 12: Slim the Overview; retire `app.html`
+
+**Files:**
+- Create: `internal/console/templates/app_overview.html`
+- Delete: `internal/console/templates/app.html`
+- Modify: `internal/console/server.go` (`handleApp` slims, `eventsCap` deleted)
+- Test: `internal/console/server_test.go` (the displaced app-page tests)
+
+**Interfaces:**
+- Consumes: everything Tasks 7–11 built. After this task, `handleApp` loads: `appPage` + `Versions` (the ladder's promote select) + `s.instances.Report` (the summary line). `AppMetrics` and `Events` loads leave `handleApp` for good.
+
+- [ ] **Step 1: Write the failing test**
+
+```go
+func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ _, _, body := get(t, c, fx.u("/apps/kass"))
+ for _, gone := range []string{"<h2>Channels</h2>", "<h2>Releases</h2>", "<h2>Recent activity</h2>", "<h2>Domains</h2>", "<h2>Resources</h2>"} {
+ if strings.Contains(body, gone) {
+ t.Fatalf("overview still renders %q", gone)
+ }
+ }
+ for _, want := range []string{`class="ladder"`, "domain", "release"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("overview missing %q", want)
+ }
+ }
+}
+```
+
+- [ ] **Step 2: Run to verify it fails** (the old page still renders everything).
+
+- [ ] **Step 3: Implement.**
+
+1. `templates/app_overview.html`:
+
+```html
+{{define "app_overview"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+{{template "appnav" .}}
+{{if .Detail.AccountMismatch}}
+<p class="banner error">
+ <strong>This app is not deploying.</strong> Its hosts read releases from
+ account <code>{{.Detail.RouteAccount}}</code>, but this page writes them to
+ <code>{{.Account.Sqid}}</code> — so a promote here succeeds and reaches
+ nothing. Its bucket objects were moved without the console's own record
+ following. On the console's box, run:
+ <br><code>console migrate-app -app {{.Detail.App}} -to {{.Detail.RouteAccount}}</code>
+ <br><span class="small">If it is the routes that are in the wrong account,
+ move those back instead — whichever of the two is right, they have to
+ agree.</span>
+</p>
+{{end}}
+{{template "ladder" .}}
+<p class="small">
+ <a href="{{.Nav}}/apps/{{.AppName}}/settings">{{len .Detail.Domains}} domain{{if ne (len .Detail.Domains) 1}}s{{end}}</a>
+ {{if and .Instances .Instances.Reporting}} · <a href="{{.Nav}}/apps/{{.AppName}}/instances">{{len .Instances.Instances}} instance{{if ne (len .Instances.Instances) 1}}s{{end}} reporting</a>{{end}}
+ · <a href="{{.Nav}}/apps/{{.AppName}}/releases">{{len .Versions}} release{{if ne (len .Versions) 1}}s{{end}}</a>
+</p>
+{{if not .CanPromote}}<p class="small">Promotions disabled: no signing key on this host.</p>{{end}}
+{{template "bottom" .}}{{end}}
+```
+
+2. `handleApp`: delete the `AppMetrics` helper call and the `Events` load; keep `Versions` and `Instances`; render `"app_overview"`.
+3. Delete `templates/app.html` and the now-unused `const eventsCap` (server.go:1291). Verify nothing references either: `grep -rn '"app"' internal/console/*.go` (render calls) and `grep -rn eventsCap internal/console/`.
+
+- [ ] **Step 4: Run the console suite; move the displaced tests to their tabs**
+
+Expected failures, each fixed by pointing the body-fetch at the section's new home (assertions themselves stay as strong as they were):
+- `TestAppPageRendersDomains` (:1007) → `…/apps/kass/settings`
+- `TestAppPageInstances` (:770), `TestAppPageOwnerMetricsBoundary` (:1210) → `…/instances` (the boundary test's assertions must pass **unmodified apart from the URL** — it is the tenant-boundary regression test)
+- `TestRecentActivity` (:796) → `…/activity` (a 10-cap assertion becomes 50)
+- `TestPromoteFlow` (:462), `TestRollbackFlow` (:549), `TestCanaryFlow` (:599), `TestRetireCanary` (:687) — where they scrape/assert the forms' page, fetch `…/releases`; their POSTs and default-redirect legs are unchanged
+- `TestCreateApp` (:409) — post-claim it asserts the ship-guidance copy on the app page; that copy now lives on the releases tab: fetch `…/apps/zephyr/releases` for those wants, and assert the Overview answer (`0 releases`) on the app page itself
+- `ladder_test.go` — drives the app URL; the ladder partial is untouched, so only assertions about surrounding page furniture (if any) change.
+
+- [ ] **Step 5: Full console suite green, then commit**
+
+```bash
+git add -A internal/console/ && git commit -m "console: the one-page app era ends
+
+Overview is the ladder plus a summary line; every section lives on its
+tab, each loading only its own data."
+```
+
+---
+
+### Task 13: Personal settings (`GET /settings`)
+
+**Files:**
+- Modify: `internal/console/authcli.go` (routes, revoke redirect, notification link), rename `internal/console/templates/cli_tokens.html` → `templates/settings.html`, `internal/console/templates/cli_approve.html` (two hrefs)
+- Test: `internal/console/authcli_test.go`
+
+- [ ] **Step 1: Write the failing test** (authcli_test.go; read how its existing tests fetch `/auth/cli/tokens` and match the client/redirect idiom):
+
+```go
+func TestPersonalSettingsPage(t *testing.T) {
+ fx := newConsoleFixture(t)
+ c := signInPaul(t, fx)
+ status, _, body := get(t, c, fx.ts.URL+"/settings")
+ if status != 200 {
+ t.Fatalf("GET /settings = %d", status)
+ }
+ for _, want := range []string{"Your settings", "Command-line access"} {
+ if !strings.Contains(body, want) {
+ t.Fatalf("/settings missing %q", want)
+ }
+ }
+ // The old URL keeps working — Keymail notification bodies link to it.
+ status, loc, _ := get(t, c, fx.ts.URL+"/auth/cli/tokens")
+ if status != http.StatusSeeOther || loc != "/settings" {
+ t.Fatalf("/auth/cli/tokens = %d -> %q, want 303 -> /settings", status, loc)
+ }
+ // Signed out, the personal page must not render.
+ a := newBrowser(t)
+ status, loc, _ = get(t, a, fx.ts.URL+"/settings")
+ if status != http.StatusFound || loc != "/" {
+ t.Fatalf("signed-out /settings = %d -> %q, want 302 -> /", status, loc)
+ }
+}
+```
+
+(Signed-out behavior must match what `GET /auth/cli/tokens` does today — read `handleCLITokens` first; if it differs from 302 `/`, assert what it actually does. The page's protection level must not decrease.)
+
+- [ ] **Step 2: Run to verify it fails.**
+
+- [ ] **Step 3: Implement.**
+
+1. In `registerCLIRoutes` (authcli.go:204-212), replace the `GET /auth/cli/tokens` registration with:
+
+```go
+ s.mux.HandleFunc("GET /settings", s.handleCLITokens)
+ s.mux.HandleFunc("GET /auth/cli/tokens", func(w http.ResponseWriter, r *http.Request) {
+ http.Redirect(w, r, "/settings", http.StatusSeeOther)
+ })
+```
+
+2. `handleCLITokenRevoke` (authcli.go): its post-revoke redirect to `/auth/cli/tokens` becomes `/settings`.
+3. Rename `templates/cli_tokens.html` → `templates/settings.html`, its define to `settings`, and the `renderCLI(w, …, "cli_tokens", …)` call in `handleCLITokens` to `"settings"`. Reshape the page top (everything below the lede — tokens table, empty state, the `carlos auth login` guidance — stays, with Revoke gaining `class="linkish danger"`):
+
+```html
+{{define "settings"}}{{template "top" .}}
+{{if .Flash}}<p class="banner">{{.Flash}}</p>{{end}}
+{{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}}
+<div class="pagehead">
+ <p class="eyebrow">{{.Address}}</p>
+ <h1>Your settings</h1>
+ <p class="lede">Yours across every account — not any one account's.</p>
+</div>
+<h2>Command-line access</h2>
+<p class="small">Every terminal that can act as {{.Address}}.</p>
+```
+
+4. `cli_approve.html:16` and `:52`: hrefs `/auth/cli/tokens` → `/settings`. The Keymail notification body (grep `auth/cli/tokens` in authcli.go, ~:442) → `/settings` too, so new mail links straight; old mail rides the redirect.
+
+- [ ] **Step 4: Run the console suite** — update authcli tests asserting the old URL in rendered bodies (`TestCLIApprovalPageNeverApprovesFromALink` and the token-page tests).
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add -A internal/console/ && git commit -m "console: your settings at /settings; the cli tokens page moves in
+
+/auth/cli/tokens 303s there, so Keymail notification links keep working."
+```
+
+---
+
+### Task 14: Full verification + template sweep + copy pass
+
+**Files:**
+- Modify: only what the checks below force.
+
+- [ ] **Step 1: The whole gate, from the worktree root**
+
+```bash
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go build ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go vet ./... &&
+GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off go test ./... -count=1
+```
+
+Expected: every package passes. Then `gofmt -l internal/console/ cmd/console/` — empty output (the three pre-existing unformatted files elsewhere stay untouched).
+
+- [ ] **Step 2: Template sanity sweep** — the two classic split bugs:
+
+```bash
+grep -n 'href="{{.Base}}' internal/console/templates/*.html # must be empty: links use .Nav
+grep -n 'action="{{.Nav}}' internal/console/templates/*.html # must be empty: forms use .Base
+grep -rn '"app"' internal/console/*.go # no render call for the retired template
+```
+
+Fix any hit.
+
+- [ ] **Step 3: Human pass.** Read every page template top-to-bottom once, checking each has: an eyebrow, an h1, a lede (or a deliberate reason not to), an empty state that says what will fill the page, and destructive buttons in `danger`. Fix in place; keep copy in the console's voice (plain sentences, no exclamation marks, the reader is told why, not just what).
+
+- [ ] **Step 4: Commit anything Steps 1–3 changed**
+
+```bash
+git add -A && git commit -m "console: redesign polish pass — full suite green"
+```
+
+- [ ] **Step 5: Opus review checklist** (per CLAUDE.md, this branch is production-touching):
+gate order in every new handler; 404 parity on every new GET (`assertNonMember404` coverage); the `back` allowlist never echoes input; the metrics tenant boundary moved verbatim; no icons_test invariant loosened; every form posts to `.Base` and every link walks `.Nav`.