web: the facet column is a disclosure below 62rem !439

merged merged by cmc on 2026-09-20 01:24 UTC · krz/gitbay:facet-disclosure into main

14 files changed, +260 −19

Layout: unified · split

CHANGELOG.org +10 −5
@@ -23,11 +23,16 @@ The desktop layout (#226): the web UI uses a wide screen.
2323- Side columns: state, labels and open milestones on the issue and
2424 merge request lists; status, jobs and branches on builds; topics on
2525 explore; kinds on search; sections on repository settings, account
26 settings and admin.
27- Below 62rem the facet and section columns stack after their content
28 and the navigator hides, since the tree page is the navigator on a
29 phone. Below 80rem the dashboard's pinned repositories return to a
30 chip row above the queues.
26 settings and admin. A label or milestone nothing is filed under is
27 left out, so the merge request list offers the labels that have
28 merge requests rather than every label the repository holds; the
29 branch group caps at ten, and the branch field below it still takes
30 any ref.
31- Below 62rem the facet and section columns become a closed disclosure
32 before their content, headed by the filters in force, and the
33 navigator hides, since the tree page is the navigator on a phone.
34 Below 80rem the dashboard's pinned repositories return to a chip row
35 above the queues.
3136
3237Three accessibility defects the wider scan found, on pages the
3338sixteen-page one never covered (#226):
docs/specs/2026-09-19-desktop-layout-design.md +12 −4
@@ -63,10 +63,18 @@ feed rows are the row format the lists should adopt.
6363 description with topics and website inline in muted text, the
6464 Pin/Watch/Bookmark/Fork buttons at the right. Row two: the tabs. The
6565 toggles hint moves to `title` text on the three buttons.
667. **Below 62rem** a facet or section column stacks after the content,
67 the way the issue aside does; the file navigator disappears (the
68 tree page exists). **Below 80rem** the dashboard's pinned column
69 returns to the chip row. Nothing the phone layout fixed moves.
667. **Below 62rem** a facet or section column becomes a closed
67 disclosure before the content, headed by its word and the filters in
68 force; the file navigator disappears (the tree page exists).
69 **Below 80rem** the dashboard's pinned column returns to the chip
70 row. Nothing the phone layout fixed moves.
71
72 *Amended 2026-09-19 (#237).* The rule read "stacks after the
73 content, the way the issue aside does". An aside carries a note on
74 one thing; a facet column carries the vocabulary the list is
75 narrowed by, and a repository with two dozen labels stacked all of
76 them under the content, taller than it. A column that stops being a
77 column is a control, so it collapses to one and leads the list.
7078
7179## Pages
7280
internal/httpd/builds.go +18 −1
@@ -3,6 +3,7 @@ package httpd
33import (
44 "net/http"
55 "net/url"
6 "slices"
67 "strconv"
78
89 "gitbay.org/gitbay/internal/control"
@@ -61,6 +62,11 @@ func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
6162 return links
6263}
6364
65// maxBranchFacets caps the Branches group, the way topicFacets caps
66// topics: a page of builds names as many refs as it likes and the column
67// is not a branch listing. The field below the column takes any ref.
68const maxBranchFacets = 10
69
6470// buildFacets is the builds page's side column: filterLinks' rows split
6571// into their groups, plus one link per branch seen, which keeps status
6672// and job and clears itself when active.
@@ -77,7 +83,18 @@ func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGro
7783 }
7884 branch := facetGroup{Title: "Branches"}
7985 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}}
80 for _, ref := range refs {
86 shown := refs
87 if len(shown) > maxBranchFacets {
88 shown = shown[:maxBranchFacets]
89 // the ref in force belongs in the group wherever it sits, or the
90 // filter it set cannot be cleared from the column. The reslice
91 // caps the capacity so the append copies instead of writing
92 // through to refs.
93 if i := slices.Index(refs, f.Ref); i >= maxBranchFacets {
94 shown = append(shown[:maxBranchFacets-1:maxBranchFacets-1], refs[i])
95 }
96 }
97 for _, ref := range shown {
8198 active := ref == f.Ref
8299 href := facetHref(base, "ref", ref)
83100 if active {
internal/httpd/builds_test.go +34
@@ -1,6 +1,7 @@
11package httpd
22
33import (
4 "fmt"
45 "reflect"
56 "testing"
67
@@ -174,3 +175,36 @@ func TestBuildFacetsGroups(t *testing.T) {
174175 t.Errorf("dev: %+v", b)
175176 }
176177}
178
179// The branch group caps, the way topicFacets caps topics: a page of
180// builds can name dozens of refs and the column is not a branch
181// listing. The ref in force is kept whatever its position (#237).
182func TestBuildFacetsCapsBranches(t *testing.T) {
183 var refs []string
184 for i := 0; i < 30; i++ {
185 refs = append(refs, fmt.Sprintf("b%02d", i))
186 }
187 groups := buildFacets(buildFilter{}, nil, refs)
188 branches := groups[2].Items
189 if len(branches) != maxBranchFacets {
190 t.Fatalf("branches: %d", len(branches))
191 }
192 if branches[0].Label != "b00" || branches[len(branches)-1].Label != "b09" {
193 t.Errorf("kept the wrong refs: %+v", branches)
194 }
195
196 groups = buildFacets(buildFilter{Ref: "b29"}, nil, refs)
197 branches = groups[2].Items
198 if len(branches) != maxBranchFacets {
199 t.Fatalf("branches with an active ref: %d", len(branches))
200 }
201 var active *facetItem
202 for i := range branches {
203 if branches[i].Label == "b29" {
204 active = &branches[i]
205 }
206 }
207 if active == nil || !active.Active || active.Href != "?" {
208 t.Errorf("active ref past the cap: %+v", branches)
209 }
210}
internal/httpd/facets.go +9 −1
@@ -41,7 +41,9 @@ func facetHref(base url.Values, key, value string) string {
4141// listFacets builds the issue or merge request list's column from the
4242// active parameters, the states the page offers, and the repository's
4343// labels and open milestones. Counts are the rows' own: a label's issue
44// count on the issue list, its MR count on the MR list.
44// count on the issue list, its MR count on the MR list. A count of zero
45// is left out — it links to an empty list — unless it is the filter in
46// force, which stays so it can be cleared (#237).
4547func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
4648 var st facetGroup
4749 st.Title = "State"
@@ -55,6 +57,9 @@ func listFacets(base url.Values, states []string, state string, labels []store.L
5557 n = l.MRs
5658 }
5759 active := base.Get("label") == l.Name
60 if n == 0 && !active {
61 continue
62 }
5863 href := facetHref(base, "label", l.Name)
5964 if active {
6065 href = facetHref(base, "label", "")
@@ -64,6 +69,9 @@ func listFacets(base url.Values, states []string, state string, labels []store.L
6469 mg := facetGroup{Title: "Milestones"}
6570 for _, m := range ms {
6671 active := base.Get("milestone") == m.Title
72 if m.OpenItems == 0 && !active {
73 continue
74 }
6775 href := facetHref(base, "milestone", m.Title)
6876 if active {
6977 href = facetHref(base, "milestone", "")
internal/httpd/facets_test.go +34 −6
@@ -35,18 +35,46 @@ func TestListFacetsGroups(t *testing.T) {
3535 t.Errorf("state items: %+v", st)
3636 }
3737 lb := groups[1].Items
38 // docs has no issues, so the issue list does not offer it (#237)
39 if len(lb) != 1 {
40 t.Fatalf("labels: %+v", lb)
41 }
3842 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
3943 t.Errorf("active label clears itself: %+v", lb[0])
4044 }
41 if lb[1].Label != "docs" || lb[1].Count != 0 || lb[1].Active || lb[1].Href != "?label=docs&state=open" {
42 t.Errorf("inactive label: %+v", lb[1])
43 }
4445 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
4546 t.Errorf("milestone: %+v", m)
4647 }
47 // on the MR list a label's count is its MR count
48 // on the MR list a label's count is its MR count, and both labels
49 // have merge requests, so both are offered
4850 mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
49 if mr[1].Items[1].Count != 3 {
50 t.Errorf("mr count: %+v", mr[1].Items[1])
51 if len(mr[1].Items) != 2 || mr[1].Items[1].Count != 3 {
52 t.Errorf("mr counts: %+v", mr[1].Items)
53 }
54}
55
56// A count of zero is a link to an empty list, so it is left out — unless
57// it is the filter in force, which has to stay clickable to clear (#237).
58func TestListFacetsDropsEmpty(t *testing.T) {
59 labels := []store.Label{{Name: "bug", Issues: 0}, {Name: "docs", Issues: 0}}
60 ms := []store.Milestone{{Title: "v2", OpenItems: 0}, {Title: "v3", OpenItems: 1}}
61
62 groups := listFacets(url.Values{"state": {"open"}}, []string{"open"}, "open", labels, ms, false)
63 if n := len(groups[1].Items); n != 0 {
64 t.Errorf("empty labels offered: %+v", groups[1].Items)
65 }
66 if n := len(groups[2].Items); n != 1 || groups[2].Items[0].Label != "v3" {
67 t.Errorf("milestones: %+v", groups[2].Items)
68 }
69
70 base := url.Values{"state": {"open"}, "label": {"bug"}, "milestone": {"v2"}}
71 groups = listFacets(base, []string{"open"}, "open", labels, ms, false)
72 lb := groups[1].Items
73 if len(lb) != 1 || lb[0].Label != "bug" || !lb[0].Active || lb[0].Href != "?milestone=v2&state=open" {
74 t.Errorf("active empty label dropped: %+v", lb)
75 }
76 mg := groups[2].Items
77 if len(mg) != 2 || mg[0].Label != "v2" || !mg[0].Active || mg[0].Href != "?label=bug&state=open" {
78 t.Errorf("active empty milestone dropped: %+v", mg)
5179 }
5280}
internal/httpd/sidecol_test.go added +74
@@ -0,0 +1,74 @@
1package httpd
2
3import (
4 "html/template"
5 "net/url"
6 "strings"
7 "testing"
8
9 "gitbay.org/gitbay/internal/store"
10 "gitbay.org/gitbay/internal/web"
11)
12
13// renderIssues renders issues.html around the facets given, which is the
14// shared sidecol partial every list page uses.
15func renderIssues(t *testing.T, facets []facetGroup) string {
16 t.Helper()
17 var sb strings.Builder
18 err := web.Render(&sb, "issues.html", struct {
19 repoPage
20 State string
21 Label string
22 Query string
23 Filters []listFilter
24 Facets []facetGroup
25 Issues []store.Issue
26 LabelColors map[string]template.CSS
27 Older string
28 }{repoPage: testRepoPage(), State: "open", Facets: facets})
29 if err != nil {
30 t.Fatalf("render: %v", err)
31 }
32 return sb.String()
33}
34
35// The column's groups sit inside a details.sidedrop whose summary names
36// the filters in force. Below 62rem that disclosure is the column; above
37// it style.css hides the summary and shows the content, so the markup is
38// the same either way (#237).
39func TestSidecolIsADisclosure(t *testing.T) {
40 base := url.Values{"state": {"open"}, "label": {"bug"}}
41 labels := []store.Label{{Name: "bug", Issues: 2}}
42 facets := listFacets(base, []string{"open", "closed"}, "open", labels, nil, false)
43
44 out := renderIssues(t, facets)
45 nav := strings.Index(out, `<nav class="sidecol" aria-label="Filters">`)
46 drop := strings.Index(out, `<details class="sidedrop">`)
47 sum := strings.Index(out, "<summary>")
48 grp := strings.Index(out, `<div class="grp">`)
49 if nav < 0 || drop < nav || sum < drop || grp < sum {
50 t.Fatalf("nav=%d details=%d summary=%d grp=%d\n%s", nav, drop, sum, grp, out)
51 }
52 if !strings.Contains(out, `<span class="sideword">Filters</span>`) {
53 t.Errorf("summary does not head with its word:\n%s", out)
54 }
55 // state and label are both in force, so both are named
56 for _, want := range []string{`<span class="sideon">open</span>`, `<span class="sideon">bug</span>`} {
57 if !strings.Contains(out, want) {
58 t.Errorf("summary does not name %q:\n%s", want, out)
59 }
60 }
61 if n := strings.Count(out, "</details>"); n != 1 {
62 t.Errorf("details not closed once: %d", n)
63 }
64}
65
66// The column leads the list in the markup, because below 62rem it is a
67// control above what it narrows rather than a note after it (#237).
68func TestSidecolPrecedesTheList(t *testing.T) {
69 facets := listFacets(url.Values{"state": {"open"}}, []string{"open"}, "open", nil, nil, false)
70 out := renderIssues(t, facets)
71 if i, j := strings.Index(out, `class="sidecol"`), strings.Index(out, `class="colmain"`); i < 0 || j < i {
72 t.Errorf("sidecol=%d colmain=%d", i, j)
73 }
74}
internal/web/static/style.css +45 −2
@@ -1430,6 +1430,46 @@ a.memberchip .role { color: var(--muted); }
14301430.sidecol form.searchform { margin-top: var(--sp-2); }
14311431.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
14321432
1433/* The column's groups sit in a disclosure. Below 62rem that is all the
1434 reader sees of the column: one closed line before the list, because a
1435 repository with two dozen labels stacked the whole vocabulary under
1436 the content it filters. Above 62rem the summary is hidden and the
1437 content shown whatever the open state, so the column is what it
1438 always was. The @supports guard is the fail-safe: an engine without
1439 ::details-content keeps a working toggle at every width instead of
1440 losing the column (#237). */
1441.sidedrop > summary {
1442 display: inline-flex;
1443 align-items: center;
1444 gap: var(--sp-2);
1445 height: 32px;
1446 padding: 0 var(--sp-3);
1447 background: var(--canvas);
1448 border: 1px solid var(--line);
1449 border-radius: var(--r-ctl);
1450 color: var(--fg);
1451 font-size: var(--fs-2);
1452 cursor: pointer;
1453 list-style: none;
1454}
1455.sidedrop[open] > summary { margin-bottom: var(--sp-3); }
1456.sidedrop > summary::-webkit-details-marker { display: none; }
1457.sidedrop > summary:hover { background: var(--hover); }
1458.sidedrop > summary::after { content: "\25be"; color: var(--muted); }
1459.sidedrop[open] > summary::after { content: "\25b4"; }
1460.sideword { font-weight: 600; }
1461/* the filters in force: where you are */
1462.sideon { color: var(--warn); }
1463/* every group came out empty, so there is nothing to open */
1464.sidedrop:not(:has(.grp)) { display: none; }
1465
1466@media (min-width: 62.01rem) {
1467 @supports selector(::details-content) {
1468 .sidedrop > summary { display: none; }
1469 .sidedrop::details-content { content-visibility: visible; }
1470 }
1471}
1472
14331473/* a sticky column taller than the viewport scrolls inside itself, or its
14341474 tail is unreachable once the page scrolls. Above 62rem only: below it
14351475 .filenav is hidden and the other two are static. .dashpins stops being
@@ -1655,9 +1695,12 @@ svg.icon { vertical-align: -0.125em; }
16551695 .blobgrid { grid-template-columns: 1fr; }
16561696 .filenav { display: none; }
16571697
1658 /* the column follows the content on a phone, the way the aside does */
1698 /* the column is a closed disclosure before the content: a filter you
1699 reach without scrolling the list, and nothing to scroll past when
1700 you do not want it. It leads rather than follows because it is what
1701 the list is narrowed by, not a note on one row (#237) */
16591702 .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1660 .sidecol { position: static; order: 2; }
1703 .sidecol { position: static; margin-bottom: var(--sp-3); }
16611704 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
16621705}
16631706
internal/web/templates/account.html +3
@@ -7,6 +7,8 @@
77
88<div class="withcol narrow">
99<nav class="sidecol" aria-label="Sections">
10<details class="sidedrop">
11<summary><span class="sideword">Sections</span></summary>
1012 <div class="grp"><h2 class="colhead">Sections</h2>
1113 <ul>
1214 <li><a href="#profile">Profile</a></li>
@@ -18,6 +20,7 @@
1820 <li><a href="#export">Export</a></li>
1921 <li><a href="#cli">On the command line</a></li>
2022 </ul></div>
23</details>
2124</nav>
2225<div class="colmain">
2326<section id="profile"><h2>Profile</h2>
internal/web/templates/admin.html +3
@@ -7,6 +7,8 @@
77
88<div class="withcol narrow">
99<nav class="sidecol" aria-label="Sections">
10<details class="sidedrop">
11<summary><span class="sideword">Sections</span></summary>
1012 <div class="grp"><h2 class="colhead">Sections</h2>
1113 <ul>
1214 <li><a href="#webhooks">Webhook deliveries</a></li>
@@ -15,6 +17,7 @@
1517 <li><a href="#builds">Builds</a></li>
1618 <li><a href="#deps">Dependency checks</a></li>
1719 </ul></div>
20</details>
1821</nav>
1922<div class="colmain">
2023
internal/web/templates/builds.html +3
@@ -3,6 +3,8 @@
33{{define "content"}}
44<div class="withcol">
55<nav class="sidecol" aria-label="Filters">
6<details class="sidedrop">
7<summary><span class="sideword">Filters</span>{{range .Facets}}{{range .Items}}{{if .Active}} <span class="sideon">{{.Label}}</span>{{end}}{{end}}{{end}}</summary>
68 {{range .Facets}}{{if .Items}}<div class="grp">
79 <h2 class="colhead">{{.Title}}</h2>
810 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
@@ -15,6 +17,7 @@
1517 <input type="hidden" name="job" value="{{.Filter.Job}}">
1618 <button type="submit" class="btn">Filter</button>
1719 </form>
20</details>
1821</nav>
1922<div class="colmain">
2023<div class="listhead">
internal/web/templates/globalsearch.html +3
@@ -3,6 +3,8 @@
33{{define "content"}}
44<div class="withcol">
55<nav class="sidecol" aria-label="Filters">
6<details class="sidedrop">
7<summary><span class="sideword">Filters</span>{{if .Kind}} <span class="sideon">{{.Kind}}</span>{{end}}</summary>
68 <div class="grp"><h2 class="colhead">Kind</h2>
79 <ul>
810 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
@@ -10,6 +12,7 @@
1012 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
1113 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
1214 </ul></div>
15</details>
1316</nav>
1417<div class="colmain">
1518<div class="listhead">
internal/web/templates/layout.html +9
@@ -122,11 +122,20 @@
122122 {{end}}</ul>
123123</nav>{{end}}
124124
125{{/* Every side column's groups sit in a details.sidedrop. Below 62rem
126 that disclosure is what the reader sees of the column: one closed
127 line they open. Above it style.css hides the summary and shows the
128 content whatever the open state, so the column reads as it always
129 has (#237). The facet columns name the filters in force in the
130 summary; the section navs head with the word alone. */}}
125131{{define "sidecol"}}<nav class="sidecol" aria-label="Filters">
132<details class="sidedrop">
133<summary><span class="sideword">Filters</span>{{range .}}{{range .Items}}{{if .Active}} <span class="sideon">{{.Label}}</span>{{end}}{{end}}{{end}}</summary>
126134 {{range .}}{{if .Items}}<div class="grp">
127135 <h2 class="colhead">{{.Title}}</h2>
128136 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
129137 </div>{{end}}{{end}}
138</details>
130139</nav>{{end}}
131140
132141{{define "branchicon"}}<svg class="icon" width="12" height="12" viewBox="0 0 16 16" aria-hidden="true" fill="currentColor"><path d="M9.5 3.25a2.25 2.25 0 1 1 3 2.122V6A2.5 2.5 0 0 1 10 8.5H6a1 1 0 0 0-1 1v1.128a2.251 2.251 0 1 1-1.5 0V5.372a2.25 2.25 0 1 1 1.5 0v1.836A2.493 2.493 0 0 1 6 7h4a1 1 0 0 0 1-1v-.628a2.25 2.25 0 0 1-1.5-2.122ZM4.25 12a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5ZM3.5 3.25a.75.75 0 1 1 1.5 0 .75.75 0 0 1-1.5 0Zm8.25-.75a.75.75 0 1 0 0 1.5.75.75 0 0 0 0-1.5Z"/></svg>{{end}}
internal/web/templates/settings.html +3
@@ -6,6 +6,8 @@
66{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
77<div class="withcol narrow">
88<nav class="sidecol" aria-label="Sections">
9<details class="sidedrop">
10<summary><span class="sideword">Sections</span></summary>
911 <div class="grp"><h2 class="colhead">Sections</h2>
1012 <ul>
1113 <li><a href="#identity">Identity</a></li>
@@ -17,6 +19,7 @@
1719 <li><a href="#runners">Runners</a></li>
1820 <li><a href="#lifecycle">Lifecycle</a></li>
1921 </ul></div>
22</details>
2023</nav>
2124<div class="colmain">
2225