detached f2f6f15

fix(theme): keep dark input text readable

commit
f2f6f1567b50c67495d3706d90bccfc5182c70eb
author
brice · · 2026-08-29 10:15 UTC
parent
28389d5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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,
287287 entry.omnibox-entry > text {
288288 background-color: var(--surface-variant);
289289 background-image: none;
290- color: var(--text);
290+ color: var(--control-text);
291291 caret-color: var(--accent);
292292 }
293293
@@ -504,7 +504,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
504504 /* Ghost text autocomplete overlay - positioned to align with entry text */
505505 .omnibox-ghost {
506506 font-size: 1em;
507- color: var(--muted);
507+ color: alpha(var(--control-text), 0.65);
508508 margin-top: -0.0625em; /* Nudge up 1px to align with entry text baseline */
509509 background-color: transparent;
510510 }
@@ -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) {
118118 if !containerRe.MatchString(css) {
119119 t.Fatalf("expected omnibox container to use the header surface color")
120120 }
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\);`)
122122 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")
124124 }
125125 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\);`)
126126 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
33
44 import (
55 "fmt"
6+ "math"
67 "regexp"
78 "strings"
89
@@ -267,6 +268,7 @@ func (p Palette) ToCSSVars() string {
267268 sb.WriteString(" --bg: " + p.Background + ";\n")
268269 sb.WriteString(" --surface: " + p.Surface + ";\n")
269270 sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
271+ sb.WriteString(" --control-text: " + readableTextColor(p.SurfaceVariant, p.Text) + ";\n")
270272 sb.WriteString(" --text: " + p.Text + ";\n")
271273 sb.WriteString(" --muted: " + p.Muted + ";\n")
272274 sb.WriteString(" --accent: " + p.Accent + ";\n")
@@ -277,6 +279,39 @@ func (p Palette) ToCSSVars() string {
277279 return sb.String()
278280 }
279281
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+
280315 // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
281316 // Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
282317 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close