docs/plans/2026-09-19-desktop-layout.md

v1.40.1
gitbay/docs/plans/2026-09-19-desktop-layout.md rendered · source · history · blame · raw

1896 lines · 72254 bytes

   1# Desktop Layout 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:** Make the web UI use a desktop screen: one centered container, per-page left columns that each carry a feature, one-line list rows, a three-column dashboard, and a two-row repository header.
   6
   7**Architecture:** Every change is CSS on existing tokens plus template markup, with three handler additions: a directory listing beside a file, facet counts beside a list, and per-repository counts beside the pinned list. No new control command, no migration. Each page keeps working at phone widths because the columns stack below 64rem.
   8
   9**Tech Stack:** Go 1.2x, `html/template`, one stylesheet (`internal/web/static/style.css`), `go test` unit tests in `internal/httpd` and `internal/web`, e2e tests in `e2e/` against a real instance.
  10
  11**Spec:** `docs/specs/2026-09-19-desktop-layout-design.md`
  12
  13## Global Constraints
  14
  15- Branch `desktop-layout-spec` already holds the spec commit; all work lands on it. Never push to `main`; the MR at the end merges with `--strategy ff` (signed commits required).
  16- Commit subjects follow the log: `web: ...`, `httpd: ...`, `e2e: ...`, `CHANGELOG: ...`, lowercase after the prefix, no trailer, no attribution of any kind. Reference `Ref #226` in bodies.
  17- `--container: 100rem`. Every left column is `15rem`, sticky. Text stays at 48rem / 78ch. Columns stack below `64rem`.
  18- Colors and spacing use existing tokens only (`--sp-*`, `--fs-*`, `--surface`, `--line`, `--faint`, `--muted`, `--mark`, `--warn`, `--fg`, `--link`, `--hover`, `--r-ctl`, `--r-card`). Never a hex value in a rule.
  19- `TestEveryTemplateClassHasARule` (`internal/web/classes_test.go`) fails on any class a template uses that no `style.css` selector names. Add the rule in the same step as the markup.
  20- Local verification per task: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/`, plus at most the one e2e test the task touches (`go test ./e2e -run TestName -count=1`). The full suite runs in CI on push.
  21- Do not mention Claude, LLMs or assistants anywhere: commits, comments, CHANGELOG, wiki.
  22
  23---
  24
  25### Task 1: One centered container
  26
  27**Files:**
  28- Modify: `internal/web/static/style.css:366-388` (shell section), `:389-395` (repohead), `:1481-1489` (52rem breakpoint)
  29- Modify: `internal/web/templates/layout.html:39-69` (repohead)
  30- Test: `internal/web/layout_test.go` (create)
  31
  32**Interfaces:**
  33- Produces: the `.repohead .wrap` element every later header change lives in; `--container` token used by Task 4 and Task 9.
  34
  35- [ ] **Step 1: Write the failing test**
  36
  37```go
  38package web
  39
  40import (
  41	"strings"
  42	"testing"
  43)
  44
  45// The repository header, main and footer share one centered container:
  46// the header's inner content is wrapped, and the stylesheet caps and
  47// centers all three on the same token (desktop layout spec).
  48func TestSharedCenteredContainer(t *testing.T) {
  49	layout, err := templateFS.ReadFile("templates/layout.html")
  50	if err != nil {
  51		t.Fatal(err)
  52	}
  53	if !strings.Contains(string(layout), `<header class="repohead">\n<div class="wrap">`) &&
  54		!strings.Contains(string(layout), "<header class=\"repohead\">\n<div class=\"wrap\">") {
  55		t.Fatalf("repohead is not wrapped in .wrap")
  56	}
  57	css := string(StyleCSS)
  58	for _, want := range []string{
  59		"--container: 100rem;",
  60		"main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }",
  61		".repohead .wrap { max-width: var(--container); margin: 0 auto; }",
  62		"main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }",
  63		"main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }",
  64	} {
  65		if !strings.Contains(css, want) {
  66			t.Errorf("style.css lacks %q", want)
  67		}
  68	}
  69}
  70```
  71
  72- [ ] **Step 2: Run it to see it fail**
  73
  74Run: `go test ./internal/web/ -run TestSharedCenteredContainer`
  75Expected: FAIL, "repohead is not wrapped in .wrap"
  76
  77- [ ] **Step 3: Add the token and the container rules**
  78
  79In `style.css`, inside `:root {` after the `--rail-mark-box: 32px;` line (around line 111), add:
  80
  81```css
  82  /* the one page container: header content, main and footer align on it */
  83  --container: 100rem;
  84```
  85
  86Replace lines 371-377 (the `main.content` rule and the three width rules) with:
  87
  88```css
  89main.content {
  90  flex: 1;
  91  width: 100%;
  92  padding: var(--sp-5) var(--sp-6) var(--sp-7);
  93}
  94main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }
  95main.wide { max-width: calc(var(--container) + 2 * var(--sp-6)); }
  96main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }
  97main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }
  98```
  99
 100Replace the `.repohead` rule (line 390-393) with:
 101
 102```css
 103.repohead {
 104  padding: var(--sp-4) 0 0;
 105  border-bottom: 1px solid var(--line);
 106}
 107.repohead .wrap { max-width: var(--container); margin: 0 auto; padding: 0 var(--sp-6); }
 108```
 109
 110In the `@media (max-width: 52rem)` block, change `.repohead { padding: var(--sp-3) var(--sp-4) 0; }` to:
 111
 112```css
 113  .repohead { padding: var(--sp-3) 0 0; }
 114  .repohead .wrap { padding: 0 var(--sp-4); }
 115```
 116
 117- [ ] **Step 4: Wrap the header content**
 118
 119In `layout.html`, line 39 `<header class="repohead">` becomes:
 120
 121```html
 122<header class="repohead">
 123<div class="wrap">
 124```
 125
 126and line 69 `</header>` becomes:
 127
 128```html
 129</div>
 130</header>
 131```
 132
 133- [ ] **Step 5: Run the tests**
 134
 135Run: `go test ./internal/web/ ./internal/httpd/`
 136Expected: PASS (the classes test sees `.wrap` in the stylesheet)
 137
 138- [ ] **Step 6: Commit**
 139
 140```bash
 141git add internal/web/static/style.css internal/web/templates/layout.html internal/web/layout_test.go
 142git commit -m "web: one centered container for header, main and footer
 143
 144Ref #226"
 145```
 146
 147---
 148
 149### Task 2: Repository header on two rows
 150
 151**Files:**
 152- Modify: `internal/web/templates/layout.html:41-58`
 153- Modify: `internal/web/static/style.css:396-425` (identity, repodesc, repometa, toggles rules)
 154- Test: `internal/httpd/repohead_test.go` (create)
 155
 156**Interfaces:**
 157- Consumes: `testRepoPage()` from `internal/httpd/buildpages_test.go:12`.
 158
 159- [ ] **Step 1: Write the failing test**
 160
 161```go
 162package httpd
 163
 164import (
 165	"strings"
 166	"testing"
 167
 168	"gitbay.org/gitbay/internal/control"
 169	"gitbay.org/gitbay/internal/web"
 170)
 171
 172// The repository header is two rows: identity with the description and
 173// the buttons, then the tabs. The toggles hint is title text on the
 174// buttons, not a line of its own (desktop layout spec).
 175func TestRepoHeaderTwoRows(t *testing.T) {
 176	var sb strings.Builder
 177	p := testRepoPage()
 178	p.Viewer = "alice"
 179	p.Desc = "A CLI-first git forge."
 180	p.Topics = []string{"cli"}
 181	p.Tab = "files"
 182	err := web.Render(&sb, "builds.html", struct {
 183		repoPage
 184		Builds      []control.BuildOut
 185		Jobs        []control.JobOut
 186		Runs        []buildRun
 187		Filter      buildFilter
 188		FilterLinks []buildFilterLink
 189		Refs        []string
 190		CanWrite    bool
 191		Notice      string
 192	}{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""})
 193	if err != nil {
 194		t.Fatal(err)
 195	}
 196	out := sb.String()
 197	if strings.Contains(out, `class="toggles"`) || strings.Contains(out, "Pinned shows on your dashboard.") {
 198		t.Error("the toggles hint still renders as a line")
 199	}
 200	for _, want := range []string{
 201		`title="Pinned repositories show on your dashboard"`,
 202		`title="Watching sends every issue, request and build to your inbox"`,
 203		`title="Bookmarked lists it under Bookmarks"`,
 204		`<p class="repodesc">A CLI-first git forge.`,
 205	} {
 206		if !strings.Contains(out, want) {
 207			t.Errorf("header lacks %q", want)
 208		}
 209	}
 210	// the description sits inside the identity row, before the buttons
 211	if strings.Index(out, `class="repodesc"`) > strings.Index(out, `action="/krz/gitbay/pin"`) {
 212		t.Error("description renders after the buttons; it belongs in the identity row")
 213	}
 214}
 215```
 216
 217- [ ] **Step 2: Run it to see it fail**
 218
 219Run: `go test ./internal/httpd/ -run TestRepoHeaderTwoRows`
 220Expected: FAIL on the `title=` strings
 221
 222- [ ] **Step 3: Rewrite the identity row**
 223
 224Replace `layout.html` lines 41-58 (from `<div class="identity">` through the `{{end}}` that closes `{{if eq $top "code"}}`) with:
 225
 226```html
 227  {{$top := topTab (str $ "Tab")}}
 228  <div class="identity">
 229    {{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
 230    {{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
 231    {{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
 232    {{if .Settings.Archived}}<span class="chip">Archived</span>{{end}}
 233    {{/* Description, topics and website belong to the code tab, inline
 234         with the name so the header is two rows on every page. */}}
 235    {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}{{with $.Repo.Settings.Website}}<a class="site" href="{{.}}" rel="nofollow">{{.}}</a>{{end}}</p>{{end}}{{end}}
 236    <span class="grow"></span>
 237    {{if $.Viewer}}<form method="post" action="/{{.OwnerName}}/{{.Name}}/pin" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Pinned"}}true{{else}}false{{end}}" title="Pinned repositories show on your dashboard"><span aria-hidden="true">{{if field $ "Pinned"}}★{{else}}☆{{end}}</span> {{if field $ "Pinned"}}Pinned{{else}}Pin{{end}}</button></form>
 238    <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>
 239    <form method="post" action="/{{.OwnerName}}/{{.Name}}/bookmark" class="inline"><button type="submit" class="btn" aria-pressed="{{if field $ "Marked"}}true{{else}}false{{end}}" title="Bookmarked lists it under Bookmarks">{{if field $ "Marked"}}Bookmarked{{else}}Bookmark{{end}}</button></form>
 240    <form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
 241  </div>
 242  {{if eq $top "code"}}{{if field $ "Mirrors"}}<p class="repometa">{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} <a href="{{$m.URL}}" rel="nofollow">{{$m.Target}}</a>{{if $m.Error}}, <span class="bad">sync error: {{$m.Error}}</span>{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}</p>{{end}}{{end}}
 243```
 244
 245Delete the old `{{$top := ...}}` line that followed the identity block (it now sits above it) and the `<p class="toggles">` line.
 246
 247- [ ] **Step 4: Style the inline description and remove the toggles rule**
 248
 249In `style.css` replace the `.repodesc` rule (lines 412-417) and delete the `.toggles` rule (line 419):
 250
 251```css
 252.repodesc {
 253  margin: 0 0 0 var(--sp-2);
 254  color: var(--muted);
 255  font-size: var(--fs-2);
 256  display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
 257  min-width: 0;
 258}
 259.repodesc a.site { color: var(--muted); }
 260.repodesc a.site:hover { color: var(--link); }
 261```
 262
 263Add under the `@media (max-width: 52rem)` block:
 264
 265```css
 266  /* a phone shows the description under the name, not beside it */
 267  .repodesc { flex-basis: 100%; margin-left: 0; }
 268```
 269
 270- [ ] **Step 5: Run the tests**
 271
 272Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestTreeSearchCodeAndClone' -count=1`
 273Expected: PASS
 274
 275- [ ] **Step 6: Commit**
 276
 277```bash
 278git add internal/web/templates/layout.html internal/web/static/style.css internal/httpd/repohead_test.go
 279git commit -m "web: the repository header is two rows
 280
 281Description, topics and website sit beside the name; the toggles hint is
 282title text on the buttons.
 283
 284Ref #226"
 285```
 286
 287---
 288
 289### Task 3: One-line list rows and wide list pages
 290
 291**Files:**
 292- Modify: `internal/web/templates/issues.html:24-32`, `mrs.html:19-32`, `notifications.html:12-21`, `globalsearch.html:22-31`, `dashboard.html:2-12`, `explore.html:1`
 293- Modify: `internal/web/static/style.css:892-907` (issuelist), `:867-877` (repolist), `:104-109` of `layout.html` (reporow)
 294- Test: `internal/web/widths_test.go` (create)
 295
 296**Interfaces:**
 297- Produces: `ul.issuelist.rows` and `ul.repolist.rows`, the row format every list task after this reuses.
 298
 299- [ ] **Step 1: Write the failing test**
 300
 301```go
 302package web
 303
 304import (
 305	"strings"
 306	"testing"
 307)
 308
 309// List pages and the dashboard render at the container width; text pages
 310// keep the reading cap (desktop layout spec).
 311func TestListPagesAreWide(t *testing.T) {
 312	wide := []string{"dashboard.html", "issues.html", "mrs.html", "explore.html", "notifications.html", "globalsearch.html", "builds.html"}
 313	for _, name := range wide {
 314		src, err := templateFS.ReadFile("templates/" + name)
 315		if err != nil {
 316			t.Fatal(err)
 317		}
 318		if !strings.HasPrefix(string(src), `{{define "width"}}wide{{end}}`) {
 319			t.Errorf("%s does not declare width wide", name)
 320		}
 321	}
 322	for _, name := range []string{"issue.html", "wiki.html", "owner.html"} {
 323		src, _ := templateFS.ReadFile("templates/" + name)
 324		if strings.Contains(string(src), `{{define "width"}}wide{{end}}`) {
 325			t.Errorf("%s is a text page and must not be wide", name)
 326		}
 327	}
 328	for _, name := range []string{"issues.html", "mrs.html", "notifications.html", "globalsearch.html", "dashboard.html"} {
 329		src, _ := templateFS.ReadFile("templates/" + name)
 330		if !strings.Contains(string(src), `<ul class="issuelist rows">`) {
 331			t.Errorf("%s does not use one-line rows", name)
 332		}
 333	}
 334	if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) {
 335		t.Error("explore.html does not use one-line rows")
 336	}
 337}
 338```
 339
 340- [ ] **Step 2: Run it to see it fail**
 341
 342Run: `go test ./internal/web/ -run TestListPagesAreWide`
 343Expected: FAIL for every listed template
 344
 345- [ ] **Step 3: Add the row rules**
 346
 347After the `ul.issuelist .title a:hover` rule (line 907) add:
 348
 349```css
 350/* one-line rows: title, labels, then the meta pushed right. Above 64rem
 351   a list is a table, not prose (desktop layout spec). */
 352ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
 353ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
 354ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 355ul.issuelist.rows .title .chip { margin-left: var(--sp-1); }
 356ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
 357ul.issuelist.rows .meta .repo { color: var(--fg); }
 358```
 359
 360After the `ul.repolist .topics, ul.repolist .meta` rule (line 877) add:
 361
 362```css
 363ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); }
 364ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); }
 365ul.repolist.rows .desc { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-size: var(--fs-2); margin: 0; }
 366ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; }
 367ul.repolist.rows .meta { font-size: var(--fs-1); }
 368```
 369
 370After the `ul.loglist .commitside` rule (line 865) add, for the builds page (Task 7 applies it):
 371
 372```css
 373ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); }
 374ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; }
 375ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; }
 376```
 377
 378In the `@media (max-width: 62rem)` block add:
 379
 380```css
 381  /* rows go back to two lines where one does not fit */
 382  ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; }
 383  ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; }
 384```
 385
 386- [ ] **Step 4: Change the templates**
 387
 388`issues.html`: line 1 becomes two lines:
 389
 390```
 391{{define "width"}}wide{{end}}
 392{{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}}
 393```
 394
 395and `<ul class="issuelist">` becomes `<ul class="issuelist rows">`. The `<li>` body is unchanged: the CSS makes it one line.
 396
 397`mrs.html`: same two changes (`merge requests ·` title).
 398
 399`notifications.html`: add `{{define "width"}}wide{{end}}` as line 1; `<ul class="issuelist">` → `<ul class="issuelist rows">`.
 400
 401`globalsearch.html`: `<ul class="issuelist">` → `<ul class="issuelist rows">` (already wide).
 402
 403`dashboard.html`: add `{{define "width"}}wide{{end}}` as line 1; in the `itemlist` define, `<ul class="issuelist">` → `<ul class="issuelist rows">`, and the meta line becomes:
 404
 405```html
 406    <p class="meta"><span class="repo">{{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}}</span> · <a href="/{{.Author}}">{{.Author}}</a> · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · <span class="chip chip-source_gone">source gone</span>{{end}}</p>
 407```
 408
 409`explore.html`: add `{{define "width"}}wide{{end}}` as line 1; `<ul class="repolist">` → `<ul class="repolist rows">`.
 410
 411- [ ] **Step 5: Run the tests**
 412
 413Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestMRListRows|TestIssueWebTriage|TestDashboard$' -count=1`
 414Expected: PASS
 415
 416- [ ] **Step 6: Commit**
 417
 418```bash
 419git add internal/web
 420git commit -m "web: one-line rows on the list pages, at the container width
 421
 422Ref #226"
 423```
 424
 425---
 426
 427### Task 4: Dashboard as three columns with count tiles and pinned counts
 428
 429**Files:**
 430- Modify: `internal/httpd/web.go:185-200` (dashboard handler)
 431- Create: `internal/httpd/dashpins.go`, `internal/httpd/dashpins_test.go`
 432- Modify: `internal/web/templates/dashboard.html`
 433- Modify: `internal/web/static/style.css` (after the `.pinned` rules, line 994; after `.feed` rules, line 1030)
 434- Modify: `e2e/dashboard_test.go:95-119`, `:354-357`
 435
 436**Interfaces:**
 437- Consumes: `s.st.PinnedRepos(userID int64) ([]store.Repo, error)`, `s.st.OpenCounts(repoID int64) (issues, mrs int)`, `s.st.ListBuilds(repoID int64, f store.BuildFilter, limit int) ([]store.Build, error)`, `policy.CanRead(viewer, repo, grant)`, `s.st.AccessRole(repoID, userID)`.
 438- Produces: `pinnedRow` and `func (s *Server) pinnedRows(viewer store.User) []pinnedRow`.
 439
 440- [ ] **Step 1: Write the failing test**
 441
 442`internal/httpd/dashpins_test.go`:
 443
 444```go
 445package httpd
 446
 447import (
 448	"strings"
 449	"testing"
 450
 451	"gitbay.org/gitbay/internal/store"
 452	"gitbay.org/gitbay/internal/web"
 453)
 454
 455// The dashboard is three columns: pinned repositories with counts, the
 456// tile strip and queue rows, the activity feed. Tiles carry every queue's
 457// count; only a non-empty queue lists rows (desktop layout spec).
 458func TestDashboardTilesAndPins(t *testing.T) {
 459	var sb strings.Builder
 460	var base basePage
 461	base.Viewer = "alice"
 462	err := web.Render(&sb, "dashboard.html", struct {
 463		basePage
 464		Tab      string
 465		Pins     []pinnedRow
 466		Reviews  []store.DashboardItem
 467		Assigned []store.DashboardItem
 468		MRs      []store.DashboardItem
 469		Issues   []store.DashboardItem
 470		Feed     []feedLine
 471	}{base, "dashboard", []pinnedRow{{Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}}, nil, nil, nil,
 472		[]store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil})
 473	if err != nil {
 474		t.Fatal(err)
 475	}
 476	out := sb.String()
 477	for _, want := range []string{
 478		`<div class="dashgrid">`,
 479		`<aside class="dashpins" aria-label="Pinned repositories">`,
 480		`<span class="owner">krz/</span>gitbay</a>`,
 481		`<b class="wants">3</b>`, `<span class="dot ok"></span>`,
 482		`<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`,
 483		`<div class="tile"><b>0</b><span>waiting on your review</span></div>`,
 484		`<div class="tile"><b>0</b><span>assigned to you</span></div>`,
 485		`<div class="tile"><b>0</b><span>open merge requests</span></div>`,
 486		`<h2 id="issues">Open issues <span class="count">1</span></h2>`,
 487		`<aside class="feedcol" aria-label="Recent activity">`,
 488	} {
 489		if !strings.Contains(out, want) {
 490			t.Errorf("dashboard lacks %q", want)
 491		}
 492	}
 493	if strings.Contains(out, `<h2 class="empty">`) {
 494		t.Error("an empty queue still renders as a heading; the tile carries it")
 495	}
 496}
 497```
 498
 499- [ ] **Step 2: Run it to see it fail**
 500
 501Run: `go test ./internal/httpd/ -run TestDashboardTilesAndPins`
 502Expected: FAIL, "undefined: pinnedRow"
 503
 504- [ ] **Step 3: The pinned rows read**
 505
 506`internal/httpd/dashpins.go`:
 507
 508```go
 509package httpd
 510
 511import (
 512	"gitbay.org/gitbay/internal/policy"
 513	"gitbay.org/gitbay/internal/store"
 514)
 515
 516// pinnedRow is one pinned repository on the dashboard with the counts
 517// that say whether it wants attention: open issues, open merge requests
 518// and the newest build's status ("" when it has none).
 519type pinnedRow struct {
 520	Owner  string
 521	Name   string
 522	Issues int
 523	MRs    int
 524	Build  string
 525}
 526
 527// pinnedRows reads the viewer's pinned repositories the way railFor does,
 528// then adds the counts. Three reads per pinned repository, on the
 529// dashboard only.
 530func (s *Server) pinnedRows(viewer store.User) []pinnedRow {
 531	pinned, _ := s.st.PinnedRepos(viewer.ID)
 532	var rows []pinnedRow
 533	for _, rp := range pinned {
 534		grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
 535		if !policy.CanRead(viewer, rp, grant) {
 536			continue
 537		}
 538		row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name}
 539		row.Issues, row.MRs = s.st.OpenCounts(rp.ID)
 540		if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
 541			row.Build = builds[0].Status
 542		}
 543		rows = append(rows, row)
 544	}
 545	return rows
 546}
 547```
 548
 549In `web.go` `dashboard`, add `Pins []pinnedRow` to the struct after `Tab` and pass `s.pinnedRows(viewer)`:
 550
 551```go
 552	s.render(w, "dashboard.html", struct {
 553		basePage
 554		Tab      string
 555		Pins     []pinnedRow
 556		Reviews  []store.DashboardItem
 557		Assigned []store.DashboardItem
 558		MRs      []store.DashboardItem
 559		Issues   []store.DashboardItem
 560		Feed     []feedLine
 561	}{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
 562```
 563
 564- [ ] **Step 4: The template**
 565
 566Replace `dashboard.html` from `{{define "queue"}}` to the end with:
 567
 568```
 569{{define "queue"}}
 570<h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
 571{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}}
 572{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}}
 573{{end}}
 574{{define "tile"}}{{if $.N}}<a class="tile wants" href="#{{$.ID}}"><b>{{$.N}}</b><span>{{$.Label}}</span></a>{{else}}<div class="tile"><b>0</b><span>{{$.Label}}</span></div>{{end}}{{end}}
 575{{define "content"}}
 576<div class="dashgrid">
 577
 578<aside class="dashpins" aria-label="Pinned repositories">
 579  <h2 class="colhead">Pinned</h2>
 580  {{if .Pins}}<ul class="pins">
 581  {{range .Pins}}<li><a href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a><span class="n" title="{{.Issues}} open issue{{if ne .Issues 1}}s{{end}}, {{.MRs}} open merge request{{if ne .MRs 1}}s{{end}}{{with .Build}}, last build {{.}}{{end}}">{{if .Issues}}<b class="wants">{{.Issues}}</b>{{else}}<b>0</b>{{end}} {{if .MRs}}<b class="wants">{{.MRs}}</b>{{else}}<b>0</b>{{end}} <span class="dot{{if eq .Build "success"}} ok{{else if eq .Build "failure"}} bad{{else if .Build}} pend{{end}}"></span></span></li>
 582  {{end}}</ul>
 583  <p class="meta">issues · merge requests · last build</p>
 584  {{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
 585</aside>
 586
 587<section class="dashmain">
 588<h1>Dashboard</h1>
 589<div class="tiles">
 590  {{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}}
 591  {{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}}
 592  {{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}}
 593  {{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}}
 594</div>
 595{{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
 596{{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
 597{{if .MRs}}{{template "queue" dict "ID" "mrs" "Title" "Open merge requests" "Items" .MRs "Kind" "mrs" "Empty" "No open merge requests" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
 598{{if .Issues}}{{template "queue" dict "ID" "issues" "Title" "Open issues" "Items" .Issues "Kind" "issues" "Empty" "No open issues" "Hint" "Yours anywhere, and every one in a repository you can write to."}}{{end}}
 599{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}}
 600</section>
 601
 602<aside class="feedcol" aria-label="Recent activity">
 603  <h2 class="colhead">Recent activity</h2>
 604  {{range .Feed}}<p class="feedline">{{if eq .State "failure"}}<span class="dot bad"></span>{{else if eq .State "success"}}<span class="dot ok"></span>{{else if .State}}<span class="dot pend"></span>{{end}}<a href="/{{.Actor}}">{{.Actor}}</a> {{.Verb}} <a href="{{.URL}}"{{if .Jobs}} title="{{join .Jobs ", "}}"{{end}}>{{.Ref}}</a><br><span class="none">{{.Repo}} · <span title="{{whenT .WhenT}}">{{ago .WhenT}}</span></span></p>
 605  {{else}}<p class="none">No activity yet</p>{{end}}
 606</aside>
 607
 608</div>
 609{{end}}
 610```
 611
 612The `dict` helper takes key/value pairs; `len .Issues` inside `dict` needs parentheses, as written.
 613
 614- [ ] **Step 5: The stylesheet**
 615
 616Replace the `.pinned` rules (lines 992-994) with:
 617
 618```css
 619/* ---- dashboard: pinned column, tiles and queues, feed ---- */
 620.dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
 621.dashgrid h1 { margin-top: 0; }
 622.dashgrid > aside { position: sticky; top: var(--sp-5); }
 623.dashgrid .dashmain h2 { margin-top: var(--sp-5); }
 624.dashgrid .dashmain .tiles + h2 { margin-top: 0; }
 625.colhead {
 626  margin: 0 0 var(--sp-2);
 627  font-size: var(--fs-0);
 628  font-weight: 500;
 629  letter-spacing: 0.08em;
 630  text-transform: uppercase;
 631  color: var(--muted);
 632}
 633.pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); }
 634.pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); }
 635.pins li:last-child { border-bottom: 0; }
 636.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
 637.pins a:hover { color: var(--link); }
 638.pins .owner { color: var(--muted); }
 639.pins .n { margin-left: auto; flex: none; display: flex; gap: var(--sp-2); font-size: var(--fs-1); color: var(--muted); font-variant-numeric: tabular-nums; }
 640.pins .n b { font-weight: 600; color: var(--fg); }
 641.pins .n b.wants { color: var(--warn); }
 642.pins .dot { margin: 0; }
 643/* count tiles: the queues' sizes in one strip; a non-zero count is orange,
 644   what wants you */
 645.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); }
 646.tile {
 647  display: block;
 648  background: var(--surface);
 649  border: 1px solid var(--line);
 650  border-radius: var(--r-card);
 651  padding: var(--sp-3) var(--sp-4);
 652  color: var(--fg);
 653}
 654a.tile:hover { background: var(--hover); text-decoration: none; }
 655.tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; }
 656.tile.wants b { color: var(--warn); }
 657.tile span { font-size: var(--fs-1); color: var(--muted); }
 658.feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); }
 659.feedcol .feedline .none { font-size: var(--fs-1); }
 660```
 661
 662Delete the `.feed` rules (lines 1026-1030): nothing uses `.feed` any more. Keep `.feedline`.
 663
 664Add to the `@media (max-width: 62rem)` block:
 665
 666```css
 667  .dashgrid { grid-template-columns: 1fr; }
 668  .dashgrid > aside { position: static; }
 669  /* the pinned column goes back to a chip row on a phone */
 670  .pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
 671  .pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
 672  .pins .n, .dashpins .meta { display: none; }
 673  .tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
 674```
 675
 676And a new block, above the 62rem one:
 677
 678```css
 679@media (max-width: 80rem) {
 680  .dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; }
 681  .dashgrid > .dashpins { grid-column: 1 / -1; position: static; }
 682}
 683```
 684
 685- [ ] **Step 6: Update the e2e assertions**
 686
 687In `e2e/dashboard_test.go` replace lines 110-118 (the `emptyHeading` block) with:
 688
 689```go
 690	// The empty queue is a tile with a zero; a populated one is a tile
 691	// that links to its rows, which render in the middle column.
 692	if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
 693		t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
 694	}
 695	if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
 696		t.Fatalf("dashboard missing the review tile:\n%s", body)
 697	}
 698	if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
 699		t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
 700	}
 701```
 702
 703Replace lines 354-357 with:
 704
 705```go
 706	if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
 707		t.Fatalf("reviewed MR still waiting:\n%s", after)
 708	}
 709```
 710
 711In the `want` list at line 96-100, keep every entry; add `` `class="pins"` ``.
 712
 713- [ ] **Step 7: Run the tests**
 714
 715Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestDashboard' -count=1`
 716Expected: PASS
 717
 718- [ ] **Step 8: Commit**
 719
 720```bash
 721git add internal/httpd/dashpins.go internal/httpd/dashpins_test.go internal/httpd/web.go internal/web e2e/dashboard_test.go
 722git commit -m "web: the dashboard is three columns
 723
 724Pinned repositories with counts, a tile per queue, the feed as an aside.
 725
 726Ref #226"
 727```
 728
 729---
 730
 731### Task 5: File navigator beside blob, blame and edit
 732
 733**Files:**
 734- Create: `internal/httpd/filenav.go`, `internal/httpd/filenav_test.go`
 735- Modify: `internal/httpd/web.go:512-527` (extract the sort), `:559-604` (blob), `:813-896` (blame)
 736- Modify: `internal/httpd/accounts.go:474-518` (editPage, editForm)
 737- Modify: `internal/web/templates/layout.html` (new `filenav` partial), `blob.html`, `blame.html`, `edit.html`
 738- Modify: `internal/web/static/style.css` (after the `.pathbar` rules, line 1287)
 739- Create: `e2e/filenav_test.go`
 740
 741**Interfaces:**
 742- Consumes: `gitutil.ListTree(dir, ref, path string) ([]gitutil.TreeEntry, error)`, `gitutil.TreeEntry{Type, Name}`, `store.Repo.Path() string`.
 743- Produces: `fileNav`, `func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav`, `func sortDirsFirst(entries []gitutil.TreeEntry)`.
 744
 745- [ ] **Step 1: Write the failing unit test**
 746
 747`internal/httpd/filenav_test.go`:
 748
 749```go
 750package httpd
 751
 752import (
 753	"testing"
 754
 755	"gitbay.org/gitbay/internal/gitutil"
 756)
 757
 758// The navigator lists the file's directory, directories first, links each
 759// entry to its tree or blob page, marks the file itself, and links the
 760// parent (the tree root when the file is at the top).
 761func TestFileNavMarksCurrentAndLinksParent(t *testing.T) {
 762	entries := []gitutil.TreeEntry{
 763		{Type: "blob", Name: "main.go"},
 764		{Type: "tree", Name: "sub"},
 765		{Type: "blob", Name: "util.go"},
 766	}
 767	nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries)
 768	if nav.Title != "cmd/gitbay" {
 769		t.Errorf("title = %q", nav.Title)
 770	}
 771	if nav.Parent != "/krz/gitbay/tree/main/cmd" {
 772		t.Errorf("parent = %q", nav.Parent)
 773	}
 774	if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir {
 775		t.Fatalf("entries not directories-first: %+v", nav.Entries)
 776	}
 777	if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" {
 778		t.Errorf("dir url = %q", nav.Entries[0].URL)
 779	}
 780	if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" {
 781		t.Errorf("current entry: %+v", nav.Entries[2])
 782	}
 783	if nav.Entries[1].Current {
 784		t.Error("main.go marked current")
 785	}
 786
 787	root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}})
 788	if root.Title != "gitbay" || root.Parent != "" {
 789		t.Errorf("root nav: title %q parent %q", root.Title, root.Parent)
 790	}
 791}
 792```
 793
 794- [ ] **Step 2: Run it to see it fail**
 795
 796Run: `go test ./internal/httpd/ -run TestFileNavMarksCurrentAndLinksParent`
 797Expected: FAIL, "undefined: fileNavFor"
 798
 799- [ ] **Step 3: The navigator**
 800
 801`internal/httpd/filenav.go`:
 802
 803```go
 804package httpd
 805
 806import (
 807	"path"
 808	"sort"
 809
 810	"gitbay.org/gitbay/internal/gitutil"
 811)
 812
 813// fileNav is the column beside a file: its directory's entries, the file
 814// marked, and a link up. It is the tree page's listing rendered as a
 815// list, so reading a repository does not mean going back for each file.
 816type fileNav struct {
 817	Title   string // the directory, or the repository name at the root
 818	Parent  string // URL of the parent tree; "" at the root
 819	Entries []fileNavEntry
 820}
 821
 822type fileNavEntry struct {
 823	Name    string // directories carry a trailing slash
 824	URL     string
 825	Dir     bool
 826	Current bool
 827}
 828
 829// sortDirsFirst orders a listing by shape before name, stably, so each
 830// group keeps the order git gave it. The tree page and the navigator
 831// share it.
 832func sortDirsFirst(entries []gitutil.TreeEntry) {
 833	sort.SliceStable(entries, func(i, j int) bool {
 834		return entries[i].Type == "tree" && entries[j].Type != "tree"
 835	})
 836}
 837
 838// fileNavFor builds the navigator for filePath from its directory's
 839// entries. repoPath is owner/name.
 840func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav {
 841	dir := path.Dir(filePath)
 842	if dir == "." {
 843		dir = ""
 844	}
 845	base := "/" + repoPath
 846	nav := fileNav{Title: dir}
 847	if dir == "" {
 848		nav.Title = path.Base(repoPath)
 849	} else if up := path.Dir(dir); up == "." {
 850		nav.Parent = base + "/tree/" + ref
 851	} else {
 852		nav.Parent = base + "/tree/" + ref + "/" + up
 853	}
 854	sortDirsFirst(entries)
 855	for _, e := range entries {
 856		full := path.Join(dir, e.Name)
 857		ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath}
 858		if ent.Dir {
 859			ent.Name += "/"
 860			ent.URL = base + "/tree/" + ref + "/" + full
 861		} else {
 862			ent.URL = base + "/blob/" + ref + "/" + full
 863		}
 864		nav.Entries = append(nav.Entries, ent)
 865	}
 866	return nav
 867}
 868```
 869
 870In `web.go` `renderTree` replace the inline `sort.SliceStable(...)` call and its comment (lines 522-527) with `sortDirsFirst(entries)`. Remove the `sort` import if nothing else in the file uses it (check with `go build`).
 871
 872- [ ] **Step 4: Attach it to blob, blame and edit**
 873
 874In `blob` (`web.go`), after `branches, _ := gitutil.Refs(p.Dir, "heads")` add:
 875
 876```go
 877	navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
 878	nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
 879```
 880
 881and add `Nav fileNav` as the last field of the anonymous struct, passing `nav` last. Add to `filenav.go`:
 882
 883```go
 884// navDir is the directory ListTree wants for filePath: "" at the root.
 885func navDir(filePath string) string {
 886	if d := path.Dir(filePath); d != "." {
 887		return d
 888	}
 889	return ""
 890}
 891```
 892
 893In `blame`, before the render add the same two lines and add `Nav fileNav` to its struct, passing `nav`.
 894
 895In `accounts.go`, add `Nav fileNav` to `editPage` and in `editForm`, before `s.render`, add:
 896
 897```go
 898	navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
 899	nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
 900```
 901
 902and `Nav: nav,` in the `editPage{...}` literal.
 903
 904- [ ] **Step 5: The partial and the templates**
 905
 906In `layout.html` after the `refmenu` define add:
 907
 908```
 909{{define "filenav"}}<nav class="filenav" aria-label="Files">
 910  <h2 class="colhead">{{.Title}}</h2>
 911  <ul>
 912  {{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}}
 913  {{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li>
 914  {{end}}</ul>
 915</nav>{{end}}
 916```
 917
 918`blob.html`: after `<h1 class="vh">{{.Path}}</h1>` insert `<div class="blobgrid">` then `{{template "filenav" .Nav}}` then `<div class="blobmain">`; before the closing `{{end}}` of the content define add `</div>\n</div>`.
 919
 920`blame.html`: same wrapping around everything after the `<h1 class="vh">`.
 921
 922`edit.html`: same wrapping around the `.pathbar`/form block; the `.Nav` field is on `editPage`.
 923
 924- [ ] **Step 6: The stylesheet**
 925
 926After the `.crumbs strong` rule add:
 927
 928```css
 929/* ---- file navigator: the directory beside a file ---- */
 930.blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
 931.blobmain { min-width: 0; }
 932.filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
 933.filenav ul { list-style: none; margin: 0; padding: 0; }
 934.filenav li a {
 935  display: block;
 936  padding: 3px var(--sp-2);
 937  border-radius: var(--r-ctl);
 938  color: var(--fg);
 939  font-family: var(--mono);
 940  font-size: var(--fs-1);
 941  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
 942}
 943.filenav li a:hover { background: var(--hover); text-decoration: none; }
 944.filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; }
 945.filenav li a.dir { color: var(--link); }
 946.filenav li a.up { color: var(--muted); }
 947```
 948
 949In the `@media (max-width: 62rem)` block add:
 950
 951```css
 952  /* the tree page is the navigator on a phone */
 953  .blobgrid { grid-template-columns: 1fr; }
 954  .filenav { display: none; }
 955```
 956
 957- [ ] **Step 7: The e2e test**
 958
 959`e2e/filenav_test.go`:
 960
 961```go
 962package e2e
 963
 964import (
 965	"os"
 966	"path/filepath"
 967	"strings"
 968	"testing"
 969)
 970
 971// A file page lists its directory beside the file, marks the file, and
 972// links up (desktop layout spec).
 973func TestFileNavigator(t *testing.T) {
 974	inst := startInstance(t)
 975	key := inst.newKey(t, "alice")
 976	inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
 977	if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 {
 978		t.Fatalf("repo create: %s", errOut)
 979	}
 980	// the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50)
 981	work := t.TempDir()
 982	env := inst.gitEnv(key)
 983	mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w")
 984	dir := filepath.Join(work, "w")
 985	os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755)
 986	os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644)
 987	os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644)
 988	os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644)
 989	mustGit(t, dir, env, "checkout", "-q", "-b", "main")
 990	mustGit(t, dir, env, "add", ".")
 991	mustGit(t, dir, env, "commit", "-q", "-m", "one")
 992	mustGit(t, dir, env, "push", "-q", "origin", "main")
 993
 994	status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go")
 995	if status != 200 {
 996		t.Fatalf("blob: %d", status)
 997	}
 998	for _, want := range []string{
 999		`<nav class="filenav" aria-label="Files">`,
1000		`<h2 class="colhead">cmd</h2>`,
1001		`<a class="up" href="/alice/nav/tree/main">..</a>`,
1002		`<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`,
1003		`<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`,
1004	} {
1005		if !strings.Contains(body, want) {
1006			t.Errorf("blob page lacks %q", want)
1007		}
1008	}
1009	_, body = inst.get(t, "/alice/nav/blame/main/README.md")
1010	if !strings.Contains(body, `<h2 class="colhead">nav</h2>`) || !strings.Contains(body, `<a aria-current="page" href="/alice/nav/blob/main/README.md">README.md</a>`) {
1011		t.Errorf("blame page lacks the root navigator:\n%s", body)
1012	}
1013}
1014```
1015
1016`mustGit(t, dir, env, args...) string`, `inst.gitEnv(key) []string` and `inst.sshURL(repo) string` are in `e2e/git_test.go`; `gitEnv` sets the author and committer, so the commit needs no `-c user.*` flags.
1017
1018- [ ] **Step 8: Run the tests**
1019
1020Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestFileNavigator|TestWebUI' -count=1`
1021Expected: PASS
1022
1023- [ ] **Step 9: Commit**
1024
1025```bash
1026git add internal/httpd/filenav.go internal/httpd/filenav_test.go internal/httpd/web.go internal/httpd/accounts.go internal/web e2e/filenav_test.go
1027git commit -m "web: a file navigator beside blob, blame and edit
1028
1029Ref #226"
1030```
1031
1032---
1033
1034### Task 6: Facet column on the issue and merge request lists
1035
1036**Files:**
1037- Create: `internal/httpd/facets.go`, `internal/httpd/facets_test.go`
1038- Modify: `internal/httpd/web.go:1690-1735` (issues), `:1814-1866` (mrs)
1039- Modify: `internal/web/templates/layout.html` (new `sidecol` partial), `issues.html`, `mrs.html`
1040- Modify: `internal/web/static/style.css` (after the `.filenav` rules from Task 5)
1041- Modify: `e2e/labelweb_test.go` (add assertions at the end of `TestLabelsWeb`)
1042
1043**Interfaces:**
1044- Consumes: `control.ReadableScope(st, user, repo) ([]int64, error)`, `s.st.ListLabels(repo, readable) ([]store.Label, error)` with `Label{Name, Issues, MRs}`, `s.st.ListMilestones(repo, "open", readable) ([]store.Milestone, error)` with `Milestone{Title, OpenItems}`, `s.labelColors(repo)`.
1045- Produces: `facetGroup`, `facetItem`, `func facetHref(base url.Values, key, value string) string`, `func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup`. Task 7 and Task 8 reuse `facetGroup`.
1046
1047- [ ] **Step 1: Write the failing test**
1048
1049`internal/httpd/facets_test.go`:
1050
1051```go
1052package httpd
1053
1054import (
1055	"net/url"
1056	"testing"
1057
1058	"gitbay.org/gitbay/internal/store"
1059)
1060
1061// A facet link keeps every other active filter, sets its own, and clears
1062// its own when it is already active (desktop layout spec).
1063func TestFacetHrefKeepsOtherFilters(t *testing.T) {
1064	base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}}
1065	if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" {
1066		t.Errorf("set: %q", got)
1067	}
1068	if got := facetHref(base, "label", ""); got != "?q=crash&state=open" {
1069		t.Errorf("clear: %q", got)
1070	}
1071	if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" {
1072		t.Errorf("replace: %q", got)
1073	}
1074}
1075
1076func TestListFacetsGroups(t *testing.T) {
1077	base := url.Values{"state": {"open"}, "label": {"bug"}}
1078	labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}}
1079	ms := []store.Milestone{{Title: "v2", OpenItems: 4}}
1080	groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false)
1081	if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" {
1082		t.Fatalf("groups: %+v", groups)
1083	}
1084	st := groups[0].Items
1085	if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" {
1086		t.Errorf("state items: %+v", st)
1087	}
1088	lb := groups[1].Items
1089	if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
1090		t.Errorf("active label clears itself: %+v", lb[0])
1091	}
1092	if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
1093		t.Errorf("inactive label: %+v", lb[1])
1094	}
1095	if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
1096		t.Errorf("milestone: %+v", m)
1097	}
1098	// on the MR list a label's count is its MR count
1099	mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
1100	if mr[1].Items[1].Count != 3 {
1101		t.Errorf("mr count: %+v", mr[1].Items[1])
1102	}
1103}
1104```
1105
1106- [ ] **Step 2: Run it to see it fail**
1107
1108Run: `go test ./internal/httpd/ -run 'TestFacetHref|TestListFacets'`
1109Expected: FAIL, "undefined: facetHref"
1110
1111- [ ] **Step 3: The facets**
1112
1113`internal/httpd/facets.go`:
1114
1115```go
1116package httpd
1117
1118import (
1119	"net/url"
1120
1121	"gitbay.org/gitbay/internal/store"
1122)
1123
1124// facetItem is one link in a list page's side column: a value the list
1125// narrows to. Clicking an active item clears it.
1126type facetItem struct {
1127	Label  string
1128	Count  int64
1129	Href   string
1130	Active bool
1131}
1132
1133// facetGroup is one heading in the column: State, Labels, Milestones.
1134type facetGroup struct {
1135	Title string
1136	Items []facetItem
1137}
1138
1139// facetHref returns "?..." with every parameter of base kept, key set to
1140// value, or dropped when value is "". url.Values encodes sorted, so the
1141// tests and the links agree byte for byte.
1142func facetHref(base url.Values, key, value string) string {
1143	q := url.Values{}
1144	for k, vs := range base {
1145		if k == key || len(vs) == 0 || vs[0] == "" {
1146			continue
1147		}
1148		q.Set(k, vs[0])
1149	}
1150	if value != "" {
1151		q.Set(key, value)
1152	}
1153	return "?" + q.Encode()
1154}
1155
1156// listFacets builds the issue or merge request list's column from the
1157// active parameters, the states the page offers, and the repository's
1158// labels and open milestones. Counts are the rows' own: a label's issue
1159// count on the issue list, its MR count on the MR list.
1160func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
1161	var st facetGroup
1162	st.Title = "State"
1163	for _, s := range states {
1164		st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state})
1165	}
1166	lb := facetGroup{Title: "Labels"}
1167	for _, l := range labels {
1168		n := l.Issues
1169		if forMRs {
1170			n = l.MRs
1171		}
1172		active := base.Get("label") == l.Name
1173		href := facetHref(base, "label", l.Name)
1174		if active {
1175			href = facetHref(base, "label", "")
1176		}
1177		lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active})
1178	}
1179	mg := facetGroup{Title: "Milestones"}
1180	for _, m := range ms {
1181		active := base.Get("milestone") == m.Title
1182		href := facetHref(base, "milestone", m.Title)
1183		if active {
1184			href = facetHref(base, "milestone", "")
1185		}
1186		mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active})
1187	}
1188	return []facetGroup{st, lb, mg}
1189}
1190```
1191
1192- [ ] **Step 4: Wire the handlers**
1193
1194In `issues` (`web.go`), after the `if labels, err := s.st.ListIssueLabels(p.Repo)` block, add:
1195
1196```go
1197	base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
1198	readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1199	allLabels, _ := s.st.ListLabels(p.Repo, readable)
1200	openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1201	facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
1202```
1203
1204add `Facets []facetGroup` to the render struct after `Filters`, passing `facets`. Add `"net/url"` to the imports if absent.
1205
1206In `mrs`, after the `labels, err := s.st.ListMRLabels(p.Repo)` block, add the same with:
1207
1208```go
1209	base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
1210	readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
1211	allLabels, _ := s.st.ListLabels(p.Repo, readable)
1212	openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
1213	facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
1214```
1215
1216and `Facets []facetGroup` in its struct.
1217
1218- [ ] **Step 5: The partial and the templates**
1219
1220In `layout.html` after the `filenav` define add:
1221
1222```
1223{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
1224  {{range .}}{{if .Items}}<div class="grp">
1225  <h2 class="colhead">{{.Title}}</h2>
1226  <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
1227  </div>{{end}}{{end}}
1228</nav>{{end}}
1229```
1230
1231`issues.html`: replace the `content` define body with:
1232
1233```html
1234<div class="withcol">
1235{{template "sidecol" .Facets}}
1236<div class="colmain">
1237<div class="listhead">
1238  <h1>Issues</h1>
1239  <form method="get" class="searchform compact">
1240    <input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
1241    <button type="submit" class="btn">Search</button>
1242    <input type="hidden" name="state" value="{{.State}}">
1243  </form>
1244  {{range .Filters}}<p class="meta">{{.Key}}: {{if eq .Key "label"}}<span class="chip label" style="{{index $.LabelColors .Value}}">{{.Value}}</span>{{else}}<b>{{.Value}}</b>{{end}} <a href="{{.Clear}}">clear</a></p>{{end}}
1245  <span class="spacer"></span>
1246  <p class="meta"><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/milestones">milestones</a> · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/labels">labels</a>{{if .Viewer}} · <a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/issues/new">new issue</a>{{end}}</p>
1247</div>
1248<ul class="issuelist rows">
1249... the existing {{range .Issues}} block, unchanged ...
1250</ul>
1251{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
1252</div>
1253</div>
1254```
1255
1256The `nav.filters` block moves into the column; the state links there are the facet group.
1257
1258`mrs.html`: the same shape. Keep its `{{range .MRs}}` block; drop its `nav.filters`.
1259
1260- [ ] **Step 6: The stylesheet**
1261
1262After the `.filenav` rules add:
1263
1264```css
1265/* ---- side column: facets or sections beside a list or a form ---- */
1266.withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
1267.withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); }
1268.colmain { min-width: 0; }
1269.sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
1270.sidecol .grp { margin-bottom: var(--sp-4); }
1271.sidecol ul { list-style: none; margin: 0; padding: 0; }
1272.sidecol li a {
1273  display: flex; align-items: baseline; gap: var(--sp-2);
1274  padding: 4px var(--sp-2);
1275  border-radius: var(--r-ctl);
1276  color: var(--fg);
1277}
1278.sidecol li a:hover { background: var(--hover); text-decoration: none; }
1279.sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; }
1280.sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
1281.sidecol form.searchform { margin-top: var(--sp-2); }
1282.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
1283```
1284
1285In the `@media (max-width: 62rem)` block add:
1286
1287```css
1288  /* the column follows the content on a phone, the way the aside does */
1289  .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1290  .sidecol { position: static; order: 2; }
1291  .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
1292```
1293
1294- [ ] **Step 7: The e2e assertion**
1295
1296At the end of `TestLabelsWeb` in `e2e/labelweb_test.go`, after the label management assertions, add:
1297
1298```go
1299	// The issue list's column lists the label with its count and a link
1300	// that keeps the state (desktop layout spec).
1301	status, page = browserGet(t, alice, base+"/issues?state=open")
1302	if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) {
1303		t.Fatalf("issues page lacks the side column: %d", status)
1304	}
1305	if !strings.Contains(page, `href="?label=bug&amp;state=open">bug <i>1</i></a>`) {
1306		t.Fatalf("issues column lacks the bug facet:\n%s", page)
1307	}
1308	status, page = browserGet(t, alice, base+"/issues?state=open&label=bug")
1309	if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) {
1310		t.Fatalf("active facet does not clear itself:\n%s", page)
1311	}
1312```
1313
1314If `TestLabelsWeb` removes the `bug` label before its end, place the block before that removal.
1315
1316- [ ] **Step 8: Run the tests**
1317
1318Run: `go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestLabelsWeb|TestMRListRows|TestMRWebLabels' -count=1`
1319Expected: PASS
1320
1321- [ ] **Step 9: Commit**
1322
1323```bash
1324git add internal/httpd/facets.go internal/httpd/facets_test.go internal/httpd/web.go internal/web e2e/labelweb_test.go
1325git commit -m "web: a facet column on the issue and merge request lists
1326
1327State, labels with counts and open milestones beside the rows; a facet
1328keeps the other filters and clears itself when active.
1329
1330Ref #226"
1331```
1332
1333---
1334
1335### Task 7: Facet column on the builds list
1336
1337**Files:**
1338- Modify: `internal/httpd/builds.go:12-60`, `:147-178`
1339- Modify: `internal/httpd/builds_test.go` (add one test), `internal/httpd/buildpages_test.go:22-58`
1340- Modify: `internal/web/templates/builds.html:4-17`
1341- Modify: `internal/httpd/repohead_test.go` (Task 2's struct gains the field)
1342
1343**Interfaces:**
1344- Consumes: `facetGroup`, `facetItem` from Task 6; `filterLinks`, `distinctRefs`, `buildStatuses` in `builds.go`.
1345- Produces: `func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup`.
1346
1347- [ ] **Step 1: Write the failing test**
1348
1349Append to `internal/httpd/builds_test.go`:
1350
1351```go
1352// The builds column groups the same links filterLinks makes: "all" and
1353// the statuses, then the jobs, then the branches seen (desktop layout spec).
1354func TestBuildFacetsGroups(t *testing.T) {
1355	f := buildFilter{Ref: "main", Status: "success"}
1356	groups := buildFacets(f, []control.JobOut{{Name: "lint"}}, []string{"main", "dev"})
1357	if len(groups) != 3 || groups[0].Title != "Status" || groups[1].Title != "Jobs" || groups[2].Title != "Branches" {
1358		t.Fatalf("groups: %+v", groups)
1359	}
1360	if groups[0].Items[0].Label != "all" || groups[0].Items[0].Href != "?ref=main" || groups[0].Items[0].Active {
1361		t.Errorf("all: %+v", groups[0].Items[0])
1362	}
1363	if s := groups[0].Items[3]; s.Label != "success" || !s.Active {
1364		t.Errorf("success: %+v", s)
1365	}
1366	if j := groups[1].Items[0]; j.Label != "lint" || j.Href != "?job=lint&ref=main&status=success" || j.Active {
1367		t.Errorf("lint: %+v", j)
1368	}
1369	if b := groups[2].Items[0]; b.Label != "main" || !b.Active || b.Href != "?status=success" {
1370		t.Errorf("active branch clears itself: %+v", b)
1371	}
1372	if b := groups[2].Items[1]; b.Label != "dev" || b.Active || b.Href != "?ref=dev&status=success" {
1373		t.Errorf("dev: %+v", b)
1374	}
1375}
1376```
1377
1378- [ ] **Step 2: Run it to see it fail**
1379
1380Run: `go test ./internal/httpd/ -run TestBuildFacetsGroups`
1381Expected: FAIL, "undefined: buildFacets"
1382
1383- [ ] **Step 3: The grouping**
1384
1385Append to `builds.go`:
1386
1387```go
1388// buildFacets is the builds page's side column: filterLinks' rows split
1389// into their groups, plus one link per branch seen, which keeps status
1390// and job and clears itself when active.
1391func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
1392	links := filterLinks(f, jobs)
1393	n := 1 + len(buildStatuses)
1394	status := facetGroup{Title: "Status"}
1395	for _, l := range links[:n] {
1396		status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
1397	}
1398	job := facetGroup{Title: "Jobs"}
1399	for _, l := range links[n:] {
1400		job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
1401	}
1402	branch := facetGroup{Title: "Branches"}
1403	base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
1404	for _, ref := range refs {
1405		active := ref == f.Ref
1406		href := facetHref(base, "ref", ref)
1407		if active {
1408			href = facetHref(base, "ref", "")
1409		}
1410		branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
1411	}
1412	return []facetGroup{status, job, branch}
1413}
1414```
1415
1416In `builds()` add `Facets []facetGroup` to the render struct after `FilterLinks`, passing `buildFacets(filter, jobs, distinctRefs(builds, filter.Ref))`. Update the two test structs that render `builds.html` (`buildpages_test.go:30-40`, `repohead_test.go`) to carry `Facets []facetGroup` in the same position, passing `nil`.
1417
1418- [ ] **Step 4: The template**
1419
1420Replace `builds.html` lines 4-17 (the `listhead`) with:
1421
1422```html
1423<div class="withcol">
1424<nav class="sidecol" aria-label="Filters">
1425  {{range .Facets}}{{if .Items}}<div class="grp">
1426  <h2 class="colhead">{{.Title}}</h2>
1427  <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
1428  </div>{{end}}{{end}}
1429  <form method="get" class="searchform compact">
1430    <label for="ref" class="colhead">Branch</label>
1431    <input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs">
1432    <datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist>
1433    <input type="hidden" name="status" value="{{.Filter.Status}}">
1434    <input type="hidden" name="job" value="{{.Filter.Job}}">
1435    <button type="submit" class="btn">Filter</button>
1436  </form>
1437</nav>
1438<div class="colmain">
1439<div class="listhead">
1440  <h1>Builds</h1>
1441```
1442
1443and close `</div>\n</div>` before the content define's `{{end}}`. The rest of the page (status badge button, run count, the `loglist`) stays inside `.colmain`. Change its `<ul class="loglist">` to `<ul class="loglist rows">`: sha, branch and date on one line, the badges right (rule 4 of the spec; the CSS landed in Task 3).
1444
1445- [ ] **Step 5: Run the tests**
1446
1447Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestBuildCancelWeb|TestRunnerSettingsWeb' -count=1`
1448Expected: PASS
1449
1450- [ ] **Step 6: Commit**
1451
1452```bash
1453git add internal/httpd/builds.go internal/httpd/builds_test.go internal/httpd/buildpages_test.go internal/httpd/repohead_test.go internal/web/templates/builds.html
1454git commit -m "web: the builds page filters in a side column
1455
1456Ref #226"
1457```
1458
1459---
1460
1461### Task 8: Facet columns on explore and site search
1462
1463**Files:**
1464- Create: `internal/httpd/topics.go`, `internal/httpd/topics_test.go`
1465- Modify: `internal/httpd/web.go:202-220` (explore)
1466- Modify: `internal/web/templates/explore.html`, `globalsearch.html:4-18`
1467
1468**Interfaces:**
1469- Consumes: `describedRepo{Topics []string}`, `facetGroup`, `facetItem`.
1470- Produces: `func topicFacets(repos []describedRepo, q string) facetGroup`.
1471
1472- [ ] **Step 1: Write the failing test**
1473
1474`internal/httpd/topics_test.go`:
1475
1476```go
1477package httpd
1478
1479import "testing"
1480
1481// Explore's column counts topics across the visible repositories, most
1482// used first then by name, capped at twenty, each linking to ?q=<topic>
1483// and the active one clearing the query (desktop layout spec).
1484func TestTopicFacets(t *testing.T) {
1485	repos := []describedRepo{
1486		{Topics: []string{"cli", "git"}},
1487		{Topics: []string{"git", "swift"}},
1488		{Topics: []string{"git"}},
1489	}
1490	g := topicFacets(repos, "cli")
1491	if g.Title != "Topics" || len(g.Items) != 3 {
1492		t.Fatalf("group: %+v", g)
1493	}
1494	if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" {
1495		t.Errorf("git: %+v", g.Items[0])
1496	}
1497	if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" {
1498		t.Errorf("cli: %+v", g.Items[1])
1499	}
1500	if g.Items[2].Label != "swift" || g.Items[2].Count != 1 {
1501		t.Errorf("swift: %+v", g.Items[2])
1502	}
1503	var many []describedRepo
1504	for i := 0; i < 30; i++ {
1505		many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}})
1506	}
1507	if n := len(topicFacets(many, "").Items); n != 20 {
1508		t.Errorf("cap: %d", n)
1509	}
1510}
1511```
1512
1513- [ ] **Step 2: Run it to see it fail**
1514
1515Run: `go test ./internal/httpd/ -run TestTopicFacets`
1516Expected: FAIL, "undefined: topicFacets"
1517
1518- [ ] **Step 3: The counter**
1519
1520`internal/httpd/topics.go`:
1521
1522```go
1523package httpd
1524
1525import (
1526	"net/url"
1527	"sort"
1528)
1529
1530// topicFacets counts the topics across repos for explore's column. The
1531// links are the ones topic chips already use, ?q=<topic>; the active
1532// topic links to explore with no query.
1533func topicFacets(repos []describedRepo, q string) facetGroup {
1534	counts := map[string]int64{}
1535	for _, r := range repos {
1536		for _, t := range r.Topics {
1537			counts[t]++
1538		}
1539	}
1540	names := make([]string, 0, len(counts))
1541	for t := range counts {
1542		names = append(names, t)
1543	}
1544	sort.Slice(names, func(i, j int) bool {
1545		if counts[names[i]] != counts[names[j]] {
1546			return counts[names[i]] > counts[names[j]]
1547		}
1548		return names[i] < names[j]
1549	})
1550	if len(names) > 20 {
1551		names = names[:20]
1552	}
1553	g := facetGroup{Title: "Topics"}
1554	for _, t := range names {
1555		item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q}
1556		if item.Active {
1557			item.Href = "/explore"
1558		}
1559		g.Items = append(g.Items, item)
1560	}
1561	return g
1562}
1563```
1564
1565In `explore` (`web.go`), compute `described := s.describeAll(repos)` once, add `Facets []facetGroup` to the struct after `Query`, and pass `[]facetGroup{topicFacets(described, q)}` and `s.filterRepos(q, described)`. The counts cover every public repository, not the filtered set, so the column stays stable while narrowing.
1566
1567- [ ] **Step 4: The templates**
1568
1569`explore.html` content define:
1570
1571```html
1572<div class="withcol">
1573{{template "sidecol" .Facets}}
1574<div class="colmain">
1575<div class="headrow">
1576<h1>Explore</h1>
1577<form method="get" action="/explore" class="searchform compact">
1578  <input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic">
1579  <button type="submit" class="btn">Search</button>
1580</form>
1581<span class="spacer"></span>
1582</div>
1583<ul class="repolist rows">
1584{{range .Repos}}{{template "reporow" .}}
1585{{else}}<li class="empty">no public repositories yet</li>{{end}}
1586</ul>
1587</div>
1588</div>
1589```
1590
1591`globalsearch.html`: wrap the content in `.withcol`; the column is the kinds, static:
1592
1593```html
1594<div class="withcol">
1595<nav class="sidecol" aria-label="Filters">
1596  <div class="grp"><h2 class="colhead">Kind</h2>
1597  <ul>
1598    <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
1599    <li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=repo">repositories</a></li>
1600    <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
1601    <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
1602  </ul></div>
1603</nav>
1604<div class="colmain">
1605<div class="listhead">
1606  <h1>Search</h1>
1607  {{if and .Query (not .QueryErr)}}<p class="meta">... unchanged ...</p>{{end}}
1608</div>
1609<form method="get" action="/search" class="searchform"> ... unchanged ... </form>
1610... the results list, unchanged ...
1611</div>
1612</div>
1613```
1614
1615- [ ] **Step 5: Run the tests**
1616
1617Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestGlobalSearchAndNotificationsWeb' -count=1`
1618Expected: PASS
1619
1620- [ ] **Step 6: Commit**
1621
1622```bash
1623git add internal/httpd/topics.go internal/httpd/topics_test.go internal/httpd/web.go internal/web/templates/explore.html internal/web/templates/globalsearch.html
1624git commit -m "web: topic and kind columns on explore and search
1625
1626Ref #226"
1627```
1628
1629---
1630
1631### Task 9: Section nav column on repository settings, account settings and admin
1632
1633**Files:**
1634- Modify: `internal/web/templates/settings.html:1-7`, `account.html`, `admin.html`
1635- Modify: `internal/web/static/style.css:694` (`nav.sections`)
1636- Modify: `internal/web/widths_test.go` (extend)
1637
1638**Interfaces:**
1639- Consumes: `.withcol.narrow` and `.sidecol` from Task 6.
1640
1641- [ ] **Step 1: Extend the failing test**
1642
1643Append to `TestListPagesAreWide` in `internal/web/widths_test.go`:
1644
1645```go
1646	// Settings pages carry a section column: every section id has a link
1647	// in the column, and the page is wide with the narrow grid.
1648	for _, name := range []string{"settings.html", "account.html", "admin.html"} {
1649		src, _ := templateFS.ReadFile("templates/" + name)
1650		s := string(src)
1651		if !strings.HasPrefix(s, `{{define "width"}}wide{{end}}`) || !strings.Contains(s, `<div class="withcol narrow">`) {
1652			t.Errorf("%s lacks the narrow column layout", name)
1653		}
1654		for _, m := range regexp.MustCompile(`<section id="([a-z]+)"`).FindAllStringSubmatch(s, -1) {
1655			if !strings.Contains(s, `href="#`+m[1]+`"`) {
1656				t.Errorf("%s: section %q has no link in the column", name, m[1])
1657			}
1658		}
1659	}
1660```
1661
1662Add `"regexp"` to the imports.
1663
1664- [ ] **Step 2: Run it to see it fail**
1665
1666Run: `go test ./internal/web/ -run TestListPagesAreWide`
1667Expected: FAIL, "settings.html lacks the narrow column layout"
1668
1669- [ ] **Step 3: Repository settings**
1670
1671`settings.html` line 1 → `{{define "width"}}wide{{end}}`. Replace line 7 (`<nav class="sections" ...>`) with:
1672
1673```html
1674<div class="withcol narrow">
1675<nav class="sidecol" aria-label="Sections">
1676  <div class="grp"><h2 class="colhead">Sections</h2>
1677  <ul>
1678    <li><a href="#identity">Identity</a></li>
1679    <li><a href="#access">Access</a></li>
1680    <li><a href="#gates">Merge gates</a></li>
1681    <li><a href="#branches">Protected branches</a></li>
1682    <li><a href="#tags">Protected tags</a></li>
1683    <li><a href="#deps">Dependencies</a></li>
1684    <li><a href="#runners">Runners</a></li>
1685    <li><a href="#lifecycle">Lifecycle</a></li>
1686  </ul></div>
1687</nav>
1688<div class="colmain">
1689```
1690
1691and add `</div>\n</div>` before the content define's final `{{end}}`. Delete the `nav.sections` rule from `style.css` (line 694): nothing uses it.
1692
1693- [ ] **Step 4: Account settings**
1694
1695`account.html` line 1 → `{{define "width"}}wide{{end}}`. After the notice lines (line 6) insert the same `withcol narrow` opener with links `#profile` Profile, `#keys` SSH keys, `#emails` Email addresses, `#pgp` OpenPGP keys, `#notifications` Notifications, `#appearance` Appearance, `#export` Export, `#ssh` On SSH only. Wrap each block from its `<h2>` to the line before the next `<h2>` in `<section id="...">` … `</section>` with those ids in order. Close `</div>\n</div>` before the final `{{end}}`.
1696
1697- [ ] **Step 5: Admin**
1698
1699`admin.html` line 1 → `{{define "width"}}wide{{end}}`. After the `<p class="meta">Server build` line insert the opener with links `#webhooks` Webhook deliveries, `#mail` Mail, `#mirrors` Mirrors, `#builds` Builds, `#deps` Dependency checks. Inside each `{{with .Queues.X}}` block wrap the content in `<section id="...">` … `</section>`. Close `</div>\n</div>` before the final `{{end}}`.
1700
1701- [ ] **Step 6: Run the tests**
1702
1703Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestRepoSettingsWeb|TestAccountSettingsWeb|TestWebTheme' -count=1`
1704Expected: PASS
1705
1706- [ ] **Step 7: Commit**
1707
1708```bash
1709git add internal/web
1710git commit -m "web: section columns on settings, account and admin
1711
1712Ref #226"
1713```
1714
1715---
1716
1717### Task 10: The pages without a column
1718
1719**Files:**
1720- Modify: `internal/web/static/style.css:997` (`.withaside`), `:832-836` (`.readme`), `:1441` (`.wikinav`)
1721- Modify: `docs/specs/2026-09-19-desktop-layout-design.md` (rule 7)
1722
1723- [ ] **Step 1: The rules**
1724
1725`.withaside` grid: `minmax(0, 1fr) 18rem` → `minmax(0, 1fr) 20rem`.
1726
1727After `.readme .cardbody { max-width: 78ch; }` add:
1728
1729```css
1730/* the overview README caps as a card, so prose and code share an edge */
1731.overview .readme { max-width: 88ch; }
1732```
1733
1734`.wikinav { flex: none; width: 14rem; }` → `width: 15rem;`.
1735
1736- [ ] **Step 2: Align the spec with what shipped**
1737
1738In the spec, rule 7 reads "a facet or section column folds into a `details` element above the content". Without JavaScript a `details` cannot open on desktop and close on a phone from one markup, so the column stacks after the content instead, the way the issue aside does since #232. Replace that rule with:
1739
1740```
17417. **Below 64rem** a facet or section column stacks after the content,
1742   the way the issue aside does; the file navigator disappears (the
1743   tree page exists); the dashboard's pinned column returns to the chip
1744   row. Nothing the phone layout fixed moves.
1745```
1746
1747In the spec's page table, the issue list row reads "State; labels with counts; milestones with open counts; assignees (issues)". No store read counts issues per assignee, and adding one is a feature the CLI does not have, so drop "assignees (issues)" from that row and from the "Source of its contents" cell. The `assignee` parameter still works from an author link; it is not a facet.
1748
1749- [ ] **Step 3: Run the tests and commit**
1750
1751Run: `go test ./internal/web/ ./internal/httpd/`
1752Expected: PASS
1753
1754```bash
1755git add internal/web/static/style.css docs/specs/2026-09-19-desktop-layout-design.md
1756git commit -m "web: wider aside, capped README card, 15rem wiki nav
1757
1758Ref #226"
1759```
1760
1761---
1762
1763### Task 11: Captures, axe scan, CHANGELOG
1764
1765**Files:**
1766- Modify: `CHANGELOG.org:7-9`
1767- Create (gitignored, not committed): `.claude/screenshots/desktop/`
1768
1769- [ ] **Step 1: Start a local instance with this branch**
1770
1771Run from the repository root:
1772
1773```bash
1774sh .claude/screenshots/local.sh
1775```
1776
1777It prints `base http://127.0.0.1:8090` and a login URL. Keep the URL.
1778
1779- [ ] **Step 2: Capture at 1920 and 1280, dark and light**
1780
1781```bash
1782cd .claude/screenshots
1783python3 shoot.py desktop/dark-1920 dark 1920 local.txt "<login url>"
1784python3 shoot.py desktop/light-1920 light 1920 local.txt "<login url>"
1785python3 shoot.py desktop/dark-1280 dark 1280 local.txt "<login url>"
1786python3 shoot.py desktop/mobile dark 375 local-mobile.txt "<login url>"
1787```
1788
1789Add these lines to `local.txt` first if absent:
1790
1791```
1792dashboard http://127.0.0.1:8090/
1793blob http://127.0.0.1:8090/krz/gitbay/blob/main/Makefile
1794builds http://127.0.0.1:8090/krz/gitbay/builds
1795settings http://127.0.0.1:8090/settings
1796repo-settings http://127.0.0.1:8090/krz/gitbay/settings
1797```
1798
1799Open each PNG and check: no horizontal scroll at 375, the column after the content at 375, the container centered at 1920, one-line rows at 1280 and 1920, the navigator marking `Makefile`.
1800
1801- [ ] **Step 3: Axe scan**
1802
1803```bash
1804python3 audit/audit.py desktop/axe dark 1280 local.txt
1805python3 audit/audit.py desktop/axe-mobile dark 375 local-mobile.txt
1806python3 audit/summ.py desktop/axe desktop/axe-mobile
1807```
1808
1809Expected: zero violations on every page except the 404 numeral already recorded. A `link-name` or `landmark` finding on the new columns is a defect in the markup, not a scan quirk: fix it and rescan.
1810
1811- [ ] **Step 4: CHANGELOG**
1812
1813Under `* v1.30.0 — unreleased` in `CHANGELOG.org`, before the existing bullet list, add a paragraph and bullets:
1814
1815```
1816The desktop layout (#226): the web UI uses a wide screen.
1817
1818- One centered container at 100rem; the repository header, main and
1819  footer align on it. Text keeps its measure.
1820- The repository header is two rows: name, description and buttons,
1821  then the tabs.
1822- Issue, merge request, build, explore, search and notification rows
1823  are one line above 64rem, and those pages render at the container
1824  width.
1825- The dashboard is three columns: pinned repositories with open issue,
1826  merge request and last-build counts; a tile per queue with the queue
1827  rows below; the activity feed.
1828- A file navigator beside blob, blame and edit pages lists the file's
1829  directory and marks the file.
1830- Side columns: state, labels and open milestones on the issue and
1831  merge request lists; status, jobs and branches on builds; topics on
1832  explore; kinds on search; sections on repository settings, account
1833  settings and admin.
1834- Below 64rem every column stacks after its content; the navigator
1835  hides, since the tree page is the navigator on a phone.
1836```
1837
1838- [ ] **Step 5: Commit**
1839
1840```bash
1841git add CHANGELOG.org
1842git commit -m "CHANGELOG: the desktop layout
1843
1844Ref #226"
1845```
1846
1847---
1848
1849### Task 12: Push, CI, merge request
1850
1851- [ ] **Step 1: Rebase onto main and push**
1852
1853```bash
1854git fetch origin && git rebase origin/main && git push -u origin desktop-layout-spec
1855```
1856
1857- [ ] **Step 2: Open the merge request**
1858
1859```bash
1860gitbay mr create --source desktop-layout-spec --target main --title "web: the desktop layout" --file - <<'EOF'
1861Ref #226. Spec docs/specs/2026-09-19-desktop-layout-design.md, plan
1862docs/plans/2026-09-19-desktop-layout.md.
1863
1864One centered container, a two-row repository header, one-line list rows,
1865a three-column dashboard with count tiles and pinned counts, a file
1866navigator on blob/blame/edit, facet columns on the issue, MR, builds,
1867explore and search lists, section columns on the settings pages.
1868EOF
1869```
1870
1871- [ ] **Step 3: Wait for CI**
1872
1873Poll once every few minutes, one ssh call per tick:
1874
1875```bash
1876gitbay build list --json | head -c 2000
1877```
1878
1879Expected: `test` and `build` succeed on the branch head. A failure: `gitbay build log <n>`, fix on the branch, push, wait again.
1880
1881- [ ] **Step 4: Merge and clean up**
1882
1883```bash
1884gitbay mr merge <n> --strategy ff
1885git checkout main && git pull && git branch -d desktop-layout-spec && git push origin --delete desktop-layout-spec
1886```
1887
1888If the merge reports the branch is behind, rebase, push, merge again.
1889
1890- [ ] **Step 5: Deploy**
1891
1892```bash
1893make deploy
1894```
1895
1896Then recapture the live site at 1920 with `shoot.py` against `public.txt` and compare with the local captures from Task 11.