docs/plans/2026-09-19-desktop-layout.md
1896 lines · 72254 bytes
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-specalready holds the spec commit; all work lands on it. Never push tomain; 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. ReferenceRef #226in bodies. --container: 100rem. Every left column is15rem, sticky. Text stays at 48rem / 78ch. Columns stack below64rem.- 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 nostyle.cssselector 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 .wrapelement every later header change lives in;--containertoken used by Task 4 and Task 9. -
Step 1: Write the failing test
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), `<header class="repohead">\n<div class="wrap">`) &&
!strings.Contains(string(layout), "<header class=\"repohead\">\n<div class=\"wrap\">") {
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:
/* 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:
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:
.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:
.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 <header class="repohead"> becomes:
<header class="repohead">
<div class="wrap">
and line 69 </header> becomes:
</div>
</header>
- 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
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()frominternal/httpd/buildpages_test.go:12. -
Step 1: Write the failing test
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"`,
`<p class="repodesc">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 <div class="identity"> through the {{end}} that closes {{if eq $top "code"}}) with:
{{$top := topTab (str $ "Tab")}}
<div class="identity">
{{if field $ "RepoHome"}}<h1 class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span>{{.Name}}</h1>
{{else}}<p class="repotitle"><a class="owner" href="/{{.OwnerName}}">{{.OwnerName}}</a><span class="sep">/</span><a href="/{{.OwnerName}}/{{.Name}}">{{.Name}}</a></p>{{end}}
{{if eq .Visibility "private"}}<span class="chip">Private</span>{{end}}
{{if .Settings.Archived}}<span class="chip">Archived</span>{{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}}<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}}
<span class="grow"></span>
{{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>
<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>
<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>
<form method="post" action="/{{.OwnerName}}/{{.Name}}/fork" class="inline"><button type="submit" class="btn">Fork</button></form>{{end}}
</div>
{{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}}
Delete the old {{$top := ...}} line that followed the identity block (it now sits above it) and the <p class="toggles"> 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):
.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:
/* 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
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-109oflayout.html(reporow) - Test:
internal/web/widths_test.go(create)
Interfaces:
-
Produces:
ul.issuelist.rowsandul.repolist.rows, the row format every list task after this reuses. -
Step 1: Write the failing test
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), `<ul class="issuelist rows">`) {
t.Errorf("%s does not use one-line rows", name)
}
}
if src, _ := templateFS.ReadFile("templates/explore.html"); !strings.Contains(string(src), `<ul class="repolist rows">`) {
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:
/* 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:
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):
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:
/* 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 <ul class="issuelist"> becomes <ul class="issuelist rows">. The <li> 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; <ul class="issuelist"> → <ul class="issuelist rows">.
globalsearch.html: <ul class="issuelist"> → <ul class="issuelist rows"> (already wide).
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:
<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>
explore.html: add {{define "width"}}wide{{end}} as line 1; <ul class="repolist"> → <ul class="repolist rows">.
- 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
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.pinnedrules, line 994; after.feedrules, 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:
pinnedRowandfunc (s *Server) pinnedRows(viewer store.User) []pinnedRow. -
Step 1: Write the failing test
internal/httpd/dashpins_test.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{
`<div class="dashgrid">`,
`<aside class="dashpins" aria-label="Pinned repositories">`,
`<span class="owner">krz/</span>gitbay</a>`,
`<b class="wants">3</b>`, `<span class="dot ok"></span>`,
`<a class="tile wants" href="#issues"><b>1</b><span>open issues</span></a>`,
`<div class="tile"><b>0</b><span>waiting on your review</span></div>`,
`<div class="tile"><b>0</b><span>assigned to you</span></div>`,
`<div class="tile"><b>0</b><span>open merge requests</span></div>`,
`<h2 id="issues">Open issues <span class="count">1</span></h2>`,
`<aside class="feedcol" aria-label="Recent activity">`,
} {
if !strings.Contains(out, want) {
t.Errorf("dashboard lacks %q", want)
}
}
if strings.Contains(out, `<h2 class="empty">`) {
t.Error("an empty queue still renders as a heading; the tile carries it")
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/httpd/ -run TestDashboardTilesAndPins
Expected: FAIL, "undefined: pinnedRow"
- Step 3: The pinned rows read
internal/httpd/dashpins.go:
package httpd
import (
"gitbay.org/gitbay/internal/policy"
"gitbay.org/gitbay/internal/store"
)
// pinnedRow is one pinned repository on the dashboard with the counts
// that say whether it wants attention: open issues, open merge requests
// and the newest build's status ("" when it has none).
type pinnedRow struct {
Owner string
Name string
Issues int
MRs int
Build string
}
// pinnedRows reads the viewer's pinned repositories the way railFor does,
// then adds the counts. Three reads per pinned repository, on the
// dashboard only.
func (s *Server) pinnedRows(viewer store.User) []pinnedRow {
pinned, _ := s.st.PinnedRepos(viewer.ID)
var rows []pinnedRow
for _, rp := range pinned {
grant, _ := s.st.AccessRole(rp.ID, viewer.ID)
if !policy.CanRead(viewer, rp, grant) {
continue
}
row := pinnedRow{Owner: rp.OwnerName, Name: rp.Name}
row.Issues, row.MRs = s.st.OpenCounts(rp.ID)
if builds, err := s.st.ListBuilds(rp.ID, store.BuildFilter{}, 1); err == nil && len(builds) > 0 {
row.Build = builds[0].Status
}
rows = append(rows, row)
}
return rows
}
In web.go dashboard, add Pins []pinnedRow to the struct after Tab and pass s.pinnedRows(viewer):
s.render(w, "dashboard.html", struct {
basePage
Tab string
Pins []pinnedRow
Reviews []store.DashboardItem
Assigned []store.DashboardItem
MRs []store.DashboardItem
Issues []store.DashboardItem
Feed []feedLine
}{s.baseFor(viewer), "dashboard", s.pinnedRows(viewer), reviews, assigned, mrs, issues, feedLines(events)})
- Step 4: The template
Replace dashboard.html from {{define "queue"}} to the end with:
{{define "queue"}}
<h2 id="{{$.ID}}">{{$.Title}} <span class="count">{{len $.Items}}</span></h2>
{{if $.Hint}}<p class="hint">{{$.Hint}}</p>{{end}}
{{template "itemlist" dict "Items" $.Items "Kind" $.Kind "Empty" $.Empty}}
{{end}}
{{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}}
{{define "content"}}
<div class="dashgrid">
<aside class="dashpins" aria-label="Pinned repositories">
<h2 class="colhead">Pinned</h2>
{{if .Pins}}<ul class="pins">
{{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>
{{end}}</ul>
<p class="meta">issues · merge requests · last build</p>
{{else}}<p class="none">Nothing pinned yet. Press Pin on a repository.</p>{{end}}
</aside>
<section class="dashmain">
<h1>Dashboard</h1>
<div class="tiles">
{{template "tile" dict "N" (len .Issues) "ID" "issues" "Label" "open issues"}}
{{template "tile" dict "N" (len .Reviews) "ID" "reviews" "Label" "waiting on your review"}}
{{template "tile" dict "N" (len .Assigned) "ID" "assigned" "Label" "assigned to you"}}
{{template "tile" dict "N" (len .MRs) "ID" "mrs" "Label" "open merge requests"}}
</div>
{{if .Reviews}}{{template "queue" dict "ID" "reviews" "Title" "Waiting on your review" "Items" .Reviews "Kind" "mrs" "Empty" "Nothing waiting on you"}}{{end}}
{{if .Assigned}}{{template "queue" dict "ID" "assigned" "Title" "Assigned to you" "Items" .Assigned "Kind" "issues" "Empty" "Nothing assigned to you"}}{{end}}
{{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}}
{{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}}
{{if not (or .Reviews .Assigned .MRs .Issues)}}<p class="none">Nothing open anywhere you can write to.</p>{{end}}
</section>
<aside class="feedcol" aria-label="Recent activity">
<h2 class="colhead">Recent activity</h2>
{{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>
{{else}}<p class="none">No activity yet</p>{{end}}
</aside>
</div>
{{end}}
The dict helper takes key/value pairs; len .Issues inside dict needs parentheses, as written.
- Step 5: The stylesheet
Replace the .pinned rules (lines 992-994) with:
/* ---- dashboard: pinned column, tiles and queues, feed ---- */
.dashgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr) 20rem; gap: var(--sp-6); align-items: start; }
.dashgrid h1 { margin-top: 0; }
.dashgrid > aside { position: sticky; top: var(--sp-5); }
.dashgrid .dashmain h2 { margin-top: var(--sp-5); }
.dashgrid .dashmain .tiles + h2 { margin-top: 0; }
.colhead {
margin: 0 0 var(--sp-2);
font-size: var(--fs-0);
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--muted);
}
.pins { list-style: none; margin: 0; padding: 0; font-size: var(--fs-2); }
.pins li { display: flex; align-items: center; gap: var(--sp-2); padding: 6px 0; border-bottom: 1px solid var(--faint); }
.pins li:last-child { border-bottom: 0; }
.pins a { color: var(--fg); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pins a:hover { color: var(--link); }
.pins .owner { color: var(--muted); }
.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; }
.pins .n b { font-weight: 600; color: var(--fg); }
.pins .n b.wants { color: var(--warn); }
.pins .dot { margin: 0; }
/* count tiles: the queues' sizes in one strip; a non-zero count is orange,
what wants you */
.tiles { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: var(--sp-3); margin: var(--sp-4) 0 var(--sp-5); }
.tile {
display: block;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--r-card);
padding: var(--sp-3) var(--sp-4);
color: var(--fg);
}
a.tile:hover { background: var(--hover); text-decoration: none; }
.tile b { display: block; font-size: var(--fs-5); font-weight: 600; line-height: 1.2; }
.tile.wants b { color: var(--warn); }
.tile span { font-size: var(--fs-1); color: var(--muted); }
.feedcol .feedline { font-size: var(--fs-2); margin-bottom: var(--sp-2); }
.feedcol .feedline .none { font-size: var(--fs-1); }
Delete the .feed rules (lines 1026-1030): nothing uses .feed any more. Keep .feedline.
Add to the @media (max-width: 62rem) block:
.dashgrid { grid-template-columns: 1fr; }
.dashgrid > aside { position: static; }
/* the pinned column goes back to a chip row on a phone */
.pins { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.pins li { border: 1px solid var(--line); border-radius: var(--r-ctl); padding: var(--sp-1) var(--sp-3); }
.pins .n, .dashpins .meta { display: none; }
.tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
And a new block, above the 62rem one:
@media (max-width: 80rem) {
.dashgrid { grid-template-columns: minmax(0, 1fr) 20rem; }
.dashgrid > .dashpins { grid-column: 1 / -1; position: static; }
}
- Step 6: Update the e2e assertions
In e2e/dashboard_test.go replace lines 110-118 (the emptyHeading block) with:
// The empty queue is a tile with a zero; a populated one is a tile
// that links to its rows, which render in the middle column.
if !strings.Contains(body, `<div class="tile"><b>0</b><span>assigned to you</span></div>`) {
t.Fatalf("dashboard missing the zero tile for assigned:\n%s", body)
}
if !strings.Contains(body, `<a class="tile wants" href="#reviews"><b>1</b><span>waiting on your review</span></a>`) {
t.Fatalf("dashboard missing the review tile:\n%s", body)
}
if !strings.Contains(body, `<h2 id="reviews">Waiting on your review`) || strings.Contains(body, `<h2 class="empty">`) {
t.Fatalf("queues do not render as tiles plus rows:\n%s", body)
}
Replace lines 354-357 with:
if !strings.Contains(after, `<div class="tile"><b>0</b><span>waiting on your review</span></div>`) {
t.Fatalf("reviewed MR still waiting:\n%s", after)
}
In the want list at line 96-100, keep every entry; add `class="pins"`.
- Step 7: Run the tests
Run: go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestDashboard' -count=1
Expected: PASS
- Step 8: Commit
git add internal/httpd/dashpins.go internal/httpd/dashpins_test.go internal/httpd/web.go internal/web e2e/dashboard_test.go
git commit -m "web: the dashboard is three columns
Pinned repositories with counts, a tile per queue, the feed as an aside.
Ref #226"
Task 5: File navigator beside blob, blame and edit
Files:
- Create:
internal/httpd/filenav.go,internal/httpd/filenav_test.go - Modify:
internal/httpd/web.go:512-527(extract the sort),:559-604(blob),:813-896(blame) - Modify:
internal/httpd/accounts.go:474-518(editPage, editForm) - Modify:
internal/web/templates/layout.html(newfilenavpartial),blob.html,blame.html,edit.html - Modify:
internal/web/static/style.css(after the.pathbarrules, line 1287) - Create:
e2e/filenav_test.go
Interfaces:
-
Consumes:
gitutil.ListTree(dir, ref, path string) ([]gitutil.TreeEntry, error),gitutil.TreeEntry{Type, Name},store.Repo.Path() string. -
Produces:
fileNav,func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav,func sortDirsFirst(entries []gitutil.TreeEntry). -
Step 1: Write the failing unit test
internal/httpd/filenav_test.go:
package httpd
import (
"testing"
"gitbay.org/gitbay/internal/gitutil"
)
// The navigator lists the file's directory, directories first, links each
// entry to its tree or blob page, marks the file itself, and links the
// parent (the tree root when the file is at the top).
func TestFileNavMarksCurrentAndLinksParent(t *testing.T) {
entries := []gitutil.TreeEntry{
{Type: "blob", Name: "main.go"},
{Type: "tree", Name: "sub"},
{Type: "blob", Name: "util.go"},
}
nav := fileNavFor("krz/gitbay", "main", "cmd/gitbay/util.go", entries)
if nav.Title != "cmd/gitbay" {
t.Errorf("title = %q", nav.Title)
}
if nav.Parent != "/krz/gitbay/tree/main/cmd" {
t.Errorf("parent = %q", nav.Parent)
}
if len(nav.Entries) != 3 || nav.Entries[0].Name != "sub/" || !nav.Entries[0].Dir {
t.Fatalf("entries not directories-first: %+v", nav.Entries)
}
if nav.Entries[0].URL != "/krz/gitbay/tree/main/cmd/gitbay/sub" {
t.Errorf("dir url = %q", nav.Entries[0].URL)
}
if nav.Entries[2].Name != "util.go" || !nav.Entries[2].Current || nav.Entries[2].URL != "/krz/gitbay/blob/main/cmd/gitbay/util.go" {
t.Errorf("current entry: %+v", nav.Entries[2])
}
if nav.Entries[1].Current {
t.Error("main.go marked current")
}
root := fileNavFor("krz/gitbay", "main", "Makefile", []gitutil.TreeEntry{{Type: "blob", Name: "Makefile"}})
if root.Title != "gitbay" || root.Parent != "" {
t.Errorf("root nav: title %q parent %q", root.Title, root.Parent)
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/httpd/ -run TestFileNavMarksCurrentAndLinksParent
Expected: FAIL, "undefined: fileNavFor"
- Step 3: The navigator
internal/httpd/filenav.go:
package httpd
import (
"path"
"sort"
"gitbay.org/gitbay/internal/gitutil"
)
// fileNav is the column beside a file: its directory's entries, the file
// marked, and a link up. It is the tree page's listing rendered as a
// list, so reading a repository does not mean going back for each file.
type fileNav struct {
Title string // the directory, or the repository name at the root
Parent string // URL of the parent tree; "" at the root
Entries []fileNavEntry
}
type fileNavEntry struct {
Name string // directories carry a trailing slash
URL string
Dir bool
Current bool
}
// sortDirsFirst orders a listing by shape before name, stably, so each
// group keeps the order git gave it. The tree page and the navigator
// share it.
func sortDirsFirst(entries []gitutil.TreeEntry) {
sort.SliceStable(entries, func(i, j int) bool {
return entries[i].Type == "tree" && entries[j].Type != "tree"
})
}
// fileNavFor builds the navigator for filePath from its directory's
// entries. repoPath is owner/name.
func fileNavFor(repoPath, ref, filePath string, entries []gitutil.TreeEntry) fileNav {
dir := path.Dir(filePath)
if dir == "." {
dir = ""
}
base := "/" + repoPath
nav := fileNav{Title: dir}
if dir == "" {
nav.Title = path.Base(repoPath)
} else if up := path.Dir(dir); up == "." {
nav.Parent = base + "/tree/" + ref
} else {
nav.Parent = base + "/tree/" + ref + "/" + up
}
sortDirsFirst(entries)
for _, e := range entries {
full := path.Join(dir, e.Name)
ent := fileNavEntry{Name: e.Name, Dir: e.Type == "tree", Current: full == filePath}
if ent.Dir {
ent.Name += "/"
ent.URL = base + "/tree/" + ref + "/" + full
} else {
ent.URL = base + "/blob/" + ref + "/" + full
}
nav.Entries = append(nav.Entries, ent)
}
return nav
}
In 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).
- Step 4: Attach it to blob, blame and edit
In blob (web.go), after branches, _ := gitutil.Refs(p.Dir, "heads") add:
navEntries, _ := gitutil.ListTree(p.Dir, p.Ref, navDir(filePath))
nav := fileNavFor(p.Repo.Path(), p.Ref, filePath, navEntries)
and add Nav fileNav as the last field of the anonymous struct, passing nav last. Add to filenav.go:
// navDir is the directory ListTree wants for filePath: "" at the root.
func navDir(filePath string) string {
if d := path.Dir(filePath); d != "." {
return d
}
return ""
}
In blame, before the render add the same two lines and add Nav fileNav to its struct, passing nav.
In accounts.go, add Nav fileNav to editPage and in editForm, before s.render, add:
navEntries, _ := gitutil.ListTree(dir, "refs/heads/"+ref, navDir(filePath))
nav := fileNavFor(repo.Path(), ref, filePath, navEntries)
and Nav: nav, in the editPage{...} literal.
- Step 5: The partial and the templates
In layout.html after the refmenu define add:
{{define "filenav"}}<nav class="filenav" aria-label="Files">
<h2 class="colhead">{{.Title}}</h2>
<ul>
{{with .Parent}}<li><a class="up" href="{{.}}">..</a></li>{{end}}
{{range .Entries}}<li><a{{if .Dir}} class="dir"{{end}}{{if .Current}} aria-current="page"{{end}} href="{{.URL}}">{{.Name}}</a></li>
{{end}}</ul>
</nav>{{end}}
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>.
blame.html: same wrapping around everything after the <h1 class="vh">.
edit.html: same wrapping around the .pathbar/form block; the .Nav field is on editPage.
- Step 6: The stylesheet
After the .crumbs strong rule add:
/* ---- file navigator: the directory beside a file ---- */
.blobgrid { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
.blobmain { min-width: 0; }
.filenav { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
.filenav ul { list-style: none; margin: 0; padding: 0; }
.filenav li a {
display: block;
padding: 3px var(--sp-2);
border-radius: var(--r-ctl);
color: var(--fg);
font-family: var(--mono);
font-size: var(--fs-1);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.filenav li a:hover { background: var(--hover); text-decoration: none; }
.filenav li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 600; }
.filenav li a.dir { color: var(--link); }
.filenav li a.up { color: var(--muted); }
In the @media (max-width: 62rem) block add:
/* the tree page is the navigator on a phone */
.blobgrid { grid-template-columns: 1fr; }
.filenav { display: none; }
- Step 7: The e2e test
e2e/filenav_test.go:
package e2e
import (
"os"
"path/filepath"
"strings"
"testing"
)
// A file page lists its directory beside the file, marks the file, and
// links up (desktop layout spec).
func TestFileNavigator(t *testing.T) {
inst := startInstance(t)
key := inst.newKey(t, "alice")
inst.admin(t, "admin", "user", "create", "alice", "--key", key+".pub")
if _, errOut, code := inst.ssh(t, key, "", "repo", "create", "alice/nav"); code != 0 {
t.Fatalf("repo create: %s", errOut)
}
// the same clone-commit-push shape TestWebUI uses (e2e/web_test.go:41-50)
work := t.TempDir()
env := inst.gitEnv(key)
mustGit(t, work, env, "clone", inst.sshURL("alice/nav"), "w")
dir := filepath.Join(work, "w")
os.MkdirAll(filepath.Join(dir, "cmd", "sub"), 0o755)
os.WriteFile(filepath.Join(dir, "README.md"), []byte("# nav\n"), 0o644)
os.WriteFile(filepath.Join(dir, "cmd", "main.go"), []byte("package main\n"), 0o644)
os.WriteFile(filepath.Join(dir, "cmd", "sub", "x.go"), []byte("package sub\n"), 0o644)
mustGit(t, dir, env, "checkout", "-q", "-b", "main")
mustGit(t, dir, env, "add", ".")
mustGit(t, dir, env, "commit", "-q", "-m", "one")
mustGit(t, dir, env, "push", "-q", "origin", "main")
status, body := inst.get(t, "/alice/nav/blob/main/cmd/main.go")
if status != 200 {
t.Fatalf("blob: %d", status)
}
for _, want := range []string{
`<nav class="filenav" aria-label="Files">`,
`<h2 class="colhead">cmd</h2>`,
`<a class="up" href="/alice/nav/tree/main">..</a>`,
`<a class="dir" href="/alice/nav/tree/main/cmd/sub">sub/</a>`,
`<a aria-current="page" href="/alice/nav/blob/main/cmd/main.go">main.go</a>`,
} {
if !strings.Contains(body, want) {
t.Errorf("blob page lacks %q", want)
}
}
_, body = inst.get(t, "/alice/nav/blame/main/README.md")
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>`) {
t.Errorf("blame page lacks the root navigator:\n%s", body)
}
}
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.
- Step 8: Run the tests
Run: go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestFileNavigator|TestWebUI' -count=1
Expected: PASS
- Step 9: Commit
git add internal/httpd/filenav.go internal/httpd/filenav_test.go internal/httpd/web.go internal/httpd/accounts.go internal/web e2e/filenav_test.go
git commit -m "web: a file navigator beside blob, blame and edit
Ref #226"
Task 6: Facet column on the issue and merge request lists
Files:
- Create:
internal/httpd/facets.go,internal/httpd/facets_test.go - Modify:
internal/httpd/web.go:1690-1735(issues),:1814-1866(mrs) - Modify:
internal/web/templates/layout.html(newsidecolpartial),issues.html,mrs.html - Modify:
internal/web/static/style.css(after the.filenavrules from Task 5) - Modify:
e2e/labelweb_test.go(add assertions at the end ofTestLabelsWeb)
Interfaces:
-
Consumes:
control.ReadableScope(st, user, repo) ([]int64, error),s.st.ListLabels(repo, readable) ([]store.Label, error)withLabel{Name, Issues, MRs},s.st.ListMilestones(repo, "open", readable) ([]store.Milestone, error)withMilestone{Title, OpenItems},s.labelColors(repo). -
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 reusefacetGroup. -
Step 1: Write the failing test
internal/httpd/facets_test.go:
package httpd
import (
"net/url"
"testing"
"gitbay.org/gitbay/internal/store"
)
// A facet link keeps every other active filter, sets its own, and clears
// its own when it is already active (desktop layout spec).
func TestFacetHrefKeepsOtherFilters(t *testing.T) {
base := url.Values{"state": {"open"}, "label": {"bug"}, "q": {"crash"}}
if got := facetHref(base, "milestone", "v2"); got != "?label=bug&milestone=v2&q=crash&state=open" {
t.Errorf("set: %q", got)
}
if got := facetHref(base, "label", ""); got != "?q=crash&state=open" {
t.Errorf("clear: %q", got)
}
if got := facetHref(base, "state", "closed"); got != "?label=bug&q=crash&state=closed" {
t.Errorf("replace: %q", got)
}
}
func TestListFacetsGroups(t *testing.T) {
base := url.Values{"state": {"open"}, "label": {"bug"}}
labels := []store.Label{{Name: "bug", Issues: 2, MRs: 1}, {Name: "docs", Issues: 0, MRs: 3}}
ms := []store.Milestone{{Title: "v2", OpenItems: 4}}
groups := listFacets(base, []string{"open", "closed", "all"}, "open", labels, ms, false)
if len(groups) != 3 || groups[0].Title != "State" || groups[1].Title != "Labels" || groups[2].Title != "Milestones" {
t.Fatalf("groups: %+v", groups)
}
st := groups[0].Items
if !st[0].Active || st[0].Href != "?label=bug&state=open" || st[1].Active || st[1].Href != "?label=bug&state=closed" {
t.Errorf("state items: %+v", st)
}
lb := groups[1].Items
if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
t.Errorf("active label clears itself: %+v", lb[0])
}
if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
t.Errorf("inactive label: %+v", lb[1])
}
if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
t.Errorf("milestone: %+v", m)
}
// on the MR list a label's count is its MR count
mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
if mr[1].Items[1].Count != 3 {
t.Errorf("mr count: %+v", mr[1].Items[1])
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/httpd/ -run 'TestFacetHref|TestListFacets'
Expected: FAIL, "undefined: facetHref"
- Step 3: The facets
internal/httpd/facets.go:
package httpd
import (
"net/url"
"gitbay.org/gitbay/internal/store"
)
// facetItem is one link in a list page's side column: a value the list
// narrows to. Clicking an active item clears it.
type facetItem struct {
Label string
Count int64
Href string
Active bool
}
// facetGroup is one heading in the column: State, Labels, Milestones.
type facetGroup struct {
Title string
Items []facetItem
}
// facetHref returns "?..." with every parameter of base kept, key set to
// value, or dropped when value is "". url.Values encodes sorted, so the
// tests and the links agree byte for byte.
func facetHref(base url.Values, key, value string) string {
q := url.Values{}
for k, vs := range base {
if k == key || len(vs) == 0 || vs[0] == "" {
continue
}
q.Set(k, vs[0])
}
if value != "" {
q.Set(key, value)
}
return "?" + q.Encode()
}
// listFacets builds the issue or merge request list's column from the
// active parameters, the states the page offers, and the repository's
// labels and open milestones. Counts are the rows' own: a label's issue
// count on the issue list, its MR count on the MR list.
func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
var st facetGroup
st.Title = "State"
for _, s := range states {
st.Items = append(st.Items, facetItem{Label: s, Href: facetHref(base, "state", s), Active: s == state})
}
lb := facetGroup{Title: "Labels"}
for _, l := range labels {
n := l.Issues
if forMRs {
n = l.MRs
}
active := base.Get("label") == l.Name
href := facetHref(base, "label", l.Name)
if active {
href = facetHref(base, "label", "")
}
lb.Items = append(lb.Items, facetItem{Label: l.Name, Count: n, Href: href, Active: active})
}
mg := facetGroup{Title: "Milestones"}
for _, m := range ms {
active := base.Get("milestone") == m.Title
href := facetHref(base, "milestone", m.Title)
if active {
href = facetHref(base, "milestone", "")
}
mg.Items = append(mg.Items, facetItem{Label: m.Title, Count: int64(m.OpenItems), Href: href, Active: active})
}
return []facetGroup{st, lb, mg}
}
- Step 4: Wire the handlers
In issues (web.go), after the if labels, err := s.st.ListIssueLabels(p.Repo) block, add:
base := url.Values{"state": {state}, "label": {f.Label}, "assignee": {f.Assignee}, "author": {f.Author}, "milestone": {f.Milestone}, "q": {f.Search}}
readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
allLabels, _ := s.st.ListLabels(p.Repo, readable)
openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
facets := listFacets(base, []string{"open", "closed", "all"}, state, allLabels, openMS, false)
add Facets []facetGroup to the render struct after Filters, passing facets. Add "net/url" to the imports if absent.
In mrs, after the labels, err := s.st.ListMRLabels(p.Repo) block, add the same with:
base := url.Values{"state": {state}, "label": {mf.Label}, "author": {mf.Author}, "milestone": {mf.Milestone}, "q": {mf.Search}}
readable, _ := control.ReadableScope(s.st, s.viewer(r), p.Repo)
allLabels, _ := s.st.ListLabels(p.Repo, readable)
openMS, _ := s.st.ListMilestones(p.Repo, "open", readable)
facets := listFacets(base, []string{"open", "merged", "closed", "all"}, state, allLabels, openMS, true)
and Facets []facetGroup in its struct.
- Step 5: The partial and the templates
In layout.html after the filenav define add:
{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
{{range .}}{{if .Items}}<div class="grp">
<h2 class="colhead">{{.Title}}</h2>
<ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
</div>{{end}}{{end}}
</nav>{{end}}
issues.html: replace the content define body with:
<div class="withcol">
{{template "sidecol" .Facets}}
<div class="colmain">
<div class="listhead">
<h1>Issues</h1>
<form method="get" class="searchform compact">
<input type="search" name="q" aria-label="Search issues" value="{{.Query}}" placeholder="search title and body">
<button type="submit" class="btn">Search</button>
<input type="hidden" name="state" value="{{.State}}">
</form>
{{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}}
<span class="spacer"></span>
<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>
</div>
<ul class="issuelist rows">
... the existing {{range .Issues}} block, unchanged ...
</ul>
{{if .Older}}<p class="pager"><a href="{{.Older}}">older →</a></p>{{end}}
</div>
</div>
The nav.filters block moves into the column; the state links there are the facet group.
mrs.html: the same shape. Keep its {{range .MRs}} block; drop its nav.filters.
- Step 6: The stylesheet
After the .filenav rules add:
/* ---- side column: facets or sections beside a list or a form ---- */
.withcol { display: grid; grid-template-columns: 15rem minmax(0, 1fr); gap: var(--sp-6); align-items: start; }
.withcol.narrow { grid-template-columns: 15rem minmax(0, 56rem); }
.colmain { min-width: 0; }
.sidecol { position: sticky; top: var(--sp-4); font-size: var(--fs-2); }
.sidecol .grp { margin-bottom: var(--sp-4); }
.sidecol ul { list-style: none; margin: 0; padding: 0; }
.sidecol li a {
display: flex; align-items: baseline; gap: var(--sp-2);
padding: 4px var(--sp-2);
border-radius: var(--r-ctl);
color: var(--fg);
}
.sidecol li a:hover { background: var(--hover); text-decoration: none; }
.sidecol li a[aria-current] { background: var(--surface); box-shadow: inset 2px 0 0 var(--mark); font-weight: 500; }
.sidecol li a i { margin-left: auto; font-style: normal; color: var(--muted); font-size: var(--fs-1); font-variant-numeric: tabular-nums; }
.sidecol form.searchform { margin-top: var(--sp-2); }
.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
In the @media (max-width: 62rem) block add:
/* the column follows the content on a phone, the way the aside does */
.withcol, .withcol.narrow { grid-template-columns: 1fr; }
.sidecol { position: static; order: 2; }
.sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
- Step 7: The e2e assertion
At the end of TestLabelsWeb in e2e/labelweb_test.go, after the label management assertions, add:
// The issue list's column lists the label with its count and a link
// that keeps the state (desktop layout spec).
status, page = browserGet(t, alice, base+"/issues?state=open")
if status != 200 || !strings.Contains(page, `<nav class="sidecol" aria-label="Filters">`) {
t.Fatalf("issues page lacks the side column: %d", status)
}
if !strings.Contains(page, `href="?label=bug&state=open">bug <i>1</i></a>`) {
t.Fatalf("issues column lacks the bug facet:\n%s", page)
}
status, page = browserGet(t, alice, base+"/issues?state=open&label=bug")
if status != 200 || !strings.Contains(page, `aria-current="page" href="?state=open">bug <i>1</i></a>`) {
t.Fatalf("active facet does not clear itself:\n%s", page)
}
If TestLabelsWeb removes the bug label before its end, place the block before that removal.
- Step 8: Run the tests
Run: go build ./... && go vet ./... && go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestLabelsWeb|TestMRListRows|TestMRWebLabels' -count=1
Expected: PASS
- Step 9: Commit
git add internal/httpd/facets.go internal/httpd/facets_test.go internal/httpd/web.go internal/web e2e/labelweb_test.go
git commit -m "web: a facet column on the issue and merge request lists
State, labels with counts and open milestones beside the rows; a facet
keeps the other filters and clears itself when active.
Ref #226"
Task 7: Facet column on the builds list
Files:
- Modify:
internal/httpd/builds.go:12-60,:147-178 - Modify:
internal/httpd/builds_test.go(add one test),internal/httpd/buildpages_test.go:22-58 - Modify:
internal/web/templates/builds.html:4-17 - Modify:
internal/httpd/repohead_test.go(Task 2's struct gains the field)
Interfaces:
-
Consumes:
facetGroup,facetItemfrom Task 6;filterLinks,distinctRefs,buildStatusesinbuilds.go. -
Produces:
func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup. -
Step 1: Write the failing test
Append to internal/httpd/builds_test.go:
// The builds column groups the same links filterLinks makes: "all" and
// the statuses, then the jobs, then the branches seen (desktop layout spec).
func TestBuildFacetsGroups(t *testing.T) {
f := buildFilter{Ref: "main", Status: "success"}
groups := buildFacets(f, []control.JobOut{{Name: "lint"}}, []string{"main", "dev"})
if len(groups) != 3 || groups[0].Title != "Status" || groups[1].Title != "Jobs" || groups[2].Title != "Branches" {
t.Fatalf("groups: %+v", groups)
}
if groups[0].Items[0].Label != "all" || groups[0].Items[0].Href != "?ref=main" || groups[0].Items[0].Active {
t.Errorf("all: %+v", groups[0].Items[0])
}
if s := groups[0].Items[3]; s.Label != "success" || !s.Active {
t.Errorf("success: %+v", s)
}
if j := groups[1].Items[0]; j.Label != "lint" || j.Href != "?job=lint&ref=main&status=success" || j.Active {
t.Errorf("lint: %+v", j)
}
if b := groups[2].Items[0]; b.Label != "main" || !b.Active || b.Href != "?status=success" {
t.Errorf("active branch clears itself: %+v", b)
}
if b := groups[2].Items[1]; b.Label != "dev" || b.Active || b.Href != "?ref=dev&status=success" {
t.Errorf("dev: %+v", b)
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/httpd/ -run TestBuildFacetsGroups
Expected: FAIL, "undefined: buildFacets"
- Step 3: The grouping
Append to builds.go:
// buildFacets is the builds page's side column: filterLinks' rows split
// into their groups, plus one link per branch seen, which keeps status
// and job and clears itself when active.
func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGroup {
links := filterLinks(f, jobs)
n := 1 + len(buildStatuses)
status := facetGroup{Title: "Status"}
for _, l := range links[:n] {
status.Items = append(status.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
}
job := facetGroup{Title: "Jobs"}
for _, l := range links[n:] {
job.Items = append(job.Items, facetItem{Label: l.Label, Href: l.Href, Active: l.Active})
}
branch := facetGroup{Title: "Branches"}
base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
for _, ref := range refs {
active := ref == f.Ref
href := facetHref(base, "ref", ref)
if active {
href = facetHref(base, "ref", "")
}
branch.Items = append(branch.Items, facetItem{Label: ref, Href: href, Active: active})
}
return []facetGroup{status, job, branch}
}
In 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.
- Step 4: The template
Replace builds.html lines 4-17 (the listhead) with:
<div class="withcol">
<nav class="sidecol" aria-label="Filters">
{{range .Facets}}{{if .Items}}<div class="grp">
<h2 class="colhead">{{.Title}}</h2>
<ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
</div>{{end}}{{end}}
<form method="get" class="searchform compact">
<label for="ref" class="colhead">Branch</label>
<input type="text" id="ref" name="ref" value="{{.Filter.Ref}}" list="buildrefs">
<datalist id="buildrefs">{{range .Refs}}<option value="{{.}}">{{end}}</datalist>
<input type="hidden" name="status" value="{{.Filter.Status}}">
<input type="hidden" name="job" value="{{.Filter.Job}}">
<button type="submit" class="btn">Filter</button>
</form>
</nav>
<div class="colmain">
<div class="listhead">
<h1>Builds</h1>
and 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).
- Step 5: Run the tests
Run: go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestBuildCancelWeb|TestRunnerSettingsWeb' -count=1
Expected: PASS
- Step 6: Commit
git 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
git commit -m "web: the builds page filters in a side column
Ref #226"
Task 8: Facet columns on explore and site search
Files:
- Create:
internal/httpd/topics.go,internal/httpd/topics_test.go - Modify:
internal/httpd/web.go:202-220(explore) - Modify:
internal/web/templates/explore.html,globalsearch.html:4-18
Interfaces:
-
Consumes:
describedRepo{Topics []string},facetGroup,facetItem. -
Produces:
func topicFacets(repos []describedRepo, q string) facetGroup. -
Step 1: Write the failing test
internal/httpd/topics_test.go:
package httpd
import "testing"
// Explore's column counts topics across the visible repositories, most
// used first then by name, capped at twenty, each linking to ?q=<topic>
// and the active one clearing the query (desktop layout spec).
func TestTopicFacets(t *testing.T) {
repos := []describedRepo{
{Topics: []string{"cli", "git"}},
{Topics: []string{"git", "swift"}},
{Topics: []string{"git"}},
}
g := topicFacets(repos, "cli")
if g.Title != "Topics" || len(g.Items) != 3 {
t.Fatalf("group: %+v", g)
}
if g.Items[0].Label != "git" || g.Items[0].Count != 3 || g.Items[0].Href != "/explore?q=git" {
t.Errorf("git: %+v", g.Items[0])
}
if g.Items[1].Label != "cli" || !g.Items[1].Active || g.Items[1].Href != "/explore" {
t.Errorf("cli: %+v", g.Items[1])
}
if g.Items[2].Label != "swift" || g.Items[2].Count != 1 {
t.Errorf("swift: %+v", g.Items[2])
}
var many []describedRepo
for i := 0; i < 30; i++ {
many = append(many, describedRepo{Topics: []string{string(rune('a' + i))}})
}
if n := len(topicFacets(many, "").Items); n != 20 {
t.Errorf("cap: %d", n)
}
}
- Step 2: Run it to see it fail
Run: go test ./internal/httpd/ -run TestTopicFacets
Expected: FAIL, "undefined: topicFacets"
- Step 3: The counter
internal/httpd/topics.go:
package httpd
import (
"net/url"
"sort"
)
// topicFacets counts the topics across repos for explore's column. The
// links are the ones topic chips already use, ?q=<topic>; the active
// topic links to explore with no query.
func topicFacets(repos []describedRepo, q string) facetGroup {
counts := map[string]int64{}
for _, r := range repos {
for _, t := range r.Topics {
counts[t]++
}
}
names := make([]string, 0, len(counts))
for t := range counts {
names = append(names, t)
}
sort.Slice(names, func(i, j int) bool {
if counts[names[i]] != counts[names[j]] {
return counts[names[i]] > counts[names[j]]
}
return names[i] < names[j]
})
if len(names) > 20 {
names = names[:20]
}
g := facetGroup{Title: "Topics"}
for _, t := range names {
item := facetItem{Label: t, Count: counts[t], Href: "/explore?q=" + url.QueryEscape(t), Active: t == q}
if item.Active {
item.Href = "/explore"
}
g.Items = append(g.Items, item)
}
return g
}
In 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.
- Step 4: The templates
explore.html content define:
<div class="withcol">
{{template "sidecol" .Facets}}
<div class="colmain">
<div class="headrow">
<h1>Explore</h1>
<form method="get" action="/explore" class="searchform compact">
<input type="search" name="q" aria-label="Filter repositories" value="{{.Query}}" placeholder="filter by name, description, topic">
<button type="submit" class="btn">Search</button>
</form>
<span class="spacer"></span>
</div>
<ul class="repolist rows">
{{range .Repos}}{{template "reporow" .}}
{{else}}<li class="empty">no public repositories yet</li>{{end}}
</ul>
</div>
</div>
globalsearch.html: wrap the content in .withcol; the column is the kinds, static:
<div class="withcol">
<nav class="sidecol" aria-label="Filters">
<div class="grp"><h2 class="colhead">Kind</h2>
<ul>
<li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
<li><a{{if eq .Kind "repo"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=repo">repositories</a></li>
<li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=issue">issues</a></li>
<li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&kind=mr">merge requests</a></li>
</ul></div>
</nav>
<div class="colmain">
<div class="listhead">
<h1>Search</h1>
{{if and .Query (not .QueryErr)}}<p class="meta">... unchanged ...</p>{{end}}
</div>
<form method="get" action="/search" class="searchform"> ... unchanged ... </form>
... the results list, unchanged ...
</div>
</div>
- Step 5: Run the tests
Run: go test ./internal/web/ ./internal/httpd/ && go test ./e2e -run 'TestWebUI|TestGlobalSearchAndNotificationsWeb' -count=1
Expected: PASS
- Step 6: Commit
git add internal/httpd/topics.go internal/httpd/topics_test.go internal/httpd/web.go internal/web/templates/explore.html internal/web/templates/globalsearch.html
git commit -m "web: topic and kind columns on explore and search
Ref #226"
Task 9: Section nav column on repository settings, account settings and admin
Files:
- Modify:
internal/web/templates/settings.html:1-7,account.html,admin.html - Modify:
internal/web/static/style.css:694(nav.sections) - Modify:
internal/web/widths_test.go(extend)
Interfaces:
-
Consumes:
.withcol.narrowand.sidecolfrom Task 6. -
Step 1: Extend the failing test
Append to TestListPagesAreWide in internal/web/widths_test.go:
// Settings pages carry a section column: every section id has a link
// in the column, and the page is wide with the narrow grid.
for _, name := range []string{"settings.html", "account.html", "admin.html"} {
src, _ := templateFS.ReadFile("templates/" + name)
s := string(src)
if !strings.HasPrefix(s, `{{define "width"}}wide{{end}}`) || !strings.Contains(s, `<div class="withcol narrow">`) {
t.Errorf("%s lacks the narrow column layout", name)
}
for _, m := range regexp.MustCompile(`<section id="([a-z]+)"`).FindAllStringSubmatch(s, -1) {
if !strings.Contains(s, `href="#`+m[1]+`"`) {
t.Errorf("%s: section %q has no link in the column", name, m[1])
}
}
}
Add "regexp" to the imports.
- Step 2: Run it to see it fail
Run: go test ./internal/web/ -run TestListPagesAreWide
Expected: FAIL, "settings.html lacks the narrow column layout"
- Step 3: Repository settings
settings.html line 1 → {{define "width"}}wide{{end}}. Replace line 7 (<nav class="sections" ...>) with:
<div class="withcol narrow">
<nav class="sidecol" aria-label="Sections">
<div class="grp"><h2 class="colhead">Sections</h2>
<ul>
<li><a href="#identity">Identity</a></li>
<li><a href="#access">Access</a></li>
<li><a href="#gates">Merge gates</a></li>
<li><a href="#branches">Protected branches</a></li>
<li><a href="#tags">Protected tags</a></li>
<li><a href="#deps">Dependencies</a></li>
<li><a href="#runners">Runners</a></li>
<li><a href="#lifecycle">Lifecycle</a></li>
</ul></div>
</nav>
<div class="colmain">
and add </div>\n</div> before the content define's final {{end}}. Delete the nav.sections rule from style.css (line 694): nothing uses it.
- Step 4: Account settings
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}}.
- Step 5: Admin
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}}.
- 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
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:
/* 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
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:
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
cd .claude/screenshots
python3 shoot.py desktop/dark-1920 dark 1920 local.txt "<login url>"
python3 shoot.py desktop/light-1920 light 1920 local.txt "<login url>"
python3 shoot.py desktop/dark-1280 dark 1280 local.txt "<login url>"
python3 shoot.py desktop/mobile dark 375 local-mobile.txt "<login url>"
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
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
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
git fetch origin && git rebase origin/main && git push -u origin desktop-layout-spec
- Step 2: Open the merge request
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:
gitbay build list --json | head -c 2000
Expected: test and build succeed on the branch head. A failure: gitbay build log <n>, fix on the branch, push, wait again.
- Step 4: Merge and clean up
gitbay mr merge <n> --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
make deploy
Then recapture the live site at 1920 with shoot.py against public.txt and compare with the local captures from Task 11.