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 @@ |
| 1 | package httpd |
| 2 | |
| 3 | import ( |
| 4 | "math" |
| 5 | "strconv" |
| 6 | "testing" |
| 7 | ) |
| 8 | |
| 9 | // relLum is WCAG relative luminance of a #rrggbb colour. |
| 10 | func 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). |
| 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) |
| 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 ( |
| 9 | 9 | "hash/fnv" |
| 10 | 10 | "io" |
| 11 | 11 | "log" |
| 12 | "math" |
| 12 | 13 | "os" |
| 13 | 14 | "path/filepath" |
| 14 | 15 | |
| @@ -1506,6 +1507,51 @@ var labelPalette = []string{ |
| 1506 | 1507 | |
| 1507 | 1508 | var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) |
| 1508 | 1509 | |
| 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). |
| 1515 | func 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 | |
| 1550 | func hexByte(s string) int64 { |
| 1551 | n, _ := strconv.ParseInt(s, 16, 32) |
| 1552 | return n |
| 1553 | } |
| 1554 | |
| 1509 | 1555 | // labelColors returns a complete label-name -> chip color map for a repo: |
| 1510 | 1556 | // the stored labels.color when it is a valid hex color, otherwise a |
| 1511 | 1557 | // stable default picked from the palette by name hash. |
| @@ -1518,7 +1564,7 @@ func (s *Server) labelColors(repoID int64) map[string]template.CSS { |
| 1518 | 1564 | h.Write([]byte(name)) |
| 1519 | 1565 | color = labelPalette[h.Sum32()%uint32(len(labelPalette))] |
| 1520 | 1566 | } |
| 1521 | | out[name] = template.CSS("--chip:" + color) |
| 1567 | out[name] = template.CSS("--chip:" + clampChip(color)) |
| 1522 | 1568 | } |
| 1523 | 1569 | return out |
| 1524 | 1570 | } |
internal/web/static/style.css
+3 −1
| @@ -51,6 +51,7 @@ |
| 51 | 51 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ |
| 52 | 52 | --accent-fg: #ffffff; |
| 53 | 53 | --fill: #0000f0; /* button grounds; white on it is 9.30:1 */ |
| 54 | --fill-edge: transparent; /* light: the fill is its own boundary */ |
| 54 | 55 | |
| 55 | 56 | /* orange: where you are, what wants you */ |
| 56 | 57 | --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */ |
| @@ -118,6 +119,7 @@ |
| 118 | 119 | there and 5.06:1 on the page. */ |
| 119 | 120 | --accent-fg: #ffffff; |
| 120 | 121 | --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) */ |
| 121 | 123 | --mark: #ff6b3d; |
| 122 | 124 | --warn: #ff6b3d; |
| 123 | 125 | --ok: #3fce7a; |
| @@ -1291,7 +1293,7 @@ input:user-invalid, textarea:user-invalid { |
| 1291 | 1293 | button { |
| 1292 | 1294 | background: var(--fill); |
| 1293 | 1295 | color: #ffffff; |
| 1294 | | border: none; |
| 1296 | border: 1px solid var(--fill-edge); |
| 1295 | 1297 | border-radius: var(--r-md); |
| 1296 | 1298 | padding: var(--sp-2) var(--sp-4); |
| 1297 | 1299 | font-weight: 600; |