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.
23- Side columns: state, labels and open milestones on the issue and 23- Side columns: state, labels and open milestones on the issue and
24 merge request lists; status, jobs and branches on builds; topics on 24 merge request lists; status, jobs and branches on builds; topics on
25 explore; kinds on search; sections on repository settings, account 25 explore; kinds on search; sections on repository settings, account
26 settings and admin. 26 settings and admin. A label or milestone nothing is filed under is
27- Below 62rem the facet and section columns stack after their content 27 left out, so the merge request list offers the labels that have
28 and the navigator hides, since the tree page is the navigator on a 28 merge requests rather than every label the repository holds; the
29 phone. Below 80rem the dashboard's pinned repositories return to a 29 branch group caps at ten, and the branch field below it still takes
30 chip row above the queues. 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.
31 36
32Three accessibility defects the wider scan found, on pages the 37Three accessibility defects the wider scan found, on pages the
33sixteen-page one never covered (#226): 38sixteen-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.
63 description with topics and website inline in muted text, the 63 description with topics and website inline in muted text, the
64 Pin/Watch/Bookmark/Fork buttons at the right. Row two: the tabs. The 64 Pin/Watch/Bookmark/Fork buttons at the right. Row two: the tabs. The
65 toggles hint moves to `title` text on the three buttons. 65 toggles hint moves to `title` text on the three buttons.
667. **Below 62rem** a facet or section column stacks after the content, 667. **Below 62rem** a facet or section column becomes a closed
67 the way the issue aside does; the file navigator disappears (the 67 disclosure before the content, headed by its word and the filters in
68 tree page exists). **Below 80rem** the dashboard's pinned column 68 force; the file navigator disappears (the tree page exists).
69 returns to the chip row. Nothing the phone layout fixed moves. 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.
70 78
71## Pages 79## Pages
72 80
internal/httpd/builds.go +18 −1
@@ -3,6 +3,7 @@ package httpd
3import ( 3import (
4 "net/http" 4 "net/http"
5 "net/url" 5 "net/url"
6 "slices"
6 "strconv" 7 "strconv"
7 8
8 "gitbay.org/gitbay/internal/control" 9 "gitbay.org/gitbay/internal/control"
@@ -61,6 +62,11 @@ func filterLinks(f buildFilter, jobs []control.JobOut) []buildFilterLink {
61 return links 62 return links
62} 63}
63 64
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
64// buildFacets is the builds page's side column: filterLinks' rows split 70// buildFacets is the builds page's side column: filterLinks' rows split
65// into their groups, plus one link per branch seen, which keeps status 71// into their groups, plus one link per branch seen, which keeps status
66// and job and clears itself when active. 72// and job and clears itself when active.
@@ -77,7 +83,18 @@ func buildFacets(f buildFilter, jobs []control.JobOut, refs []string) []facetGro
77 } 83 }
78 branch := facetGroup{Title: "Branches"} 84 branch := facetGroup{Title: "Branches"}
79 base := url.Values{"ref": {f.Ref}, "status": {f.Status}, "job": {f.Job}} 85 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 {
81 active := ref == f.Ref 98 active := ref == f.Ref
82 href := facetHref(base, "ref", ref) 99 href := facetHref(base, "ref", ref)
83 if active { 100 if active {
internal/httpd/builds_test.go +34
@@ -1,6 +1,7 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "fmt"
4 "reflect" 5 "reflect"
5 "testing" 6 "testing"
6 7
@@ -174,3 +175,36 @@ func TestBuildFacetsGroups(t *testing.T) {
174 t.Errorf("dev: %+v", b) 175 t.Errorf("dev: %+v", b)
175 } 176 }
176} 177}
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 {
41// listFacets builds the issue or merge request list's column from the 41// listFacets builds the issue or merge request list's column from the
42// active parameters, the states the page offers, and the repository's 42// active parameters, the states the page offers, and the repository's
43// labels and open milestones. Counts are the rows' own: a label's issue 43// 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).
45func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup { 47func listFacets(base url.Values, states []string, state string, labels []store.Label, ms []store.Milestone, forMRs bool) []facetGroup {
46 var st facetGroup 48 var st facetGroup
47 st.Title = "State" 49 st.Title = "State"
@@ -55,6 +57,9 @@ func listFacets(base url.Values, states []string, state string, labels []store.L
55 n = l.MRs 57 n = l.MRs
56 } 58 }
57 active := base.Get("label") == l.Name 59 active := base.Get("label") == l.Name
60 if n == 0 && !active {
61 continue
62 }
58 href := facetHref(base, "label", l.Name) 63 href := facetHref(base, "label", l.Name)
59 if active { 64 if active {
60 href = facetHref(base, "label", "") 65 href = facetHref(base, "label", "")
@@ -64,6 +69,9 @@ func listFacets(base url.Values, states []string, state string, labels []store.L
64 mg := facetGroup{Title: "Milestones"} 69 mg := facetGroup{Title: "Milestones"}
65 for _, m := range ms { 70 for _, m := range ms {
66 active := base.Get("milestone") == m.Title 71 active := base.Get("milestone") == m.Title
72 if m.OpenItems == 0 && !active {
73 continue
74 }
67 href := facetHref(base, "milestone", m.Title) 75 href := facetHref(base, "milestone", m.Title)
68 if active { 76 if active {
69 href = facetHref(base, "milestone", "") 77 href = facetHref(base, "milestone", "")
internal/httpd/facets_test.go +34 −6
@@ -35,18 +35,46 @@ func TestListFacetsGroups(t *testing.T) {
35 t.Errorf("state items: %+v", st) 35 t.Errorf("state items: %+v", st)
36 } 36 }
37 lb := groups[1].Items 37 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 }
38 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" { 42 if lb[0].Label != "bug" || lb[0].Count != 2 || !lb[0].Active || lb[0].Href != "?state=open" {
39 t.Errorf("active label clears itself: %+v", lb[0]) 43 t.Errorf("active label clears itself: %+v", lb[0])
40 } 44 }
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 }
44 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" { 45 if m := groups[2].Items[0]; m.Label != "v2" || m.Count != 4 || m.Href != "?label=bug&milestone=v2&state=open" {
45 t.Errorf("milestone: %+v", m) 46 t.Errorf("milestone: %+v", m)
46 } 47 }
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
48 mr := listFacets(base, []string{"open"}, "open", labels, nil, true) 50 mr := listFacets(base, []string{"open"}, "open", labels, nil, true)
49 if mr[1].Items[1].Count != 3 { 51 if len(mr[1].Items) != 2 || mr[1].Items[1].Count != 3 {
50 t.Errorf("mr count: %+v", mr[1].Items[1]) 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)
51 } 79 }
52} 80}
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); }
1430.sidecol form.searchform { margin-top: var(--sp-2); } 1430.sidecol form.searchform { margin-top: var(--sp-2); }
1431.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; } 1431.sidecol form.searchform input[type="text"] { min-width: 0; width: 100%; }
1432 1432
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
1433/* a sticky column taller than the viewport scrolls inside itself, or its 1473/* a sticky column taller than the viewport scrolls inside itself, or its
1434 tail is unreachable once the page scrolls. Above 62rem only: below it 1474 tail is unreachable once the page scrolls. Above 62rem only: below it
1435 .filenav is hidden and the other two are static. .dashpins stops being 1475 .filenav is hidden and the other two are static. .dashpins stops being
@@ -1655,9 +1695,12 @@ svg.icon { vertical-align: -0.125em; }
1655 .blobgrid { grid-template-columns: 1fr; } 1695 .blobgrid { grid-template-columns: 1fr; }
1656 .filenav { display: none; } 1696 .filenav { display: none; }
1657 1697
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) */
1659 .withcol, .withcol.narrow { grid-template-columns: 1fr; } 1702 .withcol, .withcol.narrow { grid-template-columns: 1fr; }
1660 .sidecol { position: static; order: 2; } 1703 .sidecol { position: static; margin-bottom: var(--sp-3); }
1661 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); } 1704 .sidecol .grp { display: inline-block; vertical-align: top; margin-right: var(--sp-5); }
1662} 1705}
1663 1706
internal/web/templates/account.html +3
@@ -7,6 +7,8 @@
7 7
8<div class="withcol narrow"> 8<div class="withcol narrow">
9<nav class="sidecol" aria-label="Sections"> 9<nav class="sidecol" aria-label="Sections">
10<details class="sidedrop">
11<summary><span class="sideword">Sections</span></summary>
10 <div class="grp"><h2 class="colhead">Sections</h2> 12 <div class="grp"><h2 class="colhead">Sections</h2>
11 <ul> 13 <ul>
12 <li><a href="#profile">Profile</a></li> 14 <li><a href="#profile">Profile</a></li>
@@ -18,6 +20,7 @@
18 <li><a href="#export">Export</a></li> 20 <li><a href="#export">Export</a></li>
19 <li><a href="#cli">On the command line</a></li> 21 <li><a href="#cli">On the command line</a></li>
20 </ul></div> 22 </ul></div>
23</details>
21</nav> 24</nav>
22<div class="colmain"> 25<div class="colmain">
23<section id="profile"><h2>Profile</h2> 26<section id="profile"><h2>Profile</h2>
internal/web/templates/admin.html +3
@@ -7,6 +7,8 @@
7 7
8<div class="withcol narrow"> 8<div class="withcol narrow">
9<nav class="sidecol" aria-label="Sections"> 9<nav class="sidecol" aria-label="Sections">
10<details class="sidedrop">
11<summary><span class="sideword">Sections</span></summary>
10 <div class="grp"><h2 class="colhead">Sections</h2> 12 <div class="grp"><h2 class="colhead">Sections</h2>
11 <ul> 13 <ul>
12 <li><a href="#webhooks">Webhook deliveries</a></li> 14 <li><a href="#webhooks">Webhook deliveries</a></li>
@@ -15,6 +17,7 @@
15 <li><a href="#builds">Builds</a></li> 17 <li><a href="#builds">Builds</a></li>
16 <li><a href="#deps">Dependency checks</a></li> 18 <li><a href="#deps">Dependency checks</a></li>
17 </ul></div> 19 </ul></div>
20</details>
18</nav> 21</nav>
19<div class="colmain"> 22<div class="colmain">
20 23
internal/web/templates/builds.html +3
@@ -3,6 +3,8 @@
3{{define "content"}} 3{{define "content"}}
4<div class="withcol"> 4<div class="withcol">
5<nav class="sidecol" aria-label="Filters"> 5<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>
6 {{range .Facets}}{{if .Items}}<div class="grp"> 8 {{range .Facets}}{{if .Items}}<div class="grp">
7 <h2 class="colhead">{{.Title}}</h2> 9 <h2 class="colhead">{{.Title}}</h2>
8 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul> 10 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}</a></li>{{end}}</ul>
@@ -15,6 +17,7 @@
15 <input type="hidden" name="job" value="{{.Filter.Job}}"> 17 <input type="hidden" name="job" value="{{.Filter.Job}}">
16 <button type="submit" class="btn">Filter</button> 18 <button type="submit" class="btn">Filter</button>
17 </form> 19 </form>
20</details>
18</nav> 21</nav>
19<div class="colmain"> 22<div class="colmain">
20<div class="listhead"> 23<div class="listhead">
internal/web/templates/globalsearch.html +3
@@ -3,6 +3,8 @@
3{{define "content"}} 3{{define "content"}}
4<div class="withcol"> 4<div class="withcol">
5<nav class="sidecol" aria-label="Filters"> 5<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>
6 <div class="grp"><h2 class="colhead">Kind</h2> 8 <div class="grp"><h2 class="colhead">Kind</h2>
7 <ul> 9 <ul>
8 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li> 10 <li><a{{if eq .Kind ""}} aria-current="page"{{end}} href="?q={{.Query}}">everything</a></li>
@@ -10,6 +12,7 @@
10 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li> 12 <li><a{{if eq .Kind "issue"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=issue">issues</a></li>
11 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li> 13 <li><a{{if eq .Kind "mr"}} aria-current="page"{{end}} href="?q={{.Query}}&amp;kind=mr">merge requests</a></li>
12 </ul></div> 14 </ul></div>
15</details>
13</nav> 16</nav>
14<div class="colmain"> 17<div class="colmain">
15<div class="listhead"> 18<div class="listhead">
internal/web/templates/layout.html +9
@@ -122,11 +122,20 @@
122 {{end}}</ul> 122 {{end}}</ul>
123</nav>{{end}} 123</nav>{{end}}
124 124
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. */}}
125{{define "sidecol"}}<nav class="sidecol" aria-label="Filters"> 131{{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>
126 {{range .}}{{if .Items}}<div class="grp"> 134 {{range .}}{{if .Items}}<div class="grp">
127 <h2 class="colhead">{{.Title}}</h2> 135 <h2 class="colhead">{{.Title}}</h2>
128 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul> 136 <ul>{{range .Items}}<li><a{{if .Active}} aria-current="page"{{end}} href="{{.Href}}">{{.Label}}{{if .Count}} <i>{{.Count}}</i>{{end}}</a></li>{{end}}</ul>
129 </div>{{end}}{{end}} 137 </div>{{end}}{{end}}
138</details>
130</nav>{{end}} 139</nav>{{end}}
131 140
132{{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}} 141{{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 @@
6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}} 6{{if .Notice}}{{if .Saved}}<p class="notice" role="status">{{.Notice}}</p>{{else}}<p class="error" role="alert">{{.Notice}}</p>{{end}}{{end}}
7<div class="withcol narrow"> 7<div class="withcol narrow">
8<nav class="sidecol" aria-label="Sections"> 8<nav class="sidecol" aria-label="Sections">
9<details class="sidedrop">
10<summary><span class="sideword">Sections</span></summary>
9 <div class="grp"><h2 class="colhead">Sections</h2> 11 <div class="grp"><h2 class="colhead">Sections</h2>
10 <ul> 12 <ul>
11 <li><a href="#identity">Identity</a></li> 13 <li><a href="#identity">Identity</a></li>
@@ -17,6 +19,7 @@
17 <li><a href="#runners">Runners</a></li> 19 <li><a href="#runners">Runners</a></li>
18 <li><a href="#lifecycle">Lifecycle</a></li> 20 <li><a href="#lifecycle">Lifecycle</a></li>
19 </ul></div> 21 </ul></div>
22</details>
20</nav> 23</nav>
21<div class="colmain"> 24<div class="colmain">
22 25