# Desktop Layout Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make 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. **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. **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. **Spec:** `docs/specs/2026-09-19-desktop-layout-design.md` ## Global Constraints - 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). - 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. - `--container: 100rem`. Every left column is `15rem`, sticky. Text stays at 48rem / 78ch. Columns stack below `64rem`. - 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. - `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. - 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. - Do not mention Claude, LLMs or assistants anywhere: commits, comments, CHANGELOG, wiki. --- ### Task 1: One centered container **Files:** - Modify: `internal/web/static/style.css:366-388` (shell section), `:389-395` (repohead), `:1481-1489` (52rem breakpoint) - Modify: `internal/web/templates/layout.html:39-69` (repohead) - Test: `internal/web/layout_test.go` (create) **Interfaces:** - Produces: the `.repohead .wrap` element every later header change lives in; `--container` token used by Task 4 and Task 9. - [ ] **Step 1: Write the failing test** ```go package web import ( "strings" "testing" ) // The repository header, main and footer share one centered container: // the header's inner content is wrapped, and the stylesheet caps and // centers all three on the same token (desktop layout spec). func TestSharedCenteredContainer(t *testing.T) { layout, err := templateFS.ReadFile("templates/layout.html") if err != nil { t.Fatal(err) } if !strings.Contains(string(layout), `
\n
`) && !strings.Contains(string(layout), "
\n
") { t.Fatalf("repohead is not wrapped in .wrap") } css := string(StyleCSS) for _, want := range []string{ "--container: 100rem;", "main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; }", ".repohead .wrap { max-width: var(--container); margin: 0 auto; }", "main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; }", "main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; }", } { if !strings.Contains(css, want) { t.Errorf("style.css lacks %q", want) } } } ``` - [ ] **Step 2: Run it to see it fail** Run: `go test ./internal/web/ -run TestSharedCenteredContainer` Expected: FAIL, "repohead is not wrapped in .wrap" - [ ] **Step 3: Add the token and the container rules** In `style.css`, inside `:root {` after the `--rail-mark-box: 32px;` line (around line 111), add: ```css /* the one page container: header content, main and footer align on it */ --container: 100rem; ``` Replace lines 371-377 (the `main.content` rule and the three width rules) with: ```css main.content { flex: 1; width: 100%; padding: var(--sp-5) var(--sp-6) var(--sp-7); } main.content, footer { max-width: calc(var(--container) + 2 * var(--sp-6)); margin: 0 auto; } main.wide { max-width: calc(var(--container) + 2 * var(--sp-6)); } main.reading { max-width: calc(72rem + 2 * var(--sp-6)); margin: 0 auto; } main.bounded { max-width: calc(48rem + 2 * var(--sp-6)); margin: 0 auto; } ``` Replace the `.repohead` rule (line 390-393) with: ```css .repohead { padding: var(--sp-4) 0 0; border-bottom: 1px solid var(--line); } .repohead .wrap { max-width: var(--container); margin: 0 auto; padding: 0 var(--sp-6); } ``` In the `@media (max-width: 52rem)` block, change `.repohead { padding: var(--sp-3) var(--sp-4) 0; }` to: ```css .repohead { padding: var(--sp-3) 0 0; } .repohead .wrap { padding: 0 var(--sp-4); } ``` - [ ] **Step 4: Wrap the header content** In `layout.html`, line 39 `
` becomes: ```html
``` and line 69 `
` becomes: ```html
``` - [ ] **Step 5: Run the tests** Run: `go test ./internal/web/ ./internal/httpd/` Expected: PASS (the classes test sees `.wrap` in the stylesheet) - [ ] **Step 6: Commit** ```bash git add internal/web/static/style.css internal/web/templates/layout.html internal/web/layout_test.go git commit -m "web: one centered container for header, main and footer Ref #226" ``` --- ### Task 2: Repository header on two rows **Files:** - Modify: `internal/web/templates/layout.html:41-58` - Modify: `internal/web/static/style.css:396-425` (identity, repodesc, repometa, toggles rules) - Test: `internal/httpd/repohead_test.go` (create) **Interfaces:** - Consumes: `testRepoPage()` from `internal/httpd/buildpages_test.go:12`. - [ ] **Step 1: Write the failing test** ```go package httpd import ( "strings" "testing" "gitbay.org/gitbay/internal/control" "gitbay.org/gitbay/internal/web" ) // The repository header is two rows: identity with the description and // the buttons, then the tabs. The toggles hint is title text on the // buttons, not a line of its own (desktop layout spec). func TestRepoHeaderTwoRows(t *testing.T) { var sb strings.Builder p := testRepoPage() p.Viewer = "alice" p.Desc = "A CLI-first git forge." p.Topics = []string{"cli"} p.Tab = "files" err := web.Render(&sb, "builds.html", struct { repoPage Builds []control.BuildOut Jobs []control.JobOut Runs []buildRun Filter buildFilter FilterLinks []buildFilterLink Refs []string CanWrite bool Notice string }{p, nil, nil, nil, buildFilter{}, nil, nil, true, ""}) if err != nil { t.Fatal(err) } out := sb.String() if strings.Contains(out, `class="toggles"`) || strings.Contains(out, "Pinned shows on your dashboard.") { t.Error("the toggles hint still renders as a line") } for _, want := range []string{ `title="Pinned repositories show on your dashboard"`, `title="Watching sends every issue, request and build to your inbox"`, `title="Bookmarked lists it under Bookmarks"`, `

