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.
2929 phone. Below 80rem the dashboard's pinned repositories return to a
3030 chip row above the queues.
3131
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
3247* v1.30.0 — 2026-09-19
3348
3449Every 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) {
4343 !strings.Contains(out, `{"name":"docs","issues":0,"mrs":0}`) {
4444 t.Fatalf("list json:\n%s", out)
4545 }
46 // The web paints the chip with the stored colour.
47 if _, body := inst.get(t, "/alice/app/issues"); !strings.Contains(body, "--chip:#cf222e") {
46 // The web paints the chip with the stored colour, as the tone each
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:#") {
4849 t.Fatalf("issue list does not carry the colour:\n%s", body)
4950 }
5051 // Clearing the colour keeps the label; removing it unlinks the issue.
internal/httpd/chip_test.go +79 −16
@@ -1,9 +1,14 @@
11package httpd
22
33import (
4 "fmt"
45 "math"
6 "regexp"
57 "strconv"
8 "strings"
69 "testing"
10
11 "gitbay.org/gitbay/internal/web"
712)
813
914// relLum is WCAG relative luminance of a #rrggbb colour.
@@ -19,25 +24,83 @@ func relLum(hex string) float64 {
1924 return 0.2126*lin(hex[1:3]) + 0.7152*lin(hex[3:5]) + 0.0722*lin(hex[5:7])
2025}
2126
22// A label colour is text on the chip. The palette passes through; a
23// stored yellow, white, black or pastel comes back with a luminance that
24// clears 3:1 against both white and the dark ground (#120).
25func TestClampChip(t *testing.T) {
26 for _, keep := range labelPalette {
27 if got := clampChip(keep); got != keep {
28 t.Errorf("palette colour %s changed to %s", keep, got)
27// ground mixes a tenth of the chip colour into the canvas the way
28// color-mix(in srgb, chip 10%, canvas) does, and ratio is WCAG contrast.
29func ground(chip, canvas string) string {
30 ch := func(a, b string) string {
31 na, _ := strconv.ParseInt(a, 16, 32)
32 nb, _ := strconv.ParseInt(b, 16, 32)
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)
2959 }
3060 }
31 for _, in := range []string{"#ffff00", "#FFFFFF", "#000000", "#ffcccc", "#00ff00", "#101010"} {
32 got := clampChip(in)
33 y := relLum(got)
34 onWhite := 1.05 / (y + 0.05)
35 onDark := (y + 0.05) / (relLum("#0a0a0a") + 0.05)
36 if onWhite < 3 || onDark < 3 {
37 t.Errorf("clampChip(%s) = %s: %.2f:1 on white, %.2f:1 on dark", in, got, onWhite, onDark)
61
62 // The hue survives the move wherever scaling can reach the ratio: a
63 // purple stays a purple, not a grey, in both schemes.
64 light, dark := chipTones("#8250df")
65 for _, tone := range []string{light, dark} {
66 chans := func(s string) (r, g, b int64) {
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
3872 }
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")
3992 }
40 if got := clampChip("#ffff00"); got == "#ffff00" {
41 t.Error("yellow passed through unchanged")
93 re := regexp.MustCompile(`--canvas:\s*(#[0-9a-f]{6});`)
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 }
42105 }
43106}
internal/httpd/web.go +97 −24
@@ -1627,12 +1627,90 @@ var labelPalette = []string{
16271627
16281628var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
16291629
1630// clampChip keeps a user-set label colour legible as text on both
1631// grounds. Contrast is defined on relative luminance, so that is what is
1632// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1633// and against the dark ground alike, and where the palette's own colours
1634// sit. The hue is kept; the channels are scaled in linear light (#120).
1635func clampChip(hex string) string {
1630// The canvases a chip is drawn on, --canvas in each scheme, and the ratio
1631// its text owes them. Chip text is 12px, which WCAG reads as small text at
1632// 4.5:1. TestChipCanvasMatchesStylesheet keeps these in step with the
1633// tokens.
1634const (
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) {
16361714 lin := func(c int64) float64 {
16371715 v := float64(c) / 255
16381716 if v <= 0.04045 {
@@ -1640,23 +1718,12 @@ func clampChip(hex string) string {
16401718 }
16411719 return math.Pow((v+0.055)/1.055, 2.4)
16421720 }
1643 r, g, b := 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
1645 const lo, hi = 0.12, 0.28
1646 if y >= lo && y <= hi {
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 }
1721 return lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1722}
1723
1724func chipHex(r, g, b float64) string {
16591725 enc := func(v float64) int {
1726 v = math.Min(1, math.Max(0, v))
16601727 if v <= 0.0031308 {
16611728 v *= 12.92
16621729 } else {
@@ -1667,6 +1734,11 @@ func clampChip(hex string) string {
16671734 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
16681735}
16691736
1737func chipLuminance(hex string) float64 {
1738 r, g, b := chipLinear(hex)
1739 return 0.2126*r + 0.7152*g + 0.0722*b
1740}
1741
16701742func hexByte(s string) int64 {
16711743 n, _ := strconv.ParseInt(s, 16, 32)
16721744 return n
@@ -1682,7 +1754,7 @@ func (s *Server) labelColors(repo store.Repo) map[string]template.CSS {
16821754
16831755// colorStyles turns a label-name -> stored color map into chip styles: the
16841756// 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.
16861758func colorStyles(stored map[string]string) map[string]template.CSS {
16871759 out := make(map[string]template.CSS, len(stored))
16881760 for name, color := range stored {
@@ -1691,7 +1763,8 @@ func colorStyles(stored map[string]string) map[string]template.CSS {
16911763 h.Write([]byte(name))
16921764 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
16931765 }
1694 out[name] = template.CSS("--chip:" + clampChip(color))
1766 light, dark := chipTones(color)
1767 out[name] = template.CSS("--chip-l:" + light + ";--chip-d:" + dark)
16951768 }
16961769 return out
16971770}
internal/web/static/style.css +21 −2
@@ -138,6 +138,10 @@
138138}
139139@media (prefers-color-scheme: dark) {
140140 :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
141145 --canvas: #101114;
142146 --surface: #212429;
143147 --inset: #0b0c0e;
@@ -195,6 +199,10 @@
195199 }
196200}
197201
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
198206/* ---- base ---- */
199207* { box-sizing: border-box; }
200208html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
@@ -218,6 +226,10 @@ a:hover { text-decoration: underline; }
218226p a, .syscomment a { text-decoration: underline; }
219227/* a title or a path is the whole line: position is the cue */
220228p 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; }
221233a.xref { color: var(--link); text-decoration: none; }
222234
223235code, pre, .mono, .code, td.mode, td.size {
@@ -1334,8 +1346,15 @@ table.difftable td.ln a.cmt:focus { color: var(--link); text-decoration: underli
13341346.chip-stale { --chip: var(--warn); }
13351347.chip.topic { --chip: var(--link); }
13361348.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 */
1338.chip.label { font-weight: 500; }
1349/* A label carries its own colour inline, from the label's hex, as one
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}
13391358.badge-verified { --chip: var(--ok); }
13401359.badge-unsigned { --chip: var(--neutral); }
13411360.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>
163163<section id="cli"><h2>On the command line</h2>
164164<p class="meta">No page here yet, and nothing refusing one: a credential is
165165easier 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
167167gitbay web sessions list # browser sessions
168168gitbay admin ... # instance administration</pre>
169169<p class="meta">All of it works from stock OpenSSH too:
internal/web/templates/blame.html +1 −1
@@ -19,7 +19,7 @@
1919 <p class="subject"><a href="/{{$.Repo.OwnerName}}/{{$.Repo.Name}}/commit/{{.SHA}}">{{.Summary}}</a></p>
2020 <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>
2121 </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}}
2323{{end}}</pre>
2424</div>
2525{{else}}<p class="empty-note">empty file</p>{{end}}
internal/web/templates/build.html +1 −1
@@ -12,5 +12,5 @@
1212{{end}}
1313</div>
1414<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}}
1616{{end}}
internal/web/templates/builds.html +1 −1
@@ -32,7 +32,7 @@
3232{{end}}
3333<details class="editbox"><summary>Status badge</summary>
3434<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>
3636<p class="meta">Add <code>?job=name</code> for one job.</p>
3737</details>
3838<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 @@
99 <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} &lt;{{.AuthorEmail}}&gt; · {{when .Date}}
1010 {{if .CommitterEmail}}<br>committer: &lt;{{.CommitterEmail}}&gt;{{end}}</p>
1111</div>
12<pre class="message">{{.Message}}</pre>
12<pre class="message" tabindex="0">{{.Message}}</pre>
1313{{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}}
1414{{template "difffiles" dict "Files" .DiffFiles "Base" "" "Viewer" ""}}
1515{{end}}
internal/web/templates/labels.html +1 −1
@@ -3,7 +3,7 @@
33<h1>Labels</h1>
44{{if .Notice}}<p class="error" role="alert">{{.Notice}}</p>{{end}}
55{{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>
77{{range .Labels}}<tr>
88 <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>
99 {{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>
1919<p>Browser sessions are minted over SSH — there is no password. From a machine
2020with your registered key:</p>
2121{{end}}
22<pre class="message">ssh git@{{.Host}} web login</pre>
22<pre class="message" tabindex="0">ssh git@{{.Host}} web login</pre>
2323<p>then open the printed URL within five minutes.</p>
2424{{end}}
2525<h2>New here?</h2>
2626{{if eq .Mode "open"}}<p><a href="/register">Create an account</a> — pick a username, paste your SSH
2727public 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>
2929{{else if eq .Mode "invite"}}<p>This instance is invite-only. With a code from an admin,
3030<a href="/register">create an account</a> — pick a username, paste your SSH public key. Or from
3131the 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>
3333{{else}}<p>This instance is not accepting new accounts.</p>{{end}}
3434{{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>
128128</details>
129129<p class="meta">Deleting an organization is a CLI operation, like deleting a
130130repository: 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>
132132{{end}}
133133{{end}}
internal/web/templates/registered.html +2 −2
@@ -3,13 +3,13 @@
33{{define "content"}}
44<div class="landing">
55<h1>Welcome, {{.Username}}</h1>
6<pre class="quickstart">{{.Message}}</pre>
6<pre class="quickstart" tabindex="0">{{.Message}}</pre>
77<h2>On the web</h2>
88<p>Check your mail for the code, <a href="/login">sign in</a> with an emailed
99link, and paste the code under <a href="/settings">Settings</a>. Then +
1010creates your first repository.</p>
1111<h2>From the terminal</h2>
12<pre class="quickstart">ssh git@{{.Host}} whoami
12<pre class="quickstart" tabindex="0">ssh git@{{.Host}} whoami
1313ssh git@{{.Host}} repo create {{.Username}}/hello
1414gitbay web login # mints a browser session from your terminal</pre>
1515</div>
internal/web/templates/search.html +1 −1
@@ -13,7 +13,7 @@
1313<ul class="matchlist">
1414{{range .Matches}}<li>
1515 <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>
1717</li>
1818{{end}}
1919</ul>
internal/web/templates/tree.html +2 −2
@@ -50,8 +50,8 @@
5050 </div>{{end}}
5151 <div class="clone">
5252 <h2>Clone</h2>
53 <span class="fieldname">SSH</span><pre><code>git clone {{.SSHCloneURL}}</code></pre>
54 <span class="fieldname">HTTPS</span><pre><code>git clone {{.CloneURL}}</code></pre>
53 <span class="fieldname">SSH</span><pre tabindex="0"><code>git clone {{.SSHCloneURL}}</code></pre>
54 <span class="fieldname">HTTPS</span><pre tabindex="0"><code>git clone {{.CloneURL}}</code></pre>
5555 </div>
5656</div>{{end}}
5757</div>
internal/web/web_test.go +43
@@ -180,3 +180,46 @@ func TestRailIconsAreLabelled(t *testing.T) {
180180 }
181181 }
182182}
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}