fix(theme): keep dark input text readable
- commit
- f2f6f1567b50c67495d3706d90bccfc5182c70eb
- author
- brice · · 2026-08-29 10:15 UTC
- parent
- 28389d5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +92 -4
M internal/ui/theme/css.go +2 -2
| @@ -287,7 +287,7 @@ entry.omnibox-entry, | |||
| 287 | 287 | entry.omnibox-entry > text { | |
| 288 | 288 | background-color: var(--surface-variant); | |
| 289 | 289 | background-image: none; | |
| 290 | - | color: var(--text); | |
| 290 | + | color: var(--control-text); | |
| 291 | 291 | caret-color: var(--accent); | |
| 292 | 292 | } | |
| 293 | 293 | ||
| @@ -504,7 +504,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | |||
| 504 | 504 | /* Ghost text autocomplete overlay - positioned to align with entry text */ | |
| 505 | 505 | .omnibox-ghost { | |
| 506 | 506 | font-size: 1em; | |
| 507 | - | color: var(--muted); | |
| 507 | + | color: alpha(var(--control-text), 0.65); | |
| 508 | 508 | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | |
| 509 | 509 | background-color: transparent; | |
| 510 | 510 | } | |
| @@ -287,7 +287,7 @@ entry.omnibox-entry, | |||||
| 287 | entry.omnibox-entry > text { | 287 | entry.omnibox-entry > text { | ||
| 288 | background-color: var(--surface-variant); | 288 | background-color: var(--surface-variant); | ||
| 289 | background-image: none; | 289 | background-image: none; | ||
| 290 | - | color: var(--text); | 290 | + | color: var(--control-text); |
| 291 | caret-color: var(--accent); | 291 | caret-color: var(--accent); | ||
| 292 | } | 292 | } | ||
| 293 | 293 | ||||
| @@ -504,7 +504,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text { | |||||
| 504 | /* Ghost text autocomplete overlay - positioned to align with entry text */ | 504 | /* Ghost text autocomplete overlay - positioned to align with entry text */ | ||
| 505 | .omnibox-ghost { | 505 | .omnibox-ghost { | ||
| 506 | font-size: 1em; | 506 | font-size: 1em; | ||
| 507 | - | color: var(--muted); | 507 | + | color: alpha(var(--control-text), 0.65); |
| 508 | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | 508 | margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */ | ||
| 509 | background-color: transparent; | 509 | background-color: transparent; | ||
| 510 | } | 510 | } | ||
M internal/ui/theme/css_test.go +2 -2
| @@ -118,9 +118,9 @@ func TestGenerateCSS_OmniboxSearchEntryUsesControlSurface(t *testing.T) { | |||
| 118 | 118 | if !containerRe.MatchString(css) { | |
| 119 | 119 | t.Fatalf("expected omnibox container to use the header surface color") | |
| 120 | 120 | } | |
| 121 | - | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;`) | |
| 121 | + | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;[^}]*color:\s*var\(--control-text\);`) | |
| 122 | 122 | if !entryRe.MatchString(css) { | |
| 123 | - | t.Fatalf("expected omnibox entry and its text node to use the control surface fill") | |
| 123 | + | t.Fatalf("expected omnibox entry and its text node to use the control surface fill and readable text") | |
| 124 | 124 | } | |
| 125 | 125 | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`) | |
| 126 | 126 | if !focusedEntryRe.MatchString(css) { | |
| @@ -118,9 +118,9 @@ func TestGenerateCSS_OmniboxSearchEntryUsesControlSurface(t *testing.T) { | |||||
| 118 | if !containerRe.MatchString(css) { | 118 | if !containerRe.MatchString(css) { | ||
| 119 | t.Fatalf("expected omnibox container to use the header surface color") | 119 | t.Fatalf("expected omnibox container to use the header surface color") | ||
| 120 | } | 120 | } | ||
| 121 | - | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;`) | 121 | + | entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;[^}]*color:\s*var\(--control-text\);`) |
| 122 | if !entryRe.MatchString(css) { | 122 | if !entryRe.MatchString(css) { | ||
| 123 | - | t.Fatalf("expected omnibox entry and its text node to use the control surface fill") | 123 | + | t.Fatalf("expected omnibox entry and its text node to use the control surface fill and readable text") |
| 124 | } | 124 | } | ||
| 125 | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`) | 125 | focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`) | ||
| 126 | if !focusedEntryRe.MatchString(css) { | 126 | if !focusedEntryRe.MatchString(css) { | ||
M internal/ui/theme/palette.go +35 -0
| @@ -3,6 +3,7 @@ package theme | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | + | "math" | |
| 6 | 7 | "regexp" | |
| 7 | 8 | "strings" | |
| 8 | 9 | ||
| @@ -267,6 +268,7 @@ func (p Palette) ToCSSVars() string { | |||
| 267 | 268 | sb.WriteString(" --bg: " + p.Background + ";\n") | |
| 268 | 269 | sb.WriteString(" --surface: " + p.Surface + ";\n") | |
| 269 | 270 | sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n") | |
| 271 | + | sb.WriteString(" --control-text: " + readableTextColor(p.SurfaceVariant, p.Text) + ";\n") | |
| 270 | 272 | sb.WriteString(" --text: " + p.Text + ";\n") | |
| 271 | 273 | sb.WriteString(" --muted: " + p.Muted + ";\n") | |
| 272 | 274 | sb.WriteString(" --accent: " + p.Accent + ";\n") | |
| @@ -277,6 +279,39 @@ func (p Palette) ToCSSVars() string { | |||
| 277 | 279 | return sb.String() | |
| 278 | 280 | } | |
| 279 | 281 | ||
| 282 | + | const minimumReadableContrast = 4.5 | |
| 283 | + | ||
| 284 | + | func readableTextColor(background, preferred string) string { | |
| 285 | + | if contrastRatio(background, preferred) >= minimumReadableContrast { | |
| 286 | + | return preferred | |
| 287 | + | } | |
| 288 | + | if contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") { | |
| 289 | + | return "#ffffff" | |
| 290 | + | } | |
| 291 | + | return "#000000" | |
| 292 | + | } | |
| 293 | + | ||
| 294 | + | func contrastRatio(first, second string) float64 { | |
| 295 | + | firstLuminance := relativeLuminance(first) | |
| 296 | + | secondLuminance := relativeLuminance(second) | |
| 297 | + | if firstLuminance < secondLuminance { | |
| 298 | + | firstLuminance, secondLuminance = secondLuminance, firstLuminance | |
| 299 | + | } | |
| 300 | + | return (firstLuminance + 0.05) / (secondLuminance + 0.05) | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | func relativeLuminance(color string) float64 { | |
| 304 | + | r, g, b, _ := HexToRGBA(color) | |
| 305 | + | linearize := func(channel float32) float64 { | |
| 306 | + | value := float64(channel) | |
| 307 | + | if value <= 0.04045 { | |
| 308 | + | return value / 12.92 | |
| 309 | + | } | |
| 310 | + | return math.Pow((value+0.055)/1.055, 2.4) | |
| 311 | + | } | |
| 312 | + | return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) | |
| 313 | + | } | |
| 314 | + | ||
| 280 | 315 | // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility). | |
| 281 | 316 | // Maps GTK palette names to Tailwind variable names used in the WebUI homepage. | |
| 282 | 317 | func (p Palette) ToWebCSSVars() string { | |
| @@ -3,6 +3,7 @@ package theme | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | + | "math" | |||
| 6 | "regexp" | 7 | "regexp" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | 9 | ||||
| @@ -267,6 +268,7 @@ func (p Palette) ToCSSVars() string { | |||||
| 267 | sb.WriteString(" --bg: " + p.Background + ";\n") | 268 | sb.WriteString(" --bg: " + p.Background + ";\n") | ||
| 268 | sb.WriteString(" --surface: " + p.Surface + ";\n") | 269 | sb.WriteString(" --surface: " + p.Surface + ";\n") | ||
| 269 | sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n") | 270 | sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n") | ||
| 271 | + | sb.WriteString(" --control-text: " + readableTextColor(p.SurfaceVariant, p.Text) + ";\n") | |||
| 270 | sb.WriteString(" --text: " + p.Text + ";\n") | 272 | sb.WriteString(" --text: " + p.Text + ";\n") | ||
| 271 | sb.WriteString(" --muted: " + p.Muted + ";\n") | 273 | sb.WriteString(" --muted: " + p.Muted + ";\n") | ||
| 272 | sb.WriteString(" --accent: " + p.Accent + ";\n") | 274 | sb.WriteString(" --accent: " + p.Accent + ";\n") | ||
| @@ -277,6 +279,39 @@ func (p Palette) ToCSSVars() string { | |||||
| 277 | return sb.String() | 279 | return sb.String() | ||
| 278 | } | 280 | } | ||
| 279 | 281 | ||||
| 282 | + | const minimumReadableContrast = 4.5 | |||
| 283 | + | ||||
| 284 | + | func readableTextColor(background, preferred string) string { | |||
| 285 | + | if contrastRatio(background, preferred) >= minimumReadableContrast { | |||
| 286 | + | return preferred | |||
| 287 | + | } | |||
| 288 | + | if contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") { | |||
| 289 | + | return "#ffffff" | |||
| 290 | + | } | |||
| 291 | + | return "#000000" | |||
| 292 | + | } | |||
| 293 | + | ||||
| 294 | + | func contrastRatio(first, second string) float64 { | |||
| 295 | + | firstLuminance := relativeLuminance(first) | |||
| 296 | + | secondLuminance := relativeLuminance(second) | |||
| 297 | + | if firstLuminance < secondLuminance { | |||
| 298 | + | firstLuminance, secondLuminance = secondLuminance, firstLuminance | |||
| 299 | + | } | |||
| 300 | + | return (firstLuminance + 0.05) / (secondLuminance + 0.05) | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | func relativeLuminance(color string) float64 { | |||
| 304 | + | r, g, b, _ := HexToRGBA(color) | |||
| 305 | + | linearize := func(channel float32) float64 { | |||
| 306 | + | value := float64(channel) | |||
| 307 | + | if value <= 0.04045 { | |||
| 308 | + | return value / 12.92 | |||
| 309 | + | } | |||
| 310 | + | return math.Pow((value+0.055)/1.055, 2.4) | |||
| 311 | + | } | |||
| 312 | + | return 0.2126*linearize(r) + 0.7152*linearize(g) + 0.0722*linearize(b) | |||
| 313 | + | } | |||
| 314 | + | ||||
| 280 | // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility). | 315 | // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility). | ||
| 281 | // Maps GTK palette names to Tailwind variable names used in the WebUI homepage. | 316 | // Maps GTK palette names to Tailwind variable names used in the WebUI homepage. | ||
| 282 | func (p Palette) ToWebCSSVars() string { | 317 | func (p Palette) ToWebCSSVars() string { | ||
A internal/ui/theme/palette_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package theme | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestReadableTextColor(t *testing.T) { | |
| 9 | + | tests := []struct { | |
| 10 | + | name string | |
| 11 | + | background string | |
| 12 | + | preferred string | |
| 13 | + | want string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "keeps readable palette text", | |
| 17 | + | background: "#282828", | |
| 18 | + | preferred: "#ffffff", | |
| 19 | + | want: "#ffffff", | |
| 20 | + | }, | |
| 21 | + | { | |
| 22 | + | name: "uses light text on dark control", | |
| 23 | + | background: "#775f79", | |
| 24 | + | preferred: "#3d303f", | |
| 25 | + | want: "#ffffff", | |
| 26 | + | }, | |
| 27 | + | { | |
| 28 | + | name: "uses dark text on light control", | |
| 29 | + | background: "#f0f0f0", | |
| 30 | + | preferred: "#dddddd", | |
| 31 | + | want: "#000000", | |
| 32 | + | }, | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | for _, tt := range tests { | |
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |
| 37 | + | if got := readableTextColor(tt.background, tt.preferred); got != tt.want { | |
| 38 | + | t.Fatalf("readableTextColor(%q, %q) = %q, want %q", tt.background, tt.preferred, got, tt.want) | |
| 39 | + | } | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestPaletteToCSSVarsAddsReadableControlText(t *testing.T) { | |
| 45 | + | palette := DefaultLightPalette() | |
| 46 | + | palette.SurfaceVariant = "#775f79" | |
| 47 | + | palette.Text = "#3d303f" | |
| 48 | + | ||
| 49 | + | css := palette.ToCSSVars() | |
| 50 | + | if !strings.Contains(css, " --control-text: #ffffff;\n") { | |
| 51 | + | t.Fatalf("ToCSSVars() did not add light control text for dark input background:\n%s", css) | |
| 52 | + | } | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package theme | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestReadableTextColor(t *testing.T) { | |||
| 9 | + | tests := []struct { | |||
| 10 | + | name string | |||
| 11 | + | background string | |||
| 12 | + | preferred string | |||
| 13 | + | want string | |||
| 14 | + | }{ | |||
| 15 | + | { | |||
| 16 | + | name: "keeps readable palette text", | |||
| 17 | + | background: "#282828", | |||
| 18 | + | preferred: "#ffffff", | |||
| 19 | + | want: "#ffffff", | |||
| 20 | + | }, | |||
| 21 | + | { | |||
| 22 | + | name: "uses light text on dark control", | |||
| 23 | + | background: "#775f79", | |||
| 24 | + | preferred: "#3d303f", | |||
| 25 | + | want: "#ffffff", | |||
| 26 | + | }, | |||
| 27 | + | { | |||
| 28 | + | name: "uses dark text on light control", | |||
| 29 | + | background: "#f0f0f0", | |||
| 30 | + | preferred: "#dddddd", | |||
| 31 | + | want: "#000000", | |||
| 32 | + | }, | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | for _, tt := range tests { | |||
| 36 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 37 | + | if got := readableTextColor(tt.background, tt.preferred); got != tt.want { | |||
| 38 | + | t.Fatalf("readableTextColor(%q, %q) = %q, want %q", tt.background, tt.preferred, got, tt.want) | |||
| 39 | + | } | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestPaletteToCSSVarsAddsReadableControlText(t *testing.T) { | |||
| 45 | + | palette := DefaultLightPalette() | |||
| 46 | + | palette.SurfaceVariant = "#775f79" | |||
| 47 | + | palette.Text = "#3d303f" | |||
| 48 | + | ||||
| 49 | + | css := palette.ToCSSVars() | |||
| 50 | + | if !strings.Contains(css, " --control-text: #ffffff;\n") { | |||
| 51 | + | t.Fatalf("ToCSSVars() did not add light control text for dark input background:\n%s", css) | |||
| 52 | + | } | |||
| 53 | + | } | |||