A CLI-first git forge.`, } { if !strings.Contains(out, want) { t.Errorf("header lacks %q", want) } } // the description sits inside the identity row, before the buttons if strings.Index(out, `class="repodesc"`) > strings.Index(out, `action="/krz/gitbay/pin"`) { t.Error("description renders after the buttons; it belongs in the identity row") } } ``` - [ ] **Step 2: Run it to see it fail** Run: `go test ./internal/httpd/ -run TestRepoHeaderTwoRows` Expected: FAIL on the `title=` strings - [ ] **Step 3: Rewrite the identity row** Replace `layout.html` lines 41-58 (from `

` through the `{{end}}` that closes `{{if eq $top "code"}}`) with: ```html {{$top := topTab (str $ "Tab")}}
{{if field $ "RepoHome"}}

{{.OwnerName}}/{{.Name}}

{{else}}

{{.OwnerName}}/{{.Name}}

{{end}} {{if eq .Visibility "private"}}Private{{end}} {{if .Settings.Archived}}Archived{{end}} {{/* Description, topics and website belong to the code tab, inline with the name so the header is two rows on every page. */}} {{if eq $top "code"}}{{if or (field $ "Desc") (field $ "Topics") $.Repo.Settings.Website}}

{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}{{.}} {{end}}{{end}}{{with $.Repo.Settings.Website}}{{.}}{{end}}

{{end}}{{end}} {{if $.Viewer}}
{{end}}
{{if eq $top "code"}}{{if field $ "Mirrors"}}

{{range $i, $m := field $ "Mirrors"}}{{if $i}} · {{end}}{{if eq $m.Direction "push"}}mirrors to{{else}}mirrors from{{end}} {{$m.Target}}{{if $m.Error}}, sync error: {{$m.Error}}{{else if $m.Synced}}, synced {{$m.Synced}}{{end}}{{end}}

{{end}}{{end}} ``` Delete the old `{{$top := ...}}` line that followed the identity block (it now sits above it) and the `

` line. - [ ] **Step 4: Style the inline description and remove the toggles rule** In `style.css` replace the `.repodesc` rule (lines 412-417) and delete the `.toggles` rule (line 419): ```css .repodesc { margin: 0 0 0 var(--sp-2); color: var(--muted); font-size: var(--fs-2); display: inline-flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; min-width: 0; } .repodesc a.site { color: var(--muted); } .repodesc a.site:hover { color: var(--link); } ``` Add under the `@media (max-width: 52rem)` block: ```css /* a phone shows the description under the name, not beside it */ .repodesc { flex-basis: 100%; margin-left: 0; } ``` - [ ] **Step 5: Run the tests** Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestTreeSearchCodeAndClone' -count=1` Expected: PASS - [ ] **Step 6: Commit** ```bash git add internal/web/templates/layout.html internal/web/static/style.css internal/httpd/repohead_test.go git commit -m "web: the repository header is two rows Description, topics and website sit beside the name; the toggles hint is title text on the buttons. Ref #226" ``` --- ### Task 3: One-line list rows and wide list pages **Files:** - 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` - Modify: `internal/web/static/style.css:892-907` (issuelist), `:867-877` (repolist), `:104-109` of `layout.html` (reporow) - Test: `internal/web/widths_test.go` (create) **Interfaces:** - Produces: `ul.issuelist.rows` and `ul.repolist.rows`, the row format every list task after this reuses. - [ ] **Step 1: Write the failing test** ```go package web import ( "strings" "testing" ) // List pages and the dashboard render at the container width; text pages // keep the reading cap (desktop layout spec). func TestListPagesAreWide(t *testing.T) { wide := []string{"dashboard.html", "issues.html", "mrs.html", "explore.html", "notifications.html", "globalsearch.html", "builds.html"} for _, name := range wide { src, err := templateFS.ReadFile("templates/" + name) if err != nil { t.Fatal(err) } if !strings.HasPrefix(string(src), `{{define "width"}}wide{{end}}`) { t.Errorf("%s does not declare width wide", name) } } for _, name := range []string{"issue.html", "wiki.html", "owner.html"} { src, _ := templateFS.ReadFile("templates/" + name) if strings.Contains(string(src), `{{define "width"}}wide{{end}}`) { t.Errorf("%s is a text page and must not be wide", name) } } for _, name := range []string{"issues.html", "mrs.html", "notifications.html", "globalsearch.html", "dashboard.html"} { src, _ := templateFS.ReadFile("templates/" + name) if !strings.Contains(string(src), `

    `) { t.Errorf("%s does not use one-line rows", name) } } if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `
      `) { t.Error("explore.html does not use one-line rows") } } ``` - [ ] **Step 2: Run it to see it fail** Run: `go test ./internal/web/ -run TestListPagesAreWide` Expected: FAIL for every listed template - [ ] **Step 3: Add the row rules** After the `ul.issuelist .title a:hover` rule (line 907) add: ```css /* one-line rows: title, labels, then the meta pushed right. Above 64rem a list is a table, not prose (desktop layout spec). */ ul.issuelist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } ul.issuelist.rows .issuemain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } ul.issuelist.rows .title { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } ul.issuelist.rows .title .chip { margin-left: var(--sp-1); } ul.issuelist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } ul.issuelist.rows .meta .repo { color: var(--fg); } ``` After the `ul.repolist .topics, ul.repolist .meta` rule (line 877) add: ```css ul.repolist.rows li { display: flex; align-items: baseline; gap: var(--sp-3); padding: var(--sp-2) var(--sp-4); } ul.repolist.rows .reponame { flex: none; font-size: var(--fs-2); } ul.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; } ul.repolist.rows .topics, ul.repolist.rows .meta { flex: none; margin: 0; white-space: nowrap; } ul.repolist.rows .meta { font-size: var(--fs-1); } ``` After the `ul.loglist .commitside` rule (line 865) add, for the builds page (Task 7 applies it): ```css ul.loglist.rows li { align-items: center; padding: var(--sp-2) var(--sp-4); } ul.loglist.rows .commitmain { display: flex; align-items: baseline; gap: var(--sp-3); min-width: 0; } ul.loglist.rows .meta { flex: none; color: var(--muted); font-size: var(--fs-1); white-space: nowrap; } ``` In the `@media (max-width: 62rem)` block add: ```css /* rows go back to two lines where one does not fit */ ul.issuelist.rows .issuemain, ul.repolist.rows li, ul.loglist.rows .commitmain { display: block; } ul.issuelist.rows .title, ul.repolist.rows .desc { white-space: normal; } ``` - [ ] **Step 4: Change the templates** `issues.html`: line 1 becomes two lines: ``` {{define "width"}}wide{{end}} {{define "title"}}issues · {{.Repo.OwnerName}}/{{.Repo.Name}}{{end}} ``` and `
        ` becomes `
          `. The `
        • ` body is unchanged: the CSS makes it one line. `mrs.html`: same two changes (`merge requests ·` title). `notifications.html`: add `{{define "width"}}wide{{end}}` as line 1; `
            ` → `
              `. `globalsearch.html`: `
                ` → `
                  ` (already wide). `dashboard.html`: add `{{define "width"}}wide{{end}}` as line 1; in the `itemlist` define, `
                    ` → `
                      `, and the meta line becomes: ```html

                      {{.RepoPath}}{{if eq $.Kind "mrs"}}!{{else}}#{{end}}{{.Number}} · {{.Author}} · {{when .UpdatedAt}}{{if eq .State "source_gone"}} · source gone{{end}}

                      ``` `explore.html`: add `{{define "width"}}wide{{end}}` as line 1; `
                        ` → `
                          `. - [ ] **Step 5: Run the tests** Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestMRListRows|TestIssueWebTriage|TestDashboard$' -count=1` Expected: PASS - [ ] **Step 6: Commit** ```bash git add internal/web git commit -m "web: one-line rows on the list pages, at the container width Ref #226" ``` --- ### Task 4: Dashboard as three columns with count tiles and pinned counts **Files:** - Modify: `internal/httpd/web.go:185-200` (dashboard handler) - Create: `internal/httpd/dashpins.go`, `internal/httpd/dashpins_test.go` - Modify: `internal/web/templates/dashboard.html` - Modify: `internal/web/static/style.css` (after the `.pinned` rules, line 994; after `.feed` rules, line 1030) - Modify: `e2e/dashboard_test.go:95-119`, `:354-357` **Interfaces:** - 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)`. - Produces: `pinnedRow` and `func (s *Server) pinnedRows(viewer store.User) []pinnedRow`. - [ ] **Step 1: Write the failing test** `internal/httpd/dashpins_test.go`: ```go package httpd import ( "strings" "testing" "gitbay.org/gitbay/internal/store" "gitbay.org/gitbay/internal/web" ) // The dashboard is three columns: pinned repositories with counts, the // tile strip and queue rows, the activity feed. Tiles carry every queue's // count; only a non-empty queue lists rows (desktop layout spec). func TestDashboardTilesAndPins(t *testing.T) { var sb strings.Builder var base basePage base.Viewer = "alice" err := web.Render(&sb, "dashboard.html", struct { basePage Tab string Pins []pinnedRow Reviews []store.DashboardItem Assigned []store.DashboardItem MRs []store.DashboardItem Issues []store.DashboardItem Feed []feedLine }{base, "dashboard", []pinnedRow{{Owner: "krz", Name: "gitbay", Issues: 3, MRs: 0, Build: "success"}}, nil, nil, nil, []store.DashboardItem{{RepoPath: "krz/gitbay", Number: 1, Title: "one", Author: "alice", State: "open"}}, nil}) if err != nil { t.Fatal(err) } out := sb.String() for _, want := range []string{ `
                          `, `
                          \n
