Commit 40960c8f5b
Verified · cmc
e2e/label_test.go +3 −2
| @@ -43,8 +43,9 @@ func TestLabelColors(t *testing.T) { | ||
| 43 | 43 | !strings.Contains(out, `{"name":"docs","issues":0,"mrs":0}`) { |
| 44 | 44 | t.Fatalf("list json:\n%s", out) |
| 45 | 45 | } |
| 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:#") { | |
| 48 | 49 | t.Fatalf("issue list does not carry the colour:\n%s", body) |
| 49 | 50 | } |
| 50 | 51 | // Clearing the colour keeps the label; removing it unlinks the issue. |
internal/httpd/chip_test.go +79 −16
| @@ -1,9 +1,14 @@ | ||
| 1 | 1 | package httpd |
| 2 | 2 | |
| 3 | 3 | import ( |
| 4 | "fmt" | |
| 4 | 5 | "math" |
| 6 | "regexp" | |
| 5 | 7 | "strconv" |
| 8 | "strings" | |
| 6 | 9 | "testing" |
| 10 | ||
| 11 | "gitbay.org/gitbay/internal/web" | |
| 7 | 12 | ) |
| 8 | 13 | |
| 9 | 14 | // relLum is WCAG relative luminance of a #rrggbb colour. |
| @@ -19,25 +24,83 @@ func relLum(hex string) float64 { | ||
| 19 | 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 | |
| 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). | |
| 25 | func 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. | |
| 29 | func 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 | ||
| 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"} { | |
| 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 | |
| 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" { | |
| 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 | } | |
| 42 | 105 | } |
| 43 | 106 | } |
internal/httpd/web.go +97 −24
| @@ -1627,12 +1627,90 @@ var labelPalette = []string{ | ||
| 1627 | 1627 | |
| 1628 | 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 | |
| 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). | |
| 1635 | func 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. | |
| 1634 | const ( | |
| 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 | 1714 | lin := func(c int64) float64 { |
| 1637 | 1715 | v := float64(c) / 255 |
| 1638 | 1716 | if v <= 0.04045 { |
| @@ -1640,23 +1718,12 @@ func clampChip(hex string) string { | ||
| 1640 | 1718 | } |
| 1641 | 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])) | |
| 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 | ||
| 1724 | func chipHex(r, g, b float64) string { | |
| 1659 | 1725 | enc := func(v float64) int { |
| 1726 | v = math.Min(1, math.Max(0, v)) | |
| 1660 | 1727 | if v <= 0.0031308 { |
| 1661 | 1728 | v *= 12.92 |
| 1662 | 1729 | } else { |
| @@ -1667,6 +1734,11 @@ func clampChip(hex string) string { | ||
| 1667 | 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 | 1742 | func hexByte(s string) int64 { |
| 1671 | 1743 | n, _ := strconv.ParseInt(s, 16, 32) |
| 1672 | 1744 | return n |
| @@ -1682,7 +1754,7 @@ func (s *Server) labelColors(repo store.Repo) map[string]template.CSS { | ||
| 1682 | 1754 | |
| 1683 | 1755 | // colorStyles turns a label-name -> stored color map into chip styles: the |
| 1684 | 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 | 1758 | func colorStyles(stored map[string]string) map[string]template.CSS { |
| 1687 | 1759 | out := make(map[string]template.CSS, len(stored)) |
| 1688 | 1760 | for name, color := range stored { |
| @@ -1691,7 +1763,8 @@ func colorStyles(stored map[string]string) map[string]template.CSS { | ||
| 1691 | 1763 | h.Write([]byte(name)) |
| 1692 | 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 | 1769 | return out |
| 1697 | 1770 | } |
internal/web/static/style.css +21 −2
| @@ -138,6 +138,10 @@ | ||
| 138 | 138 | } |
| 139 | 139 | @media (prefers-color-scheme: dark) { |
| 140 | 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 | 145 | --canvas: #101114; |
| 142 | 146 | --surface: #212429; |
| 143 | 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 | 206 | /* ---- base ---- */ |
| 199 | 207 | * { box-sizing: border-box; } |
| 200 | 208 | html { font-size: 16px; -webkit-text-size-adjust: 100%; text-size-adjust: 100%; } |
| @@ -218,6 +226,10 @@ a:hover { text-decoration: underline; } | ||
| 218 | 226 | p a, .syscomment a { text-decoration: underline; } |
| 219 | 227 | /* a title or a path is the whole line: position is the cue */ |
| 220 | 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 | 233 | a.xref { color: var(--link); text-decoration: none; } |
| 222 | 234 | |
| 223 | 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 | 1346 | .chip-stale { --chip: var(--warn); } |
| 1335 | 1347 | .chip.topic { --chip: var(--link); } |
| 1336 | 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 */ | |
| 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 | } | |
| 1339 | 1358 | .badge-verified { --chip: var(--ok); } |
| 1340 | 1359 | .badge-unsigned { --chip: var(--neutral); } |
| 1341 | 1360 | .badge-signed_unknown_key, .badge-signed_key_expired { --chip: var(--warn); } |
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 | } | |