Commit fc374bc57b

fc374bc57b28b530946263738fdfa309ebae9165

parent: 2f6b4f2de7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-18 21:04 UTC

web: the rail is an icon column

One glyph per destination, drawn inline in an icon partial: dashboard,
explore, search, notifications, bookmarks, new repository, settings,
admin, profile and log out. Each link carries an aria-label, a visually
hidden name and an aria-hidden svg, so the nav reads as it did when it
was a text list. The rail is 3.5rem wide, sizes come from --rail-*
tokens, and the search field moves out to /search, which the rail links.
The pinned and review groups leave the rail; dashboard, explore and the
global search page now set a Tab so the current item is marked.

Ref #220
internal/httpd/page.go +6 −27
@@ -13,27 +13,14 @@ type railRepo struct {
1313 Name string
1414}
1515
16// railItem is one merge request in the rail's review queue.
17type 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.
2719type rail struct {
28 Pinned []railRepo
29 Reviews []railItem
30 Unread int
20 Pinned []railRepo
21 Unread int
3122}
3223
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
3724// basePage is what the layout needs on every page, repo or not. Page
3825// structs embed it so the rail and the site name are always in scope.
3926type basePage struct {
@@ -68,7 +55,7 @@ func (s *Server) baseFor(viewer store.User) basePage {
6855 return b
6956}
7057
71// railFor collects the viewer's pinned repositories and review queue,
58// railFor collects the viewer's pinned repositories and unread count,
7259// dropping anything they may no longer read.
7360func (s *Server) railFor(viewer store.User) rail {
7461 var rl rail
@@ -80,13 +67,5 @@ func (s *Server) railFor(viewer store.User) rail {
8067 }
8168 }
8269 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 }
9170 return rl
9271}
internal/httpd/searchweb.go +2 −1
@@ -53,11 +53,12 @@ func (s *Server) globalSearch(w http.ResponseWriter, r *http.Request) {
5353 }
5454 s.render(w, "globalsearch.html", struct {
5555 basePage
56 Tab string
5657 Query string
5758 Kind string
5859 QueryErr string
5960 Results []searchResult
60 }{s.baseFor(viewer), q, kind, queryErr, results})
61 }{s.baseFor(viewer), "sitesearch", q, kind, queryErr, results})
6162}
6263
6364func 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.
190190 events, _ := s.st.RecentEvents(viewer.ID, 20, 0)
191191 s.render(w, "dashboard.html", struct {
192192 basePage
193 Tab string
193194 Reviews []store.DashboardItem
194195 Assigned []store.DashboardItem
195196 MRs []store.DashboardItem
196197 Issues []store.DashboardItem
197198 Feed []feedLine
198 }{s.baseFor(viewer), reviews, assigned, mrs, issues, feedLines(events)})
199 }{s.baseFor(viewer), "dashboard", reviews, assigned, mrs, issues, feedLines(events)})
199200}
200201
201202func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
@@ -211,9 +212,10 @@ func (s *Server) explore(w http.ResponseWriter, r *http.Request) {
211212 q := strings.TrimSpace(r.URL.Query().Get("q"))
212213 s.render(w, "explore.html", struct {
213214 basePage
215 Tab string
214216 Query string
215217 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))})
217219}
218220
219221// privacy renders the privacy page: what the gitbay software does with
internal/web/static/style.css +66 −80
@@ -119,6 +119,13 @@
119119 --sp-6: 32px;
120120 --sp-7: 48px;
121121
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
122129 --r-ctl: 4px; /* controls and chips */
123130 --r-card: 6px; /* cards, lists, code blocks */
124131 --shadow: 0 4px 16px rgba(0, 0, 0, 0.12);
@@ -244,10 +251,14 @@ a.skip {
244251}
245252a.skip:focus { top: var(--sp-3); text-decoration: none; }
246253
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. */
248259.rail {
249260 flex: none;
250 width: 14rem;
261 width: var(--rail-w);
251262 background: var(--shell-bg);
252263 color: var(--shell-fg);
253264 border-right: 1px solid var(--shell-line);
@@ -256,83 +267,55 @@ a.skip:focus { top: var(--sp-3); text-decoration: none; }
256267 height: 100vh;
257268 display: flex;
258269 flex-direction: column;
270 align-items: center;
271 gap: var(--sp-1);
272 padding: var(--sp-3) 0;
259273}
260274a.brand {
261275 display: flex;
262276 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);
265281 color: var(--shell-fg);
266 font-weight: 600;
267 font-size: var(--fs-3);
268282}
269283a.brand:hover { text-decoration: none; }
270a.brand svg.mark { display: block; flex: none; }
271a.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 */
277form.railsearch { margin-bottom: var(--sp-3); }
278form.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);
284a.brand svg.mark { display: block; flex: none; width: var(--rail-mark); height: var(--rail-mark); }
285
286ul.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;
288295}
289form.railsearch input[type="search"]::placeholder { color: var(--shell-muted); }
290ul.raillist { list-style: none; margin: 0 0 var(--sp-4); padding: 0; }
291ul.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;
292301 display: flex;
293302 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;
296310 color: var(--shell-fg);
297 font-size: var(--fs-2);
298311 border-radius: var(--r-ctl);
299 overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
312 cursor: pointer;
300313}
301ul.raillist a:hover { background: var(--shell-hover); text-decoration: none; }
314.railicon:hover { background: var(--shell-hover); text-decoration: none; }
302315/* orange marks position — the rail's only accent */
303ul.raillist a[aria-current] {
316.railicon[aria-current] {
304317 background: var(--shell-hover);
305318 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);
336319}
337320.count {
338321 font-size: var(--fs-0);
@@ -344,32 +327,35 @@ ul.raillist.wide .owner { display: block; font-size: var(--fs-0); }
344327 border-radius: var(--r-ctl);
345328 margin-left: auto;
346329}
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;
351335 margin: 0;
352 border-top: 1px solid var(--shell-line);
336 padding: 0 3px;
337 line-height: 1.35;
353338}
354339.railfoot {
355340 flex: none;
356 border-top: 1px solid var(--shell-line);
357 padding: var(--sp-3) var(--sp-4);
358341 display: flex;
342 flex-direction: column;
359343 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;
363346}
364347.railfoot a { color: var(--shell-fg); }
365a.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; }
349a.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}
368357a.railuser:hover { text-decoration: none; }
369a.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); }
358a.railuser .avatar { width: var(--rail-mark); height: var(--rail-mark); }
373359.avatar {
374360 display: inline-grid;
375361 place-items: center;
internal/web/templates/layout.html +34 −41
@@ -12,49 +12,25 @@
1212<a class="skip" href="#content">Skip to content</a>
1313<div class="shell">
1414
15{{$here := ""}}{{with field . "Repo"}}{{$here = printf "%s/%s" .OwnerName .Name}}{{end}}
1615<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}}
5329 </ul>
5430 <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}}
5834 </div>
5935</nav>
6036
@@ -79,7 +55,7 @@
7955 {{if eq $top "code"}}
8056 {{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}}
8157 {{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}}
8359 {{end}}
8460 <nav class="tabs" aria-label="Repository">
8561 <a {{if eq $top "code"}}aria-current="page" {{end}}href="/{{.OwnerName}}/{{.Name}}">Code</a>
@@ -105,6 +81,23 @@
10581</body>
10682</html>{{end}}
10783
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"}}<circle cx="8" cy="8" r="3"/><path d="M8 1.5v1.75M8 12.75v1.75M1.5 8h1.75M12.75 8h1.75M3.4 3.4l1.25 1.25M10.35 10.35l1.25 1.25M12.6 3.4l-1.25 1.25M4.65 10.35 3.4 11.6"/>
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
108101{{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}}
109102{{define "width"}}reading{{end}}
110103