` before the final `{{end}}`. - [ ] **Step 6: Run the tests** Run: `go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestRepoSettingsWeb|TestAccountSettingsWeb|TestWebTheme' -count=1` Expected: PASS - [ ] **Step 7: Commit** ```bash git add internal/web git commit -m "web: section columns on settings, account and admin Ref #226" ``` --- ### Task 10: The pages without a column **Files:** - Modify: `internal/web/static/style.css:997` (`.withaside`), `:832-836` (`.readme`), `:1441` (`.wikinav`) - Modify: `docs/specs/2026-09-19-desktop-layout-design.md` (rule 7) - [ ] **Step 1: The rules** `.withaside` grid: `minmax(0, 1fr) 18rem` → `minmax(0, 1fr) 20rem`. After `.readme .cardbody { max-width: 78ch; }` add: ```css /* the overview README caps as a card, so prose and code share an edge */ .overview .readme { max-width: 88ch; } ``` `.wikinav { flex: none; width: 14rem; }` → `width: 15rem;`. - [ ] **Step 2: Align the spec with what shipped** In 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: ``` 7. **Below 64rem** a facet or section column stacks after the content, the way the issue aside does; the file navigator disappears (the tree page exists); the dashboard's pinned column returns to the chip row. Nothing the phone layout fixed moves. ``` In 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. - [ ] **Step 3: Run the tests and commit** Run: `go test ./internal/web/ ./internal/httpd/` Expected: PASS ```bash git add internal/web/static/style.css docs/specs/2026-09-19-desktop-layout-design.md git commit -m "web: wider aside, capped README card, 15rem wiki nav Ref #226" ``` --- ### Task 11: Captures, axe scan, CHANGELOG **Files:** - Modify: `CHANGELOG.org:7-9` - Create (gitignored, not committed): `.claude/screenshots/desktop/` - [ ] **Step 1: Start a local instance with this branch** Run from the repository root: ```bash sh .claude/screenshots/local.sh ``` It prints `base http://127.0.0.1:8090` and a login URL. Keep the URL. - [ ] **Step 2: Capture at 1920 and 1280, dark and light** ```bash cd .claude/screenshots python3 shoot.py desktop/dark-1920 dark 1920 local.txt "" python3 shoot.py desktop/light-1920 light 1920 local.txt "" python3 shoot.py desktop/dark-1280 dark 1280 local.txt "" python3 shoot.py desktop/mobile dark 375 local-mobile.txt "" ``` Add these lines to `local.txt` first if absent: ``` dashboard http://127.0.0.1:8090/ blob http://127.0.0.1:8090/krz/gitbay/blob/main/Makefile builds http://127.0.0.1:8090/krz/gitbay/builds settings http://127.0.0.1:8090/settings repo-settings http://127.0.0.1:8090/krz/gitbay/settings ``` Open 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`. - [ ] **Step 3: Axe scan** ```bash python3 audit/audit.py desktop/axe dark 1280 local.txt python3 audit/audit.py desktop/axe-mobile dark 375 local-mobile.txt python3 audit/summ.py desktop/axe desktop/axe-mobile ``` Expected: 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. - [ ] **Step 4: CHANGELOG** Under `* v1.30.0 — unreleased` in `CHANGELOG.org`, before the existing bullet list, add a paragraph and bullets: ``` The desktop layout (#226): the web UI uses a wide screen. - One centered container at 100rem; the repository header, main and footer align on it. Text keeps its measure. - The repository header is two rows: name, description and buttons, then the tabs. - Issue, merge request, build, explore, search and notification rows are one line above 64rem, and those pages render at the container width. - The dashboard is three columns: pinned repositories with open issue, merge request and last-build counts; a tile per queue with the queue rows below; the activity feed. - A file navigator beside blob, blame and edit pages lists the file's directory and marks the file. - Side columns: state, labels and open milestones on the issue and merge request lists; status, jobs and branches on builds; topics on explore; kinds on search; sections on repository settings, account settings and admin. - Below 64rem every column stacks after its content; the navigator hides, since the tree page is the navigator on a phone. ``` - [ ] **Step 5: Commit** ```bash git add CHANGELOG.org git commit -m "CHANGELOG: the desktop layout Ref #226" ``` --- ### Task 12: Push, CI, merge request - [ ] **Step 1: Rebase onto main and push** ```bash git fetch origin && git rebase origin/main && git push -u origin desktop-layout-spec ``` - [ ] **Step 2: Open the merge request** ```bash gitbay mr create --source desktop-layout-spec --target main --title "web: the desktop layout" --file - <<'EOF' Ref #226. Spec docs/specs/2026-09-19-desktop-layout-design.md, plan docs/plans/2026-09-19-desktop-layout.md. One centered container, a two-row repository header, one-line list rows, a three-column dashboard with count tiles and pinned counts, a file navigator on blob/blame/edit, facet columns on the issue, MR, builds, explore and search lists, section columns on the settings pages. EOF ``` - [ ] **Step 3: Wait for CI** Poll once every few minutes, one ssh call per tick: ```bash gitbay build list --json | head -c 2000 ``` Expected: `test` and `build` succeed on the branch head. A failure: `gitbay build log `, fix on the branch, push, wait again. - [ ] **Step 4: Merge and clean up** ```bash gitbay mr merge --strategy ff git checkout main && git pull && git branch -d desktop-layout-spec && git push origin --delete desktop-layout-spec ``` If the merge reports the branch is behind, rebase, push, merge again. - [ ] **Step 5: Deploy** ```bash make deploy ``` Then recapture the live site at 1920 with `shoot.py` against `public.txt` and compare with the local captures from Task 11.