web: the rail is an icon column, one row on a phone; pinned repositories on the dashboard !411

merged merged by cmc on 2026-09-18 21:28 UTC · krz/gitbay:nav-icons into main

8 files changed, +197 −198

Layout: unified · split

e2e/dashboard_test.go +1 −1
@@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) {
93 t.Fatalf("dashboard: %d", status) 93 t.Fatalf("dashboard: %d", status)
94 } 94 }
95 for _, want := range []string{ 95 for _, want := range []string{
96 `aria-labelledby="rail-pinned"`, `</span>app</a>`, // rail lists the pinned repo 96 `aria-label="Pinned repositories"`, `</span>app</a>`, // the dashboard lists the pinned repo
97 "Waiting on your review", "Assigned to you", "Recent activity", 97 "Waiting on your review", "Assigned to you", "Recent activity",
98 "from bob", "alice/app!1", "todo one", "alice/app#1", 98 "from bob", "alice/app!1", "todo one", "alice/app#1",
99 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`, 99 `href="/alice/app/mrs/1"`, `href="/alice/app/issues/1"`,
internal/httpd/page.go +6 −27
@@ -13,27 +13,14 @@ type railRepo struct {
13 Name string 13 Name string
14} 14}
15 15
16// railItem is one merge request in the rail's review queue. 16// rail is the viewer's cross-repo state the layout needs. The rail itself
17type railItem struct { 17// is icons only, so what is left is the unread count on its bell; the
18 RepoPath string 18// pinned repositories it used to list are rendered by the dashboard.
19 Number int64
20 Title string
21}
22
23// rail is the cross-repo state in the left rail: the repositories and the
24// review work a viewer moves between. A repo's own navigation lives in its
25// header tabs, not here — the rail holds only what changes as you switch
26// context, which is what earns it the width.
27type rail struct { 19type rail struct {
28 Pinned []railRepo 20 Pinned []railRepo
29 Reviews []railItem 21 Unread int
30 Unread int
31} 22}
32 23
33// Empty reports whether the rail has nothing to show beyond the global
34// links, so the template can skip its group headings.
35func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 }
36
37// basePage is what the layout needs on every page, repo or not. Page 24// basePage is what the layout needs on every page, repo or not. Page
38// structs embed it so the rail and the site name are always in scope. 25// structs embed it so the rail and the site name are always in scope.
39type basePage struct { 26type basePage struct {
@@ -68,7 +55,7 @@ func (s *Server) baseFor(viewer store.User) basePage {
68 return b 55 return b
69} 56}
70 57
71// railFor collects the viewer's pinned repositories and review queue, 58// railFor collects the viewer's pinned repositories and unread count,
72// dropping anything they may no longer read. 59// dropping anything they may no longer read.
73func (s *Server) railFor(viewer store.User) rail { 60func (s *Server) railFor(viewer store.User) rail {
74 var rl rail 61 var rl rail
@@ -80,13 +67,5 @@ func (s *Server) railFor(viewer store.User) rail {
80 } 67 }
81 } 68 }
82 rl.Unread = s.st.UnreadNotices(viewer.ID) 69 rl.Unread = s.st.UnreadNotices(viewer.ID)
83 queue, _ := s.st.ReviewQueue(viewer.ID)
84 for _, q := range queue {
85 rl.Reviews = append(rl.Reviews, railItem{
86 RepoPath: q.RepoPath,
87 Number: q.Number,
88 Title: q.Title,
89 })
90 }
91 return rl 70 return rl
92} 71}
internal/httpd/searchweb.go +2 −1
@@ -53,11 +53,12 @@ func (s *Server) globalSearch(w http.ResponseWriter, r *http.Request) {
53 } 53 }
54 s.render(w, "globalsearch.html", struct { 54 s.render(w, "globalsearch.html", struct {
55 basePage 55 basePage
56 Tab string
56 Query string 57 Query string
57 Kind string 58 Kind string
58 QueryErr string 59 QueryErr string
59 Results []searchResult 60 Results []searchResult
60 }{s.baseFor(viewer), q, kind, queryErr, results}) 61 }{s.baseFor(viewer), "sitesearch", q, kind, queryErr, results})
61} 62}
62 63
63func searchHref(h control.SearchResult) string { 64func searchHref(h control.SearchResult) string {
internal/httpd/web.go +4 −2
@@ -190,12 +190,13 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request, viewer store.
190 events, _ := s.st.RecentEvents(viewer.ID, 20, 0) 190 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
191 s.render(w, "dashboard.html", struct { 191 s.render(w, "dashboard.html", struct {
192 basePage 192 basePage
193 Tab string
193 Reviews []store.DashboardItem 194 Reviews []store.DashboardItem
194 Assigned []store.DashboardItem 195 Assigned []store.DashboardItem
195 MRs []store.DashboardItem 196 MRs []store.DashboardItem
196 Issues []store.DashboardItem 197 Issues []store.DashboardItem
197 Feed []feedLine 198 Feed []feedLine
198 }{s.baseFor(viewer), reviews, assigned, mrs, issues, feedLines(events)}) 199 }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)})
199} 200}
200 201
201func (s *Server) explore(w http.ResponseWriter, r *http.Request) { 202func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -211,9 +212,10 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
211 q := strings.TrimSpace(r.URL.Query().Get("q")) 212 q := strings.TrimSpace(r.URL.Query().Get("q"))
212 s.render(w, "explore.html", struct { 213 s.render(w, "explore.html", struct {
213 basePage 214 basePage
215 Tab string
214 Query string 216 Query string
215 Repos []describedRepo 217 Repos []describedRepo
216 }{s.baseFor(viewer), q, s.filterRepos(q, s.describeAll(repos))}) 218 }{s.baseFor(viewer), "explore", q, s.filterRepos(q, s.describeAll(repos))})
217} 219}
218 220
219// privacy renders the privacy page: what the gitbay software does with 221// privacy renders the privacy page: what the gitbay software does with
internal/web/static/style.css +89 −126
@@ -119,6 +119,13 @@
119 --sp-6: 32px; 119 --sp-6: 32px;
120 --sp-7: 48px; 120 --sp-7: 48px;
121 121
122 /* the rail: a column the width of one hit target, a row one tall */
123 --rail-w: 3.5rem;
124 --rail-hit: 40px;
125 --rail-strip-h: 48px;
126 --rail-mark: 24px;
127 --rail-mark-box: 32px;
128
122 --r-ctl: 4px; /* controls and chips */ 129 --r-ctl: 4px; /* controls and chips */
123 --r-card: 6px; /* cards, lists, code blocks */ 130 --r-card: 6px; /* cards, lists, code blocks */
124 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12); 131 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
@@ -244,10 +251,14 @@ a.skip {
244} 251}
245a.skip:focus { top: var(--sp-3); text-decoration: none; } 252a.skip:focus { top: var(--sp-3); text-decoration: none; }
246 253
247/* ---- rail ---- */ 254/* ---- rail: one glyph per destination, no labels ----
255 The column is the width of a hit target and nothing else. Every item is
256 a square link carrying an inline svg, its name in an aria-label and in a
257 visually hidden span, so the nav reads the same to a screen reader as
258 the old text list did. */
248.rail { 259.rail {
249 flex: none; 260 flex: none;
250 width: 14rem; 261 width: var(--rail-w);
251 background: var(--shell-bg); 262 background: var(--shell-bg);
252 color: var(--shell-fg); 263 color: var(--shell-fg);
253 border-right: 1px solid var(--shell-line); 264 border-right: 1px solid var(--shell-line);
@@ -256,83 +267,55 @@ a.skip:focus { top: var(--sp-3); text-decoration: none; }
256 height: 100vh; 267 height: 100vh;
257 display: flex; 268 display: flex;
258 flex-direction: column; 269 flex-direction: column;
270 align-items: center;
271 gap: var(--sp-1);
272 padding: var(--sp-3) 0;
259} 273}
260a.brand { 274a.brand {
261 display: flex; 275 display: flex;
262 align-items: center; 276 align-items: center;
263 gap: var(--sp-2); 277 justify-content: center;
264 padding: var(--sp-4) var(--sp-4) var(--sp-3); 278 flex: none;
279 width: var(--rail-hit);
280 height: var(--rail-hit);
265 color: var(--shell-fg); 281 color: var(--shell-fg);
266 font-weight: 600;
267 font-size: var(--fs-3);
268} 282}
269a.brand:hover { text-decoration: none; } 283a.brand:hover { text-decoration: none; }
270a.brand svg.mark { display: block; flex: none; } 284a.brand svg.mark { display: block; flex: none; width: var(--rail-mark); height: var(--rail-mark); }
271a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } 285
272 286ul.raillist {
273.railbody { flex: 1; overflow-y: auto; padding: 0 var(--sp-3); } 287 list-style: none;
274.railtop { display: block; } 288 margin: 0;
275/* search is reachable from every page, so it sits above the rail's links 289 padding: 0;
276 rather than on one page of its own */ 290 display: flex;
277form.railsearch { margin-bottom: var(--sp-3); } 291 flex-direction: column;
278form.railsearch input[type="search"] { 292 align-items: center;
279 width: 100%; 293 gap: var(--sp-1);
280 background: var(--shell-hover); 294 min-width: 0;
281 border: 1px solid var(--shell-line);
282 color: var(--shell-fg);
283 border-radius: var(--r-ctl);
284 padding: 6px 10px;
285 height: 32px;
286 font: inherit;
287 font-size: var(--fs-2);
288} 295}
289form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); } 296/* the spacer that holds settings, admin and the account cell at the far
290ul.raillist { list-style: none; margin: 0 0 var(--sp-4); padding: 0; } 297 end of the rail in either orientation */
291ul.raillist a { 298.railgap { flex: 1; }
299.railicon {
300 position: relative;
292 display: flex; 301 display: flex;
293 align-items: center; 302 align-items: center;
294 gap: var(--sp-2); 303 justify-content: center;
295 padding: 5px var(--sp-2); 304 flex: none;
305 width: var(--rail-hit);
306 height: var(--rail-hit);
307 padding: 0;
308 border: 0;
309 background: none;
296 color: var(--shell-fg); 310 color: var(--shell-fg);
297 font-size: var(--fs-2);
298 border-radius: var(--r-ctl); 311 border-radius: var(--r-ctl);
299 overflow: hidden; text-overflow: ellipsis; white-space: nowrap; 312 cursor: pointer;
300} 313}
301ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; } 314.railicon:hover { background: var(--shell-hover); text-decoration: none; }
302/* orange marks position — the rail's only accent */ 315/* orange marks position — the rail's only accent */
303ul.raillist a[aria-current] { 316.railicon[aria-current] {
304 background: var(--shell-hover); 317 background: var(--shell-hover);
305 box-shadow: inset 3px 0 0 var(--shell-mark); 318 box-shadow: inset 3px 0 0 var(--shell-mark);
306 font-weight: 600;
307}
308ul.raillist .owner { color: var(--shell-muted); }
309ul.raillist a.newrepo {
310 display: inline-flex;
311 width: 28px;
312 height: 28px;
313 align-items: center;
314 justify-content: center;
315 padding: 0;
316 border: 1px solid var(--shell-line);
317 border-radius: var(--r-ctl);
318 font-size: var(--fs-3);
319 font-weight: 600;
320}
321ul.raillist.wide a { white-space: normal; display: block; }
322ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); }
323ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
324
325.railgroup { margin-bottom: var(--sp-4); }
326.raillabel {
327 display: flex;
328 align-items: center;
329 gap: var(--sp-2);
330 margin: 0 var(--sp-2) var(--sp-1);
331 font-size: var(--fs-0);
332 font-weight: 500;
333 letter-spacing: 0.08em;
334 text-transform: uppercase;
335 color: var(--shell-muted);
336} 319}
337.count { 320.count {
338 font-size: var(--fs-0); 321 font-size: var(--fs-0);
@@ -344,32 +327,35 @@ ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
344 border-radius: var(--r-ctl); 327 border-radius: var(--r-ctl);
345 margin-left: auto; 328 margin-left: auto;
346} 329}
347/* pinned above the account cell: always reachable, never scrolled away */ 330/* the unread count is a badge on the bell's corner, not a trailing number */
348.railpinned { 331.railicon .count {
349 flex: none; 332 position: absolute;
350 padding: var(--sp-2) var(--sp-3); 333 top: 2px;
334 right: 2px;
351 margin: 0; 335 margin: 0;
352 border-top: 1px solid var(--shell-line); 336 padding: 0 3px;
337 line-height: 1.35;
353} 338}
354.railfoot { 339.railfoot {
355 flex: none; 340 flex: none;
356 border-top: 1px solid var(--shell-line);
357 padding: var(--sp-3) var(--sp-4);
358 display: flex; 341 display: flex;
342 flex-direction: column;
359 align-items: center; 343 align-items: center;
360 justify-content: space-between; 344 gap: var(--sp-1);
361 gap: var(--sp-3); 345 min-width: 0;
362 font-size: var(--fs-2);
363} 346}
364.railfoot a { color: var(--shell-fg); } 347.railfoot a { color: var(--shell-fg); }
365a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } 348.railfoot form { margin: 0; }
366/* a descendant cannot cancel an ancestor's underline, so the link carries 349a.railuser {
367 none and the name draws its own */ 350 display: flex;
351 align-items: center;
352 justify-content: center;
353 flex: none;
354 width: var(--rail-hit);
355 height: var(--rail-hit);
356}
368a.railuser:hover { text-decoration: none; } 357a.railuser:hover { text-decoration: none; }
369a.railuser:hover .uname { text-decoration: underline; } 358a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); }
370.railfoot form { margin-left: auto; }
371.railfoot button.linklike { color: var(--shell-muted); text-decoration: underline; }
372.railfoot button.linklike:hover { color: var(--shell-fg); }
373.avatar { 359.avatar {
374 display: inline-grid; 360 display: inline-grid;
375 place-items: center; 361 place-items: center;
@@ -1005,6 +991,10 @@ pre.message {
1005.blobimage { text-align: center; } 991.blobimage { text-align: center; }
1006.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); } 992.blobimage img { max-width: 100%; border: 1px solid var(--line); border-radius: var(--r-card); background: var(--surface); }
1007 993
994/* pinned repositories, on the dashboard now that the rail is icons only */
995.pinned { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin-bottom: var(--sp-4); }
996.pinned .owner { color: var(--muted); }
997
1008/* ---- two-column: conversation and aside ---- */ 998/* ---- two-column: conversation and aside ---- */
1009.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } 999.withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; }
1010.withaside .mainside { min-width: 0; } 1000.withaside .mainside { min-width: 0; }
@@ -1466,60 +1456,27 @@ svg.icon { vertical-align: -0.125em; }
1466} 1456}
1467 1457
1468@media (max-width: 52rem) { 1458@media (max-width: 52rem) {
1469 /* the rail becomes a single strip, not a wall of links */ 1459 /* the rail becomes one row of the same glyphs: fixed squares, no
1460 wrapping and no scrolling. The squares narrow a step further below
1461 30rem, where eleven of them would not otherwise fit a phone. */
1470 .shell { display: block; } 1462 .shell { display: block; }
1471 .rail { 1463 .rail {
1472 width: auto; 1464 width: auto;
1473 height: auto; 1465 height: var(--rail-strip-h);
1474 position: static; 1466 position: static;
1475 flex-direction: row; 1467 flex-direction: row;
1476 align-items: flex-start; 1468 align-items: center;
1477 gap: var(--sp-2); 1469 gap: 0;
1470 padding: 0 var(--sp-1);
1478 border-right: 0; 1471 border-right: 0;
1479 border-bottom: 1px solid var(--shell-line); 1472 border-bottom: 1px solid var(--shell-line);
1480 overflow-x: auto; 1473 overflow: visible;
1481 }
1482 a.brand { padding: var(--sp-2) var(--sp-3); min-height: 39px; display: flex; align-items: center; }
1483 .railbody { flex: 1; overflow: visible; padding: 0; display: flex; flex-direction: column; }
1484 .railtop { display: flex; align-items: center; gap: var(--sp-2); overflow-x: auto; }
1485 .railtop > * { flex-shrink: 0; }
1486 form.railsearch { margin: 0 var(--sp-2); }
1487 form.railsearch input[type="search"] { min-width: 8rem; }
1488 /* the strip hides the review queue, reachable from the dashboard, and
1489 shows pinned repositories as a second, quieter row of the strip */
1490 .railgroup { display: none; }
1491 .railgroup.pinned {
1492 display: flex;
1493 align-items: center;
1494 gap: var(--sp-2);
1495 width: 100%;
1496 border-top: 1px solid var(--shell-line);
1497 padding: 0 var(--sp-2);
1498 min-height: 32px;
1499 } 1474 }
1500 .railgroup.pinned .raillabel { 1475 a.brand { width: var(--rail-mark-box); height: var(--rail-strip-h); }
1501 display: block; 1476 ul.raillist, .railfoot { flex-direction: row; gap: 0; }
1502 margin: 0; 1477 .railicon, a.railuser { height: var(--rail-strip-h); border-radius: 0; }
1503 font-size: var(--fs-0); 1478 /* the current item is marked along the bottom edge in a row */
1504 color: var(--shell-muted); 1479 .railicon[aria-current] { box-shadow: inset 0 -2px 0 var(--shell-mark); }
1505 text-transform: uppercase;
1506 letter-spacing: .08em;
1507 flex: none;
1508 }
1509 .railgroup.pinned ul.raillist {
1510 display: flex;
1511 overflow-x: auto;
1512 margin: 0;
1513 flex: 1;
1514 min-width: 0;
1515 }
1516 .railgroup.pinned ul.raillist a { font-size: var(--fs-1); padding: var(--sp-1) var(--sp-2); }
1517 .railgroup.pinned ul.raillist a .owner { display: none; }
1518 ul.raillist { display: flex; margin: 0; }
1519 ul.raillist a { border-radius: 0; border-bottom: 2px solid transparent; padding: var(--sp-2) var(--sp-3); }
1520 ul.raillist a[aria-current] { box-shadow: none; border-bottom-color: var(--shell-mark); }
1521 .railfoot { border-top: 0; padding: var(--sp-2); }
1522 .railpinned { border-top: 0; padding: 0; }
1523 main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); } 1480 main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); }
1524 footer { padding: var(--sp-4); } 1481 footer { padding: var(--sp-4); }
1525 .repohead { padding: var(--sp-3) var(--sp-4) 0; } 1482 .repohead { padding: var(--sp-3) var(--sp-4) 0; }
@@ -1551,3 +1508,9 @@ svg.icon { vertical-align: -0.125em; }
1551 ul.loglist li { flex-wrap: wrap; } 1508 ul.loglist li { flex-wrap: wrap; }
1552 ul.loglist .commitside { width: 100%; justify-content: flex-start; } 1509 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
1553} 1510}
1511
1512/* eleven squares at 40px overrun a phone; 32px keeps the row whole and
1513 the 48px height keeps the target big enough to hit */
1514@media (max-width: 30rem) {
1515 :root { --rail-hit: 32px; }
1516}
internal/web/templates/dashboard.html +1
@@ -17,6 +17,7 @@
17{{end}} 17{{end}}
18{{define "content"}} 18{{define "content"}}
19<h1>Dashboard</h1> 19<h1>Dashboard</h1>
20{{with .Rail.Pinned}}<p class="pinned" aria-label="Pinned repositories">{{range .}}<a class="chip" href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a> {{end}}</p>{{end}}
20 21
21<div class="withaside"> 22<div class="withaside">
22<div class="mainside"> 23<div class="mainside">
internal/web/templates/layout.html +34 −41
@@ -12,49 +12,25 @@
12<a class="skip" href="#content">Skip to content</a> 12<a class="skip" href="#content">Skip to content</a>
13<div class="shell"> 13<div class="shell">
14 14
15{{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}}
16<nav class="rail" aria-label="Site"> 15<nav class="rail" aria-label="Site">
17 <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a> 16 <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a>
18 <div class="railbody"> 17 <ul class="raillist">
19 <div class="railtop"> 18 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "dashboard"}}aria-current="page" {{end}}href="/" aria-label="Dashboard" title="Dashboard">{{template "icon" "home"}}<span class="vh">Dashboard</span></a></li>{{end}}
20 <form method="get" action="/search" class="railsearch" role="search"> 19 <li><a class="railicon" {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore" aria-label="Explore" title="Explore">{{template "icon" "compass"}}<span class="vh">Explore</span></a></li>
21 <input type="search" name="q" aria-label="Search {{.Site}}" placeholder="Search"> 20 <li><a class="railicon" {{if eq (str . "Tab") "sitesearch"}}aria-current="page" {{end}}href="/search" aria-label="Search" title="Search">{{template "icon" "search"}}<span class="vh">Search</span></a></li>
22 </form> 21 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications" aria-label="Notifications" title="Notifications">{{template "icon" "bell"}}<span class="vh">Notifications</span>{{with .Rail.Unread}}<b class="count">{{.}}</b>{{end}}</a></li>
23 <ul class="raillist"> 22 <li><a class="railicon" {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks" aria-label="Bookmarks" title="Bookmarks">{{template "icon" "bookmark"}}<span class="vh">Bookmarks</span></a></li>
24 {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} 23 <li><a class="railicon" href="/new" aria-label="New repository" title="New repository">{{template "icon" "plus"}}<span class="vh">New repository</span></a></li>{{end}}
25 <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> 24 </ul>
26 {{if .Viewer}}<li><a {{if eq (str . "Tab") "notifications"}}aria-current="page" {{end}}href="/notifications">Notifications{{with .Rail.Unread}} <b class="count">{{.}}</b>{{end}}</a></li> 25 <span class="railgap"></span>
27 <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li> 26 <ul class="raillist">
28 <li><a class="newrepo" href="/new" aria-label="New repository" title="New repository">+</a></li>{{end}} 27 {{if .Viewer}}<li><a class="railicon" {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings" aria-label="Settings" title="Settings">{{template "icon" "gear"}}<span class="vh">Settings</span></a></li>{{end}}
29 </ul> 28 {{if .Admin}}<li><a class="railicon" {{if eq (str . "Tab") "admin"}}aria-current="page" {{end}}href="/admin" aria-label="Admin" title="Admin">{{template "icon" "shield"}}<span class="vh">Admin</span></a></li>{{end}}
30 </div>
31 {{with .Rail.Pinned}}
32 <div class="railgroup pinned">
33 <p class="raillabel" id="rail-pinned">Pinned</p>
34 <ul class="raillist" aria-labelledby="rail-pinned">
35 {{range .}}<li><a {{if eq $here (printf "%s/%s" .Owner .Name)}}aria-current="page" {{end}}href="/{{.Owner}}/{{.Name}}"><span class="owner">{{.Owner}}/</span>{{.Name}}</a></li>
36 {{end}}
37 </ul>
38 </div>
39 {{end}}
40 {{with .Rail.Reviews}}
41 <div class="railgroup">
42 <p class="raillabel" id="rail-reviews">Waiting on you <b class="count">{{len .}}</b></p>
43 <ul class="raillist wide" aria-labelledby="rail-reviews">
44 {{range .}}<li><a href="/{{.RepoPath}}/mrs/{{.Number}}"><b>!{{.Number}} {{.Title}}</b><span class="owner">{{.RepoPath}}</span></a></li>
45 {{end}}
46 </ul>
47 </div>
48 {{end}}
49 </div>
50 <ul class="raillist railpinned">
51 {{if .Viewer}}<li><a {{if eq (str . "Tab") "account"}}aria-current="page" {{end}}href="/settings">Settings</a></li>{{end}}
52 {{if .Admin}}<li><a {{if eq (str . "Tab") "admin"}}aria-current="page" {{end}}href="/admin">Admin</a></li>{{end}}
53 </ul> 29 </ul>
54 <div class="railfoot"> 30 <div class="railfoot">
55 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a> 31 {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
56 <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form> 32 <form method="post" action="/logout"><button type="submit" class="railicon" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></button></form>
57 {{else}}<a href="/login">Sign in</a>{{end}} 33 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
58 </div> 34 </div>
59</nav> 35</nav>
60 36
@@ -79,7 +55,7 @@
79 {{if eq $top "code"}} 55 {{if eq $top "code"}}
80 {{if or (field $ "Desc") (field $ "Topics")}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>{{end}} 56 {{if or (field $ "Desc") (field $ "Topics")}}<p class="repodesc">{{with field $ "Desc"}}{{.}}{{end}} {{with field $ "Topics"}}{{range .}}<a class="chip topic" href="/explore?q={{.}}">{{.}}</a> {{end}}{{end}}</p>{{end}}
81 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{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}} 57 {{if or $.Repo.Settings.Website (field $ "Mirrors")}}<p class="repometa">{{with $.Repo.Settings.Website}}<a href="{{.}}" rel="nofollow">{{.}}</a>{{end}}{{range $i, $m := field $ "Mirrors"}}{{if or $i $.Repo.Settings.Website}} · {{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}}
82 {{if $.Viewer}}<p class="toggles">Pinned shows in your rail. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}} 58 {{if $.Viewer}}<p class="toggles">Pinned shows on your dashboard. Watching sends every issue, request and build to your inbox. Bookmarked lists it under Bookmarks.</p>{{end}}
83 {{end}} 59 {{end}}
84 <nav class="tabs" aria-label="Repository"> 60 <nav class="tabs" aria-label="Repository">
85 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> 61 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
@@ -105,6 +81,23 @@
105</body> 81</body>
106</html>{{end}} 82</html>{{end}}
107 83
84{{/* icon draws one 16px glyph for the rail. The argument names it. The
85 paths are stroked with currentColor, so a link's own colour and the
86 current marker carry through, and every one is hidden from the
87 accessibility tree — the link around it carries the name. */}}
88{{define "icon"}}<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
89{{- if eq . "home"}}<path d="M2.25 7 8 2.5 13.75 7v6.25a.5.5 0 0 1-.5.5H2.75a.5.5 0 0 1-.5-.5Z"/><path d="M6.25 13.75V9.25h3.5v4.5"/>
90{{- else if eq . "search"}}<circle cx="6.75" cy="6.75" r="4.25"/><path d="m9.9 9.9 3.6 3.6"/>
91{{- else if eq . "bell"}}<path d="M4.25 7a3.75 3.75 0 0 1 7.5 0v2.25l1.25 2.25H3l1.25-2.25Z"/><path d="M6.5 11.5a1.5 1.5 0 0 0 3 0"/>
92{{- else if eq . "bookmark"}}<path d="M4 2.75h8v11L8 10.5l-4 3.25Z"/>
93{{- else if eq . "compass"}}<circle cx="8" cy="8" r="6"/><path d="m10.5 5.5-1.25 3.75-3.75 1.25 1.25-3.75Z"/>
94{{- else if eq . "plus"}}<path d="M8 3.25v9.5M3.25 8h9.5"/>
95{{- else if eq . "gear"}}<path d="M6.63 1.13L9.37 1.13L9.51 3.02L10.45 3.41L11.89 2.18L13.82 4.11L12.59 5.55L12.98 6.49L14.87 6.63L14.87 9.37L12.98 9.51L12.59 10.45L13.82 11.89L11.89 13.82L10.45 12.59L9.51 12.98L9.37 14.87L6.63 14.87L6.49 12.98L5.55 12.59L4.11 13.82L2.18 11.89L3.41 10.45L3.02 9.51L1.13 9.37L1.13 6.63L3.02 6.49L3.41 5.55L2.18 4.11L4.11 2.18L5.55 3.41L6.49 3.02Z"/><circle cx="8" cy="8" r="2.2"/>
96{{- else if eq . "shield"}}<path d="M8 1.75 13 3.5v4.25c0 3.1-2 5.4-5 6.5-3-1.1-5-3.4-5-6.5V3.5Z"/><path d="m5.9 7.75 1.6 1.6 2.85-3.1"/>
97{{- else if eq . "signout"}}<path d="M6.5 2.25H3.25a1 1 0 0 0-1 1v9.5a1 1 0 0 0 1 1H6.5"/><path d="M7.75 8h6M11.25 5.5 13.75 8l-2.5 2.5"/>
98{{- else if eq . "person"}}<circle cx="8" cy="5.5" r="2.75"/><path d="M2.75 14a5.25 5.25 0 0 1 10.5 0"/>
99{{- end}}</svg>{{end}}
100
108{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}} 101{{define "mark"}}<svg class="mark" width="19" height="19" viewBox="0 0 24 24" aria-hidden="true"><path d="M12 2.25 18.75 12H5.25z" fill="#ff6b3d"/><rect x="3" y="13.5" width="18" height="3" fill="currentColor"/><rect x="7.5" y="18" width="9" height="3" fill="currentColor"/></svg>{{end}}
109{{define "width"}}reading{{end}} 102{{define "width"}}reading{{end}}
110 103
internal/web/web_test.go +60
@@ -120,3 +120,63 @@ func TestMainWidthClass(t *testing.T) {
120 t.Error("layout.html: no default width") 120 t.Error("layout.html: no default width")
121 } 121 }
122} 122}
123
124// The rail carries no visible text, so every one of its controls has to
125// name itself twice over: an aria-label for the accessibility tree and a
126// visually hidden span for anything reading the DOM text, with the glyph
127// itself hidden from both so it is never announced as a graphic. This
128// parses layout.html, so an icon link that forgets one fails here rather
129// than on the page.
130func TestRailIconsAreLabelled(t *testing.T) {
131 src, err := TemplateSource("layout.html")
132 if err != nil {
133 t.Fatal(err)
134 }
135 // A control names its glyph either through the icon partial or with an
136 // svg of its own; the aria-hidden loop below covers both.
137 glyph := func(s string) bool {
138 return strings.Contains(s, `{{template "icon" `) || strings.Contains(s, "<svg")
139 }
140 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<button [^>]*class="railicon".*?</button>`)
141 controls := tagRe.FindAllString(src, -1)
142 if len(controls) < 7 {
143 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
144 }
145 for _, c := range controls {
146 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
147 if !strings.Contains(c, want) {
148 t.Errorf("railicon control missing %s: %.80s", want, c)
149 }
150 }
151 if !glyph(c) {
152 t.Errorf("railicon control draws no glyph: %.80s", c)
153 }
154 }
155
156 // Every button in the rail's foot is an icon button under the same
157 // rule: the Log out form's submit is the only one today.
158 foot := src[strings.Index(src, `<div class="railfoot">`):]
159 foot = foot[:strings.Index(foot, "</nav>")]
160 buttons := regexp.MustCompile(`(?s)<button.*?</button>`).FindAllString(foot, -1)
161 if len(buttons) == 0 {
162 t.Fatal("no button in the rail foot")
163 }
164 for _, b := range buttons {
165 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
166 if !strings.Contains(b, want) {
167 t.Errorf("rail foot button missing %s: %.80s", want, b)
168 }
169 }
170 if !glyph(b) {
171 t.Errorf("rail foot button draws no glyph: %.80s", b)
172 }
173 }
174
175 // No decorative graphic anywhere in the layout is exposed, the brand
176 // mark included: the link around it carries the name.
177 for _, svg := range regexp.MustCompile(`<svg[^>]*>`).FindAllString(src, -1) {
178 if !strings.Contains(svg, `aria-hidden="true"`) {
179 t.Errorf("svg without aria-hidden: %s", svg)
180 }
181 }
182}