Commit 8bae67052c

8bae67052c75bf6469522c4b6501791f820de5e3

parent: 9d8fd17820

Verified · cmc ci/build: success ci/test: success ci/vuln: success

cmc <hello@cleberg.net> · 2026-09-04 01:42 UTC

web: a visible edge on dark-mode buttons, and label colours held legible

--fill stayed #0000f0 in dark and buttons had no border, so a button's
boundary on #0a0a0a was 1.3:1 against the 3:1 WCAG asks of non-text
contrast; buttons carry a 1px --fill-edge, transparent in light and a
lighter blue in dark. A stored label colour was used as chip text as
given, so #ffff00 was unreadable on white; clampChip holds its relative
luminance between 0.12 and 0.28, where it clears 3:1 on both grounds
and where the palette's own colours sit, keeping the hue.

Closes #120

Layout: unified · split

internal/httpd/chip_test.go added +43
@@ -0,0 +1,43 @@
1package httpd
2
3import (
4 "math"
5 "strconv"
6 "testing"
7)
8
9// relLum is WCAG relative luminance of a #rrggbb colour.
10func relLum(hex string) float64 {
11 lin := func(s string) float64 {
12 n, _ := strconv.ParseInt(s, 16, 32)
13 v := float64(n) / 255
14 if v <= 0.04045 {
15 return v / 12.92
16 }
17 return math.Pow((v+0.055)/1.055, 2.4)
18 }
19 return 0.2126*lin(hex[1:3]) + 0.7152*lin(hex[3:5]) + 0.0722*lin(hex[5:7])
20}
21
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)
29 }
30 }
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)
38 }
39 }
40 if got := clampChip("#ffff00"); got == "#ffff00" {
41 t.Error("yellow passed through unchanged")
42 }
43}
internal/httpd/web.go +47 −1
@@ -9,6 +9,7 @@ import (
99 "hash/fnv"
1010 "io"
1111 "log"
12 "math"
1213 "os"
1314 "path/filepath"
1415
@@ -1506,6 +1507,51 @@ var labelPalette = []string{
15061507
15071508var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
15081509
1510// clampChip keeps a user-set label colour legible as text on both
1511// grounds. Contrast is defined on relative luminance, so that is what is
1512// held: between 0.12 and 0.28, where the chip clears 3:1 against white
1513// and against the dark ground alike, and where the palette's own colours
1514// sit. The hue is kept; the channels are scaled in linear light (#120).
1515func clampChip(hex string) string {
1516 lin := func(c int64) float64 {
1517 v := float64(c) / 255
1518 if v <= 0.04045 {
1519 return v / 12.92
1520 }
1521 return math.Pow((v+0.055)/1.055, 2.4)
1522 }
1523 r, g, b := lin(hexByte(hex[1:3])), lin(hexByte(hex[3:5])), lin(hexByte(hex[5:7]))
1524 y := 0.2126*r + 0.7152*g + 0.0722*b
1525 const lo, hi = 0.12, 0.28
1526 if y >= lo && y <= hi {
1527 return strings.ToLower(hex)
1528 }
1529 target := hi
1530 if y < lo {
1531 target = lo
1532 }
1533 if y == 0 {
1534 r, g, b = target, target, target
1535 } else {
1536 k := target / y
1537 r, g, b = math.Min(1, r*k), math.Min(1, g*k), math.Min(1, b*k)
1538 }
1539 enc := func(v float64) int {
1540 if v <= 0.0031308 {
1541 v *= 12.92
1542 } else {
1543 v = 1.055*math.Pow(v, 1/2.4) - 0.055
1544 }
1545 return int(math.Round(v * 255))
1546 }
1547 return fmt.Sprintf("#%02x%02x%02x", enc(r), enc(g), enc(b))
1548}
1549
1550func hexByte(s string) int64 {
1551 n, _ := strconv.ParseInt(s, 16, 32)
1552 return n
1553}
1554
15091555// labelColors returns a complete label-name -> chip color map for a repo:
15101556// the stored labels.color when it is a valid hex color, otherwise a
15111557// stable default picked from the palette by name hash.
@@ -1518,7 +1564,7 @@ func (s *Server) labelColors(repoID int64) map[string]template.CSS {
15181564 h.Write([]byte(name))
15191565 color = labelPalette[h.Sum32()%uint32(len(labelPalette))]
15201566 }
1521 out[name] = template.CSS("--chip:" + color)
1567 out[name] = template.CSS("--chip:" + clampChip(color))
15221568 }
15231569 return out
15241570}
internal/web/static/style.css +3 −1
@@ -51,6 +51,7 @@
5151 --accent: #0000f0; /* links, focus — 9.30:1 on white */
5252 --accent-fg: #ffffff;
5353 --fill: #0000f0; /* button grounds; white on it is 9.30:1 */
54 --fill-edge: transparent; /* light: the fill is its own boundary */
5455
5556 /* orange: where you are, what wants you */
5657 --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */
@@ -118,6 +119,7 @@
118119 there and 5.06:1 on the page. */
119120 --accent-fg: #ffffff;
120121 --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */
122 --fill-edge: #7d8cff; /* a button's boundary: #0000f0 on #0a0a0a is 1.3:1 (#120) */
121123 --mark: #ff6b3d;
122124 --warn: #ff6b3d;
123125 --ok: #3fce7a;
@@ -1291,7 +1293,7 @@ input:user-invalid, textarea:user-invalid {
12911293button {
12921294 background: var(--fill);
12931295 color: #ffffff;
1294 border: none;
1296 border: 1px solid var(--fill-edge);
12951297 border-radius: var(--r-md);
12961298 padding: var(--sp-2) var(--sp-4);
12971299 font-weight: 600;