Commit 419f6dfdc5

419f6dfdc5489a0c6374e36dd1ebbfca68040056

parent: 4e509e8b58

Verified · cmc ci/build: success ci/test: success

cmc <hello@cleberg.net> · 2026-09-22 03:18 UTC

web: the alignment and affordance sweep

Logging out asks first: GET /logout is a confirmation page, and the
rail's signout square and the More menu link to it rather than posting.
The POST is untouched and is still what ends the session. Log out being
a link retires the rail-foot button check in TestRailIconsAreLabelled —
the loop above it matches an <a class="railicon"> wherever it sits, so
the foot is covered either way, and what replaces the block is an
assertion that the foot holds a control at all.

Bookmarks and Snippets leave the rail and the More menu. Both land on
the profile, where #242 made them tabs, so every square left in the
rail is an instance-level destination and the avatar is the door to the
profile. Nine squares and the mark, not eleven.

A tree marks a directory with a folder glyph and a file with a file
glyph, muted in both rows: a directory was distinguished by the link
colour and a trailing slash, and the slash is still the text cue with
the glyphs aria-hidden. Per-filetype icons want an extension-to-glyph
map and would spend colour on filetypes, which the two accents are not
for.

A settings row lines its control up on the right. The label column
takes the slack, so a checkbox, a number and a full-width text input
all end on one rule with Save in its own column; its 14rem floor is
what keeps a hint from wrapping a word to a line once the control
column claims its 28rem. account.html's bare checkboxes are wrapped in
.check to match the repo page rather than growing a second selector.
The email rows go the same way, with the confirm field travelling with
the button it belongs to.

The rest: a /search hit carries its kind in a chip, in the words the
CLI prints, which is why the meta line no longer says "repository";
/admin/users draws a disabled Promote on an account that is not active;
the profile's inactive tabs underline in --line, the token whose job is
a border you can see, since --faint at 2px disappears into the canvas;
.listhead sheds its children's block margins, which is what kept a
p.meta from sitting level with the heading; nav.tabs has a bottom
margin; and the footer links the iOS client.

The changelog's line about the rail pointing at the bookmarks tab goes
with the square it described.

Closes #247
CHANGELOG.org +30 −2
@@ -19,12 +19,40 @@ 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.
49- The profile's inactive tabs draw their underline in =--line=, so the
50 strip reads as a track the current tab is marked in.
51- =.listhead= sheds its children's block margins, which is what kept a
52 =p.meta= from sitting level with the heading beside it.
53- =nav.tabs= has a bottom margin.
54- The footer links the iOS client.
55
2856* v1.33.0 — 2026-09-21
2957
3058Five 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 +30 −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; }
@@ -758,10 +768,15 @@ details.editbox[open] > summary { margin-bottom: var(--sp-2); }
758768details.editbox input[type="text"] { width: 100%; }
759769
760770/* ---- settings: one row per control, label then input then its own
761 button. The name stays setform because the templates carry it. ---- */
771 button. The name stays setform because the templates carry it.
772 The label column takes the slack, so the control and the button ride
773 the right edge: a checkbox, a number and a full-width text input all
774 end on the same rule instead of the narrow ones stranding left. Its
775 14rem floor is what keeps a hint from wrapping a word to a line once
776 the control column claims its 28rem. ---- */
762777form.setform {
763778 display: grid;
764 grid-template-columns: 14rem minmax(0, 28rem) auto;
779 grid-template-columns: minmax(14rem, 1fr) minmax(0, 28rem) auto;
765780 gap: var(--sp-4);
766781 align-items: start;
767782 padding: var(--sp-3) 0;
@@ -769,7 +784,12 @@ form.setform {
769784}
770785form.setform label { margin-top: 6px; }
771786form.setform .hint { margin: 2px 0 0; }
772form.setform input[type="text"], form.setform input[type="number"], form.setform select { width: 100%; }
787form.setform input[type="text"], form.setform select { width: 100%; }
788/* a control narrower than its column is pushed to the column's end,
789 which is where a full-width input's own right edge lands */
790form.setform .check { justify-content: flex-end; }
791form.setform .num { text-align: right; }
792form.setform .num input[type="number"] { width: auto; }
773793/* the third column takes the leftover width; the button keeps its own */
774794form.setform > button, form.setform > .btngroup { justify-self: start; }
775795form.setform.stack { grid-template-columns: 1fr; }
@@ -786,6 +806,9 @@ ul.protlist li:last-child { border-bottom: 0; }
786806ul.protlist form { margin-left: auto; }
787807ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
788808ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
809/* the email list: address and badges read left, the controls sit right,
810 so the buttons line up down the list however long an address is */
811ul.plain li > form:first-of-type { margin-left: auto; }
789812
790813/* ---- messages: .error and .notice are one family. The names stay
791814 because e2e tests match class="error". ---- */
@@ -863,6 +886,9 @@ td.name a { color: var(--fg); }
863886td.name a:hover { color: var(--link); }
864887table.tree td.name { width: 25%; white-space: nowrap; }
865888table.tree td.name.dir a { color: var(--link); }
889/* the glyph stays muted in both rows: it is a shape cue, and colour here
890 goes on carrying what it already carries */
891table.tree td.name svg { color: var(--muted); vertical-align: -0.125em; margin-right: var(--sp-2); }
866892table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
867893table.tree td.lastcommit {
868894 max-width: 0;
@@ -1800,7 +1826,7 @@ svg.icon { vertical-align: -0.125em; }
18001826 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
18011827}
18021828
1803/* Eleven 44px squares and the mark are 524px, so below 34rem the rail
1829/* Nine 44px squares and the mark are 440px, so below 34rem the rail
18041830 keeps five dashboard, search, notifications, More, your avatar and
18051831 the More menu holds the rest. Shrinking the squares instead is what
18061832 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 +8
@@ -36,6 +36,14 @@
3636 <input type="hidden" name="state" value="{{$.State}}">
3737 <button type="submit" class="btn">Promote</button>
3838 </form>
39 {{else}}
40 {{/* An account that is not active cannot be promoted, but the row
41 still draws the control so every Actions cell is the same
42 shape. Both parts are disabled, so the form never posts. */}}
43 <form method="post" action="/admin/users" class="actions" title="Only an active account can be promoted">
44 <input type="text" name="confirm" aria-label="Type {{.Username}} to confirm" placeholder="type {{.Username}} to confirm" size="{{len .Username}}" autocomplete="off" disabled>
45 <button type="submit" class="btn" disabled>Promote</button>
46 </form>
3947 {{end}}
4048 {{if eq .State "disabled"}}
4149 <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, and signing back in is a mailed link.</p>
6<form method="post" action="/logout">
7<p><button type="submit">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 +13 −18
@@ -90,7 +90,7 @@ func TestWhenNamesTheZone(t *testing.T) {
9090// a per-view define instead of a fixed one.
9191func TestMainWidthClass(t *testing.T) {
9292 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}
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, "adminusers.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