web: the rail is an icon column, one row on a phone; pinned repositories on the dashboard !411
8 files changed, +197 −198
Layout: unified · split
e2e/dashboard_test.go +1 −1
| @@ -93,7 +93,7 @@ func TestDashboard(t *testing.T) { | ||
| 93 | 93 | t.Fatalf("dashboard: %d", status) |
| 94 | 94 | } |
| 95 | 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 | 97 | "Waiting on your review", "Assigned to you", "Recent activity", |
| 98 | 98 | "from bob", "alice/app!1", "todo one", "alice/app#1", |
| 99 | 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 | 13 | Name string |
| 14 | 14 | } |
| 15 | 15 | |
| 16 | // railItem is one merge request in the rail's review queue. | |
| 17 | type railItem struct { | |
| 18 | RepoPath string | |
| 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. | |
| 16 | // rail is the viewer's cross-repo state the layout needs. The rail itself | |
| 17 | // is icons only, so what is left is the unread count on its bell; the | |
| 18 | // pinned repositories it used to list are rendered by the dashboard. | |
| 27 | 19 | type rail struct { |
| 28 | Pinned []railRepo | |
| 29 | Reviews []railItem | |
| 30 | Unread int | |
| 20 | Pinned []railRepo | |
| 21 | 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. | |
| 35 | func (r rail) Empty() bool { return len(r.Pinned) == 0 && len(r.Reviews) == 0 } | |
| 36 | ||
| 37 | 24 | // basePage is what the layout needs on every page, repo or not. Page |
| 38 | 25 | // structs embed it so the rail and the site name are always in scope. |
| 39 | 26 | type basePage struct { |
| @@ -68,7 +55,7 @@ func (s *Server) baseFor(viewer store.User) basePage { | ||
| 68 | 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 | 59 | // dropping anything they may no longer read. |
| 73 | 60 | func (s *Server) railFor(viewer store.User) rail { |
| 74 | 61 | var rl rail |
| @@ -80,13 +67,5 @@ func (s *Server) railFor(viewer store.User) rail { | ||
| 80 | 67 | } |
| 81 | 68 | } |
| 82 | 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 | 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 | 54 | s.render(w, "globalsearch.html", struct { |
| 55 | 55 | basePage |
| 56 | Tab string | |
| 56 | 57 | Query string |
| 57 | 58 | Kind string |
| 58 | 59 | QueryErr string |
| 59 | 60 | Results []searchResult |
| 60 | }{s.baseFor(viewer), q, kind, queryErr, results}) | |
| 61 | }{s.baseFor(viewer), "sitesearch", q, kind, queryErr, results}) | |
| 61 | 62 | } |
| 62 | 63 | |
| 63 | 64 | func 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 | 190 | events, _ := s.st.RecentEvents(viewer.ID, 20, 0) |
| 191 | 191 | s.render(w, "dashboard.html", struct { |
| 192 | 192 | basePage |
| 193 | Tab string | |
| 193 | 194 | Reviews []store.DashboardItem |
| 194 | 195 | Assigned []store.DashboardItem |
| 195 | 196 | MRs []store.DashboardItem |
| 196 | 197 | Issues []store.DashboardItem |
| 197 | 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 | |
| 201 | 202 | func (s *Server) explore(w http.ResponseWriter, r *http.Request) { |
| @@ -211,9 +212,10 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) { | ||
| 211 | 212 | q := strings.TrimSpace(r.URL.Query().Get("q")) |
| 212 | 213 | s.render(w, "explore.html", struct { |
| 213 | 214 | basePage |
| 215 | Tab string | |
| 214 | 216 | Query string |
| 215 | 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 | 221 | // privacy renders the privacy page: what the gitbay software does with |
internal/web/static/style.css +89 −126
| @@ -119,6 +119,13 @@ | ||
| 119 | 119 | --sp-6: 32px; |
| 120 | 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 | 129 | --r-ctl: 4px; /* controls and chips */ |
| 123 | 130 | --r-card: 6px; /* cards, lists, code blocks */ |
| 124 | 131 | --shadow: 0 4px 16px rgba(0, 0, 0, 0.12); |
| @@ -244,10 +251,14 @@ a.skip { | ||
| 244 | 251 | } |
| 245 | 252 | a.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 | 259 | .rail { |
| 249 | 260 | flex: none; |
| 250 | width: 14rem; | |
| 261 | width: var(--rail-w); | |
| 251 | 262 | background: var(--shell-bg); |
| 252 | 263 | color: var(--shell-fg); |
| 253 | 264 | border-right: 1px solid var(--shell-line); |
| @@ -256,83 +267,55 @@ a.skip:focus { top: var(--sp-3); text-decoration: none; } | ||
| 256 | 267 | height: 100vh; |
| 257 | 268 | display: flex; |
| 258 | 269 | flex-direction: column; |
| 270 | align-items: center; | |
| 271 | gap: var(--sp-1); | |
| 272 | padding: var(--sp-3) 0; | |
| 259 | 273 | } |
| 260 | 274 | a.brand { |
| 261 | 275 | display: flex; |
| 262 | 276 | align-items: center; |
| 263 | gap: var(--sp-2); | |
| 264 | padding: var(--sp-4) var(--sp-4) var(--sp-3); | |
| 277 | justify-content: center; | |
| 278 | flex: none; | |
| 279 | width: var(--rail-hit); | |
| 280 | height: var(--rail-hit); | |
| 265 | 281 | color: var(--shell-fg); |
| 266 | font-weight: 600; | |
| 267 | font-size: var(--fs-3); | |
| 268 | 282 | } |
| 269 | 283 | a.brand:hover { text-decoration: none; } |
| 270 | a.brand svg.mark { display: block; flex: none; } | |
| 271 | a.brand span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } | |
| 272 | ||
| 273 | .railbody { flex: 1; overflow-y: auto; padding: 0 var(--sp-3); } | |
| 274 | .railtop { display: block; } | |
| 275 | /* search is reachable from every page, so it sits above the rail's links | |
| 276 | rather than on one page of its own */ | |
| 277 | form.railsearch { margin-bottom: var(--sp-3); } | |
| 278 | form.railsearch input[type="search"] { | |
| 279 | width: 100%; | |
| 280 | background: var(--shell-hover); | |
| 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); | |
| 284 | a.brand svg.mark { display: block; flex: none; width: var(--rail-mark); height: var(--rail-mark); } | |
| 285 | ||
| 286 | ul.raillist { | |
| 287 | list-style: none; | |
| 288 | margin: 0; | |
| 289 | padding: 0; | |
| 290 | display: flex; | |
| 291 | flex-direction: column; | |
| 292 | align-items: center; | |
| 293 | gap: var(--sp-1); | |
| 294 | min-width: 0; | |
| 288 | 295 | } |
| 289 | form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); } | |
| 290 | ul.raillist { list-style: none; margin: 0 0 var(--sp-4); padding: 0; } | |
| 291 | ul.raillist a { | |
| 296 | /* the spacer that holds settings, admin and the account cell at the far | |
| 297 | end of the rail in either orientation */ | |
| 298 | .railgap { flex: 1; } | |
| 299 | .railicon { | |
| 300 | position: relative; | |
| 292 | 301 | display: flex; |
| 293 | 302 | align-items: center; |
| 294 | gap: var(--sp-2); | |
| 295 | padding: 5px var(--sp-2); | |
| 303 | justify-content: center; | |
| 304 | flex: none; | |
| 305 | width: var(--rail-hit); | |
| 306 | height: var(--rail-hit); | |
| 307 | padding: 0; | |
| 308 | border: 0; | |
| 309 | background: none; | |
| 296 | 310 | color: var(--shell-fg); |
| 297 | font-size: var(--fs-2); | |
| 298 | 311 | border-radius: var(--r-ctl); |
| 299 | overflow: hidden; text-overflow: ellipsis; white-space: nowrap; | |
| 312 | cursor: pointer; | |
| 300 | 313 | } |
| 301 | ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; } | |
| 314 | .railicon:hover { background: var(--shell-hover); text-decoration: none; } | |
| 302 | 315 | /* orange marks position — the rail's only accent */ |
| 303 | ul.raillist a[aria-current] { | |
| 316 | .railicon[aria-current] { | |
| 304 | 317 | background: var(--shell-hover); |
| 305 | 318 | box-shadow: inset 3px 0 0 var(--shell-mark); |
| 306 | font-weight: 600; | |
| 307 | } | |
| 308 | ul.raillist .owner { color: var(--shell-muted); } | |
| 309 | ul.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 | } | |
| 321 | ul.raillist.wide a { white-space: normal; display: block; } | |
| 322 | ul.raillist.wide b { display: block; font-weight: 500; font-size: var(--fs-1); } | |
| 323 | ul.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 | 320 | .count { |
| 338 | 321 | font-size: var(--fs-0); |
| @@ -344,32 +327,35 @@ ul.raillist.wide .owner { display: block; font-size: var(--fs-0); } | ||
| 344 | 327 | border-radius: var(--r-ctl); |
| 345 | 328 | margin-left: auto; |
| 346 | 329 | } |
| 347 | /* pinned above the account cell: always reachable, never scrolled away */ | |
| 348 | .railpinned { | |
| 349 | flex: none; | |
| 350 | padding: var(--sp-2) var(--sp-3); | |
| 330 | /* the unread count is a badge on the bell's corner, not a trailing number */ | |
| 331 | .railicon .count { | |
| 332 | position: absolute; | |
| 333 | top: 2px; | |
| 334 | right: 2px; | |
| 351 | 335 | margin: 0; |
| 352 | border-top: 1px solid var(--shell-line); | |
| 336 | padding: 0 3px; | |
| 337 | line-height: 1.35; | |
| 353 | 338 | } |
| 354 | 339 | .railfoot { |
| 355 | 340 | flex: none; |
| 356 | border-top: 1px solid var(--shell-line); | |
| 357 | padding: var(--sp-3) var(--sp-4); | |
| 358 | 341 | display: flex; |
| 342 | flex-direction: column; | |
| 359 | 343 | align-items: center; |
| 360 | justify-content: space-between; | |
| 361 | gap: var(--sp-3); | |
| 362 | font-size: var(--fs-2); | |
| 344 | gap: var(--sp-1); | |
| 345 | min-width: 0; | |
| 363 | 346 | } |
| 364 | 347 | .railfoot a { color: var(--shell-fg); } |
| 365 | a.railuser { display: inline-flex; align-items: center; gap: var(--sp-2); } | |
| 366 | /* a descendant cannot cancel an ancestor's underline, so the link carries | |
| 367 | none and the name draws its own */ | |
| 348 | .railfoot form { margin: 0; } | |
| 349 | a.railuser { | |
| 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 | } | |
| 368 | 357 | a.railuser:hover { text-decoration: none; } |
| 369 | a.railuser:hover .uname { text-decoration: underline; } | |
| 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); } | |
| 358 | a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); } | |
| 373 | 359 | .avatar { |
| 374 | 360 | display: inline-grid; |
| 375 | 361 | place-items: center; |
| @@ -1005,6 +991,10 @@ pre.message { | ||
| 1005 | 991 | .blobimage { text-align: center; } |
| 1006 | 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 | 998 | /* ---- two-column: conversation and aside ---- */ |
| 1009 | 999 | .withaside { display: grid; grid-template-columns: minmax(0, 1fr) 18rem; gap: var(--sp-6); align-items: start; } |
| 1010 | 1000 | .withaside .mainside { min-width: 0; } |
| @@ -1466,60 +1456,27 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1466 | 1456 | } |
| 1467 | 1457 | |
| 1468 | 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 | 1462 | .shell { display: block; } |
| 1471 | 1463 | .rail { |
| 1472 | 1464 | width: auto; |
| 1473 | height: auto; | |
| 1465 | height: var(--rail-strip-h); | |
| 1474 | 1466 | position: static; |
| 1475 | 1467 | flex-direction: row; |
| 1476 | align-items: flex-start; | |
| 1477 | gap: var(--sp-2); | |
| 1468 | align-items: center; | |
| 1469 | gap: 0; | |
| 1470 | padding: 0 var(--sp-1); | |
| 1478 | 1471 | border-right: 0; |
| 1479 | 1472 | border-bottom: 1px solid var(--shell-line); |
| 1480 | overflow-x: auto; | |
| 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; | |
| 1473 | overflow: visible; | |
| 1499 | 1474 | } |
| 1500 | .railgroup.pinned .raillabel { | |
| 1501 | display: block; | |
| 1502 | margin: 0; | |
| 1503 | font-size: var(--fs-0); | |
| 1504 | color: var(--shell-muted); | |
| 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; } | |
| 1475 | a.brand { width: var(--rail-mark-box); height: var(--rail-strip-h); } | |
| 1476 | ul.raillist, .railfoot { flex-direction: row; gap: 0; } | |
| 1477 | .railicon, a.railuser { height: var(--rail-strip-h); border-radius: 0; } | |
| 1478 | /* the current item is marked along the bottom edge in a row */ | |
| 1479 | .railicon[aria-current] { box-shadow: inset 0 -2px 0 var(--shell-mark); } | |
| 1523 | 1480 | main.content { padding: var(--sp-4) var(--sp-4) var(--sp-5); } |
| 1524 | 1481 | footer { padding: var(--sp-4); } |
| 1525 | 1482 | .repohead { padding: var(--sp-3) var(--sp-4) 0; } |
| @@ -1551,3 +1508,9 @@ svg.icon { vertical-align: -0.125em; } | ||
| 1551 | 1508 | ul.loglist li { flex-wrap: wrap; } |
| 1552 | 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 | 17 | {{end}} |
| 18 | 18 | {{define "content"}} |
| 19 | 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 | 22 | <div class="withaside"> |
| 22 | 23 | <div class="mainside"> |
internal/web/templates/layout.html +34 −41
| @@ -12,49 +12,25 @@ | ||
| 12 | 12 | <a class="skip" href="#content">Skip to content</a> |
| 13 | 13 | <div class="shell"> |
| 14 | 14 | |
| 15 | {{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}} | |
| 16 | 15 | <nav class="rail" aria-label="Site"> |
| 17 | <a class="brand" href="/">{{template "mark"}}<span>{{.Site}}</span></a> | |
| 18 | <div class="railbody"> | |
| 19 | <div class="railtop"> | |
| 20 | <form method="get" action="/search" class="railsearch" role="search"> | |
| 21 | <input type="search" name="q" aria-label="Search {{.Site}}" placeholder="Search"> | |
| 22 | </form> | |
| 23 | <ul class="raillist"> | |
| 24 | {{if .Viewer}}<li><a href="/">Dashboard</a></li>{{end}} | |
| 25 | <li><a {{if eq (str . "Tab") "explore"}}aria-current="page" {{end}}href="/explore">Explore</a></li> | |
| 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> | |
| 27 | <li><a {{if eq (str . "Tab") "bookmarks"}}aria-current="page" {{end}}href="/bookmarks">Bookmarks</a></li> | |
| 28 | <li><a class="newrepo" href="/new" aria-label="New repository" title="New repository">+</a></li>{{end}} | |
| 29 | </ul> | |
| 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}} | |
| 16 | <a class="brand" href="/" aria-label="{{.Site}} home">{{template "mark"}}<span class="vh">{{.Site}}</span></a> | |
| 17 | <ul class="raillist"> | |
| 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}} | |
| 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> | |
| 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> | |
| 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> | |
| 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> | |
| 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}} | |
| 24 | </ul> | |
| 25 | <span class="railgap"></span> | |
| 26 | <ul class="raillist"> | |
| 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}} | |
| 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}} | |
| 53 | 29 | </ul> |
| 54 | 30 | <div class="railfoot"> |
| 55 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span><span class="uname">{{.Viewer}}</span></a> | |
| 56 | <form method="post" action="/logout"><button type="submit" class="linklike">Log out</button></form> | |
| 57 | {{else}}<a href="/login">Sign in</a>{{end}} | |
| 31 | {{if .Viewer}}<a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a> | |
| 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> | |
| 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 | 34 | </div> |
| 59 | 35 | </nav> |
| 60 | 36 | |
| @@ -79,7 +55,7 @@ | ||
| 79 | 55 | {{if eq $top "code"}} |
| 80 | 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 | 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 | 59 | {{end}} |
| 84 | 60 | <nav class="tabs" aria-label="Repository"> |
| 85 | 61 | <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a> |
| @@ -105,6 +81,23 @@ | ||
| 105 | 81 | </body> |
| 106 | 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 | 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 | 102 | {{define "width"}}reading{{end}} |
| 110 | 103 | |
internal/web/web_test.go +60
| @@ -120,3 +120,63 @@ func TestMainWidthClass(t *testing.T) { | ||
| 120 | 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. | |
| 130 | func 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 | } | |