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.
19 goes back further. 19 goes back further.
20- Bookmarks are a tab on your own profile. =repo bookmarks= takes no 20- Bookmarks are a tab on your own profile. =repo bookmarks= takes no
21 owner and lists the caller's, so the tab is not offered on anyone 21 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 22 else's and its URL is a 404 there. =/bookmarks= redirects to it.
23 the rail points at the tab.
24- The snippet list renders inside the profile rather than on a page of 23- The snippet list renders inside the profile rather than on a page of
25 its own, so it carries the header and the tab bar like every other 24 its own, so it carries the header and the tab bar like every other
26 section. A snippet itself is still its own page. 25 section. A snippet itself is still its own page.
27 26
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
28* v1.33.0 — 2026-09-21 59* v1.33.0 — 2026-09-21
29 60
30Five findings from the outside review of the web UI (#241–#245). 61Five 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) {
235 t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode) 235 t.Fatalf("cross-origin POST: %d, want 403", resp.StatusCode)
236 } 236 }
237 237
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
238 // Logout kills the session. 248 // Logout kills the session.
239 if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 { 249 if status, _ = browserPost(t, browser, inst.base()+"/logout", url.Values{}); status != 200 {
240 t.Fatalf("logout: %d", status) 250 t.Fatalf("logout: %d", status)
internal/httpd/accounts.go +9
@@ -167,6 +167,15 @@ func (s *Server) sessionCookieFor(tok string) *http.Cookie {
167 } 167 }
168} 168}
169 169
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
170func (s *Server) logout(w http.ResponseWriter, r *http.Request) { 179func (s *Server) logout(w http.ResponseWriter, r *http.Request) {
171 if ck, err := r.Cookie(sessionCookie); err == nil { 180 if ck, err := r.Cookie(sessionCookie); err == nil {
172 s.st.DeleteWebSession(store.HashToken(ck.Value)) 181 s.st.DeleteWebSession(store.HashToken(ck.Value))
internal/httpd/routes.go +1
@@ -117,6 +117,7 @@ func (s *Server) Routes() []Route {
117 Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token 117 Route{Method: "GET", Pattern: "/login", Handler: s.login, Mutating: true}, // consumes a one-time token
118 Route{Method: "POST", Pattern: "/login", Mutating: true, 118 Route{Method: "POST", Pattern: "/login", Mutating: true,
119 Handler: s.checkOrigin(s.loginSubmit)}, 119 Handler: s.checkOrigin(s.loginSubmit)},
120 Route{Method: "GET", Pattern: "/logout", Handler: s.requireUser(s.logoutForm)},
120 Route{Method: "POST", Pattern: "/logout", Mutating: true, 121 Route{Method: "POST", Pattern: "/logout", Mutating: true,
121 Handler: s.checkOrigin(s.logout)}, 122 Handler: s.checkOrigin(s.logout)},
122 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)}, 123 Route{Method: "GET", Pattern: "/new", Handler: s.requireUser(s.newRepoForm)},
internal/web/static/style.css +33 −4
@@ -480,6 +480,7 @@ nav.tabs {
480 display: flex; 480 display: flex;
481 gap: var(--sp-1); 481 gap: var(--sp-1);
482 margin-top: var(--sp-3); 482 margin-top: var(--sp-3);
483 margin-bottom: var(--sp-4);
483 overflow-x: auto; 484 overflow-x: auto;
484 /* with overflow-y left visible the browser computes it to auto, which 485 /* with overflow-y left visible the browser computes it to auto, which
485 is a vertical drag on touch; the strip scrolls sideways only */ 486 is a vertical drag on touch; the strip scrolls sideways only */
@@ -500,6 +501,12 @@ nav.tabs a {
500} 501}
501nav.tabs a:hover { color: var(--fg); text-decoration: none; } 502nav.tabs a:hover { color: var(--fg); text-decoration: none; }
502nav.tabs a[aria-current] { color: var(--fg); font-weight: 600; border-bottom-color: var(--mark); } 503nav.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); }
503/* a count in a tab is plain metadata, not a badge */ 510/* a count in a tab is plain metadata, not a badge */
504nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; } 511nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
505 512
@@ -511,7 +518,10 @@ nav.tabs a i { font-style: normal; color: var(--muted); margin-left: 4px; }
511 flex-wrap: wrap; 518 flex-wrap: wrap;
512 margin-bottom: var(--sp-5); 519 margin-bottom: var(--sp-5);
513} 520}
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 */
514.pagehead h1, .listhead h1, .headrow h1, .listhead h2, .headrow h2 { margin: 0; } 523.pagehead h1, .listhead h1, .headrow h1, .listhead h2, .headrow h2 { margin: 0; }
524.pagehead > p, .listhead > p, .headrow > p { margin: 0; }
515.pagehead .grow, .listhead .spacer, .headrow .spacer { flex: 1; } 525.pagehead .grow, .listhead .spacer, .headrow .spacer { flex: 1; }
516.issuetitle { margin: 0 0 var(--sp-2); font-size: var(--fs-5); } 526.issuetitle { margin: 0 0 var(--sp-2); font-size: var(--fs-5); }
517.issuenumber { color: var(--muted); font-weight: 400; } 527.issuenumber { color: var(--muted); font-weight: 400; }
@@ -596,6 +606,9 @@ button.danger, .button.danger {
596button.danger:hover, .button.danger:hover { background: var(--bad); color: var(--canvas); filter: none; } 606button.danger:hover, .button.danger:hover { background: var(--bad); color: var(--canvas); filter: none; }
597button:disabled { opacity: 0.55; cursor: default; filter: none; } 607button:disabled { opacity: 0.55; cursor: default; filter: none; }
598.btngroup { display: inline-flex; gap: var(--sp-2); flex-wrap: wrap; } 608.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); }
599form.inline { display: inline; } 612form.inline { display: inline; }
600.act { text-align: right; } 613.act { text-align: right; }
601 614
@@ -758,10 +771,15 @@ details.editbox[open] > summary { margin-bottom: var(--sp-2); }
758details.editbox input[type="text"] { width: 100%; } 771details.editbox input[type="text"] { width: 100%; }
759 772
760/* ---- settings: one row per control, label then input then its own 773/* ---- 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. ---- */
762form.setform { 780form.setform {
763 display: grid; 781 display: grid;
764 grid-template-columns: 14rem minmax(0, 28rem) auto; 782 grid-template-columns: minmax(14rem, 1fr) minmax(0, 28rem) auto;
765 gap: var(--sp-4); 783 gap: var(--sp-4);
766 align-items: start; 784 align-items: start;
767 padding: var(--sp-3) 0; 785 padding: var(--sp-3) 0;
@@ -769,7 +787,12 @@ form.setform {
769} 787}
770form.setform label { margin-top: 6px; } 788form.setform label { margin-top: 6px; }
771form.setform .hint { margin: 2px 0 0; } 789form.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; }
773/* the third column takes the leftover width; the button keeps its own */ 796/* the third column takes the leftover width; the button keeps its own */
774form.setform > button, form.setform > .btngroup { justify-self: start; } 797form.setform > button, form.setform > .btngroup { justify-self: start; }
775form.setform.stack { grid-template-columns: 1fr; } 798form.setform.stack { grid-template-columns: 1fr; }
@@ -786,6 +809,9 @@ ul.protlist li:last-child { border-bottom: 0; }
786ul.protlist form { margin-left: auto; } 809ul.protlist form { margin-left: auto; }
787ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; } 810ul.plain { list-style: none; margin: 0 0 var(--sp-3); padding: 0; }
788ul.plain li { padding: var(--sp-1) 0; display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } 811ul.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; }
789 815
790/* ---- messages: .error and .notice are one family. The names stay 816/* ---- messages: .error and .notice are one family. The names stay
791 because e2e tests match class="error". ---- */ 817 because e2e tests match class="error". ---- */
@@ -863,6 +889,9 @@ td.name a { color: var(--fg); }
863td.name a:hover { color: var(--link); } 889td.name a:hover { color: var(--link); }
864table.tree td.name { width: 25%; white-space: nowrap; } 890table.tree td.name { width: 25%; white-space: nowrap; }
865table.tree td.name.dir a { color: var(--link); } 891table.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); }
866table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; } 895table.tree th.lastcommit, table.tree td.lastcommit { width: 55%; }
867table.tree td.lastcommit { 896table.tree td.lastcommit {
868 max-width: 0; 897 max-width: 0;
@@ -1800,7 +1829,7 @@ svg.icon { vertical-align: -0.125em; }
1800 ul.loglist .commitside { width: 100%; justify-content: flex-start; } 1829 ul.loglist .commitside { width: 100%; justify-content: flex-start; }
1801} 1830}
1802 1831
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
1804 keeps five — dashboard, search, notifications, More, your avatar — and 1833 keeps five — dashboard, search, notifications, More, your avatar — and
1805 the More menu holds the rest. Shrinking the squares instead is what 1834 the More menu holds the rest. Shrinking the squares instead is what
1806 this replaces: 32px was under the target every phone guideline asks 1835 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>
133<form method="post" action="/settings" class="setform"> 133<form method="post" action="/settings" class="setform">
134 <input type="hidden" name="field" value="notify-mail"> 134 <input type="hidden" name="field" value="notify-mail">
135 <label for="notify-mail">Activity by mail</label> 135 <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>
137 <button type="submit" class="btn">Save</button> 137 <button type="submit" class="btn">Save</button>
138</form> 138</form>
139<p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p> 139<p class="meta">Enable to receive activity alerts by email. Login links will arrive regardless of this setting.</p>
140<form method="post" action="/settings" class="setform"> 140<form method="post" action="/settings" class="setform">
141 <input type="hidden" name="field" value="notify-watch"> 141 <input type="hidden" name="field" value="notify-watch">
142 <label for="notify-watch">Watch repositories you can write to</label> 142 <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>
144 <button type="submit" class="btn">Save</button> 144 <button type="submit" class="btn">Save</button>
145</form> 145</form>
146<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> 146<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>
147<form method="post" action="/settings" class="setform"> 147<form method="post" action="/settings" class="setform">
148 <input type="hidden" name="field" value="notify-push"> 148 <input type="hidden" name="field" value="notify-push">
149 <label for="notify-push">Activity on your registered devices</label> 149 <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>
151 <button type="submit" class="btn">Save</button> 151 <button type="submit" class="btn">Save</button>
152</form> 152</form>
153<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> 153<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}}
2{{define "title"}}accounts · admin · {{.Site}}{{end}} 6{{define "title"}}accounts · admin · {{.Site}}{{end}}
3{{define "content"}} 7{{define "content"}}
4<h1>Accounts</h1> 8<h1>Accounts</h1>
@@ -36,6 +40,14 @@
36 <input type="hidden" name="state" value="{{$.State}}"> 40 <input type="hidden" name="state" value="{{$.State}}">
37 <button type="submit" class="btn">Promote</button> 41 <button type="submit" class="btn">Promote</button>
38 </form> 42 </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>
39 {{end}} 51 {{end}}
40 {{if eq .State "disabled"}} 52 {{if eq .State "disabled"}}
41 <form method="post" action="/admin/users" class="actions"> 53 <form method="post" action="/admin/users" class="actions">
internal/web/templates/globalsearch.html +4 −1
@@ -31,8 +31,11 @@
31{{range .Results}}<li> 31{{range .Results}}<li>
32 <div class="issuemain"> 32 <div class="issuemain">
33 <p class="title"><a href="{{.Href}}">{{if .Number}}{{.Title}}{{else}}{{.Repo}}{{end}}</a></p> 33 <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>
35 </div> 35 </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>
36 {{if .State}}<span class="chip chip-{{.State}}">{{.State}}</span>{{end}} 39 {{if .State}}<span class="chip chip-{{.State}}">{{.State}}</span>{{end}}
37</li> 40</li>
38{{end}} 41{{end}}
internal/web/templates/layout.html +5 −7
@@ -22,8 +22,6 @@
22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li> 22 <li class="railopt">{{template "raillink" dict "Href" "/explore" "Icon" "compass" "Name" "Explore" "Current" (eq (str . "Tab") "explore")}}</li>
23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li> 23 <li>{{template "raillink" dict "Href" "/search" "Icon" "search" "Name" "Search" "Current" (eq (str . "Tab") "sitesearch")}}</li>
24 {{if .Viewer}}<li>{{template "raillink" dict "Href" "/notifications" "Icon" "bell" "Name" "Notifications" "Current" (eq (str . "Tab") "notifications") "Count" .Rail.Unread}}</li> 24 {{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>
27 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}} 25 <li class="railopt">{{template "raillink" dict "Href" "/new" "Icon" "plus" "Name" "New repository"}}</li>{{end}}
28 </ul> 26 </ul>
29 <span class="railgap"></span> 27 <span class="railgap"></span>
@@ -36,16 +34,14 @@
36 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary> 34 <summary class="railicon" aria-label="More" title="More">{{template "icon" "ellipsis"}}<span class="vh">More</span></summary>
37 <div class="raildrop"> 35 <div class="raildrop">
38 <a href="/explore">{{template "icon" "compass"}} Explore</a> 36 <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>
41 <a href="/new">{{template "icon" "plus"}} New repository</a> 37 <a href="/new">{{template "icon" "plus"}} New repository</a>
42 <a href="/settings">{{template "icon" "gear"}} Settings</a> 38 <a href="/settings">{{template "icon" "gear"}} Settings</a>
43 {{if .Admin}}<a href="/admin">{{template "icon" "shield"}} Admin</a>{{end}} 39 {{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>
45 </div> 41 </div>
46 </details> 42 </details>
47 <a class="railuser" href="/{{.Viewer}}" aria-label="Your profile" title="{{.Viewer}}"><span class="avatar">{{initial .Viewer}}</span></a> 43 <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>
49 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}} 45 {{else}}<a class="railicon" href="/login" aria-label="Sign in" title="Sign in">{{template "icon" "person"}}<span class="vh">Sign in</span></a>{{end}}
50 </div> 46 </div>
51</nav> 47</nav>
@@ -88,7 +84,7 @@
88</main> 84</main>
89 85
90<footer> 86<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>
92</footer> 88</footer>
93</div> 89</div>
94</div> 90</div>
@@ -112,6 +108,8 @@
112{{- 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"/> 108{{- 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"/>
113{{- 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"/> 109{{- 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"/>
114{{- 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"/> 110{{- 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"/>
115{{- end}}</svg>{{end}} 113{{- end}}</svg>{{end}}
116 114
117{{/* raillink draws one square in the rail: the icon, the name for a 115{{/* 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 @@
13 push the repositories off the bottom of the page (#242). About is 13 push the repositories off the bottom of the page (#242). About is
14 the bare /{owner}; the rest hang off /-/. A tab nobody may open is 14 the bare /{owner}; the rest hang off /-/. A tab nobody may open is
15 not offered and its URL is a 404. */}} 15 not offered and its URL is a 404. */}}
16<nav class="tabs" aria-label="Profile"> 16<nav class="tabs profiletabs" aria-label="Profile">
17 <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a> 17 <a {{if eq .Tab "about"}}aria-current="page" {{end}}href="/{{.Owner}}">About</a>
18 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a> 18 <a {{if eq .Tab "repos"}}aria-current="page" {{end}}href="/{{.Owner}}/-/repositories">Repositories{{if .Repos}} <i>{{len .Repos}}</i>{{end}}</a>
19 {{if .Self}}<a {{if eq .Tab "bookmarks"}}aria-current="page" {{end}}href="/{{.Owner}}/-/bookmarks">Bookmarks</a>{{end}} 19 {{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 @@
79<form method="post" action="{{$base}}" class="setform"> 79<form method="post" action="{{$base}}" class="setform">
80 <input type="hidden" name="field" value="require-approvals"> 80 <input type="hidden" name="field" value="require-approvals">
81 <div><label for="approvals">Approvals</label><p class="hint">Approvals from anyone with write access. Zero means none required.</p></div> 81 <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>
83 <div><button type="submit" class="btn">Save</button></div> 83 <div><button type="submit" class="btn">Save</button></div>
84</form> 84</form>
85<form method="post" action="{{$base}}" class="setform"> 85<form method="post" action="{{$base}}" class="setform">
internal/web/templates/tree.html +5 −2
@@ -19,8 +19,11 @@
19{{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}} 19{{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}}
20<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr> 20<tr class="cols"><th scope="col">name</th><th scope="col" class="lastcommit">last commit</th><th scope="col" class="age">updated</th></tr>
21{{range .Entries}}{{$c := index $.LastCommits .Name}}<tr> 21{{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> 22 {{/* the glyph is decorative: the trailing slash is what says directory
23 {{else}}<td class="name"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{$.Prefix}}{{.Name}}">{{.Name}}</a></td>{{end}} 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}}
24 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td> 27 <td class="lastcommit">{{with $c.Subject}}<a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{$c.SHA}}" title="{{.}}">{{.}}</a>{{end}}</td>
25 <td class="age"{{if not $c.When.IsZero}} title="{{whenT $c.When}}"{{end}}>{{ago $c.When}}</td> 28 <td class="age"{{if not $c.When.IsZero}} title="{{whenT $c.When}}"{{end}}>{{ago $c.When}}</td>
26</tr> 29</tr>
internal/web/web_test.go +14 −19
@@ -89,8 +89,8 @@ func TestWhenNamesTheZone(t *testing.T) {
89// none. The merge request page picks wide for its diff view, so it gets 89// none. The merge request page picks wide for its diff view, so it gets
90// a per-view define instead of a fixed one. 90// a per-view define instead of a fixed one.
91func TestMainWidthClass(t *testing.T) { 91func 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} 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, "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, "snippetnew.html": true, "privacy.html": true, "404.html": true}
94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`} 94 perView := map[string]string{"mr.html": `{{define "width"}}{{if eq .View "diff"}}wide{{else}}reading{{end}}{{end}}`}
95 for _, name := range Pages() { 95 for _, name := range Pages() {
96 src, err := TemplateSource(name) 96 src, err := TemplateSource(name)
@@ -160,7 +160,13 @@ func TestRailIconsAreLabelled(t *testing.T) {
160 } 160 }
161 } 161 }
162 162
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>`)
164 controls := tagRe.FindAllString(src, -1) 170 controls := tagRe.FindAllString(src, -1)
165 if len(controls) < 3 { 171 if len(controls) < 3 {
166 t.Fatalf("found %d railicon controls in layout.html, want the rail's full set", len(controls)) 172 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) {
176 } 182 }
177 } 183 }
178 184
179 // An icon button in the rail's foot is under the same rule: Log out 185 // The foot holds controls of its own rather than raillink squares, so
180 // is the only one today. The More menu's own rows are not icon 186 // check it is in what tagRe just scanned: a foot written with none
181 // buttons — their visible text is their name. 187 // would pass the loop above by being empty.
182 foot := src[strings.Index(src, `<div class="railfoot">`):] 188 foot := src[strings.Index(src, `<div class="railfoot">`):]
183 foot = foot[:strings.Index(foot, "</nav>")] 189 foot = foot[:strings.Index(foot, "</nav>")]
184 buttons := regexp.MustCompile(`(?s)<button [^>]*class="railicon".*?</button>`).FindAllString(foot, -1) 190 if len(tagRe.FindAllString(foot, -1)) == 0 {
185 if len(buttons) == 0 { 191 t.Error("no railicon control in the rail foot")
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 }
197 } 192 }
198 193
199 // No decorative graphic anywhere in the layout is exposed, the brand 194 // No decorative graphic anywhere in the layout is exposed, the brand