web: the accessibility findings from the wider scan !437

merged merged by cmc on 2026-09-20 00:21 UTC · krz/gitbay:axe-226 into main

17 files changed, +273 −59

Layout: unified · split

CHANGELOG.org +15
@@ -29,6 +29,21 @@ The desktop layout (#226): the web UI uses a wide screen.
29 phone. Below 80rem the dashboard's pinned repositories return to a 29 phone. Below 80rem the dashboard's pinned repositories return to a
30 chip row above the queues. 30 chip row above the queues.
31 31
32Three accessibility defects the wider scan found, on pages the
33sixteen-page one never covered (#226):
34
35- A label chip carries a tone per scheme. Chip text is 12px, which owes
36 4.5:1 against the ground mixed from the colour itself, and no single
37 colour clears that on both canvases; the stored hex still names the
38 label, and the page picks the tone. A page stamped light or dark now
39 sets =color-scheme= to match, so native controls follow the theme.
40- Every =pre= scrolls sideways, so every one takes =tabindex="0"=: a
41 keyboard reaches a blame view, a build log and a search match the way
42 it already reached the pre blocks of rendered markup.
43- A link that shares its line with other text is underlined — a path
44 crumb, a heading that names an owner, an empty-state note. Link and
45 muted text are 1.07:1 apart in dark.
46
32* v1.30.0 — 2026-09-19 47* v1.30.0 — 2026-09-19
33 48
34Every command runs on every surface, markup previews before it is 49Every command runs on every surface, markup previews before it is
e2e/label_test.go +3 −2
@@ -43,8 +43,9 @@ func TestLabelColors(t *testing.T) {
43 !strings.Contains(out, `{"name":"docs","issues":0,"mrs":0}`) { 43 !strings.Contains(out, `{"name":"docs","issues":0,"mrs":0}`) {
44 t.Fatalf("list json:\n%s", out) 44 t.Fatalf("list json:\n%s", out)
45 } 45 }
46 // The web paints the chip with the stored colour. 46 // The web paints the chip with the stored colour, as the tone each
47 if _, body := inst.get(t, "/alice/app/issues"); !strings.Contains(body, "--chip:#cf222e") { 47 // scheme needs to clear 4.5:1 on the chip's own ground (#226).
48 if _, body := inst.get(t, "/alice/app/issues"); !strings.Contains(body, "--chip-l:#") || !strings.Contains(body, ";--chip-d:#") {
48 t.Fatalf("issue list does not carry the colour:\n%s", body) 49 t.Fatalf("issue list does not carry the colour:\n%s", body)
49 } 50 }
50 // Clearing the colour keeps the label; removing it unlinks the issue. 51 // Clearing the colour keeps the label; removing it unlinks the issue.
internal/httpd/chip_test.go +79 −16
@@ -1,9 +1,14 @@
1package httpd 1package httpd
2 2
3import ( 3import (
4 "fmt"
4 "math" 5 "math"
6 "regexp"
5 "strconv" 7 "strconv"
8 "strings"
6 "testing" 9 "testing"
10
11 "gitbay.org/gitbay/internal/web"
7) 12)
8 13
9// relLum is WCAG relative luminance of a #rrggbb colour. 14// relLum is WCAG relative luminance of a #rrggbb colour.
@@ -19,25 +24,83 @@ func relLum(hex string) float64 {
19 return 0.2126*lin(hex[1:3]) + 0.7152*lin(hex[3:5]) + 0.0722*lin(hex[5:7]) 24 return 0.2126*lin(hex[1:3]) + 0.7152*lin(hex[3:5]) + 0.0722*lin(hex[5:7])
20} 25}
21 26
22// A label colour is text on the chip. The palette passes through; a 27// ground mixes a tenth of the chip colour into the canvas the way
23// stored yellow, white, black or pastel comes back with a luminance that 28// color-mix(in srgb, chip 10%, canvas) does, and ratio is WCAG contrast.
24// clears 3:1 against both white and the dark ground (#120). 29func ground(chip, canvas string) string {
25func TestClampChip(t *testing.T) { 30 ch := func(a, b string) string {
26 for _, keep := range labelPalette { 31 na, _ := strconv.ParseInt(a, 16, 32)
27 if got := clampChip(keep); got != keep { 32 nb, _ := strconv.ParseInt(b, 16, 32)
28 t.Errorf("palette colour %s changed to %s", keep, got) 33 return fmt.Sprintf("%02x", int(math.Round(0.1*float64(na)+0.9*float64(nb))))
34 }
35 return "#" + ch(chip[1:3], canvas[1:3]) + ch(chip[3:5], canvas[3:5]) + ch(chip[5:7], canvas[5:7])
36}
37
38func ratio(a, b string) float64 {
39 la, lb := relLum(a), relLum(b)
40 if la < lb {
41 la, lb = lb, la
42 }
43 return (la + 0.05) / (lb + 0.05)
44}
45
46// A label colour is 12px text on a ground mixed from itself, so it needs
47// 4.5:1 there — a ratio no one colour reaches on both the light and the
48// dark canvas. Each label carries a tone per scheme (#226).
49func TestChipTones(t *testing.T) {
50 cases := append([]string{}, labelPalette...)
51 cases = append(cases, "#ffff00", "#FFFFFF", "#000000", "#ffcccc", "#00ff00", "#101010", "#0000ff", "#8250df", "#bf5b16")
52 for _, in := range cases {
53 light, dark := chipTones(in)
54 if got := ratio(light, ground(light, chipCanvasLight)); got < 4.5 {
55 t.Errorf("chipTones(%s) light = %s: %.2f:1 on its ground", in, light, got)
56 }
57 if got := ratio(dark, ground(dark, chipCanvasDark)); got < 4.5 {
58 t.Errorf("chipTones(%s) dark = %s: %.2f:1 on its ground", in, dark, got)
29 } 59 }
30 } 60 }
31 for _, in := range []string{"#ffff00", "#FFFFFF", "#000000", "#ffcccc", "#00ff00", "#101010"} { 61
32 got := clampChip(in) 62 // The hue survives the move wherever scaling can reach the ratio: a
33 y := relLum(got) 63 // purple stays a purple, not a grey, in both schemes.
34 onWhite := 1.05 / (y + 0.05) 64 light, dark := chipTones("#8250df")
35 onDark := (y + 0.05) / (relLum("#0a0a0a") + 0.05) 65 for _, tone := range []string{light, dark} {
36 if onWhite < 3 || onDark < 3 { 66 chans := func(s string) (r, g, b int64) {
37 t.Errorf("clampChip(%s) = %s: %.2f:1 on white, %.2f:1 on dark", in, got, onWhite, onDark) 67 for i, p := range []*int64{&r, &g, &b} {
68 v, _ := strconv.ParseInt(s[1+2*i:3+2*i], 16, 32)
69 *p = v
70 }
71 return
38 } 72 }
73 r, g, b := chans(tone)
74 if !(b > r && r > g) {
75 t.Errorf("#8250df became %s: %d red, %d green, %d blue is no longer a purple", tone, r, g, b)
76 }
77 }
78
79 // A colour that already clears the ratio is left alone.
80 if light, _ := chipTones("#3b2178"); light != "#3b2178" {
81 t.Errorf("a colour that already passes on the light canvas moved to %s", light)
82 }
83}
84
85// The canvases the tones are computed against are the stylesheet's, in
86// both schemes: a token moves and these constants move with it.
87func TestChipCanvasMatchesStylesheet(t *testing.T) {
88 s := string(web.StyleCSS)
89 dark := strings.Index(s, "@media (prefers-color-scheme: dark)")
90 if dark < 0 {
91 t.Fatal("no dark media query in style.css")
39 } 92 }
40 if got := clampChip("#ffff00"); got == "#ffff00" { 93 re := regexp.MustCompile(`--canvas:\s*(#[0-9a-f]{6});`)
41 t.Error("yellow passed through unchanged") 94 for _, c := range []struct{ scheme, in, want string }{
95 {"light", s[:dark], chipCanvasLight},
96 {"dark", s[dark:], chipCanvasDark},
97 } {
98 m := re.FindStringSubmatch(c.in)
99 if m == nil {
100 t.Fatalf("%s: no --canvas token", c.scheme)
101 }
102 if m[1] != c.want {
103 t.Errorf("%s: style.css --canvas is %s, the chip tones use %s", c.scheme, m[1], c.want)
104 }
42 } 105 }
43} 106}
internal/httpd/web.go +97 −24
@@ -1627,12 +1627,90 @@ var labelPalette = []string{
1627 1627
1628var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) 1628var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
1629 1629
1630// clampChip keeps a user-set label colour legible as text on both 1630// The canvases a chip is drawn on, --canvas in each scheme, and the ratio
1631// grounds. Contrast is defined on relative luminance, so that is what is 1631// its text owes them. Chip text is 12px, which WCAG reads as small text at
1632// held: between 0.12 and 0.28, where the chip clears 3:1 against white 1632// 4.5:1. TestChipCanvasMatchesStylesheet keeps these in step with the
1633// and against the dark ground alike, and where the palette's own colours 1633// tokens.
1634// sit. The hue is kept; the channels are scaled in linear light (#120). 1634const (
1635func clampChip(hex string) string { 1635 chipCanvasLight = "#ffffff"
1636 chipCanvasDark = "#101114"
1637 chipRatio = 4.5
1638)
1639
1640// chipTones returns a user-set label colour as it is drawn in each scheme.
1641// The chip's ground is mixed from the colour itself, and the luminance
1642// band that clears 4.5:1 on white ends below the band that clears it on
1643// the dark canvas, so one colour cannot serve both and each label carries
1644// two (#226, replacing the single clamp of #120). The hue is kept — the
1645// channels are scaled in linear light — and only a colour too dark to
1646// brighten any further, a saturated blue, is blended on toward white.
1647func chipTones(hex string) (light, dark string) {
1648 return chipTone(hex, chipCanvasLight, false), chipTone(hex, chipCanvasDark, true)
1649}
1650
1651// chipTone walks the colour along its ramp until it clears the ratio,
1652// stopping at the first tone that does: contrast rises with the distance
1653// travelled, so the bisection finds the tone nearest the one asked for.
1654func chipTone(hex, canvas string, up bool) string {
1655 if chipContrast(strings.ToLower(hex), canvas) >= chipRatio {
1656 return strings.ToLower(hex)
1657 }
1658 lo, hi := 0.0, 1.0
1659 for i := 0; i < 24; i++ {
1660 mid := (lo + hi) / 2
1661 if chipContrast(chipStep(hex, mid, up), canvas) >= chipRatio {
1662 hi = mid
1663 } else {
1664 lo = mid
1665 }
1666 }
1667 return chipStep(hex, hi, up)
1668}
1669
1670// chipStep is the colour s of the way along its ramp: down to black on a
1671// light canvas, and on a dark one up through the brightest tone that
1672// keeps the hue and from there on to white.
1673func chipStep(hex string, s float64, up bool) string {
1674 r, g, b := chipLinear(hex)
1675 switch m := math.Max(r, math.Max(g, b)); {
1676 case !up:
1677 k := 1 - s
1678 r, g, b = r*k, g*k, b*k
1679 case m == 0: // black has no hue to keep
1680 r, g, b = s, s, s
1681 case s <= 0.5:
1682 k := 1 + (s/0.5)*(1/m-1)
1683 r, g, b = r*k, g*k, b*k
1684 default:
1685 k, t := 1/m, (s-0.5)/0.5
1686 r, g, b = r*k, g*k, b*k
1687 r, g, b = r+t*(1-r), g+t*(1-g), b+t*(1-b)
1688 }
1689 return chipHex(r, g, b)
1690}
1691
1692// chipContrast is the WCAG ratio between a chip colour and its own
1693// ground, color-mix(in srgb, chip 10%, canvas).
1694func chipContrast(hex, canvas string) float64 {
1695 y, g := chipLuminance(hex), chipLuminance(chipGround(hex, canvas))
1696 if y < g {
1697 y, g = g, y
1698 }
1699 return (y + 0.05) / (g + 0.05)
1700}
1701
1702// chipGround mixes a tenth of the chip colour into the canvas, the blend
1703// color-mix(in srgb, ...) makes: gamma-encoded channels, not linear ones.
1704func chipGround(hex, canvas string) string {
1705 mix := func(a, b string) string {
1706 return fmt.Sprintf("%02x", int(math.Round(0.1*float64(hexByte(a))+0.9*float64(hexByte(b)))))
1707 }
1708 return "#" + mix(hex[1:3], canvas[1:3]) + mix(hex[3:5], canvas[3:5]) + mix(hex[5:7], canvas[5:7])
1709}
1710
1711// chipLinear is a #rrggbb colour in linear light, chipHex the way back,
1712// and chipLuminance the WCAG relative luminance of one.
1713func chipLinear(hex string) (r, g, b float64) {
1636 lin := func(c int64) float64 { 1714 lin := func(c int64) float64 {
1637 v := float64(c) / 255 1715 v := float64(c) / 255
1638 if v <= 0.04045 { 1716 if v <= 0.04045 {
@@ -1640,23 +1718,12 @@ func clampChip(hex string) string {
1640 } 1718 }
1641 return math.Pow((v+0.055)/1.055, 2.4) 1719 return math.Pow((v+0.055)/1.055, 2.4)
1642 } 1720 }
1643 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7])) 1721 return lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1644 y := 0.2126*r + 0.7152*g + 0.0722*b 1722}
1645 const lo, hi = 0.12, 0.28 1723
1646 if y >= lo && y <= hi { 1724func chipHex(r, g, b float64) string {
1647 return strings.ToLower(hex)
1648 }
1649 target := hi
1650 if y < lo {
1651 target = lo
1652 }
1653 if y == 0 {
1654 r, g, b = target, target, target
1655 } else {
1656 k := target / y
1657 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1658 }
1659 enc := func(v float64) int { 1725 enc := func(v float64) int {
1726 v = math.Min(1, math.Max(0, v))
1660 if v <= 0.0031308 { 1727 if v <= 0.0031308 {
1661 v *= 12.92 1728 v *= 12.92
1662 } else { 1729 } else {
@@ -1667,6 +1734,11 @@ func clampChip(hex string) string {
1667 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b)) 1734 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1668} 1735}
1669 1736
1737func chipLuminance(hex string) float64 {
1738 r, g, b := chipLinear(hex)
1739 return 0.2126*r + 0.7152*g + 0.0722*b
1740}
1741
1670func hexByte(s string) int64 { 1742func hexByte(s string) int64 {
1671 n, _ := strconv.ParseInt(s, 16, 32) 1743 n, _ := strconv.ParseInt(s, 16, 32)
1672 return n 1744 return n
@@ -1682,7 +1754,7 @@ func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
1682 1754
1683// colorStyles turns a label-name -> stored color map into chip styles: the 1755// colorStyles turns a label-name -> stored color map into chip styles: the
1684// stored color when it is a valid hex color, otherwise a stable default 1756// stored color when it is a valid hex color, otherwise a stable default
1685// picked from the palette by name hash. 1757// picked from the palette by name hash, as a tone per scheme.
1686func colorStyles(stored map[string]string) map[string]template.CSS { 1758func colorStyles(stored map[string]string) map[string]template.CSS {
1687 out := make(map[string]template.CSS, len(stored)) 1759 out := make(map[string]template.CSS, len(stored))
1688 for name, color := range stored { 1760 for name, color := range stored {
@@ -1691,7 +1763,8 @@ func colorStyles(stored map[string]string) map[string]template.CSS {
1691 h.Write([]byte(name)) 1763 h.Write([]byte(name))
1692 color = labelPalette[h.Sum32()%uint32(len(labelPalette))] 1764 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
1693 } 1765 }
1694 out[name] = template.CSS("--chip:" + clampChip(color)) 1766 light, dark := chipTones(color)
1767 out[name] = template.CSS("--chip-l:" + light + ";--chip-d:" + dark)
1695 } 1768 }
1696 return out 1769 return out
1697} 1770}
internal/web/static/style.css +21 −2
@@ -138,6 +138,10 @@
138} 138}
139@media (prefers-color-scheme: dark) { 139@media (prefers-color-scheme: dark) {
140 :root { 140 :root {
141 /* the scheme in force, not just the tokens: light-dark() reads it,
142 and themedCSS repeats this block for a page stamped dark */
143 color-scheme: dark;
144
141 --canvas: #101114; 145 --canvas: #101114;
142 --surface: #212429; 146 --surface: #212429;
143 --inset: #0b0c0e; 147 --inset: #0b0c0e;
@@ -195,6 +199,10 @@
195 } 199 }
196} 200}
197 201
202/* a page stamped light keeps the light scheme under a dark OS: the token
203 block above is guarded for it, and this is the other half of that */
204:root[data-theme="light"] { color-scheme: light; }
205
198/* ---- base ---- */ 206/* ---- base ---- */
199* { box-sizing: border-box; } 207* { box-sizing: border-box; }
200html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } 208html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
@@ -218,6 +226,10 @@ a:hover { text-decoration: underline; }
218p a, .syscomment a { text-decoration: underline; } 226p a, .syscomment a { text-decoration: underline; }
219/* a title or a path is the whole line: position is the cue */ 227/* a title or a path is the whole line: position is the cue */
220p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; } 228p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; }
229/* where a link shares its line with other text the underline is the cue
230 again, path bar or not: a crumb beside the file name, a heading that
231 names an owner, an empty-state note (#226) */
232.crumbs a, h1 a, li.empty a { text-decoration: underline; }
221a.xref { color: var(--link); text-decoration: none; } 233a.xref { color: var(--link); text-decoration: none; }
222 234
223code, pre, .mono, .code, td.mode, td.size { 235code, pre, .mono, .code, td.mode, td.size {
@@ -1334,8 +1346,15 @@ table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underli
1334.chip-stale { --chip: var(--warn); } 1346.chip-stale { --chip: var(--warn); }
1335.chip.topic { --chip: var(--link); } 1347.chip.topic { --chip: var(--link); }
1336.chip.topic:hover { text-decoration: none; background: color-mix(in srgb, var(--chip) 18%, transparent); } 1348.chip.topic:hover { text-decoration: none; background: color-mix(in srgb, var(--chip) 18%, transparent); }
1337/* a label carries its own colour inline, from the label's hex */ 1349/* A label carries its own colour inline, from the label's hex, as one
1338.chip.label { font-weight: 500; } 1350 tone per scheme: chip text is 12px, so it needs 4.5:1 against its own
1351 ground, and no single colour clears that on both canvases (#226). An
1352 old browser without light-dark() drops the declaration and the chip
1353 falls back to the neutral above. */
1354.chip.label {
1355 --chip: light-dark(var(--chip-l, var(--neutral)), var(--chip-d, var(--neutral)));
1356 font-weight: 500;
1357}
1339.badge-verified { --chip: var(--ok); } 1358.badge-verified { --chip: var(--ok); }
1340.badge-unsigned { --chip: var(--neutral); } 1359.badge-unsigned { --chip: var(--neutral); }
1341.badge-signed_unknown_key, .badge-signed_key_expired { --chip: var(--warn); } 1360.badge-signed_unknown_key, .badge-signed_key_expired { --chip: var(--warn); }
internal/web/templates/account.html +1 −1
@@ -163,7 +163,7 @@ never included; a replayed bundle's emails arrive unverified.</p>
163<section id="cli"><h2>On the command line</h2> 163<section id="cli"><h2>On the command line</h2>
164<p class="meta">No page here yet, and nothing refusing one: a credential is 164<p class="meta">No page here yet, and nothing refusing one: a credential is
165easier to pipe than to paste, and a minted token is shown once.</p> 165easier to pipe than to paste, and a minted token is shown once.</p>
166<pre class="message">gitbay auth token create --name laptop # API tokens 166<pre class="message" tabindex="0">gitbay auth token create --name laptop # API tokens
167gitbay web sessions list # browser sessions 167gitbay web sessions list # browser sessions
168gitbay admin ... # instance administration</pre> 168gitbay admin ... # instance administration</pre>
169<p class="meta">All of it works from stock OpenSSH too: 169<p class="meta">All of it works from stock OpenSSH too:
internal/web/templates/blame.html +1 −1
@@ -19,7 +19,7 @@
19 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p> 19 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p>
20 <p class="meta"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> <span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{when .Date}} {{template "sigbadge" .Sig}}</p> 20 <p class="meta"><code><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.ShortSHA}}</a></code> <span title="{{.AuthorEmail}}">{{.AuthorName}}</span> · {{when .Date}} {{template "sigbadge" .Sig}}</p>
21 </div> 21 </div>
22 <pre class="blamecode">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}} 22 <pre class="blamecode" tabindex="0">{{range .Numbered}}<span class="lineno">{{.N}}</span>{{.Text}}
23{{end}}</pre> 23{{end}}</pre>
24</div> 24</div>
25{{else}}<p class="empty-note">empty file</p>{{end}} 25{{else}}<p class="empty-note">empty file</p>{{end}}
internal/web/templates/build.html +1 −1
@@ -12,5 +12,5 @@
12{{end}} 12{{end}}
13</div> 13</div>
14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p> 14<p class="meta">{{.Build.Job}} on {{.Build.Ref}} · <code><a href="/{{.Repo.OwnerName}}/{{.Repo.Name}}/commit/{{.Build.SHA}}">{{printf "%.10s" .Build.SHA}}</a></code> · queued {{when .Build.CreatedAt}}{{if .Build.FinishedAt}} · finished {{when .Build.FinishedAt}}{{end}}</p>
15{{if .Log}}<pre class="code buildlog">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}} 15{{if .Log}}<pre class="code buildlog" tabindex="0">{{.Log}}</pre>{{else}}<p class="empty-note">no log yet</p>{{end}}
16{{end}} 16{{end}}
internal/web/templates/builds.html +1 −1
@@ -32,7 +32,7 @@
32{{end}} 32{{end}}
33<details class="editbox"><summary>Status badge</summary> 33<details class="editbox"><summary>Status badge</summary>
34<p class="meta">Paste into a README; it shows the newest build's state.</p> 34<p class="meta">Paste into a README; it shows the newest build's state.</p>
35<pre class="code">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> 35<pre class="code" tabindex="0">[![build](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/badge/build.svg)](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre>
36<p class="meta">Add <code>?job=name</code> for one job.</p> 36<p class="meta">Add <code>?job=name</code> for one job.</p>
37</details> 37</details>
38<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p> 38<p class="meta">{{len .Runs}} run{{if ne (len .Runs) 1}}s{{end}}{{if or .Filter.Ref .Filter.Status .Filter.Job}}, <a href="?">clear filters</a>{{end}}</p>
internal/web/templates/commit.html +1 −1
@@ -9,7 +9,7 @@
9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{when .Date}} 9 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{when .Date}}
10 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p> 10 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
11</div> 11</div>
12<pre class="message">{{.Message}}</pre> 12<pre class="message" tabindex="0">{{.Message}}</pre>
13{{if .DiffTruncated}}<p class="error" role="alert">This patch is larger than 4 MiB and is cut off below. Clone the repository to see all of it.</p>{{end}} 13{{if .DiffTruncated}}<p class="error" role="alert">This patch is larger than 4 MiB and is cut off below. Clone the repository to see all of it.</p>{{end}}
14{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}} 14{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}
15{{end}} 15{{end}}
internal/web/templates/labels.html +1 −1
@@ -3,7 +3,7 @@
3<h1>Labels</h1> 3<h1>Labels</h1>
4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}} 4{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
5{{if .Labels}}<div class="tablewrap"><table class="keys"> 5{{if .Labels}}<div class="tablewrap"><table class="keys">
6<tr class="cols"><th scope="col">label</th>{{if or $.CanWrite $.AnyColor}}<th scope="col">colour</th>{{end}}<th scope="col">issues</th><th scope="col">merge requests</th><th scope="col"></th></tr> 6<tr class="cols"><th scope="col">label</th>{{if or $.CanWrite $.AnyColor}}<th scope="col">colour</th>{{end}}<th scope="col">issues</th><th scope="col">merge requests</th><th scope="col"><span class="vh">actions</span></th></tr>
7{{range .Labels}}<tr> 7{{range .Labels}}<tr>
8 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td> 8 <td><a class="chip label" style="{{index $.LabelColors .Name}}" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/issues?label={{.Name}}">{{.Name}}</a>{{if .Org}} <span class="chip chip-neutral">org</span>{{end}}</td>
9 {{if or $.CanWrite $.AnyColor}}<td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline"> 9 {{if or $.CanWrite $.AnyColor}}<td>{{if and $.CanWrite (not .Org)}}<form method="post" action="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/labels" class="inline">
internal/web/templates/login.html +3 −3
@@ -19,16 +19,16 @@ expires in fifteen minutes.</p>
19<p>Browser sessions are minted over SSH — there is no password. From a machine 19<p>Browser sessions are minted over SSH — there is no password. From a machine
20with your registered key:</p> 20with your registered key:</p>
21{{end}} 21{{end}}
22<pre class="message">ssh git@{{.Host}} web login</pre> 22<pre class="message" tabindex="0">ssh git@{{.Host}} web login</pre>
23<p>then open the printed URL within five minutes.</p> 23<p>then open the printed URL within five minutes.</p>
24{{end}} 24{{end}}
25<h2>New here?</h2> 25<h2>New here?</h2>
26{{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH 26{{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH
27public key, verify your email. Or from the terminal:</p> 27public key, verify your email. Or from the terminal:</p>
28<pre class="message">ssh git@{{.Host}} register --username you --email you@example.org</pre> 28<pre class="message" tabindex="0">ssh git@{{.Host}} register --username you --email you@example.org</pre>
29{{else if eq .Mode "invite"}}<p>This instance is invite-only. With a code from an admin, 29{{else if eq .Mode "invite"}}<p>This instance is invite-only. With a code from an admin,
30<a href="/register">create an account</a> — pick a username, paste your SSH public key. Or from 30<a href="/register">create an account</a> — pick a username, paste your SSH public key. Or from
31the terminal:</p> 31the terminal:</p>
32<pre class="message">ssh git@{{.Host}} register --username you --invite &lt;code&gt;</pre> 32<pre class="message" tabindex="0">ssh git@{{.Host}} register --username you --invite &lt;code&gt;</pre>
33{{else}}<p>This instance is not accepting new accounts.</p>{{end}} 33{{else}}<p>This instance is not accepting new accounts.</p>{{end}}
34{{end}} 34{{end}}
internal/web/templates/owner.html +1 −1
@@ -128,6 +128,6 @@ of a team get its role on every repository it is granted.</p>
128</details> 128</details>
129<p class="meta">Deleting an organization is a CLI operation, like deleting a 129<p class="meta">Deleting an organization is a CLI operation, like deleting a
130repository: it wants a typed confirmation rather than a button.</p> 130repository: it wants a typed confirmation rather than a button.</p>
131<pre class="message">gitbay org delete {{$org}} --yes</pre> 131<pre class="message" tabindex="0">gitbay org delete {{$org}} --yes</pre>
132{{end}} 132{{end}}
133{{end}} 133{{end}}
internal/web/templates/registered.html +2 −2
@@ -3,13 +3,13 @@
3{{define "content"}} 3{{define "content"}}
4<div class="landing"> 4<div class="landing">
5<h1>Welcome, {{.Username}}</h1> 5<h1>Welcome, {{.Username}}</h1>
6<pre class="quickstart">{{.Message}}</pre> 6<pre class="quickstart" tabindex="0">{{.Message}}</pre>
7<h2>On the web</h2> 7<h2>On the web</h2>
8<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed 8<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
9link, and paste the code under <a href="/settings">Settings</a>. Then + 9link, and paste the code under <a href="/settings">Settings</a>. Then +
10creates your first repository.</p> 10creates your first repository.</p>
11<h2>From the terminal</h2> 11<h2>From the terminal</h2>
12<pre class="quickstart">ssh git@{{.Host}} whoami 12<pre class="quickstart" tabindex="0">ssh git@{{.Host}} whoami
13ssh git@{{.Host}} repo create {{.Username}}/hello 13ssh git@{{.Host}} repo create {{.Username}}/hello
14gitbay web login # mints a browser session from your terminal</pre> 14gitbay web login # mints a browser session from your terminal</pre>
15</div> 15</div>
internal/web/templates/search.html +1 −1
@@ -13,7 +13,7 @@
13<ul class="matchlist"> 13<ul class="matchlist">
14{{range .Matches}}<li> 14{{range .Matches}}<li>
15 <a class="matchpath" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}#L{{.Line}}">{{.Path}}:{{.Line}}</a> 15 <a class="matchpath" href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/blob/{{$.Ref}}/{{.Path}}#L{{.Line}}">{{.Path}}:{{.Line}}</a>
16 <pre class="matchline">{{.TextHTML}}</pre> 16 <pre class="matchline" tabindex="0">{{.TextHTML}}</pre>
17</li> 17</li>
18{{end}} 18{{end}}
19</ul> 19</ul>
internal/web/templates/tree.html +2 −2
@@ -50,8 +50,8 @@
50 </div>{{end}} 50 </div>{{end}}
51 <div class="clone"> 51 <div class="clone">
52 <h2>Clone</h2> 52 <h2>Clone</h2>
53 <span class="fieldname">SSH</span><pre><code>git clone {{.SSHCloneURL}}</code></pre> 53 <span class="fieldname">SSH</span><pre tabindex="0"><code>git clone {{.SSHCloneURL}}</code></pre>
54 <span class="fieldname">HTTPS</span><pre><code>git clone {{.CloneURL}}</code></pre> 54 <span class="fieldname">HTTPS</span><pre tabindex="0"><code>git clone {{.CloneURL}}</code></pre>
55 </div> 55 </div>
56</div>{{end}} 56</div>{{end}}
57</div> 57</div>
internal/web/web_test.go +43
@@ -180,3 +180,46 @@ func TestRailIconsAreLabelled(t *testing.T) {
180 } 180 }
181 } 181 }
182} 182}
183
184// Every pre in the stylesheet scrolls sideways, which makes it a
185// scrollable region: a keyboard reaches its content only if the element
186// can take focus, so every authored pre carries tabindex="0" — the same
187// attribute focusableBlocks puts on the pre blocks of rendered markup
188// (#226).
189func TestPreBlocksAreFocusable(t *testing.T) {
190 if !regexp.MustCompile(`(?s)\npre \{[^}]*overflow-x: auto`).Match(StyleCSS) {
191 t.Fatal("style.css no longer scrolls every pre; this test's premise is gone")
192 }
193 preRe := regexp.MustCompile(`<pre[^>]*>`)
194 n := 0
195 for _, name := range append(Pages(), "layout.html") {
196 src, err := TemplateSource(name)
197 if err != nil {
198 t.Fatal(err)
199 }
200 for _, tag := range preRe.FindAllString(src, -1) {
201 n++
202 if !strings.Contains(tag, `tabindex="0"`) {
203 t.Errorf("%s: %s is a scrollable region with no way to focus it", name, tag)
204 }
205 }
206 }
207 if n < 10 {
208 t.Fatalf("found %d pre blocks in the templates, want the site's set", n)
209 }
210}
211
212// A link mixed into other text is told apart by its underline. The rule
213// covers running text; these are the other places a link sits in a line
214// of text it has to be picked out of — a path, a heading, an empty-state
215// note. Link and muted text are 1.07:1 apart in dark, so colour alone is
216// no cue (#226).
217func TestMixedTextLinksAreUnderlined(t *testing.T) {
218 css := string(StyleCSS)
219 for _, sel := range []string{".crumbs a", "h1 a", "li.empty a"} {
220 re := regexp.MustCompile(`(?m)^[^{}\n]*` + regexp.QuoteMeta(sel) + `[^{}\n]*\{[^}]*text-decoration: underline`)
221 if !re.MatchString(css) {
222 t.Errorf("%s is not underlined", sel)
223 }
224 }
225}