plan: console clarity batch (P0s+P1s from the 2026-08-09 critique)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+993
−0
docs/superpowers/plans/2026-08-09-console-clarity-batch.md+993 −0
diff --git a/docs/superpowers/plans/2026-08-09-console-clarity-batch.md b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md| new file mode 100644 |
| index 000000000..e0d21fe8c |
| --- /dev/null |
| +++ b/docs/superpowers/plans/2026-08-09-console-clarity-batch.md |
| @@ -0,0 +1,993 @@ |
| +# Console Clarity Batch (P0s + P1s) 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:** Make every console screen answer "what are my apps, what are they doing, is everything healthy" in plain language, with the technical layer (ladder, channels, bake mechanics) behind progressive reveals. |
| + |
| +**Architecture:** All copy and layout changes live in `internal/console/` (templates + the Go that assembles their view data — templates render facts, never compute them). Two small new computations: a health verdict derived from data the pages already load, and a release summary derived from the channels the Releases page already reads. Wire shapes, channel names, URL paths, and template `define` names are untouched. |
| + |
| +**Tech Stack:** Go server-rendered `html/template`, no JS dependencies, vendored Lucide icons via the existing `icon` funcmap helper. |
| + |
| +**Source of truth for the copy:** `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` (the critique this plan implements). Scope is its P0s + P1s only. Paul's decisions 2026-08-09: bake → "safety delay"; health verdict ships in this same batch. |
| + |
| +## Global Constraints |
| + |
| +- Channel names on the wire (`stable`, `beta`, `edge`, `canary/*`) are NEVER renamed — display copy only. `internal/release/laddercheck_test.go` pins production channel names; do not touch `internal/release/`. |
| +- URL paths are unchanged (`/apps/<app>/instances` stays even though the tab label becomes "Running") — bookmarks and tests depend on them. |
| +- Template `define` names are unchanged (`app_instances`, `ladder`, etc.) — `render()` calls them by name. |
| +- The em-dash discipline stands: never render a guessed zero; unknown is `—` or an explicit "can't tell" word. |
| +- Chip grammar stands (layout.html comments): bordered = a fact worth noticing; filled = must-not-skim. `chip-prod` stays the only filled red chip. New health chips: `Needs attention` may be filled amber; `All good` is bordered. |
| +- Gate before every commit: `go build ./... && go vet ./... && go test ./... -count=1` all clean, plus `GOOS=darwin go build ./...`, plus `gofmt -l` clean for touched files. Sandbox env: `GOCACHE=/tmp/claude-1001/gocache GOFLAGS=-mod=mod GOPROXY=off`; go commands may need the sandbox disabled. |
| +- Copy strings below are exact — implement them verbatim. Where a test asserts an old string, update the test in the same task (grep `internal/console/*_test.go` for the old string before assuming there isn't one). |
| +- Work happens in this worktree on branch `console-clarity-batch`. |
| + |
| +--- |
| + |
| +### Task 1: Health verdict computation (overview.go) |
| + |
| +**Files:** |
| +- Modify: `internal/console/overview.go` |
| +- Test: `internal/console/overview_test.go` |
| + |
| +**Interfaces:** |
| +- Consumes: `OverviewStats` fields already assembled by `assembleOverview` (`internal/console/overview.go:15-54`). |
| +- Produces: `OverviewStats.InstanceExpected int` (count of account routes with `Kind == "instance"`), `OverviewStats.Verdict string` (one of `"ok" | "attention" | "notdeployed" | "unknown"`), `OverviewStats.VerdictLabel string` (one of `"All good" | "Needs attention" | "Not deployed yet" | "Can't tell right now"`). Tasks 3 and 2 render these. |
| + |
| +- [ ] **Step 1: Write the failing tests** |
| + |
| +Append to `internal/console/overview_test.go`: |
| + |
| +```go |
| +func TestOverviewVerdict(t *testing.T) { |
| + cases := []struct { |
| + name string |
| + st OverviewStats |
| + verdict string |
| + label string |
| + }{ |
| + {"all good", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 2}, "ok", "All good"}, |
| + {"asleep counts as accounted for", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1, InstanceAsleep: 1}, "ok", "All good"}, |
| + {"silent instance", OverviewStats{HasStable: true, InstanceExpected: 2, InstanceReporting: 1}, "attention", "Needs attention"}, |
| + {"domain attention", OverviewStats{HasStable: true, InstanceExpected: 1, InstanceReporting: 1, DomainAttention: 1}, "attention", "Needs attention"}, |
| + {"not deployed", OverviewStats{}, "notdeployed", "Not deployed yet"}, |
| + {"unreadable stable pointer", OverviewStats{LiveErr: "boom"}, "unknown", "Can't tell right now"}, |
| + {"sweep failed with instances expected", OverviewStats{HasStable: true, InstanceExpected: 1, MetricsUnavailable: true}, "unknown", "Can't tell right now"}, |
| + {"static-only app has nothing expected", OverviewStats{HasStable: true, InstanceExpected: 0, MetricsUnavailable: true}, "ok", "All good"}, |
| + } |
| + for _, c := range cases { |
| + v, l := healthVerdict(c.st) |
| + if v != c.verdict || l != c.label { |
| + t.Errorf("%s: got %q/%q, want %q/%q", c.name, v, l, c.verdict, c.label) |
| + } |
| + } |
| +} |
| + |
| +func TestOverviewExpectedCountsInstanceKindOnly(t *testing.T) { |
| + view := &AppView{App: "kass", Routes: []RouteView{ |
| + {Host: "a", Account: "bab", Kind: "instance"}, |
| + {Host: "b", Account: "bab", Kind: "static"}, |
| + {Host: "c", Account: "other", Kind: "instance"}, |
| + }} |
| + st := assembleOverview(view, "bab", []InstanceMetrics{}, nil) |
| + if st.InstanceExpected != 1 { |
| + t.Errorf("InstanceExpected = %d, want 1 (instance-kind, this account only)", st.InstanceExpected) |
| + } |
| + if st.Verdict == "" || st.VerdictLabel == "" { |
| + t.Error("assembleOverview must fill Verdict and VerdictLabel") |
| + } |
| +} |
| +``` |
| + |
| +Note: check `RouteView` in `view.go` for the exact field names (`Account`, `Kind`, `Host`) before writing — mirror whatever `TestInstanceRowsSkipOtherAccountsRoutes` (overview_test.go:258) constructs. |
| + |
| +- [ ] **Step 2: Run tests to verify they fail** |
| + |
| +Run: `go test ./internal/console/ -run 'TestOverviewVerdict|TestOverviewExpected' -count=1` |
| +Expected: FAIL — `healthVerdict` undefined, `InstanceExpected` undefined. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In `internal/console/overview.go`, add to `OverviewStats` (after `MetricsUnavailable`): |
| + |
| +```go |
| + // InstanceExpected is how many of this account's routes are lifecycle- |
| + // managed processes (kind "instance") — the only kind the metrics sweep |
| + // can ever hear from. Static and service routes are served without a |
| + // tenant process, so they are never "silent": expecting a reading from |
| + // them would flag every static site as unhealthy forever. |
| + InstanceExpected int |
| + |
| + // Verdict is the dashboard's one-word answer to "is this app healthy", |
| + // computed here so templates render it as a fact. Verdict is the CSS |
| + // suffix ("ok", "attention", "notdeployed", "unknown"); VerdictLabel the |
| + // words. The rule errs toward "can't tell" over a guessed "all good" — |
| + // same honesty contract as the em-dash. |
| + Verdict string |
| + VerdictLabel string |
| +``` |
| + |
| +In `assembleOverview`, inside the existing routes loop (`overview.go:100-110`), add after `st.InstanceTotal++`: |
| + |
| +```go |
| + if rt.Kind == "instance" { |
| + st.InstanceExpected++ |
| + } |
| +``` |
| + |
| +At the end of `assembleOverview`, before `return st`: |
| + |
| +```go |
| + st.Verdict, st.VerdictLabel = healthVerdict(st) |
| +``` |
| + |
| +Add the function: |
| + |
| +```go |
| +// healthVerdict folds the dashboard's numbers into one word. Order matters: |
| +// an unreadable stable pointer means we don't know what's live, which |
| +// outranks everything; an app never promoted isn't unhealthy, it's not |
| +// deployed; and a failed sweep with real instances is "can't tell", never |
| +// "all good" — a verdict this page can't back is the lie the old Status |
| +// column told. |
| +func healthVerdict(st OverviewStats) (string, string) { |
| + switch { |
| + case st.LiveErr != "": |
| + return "unknown", "Can't tell right now" |
| + case !st.HasStable: |
| + return "notdeployed", "Not deployed yet" |
| + case st.DomainAttention > 0: |
| + return "attention", "Needs attention" |
| + case st.MetricsUnavailable && st.InstanceExpected > 0: |
| + return "unknown", "Can't tell right now" |
| + case st.InstanceReporting+st.InstanceAsleep < st.InstanceExpected: |
| + return "attention", "Needs attention" |
| + default: |
| + return "ok", "All good" |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Run tests to verify they pass** |
| + |
| +Run: `go test ./internal/console/ -count=1` |
| +Expected: PASS (all existing tests too — `assembleOverview`'s signature is unchanged). |
| + |
| +- [ ] **Step 5: Commit** |
| + |
| +```bash |
| +git add internal/console/overview.go internal/console/overview_test.go |
| +git commit -m "console: compute a health verdict on the app overview" |
| +``` |
| + |
| +--- |
| + |
| +### Task 2: Apps list — verdict chip, plain columns (server.go, apps.html, layout.html) |
| + |
| +**Files:** |
| +- Modify: `internal/console/server.go:520-528` (appRow), `internal/console/server.go:2404-2438` (summarize) |
| +- Modify: `internal/console/templates/apps.html` |
| +- Modify: `internal/console/templates/layout.html` (chip CSS, after the `.chip-env` rule ~line 179) |
| +- Test: `internal/console/server_test.go` (grep `summarize` in tests first; if untested, add `TestSummarizeVerdictAndURL` to server_test.go) |
| + |
| +**Interfaces:** |
| +- Consumes: `AppView` (view.go), `humanAge` (view.go), the verdict vocabulary from Task 1 (labels only — the list computes its own registry-scope verdict; it must NOT claim "All good", see Step 3 comment). |
| +- Produces: new `appRow` shape rendered by apps.html: `App, URL, Verdict, VerdictLabel, Stable, StableAge string; CanaryN int`. Shared helper `apexHost(routes []RouteView) string` in ladder.go. CSS classes `chip-health-ok`, `chip-health-attention`, `chip-health-notdeployed`, `chip-health-unknown` used again in Task 3. |
| + |
| +- [ ] **Step 1: Write the failing test** |
| + |
| +```go |
| +func TestSummarizeVerdictAndURL(t *testing.T) { |
| + now := time.Now() |
| + v := AppView{App: "kass", |
| + Routes: []RouteView{ |
| + {Host: "kass.bab.oncarlos.com", Account: "bab", Kind: "instance", Status: "ready"}, |
| + {Host: "kass.example.com", Account: "bab", Kind: "instance", Status: "ready"}, |
| + }, |
| + Channels: []ChannelView{ |
| + {Name: "stable", Version: "d388035", PromotedAt: now.Add(-2 * time.Hour)}, |
| + {Name: "canary/rc-1", Version: "aaa1111"}, |
| + }} |
| + row := summarize(v) |
| + if row.URL != "kass.example.com" { |
| + t.Errorf("URL = %q, want the apex host kass.example.com", row.URL) |
| + } |
| + if row.Verdict != "ok" || row.VerdictLabel != "Deployed" { |
| + t.Errorf("verdict = %q/%q, want ok/Deployed", row.Verdict, row.VerdictLabel) |
| + } |
| + if row.CanaryN != 1 { |
| + t.Errorf("CanaryN = %d, want 1", row.CanaryN) |
| + } |
| + |
| + v.Routes[0].Status = "failed" |
| + row = summarize(v) |
| + if row.Verdict != "attention" || row.VerdictLabel != "Needs attention" { |
| + t.Errorf("failed route: verdict = %q/%q, want attention/Needs attention", row.Verdict, row.VerdictLabel) |
| + } |
| + |
| + empty := summarize(AppView{App: "new"}) |
| + if empty.Verdict != "notdeployed" || empty.VerdictLabel != "Not deployed yet" { |
| + t.Errorf("no stable: verdict = %q/%q, want notdeployed/Not deployed yet", empty.Verdict, empty.VerdictLabel) |
| + } |
| + |
| + bad := summarize(AppView{App: "kass", Channels: []ChannelView{{Name: "stable", Err: "unreadable"}}}) |
| + if bad.Verdict != "unknown" || bad.VerdictLabel != "Can't tell right now" { |
| + t.Errorf("stable err: verdict = %q/%q, want unknown/Can't tell right now", bad.Verdict, bad.VerdictLabel) |
| + } |
| +} |
| +``` |
| + |
| +(Adjust `RouteView`/`ChannelView` field literals to match view.go — same rule as Task 1. Check whether `RouteView` carries `LastError`; if it does, a route with non-empty `LastError` is also `attention` — add a case. If it exists only on `registry.Route` and not on the view, do NOT add it to the view in this task.) |
| + |
| +- [ ] **Step 2: Run test to verify it fails** |
| + |
| +Run: `go test ./internal/console/ -run TestSummarizeVerdictAndURL -count=1` |
| +Expected: FAIL — `appRow` has no `URL`/`Verdict` fields. |
| + |
| +- [ ] **Step 3: Implement summarize + appRow + apexHost** |
| + |
| +Replace `appRow` (server.go:521-528): |
| + |
| +```go |
| +// appRow is one line of the "your apps" table: the app, where it's running, |
| +// whether anything the REGISTRY tracks needs attention, and what's live. |
| +// The verdict here is registry-scope by design — process liveness only |
| +// exists in the per-app metrics sweep, which is too heavy to run across a |
| +// whole account's list. So the healthy word is "Deployed", never "All |
| +// good": the list claims exactly what it knows, and the app's Overview |
| +// (which has the sweep) gives the full verdict. Publishing per-app liveness |
| +// into the aggregate feed so this list can say more is a filed follow-up. |
| +type appRow struct { |
| + App string |
| + URL string // apex host, "" when no routes |
| + Verdict string // ok | attention | notdeployed | unknown |
| + VerdictLabel string // Deployed | Needs attention | Not deployed yet | Can't tell right now |
| + Stable string // live version, "" when none |
| + StableAge string // humanAge of its promotion, "" when unknown |
| + CanaryN int // count of canary/* channels |
| +} |
| +``` |
| + |
| +Replace `summarize` (server.go:2404-2438): |
| + |
| +```go |
| +// summarize folds one AppView into its "your apps" table row. See appRow for |
| +// why its verdict is registry-scope. |
| +func summarize(v AppView) appRow { |
| + row := appRow{App: v.App, URL: apexHost(v.Routes)} |
| + attention := false |
| + for _, rt := range v.Routes { |
| + if rt.Status == "failed" { |
| + attention = true |
| + } |
| + } |
| + stableErr := false |
| + for _, ch := range v.Channels { |
| + if ch.Name == "stable" { |
| + if ch.Err != "" { |
| + stableErr = true |
| + continue |
| + } |
| + row.Stable = ch.Version |
| + if !ch.PromotedAt.IsZero() { |
| + row.StableAge = humanAge(time.Since(ch.PromotedAt)) |
| + } |
| + } |
| + if ch.Err == "" && strings.HasPrefix(ch.Name, "canary/") { |
| + row.CanaryN++ |
| + } |
| + } |
| + switch { |
| + case stableErr: |
| + row.Verdict, row.VerdictLabel = "unknown", "Can't tell right now" |
| + case attention: |
| + row.Verdict, row.VerdictLabel = "attention", "Needs attention" |
| + case row.Stable == "": |
| + row.Verdict, row.VerdictLabel = "notdeployed", "Not deployed yet" |
| + default: |
| + row.Verdict, row.VerdictLabel = "ok", "Deployed" |
| + } |
| + return row |
| +} |
| +``` |
| + |
| +Extract the apex ordering that `prodWiredCanaryHosts` (ladder.go:251-269) already implements, so the two never drift. In `ladder.go`, pull the inline logic into a package function and call it from both places: |
| + |
| +```go |
| +// apexHost picks the app's primary host — fewest labels, then shortest, |
| +// then alphabetical — the same "which host is the apex" answer |
| +// prodWiredCanaryHosts uses. "" when there are no named routes. |
| +func apexHost(routes []RouteView) string { |
| + apex := "" |
| + better := func(a, b string) bool { |
| + la, lb := strings.Count(a, "."), strings.Count(b, ".") |
| + if la != lb { |
| + return la < lb |
| + } |
| + if len(a) != len(b) { |
| + return len(a) < len(b) |
| + } |
| + return a < b |
| + } |
| + for _, rt := range routes { |
| + if rt.Host == "" { |
| + continue |
| + } |
| + if apex == "" || better(rt.Host, apex) { |
| + apex = rt.Host |
| + } |
| + } |
| + return apex |
| +} |
| +``` |
| + |
| +and in `prodWiredCanaryHosts` replace the inline apex block with `apex := apexHost(routes)` (delete the now-duplicated `better` closure there). |
| + |
| +- [ ] **Step 4: Rewrite apps.html** |
| + |
| +Replace the table block (apps.html:6-21): |
| + |
| +```html |
| +<h1>{{.Account.Name}}</h1> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th scope="col">App</th><th scope="col">Running at</th><th scope="col">Health</th><th scope="col">Live version</th></tr> |
| + {{range .Apps}} |
| + <tr> |
| + <td><a href="{{$.Nav}}/apps/{{.App}}">{{.App}}</a></td> |
| + <td>{{if .URL}}<a href="https://{{.URL}}">{{.URL}}</a>{{else}}—{{end}}</td> |
| + <td><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></td> |
| + <td>{{if .Stable}}<code>{{.Stable}}</code>{{if .StableAge}} <span class="small">· {{.StableAge}}</span>{{end}}{{else}}—{{end}}{{if .CanaryN}} <span class="chip chip-canary">{{.CanaryN}} test version{{if ne .CanaryN 1}}s{{end}}</span>{{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +``` |
| + |
| +Change the trash line (apps.html:26-27) to: |
| + |
| +```html |
| +{{if .TrashN}}<p class="small"><a href="{{.Nav}}/trash">Trash ({{.TrashN}})</a> — deleted apps, |
| + restorable for 30 days.</p>{{end}} |
| +``` |
| + |
| +- [ ] **Step 5: Add the health chip CSS** |
| + |
| +In layout.html, directly after the `.chip-env` rule (~line 179), add: |
| + |
| +```css |
| + /* Health verdict chips. "attention" is the one filled variant here — the |
| + row a reader must not skim past — in the warn tone, not .chip-prod's |
| + red: a silent instance is a problem, not the unreviewed-build outage. |
| + ok/notdeployed/unknown are bordered facts. */ |
| + .chip-health-ok { border: 1px solid #2f7a4f; color: #2f7a4f; } |
| + .chip-health-attention { background: var(--warn, #a6790f); color: var(--bg); } |
| + .chip-health-notdeployed { border: 1px solid var(--rule); color: var(--muted); } |
| + .chip-health-unknown { border: 1px solid var(--warn, #a6790f); color: var(--warn, #a6790f); } |
| +``` |
| + |
| +(Check whether layout.html defines a green token — grep `--good` / `2f7a4f`; `chip-up` at line 181 uses `#2f7a4f`, reuse whatever it uses.) |
| + |
| +- [ ] **Step 6: Run the full gate** |
| + |
| +Run: `go build ./... && go vet ./... && go test ./... -count=1 && gofmt -l internal/console/` |
| +Expected: all clean, no files listed by gofmt. Grep first for tests asserting the old columns: `grep -rn '"Hosts"\|Instances</th>\|Statuses' internal/console/*_test.go` — update any hits. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add internal/console/server.go internal/console/ladder.go internal/console/templates/apps.html internal/console/templates/layout.html internal/console/server_test.go |
| +git commit -m "console: apps list answers app/where/health/version, drops provisioning jargon" |
| +``` |
| + |
| +--- |
| + |
| +### Task 3: Overview — headline verdict, four tiles, plain tile copy (app_overview.html, overview.go) |
| + |
| +**Files:** |
| +- Modify: `internal/console/overview.go` (add `PrimaryHost`) |
| +- Modify: `internal/console/templates/app_overview.html` |
| +- Test: `internal/console/overview_test.go` (extend `TestOverviewPageRendersTiles` at line 274) |
| + |
| +**Interfaces:** |
| +- Consumes: Task 1's `Verdict`/`VerdictLabel`/`InstanceExpected`, Task 2's `apexHost` and `chip-health-*` CSS. |
| +- Produces: `OverviewStats.PrimaryHost string`. Final Overview layout other tasks link to. |
| + |
| +- [ ] **Step 1: Failing test** |
| + |
| +In `overview_test.go`, extend the assemble tests: construct a view with routes and assert `st.PrimaryHost` equals the apex host (same fixture shape as Task 2's test). Extend `TestOverviewPageRendersTiles` to assert the rendered page contains the fixture's verdict label and `Machines`, and does NOT contain `stable pointer unreadable` or `Supply-chain bake`. |
| + |
| +- [ ] **Step 2: Run to verify failure** |
| + |
| +Run: `go test ./internal/console/ -run 'TestOverview' -count=1` — FAIL. |
| + |
| +- [ ] **Step 3: Implement `PrimaryHost`** |
| + |
| +In `OverviewStats` add `PrimaryHost string` (comment: "the apex host, for the headline's 'live at' link; empty when no routes"). In `assembleOverview`, before `return st`: |
| + |
| +```go |
| + var mine []RouteView |
| + for _, rt := range view.Routes { |
| + if rt.Account == acct { |
| + mine = append(mine, rt) |
| + } |
| + } |
| + st.PrimaryHost = apexHost(mine) |
| +``` |
| + |
| +- [ ] **Step 4: Rewrite the Overview template** |
| + |
| +Replace app_overview.html:16-68 (the whole `{{with .Overview}}` section and the bake block) with: |
| + |
| +```html |
| +{{with .Overview}} |
| +<section class="pagehead" aria-label="App status"> |
| + <p><span class="chip chip-health-{{.Verdict}}">{{.VerdictLabel}}</span></p> |
| + {{if .HasStable}} |
| + <p>{{$.AppName}} is live{{if .PrimaryHost}} at <a href="https://{{.PrimaryHost}}">{{.PrimaryHost}}</a>{{end}} — |
| + running <code>{{.LiveVersion}}</code>, published {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}.</p> |
| + {{else if .LiveErr}} |
| + <p>We can't tell what's live right now — the record of what's live didn't read. It usually recovers on its own; check again shortly.</p> |
| + {{else}} |
| + <p>Nothing is live yet. Publish a first version from your terminal — the command is on the |
| + <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| + {{end}} |
| +</section> |
| +<section class="tiles stat-tiles" aria-label="Vital statistics"> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| + <h2>Machines</h2> |
| + <p class="tile-value">{{.InstanceTotal}}</p> |
| + <p class="tile-detail">{{if eq .InstanceTotal 0}}none yet{{else if .MetricsUnavailable}}no data right now{{else if eq .InstanceExpected 0}}served by the platform{{else}}{{.InstanceReporting}} of {{.InstanceExpected}} responding{{if .InstanceAsleep}} · {{.InstanceAsleep}} asleep{{end}}{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/instances"> |
| + <h2>Resources</h2> |
| + <p class="tile-value">{{pct .CPUSum}} · {{bytesOrDash .MemSum}}</p> |
| + <p class="tile-detail">{{if .CPUSum}}CPU · memory, from the machines responding{{else if .MetricsUnavailable}}no data right now{{else}}no recent data{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/settings"> |
| + <h2>Web addresses</h2> |
| + <p class="tile-value">{{.DomainTotal}}</p> |
| + <p class="tile-detail">{{if eq .DomainTotal 0}}one appears after your first deploy{{else if .DomainAttention}}<span class="error">{{.DomainAttention}} need{{if eq .DomainAttention 1}}s{{end}} attention</span>{{else}}all working{{end}}</p> |
| + </a> |
| + <a class="tile" href="{{$.Nav}}/apps/{{$.AppName}}/releases"> |
| + <h2>Versions</h2> |
| + <p class="tile-value">{{.ReleaseCount}}{{if .ReleasesCapped}}+{{end}}</p> |
| + <p class="tile-detail">{{if .LatestVersion}}latest <code>{{.LatestVersion}}</code>{{else}}nothing shipped yet{{end}}</p> |
| + </a> |
| +</section> |
| +{{end}} |
| +{{with .Detail.Production}} |
| +{{if .Err}} |
| +<p class="small error">Safety delay: this app's setting can't be read right now, so the delay stays on. Repair it on the <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a> tab.</p> |
| +{{else if .On}} |
| +<p class="small">Safety delay: <strong>on</strong> — new versions wait before going live. Managed on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.</p> |
| +{{else if .TurningOff}} |
| +<p class="small">Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.</p> |
| +{{else}} |
| +<p class="small">Safety delay: off — new versions go live as soon as they're promoted.{{if .Nudge}} {{icon "triangle-alert"}} This app looks like it's serving real users — consider turning it on, on <a href="{{$.Nav}}/apps/{{$.AppName}}/releases">Versions</a>.{{end}}</p> |
| +{{end}} |
| +{{end}} |
| +``` |
| + |
| +And change the footer line (app_overview.html:78): |
| + |
| +```html |
| +{{if not .CanPromote}}<p class="small">Deploys are temporarily unavailable on this console.</p>{{end}} |
| +``` |
| + |
| +Note the section reuses the `pagehead` class (layout.html:262-270). Verify the chip renders sanely inside it; if `pagehead p` styling fights the chip, wrap the chip line in `<p class="eyebrow">` instead and note it in the commit. |
| + |
| +- [ ] **Step 5: Gate and commit** |
| + |
| +Run the full gate (Global Constraints). Grep for tests asserting the old tile copy: `grep -rn 'Live now\|on stable ·\|readings unavailable\|reporting' internal/console/*_test.go` — update hits (especially `TestOverviewPageRendersTiles`). |
| + |
| +```bash |
| +git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/app_overview.html |
| +git commit -m "console: overview leads with URL + health verdict, four plain tiles" |
| +``` |
| + |
| +--- |
| + |
| +### Task 4: Safety-delay rename — every bake string (templates, server.go, confirm.go) |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/ladder.html:5-53` (bake block), `internal/console/templates/app_releases.html:21,30,44-47,77,88-94`, `internal/console/templates/app_settings.html:91-92`, `internal/console/templates/trash.html:35-39` |
| +- Modify: `internal/console/server.go` flash/audit strings (lines 1900, 1906, 1908-1909, 1920, 2062, 2070-2074, 2132, 2158, 2162) |
| +- Modify: `internal/console/confirm.go:137,142,181-183,188,192-196,201` |
| +- Modify: `internal/console/server.go` funcmap — add `until` helper |
| +- Test: `internal/console/bakecontrols_test.go`, `internal/console/confirm_test.go` (update assertions), plus a new funcmap test |
| + |
| +**Interfaces:** |
| +- Consumes: existing `humanAge` (view.go:681-693). |
| +- Produces: funcmap helper `until` (Go func `untilText(t time.Time) string` → `"~4h"`-style horizon via `humanAge(time.Until(t))`, `"now"` for past/zero) used by Task 6's summary strip too. |
| + |
| +- [ ] **Step 1: Inventory + failing test** |
| + |
| +Run `grep -rn -i 'bake\|fast.track\|production-wired\|Re-declare' internal/console/ --include='*.go' --include='*.html' | grep -v _test.go` and keep the list — every hit must be visited. Write the funcmap test first: |
| + |
| +```go |
| +func TestUntilHelper(t *testing.T) { |
| + if got := untilText(time.Now().Add(4*time.Hour + time.Minute)); got != "~4h1m" && got != "~4h" { |
| + t.Errorf("until ~4h = %q", got) |
| + } |
| + if got := untilText(time.Time{}); got != "now" { |
| + t.Errorf("until zero = %q", got) |
| + } |
| + if got := untilText(time.Now().Add(-time.Hour)); got != "now" { |
| + t.Errorf("until past = %q", got) |
| + } |
| +} |
| +``` |
| + |
| +(Check `humanAge`'s exact formatting at view.go:681-693 first and pin the assertion to what it actually emits for ~4h — the test must not encode a guess.) |
| + |
| +- [ ] **Step 2: Run to verify failure** — `go test ./internal/console/ -run TestUntilHelper -count=1` FAILS. |
| + |
| +- [ ] **Step 3: Implement `untilText` + register `until`** |
| + |
| +Following the funcmap pattern at server.go:61-123: |
| + |
| +```go |
| +// untilText renders a future horizon the way the cards speak: "~4h", never |
| +// a timestamp. Past or zero is "now" — the wait is over, not unknown. |
| +func untilText(t time.Time) string { |
| + if t.IsZero() || !t.After(time.Now()) { |
| + return "now" |
| + } |
| + return "~" + humanAge(time.Until(t)) |
| +} |
| +``` |
| + |
| +Register as `"until": untilText` in the template FuncMap. |
| + |
| +- [ ] **Step 4: The template strings** |
| + |
| +ladder.html bake block (lines 5-53) — replace the four states' copy (forms/CSRF fields unchanged, only words; the one `title=` tooltip becomes visible text): |
| + |
| +- Err: `Safety delay: this app's setting can't be read right now, so the delay stays on.` — button text `Re-declare production` → `Set it again`; trailing clause `— writes a fresh signed flag over the unreadable one.` stays. |
| +- On: `Safety delay: <strong>on</strong> — new versions wait ~24h on beta and ~72h on live before machines pick them up.` Replace line 26's tooltip-span entirely with visible text after the Turn off button: `<span class="small">Turning it off asks for your passkey and takes effect after ~72h.</span>` (no `title=` attribute). |
| +- TurningOff: `Safety delay: <strong>turning off</strong> — in effect until ~{{fmtTime .EffectiveAt}}.` |
| +- Off: `Safety delay: off — new versions go live as soon as they're promoted.` |
| +- Nudge (line 51): `{{icon "triangle-alert"}} This app looks like it's serving real users but new versions go live immediately — consider turning the safety delay on.` |
| + |
| +ladder.html rung chips and control (lines 67-75): |
| +- Hotfix chip: `{{icon "zap"}} published directly — skipped the pipeline` |
| +- FastTrack chip: `{{icon "fast-forward"}} delay skipped` |
| +- Fast-track button: `<button type="submit" class="btn-sm btn-danger">Skip the delay for {{.Version}}</button>` |
| +- The `baking until` span: `<span class="small">goes live {{until .BakedUntil}}</span>` |
| + |
| +app_releases.html: |
| +- Line 21 header `<th>Bake</th>` → `<th>Goes live</th>` |
| +- Line 30 cell → `{{if .BakedUntil.IsZero}}live now{{else}}in {{until .BakedUntil}}{{end}}{{if .FastTrack}} · delay skipped{{end}}` |
| +- Lines 44-46 footnote → `<p class="small">Go-live times are estimates — each machine keeps its own clock. Rollbacks skip the delay.</p>` |
| +- Line 77 canary empty state → `No test versions — a test version is a private copy serving one chosen build immediately, with no safety delay.` |
| +- Line 88 → `Creates <code>canary/<name></code> — a test version serving the chosen build immediately, no delay. It gets its own URL from a machine that follows it:` |
| +- Lines 91/93 → `This app has no machines yet, so the first line sets one up under this account's platform subdomain. It comes live within a couple of minutes.` / `The address comes live within a couple of minutes.` |
| + |
| +app_settings.html:91-92 → `It is marked as production right now, which blocks deletion — turn the safety delay off on the Versions tab first.` |
| + |
| +trash.html:35-39 → adjust the "flagged production" sentence to: `An app still marked as production can't be permanently deleted, whatever you type — turn its safety delay off first.` (Keep the surrounding warning structure.) |
| + |
| +- [ ] **Step 5: The Go strings** |
| + |
| +server.go (exact replacements at the listed lines): |
| +- 1900: `" The production flag could not be read, so the safety delay was not enabled — use the toggle on the app's Versions tab."` |
| +- 1906: `" Turning the safety delay on failed — use the toggle on the app's Versions tab."` |
| +- 1909: `" Safety delay enabled."` |
| +- 1920: `" It goes live on each machine after the safety delay — rollbacks skip it."` |
| +- 2062: `"Could not change the safety delay: "+err.Error()` |
| +- 2071: `"Safety delay enabled — new versions now wait on beta and stable before going live."` |
| +- 2074: `"Safety delay turning off — takes effect on each machine after ~72h."` |
| +- 2132: `"Channel "+channel+" has no safety delay running — there is nothing to skip."` |
| +- 2162: `"Skipped the delay for "+ver+" on "+channel+" — machines pick it up within a couple of minutes."` |
| +- Audit details 1908/2070/2073/2158: keep the action keys (`"production"`, `"fast-track"`) unchanged; details become `"enabled the safety delay (production: on, at stable promote)"`, `"enabled the safety delay (production: on)"`, `"disabled the safety delay (production: off)"`, `"skipped the safety delay for "+ver+" on "+channel`. |
| + |
| +confirm.go: |
| +- 137: `cv.Lines = append(cv.Lines, "This app has the safety delay on: each machine waits "+holdText(hold)+" on "+channel+" before running "+version+".")` |
| +- 142: `cv.CheckboxLabel = "This app is live for real users — keep new versions on the safety delay (recommended)"` |
| +- 181 Title: `"Skip the safety delay on " + channel + "?"` |
| +- 182 Warn: `"The safety delay is the window where a new build proves itself — skipping it puts an unproven build in front of users."` |
| +- 183 Note: `"You'll confirm it's really you with your passkey. The version keeps a permanent 'delay skipped' badge."` |
| +- 188 Button: `"Skip the delay"` |
| +- 192 Title: `"Skip the safety delay for " + cur.Version + " on " + channel + "?"` |
| +- 193-195 Lines: `channel+" holds "+cur.Version+", promoted "+ago(cur.PromotedAt)+", still in its safety delay.", "Skipping waives the rest of it: machines run "+cur.Version+" within a couple of minutes."` |
| +- 196 Button: `"Skip the delay for " + cur.Version` |
| +- 201: `"Waives the rest of "+channel+"'s safety delay: machines run its current version within a couple of minutes."` |
| + |
| +- [ ] **Step 6: Update asserting tests, run gate** |
| + |
| +`grep -rn -i 'bake\|fast.track' internal/console/*_test.go` — update every assertion to the new strings (bakecontrols_test.go and confirm_test.go will have several). Then the full gate. Re-run the Step 1 inventory grep: the only remaining `bake`/`fasttrack` hits must be identifiers (form field `enable_bake`, path `/fasttrack`, `BakedUntil`, `FastTrack` field refs) — wire/code names, correctly untouched. |
| + |
| +- [ ] **Step 7: Commit** |
| + |
| +```bash |
| +git add -A internal/console/ |
| +git commit -m "console: rename the supply-chain bake to 'safety delay' in every user-facing string" |
| +``` |
| + |
| +--- |
| + |
| +### Task 5: Instances disambiguation — Running tab, honest columns (appnav.html, app_instances.html, overview.go) |
| + |
| +**Files:** |
| +- Modify: `internal/console/templates/appnav.html:9` |
| +- Modify: `internal/console/templates/app_instances.html` |
| +- Modify: `internal/console/overview.go` (InstanceRow gains `HealthWord`) |
| +- Modify: `internal/console/server.go` (pass attach-hint fields to the instances page if not already on its page struct — check `attachhint.go` and how app_releases gets `.AttachEnable`/`.AttachCmd`; reuse, do not duplicate) |
| +- Test: `internal/console/overview_test.go`, plus the page-render test `TestOverviewInstancesPageRendersTable` (overview_test.go:299) |
| + |
| +**Interfaces:** |
| +- Consumes: `InstanceRow` (overview.go:153-161), attach-hint builder in `attachhint.go`. |
| +- Produces: `InstanceRow.HealthWord string` — one of `Running | Asleep | Starting up | Setting up | Not responding | Failed | Serving` — via `healthWord(kind, status string, hibernate, reporting bool) string`. |
| + |
| +- [ ] **Step 1: Failing test** |
| + |
| +```go |
| +func TestInstanceRowHealthWord(t *testing.T) { |
| + cases := []struct { |
| + kind, status string |
| + hibernate bool |
| + reporting bool |
| + want string |
| + }{ |
| + {"instance", "ready", false, true, "Running"}, |
| + {"instance", "ready", true, false, "Asleep"}, |
| + {"instance", "ready", false, false, "Not responding"}, |
| + {"instance", "starting", false, false, "Setting up"}, |
| + {"instance", "pending", false, false, "Setting up"}, |
| + {"instance", "creating", false, false, "Setting up"}, |
| + {"instance", "issuing", false, false, "Setting up"}, |
| + {"instance", "failed", false, false, "Failed"}, |
| + {"static", "ready", false, false, "Serving"}, |
| + {"service", "ready", false, false, "Serving"}, |
| + } |
| + for _, c := range cases { |
| + got := healthWord(c.kind, c.status, c.hibernate, c.reporting) |
| + if got != c.want { |
| + t.Errorf("healthWord(%q,%q,hib=%v,rep=%v) = %q, want %q", c.kind, c.status, c.hibernate, c.reporting, got, c.want) |
| + } |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — FAIL, `healthWord` undefined. |
| + |
| +- [ ] **Step 3: Implement** |
| + |
| +In overview.go add to `InstanceRow`: `HealthWord string` (comment: "the Status enum + hibernate + reporting folded into one plain word — the column's whole job"). In `assembleInstanceRows`, set `HealthWord: healthWord(rt.Kind, status, rt.Hibernate, byHost[rt.Host].Reporting())` in the row literal. Add: |
| + |
| +```go |
| +// healthWord folds provisioning state, hibernation and liveness into the one |
| +// word the table shows. Provisioning words win while provisioning is real |
| +// (a cert mid-issue IS the situation); once the platform says ready, the |
| +// question becomes "is the process alive", which only instance-kind routes |
| +// can answer — static and service routes are served without a tenant |
| +// process, so ready means Serving, full stop. |
| +func healthWord(kind, status string, hibernate, reporting bool) string { |
| + switch status { |
| + case "pending", "creating", "starting", "issuing": |
| + return "Setting up" |
| + case "failed": |
| + return "Failed" |
| + } |
| + if kind != "instance" { |
| + return "Serving" |
| + } |
| + switch { |
| + case reporting: |
| + return "Running" |
| + case hibernate: |
| + return "Asleep" |
| + default: |
| + return "Not responding" |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Templates** |
| + |
| +appnav.html:9 — label only: `>Running</a>` (href unchanged). |
| + |
| +app_instances.html — replace lines 6-33 with: |
| + |
| +```html |
| +<h2>Running</h2> |
| +{{if .InstanceRows}} |
| +<div class="tablewrap"> |
| +<table class="instances"> |
| + <tr><th scope="col">Address</th><th scope="col">Health</th><th scope="col">Version</th><th scope="col">Channel</th><th scope="col">CPU</th><th scope="col">Memory</th></tr> |
| + {{range .InstanceRows}} |
| + <tr> |
| + <td><a href="https://{{.Host}}">{{.Host}}</a></td> |
| + <td>{{.HealthWord}}{{if eq .HealthWord "Asleep"}}<span class="small"> · wakes on the next request</span>{{end}}</td> |
| + <td>{{if .Version}}<code>{{.Version}}</code>{{else}}—{{end}}</td> |
| + <td><code>{{.Channel}}</code></td> |
| + {{if .Metrics.Reporting}} |
| + <td><span class="cell-value">{{pct .Metrics.CPUFrac}}</span>{{if .Metrics.SparkCPU}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkCPU}}"/></svg>{{end}}</td> |
| + <td><span class="cell-value">{{bytesOrDash .Metrics.MemBytes}}</span>{{if .Metrics.SparkMem}}<svg class="cellspark" aria-hidden="true" viewBox="0 0 240 40" preserveAspectRatio="none"><polyline fill="none" stroke="currentColor" points="{{.Metrics.SparkMem}}"/></svg>{{end}}</td> |
| + {{else}} |
| + <td colspan="2"><span class="small">no data</span></td> |
| + {{end}} |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +<p class="small">Version is what each machine reports it is running — a dash means |
| +it hasn't reported yet. Charts cover the last 48 hours; the memory line is drawn |
| +against that machine's own peak.</p> |
| +{{else}} |
| +<p>Nothing running yet. Publish a version, then give it a web address:</p> |
| +{{if .AttachEnable}}<pre><code>{{.AttachEnable}} |
| +{{.AttachCmd}}</code></pre>{{else if .AttachCmd}}<pre><code>{{.AttachCmd}}</code></pre>{{end}} |
| +<p class="small">The address comes live within a couple of minutes.</p> |
| +{{end}} |
| +``` |
| + |
| +(The `Kind` suffix on the host is dropped: `HealthWord` already folds what mattered. The old box-only `carlos add` command is gone; the attach hint is the same one Releases shows — check whether the instances handler fills `AttachEnable`/`AttachCmd`; if only the releases handler does, lift that fill into the shared app-page assembly so both tabs get it. If the hint genuinely can't be built there without new I/O, fall back to a literal: `<p class="small">Set one up from your terminal: <code>carlos instances create -app {{.Detail.App}} -host <name></code></p>` — never `carlos add`.) |
| + |
| +And the second section (lines 35-52) becomes a disclosure: |
| + |
| +```html |
| +{{if and .Instances .Instances.Reporting .Instances.Instances}} |
| +<details> |
| +<summary>Reported by the app itself ({{len .Instances.Instances}})</summary> |
| +<p class="small">This app tracks its own internal workers and reports them to the platform.</p> |
| +<div class="tablewrap"> |
| +<table> |
| + <tr><th scope="col">Name</th><th scope="col">Status</th><th scope="col">Region</th><th scope="col">Detail</th></tr> |
| + {{range .Instances.Instances}} |
| + <tr> |
| + <td><code>{{.Slug}}</code></td> |
| + <td>{{if .Status}}{{.Status}}{{else}}—{{end}}</td> |
| + <td>{{if .Region}}{{.Region}}{{else}}—{{end}}</td> |
| + <td>{{if .Detail}}{{.Detail}}{{else}}—{{end}}</td> |
| + </tr> |
| + {{end}} |
| +</table> |
| +</div> |
| +</details> |
| +{{end}} |
| +``` |
| + |
| +- [ ] **Step 5: Gate + commit** |
| + |
| +Update `TestOverviewInstancesPageRendersTable` assertions (old headers → new). Full gate. |
| + |
| +```bash |
| +git add internal/console/overview.go internal/console/overview_test.go internal/console/templates/appnav.html internal/console/templates/app_instances.html internal/console/server.go |
| +git commit -m "console: Instances tab becomes Running with honest one-word health" |
| +``` |
| + |
| +--- |
| + |
| +### Task 6: Releases progressive reveal — summary strip, two disclosures (releasesummary.go, app_releases.html, ladder.html, ladder.go) |
| + |
| +**Files:** |
| +- Create: `internal/console/releasesummary.go` |
| +- Create: `internal/console/releasesummary_test.go` |
| +- Modify: `internal/console/templates/app_releases.html`, `internal/console/templates/ladder.html:2-4,91,112`, `internal/console/ladder.go:119-124,173` |
| +- Modify: `internal/console/server.go` (add `ReleaseSummary ReleaseSummary` to the `page` struct near `Detail`, fill it in the releases handler where `p.Detail` is set: `p.ReleaseSummary = assembleReleaseSummary(p.Detail.Channels)`) |
| +- Modify: `internal/console/templates/layout.html` (one `.reveal summary` rule) |
| +- Test: update `internal/console/ladder_test.go` gate-string assertions if any |
| + |
| +**Interfaces:** |
| +- Consumes: `ChannelView` (view.go — fields `Name, Version, PromotedAt, Actor, Err, FastTrack, BakedUntil` per ladder.go:156-159), Task 4's `until` funcmap helper. |
| +- Produces: `type ReleaseSummary struct { HasLive bool; LiveVersion string; LivePromotedAt time.Time; LiveActor string; LiveErr string; NextVersion string; NextBakedUntil time.Time }` and `func assembleReleaseSummary(channels []ChannelView) ReleaseSummary`. |
| + |
| +- [ ] **Step 1: Failing tests** |
| + |
| +`internal/console/releasesummary_test.go`: |
| + |
| +```go |
| +package console |
| + |
| +import ( |
| + "testing" |
| + "time" |
| +) |
| + |
| +func TestReleaseSummary(t *testing.T) { |
| + now := time.Now() |
| + chans := []ChannelView{ |
| + {Name: "stable", Version: "d388035", PromotedAt: now.Add(-48 * time.Hour), Actor: "paul@keymail.dev"}, |
| + {Name: "beta", Version: "e91ac22", PromotedAt: now.Add(-20 * time.Hour), BakedUntil: now.Add(4 * time.Hour)}, |
| + {Name: "canary/rc-1", Version: "aaa1111"}, |
| + } |
| + s := assembleReleaseSummary(chans) |
| + if !s.HasLive || s.LiveVersion != "d388035" || s.LiveActor != "paul@keymail.dev" { |
| + t.Errorf("live = %+v", s) |
| + } |
| + if s.NextVersion != "e91ac22" || !s.NextBakedUntil.Equal(now.Add(4*time.Hour)) { |
| + t.Errorf("next = %+v", s) |
| + } |
| + |
| + same := assembleReleaseSummary([]ChannelView{ |
| + {Name: "stable", Version: "v1"}, {Name: "beta", Version: "v1"}, |
| + }) |
| + if same.NextVersion != "" { |
| + t.Errorf("same version on beta must not be Next, got %q", same.NextVersion) |
| + } |
| + |
| + bad := assembleReleaseSummary([]ChannelView{{Name: "stable", Err: "boom"}, {Name: "beta", Version: "v2"}}) |
| + if bad.HasLive || bad.LiveErr == "" || bad.NextVersion != "" { |
| + t.Errorf("unreadable stable = %+v", bad) |
| + } |
| +} |
| +``` |
| + |
| +- [ ] **Step 2: Run to verify failure** — FAIL. |
| + |
| +- [ ] **Step 3: Implement releasesummary.go** |
| + |
| +```go |
| +// The Versions tab's first-glance strip: what is live, and what is next in |
| +// line — the two facts a visitor came for, computed here so the template |
| +// renders them without touching the ladder. "Next" is beta's version when it |
| +// differs from stable's: the ladder's one real candidate step. Everything |
| +// deeper (rungs, gates, canaries) stays in the pipeline disclosure. |
| +package console |
| + |
| +import "time" |
| + |
| +type ReleaseSummary struct { |
| + HasLive bool |
| + LiveVersion string |
| + LivePromotedAt time.Time |
| + LiveActor string |
| + LiveErr string |
| + // NextVersion is beta's candidate when it differs from live; "" when |
| + // there is nothing next. NextBakedUntil zero means it can go live now. |
| + NextVersion string |
| + NextBakedUntil time.Time |
| +} |
| + |
| +func assembleReleaseSummary(channels []ChannelView) ReleaseSummary { |
| + var s ReleaseSummary |
| + var beta ChannelView |
| + for _, ch := range channels { |
| + switch ch.Name { |
| + case "stable": |
| + if ch.Err != "" { |
| + s.LiveErr = ch.Err |
| + continue |
| + } |
| + s.HasLive = true |
| + s.LiveVersion = ch.Version |
| + s.LivePromotedAt = ch.PromotedAt |
| + s.LiveActor = ch.Actor |
| + case "beta": |
| + beta = ch |
| + } |
| + } |
| + if s.LiveErr == "" && beta.Err == "" && beta.Version != "" && beta.Version != s.LiveVersion { |
| + s.NextVersion = beta.Version |
| + s.NextBakedUntil = beta.BakedUntil |
| + } |
| + return s |
| +} |
| +``` |
| + |
| +- [ ] **Step 4: Restructure app_releases.html** |
| + |
| +Replace line 15 (`{{template "ladder" .}}`) with the summary strip: |
| + |
| +```html |
| +{{with .ReleaseSummary}} |
| +<section class="pagehead" aria-label="Release status"> |
| + {{if .HasLive}} |
| + <p>Live: <code>{{.LiveVersion}}</code> <span class="small">— promoted {{ago .LivePromotedAt}}{{if .LiveActor}} by {{.LiveActor}}{{end}}</span></p> |
| + {{else if .LiveErr}} |
| + <p>We can't tell what's live right now — it usually recovers on its own; check again shortly.</p> |
| + {{else}} |
| + <p>Nothing is live yet.</p> |
| + {{end}} |
| + {{if .NextVersion}} |
| + <p>Next up: <code>{{.NextVersion}}</code> on beta — {{if .NextBakedUntil.IsZero}}ready to go live now{{else}}goes live {{until .NextBakedUntil}}{{end}}.</p> |
| + {{if and $.CanPromote $.IsOwner}} |
| + <form method="post" action="{{$.Base}}/apps/{{$.Detail.App}}/promote"> |
| + <input type="hidden" name="csrf" value="{{$.CSRF}}"> |
| + <input type="hidden" name="channel" value="stable"> |
| + <input type="hidden" name="version" value="{{.NextVersion}}"> |
| + <input type="hidden" name="back" value="releases"> |
| + <button type="submit" class="btn">Make {{.NextVersion}} live</button> |
| + </form> |
| + {{else}} |
| + <p class="small">Only an account owner can make it live.</p> |
| + {{end}} |
| + {{end}} |
| +</section> |
| +{{end}} |
| +``` |
| + |
| +(Check the funcmap's `ago` output — if it emits `2d ago`, the strip's first line reads "promoted 2d ago by …", which is correct as written. The Make-live button posts the existing promote endpoint, which renders the existing confirm card + passkey step — no handler change.) |
| + |
| +Reorder the rest of the page: the Versions table (current "Releases" section, lines 97-135) moves UP to sit directly under the strip, wrapped: |
| + |
| +```html |
| +{{if .Versions}} |
| +<details class="reveal" open> |
| +<summary>All versions ({{len .Versions}})</summary> |
| +... existing Releases table, with two edits below ... |
| +</details> |
| +{{else}} |
| +... existing empty state (Nothing shipped yet + carlos ship pre-block), unchanged ... |
| +{{end}} |
| +``` |
| + |
| +Two edits inside the table: delete the free-text input line |
| + |
| +```html |
| + <input type="text" name="new_channel" placeholder="or a new channel" size="14"> |
| +``` |
| + |
| +and give the select an accessible name: `<select name="channel" aria-label="Channel to promote {{.Version}} to">`. (The server keeps accepting `new_channel` — no handler change; new-channel creation now happens via the canary form or CLI. Grep tests for `new_channel` posts through this page first.) |
| + |
| +Then the expert layer — the ladder plus the whole Channels, Canaries (+create form and attach hint), and Tags sections move inside one closed disclosure at the bottom: |
| + |
| +```html |
| +<details class="reveal"> |
| +<summary>Release pipeline — channels, test versions, and the promotion ladder</summary> |
| +{{template "ladder" .}} |
| +... Channels section (existing, with Task 4's strings) ... |
| +... Canaries section (existing, with Task 4's strings) ... |
| +... Tags section (existing) ... |
| +</details> |
| +``` |
| + |
| +In layout.html add one rule near the existing `details` styling (grep `summary` first; add if absent): |
| + |
| +```css |
| + .reveal > summary { font-size: 1.1rem; font-weight: 600; cursor: pointer; } |
| +``` |
| + |
| +- [ ] **Step 5: Ladder copy (ladder.html, ladder.go)** |
| + |
| +ladder.html header block (lines 2-4) becomes: |
| + |
| +```html |
| +<h2>The ladder</h2> |
| +<p class="small">Every version climbs the same steps — canary (test) → edge → beta → stable (live). |
| +Promoting moves it one step up; the machines following that step pick it up.</p> |
| +``` |
| + |
| +ladder.go `gates` map (lines 119-124) becomes: |
| + |
| +```go |
| +var gates = map[string]string{ |
| + release.RungCanary: "No sign-off needed — a test version can be pointed at anything you choose, including production.", |
| + release.RungEdge: "Builds land here when they merge to main — nothing is promoted onto edge.", |
| + release.RungBeta: "Any account member can promote here; it's recorded.", |
| + release.RungStable: "An account owner promotes here with their passkey, after the safety delay.", |
| +} |
| +``` |
| + |
| +ladder.go:173 → `Err: "nothing has been published here yet — these machines are waiting for a version"`. |
| +ladder.html:91 → `No machine follows this channel.` |
| +ladder.html:112 → `Only an account owner can promote to {{.Up.To}}.` |
| +(Rung names `canary/edge/beta/stable` stay verbatim inside the pipeline layer — it is the expert layer; the lede's parentheticals do the teaching.) |
| + |
| +- [ ] **Step 6: Gate + commit** |
| + |
| +Update `ladder_test.go` if it asserts gate strings (`grep -n 'canary is proven\|Sign-off\|pointer that does not exist' internal/console/ladder_test.go`). Full gate. |
| + |
| +```bash |
| +git add internal/console/releasesummary.go internal/console/releasesummary_test.go internal/console/server.go internal/console/templates/app_releases.html internal/console/templates/ladder.html internal/console/ladder.go internal/console/ladder_test.go internal/console/templates/layout.html |
| +git commit -m "console: releases page leads with live/next, ladder and tables move behind reveals" |
| +``` |
| + |
| +--- |
| + |
| +### Task 7: Whole-batch verification + review |
| + |
| +**Files:** none new. |
| + |
| +- [ ] **Step 1: Full gate, all packages** — `go build ./... && go vet ./... && go test ./... -count=1`, `GOOS=darwin go build ./...`, `gofmt -l` on every touched file. All clean. |
| + |
| +- [ ] **Step 2: Jargon regression grep** — `grep -rn -i 'supply-chain\|bake\|fast-track\|adopt\|pointer\|manifest' internal/console/templates/` must return only: identifiers/paths (`/fasttrack`, `enable_bake` form field, `.BakedUntil`, `.FastTrack` field refs) and operator-only pages (fleet.html, metrics.html — out of this batch's scope). Anything else is a miss — fix it. |
| + |
| +- [ ] **Step 3: Render smoke** — the page-render tests execute the real templates and are the closest thing to a screenshot in CI: `go test ./internal/console/ -run 'Render|Page' -v -count=1`. |
| + |
| +- [ ] **Step 4: Reviewer dispatch (per CLAUDE.md delegation policy)** — this batch touches production console paths: request an **Opus** review of the whole branch diff (superpowers:requesting-code-review), attaching the critique file as the spec. Fix findings, re-run the gate. |
| + |
| +- [ ] **Step 5: Push and open a draft PR; do NOT merge** — Paul's human review is the explicit next gate. Draft PR title: `console: clarity batch — health verdict, safety delay, progressive reveal (P0s+P1s)`. Body links `.impeccable/critique/2026-08-09T15-36-14Z__internal-console-templates.md` and names the two deliberate scope-outs: (1) apps-list verdict is registry-scope ("Deployed", not "All good") pending a per-app liveness aggregate — file that gap as an issue; (2) P2s/P3s (box-only command copy on Settings, raw err two-parting, footer disclaimer, full a11y sweep) are a follow-up batch. |
| + |
| +**Deploy note (not part of this plan's execution):** rolling this needs the standard console artifact rules — console = `cmd/console` build, NOT the root binary; both boxes arm64; promote → adopt → console restart (until #110's unit-stamped route is rolled everywhere). Deploy only on Paul's go. |