Commit 40960c8f5b

40960c8f5b3febad7ddb7ed42ef02a4b572fb4d6

parent: 85130480d7

Verified · cmc

cmc <hello@cleberg.net> · 2026-09-19 23:57 UTC

httpd, web: a chip tone per scheme, and an underline where a link shares its line

Chip text is 12px, so it owes 4.5:1 against the ground mixed from the
colour itself, and the luminance band that clears it on white ends
below the band that clears it on the dark canvas: one colour cannot
serve both. A label now carries a tone per scheme, picked by
light-dark(), with the hue kept where scaling in linear light reaches
the ratio and a blend toward white where it cannot. The stored hex is
untouched. A page stamped light or dark sets color-scheme with its
tokens, which light-dark() reads and native controls follow.

The same stylesheet underlines a link that shares its line with other
text — a path crumb, a heading naming an owner, an empty-state note —
since link and muted text are 1.07:1 apart in dark.

Ref #226
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/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}