Skip to content

Commit bde34d5

Browse files
committed
Expand a11y testing, fix inaccessible colors
This commit is focused on expanded accessibility testing and addressing discoveries from them: 1. `TestAccessibleDarkStyleConfigIs4Bit` and `TestAccessibleLightStyleConfigIs4Bit` will walk the Glamour StyleConfig type and check all elements 2. `Test_RenderAccessible` has been expanded with anchor and image elements In addition, there are several fixes to logic and tests: 1. `Test_RenderAccessible` Go codeblock was missing replacement value, resulting in inaccurate test input 2. Dark and light accessible style logic was missing link text, image text, and H6 styling 3. Handling the `StyleBlock` of codeblocks which is unclear how it interacts with Chroma
1 parent d3e3248 commit bde34d5

File tree

3 files changed

+187
-5
lines changed

3 files changed

+187
-5
lines changed

pkg/markdown/markdown_test.go

+73-1
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,14 @@ const (
2828
// It works by parsing the rendered markdown for ANSI escape sequences and checking their display attributes.
2929
// Test scenarios allow multiple color mode / depths because `ansi.Parse()` considers `\x1b[0m` sequence as part of `ansi.Default`.
3030
func Test_RenderAccessible(t *testing.T) {
31+
anchor := heredoc.Doc(`
32+
[GitHub CLI repository](https://github.com/cli/cli)
33+
`)
34+
35+
img := heredoc.Doc(`
36+
[Animated Mona for loading screen](https://github.com/user-attachments/assets/a43e7ce6-8360-466c-b2d5-0c74a60c30a4)
37+
`)
38+
3139
goCodeBlock := heredoc.Docf(`
3240
%[1]s%[1]s%[1]sgo
3341
package main
@@ -59,7 +67,7 @@ func Test_RenderAccessible(t *testing.T) {
5967
}
6068
'
6169
%[1]s%[1]s%[1]s
62-
`)
70+
`, "`")
6371

6472
tests := []struct {
6573
name string
@@ -69,6 +77,7 @@ func Test_RenderAccessible(t *testing.T) {
6977
wantColourModes []ansi.ColourMode
7078
allowDimFaintText bool
7179
}{
80+
// Go block
7281
{
7382
name: "when the light theme is selected, the Go codeblock renders using 8-bit colors",
7483
text: goCodeBlock,
@@ -99,6 +108,7 @@ func Test_RenderAccessible(t *testing.T) {
99108
wantColourModes: []ansi.ColourMode{ansi.Default},
100109
allowDimFaintText: false,
101110
},
111+
// shell block
102112
{
103113
name: "when the light theme is selected, the Shell codeblock renders using 8-bit colors",
104114
text: shellCodeBlock,
@@ -129,6 +139,68 @@ func Test_RenderAccessible(t *testing.T) {
129139
wantColourModes: []ansi.ColourMode{ansi.Default},
130140
allowDimFaintText: false,
131141
},
142+
// image text and link
143+
{
144+
name: "when the light theme is selected, the image text and link rendered using 8-bit colors",
145+
text: img,
146+
theme: "light",
147+
wantColourModes: []ansi.ColourMode{ansi.Default, ansi.TwoFiveSix},
148+
allowDimFaintText: true,
149+
},
150+
{
151+
name: "when the dark theme is selected, the image text and link rendered using 8-bit colors",
152+
text: img,
153+
theme: "dark",
154+
wantColourModes: []ansi.ColourMode{ansi.Default, ansi.TwoFiveSix},
155+
allowDimFaintText: true,
156+
},
157+
{
158+
name: "when the accessible env var is set and the light theme is selected, the image text and link render using 4-bit colors without dim/faint text",
159+
text: img,
160+
theme: "light",
161+
accessible: true,
162+
wantColourModes: []ansi.ColourMode{ansi.Default},
163+
allowDimFaintText: false,
164+
},
165+
{
166+
name: "when the accessible env var is set and the dark theme is selected, the image text and link render using 4-bit colors without dim/faint text",
167+
text: img,
168+
theme: "dark",
169+
accessible: true,
170+
wantColourModes: []ansi.ColourMode{ansi.Default},
171+
allowDimFaintText: false,
172+
},
173+
// anchor text and link
174+
{
175+
name: "when the light theme is selected, the anchor text and link rendered using 8-bit colors",
176+
text: anchor,
177+
theme: "light",
178+
wantColourModes: []ansi.ColourMode{ansi.Default, ansi.TwoFiveSix},
179+
allowDimFaintText: true,
180+
},
181+
{
182+
name: "when the dark theme is selected, the anchor text and link rendered using 8-bit colors",
183+
text: anchor,
184+
theme: "dark",
185+
wantColourModes: []ansi.ColourMode{ansi.Default, ansi.TwoFiveSix},
186+
allowDimFaintText: true,
187+
},
188+
{
189+
name: "when the accessible env var is set and the light theme is selected, the anchor text and link render using 4-bit colors without dim/faint text",
190+
text: anchor,
191+
theme: "light",
192+
accessible: true,
193+
wantColourModes: []ansi.ColourMode{ansi.Default},
194+
allowDimFaintText: false,
195+
},
196+
{
197+
name: "when the accessible env var is set and the dark theme is selected, the anchor text and link render using 4-bit colors without dim/faint text",
198+
text: anchor,
199+
theme: "dark",
200+
accessible: true,
201+
wantColourModes: []ansi.ColourMode{ansi.Default},
202+
allowDimFaintText: false,
203+
},
132204
}
133205
for _, tt := range tests {
134206
t.Run(tt.name, func(t *testing.T) {

pkg/x/markdown/accessibility.go

+53-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package markdown
22

33
import (
4+
"fmt"
45
"strconv"
56

67
"github.com/charmbracelet/glamour/ansi"
@@ -31,11 +32,50 @@ const (
3132
brightWhite
3233
)
3334

34-
func (a glamourStyleColor) code() *string {
35-
s := strconv.Itoa(int(a))
35+
func (gsc glamourStyleColor) code() *string {
36+
s := strconv.Itoa(int(gsc))
3637
return &s
3738
}
3839

40+
func parseGlamourStyleColor(code string) (glamourStyleColor, error) {
41+
switch code {
42+
case "0":
43+
return black, nil
44+
case "1":
45+
return red, nil
46+
case "2":
47+
return green, nil
48+
case "3":
49+
return yellow, nil
50+
case "4":
51+
return blue, nil
52+
case "5":
53+
return magenta, nil
54+
case "6":
55+
return cyan, nil
56+
case "7":
57+
return white, nil
58+
case "8":
59+
return brightBlack, nil
60+
case "9":
61+
return brightRed, nil
62+
case "10":
63+
return brightGreen, nil
64+
case "11":
65+
return brightYellow, nil
66+
case "12":
67+
return brightBlue, nil
68+
case "13":
69+
return brightMagenta, nil
70+
case "14":
71+
return brightCyan, nil
72+
case "15":
73+
return brightWhite, nil
74+
default:
75+
return 0, fmt.Errorf("invalid color code: %s", code)
76+
}
77+
}
78+
3979
func AccessibleStyleConfig(theme string) ansi.StyleConfig {
4080
switch theme {
4181
case "light":
@@ -55,22 +95,28 @@ func accessibleDarkStyleConfig() ansi.StyleConfig {
5595

5696
// Link colors
5797
cfg.Link.Color = brightCyan.code()
98+
cfg.LinkText.Color = brightCyan.code()
5899

59100
// Heading colors
60101
cfg.Heading.StylePrimitive.Color = brightMagenta.code()
61102
cfg.H1.StylePrimitive.Color = brightWhite.code()
62103
cfg.H1.StylePrimitive.BackgroundColor = brightBlue.code()
104+
cfg.H6.StylePrimitive.Color = brightMagenta.code()
63105

64106
// Code colors
65107
cfg.Code.BackgroundColor = brightWhite.code()
66108
cfg.Code.Color = red.code()
67109

68110
// Image colors
69111
cfg.Image.Color = brightMagenta.code()
112+
cfg.ImageText.Color = brightMagenta.code()
70113

71114
// Horizontal rule colors
72115
cfg.HorizontalRule.Color = white.code()
73116

117+
// Code block colors
118+
cfg.CodeBlock.StyleBlock.StylePrimitive.Color = nil
119+
74120
return cfg
75121
}
76122

@@ -82,6 +128,7 @@ func accessibleLightStyleConfig() ansi.StyleConfig {
82128

83129
// Link colors
84130
cfg.Link.Color = brightBlue.code()
131+
cfg.LinkText.Color = brightBlue.code()
85132

86133
// Heading colors
87134
cfg.Heading.StylePrimitive.Color = magenta.code()
@@ -94,9 +141,13 @@ func accessibleLightStyleConfig() ansi.StyleConfig {
94141

95142
// Image colors
96143
cfg.Image.Color = magenta.code()
144+
cfg.ImageText.Color = magenta.code()
97145

98146
// Horizontal rule colors
99147
cfg.HorizontalRule.Color = white.code()
100148

149+
// Code block colors
150+
cfg.CodeBlock.StyleBlock.StylePrimitive.Color = nil
151+
101152
return cfg
102153
}

pkg/x/markdown/accessibility_test.go

+61-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package markdown
22

33
import (
4+
"reflect"
45
"testing"
56

67
"github.com/charmbracelet/glamour/ansi"
@@ -132,7 +133,7 @@ func TestAccessibleStyleConfig(t *testing.T) {
132133
}
133134
}
134135

135-
func Test_accessibleDarkStyleConfig(t *testing.T) {
136+
func TestAccessibleDarkStyleConfig(t *testing.T) {
136137
cfg := accessibleDarkStyleConfig()
137138
assert.Equal(t, white.code(), cfg.Document.StylePrimitive.Color)
138139
assert.Equal(t, brightCyan.code(), cfg.Link.Color)
@@ -148,7 +149,12 @@ func Test_accessibleDarkStyleConfig(t *testing.T) {
148149
assert.Equal(t, styles.DarkStyleConfig.H2, cfg.H2)
149150
}
150151

151-
func Test_accessibleLightStyleConfig(t *testing.T) {
152+
func TestAccessibleDarkStyleConfigIs4Bit(t *testing.T) {
153+
cfg := accessibleDarkStyleConfig()
154+
validateColors(t, reflect.ValueOf(cfg), "StyleConfig")
155+
}
156+
157+
func TestAccessibleLightStyleConfig(t *testing.T) {
152158
cfg := accessibleLightStyleConfig()
153159
assert.Equal(t, black.code(), cfg.Document.StylePrimitive.Color)
154160
assert.Equal(t, brightBlue.code(), cfg.Link.Color)
@@ -163,3 +169,56 @@ func Test_accessibleLightStyleConfig(t *testing.T) {
163169
// Test that we haven't changed the original style
164170
assert.Equal(t, styles.LightStyleConfig.H2, cfg.H2)
165171
}
172+
173+
func TestAccessibleLightStyleConfigIs4Bit(t *testing.T) {
174+
cfg := accessibleLightStyleConfig()
175+
validateColors(t, reflect.ValueOf(cfg), "StyleConfig")
176+
}
177+
178+
// Walk every field in the StyleConfig struct, checking that the Color and
179+
// BackgroundColor fields are valid 4-bit colors.
180+
//
181+
// This test skips Chroma fields because their Color fields are RGB hex values
182+
// that are downsampled to 4-bit colors unlike Glamour, which are 8-bit colors.
183+
// For more information, https://github.com/alecthomas/chroma/blob/0bf0e9f9ae2a81d463afe769cce01ff821bee3ba/formatters/tty_indexed.go#L32-L44
184+
func validateColors(t *testing.T, v reflect.Value, path string) {
185+
if v.Kind() == reflect.Ptr {
186+
if v.IsNil() {
187+
return
188+
}
189+
v = v.Elem()
190+
}
191+
192+
switch v.Kind() {
193+
case reflect.Struct:
194+
for i := range v.NumField() {
195+
field := v.Field(i)
196+
fieldType := v.Type().Field(i)
197+
198+
// Construct path for better error reporting
199+
fieldPath := path + "." + fieldType.Name
200+
201+
// Ensure we only check Glamour "Color" and "BackgroundColor"
202+
if fieldType.Name == "Chroma" {
203+
continue
204+
} else if (fieldType.Name == "Color" || fieldType.Name == "BackgroundColor") &&
205+
fieldType.Type.Kind() == reflect.Ptr && fieldType.Type.Elem().Kind() == reflect.String {
206+
207+
if field.IsNil() {
208+
continue
209+
}
210+
color := field.Elem().String()
211+
_, err := parseGlamourStyleColor(color)
212+
assert.NoError(t, err, "Failed to parse color '%s' in %s", color, fieldPath)
213+
} else {
214+
// Recurse into nested structs
215+
validateColors(t, field, fieldPath)
216+
}
217+
}
218+
case reflect.Slice:
219+
// Handle slices of structs
220+
for i := range v.Len() {
221+
validateColors(t, v.Index(i), path+"[]")
222+
}
223+
}
224+
}

0 commit comments

Comments
 (0)