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 | "hash/fnv" |
9 | "hash/fnv" |
| 10 | "io" |
10 | "io" |
| 11 | "log" |
11 | "log" |
| |
12 | "math" |
| 12 | "os" |
13 | "os" |
| 13 | "path/filepath" |
14 | "path/filepath" |
| 14 | |
15 | |
| @@ -1506,6 +1507,51 @@ var labelPalette = []string{ |
| 1506 | |
1507 | |
| 1507 | var hexColorPat = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`) |
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 | // labelColors returns a complete label-name -> chip color map for a repo: |
1555 | // labelColors returns a complete label-name -> chip color map for a repo: |
| 1510 | // the stored labels.color when it is a valid hex color, otherwise a |
1556 | // the stored labels.color when it is a valid hex color, otherwise a |
| 1511 | // stable default picked from the palette by name hash. |
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 | h.Write([]byte(name)) |
1564 | h.Write([]byte(name)) |
| 1519 | color = labelPalette[h.Sum32()%uint32(len(labelPalette))] |
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 | return out |
1569 | return out |
| 1524 | } |
1570 | } |
internal/web/static/style.css
+3 −1
| @@ -51,6 +51,7 @@ |
| 51 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ |
51 | --accent: #0000f0; /* links, focus — 9.30:1 on white */ |
| 52 | --accent-fg: #ffffff; |
52 | --accent-fg: #ffffff; |
| 53 | --fill: #0000f0; /* button grounds; white on it is 9.30:1 */ |
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 | /* orange: where you are, what wants you */ |
56 | /* orange: where you are, what wants you */ |
| 56 | --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */ |
57 | --mark: #e4572e; /* bars and underlines — non-text, 3.0 bar */ |
| @@ -118,6 +119,7 @@ |
| 118 | there and 5.06:1 on the page. */ |
119 | there and 5.06:1 on the page. */ |
| 119 | --accent-fg: #ffffff; |
120 | --accent-fg: #ffffff; |
| 120 | --fill: #0000f0; /* fills stay pure; white on it clears 9:1 */ |
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 | --mark: #ff6b3d; |
123 | --mark: #ff6b3d; |
| 122 | --warn: #ff6b3d; |
124 | --warn: #ff6b3d; |
| 123 | --ok: #3fce7a; |
125 | --ok: #3fce7a; |
| @@ -1291,7 +1293,7 @@ input:user-invalid, textarea:user-invalid { |
| 1291 | button { |
1293 | button { |
| 1292 | background: var(--fill); |
1294 | background: var(--fill); |
| 1293 | color: #ffffff; |
1295 | color: #ffffff; |
| 1294 | border: none; |
1296 | border: 1px solid var(--fill-edge); |
| 1295 | border-radius: var(--r-md); |
1297 | border-radius: var(--r-md); |
| 1296 | padding: var(--sp-2) var(--sp-4); |
1298 | padding: var(--sp-2) var(--sp-4); |
| 1297 | font-weight: 600; |
1299 | font-weight: 600; |