detached 56bd04d

Merge pull request #54 from bnema/ui/refinements

fix: eliminate dark mode navigation flash
commit
56bd04d4fbb82fce69b31bf774e66bdb054dc1a3
author
bnema · · 2025-12-28 12:18 UTC
committer
GitHub · · 2025-12-28 12:18 UTC
merge of
2af3604 + f6d019b · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +379 -50
M CHANGELOG.md +1 -0
@@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file.
3939 ### Fixed
4040 - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.
4141 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.
42+- **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode.
4243 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
4344 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
4445 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
@@ -39,6 +39,7 @@ All notable changes to this project will be documented in this file.
39 ### Fixed39 ### Fixed
40 - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.40 - **Config migration type matching**: Fixed migration incorrectly matching keys of different types (e.g., `_width` int keys with `_color` string keys) during rename detection, preventing value swapping.
41 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.41 - **Fullscreen video tab bar**: Fixed tab bar remaining visible during fullscreen video playback. Now hides automatically when entering fullscreen and restores based on normal visibility logic when exiting.
42+- **Dark mode navigation flash**: Fixed a brief white flash between the loading skeleton and page content when navigating in dark mode.
42 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.43 - **Progress bar stuck state**: Added 30-second timeout to progress bar to auto-hide if page load stalls and never completes.
43 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.44 - **OAuth popup login**: Fixed parent page going blank after OAuth popup closes (e.g., Google login on claude.ai, notion.com). Related WebViews share a web process with their parent; destroying the popup was terminating the shared process, killing the parent. Now skips `TerminateWebProcess()` for popup WebViews.
44 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.45 - Startup: defer WebView pool prewarm until after initial tab creation to reduce cold-start navigation latency.
M internal/bootstrap/webkit_stack.go +6 -0
@@ -95,6 +95,9 @@ func BuildWebKitStack(
9595 settings := webkit.NewSettingsManager(ctx, cfg)
9696 injector := webkit.NewContentInjector(themeManager.PrefersDark())
9797
98+ // Set background color on injector for early CSS injection (prevents white flash)
99+ injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background)
100+
98101 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99102 themeCSSText := themeManager.GetWebUIThemeCSS()
100103 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -108,6 +111,9 @@ func BuildWebKitStack(
108111 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109112 }
110113 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
114+ // Ensure prewarmed WebViews pick up the theme background color.
115+ bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
116+ pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
111117
112118 if filterManager != nil {
113119 pool.SetFilterApplier(filterManager)
@@ -95,6 +95,9 @@ func BuildWebKitStack(
95 settings := webkit.NewSettingsManager(ctx, cfg)95 settings := webkit.NewSettingsManager(ctx, cfg)
96 injector := webkit.NewContentInjector(themeManager.PrefersDark())96 injector := webkit.NewContentInjector(themeManager.PrefersDark())
97 97
98+ // Set background color on injector for early CSS injection (prevents white flash)
99+ injector.SetBackgroundColor(themeManager.GetCurrentPalette().Background)
100+
98 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)101 prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
99 themeCSSText := themeManager.GetWebUIThemeCSS()102 themeCSSText := themeManager.GetWebUIThemeCSS()
100 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {103 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
@@ -108,6 +111,9 @@ func BuildWebKitStack(
108 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount111 poolCfg.PrewarmCount = cfg.Performance.WebViewPoolPrewarmCount
109 }112 }
110 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)113 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
114+ // Ensure prewarmed WebViews pick up the theme background color.
115+ bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
116+ pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
111 117
112 if filterManager != nil {118 if filterManager != nil {
113 pool.SetFilterApplier(filterManager)119 pool.SetFilterApplier(filterManager)
A internal/infrastructure/webkit/bgcolor.go +38 -0
@@ -0,0 +1,38 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+// bgColor holds RGBA components for WebView background color (eliminates white flash).
10+// It is thread-safe and can be embedded in pool/factory structs.
11+type bgColor struct {
12+ r, g, b, a float32
13+ mu sync.RWMutex
14+}
15+
16+// set stores the background color components.
17+func (c *bgColor) set(r, g, b, a float32) {
18+ c.mu.Lock()
19+ c.r, c.g, c.b, c.a = r, g, b, a
20+ c.mu.Unlock()
21+}
22+
23+// get returns the background color components.
24+func (c *bgColor) get() (r, g, b, a float32) {
25+ c.mu.RLock()
26+ r, g, b, a = c.r, c.g, c.b, c.a
27+ c.mu.RUnlock()
28+ return
29+}
30+
31+// toGdkRGBA returns a *gdk.RGBA if a valid color is configured (alpha > 0), nil otherwise.
32+func (c *bgColor) toGdkRGBA() *gdk.RGBA {
33+ r, g, b, a := c.get()
34+ if a <= 0 {
35+ return nil
36+ }
37+ return &gdk.RGBA{Red: r, Green: g, Blue: b, Alpha: a}
38+}
@@ -0,0 +1,38 @@
1+package webkit
2+
3+import (
4+ "sync"
5+
6+ "github.com/jwijenbergh/puregotk/v4/gdk"
7+)
8+
9+// bgColor holds RGBA components for WebView background color (eliminates white flash).
10+// It is thread-safe and can be embedded in pool/factory structs.
11+type bgColor struct {
12+ r, g, b, a float32
13+ mu sync.RWMutex
14+}
15+
16+// set stores the background color components.
17+func (c *bgColor) set(r, g, b, a float32) {
18+ c.mu.Lock()
19+ c.r, c.g, c.b, c.a = r, g, b, a
20+ c.mu.Unlock()
21+}
22+
23+// get returns the background color components.
24+func (c *bgColor) get() (r, g, b, a float32) {
25+ c.mu.RLock()
26+ r, g, b, a = c.r, c.g, c.b, c.a
27+ c.mu.RUnlock()
28+ return
29+}
30+
31+// toGdkRGBA returns a *gdk.RGBA if a valid color is configured (alpha > 0), nil otherwise.
32+func (c *bgColor) toGdkRGBA() *gdk.RGBA {
33+ r, g, b, a := c.get()
34+ if a <= 0 {
35+ return nil
36+ }
37+ return &gdk.RGBA{Red: r, Green: g, Blue: b, Alpha: a}
38+}
M internal/infrastructure/webkit/factory.go +6 -18
@@ -3,7 +3,6 @@ package webkit
33 import (
44 "context"
55 "fmt"
6- "sync"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/logging"
@@ -21,8 +20,7 @@ type WebViewFactory struct {
2120 filterApplier FilterApplier // Optional content filter applier
2221
2322 // Background color for WebViews (eliminates white flash)
24- bgR, bgG, bgB, bgA float32
25- bgMu sync.RWMutex
23+ bg bgColor
2624 }
2725
2826 // NewWebViewFactory creates a new WebViewFactory.
@@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
5654 // SetBackgroundColor sets the background color for newly created WebViews.
5755 // This color is shown before content is painted, eliminating white flash.
5856 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59- f.bgMu.Lock()
60- f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61- f.bgMu.Unlock()
57+ f.bg.set(r, g, b, a)
6258 // Also propagate to the pool if present
6359 if f.pool != nil {
6460 f.pool.SetBackgroundColor(r, g, b, a)
@@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
106102 }
107103
108104 // Set background color to match theme (eliminates white flash)
109- f.bgMu.RLock()
110- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111- f.bgMu.RUnlock()
112- if a > 0 {
105+ if r, g, b, a := f.bg.get(); a > 0 {
113106 wv.SetBackgroundColor(r, g, b, a)
114107 }
115108
@@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
138131 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
139132 log := logging.FromContext(ctx)
140133
141- wv, err := NewWebView(ctx, f.wkCtx, f.settings)
134+ wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA())
142135 if err != nil {
143136 return nil, err
144137 }
145138
146- // Set background color to match theme (eliminates white flash)
147- f.bgMu.RLock()
148- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
149- f.bgMu.RUnlock()
150- if a > 0 {
151- wv.SetBackgroundColor(r, g, b, a)
152- }
139+ // Add CSS class for theme background styling (prevents white flash)
140+ wv.inner.AddCssClass("webview-themed")
153141
154142 // Keep hidden until content is painted
155143 wv.inner.SetVisible(false)
@@ -3,7 +3,6 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6- "sync"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
@@ -21,8 +20,7 @@ type WebViewFactory struct {
21 filterApplier FilterApplier // Optional content filter applier20 filterApplier FilterApplier // Optional content filter applier
22 21
23 // Background color for WebViews (eliminates white flash)22 // Background color for WebViews (eliminates white flash)
24- bgR, bgG, bgB, bgA float3223+ bg bgColor
25- bgMu sync.RWMutex
26 }24 }
27 25
28 // NewWebViewFactory creates a new WebViewFactory.26 // NewWebViewFactory creates a new WebViewFactory.
@@ -56,9 +54,7 @@ func (f *WebViewFactory) SetFilterApplier(applier FilterApplier) {
56 // SetBackgroundColor sets the background color for newly created WebViews.54 // SetBackgroundColor sets the background color for newly created WebViews.
57 // This color is shown before content is painted, eliminating white flash.55 // This color is shown before content is painted, eliminating white flash.
58 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {56 func (f *WebViewFactory) SetBackgroundColor(r, g, b, a float32) {
59- f.bgMu.Lock()57+ f.bg.set(r, g, b, a)
60- f.bgR, f.bgG, f.bgB, f.bgA = r, g, b, a
61- f.bgMu.Unlock()
62 // Also propagate to the pool if present58 // Also propagate to the pool if present
63 if f.pool != nil {59 if f.pool != nil {
64 f.pool.SetBackgroundColor(r, g, b, a)60 f.pool.SetBackgroundColor(r, g, b, a)
@@ -106,10 +102,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
106 }102 }
107 103
108 // Set background color to match theme (eliminates white flash)104 // Set background color to match theme (eliminates white flash)
109- f.bgMu.RLock()105+ if r, g, b, a := f.bg.get(); a > 0 {
110- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
111- f.bgMu.RUnlock()
112- if a > 0 {
113 wv.SetBackgroundColor(r, g, b, a)106 wv.SetBackgroundColor(r, g, b, a)
114 }107 }
115 108
@@ -138,18 +131,13 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
138 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {131 func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) {
139 log := logging.FromContext(ctx)132 log := logging.FromContext(ctx)
140 133
141- wv, err := NewWebView(ctx, f.wkCtx, f.settings)134+ wv, err := NewWebView(ctx, f.wkCtx, f.settings, f.bg.toGdkRGBA())
142 if err != nil {135 if err != nil {
143 return nil, err136 return nil, err
144 }137 }
145 138
146- // Set background color to match theme (eliminates white flash)139+ // Add CSS class for theme background styling (prevents white flash)
147- f.bgMu.RLock()140+ wv.inner.AddCssClass("webview-themed")
148- r, g, b, a := f.bgR, f.bgG, f.bgB, f.bgA
149- f.bgMu.RUnlock()
150- if a > 0 {
151- wv.SetBackgroundColor(r, g, b, a)
152- }
153 141
154 // Keep hidden until content is painted142 // Keep hidden until content is painted
155 wv.inner.SetVisible(false)143 wv.inner.SetVisible(false)
M internal/infrastructure/webkit/injector.go +56 -5
@@ -25,6 +25,34 @@ const themeCSSScript = `(function() {
2525 (document.head || document.documentElement).appendChild(style);
2626 })();`
2727
28+// earlyBackgroundScript injects a temporary background color to prevent white flash.
29+// It's injected at document start and removed after the page loads.
30+// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31+const earlyBackgroundScript = `(function() {
32+ var style = document.createElement('style');
33+ style.id = '__dumber_early_bg';
34+ style.textContent = 'html,body{background-color:%s!important}';
35+ document.documentElement.appendChild(style);
36+
37+ // Remove the style after page content loads to avoid overriding page styles
38+ function removeEarlyBg() {
39+ var s = document.getElementById('__dumber_early_bg');
40+ if (s) s.remove();
41+ }
42+
43+ // Try multiple events to ensure removal
44+ if (document.readyState === 'complete') {
45+ setTimeout(removeEarlyBg, 100);
46+ } else {
47+ window.addEventListener('DOMContentLoaded', function() {
48+ setTimeout(removeEarlyBg, 100);
49+ }, {once: true});
50+ window.addEventListener('load', function() {
51+ setTimeout(removeEarlyBg, 100);
52+ }, {once: true});
53+ }
54+})();`
55+
2856 // darkModeScript patches window.matchMedia and sets theme class on <html>.
2957 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
3058 // This script:
@@ -132,6 +160,7 @@ type ContentInjector struct {
132160 prefersDark bool
133161 themeCSSVars string // CSS custom property declarations for WebUI
134162 findCSS string // CSS for find-in-page highlight styling
163+ bgColor string // Background color hex for early injection (prevents white flash)
135164 }
136165
137166 // NewContentInjector creates a new injector instance.
@@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
171200 ci.prefersDark = prefersDark
172201 }
173202
203+// SetBackgroundColor sets the background color for early CSS injection.
204+// This color is applied to html/body at document start to prevent white flash.
205+// The hex color should include the # prefix (e.g., "#0a0a0b").
206+func (ci *ContentInjector) SetBackgroundColor(hex string) {
207+ ci.bgColor = hex
208+}
209+
174210 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.
175211 // Only injects:
176212 // - window.__dumber_gtk_prefers_dark flag
@@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
193229 log.Debug().Str("script", label).Msg("injected user script")
194230 }
195231
196- // 1. Inject GTK dark mode preference (must be before dark mode handler)
232+ // 1. Inject early background color to prevent white flash (must be first!)
233+ if ci.bgColor != "" {
234+ earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235+ addScript(
236+ webkit.NewUserScript(
237+ earlyBgScript,
238+ webkit.UserContentInjectTopFrameValue,
239+ webkit.UserScriptInjectAtDocumentStartValue,
240+ nil,
241+ nil,
242+ ),
243+ "early-background",
244+ )
245+ }
246+
247+ // 2. Inject GTK dark mode preference (must be before dark mode handler)
197248 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
198249 addScript(
199250 webkit.NewUserScript(
@@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
206257 "gtk-dark-mode-pref",
207258 )
208259
209- // 2. Inject WebView ID for debugging
260+ // 3. Inject WebView ID for debugging
210261 if webviewID != 0 {
211262 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
212263 addScript(
@@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
221272 )
222273 }
223274
224- // 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
275+ // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
225276 addScript(
226277 webkit.NewUserScript(
227278 darkModeScript,
@@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
233284 "dark-mode-handler",
234285 )
235286
236- // 4. Inject theme CSS for internal pages (dumb://* only)
287+ // 5. Inject theme CSS for internal pages (dumb://* only)
237288 if ci.themeCSSVars != "" {
238289 // Escape for JS string literal
239290 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
253304 log.Debug().Msg("theme CSS vars injection configured")
254305 }
255306
256- // 5. Inject find highlight CSS for all pages
307+ // 6. Inject find highlight CSS for all pages
257308 if ci.findCSS != "" {
258309 stylesheet := webkit.NewUserStyleSheet(
259310 ci.findCSS,
@@ -25,6 +25,34 @@ const themeCSSScript = `(function() {
25 (document.head || document.documentElement).appendChild(style);25 (document.head || document.documentElement).appendChild(style);
26 })();`26 })();`
27 27
28+// earlyBackgroundScript injects a temporary background color to prevent white flash.
29+// It's injected at document start and removed after the page loads.
30+// The %s placeholder is replaced with the hex color (e.g., "#0a0a0b").
31+const earlyBackgroundScript = `(function() {
32+ var style = document.createElement('style');
33+ style.id = '__dumber_early_bg';
34+ style.textContent = 'html,body{background-color:%s!important}';
35+ document.documentElement.appendChild(style);
36+
37+ // Remove the style after page content loads to avoid overriding page styles
38+ function removeEarlyBg() {
39+ var s = document.getElementById('__dumber_early_bg');
40+ if (s) s.remove();
41+ }
42+
43+ // Try multiple events to ensure removal
44+ if (document.readyState === 'complete') {
45+ setTimeout(removeEarlyBg, 100);
46+ } else {
47+ window.addEventListener('DOMContentLoaded', function() {
48+ setTimeout(removeEarlyBg, 100);
49+ }, {once: true});
50+ window.addEventListener('load', function() {
51+ setTimeout(removeEarlyBg, 100);
52+ }, {once: true});
53+ }
54+})();`
55+
28 // darkModeScript patches window.matchMedia and sets theme class on <html>.56 // darkModeScript patches window.matchMedia and sets theme class on <html>.
29 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.57 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
30 // This script:58 // This script:
@@ -132,6 +160,7 @@ type ContentInjector struct {
132 prefersDark bool160 prefersDark bool
133 themeCSSVars string // CSS custom property declarations for WebUI161 themeCSSVars string // CSS custom property declarations for WebUI
134 findCSS string // CSS for find-in-page highlight styling162 findCSS string // CSS for find-in-page highlight styling
163+ bgColor string // Background color hex for early injection (prevents white flash)
135 }164 }
136 165
137 // NewContentInjector creates a new injector instance.166 // NewContentInjector creates a new injector instance.
@@ -171,6 +200,13 @@ func (ci *ContentInjector) SetPrefersDark(prefersDark bool) {
171 ci.prefersDark = prefersDark200 ci.prefersDark = prefersDark
172 }201 }
173 202
203+// SetBackgroundColor sets the background color for early CSS injection.
204+// This color is applied to html/body at document start to prevent white flash.
205+// The hex color should include the # prefix (e.g., "#0a0a0b").
206+func (ci *ContentInjector) SetBackgroundColor(hex string) {
207+ ci.bgColor = hex
208+}
209+
174 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.210 // InjectScripts adds the minimal dark mode detection scripts to the given content manager.
175 // Only injects:211 // Only injects:
176 // - window.__dumber_gtk_prefers_dark flag212 // - window.__dumber_gtk_prefers_dark flag
@@ -193,7 +229,22 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
193 log.Debug().Str("script", label).Msg("injected user script")229 log.Debug().Str("script", label).Msg("injected user script")
194 }230 }
195 231
196- // 1. Inject GTK dark mode preference (must be before dark mode handler)232+ // 1. Inject early background color to prevent white flash (must be first!)
233+ if ci.bgColor != "" {
234+ earlyBgScript := fmt.Sprintf(earlyBackgroundScript, ci.bgColor)
235+ addScript(
236+ webkit.NewUserScript(
237+ earlyBgScript,
238+ webkit.UserContentInjectTopFrameValue,
239+ webkit.UserScriptInjectAtDocumentStartValue,
240+ nil,
241+ nil,
242+ ),
243+ "early-background",
244+ )
245+ }
246+
247+ // 2. Inject GTK dark mode preference (must be before dark mode handler)
197 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)248 darkModePrefScript := fmt.Sprintf("window.__dumber_gtk_prefers_dark=%t;", ci.prefersDark)
198 addScript(249 addScript(
199 webkit.NewUserScript(250 webkit.NewUserScript(
@@ -206,7 +257,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
206 "gtk-dark-mode-pref",257 "gtk-dark-mode-pref",
207 )258 )
208 259
209- // 2. Inject WebView ID for debugging260+ // 3. Inject WebView ID for debugging
210 if webviewID != 0 {261 if webviewID != 0 {
211 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))262 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
212 addScript(263 addScript(
@@ -221,7 +272,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
221 )272 )
222 }273 }
223 274
224- // 3. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)275+ // 4. Inject dark mode handler (patches matchMedia using __dumber_gtk_prefers_dark)
225 addScript(276 addScript(
226 webkit.NewUserScript(277 webkit.NewUserScript(
227 darkModeScript,278 darkModeScript,
@@ -233,7 +284,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
233 "dark-mode-handler",284 "dark-mode-handler",
234 )285 )
235 286
236- // 4. Inject theme CSS for internal pages (dumb://* only)287+ // 5. Inject theme CSS for internal pages (dumb://* only)
237 if ci.themeCSSVars != "" {288 if ci.themeCSSVars != "" {
238 // Escape for JS string literal289 // Escape for JS string literal
239 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")290 escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\\", "\\\\")
@@ -253,7 +304,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
253 log.Debug().Msg("theme CSS vars injection configured")304 log.Debug().Msg("theme CSS vars injection configured")
254 }305 }
255 306
256- // 5. Inject find highlight CSS for all pages307+ // 6. Inject find highlight CSS for all pages
257 if ci.findCSS != "" {308 if ci.findCSS != "" {
258 stylesheet := webkit.NewUserStyleSheet(309 stylesheet := webkit.NewUserStyleSheet(
259 ci.findCSS,310 ci.findCSS,
M internal/infrastructure/webkit/pool.go +12 -13
@@ -55,8 +55,7 @@ type WebViewPool struct {
5555 filterApplier FilterApplier // Optional content filter applier
5656
5757 // Background color for WebViews (eliminates white flash)
58- bgR, bgG, bgB, bgA float32
59- bgMu sync.RWMutex
58+ bg bgColor
6059
6160 closed atomic.Bool
6261 wg sync.WaitGroup
@@ -108,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
108107 // SetBackgroundColor sets the background color for newly created WebViews.
109108 // This color is shown before content is painted, eliminating white flash.
110109 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
111- p.bgMu.Lock()
112- p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
113- p.bgMu.Unlock()
110+ p.bg.set(r, g, b, a)
114111 }
115112
116113 // Acquire gets a WebView from the pool or creates a new one.
@@ -125,6 +122,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
125122 select {
126123 case wv := <-p.pool:
127124 if wv != nil && !wv.IsDestroyed() {
125+ if r, g, b, a := p.bg.get(); a > 0 {
126+ wv.SetBackgroundColor(r, g, b, a)
127+ }
128+ wv.inner.AddCssClass("webview-themed")
129+ // Keep pooled WebViews hidden until we explicitly reveal them.
130+ wv.inner.SetVisible(false)
131+
128132 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
129133 _ = wv.AttachFrontend(ctx, p.injector, p.router)
130134 // Apply filters if available (may not have been applied during prewarm)
@@ -147,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
147151 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
148152 log := logging.FromContext(ctx)
149153
150- wv, err := NewWebView(ctx, p.wkCtx, p.settings)
154+ wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
151155 if err != nil {
152156 return nil, err
153157 }
154158
155- // Set background color to match theme (eliminates white flash)
156- p.bgMu.RLock()
157- r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
158- p.bgMu.RUnlock()
159- if a > 0 { // Only set if a valid color was configured
160- wv.SetBackgroundColor(r, g, b, a)
161- }
159+ // Add CSS class for theme background styling (prevents white flash)
160+ wv.inner.AddCssClass("webview-themed")
162161
163162 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
164163 wv.inner.SetVisible(false)
@@ -55,8 +55,7 @@ type WebViewPool struct {
55 filterApplier FilterApplier // Optional content filter applier55 filterApplier FilterApplier // Optional content filter applier
56 56
57 // Background color for WebViews (eliminates white flash)57 // Background color for WebViews (eliminates white flash)
58- bgR, bgG, bgB, bgA float3258+ bg bgColor
59- bgMu sync.RWMutex
60 59
61 closed atomic.Bool60 closed atomic.Bool
62 wg sync.WaitGroup61 wg sync.WaitGroup
@@ -108,9 +107,7 @@ func (p *WebViewPool) SetFilterApplier(applier FilterApplier) {
108 // SetBackgroundColor sets the background color for newly created WebViews.107 // SetBackgroundColor sets the background color for newly created WebViews.
109 // This color is shown before content is painted, eliminating white flash.108 // This color is shown before content is painted, eliminating white flash.
110 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {109 func (p *WebViewPool) SetBackgroundColor(r, g, b, a float32) {
111- p.bgMu.Lock()110+ p.bg.set(r, g, b, a)
112- p.bgR, p.bgG, p.bgB, p.bgA = r, g, b, a
113- p.bgMu.Unlock()
114 }111 }
115 112
116 // Acquire gets a WebView from the pool or creates a new one.113 // Acquire gets a WebView from the pool or creates a new one.
@@ -125,6 +122,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
125 select {122 select {
126 case wv := <-p.pool:123 case wv := <-p.pool:
127 if wv != nil && !wv.IsDestroyed() {124 if wv != nil && !wv.IsDestroyed() {
125+ if r, g, b, a := p.bg.get(); a > 0 {
126+ wv.SetBackgroundColor(r, g, b, a)
127+ }
128+ wv.inner.AddCssClass("webview-themed")
129+ // Keep pooled WebViews hidden until we explicitly reveal them.
130+ wv.inner.SetVisible(false)
131+
128 // Ensure frontend is attached even if this WebView was pooled before injection was configured.132 // Ensure frontend is attached even if this WebView was pooled before injection was configured.
129 _ = wv.AttachFrontend(ctx, p.injector, p.router)133 _ = wv.AttachFrontend(ctx, p.injector, p.router)
130 // Apply filters if available (may not have been applied during prewarm)134 // Apply filters if available (may not have been applied during prewarm)
@@ -147,18 +151,13 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
147 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {151 func (p *WebViewPool) createWebView(ctx context.Context) (*WebView, error) {
148 log := logging.FromContext(ctx)152 log := logging.FromContext(ctx)
149 153
150- wv, err := NewWebView(ctx, p.wkCtx, p.settings)154+ wv, err := NewWebView(ctx, p.wkCtx, p.settings, p.bg.toGdkRGBA())
151 if err != nil {155 if err != nil {
152 return nil, err156 return nil, err
153 }157 }
154 158
155- // Set background color to match theme (eliminates white flash)159+ // Add CSS class for theme background styling (prevents white flash)
156- p.bgMu.RLock()160+ wv.inner.AddCssClass("webview-themed")
157- r, g, b, a := p.bgR, p.bgG, p.bgB, p.bgA
158- p.bgMu.RUnlock()
159- if a > 0 { // Only set if a valid color was configured
160- wv.SetBackgroundColor(r, g, b, a)
161- }
162 161
163 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)162 // Keep WebView hidden until content is painted (see content.go:onLoadCommitted)
164 wv.inner.SetVisible(false)163 wv.inner.SetVisible(false)
M internal/infrastructure/webkit/webview.go +8 -1
@@ -240,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
240240
241241 // NewWebView creates a new WebView with the given context and settings.
242242 // Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
243-func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {
243+// bgColor is optional - if provided, sets background immediately to prevent white flash.
244+func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
244245 log := logging.FromContext(ctx)
245246
246247 if wkCtx == nil || !wkCtx.IsInitialized() {
@@ -253,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
253254 return nil, fmt.Errorf("failed to create webkit webview with network session")
254255 }
255256
257+ // Set background color IMMEDIATELY after creation, before any other operations.
258+ // This prevents white flash by ensuring WebKit's renderer starts with correct color.
259+ if bgColor != nil {
260+ inner.SetBackgroundColor(bgColor)
261+ }
262+
256263 wv := &WebView{
257264 inner: inner,
258265 ucm: inner.GetUserContentManager(),
@@ -240,7 +240,8 @@ func (a *findControllerAdapter) DisconnectSignal(id uint32) {
240 240
241 // NewWebView creates a new WebView with the given context and settings.241 // NewWebView creates a new WebView with the given context and settings.
242 // Uses the persistent NetworkSession from wkCtx for cookie/data persistence.242 // Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
243-func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager) (*WebView, error) {243+// bgColor is optional - if provided, sets background immediately to prevent white flash.
244+func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
244 log := logging.FromContext(ctx)245 log := logging.FromContext(ctx)
245 246
246 if wkCtx == nil || !wkCtx.IsInitialized() {247 if wkCtx == nil || !wkCtx.IsInitialized() {
@@ -253,6 +254,12 @@ func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsMan
253 return nil, fmt.Errorf("failed to create webkit webview with network session")254 return nil, fmt.Errorf("failed to create webkit webview with network session")
254 }255 }
255 256
257+ // Set background color IMMEDIATELY after creation, before any other operations.
258+ // This prevents white flash by ensuring WebKit's renderer starts with correct color.
259+ if bgColor != nil {
260+ inner.SetBackgroundColor(bgColor)
261+ }
262+
256 wv := &WebView{263 wv := &WebView{
257 inner: inner,264 inner: inner,
258 ucm: inner.GetUserContentManager(),265 ucm: inner.GetUserContentManager(),
M internal/ui/component/pane_view.go +7 -1
@@ -148,15 +148,21 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
148148 // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149149 // already painted, so the loading skeleton should be hidden immediately.
150150 hadParent := widget.GetParent() != nil
151+ wasVisible := false
152+ if hadParent {
153+ wasVisible = widget.IsVisible()
154+ }
151155
152156 // Unparent widget from any previous parent (critical for rebuild scenarios)
153157 // In GTK4, a widget can only have one parent at a time
154158 if hadParent {
155159 widget.Unparent()
156160 }
157- widget.SetVisible(true)
158161 pv.overlay.SetChild(widget)
159162
163+ if hadParent && wasVisible {
164+ widget.SetVisible(true)
165+ }
160166 if hadParent && pv.loading != nil {
161167 pv.loading.SetVisible(false)
162168 }
@@ -148,15 +148,21 @@ func (pv *PaneView) SetWebViewWidget(widget layout.Widget) {
148 // (e.g. after rebuilding/moving panes). In that case the WebView has likely148 // (e.g. after rebuilding/moving panes). In that case the WebView has likely
149 // already painted, so the loading skeleton should be hidden immediately.149 // already painted, so the loading skeleton should be hidden immediately.
150 hadParent := widget.GetParent() != nil150 hadParent := widget.GetParent() != nil
151+ wasVisible := false
152+ if hadParent {
153+ wasVisible = widget.IsVisible()
154+ }
151 155
152 // Unparent widget from any previous parent (critical for rebuild scenarios)156 // Unparent widget from any previous parent (critical for rebuild scenarios)
153 // In GTK4, a widget can only have one parent at a time157 // In GTK4, a widget can only have one parent at a time
154 if hadParent {158 if hadParent {
155 widget.Unparent()159 widget.Unparent()
156 }160 }
157- widget.SetVisible(true)
158 pv.overlay.SetChild(widget)161 pv.overlay.SetChild(widget)
159 162
163+ if hadParent && wasVisible {
164+ widget.SetVisible(true)
165+ }
160 if hadParent && pv.loading != nil {166 if hadParent && pv.loading != nil {
161 pv.loading.SetVisible(false)167 pv.loading.SetVisible(false)
162 }168 }
M internal/ui/component/pane_view_test.go +0 -2
@@ -211,7 +211,6 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
211211 // Expect removal of old widget and addition of new
212212 mockOverlay.EXPECT().SetChild(nil).Once()
213213 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
214- mockNewWebView.EXPECT().SetVisible(true).Once()
215214 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
216215
217216 // Act
@@ -234,7 +233,6 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
234233
235234 // Expect only setting new child (no removal since old was nil)
236235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
237- mockNewWebView.EXPECT().SetVisible(true).Once()
238236 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
239237
240238 // Act
@@ -211,7 +211,6 @@ func TestSetWebViewWidget_ReplacesWidget(t *testing.T) {
211 // Expect removal of old widget and addition of new211 // Expect removal of old widget and addition of new
212 mockOverlay.EXPECT().SetChild(nil).Once()212 mockOverlay.EXPECT().SetChild(nil).Once()
213 mockNewWebView.EXPECT().GetParent().Return(nil).Once()213 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
214- mockNewWebView.EXPECT().SetVisible(true).Once()
215 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()214 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
216 215
217 // Act216 // Act
@@ -234,7 +233,6 @@ func TestSetWebViewWidget_FromNil(t *testing.T) {
234 233
235 // Expect only setting new child (no removal since old was nil)234 // Expect only setting new child (no removal since old was nil)
236 mockNewWebView.EXPECT().GetParent().Return(nil).Once()235 mockNewWebView.EXPECT().GetParent().Return(nil).Once()
237- mockNewWebView.EXPECT().SetVisible(true).Once()
238 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()236 mockOverlay.EXPECT().SetChild(mockNewWebView).Once()
239 237
240 // Act238 // Act
M internal/ui/coordinator/content.go +57 -6
@@ -55,6 +55,9 @@ type ContentCoordinator struct {
5555 // Callback when the WebView becomes visible (first real commit)
5656 onWebViewShown func(paneID entity.PaneID)
5757
58+ revealMu sync.Mutex
59+ pendingReveal map[entity.PaneID]bool
60+
5861 // Gesture action handler for mouse button navigation
5962 gestureActionHandler input.ActionHandler
6063
@@ -100,6 +103,7 @@ func NewContentCoordinator(
100103 webViews: make(map[entity.PaneID]*webkit.WebView),
101104 paneTitles: make(map[entity.PaneID]string),
102105 navOrigins: make(map[entity.PaneID]string),
106+ pendingReveal: make(map[entity.PaneID]bool),
103107 getActiveWS: getActiveWS,
104108 pendingPopups: make(map[port.WebViewID]*PendingPopup),
105109 }
@@ -721,15 +725,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
721725 // Avoid updating UI/domain state to about:blank when we know the pane is
722726 // navigating to a different URL. This prevents the omnibox/window title from
723727 // briefly showing about:blank on cold start.
728+ c.clearPendingReveal(paneID)
724729 return
725730 }
726731
727- if inner := wv.Widget(); inner != nil {
728- inner.SetVisible(true)
729- log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("webview shown on load committed")
730- }
731- if c.onWebViewShown != nil {
732- c.onWebViewShown(paneID)
732+ c.markPendingReveal(paneID)
733+ if wv.EstimatedProgress() > 0 {
734+ c.revealIfPending(ctx, paneID, url, "progress-after-commit")
733735 }
734736
735737 // Update domain model with current URI for session snapshots
@@ -800,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
800802 if paneView != nil {
801803 paneView.SetLoading(false)
802804 }
805+
806+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
803807 }
804808
805809 // onProgressChanged updates the progress bar with current load progress.
806810 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
811+ if progress > 0 {
812+ c.revealIfPending(context.Background(), paneID, "", "progress")
813+ }
814+
807815 _, wsView := c.getActiveWS()
808816 if wsView == nil {
809817 return
@@ -815,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
815823 }
816824 }
817825
826+func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
827+ c.revealMu.Lock()
828+ c.pendingReveal[paneID] = true
829+ c.revealMu.Unlock()
830+}
831+
832+func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
833+ c.revealMu.Lock()
834+ delete(c.pendingReveal, paneID)
835+ c.revealMu.Unlock()
836+}
837+
838+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
839+ c.revealMu.Lock()
840+ pending := c.pendingReveal[paneID]
841+ if pending {
842+ delete(c.pendingReveal, paneID)
843+ }
844+ c.revealMu.Unlock()
845+
846+ if !pending {
847+ return
848+ }
849+
850+ wv := c.webViews[paneID]
851+ if wv == nil || wv.IsDestroyed() {
852+ return
853+ }
854+
855+ if inner := wv.Widget(); inner != nil {
856+ inner.SetVisible(true)
857+ logging.FromContext(ctx).
858+ Debug().
859+ Str("pane_id", string(paneID)).
860+ Str("url", url).
861+ Str("reason", reason).
862+ Msg("webview revealed")
863+ }
864+ if c.onWebViewShown != nil {
865+ c.onWebViewShown(paneID)
866+ }
867+}
868+
818869 // onLinkHover updates the link status overlay when hovering over links.
819870 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
820871 _, wsView := c.getActiveWS()
@@ -55,6 +55,9 @@ type ContentCoordinator struct {
55 // Callback when the WebView becomes visible (first real commit)55 // Callback when the WebView becomes visible (first real commit)
56 onWebViewShown func(paneID entity.PaneID)56 onWebViewShown func(paneID entity.PaneID)
57 57
58+ revealMu sync.Mutex
59+ pendingReveal map[entity.PaneID]bool
60+
58 // Gesture action handler for mouse button navigation61 // Gesture action handler for mouse button navigation
59 gestureActionHandler input.ActionHandler62 gestureActionHandler input.ActionHandler
60 63
@@ -100,6 +103,7 @@ func NewContentCoordinator(
100 webViews: make(map[entity.PaneID]*webkit.WebView),103 webViews: make(map[entity.PaneID]*webkit.WebView),
101 paneTitles: make(map[entity.PaneID]string),104 paneTitles: make(map[entity.PaneID]string),
102 navOrigins: make(map[entity.PaneID]string),105 navOrigins: make(map[entity.PaneID]string),
106+ pendingReveal: make(map[entity.PaneID]bool),
103 getActiveWS: getActiveWS,107 getActiveWS: getActiveWS,
104 pendingPopups: make(map[port.WebViewID]*PendingPopup),108 pendingPopups: make(map[port.WebViewID]*PendingPopup),
105 }109 }
@@ -721,15 +725,13 @@ func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.
721 // Avoid updating UI/domain state to about:blank when we know the pane is725 // Avoid updating UI/domain state to about:blank when we know the pane is
722 // navigating to a different URL. This prevents the omnibox/window title from726 // navigating to a different URL. This prevents the omnibox/window title from
723 // briefly showing about:blank on cold start.727 // briefly showing about:blank on cold start.
728+ c.clearPendingReveal(paneID)
724 return729 return
725 }730 }
726 731
727- if inner := wv.Widget(); inner != nil {732+ c.markPendingReveal(paneID)
728- inner.SetVisible(true)733+ if wv.EstimatedProgress() > 0 {
729- log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("webview shown on load committed")734+ c.revealIfPending(ctx, paneID, url, "progress-after-commit")
730- }
731- if c.onWebViewShown != nil {
732- c.onWebViewShown(paneID)
733 }735 }
734 736
735 // Update domain model with current URI for session snapshots737 // Update domain model with current URI for session snapshots
@@ -800,10 +802,16 @@ func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
800 if paneView != nil {802 if paneView != nil {
801 paneView.SetLoading(false)803 paneView.SetLoading(false)
802 }804 }
805+
806+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
803 }807 }
804 808
805 // onProgressChanged updates the progress bar with current load progress.809 // onProgressChanged updates the progress bar with current load progress.
806 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {810 func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
811+ if progress > 0 {
812+ c.revealIfPending(context.Background(), paneID, "", "progress")
813+ }
814+
807 _, wsView := c.getActiveWS()815 _, wsView := c.getActiveWS()
808 if wsView == nil {816 if wsView == nil {
809 return817 return
@@ -815,6 +823,49 @@ func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress fl
815 }823 }
816 }824 }
817 825
826+func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
827+ c.revealMu.Lock()
828+ c.pendingReveal[paneID] = true
829+ c.revealMu.Unlock()
830+}
831+
832+func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
833+ c.revealMu.Lock()
834+ delete(c.pendingReveal, paneID)
835+ c.revealMu.Unlock()
836+}
837+
838+func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
839+ c.revealMu.Lock()
840+ pending := c.pendingReveal[paneID]
841+ if pending {
842+ delete(c.pendingReveal, paneID)
843+ }
844+ c.revealMu.Unlock()
845+
846+ if !pending {
847+ return
848+ }
849+
850+ wv := c.webViews[paneID]
851+ if wv == nil || wv.IsDestroyed() {
852+ return
853+ }
854+
855+ if inner := wv.Widget(); inner != nil {
856+ inner.SetVisible(true)
857+ logging.FromContext(ctx).
858+ Debug().
859+ Str("pane_id", string(paneID)).
860+ Str("url", url).
861+ Str("reason", reason).
862+ Msg("webview revealed")
863+ }
864+ if c.onWebViewShown != nil {
865+ c.onWebViewShown(paneID)
866+ }
867+}
868+
818 // onLinkHover updates the link status overlay when hovering over links.869 // onLinkHover updates the link status overlay when hovering over links.
819 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {870 func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
820 _, wsView := c.getActiveWS()871 _, wsView := c.getActiveWS()
A internal/ui/coordinator/content_reveal_test.go +179 -0
@@ -0,0 +1,179 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+)
13+
14+// TestPendingReveal tests the pending reveal state management.
15+// These are internal package tests that verify the state transitions
16+// without needing to mock WebKit dependencies.
17+
18+func TestMarkPendingReveal_SetsState(t *testing.T) {
19+ c := &ContentCoordinator{
20+ pendingReveal: make(map[entity.PaneID]bool),
21+ }
22+ paneID := entity.PaneID("pane-1")
23+
24+ c.markPendingReveal(paneID)
25+
26+ c.revealMu.Lock()
27+ defer c.revealMu.Unlock()
28+ assert.True(t, c.pendingReveal[paneID])
29+}
30+
31+func TestClearPendingReveal_RemovesState(t *testing.T) {
32+ c := &ContentCoordinator{
33+ pendingReveal: make(map[entity.PaneID]bool),
34+ }
35+ paneID := entity.PaneID("pane-1")
36+
37+ // First mark as pending
38+ c.markPendingReveal(paneID)
39+
40+ // Then clear
41+ c.clearPendingReveal(paneID)
42+
43+ c.revealMu.Lock()
44+ defer c.revealMu.Unlock()
45+ assert.False(t, c.pendingReveal[paneID])
46+}
47+
48+func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
49+ callbackCalled := false
50+ c := &ContentCoordinator{
51+ pendingReveal: make(map[entity.PaneID]bool),
52+ webViews: make(map[entity.PaneID]*webkit.WebView),
53+ onWebViewShown: func(paneID entity.PaneID) {
54+ callbackCalled = true
55+ },
56+ }
57+ paneID := entity.PaneID("pane-1")
58+
59+ // Call reveal without marking pending first
60+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
61+
62+ assert.False(t, callbackCalled, "callback should not be called when not pending")
63+}
64+
65+func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66+ c := &ContentCoordinator{
67+ pendingReveal: make(map[entity.PaneID]bool),
68+ webViews: make(map[entity.PaneID]*webkit.WebView),
69+ }
70+ paneID := entity.PaneID("pane-1")
71+
72+ // Mark as pending
73+ c.markPendingReveal(paneID)
74+
75+ // Reveal (will return early since webview is nil, but should still clear state)
76+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
77+
78+ c.revealMu.Lock()
79+ defer c.revealMu.Unlock()
80+ assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")
81+}
82+
83+func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
84+ revealCount := 0
85+ c := &ContentCoordinator{
86+ pendingReveal: make(map[entity.PaneID]bool),
87+ webViews: make(map[entity.PaneID]*webkit.WebView),
88+ onWebViewShown: func(paneID entity.PaneID) {
89+ revealCount++
90+ },
91+ }
92+ paneID := entity.PaneID("pane-1")
93+
94+ // Mark as pending
95+ c.markPendingReveal(paneID)
96+
97+ // Call reveal multiple times
98+ c.revealIfPending(context.Background(), paneID, "", "first")
99+ c.revealIfPending(context.Background(), paneID, "", "second")
100+ c.revealIfPending(context.Background(), paneID, "", "third")
101+
102+ // Callback should not be called since webview is nil, but state should be cleared
103+ assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
104+
105+ // Verify state is cleared
106+ c.revealMu.Lock()
107+ defer c.revealMu.Unlock()
108+ assert.False(t, c.pendingReveal[paneID])
109+}
110+
111+func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112+ c := &ContentCoordinator{
113+ pendingReveal: make(map[entity.PaneID]bool),
114+ webViews: make(map[entity.PaneID]*webkit.WebView),
115+ }
116+
117+ var wg sync.WaitGroup
118+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
119+
120+ // Concurrently mark and clear panes
121+ for i := 0; i < 100; i++ {
122+ for _, paneID := range paneIDs {
123+ wg.Add(3)
124+
125+ go func(id entity.PaneID) {
126+ defer wg.Done()
127+ c.markPendingReveal(id)
128+ }(paneID)
129+
130+ go func(id entity.PaneID) {
131+ defer wg.Done()
132+ c.clearPendingReveal(id)
133+ }(paneID)
134+
135+ go func(id entity.PaneID) {
136+ defer wg.Done()
137+ c.revealIfPending(context.Background(), id, "", "concurrent")
138+ }(paneID)
139+ }
140+ }
141+
142+ wg.Wait()
143+ // Test passes if no race conditions occur
144+}
145+
146+func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147+ c := &ContentCoordinator{
148+ pendingReveal: make(map[entity.PaneID]bool),
149+ }
150+
151+ pane1 := entity.PaneID("pane-1")
152+ pane2 := entity.PaneID("pane-2")
153+
154+ c.markPendingReveal(pane1)
155+ c.markPendingReveal(pane2)
156+
157+ c.revealMu.Lock()
158+ defer c.revealMu.Unlock()
159+ assert.True(t, c.pendingReveal[pane1])
160+ assert.True(t, c.pendingReveal[pane2])
161+}
162+
163+func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164+ c := &ContentCoordinator{
165+ pendingReveal: make(map[entity.PaneID]bool),
166+ }
167+
168+ pane1 := entity.PaneID("pane-1")
169+ pane2 := entity.PaneID("pane-2")
170+
171+ c.markPendingReveal(pane1)
172+ c.markPendingReveal(pane2)
173+ c.clearPendingReveal(pane1)
174+
175+ c.revealMu.Lock()
176+ defer c.revealMu.Unlock()
177+ assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
178+ assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
179+}
@@ -0,0 +1,179 @@
1+package coordinator
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+)
13+
14+// TestPendingReveal tests the pending reveal state management.
15+// These are internal package tests that verify the state transitions
16+// without needing to mock WebKit dependencies.
17+
18+func TestMarkPendingReveal_SetsState(t *testing.T) {
19+ c := &ContentCoordinator{
20+ pendingReveal: make(map[entity.PaneID]bool),
21+ }
22+ paneID := entity.PaneID("pane-1")
23+
24+ c.markPendingReveal(paneID)
25+
26+ c.revealMu.Lock()
27+ defer c.revealMu.Unlock()
28+ assert.True(t, c.pendingReveal[paneID])
29+}
30+
31+func TestClearPendingReveal_RemovesState(t *testing.T) {
32+ c := &ContentCoordinator{
33+ pendingReveal: make(map[entity.PaneID]bool),
34+ }
35+ paneID := entity.PaneID("pane-1")
36+
37+ // First mark as pending
38+ c.markPendingReveal(paneID)
39+
40+ // Then clear
41+ c.clearPendingReveal(paneID)
42+
43+ c.revealMu.Lock()
44+ defer c.revealMu.Unlock()
45+ assert.False(t, c.pendingReveal[paneID])
46+}
47+
48+func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
49+ callbackCalled := false
50+ c := &ContentCoordinator{
51+ pendingReveal: make(map[entity.PaneID]bool),
52+ webViews: make(map[entity.PaneID]*webkit.WebView),
53+ onWebViewShown: func(paneID entity.PaneID) {
54+ callbackCalled = true
55+ },
56+ }
57+ paneID := entity.PaneID("pane-1")
58+
59+ // Call reveal without marking pending first
60+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
61+
62+ assert.False(t, callbackCalled, "callback should not be called when not pending")
63+}
64+
65+func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66+ c := &ContentCoordinator{
67+ pendingReveal: make(map[entity.PaneID]bool),
68+ webViews: make(map[entity.PaneID]*webkit.WebView),
69+ }
70+ paneID := entity.PaneID("pane-1")
71+
72+ // Mark as pending
73+ c.markPendingReveal(paneID)
74+
75+ // Reveal (will return early since webview is nil, but should still clear state)
76+ c.revealIfPending(context.Background(), paneID, "http://example.com", "test")
77+
78+ c.revealMu.Lock()
79+ defer c.revealMu.Unlock()
80+ assert.False(t, c.pendingReveal[paneID], "pending state should be cleared after reveal attempt")
81+}
82+
83+func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
84+ revealCount := 0
85+ c := &ContentCoordinator{
86+ pendingReveal: make(map[entity.PaneID]bool),
87+ webViews: make(map[entity.PaneID]*webkit.WebView),
88+ onWebViewShown: func(paneID entity.PaneID) {
89+ revealCount++
90+ },
91+ }
92+ paneID := entity.PaneID("pane-1")
93+
94+ // Mark as pending
95+ c.markPendingReveal(paneID)
96+
97+ // Call reveal multiple times
98+ c.revealIfPending(context.Background(), paneID, "", "first")
99+ c.revealIfPending(context.Background(), paneID, "", "second")
100+ c.revealIfPending(context.Background(), paneID, "", "third")
101+
102+ // Callback should not be called since webview is nil, but state should be cleared
103+ assert.Equal(t, 0, revealCount, "callback not called when webview is nil")
104+
105+ // Verify state is cleared
106+ c.revealMu.Lock()
107+ defer c.revealMu.Unlock()
108+ assert.False(t, c.pendingReveal[paneID])
109+}
110+
111+func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112+ c := &ContentCoordinator{
113+ pendingReveal: make(map[entity.PaneID]bool),
114+ webViews: make(map[entity.PaneID]*webkit.WebView),
115+ }
116+
117+ var wg sync.WaitGroup
118+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3", "pane-4", "pane-5"}
119+
120+ // Concurrently mark and clear panes
121+ for i := 0; i < 100; i++ {
122+ for _, paneID := range paneIDs {
123+ wg.Add(3)
124+
125+ go func(id entity.PaneID) {
126+ defer wg.Done()
127+ c.markPendingReveal(id)
128+ }(paneID)
129+
130+ go func(id entity.PaneID) {
131+ defer wg.Done()
132+ c.clearPendingReveal(id)
133+ }(paneID)
134+
135+ go func(id entity.PaneID) {
136+ defer wg.Done()
137+ c.revealIfPending(context.Background(), id, "", "concurrent")
138+ }(paneID)
139+ }
140+ }
141+
142+ wg.Wait()
143+ // Test passes if no race conditions occur
144+}
145+
146+func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147+ c := &ContentCoordinator{
148+ pendingReveal: make(map[entity.PaneID]bool),
149+ }
150+
151+ pane1 := entity.PaneID("pane-1")
152+ pane2 := entity.PaneID("pane-2")
153+
154+ c.markPendingReveal(pane1)
155+ c.markPendingReveal(pane2)
156+
157+ c.revealMu.Lock()
158+ defer c.revealMu.Unlock()
159+ assert.True(t, c.pendingReveal[pane1])
160+ assert.True(t, c.pendingReveal[pane2])
161+}
162+
163+func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164+ c := &ContentCoordinator{
165+ pendingReveal: make(map[entity.PaneID]bool),
166+ }
167+
168+ pane1 := entity.PaneID("pane-1")
169+ pane2 := entity.PaneID("pane-2")
170+
171+ c.markPendingReveal(pane1)
172+ c.markPendingReveal(pane2)
173+ c.clearPendingReveal(pane1)
174+
175+ c.revealMu.Lock()
176+ defer c.revealMu.Unlock()
177+ assert.False(t, c.pendingReveal[pane1], "pane1 should be cleared")
178+ assert.True(t, c.pendingReveal[pane2], "pane2 should still be pending")
179+}
M internal/ui/theme/css.go +9 -4
@@ -339,15 +339,15 @@ entry.omnibox-entry > *:focus-visible {
339339 font-weight: 500;
340340 }
341341
342-/* URL text below title */
343-.omnibox-suggestion-url {
342+/* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
343+.omnibox-row .omnibox-suggestion-url {
344344 font-size: 0.75em;
345- color: var(--muted);
345+ color: alpha(var(--muted), 0.65);
346346 font-weight: 400;
347347 }
348348
349349 .omnibox-row:selected .omnibox-suggestion-url {
350- color: var(--muted);
350+ color: alpha(var(--muted), 0.75);
351351 }
352352
353353 /* Keyboard shortcut badge */
@@ -475,6 +475,11 @@ func generatePaneCSS(p Palette) string {
475475 background-color: var(--bg);
476476 }
477477
478+/* WebView widget - theme background prevents white flash during page load */
479+.webview-themed {
480+ background-color: var(--bg);
481+}
482+
478483 /* Loading skeleton - shown until WebView paints */
479484 .loading-skeleton {
480485 background-color: var(--bg);
@@ -339,15 +339,15 @@ entry.omnibox-entry > *:focus-visible {
339 font-weight: 500;339 font-weight: 500;
340 }340 }
341 341
342-/* URL text below title */342+/* URL text below title - use .omnibox-row prefix for specificity over .omnibox-row label */
343-.omnibox-suggestion-url {343+.omnibox-row .omnibox-suggestion-url {
344 font-size: 0.75em;344 font-size: 0.75em;
345- color: var(--muted);345+ color: alpha(var(--muted), 0.65);
346 font-weight: 400;346 font-weight: 400;
347 }347 }
348 348
349 .omnibox-row:selected .omnibox-suggestion-url {349 .omnibox-row:selected .omnibox-suggestion-url {
350- color: var(--muted);350+ color: alpha(var(--muted), 0.75);
351 }351 }
352 352
353 /* Keyboard shortcut badge */353 /* Keyboard shortcut badge */
@@ -475,6 +475,11 @@ func generatePaneCSS(p Palette) string {
475 background-color: var(--bg);475 background-color: var(--bg);
476 }476 }
477 477
478+/* WebView widget - theme background prevents white flash during page load */
479+.webview-themed {
480+ background-color: var(--bg);
481+}
482+
478 /* Loading skeleton - shown until WebView paints */483 /* Loading skeleton - shown until WebView paints */
479 .loading-skeleton {484 .loading-skeleton {
480 background-color: var(--bg);485 background-color: var(--bg);

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