detached 42243bb

Merge pull request #387 from bnema/fix/noctalia-dark-input-text

fix(theme): keep dark input text readable
commit
42243bbbdd07cbb2fc47ebc40cf29e876e4b2836
author
bnema · · 2026-08-29 13:07 UTC
committer
GitHub · · 2026-08-29 13:07 UTC
merge of
28389d5 + 47ad4a2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
5 files · +111 -6
M Makefile +3 -2
@@ -14,7 +14,7 @@ MAIN_PATH=./cmd/dumber
1414 DIST_DIR=dist
1515 LOCAL_BIN_DIR?=$(HOME)/.local/bin
1616 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin
17-GOLANGCI_LINT_VERSION?=v2.12.2
17+GOLANGCI_LINT_VERSION?=v2.13.2
1818 STATICCHECK_VERSION?=v0.7.0
1919
2020 # Detect number of CPU cores for parallel compilation
@@ -165,7 +165,8 @@ install-golangci-lint: ## Install pinned golangci-lint
165165 # Build the pinned version with the active Go toolchain instead of using the
166166 # upstream install.sh helper, which currently misidentifies release assets now
167167 # that golangci-lint publishes sibling .sbom.json files for each tarball.
168- GOBIN=$(TOOL_BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
168+ # Run outside this module so Go resolves the requested tool version independently.
169+ cd "$${TMPDIR:-/tmp}" && GOBIN=$(TOOL_BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
169170
170171 install-staticcheck: ## Install pinned Staticcheck with the active Go toolchain
171172 @echo "Installing staticcheck $(STATICCHECK_VERSION)..."
@@ -14,7 +14,7 @@ MAIN_PATH=./cmd/dumber
14 DIST_DIR=dist14 DIST_DIR=dist
15 LOCAL_BIN_DIR?=$(HOME)/.local/bin15 LOCAL_BIN_DIR?=$(HOME)/.local/bin
16 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin16 TOOL_BIN_DIR?=$(shell go env GOPATH)/bin
17-GOLANGCI_LINT_VERSION?=v2.12.217+GOLANGCI_LINT_VERSION?=v2.13.2
18 STATICCHECK_VERSION?=v0.7.018 STATICCHECK_VERSION?=v0.7.0
19 19
20 # Detect number of CPU cores for parallel compilation20 # Detect number of CPU cores for parallel compilation
@@ -165,7 +165,8 @@ install-golangci-lint: ## Install pinned golangci-lint
165 # Build the pinned version with the active Go toolchain instead of using the165 # Build the pinned version with the active Go toolchain instead of using the
166 # upstream install.sh helper, which currently misidentifies release assets now166 # upstream install.sh helper, which currently misidentifies release assets now
167 # that golangci-lint publishes sibling .sbom.json files for each tarball.167 # that golangci-lint publishes sibling .sbom.json files for each tarball.
168- GOBIN=$(TOOL_BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)168+ # Run outside this module so Go resolves the requested tool version independently.
169+ cd "$${TMPDIR:-/tmp}" && GOBIN=$(TOOL_BIN_DIR) go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$(GOLANGCI_LINT_VERSION)
169 170
170 install-staticcheck: ## Install pinned Staticcheck with the active Go toolchain171 install-staticcheck: ## Install pinned Staticcheck with the active Go toolchain
171 @echo "Installing staticcheck $(STATICCHECK_VERSION)..."172 @echo "Installing staticcheck $(STATICCHECK_VERSION)..."
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: var(--control-text);
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: var(--control-text);
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 +5 -2
@@ -118,10 +118,13 @@ 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 }
125+ ghostBlock := cssRuleBlock(t, css, ".omnibox-ghost")
126+ assert.Contains(t, ghostBlock, "color: var(--control-text);")
127+ assert.NotContains(t, ghostBlock, "alpha(var(--control-text)")
125128 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\);`)
126129 if !focusedEntryRe.MatchString(css) {
127130 t.Fatalf("expected focused omnibox entry text node to keep a distinct control-surface fill")
@@ -118,10 +118,13 @@ 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+ ghostBlock := cssRuleBlock(t, css, ".omnibox-ghost")
126+ assert.Contains(t, ghostBlock, "color: var(--control-text);")
127+ assert.NotContains(t, ghostBlock, "alpha(var(--control-text)")
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\);`)128 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) {129 if !focusedEntryRe.MatchString(css) {
127 t.Fatalf("expected focused omnibox entry text node to keep a distinct control-surface fill")130 t.Fatalf("expected focused omnibox entry text node to keep a distinct control-surface fill")
M internal/ui/theme/palette.go +48 -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,52 @@ func (p Palette) ToCSSVars() string {
277279 return sb.String()
278280 }
279281
282+const (
283+ minimumReadableContrast = 4.5
284+ contrastLuminanceOffset = 0.05
285+ sRGBLinearThreshold = 0.04045
286+ sRGBLinearDivisor = 12.92
287+ sRGBGammaOffset = 0.055
288+ sRGBGammaScale = 1.055
289+ sRGBGammaExponent = 2.4
290+ redLuminanceWeight = 0.2126
291+ greenLuminanceWeight = 0.7152
292+ blueLuminanceWeight = 0.0722
293+)
294+
295+func readableTextColor(background, preferred string) string {
296+ if contrastRatio(background, preferred) >= minimumReadableContrast {
297+ return preferred
298+ }
299+ if contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") {
300+ return "#ffffff"
301+ }
302+ return "#000000"
303+}
304+
305+func contrastRatio(first, second string) float64 {
306+ firstLuminance := relativeLuminance(first)
307+ secondLuminance := relativeLuminance(second)
308+ if firstLuminance < secondLuminance {
309+ firstLuminance, secondLuminance = secondLuminance, firstLuminance
310+ }
311+ return (firstLuminance + contrastLuminanceOffset) / (secondLuminance + contrastLuminanceOffset)
312+}
313+
314+func relativeLuminance(color string) float64 {
315+ r, g, b, _ := HexToRGBA(color)
316+ linearize := func(channel float32) float64 {
317+ value := float64(channel)
318+ if value <= sRGBLinearThreshold {
319+ return value / sRGBLinearDivisor
320+ }
321+ return math.Pow((value+sRGBGammaOffset)/sRGBGammaScale, sRGBGammaExponent)
322+ }
323+ return redLuminanceWeight*linearize(r) +
324+ greenLuminanceWeight*linearize(g) +
325+ blueLuminanceWeight*linearize(b)
326+}
327+
280328 // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
281329 // Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
282330 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,52 @@ func (p Palette) ToCSSVars() string {
277 return sb.String()279 return sb.String()
278 }280 }
279 281
282+const (
283+ minimumReadableContrast = 4.5
284+ contrastLuminanceOffset = 0.05
285+ sRGBLinearThreshold = 0.04045
286+ sRGBLinearDivisor = 12.92
287+ sRGBGammaOffset = 0.055
288+ sRGBGammaScale = 1.055
289+ sRGBGammaExponent = 2.4
290+ redLuminanceWeight = 0.2126
291+ greenLuminanceWeight = 0.7152
292+ blueLuminanceWeight = 0.0722
293+)
294+
295+func readableTextColor(background, preferred string) string {
296+ if contrastRatio(background, preferred) >= minimumReadableContrast {
297+ return preferred
298+ }
299+ if contrastRatio(background, "#ffffff") >= contrastRatio(background, "#000000") {
300+ return "#ffffff"
301+ }
302+ return "#000000"
303+}
304+
305+func contrastRatio(first, second string) float64 {
306+ firstLuminance := relativeLuminance(first)
307+ secondLuminance := relativeLuminance(second)
308+ if firstLuminance < secondLuminance {
309+ firstLuminance, secondLuminance = secondLuminance, firstLuminance
310+ }
311+ return (firstLuminance + contrastLuminanceOffset) / (secondLuminance + contrastLuminanceOffset)
312+}
313+
314+func relativeLuminance(color string) float64 {
315+ r, g, b, _ := HexToRGBA(color)
316+ linearize := func(channel float32) float64 {
317+ value := float64(channel)
318+ if value <= sRGBLinearThreshold {
319+ return value / sRGBLinearDivisor
320+ }
321+ return math.Pow((value+sRGBGammaOffset)/sRGBGammaScale, sRGBGammaExponent)
322+ }
323+ return redLuminanceWeight*linearize(r) +
324+ greenLuminanceWeight*linearize(g) +
325+ blueLuminanceWeight*linearize(b)
326+}
327+
280 // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).328 // ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
281 // Maps GTK palette names to Tailwind variable names used in the WebUI homepage.329 // Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
282 func (p Palette) ToWebCSSVars() string {330 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