detached ccc1c3e

feat(theme): inject CSS variables into WebUI pages for dynamic theming

Add complete theme injection infrastructure to sync GTK palette with WebUI
via CSS custom properties. Implements port interface, use case, and webkit
injector for CSS variable injection. Updates palette converter to generate
Tailwind-compatible CSS variables and refines GTK button styling for
accent color usage (borders only, per design requirements).
commit
ccc1c3eb6582f112377fe9f10e9a526b6d812767
author
brice · · 2025-12-18 17:03 UTC
parent
8a86fff
signature
unsigned
6 files · +141 -6
M cmd/dumber/main.go +8 -0
@@ -136,6 +136,14 @@ func runGUI() {
136136
137137 settings := webkit.NewSettingsManager(ctx, cfg)
138138 injector := webkit.NewContentInjector(themeManager.PrefersDark())
139+
140+ // Prepare theme CSS for WebUI pages (dumb://*)
141+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
142+ themeCSSVars := themeManager.GetCurrentPalette().ToWebCSSVars()
143+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSVars}); err != nil {
144+ logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
145+ }
146+
139147 messageRouter := webkit.NewMessageRouter(ctx)
140148 poolCfg := webkit.DefaultPoolConfig()
141149 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
@@ -136,6 +136,14 @@ func runGUI() {
136 136
137 settings := webkit.NewSettingsManager(ctx, cfg)137 settings := webkit.NewSettingsManager(ctx, cfg)
138 injector := webkit.NewContentInjector(themeManager.PrefersDark())138 injector := webkit.NewContentInjector(themeManager.PrefersDark())
139+
140+ // Prepare theme CSS for WebUI pages (dumb://*)
141+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
142+ themeCSSVars := themeManager.GetCurrentPalette().ToWebCSSVars()
143+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSVars}); err != nil {
144+ logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
145+ }
146+
139 messageRouter := webkit.NewMessageRouter(ctx)147 messageRouter := webkit.NewMessageRouter(ctx)
140 poolCfg := webkit.DefaultPoolConfig()148 poolCfg := webkit.DefaultPoolConfig()
141 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)149 pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
A internal/application/port/content_injector.go +11 -0
@@ -0,0 +1,11 @@
1+package port
2+
3+import "context"
4+
5+// ContentInjector defines the port interface for injecting scripts and styles into web views.
6+// This abstracts platform-specific content injection implementations (WebKit, etc.).
7+type ContentInjector interface {
8+ // InjectThemeCSS injects CSS variables into the page for theming.
9+ // The css parameter should contain CSS custom property declarations.
10+ InjectThemeCSS(ctx context.Context, css string) error
11+}
@@ -0,0 +1,11 @@
1+package port
2+
3+import "context"
4+
5+// ContentInjector defines the port interface for injecting scripts and styles into web views.
6+// This abstracts platform-specific content injection implementations (WebKit, etc.).
7+type ContentInjector interface {
8+ // InjectThemeCSS injects CSS variables into the page for theming.
9+ // The css parameter should contain CSS custom property declarations.
10+ InjectThemeCSS(ctx context.Context, css string) error
11+}
A internal/application/usecase/prepare_webui_theme.go +51 -0
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// PrepareWebUIThemeUseCase handles theme CSS injection for internal web pages.
11+type PrepareWebUIThemeUseCase struct {
12+ injector port.ContentInjector
13+}
14+
15+// NewPrepareWebUIThemeUseCase creates a new theme preparation use case.
16+func NewPrepareWebUIThemeUseCase(injector port.ContentInjector) *PrepareWebUIThemeUseCase {
17+ return &PrepareWebUIThemeUseCase{
18+ injector: injector,
19+ }
20+}
21+
22+// PrepareWebUIThemeInput contains parameters for theme preparation.
23+type PrepareWebUIThemeInput struct {
24+ // CSSVars is the CSS custom property declarations to inject.
25+ // Should be generated from palette.ToWebCSSVars().
26+ CSSVars string
27+}
28+
29+// Execute injects theme CSS variables into the web view.
30+func (uc *PrepareWebUIThemeUseCase) Execute(ctx context.Context, input PrepareWebUIThemeInput) error {
31+ log := logging.FromContext(ctx)
32+ log.Debug().Msg("preparing WebUI theme CSS")
33+
34+ if uc.injector == nil {
35+ log.Warn().Msg("content injector is nil, skipping theme injection")
36+ return nil
37+ }
38+
39+ if input.CSSVars == "" {
40+ log.Debug().Msg("no CSS vars to inject")
41+ return nil
42+ }
43+
44+ if err := uc.injector.InjectThemeCSS(ctx, input.CSSVars); err != nil {
45+ log.Error().Err(err).Msg("failed to inject theme CSS")
46+ return err
47+ }
48+
49+ log.Debug().Msg("theme CSS injected successfully")
50+ return nil
51+}
@@ -0,0 +1,51 @@
1+package usecase
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// PrepareWebUIThemeUseCase handles theme CSS injection for internal web pages.
11+type PrepareWebUIThemeUseCase struct {
12+ injector port.ContentInjector
13+}
14+
15+// NewPrepareWebUIThemeUseCase creates a new theme preparation use case.
16+func NewPrepareWebUIThemeUseCase(injector port.ContentInjector) *PrepareWebUIThemeUseCase {
17+ return &PrepareWebUIThemeUseCase{
18+ injector: injector,
19+ }
20+}
21+
22+// PrepareWebUIThemeInput contains parameters for theme preparation.
23+type PrepareWebUIThemeInput struct {
24+ // CSSVars is the CSS custom property declarations to inject.
25+ // Should be generated from palette.ToWebCSSVars().
26+ CSSVars string
27+}
28+
29+// Execute injects theme CSS variables into the web view.
30+func (uc *PrepareWebUIThemeUseCase) Execute(ctx context.Context, input PrepareWebUIThemeInput) error {
31+ log := logging.FromContext(ctx)
32+ log.Debug().Msg("preparing WebUI theme CSS")
33+
34+ if uc.injector == nil {
35+ log.Warn().Msg("content injector is nil, skipping theme injection")
36+ return nil
37+ }
38+
39+ if input.CSSVars == "" {
40+ log.Debug().Msg("no CSS vars to inject")
41+ return nil
42+ }
43+
44+ if err := uc.injector.InjectThemeCSS(ctx, input.CSSVars); err != nil {
45+ log.Error().Err(err).Msg("failed to inject theme CSS")
46+ return err
47+ }
48+
49+ log.Debug().Msg("theme CSS injected successfully")
50+ return nil
51+}
M internal/infrastructure/webkit/injector.go +43 -2
@@ -3,6 +3,7 @@ package webkit
33 import (
44 "context"
55 "fmt"
6+ "strings"
67
78 "github.com/bnema/dumber/internal/logging"
89 "github.com/bnema/puregotk-webkit/webkit"
@@ -15,6 +16,15 @@ const (
1516 MessageHandlerName = "dumber"
1617 )
1718
19+// themeCSSScript injects CSS custom properties into the page.
20+// The %s placeholder is replaced with the CSS variable declarations (newlines escaped as \n).
21+const themeCSSScript = `(function() {
22+ var style = document.createElement('style');
23+ style.setAttribute('data-dumber-theme', '');
24+ style.textContent = ':root {%s}';
25+ (document.head || document.documentElement).appendChild(style);
26+})();`
27+
1828 // darkModeScript patches window.matchMedia and sets theme class on <html>.
1929 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
2030 const darkModeScript = `(function() {
@@ -75,9 +85,12 @@ const darkModeScript = `(function() {
7585 })();`
7686
7787 // ContentInjector encapsulates script injection into WebViews.
78-// It injects minimal scripts for dark mode detection in web pages.
88+// It injects minimal scripts for dark mode detection in web pages
89+// and theme CSS variables for internal pages (dumb://).
90+// Implements port.ContentInjector interface.
7991 type ContentInjector struct {
80- prefersDark bool
92+ prefersDark bool
93+ themeCSSVars string // CSS custom property declarations for WebUI
8194 }
8295
8396 // NewContentInjector creates a new injector instance.
@@ -88,6 +101,16 @@ func NewContentInjector(prefersDark bool) *ContentInjector {
88101 }
89102 }
90103
104+// InjectThemeCSS stores CSS variables for injection into internal pages.
105+// Implements port.ContentInjector interface.
106+// The CSS will be injected when InjectScripts is called on WebView creation.
107+func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
108+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
109+ ci.themeCSSVars = css
110+ log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
111+ return nil
112+}
113+
91114 // PrefersDark returns the current dark mode preference.
92115 func (ci *ContentInjector) PrefersDark() bool {
93116 return ci.prefersDark
@@ -161,5 +184,23 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
161184 "dark-mode-handler",
162185 )
163186
187+ // 4. Inject theme CSS variables for internal pages (dumb://* only)
188+ if ci.themeCSSVars != "" {
189+ // Escape newlines for JS string literal
190+ escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\n", "\\n")
191+ themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
192+ addScript(
193+ webkit.NewUserScript(
194+ themeCSSInjectionScript,
195+ webkit.UserContentInjectTopFrameValue,
196+ webkit.UserScriptInjectAtDocumentEndValue,
197+ nil, // Inject on all pages - allowlist patterns don't work for custom schemes
198+ nil,
199+ ),
200+ "theme-css-vars",
201+ )
202+ log.Debug().Msg("theme CSS vars injection configured")
203+ }
204+
164205 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
165206 }
@@ -3,6 +3,7 @@ package webkit
3 import (3 import (
4 "context"4 "context"
5 "fmt"5 "fmt"
6+ "strings"
6 7
7 "github.com/bnema/dumber/internal/logging"8 "github.com/bnema/dumber/internal/logging"
8 "github.com/bnema/puregotk-webkit/webkit"9 "github.com/bnema/puregotk-webkit/webkit"
@@ -15,6 +16,15 @@ const (
15 MessageHandlerName = "dumber"16 MessageHandlerName = "dumber"
16 )17 )
17 18
19+// themeCSSScript injects CSS custom properties into the page.
20+// The %s placeholder is replaced with the CSS variable declarations (newlines escaped as \n).
21+const themeCSSScript = `(function() {
22+ var style = document.createElement('style');
23+ style.setAttribute('data-dumber-theme', '');
24+ style.textContent = ':root {%s}';
25+ (document.head || document.documentElement).appendChild(style);
26+})();`
27+
18 // darkModeScript patches window.matchMedia and sets theme class on <html>.28 // darkModeScript patches window.matchMedia and sets theme class on <html>.
19 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.29 // It must be injected at document start, after __dumber_gtk_prefers_dark is set.
20 const darkModeScript = `(function() {30 const darkModeScript = `(function() {
@@ -75,9 +85,12 @@ const darkModeScript = `(function() {
75 })();`85 })();`
76 86
77 // ContentInjector encapsulates script injection into WebViews.87 // ContentInjector encapsulates script injection into WebViews.
78-// It injects minimal scripts for dark mode detection in web pages.88+// It injects minimal scripts for dark mode detection in web pages
89+// and theme CSS variables for internal pages (dumb://).
90+// Implements port.ContentInjector interface.
79 type ContentInjector struct {91 type ContentInjector struct {
80- prefersDark bool92+ prefersDark bool
93+ themeCSSVars string // CSS custom property declarations for WebUI
81 }94 }
82 95
83 // NewContentInjector creates a new injector instance.96 // NewContentInjector creates a new injector instance.
@@ -88,6 +101,16 @@ func NewContentInjector(prefersDark bool) *ContentInjector {
88 }101 }
89 }102 }
90 103
104+// InjectThemeCSS stores CSS variables for injection into internal pages.
105+// Implements port.ContentInjector interface.
106+// The CSS will be injected when InjectScripts is called on WebView creation.
107+func (ci *ContentInjector) InjectThemeCSS(ctx context.Context, css string) error {
108+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
109+ ci.themeCSSVars = css
110+ log.Debug().Int("css_len", len(css)).Msg("theme CSS vars set for injection")
111+ return nil
112+}
113+
91 // PrefersDark returns the current dark mode preference.114 // PrefersDark returns the current dark mode preference.
92 func (ci *ContentInjector) PrefersDark() bool {115 func (ci *ContentInjector) PrefersDark() bool {
93 return ci.prefersDark116 return ci.prefersDark
@@ -161,5 +184,23 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
161 "dark-mode-handler",184 "dark-mode-handler",
162 )185 )
163 186
187+ // 4. Inject theme CSS variables for internal pages (dumb://* only)
188+ if ci.themeCSSVars != "" {
189+ // Escape newlines for JS string literal
190+ escapedCSS := strings.ReplaceAll(ci.themeCSSVars, "\n", "\\n")
191+ themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
192+ addScript(
193+ webkit.NewUserScript(
194+ themeCSSInjectionScript,
195+ webkit.UserContentInjectTopFrameValue,
196+ webkit.UserScriptInjectAtDocumentEndValue,
197+ nil, // Inject on all pages - allowlist patterns don't work for custom schemes
198+ nil,
199+ ),
200+ "theme-css-vars",
201+ )
202+ log.Debug().Msg("theme CSS vars injection configured")
203+ }
204+
164 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")205 log.Debug().Bool("prefers_dark", ci.prefersDark).Msg("minimal scripts injected")
165 }206 }
M internal/ui/theme/css.go +4 -3
@@ -99,7 +99,8 @@ button.tab-button:hover {
9999 }
100100
101101 button.tab-button.tab-button-active {
102- background-color: shade(var(--surface-variant), 1.4);
102+ background-color: shade(var(--surface-variant), 1.2);
103+ border-bottom: 0.125em solid var(--accent);
103104 font-weight: 600;
104105 }
105106
@@ -353,7 +354,7 @@ button.stacked-pane-title-button {
353354 }
354355
355356 button.stacked-pane-title-button:hover {
356- background-color: alpha(var(--accent), 0.15);
357+ background-color: shade(var(--surface-variant), 1.15);
357358 }
358359
359360 button.stacked-pane-title-button:focus {
@@ -363,7 +364,7 @@ button.stacked-pane-title-button:focus {
363364
364365 /* Title bar content box */
365366 .stacked-pane-titlebar.active {
366- background-color: alpha(var(--accent), 0.1);
367+ background-color: shade(var(--surface-variant), 1.2);
367368 border-left: 0.1875em solid var(--accent);
368369 }
369370
@@ -99,7 +99,8 @@ button.tab-button:hover {
99 }99 }
100 100
101 button.tab-button.tab-button-active {101 button.tab-button.tab-button-active {
102- background-color: shade(var(--surface-variant), 1.4);102+ background-color: shade(var(--surface-variant), 1.2);
103+ border-bottom: 0.125em solid var(--accent);
103 font-weight: 600;104 font-weight: 600;
104 }105 }
105 106
@@ -353,7 +354,7 @@ button.stacked-pane-title-button {
353 }354 }
354 355
355 button.stacked-pane-title-button:hover {356 button.stacked-pane-title-button:hover {
356- background-color: alpha(var(--accent), 0.15);357+ background-color: shade(var(--surface-variant), 1.15);
357 }358 }
358 359
359 button.stacked-pane-title-button:focus {360 button.stacked-pane-title-button:focus {
@@ -363,7 +364,7 @@ button.stacked-pane-title-button:focus {
363 364
364 /* Title bar content box */365 /* Title bar content box */
365 .stacked-pane-titlebar.active {366 .stacked-pane-titlebar.active {
366- background-color: alpha(var(--accent), 0.1);367+ background-color: shade(var(--surface-variant), 1.2);
367 border-left: 0.1875em solid var(--accent);368 border-left: 0.1875em solid var(--accent);
368 }369 }
369 370
M internal/ui/theme/palette.go +24 -1
@@ -114,7 +114,7 @@ func (p Palette) Validate() error {
114114 return nil
115115 }
116116
117-// ToCSSVars generates CSS custom property declarations.
117+// ToCSSVars generates CSS custom property declarations for GTK.
118118 func (p Palette) ToCSSVars() string {
119119 var sb strings.Builder
120120 sb.WriteString(" --bg: " + p.Background + ";\n")
@@ -126,3 +126,26 @@ func (p Palette) ToCSSVars() string {
126126 sb.WriteString(" --border: " + p.Border + ";\n")
127127 return sb.String()
128128 }
129+
130+// ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
131+// Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
132+func (p Palette) ToWebCSSVars() string {
133+ var sb strings.Builder
134+ // Tailwind-compatible variables
135+ sb.WriteString(" --background: " + p.Background + ";\n")
136+ sb.WriteString(" --foreground: " + p.Text + ";\n")
137+ sb.WriteString(" --card: " + p.Surface + ";\n")
138+ sb.WriteString(" --card-foreground: " + p.Text + ";\n")
139+ sb.WriteString(" --primary: " + p.Accent + ";\n")
140+ sb.WriteString(" --primary-foreground: " + p.Background + ";\n")
141+ sb.WriteString(" --muted: " + p.SurfaceVariant + ";\n")
142+ sb.WriteString(" --muted-foreground: " + p.Muted + ";\n")
143+ sb.WriteString(" --border: " + p.Border + ";\n")
144+ // Keep GTK names for compatibility
145+ sb.WriteString(" --bg: " + p.Background + ";\n")
146+ sb.WriteString(" --surface: " + p.Surface + ";\n")
147+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
148+ sb.WriteString(" --text: " + p.Text + ";\n")
149+ sb.WriteString(" --accent: " + p.Accent + ";\n")
150+ return sb.String()
151+}
@@ -114,7 +114,7 @@ func (p Palette) Validate() error {
114 return nil114 return nil
115 }115 }
116 116
117-// ToCSSVars generates CSS custom property declarations.117+// ToCSSVars generates CSS custom property declarations for GTK.
118 func (p Palette) ToCSSVars() string {118 func (p Palette) ToCSSVars() string {
119 var sb strings.Builder119 var sb strings.Builder
120 sb.WriteString(" --bg: " + p.Background + ";\n")120 sb.WriteString(" --bg: " + p.Background + ";\n")
@@ -126,3 +126,26 @@ func (p Palette) ToCSSVars() string {
126 sb.WriteString(" --border: " + p.Border + ";\n")126 sb.WriteString(" --border: " + p.Border + ";\n")
127 return sb.String()127 return sb.String()
128 }128 }
129+
130+// ToWebCSSVars generates CSS custom properties for web UI (Tailwind compatibility).
131+// Maps GTK palette names to Tailwind variable names used in the WebUI homepage.
132+func (p Palette) ToWebCSSVars() string {
133+ var sb strings.Builder
134+ // Tailwind-compatible variables
135+ sb.WriteString(" --background: " + p.Background + ";\n")
136+ sb.WriteString(" --foreground: " + p.Text + ";\n")
137+ sb.WriteString(" --card: " + p.Surface + ";\n")
138+ sb.WriteString(" --card-foreground: " + p.Text + ";\n")
139+ sb.WriteString(" --primary: " + p.Accent + ";\n")
140+ sb.WriteString(" --primary-foreground: " + p.Background + ";\n")
141+ sb.WriteString(" --muted: " + p.SurfaceVariant + ";\n")
142+ sb.WriteString(" --muted-foreground: " + p.Muted + ";\n")
143+ sb.WriteString(" --border: " + p.Border + ";\n")
144+ // Keep GTK names for compatibility
145+ sb.WriteString(" --bg: " + p.Background + ";\n")
146+ sb.WriteString(" --surface: " + p.Surface + ";\n")
147+ sb.WriteString(" --surface-variant: " + p.SurfaceVariant + ";\n")
148+ sb.WriteString(" --text: " + p.Text + ";\n")
149+ sb.WriteString(" --accent: " + p.Accent + ";\n")
150+ return sb.String()
151+}

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