web: pick syntax palettes on measured contrast !89

merged merged by cmc on 2026-08-26 23:06 UTC · krz/gitbay:light-palette into main

3 files changed, +116 −5

Layout: unified · split

internal/httpd/diff.go +1 −1
@@ -227,7 +227,7 @@ func highlightLines(lexer chroma.Lexer, src string) []template.HTML {
227 return nil 227 return nil
228 } 228 }
229 var buf bytes.Buffer 229 var buf bytes.Buffer
230 if err := diffFormatter.Format(&buf, styles.Get("friendly"), it); err != nil { 230 if err := diffFormatter.Format(&buf, styles.Get(lightStyle), it); err != nil {
231 return nil 231 return nil
232 } 232 }
233 body := buf.String() 233 body := buf.String()
internal/httpd/palette_test.go added +96
@@ -0,0 +1,96 @@
1package httpd
2
3import (
4 "math"
5 "testing"
6
7 "github.com/alecthomas/chroma/v2"
8 "github.com/alecthomas/chroma/v2/styles"
9)
10
11func channel(v float64) float64 {
12 if v <= 0.03928 {
13 return v / 12.92
14 }
15 return math.Pow((v+0.055)/1.055, 2.4)
16}
17
18func luminance(c chroma.Colour) float64 {
19 return 0.2126*channel(float64(c.Red())/255) +
20 0.7152*channel(float64(c.Green())/255) +
21 0.0722*channel(float64(c.Blue())/255)
22}
23
24func contrast(a, b chroma.Colour) float64 {
25 hi, lo := luminance(a), luminance(b)
26 if hi < lo {
27 hi, lo = lo, hi
28 }
29 return (hi + 0.05) / (lo + 0.05)
30}
31
32// TestSyntaxPaletteContrast holds both palettes to WCAG AA for body text
33// against every ground code sits on here: the page, a code block, and the
34// two diff tints. The chroma default (friendly) put 61 token/ground pairs
35// under the floor, which is why the styles are chosen rather than assumed.
36//
37// Two tokens are exempted because the stylesheet overrides them after
38// writing the palette, which this test cannot see: NameAttribute in light
39// (#6f5a21) and the line-number tokens in dark (#8b949e).
40func TestSyntaxPaletteContrast(t *testing.T) {
41 const floor = 4.5
42 // chroma's own line-number gutter renders on blob pages only — the diff
43 // supplies its own gutter — so those tokens are checked against the page
44 // and code grounds, never against a diff tint.
45 gutterOnly := map[chroma.TokenType]bool{
46 chroma.LineNumbers: true, chroma.LineNumbersTable: true,
47 chroma.LineHighlight: true, chroma.LineTable: true, chroma.LineTableTD: true,
48 }
49 cases := []struct {
50 style string
51 grounds map[string]chroma.Colour
52 exempt map[chroma.TokenType]bool
53 }{
54 {lightStyle, map[string]chroma.Colour{
55 "page": chroma.NewColour(0xff, 0xff, 0xff),
56 "code": chroma.NewColour(0xf5, 0xf5, 0xf5),
57 "add": chroma.NewColour(0xe4, 0xf6, 0xea),
58 "del": chroma.NewColour(0xfd, 0xea, 0xea),
59 }, map[chroma.TokenType]bool{chroma.NameAttribute: true}},
60 {darkStyle, map[string]chroma.Colour{
61 "page": chroma.NewColour(0x0a, 0x0a, 0x0a),
62 "code": chroma.NewColour(0x05, 0x05, 0x05),
63 "add": chroma.NewColour(0x0d, 0x2a, 0x18),
64 "del": chroma.NewColour(0x2c, 0x11, 0x13),
65 }, map[chroma.TokenType]bool{
66 chroma.LineNumbers: true, chroma.LineNumbersTable: true,
67 }},
68 }
69
70 for _, tc := range cases {
71 st := styles.Get(tc.style)
72 if st == nil || st.Name != tc.style {
73 t.Fatalf("chroma style %q is not available", tc.style)
74 }
75 for _, tt := range st.Types() {
76 // Whitespace markers are meant to be near-invisible, and the
77 // background entry is a ground, not text.
78 if tt == chroma.TextWhitespace || tt == chroma.Background || tc.exempt[tt] {
79 continue
80 }
81 e := st.Get(tt)
82 if !e.Colour.IsSet() {
83 continue
84 }
85 for name, ground := range tc.grounds {
86 if gutterOnly[tt] && (name == "add" || name == "del") {
87 continue
88 }
89 if got := contrast(e.Colour, ground); got < floor {
90 t.Errorf("%s: %s (%s) on %s is %.2f:1, want >= %.1f",
91 tc.style, tt, e.Colour, name, got, floor)
92 }
93 }
94 }
95 }
96}
internal/httpd/web.go +19 −4
@@ -823,7 +823,7 @@ func highlight(filePath string, data []byte) template.HTML {
823 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 823 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
824 } 824 }
825 var buf bytes.Buffer 825 var buf bytes.Buffer
826 if err := chromaFormatter.Format(&buf, styles.Get("friendly"), iterator); err != nil { 826 if err := chromaFormatter.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
827 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>") 827 return template.HTML("<pre>" + template.HTMLEscapeString(string(data)) + "</pre>")
828 } 828 }
829 return template.HTML(buf.String()) 829 return template.HTML(buf.String())
@@ -835,12 +835,27 @@ func highlight(filePath string, data []byte) template.HTML {
835// light-theme colour on a black ground — NameAttribute landed at 2.97:1. 835// light-theme colour on a black ground — NameAttribute landed at 2.97:1.
836// Scoped, an unnamed token inherits the wrapper's colour instead, which is 836// Scoped, an unnamed token inherits the wrapper's colour instead, which is
837// readable in both. The site's --code-bg stays the background either way. 837// readable in both. The site's --code-bg stays the background either way.
838// lightStyle and darkStyle are chosen on measured contrast against the
839// grounds code actually sits on here — page, code block, and the diff
840// tints. friendly, the chroma default, put 61 token/ground pairs under
841// 4.5:1; xcode puts one.
842const (
843 lightStyle = "xcode"
844 darkStyle = "github-dark"
845)
846
838var chromaCSS = func() []byte { 847var chromaCSS = func() []byte {
839 var buf bytes.Buffer 848 var buf bytes.Buffer
840 buf.WriteString("@media (prefers-color-scheme: light) {\n") 849 buf.WriteString("@media (prefers-color-scheme: light) {\n")
841 chromaFormatter.WriteCSS(&buf, styles.Get("friendly")) 850 chromaFormatter.WriteCSS(&buf, styles.Get(lightStyle))
851 // xcode's NameAttribute is its one token under 4.5:1 against the diff
852 // tints (4.51 on additions, 4.38 on deletions); darkened it clears both.
853 buf.WriteString(".chroma .na { color: #6f5a21 }\n")
842 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n") 854 buf.WriteString("}\n@media (prefers-color-scheme: dark) {\n")
843 chromaFormatter.WriteCSS(&buf, styles.Get("github-dark")) 855 chromaFormatter.WriteCSS(&buf, styles.Get(darkStyle))
856 // github-dark's line numbers are #6e7681, 4.31:1 on the page; lifted to
857 // the same grey its comments use, which clears the floor.
858 buf.WriteString(".chroma .lnt, .chroma .ln { color: #8b949e }\n")
844 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n") 859 buf.WriteString("}\n.chroma, .bg { background: transparent !important; }\n")
845 return buf.Bytes() 860 return buf.Bytes()
846}() 861}()
@@ -923,7 +938,7 @@ func fenceHighlight(source, lang string) string {
923 } 938 }
924 var buf bytes.Buffer 939 var buf bytes.Buffer
925 f := html.New(html.WithClasses(true)) 940 f := html.New(html.WithClasses(true))
926 if err := f.Format(&buf, styles.Get("friendly"), iterator); err != nil { 941 if err := f.Format(&buf, styles.Get(lightStyle), iterator); err != nil {
927 return "<pre>" + template.HTMLEscapeString(source) + "</pre>" 942 return "<pre>" + template.HTMLEscapeString(source) + "</pre>"
928 } 943 }
929 return buf.String() 944 return buf.String()