detached 4be3d7e

fix(webkit): inject palette config and GTK theme at document-start

Fixes appearance config injection timing issue where palette was being
injected after page load, causing race condition with GUI initialization.

Changes:
- Add GTK theme detection using PrefersDarkTheme() from GTK4 settings
- Inject window.__dumber_gtk_prefers_dark before color-scheme script
- Inject window.__dumber_palette at document-start via UserContentManager
- Remove legacy exposeAppearanceConfig() that used post-load injection
- Pass appearance config through webkit.Config to UserContentManager

This ensures GUI scripts have immediate access to palette and theme data,
resolving "[dumber-palette] ERROR: No palette found" console errors.
commit
4be3d7e02bf8f7e71d80e72878f4565b93cf8db9
author
bamen · · 2025-10-08 20:03 UTC
committer
bamen · · 2025-10-08 21:28 UTC
parent
a999d8a
signature
unsigned
4 files · +76 -65
M internal/app/browser/webview.go +42 -63
@@ -51,6 +51,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
5151 MinimumFontSize: 8,
5252 DataDir: webkitData,
5353 CacheDir: webkitCache,
54+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
5455 }
5556
5657 return cfg, nil
@@ -65,6 +66,45 @@ func buildUserAgent(cfg *config.Config) string {
6566 return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
6667 }
6768
69+// buildAppearanceConfigJSON builds the palette configuration as JSON
70+// The GUI expects window.__dumber_palette with just the palette data
71+func (app *BrowserApp) buildAppearanceConfigJSON() string {
72+ if app.config == nil {
73+ return ""
74+ }
75+
76+ // Build palette config in the format main-world.ts expects
77+ // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
78+ paletteConfig := map[string]interface{}{
79+ "light": map[string]string{
80+ "background": app.config.Appearance.LightPalette.Background,
81+ "surface": app.config.Appearance.LightPalette.Surface,
82+ "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
83+ "text": app.config.Appearance.LightPalette.Text,
84+ "muted": app.config.Appearance.LightPalette.Muted,
85+ "accent": app.config.Appearance.LightPalette.Accent,
86+ "border": app.config.Appearance.LightPalette.Border,
87+ },
88+ "dark": map[string]string{
89+ "background": app.config.Appearance.DarkPalette.Background,
90+ "surface": app.config.Appearance.DarkPalette.Surface,
91+ "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
92+ "text": app.config.Appearance.DarkPalette.Text,
93+ "muted": app.config.Appearance.DarkPalette.Muted,
94+ "accent": app.config.Appearance.DarkPalette.Accent,
95+ "border": app.config.Appearance.DarkPalette.Border,
96+ },
97+ }
98+
99+ payload, err := json.Marshal(paletteConfig)
100+ if err != nil {
101+ log.Printf("[webview] Failed to marshal palette config: %v", err)
102+ return ""
103+ }
104+
105+ return string(payload)
106+}
107+
68108 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
69109 pane := &BrowserPane{
70110 webView: view,
@@ -239,74 +279,13 @@ func (app *BrowserApp) createWebView() error {
239279 func (app *BrowserApp) setupWebViewIntegration() {
240280 app.browserService.AttachWebView(app.webView)
241281
242- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
243- // No need to load separately in browser service
282+ // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
283+ // Appearance config is also injected at document-start via UserContentManager
244284
245285 // Use native window as title updater
246286 if win := app.webView.Window(); win != nil {
247287 app.browserService.SetWindowTitleUpdater(win)
248288 }
249-
250- // Expose appearance configuration to the injected GUI modules
251- app.exposeAppearanceConfig(app.webView)
252-}
253-
254-// exposeAppearanceConfig injects appearance configuration as a typed global
255-// This makes color palettes and font preferences available to TypeScript modules
256-func (app *BrowserApp) exposeAppearanceConfig(view *webkit.WebView) {
257- if view == nil || app.config == nil {
258- return
259- }
260-
261- // Build appearance config matching the TypeScript AppearanceConfig interface
262- // Note: Theme preference comes from GTK system settings (via color-scheme.ts),
263- // not from config file. We default to "light" here.
264- appearanceConfig := map[string]interface{}{
265- "theme": "light", // Default theme - color-scheme.ts will detect actual GTK preference
266- "palettes": map[string]interface{}{
267- "light": map[string]string{
268- "background": app.config.Appearance.LightPalette.Background,
269- "surface": app.config.Appearance.LightPalette.Surface,
270- "surfaceVariant": app.config.Appearance.LightPalette.SurfaceVariant,
271- "text": app.config.Appearance.LightPalette.Text,
272- "muted": app.config.Appearance.LightPalette.Muted,
273- "accent": app.config.Appearance.LightPalette.Accent,
274- "border": app.config.Appearance.LightPalette.Border,
275- },
276- "dark": map[string]string{
277- "background": app.config.Appearance.DarkPalette.Background,
278- "surface": app.config.Appearance.DarkPalette.Surface,
279- "surfaceVariant": app.config.Appearance.DarkPalette.SurfaceVariant,
280- "text": app.config.Appearance.DarkPalette.Text,
281- "muted": app.config.Appearance.DarkPalette.Muted,
282- "accent": app.config.Appearance.DarkPalette.Accent,
283- "border": app.config.Appearance.DarkPalette.Border,
284- },
285- },
286- "fonts": map[string]interface{}{
287- "sans": app.config.Appearance.SansFont,
288- "serif": app.config.Appearance.SerifFont,
289- "monospace": app.config.Appearance.MonospaceFont,
290- "defaultSize": app.config.Appearance.DefaultFontSize,
291- },
292- }
293-
294- // Convert to JSON
295- payload, err := json.Marshal(appearanceConfig)
296- if err != nil {
297- log.Printf("[webview] Failed to marshal appearance config: %v", err)
298- return
299- }
300-
301- // Inject as global variable before any modules load
302- // The appearance-config.ts module will detect and apply this
303- script := fmt.Sprintf(`window.__dumber_appearance_config = %s;`, string(payload))
304-
305- if err := view.InjectScript(script); err != nil {
306- log.Printf("[webview] Failed to expose appearance config: %v", err)
307- } else {
308- log.Printf("[webview] Appearance config exposed with default theme=light")
309- }
310289 }
311290
312291 // setupContentBlocking initializes the content blocking system with proper timing
@@ -51,6 +51,7 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
51 MinimumFontSize: 8,51 MinimumFontSize: 8,
52 DataDir: webkitData,52 DataDir: webkitData,
53 CacheDir: webkitCache,53 CacheDir: webkitCache,
54+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
54 }55 }
55 56
56 return cfg, nil57 return cfg, nil
@@ -65,6 +66,45 @@ func buildUserAgent(cfg *config.Config) string {
65 return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"66 return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
66 }67 }
67 68
69+// buildAppearanceConfigJSON builds the palette configuration as JSON
70+// The GUI expects window.__dumber_palette with just the palette data
71+func (app *BrowserApp) buildAppearanceConfigJSON() string {
72+ if app.config == nil {
73+ return ""
74+ }
75+
76+ // Build palette config in the format main-world.ts expects
77+ // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
78+ paletteConfig := map[string]interface{}{
79+ "light": map[string]string{
80+ "background": app.config.Appearance.LightPalette.Background,
81+ "surface": app.config.Appearance.LightPalette.Surface,
82+ "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
83+ "text": app.config.Appearance.LightPalette.Text,
84+ "muted": app.config.Appearance.LightPalette.Muted,
85+ "accent": app.config.Appearance.LightPalette.Accent,
86+ "border": app.config.Appearance.LightPalette.Border,
87+ },
88+ "dark": map[string]string{
89+ "background": app.config.Appearance.DarkPalette.Background,
90+ "surface": app.config.Appearance.DarkPalette.Surface,
91+ "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
92+ "text": app.config.Appearance.DarkPalette.Text,
93+ "muted": app.config.Appearance.DarkPalette.Muted,
94+ "accent": app.config.Appearance.DarkPalette.Accent,
95+ "border": app.config.Appearance.DarkPalette.Border,
96+ },
97+ }
98+
99+ payload, err := json.Marshal(paletteConfig)
100+ if err != nil {
101+ log.Printf("[webview] Failed to marshal palette config: %v", err)
102+ return ""
103+ }
104+
105+ return string(payload)
106+}
107+
68 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {108 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
69 pane := &BrowserPane{109 pane := &BrowserPane{
70 webView: view,110 webView: view,
@@ -239,74 +279,13 @@ func (app *BrowserApp) createWebView() error {
239 func (app *BrowserApp) setupWebViewIntegration() {279 func (app *BrowserApp) setupWebViewIntegration() {
240 app.browserService.AttachWebView(app.webView)280 app.browserService.AttachWebView(app.webView)
241 281
242- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager282+ // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
243- // No need to load separately in browser service283+ // Appearance config is also injected at document-start via UserContentManager
244 284
245 // Use native window as title updater285 // Use native window as title updater
246 if win := app.webView.Window(); win != nil {286 if win := app.webView.Window(); win != nil {
247 app.browserService.SetWindowTitleUpdater(win)287 app.browserService.SetWindowTitleUpdater(win)
248 }288 }
249-
250- // Expose appearance configuration to the injected GUI modules
251- app.exposeAppearanceConfig(app.webView)
252-}
253-
254-// exposeAppearanceConfig injects appearance configuration as a typed global
255-// This makes color palettes and font preferences available to TypeScript modules
256-func (app *BrowserApp) exposeAppearanceConfig(view *webkit.WebView) {
257- if view == nil || app.config == nil {
258- return
259- }
260-
261- // Build appearance config matching the TypeScript AppearanceConfig interface
262- // Note: Theme preference comes from GTK system settings (via color-scheme.ts),
263- // not from config file. We default to "light" here.
264- appearanceConfig := map[string]interface{}{
265- "theme": "light", // Default theme - color-scheme.ts will detect actual GTK preference
266- "palettes": map[string]interface{}{
267- "light": map[string]string{
268- "background": app.config.Appearance.LightPalette.Background,
269- "surface": app.config.Appearance.LightPalette.Surface,
270- "surfaceVariant": app.config.Appearance.LightPalette.SurfaceVariant,
271- "text": app.config.Appearance.LightPalette.Text,
272- "muted": app.config.Appearance.LightPalette.Muted,
273- "accent": app.config.Appearance.LightPalette.Accent,
274- "border": app.config.Appearance.LightPalette.Border,
275- },
276- "dark": map[string]string{
277- "background": app.config.Appearance.DarkPalette.Background,
278- "surface": app.config.Appearance.DarkPalette.Surface,
279- "surfaceVariant": app.config.Appearance.DarkPalette.SurfaceVariant,
280- "text": app.config.Appearance.DarkPalette.Text,
281- "muted": app.config.Appearance.DarkPalette.Muted,
282- "accent": app.config.Appearance.DarkPalette.Accent,
283- "border": app.config.Appearance.DarkPalette.Border,
284- },
285- },
286- "fonts": map[string]interface{}{
287- "sans": app.config.Appearance.SansFont,
288- "serif": app.config.Appearance.SerifFont,
289- "monospace": app.config.Appearance.MonospaceFont,
290- "defaultSize": app.config.Appearance.DefaultFontSize,
291- },
292- }
293-
294- // Convert to JSON
295- payload, err := json.Marshal(appearanceConfig)
296- if err != nil {
297- log.Printf("[webview] Failed to marshal appearance config: %v", err)
298- return
299- }
300-
301- // Inject as global variable before any modules load
302- // The appearance-config.ts module will detect and apply this
303- script := fmt.Sprintf(`window.__dumber_appearance_config = %s;`, string(payload))
304-
305- if err := view.InjectScript(script); err != nil {
306- log.Printf("[webview] Failed to expose appearance config: %v", err)
307- } else {
308- log.Printf("[webview] Appearance config exposed with default theme=light")
309- }
310 }289 }
311 290
312 // setupContentBlocking initializes the content blocking system with proper timing291 // setupContentBlocking initializes the content blocking system with proper timing
M pkg/webkit/types.go +4 -0
@@ -50,6 +50,10 @@ type Config struct {
5050
5151 // CacheDir is the directory for HTTP cache
5252 CacheDir string
53+
54+ // AppearanceConfigJSON is the JSON string for appearance configuration
55+ // This will be injected at document-start via UserContentManager
56+ AppearanceConfigJSON string
5357 }
5458
5559 // GetDefaultConfig returns a Config with sensible defaults
@@ -50,6 +50,10 @@ type Config struct {
50 50
51 // CacheDir is the directory for HTTP cache51 // CacheDir is the directory for HTTP cache
52 CacheDir string52 CacheDir string
53+
54+ // AppearanceConfigJSON is the JSON string for appearance configuration
55+ // This will be injected at document-start via UserContentManager
56+ AppearanceConfigJSON string
53 }57 }
54 58
55 // GetDefaultConfig returns a Config with sensible defaults59 // GetDefaultConfig returns a Config with sensible defaults
M pkg/webkit/user_content.go +29 -1
@@ -1,6 +1,7 @@
11 package webkit
22
33 import (
4+ "fmt"
45 "log"
56
67 "github.com/bnema/dumber/assets"
@@ -9,7 +10,7 @@ import (
910
1011 // SetupUserContentManager configures UserContentManager for the WebView
1112 // This injects GUI scripts at document-start and registers message handlers
12-func SetupUserContentManager(view *webkit.WebView) error {
13+func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string) error {
1314 if view == nil {
1415 return nil
1516 }
@@ -21,6 +22,33 @@ func SetupUserContentManager(view *webkit.WebView) error {
2122 return nil
2223 }
2324
25+ // Inject GTK theme detection FIRST, before color-scheme script
26+ // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
27+ prefersDark := PrefersDarkTheme()
28+ gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
29+ ucm.AddScript(webkit.NewUserScript(
30+ gtkThemeScript,
31+ webkit.UserContentInjectAllFrames,
32+ webkit.UserScriptInjectAtDocumentStart,
33+ nil,
34+ nil,
35+ ))
36+ log.Printf("[webkit] Injected GTK theme preference: prefersDark=%t", prefersDark)
37+
38+ // Inject palette config SECOND, before GUI scripts
39+ // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }
40+ if appearanceConfigJSON != "" {
41+ paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)
42+ ucm.AddScript(webkit.NewUserScript(
43+ paletteScript,
44+ webkit.UserContentInjectAllFrames,
45+ webkit.UserScriptInjectAtDocumentStart,
46+ nil,
47+ nil,
48+ ))
49+ log.Printf("[webkit] Injected palette config at document-start (%d bytes)", len(paletteScript))
50+ }
51+
2452 // Inject color-scheme script at document-start
2553 if assets.ColorSchemeScript != "" {
2654 ucm.AddScript(webkit.NewUserScript(
@@ -1,6 +1,7 @@
1 package webkit1 package webkit
2 2
3 import (3 import (
4+ "fmt"
4 "log"5 "log"
5 6
6 "github.com/bnema/dumber/assets"7 "github.com/bnema/dumber/assets"
@@ -9,7 +10,7 @@ import (
9 10
10 // SetupUserContentManager configures UserContentManager for the WebView11 // SetupUserContentManager configures UserContentManager for the WebView
11 // This injects GUI scripts at document-start and registers message handlers12 // This injects GUI scripts at document-start and registers message handlers
12-func SetupUserContentManager(view *webkit.WebView) error {13+func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string) error {
13 if view == nil {14 if view == nil {
14 return nil15 return nil
15 }16 }
@@ -21,6 +22,33 @@ func SetupUserContentManager(view *webkit.WebView) error {
21 return nil22 return nil
22 }23 }
23 24
25+ // Inject GTK theme detection FIRST, before color-scheme script
26+ // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
27+ prefersDark := PrefersDarkTheme()
28+ gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
29+ ucm.AddScript(webkit.NewUserScript(
30+ gtkThemeScript,
31+ webkit.UserContentInjectAllFrames,
32+ webkit.UserScriptInjectAtDocumentStart,
33+ nil,
34+ nil,
35+ ))
36+ log.Printf("[webkit] Injected GTK theme preference: prefersDark=%t", prefersDark)
37+
38+ // Inject palette config SECOND, before GUI scripts
39+ // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }
40+ if appearanceConfigJSON != "" {
41+ paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)
42+ ucm.AddScript(webkit.NewUserScript(
43+ paletteScript,
44+ webkit.UserContentInjectAllFrames,
45+ webkit.UserScriptInjectAtDocumentStart,
46+ nil,
47+ nil,
48+ ))
49+ log.Printf("[webkit] Injected palette config at document-start (%d bytes)", len(paletteScript))
50+ }
51+
24 // Inject color-scheme script at document-start52 // Inject color-scheme script at document-start
25 if assets.ColorSchemeScript != "" {53 if assets.ColorSchemeScript != "" {
26 ucm.AddScript(webkit.NewUserScript(54 ucm.AddScript(webkit.NewUserScript(
M pkg/webkit/webview.go +1 -1
@@ -93,7 +93,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
9393
9494 // Setup UserContentManager and inject GUI scripts
9595 // This must be done BEFORE any pages are loaded
96- if err := SetupUserContentManager(wkView); err != nil {
96+ if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON); err != nil {
9797 return nil, fmt.Errorf("failed to setup user content manager: %w", err)
9898 }
9999
@@ -93,7 +93,7 @@ func NewWebView(cfg *Config) (*WebView, error) {
93 93
94 // Setup UserContentManager and inject GUI scripts94 // Setup UserContentManager and inject GUI scripts
95 // This must be done BEFORE any pages are loaded95 // This must be done BEFORE any pages are loaded
96- if err := SetupUserContentManager(wkView); err != nil {96+ if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON); err != nil {
97 return nil, fmt.Errorf("failed to setup user content manager: %w", err)97 return nil, fmt.Errorf("failed to setup user content manager: %w", err)
98 }98 }
99 99

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