plan: console redesign — 14 tasks, additive tabs, suite green per commit
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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 & 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 & 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`. |