console: redesign polish pass — full suite green
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
6 files changed,
+34
−14
docs/superpowers/specs/2026-08-06-console-redesign-design.md+1 −1internal/console/authcli.go+1 −1internal/console/server_test.go+27 −10internal/console/templates/app_activity.html+1 −0internal/console/templates/app_new.html+3 −1internal/console/templates/app_releases.html+1 −1
diff --git a/docs/superpowers/specs/2026-08-06-console-redesign-design.md b/docs/superpowers/specs/2026-08-06-console-redesign-design.md| index a96b85c9a..a9ecf131d 100644 |
| --- a/docs/superpowers/specs/2026-08-06-console-redesign-design.md |
| +++ b/docs/superpowers/specs/2026-08-06-console-redesign-design.md |
| @@ -101,7 +101,7 @@ LAST). |
| | `/a/{sqid}/apps/{app}/activity` | App · Activity | audit table, cap 10 → 50 | |
| | `/a/{sqid}/apps/{app}/settings` | App · Settings | app identity + **Domains & TLS** (Heroku precedent; the tab has real content today) | |
| | `/settings` | Your settings | command-line access (cli_tokens content moves here) | |
| -| `/auth/cli/tokens` | 302 → `/settings` | Keymail notification bodies and cli_approve links keep working | |
| +| `/auth/cli/tokens` | 303 → `/settings` | Keymail notification bodies and cli_approve links keep working | |
| Existing legacy redirects (`/apps`, `/apps/{app}`) stay. No new legacy |
| redirects needed — the old GET URLs (`/a/{sqid}/`, `/a/{sqid}/apps/{app}`) |
diff --git a/internal/console/authcli.go b/internal/console/authcli.go| index 042725d5c..bc1f4e2f4 100644 |
| --- a/internal/console/authcli.go |
| +++ b/internal/console/authcli.go |
| @@ -699,7 +699,7 @@ func (s *Server) handleCLITokens(w http.ResponseWriter, r *http.Request) { |
| s.renderMessage(w, r, http.StatusInternalServerError, "Error", "Could not read your terminals. Please try again.") |
| return |
| } |
| - p := cliPage{page: page{Title: "Command-line access", Address: addr, CSRF: raw}, |
| + p := cliPage{page: page{Title: "Your settings", Address: addr, CSRF: raw}, |
| Tokens: toks, Console: s.cfg.Origin, Days: int(CLITokenTTL / (24 * time.Hour))} |
| p.Flash, p.FlashErr = s.takeFlash(w, r) |
| s.renderCLI(w, http.StatusOK, "settings", p) |
diff --git a/internal/console/server_test.go b/internal/console/server_test.go| index 9b83d292b..00a93b3d9 100644 |
| --- a/internal/console/server_test.go |
| +++ b/internal/console/server_test.go |
| @@ -14,6 +14,7 @@ import ( |
| "os" |
| "path/filepath" |
| "regexp" |
| + "slices" |
| "strings" |
| "testing" |
| "time" |
| @@ -530,6 +531,7 @@ func TestOverviewIsALadderNotAnEverythingPage(t *testing.T) { |
| t.Fatalf("overview missing %q", want) |
| } |
| } |
| + assertNonMember404(t, fx, "/apps/kass") |
| } |
| func TestReleasesTab(t *testing.T) { |
| @@ -547,9 +549,11 @@ func TestReleasesTab(t *testing.T) { |
| t.Fatalf("releases tab missing %q", want) |
| } |
| } |
| - // Every mutation form on the tab (promote, rollback, new canary, retire |
| - // canary) carries a back=releases hidden field, so its redirect returns |
| - // here rather than to the overview — exactly 4, one per form. |
| + // Every mutation form on the tab carries a back=releases hidden field, so |
| + // its redirect returns here rather than to the overview. The fixture's |
| + // count of 4 is 2 promote forms (one per version, v1 and v2) + 1 create- |
| + // canary form + 1 retire-canary form (canary/x) — the rollback form |
| + // contributes 0 because fixture stable is v1 with no history yet. |
| if n := strings.Count(body, `name="back" value="releases"`); n != 4 { |
| t.Fatalf(`releases tab has %d back=releases fields, want exactly 4`, n) |
| } |
| @@ -718,7 +722,7 @@ func TestAppNamedNewIsRefused(t *testing.T) { |
| 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") { |
| + if apps, _ := fx.st.AppsIn(OpsAccountID); slices.Contains(apps, "new") { |
| t.Fatal("reserved name 'new' was claimed") |
| } |
| } |
| @@ -1152,6 +1156,14 @@ func TestActivityTab(t *testing.T) { |
| assertNonMember404(t, fx, "/apps/kass/activity") |
| } |
| +// NOTE: Assemble groups routes by app NAME with no account filter (view.go), |
| +// so two accounts that happen to own an app of the same name share routes |
| +// here — rival-account hosts render in this tab's Domains table, and the |
| +// same leak reaches the overview tab's ladder. PRE-EXISTING, gated (the app |
| +// page's session → membership → HasAppIn 404 runs before Assemble, so this |
| +// is not a non-member leak), and neither introduced nor fixed by this task — |
| +// fixing it there would also decide what the half-migrated AccountMismatch |
| +// banner shows, a separate call. Source fix: view.go:294-308. |
| func TestAppSettingsTab(t *testing.T) { |
| fx := newConsoleFixture(t) |
| c := signInPaul(t, fx) |
| @@ -1759,6 +1771,13 @@ func TestAppPageSurvivesMetricsFailure(t *testing.T) { |
| if !strings.Contains(body, `aria-current="page">Instances</a>`) { |
| t.Fatalf("instances tab did not render its normal chrome: %s", body) |
| } |
| + // Content, not just chrome: with the metrics read failing closed |
| + // (appResourceMetrics returns nil) and the fixture route unreachable |
| + // (Instances.Reporting false), the tab's real nil-metrics empty state |
| + // is what a reader sees — not a partially-rendered page. |
| + if !strings.Contains(body, "Nothing to show yet") { |
| + t.Fatalf("instances tab did not render its nil-metrics empty state: %s", body) |
| + } |
| } |
| // failingListStore fails exactly the List the metrics sweep starts with, |
| @@ -1823,12 +1842,10 @@ func TestAppPageOwnerMetricsCrossAccountAppName(t *testing.T) { |
| } |
| // And the other account's host is not in the Resources section at all. |
| // |
| - // NOTE: the assertion is scoped to Resources on purpose. Assemble groups |
| - // routes by app NAME with no account filter (view.go), so rival's host |
| - // already renders in this page's Domains/ladder tables — a PRE-EXISTING |
| - // cross-account leak this task neither introduces nor fixes (fixing it |
| - // there would also decide what the half-migrated AccountMismatch banner |
| - // shows, which is a separate call). See the task report. |
| + // NOTE: the assertion is scoped to Resources on purpose — this is the |
| + // /instances tab, which renders neither Domains nor the ladder, so |
| + // rival's host has nowhere else on THIS page to leak. It still leaks |
| + // on the settings and overview tabs; see the NOTE above TestAppSettingsTab. |
| if res := resourcesSection(t, body); strings.Contains(res, "rival.example.com") { |
| t.Fatalf("Resources section leaked another account's host:\n%s", res) |
| } |
diff --git a/internal/console/templates/app_activity.html b/internal/console/templates/app_activity.html| index 814d9e917..10c8ea993 100644 |
| --- a/internal/console/templates/app_activity.html |
| +++ b/internal/console/templates/app_activity.html |
| @@ -3,6 +3,7 @@ |
| {{if .FlashErr}}<p class="banner error">{{.FlashErr}}</p>{{end}} |
| {{template "appnav" .}} |
| +<h2>Activity</h2> |
| {{if .Events}} |
| <div class="tablewrap"> |
| <table> |
diff --git a/internal/console/templates/app_new.html b/internal/console/templates/app_new.html| index f558958d2..f3814a228 100644 |
| --- a/internal/console/templates/app_new.html |
| +++ b/internal/console/templates/app_new.html |
| @@ -8,7 +8,9 @@ |
| </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> |
| + <label class="visually-hidden" for="app-name">App name</label> |
| + <input id="app-name" type="text" name="name" placeholder="my-app" autofocus |
| + required pattern="[a-z][a-z0-9-]{0,31}" maxlength="32"> |
| <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> |
diff --git a/internal/console/templates/app_releases.html b/internal/console/templates/app_releases.html| index 3050813df..b9eeb9e10 100644 |
| --- a/internal/console/templates/app_releases.html |
| +++ b/internal/console/templates/app_releases.html |
| @@ -31,7 +31,7 @@ |
| </table> |
| </div> |
| <p class="small">Bake times are an approximation from each channel's default hold and the promotion time; the authoritative clock is each box's own first-seen record. Rollback never bakes.</p> |
| -{{else}}<p class="small">No channels yet.</p>{{end}} |
| +{{else}}<p class="small">No channels yet — ship a release, then promote it below to create the first one.</p>{{end}} |
| <h2>Canaries</h2> |
| {{if .Detail.Canaries}} |