web: the accessibility findings from the wider scan !437
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 | ||
| 32 | Three accessibility defects the wider scan found, on pages the | ||
| 33 | sixteen-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 | ||
| 34 | Every command runs on every surface, markup previews before it is | 49 | Every 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 @@ | |||
| 1 | package httpd | 1 | package httpd |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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). | 29 | func ground(chip, canvas string) string { |
| 25 | func 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 | |||
| 38 | func 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). | ||
| 49 | func 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. | ||
| 87 | func 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 | ||
| 1628 | var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) | 1628 | var 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). | 1634 | const ( |
| 1635 | func 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. | ||
| 1647 | func 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. | ||
| 1654 | func 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. | ||
| 1673 | func 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). | ||
| 1694 | func 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. | ||
| 1704 | func 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. | ||
| 1713 | func 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 { | 1724 | func 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 | ||
| 1737 | func chipLuminance(hex string) float64 { | ||
| 1738 | r, g, b := chipLinear(hex) | ||
| 1739 | return 0.2126*r + 0.7152*g + 0.0722*b | ||
| 1740 | } | ||
| 1741 | |||
| 1670 | func hexByte(s string) int64 { | 1742 | func 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. |
| 1686 | func colorStyles(stored map[string]string) map[string]template.CSS { | 1758 | func 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; } |
| 200 | html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } | 208 | html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } |
| @@ -218,6 +226,10 @@ a:hover { text-decoration: underline; } | |||
| 218 | p a, .syscomment a { text-decoration: underline; } | 226 | p 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 */ |
| 220 | p a.button, p a.btn, p a.chip, .repotitle a, p.title a, .pathbar a, .pager a { text-decoration: none; } | 228 | p 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; } | ||
| 221 | a.xref { color: var(--link); text-decoration: none; } | 233 | a.xref { color: var(--link); text-decoration: none; } |
| 222 | 234 | ||
| 223 | code, pre, .mono, .code, td.mode, td.size { | 235 | code, 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 |
| 165 | easier to pipe than to paste, and a minted token is shown once.</p> | 165 | easier 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 |
| 167 | gitbay web sessions list # browser sessions | 167 | gitbay web sessions list # browser sessions |
| 168 | gitbay admin ... # instance administration</pre> | 168 | gitbay 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">[](https://{{.Host}}/{{.Repo.OwnerName}}/{{.Repo.Name}}/builds)</pre> | 35 | <pre class="code" tabindex="0">[](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}} <{{.AuthorEmail}}> · {{when .Date}} | 9 | <p class="meta">{{template "authorname" dict "Name" .AuthorName "User" .AuthorUser "Email" .AuthorEmail}} <{{.AuthorEmail}}> · {{when .Date}} |
| 10 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{end}}</p> | 10 | {{if .CommitterEmail}}<br>committer: <{{.CommitterEmail}}>{{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 |
| 20 | with your registered key:</p> | 20 | with 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 |
| 27 | public key, verify your email. Or from the terminal:</p> | 27 | public 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 |
| 31 | the terminal:</p> | 31 | the terminal:</p> |
| 32 | <pre class="message">ssh git@{{.Host}} register --username you --invite <code></pre> | 32 | <pre class="message" tabindex="0">ssh git@{{.Host}} register --username you --invite <code></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 |
| 130 | repository: it wants a typed confirmation rather than a button.</p> | 130 | repository: 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 |
| 9 | link, and paste the code under <a href="/settings">Settings</a>. Then + | 9 | link, and paste the code under <a href="/settings">Settings</a>. Then + |
| 10 | creates your first repository.</p> | 10 | creates 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 |
| 13 | ssh git@{{.Host}} repo create {{.Username}}/hello | 13 | ssh git@{{.Host}} repo create {{.Username}}/hello |
| 14 | gitbay web login # mints a browser session from your terminal</pre> | 14 | gitbay 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). | ||
| 189 | func 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). | ||
| 217 | func 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 | } | ||