docs/plans/2026-09-27-web-ux.md

main
gitbay/docs/plans/2026-09-27-web-ux.md rendered · source · history · blame · raw

3045 lines · 110761 bytes

41 symbols in this file
   1# Web UX sweep implementation plan
   2
   3> **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.
   4
   5**Goal:** Close out the architecture-review web findings (#261), the two
   6web UX-review issues (#263, #271), the empty-state sweep (#270), the
   7missing API-token page (#264), the MR range-diff view (#269), and the
   8wiki non-page-link 404 (#283).
   9
  10**Architecture:** No new subsystems. Every write goes through
  11`s.runControl`/`s.runControlCode` into the existing control registry
  12(`internal/httpd/control.go`), the same rule every other web write
  13already follows — this plan fixes the three handlers that did not
  14(`pinToggle`, `watchToggle`, and adds a mute state to the latter). Reads
  15either dispatch into a command (range-diff, whose text output the CLI
  16and iOS already render, so the web reuses it rather than re-implementing
  17revision resolution) or read the store directly, matching the existing
  18convention for GET handlers (`accountPage` already reads
  19`ListSSHKeys`/`ListPGPKeys` directly). Template copy changes are text-only;
  20one new page (`mrrangediff.html`) and one new settings section
  21(`Settings → Tokens`) are added following the existing settings-page and
  22repo-page patterns.
  23
  24**Tech stack:** Go, `html/template`, the existing SQLite store, cobra
  25(`cmd/gitbay`).
  26
  27**Spec:** none — this plan is written directly from the issue texts
  28(`.gitbay/wiki` doc drift, architecture-review findings) and the current
  29source; there is no separate design doc.
  30
  31## Global constraints
  32
  33- Five MRs (one skipped: this plan issues #261/#263/#269/#270/#271/#283
  34  are covered by five MRs; #264 is a sixth), each on its own branch off
  35  `main`. Commits are signed (the repository refuses unsigned ones);
  36  messages end with `Ref #N`, and the last commit closing an issue ends
  37  with `Closes #N`. No attribution to any assistant, model or AI
  38  anywhere: commits, MR bodies, comments.
  39- `gitbay mr create --source <branch> --target main --title "..."`;
  40  merge with `gitbay mr merge <n> --strategy ff` once CI is green, then
  41  delete the branch locally and on the remote. If the merge reports the
  42  branch is behind, rebase onto `main`, force-push, merge again.
  43- Locally: `go build ./...`, `go vet ./...`, the unit tests of touched
  44  packages, and at most the one e2e test being written
  45  (`go test ./e2e -run TestName -count=1`). CI on bay1 runs the full
  46  suite (`go test ./...`), including `TestMainWidthClass`,
  47  `e2e/readonly_test.go`'s `readArgs` coverage, and the `cmd/gitbay`
  48  coverage test over `pass()` registrations — none of this plan's tasks
  49  add a new control command, so none of those three registries gain a
  50  new required row, but a new page template (`mrrangediff.html`) **does**
  51  need a row in `TestMainWidthClass`'s width map
  52  (`internal/web/web_test.go`).
  53- No migrations in this plan (no schema changes).
  54- Web writes dispatch through control commands
  55  (`internal/httpd/control.go`: `runControl`/`runControlCode`/
  56  `runControlStdin`); a handler that calls the store directly for a
  57  *write* is exactly the bug #261 reports for `pinToggle` and
  58  `watchToggle`, and this plan does not introduce a new instance of it.
  59  Reads may call the store directly (the existing convention throughout
  60  `internal/httpd`) or dispatch when the logic they need (e.g. revision
  61  resolution for range-diff) already lives in a command.
  62- Secrets travel on stdin or, for the one case that already carries one
  63  in a URL by design (the emailed login link), never in a place the
  64  code cannot document and cache-guard; never logged.
  65- Wiki pages live in `.gitbay/wiki/`: `Parity.org`, `API.org`,
  66  `Threat-Model.org`. Update the page in the same MR that changes the
  67  behaviour it describes.
  68- Writing style: plain, direct, no hype; UI copy uses the register fixed
  69  in MR 4 (Task 4.1) everywhere else it appears afterward.
  70- CLAUDE.md's rules apply throughout: surgical changes only, no
  71  unrelated refactors, no speculative flexibility.
  72
  73## Order and dependencies
  74
  751. **`web-audit-fixes`** (branch `web-audit-fixes`) — closes #261.
  76   Independent. Landing this first matters because MR 6 (#271's mute
  77   option) depends on the toggle-dispatch fix here.
  782. **`web-settings-commands`** (branch `web-settings-commands`) — closes
  79   #263. Independent of 1. Lands after `cli-ux-help` (CLI UX plan),
  80   which carries the auth summary and help part of #263.
  813. **`web-mr-range-diff`** (branch `web-mr-range-diff`) — closes #269.
  82   Independent.
  834. **`web-empty-states`** (branch `web-empty-states`) — closes #270.
  84   Independent, but touches `mrs.html` and `globalsearch.html`; land
  85   before MR 6 to avoid the same files diverging on two branches at
  86   once (MR 6 does not touch either).
  875. **`web-ux-small-fixes`** (branch `web-ux-small-fixes`) — closes #271.
  88   Depends on MR 1 (`repo watch`/`repo mute` dispatch and the cycling
  89   toggle it introduces; #271's Muted option builds directly on it).
  906. **`wiki-raw-links`** (branch `wiki-raw-links`) — closes #283.
  91   Independent; small, can land anywhere, placed last only because it
  92   is unrelated to the rest.
  937. **`web-api-tokens`** (branch `web-api-tokens`) — closes #264.
  94   Depends on plan 1 (`credentials-and-sessions`, #257): that plan
  95   changes `token create`'s own default scope to `read`. This plan's
  96   Task 7.1 makes the *web form* always send an explicit `--scope`
  97   value regardless of what the command defaults to, so this MR does
  98   not have to wait for plan 1 to land — but merge it after plan 1 to
  99   pick up the release-note and any command-message changes plan 1
 100   makes to `token create`. If plan 1 has not landed yet, this MR still
 101   works correctly (the form never relies on the flag's default); note
 102   in the MR description that it does not depend on plan 1 having
 103   merged, only on eventually being consistent with it.
 104
 105---
 106
 107# MR 1: architecture-review small fixes (branch `web-audit-fixes`)
 108
 109Closes #261.
 110
 111### Task 1.1: run `foreign_key_check` inside the migration transaction, before commit
 112
 113**Files:**
 114- Modify: `internal/store/store.go:182-259` (`step`, the `fkOff` branch)
 115- Test: `internal/store/store_test.go` (create the case if no existing
 116  migration test exercises an `fkOff` step; check first)
 117
 118**Interfaces:**
 119- No signature changes; `step`'s behaviour changes only.
 120
 121- [ ] **Step 1: Confirm there is no existing FK-violation test to build on**
 122
 123Run: `grep -n "foreign_key_check\|fkOff\|foreign_keys: off" internal/store/store_test.go`
 124If nothing matches, the test below is new.
 125
 126- [ ] **Step 2: Write the failing test**
 127
 128Add to `internal/store/store_test.go`:
 129
 130```go
 131// A migration marked "-- foreign_keys: off" must have its
 132// foreign_key_check run before the transaction commits, not after —
 133// otherwise a violation is reported once the bad schema and
 134// user_version are already persisted (#261).
 135func TestFKOffMigrationChecksBeforeCommit(t *testing.T) {
 136	dir := t.TempDir()
 137	st, err := Open(dir + "/test.db")
 138	if err != nil {
 139		t.Fatal(err)
 140	}
 141	defer st.Close()
 142
 143	// A minimal two-step schema: a parent table, then a child that
 144	// references it, or el se this migration wouldn't exercise anything.
 145	// Reach in through the exported entry point rather than duplicating
 146	// migration internals: two ad hoc migrations appended to the real
 147	// list would require touching the embedded migration files, so this
 148	// test instead runs the real migration set up to its current head
 149	// and then drives step() through a synthetic single-migration
 150	// upgrade using the unexported hook the package already has for
 151	// tests, if one exists.
 152	if err := st.MigrateUp(); err != nil {
 153		t.Fatal(err)
 154	}
 155	before, err := st.DB.Query("PRAGMA user_version")
 156	if err != nil {
 157		t.Fatal(err)
 158	}
 159	before.Close()
 160
 161	// Insert a row through a raw statement that a fkOff rebuild would
 162	// have to preserve or complain about: a milestone with no matching
 163	// repo_id (the deliberately impossible case a corrupt migration
 164	// would produce).
 165	if _, err := st.DB.Exec("PRAGMA foreign_keys = OFF"); err != nil {
 166		t.Fatal(err)
 167	}
 168	if _, err := st.DB.Exec(
 169		"INSERT INTO milestones (repo_id, title, state, created_at) VALUES (99999, 'orphan', 'open', datetime('now'))"); err != nil {
 170		t.Fatal(err)
 171	}
 172	if _, err := st.DB.Exec("PRAGMA foreign_keys = ON"); err != nil {
 173		t.Fatal(err)
 174	}
 175
 176	// A no-op fkOff step (rewriting milestones to itself) must now
 177	// refuse — before it commits, not after — because the orphan row
 178	// fails foreign_key_check. Confirm today's ordering leaves the
 179	// schema version bumped despite the row it can never satisfy: this
 180	// is the bug. Run the check directly the way step() will, and
 181	// compare against the version left behind.
 182	versionBefore := currentUserVersion(t, st)
 183	err = st.runFKOffStepForTest(
 184		"UPDATE sqlite_master SET name = name WHERE 0", versionBefore+1)
 185	if err == nil {
 186		t.Fatal("expected foreign_key_check to refuse the orphaned row")
 187	}
 188	if got := currentUserVersion(t, st); got != versionBefore {
 189		t.Fatalf("user_version changed to %d despite the refused check (should stay %d)", got, versionBefore)
 190	}
 191}
 192
 193func currentUserVersion(t *testing.T, st *Store) int {
 194	t.Helper()
 195	var v int
 196	if err := st.DB.QueryRow("PRAGMA user_version").Scan(&v); err != nil {
 197		t.Fatal(err)
 198	}
 199	return v
 200}
 201```
 202
 203This calls an unexported `runFKOffStepForTest` that does not exist yet —
 204it is Step 3's job to expose the already-unexported `step` closure's
 205`fkOff` path under a name the test package can call. `step` is currently
 206a closure local to `MigrateTo`; Step 3 promotes it to a package-level
 207method so this test (and the real migration loop) can call the same
 208code.
 209
 210- [ ] **Step 2: Run it and see it fail to compile**
 211
 212Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
 213Expected: FAIL to compile (`runFKOffStepForTest` undefined).
 214
 215- [ ] **Step 3: Promote `step` to a method and fix the ordering**
 216
 217In `internal/store/store.go`, replace the `step` closure inside
 218`MigrateTo` (the whole `step := func(sqlText string, newVersion int,
 219fkOff bool) (retErr error) { ... }` block at lines 182-259) with a call
 220to a new method, and move its body there:
 221
 222```go
 223func (s *Store) migrateStep(sqlText string, newVersion int, fkOff bool) (retErr error) {
 224	if !fkOff {
 225		tx, err := s.DB.Begin()
 226		if err != nil {
 227			return err
 228		}
 229		defer tx.Rollback()
 230		if _, err := tx.Exec(sqlText); err != nil {
 231			return err
 232		}
 233		if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
 234			return err
 235		}
 236		return tx.Commit()
 237	}
 238
 239	// A script whose first line is "-- foreign_keys: off" rebuilds a
 240	// table that other tables reference (labels, milestones): with
 241	// foreign keys on, the rebuild-by-rename loses the children's
 242	// rows. PRAGMA foreign_keys is a no-op inside a transaction, and
 243	// the pool gives no guarantee that a pragma set on one connection
 244	// is seen by the connection Begin() draws next, so the whole step
 245	// — pragma off, transaction, foreign_key_check, commit, pragma on —
 246	// runs on a single pinned connection. The check runs before commit:
 247	// checking after would report a violation once the bad schema and
 248	// user_version were already persisted.
 249	ctx := context.Background()
 250	conn, err := s.DB.Conn(ctx)
 251	if err != nil {
 252		return err
 253	}
 254	defer conn.Close()
 255	if _, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = OFF"); err != nil {
 256		return err
 257	}
 258	// The connection goes back to the pool when this returns, so every
 259	// path out of here has to put foreign keys back on first.
 260	restoreFK := func() error {
 261		_, err := conn.ExecContext(ctx, "PRAGMA foreign_keys = ON")
 262		return err
 263	}
 264	defer func() {
 265		if err := restoreFK(); err != nil && retErr == nil {
 266			retErr = err
 267		}
 268	}()
 269	tx, err := conn.BeginTx(ctx, nil)
 270	if err != nil {
 271		return err
 272	}
 273	defer tx.Rollback()
 274	if _, err := tx.Exec(sqlText); err != nil {
 275		return err
 276	}
 277	if _, err := tx.Exec(fmt.Sprintf("PRAGMA user_version = %d", newVersion)); err != nil {
 278		return err
 279	}
 280	// foreign_key_check works with enforcement off: it inspects the
 281	// data directly rather than consulting the pragma. Running it here,
 282	// inside the transaction, means a violation rolls back the whole
 283	// rebuild (deferred tx.Rollback fires) instead of leaving the bad
 284	// schema and version committed.
 285	rows, err := tx.QueryContext(ctx, "PRAGMA foreign_key_check")
 286	if err != nil {
 287		return err
 288	}
 289	if rows.Next() {
 290		var table string
 291		var rowid sql.NullInt64
 292		var referredTable string
 293		var fkid int
 294		if err := rows.Scan(&table, &rowid, &referredTable, &fkid); err != nil {
 295			rows.Close()
 296			return err
 297		}
 298		rows.Close()
 299		return fmt.Errorf("foreign_key_check failed after migration: %s", table)
 300	}
 301	if err := rows.Err(); err != nil {
 302		rows.Close()
 303		return err
 304	}
 305	rows.Close()
 306	return tx.Commit()
 307}
 308
 309// runFKOffStepForTest exposes migrateStep's fkOff path to the package's
 310// own tests, which need to drive one step in isolation rather than the
 311// whole migration list MigrateTo runs.
 312func (s *Store) runFKOffStepForTest(sqlText string, newVersion int) error {
 313	return s.migrateStep(sqlText, newVersion, true)
 314}
 315```
 316
 317Update `MigrateTo`'s two loops to call the method instead of the removed
 318closure:
 319
 320```go
 321	for cur < target {
 322		m := ms[cur]
 323		if err := s.migrateStep(m.up, m.version, m.upFKOff); err != nil {
 324			return fmt.Errorf("migration %d up: %w", m.version, err)
 325		}
 326		cur = m.version
 327	}
 328	for cur > target {
 329		m := ms[cur-1]
 330		if err := s.migrateStep(m.down, m.version-1, m.downFKOff); err != nil {
 331			return fmt.Errorf("migration %d down: %w", m.version, err)
 332		}
 333		cur = m.version - 1
 334	}
 335```
 336
 337`runFKOffStepForTest` is exported to the test file only in the sense
 338that it is an ordinary method in a `_test.go`-adjacent non-test file, so
 339it ships in the binary; that is acceptable here since it is a one-line
 340wrapper with no side effect beyond calling the real path, and keeping it
 341out of the production file would mean either duplicating `migrateStep`
 342in a test-only file or using an unexported test hook pattern the package
 343does not otherwise have. If review prefers it test-only, move it to
 344`internal/store/storetest_export_test.go` (package `store`) instead —
 345functionally identical either way.
 346
 347- [ ] **Step 4: Run the test**
 348
 349Run: `go test ./internal/store -run TestFKOffMigrationChecksBeforeCommit -count=1`
 350Expected: PASS (the orphan row now fails the check before commit, and
 351`user_version` is left unchanged because `tx.Rollback()` fires).
 352
 353- [ ] **Step 5: Run the full package**
 354
 355Run: `go test ./internal/store -count=1`
 356Expected: PASS — this is a reordering, not a behaviour change, for every
 357migration that does not already violate its own foreign keys.
 358
 359- [ ] **Step 6: Commit**
 360
 361```bash
 362git add internal/store/store.go internal/store/store_test.go
 363git commit -m "store: run foreign_key_check inside the migration transaction, before commit" -m "Ref #261"
 364```
 365
 366### Task 1.2: `pinToggle` and `watchToggle` dispatch through their commands
 367
 368**Files:**
 369- Modify: `internal/httpd/accounts.go:241-250` (`pinToggle`)
 370- Modify: `internal/httpd/notifyweb.go:61-72` (`watchToggle`)
 371- Test: `internal/httpd/accounts_test.go` or `internal/httpd/account_test.go`
 372  (check which file already has repo-toggle tests; add beside them)
 373
 374**Interfaces:**
 375- Consumes: `s.runControl` (`internal/httpd/control.go:25`, already used
 376  by `bookmarkToggle`, the correct existing model for this fix).
 377- Produces: no new exported names; `watchToggle`'s behaviour becomes a
 378  three-way cycle (`""` → `watching` → `muted` → `""`), which Task 5.x
 379  in MR 5 (`web-ux-small-fixes`) builds on to expose "Muted" as a
 380  reachable state rather than adding a new endpoint.
 381
 382- [ ] **Step 1: Write the failing tests**
 383
 384Add to `internal/httpd/accounts_test.go` (create the file if repo pin/watch
 385tests do not already live somewhere; check with
 386`grep -rln "pinToggle\|watchToggle" internal/httpd/*_test.go` first and
 387add beside whatever that finds):
 388
 389```go
 390package httpd
 391
 392import (
 393	"net/http/httptest"
 394	"testing"
 395
 396	"gitbay.org/gitbay/internal/config"
 397	"gitbay.org/gitbay/internal/store"
 398)
 399
 400// Pinning writes through the repo pin command, not the store directly,
 401// so it carries the same audit trail and write budget as every other
 402// mutating command (#261).
 403func TestPinToggleDispatchesRepoPin(t *testing.T) {
 404	st, err := store.Open(":memory:")
 405	if err != nil {
 406		t.Fatal(err)
 407	}
 408	defer st.Close()
 409	if err := st.MigrateUp(); err != nil {
 410		t.Fatal(err)
 411	}
 412	uid, err := st.CreateUser("alice", false)
 413	if err != nil {
 414		t.Fatal(err)
 415	}
 416	u := store.User{ID: uid, Username: "alice"}
 417	if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
 418		t.Fatal(err)
 419	}
 420
 421	s := New(config.Default(), st)
 422	req := httptest.NewRequest("POST", "/alice/app/pin", nil)
 423	req.SetPathValue("owner", "alice")
 424	req.SetPathValue("repo", "app")
 425	rr := httptest.NewRecorder()
 426	s.pinToggle(rr, req, u)
 427
 428	repo, err := st.RepoByPath("alice/app")
 429	if err != nil {
 430		t.Fatal(err)
 431	}
 432	if !st.IsPinned(uid, repo.ID) {
 433		t.Fatal("pin did not take effect")
 434	}
 435
 436	rr2 := httptest.NewRecorder()
 437	s.pinToggle(rr2, req, u)
 438	if st.IsPinned(uid, repo.ID) {
 439		t.Fatal("second toggle should have unpinned")
 440	}
 441}
 442
 443// The watch button cycles default, watching, muted — the three states
 444// repo watch/repo mute/repo unwatch already support — rather than the
 445// two the store-writing version offered (#261, #271).
 446func TestWatchToggleCyclesThroughMuted(t *testing.T) {
 447	st, err := store.Open(":memory:")
 448	if err != nil {
 449		t.Fatal(err)
 450	}
 451	defer st.Close()
 452	if err := st.MigrateUp(); err != nil {
 453		t.Fatal(err)
 454	}
 455	uid, err := st.CreateUser("alice", false)
 456	if err != nil {
 457		t.Fatal(err)
 458	}
 459	u := store.User{ID: uid, Username: "alice"}
 460	if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
 461		t.Fatal(err)
 462	}
 463	repo, err := st.RepoByPath("alice/app")
 464	if err != nil {
 465		t.Fatal(err)
 466	}
 467
 468	s := New(config.Default(), st)
 469	req := httptest.NewRequest("POST", "/alice/app/watch", nil)
 470	req.SetPathValue("owner", "alice")
 471	req.SetPathValue("repo", "app")
 472
 473	click := func() string {
 474		rr := httptest.NewRecorder()
 475		s.watchToggle(rr, req, u)
 476		return st.RepoWatchState(repo.ID, uid)
 477	}
 478	if got := click(); got != "watching" {
 479		t.Fatalf("first click: got %q, want watching", got)
 480	}
 481	if got := click(); got != "muted" {
 482		t.Fatalf("second click: got %q, want muted", got)
 483	}
 484	if got := click(); got != "" {
 485		t.Fatalf("third click: got %q, want default (unwatched)", got)
 486	}
 487}
 488```
 489
 490(Check `CreateRepo`'s exact signature with
 491`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` before
 492using it — adjust argument order/names to match if it differs from the
 493guess above.)
 494
 495- [ ] **Step 2: Run and see them fail**
 496
 497Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1`
 498Expected: FAIL — pin toggles once but not twice cleanly is unlikely to
 499be the failure; more likely the watch test fails because today's
 500`watchToggle` only ever sets `"watching"` or clears it, never `"muted"`.
 501
 502- [ ] **Step 3: Fix `pinToggle`**
 503
 504In `internal/httpd/accounts.go`, replace:
 505
 506```go
 507// pinToggle pins or unpins the repo for the logged-in viewer.
 508func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
 509	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
 510	if !ok {
 511		return
 512	}
 513	if s.st.IsPinned(u.ID, repo.ID) {
 514		s.st.UnpinRepo(u.ID, repo.ID)
 515	} else {
 516		s.st.PinRepo(u.ID, repo.ID)
 517	}
 518	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
 519}
 520```
 521
 522with:
 523
 524```go
 525// pinToggle pins or unpins the repo for the logged-in viewer, through
 526// repo pin/repo unpin — the same commands the CLI runs — rather than
 527// writing the store directly (#261).
 528func (s *Server) pinToggle(w http.ResponseWriter, r *http.Request, u store.User) {
 529	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
 530	if !ok {
 531		return
 532	}
 533	verb := "pin"
 534	if s.st.IsPinned(u.ID, repo.ID) {
 535		verb = "unpin"
 536	}
 537	if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
 538		s.setFlash(w, msg)
 539	}
 540	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
 541}
 542```
 543
 544- [ ] **Step 4: Fix `watchToggle`**
 545
 546In `internal/httpd/notifyweb.go`, replace:
 547
 548```go
 549// watchToggle turns watching a repository on and off from its header,
 550// the way the pin button does.
 551func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
 552	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
 553	if !ok {
 554		return
 555	}
 556	if s.st.RepoWatchState(repo.ID, u.ID) == "watching" {
 557		s.st.ClearRepoWatch(repo.ID, u.ID)
 558	} else {
 559		s.st.SetRepoWatch(repo.ID, u.ID, "watching")
 560	}
 561	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
 562}
 563```
 564
 565with:
 566
 567```go
 568// watchToggle cycles the viewer's watch state on a repository: default,
 569// watching, muted, back to default — through repo watch/repo mute/repo
 570// unwatch, the same commands the CLI runs (#261, #271).
 571func (s *Server) watchToggle(w http.ResponseWriter, r *http.Request, u store.User) {
 572	repo, ok := s.repoForUser(w, r, u, policy.CanRead)
 573	if !ok {
 574		return
 575	}
 576	next := map[string]string{"": "watch", "watching": "mute", "muted": "unwatch"}
 577	verb := next[s.st.RepoWatchState(repo.ID, u.ID)]
 578	if _, msg, ok := s.runControl(u, []string{"repo", verb, repo.Path()}); !ok {
 579		s.setFlash(w, msg)
 580	}
 581	http.Redirect(w, r, "/"+repo.Path(), http.StatusSeeOther)
 582}
 583```
 584
 585- [ ] **Step 5: Run**
 586
 587Run: `go test ./internal/httpd -run 'TestPinToggleDispatchesRepoPin|TestWatchToggleCyclesThroughMuted' -count=1 && go test ./internal/httpd -count=1`
 588Expected: PASS. If an existing test asserted the old two-state watch
 589behaviour, update its expectation to the three-state cycle rather than
 590reverting the fix.
 591
 592- [ ] **Step 6: Commit**
 593
 594```bash
 595git add internal/httpd/accounts.go internal/httpd/notifyweb.go internal/httpd/accounts_test.go
 596git commit -m "web: pin and watch toggles dispatch through repo pin/watch/mute/unwatch" -m "Ref #261"
 597```
 598
 599### Task 1.3: Cache-Control: no-store on the login-link consuming request
 600
 601**Files:**
 602- Modify: `internal/httpd/accounts.go:123-157` (`login`)
 603- Test: `internal/httpd/logincookie_test.go` (add beside its existing
 604  login tests)
 605
 606- [ ] **Step 1: Write the failing test**
 607
 608Add to `internal/httpd/logincookie_test.go`:
 609
 610```go
 611// The login link's token rides in the query string — the one
 612// documented exception to "never in a URL" — so the response that
 613// consumes it must never be cached by an intermediary that might log
 614// or replay the URL (#261).
 615func TestLoginNoStoreHeader(t *testing.T) {
 616	st, err := store.Open(":memory:")
 617	if err != nil {
 618		t.Fatal(err)
 619	}
 620	defer st.Close()
 621	if err := st.MigrateUp(); err != nil {
 622		t.Fatal(err)
 623	}
 624	s := New(config.Default(), st)
 625	rr := httptest.NewRecorder()
 626	req := httptest.NewRequest("GET", "/login?token=bogus", nil)
 627	s.login(rr, req)
 628	if got := rr.Header().Get("Cache-Control"); got != "no-store" {
 629		t.Errorf("Cache-Control = %q, want no-store", got)
 630	}
 631}
 632```
 633
 634Check the file's existing imports (`config`, `store`, `httptest`, `testing`)
 635before adding — they are almost certainly already present given the
 636file already tests `/login`.
 637
 638- [ ] **Step 2: Run and see it fail**
 639
 640Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1`
 641Expected: FAIL (`Cache-Control` header absent).
 642
 643- [ ] **Step 3: Set the header**
 644
 645In `internal/httpd/accounts.go`, at the top of `login`:
 646
 647```go
 648func (s *Server) login(w http.ResponseWriter, r *http.Request) {
 649	// token, when present, is a single-use secret in the query string —
 650	// the documented exception to "never in a URL" (Threat-Model). No
 651	// cache may keep a copy of this response.
 652	w.Header().Set("Cache-Control", "no-store")
 653	token := r.URL.Query().Get("token")
 654```
 655
 656- [ ] **Step 4: Run**
 657
 658Run: `go test ./internal/httpd -run TestLoginNoStoreHeader -count=1 && go test ./internal/httpd -count=1`
 659Expected: PASS.
 660
 661- [ ] **Step 5: Commit**
 662
 663```bash
 664git add internal/httpd/accounts.go internal/httpd/logincookie_test.go
 665git commit -m "web: Cache-Control: no-store on the login-link request" -m "Ref #261"
 666```
 667
 668### Task 1.4: doc drift — API.org, Parity.org, Threat-Model.org
 669
 670**Files:**
 671- Modify: `.gitbay/wiki/API.org` (the token-refusal line, near "Git
 672  transport commands and the token commands are refused by name.")
 673- Modify: `.gitbay/wiki/Parity.org` (the "Batched review is not built."
 674  line; the watch/pin dispatch paragraph at lines 249-251)
 675- Modify: `.gitbay/wiki/Threat-Model.org` (the "never as command
 676  arguments... or query strings" line)
 677
 678No test — these are prose fixes; CI has no wiki-content check beyond
 679what already exists (link and page-name tests in
 680`internal/httpd/wiki_test.go`, untouched by this task).
 681
 682- [ ] **Step 1: Fix API.org's incorrect claim about token commands**
 683
 684The 401 message (`internal/httpd/api.go:131`, unchanged by this task)
 685already reads `missing bearer token; mint one over SSH: token create
 686--name <n>` — it does not say token commands are refused on the API,
 687because they are not: `token create`/`token list`/`token revoke` all
 688dispatch normally through `POST /api/v1/cmd` like any other command
 689(minting a token from a token is exactly what a full-scope token can do,
 690per `#234`/the "one registry" rule). Replace the false claim:
 691
 692```
 693Commands that emit raw text rather than an envelope (=help=, =mr diff=)
 694come wrapped as ={"output": "..."}=. Git transport commands are refused
 695by name; the token commands are not — a full-scope token can mint,
 696list and revoke tokens the same way it can run anything else.
 697```
 698
 699(Replaces the sentence "Git transport commands and the token commands
 700are refused by name.")
 701
 702- [ ] **Step 2: Fix Parity.org's stale "batched review" claim**
 703
 704Find (near "view. Batched review is not built."):
 705
 706```
 707=mr range-diff= compares two heads: the iOS client
 708shows it from a revision to the one before, as text; the web has no
 709view. Batched review is not built.
 710```
 711
 712`mr comment --pending`/`--discard` and `PublishPendingComments`
 713(`internal/control/mr.go:161-162,1044,1052`) are the batched-review
 714mechanism, and the web's diff-comment form already composes pending
 715comments before publishing (`internal/httpd/mractions.go`'s
 716`mrDiffCommentSubmit`, unchanged by this task — confirm with
 717`grep -n "diff-comment\|PendingComments" internal/httpd/*.go`). Replace:
 718
 719```
 720=mr range-diff= compares two heads: the iOS client shows it from a
 721revision to the one before, as text; the web renders the same view
 722(krz/gitbay#269). Batched review — draft diff comments held with
 723=mr comment --pending= and sent together with =--comment=/=--discard=
 724or a verdict — is built and the web uses it: composing a review comments
 725before publishing them is the same round trip as the CLI's =--pending=
 726flag.
 727```
 728
 729Leave the `mr range-diff` web-view claim as `krz/gitbay#269` for now;
 730MR 3 of this plan (`web-mr-range-diff`) lands the actual view and
 731updates this sentence again to drop the issue reference — do not
 732pre-empt that here, since this task's branch may merge before or after
 733MR 3 and the wiki text must describe what is actually deployed at each
 734point. (If MR 3 has already merged when this task is done, skip the
 735issue-reference wording and write the view as already existing instead;
 736check `ls internal/web/templates/mrrangediff.html` first.)
 737
 738- [ ] **Step 3: Fix the pin/watch dispatch paragraph**
 739
 740Find (lines 249-251):
 741
 742```
 743The web's watch and pin controls write the store directly instead of
 744dispatching =repo watch= and =repo pin=. That is why the web cannot
 745mute: its toggle knows watching and default only.
 746```
 747
 748Replace:
 749
 750```
 751The web's watch and pin controls dispatch =repo pin=/=repo unpin= and
 752=repo watch=/=repo mute=/=repo unwatch=, the same commands the CLI runs
 753(krz/gitbay#261). The single watch button cycles default, watching and
 754muted.
 755```
 756
 757And update the `mute` row in the capability table (around line 189)
 758from:
 759
 760```
 761| mute                        | yes | no  | yes |
 762```
 763
 764to:
 765
 766```
 767| mute                        | yes | yes | yes |
 768```
 769
 770- [ ] **Step 4: Fix Threat-Model.org's "never in a URL" claim**
 771
 772Find (in "What gitbay never does"):
 773
 774```
 775- *Put secrets in argv, URLs, or logs.* Import and mirror credentials,
 776  registration invites, and API tokens travel on stdin or in request
 777  bodies, never as command arguments (visible in =/proc=) or query
 778  strings. Tokens are stored only as SHA-256 hashes.
 779```
 780
 781Replace with (documenting the one deliberate exception and its
 782mitigations from Task 1.3):
 783
 784```
 785- *Put secrets in argv, URLs, or logs, with one documented exception.*
 786  Import and mirror credentials, registration invites, and API tokens
 787  travel on stdin or in request bodies, never as command arguments
 788  (visible in =/proc=) or query strings. The one exception is the
 789  emailed login link, =/login?token=...=: single-use, 15-minute expiry,
 790  and the response that consumes it carries =Cache-Control: no-store= so
 791  no intermediary keeps a copy. An operator running gitbay behind a
 792  reverse proxy should configure that proxy to strip the query string
 793  from its own access logs. Tokens are stored only as SHA-256 hashes.
 794```
 795
 796- [ ] **Step 5: Commit**
 797
 798```bash
 799git add .gitbay/wiki/API.org .gitbay/wiki/Parity.org .gitbay/wiki/Threat-Model.org
 800git commit -m "wiki: fix API token-refusal claim, batched-review status, watch/pin dispatch, login-link URL exception" -m "Ref #261"
 801```
 802
 803### Task 1.5: open MR 1
 804
 805- [ ] **Step 1: Push and open the MR**
 806
 807```bash
 808git push -u origin web-audit-fixes
 809gitbay mr create --source web-audit-fixes --target main --title "Architecture review small fixes: FK check, web toggles, doc drift"
 810```
 811
 812- [ ] **Step 2: Wait for CI, merge, delete the branch**
 813
 814```bash
 815gitbay mr merge <n> --strategy ff
 816git branch -d web-audit-fixes
 817git push origin --delete web-audit-fixes
 818```
 819
 820The last commit in this branch (Task 1.4's) should be amended in message
 821only if not already — reference `Closes #261` there instead of `Ref
 822#261` before pushing, since this MR closes the issue in full.
 823
 824---
 825
 826# MR 2: settings page quotes working commands (branch `web-settings-commands`)
 827
 828Closes #263.
 829
 830### Task 2.1: fix the two known-wrong quoted commands
 831
 832**Files:**
 833- Modify: `internal/web/templates/account.html:186,199-200`
 834
 835- [ ] **Step 1: Fix the `whoami` line**
 836
 837Find (`account.html:199-200`):
 838
 839```html
 840<p class="meta">All of it works from stock OpenSSH too:
 841<code>ssh git@{{.Host}} auth whoami</code>.</p>
 842```
 843
 844`auth` is a CLI-only grouping (`cmd/gitbay/main.go`'s `authCmd`); the
 845server command is `whoami` (`internal/control/identity.go:18`). Replace:
 846
 847```html
 848<p class="meta">All of it works from stock OpenSSH too:
 849<code>ssh git@{{.Host}} whoami</code>.</p>
 850```
 851
 852- [ ] **Step 2: Show both forms for the token line**
 853
 854Find (`account.html:194-197`):
 855
 856```html
 857<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
 858gitbay web sessions list               # browser sessions
 859gitbay admin ...                       # instance administration</pre>
 860```
 861
 862The CLI form (`gitbay auth token create ...`) and the literal stock-SSH
 863form (`ssh git@host token create ...`) differ because `token` is nested
 864under the CLI-only `auth` group but is a top-level server command
 865(`internal/control/token.go`). Replace the pre block and the sentence
 866after it:
 867
 868```html
 869<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
 870gitbay web sessions list               # browser sessions
 871gitbay admin ...                       # instance administration</pre>
 872<p class="meta">All of it works from stock OpenSSH too, with the CLI's
 873grouping words dropped: <code>ssh git@{{.Host}} whoami</code>,
 874<code>ssh git@{{.Host}} token create --name laptop</code>.</p>
 875```
 876
 877(This merges the "works from stock OpenSSH" sentence that Step 1 edited
 878with the new token example, so it appears once rather than twice —
 879remove the now-duplicate sentence Step 1 produced and keep this single
 880combined one instead. After this step, the section reads: the `<pre>`
 881block, then one `<p class="meta">` with both stock-SSH examples.)
 882
 883- [ ] **Step 3: Fix `gitbay account export`**
 884
 885Find (`account.html:186`, in the Export section):
 886
 887```html
 888<p class="meta">Your profile, repositories, issues and merge requests as one
 889JSON bundle, the same one <code>gitbay account export</code> writes. Keys are
 890never included; a replayed bundle's emails arrive unverified.</p>
 891```
 892
 893`account` is not a real top-level CLI command — the real path is `auth
 894export` (`cmd/gitbay/main.go:471`, nested under `authCmd`), as
 895`privacy.html:20` already correctly says. Replace:
 896
 897```html
 898<p class="meta">Your profile, repositories, issues and merge requests as one
 899JSON bundle, the same one <code>gitbay auth export</code> writes. Keys are
 900never included; a replayed bundle's emails arrive unverified.</p>
 901```
 902
 903- [ ] **Step 4: Commit (folded into Task 2.3, which adds the test these
 904  fixes make pass — do not commit yet; Task 2.2 and 2.3 come first so
 905  the fixes and their proof land together)**
 906
 907Skip committing here; continue to Task 2.2.
 908
 909### Task 2.2: auth summary and help — done in the CLI UX plan
 910
 911The auth summary ("whoami, SSH and PGP keys, email, API tokens") and
 912the registry-layout `gitbay auth --help` are Task 2.3 of
 913`docs/plans/2026-09-27-cli-ux.md` (MR `cli-ux-help`, Ref #267), which
 914adds `nounAliases` to `internal/control/help.go` so `help auth` renders
 915in one pass. Land `cli-ux-help` before this MR; nothing to do here.
 916Check after rebasing: `go run ./cmd/gitbay auth --help` lists email and
 917token commands.
 918
 919### Task 2.3: a test that runs every quoted command through the registry
 920
 921**Files:**
 922- Create: `cmd/gitbay/templatecmds_test.go`
 923
 924**Interfaces:**
 925- Consumes: `newRoot()` (`cmd/gitbay/main.go:30`, unexported — this test
 926  must live in package `main`), `control.Lookup`
 927  (`internal/control/control.go:102`), `web.Pages`/`web.TemplateSource`
 928  (`internal/web/web.go:271,277`).
 929
 930- [ ] **Step 1: Write the test**
 931
 932```go
 933package main
 934
 935import (
 936	"regexp"
 937	"strings"
 938	"testing"
 939
 940	"gitbay.org/gitbay/internal/control"
 941	"gitbay.org/gitbay/internal/web"
 942)
 943
 944// quotedRe finds the two shapes a command appears in on a page: inline
 945// in <code>, or one per line in a <pre class="quickstart"> quickstart
 946// block. Both need (?s) so a multi-line <pre> is captured as one match.
 947var quotedRe = regexp.MustCompile(`(?s)<code>(.*?)</code>|<pre class="quickstart"[^>]*>(.*?)</pre>`)
 948
 949// commandArgv reads the literal words at the front of a quoted command
 950// line — the part naming the command rather than its arguments — and
 951// stops at the first flag, template action, or literal ellipsis, since
 952// those mark the boundary between "what command" and "what argument".
 953func commandArgv(rest string) []string {
 954	var argv []string
 955	for _, tok := range strings.Fields(rest) {
 956		if strings.HasPrefix(tok, "-") || strings.Contains(tok, "{{") || strings.Contains(tok, "...") {
 957			break
 958		}
 959		argv = append(argv, tok)
 960	}
 961	return argv
 962}
 963
 964// TestTemplateQuotedCommandsResolve runs every command quoted in a web
 965// template through the same registry the server uses, so a renamed
 966// command fails CI instead of shipping a dead instruction (#263).
 967//
 968// A line starting "gitbay " is checked against the CLI's own command
 969// tree with cobra's Find, since the CLI's grouping words (like "auth")
 970// are not part of the server's argv. A line starting "ssh git@{{.Host}}
 971// " is checked directly against control.Lookup, since that is exactly
 972// the argv the server receives.
 973func TestTemplateQuotedCommandsResolve(t *testing.T) {
 974	root := newRoot()
 975	for _, name := range web.Pages() {
 976		src, err := web.TemplateSource(name)
 977		if err != nil {
 978			t.Fatalf("%s: %v", name, err)
 979		}
 980		for _, m := range quotedRe.FindAllStringSubmatch(src, -1) {
 981			block := m[1] + m[2]
 982			for _, line := range strings.Split(block, "\n") {
 983				if i := strings.Index(line, "#"); i >= 0 {
 984					line = line[:i]
 985				}
 986				line = strings.TrimSpace(line)
 987				switch {
 988				case strings.HasPrefix(line, "gitbay "):
 989					argv := commandArgv(strings.TrimPrefix(line, "gitbay "))
 990					if len(argv) == 0 {
 991						continue
 992					}
 993					found, _, err := root.Find(argv)
 994					if err != nil || found == root {
 995						t.Errorf("%s: %q: gitbay %s does not resolve (%v)", name, line, strings.Join(argv, " "), err)
 996					}
 997				case strings.HasPrefix(line, "ssh git@{{.Host}} "):
 998					argv := commandArgv(strings.TrimPrefix(line, "ssh git@{{.Host}} "))
 999					if len(argv) == 0 {
1000						continue
1001					}
1002					if _, _, ok := control.Lookup(argv); !ok {
1003						t.Errorf("%s: %q: %s is not in the control registry", name, line, strings.Join(argv, " "))
1004					}
1005				}
1006			}
1007		}
1008	}
1009}
1010```
1011
1012- [ ] **Step 2: Run and see it fail on the two known bugs**
1013
1014Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1015Expected: FAIL on `account.html`'s `ssh git@{{.Host}} auth whoami` (not
1016in the registry — `auth` is not a server path) and `gitbay account
1017export` (not a real CLI path — the top-level command is `auth`, not
1018`account`), unless Task 2.1's edits are already applied (do Task 2.1 and
1019Task 2.2 first if not already committed, then this test should already
1020pass on those — if it still fails, the fixes in Task 2.1 or 2.2 are
1021incomplete).
1022
1023- [ ] **Step 3: Confirm it passes with Tasks 2.1 and 2.2 applied**
1024
1025Run: `go test ./cmd/gitbay -run TestTemplateQuotedCommandsResolve -count=1`
1026Expected: PASS. If it fails on a *different* template than
1027`account.html`, that is a genuine additional bug this test caught —
1028fix the template's text the same way (correct the command to what
1029`control.Lookup`/cobra's tree actually accepts), do not weaken the test.
1030As of this plan being written, every other quoted command in the
1031templates (`admin.html`, `adminusers.html`, `issues.html`, `landing.html`,
1032`login.html`, `mrs.html`, `mrnew.html`, `privacy.html`, `registered.html`,
1033plus the ones this task edits) was checked by hand against
1034`cmd/gitbay/main.go` and `internal/control/*.go` and resolves correctly —
1035see the research notes in this plan's Order section — but the test is
1036the source of truth, not that hand check.
1037
1038- [ ] **Step 4: Run the full package**
1039
1040Run: `go build ./... && go vet ./... && go test ./cmd/gitbay -count=1`
1041Expected: PASS.
1042
1043- [ ] **Step 5: Commit everything for this MR**
1044
1045```bash
1046git add internal/web/templates/account.html cmd/gitbay/main.go cmd/gitbay/templatecmds_test.go
1047git commit -m "web, cli: fix two dead quoted commands; test every quoted command against the registry" -m "Closes #263"
1048```
1049
1050### Task 2.4: open MR 2
1051
1052```bash
1053git push -u origin web-settings-commands
1054gitbay mr create --source web-settings-commands --target main --title "Settings page: working stock-OpenSSH commands, registry-checked"
1055```
1056
1057Wait for CI, `gitbay mr merge <n> --strategy ff`, delete the branch both
1058places.
1059
1060---
1061
1062# MR 3: MR range-diff page (branch `web-mr-range-diff`)
1063
1064Closes #269.
1065
1066### Task 3.1: `/{owner}/{repo}/mrs/{n}/range-diff`
1067
1068**Files:**
1069- Create: `internal/httpd/mrrangediff.go`
1070- Create: `internal/web/templates/mrrangediff.html`
1071- Modify: `internal/httpd/routes.go` (add the route beside
1072  `/{owner}/{repo}/mrs/{n}` at line 102, in the always-registered block)
1073- Modify: `internal/web/web_test.go` (`TestMainWidthClass`'s `wide` map)
1074- Test: `internal/httpd/mrrangediff_test.go`
1075
1076**Interfaces:**
1077- Consumes: `mrArgs` (`internal/httpd/mractions.go:37`), `s.repoFor`,
1078  `s.runControlCode`, `s.webViewer`.
1079- Produces: `func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request)`
1080
1081- [ ] **Step 1: Write the failing test**
1082
1083```go
1084package httpd
1085
1086import (
1087	"net/http/httptest"
1088	"strings"
1089	"testing"
1090
1091	"gitbay.org/gitbay/internal/config"
1092	"gitbay.org/gitbay/internal/store"
1093)
1094
1095// The range-diff page dispatches mr range-diff and renders its text
1096// output, the same comparison the CLI and iOS already show (#269).
1097func TestMRRangeDiffPageRendersCommandOutput(t *testing.T) {
1098	st, err := store.Open(":memory:")
1099	if err != nil {
1100		t.Fatal(err)
1101	}
1102	defer st.Close()
1103	if err := st.MigrateUp(); err != nil {
1104		t.Fatal(err)
1105	}
1106	uid, err := st.CreateUser("alice", false)
1107	if err != nil {
1108		t.Fatal(err)
1109	}
1110	u := store.User{ID: uid, Username: "alice"}
1111	if _, err := st.CreateRepo("user", uid, "app", "public"); err != nil {
1112		t.Fatal(err)
1113	}
1114
1115	s := New(config.Default(), st)
1116	req := httptest.NewRequest("GET", "/alice/app/mrs/1/range-diff", nil)
1117	req.SetPathValue("owner", "alice")
1118	req.SetPathValue("repo", "app")
1119	req.SetPathValue("n", "1")
1120	rr := httptest.NewRecorder()
1121	s.mrRangeDiff(rr, req)
1122
1123	// No merge request 1 exists yet, so this must 404 rather than error.
1124	if rr.Code != 404 {
1125		t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
1126	}
1127	_ = strings.TrimSpace // placeholder import use removed once a real MR fixture is added below
1128}
1129```
1130
1131(`CreateRepo`'s signature: confirm with
1132`grep -n "func (s \*Store) CreateRepo" internal/store/*.go` and adjust
1133the call above to match — the guess here follows the shape used
1134elsewhere in this plan's other tests.)
1135
1136- [ ] **Step 2: Run and see it fail to compile**
1137
1138Run: `go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1`
1139Expected: FAIL to compile (`s.mrRangeDiff` undefined).
1140
1141- [ ] **Step 3: Write the handler**
1142
1143Create `internal/httpd/mrrangediff.go`:
1144
1145```go
1146package httpd
1147
1148import (
1149	"net/http"
1150	"strconv"
1151
1152	"gitbay.org/gitbay/internal/protocol"
1153	"gitbay.org/gitbay/internal/store"
1154)
1155
1156// mrRangeDiff renders what changed between two revisions of a merge
1157// request — the same comparison `mr range-diff` prints on the CLI and
1158// the iOS app already show — so a reviewer whose approval a force-push
1159// staled can see what moved without leaving the browser (#269).
1160func (s *Server) mrRangeDiff(w http.ResponseWriter, r *http.Request) {
1161	p, ok := s.repoFor(w, r, "")
1162	if !ok {
1163		return
1164	}
1165	p.Tab = "merge requests"
1166	n, err := strconv.ParseInt(r.PathValue("n"), 10, 64)
1167	if err != nil {
1168		s.notFound(w, r)
1169		return
1170	}
1171	m, err := s.st.MRByNumber(p.Repo.ID, n)
1172	if err != nil {
1173		s.notFound(w, r)
1174		return
1175	}
1176
1177	viewer := s.webViewer(r)
1178	argv := mrArgs(r, "range-diff")
1179	if from := r.URL.Query().Get("from"); from != "" {
1180		argv = append(argv, "--from", from)
1181	}
1182	if to := r.URL.Query().Get("to"); to != "" {
1183		argv = append(argv, "--to", to)
1184	}
1185	out, msg, code := s.runControlCode(viewer, argv)
1186	if code == protocol.ExitNotFound {
1187		s.notFound(w, r)
1188		return
1189	}
1190	errMsg := ""
1191	if code != protocol.ExitOK {
1192		errMsg = msg
1193	}
1194	s.render(w, "mrrangediff.html", struct {
1195		repoPage
1196		MR    store.MR
1197		Diff  string
1198		Error string
1199	}{p, m, out, errMsg})
1200}
1201```
1202
1203- [ ] **Step 4: Write the template**
1204
1205Create `internal/web/templates/mrrangediff.html`:
1206
1207```html
1208{{define "width"}}wide{{end}}
1209{{define "title"}}range-diff · !{{.MR.Number}} · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
1210{{define "content"}}
1211<h1>Range-diff <span class="issuenumber">!{{.MR.Number}}</span></h1>
1212<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/{{.MR.Number}}">back to !{{.MR.Number}} {{.MR.Title}}</a></p>
1213{{if .Error}}<p class="error" role="alert">{{.Error}}</p>
1214{{else if .Diff}}<pre class="code buildlog" tabindex="0">{{.Diff}}</pre>
1215{{else}}<p class="empty-note">Nothing to compare: this merge request has one revision.</p>{{end}}
1216{{end}}
1217```
1218
1219- [ ] **Step 5: Register the route**
1220
1221In `internal/httpd/routes.go`, right after the existing
1222`/{owner}/{repo}/mrs/{n}` route (line 102, in the block registered
1223regardless of `web.mode`, so range-diff reads the same way the MR page
1224itself does — anonymously on a public repository):
1225
1226```go
1227		Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}", Handler: s.mr},
1228		Route{Method: "GET", Pattern: "/{owner}/{repo}/mrs/{n}/range-diff", Handler: s.mrRangeDiff},
1229```
1230
1231- [ ] **Step 6: Add the width-map row**
1232
1233In `internal/web/web_test.go`, `TestMainWidthClass`, add
1234`"mrrangediff.html": true` to the `wide` map (alongside `"mrs.html"` and
1235`"build.html"`, which it resembles).
1236
1237- [ ] **Step 7: Run**
1238
1239Run: `go build ./... && go test ./internal/httpd -run TestMRRangeDiffPageRendersCommandOutput -count=1 && go test ./internal/web -run TestMainWidthClass -count=1`
1240Expected: PASS.
1241
1242- [ ] **Step 8: Commit**
1243
1244```bash
1245git add internal/httpd/mrrangediff.go internal/httpd/mrrangediff_test.go internal/httpd/routes.go internal/web/templates/mrrangediff.html internal/web/web_test.go
1246git commit -m "web: range-diff page for a merge request's revisions" -m "Ref #269"
1247```
1248
1249### Task 3.2: revisions list with a "compare to previous" link per row
1250
1251**Files:**
1252- Modify: `internal/web/templates/mr.html:136-138`
1253- Test: `internal/httpd/mrpage_test.go`
1254
1255- [ ] **Step 1: Write the failing test**
1256
1257Add to `internal/httpd/mrpage_test.go`:
1258
1259```go
1260// Each revision after the first carries a link comparing it to the one
1261// before, so a reviewer does not have to type mr range-diff by hand
1262// (#269).
1263func TestMRPageListsRevisionsWithCompareLinks(t *testing.T) {
1264	var sb strings.Builder
1265	revs := []store.MRHead{
1266		{SHA: "aaaa1111", CreatedAt: "2026-09-23T10:00:00Z"},
1267		{SHA: "bbbb2222", CreatedAt: "2026-09-24T10:00:00Z"},
1268	}
1269	if err := web.Render(&sb, "mr.html", mrPageData{
1270		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation", Revisions: revs,
1271	}); err != nil {
1272		t.Fatalf("render: %v", err)
1273	}
1274	out := sb.String()
1275	for _, want := range []string{"aaaa1111", "bbbb2222", "compare to previous", "from=aaaa1111", "to=bbbb2222"} {
1276		if !strings.Contains(out, want) {
1277			t.Errorf("missing %q in:\n%s", want, out)
1278		}
1279	}
1280	if strings.Contains(out, "gitbay mr range-diff") {
1281		t.Error("still quotes the CLI command instead of linking the new page")
1282	}
1283}
1284```
1285
1286- [ ] **Step 2: Run and see it fail**
1287
1288Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1`
1289Expected: FAIL (no "compare to previous" text yet).
1290
1291- [ ] **Step 3: Replace the one-liner with a revisions list**
1292
1293Find (`mr.html:136-138`):
1294
1295```html
1296    {{if gt (len .Revisions) 1}}<p class="row none">{{len .Revisions}} revisions pushed. What changed between the last two:
1297      <code>gitbay mr range-diff {{.Repo.OwnerName}}/{{.Repo.Name}} {{.MR.Number}}</code></p>{{end}}
1298  </div>
1299```
1300
1301Replace with:
1302
1303```html
1304  </div>
1305  {{if .Revisions}}<div class="grp">
1306    <h2>Revisions</h2>
1307    {{range $i, $rv := .Revisions}}<p class="row none">{{add $i 1}}. <code>{{short $rv.SHA}}</code> {{when $rv.CreatedAt}}{{if $i}} · <a href="{{$base}}/range-diff?from={{(index $.Revisions (sub $i 1)).SHA}}&amp;to={{$rv.SHA}}">compare to previous</a>{{end}}</p>
1308    {{end}}
1309  </div>{{end}}
1310```
1311
1312(The closing `</div>` that used to end the Reviews `grp` stays where it
1313is — this adds a new sibling `grp` right after it, using `add`/`sub`,
1314already registered template funcs in `internal/web/web.go`.)
1315
1316- [ ] **Step 4: Run**
1317
1318Run: `go test ./internal/httpd -run TestMRPageListsRevisionsWithCompareLinks -count=1 && go test ./internal/httpd -count=1`
1319Expected: PASS.
1320
1321- [ ] **Step 5: Commit**
1322
1323```bash
1324git add internal/web/templates/mr.html internal/httpd/mrpage_test.go
1325git commit -m "web: list each MR revision with a compare-to-previous link" -m "Ref #269"
1326```
1327
1328### Task 3.3: update Parity
1329
1330**Files:**
1331- Modify: `.gitbay/wiki/Parity.org`
1332
1333- [ ] **Step 1: Fix the range-diff line**
1334
1335Find:
1336
1337```
1338=mr range-diff= compares two heads: the iOS client
1339shows it from a revision to the one before, as text; the web has no
1340view. Batched review is not built.
1341```
1342
1343(If MR 1's Task 1.4 already changed this sentence to reference
1344`krz/gitbay#269`, edit that version instead — the end state either way
1345is:)
1346
1347```
1348=mr range-diff= compares two heads: the iOS client shows it from a
1349revision to the one before, as text; the web renders the same view, with
1350a "compare to previous" link on each revision after the first. Batched
1351review — draft diff comments held with =mr comment --pending= and sent
1352together with =--comment=/=--discard= or a verdict — is built and the
1353web uses it.
1354```
1355
1356- [ ] **Step 2: Commit**
1357
1358```bash
1359git add .gitbay/wiki/Parity.org
1360git commit -m "wiki: Parity reflects the web range-diff view" -m "Closes #269"
1361```
1362
1363### Task 3.4: open MR 3
1364
1365```bash
1366git push -u origin web-mr-range-diff
1367gitbay mr create --source web-mr-range-diff --target main --title "Web: MR range-diff page"
1368```
1369
1370Wait for CI, merge (`--strategy ff`), delete the branch both places.
1371
1372---
1373
1374# MR 4: empty states and contribution hints sweep (branch `web-empty-states`)
1375
1376Closes #270.
1377
1378This MR is one register applied across templates. The table below is
1379the full set of strings this task changes — every empty-state or
1380contribution-hint string identified in the issue and confirmed against
1381the current template source. Implement exactly this table; do not
1382invent additional wording beyond it.
1383
1384| File:line | Current | New |
1385|---|---|---|
1386| `mrs.html:29` (no query) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code>` | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests` (the create instruction moves to the new "New merge request"/fork/sign-in line — Task 4.2 — and is never a CLI command on the web) |
1387| `mrs.html:28` (search, no match) | `no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching "{{.Query}}"` | unchanged (already follows the register: lower-case, no CLI command, states the fact) |
1388| `mr.html:136` (reviews, none) | `none yet` | unchanged — "none yet" is correct register for a section that can still gain entries (open MR); Task 4.1's rule is "no 'yet' on a *finished* item", and reviews on an open MR are not finished |
1389| `mr.html:143` (reviewers, none) | `nobody yet` | `no reviewers` (drops "yet" for consistency with the rest of the sweep's noun-first register even though the MR could still be open — "nobody yet" reads as a placeholder guess about who *will* review, which is not information the page has; "no reviewers" states the fact plainly, matching `dashboard.html`'s "No open merge requests") |
1390| `mr.html:174` (labels, none, on a merged/closed MR) | `none yet` | `no labels` when `.MR.State` is `merged` or `closed` (a finished item gets no "yet"); keep `none yet` when open |
1391| `builds.html:53` | `no builds — push a commit with a <code>.gitbay/ci.yml</code>` | `no builds` when the viewer cannot push (`.CanWrite` false or absent); `no builds — push a commit with a .gitbay/ci.yml` (drop `<code>`, since a filename is not a command) stays for a viewer who can push. Never plain "no builds" for a writer, since that leaves them without the one instruction the page can give them |
1392| `dashboard.html:29` | `Nothing pinned yet. Press Pin on a repository.` | `nothing pinned — press Pin on a repository you visit` |
1393| `dashboard.html:42` (`Empty` value for MRs queue) | `No open merge requests` | unchanged (already matches the register: capital first word is this partial's own convention — see Step 1 below — lower-case the whole sweep *within* `<li class="empty">` items, leave `queue` partial's own `Empty` string as-is since it is Title Case by that partial's design, confirmed by reading the `queue` template define before changing it) |
1394| `notifications.html:21` (all read) | `nothing here yet` | `no unread notifications` when `.All` is false is already separate; the `.All` branch (nothing at all, read or unread, in the *whole* inbox) becomes `nothing to show` |
1395| `notifications.html:21` (unread, default) | `nothing unread — <a>show all</a>` | `no unread notifications — <a href="/notifications?all=1">show all</a>` (already close; #265, a different plan, covers the CLI side of this exact wording — keep the two in step: `no unread notifications` matches what plan 6's CLI task sets for `notifications list`) |
1396| `globalsearch.html:47` | shown only when `.Query` is empty | shown always, as a permanent caption under the search input (Task 4.3) |
1397
1398- [ ] **Step 1: Read the `queue` partial before touching `dashboard.html`**
1399
1400Run: `grep -n '{{define "queue"}}' -A 15 internal/web/templates/dashboard.html`
1401Confirm whether its `Empty` value is rendered as given (in which case
1402`"No open merge requests"` stays capitalised by the caller's choice) or
1403lower-cased by the partial itself. Write down which, then leave that
1404line's casing exactly as the partial expects — do not change
1405`dashboard.html:42-43`'s `"Empty"` values in this task; the table above
1406already reflects "unchanged" for it.
1407
1408### Task 4.1: apply the table
1409
1410**Files:**
1411- Modify: `internal/web/templates/mrs.html:28-29`
1412- Modify: `internal/web/templates/mr.html:143,174`
1413- Modify: `internal/web/templates/builds.html:53`
1414- Modify: `internal/web/templates/dashboard.html:29`
1415- Modify: `internal/web/templates/notifications.html:21`
1416- Test: `internal/httpd/mrpage_test.go`, `internal/httpd/buildpages_test.go`
1417  (or wherever a `builds.html` render test already lives — check with
1418  `grep -rln '"builds.html"' internal/httpd/*_test.go`), a new or
1419  existing dashboard render test, a new or existing notifications test.
1420
1421- [ ] **Step 1: Write the failing tests**
1422
1423Add to `internal/httpd/mrpage_test.go`:
1424
1425```go
1426// A finished merge request states an empty label list as a fact, not a
1427// promise something is still coming (#270).
1428func TestMRPageLabelsNoYetOnFinishedState(t *testing.T) {
1429	var sb strings.Builder
1430	if err := web.Render(&sb, "mr.html", mrPageData{
1431		repoPage: testRepoPage(), MR: testMR("merged"), View: "conversation",
1432	}); err != nil {
1433		t.Fatalf("render: %v", err)
1434	}
1435	if !strings.Contains(sb.String(), "no labels") {
1436		t.Error(`merged MR with no labels should read "no labels", not "none yet"`)
1437	}
1438}
1439
1440func TestMRPageReviewersEmptyStateDropsNobody(t *testing.T) {
1441	var sb strings.Builder
1442	if err := web.Render(&sb, "mr.html", mrPageData{
1443		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
1444	}); err != nil {
1445		t.Fatalf("render: %v", err)
1446	}
1447	if strings.Contains(sb.String(), "nobody yet") {
1448		t.Error(`reviewers empty state should read "no reviewers"`)
1449	}
1450	if !strings.Contains(sb.String(), "no reviewers") {
1451		t.Error(`missing "no reviewers"`)
1452	}
1453}
1454```
1455
1456Add to whichever file already renders `builds.html` (or create
1457`internal/httpd/buildslist_test.go` if none does; check first with the
1458grep in the Files list above):
1459
1460```go
1461// A writer sees the instruction to add CI; a reader without push access
1462// sees only the fact, since the instruction is not theirs to act on
1463// (#270).
1464func TestBuildsEmptyStateOmitsInstructionForReaders(t *testing.T) {
1465	var sb strings.Builder
1466	if err := web.Render(&sb, "builds.html", buildsPageData{
1467		repoPage: testRepoPage(), CanWrite: false,
1468	}); err != nil {
1469		t.Fatalf("render: %v", err)
1470	}
1471	out := sb.String()
1472	if !strings.Contains(out, "no builds") {
1473		t.Error(`missing "no builds"`)
1474	}
1475	if strings.Contains(out, "ci.yml") {
1476		t.Error("a reader without push access should not see the push instruction")
1477	}
1478}
1479```
1480
1481(`buildsPageData` may not exist as a named type the way `mrPageData`
1482does for `mr.html` — check
1483`grep -n '"builds.html"' internal/httpd/web.go` for the anonymous
1484struct `builds` (the list handler, not `build`, the single-build one)
1485renders with, and mirror its fields the way `mrPageData` mirrors
1486`mr.html`'s, the same pattern `mrpage_test.go` already uses.)
1487
1488- [ ] **Step 2: Run and see them fail**
1489
1490Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1`
1491Expected: FAIL.
1492
1493- [ ] **Step 3: `mr.html` reviewers and labels**
1494
1495Find (`mr.html:143`):
1496
1497```html
1498    {{else}}<p class="none">nobody yet</p>{{end}}
1499```
1500
1501(in the Reviewers `grp`). Replace:
1502
1503```html
1504    {{else}}<p class="none">no reviewers</p>{{end}}
1505```
1506
1507Find (`mr.html:174`, in the Labels `grp`):
1508
1509```html
1510    {{else}}<p class="none">none yet</p>{{end}}
1511```
1512
1513Replace:
1514
1515```html
1516    {{else}}<p class="none">{{if or (eq .MR.State "merged") (eq .MR.State "closed")}}no labels{{else}}none yet{{end}}</p>{{end}}
1517```
1518
1519- [ ] **Step 4: `builds.html`**
1520
1521Read the current line first: `grep -n "no builds" internal/web/templates/builds.html`.
1522Replace it (adjust the exact surrounding markup to match what that grep
1523shows; the text change is):
1524
1525```html
1526{{else}}<li class="empty">no builds{{if .CanWrite}} — push a commit with a <code>.gitbay/ci.yml</code>{{end}}</li>{{end}}
1527```
1528
1529Check whether `builds.html`'s page struct already carries `CanWrite`
1530(`grep -n "CanWrite" internal/httpd/builds.go internal/web/templates/builds.html`);
1531if it does not, add it the way `mr.html`'s does
1532(`s.canWriteRepo(r, p.Repo)` in the handler, a new `CanWrite bool` field
1533in the render struct).
1534
1535- [ ] **Step 5: `dashboard.html`**
1536
1537Find (`dashboard.html:29`):
1538
1539```html
1540  {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
1541```
1542
1543Replace:
1544
1545```html
1546  {{else}}<p class="none">nothing pinned — press Pin on a repository you visit</p>{{end}}
1547```
1548
1549- [ ] **Step 6: `notifications.html`**
1550
1551Find (`notifications.html:21`):
1552
1553```html
1554{{else}}<li class="empty">{{if .All}}nothing here yet{{else}}nothing unread — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1555```
1556
1557Replace:
1558
1559```html
1560{{else}}<li class="empty">{{if .All}}nothing to show{{else}}no unread notifications — <a href="/notifications?all=1">show all</a>{{end}}</li>{{end}}
1561```
1562
1563- [ ] **Step 7: `mrs.html`**
1564
1565Find (`mrs.html:28-29`):
1566
1567```html
1568{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1569{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests — open one with <code>gitbay mr create {{.Repo.OwnerName}}/{{.Repo.Name}} --source ... --target {{.Repo.DefaultBranch}}</code></li>{{end}}{{end}}
1570```
1571
1572Replace (the create instruction moves to Task 4.2's contribution-hint
1573line, so the empty state itself states only the fact):
1574
1575```html
1576{{else}}{{if .Query}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests matching “{{.Query}}”</li>
1577{{else}}<li class="empty">no {{if ne .State "all"}}{{.State}} {{end}}merge requests</li>{{end}}{{end}}
1578```
1579
1580- [ ] **Step 8: Run**
1581
1582Run: `go test ./internal/httpd -run 'TestMRPageLabelsNoYetOnFinishedState|TestMRPageReviewersEmptyStateDropsNobody|TestBuildsEmptyStateOmitsInstructionForReaders' -count=1 && go test ./internal/httpd -count=1`
1583Expected: PASS. Fix any pre-existing test that asserted the old strings
1584("nobody yet", "none yet" on a merged MR's labels, "Nothing pinned yet",
1585"nothing here yet", "nothing unread", the old `mrs.html` CLI-command
1586text) to expect the new ones — these are exactly the tests this sweep
1587is supposed to change.
1588
1589- [ ] **Step 9: Commit**
1590
1591```bash
1592git add internal/web/templates/mr.html internal/web/templates/builds.html internal/web/templates/dashboard.html internal/web/templates/notifications.html internal/web/templates/mrs.html internal/httpd/mrpage_test.go internal/httpd/*_test.go
1593git commit -m "web: one empty-state register — no CLI commands, no 'yet' on finished items" -m "Ref #270"
1594```
1595
1596### Task 4.2: MR list contribution hint by access level
1597
1598**Files:**
1599- Modify: `internal/httpd/web.go:2061-2126` (`mrs` handler, add `CanWrite`)
1600- Modify: `internal/web/templates/mrs.html:16`
1601- Test: `internal/httpd/mrslist_test.go` (create, or add beside an
1602  existing `mrs.html` render test if one exists — check first)
1603
1604- [ ] **Step 1: Write the failing test**
1605
1606```go
1607package httpd
1608
1609import (
1610	"net/http"
1611	"net/http/httptest"
1612	"strings"
1613	"testing"
1614	"time"
1615
1616	"gitbay.org/gitbay/internal/config"
1617	"gitbay.org/gitbay/internal/store"
1618)
1619
1620// A repository's MR list offers the right next step by access level: a
1621// writer gets "New merge request", a signed-in reader without push gets
1622// a fork link, and a signed-out visitor gets a sign-in prompt (#270).
1623func TestMRsListContributionHintByAccess(t *testing.T) {
1624	st, err := store.Open(":memory:")
1625	if err != nil {
1626		t.Fatal(err)
1627	}
1628	defer st.Close()
1629	if err := st.MigrateUp(); err != nil {
1630		t.Fatal(err)
1631	}
1632	owner, err := st.CreateUser("alice", false)
1633	if err != nil {
1634		t.Fatal(err)
1635	}
1636	reader, err := st.CreateUser("bob", false)
1637	if err != nil {
1638		t.Fatal(err)
1639	}
1640	if _, err := st.CreateRepo("user", owner, "app", "public"); err != nil {
1641		t.Fatal(err)
1642	}
1643
1644	cfg := config.Default()
1645	cfg.Web.Mode = "accounts"
1646	s := New(cfg, st)
1647
1648	// mrs reads the viewer through s.viewer(r), which resolves a
1649	// session cookie (internal/httpd/accounts.go:37-47) rather than
1650	// taking the viewer as a parameter the way a POST handler test
1651	// does. Give a real viewer a real session; leave the request
1652	// cookie-less for the anonymous case.
1653	sessionFor := func(uid int64) *http.Cookie {
1654		tok, hash, err := store.NewToken()
1655		if err != nil {
1656			t.Fatal(err)
1657		}
1658		if err := st.CreateWebSession(hash, uid, time.Hour); err != nil {
1659			t.Fatal(err)
1660		}
1661		return s.sessionCookieFor(tok)
1662	}
1663
1664	get := func(uid int64) string {
1665		req := httptest.NewRequest("GET", "/alice/app/mrs", nil)
1666		req.SetPathValue("owner", "alice")
1667		req.SetPathValue("repo", "app")
1668		if uid != 0 {
1669			req.AddCookie(sessionFor(uid))
1670		}
1671		rr := httptest.NewRecorder()
1672		s.mrs(rr, req)
1673		return rr.Body.String()
1674	}
1675	anonymous := get(0)
1676	if !strings.Contains(anonymous, "Sign in to propose a change") {
1677		t.Errorf("signed-out visitor: missing sign-in prompt:\n%s", anonymous)
1678	}
1679	if strings.Contains(anonymous, "New merge request") {
1680		t.Error("signed-out visitor should not see New merge request")
1681	}
1682
1683	readerOut := get(reader)
1684	if !strings.Contains(readerOut, "Fork this repository to propose a change") {
1685		t.Errorf("reader without push: missing fork hint:\n%s", readerOut)
1686	}
1687
1688	ownerOut := get(owner)
1689	if !strings.Contains(ownerOut, "New merge request") {
1690		t.Errorf("owner: missing New merge request link:\n%s", ownerOut)
1691	}
1692}
1693```
1694
1695- [ ] **Step 2: Run and see it fail**
1696
1697Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1`
1698Expected: FAIL (no such text yet — `mrs.html:16` still only checks
1699`.Viewer`).
1700
1701- [ ] **Step 3: Add `CanWrite` to the `mrs` page struct**
1702
1703In `internal/httpd/web.go`, in `mrs` (around line 2061), after
1704`p.Tab = "merge requests"`:
1705
1706```go
1707	canWrite := s.canWriteRepo(r, p.Repo)
1708```
1709
1710and add `CanWrite bool` to the anonymous struct passed to `s.render`,
1711with `canWrite` in the corresponding position of the literal.
1712
1713- [ ] **Step 4: Update `mrs.html`**
1714
1715Find (`mrs.html:16`):
1716
1717```html
1718{{if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>{{end}}
1719```
1720
1721Replace:
1722
1723```html
1724{{if .CanWrite}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/mrs/new">New merge request</a></p>
1725{{else if .Viewer}}<p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/fork">Fork this repository to propose a change</a></p>
1726{{else}}<p class="meta"><a href="/login">Sign in to propose a change</a></p>{{end}}
1727```
1728
1729- [ ] **Step 5: Run**
1730
1731Run: `go test ./internal/httpd -run TestMRsListContributionHintByAccess -count=1 && go test ./internal/httpd -count=1`
1732Expected: PASS.
1733
1734- [ ] **Step 6: Commit**
1735
1736```bash
1737git add internal/httpd/web.go internal/web/templates/mrs.html internal/httpd/mrslist_test.go
1738git commit -m "web: MR list offers a fork link or a sign-in prompt to visitors who cannot open one directly" -m "Ref #270"
1739```
1740
1741### Task 4.3: search scope caption always visible; tab zero-count rule
1742
1743**Files:**
1744- Modify: `internal/web/templates/globalsearch.html:45-48`
1745- Modify: `internal/web/templates/layout.html:71-72` (comment only — see
1746  Step 2)
1747- Test: `internal/httpd/searchweb_test.go` (or wherever a
1748  `globalsearch.html` render test already lives)
1749
1750- [ ] **Step 1: Write the failing test**
1751
1752```go
1753// The scope sentence is a permanent caption, not a first-visit-only
1754// hint: a visitor who has already searched still needs to know what a
1755// search here does and does not cover (#270).
1756func TestGlobalSearchScopeCaptionAlwaysShown(t *testing.T) {
1757	var sb strings.Builder
1758	if err := web.Render(&sb, "globalsearch.html", struct {
1759		basePage
1760		Query, Kind string
1761		Results     []searchHit
1762		QueryErr    string
1763	}{Query: "gitbay"}); err != nil {
1764		t.Fatalf("render: %v", err)
1765	}
1766	if !strings.Contains(sb.String(), "File contents are searched per repository") {
1767		t.Error("scope caption missing once a query is present")
1768	}
1769}
1770```
1771
1772(Check the real render struct's field names and the `searchHit` type
1773name with `grep -n '"globalsearch.html"' internal/httpd/*.go` and match
1774them exactly — the struct above is a best guess at the shape from
1775reading the template, not a verified signature.)
1776
1777- [ ] **Step 2: Run and see it fail**
1778
1779Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1`
1780Expected: FAIL (the caption is currently inside the `{{else}}` branch
1781that only renders when `.Query` is empty).
1782
1783- [ ] **Step 3: Move the caption out of the conditional**
1784
1785Find (`globalsearch.html:45-48`):
1786
1787```html
1788{{/* The count line above already says nothing matched, so this one
1789     carries the way out instead of repeating it. */}}
1790{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1791{{else}}
1792<p class="empty-note">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1793{{end}}
1794```
1795
1796Replace with (the scope sentence moves out to render unconditionally,
1797right after the search form, and the "no results" hint keeps its own
1798conditional unchanged):
1799
1800```html
1801{{/* The count line above already says nothing matched, so this one
1802     carries the way out instead of repeating it. */}}
1803{{else}}<p class="empty-note">Try fewer words{{if .Kind}}, <a href="?q={{.Query}}">search everything</a>,{{end}} or <a href="/explore">browse the repositories</a>.</p>{{end}}
1804{{end}}
1805<p class="meta">Repository names, descriptions and topics, and the title and body of every issue and merge request you can read. File contents are searched per repository, from a repository's Code tab.</p>
1806```
1807
1808(Dropping the outer `{{if .QueryErr}}...{{else if .Query}}...{{else}}...{{end}}`'s
1809final `{{else}}` branch this way requires re-reading the template's
1810actual brace nesting before editing — the three-way `{{if
1811.QueryErr}}{{else if .Query}}{{else}}{{end}}` collapses to a two-way
1812`{{if .QueryErr}}{{else}}...{{end}}` once the "no query yet" case no
1813longer needs its own branch for this sentence. Read
1814`globalsearch.html`'s full `{{if}}/{{else}}` structure before editing —
1815line numbers above are from this plan's research and may have shifted.)
1816
1817- [ ] **Step 4: Run**
1818
1819Run: `go test ./internal/httpd -run TestGlobalSearchScopeCaptionAlwaysShown -count=1 && go test ./internal/httpd -count=1`
1820Expected: PASS.
1821
1822- [ ] **Step 5: Document the tab zero-count rule (no code change: the
1823  current behaviour is already the rule)**
1824
1825Reading `layout.html:71-74`: `Issues` and `Merge requests` already hide
1826their count badge at zero (`{{with field $ "OpenIssues"}}{{if .}} <i>{{.}}</i>{{end}}{{end}}`,
1827same for `OpenMRs`); `Builds`, `Releases`, `Wiki` and `Settings` never
1828carry a count at all. The inconsistency the issue names ("Issues 9, then
1829Merge requests with no count") is two tabs following the same rule
1830producing different-looking output depending on the data, not a code
1831bug — but `dashboard.html`'s pin row (`<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} ...`)
1832always prints the number, including `0`, which genuinely is a different
1833rule from the tabs'. Fix that inconsistency by hiding a zero the same
1834way the tabs do. Find (`dashboard.html:27`, inside the pin row):
1835
1836```html
1837<b{{if .Issues}} class="wants"{{end}}>{{.Issues}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{.MRs}} <span class="vh">open merge requests</span></b>
1838```
1839
1840Replace:
1841
1842```html
1843<b{{if .Issues}} class="wants"{{end}}>{{if .Issues}}{{.Issues}}{{else}}0{{end}} <span class="vh">open issues</span></b> <b{{if .MRs}} class="wants"{{end}}>{{if .MRs}}{{.MRs}}{{else}}0{{end}} <span class="vh">open merge requests</span></b>
1844```
1845
1846Wait — re-read this before implementing: this keeps `0` printed, which
1847does not change anything (`{{.Issues}}` and `{{if .Issues}}{{.Issues}}{{else}}0{{end}}`
1848render identically for an int, since Go's `%v`-style template output of
1849`0` is already `"0"`). The dashboard pin row is not actually
1850inconsistent with the tabs in a way a template edit can fix: it is a
1851`<b>` badge that always shows a resting value (like a count chip
1852elsewhere in the app, e.g. label/milestone counts), whereas the tabs
1853hide their `<i>` badge entirely at zero because an empty `<i>` there
1854would look like stray punctuation next to the tab word. These are
1855two different UI elements with two different, both-reasonable rules.
1856Do not change `dashboard.html` in this task. Instead add a one-line
1857comment at `layout.html:69` (just above the `<nav class="tabs">`)
1858recording the decision so a future pass does not "fix" this again:
1859
1860```html
1861  {{/* A tab's own count badge is omitted at zero (an empty <i> reads as
1862       stray punctuation next to the tab word); the dashboard pin row's
1863       count chip always shows its number, zero included, the same as
1864       every other count chip in the app. Two elements, two rules,
1865       decided once here (#270). */}}
1866  <nav class="tabs" aria-label="Repository">
1867```
1868
1869- [ ] **Step 6: Run the full package once more**
1870
1871Run: `go test ./internal/httpd ./internal/web -count=1`
1872Expected: PASS.
1873
1874- [ ] **Step 7: Commit**
1875
1876```bash
1877git add internal/web/templates/globalsearch.html internal/web/templates/layout.html internal/httpd/searchweb_test.go
1878git commit -m "web: search scope caption is permanent; document the tab zero-count rule" -m "Closes #270"
1879```
1880
1881### Task 4.4: open MR 4
1882
1883```bash
1884git push -u origin web-empty-states
1885gitbay mr create --source web-empty-states --target main --title "Web: empty-state and contribution-hint sweep"
1886```
1887
1888Wait for CI, merge, delete the branch both places.
1889
1890---
1891
1892# MR 5: UX review small fixes (branch `web-ux-small-fixes`)
1893
1894Closes #271. Depends on MR 1 (`repo watch`/`repo mute` dispatch and the
1895cycling toggle).
1896
1897### Task 5.1: issue form gains milestone and assignee
1898
1899**Files:**
1900- Modify: `internal/web/templates/issuenew.html`
1901- Modify: `internal/httpd/accounts.go:455-477` (`issueCreateSubmit`)
1902- Test: `internal/httpd/accounts_test.go` or wherever an existing
1903  `issueCreateSubmit` test lives (`grep -rln "issueCreateSubmit" internal/httpd/*_test.go`)
1904
1905**Ground truth from reading the code:** `issue create`
1906(`internal/control/issue.go:18-31`) takes only `--title`, `--body`/
1907`--file`, and `--format` — no `--milestone`/`--assignee` flags.
1908`issueCreateSubmit` (`internal/httpd/accounts.go:455-477`) already
1909handles this shape for labels: it creates the issue first (decoding the
1910created issue's number via `dispatchIntoStdin` into `control.Created`),
1911then, only if the labels field was non-empty, makes a second dispatch
1912(`issue label ... --add ...`) with that number. Milestone and assignee
1913follow the same two-step shape, using the existing commands `issue
1914milestone <owner/name> <n> <title>` and `issue assign <owner/name> <n>
1915[--add <user>]` (`internal/control/issue.go:101-109` for assign; the
1916milestone command's exact path is confirmed by
1917`internal/httpd/issueactions.go`'s `issueMilestoneSubmit`, which already
1918calls `issueArgs(r, "milestone", title)`).
1919
1920- [ ] **Step 1: Write the failing test**
1921
1922```go
1923// The new-issue form takes milestone and assignee, the same as the
1924// issue page's own edit controls already do (#271).
1925func TestIssueCreateFormHasMilestoneAndAssignee(t *testing.T) {
1926	var sb strings.Builder
1927	if err := web.Render(&sb, "issuenew.html", struct {
1928		basePage
1929		Repo       store.Repo
1930		Milestones []string
1931		Draft      *draft
1932	}{Repo: store.Repo{OwnerName: "alice", Name: "app"}}); err != nil {
1933		t.Fatalf("render: %v", err)
1934	}
1935	out := sb.String()
1936	if !strings.Contains(out, `name="milestone"`) {
1937		t.Error("no milestone field")
1938	}
1939	if !strings.Contains(out, `name="assignee"`) {
1940		t.Error("no assignee field")
1941	}
1942}
1943```
1944
1945(Match the render struct to whatever `issueCreateForm` actually passes —
1946read its handler first, per Step 1, and adjust field names here.)
1947
1948- [ ] **Step 2: Run and see it fail**
1949
1950Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1`
1951Expected: FAIL.
1952
1953- [ ] **Step 3: Add the fields to the form**
1954
1955Add to `issuenew.html`, alongside the existing labels input (matching
1956its markup style exactly — an `<input>` with the same classes/attributes
1957the labels field uses, adjusted for name and placeholder):
1958
1959```html
1960<p><input type="text" name="milestone" aria-label="Milestone" placeholder="milestone (optional)"></p>
1961<p><input type="text" name="assignee" aria-label="Assignee" placeholder="assignee, one username (optional)"></p>
1962```
1963
1964Place these after the existing labels `<input>` and before the submit
1965button, matching the vertical rhythm (`<p>` wrapping) the rest of the
1966form uses.
1967
1968- [ ] **Step 4: Wire them into the handler as follow-up dispatches**
1969
1970In `internal/httpd/accounts.go`, `issueCreateSubmit` (lines 455-477),
1971add two more follow-up dispatches after the existing labels one, using
1972the same `n` (the created issue's number, already decoded from
1973`created.Number`):
1974
1975```go
1976	if args := fieldArgs("--add", r.FormValue("labels")); len(args) > 0 {
1977		s.runControl(u, append([]string{"issue", "label", repoPath, fmt.Sprint(n)}, args...))
1978	}
1979	if milestone := strings.TrimSpace(r.FormValue("milestone")); milestone != "" {
1980		s.runControl(u, []string{"issue", "milestone", repoPath, fmt.Sprint(n), milestone})
1981	}
1982	if assignee := strings.TrimSpace(r.FormValue("assignee")); assignee != "" {
1983		s.runControl(u, []string{"issue", "assign", repoPath, fmt.Sprint(n), "--add", assignee})
1984	}
1985	http.Redirect(w, r, fmt.Sprintf("/%s/issues/%d", repoPath, n), http.StatusSeeOther)
1986```
1987
1988(The first block — the existing labels dispatch — is unchanged; the
1989milestone and assignee blocks are new, inserted between it and the
1990final redirect.)
1991
1992- [ ] **Step 5: Run**
1993
1994Run: `go test ./internal/httpd -run TestIssueCreateFormHasMilestoneAndAssignee -count=1 && go test ./internal/httpd -count=1`
1995Expected: PASS.
1996
1997- [ ] **Step 6: Commit**
1998
1999```bash
2000git add internal/web/templates/issuenew.html internal/httpd/accounts.go internal/httpd/*_test.go
2001git commit -m "web: new-issue form takes milestone and assignee" -m "Ref #271"
2002```
2003
2004### Task 5.2: "Muted" reachable on the watch control
2005
2006MR 1 (Task 1.2) already made `watchToggle` cycle default → watching →
2007muted → default, closing the functional half of this. This task is the
2008UI half: the header button's label and title must describe all three
2009states (today it only ever renders "Watch" or "Watching").
2010
2011**Files:**
2012- Modify: `internal/web/templates/layout.html:64`
2013- Test: a `layout.html` render test, or a repo-page test that already
2014  checks the watch button (`grep -rln 'aria-pressed.*Watch\|"Watching"' internal/httpd/*_test.go`)
2015
2016- [ ] **Step 1: Write the failing test**
2017
2018```go
2019// The watch button names all three states it cycles through, including
2020// muted, which MR 1 made reachable (#271).
2021func TestRepoHeaderWatchButtonNamesMutedState(t *testing.T) {
2022	var sb strings.Builder
2023	rp := testRepoPage()
2024	rp.Watch = "muted"
2025	if err := web.Render(&sb, "dashboard.html", struct {
2026		repoPage
2027	}{rp}); err != nil {
2028		t.Fatalf("render: %v", err)
2029	}
2030	if !strings.Contains(sb.String(), "Muted") {
2031		t.Error(`watch button does not render "Muted" for a muted repo`)
2032	}
2033}
2034```
2035
2036`dashboard.html` is not a repo page and will not carry the header at
2037all — use whatever page template this plan's other tasks already found
2038does render `field $ "Repo"` (any `repoPage`-embedding page works,
2039e.g. `mr.html`); adjust the render call to a page that actually shows
2040the header (check with `grep -n 'field \$ "Repo"' internal/web/templates/layout.html`
2041and pick any page in the `repoPage` family, such as `mrs.html`, matching
2042whatever fixture data that page's own tests already use).
2043
2044- [ ] **Step 2: Run and see it fail**
2045
2046Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1`
2047Expected: FAIL.
2048
2049- [ ] **Step 3: Update the button**
2050
2051Find (`layout.html:64`):
2052
2053```html
2054    <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if eq (str $ "Watch") "watching"}}true{{else}}false{{end}}" title="Watching sends every issue, request and build to your inbox">{{if eq (str $ "Watch") "watching"}}Watching{{else}}Watch{{end}}</button></form>
2055```
2056
2057Replace:
2058
2059```html
2060    <form method="post" action="/{{.OwnerName}}/{{.Name}}/watch" class="inline"><button type="submit" class="btn" aria-pressed="{{if ne (str $ "Watch") ""}}true{{else}}false{{end}}" title="{{if eq (str $ "Watch") "watching"}}Watching: every issue, request and build. Click to mute.{{else if eq (str $ "Watch") "muted"}}Muted: nothing from this repository. Click to stop muting.{{else}}Only what involves you. Click to watch everything.{{end}}">{{if eq (str $ "Watch") "watching"}}Watching{{else if eq (str $ "Watch") "muted"}}Muted{{else}}Watch{{end}}</button></form>
2061```
2062
2063- [ ] **Step 4: Run**
2064
2065Run: `go test ./internal/httpd -run TestRepoHeaderWatchButtonNamesMutedState -count=1 && go test ./internal/httpd -count=1`
2066Expected: PASS.
2067
2068- [ ] **Step 5: Commit**
2069
2070```bash
2071git add internal/web/templates/layout.html internal/httpd/*_test.go
2072git commit -m "web: watch button names all three states, muted included" -m "Ref #271"
2073```
2074
2075### Task 5.3: rail and phone "More" menu render from one list
2076
2077**Files:**
2078- Modify: `internal/web/web.go` (add `railItem` type, `railOptItems`
2079  func, register it in `funcs`)
2080- Modify: `internal/web/templates/layout.html:26,30-31,37-40`
2081- Test: `internal/web/web_test.go` (`TestRailIconsAreLabelled` already
2082  parses `layout.html`; add a focused new test rather than folding into
2083  that one)
2084
2085- [ ] **Step 1: Write the failing test**
2086
2087Add to `internal/web/web_test.go`:
2088
2089```go
2090// The main rail and the phone "More" menu render New repository,
2091// Settings, Admin and Log out from one list, so adding a destination in
2092// one place reaches both (#271).
2093func TestRailOptItemsDriveBothRailAndMoreMenu(t *testing.T) {
2094	items := railOptItems(struct {
2095		Tab   string
2096		Admin bool
2097	}{Tab: "admin", Admin: true})
2098	if len(items) != 4 {
2099		t.Fatalf("got %d items, want 4 (New repository, Settings, Admin, Log out)", len(items))
2100	}
2101	if items[2].Name != "Admin" || !items[2].Show {
2102		t.Errorf("Admin item: %+v", items[2])
2103	}
2104	if !items[2].Current {
2105		t.Error("Admin item should be Current when Tab is admin")
2106	}
2107
2108	nonAdmin := railOptItems(struct {
2109		Tab   string
2110		Admin bool
2111	}{Tab: "account"})
2112	if nonAdmin[2].Show {
2113		t.Error("Admin item should not Show for a non-admin viewer")
2114	}
2115	if !nonAdmin[1].Current {
2116		t.Error("Settings item should be Current when Tab is account")
2117	}
2118}
2119```
2120
2121- [ ] **Step 2: Run and see it fail to compile**
2122
2123Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2124Expected: FAIL (`railOptItems` undefined).
2125
2126- [ ] **Step 3: Add the type and function**
2127
2128In `internal/web/web.go`, near the other template-data helpers (before
2129the `funcs` map, so it can be referenced there):
2130
2131```go
2132// railItem is one destination the rail's icon strip and the phone
2133// "More" menu both render — from this one list, so a destination added
2134// here reaches both instead of the two being hand-kept in step (#271).
2135type railItem struct {
2136	Href    string
2137	Icon    string
2138	Name    string
2139	Current bool
2140	Count   int64 // unused by railOptItems; present so "raillink" can read it uniformly
2141	Show    bool
2142}
2143
2144// railField and railBool read a named field off the page value the
2145// layout was given — the same reflection str/field already do for the
2146// repo header, duplicated narrowly here rather than exported, since
2147// railOptItems is their only other caller.
2148func railField(v any, name string) string {
2149	rv := reflect.ValueOf(v)
2150	for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2151		rv = rv.Elem()
2152	}
2153	if rv.Kind() != reflect.Struct {
2154		return ""
2155	}
2156	f := rv.FieldByName(name)
2157	if !f.IsValid() || f.Kind() != reflect.String {
2158		return ""
2159	}
2160	return f.String()
2161}
2162
2163func railBool(v any, name string) bool {
2164	rv := reflect.ValueOf(v)
2165	for rv.Kind() == reflect.Ptr || rv.Kind() == reflect.Interface {
2166		rv = rv.Elem()
2167	}
2168	if rv.Kind() != reflect.Struct {
2169		return false
2170	}
2171	f := rv.FieldByName(name)
2172	return f.IsValid() && f.Kind() == reflect.Bool && f.Bool()
2173}
2174
2175// railOptItems is the rail's "New repository", "Settings", "Admin" and
2176// "Log out" destinations, in the order the rail shows them. v is the
2177// page value the layout renders (any page struct that embeds
2178// basePage), read by field name since the layout has no single common
2179// type for every page.
2180func railOptItems(v any) []railItem {
2181	tab := railField(v, "Tab")
2182	admin := railBool(v, "Admin")
2183	return []railItem{
2184		{Href: "/new", Icon: "plus", Name: "New repository", Show: true},
2185		{Href: "/settings", Icon: "gear", Name: "Settings", Current: tab == "account", Show: true},
2186		{Href: "/admin", Icon: "shield", Name: "Admin", Current: tab == "admin", Show: admin},
2187		{Href: "/logout", Icon: "signout", Name: "Log out", Show: true},
2188	}
2189}
2190```
2191
2192Add `"reflect"` to the file's imports if not already present (it almost
2193certainly is, since `str`/`field` already use it).
2194
2195Register it in the `funcs` map (anywhere in the literal, e.g. beside
2196`"initial"`):
2197
2198```go
2199	"railOptItems": railOptItems,
2200```
2201
2202- [ ] **Step 4: Run the new test**
2203
2204Run: `go test ./internal/web -run TestRailOptItemsDriveBothRailAndMoreMenu -count=1`
2205Expected: PASS.
2206
2207- [ ] **Step 5: Use it in `layout.html`**
2208
2209Find (lines 21-32):
2210
2211```html
2212  <ul class="raillist">
2213    {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2214    <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2215    <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2216    {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2217    <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2218  </ul>
2219  <span class="railgap"></span>
2220  <ul class="raillist">
2221    {{if .Viewer}}<li class="railopt">{{template "raillink" dict "Href" "/settings" "Icon" "gear" "Name" "Settings" "Current" (eq (str . "Tab") "account")}}</li>{{end}}
2222    {{if .Admin}}<li class="railopt">{{template "raillink" dict "Href" "/admin" "Icon" "shield" "Name" "Admin" "Current" (eq (str . "Tab") "admin")}}</li>{{end}}
2223  </ul>
2224```
2225
2226Replace:
2227
2228```html
2229  <ul class="raillist">
2230    {{if .Viewer}}<li>{{template "raillink" dict "Href" "/" "Icon" "home" "Name" "Dashboard" "Current" (eq (str . "Tab") "dashboard")}}</li>{{end}}
2231    <li>{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2232    <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2233    {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
2234    <li class="railopt">{{template "raillink" (index (railOptItems .) 0)}}</li>{{end}}
2235  </ul>
2236  <span class="railgap"></span>
2237  <ul class="raillist">
2238    {{if .Viewer}}<li class="railopt">{{template "raillink" (index (railOptItems .) 1)}}</li>
2239    {{if (index (railOptItems .) 2).Show}}<li class="railopt">{{template "raillink" (index (railOptItems .) 2)}}</li>{{end}}{{end}}
2240  </ul>
2241```
2242
2243Find (lines 34-42):
2244
2245```html
2246    {{if .Viewer}}<details class="railmore">
2247      <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2248      <div class="raildrop">
2249        <a href="/new">{{template "icon" "plus"}} New repository</a>
2250        <a href="/settings">{{template "icon" "gear"}} Settings</a>
2251        {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
2252        <a href="/logout">{{template "icon" "signout"}} Log out</a>
2253      </div>
2254    </details>
2255```
2256
2257Replace:
2258
2259```html
2260    {{if .Viewer}}<details class="railmore">
2261      <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
2262      <div class="raildrop">
2263        {{range railOptItems .}}{{if .Show}}<a href="{{.Href}}">{{template "icon" .Icon}} {{.Name}}</a>{{end}}{{end}}
2264      </div>
2265    </details>
2266```
2267
2268- [ ] **Step 6: Run**
2269
2270Run: `go test ./internal/web -count=1 && go test ./internal/httpd -count=1`
2271Expected: PASS. `TestRailIconsAreLabelled` must still pass unchanged —
2272`raillink`'s template still receives the same field names (`Href`,
2273`Icon`, `Name`, `Current`, `Count`), now off a `railItem` struct instead
2274of a `dict` map, which `html/template`'s field access treats
2275identically.
2276
2277- [ ] **Step 7: Commit**
2278
2279```bash
2280git add internal/web/web.go internal/web/web_test.go internal/web/templates/layout.html
2281git commit -m "web: rail and phone More menu render New repository/Settings/Admin/Log out from one list" -m "Ref #271"
2282```
2283
2284### Task 5.4: "Discussion" heading before the comment thread
2285
2286**Files:**
2287- Modify: `internal/web/templates/mr.html` (inside the `conversation` view)
2288- Modify: `internal/web/templates/issue.html`
2289- Test: existing render tests in `mrpage_test.go`; a new or existing
2290  `issue.html` render test
2291
2292- [ ] **Step 1: Write the failing tests**
2293
2294Add to `internal/httpd/mrpage_test.go`:
2295
2296```go
2297// A heading precedes the comment thread, so a screen-reader user
2298// skimming by heading does not fall from the aside's groups straight
2299// into the first comment with no landmark (#271).
2300func TestMRPageHasDiscussionHeading(t *testing.T) {
2301	var sb strings.Builder
2302	if err := web.Render(&sb, "mr.html", mrPageData{
2303		repoPage: testRepoPage(), MR: testMR("open"), View: "conversation",
2304	}); err != nil {
2305		t.Fatalf("render: %v", err)
2306	}
2307	if !strings.Contains(sb.String(), "<h2>Discussion</h2>") {
2308		t.Error("no Discussion heading")
2309	}
2310}
2311```
2312
2313Add the equivalent for `issue.html` in whatever file already tests it
2314(`grep -rln '"issue.html"' internal/httpd/*_test.go`).
2315
2316- [ ] **Step 2: Run and see them fail**
2317
2318Run: `go test ./internal/httpd -run TestMRPageHasDiscussionHeading -count=1`
2319Expected: FAIL.
2320
2321- [ ] **Step 3: `mr.html`**
2322
2323Find, inside the `{{if eq .View "conversation"}}` block, right after
2324`<div class="prose">`:
2325
2326```html
2327{{if eq .View "conversation"}}
2328<div class="prose">
2329{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2330```
2331
2332Replace:
2333
2334```html
2335{{if eq .View "conversation"}}
2336<div class="prose">
2337<h2>Discussion</h2>
2338{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>Edit</summary>
2339```
2340
2341- [ ] **Step 4: `issue.html`**
2342
2343Find, right after `<div class="mainside">`:
2344
2345```html
2346<div class="mainside">
2347{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2348```
2349
2350Replace:
2351
2352```html
2353<div class="mainside">
2354<h2>Discussion</h2>
2355{{if .CanEdit}}<details class="editbox"{{if .Draft.Is "edit"}} open{{end}}><summary>edit</summary>
2356```
2357
2358- [ ] **Step 5: Run**
2359
2360Run: `go test ./internal/httpd -count=1`
2361Expected: PASS.
2362
2363- [ ] **Step 6: Commit**
2364
2365```bash
2366git add internal/web/templates/mr.html internal/web/templates/issue.html internal/httpd/*_test.go
2367git commit -m "web: Discussion heading before the comment thread on issue and MR pages" -m "Ref #271"
2368```
2369
2370### Task 5.5: build page's "Live" note says the page updates itself
2371
2372**Files:**
2373- Modify: `internal/web/templates/build.html:15`
2374- Test: `internal/httpd/builds_test.go` (or wherever a live-build render
2375  test already exists — check with `grep -rln '"Live"' internal/httpd/*_test.go`)
2376
2377- [ ] **Step 1: Write the failing test**
2378
2379```go
2380// The Live note says the page updates itself, in plain words, rather
2381// than the more technical "streams here" (#271).
2382func TestBuildPageLiveNoteSaysItUpdatesItself(t *testing.T) {
2383	var sb strings.Builder
2384	if err := web.Render(&sb, "build.html", buildView{
2385		repoPage: testRepoPage(), Live: true,
2386	}); err != nil {
2387		t.Fatalf("render: %v", err)
2388	}
2389	if !strings.Contains(sb.String(), "This page updates itself") {
2390		t.Error(`Live note does not say the page updates itself`)
2391	}
2392}
2393```
2394
2395- [ ] **Step 2: Run and see it fail**
2396
2397Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1`
2398Expected: FAIL.
2399
2400- [ ] **Step 3: Update the note**
2401
2402Find (`build.html:15`):
2403
2404```html
2405{{if .Live}}<p class="meta">Live: the log streams here until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2406```
2407
2408Replace:
2409
2410```html
2411{{if .Live}}<p class="meta">This page updates itself until the build ends. If it stops without a “build finished” line, reload to pick it up again. <a href="?follow=0">Show it without updates</a></p>
2412```
2413
2414- [ ] **Step 4: Run**
2415
2416Run: `go test ./internal/httpd -run TestBuildPageLiveNoteSaysItUpdatesItself -count=1 && go test ./internal/httpd -count=1`
2417Expected: PASS.
2418
2419- [ ] **Step 5: Commit**
2420
2421```bash
2422git add internal/web/templates/build.html internal/httpd/builds_test.go
2423git commit -m "web: build page's Live note says the page updates itself" -m "Closes #271"
2424```
2425
2426### Task 5.6: open MR 5
2427
2428```bash
2429git push -u origin web-ux-small-fixes
2430gitbay mr create --source web-ux-small-fixes --target main --title "Web UX review small fixes: issue form, mute, rail list, Discussion heading"
2431```
2432
2433Wait for CI, merge, delete the branch both places.
2434
2435---
2436
2437# MR 6: wiki non-page links go to `_raw` (branch `wiki-raw-links`)
2438
2439Closes #283.
2440
2441### Task 6.1: `rewriteWikiLinks` sends a non-page file link to `_raw`
2442
2443**Files:**
2444- Modify: `internal/httpd/wiki.go:250-253`
2445- Test: `internal/httpd/wiki_test.go`
2446
2447**Interfaces:**
2448- No signature changes — `rewriteWikiLinks`'s parameters and the
2449  `isPage`/`isFile` predicates it already takes are unchanged; only its
2450  href branch's internal logic changes.
2451
2452- [ ] **Step 1: Write the failing test**
2453
2454Add to `internal/httpd/wiki_test.go`, in `TestRewriteWikiLinksInSubfolder`
2455(extend the existing test rather than adding a new one — it already sets
2456up exactly the `pages`/`files` fixtures this needs):
2457
2458```go
2459	in := template.HTML(`<a href="Identity.org">i</a><a href="Admin.org">a</a>` +
2460		`<a href="b.svg">diagram</a>` +
2461		`<img src="b.svg"><img src="diagrams/a.svg"><a href="https://x.test/">x</a>`)
2462	out := string(rewriteWikiLinks(in, p, "Architecture/Trust",
2463		func(s string) bool { return pages[s] }, func(s string) bool { return files[s] }))
2464	for _, want := range []string{
2465		`href="/krz/gitbay/wiki/Architecture/Identity"`,
2466		`href="/krz/gitbay/wiki/Admin"`,
2467		`href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2468		`src="/krz/gitbay/wiki/_raw/Architecture/b.svg"`,
2469		`src="/krz/gitbay/wiki/_raw/diagrams/a.svg"`,
2470		`href="https://x.test/"`,
2471	} {
2472```
2473
2474(This adds the `<a href="b.svg">` link to the input and the matching
2475`href="...wiki/_raw/Architecture/b.svg"` expectation to the existing
2476`for _, want := range` loop — the surrounding test body, `p`, `pages`
2477and `files` setup, and the final `if !strings.Contains` check stay
2478exactly as they are.)
2479
2480- [ ] **Step 2: Run and see it fail**
2481
2482Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2483Expected: FAIL — the new href expectation
2484(`href="/krz/gitbay/wiki/_raw/Architecture/b.svg"`) is missing; today's
2485code rewrites that link to `href="/krz/gitbay/wiki/Architecture/b.svg"`
2486instead (a page-style link to a file that is not a page, which 404s —
2487the bug #283 reports).
2488
2489- [ ] **Step 3: Fix `rewriteWikiLinks`**
2490
2491Find (`internal/httpd/wiki.go:250-253`):
2492
2493```go
2494				if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2495					n.Attr[i].Val = base + "/" + target
2496				}
2497```
2498
2499Replace:
2500
2501```go
2502				// A plain link is usually to another page, but a link to
2503				// an existing non-page file (an .svg, .txt, .pdf) must
2504				// go to _raw the same as an image src, or it 404s
2505				// against the page route (#283).
2506				if target, ok := wikiResolve(page, trimPageExt(v), isPage); ok {
2507					if raw, rok := wikiResolve(page, v, isFile); rok && isFile(raw) && !isPage(target) {
2508						n.Attr[i].Val = base + "/_raw/" + raw
2509					} else {
2510						n.Attr[i].Val = base + "/" + target
2511					}
2512				}
2513```
2514
2515- [ ] **Step 4: Run**
2516
2517Run: `go test ./internal/httpd -run TestRewriteWikiLinksInSubfolder -count=1`
2518Expected: PASS.
2519
2520- [ ] **Step 5: Run the package**
2521
2522Run: `go test ./internal/httpd -count=1`
2523Expected: PASS.
2524
2525- [ ] **Step 6: Commit**
2526
2527```bash
2528git add internal/httpd/wiki.go internal/httpd/wiki_test.go
2529git commit -m "wiki: a link to an existing non-page file resolves to _raw, not the page route" -m "Closes #283"
2530```
2531
2532### Task 6.2: open MR 6
2533
2534```bash
2535git push -u origin wiki-raw-links
2536gitbay mr create --source wiki-raw-links --target main --title "Wiki: links to non-page files resolve to _raw"
2537```
2538
2539Wait for CI, merge, delete the branch both places.
2540
2541---
2542
2543# MR 7: API token page (branch `web-api-tokens`)
2544
2545Closes #264. Depends on plan 1 (`credentials-and-sessions`, #257) per
2546the "Order and dependencies" section above — implementable and testable
2547independently, merge after plan 1 lands.
2548
2549### Task 7.1: `Settings → Tokens`: create, list, revoke
2550
2551**Files:**
2552- Modify: `internal/httpd/account.go` (`accountPage`, `accountSubmit`)
2553- Modify: `internal/httpd/flash.go` (a token-shown-once cookie, parallel
2554  to the existing flash cookie)
2555- Modify: `internal/web/templates/account.html`
2556- Test: `internal/httpd/account_test.go`
2557
2558**Interfaces:**
2559- Consumes: `store.ListAPITokens`/`RevokeAPIToken`
2560  (`internal/store/tokens.go:53,74`, read/delete directly, matching how
2561  `accountPage` already reads keys and PGP keys); `s.runControl`
2562  dispatching `token create` (a write, so it goes through the command,
2563  matching every other write on this page).
2564- Produces: `func (s *Server) setTokenFlash(w http.ResponseWriter, msg string)`,
2565  `func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string`
2566  (`internal/httpd/flash.go`).
2567
2568- [ ] **Step 1: Write the failing tests**
2569
2570Add to `internal/httpd/account_test.go`:
2571
2572```go
2573// The settings page lists a user's API tokens with scope and expiry,
2574// and creating one shows the token exactly once, never in the URL
2575// (#264).
2576func TestAccountPageListsTokens(t *testing.T) {
2577	st, err := store.Open(":memory:")
2578	if err != nil {
2579		t.Fatal(err)
2580	}
2581	defer st.Close()
2582	if err := st.MigrateUp(); err != nil {
2583		t.Fatal(err)
2584	}
2585	uid, err := st.CreateUser("alice", false)
2586	if err != nil {
2587		t.Fatal(err)
2588	}
2589	if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2590		t.Fatal(err)
2591	}
2592
2593	s := New(config.Default(), st)
2594	rr := httptest.NewRecorder()
2595	req := httptest.NewRequest("GET", "/settings", nil)
2596	s.accountPage(rr, req, store.User{ID: uid, Username: "alice"})
2597
2598	body := rr.Body.String()
2599	if !strings.Contains(body, "laptop") || !strings.Contains(body, "read") {
2600		t.Fatalf("token row missing: %s", body)
2601	}
2602	if strings.Contains(body, "somehash") {
2603		t.Fatal("the page printed a token hash")
2604	}
2605}
2606
2607// Creating a token always sends an explicit --scope, defaulting the
2608// form to read regardless of what token create itself defaults to, so
2609// this page's behaviour does not depend on that command's default
2610// (#264, #257).
2611func TestAccountSubmitTokenCreateDefaultsToReadScope(t *testing.T) {
2612	st, err := store.Open(":memory:")
2613	if err != nil {
2614		t.Fatal(err)
2615	}
2616	defer st.Close()
2617	if err := st.MigrateUp(); err != nil {
2618		t.Fatal(err)
2619	}
2620	uid, err := st.CreateUser("alice", false)
2621	if err != nil {
2622		t.Fatal(err)
2623	}
2624	u := store.User{ID: uid, Username: "alice"}
2625	s := New(config.Default(), st)
2626
2627	rr := submitAccountForm(t, s, u, url.Values{"field": {"token-create"}, "name": {"laptop"}})
2628	if rr.Code != http.StatusSeeOther {
2629		t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2630	}
2631	if !strings.Contains(strings.Join(rr.Result().Header.Values("Set-Cookie"), ";"), "gitbay_token=") {
2632		t.Fatal("no token-shown cookie set")
2633	}
2634	tokens, err := st.ListAPITokens(uid)
2635	if err != nil || len(tokens) != 1 {
2636		t.Fatalf("tokens: %v %v", tokens, err)
2637	}
2638	if tokens[0].Scope != "read" {
2639		t.Errorf("scope = %q, want read", tokens[0].Scope)
2640	}
2641}
2642
2643// Revoking a token requires the name typed back, the same guard every
2644// other removal on this page uses.
2645func TestAccountSubmitTokenRevokeRequiresConfirm(t *testing.T) {
2646	st, err := store.Open(":memory:")
2647	if err != nil {
2648		t.Fatal(err)
2649	}
2650	defer st.Close()
2651	if err := st.MigrateUp(); err != nil {
2652		t.Fatal(err)
2653	}
2654	uid, err := st.CreateUser("alice", false)
2655	if err != nil {
2656		t.Fatal(err)
2657	}
2658	if err := st.CreateAPIToken(uid, "laptop", "somehash", "read", nil); err != nil {
2659		t.Fatal(err)
2660	}
2661	u := store.User{ID: uid, Username: "alice"}
2662	s := New(config.Default(), st)
2663
2664	submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}})
2665	if tokens, _ := st.ListAPITokens(uid); len(tokens) != 1 {
2666		t.Fatal("token revoked without confirmation")
2667	}
2668
2669	rr := submitAccountForm(t, s, u, url.Values{"field": {"token-revoke"}, "name": {"laptop"}, "confirm": {"laptop"}})
2670	if rr.Code != http.StatusSeeOther {
2671		t.Fatalf("status %d, body %s", rr.Code, rr.Body.String())
2672	}
2673	if tokens, _ := st.ListAPITokens(uid); len(tokens) != 0 {
2674		t.Fatal("token not revoked")
2675	}
2676}
2677```
2678
2679- [ ] **Step 2: Run and see them fail**
2680
2681Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1`
2682Expected: FAIL to compile (no `token-create`/`token-revoke` cases, no
2683token rows on the page).
2684
2685- [ ] **Step 3: Add the token-shown-once cookie**
2686
2687In `internal/httpd/flash.go`, alongside `flashCookie`/`setFlash`/`takeFlash`:
2688
2689```go
2690// tokenFlashCookie carries a freshly minted API token to the settings
2691// page exactly once. A cookie, not the ?m= query parameter the other
2692// account forms use for their success text, because a token is a
2693// secret and must never ride a URL a browser might history, bookmark,
2694// or hand to a proxy's access log (#264).
2695const tokenFlashCookie = "gitbay_token"
2696
2697// setTokenFlash queues a freshly minted token's display text for the
2698// next render of the settings page.
2699func (s *Server) setTokenFlash(w http.ResponseWriter, msg string) {
2700	if msg == "" {
2701		return
2702	}
2703	http.SetCookie(w, &http.Cookie{
2704		Name: tokenFlashCookie, Value: url.QueryEscape(msg), Path: "/settings",
2705		HttpOnly: true, SameSite: http.SameSiteLaxMode,
2706		Secure: s.cfg.HTTP.TLS != "off",
2707		MaxAge: 60,
2708	})
2709}
2710
2711// takeTokenFlash returns the queued token text, if any, and clears it.
2712func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2713	c, err := r.Cookie(tokenFlashCookie)
2714	if err != nil || c.Value == "" {
2715		return ""
2716	}
2717	http.SetCookie(w, s.clearCookie(tokenFlashCookie, http.SameSiteLaxMode))
2718	msg, err := url.QueryUnescape(c.Value)
2719	if err != nil {
2720		return ""
2721	}
2722	return msg
2723}
2724```
2725
2726`clearCookie` takes only `name` and `sameSite` and hard-codes `Path:
2727"/"` (`internal/httpd/flash.go:101-108`) — confirm this still clears a
2728cookie set with `Path: "/settings"` (it does: browsers key deletion on
2729name+domain+path, and `"/settings"` is under `"/"`... actually a
2730`Path=/` clearing cookie does **not** delete a `Path=/settings` cookie —
2731paths must match exactly for deletion semantics in most browsers).
2732Fix this by setting `Path: "/settings"` on both the set and the clear:
2733add a `path` parameter to a small local variant, or simplest, write
2734`takeTokenFlash`'s clear inline instead of reusing `clearCookie`:
2735
2736```go
2737func (s *Server) takeTokenFlash(w http.ResponseWriter, r *http.Request) string {
2738	c, err := r.Cookie(tokenFlashCookie)
2739	if err != nil || c.Value == "" {
2740		return ""
2741	}
2742	http.SetCookie(w, &http.Cookie{
2743		Name: tokenFlashCookie, Value: "", Path: "/settings",
2744		HttpOnly: true, SameSite: http.SameSiteLaxMode,
2745		Secure: s.cfg.HTTP.TLS != "off", MaxAge: -1,
2746	})
2747	msg, err := url.QueryUnescape(c.Value)
2748	if err != nil {
2749		return ""
2750	}
2751	return msg
2752}
2753```
2754
2755(Use this version; drop the `clearCookie` call from the draft above.)
2756
2757- [ ] **Step 4: Add token data to `accountPage`**
2758
2759In `internal/httpd/account.go`, add a view type near `accountDevice`:
2760
2761```go
2762// accountToken is one API token as the settings page shows it: never
2763// the token itself, only what identifies and describes it.
2764type accountToken struct {
2765	Name       string
2766	Scope      string
2767	Created    string
2768	Expires    string // "never" or a formatted timestamp
2769	LastUsed   string // "never" or a formatted timestamp
2770}
2771```
2772
2773In `accountPage`, alongside the existing `devices` collection:
2774
2775```go
2776	var tokens []accountToken
2777	if list, err := s.st.ListAPITokens(u.ID); err == nil {
2778		for _, tk := range list {
2779			expires, lastUsed := "never", "never"
2780			if tk.ExpiresAt != nil {
2781				expires = tk.ExpiresAt.UTC().Format("2006-01-02 15:04 UTC")
2782			}
2783			if tk.LastUsedAt != nil {
2784				lastUsed = tk.LastUsedAt.UTC().Format("2006-01-02 15:04 UTC")
2785			}
2786			tokens = append(tokens, accountToken{tk.Name, tk.Scope, tk.CreatedAt, expires, lastUsed})
2787		}
2788	}
2789```
2790
2791Add `Tokens []accountToken` and `TokenShown string` to the struct passed
2792to `s.render(w, "account.html", ...)`, with `tokens` and
2793`s.takeTokenFlash(w, r)` in the corresponding literal positions.
2794
2795- [ ] **Step 5: Add `token-create` and `token-revoke` to `accountSubmit`**
2796
2797In `internal/httpd/account.go`, `accountSubmit`'s `switch`, add two
2798cases (alongside `email-primary` and before `theme`, or anywhere in the
2799switch — order does not matter):
2800
2801```go
2802	case "token-create":
2803		name := strings.TrimSpace(r.FormValue("name"))
2804		if name == "" {
2805			back("name the token", "")
2806			return
2807		}
2808		scope := r.FormValue("scope")
2809		if scope != "full" {
2810			scope = "read" // this page's own default, regardless of what token create defaults to (#257, #264)
2811		}
2812		argv := []string{"token", "create", "--name", name, "--scope", scope}
2813		if ttl := strings.TrimSpace(r.FormValue("ttl")); ttl != "" {
2814			argv = append(argv, "--ttl", ttl)
2815		}
2816		out, msg, ok := s.runControl(u, argv)
2817		if !ok {
2818			back(msg, "")
2819			return
2820		}
2821		s.setTokenFlash(w, out)
2822		http.Redirect(w, r, "/settings#tokens", http.StatusSeeOther)
2823		return
2824	case "token-revoke":
2825		name := r.FormValue("name")
2826		if ok, msg := confirmed(r, name); !ok {
2827			back(msg, "")
2828			return
2829		}
2830		if _, msg, ok := s.runControl(u, []string{"token", "revoke", name}); !ok {
2831			back(msg, "")
2832			return
2833		}
2834		back("", "token revoked")
2835```
2836
2837(This `switch` does not use `back`'s redirect-with-query pattern for
2838`token-create`'s success path, since the token's display text cannot go
2839through `?m=`; it returns directly after the redirect, matching the
2840early-return shape every other case already uses.)
2841
2842- [ ] **Step 6: Add the Tokens section to `account.html`**
2843
2844Add a new `<section id="tokens">` — placed before the existing `<section
2845id="cli">` (which the sidebar's existing anchor list under "On the
2846command line" leaves in place; add a `<li><a href="#tokens">API
2847tokens</a></li>` to that anchor list too, alongside the other section
2848links):
2849
2850```html
2851<section id="tokens"><h2>API tokens</h2>
2852<p class="meta">A token signs in the iOS app, or a script, without your
2853password. A phone app needs full scope to comment and merge; full scope
2854on an admin account can administer the instance, so give a token the
2855narrowest scope and shortest lifetime the job needs.</p>
2856{{if .TokenShown}}<pre class="message" tabindex="0">{{.TokenShown}}</pre>{{end}}
2857<table>
2858<tr><th>Name</th><th>Scope</th><th>Created</th><th>Expires</th><th>Last used</th><th></th></tr>
2859{{range .Tokens}}<tr>
2860  <td>{{.Name}}</td><td>{{.Scope}}</td><td>{{.Created}}</td><td>{{.Expires}}</td><td>{{.LastUsed}}</td>
2861  <td class="act"><form method="post" action="/settings"><input type="hidden" name="field" value="token-revoke"><input type="hidden" name="name" value="{{.Name}}">{{template "confirmfield" .Name}} <button type="submit" class="danger">Revoke</button></form></td>
2862</tr>{{else}}<tr><td colspan="6">no tokens</td></tr>{{end}}
2863</table>
2864<form method="post" action="/settings" class="actions">
2865<input type="hidden" name="field" value="token-create">
2866<input type="text" name="name" aria-label="Token name" placeholder="name, e.g. iphone" required>
2867<select name="scope" aria-label="Scope">
2868  <option value="read" selected>read</option>
2869  <option value="full">full</option>
2870</select>
2871<input type="text" name="ttl" aria-label="Expires after" placeholder="expires after, e.g. 30d (optional)">
2872<button type="submit" class="btn">Create token</button>
2873</form>
2874</section>
2875```
2876
2877- [ ] **Step 7: Run**
2878
2879Run: `go test ./internal/httpd -run 'TestAccountPageListsTokens|TestAccountSubmitTokenCreateDefaultsToReadScope|TestAccountSubmitTokenRevokeRequiresConfirm' -count=1 && go test ./internal/httpd -count=1`
2880Expected: PASS.
2881
2882- [ ] **Step 8: Commit**
2883
2884```bash
2885git add internal/httpd/flash.go internal/httpd/account.go internal/web/templates/account.html internal/httpd/account_test.go
2886git commit -m "web: Settings → Tokens — create, list, revoke API tokens" -m "Ref #264"
2887```
2888
2889### Task 7.2: `registered.html` next steps as a numbered list
2890
2891**Files:**
2892- Modify: `internal/web/templates/registered.html`
2893- Test: a render test in whatever file covers signup
2894  (`grep -rln '"registered.html"' internal/httpd/*_test.go`; create one
2895  if none exists)
2896
2897- [ ] **Step 1: Write the failing test**
2898
2899```go
2900func TestRegisteredPageNumberedStepsAndTokenMention(t *testing.T) {
2901	var sb strings.Builder
2902	if err := web.Render(&sb, "registered.html", struct {
2903		basePage
2904		Username, Message, Host string
2905	}{Username: "alice", Host: "gitbay.org"}); err != nil {
2906		t.Fatalf("render: %v", err)
2907	}
2908	out := sb.String()
2909	if !strings.Contains(out, "<ol>") {
2910		t.Error("next steps are not a numbered list")
2911	}
2912	if !strings.Contains(out, "Settings → Tokens") {
2913		t.Error("no mention of Settings → Tokens for the iOS app")
2914	}
2915}
2916```
2917
2918- [ ] **Step 2: Run and see it fail**
2919
2920Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1`
2921Expected: FAIL.
2922
2923- [ ] **Step 3: Rewrite the section**
2924
2925Find (`registered.html:7-10`):
2926
2927```html
2928<h2>On the web</h2>
2929<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
2930link, and paste the code under <a href="/settings">Settings</a>. Then +
2931creates your first repository.</p>
2932```
2933
2934Replace:
2935
2936```html
2937<h2>On the web</h2>
2938<ol>
2939<li>Copy the verification code from the mail you were just sent.</li>
2940<li><a href="/login">Sign in</a> with an emailed link.</li>
2941<li>Paste the code in <a href="/settings#emails">Settings → Email</a>.</li>
2942</ol>
2943<p>Then + creates your first repository. Using the iOS app? Create a
2944token in <a href="/settings#tokens">Settings → Tokens</a>.</p>
2945```
2946
2947(`#emails` matches the existing section id in `account.html`; confirm
2948with `grep -n 'id="email' internal/web/templates/account.html` — it may
2949be `id="emails"` plural or singular, match whichever is actually there.)
2950
2951- [ ] **Step 4: Run**
2952
2953Run: `go test ./internal/httpd -run TestRegisteredPageNumberedStepsAndTokenMention -count=1 && go test ./internal/httpd -count=1`
2954Expected: PASS.
2955
2956- [ ] **Step 5: Commit**
2957
2958```bash
2959git add internal/web/templates/registered.html internal/httpd/*_test.go
2960git commit -m "web: registered page's next steps as a numbered list, with a token mention for the iOS app" -m "Ref #264"
2961```
2962
2963### Task 7.3: update Parity
2964
2965**Files:**
2966- Modify: `.gitbay/wiki/Parity.org`
2967
2968- [ ] **Step 1: Fix the API token mint row**
2969
2970Find:
2971
2972```
2973| API token mint              | yes | no  | no  |
2974```
2975
2976Replace:
2977
2978```
2979| API token mint              | yes | yes | no  |
2980```
2981
2982(The iOS side — linking to this page from sign-in — is filed separately
2983in `krz/gitbay-ios`, per the issue text, so its column stays `no` here.)
2984
2985- [ ] **Step 2: Commit**
2986
2987```bash
2988git add .gitbay/wiki/Parity.org
2989git commit -m "wiki: Parity reflects the web API-token page" -m "Closes #264"
2990```
2991
2992### Task 7.4: open MR 7
2993
2994```bash
2995git push -u origin web-api-tokens
2996gitbay mr create --source web-api-tokens --target main --title "Web: Settings → Tokens page"
2997```
2998
2999Wait for CI, merge, delete the branch both places.
3000
3001---
3002
3003## Self-review
3004
3005**Spec coverage** (against the seven issue texts):
3006- #261: FK check ordering (Task 1.1), pin/watch dispatch (Task 1.2),
3007  Cache-Control (Task 1.3), all four doc-drift bullets (Task 1.4). ✓.
3008- #263: whoami line, token line both forms, auth summary, registry test
3009  (Tasks 2.1-2.3). ✓.
3010- #264: create/list/revoke with confirmfield, scope defaults to read on
3011  this page regardless of the command default, registered.html numbered
3012  steps + token mention, Parity (Tasks 7.1-7.3). ✓.
3013- #269: range-diff page, compare-to-previous per row, Parity (Tasks
3014  3.1-3.3). ✓.
3015- #270: the empty-state table (Task 4.1), MR list contribution hint
3016  (Task 4.2), search caption + tab zero-count rule (Task 4.3). ✓.
3017- #271: issue form fields, Muted reachable, rail/More unification,
3018  Discussion heading, Live note (Tasks 5.1-5.5). ✓.
3019- #283: `_raw` fix and test (Task 6.1). ✓.
3020
3021**Placeholder scan:** no task stops short of real code. The three points
3022this plan's first draft could not pin down from the code — `group()`'s
3023help-rendering mechanism (Task 2.2), `issue create`'s flag set (Task
30245.1), and how an `internal/httpd` test authenticates a GET as a given
3025viewer (Task 4.2) — were each resolved by reading the relevant source
3026(`cmd/gitbay/main.go`'s `group()`/`serverHelp()`, `internal/control/help.go`'s
3027`runHelp()`, `internal/control/issue.go`'s `issue create`/`issue
3028milestone`/`issue assign` registrations, and `internal/httpd/logincookie_test.go`'s
3029`sessionCookieFor` plus `store.CreateWebSession`) before this plan was
3030finished; the tasks above carry the resolved code directly, not a
3031placeholder.
3032
3033**Type consistency:** `railItem` (Task 5.3) is used identically in
3034`web.go` and `layout.html`; `accountToken` (Task 7.1) fields match the
3035template's `.Name`/`.Scope`/`.Created`/`.Expires`/`.LastUsed` access;
3036`mrRangeDiff`'s render struct (Task 3.1) matches `mrrangediff.html`'s
3037`.MR`/`.Diff`/`.Error`.
3038
3039## Open questions
3040
3041None outstanding — the three research gaps found while first drafting
3042this plan (Task 2.2's help mechanism, Task 4.2's session-cookie test
3043fixture, Task 5.1's `issue create` flag set) were each resolved by
3044reading the relevant source before this plan was finished; see
3045"Placeholder scan" above for what was read.