web: the alignment and affordance sweep !456

merged merged by cmc on 2026-09-22 03:44 UTC · krz/gitbay:web-sweep-oct into main

14 files changed, +141 −41

Layout: unified · split

CHANGELOG.org +33 −2
@@ -19,12 +19,43 @@ The profile's tabs, reworked.
1919 goes back further.
2020- Bookmarks are a tab on your own profile. =repo bookmarks= takes no
2121 owner and lists the caller's, so the tab is not offered on anyone
22 else's and its URL is a 404 there. =/bookmarks= redirects to it, and
23 the rail points at the tab.
22 else's and its URL is a 404 there. =/bookmarks= redirects to it.
2423- The snippet list renders inside the profile rather than on a page of
2524 its own, so it carries the header and the tab bar like every other
2625 section. A snippet itself is still its own page.
2726
27Eleven web findings, mostly alignment and affordance (#247).
28
29- Logging out asks first. =GET /logout= is the confirmation page the
30 rail's signout square and the More menu link to; the button on it
31 posts to the same path, which is still what ends the session.
32- Bookmarks and Snippets are gone from the rail and the More menu. Both
33 land on the profile, where they are tabs, and every square left in
34 the rail is an instance-level destination.
35- A repository tree marks a directory with a folder glyph and a file
36 with a file glyph. The trailing slash still says it in text and the
37 glyphs are =aria-hidden=, so the cue no longer rests on the link
38 colour alone. Per-filetype icons are deliberately not done.
39- A =/search= hit carries its kind in a chip, in the words =search=
40 prints, so the meta line no longer has to say "repository".
41- A settings row lines its control up on the right: a checkbox, a
42 number and a full-width text input all end on the same rule, with
43 Save in its own column. The label column keeps a 14rem floor so a
44 hint does not wrap a word to a line.
45- The email rows on =/settings= put their buttons and confirm field on
46 the right, so they line up however long an address is.
47- =/admin/users= draws a disabled Promote on an account that is not
48 active, rather than leaving the cell short, and the page is wide. At
49 48rem the Actions cell clipped the second form's button, which left
50 its confirm field looking like it belonged to the button before it —
51 an active row read as though Promote wanted the name typed.
52- The profile's inactive tabs draw their underline in =--line=, so the
53 strip reads as a track the current tab is marked in.
54- =.listhead= sheds its children's block margins, which is what kept a
55 =p.meta= from sitting level with the heading beside it.
56- =nav.tabs= has a bottom margin.
57- The footer links the iOS client.
58
2859* v1.33.0 — 2026-09-21
2960
3061Five findings from the outside review of the web UI (#241–#245).
e2e/accounts_test.go +10
@@ -235,6 +235,16 @@ func TestWebAccounts(t *testing.T) {
235235 t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
236236 }
237237
238 // Logging out is confirmed first: the GET renders the page and leaves
239 // the session alone, and only the POST ends it.
240 status, body = browserGet(t, browser, inst.base()+"/logout")
241 if status != 200 || !strings.Contains(body, `action="/logout"`) {
242 t.Fatalf("logout confirmation: %d\n%s", status, body)
243 }
244 if status, _ = browserGet(t, browser, inst.base()+"/alice/webborn"); status != 200 {
245 t.Fatalf("GET /logout ended the session: %d", status)
246 }
247
238248 // Logout kills the session.
239249 if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
240250 t.Fatalf("logout: %d", status)
internal/httpd/accounts.go +9
@@ -167,6 +167,15 @@ func (s *Server) sessionCookieFor(tok string) *http.Cookie {
167167 }
168168}
169169
170// logoutForm is GET /logout: the confirmation the rail's signout square
171// and the More menu link to, so the session does not end on one stray
172// click. The button posts to the same path.
173func (s *Server) logoutForm(w http.ResponseWriter, r *http.Request, u store.User) {
174 s.render(w, "logout.html", struct {
175 basePage
176 }{s.baseFor(u)})
177}
178
170179func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
171180 if ck, err := r.Cookie(sessionCookie); err == nil {
172181 s.st.DeleteWebSession(store.HashToken(ck.Value))
internal/httpd/routes.go +1
@@ -117,6 +117,7 @@ func (s *Server) Routes() []Route {
117117 Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token
118118 Route{Method: "POST", Pattern: "/login", Mutating: true,
119119 Handler: s.checkOrigin(s.loginSubmit)},
120 Route{Method: "GET", Pattern: "/logout", Handler: s.requireUser(s.logoutForm)},
120121 Route{Method: "POST", Pattern: "/logout", Mutating: true,
121122 Handler: s.checkOrigin(s.logout)},
122123 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
internal/web/static/style.css +33 −4
@@ -480,6 +480,7 @@ nav.tabs {
480480 display: flex;
481481 gap: var(--sp-1);
482482 margin-top: var(--sp-3);
483 margin-bottom: var(--sp-4);
483484 overflow-x: auto;
484485 /* with overflow-y left visible the browser computes it to auto, which
485486 is a vertical drag on touch; the strip scrolls sideways only */
@@ -500,6 +501,12 @@ nav.tabs a {
500501}
501502nav.tabs a:hover { color: var(--fg); text-decoration: none; }
502503nav.tabs a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--mark); }
504/* the profile strip draws its unlit tabs, so the underline reads as the
505 control rather than appearing only on the one you are already on.
506 --line, not --faint: the track has to be visible to be a track, and
507 at 2px --faint disappears into the canvas in both schemes. */
508nav.tabs.profiletabs a { border-bottom-color: var(--line); }
509nav.tabs.profiletabs a[aria-current] { border-bottom-color: var(--mark); }
503510/* a count in a tab is plain metadata, not a badge */
504511nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
505512
@@ -511,7 +518,10 @@ nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
511518 flex-wrap: wrap;
512519 margin-bottom: var(--sp-5);
513520}
521/* every child sheds its block margins, or a p.meta beside an h1 centres
522 its text inside a box the margins made taller than the heading's */
514523.pagehead h1, .listhead h1, .headrow h1, .listhead h2, .headrow h2 { margin: 0; }
524.pagehead > p, .listhead > p, .headrow > p { margin: 0; }
515525.pagehead .grow, .listhead .spacer, .headrow .spacer { flex: 1; }
516526.issuetitle { margin: 0 0 var(--sp-2); font-size: var(--fs-5); }
517527.issuenumber { color: var(--muted); font-weight: 400; }
@@ -596,6 +606,9 @@ button.danger, .button.danger {
596606button.danger:hover, .button.danger:hover { background: var(--bad); color: var(--canvas); filter: none; }
597607button:disabled { opacity: 0.55; cursor: default; filter: none; }
598608.btngroup { display: inline-flex; gap: var(--sp-2); flex-wrap: wrap; }
609/* a destructive button holds the way out at arm's length: 8px puts the
610 cancel's underline against the button's border */
611.btngroup:has(> button.danger) { gap: var(--sp-4); }
599612form.inline { display: inline; }
600613.act { text-align: right; }
601614
@@ -758,10 +771,15 @@ details.editbox[open] > summary { margin-bottom: var(--sp-2); }
758771details.editbox input[type="text"] { width: 100%; }
759772
760773/* ---- settings: one row per control, label then input then its own
761 button. The name stays setform because the templates carry it. ---- */
774 button. The name stays setform because the templates carry it.
775 The label column takes the slack, so the control and the button ride
776 the right edge: a checkbox, a number and a full-width text input all
777 end on the same rule instead of the narrow ones stranding left. Its
778 14rem floor is what keeps a hint from wrapping a word to a line once
779 the control column claims its 28rem. ---- */
762780form.setform {
763781 display: grid;
764 grid-template-columns: 14rem minmax(0, 28rem) auto;
782 grid-template-columns: minmax(14rem, 1fr) minmax(0, 28rem) auto;
765783 gap: var(--sp-4);
766784 align-items: start;
767785 padding: var(--sp-3) 0;
@@ -769,7 +787,12 @@ form.setform {
769787}
770788form.setform label { margin-top: 6px; }
771789form.setform .hint { margin: 2px 0 0; }
772form.setform input[type="text"], form.setform input[type="number"], form.setform select { width: 100%; }
790form.setform input[type="text"], form.setform select { width: 100%; }
791/* a control narrower than its column is pushed to the column's end,
792 which is where a full-width input's own right edge lands */
793form.setform .check { justify-content: flex-end; }
794form.setform .num { text-align: right; }
795form.setform .num input[type="number"] { width: auto; }
773796/* the third column takes the leftover width; the button keeps its own */
774797form.setform > button, form.setform > .btngroup { justify-self: start; }
775798form.setform.stack { grid-template-columns: 1fr; }
@@ -786,6 +809,9 @@ ul.protlist li:last-child { border-bottom: 0; }
786809ul.protlist form { margin-left: auto; }
787810ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
788811ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
812/* the email list: address and badges read left, the controls sit right,
813 so the buttons line up down the list however long an address is */
814ul.plain li > form:first-of-type { margin-left: auto; }
789815
790816/* ---- messages: .error and .notice are one family. The names stay
791817 because e2e tests match class="error". ---- */
@@ -863,6 +889,9 @@ td.name a { color: var(--fg); }
863889td.name a:hover { color: var(--link); }
864890table.tree td.name { width: 25%; white-space: nowrap; }
865891table.tree td.name.dir a { color: var(--link); }
892/* the glyph stays muted in both rows: it is a shape cue, and colour here
893 goes on carrying what it already carries */
894table.tree td.name svg { color: var(--muted); vertical-align: -0.125em; margin-right: var(--sp-2); }
866895table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
867896table.tree td.lastcommit {
868897 max-width: 0;
@@ -1800,7 +1829,7 @@ svg.icon { vertical-align: -0.125em; }
18001829 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
18011830}
18021831
1803/* Eleven 44px squares and the mark are 524px, so below 34rem the rail
1832/* Nine 44px squares and the mark are 440px, so below 34rem the rail
18041833 keeps five — dashboard, search, notifications, More, your avatar — and
18051834 the More menu holds the rest. Shrinking the squares instead is what
18061835 this replaces: 32px was under the target every phone guideline asks
internal/web/templates/account.html +3 −3
@@ -133,21 +133,21 @@ account and where notifications go.</p>
133133<form method="post" action="/settings" class="setform">
134134 <input type="hidden" name="field" value="notify-mail">
135135 <label for="notify-mail">Activity by mail</label>
136 <input type="checkbox" id="notify-mail" name="mail" value="on"{{if .MailOn}} checked{{end}}>
136 <div class="check"><input type="checkbox" id="notify-mail" name="mail" value="on"{{if .MailOn}} checked{{end}}></div>
137137 <button type="submit" class="btn">Save</button>
138138</form>
139139<p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p>
140140<form method="post" action="/settings" class="setform">
141141 <input type="hidden" name="field" value="notify-watch">
142142 <label for="notify-watch">Watch repositories you can write to</label>
143 <input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}>
143 <div class="check"><input type="checkbox" id="notify-watch" name="watch" value="on"{{if .WatchOn}} checked{{end}}></div>
144144 <button type="submit" class="btn">Save</button>
145145</form>
146146<p class="meta">Alerts for every issue and merge request on those repositories. A watch or mute on a repository has priority over this setting.</p>
147147<form method="post" action="/settings" class="setform">
148148 <input type="hidden" name="field" value="notify-push">
149149 <label for="notify-push">Activity on your registered devices</label>
150 <input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}>
150 <div class="check"><input type="checkbox" id="notify-push" name="push" value="on"{{if .PushOn}} checked{{end}}></div>
151151 <button type="submit" class="btn">Save</button>
152152</form>
153153<p class="meta">Notification text is sent in full, including for private repositories, so a repository name and item number reach Apple and appear on a lock screen.</p>
internal/web/templates/adminusers.html +13 −1
@@ -1,4 +1,8 @@
1{{define "width"}}bounded{{end}}
1{{/* wide, because the Actions cell carries up to two forms, each an
2 11rem confirm field and a button: about 1100px of row, which 48rem
3 clipped. What it clipped was the second form's button, leaving its
4 confirm field looking like it belonged to the button before it. */}}
5{{define "width"}}wide{{end}}
26{{define "title"}}accounts · admin · {{.Site}}{{end}}
37{{define "content"}}
48<h1>Accounts</h1>
@@ -36,6 +40,14 @@
3640 <input type="hidden" name="state" value="{{$.State}}">
3741 <button type="submit" class="btn">Promote</button>
3842 </form>
43 {{else}}
44 {{/* An account that is not active cannot be promoted, but the row
45 still draws the control so every Actions cell is the same
46 shape. Both parts are disabled, so the form never posts. */}}
47 <form method="post" action="/admin/users" class="actions" title="Only an active account can be promoted">
48 <input type="text" name="confirm" aria-label="Type {{.Username}} to confirm" placeholder="type {{.Username}} to confirm" size="{{len .Username}}" autocomplete="off" disabled>
49 <button type="submit" class="btn" disabled>Promote</button>
50 </form>
3951 {{end}}
4052 {{if eq .State "disabled"}}
4153 <form method="post" action="/admin/users" class="actions">
internal/web/templates/globalsearch.html +4 −1
@@ -31,8 +31,11 @@
3131{{range .Results}}<li>
3232 <div class="issuemain">
3333 <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p>
34 <p class="meta">{{if .Number}}<a href="/{{.Repo}}">{{.Repo}}</a>{{.Marker}}{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{else if .Title}}{{.Title}}{{else}}repository{{end}}</p>
34 <p class="meta">{{if .Number}}<a href="/{{.Repo}}">{{.Repo}}</a>{{.Marker}}{{.Number}} opened by <a href="/{{.Author}}">{{.Author}}</a>{{else if .Title}}{{.Title}}{{end}}</p>
3535 </div>
36 {{/* the kind in the words the CLI prints, so a hit reads the same in
37 both places; it is why the meta no longer says "repository" */}}
38 <span class="chip chip-neutral">{{.Kind}}</span>
3639 {{if .State}}<span class="chip chip-{{.State}}">{{.State}}</span>{{end}}
3740</li>
3841{{end}}
internal/web/templates/layout.html +5 −7
@@ -22,8 +22,6 @@
2222 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
2323 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
2424 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li>
25 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/bookmarks" .Viewer) "Icon" "bookmark" "Name" "Bookmarks" "Current" (eq (str . "Tab") "bookmarks")}}</li>
26 <li class="railopt">{{template "raillink" dict "Href" (printf "/%s/-/snippets" .Viewer) "Icon" "snippet" "Name" "Snippets" "Current" (eq (str . "Tab") "snippets")}}</li>
2725 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
2826 </ul>
2927 <span class="railgap"></span>
@@ -36,16 +34,14 @@
3634 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
3735 <div class="raildrop">
3836 <a href="/explore">{{template "icon" "compass"}} Explore</a>
39 <a href="/{{.Viewer}}/-/bookmarks">{{template "icon" "bookmark"}} Bookmarks</a>
40 <a href="/{{.Viewer}}/-/snippets">{{template "icon" "snippet"}} Snippets</a>
4137 <a href="/new">{{template "icon" "plus"}} New repository</a>
4238 <a href="/settings">{{template "icon" "gear"}} Settings</a>
4339 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}}
44 <form method="post" action="/logout"><button type="submit">{{template "icon" "signout"}} Log out</button></form>
40 <a href="/logout">{{template "icon" "signout"}} Log out</a>
4541 </div>
4642 </details>
4743 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a>
48 <form class="railopt" 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>
44 <a class="railopt railicon" href="/logout" aria-label="Log out" title="Log out">{{template "icon" "signout"}}<span class="vh">Log out</span></a>
4945 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
5046 </div>
5147</nav>
@@ -88,7 +84,7 @@
8884</main>
8985
9086<footer>
91 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="/privacy">Privacy</a></p>
87 <p>Powered by <a href="https://gitbay.org/krz/gitbay">gitbay</a>{{with gitbayVersion}} · <code><a href="https://gitbay.org/krz/gitbay/commit/{{gitbayCommit}}">{{.}}</a></code>{{end}} · <a href="https://gitbay.org/krz/gitbay-ios">iOS app</a> · <a href="/privacy">Privacy</a></p>
9288</footer>
9389</div>
9490</div>
@@ -112,6 +108,8 @@
112108{{- else if eq . "ellipsis"}}<circle cx="3.25" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="8" cy="8" r="1.1" fill="currentColor" stroke="none"/><circle cx="12.75" cy="8" r="1.1" fill="currentColor" stroke="none"/>
113109{{- else if eq . "snippet"}}<path d="M3.25 2.75h6L12.75 6v7.25a.5.5 0 0 1-.5.5H3.75a.5.5 0 0 1-.5-.5Z"/><path d="M9 2.75V6h3.75"/><path d="m6.75 8.75-1.5 1.5 1.5 1.5M9.75 8.75l1.5 1.5-1.5 1.5"/>
114110{{- 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"/>
111{{- else if eq . "folder"}}<path d="M1.75 3.25h4l1.5 1.75h6.5v7.5a.5.5 0 0 1-.5.5H2.25a.5.5 0 0 1-.5-.5Z"/>
112{{- else if eq . "file"}}<path d="M3.25 2.75h6L12.75 6v7.25a.5.5 0 0 1-.5.5H3.75a.5.5 0 0 1-.5-.5Z"/><path d="M9 2.75V6h3.75"/>
115113{{- end}}</svg>{{end}}
116114
117115{{/* raillink draws one square in the rail: the icon, the name for a
internal/web/templates/logout.html added +9
@@ -0,0 +1,9 @@
1{{define "width"}}bounded{{end}}
2{{define "title"}}log out · {{.Site}}{{end}}
3{{define "content"}}
4<h1>Log out</h1>
5<p class="meta">Ends this browser's session as <a href="/{{.Viewer}}">{{.Viewer}}</a>. Keys and tokens are untouched.</p>
6<form method="post" action="/logout">
7<p class="btngroup"><button type="submit" class="danger">Log out</button> <a href="/">Cancel</a></p>
8</form>
9{{end}}
internal/web/templates/owner.html +1 −1
@@ -13,7 +13,7 @@
1313 push the repositories off the bottom of the page (#242). About is
1414 the bare /{owner}; the rest hang off /-/. A tab nobody may open is
1515 not offered and its URL is a 404. */}}
16<nav class="tabs" aria-label="Profile">
16<nav class="tabs profiletabs" aria-label="Profile">
1717 <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a>
1818 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
1919 {{if .Self}}<a {{if eq .Tab "bookmarks"}}aria-current="page" {{end}}href="/{{.Owner}}/-/bookmarks">Bookmarks</a>{{end}}
internal/web/templates/settings.html +1 −1
@@ -79,7 +79,7 @@
7979<form method="post" action="{{$base}}" class="setform">
8080 <input type="hidden" name="field" value="require-approvals">
8181 <div><label for="approvals">Approvals</label><p class="hint">Approvals from anyone with write access. Zero means none required.</p></div>
82 <div><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div>
82 <div class="num"><input type="number" id="approvals" name="approvals" min="0" max="10" value="{{.Repo.Settings.RequireApprovals}}"></div>
8383 <div><button type="submit" class="btn">Save</button></div>
8484</form>
8585<form method="post" action="{{$base}}" class="setform">
internal/web/templates/tree.html +5 −2
@@ -19,8 +19,11 @@
1919{{with .Tip}}{{if .SHA}}<tr class="tipbar"><td colspan="3"><div class="tip"><span class="who">{{template "authorname" dict "Name" .Author "User" .User "Email" .Email}}</span> <a class="subject" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Subject}}</a><span class="spacer"></span><a class="sha" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}"><code>{{short .SHA}}</code></a> <span class="age"{{if not .When.IsZero}} title="{{whenT .When}}"{{end}}>{{ago .When}}</span></div></td></tr>{{end}}{{end}}
2020<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
2121{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr>
22 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}/</a></td>
23 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}}
22 {{/* the glyph is decorative: the trailing slash is what says directory
23 to anything reading the text, and the icon repeats it in shape so
24 the cue does not rest on the link colour alone */}}
25 {{if eq .Type "tree"}}<td class="name dir"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/tree/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{template "icon" "folder"}}{{.Name}}/</a></td>
26 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{template "icon" "file"}}{{.Name}}</a></td>{{end}}
2427 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td>
2528 <td class="age"{{if not $c.When.IsZero}} title="{{whenT $c.When}}"{{end}}>{{ago $c.When}}</td>
2629</tr>
internal/web/web_test.go +14 −19
@@ -89,8 +89,8 @@ func TestWhenNamesTheZone(t *testing.T) {
8989// none. The merge request page picks wide for its diff view, so it gets
9090// a per-view define instead of a fixed one.
9191func TestMainWidthClass(t *testing.T) {
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "adminusers.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
92 wide := map[string]bool{"tree.html": true, "blob.html": true, "blame.html": true, "log.html": true, "commit.html": true, "compare.html": true, "builds.html": true, "build.html": true, "search.html": true, "globalsearch.html": true, "edit.html": true, "dashboard.html": true, "issues.html": true, "mrs.html": true, "explore.html": true, "notifications.html": true, "settings.html": true, "account.html": true, "admin.html": true, "adminusers.html": true}
93 bounded := map[string]bool{"landing.html": true, "fork.html": true, "login.html": true, "logout.html": true, "register.html": true, "registered.html": true, "new.html": true, "issuenew.html": true, "mrnew.html": true, "snippetnew.html": true, "privacy.html": true, "404.html": true}
9494 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
9595 for _, name := range Pages() {
9696 src, err := TemplateSource(name)
@@ -160,7 +160,13 @@ func TestRailIconsAreLabelled(t *testing.T) {
160160 }
161161 }
162162
163 tagRe := regexp.MustCompile(`(?s)<a class="railicon".*?</a>|<summary class="railicon".*?</summary>|<button [^>]*class="railicon".*?</button>`)
163 // Every railicon control in the file, wherever it sits and whatever
164 // else its class carries — the strip, the More summary, and the
165 // foot's Log out and Sign in. Log out used to be a button and had a
166 // check of its own here; it is a link to the confirmation page now,
167 // and this loop covers the foot either way. The More menu's own rows
168 // are not icon controls — their visible text is their name.
169 tagRe := regexp.MustCompile(`(?s)<a [^>]*class="[^"]*railicon.*?</a>|<summary [^>]*class="[^"]*railicon.*?</summary>|<button [^>]*class="[^"]*railicon.*?</button>`)
164170 controls := tagRe.FindAllString(src, -1)
165171 if len(controls) < 3 {
166172 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls))
@@ -176,24 +182,13 @@ func TestRailIconsAreLabelled(t *testing.T) {
176182 }
177183 }
178184
179 // An icon button in the rail's foot is under the same rule: Log out
180 // is the only one today. The More menu's own rows are not icon
181 // buttons — their visible text is their name.
185 // The foot holds controls of its own rather than raillink squares, so
186 // check it is in what tagRe just scanned: a foot written with none
187 // would pass the loop above by being empty.
182188 foot := src[strings.Index(src, `<div class="railfoot">`):]
183189 foot = foot[:strings.Index(foot, "</nav>")]
184 buttons := regexp.MustCompile(`(?s)<button [^>]*class="railicon".*?</button>`).FindAllString(foot, -1)
185 if len(buttons) == 0 {
186 t.Fatal("no icon button in the rail foot")
187 }
188 for _, b := range buttons {
189 for _, want := range []string{`aria-label="`, `<span class="vh">`} {
190 if !strings.Contains(b, want) {
191 t.Errorf("rail foot button missing %s: %.80s", want, b)
192 }
193 }
194 if !glyph(b) {
195 t.Errorf("rail foot button draws no glyph: %.80s", b)
196 }
190 if len(tagRe.FindAllString(foot, -1)) == 0 {
191 t.Error("no railicon control in the rail foot")
197192 }
198193
199194 // No decorative graphic anywhere in the layout is exposed, the brand