detached 66d7b57

feat(theme): follow Noctalia external palettes

commit
66d7b57d4bb4cad13105836050d8f70027c38fab
author
brice · · 2026-06-02 13:15 UTC
committer
brice · · 2026-06-09 20:01 UTC
parent
9807be4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
47 files · +6022 -823
M cmd/dumber/main.go +33 -20
@@ -249,6 +249,7 @@ func runGUI(cfg *config.Config) int {
249 249
250 ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog)250 ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog)
251 defer sessionCleanup()251 defer sessionCleanup()
252+ bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme)
252 timer.Mark("session")253 timer.Mark("session")
253 254
254 log := logging.FromContext(ctx)255 log := logging.FromContext(ctx)
@@ -326,6 +327,9 @@ func runStandaloneOmnibox() int {
326 cfg,327 cfg,
327 initResult.RuntimeProfile,328 initResult.RuntimeProfile,
328 initResult.ThemeManager,329 initResult.ThemeManager,
330+ initResult.ResolveThemeUC,
331+ initResult.ExternalThemeSource,
332+ initResult.ExternalThemeWatcher,
329 initResult.ColorResolver,333 initResult.ColorResolver,
330 initResult.AdwaitaDetector,334 initResult.AdwaitaDetector,
331 nil,335 nil,
@@ -471,7 +475,8 @@ func buildAndConfigureApp(
471 ) (*ui.App, error) {475 ) (*ui.App, error) {
472 uiDeps, err := buildUIDependencies(476 uiDeps, err := buildUIDependencies(
473 ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,477 ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,
474- initResult.ColorResolver, initResult.AdwaitaDetector,478+ initResult.ResolveThemeUC, initResult.ExternalThemeSource,
479+ initResult.ExternalThemeWatcher, initResult.ColorResolver, initResult.AdwaitaDetector,
475 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),480 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
476 )481 )
477 if err != nil {482 if err != nil {
@@ -506,11 +511,12 @@ func initStackAndRepos(
506 if needsEagerDB {511 if needsEagerDB {
507 // Parallel phase 2: Database + engine initialize concurrently512 // Parallel phase 2: Database + engine initialize concurrently
508 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{513 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{
509- Ctx: ctx,514+ Ctx: ctx,
510- Config: cfg,515+ Config: cfg,
511- RuntimeProfile: initResult.RuntimeProfile,516+ RuntimeProfile: initResult.RuntimeProfile,
512- ThemeManager: initResult.ThemeManager,517+ ThemeManager: initResult.ThemeManager,
513- ColorResolver: initResult.ColorResolver,518+ ExternalThemeSource: initResult.ExternalThemeSource,
519+ ColorResolver: initResult.ColorResolver,
514 })520 })
515 if err != nil {521 if err != nil {
516 return nil, nil, nil, err522 return nil, nil, nil, err
@@ -520,12 +526,13 @@ func initStackAndRepos(
520 526
521 log := logging.FromContext(ctx)527 log := logging.FromContext(ctx)
522 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{528 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{
523- Ctx: ctx,529+ Ctx: ctx,
524- Config: cfg,530+ Config: cfg,
525- RuntimeProfile: initResult.RuntimeProfile,531+ RuntimeProfile: initResult.RuntimeProfile,
526- ThemeManager: initResult.ThemeManager,532+ ThemeManager: initResult.ThemeManager,
527- ColorResolver: initResult.ColorResolver,533+ ExternalThemeSource: initResult.ExternalThemeSource,
528- Logger: *log,534+ ColorResolver: initResult.ColorResolver,
535+ Logger: *log,
529 })536 })
530 if err != nil {537 if err != nil {
531 return nil, nil, nil, err538 return nil, nil, nil, err
@@ -834,6 +841,9 @@ func buildUIDependencies(
834 cfg *config.Config,841 cfg *config.Config,
835 runtimeProfile runtimeprofile.Profile,842 runtimeProfile runtimeprofile.Profile,
836 themeManager *theme.Manager,843 themeManager *theme.Manager,
844+ resolveThemeUC *usecase.ResolveThemeUseCase,
845+ externalThemeSource port.ConfigurableExternalThemeSource,
846+ externalThemeWatcher port.ExternalThemeWatcher,
837 colorResolver port.ColorSchemeResolver,847 colorResolver port.ColorSchemeResolver,
838 adwaitaDetector port.ToolkitAvailabilityNotifier,848 adwaitaDetector port.ToolkitAvailabilityNotifier,
839 engine port.Engine,849 engine port.Engine,
@@ -857,14 +867,17 @@ func buildUIDependencies(
857 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)867 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
858 868
859 uiDeps := &ui.Dependencies{869 uiDeps := &ui.Dependencies{
860- Ctx: ctx,870+ Ctx: ctx,
861- Config: cfg,871+ Config: cfg,
862- InitialURL: initialURL,872+ InitialURL: initialURL,
863- RestoreSessionID: restoreSessionID,873+ RestoreSessionID: restoreSessionID,
864- StartupCrashReports: startupCrashReports,874+ StartupCrashReports: startupCrashReports,
865- Theme: themeManager,875+ Theme: themeManager,
866- ColorResolver: colorResolver,876+ ResolveThemeUC: resolveThemeUC,
867- AdwaitaDetector: adwaitaDetector,877+ ExternalThemeSource: externalThemeSource,
878+ ExternalThemeWatcher: externalThemeWatcher,
879+ ColorResolver: colorResolver,
880+ AdwaitaDetector: adwaitaDetector,
868 XDG: xdg.New(881 XDG: xdg.New(
869 runtimeProfile.Mode == runtimeprofile.ModeDev,882 runtimeProfile.Mode == runtimeprofile.ModeDev,
870 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),883 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),
M docs/config/index.md +68 -0
@@ -141,6 +141,10 @@ auto_open_on_new_pane = false
141 | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font |141 | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font |
142 | `appearance.default_font_size` | int | `16` | Font size in points |142 | `appearance.default_font_size` | int | `16` | Font size in points |
143 | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` |143 | `appearance.color_scheme` | string | `"default"` | `prefer-dark`, `prefer-light`, `default` |
144+| `appearance.external_theme.enabled` | bool | `false` | Enable an external palette source |
145+| `appearance.external_theme.provider` | string | `"noctalia"` | External provider. Only `noctalia` is supported |
146+| `appearance.external_theme.format` | string | `"colors-json"` | External file format: `colors-json` or `dumber-json` |
147+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | Path to the external theme JSON file |
144 148
145 ### Color Palettes149 ### Color Palettes
146 150
@@ -168,6 +172,70 @@ accent = "#a8a8a8"
168 border = "#363636"172 border = "#363636"
169 ```173 ```
170 174
175+### External Theme: Noctalia
176+
177+Dumber can follow Noctalia by reading the native Noctalia palette file directly. No user template is required for the default integration.
178+
179+```toml
180+[appearance.external_theme]
181+enabled = true
182+provider = "noctalia"
183+format = "colors-json" # default
184+# Optional. Defaults to $XDG_CONFIG_HOME/noctalia/colors.json.
185+path = "~/.config/noctalia/colors.json"
186+```
187+
188+The external file overrides palette colors only. Fonts, UI scale, pane-mode colors, and other appearance settings still come from Dumber config.
189+
190+#### `colors-json` contract
191+
192+`colors-json` reads Noctalia's active `colors.json` palette. Example file: [`docs/examples/noctalia-colors.json`](../examples/noctalia-colors.json).
193+
194+Dumber maps Noctalia roles like this:
195+
196+| Noctalia key | Dumber palette field |
197+|--------------|----------------------|
198+| `mShadow` | `background` when present, otherwise `mSurface` |
199+| `mSurface` | `surface` |
200+| `mHover` | `surface_variant` when present, otherwise `mSurfaceVariant` |
201+| `mOnSurface` | `text` |
202+| `mOnSurfaceVariant` | `muted` |
203+| `mPrimary` | `accent` |
204+| `mOutline` | `border` |
205+
206+Every mapped value must be a CSS-safe 6-digit hex color such as `#AABBCC`. `mShadow` and `mHover` are optional nuance roles; when they are present they are validated and used to preserve Dumber's background/surface/input contrast. Because Noctalia's native file contains the active palette only, Dumber applies that mapped palette to both resolved light and dark palettes so the app follows the file immediately.
207+
208+#### Advanced: `dumber-json` user template
209+
210+`dumber-json` remains supported for an explicit Dumber-specific Noctalia user template:
211+
212+```toml
213+[appearance.external_theme]
214+enabled = true
215+provider = "noctalia"
216+format = "dumber-json"
217+# Optional for dumber-json. Defaults to $XDG_CONFIG_HOME/dumber/noctalia-theme.json.
218+path = "~/.config/dumber/noctalia-theme.json"
219+```
220+
221+The file must be JSON with root `light` and `dark` objects. Optional `source`, `name`, and `mode` metadata fields are accepted; `name` or `source` is used only as source metadata. Dumber chooses the active palette from `appearance.color_scheme` and the system color-scheme preference.
222+
223+Supported palette fields are:
224+
225+- `background`
226+- `surface`
227+- `surface_variant`
228+- `text`
229+- `muted`
230+- `accent`
231+- `border`
232+
233+Each non-empty palette value must be a CSS-safe 6-digit hex color such as `#AABBCC`. Empty or omitted palette fields inherit from the configured Dumber palette for that mode.
234+
235+Example rendered file: [`docs/examples/noctalia-dumber-theme.json`](../examples/noctalia-dumber-theme.json). Example template: [`docs/examples/noctalia-dumber-theme.template.json`](../examples/noctalia-dumber-theme.template.json).
236+
237+When Noctalia rewrites the watched file, Dumber reapplies the same resolved theme path used by config reloads. A malformed or temporarily missing file keeps the last valid external palette when one exists; disabling `appearance.external_theme.enabled` clears that last-good external palette and returns to Dumber's configured palettes.
238+
171 ## Debug Options239 ## Debug Options
172 240
173 | Key | Type | Default | Description |241 | Key | Type | Default | Description |
A docs/examples/noctalia-colors.json +18 -0
@@ -0,0 +1,18 @@
1+{
2+ "mError": "#ff8080",
3+ "mHover": "#282828",
4+ "mOnError": "#000000",
5+ "mOnHover": "#ffffff",
6+ "mOnPrimary": "#000000",
7+ "mOnSecondary": "#000000",
8+ "mOnSurface": "#ffffff",
9+ "mOnSurfaceVariant": "#a0a0a0",
10+ "mOnTertiary": "#000000",
11+ "mOutline": "#505050",
12+ "mPrimary": "#ffc799",
13+ "mSecondary": "#99ffe4",
14+ "mShadow": "#000000",
15+ "mSurface": "#0c0c0c",
16+ "mSurfaceVariant": "#1c1c1c",
17+ "mTertiary": "#fbadff"
18+}
A docs/examples/noctalia-dumber-theme.json +22 -0
@@ -0,0 +1,22 @@
1+{
2+ "source": "noctalia-user-template",
3+ "mode": "dark",
4+ "light": {
5+ "background": "#fbf8ff",
6+ "surface": "#f4eff7",
7+ "surface_variant": "#e7e0ec",
8+ "text": "#1d1b20",
9+ "muted": "#49454f",
10+ "accent": "#6750a4",
11+ "border": "#79747e"
12+ },
13+ "dark": {
14+ "background": "#141218",
15+ "surface": "#211f26",
16+ "surface_variant": "#49454f",
17+ "text": "#e6e0e9",
18+ "muted": "#cac4d0",
19+ "accent": "#d0bcff",
20+ "border": "#938f99"
21+ }
22+}
A docs/examples/noctalia-dumber-theme.template.json +22 -0
@@ -0,0 +1,22 @@
1+{
2+ "source": "noctalia-user-template",
3+ "mode": "{{mode}}",
4+ "light": {
5+ "background": "{{colors.background.light.hex}}",
6+ "surface": "{{colors.surface.light.hex}}",
7+ "surface_variant": "{{colors.surface_variant.light.hex}}",
8+ "text": "{{colors.on_surface.light.hex}}",
9+ "muted": "{{colors.on_surface_variant.light.hex}}",
10+ "accent": "{{colors.primary.light.hex}}",
11+ "border": "{{colors.outline.light.hex}}"
12+ },
13+ "dark": {
14+ "background": "{{colors.background.dark.hex}}",
15+ "surface": "{{colors.surface.dark.hex}}",
16+ "surface_variant": "{{colors.surface_variant.dark.hex}}",
17+ "text": "{{colors.on_surface.dark.hex}}",
18+ "muted": "{{colors.on_surface_variant.dark.hex}}",
19+ "accent": "{{colors.primary.dark.hex}}",
20+ "border": "{{colors.outline.dark.hex}}"
21+ }
22+}
M docs/reference/configuration.md +4 -0
@@ -33,6 +33,10 @@
33 | `appearance.monospace_font` | string | `Fira Code` | |33 | `appearance.monospace_font` | string | `Fira Code` | |
34 | `appearance.default_font_size` | int | `16` | |34 | `appearance.default_font_size` | int | `16` | |
35 | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` |35 | `appearance.color_scheme` | string | `default` | `prefer-dark`, `prefer-light`, `default` |
36+| `appearance.external_theme.enabled` | bool | `false` | |
37+| `appearance.external_theme.provider` | string | `noctalia` | `noctalia` |
38+| `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` |
39+| `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia colors.json or explicit dumber-json |
36 | `appearance.light_palette.background` | string | `#f8f8f8` | |40 | `appearance.light_palette.background` | string | `#f8f8f8` | |
37 | `appearance.light_palette.surface` | string | `#f2f2f2` | |41 | `appearance.light_palette.surface` | string | `#f2f2f2` | |
38 | `appearance.light_palette.surface_variant` | string | `#ececec` | |42 | `appearance.light_palette.surface_variant` | string | `#ececec` | |
M internal/application/dto/webui_config.go +25 -20
@@ -1,5 +1,7 @@
1 package dto1 package dto
2 2
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
3 // WebUIConfig represents the subset of configuration editable in dumb://config.5 // WebUIConfig represents the subset of configuration editable in dumb://config.
4 type WebUIConfig struct {6 type WebUIConfig struct {
5 Appearance WebUIAppearanceConfig `json:"appearance"`7 Appearance WebUIAppearanceConfig `json:"appearance"`
@@ -43,7 +45,11 @@ type SystemviewPerformancePayload struct {
43 45
44 // SystemviewConfigPayload is the full JSON shape expected by dumb://config.46 // SystemviewConfigPayload is the full JSON shape expected by dumb://config.
45 type SystemviewConfigPayload struct {47 type SystemviewConfigPayload struct {
46- Appearance WebUIAppearanceConfig `json:"appearance"`48+ // Appearance is the editable config snapshot.
49+ Appearance WebUIAppearanceConfig `json:"appearance"`
50+ // ResolvedAppearance is optional, display-only appearance resolved from config
51+ // plus external theme sources. Forms must save Appearance, never this field.
52+ ResolvedAppearance *WebUIAppearanceConfig `json:"resolved_appearance,omitempty"`
47 Performance SystemviewPerformancePayload `json:"performance"`53 Performance SystemviewPerformancePayload `json:"performance"`
48 DefaultUIScale float64 `json:"default_ui_scale"`54 DefaultUIScale float64 `json:"default_ui_scale"`
49 DefaultSearchEngine string `json:"default_search_engine"`55 DefaultSearchEngine string `json:"default_search_engine"`
@@ -66,28 +72,27 @@ type WebUICustomPerformanceConfig struct {
66 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`72 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
67 }73 }
68 74
69-type WebUIAppearanceConfig struct {75+type WebUIAppearanceConfig = entity.AppearanceConfig
70- SansFont string `json:"sans_font"`
71- SerifFont string `json:"serif_font"`
72- MonospaceFont string `json:"monospace_font"`
73- GtkFont string `json:"gtk_font"`
74- DefaultFontSize int `json:"default_font_size"`
75- ColorScheme string `json:"color_scheme"`
76- LightPalette ColorPalette `json:"light_palette"`
77- DarkPalette ColorPalette `json:"dark_palette"`
78-}
79 76
80-type ColorPalette struct {77+type WebUIExternalThemeConfig = entity.ExternalThemeConfig
81- Background string `json:"background"`78+
82- Surface string `json:"surface"`79+type ColorPalette = entity.ColorPalette
83- SurfaceVariant string `json:"surface_variant"`
84- Text string `json:"text"`
85- Muted string `json:"muted"`
86- Accent string `json:"accent"`
87- Border string `json:"border"`
88-}
89 80
90 type SearchShortcut struct {81 type SearchShortcut struct {
91 URL string `json:"url"`82 URL string `json:"url"`
92 Description string `json:"description"`83 Description string `json:"description"`
93 }84 }
85+
86+// WebUIAppearanceWithResolvedTheme builds a display-only appearance payload from
87+// resolved theme values while preserving fields that are not theme-resolution
88+// outputs, such as ColorScheme and ExternalTheme.
89+func WebUIAppearanceWithResolvedTheme(base WebUIAppearanceConfig, resolved entity.ResolvedTheme) WebUIAppearanceConfig {
90+ base.SansFont = resolved.Fonts.SansFont
91+ base.SerifFont = resolved.Fonts.SerifFont
92+ base.MonospaceFont = resolved.Fonts.MonospaceFont
93+ base.GtkFont = resolved.Fonts.GtkFont
94+ base.DefaultFontSize = resolved.Fonts.DefaultSize
95+ base.LightPalette = resolved.LightPalette
96+ base.DarkPalette = resolved.DarkPalette
97+ return base
98+}
A internal/application/dto/webui_config_test.go +46 -0
@@ -0,0 +1,46 @@
1+package dto
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+
8+ "github.com/bnema/dumber/internal/domain/entity"
9+)
10+
11+func TestWebUIAppearanceWithResolvedThemePreservesColorSchemeAndUsesResolvedValues(t *testing.T) {
12+ base := WebUIAppearanceConfig{
13+ ColorScheme: "prefer-dark",
14+ ExternalTheme: WebUIExternalThemeConfig{
15+ Enabled: true,
16+ Provider: "noctalia",
17+ Format: "dumber-json",
18+ Path: "/tmp/theme.json",
19+ },
20+ }
21+ resolved := entity.ResolvedTheme{
22+ Fonts: entity.ThemeFonts{
23+ SansFont: "Inter",
24+ SerifFont: "Georgia",
25+ MonospaceFont: "JetBrains Mono",
26+ GtkFont: "Adwaita Sans",
27+ DefaultSize: 18,
28+ },
29+ LightPalette: entity.ColorPalette{Background: "#ffffff", Accent: "#0055ff"},
30+ DarkPalette: entity.ColorPalette{Background: "#111111", Accent: "#66aaff"},
31+ }
32+
33+ got := WebUIAppearanceWithResolvedTheme(base, resolved)
34+
35+ require.Equal(t, "prefer-dark", got.ColorScheme)
36+ require.Equal(t, base.ExternalTheme, got.ExternalTheme)
37+ require.Equal(t, "Inter", got.SansFont)
38+ require.Equal(t, "Georgia", got.SerifFont)
39+ require.Equal(t, "JetBrains Mono", got.MonospaceFont)
40+ require.Equal(t, "Adwaita Sans", got.GtkFont)
41+ require.Equal(t, 18, got.DefaultFontSize)
42+ require.Equal(t, "#ffffff", got.LightPalette.Background)
43+ require.Equal(t, "#0055ff", got.LightPalette.Accent)
44+ require.Equal(t, "#111111", got.DarkPalette.Background)
45+ require.Equal(t, "#66aaff", got.DarkPalette.Accent)
46+}
A internal/application/port/external_theme.go +41 -0
@@ -0,0 +1,41 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
8+
9+// ExternalThemeSource provides access to an external theme provider (e.g., Noctalia).
10+// It must not import GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config.
11+type ExternalThemeSource interface {
12+ // Get returns the current external theme, or nil if no external theme is available.
13+ // An error indicates a transient I/O problem (e.g., connection lost).
14+ // A nil theme with nil error means the source is enabled but has no theme data yet.
15+ Get(ctx context.Context) (*entity.ExternalTheme, error)
16+
17+ // IsEnabled returns true if the external theme source is enabled.
18+ // When disabled, the usecase should fall back to config/default palettes.
19+ IsEnabled() bool
20+}
21+
22+// ExternalThemeIdentityProvider exposes a stable source identity for last-good cache invalidation.
23+// Implementations should change this value when the configured provider/format/path changes.
24+type ExternalThemeIdentityProvider interface {
25+ ExternalThemeIdentity() string
26+}
27+
28+// ConfigurableExternalThemeSource is an external source whose settings can be
29+// updated from a new config snapshot without replacing the ResolveThemeUseCase.
30+type ConfigurableExternalThemeSource interface {
31+ ExternalThemeSource
32+ ExternalThemeIdentityProvider
33+ Configure(entity.ExternalThemeConfig)
34+}
35+
36+// ExternalThemeWatcher watches an external theme source and notifies callers
37+// when they should refresh the resolved theme through the shared apply path.
38+type ExternalThemeWatcher interface {
39+ Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error
40+ Stop() error
41+}
A internal/application/port/external_theme_test.go +51 -0
@@ -0,0 +1,51 @@
1+package port
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// compileCheckExternalThemeSource ensures the ExternalThemeSource interface
11+// is defined and can be implemented by test mocks.
12+func TestCompileCheckExternalThemeSource(_ *testing.T) {
13+ var _ ExternalThemeSource = (*mockExternalThemeSource)(nil)
14+ var _ ConfigurableExternalThemeSource = (*mockExternalThemeSource)(nil)
15+ var _ ExternalThemeWatcher = (*mockExternalThemeWatcher)(nil)
16+}
17+
18+// mockExternalThemeSource is a test-only implementation of ExternalThemeSource.
19+type mockExternalThemeSource struct {
20+ theme *entity.ExternalTheme
21+ err error
22+ enabled bool
23+ identity string
24+}
25+
26+func (m *mockExternalThemeSource) Get(_ context.Context) (*entity.ExternalTheme, error) {
27+ return m.theme, m.err
28+}
29+
30+func (m *mockExternalThemeSource) IsEnabled() bool {
31+ return m.enabled
32+}
33+
34+func (m *mockExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) {
35+ m.enabled = cfg.Enabled
36+ m.identity = cfg.Provider + "|" + cfg.Format + "|" + cfg.Path
37+}
38+
39+func (m *mockExternalThemeSource) ExternalThemeIdentity() string {
40+ return m.identity
41+}
42+
43+type mockExternalThemeWatcher struct{}
44+
45+func (*mockExternalThemeWatcher) Start(context.Context, entity.ExternalThemeConfig, func()) error {
46+ return nil
47+}
48+
49+func (*mockExternalThemeWatcher) Stop() error {
50+ return nil
51+}
M internal/application/usecase/read_systemview_config.go +88 -4
@@ -6,19 +6,53 @@ import (
6 6
7 "github.com/bnema/dumber/internal/application/dto"7 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
9 )10 )
10 11
11 // ReadSystemviewConfigUseCase exposes read-side systemview config payloads.12 // ReadSystemviewConfigUseCase exposes read-side systemview config payloads.
12 type ReadSystemviewConfigUseCase struct {13 type ReadSystemviewConfigUseCase struct {
13- reader port.SystemviewConfigReader14+ reader port.SystemviewConfigReader
15+ resolvedAppearance *systemviewResolvedAppearanceResolver
16+}
17+
18+type systemviewResolvedAppearanceResolver struct {
19+ externalTheme port.ExternalThemeSource
20+ colorResolver port.ColorSchemeResolver
21+ prefersDark func() bool
22+}
23+
24+// ReadSystemviewConfigOption customizes ReadSystemviewConfigUseCase behavior.
25+type ReadSystemviewConfigOption func(*ReadSystemviewConfigUseCase)
26+
27+// WithSystemviewResolvedAppearance overlays the current config payload with a display-only
28+// resolved appearance snapshot. It uses an isolated theme resolver per read so opening
29+// dumb://config cannot mutate the live browser theme resolver's last-good cache.
30+func WithSystemviewResolvedAppearance(
31+ externalTheme port.ExternalThemeSource,
32+ colorResolver port.ColorSchemeResolver,
33+ prefersDark func() bool,
34+) ReadSystemviewConfigOption {
35+ return func(uc *ReadSystemviewConfigUseCase) {
36+ uc.resolvedAppearance = &systemviewResolvedAppearanceResolver{
37+ externalTheme: externalTheme,
38+ colorResolver: colorResolver,
39+ prefersDark: prefersDark,
40+ }
41+ }
14 }42 }
15 43
16 // NewReadSystemviewConfigUseCase creates a new read systemview config use case.44 // NewReadSystemviewConfigUseCase creates a new read systemview config use case.
17-func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader) *ReadSystemviewConfigUseCase {45+func NewReadSystemviewConfigUseCase(reader port.SystemviewConfigReader, opts ...ReadSystemviewConfigOption) *ReadSystemviewConfigUseCase {
18 if reader == nil {46 if reader == nil {
19 panic("NewReadSystemviewConfigUseCase: reader is nil")47 panic("NewReadSystemviewConfigUseCase: reader is nil")
20 }48 }
21- return &ReadSystemviewConfigUseCase{reader: reader}49+ uc := &ReadSystemviewConfigUseCase{reader: reader}
50+ for _, opt := range opts {
51+ if opt != nil {
52+ opt(uc)
53+ }
54+ }
55+ return uc
22 }56 }
23 57
24 // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.58 // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
@@ -29,7 +63,11 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.Systemv
29 if uc == nil || uc.reader == nil {63 if uc == nil || uc.reader == nil {
30 return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader64 return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
31 }65 }
32- return uc.reader.Current(ctx)66+ payload, err := uc.reader.Current(ctx)
67+ if err != nil {
68+ return dto.SystemviewConfigPayload{}, err
69+ }
70+ return uc.withResolvedAppearance(ctx, payload), nil
33 }71 }
34 72
35 // Default returns the default systemview config payload.73 // Default returns the default systemview config payload.
@@ -39,3 +77,49 @@ func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.Systemv
39 }77 }
40 return uc.reader.Default(ctx)78 return uc.reader.Default(ctx)
41 }79 }
80+
81+func (uc *ReadSystemviewConfigUseCase) withResolvedAppearance(
82+ ctx context.Context,
83+ payload dto.SystemviewConfigPayload,
84+) dto.SystemviewConfigPayload {
85+ if uc == nil || uc.resolvedAppearance == nil {
86+ return payload
87+ }
88+ resolved, ok := uc.resolvedAppearance.resolve(ctx, payload)
89+ if !ok {
90+ return payload
91+ }
92+ appearance := dto.WebUIAppearanceWithResolvedTheme(payload.Appearance, resolved)
93+ payload.ResolvedAppearance = &appearance
94+ return payload
95+}
96+
97+func (r *systemviewResolvedAppearanceResolver) resolve(
98+ ctx context.Context,
99+ payload dto.SystemviewConfigPayload,
100+) (entity.ResolvedTheme, bool) {
101+ if r == nil {
102+ return entity.ResolvedTheme{}, false
103+ }
104+ resolver := NewResolveThemeUseCase(r.externalTheme)
105+ out, err := resolver.Execute(ctx, ResolveThemeInputFromConfig(
106+ &payload.Appearance,
107+ payload.DefaultUIScale,
108+ nil,
109+ r.preference(payload.Appearance.ColorScheme),
110+ ))
111+ if err != nil {
112+ return entity.ResolvedTheme{}, false
113+ }
114+ return out.Theme, true
115+}
116+
117+func (r *systemviewResolvedAppearanceResolver) preference(colorScheme string) port.ColorSchemePreference {
118+ fallback := port.ColorSchemePreference{PrefersDark: true, Source: "systemviews"}
119+ if r.colorResolver != nil {
120+ fallback = r.colorResolver.Resolve()
121+ } else if r.prefersDark != nil {
122+ fallback.PrefersDark = r.prefersDark()
123+ }
124+ return ColorSchemePreferenceFromConfig(colorScheme, fallback)
125+}
A internal/application/usecase/read_systemview_config_theme_test.go +129 -0
@@ -0,0 +1,129 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+type systemviewConfigReaderStub struct {
14+ current dto.SystemviewConfigPayload
15+ def dto.SystemviewConfigPayload
16+}
17+
18+func (s systemviewConfigReaderStub) Current(context.Context) (dto.SystemviewConfigPayload, error) {
19+ return s.current, nil
20+}
21+
22+func (s systemviewConfigReaderStub) Default(context.Context) (dto.SystemviewConfigPayload, error) {
23+ return s.def, nil
24+}
25+
26+type systemviewExternalThemeSource struct {
27+ theme *entity.ExternalTheme
28+ enabled bool
29+}
30+
31+func (s systemviewExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) {
32+ return s.theme, nil
33+}
34+
35+func (s systemviewExternalThemeSource) IsEnabled() bool {
36+ return s.enabled
37+}
38+
39+func TestReadSystemviewConfigUseCase_CurrentResolvedAppearanceUsesPayloadSnapshot(t *testing.T) {
40+ payload := systemviewThemePayload("prefer-light")
41+ uc := NewReadSystemviewConfigUseCase(
42+ systemviewConfigReaderStub{current: payload},
43+ WithSystemviewResolvedAppearance(nil, nil, func() bool { return true }),
44+ )
45+
46+ got, err := uc.Current(context.Background())
47+ require.NoError(t, err)
48+
49+ require.NotNil(t, got.ResolvedAppearance)
50+ require.NotEmpty(t, got.ResolvedAppearance.ColorScheme, "resolved appearance should preserve color scheme metadata")
51+ require.Equal(t, "prefer-light", got.ResolvedAppearance.ColorScheme)
52+ require.Equal(t, "#ffffff", got.ResolvedAppearance.LightPalette.Background)
53+ require.Equal(t, "Inter", got.ResolvedAppearance.SansFont)
54+ require.Equal(t, 18, got.ResolvedAppearance.DefaultFontSize)
55+}
56+
57+func TestReadSystemviewConfigUseCase_CurrentResolvedAppearanceKeepsEditableAppearanceUnchanged(t *testing.T) {
58+ payload := systemviewThemePayload("prefer-dark")
59+ external := &entity.ExternalTheme{
60+ Name: "Noctalia",
61+ Provider: "noctalia",
62+ LightPalette: &entity.ColorPalette{
63+ Background: "#eeeeee",
64+ Accent: "#112233",
65+ },
66+ DarkPalette: &entity.ColorPalette{
67+ Background: "#000000",
68+ Accent: "#445566",
69+ },
70+ }
71+ uc := NewReadSystemviewConfigUseCase(
72+ systemviewConfigReaderStub{current: payload},
73+ WithSystemviewResolvedAppearance(systemviewExternalThemeSource{theme: external, enabled: true}, nil, nil),
74+ )
75+
76+ got, err := uc.Current(context.Background())
77+ require.NoError(t, err)
78+
79+ require.Equal(t, "#111111", got.Appearance.DarkPalette.Background)
80+ require.Equal(t, "#66aaff", got.Appearance.DarkPalette.Accent)
81+ require.NotNil(t, got.ResolvedAppearance)
82+ require.Equal(t, "#000000", got.ResolvedAppearance.DarkPalette.Background)
83+ require.Equal(t, "#445566", got.ResolvedAppearance.DarkPalette.Accent)
84+ require.Equal(t, got.Appearance.ExternalTheme, got.ResolvedAppearance.ExternalTheme)
85+}
86+
87+func TestReadSystemviewConfigUseCase_DefaultDoesNotOverlayResolvedAppearance(t *testing.T) {
88+ payload := systemviewThemePayload("prefer-dark")
89+ uc := NewReadSystemviewConfigUseCase(
90+ systemviewConfigReaderStub{def: payload},
91+ WithSystemviewResolvedAppearance(systemviewExternalThemeSource{theme: validExternalTheme(), enabled: true}, nil, nil),
92+ )
93+
94+ got, err := uc.Default(context.Background())
95+ require.NoError(t, err)
96+ require.Nil(t, got.ResolvedAppearance)
97+}
98+
99+func systemviewThemePayload(colorScheme string) dto.SystemviewConfigPayload {
100+ return dto.SystemviewConfigPayload{
101+ DefaultUIScale: 1.25,
102+ Appearance: dto.WebUIAppearanceConfig{
103+ ColorScheme: colorScheme,
104+ SansFont: "Inter",
105+ SerifFont: "Georgia",
106+ MonospaceFont: "JetBrains Mono",
107+ GtkFont: "Adwaita Sans",
108+ DefaultFontSize: 18,
109+ LightPalette: dto.ColorPalette{
110+ Background: "#ffffff",
111+ Surface: "#f8f8f8",
112+ SurfaceVariant: "#eeeeee",
113+ Text: "#111111",
114+ Muted: "#666666",
115+ Accent: "#0055ff",
116+ Border: "#dddddd",
117+ },
118+ DarkPalette: dto.ColorPalette{
119+ Background: "#111111",
120+ Surface: "#1a1a1a",
121+ SurfaceVariant: "#2a2a2a",
122+ Text: "#f5f5f5",
123+ Muted: "#a0a0a0",
124+ Accent: "#66aaff",
125+ Border: "#333333",
126+ },
127+ },
128+ }
129+}
A internal/application/usecase/resolve_theme.go +436 -0
@@ -0,0 +1,436 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+// ResolveThemeInput contains the inputs needed to resolve the active theme.
13+// It accepts config-level overrides and delegates to external sources via port.
14+type ResolveThemeInput struct {
15+ // ColorScheme is the user-configured color scheme preference.
16+ // Values: "default", "prefer-dark", "prefer-light", "dark", "light".
17+ // Phase 2 expects callers to resolve this into PrefersDark before invoking the usecase.
18+ ColorScheme string
19+
20+ // PrefersDark is the detected system color scheme preference.
21+ PrefersDark bool
22+
23+ // ColorSchemeSource identifies which detector provided the color scheme preference.
24+ // Examples: "config", "adwaita", "gsettings", "env", "fallback".
25+ ColorSchemeSource string
26+
27+ // LightPalette from user config (may be empty/nil).
28+ LightPalette *entity.ColorPalette
29+
30+ // DarkPalette from user config (may be empty/nil).
31+ DarkPalette *entity.ColorPalette
32+
33+ // Fonts from user config (may be nil for defaults).
34+ Fonts *entity.ThemeFonts
35+
36+ // UIScale from GTK/display settings.
37+ UIScale float64
38+
39+ // ModeColors from user config (may be nil for defaults).
40+ ModeColors *entity.ThemeModeColors
41+}
42+
43+// ResolveThemeOutput is the result of theme resolution.
44+type ResolveThemeOutput struct {
45+ // Theme is the resolved theme.
46+ Theme entity.ResolvedTheme
47+}
48+
49+// ResolveThemeUseCase resolves the active theme by merging config defaults,
50+// user configuration, and an optional external theme source.
51+//
52+// Resolution precedence (locked contract):
53+// - External disabled -> config/default palettes
54+// - Valid external -> external palettes override palette colors only
55+// - Malformed/missing external at startup -> config/default palettes + warning
56+// - Malformed after valid external -> keep last-good external + warning/source metadata
57+// - Disabling external after valid -> clear last-good and use config/defaults
58+//
59+// CSS generation is NOT part of this usecase.
60+// This usecase must NOT import GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config.
61+type ResolveThemeUseCase struct {
62+ external port.ExternalThemeSource
63+ mu sync.Mutex
64+
65+ // lastGoodExternal holds the most recent fully-resolved valid external palette.
66+ // Used for the "malformed after valid" -> keep last-good contract.
67+ lastGoodExternal *entity.ExternalTheme
68+
69+ // lastExternalIdentity tracks the configured external source identity that produced lastGoodExternal.
70+ // If the enabled provider/format/path changes, last-good is invalidated before resolving.
71+ lastExternalIdentity string
72+}
73+
74+// NewResolveThemeUseCase creates a new ResolveThemeUseCase.
75+// external may be nil if no external theme source is available.
76+func NewResolveThemeUseCase(external port.ExternalThemeSource) *ResolveThemeUseCase {
77+ return &ResolveThemeUseCase{external: external}
78+}
79+
80+// ResolveThemeInputFromConfig maps domain config shapes plus a detected color-scheme
81+// preference into the application usecase input. It performs no precedence/fallback
82+// resolution; Execute/Refresh owns that business logic.
83+func ResolveThemeInputFromConfig(
84+ appearance *entity.AppearanceConfig,
85+ uiScale float64,
86+ styling *entity.WorkspaceStylingConfig,
87+ preference port.ColorSchemePreference,
88+) ResolveThemeInput {
89+ input := ResolveThemeInput{
90+ PrefersDark: preference.PrefersDark,
91+ ColorSchemeSource: preference.Source,
92+ UIScale: uiScale,
93+ }
94+
95+ if appearance != nil {
96+ lightPalette := appearance.LightPalette
97+ darkPalette := appearance.DarkPalette
98+ fonts := entity.ThemeFonts{
99+ SansFont: appearance.SansFont,
100+ SerifFont: appearance.SerifFont,
101+ MonospaceFont: appearance.MonospaceFont,
102+ GtkFont: appearance.GtkFont,
103+ DefaultSize: appearance.DefaultFontSize,
104+ }
105+ input.ColorScheme = appearance.ColorScheme
106+ input.LightPalette = &lightPalette
107+ input.DarkPalette = &darkPalette
108+ input.Fonts = &fonts
109+ }
110+
111+ if styling != nil {
112+ modeColors := entity.ThemeModeColors{
113+ PaneMode: styling.PaneModeColor,
114+ TabMode: styling.TabModeColor,
115+ SessionMode: styling.SessionModeColor,
116+ ResizeMode: styling.ResizeModeColor,
117+ }
118+ input.ModeColors = &modeColors
119+ }
120+
121+ return input
122+}
123+
124+// ColorSchemePreferenceFromConfig converts explicit config color-scheme values into
125+// a resolver preference, falling back when config asks for the default/system value.
126+func ColorSchemePreferenceFromConfig(colorScheme string, fallback port.ColorSchemePreference) port.ColorSchemePreference {
127+ switch strings.ToLower(strings.TrimSpace(colorScheme)) {
128+ case "prefer-dark", "dark":
129+ return port.ColorSchemePreference{PrefersDark: true, Source: "config"}
130+ case "prefer-light", "light":
131+ return port.ColorSchemePreference{PrefersDark: false, Source: "config"}
132+ default:
133+ return fallback
134+ }
135+}
136+
137+// Refresh resolves the active theme for config reloads and future external-theme reloads.
138+func (uc *ResolveThemeUseCase) Refresh(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
139+ return uc.Execute(ctx, input)
140+}
141+
142+// Execute resolves the active theme.
143+func (uc *ResolveThemeUseCase) Execute(ctx context.Context, input ResolveThemeInput) (ResolveThemeOutput, error) {
144+ uc.mu.Lock()
145+ defer uc.mu.Unlock()
146+
147+ base := uc.resolveBase(input)
148+ lightPalette := base.lightPalette
149+ darkPalette := base.darkPalette
150+ warnings := base.warnings
151+ themeSource := base.themeSource
152+
153+ if uc.external != nil && uc.external.IsEnabled() {
154+ uc.syncExternalIdentity(externalThemeIdentity(uc.external))
155+ ext, err := uc.external.Get(ctx)
156+ if err != nil {
157+ lightPalette, darkPalette, themeSource, warnings = uc.handleExternalError(err, lightPalette, darkPalette, themeSource, warnings)
158+ } else if ext != nil {
159+ lightPalette, darkPalette, themeSource, warnings = uc.applyExternalTheme(ext, lightPalette, darkPalette, themeSource, warnings)
160+ } else {
161+ lightPalette, darkPalette, themeSource, warnings = uc.handleMissingExternalTheme(lightPalette, darkPalette, themeSource, warnings)
162+ }
163+ } else {
164+ uc.clearLastGoodExternal()
165+ }
166+
167+ return ResolveThemeOutput{
168+ Theme: buildResolvedTheme(input, lightPalette, darkPalette, base.fonts, base.modeColors, base.uiScale, themeSource, warnings),
169+ }, nil
170+}
171+
172+type resolvedThemeBase struct {
173+ lightPalette entity.ColorPalette
174+ darkPalette entity.ColorPalette
175+ fonts entity.ThemeFonts
176+ modeColors entity.ThemeModeColors
177+ uiScale float64
178+ themeSource entity.ThemeSourceMetadata
179+ warnings []entity.ThemeWarning
180+}
181+
182+func (*ResolveThemeUseCase) resolveBase(input ResolveThemeInput) resolvedThemeBase {
183+ lightDefault := entity.DefaultLightPalette()
184+ darkDefault := entity.DefaultDarkPalette()
185+ lightPalette := entity.MergePalette(input.LightPalette, &lightDefault)
186+ darkPalette := entity.MergePalette(input.DarkPalette, &darkDefault)
187+
188+ fonts := entity.MergeFonts(input.Fonts, entity.DefaultThemeFonts())
189+ modeColors := entity.MergeModeColors(input.ModeColors, entity.DefaultThemeModeColors())
190+ uiScale := input.UIScale
191+ if uiScale <= 0 {
192+ uiScale = 1.0
193+ }
194+
195+ themeSourceKind := entity.ThemeSourceConfig
196+ if input.LightPalette == nil && input.DarkPalette == nil {
197+ themeSourceKind = entity.ThemeSourceDefault
198+ }
199+
200+ return resolvedThemeBase{
201+ lightPalette: lightPalette,
202+ darkPalette: darkPalette,
203+ fonts: fonts,
204+ modeColors: modeColors,
205+ uiScale: uiScale,
206+ themeSource: entity.ThemeSourceMetadata{
207+ Kind: themeSourceKind,
208+ },
209+ }
210+}
211+
212+func (uc *ResolveThemeUseCase) clearLastGoodExternal() {
213+ uc.lastGoodExternal = nil
214+ uc.lastExternalIdentity = ""
215+}
216+
217+func (uc *ResolveThemeUseCase) syncExternalIdentity(identity string) {
218+ if identity == "" {
219+ return
220+ }
221+ if uc.lastExternalIdentity != "" && uc.lastExternalIdentity != identity {
222+ uc.lastGoodExternal = nil
223+ }
224+ uc.lastExternalIdentity = identity
225+}
226+
227+func externalThemeIdentity(source port.ExternalThemeSource) string {
228+ identityProvider, ok := source.(port.ExternalThemeIdentityProvider)
229+ if !ok {
230+ return ""
231+ }
232+ return identityProvider.ExternalThemeIdentity()
233+}
234+
235+func (uc *ResolveThemeUseCase) handleMissingExternalTheme(
236+ lightPalette, darkPalette entity.ColorPalette,
237+ themeSource entity.ThemeSourceMetadata,
238+ warnings []entity.ThemeWarning,
239+) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
240+ if uc.lastGoodExternal != nil {
241+ warnings = append(warnings, entity.ThemeWarning{
242+ Field: "external",
243+ Message: "external theme is missing, using last-good",
244+ })
245+ return uc.lastGoodPalettes(themeSource, warnings)
246+ }
247+
248+ warnings = append(warnings, entity.ThemeWarning{
249+ Field: "external",
250+ Message: "external theme is missing, using config/defaults",
251+ })
252+ return lightPalette, darkPalette, themeSource, warnings
253+}
254+
255+func (uc *ResolveThemeUseCase) handleExternalError(
256+ err error,
257+ lightPalette, darkPalette entity.ColorPalette,
258+ themeSource entity.ThemeSourceMetadata,
259+ warnings []entity.ThemeWarning,
260+) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
261+ if uc.lastGoodExternal != nil {
262+ warnings = append(warnings, entity.ThemeWarning{
263+ Field: "external",
264+ Message: "failed to fetch external theme: " + err.Error() + ", using last-good",
265+ })
266+ return uc.lastGoodPalettes(themeSource, warnings)
267+ }
268+
269+ warnings = append(warnings, entity.ThemeWarning{
270+ Field: "external",
271+ Message: "failed to fetch external theme: " + err.Error(),
272+ })
273+ return lightPalette, darkPalette, themeSource, warnings
274+}
275+
276+func (uc *ResolveThemeUseCase) applyExternalTheme(
277+ ext *entity.ExternalTheme,
278+ lightPalette, darkPalette entity.ColorPalette,
279+ themeSource entity.ThemeSourceMetadata,
280+ warnings []entity.ThemeWarning,
281+) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
282+ validationWarnings := externalThemeWarnings(ext)
283+ if len(validationWarnings) > 0 {
284+ if uc.lastGoodExternal != nil {
285+ warnings = append(warnings, entity.ThemeWarning{
286+ Field: "external",
287+ Message: "malformed external theme update, keeping last-good",
288+ })
289+ warnings = append(warnings, validationWarnings...)
290+ return uc.lastGoodPalettes(themeSource, warnings)
291+ }
292+
293+ warnings = append(warnings, entity.ThemeWarning{
294+ Field: "external",
295+ Message: "malformed external theme, using config/defaults",
296+ })
297+ warnings = append(warnings, validationWarnings...)
298+ return lightPalette, darkPalette, themeSource, warnings
299+ }
300+
301+ // External palettes are partial overrides. Empty fields fall back through a
302+ // repaired config/default palette so one invalid config value cannot discard
303+ // otherwise valid external overrides or poison the last-good cache.
304+ lightPalette, warnings = paletteWithValidFallbackFields(lightPalette, entity.DefaultLightPalette(), "light_palette", warnings)
305+ darkPalette, warnings = paletteWithValidFallbackFields(darkPalette, entity.DefaultDarkPalette(), "dark_palette", warnings)
306+ resolvedLight := entity.MergePalette(ext.LightPalette, &lightPalette)
307+ resolvedDark := entity.MergePalette(ext.DarkPalette, &darkPalette)
308+
309+ provider := ext.Provider
310+ lightCopy := resolvedLight
311+ darkCopy := resolvedDark
312+ uc.lastGoodExternal = &entity.ExternalTheme{
313+ Name: ext.Name,
314+ Provider: provider,
315+ LightPalette: &lightCopy,
316+ DarkPalette: &darkCopy,
317+ }
318+
319+ return resolvedLight, resolvedDark, entity.ThemeSourceMetadata{
320+ Kind: entity.ThemeSourceExternal,
321+ Provider: provider,
322+ LastGood: false,
323+ }, warnings
324+}
325+
326+func externalThemeWarnings(ext *entity.ExternalTheme) []entity.ThemeWarning {
327+ if ext == nil {
328+ return []entity.ThemeWarning{{Field: "external", Message: "theme is nil"}}
329+ }
330+ var warnings []entity.ThemeWarning
331+ warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.LightPalette, "external.light_palette")...)
332+ warnings = append(warnings, entity.ValidatePaletteOverrideHex(ext.DarkPalette, "external.dark_palette")...)
333+ return warnings
334+}
335+
336+func paletteWithValidFallbackFields(
337+ palette entity.ColorPalette,
338+ fallback entity.ColorPalette,
339+ fieldPrefix string,
340+ warnings []entity.ThemeWarning,
341+) (entity.ColorPalette, []entity.ThemeWarning) {
342+ if entity.IsPaletteValid(&palette) {
343+ return palette, warnings
344+ }
345+ warnings = append(warnings, entity.ThemeWarning{
346+ Field: fieldPrefix,
347+ Message: "resolved config palette contained invalid colors, using built-in defaults for invalid fields",
348+ })
349+ if !entity.IsValidHex(palette.Background) {
350+ palette.Background = fallback.Background
351+ }
352+ if !entity.IsValidHex(palette.Surface) {
353+ palette.Surface = fallback.Surface
354+ }
355+ if !entity.IsValidHex(palette.SurfaceVariant) {
356+ palette.SurfaceVariant = fallback.SurfaceVariant
357+ }
358+ if !entity.IsValidHex(palette.Text) {
359+ palette.Text = fallback.Text
360+ }
361+ if !entity.IsValidHex(palette.Muted) {
362+ palette.Muted = fallback.Muted
363+ }
364+ if !entity.IsValidHex(palette.Accent) {
365+ palette.Accent = fallback.Accent
366+ }
367+ if !entity.IsValidHex(palette.Border) {
368+ palette.Border = fallback.Border
369+ }
370+ return palette, warnings
371+}
372+
373+func (uc *ResolveThemeUseCase) lastGoodPalettes(
374+ fallback entity.ThemeSourceMetadata,
375+ warnings []entity.ThemeWarning,
376+) (entity.ColorPalette, entity.ColorPalette, entity.ThemeSourceMetadata, []entity.ThemeWarning) {
377+ if uc.lastGoodExternal == nil || uc.lastGoodExternal.LightPalette == nil || uc.lastGoodExternal.DarkPalette == nil {
378+ return entity.DefaultLightPalette(), entity.DefaultDarkPalette(), fallback, warnings
379+ }
380+
381+ return *uc.lastGoodExternal.LightPalette, *uc.lastGoodExternal.DarkPalette, entity.ThemeSourceMetadata{
382+ Kind: entity.ThemeSourceExternal,
383+ Provider: uc.lastGoodExternal.Provider,
384+ LastGood: true,
385+ }, warnings
386+}
387+
388+func buildResolvedTheme(
389+ input ResolveThemeInput,
390+ lightPalette, darkPalette entity.ColorPalette,
391+ fonts entity.ThemeFonts,
392+ modeColors entity.ThemeModeColors,
393+ uiScale float64,
394+ themeSource entity.ThemeSourceMetadata,
395+ warnings []entity.ThemeWarning,
396+) entity.ResolvedTheme {
397+ if !entity.IsPaletteValid(&lightPalette) {
398+ warnings = append(warnings, entity.ThemeWarning{
399+ Field: "light_palette",
400+ Message: "resolved light palette was invalid, using built-in default",
401+ })
402+ lightPalette = entity.DefaultLightPalette()
403+ }
404+ if !entity.IsPaletteValid(&darkPalette) {
405+ warnings = append(warnings, entity.ThemeWarning{
406+ Field: "dark_palette",
407+ Message: "resolved dark palette was invalid, using built-in default",
408+ })
409+ darkPalette = entity.DefaultDarkPalette()
410+ }
411+ if !entity.IsModeColorsValid(&modeColors) {
412+ warnings = append(warnings, entity.ThemeWarning{
413+ Field: "mode_colors",
414+ Message: "resolved mode colors were invalid, using built-in defaults",
415+ })
416+ modeColors = entity.DefaultThemeModeColors()
417+ }
418+
419+ activePalette := lightPalette
420+ if input.PrefersDark {
421+ activePalette = darkPalette
422+ }
423+
424+ return entity.ResolvedTheme{
425+ LightPalette: lightPalette,
426+ DarkPalette: darkPalette,
427+ ActivePalette: activePalette,
428+ PrefersDark: input.PrefersDark,
429+ ColorSchemeSource: input.ColorSchemeSource,
430+ ThemeSource: themeSource,
431+ Fonts: fonts,
432+ UIScale: uiScale,
433+ ModeColors: modeColors,
434+ Warnings: warnings,
435+ }
436+}
A internal/application/usecase/resolve_theme_test.go +1365 -0
@@ -0,0 +1,1365 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "strings"
8+ "testing"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
12+)
13+
14+// assertNoWarnings fails the test if there are any warnings.
15+func assertNoWarnings(t *testing.T, warnings []entity.ThemeWarning) {
16+ t.Helper()
17+ if len(warnings) > 0 {
18+ var msgs []string
19+ for _, w := range warnings {
20+ msgs = append(msgs, fmt.Sprintf("%s: %s", w.Field, w.Message))
21+ }
22+ t.Errorf("unexpected warnings: %s", strings.Join(msgs, "; "))
23+ }
24+}
25+
26+// assertValidPaletteHex checks every color field in a palette is valid CSS-safe hex.
27+func assertValidPaletteHex(t *testing.T, label string, p entity.ResolvedThemePalette) {
28+ t.Helper()
29+ fields := map[string]string{
30+ "Background": p.Background,
31+ "Surface": p.Surface,
32+ "SurfaceVariant": p.SurfaceVariant,
33+ "Text": p.Text,
34+ "Muted": p.Muted,
35+ "Accent": p.Accent,
36+ "Border": p.Border,
37+ }
38+ for name, value := range fields {
39+ if !entity.IsValidHex(value) {
40+ t.Errorf("%s.%s = %q is not a valid CSS-safe hex color", label, name, value)
41+ }
42+ }
43+}
44+
45+// newMockExternalSource creates a test double for the external theme source port.
46+type newMockExternalSource struct {
47+ theme *entity.ExternalTheme
48+ err error
49+ enabled bool
50+ identity string
51+}
52+
53+func (m *newMockExternalSource) Get(_ context.Context) (*entity.ExternalTheme, error) {
54+ return m.theme, m.err
55+}
56+
57+func (m *newMockExternalSource) IsEnabled() bool {
58+ return m.enabled
59+}
60+
61+func (m *newMockExternalSource) ExternalThemeIdentity() string {
62+ return m.identity
63+}
64+
65+// configLightPalette returns a recognizable light palette for config.
66+func configLightPalette() *entity.ColorPalette {
67+ return &entity.ColorPalette{
68+ Background: "#fafafa",
69+ Surface: "#ffffff",
70+ SurfaceVariant: "#f0f0f0",
71+ Text: "#1a1a1a",
72+ Muted: "#666666",
73+ Accent: "#22c55e",
74+ Border: "#dddddd",
75+ }
76+}
77+
78+// configDarkPalette returns a recognizable dark palette for config.
79+func configDarkPalette() *entity.ColorPalette {
80+ return &entity.ColorPalette{
81+ Background: "#0a0a0b",
82+ Surface: "#1a1a1b",
83+ SurfaceVariant: "#2d2d2d",
84+ Text: "#ffffff",
85+ Muted: "#909090",
86+ Accent: "#4ade80",
87+ Border: "#333333",
88+ }
89+}
90+
91+// externalLightPalette returns a different light palette for external testing.
92+func externalLightPalette() *entity.ColorPalette {
93+ return &entity.ColorPalette{
94+ Background: "#fff8e7",
95+ Surface: "#fffef5",
96+ SurfaceVariant: "#f5ecd7",
97+ Text: "#2d2a1e",
98+ Muted: "#8c876e",
99+ Accent: "#d4a017",
100+ Border: "#e0d8b0",
101+ }
102+}
103+
104+// externalDarkPalette returns a different dark palette for external testing.
105+func externalDarkPalette() *entity.ColorPalette {
106+ return &entity.ColorPalette{
107+ Background: "#1a1a2e",
108+ Surface: "#16213e",
109+ SurfaceVariant: "#0f3460",
110+ Text: "#e0e0e0",
111+ Muted: "#a0a0a0",
112+ Accent: "#e94560",
113+ Border: "#533483",
114+ }
115+}
116+
117+// validExternalTheme returns a fully valid ExternalTheme for testing.
118+func validExternalTheme() *entity.ExternalTheme {
119+ return &entity.ExternalTheme{
120+ Name: "Noctalia Test",
121+ Provider: "noctalia",
122+ LightPalette: externalLightPalette(),
123+ DarkPalette: externalDarkPalette(),
124+ }
125+}
126+
127+// ============================================================
128+// Precedence: External disabled → config/default palettes
129+// ============================================================
130+
131+// TestResolveTheme_ExternalDisabled_UsesConfigPalettes verifies that when the external
132+// source is disabled, config palettes (or defaults) are used.
133+func TestResolveTheme_ExternalDisabled_UsesConfigPalettes(t *testing.T) {
134+ uc := NewResolveThemeUseCase(nil)
135+
136+ light := configLightPalette()
137+ dark := configDarkPalette()
138+
139+ input := ResolveThemeInput{
140+ ColorScheme: "default",
141+ PrefersDark: true,
142+ ColorSchemeSource: "adwaita",
143+ LightPalette: light,
144+ DarkPalette: dark,
145+ UIScale: 1.0,
146+ }
147+
148+ output, err := uc.Execute(context.Background(), input)
149+ if err != nil {
150+ t.Fatalf("unexpected error: %v", err)
151+ }
152+
153+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
154+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
155+ assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
156+
157+ // With PrefersDark=true and no external, light palette should be config
158+ if output.Theme.LightPalette.Background != light.Background {
159+ t.Errorf("expected LightPalette.Background=%q, got %q", light.Background, output.Theme.LightPalette.Background)
160+ }
161+ // Active palette should be dark (PrefersDark=true)
162+ if output.Theme.ActivePalette.Background != dark.Background {
163+ t.Errorf("expected ActivePalette.Background=%q (dark), got %q", dark.Background, output.Theme.ActivePalette.Background)
164+ }
165+}
166+
167+// TestResolveTheme_NoConfig_UsesDefaults verifies that when neither config nor external
168+// provide palettes, sensible defaults are returned.
169+func TestResolveTheme_NoConfig_UsesDefaults(t *testing.T) {
170+ uc := NewResolveThemeUseCase(nil)
171+
172+ input := ResolveThemeInput{
173+ ColorScheme: "default",
174+ PrefersDark: true,
175+ ColorSchemeSource: "fallback",
176+ UIScale: 1.0,
177+ // LightPalette and DarkPalette are nil (no config)
178+ }
179+
180+ output, err := uc.Execute(context.Background(), input)
181+ if err != nil {
182+ t.Fatalf("unexpected error: %v", err)
183+ }
184+
185+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
186+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
187+ assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
188+
189+ // Defaults must be non-trivial (Background, Text non-empty)
190+ if output.Theme.DarkPalette.Background == "" {
191+ t.Error("expected non-empty DarkPalette.Background from defaults")
192+ }
193+ if output.Theme.DarkPalette.Text == "" {
194+ t.Error("expected non-empty DarkPalette.Text from defaults")
195+ }
196+ if output.Theme.LightPalette.Background == "" {
197+ t.Error("expected non-empty LightPalette.Background from defaults")
198+ }
199+}
200+
201+// ============================================================
202+// Precedence: Valid external → external palettes override palette colors only
203+// ============================================================
204+
205+// TestResolveTheme_ValidExternal_OverridesPaletteColors verifies that when a valid
206+// external theme is available, its palette colors override config palette colors,
207+// but other fields (fonts, mode colors, scale) come from config or defaults.
208+func TestResolveTheme_ValidExternal_OverridesPaletteColors(t *testing.T) {
209+ ext := validExternalTheme()
210+ mock := &newMockExternalSource{
211+ theme: ext,
212+ enabled: true,
213+ }
214+ uc := NewResolveThemeUseCase(mock)
215+
216+ input := ResolveThemeInput{
217+ ColorScheme: "default",
218+ PrefersDark: true,
219+ ColorSchemeSource: "adwaita",
220+ LightPalette: configLightPalette(),
221+ DarkPalette: configDarkPalette(),
222+ Fonts: &entity.ThemeFonts{
223+ SansFont: "config-sans",
224+ },
225+ UIScale: 1.5,
226+ }
227+
228+ output, err := uc.Execute(context.Background(), input)
229+ if err != nil {
230+ t.Fatalf("unexpected error: %v", err)
231+ }
232+
233+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
234+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
235+ assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
236+
237+ // External palette should override config palette
238+ if output.Theme.DarkPalette.Background != ext.DarkPalette.Background {
239+ t.Errorf("expected DarkPalette.Background from external (%q), got %q",
240+ ext.DarkPalette.Background, output.Theme.DarkPalette.Background)
241+ }
242+ if output.Theme.DarkPalette.Accent != ext.DarkPalette.Accent {
243+ t.Errorf("expected DarkPalette.Accent from external (%q), got %q",
244+ ext.DarkPalette.Accent, output.Theme.DarkPalette.Accent)
245+ }
246+
247+ // ThemeSource should indicate external
248+ if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal {
249+ t.Errorf("expected ThemeSource.Kind=external, got %q", output.Theme.ThemeSource.Kind)
250+ }
251+ if output.Theme.ThemeSource.Provider != "noctalia" {
252+ t.Errorf("expected ThemeSource.Provider=noctalia, got %q", output.Theme.ThemeSource.Provider)
253+ }
254+ if output.Theme.ThemeSource.LastGood {
255+ t.Error("expected ThemeSource.LastGood=false for fresh valid external theme")
256+ }
257+
258+ // ColorSchemeSource should still reflect the detector
259+ if output.Theme.ColorSchemeSource != "adwaita" {
260+ t.Errorf("expected ColorSchemeSource='adwaita', got %q", output.Theme.ColorSchemeSource)
261+ }
262+}
263+
264+// TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields verifies that
265+// external theme only overrides palette colors, not fonts, scale, or mode colors.
266+func TestResolveTheme_ValidExternal_DoesNotOverrideNonPaletteFields(t *testing.T) {
267+ ext := validExternalTheme()
268+ mock := &newMockExternalSource{
269+ theme: ext,
270+ enabled: true,
271+ }
272+ uc := NewResolveThemeUseCase(mock)
273+
274+ input := ResolveThemeInput{
275+ ColorScheme: "default",
276+ PrefersDark: false,
277+ ColorSchemeSource: "env",
278+ Fonts: &entity.ThemeFonts{
279+ SansFont: "MyConfigSans",
280+ SerifFont: "MyConfigSerif",
281+ MonospaceFont: "MyConfigMono",
282+ GtkFont: "MyConfigGtk",
283+ DefaultSize: 14,
284+ },
285+ UIScale: 2.0,
286+ ModeColors: &entity.ThemeModeColors{
287+ PaneMode: "#111111",
288+ TabMode: "#222222",
289+ SessionMode: "#333333",
290+ ResizeMode: "#444444",
291+ },
292+ }
293+
294+ output, err := uc.Execute(context.Background(), input)
295+ if err != nil {
296+ t.Fatalf("unexpected error: %v", err)
297+ }
298+
299+ // Fonts should come from config, not external
300+ if output.Theme.Fonts.SansFont != "MyConfigSans" {
301+ t.Errorf("expected SansFont from config (%q), got %q",
302+ "MyConfigSans", output.Theme.Fonts.SansFont)
303+ }
304+ if output.Theme.Fonts.DefaultSize != 14 {
305+ t.Errorf("expected DefaultFontSize from config (14), got %d",
306+ output.Theme.Fonts.DefaultSize)
307+ }
308+
309+ // UI scale should come from config
310+ if output.Theme.UIScale != 2.0 {
311+ t.Errorf("expected UIScale=2.0 from config, got %f", output.Theme.UIScale)
312+ }
313+
314+ // Mode colors should come from config
315+ if output.Theme.ModeColors.PaneMode != "#111111" {
316+ t.Errorf("expected PaneMode from config (#111111), got %q",
317+ output.Theme.ModeColors.PaneMode)
318+ }
319+}
320+
321+// TestResolveTheme_PartialExternal_MergesOverConfig verifies that empty external
322+// fields are treated as "inherit" rather than malformed.
323+func TestResolveTheme_PartialExternal_MergesOverConfig(t *testing.T) {
324+ partialLight := &entity.ColorPalette{
325+ Accent: "#123456",
326+ }
327+ partialDark := &entity.ColorPalette{
328+ Background: "#010203",
329+ Border: "#abcdef",
330+ }
331+ mock := &newMockExternalSource{
332+ theme: &entity.ExternalTheme{
333+ Name: "Partial Noctalia",
334+ Provider: "noctalia",
335+ LightPalette: partialLight,
336+ DarkPalette: partialDark,
337+ },
338+ enabled: true,
339+ }
340+ uc := NewResolveThemeUseCase(mock)
341+
342+ input := ResolveThemeInput{
343+ PrefersDark: true,
344+ ColorSchemeSource: "adwaita",
345+ LightPalette: configLightPalette(),
346+ DarkPalette: configDarkPalette(),
347+ UIScale: 1.0,
348+ }
349+
350+ output, err := uc.Execute(context.Background(), input)
351+ if err != nil {
352+ t.Fatalf("unexpected error: %v", err)
353+ }
354+
355+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
356+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
357+ assertNoWarnings(t, output.Theme.Warnings)
358+
359+ if output.Theme.LightPalette.Accent != "#123456" {
360+ t.Errorf("expected partial external light accent override, got %q", output.Theme.LightPalette.Accent)
361+ }
362+ if output.Theme.LightPalette.Background != configLightPalette().Background {
363+ t.Errorf("expected empty external light background to inherit config value %q, got %q",
364+ configLightPalette().Background, output.Theme.LightPalette.Background)
365+ }
366+ if output.Theme.DarkPalette.Background != "#010203" {
367+ t.Errorf("expected partial external dark background override, got %q", output.Theme.DarkPalette.Background)
368+ }
369+ if output.Theme.DarkPalette.Text != configDarkPalette().Text {
370+ t.Errorf("expected empty external dark text to inherit config value %q, got %q",
371+ configDarkPalette().Text, output.Theme.DarkPalette.Text)
372+ }
373+ if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.Provider != "noctalia" {
374+ t.Errorf("expected noctalia external source metadata, got %+v", output.Theme.ThemeSource)
375+ }
376+}
377+
378+// TestResolveTheme_PartialExternal_MergesOverDefaults verifies partial external
379+// palettes also merge over built-in defaults when config palettes are absent.
380+func TestResolveTheme_PartialExternal_MergesOverDefaults(t *testing.T) {
381+ mock := &newMockExternalSource{
382+ theme: &entity.ExternalTheme{
383+ Name: "Partial Defaults",
384+ LightPalette: &entity.ColorPalette{
385+ Accent: "#112233",
386+ },
387+ DarkPalette: &entity.ColorPalette{
388+ Accent: "#445566",
389+ },
390+ },
391+ enabled: true,
392+ }
393+ uc := NewResolveThemeUseCase(mock)
394+
395+ output, err := uc.Execute(context.Background(), ResolveThemeInput{PrefersDark: false})
396+ if err != nil {
397+ t.Fatalf("unexpected error: %v", err)
398+ }
399+
400+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
401+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
402+ if output.Theme.LightPalette.Accent != "#112233" {
403+ t.Errorf("expected external light accent, got %q", output.Theme.LightPalette.Accent)
404+ }
405+ if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background {
406+ t.Errorf("expected light background to inherit default %q, got %q",
407+ entity.DefaultLightPalette().Background, output.Theme.LightPalette.Background)
408+ }
409+ if output.Theme.DarkPalette.Accent != "#445566" {
410+ t.Errorf("expected external dark accent, got %q", output.Theme.DarkPalette.Accent)
411+ }
412+}
413+
414+func TestResolveTheme_PartialExternal_RepairsInvalidConfigFallbackFields(t *testing.T) {
415+ partialLight := &entity.ColorPalette{Accent: "#112233"}
416+ partialDark := &entity.ColorPalette{Background: "#445566"}
417+ mock := &newMockExternalSource{
418+ theme: &entity.ExternalTheme{
419+ Name: "Partial With Invalid Config",
420+ Provider: "noctalia",
421+ LightPalette: partialLight,
422+ DarkPalette: partialDark,
423+ },
424+ enabled: true,
425+ }
426+ uc := NewResolveThemeUseCase(mock)
427+ invalidLight := *configLightPalette()
428+ invalidLight.Background = "not-a-hex"
429+ invalidDark := *configDarkPalette()
430+ invalidDark.Text = "also-invalid"
431+
432+ output, err := uc.Execute(context.Background(), ResolveThemeInput{
433+ PrefersDark: true,
434+ LightPalette: &invalidLight,
435+ DarkPalette: &invalidDark,
436+ UIScale: 1.0,
437+ })
438+ if err != nil {
439+ t.Fatalf("unexpected error: %v", err)
440+ }
441+
442+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
443+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
444+ if output.Theme.LightPalette.Accent != "#112233" {
445+ t.Fatalf("expected valid external light accent to survive invalid config fallback, got %q", output.Theme.LightPalette.Accent)
446+ }
447+ if output.Theme.LightPalette.Background != entity.DefaultLightPalette().Background {
448+ t.Fatalf("expected invalid config light background to be repaired from defaults, got %q", output.Theme.LightPalette.Background)
449+ }
450+ if output.Theme.DarkPalette.Background != "#445566" {
451+ t.Fatalf("expected valid external dark background to survive invalid config fallback, got %q", output.Theme.DarkPalette.Background)
452+ }
453+ if output.Theme.DarkPalette.Text != entity.DefaultDarkPalette().Text {
454+ t.Fatalf("expected invalid config dark text to be repaired from defaults, got %q", output.Theme.DarkPalette.Text)
455+ }
456+ if output.Theme.ThemeSource.Kind != entity.ThemeSourceExternal || output.Theme.ThemeSource.LastGood {
457+ t.Fatalf("expected fresh external source metadata, got %+v", output.Theme.ThemeSource)
458+ }
459+}
460+
461+// ============================================================
462+// PrefersDark selects ActivePalette
463+// ============================================================
464+
465+// TestResolveTheme_PrefersDark_SelectsDarkPalette verifies that when PrefersDark=true,
466+// ActivePalette is the dark palette.
467+func TestResolveTheme_PrefersDark_SelectsDarkPalette(t *testing.T) {
468+ uc := NewResolveThemeUseCase(nil)
469+
470+ input := ResolveThemeInput{
471+ PrefersDark: true,
472+ ColorSchemeSource: "config",
473+ LightPalette: configLightPalette(),
474+ DarkPalette: configDarkPalette(),
475+ UIScale: 1.0,
476+ }
477+
478+ output, err := uc.Execute(context.Background(), input)
479+ if err != nil {
480+ t.Fatalf("unexpected error: %v", err)
481+ }
482+
483+ if output.Theme.ActivePalette.Background != configDarkPalette().Background {
484+ t.Errorf("expected ActivePalette to be dark palette, got Background=%q",
485+ output.Theme.ActivePalette.Background)
486+ }
487+ if !output.Theme.PrefersDark {
488+ t.Error("expected PrefersDark=true in output")
489+ }
490+}
491+
492+// TestResolveTheme_PrefersLight_SelectsLightPalette verifies that when PrefersDark=false,
493+// ActivePalette is the light palette.
494+func TestResolveTheme_PrefersLight_SelectsLightPalette(t *testing.T) {
495+ uc := NewResolveThemeUseCase(nil)
496+
497+ input := ResolveThemeInput{
498+ PrefersDark: false,
499+ ColorSchemeSource: "config",
500+ LightPalette: configLightPalette(),
501+ DarkPalette: configDarkPalette(),
502+ UIScale: 1.0,
503+ }
504+
505+ output, err := uc.Execute(context.Background(), input)
506+ if err != nil {
507+ t.Fatalf("unexpected error: %v", err)
508+ }
509+
510+ if output.Theme.ActivePalette.Background != configLightPalette().Background {
511+ t.Errorf("expected ActivePalette to be light palette, got Background=%q",
512+ output.Theme.ActivePalette.Background)
513+ }
514+ if output.Theme.PrefersDark {
515+ t.Error("expected PrefersDark=false in output")
516+ }
517+}
518+
519+// ============================================================
520+// Malformed external at startup → config/default palettes + warning
521+// ============================================================
522+
523+// TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig tests that when the
524+// external theme has malformed palette colors on first resolution, the usecase falls
525+// back to config/defaults and includes a warning.
526+func TestResolveTheme_MalformedExternalAtStartup_FallsBackToConfig(t *testing.T) {
527+ malformed := &entity.ExternalTheme{
528+ Name: "Malformed Theme",
529+ LightPalette: &entity.ColorPalette{
530+ Background: "rgb(0,0,0)", // not hex
531+ Text: "#ffffff",
532+ },
533+ DarkPalette: &entity.ColorPalette{
534+ Background: "#hijklm", // invalid hex
535+ Text: "#ffffff",
536+ },
537+ }
538+ mock := &newMockExternalSource{
539+ theme: malformed,
540+ enabled: true,
541+ }
542+ uc := NewResolveThemeUseCase(mock)
543+
544+ input := ResolveThemeInput{
545+ PrefersDark: true,
546+ ColorSchemeSource: "adwaita",
547+ LightPalette: configLightPalette(),
548+ DarkPalette: configDarkPalette(),
549+ UIScale: 1.0,
550+ }
551+
552+ output, err := uc.Execute(context.Background(), input)
553+ if err != nil {
554+ t.Fatalf("unexpected error: %v", err)
555+ }
556+
557+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
558+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
559+ assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
560+
561+ // Should fall back to config palette (not the malformed external)
562+ if output.Theme.DarkPalette.Background != configDarkPalette().Background {
563+ t.Errorf("expected fallback to config dark palette (%q), got %q",
564+ configDarkPalette().Background, output.Theme.DarkPalette.Background)
565+ }
566+
567+ // Should have at least one warning about the malformed external
568+ if len(output.Theme.Warnings) == 0 {
569+ t.Error("expected at least one warning for malformed external theme")
570+ }
571+}
572+
573+// TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning verifies that enabled
574+// external sources returning nil data are treated as missing, not silently ignored.
575+func TestResolveTheme_MissingExternalAtStartup_FallsBackWithWarning(t *testing.T) {
576+ mock := &newMockExternalSource{
577+ theme: nil,
578+ enabled: true,
579+ }
580+ uc := NewResolveThemeUseCase(mock)
581+
582+ input := ResolveThemeInput{
583+ PrefersDark: true,
584+ ColorSchemeSource: "adwaita",
585+ LightPalette: configLightPalette(),
586+ DarkPalette: configDarkPalette(),
587+ UIScale: 1.0,
588+ }
589+
590+ output, err := uc.Execute(context.Background(), input)
591+ if err != nil {
592+ t.Fatalf("unexpected error: %v", err)
593+ }
594+
595+ if output.Theme.DarkPalette.Background != configDarkPalette().Background {
596+ t.Errorf("expected missing external startup fallback to config dark palette %q, got %q",
597+ configDarkPalette().Background, output.Theme.DarkPalette.Background)
598+ }
599+ if len(output.Theme.Warnings) == 0 {
600+ t.Fatal("expected warning for missing external theme")
601+ }
602+ if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
603+ t.Errorf("expected non-external source for startup missing external, got %+v", output.Theme.ThemeSource)
604+ }
605+}
606+
607+func TestResolveTheme_ReadErrorAtStartup_FallsBackWithWarning(t *testing.T) {
608+ mock := &newMockExternalSource{
609+ err: errors.New("read failed"),
610+ enabled: true,
611+ identity: "noctalia|colors-json|/tmp/theme-a.json",
612+ }
613+ uc := NewResolveThemeUseCase(mock)
614+
615+ input := ResolveThemeInput{
616+ PrefersDark: true,
617+ ColorSchemeSource: "adwaita",
618+ LightPalette: configLightPalette(),
619+ DarkPalette: configDarkPalette(),
620+ UIScale: 1.0,
621+ }
622+
623+ output, err := uc.Execute(context.Background(), input)
624+ if err != nil {
625+ t.Fatalf("unexpected error: %v", err)
626+ }
627+ if output.Theme.DarkPalette.Background != configDarkPalette().Background {
628+ t.Errorf("expected read-error startup fallback to config dark palette %q, got %q",
629+ configDarkPalette().Background, output.Theme.DarkPalette.Background)
630+ }
631+ if output.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
632+ t.Errorf("expected non-external source for startup read error, got %+v", output.Theme.ThemeSource)
633+ }
634+ if len(output.Theme.Warnings) == 0 {
635+ t.Fatal("expected warning for external read error")
636+ }
637+}
638+
639+func TestResolveTheme_ReadErrorAfterValid_KeepsLastGoodForSameIdentity(t *testing.T) {
640+ ext := validExternalTheme()
641+ mock := &newMockExternalSource{
642+ theme: ext,
643+ enabled: true,
644+ identity: "noctalia|colors-json|/tmp/theme-a.json",
645+ }
646+ uc := NewResolveThemeUseCase(mock)
647+
648+ input := ResolveThemeInput{
649+ PrefersDark: true,
650+ ColorSchemeSource: "adwaita",
651+ LightPalette: configLightPalette(),
652+ DarkPalette: configDarkPalette(),
653+ UIScale: 1.0,
654+ }
655+
656+ first, err := uc.Execute(context.Background(), input)
657+ if err != nil {
658+ t.Fatalf("first resolution error: %v", err)
659+ }
660+ mock.theme = nil
661+ mock.err = errors.New("temporary read failure")
662+
663+ second, err := uc.Execute(context.Background(), input)
664+ if err != nil {
665+ t.Fatalf("second resolution error: %v", err)
666+ }
667+ if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background {
668+ t.Errorf("expected same-identity read error to keep last-good dark background %q, got %q",
669+ first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background)
670+ }
671+ if !second.Theme.ThemeSource.LastGood {
672+ t.Fatal("expected LastGood=true for same-identity read error after valid external")
673+ }
674+}
675+
676+func TestResolveTheme_ExternalIdentityChangeClearsLastGoodBeforeReadError(t *testing.T) {
677+ ext := validExternalTheme()
678+ mock := &newMockExternalSource{
679+ theme: ext,
680+ enabled: true,
681+ identity: "noctalia|colors-json|/tmp/theme-a.json",
682+ }
683+ uc := NewResolveThemeUseCase(mock)
684+
685+ input := ResolveThemeInput{
686+ PrefersDark: true,
687+ ColorSchemeSource: "adwaita",
688+ LightPalette: configLightPalette(),
689+ DarkPalette: configDarkPalette(),
690+ UIScale: 1.0,
691+ }
692+
693+ first, err := uc.Execute(context.Background(), input)
694+ if err != nil {
695+ t.Fatalf("first resolution error: %v", err)
696+ }
697+ if first.Theme.DarkPalette.Background == configDarkPalette().Background {
698+ t.Fatal("first resolution should use external palette, not config")
699+ }
700+
701+ mock.theme = nil
702+ mock.err = errors.New("new path missing")
703+ mock.identity = "noctalia|colors-json|/tmp/theme-b.json"
704+
705+ second, err := uc.Execute(context.Background(), input)
706+ if err != nil {
707+ t.Fatalf("second resolution error: %v", err)
708+ }
709+ if second.Theme.DarkPalette.Background != configDarkPalette().Background {
710+ t.Errorf("expected changed-identity read error to fall back to config dark background %q, got %q",
711+ configDarkPalette().Background, second.Theme.DarkPalette.Background)
712+ }
713+ if second.Theme.ThemeSource.LastGood {
714+ t.Fatal("expected LastGood=false after external identity changed")
715+ }
716+ if second.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
717+ t.Errorf("expected non-external source after changed-identity read error, got %+v", second.Theme.ThemeSource)
718+ }
719+}
720+
721+// TestResolveTheme_MissingExternalAfterValid_KeepsLastGood verifies missing nil data
722+// after a valid external theme keeps last-good with warning metadata.
723+func TestResolveTheme_MissingExternalAfterValid_KeepsLastGood(t *testing.T) {
724+ ext := validExternalTheme()
725+ mock := &newMockExternalSource{
726+ theme: ext,
727+ enabled: true,
728+ }
729+ uc := NewResolveThemeUseCase(mock)
730+
731+ input := ResolveThemeInput{
732+ PrefersDark: true,
733+ ColorSchemeSource: "adwaita",
734+ LightPalette: configLightPalette(),
735+ DarkPalette: configDarkPalette(),
736+ UIScale: 1.0,
737+ }
738+
739+ first, err := uc.Execute(context.Background(), input)
740+ if err != nil {
741+ t.Fatalf("first resolution error: %v", err)
742+ }
743+ mock.theme = nil
744+
745+ second, err := uc.Execute(context.Background(), input)
746+ if err != nil {
747+ t.Fatalf("second resolution error: %v", err)
748+ }
749+
750+ if second.Theme.DarkPalette.Background != first.Theme.DarkPalette.Background {
751+ t.Errorf("expected last-good dark background %q, got %q",
752+ first.Theme.DarkPalette.Background, second.Theme.DarkPalette.Background)
753+ }
754+ if !second.Theme.ThemeSource.LastGood {
755+ t.Error("expected LastGood=true when missing external follows valid external")
756+ }
757+ if second.Theme.ThemeSource.Provider != "noctalia" {
758+ t.Errorf("expected provider noctalia, got %q", second.Theme.ThemeSource.Provider)
759+ }
760+ if len(second.Theme.Warnings) == 0 {
761+ t.Fatal("expected warning for missing external after valid")
762+ }
763+}
764+
765+// TestResolveTheme_MalformedExternalMissingPalette_FallsBack verifies fallback
766+// when the external theme has nil palettes.
767+func TestResolveTheme_MalformedExternalMissingPalette_FallsBack(t *testing.T) {
768+ malformed := &entity.ExternalTheme{
769+ Name: "Missing Palettes",
770+ LightPalette: nil,
771+ DarkPalette: nil,
772+ }
773+ mock := &newMockExternalSource{
774+ theme: malformed,
775+ enabled: true,
776+ }
777+ uc := NewResolveThemeUseCase(mock)
778+
779+ input := ResolveThemeInput{
780+ PrefersDark: true,
781+ ColorSchemeSource: "adwaita",
782+ LightPalette: configLightPalette(),
783+ DarkPalette: configDarkPalette(),
784+ UIScale: 1.0,
785+ }
786+
787+ output, err := uc.Execute(context.Background(), input)
788+ if err != nil {
789+ t.Fatalf("unexpected error: %v", err)
790+ }
791+
792+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
793+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
794+
795+ // Should have warnings about missing palettes
796+ if len(output.Theme.Warnings) == 0 {
797+ t.Error("expected warnings for external theme with nil palettes")
798+ }
799+}
800+
801+// ============================================================
802+// Malformed after valid external → keep last-good external + warning
803+// ============================================================
804+
805+// TestResolveTheme_MalformedAfterValid_KeepsLastGood tests the "last-good" contract:
806+// when an external theme becomes malformed after previously being valid, the usecase
807+// keeps the last-good external palette colors and emits a warning.
808+func TestResolveTheme_MalformedAfterValid_KeepsLastGood(t *testing.T) {
809+ // Step 1: First resolution with valid external
810+ ext := validExternalTheme()
811+ mock := &newMockExternalSource{
812+ theme: ext,
813+ enabled: true,
814+ }
815+ uc := NewResolveThemeUseCase(mock)
816+
817+ input := ResolveThemeInput{
818+ PrefersDark: true,
819+ ColorSchemeSource: "adwaita",
820+ LightPalette: configLightPalette(),
821+ DarkPalette: configDarkPalette(),
822+ UIScale: 1.0,
823+ }
824+
825+ output1, err := uc.Execute(context.Background(), input)
826+ if err != nil {
827+ t.Fatalf("first resolution error: %v", err)
828+ }
829+
830+ firstDarkBg := output1.Theme.DarkPalette.Background
831+ // Verify first resolution uses external palette
832+ if firstDarkBg != ext.DarkPalette.Background {
833+ t.Fatalf("expected first resolution to use external dark palette (%q), got %q",
834+ ext.DarkPalette.Background, firstDarkBg)
835+ }
836+
837+ // Step 2: External becomes malformed
838+ malformed := &entity.ExternalTheme{
839+ Name: "Now Malformed",
840+ LightPalette: &entity.ColorPalette{
841+ Background: "bad-color",
842+ Text: "#ffffff",
843+ },
844+ DarkPalette: nil, // missing palette
845+ }
846+ mock.theme = malformed
847+
848+ output2, err := uc.Execute(context.Background(), input)
849+ if err != nil {
850+ t.Fatalf("second resolution error: %v", err)
851+ }
852+
853+ assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette)
854+ assertValidPaletteHex(t, "ActivePalette", output2.Theme.ActivePalette)
855+
856+ // Should keep last-good colors (from first resolution)
857+ if output2.Theme.DarkPalette.Background != firstDarkBg {
858+ t.Errorf("expected to keep last-good dark palette background (%q), got %q",
859+ firstDarkBg, output2.Theme.DarkPalette.Background)
860+ }
861+
862+ // Should have warnings about the malformed update
863+ if len(output2.Theme.Warnings) == 0 {
864+ t.Error("expected warnings when external becomes malformed after being valid")
865+ }
866+
867+ // ThemeSource should still indicate external (last-good)
868+ if output2.Theme.ThemeSource.Kind != entity.ThemeSourceExternal {
869+ t.Errorf("expected ThemeSource.Kind='external' for last-good, got %q", output2.Theme.ThemeSource.Kind)
870+ }
871+ if output2.Theme.ThemeSource.Provider != "noctalia" {
872+ t.Errorf("expected ThemeSource.Provider='noctalia' for last-good, got %q", output2.Theme.ThemeSource.Provider)
873+ }
874+ if !output2.Theme.ThemeSource.LastGood {
875+ t.Error("expected ThemeSource.LastGood=true for last-good external palette")
876+ }
877+}
878+
879+// ============================================================
880+// Disabling external after valid → clear last-good and use config/defaults
881+// ============================================================
882+
883+// TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood verifies that when the
884+// external source is disabled after having provided a valid theme, last-good is cleared
885+// and config/defaults are used instead.
886+func TestResolveTheme_DisablingExternalAfterValid_ClearsLastGood(t *testing.T) {
887+ // Step 1: First resolution with valid external
888+ ext := validExternalTheme()
889+ mock := &newMockExternalSource{
890+ theme: ext,
891+ enabled: true,
892+ }
893+ uc := NewResolveThemeUseCase(mock)
894+
895+ input := ResolveThemeInput{
896+ PrefersDark: true,
897+ ColorSchemeSource: "adwaita",
898+ LightPalette: configLightPalette(),
899+ DarkPalette: configDarkPalette(),
900+ UIScale: 1.0,
901+ }
902+
903+ output1, err := uc.Execute(context.Background(), input)
904+ if err != nil {
905+ t.Fatalf("first resolution error: %v", err)
906+ }
907+
908+ // Verify first resolution uses external palette
909+ if output1.Theme.DarkPalette.Background == configDarkPalette().Background {
910+ t.Fatal("first resolution should use external palette, not config")
911+ }
912+
913+ // Step 2: External becomes disabled
914+ mock.theme = nil
915+ mock.enabled = false
916+
917+ output2, err := uc.Execute(context.Background(), input)
918+ if err != nil {
919+ t.Fatalf("second resolution error: %v", err)
920+ }
921+
922+ assertValidPaletteHex(t, "DarkPalette", output2.Theme.DarkPalette)
923+
924+ // Should fall back to config palette (NOT keep last-good)
925+ if output2.Theme.DarkPalette.Background != configDarkPalette().Background {
926+ t.Errorf("expected fallback to config dark palette (%q) after external disabled, got %q",
927+ configDarkPalette().Background, output2.Theme.DarkPalette.Background)
928+ }
929+
930+ // ThemeSource should indicate config or default
931+ if output2.Theme.ThemeSource.Kind == entity.ThemeSourceExternal {
932+ t.Error("expected ThemeSource.Kind NOT to be 'external' after external is disabled")
933+ }
934+ if output2.Theme.ThemeSource.LastGood {
935+ t.Error("expected ThemeSource.LastGood=false after external is disabled")
936+ }
937+}
938+
939+// TestResolveTheme_DisablingExternalTwice_Noop verifies that disabling external
940+// when it was already disabled is a no-op (no warnings, uses config).
941+func TestResolveTheme_DisablingExternalTwice_Noop(t *testing.T) {
942+ // External already disabled from start
943+ mock := &newMockExternalSource{
944+ enabled: false,
945+ }
946+ uc := NewResolveThemeUseCase(mock)
947+
948+ input := ResolveThemeInput{
949+ PrefersDark: true,
950+ ColorSchemeSource: "adwaita",
951+ LightPalette: configLightPalette(),
952+ DarkPalette: configDarkPalette(),
953+ UIScale: 1.0,
954+ }
955+
956+ output1, err := uc.Execute(context.Background(), input)
957+ if err != nil {
958+ t.Fatalf("first resolution error: %v", err)
959+ }
960+
961+ // Should use config
962+ if output1.Theme.DarkPalette.Background != configDarkPalette().Background {
963+ t.Errorf("expected config dark palette, got %q", output1.Theme.DarkPalette.Background)
964+ }
965+
966+ // Second resolution with same disabled state — should behave identically
967+ output2, err := uc.Execute(context.Background(), input)
968+ if err != nil {
969+ t.Fatalf("second resolution error: %v", err)
970+ }
971+
972+ if output2.Theme.DarkPalette.Background != configDarkPalette().Background {
973+ t.Errorf("expected config dark palette on second call, got %q", output2.Theme.DarkPalette.Background)
974+ }
975+
976+ assertNoWarnings(t, output2.Theme.Warnings)
977+}
978+
979+// ============================================================
980+// Output completeness: all ResolvedTheme fields
981+// ============================================================
982+
983+// TestResolveTheme_OutputContainsAllFields verifies that every field of
984+// ResolvedTheme is populated in the output (no zero-value surprises).
985+func TestResolveTheme_OutputContainsAllFields(t *testing.T) {
986+ uc := NewResolveThemeUseCase(nil)
987+
988+ fonts := entity.ThemeFonts{
989+ SansFont: "config-sans",
990+ SerifFont: "config-serif",
991+ MonospaceFont: "config-mono",
992+ GtkFont: "config-gtk",
993+ DefaultSize: 12,
994+ }
995+ modeColors := entity.ThemeModeColors{
996+ PaneMode: "#111111",
997+ TabMode: "#222222",
998+ SessionMode: "#333333",
999+ ResizeMode: "#444444",
1000+ }
1001+
1002+ input := ResolveThemeInput{
1003+ ColorScheme: "prefer-dark",
1004+ PrefersDark: true,
1005+ ColorSchemeSource: "config",
1006+ LightPalette: configLightPalette(),
1007+ DarkPalette: configDarkPalette(),
1008+ Fonts: &fonts,
1009+ UIScale: 1.25,
1010+ ModeColors: &modeColors,
1011+ }
1012+
1013+ output, err := uc.Execute(context.Background(), input)
1014+ if err != nil {
1015+ t.Fatalf("unexpected error: %v", err)
1016+ }
1017+
1018+ rt := output.Theme
1019+
1020+ // LightPalette must be valid
1021+ assertValidPaletteHex(t, "LightPalette", rt.LightPalette)
1022+ // DarkPalette must be valid
1023+ assertValidPaletteHex(t, "DarkPalette", rt.DarkPalette)
1024+ // ActivePalette must be valid
1025+ assertValidPaletteHex(t, "ActivePalette", rt.ActivePalette)
1026+ // PrefersDark
1027+ if !rt.PrefersDark {
1028+ t.Error("expected PrefersDark=true")
1029+ }
1030+ // ColorSchemeSource
1031+ if rt.ColorSchemeSource != "config" {
1032+ t.Errorf("expected ColorSchemeSource='config', got %q", rt.ColorSchemeSource)
1033+ }
1034+ // ThemeSource
1035+ if rt.ThemeSource.Kind == "" {
1036+ t.Error("expected non-empty ThemeSource.Kind")
1037+ }
1038+ // Fonts
1039+ if rt.Fonts.SansFont == "" {
1040+ t.Error("expected non-empty Fonts.SansFont")
1041+ }
1042+ if rt.Fonts.SerifFont == "" {
1043+ t.Error("expected non-empty Fonts.SerifFont")
1044+ }
1045+ if rt.Fonts.DefaultSize <= 0 {
1046+ t.Errorf("expected positive Fonts.DefaultSize, got %d", rt.Fonts.DefaultSize)
1047+ }
1048+ // UIScale
1049+ if rt.UIScale <= 0 {
1050+ t.Errorf("expected positive UIScale, got %f", rt.UIScale)
1051+ }
1052+ // ModeColors
1053+ if rt.ModeColors.PaneMode == "" {
1054+ t.Error("expected non-empty ModeColors.PaneMode")
1055+ }
1056+ if rt.ModeColors.TabMode == "" {
1057+ t.Error("expected non-empty ModeColors.TabMode")
1058+ }
1059+ if rt.ModeColors.SessionMode == "" {
1060+ t.Error("expected non-empty ModeColors.SessionMode")
1061+ }
1062+ if rt.ModeColors.ResizeMode == "" {
1063+ t.Error("expected non-empty ModeColors.ResizeMode")
1064+ }
1065+}
1066+
1067+// ============================================================
1068+// All palette colors must be valid CSS-safe hex
1069+// ============================================================
1070+
1071+// TestResolveTheme_AllOutputPalettesAreCSSSafeHex is the contract test: every color
1072+// field in every palette of the output must be a valid #RRGGBB hex color.
1073+func TestResolveTheme_AllOutputPalettesAreCSSSafeHex(t *testing.T) {
1074+ uc := NewResolveThemeUseCase(nil)
1075+
1076+ input := ResolveThemeInput{
1077+ PrefersDark: false,
1078+ ColorSchemeSource: "fallback",
1079+ UIScale: 1.0,
1080+ // No config palettes — must get valid defaults
1081+ }
1082+
1083+ output, err := uc.Execute(context.Background(), input)
1084+ if err != nil {
1085+ t.Fatalf("unexpected error: %v", err)
1086+ }
1087+
1088+ assertValidPaletteHex(t, "LightPalette", output.Theme.LightPalette)
1089+ assertValidPaletteHex(t, "DarkPalette", output.Theme.DarkPalette)
1090+ assertValidPaletteHex(t, "ActivePalette", output.Theme.ActivePalette)
1091+}
1092+
1093+// ============================================================
1094+// Font defaults are used when no config or external fonts provided
1095+// ============================================================
1096+
1097+// TestResolveTheme_NoConfigFonts_UsesDefaults verifies that when fonts are not
1098+// provided in config or external, sensible defaults are used.
1099+func TestResolveTheme_NoConfigFonts_UsesDefaults(t *testing.T) {
1100+ uc := NewResolveThemeUseCase(nil)
1101+
1102+ input := ResolveThemeInput{
1103+ PrefersDark: true,
1104+ ColorSchemeSource: "fallback",
1105+ UIScale: 1.0,
1106+ // Fonts is nil
1107+ }
1108+
1109+ output, err := uc.Execute(context.Background(), input)
1110+ if err != nil {
1111+ t.Fatalf("unexpected error: %v", err)
1112+ }
1113+
1114+ defaults := entity.DefaultThemeFonts()
1115+
1116+ if output.Theme.Fonts.SansFont != defaults.SansFont {
1117+ t.Errorf("expected default SansFont (%q), got %q",
1118+ defaults.SansFont, output.Theme.Fonts.SansFont)
1119+ }
1120+ if output.Theme.Fonts.SerifFont != defaults.SerifFont {
1121+ t.Errorf("expected default SerifFont (%q), got %q",
1122+ defaults.SerifFont, output.Theme.Fonts.SerifFont)
1123+ }
1124+ if output.Theme.Fonts.DefaultSize != defaults.DefaultSize {
1125+ t.Errorf("expected default DefaultSize (%d), got %d",
1126+ defaults.DefaultSize, output.Theme.Fonts.DefaultSize)
1127+ }
1128+}
1129+
1130+// ============================================================
1131+// Mode color defaults are used when not provided
1132+// ============================================================
1133+
1134+// TestResolveTheme_NoConfigModeColors_UsesDefaults verifies that when mode colors
1135+// are not provided, sensible defaults are used.
1136+func TestResolveTheme_NoConfigModeColors_UsesDefaults(t *testing.T) {
1137+ uc := NewResolveThemeUseCase(nil)
1138+
1139+ input := ResolveThemeInput{
1140+ PrefersDark: true,
1141+ ColorSchemeSource: "fallback",
1142+ UIScale: 1.0,
1143+ // ModeColors is nil
1144+ }
1145+
1146+ output, err := uc.Execute(context.Background(), input)
1147+ if err != nil {
1148+ t.Fatalf("unexpected error: %v", err)
1149+ }
1150+
1151+ // Mode colors must be valid CSS-safe hex
1152+ fields := map[string]string{
1153+ "PaneMode": output.Theme.ModeColors.PaneMode,
1154+ "TabMode": output.Theme.ModeColors.TabMode,
1155+ "SessionMode": output.Theme.ModeColors.SessionMode,
1156+ "ResizeMode": output.Theme.ModeColors.ResizeMode,
1157+ }
1158+ for name, value := range fields {
1159+ if value == "" {
1160+ t.Errorf("expected non-empty ModeColors.%s", name)
1161+ }
1162+ if !entity.IsValidHex(value) {
1163+ t.Errorf("ModeColors.%s = %q is not valid CSS-safe hex", name, value)
1164+ }
1165+ }
1166+}
1167+
1168+// TestResolveTheme_InvalidModeColors_FallsBackToDefaults verifies invalid config mode
1169+// colors cannot pass through ResolvedTheme.
1170+func TestResolveTheme_InvalidModeColors_FallsBackToDefaults(t *testing.T) {
1171+ uc := NewResolveThemeUseCase(nil)
1172+ invalid := &entity.ThemeModeColors{
1173+ PaneMode: "not-a-color",
1174+ TabMode: "#222222",
1175+ SessionMode: "#333333",
1176+ ResizeMode: "#444444",
1177+ }
1178+
1179+ output, err := uc.Execute(context.Background(), ResolveThemeInput{
1180+ PrefersDark: true,
1181+ ColorSchemeSource: "config",
1182+ ModeColors: invalid,
1183+ })
1184+ if err != nil {
1185+ t.Fatalf("unexpected error: %v", err)
1186+ }
1187+
1188+ defaults := entity.DefaultThemeModeColors()
1189+ if output.Theme.ModeColors != defaults {
1190+ t.Errorf("expected invalid mode colors to fall back to defaults %+v, got %+v", defaults, output.Theme.ModeColors)
1191+ }
1192+ if len(output.Theme.Warnings) == 0 {
1193+ t.Fatal("expected warning for invalid mode colors")
1194+ }
1195+}
1196+
1197+// ============================================================
1198+// UIScale defaults
1199+// ============================================================
1200+
1201+// TestResolveTheme_UIScaleDefaultsToOne verifies that when UIScale is zero or negative,
1202+// it defaults to 1.0.
1203+func TestResolveTheme_UIScaleDefaultsToOne(t *testing.T) {
1204+ uc := NewResolveThemeUseCase(nil)
1205+
1206+ tests := []struct {
1207+ name string
1208+ uiScale float64
1209+ expected float64
1210+ }{
1211+ {"zero", 0.0, 1.0},
1212+ {"negative", -0.5, 1.0},
1213+ {"positive", 1.25, 1.25},
1214+ }
1215+
1216+ for _, tt := range tests {
1217+ t.Run(tt.name, func(t *testing.T) {
1218+ input := ResolveThemeInput{
1219+ PrefersDark: true,
1220+ ColorSchemeSource: "fallback",
1221+ UIScale: tt.uiScale,
1222+ }
1223+
1224+ output, err := uc.Execute(context.Background(), input)
1225+ if err != nil {
1226+ t.Fatalf("unexpected error: %v", err)
1227+ }
1228+
1229+ if output.Theme.UIScale != tt.expected {
1230+ t.Errorf("expected UIScale=%f, got %f", tt.expected, output.Theme.UIScale)
1231+ }
1232+ })
1233+ }
1234+}
1235+
1236+// ============================================================
1237+// Color scheme source is properly captured
1238+// ============================================================
1239+
1240+// TestResolveTheme_ColorSchemeSourceIsCaptured verifies that the color scheme
1241+// source string is passed through to the output.
1242+func TestResolveTheme_ColorSchemeSourceIsCaptured(t *testing.T) {
1243+ uc := NewResolveThemeUseCase(nil)
1244+
1245+ sources := []string{"config", "adwaita", "gsettings", "env", "fallback"}
1246+
1247+ for _, source := range sources {
1248+ input := ResolveThemeInput{
1249+ PrefersDark: true,
1250+ ColorSchemeSource: source,
1251+ UIScale: 1.0,
1252+ }
1253+
1254+ output, err := uc.Execute(context.Background(), input)
1255+ if err != nil {
1256+ t.Fatalf("unexpected error for source %q: %v", source, err)
1257+ }
1258+
1259+ if output.Theme.ColorSchemeSource != source {
1260+ t.Errorf("expected ColorSchemeSource=%q, got %q", source, output.Theme.ColorSchemeSource)
1261+ }
1262+ }
1263+}
1264+
1265+// ============================================================
1266+// Refresh API
1267+// ============================================================
1268+
1269+// TestResolveTheme_RefreshDelegatesToExecute verifies the refresh/update API returns
1270+// the same single result shape as Execute without adding CSS generation or I/O.
1271+func TestResolveTheme_RefreshDelegatesToExecute(t *testing.T) {
1272+ uc := NewResolveThemeUseCase(nil)
1273+ input := ResolveThemeInput{
1274+ PrefersDark: true,
1275+ ColorSchemeSource: "adwaita",
1276+ LightPalette: configLightPalette(),
1277+ DarkPalette: configDarkPalette(),
1278+ UIScale: 1.25,
1279+ }
1280+
1281+ output, err := uc.Refresh(context.Background(), input)
1282+ if err != nil {
1283+ t.Fatalf("unexpected error: %v", err)
1284+ }
1285+
1286+ if !output.Theme.PrefersDark {
1287+ t.Error("expected refreshed theme to preserve PrefersDark")
1288+ }
1289+ if output.Theme.ActivePalette.Background != configDarkPalette().Background {
1290+ t.Errorf("expected refreshed active palette background %q, got %q",
1291+ configDarkPalette().Background, output.Theme.ActivePalette.Background)
1292+ }
1293+ if output.Theme.UIScale != 1.25 {
1294+ t.Errorf("expected refreshed UI scale 1.25, got %f", output.Theme.UIScale)
1295+ }
1296+}
1297+
1298+// ============================================================
1299+// Input mapping from domain config shapes
1300+// ============================================================
1301+
1302+// TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference verifies
1303+// app/bootstrap callers can build usecase input without duplicating fallback logic.
1304+func TestResolveThemeInputFromConfig_MapsAppearanceStylingAndPreference(t *testing.T) {
1305+ appearance := &entity.AppearanceConfig{
1306+ SansFont: "Inter",
1307+ SerifFont: "Georgia",
1308+ MonospaceFont: "JetBrains Mono",
1309+ GtkFont: "Adwaita Sans",
1310+ DefaultFontSize: 18,
1311+ ColorScheme: "prefer-dark",
1312+ LightPalette: *configLightPalette(),
1313+ DarkPalette: *configDarkPalette(),
1314+ }
1315+ styling := &entity.WorkspaceStylingConfig{
1316+ PaneModeColor: "#111111",
1317+ TabModeColor: "#222222",
1318+ SessionModeColor: "#333333",
1319+ ResizeModeColor: "#444444",
1320+ }
1321+ preference := port.ColorSchemePreference{PrefersDark: true, Source: "adwaita"}
1322+
1323+ input := ResolveThemeInputFromConfig(appearance, 1.25, styling, preference)
1324+
1325+ if input.ColorScheme != "prefer-dark" {
1326+ t.Errorf("expected color scheme prefer-dark, got %q", input.ColorScheme)
1327+ }
1328+ if !input.PrefersDark || input.ColorSchemeSource != "adwaita" {
1329+ t.Errorf("expected preference from detector, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource)
1330+ }
1331+ if input.LightPalette == nil || input.LightPalette.Background != configLightPalette().Background {
1332+ t.Fatalf("expected light palette pointer from appearance, got %+v", input.LightPalette)
1333+ }
1334+ if input.DarkPalette == nil || input.DarkPalette.Background != configDarkPalette().Background {
1335+ t.Fatalf("expected dark palette pointer from appearance, got %+v", input.DarkPalette)
1336+ }
1337+ if input.Fonts == nil || input.Fonts.SansFont != "Inter" || input.Fonts.DefaultSize != 18 {
1338+ t.Fatalf("expected fonts from appearance, got %+v", input.Fonts)
1339+ }
1340+ if input.UIScale != 1.25 {
1341+ t.Errorf("expected UI scale 1.25, got %f", input.UIScale)
1342+ }
1343+ if input.ModeColors == nil || input.ModeColors.PaneMode != "#111111" || input.ModeColors.ResizeMode != "#444444" {
1344+ t.Fatalf("expected mode colors from styling, got %+v", input.ModeColors)
1345+ }
1346+}
1347+
1348+// TestResolveThemeInputFromConfig_AllowsNilConfig verifies callers can resolve defaults
1349+// when appearance/styling config is unavailable.
1350+func TestResolveThemeInputFromConfig_AllowsNilConfig(t *testing.T) {
1351+ preference := port.ColorSchemePreference{PrefersDark: false, Source: "fallback"}
1352+
1353+ input := ResolveThemeInputFromConfig(nil, 0, nil, preference)
1354+
1355+ if input.ColorScheme != "" {
1356+ t.Errorf("expected empty color scheme for nil appearance, got %q", input.ColorScheme)
1357+ }
1358+ if input.PrefersDark || input.ColorSchemeSource != "fallback" {
1359+ t.Errorf("expected fallback light preference, got PrefersDark=%v Source=%q", input.PrefersDark, input.ColorSchemeSource)
1360+ }
1361+ if input.LightPalette != nil || input.DarkPalette != nil || input.Fonts != nil || input.ModeColors != nil {
1362+ t.Fatalf("expected nil optional inputs, got light=%v dark=%v fonts=%v modes=%v",
1363+ input.LightPalette, input.DarkPalette, input.Fonts, input.ModeColors)
1364+ }
1365+}
M internal/bootstrap/engine.go +24 -7
@@ -23,19 +23,27 @@ import (
23 23
24 // EngineInput holds the input for BuildEngine.24 // EngineInput holds the input for BuildEngine.
25 type EngineInput struct {25 type EngineInput struct {
26- Ctx context.Context26+ Ctx context.Context
27- Config *config.Config27+ Config *config.Config
28- RuntimeProfile runtimeprofile.Profile28+ RuntimeProfile runtimeprofile.Profile
29- ThemeManager *theme.Manager29+ ThemeManager *theme.Manager
30- ColorResolver port.ColorSchemeResolver30+ ExternalThemeSource port.ExternalThemeSource
31- Logger zerolog.Logger31+ ColorResolver port.ColorSchemeResolver
32+ Logger zerolog.Logger
32 }33 }
33 34
34 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.35 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
35 func BuildEngine(input EngineInput) (port.Engine, error) {36 func BuildEngine(input EngineInput) (port.Engine, error) {
36 cfg := input.Config37 cfg := input.Config
37 systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())38 systemviewReader := config.NewSystemviewConfigReader(env.NewHardwareSurveyor())
38- systemviewUC := usecase.NewReadSystemviewConfigUseCase(systemviewReader)39+ systemviewUC := usecase.NewReadSystemviewConfigUseCase(
40+ systemviewReader,
41+ usecase.WithSystemviewResolvedAppearance(
42+ input.ExternalThemeSource,
43+ input.ColorResolver,
44+ prefersDarkProvider(input.ThemeManager),
45+ ),
46+ )
39 buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {47 buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
40 return func() ([]byte, error) {48 return func() ([]byte, error) {
41 payload, err := read(input.Ctx)49 payload, err := read(input.Ctx)
@@ -120,6 +128,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
120 }128 }
121 }129 }
122 130
131+func prefersDarkProvider(manager *theme.Manager) func() bool {
132+ return func() bool {
133+ if manager == nil {
134+ return true
135+ }
136+ return manager.PrefersDark()
137+ }
138+}
139+
123 func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) {140 func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) {
124 if profile.Engine == "" {141 if profile.Engine == "" {
125 return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType)142 return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType)
M internal/bootstrap/gui.go +77 -28
@@ -11,10 +11,12 @@ import (
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/application/usecase"13 "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/colorscheme"15 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
15 "github.com/bnema/dumber/internal/infrastructure/config"16 "github.com/bnema/dumber/internal/infrastructure/config"
16 "github.com/bnema/dumber/internal/infrastructure/deps"17 "github.com/bnema/dumber/internal/infrastructure/deps"
17 "github.com/bnema/dumber/internal/infrastructure/env"18 "github.com/bnema/dumber/internal/infrastructure/env"
19+ "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
18 "github.com/bnema/dumber/internal/infrastructure/media"20 "github.com/bnema/dumber/internal/infrastructure/media"
19 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"21 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"22 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
@@ -30,11 +32,15 @@ type DatabaseResult struct {
30 32
31 // ParallelInitResult holds the results of parallel initialization phase.33 // ParallelInitResult holds the results of parallel initialization phase.
32 type ParallelInitResult struct {34 type ParallelInitResult struct {
33- RuntimeProfile runtimeprofile.Profile35+ RuntimeProfile runtimeprofile.Profile
34- ThemeManager *theme.Manager36+ ThemeManager *theme.Manager
35- ColorResolver port.ColorSchemeResolver37+ ResolvedTheme entity.ResolvedTheme
36- AdwaitaDetector *colorscheme.AdwaitaDetector38+ ResolveThemeUC *usecase.ResolveThemeUseCase
37- Duration time.Duration39+ ExternalThemeSource port.ConfigurableExternalThemeSource
40+ ExternalThemeWatcher port.ExternalThemeWatcher
41+ ColorResolver port.ColorSchemeResolver
42+ AdwaitaDetector *colorscheme.AdwaitaDetector
43+ Duration time.Duration
38 }44 }
39 45
40 // DeferredInitResult holds results from deferred initialization checks.46 // DeferredInitResult holds results from deferred initialization checks.
@@ -90,8 +96,9 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
90 // Returns the first fatal error encountered, or nil with the results.96 // Returns the first fatal error encountered, or nil with the results.
91 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {97 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
92 var (98 var (
93- dirsErr error99+ dirsErr error
94- wg sync.WaitGroup100+ themeErr error
101+ wg sync.WaitGroup
95 )102 )
96 103
97 profile, err := ResolveRuntimeProfile(input.Config)104 profile, err := ResolveRuntimeProfile(input.Config)
@@ -122,14 +129,28 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
122 dirsErr = resolveWebKitDirs(profile)129 dirsErr = resolveWebKitDirs(profile)
123 }()130 }()
124 131
125- // Theme manager (CPU-bound, no I/O)132+ // Theme manager. External theme file reads, when enabled, are non-fatal
133+ // and reported as theme warnings by the usecase.
134+ externalThemeSource := noctalia.NewFileSourceFromConfig(input.Config.Appearance.ExternalTheme)
135+ resolveThemeUC := usecase.NewResolveThemeUseCase(externalThemeSource)
126 var themeManager *theme.Manager136 var themeManager *theme.Manager
137+ var resolvedTheme entity.ResolvedTheme
127 go func() {138 go func() {
128 defer wg.Done()139 defer wg.Done()
129- themeManager = theme.NewManager(140+ preference := resolver.Resolve()
130- input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale,141+ resolved, err := resolveThemeUC.Execute(input.Ctx, usecase.ResolveThemeInputFromConfig(
131- &input.Config.Workspace.Styling, resolver,142+ &input.Config.Appearance,
132- )143+ input.Config.DefaultUIScale,
144+ &input.Config.Workspace.Styling,
145+ preference,
146+ ))
147+ if err != nil {
148+ themeErr = err
149+ return
150+ }
151+ LogResolvedTheme(input.Ctx, resolved.Theme)
152+ resolvedTheme = resolved.Theme
153+ themeManager = theme.NewManager(input.Ctx, resolved.Theme)
133 }()154 }()
134 155
135 wg.Wait()156 wg.Wait()
@@ -139,16 +160,42 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
139 if dirsErr != nil {160 if dirsErr != nil {
140 return nil, fmt.Errorf("resolve directories: %w", dirsErr)161 return nil, fmt.Errorf("resolve directories: %w", dirsErr)
141 }162 }
163+ if themeErr != nil {
164+ return nil, fmt.Errorf("resolve theme: %w", themeErr)
165+ }
142 166
143 return &ParallelInitResult{167 return &ParallelInitResult{
144- RuntimeProfile: profile,168+ RuntimeProfile: profile,
145- ThemeManager: themeManager,169+ ThemeManager: themeManager,
146- ColorResolver: resolver,170+ ResolvedTheme: resolvedTheme,
147- AdwaitaDetector: adwaitaDetector,171+ ResolveThemeUC: resolveThemeUC,
148- Duration: duration,172+ ExternalThemeSource: externalThemeSource,
173+ ExternalThemeWatcher: noctalia.NewFileWatcher(),
174+ ColorResolver: resolver,
175+ AdwaitaDetector: adwaitaDetector,
176+ Duration: duration,
149 }, nil177 }, nil
150 }178 }
151 179
180+// LogResolvedTheme writes a compact theme-source summary and non-fatal warnings.
181+func LogResolvedTheme(ctx context.Context, resolved entity.ResolvedTheme) {
182+ log := logging.FromContext(ctx)
183+ for _, warning := range resolved.Warnings {
184+ log.Warn().Str("field", warning.Field).Msg(warning.Message)
185+ }
186+ event := log.Info().
187+ Str("theme_source", string(resolved.ThemeSource.Kind)).
188+ Str("color_scheme_source", resolved.ColorSchemeSource).
189+ Bool("prefers_dark", resolved.PrefersDark)
190+ if resolved.ThemeSource.Provider != "" {
191+ event = event.Str("provider", resolved.ThemeSource.Provider)
192+ }
193+ if resolved.ThemeSource.LastGood {
194+ event = event.Bool("last_good", true)
195+ }
196+ event.Msg("theme resolved")
197+}
198+
152 // RunDeferredInit runs deferred initialization checks off the critical path.199 // RunDeferredInit runs deferred initialization checks off the critical path.
153 // This includes runtime requirements and media checks.200 // This includes runtime requirements and media checks.
154 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {201 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
@@ -275,11 +322,12 @@ type ParallelDBEngineResult struct {
275 322
276 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.323 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
277 type ParallelDBEngineInput struct {324 type ParallelDBEngineInput struct {
278- Ctx context.Context325+ Ctx context.Context
279- Config *config.Config326+ Config *config.Config
280- RuntimeProfile runtimeprofile.Profile327+ RuntimeProfile runtimeprofile.Profile
281- ThemeManager *theme.Manager328+ ThemeManager *theme.Manager
282- ColorResolver port.ColorSchemeResolver329+ ExternalThemeSource port.ExternalThemeSource
330+ ColorResolver port.ColorSchemeResolver
283 }331 }
284 332
285 // Note: Database path is resolved via config.GetDatabaseFile() internally.333 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -314,12 +362,13 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult,
314 362
315 // Engine on main thread (GTK requirement)363 // Engine on main thread (GTK requirement)
316 engine, err := BuildEngine(EngineInput{364 engine, err := BuildEngine(EngineInput{
317- Ctx: input.Ctx,365+ Ctx: input.Ctx,
318- Config: input.Config,366+ Config: input.Config,
319- RuntimeProfile: input.RuntimeProfile,367+ RuntimeProfile: input.RuntimeProfile,
320- ThemeManager: input.ThemeManager,368+ ThemeManager: input.ThemeManager,
321- ColorResolver: input.ColorResolver,369+ ExternalThemeSource: input.ExternalThemeSource,
322- Logger: *log,370+ ColorResolver: input.ColorResolver,
371+ Logger: *log,
323 })372 })
324 if err != nil {373 if err != nil {
325 dbRes := <-dbCh374 dbRes := <-dbCh
M internal/cli/app.go +41 -2
@@ -13,8 +13,10 @@ import (
13 "github.com/bnema/dumber/internal/bootstrap"13 "github.com/bnema/dumber/internal/bootstrap"
14 "github.com/bnema/dumber/internal/cli/styles"14 "github.com/bnema/dumber/internal/cli/styles"
15 "github.com/bnema/dumber/internal/domain/build"15 "github.com/bnema/dumber/internal/domain/build"
16+ "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/bnema/dumber/internal/domain/repository"17 "github.com/bnema/dumber/internal/domain/repository"
17 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
18 "github.com/bnema/dumber/internal/infrastructure/favicon"20 "github.com/bnema/dumber/internal/infrastructure/favicon"
19 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"21 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
20 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"22 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
@@ -56,8 +58,9 @@ func NewApp() (*App, error) {
56 return nil, fmt.Errorf("resolve runtime profile: %w", err)58 return nil, fmt.Errorf("resolve runtime profile: %w", err)
57 }59 }
58 60
59- // Create theme from config61+ // Create theme through the shared resolver. CLI keeps the historical dark default
60- theme := styles.NewTheme(cfg)62+ // unless config explicitly requests a light scheme.
63+ theme := styles.NewThemeFromResolved(resolveCLITheme(cfg))
61 64
62 // Start with a quiet logger. If a browser session is active, we'll attach to it.65 // Start with a quiet logger. If a browser session is active, we'll attach to it.
63 logLevel := cfg.Logging.Level66 logLevel := cfg.Logging.Level
@@ -157,6 +160,42 @@ func (a *App) Close() error {
157 return nil160 return nil
158 }161 }
159 162
163+func resolveCLITheme(cfg *config.Config) entity.ResolvedTheme {
164+ colorScheme := ""
165+ if cfg != nil {
166+ colorScheme = cfg.Appearance.ColorScheme
167+ }
168+ preference := usecase.ColorSchemePreferenceFromConfig(colorScheme, port.ColorSchemePreference{
169+ PrefersDark: true,
170+ Source: "cli",
171+ })
172+
173+ var appearance *entity.AppearanceConfig
174+ var styling *entity.WorkspaceStylingConfig
175+ var uiScale float64
176+ if cfg != nil {
177+ appearance = &cfg.Appearance
178+ styling = &cfg.Workspace.Styling
179+ uiScale = cfg.DefaultUIScale
180+ }
181+
182+ externalThemeSource := noctalia.NewFileSource(false, "")
183+ if cfg != nil {
184+ externalThemeSource.Configure(cfg.Appearance.ExternalTheme)
185+ }
186+ uc := usecase.NewResolveThemeUseCase(externalThemeSource)
187+ out, err := uc.Execute(context.Background(), usecase.ResolveThemeInputFromConfig(
188+ appearance,
189+ uiScale,
190+ styling,
191+ preference,
192+ ))
193+ if err != nil {
194+ return entity.ResolvedTheme{ActivePalette: entity.DefaultDarkPalette()}
195+ }
196+ return out.Theme
197+}
198+
160 // Ctx returns the application context with logger.199 // Ctx returns the application context with logger.
161 func (a *App) Ctx() context.Context {200 func (a *App) Ctx() context.Context {
162 return a.ctx201 return a.ctx
A internal/cli/app_theme_test.go +52 -0
@@ -0,0 +1,52 @@
1+package cli
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/stretchr/testify/require"
9+
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+)
12+
13+func TestResolveCLIThemePreservesDarkDefault(t *testing.T) {
14+ cfg := config.DefaultConfig()
15+ cfg.Appearance.ColorScheme = "default"
16+
17+ resolved := resolveCLITheme(cfg)
18+
19+ require.True(t, resolved.PrefersDark)
20+ require.Equal(t, cfg.Appearance.DarkPalette.Background, resolved.ActivePalette.Background)
21+}
22+
23+func TestResolveCLIThemeHonorsExplicitLightPreference(t *testing.T) {
24+ cfg := config.DefaultConfig()
25+ cfg.Appearance.ColorScheme = "prefer-light"
26+
27+ resolved := resolveCLITheme(cfg)
28+
29+ require.False(t, resolved.PrefersDark)
30+ require.Equal(t, cfg.Appearance.LightPalette.Background, resolved.ActivePalette.Background)
31+}
32+
33+func TestResolveCLIThemeUsesEnabledExternalTheme(t *testing.T) {
34+ path := filepath.Join(t.TempDir(), "theme.json")
35+ require.NoError(t, os.WriteFile(path, []byte(`{
36+ "light":{"background":"#ffffff","accent":"#112233"},
37+ "dark":{"background":"#000000","accent":"#445566"}
38+ }`), 0o600))
39+ cfg := config.DefaultConfig()
40+ cfg.Appearance.ExternalTheme.Enabled = true
41+ cfg.Appearance.ExternalTheme.Provider = "noctalia"
42+ cfg.Appearance.ExternalTheme.Format = "dumber-json"
43+ cfg.Appearance.ExternalTheme.Path = path
44+
45+ resolved := resolveCLITheme(cfg)
46+
47+ require.True(t, resolved.PrefersDark)
48+ require.Equal(t, "#000000", resolved.ActivePalette.Background)
49+ require.Equal(t, "#445566", resolved.ActivePalette.Accent)
50+ require.Equal(t, "external", string(resolved.ThemeSource.Kind))
51+ require.Equal(t, "noctalia", resolved.ThemeSource.Provider)
52+}
M internal/cli/styles/theme.go +13 -0
@@ -4,6 +4,7 @@ package styles
4 import (4 import (
5 "github.com/charmbracelet/lipgloss"5 "github.com/charmbracelet/lipgloss"
6 6
7+ "github.com/bnema/dumber/internal/domain/entity"
7 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
8 )9 )
9 10
@@ -81,6 +82,18 @@ func NewTheme(cfg *config.Config) *Theme {
81 return NewThemeFromPalette(p)82 return NewThemeFromPalette(p)
82 }83 }
83 84
85+// NewThemeFromResolved creates a Theme from the active resolved palette.
86+func NewThemeFromResolved(resolved entity.ResolvedTheme) *Theme {
87+ p := resolved.ActivePalette
88+ if p == (entity.ColorPalette{}) {
89+ p = resolved.DarkPalette
90+ }
91+ if p == (entity.ColorPalette{}) {
92+ p = DefaultDarkPalette()
93+ }
94+ return NewThemeFromPalette(p)
95+}
96+
84 // NewThemeFromPalette creates a Theme from a ColorPalette.97 // NewThemeFromPalette creates a Theme from a ColorPalette.
85 func NewThemeFromPalette(p config.ColorPalette) *Theme {98 func NewThemeFromPalette(p config.ColorPalette) *Theme {
86 t := &Theme{99 t := &Theme{
A internal/cli/styles/theme_test.go +50 -0
@@ -0,0 +1,50 @@
1+package styles_test
2+
3+import (
4+ "testing"
5+
6+ "github.com/charmbracelet/lipgloss"
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/dumber/internal/cli/styles"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+func TestNewThemeFromResolvedUsesActivePalette(t *testing.T) {
14+ resolved := entity.ResolvedTheme{
15+ ActivePalette: entity.ColorPalette{
16+ Background: "#010101",
17+ Surface: "#020202",
18+ SurfaceVariant: "#030303",
19+ Text: "#fefefe",
20+ Muted: "#999999",
21+ Accent: "#abcdef",
22+ Border: "#444444",
23+ },
24+ }
25+
26+ theme := styles.NewThemeFromResolved(resolved)
27+
28+ require.Equal(t, lipgloss.Color("#010101"), theme.Background)
29+ require.Equal(t, lipgloss.Color("#abcdef"), theme.Accent)
30+ require.Equal(t, lipgloss.Color("#abcdef"), theme.Success)
31+}
32+
33+func TestNewThemeFromResolvedFallsBackToDarkPalette(t *testing.T) {
34+ resolved := entity.ResolvedTheme{
35+ DarkPalette: entity.ColorPalette{
36+ Background: "#111111",
37+ Surface: "#222222",
38+ SurfaceVariant: "#333333",
39+ Text: "#eeeeee",
40+ Muted: "#888888",
41+ Accent: "#fedcba",
42+ Border: "#444444",
43+ },
44+ }
45+
46+ theme := styles.NewThemeFromResolved(resolved)
47+
48+ require.Equal(t, lipgloss.Color("#111111"), theme.Background)
49+ require.Equal(t, lipgloss.Color("#fedcba"), theme.Accent)
50+}
M internal/domain/entity/config_types.go +17 -8
@@ -6,14 +6,23 @@ package entity
6 6
7 // AppearanceConfig holds visual appearance settings.7 // AppearanceConfig holds visual appearance settings.
8 type AppearanceConfig struct {8 type AppearanceConfig struct {
9- SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"`9+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"`
10- SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font" json:"serif_font"`10+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font" json:"serif_font"`
11- MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font" json:"monospace_font"`11+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font" json:"monospace_font"`
12- GtkFont string `mapstructure:"gtk_font" yaml:"gtk_font" toml:"gtk_font" json:"gtk_font"`12+ GtkFont string `mapstructure:"gtk_font" yaml:"gtk_font" toml:"gtk_font" json:"gtk_font"`
13- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"`13+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"` //nolint:lll // struct tags must stay on one line
14- LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette" json:"light_palette"`14+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette" json:"light_palette"`
15- DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette" json:"dark_palette"`15+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette" json:"dark_palette"`
16- ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"`16+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"`
17+ ExternalTheme ExternalThemeConfig `mapstructure:"external_theme" yaml:"external_theme" toml:"external_theme" json:"external_theme"`
18+}
19+
20+// ExternalThemeConfig controls optional external theme loading.
21+type ExternalThemeConfig struct {
22+ Enabled bool `mapstructure:"enabled" yaml:"enabled" toml:"enabled" json:"enabled"`
23+ Provider string `mapstructure:"provider" yaml:"provider" toml:"provider" json:"provider"`
24+ Format string `mapstructure:"format" yaml:"format" toml:"format" json:"format"`
25+ Path string `mapstructure:"path" yaml:"path" toml:"path" json:"path"`
17 }26 }
18 27
19 // ColorPalette defines the color scheme for a theme variant (light/dark).28 // ColorPalette defines the color scheme for a theme variant (light/dark).
A internal/domain/entity/theme.go +299 -0
@@ -0,0 +1,299 @@
1+package entity
2+
3+import "regexp"
4+
5+// ThemeSourceKind describes where the resolved palette colors originated.
6+type ThemeSourceKind string
7+
8+const (
9+ // ThemeSourceDefault indicates palette colors come from built-in defaults.
10+ ThemeSourceDefault ThemeSourceKind = "default"
11+ // ThemeSourceConfig indicates palette colors come from user configuration.
12+ ThemeSourceConfig ThemeSourceKind = "config"
13+ // ThemeSourceExternal indicates palette colors come from an external theme source.
14+ ThemeSourceExternal ThemeSourceKind = "external"
15+)
16+
17+// ThemeSourceMetadata describes the palette source used for a resolved theme.
18+type ThemeSourceMetadata struct {
19+ Kind ThemeSourceKind
20+ Provider string
21+ LastGood bool
22+}
23+
24+// ThemeWarning represents a non-fatal issue encountered during theme resolution.
25+type ThemeWarning struct {
26+ Field string
27+ Message string
28+}
29+
30+// ThemeFonts holds the resolved font families and default size.
31+type ThemeFonts struct {
32+ SansFont string
33+ SerifFont string
34+ MonospaceFont string
35+ GtkFont string
36+ DefaultSize int
37+}
38+
39+// ThemeModeColors holds mode indicator colors.
40+// All values must be CSS-safe hex colors (e.g., "#4A90E2").
41+type ThemeModeColors struct {
42+ PaneMode string
43+ TabMode string
44+ SessionMode string
45+ ResizeMode string
46+}
47+
48+// ResolvedThemePalette is a CSS-safe hex color palette.
49+// Alias for ColorPalette with the contract that all color strings
50+// are valid CSS hex colors (validated during resolution).
51+type ResolvedThemePalette = ColorPalette
52+
53+// ExternalTheme represents palette data from an external source (e.g., Noctalia).
54+// External themes override palette colors only; fonts, UI scale, and mode colors stay in Dumber config.
55+// It must not depend on GTK, WebKit, CEF, lipgloss, fsnotify, Viper, or infrastructure config.
56+type ExternalTheme struct {
57+ Name string
58+ Provider string
59+
60+ // LightPalette and DarkPalette may contain partial overrides.
61+ // Nil palette pointers are malformed; empty fields mean "use the resolved config/default field".
62+ LightPalette *ColorPalette
63+ DarkPalette *ColorPalette
64+}
65+
66+// ResolvedTheme is the final resolved theme output.
67+// It contains palettes, fonts, colors, scale, and metadata.
68+// All palette colors are guaranteed to be CSS-safe hex colors.
69+// CSS generation is NOT part of this core type — it is a separate adapter concern.
70+type ResolvedTheme struct {
71+ LightPalette ResolvedThemePalette
72+ DarkPalette ResolvedThemePalette
73+ ActivePalette ResolvedThemePalette
74+ PrefersDark bool
75+ ColorSchemeSource string
76+ ThemeSource ThemeSourceMetadata
77+ Fonts ThemeFonts
78+ UIScale float64
79+ ModeColors ThemeModeColors
80+ Warnings []ThemeWarning
81+}
82+
83+// hexColorRegex matches valid CSS-safe #RRGGBB hex colors (strict).
84+var hexColorRegex = regexp.MustCompile(`^#[0-9a-fA-F]{6}$`)
85+
86+// IsValidHex returns true if s is a valid CSS-safe hex color (#RRGGBB format only).
87+func IsValidHex(s string) bool {
88+ return hexColorRegex.MatchString(s)
89+}
90+
91+// DefaultThemeFonts returns Dumber's appearance font defaults.
92+func DefaultThemeFonts() ThemeFonts {
93+ return ThemeFonts{
94+ SansFont: "Fira Sans",
95+ SerifFont: "Fira Sans",
96+ MonospaceFont: "Fira Code",
97+ GtkFont: "Adwaita Sans",
98+ DefaultSize: 16,
99+ }
100+}
101+
102+// DefaultThemeModeColors returns the default mode indicator colors.
103+// These match the existing default mode colors from config/infrastructure.
104+func DefaultThemeModeColors() ThemeModeColors {
105+ return ThemeModeColors{
106+ PaneMode: "#4A90E2",
107+ TabMode: "#FFA500",
108+ SessionMode: "#9B59B6",
109+ ResizeMode: "#00D4AA",
110+ }
111+}
112+
113+// DefaultDarkPalette returns the built-in dark theme palette.
114+// These values mirror the config default dark palette.
115+func DefaultDarkPalette() ColorPalette {
116+ return ColorPalette{
117+ Background: "#0a0a0b",
118+ Surface: "#18181b",
119+ SurfaceVariant: "#27272a",
120+ Text: "#fafafa",
121+ Muted: "#a1a1aa",
122+ Accent: "#4ade80",
123+ Border: "#3f3f46",
124+ }
125+}
126+
127+// DefaultLightPalette returns the built-in light theme palette.
128+// These values mirror the config default light palette.
129+func DefaultLightPalette() ColorPalette {
130+ return ColorPalette{
131+ Background: "#fafafa",
132+ Surface: "#f4f4f5",
133+ SurfaceVariant: "#e4e4e7",
134+ Text: "#18181b",
135+ Muted: "#71717a",
136+ Accent: "#22c55e",
137+ Border: "#d4d4d8",
138+ }
139+}
140+
141+// ValidatePaletteHex checks every field in the palette is a valid #RRGGBB hex color.
142+// Returns a slice of warnings for each invalid field. Empty slice means valid.
143+func ValidatePaletteHex(p *ColorPalette, prefix string) []ThemeWarning {
144+ if p == nil {
145+ return []ThemeWarning{{Field: prefix, Message: "palette is nil"}}
146+ }
147+ var warnings []ThemeWarning
148+ checkRequiredHex(&warnings, prefix, "background", p.Background)
149+ checkRequiredHex(&warnings, prefix, "surface", p.Surface)
150+ checkRequiredHex(&warnings, prefix, "surface_variant", p.SurfaceVariant)
151+ checkRequiredHex(&warnings, prefix, "text", p.Text)
152+ checkRequiredHex(&warnings, prefix, "muted", p.Muted)
153+ checkRequiredHex(&warnings, prefix, "accent", p.Accent)
154+ checkRequiredHex(&warnings, prefix, "border", p.Border)
155+ return warnings
156+}
157+
158+// ValidatePaletteOverrideHex checks non-empty override fields are valid #RRGGBB hex colors.
159+// Empty fields are accepted and mean "inherit from the fallback palette".
160+func ValidatePaletteOverrideHex(p *ColorPalette, prefix string) []ThemeWarning {
161+ if p == nil {
162+ return []ThemeWarning{{Field: prefix, Message: "palette is nil"}}
163+ }
164+ var warnings []ThemeWarning
165+ checkOptionalHex(&warnings, prefix, "background", p.Background)
166+ checkOptionalHex(&warnings, prefix, "surface", p.Surface)
167+ checkOptionalHex(&warnings, prefix, "surface_variant", p.SurfaceVariant)
168+ checkOptionalHex(&warnings, prefix, "text", p.Text)
169+ checkOptionalHex(&warnings, prefix, "muted", p.Muted)
170+ checkOptionalHex(&warnings, prefix, "accent", p.Accent)
171+ checkOptionalHex(&warnings, prefix, "border", p.Border)
172+ return warnings
173+}
174+
175+func checkRequiredHex(warnings *[]ThemeWarning, prefix, field, value string) {
176+ if !IsValidHex(value) {
177+ *warnings = append(*warnings, ThemeWarning{
178+ Field: prefix + "." + field,
179+ Message: "not a valid CSS-safe hex color (#RRGGBB)",
180+ })
181+ }
182+}
183+
184+func checkOptionalHex(warnings *[]ThemeWarning, prefix, field, value string) {
185+ if value != "" && !IsValidHex(value) {
186+ *warnings = append(*warnings, ThemeWarning{
187+ Field: prefix + "." + field,
188+ Message: "not a valid CSS-safe hex color (#RRGGBB)",
189+ })
190+ }
191+}
192+
193+// IsPaletteValid returns true if every field in the palette is a valid #RRGGBB hex color.
194+func IsPaletteValid(p *ColorPalette) bool {
195+ return len(ValidatePaletteHex(p, "palette")) == 0
196+}
197+
198+// IsPaletteOverrideValid returns true if every non-empty field in the palette is a valid #RRGGBB hex color.
199+func IsPaletteOverrideValid(p *ColorPalette) bool {
200+ return len(ValidatePaletteOverrideHex(p, "palette")) == 0
201+}
202+
203+// MergePalette returns a new ColorPalette where empty fields in src are filled from defaults.
204+// Both src and defaults may be nil. Returns a copy, never mutates inputs.
205+func MergePalette(src, defaults *ColorPalette) ColorPalette {
206+ var d ColorPalette
207+ if defaults != nil {
208+ d = *defaults
209+ }
210+ if src == nil {
211+ return d
212+ }
213+ result := *src
214+ if result.Background == "" {
215+ result.Background = d.Background
216+ }
217+ if result.Surface == "" {
218+ result.Surface = d.Surface
219+ }
220+ if result.SurfaceVariant == "" {
221+ result.SurfaceVariant = d.SurfaceVariant
222+ }
223+ if result.Text == "" {
224+ result.Text = d.Text
225+ }
226+ if result.Muted == "" {
227+ result.Muted = d.Muted
228+ }
229+ if result.Accent == "" {
230+ result.Accent = d.Accent
231+ }
232+ if result.Border == "" {
233+ result.Border = d.Border
234+ }
235+ return result
236+}
237+
238+// MergeFonts returns a ThemeFonts where zero/empty fields in src are filled from defaults.
239+func MergeFonts(src *ThemeFonts, defaults ThemeFonts) ThemeFonts {
240+ if src == nil {
241+ return defaults
242+ }
243+ result := *src
244+ if result.SansFont == "" {
245+ result.SansFont = defaults.SansFont
246+ }
247+ if result.SerifFont == "" {
248+ result.SerifFont = defaults.SerifFont
249+ }
250+ if result.MonospaceFont == "" {
251+ result.MonospaceFont = defaults.MonospaceFont
252+ }
253+ if result.GtkFont == "" {
254+ result.GtkFont = defaults.GtkFont
255+ }
256+ if result.DefaultSize <= 0 {
257+ result.DefaultSize = defaults.DefaultSize
258+ }
259+ return result
260+}
261+
262+// ValidateModeColorsHex checks every mode color field is a valid #RRGGBB hex color.
263+func ValidateModeColorsHex(colors *ThemeModeColors, prefix string) []ThemeWarning {
264+ if colors == nil {
265+ return []ThemeWarning{{Field: prefix, Message: "mode colors are nil"}}
266+ }
267+ var warnings []ThemeWarning
268+ checkRequiredHex(&warnings, prefix, "pane_mode", colors.PaneMode)
269+ checkRequiredHex(&warnings, prefix, "tab_mode", colors.TabMode)
270+ checkRequiredHex(&warnings, prefix, "session_mode", colors.SessionMode)
271+ checkRequiredHex(&warnings, prefix, "resize_mode", colors.ResizeMode)
272+ return warnings
273+}
274+
275+// IsModeColorsValid returns true when every mode color is a valid #RRGGBB hex color.
276+func IsModeColorsValid(colors *ThemeModeColors) bool {
277+ return len(ValidateModeColorsHex(colors, "mode_colors")) == 0
278+}
279+
280+// MergeModeColors returns a ThemeModeColors where empty fields in src are filled from defaults.
281+func MergeModeColors(src *ThemeModeColors, defaults ThemeModeColors) ThemeModeColors {
282+ if src == nil {
283+ return defaults
284+ }
285+ result := *src
286+ if result.PaneMode == "" {
287+ result.PaneMode = defaults.PaneMode
288+ }
289+ if result.TabMode == "" {
290+ result.TabMode = defaults.TabMode
291+ }
292+ if result.SessionMode == "" {
293+ result.SessionMode = defaults.SessionMode
294+ }
295+ if result.ResizeMode == "" {
296+ result.ResizeMode = defaults.ResizeMode
297+ }
298+ return result
299+}
A internal/domain/entity/theme_test.go +528 -0
@@ -0,0 +1,528 @@
1+package entity
2+
3+import (
4+ "testing"
5+)
6+
7+// TestValidateHexColor_ValidHex tests that standard hex colors are recognized.
8+func TestValidateHexColor_ValidHex(t *testing.T) {
9+ valid := []string{
10+ "#000000",
11+ "#ffffff",
12+ "#0a0a0b",
13+ "#4ade80",
14+ "#4A90E2",
15+ "#FFA500",
16+ "#9B59B6",
17+ "#00D4AA",
18+ }
19+
20+ for _, hex := range valid {
21+ if !IsValidHex(hex) {
22+ t.Errorf("expected %q to be a valid hex color", hex)
23+ }
24+ }
25+}
26+
27+// TestValidateHexColor_InvalidHex tests that non-hex or malformed strings are rejected.
28+func TestValidateHexColor_InvalidHex(t *testing.T) {
29+ invalid := []string{
30+ "",
31+ "rgb(0,0,0)",
32+ "#FFF", // 3-char shorthand
33+ "#FFFFFFFF", // 8-char with alpha
34+ "#GGGGGG", // non-hex chars
35+ "red",
36+ "#12", // too short
37+ "#1234567", // odd length
38+ " #ffffff ", // whitespace
39+ "#ffffff ", // trailing space
40+ }
41+
42+ for _, hex := range invalid {
43+ if IsValidHex(hex) {
44+ t.Errorf("expected %q to be invalid hex", hex)
45+ }
46+ }
47+}
48+
49+// TestDefaultThemeFonts_ReturnsSaneDefaults verifies the default font configuration has non-empty,
50+// sensible values.
51+func TestDefaultThemeFonts_ReturnsSaneDefaults(t *testing.T) {
52+ fonts := DefaultThemeFonts()
53+
54+ if fonts.SansFont != "Fira Sans" {
55+ t.Errorf("expected SansFont Fira Sans, got %q", fonts.SansFont)
56+ }
57+ if fonts.SerifFont != "Fira Sans" {
58+ t.Errorf("expected SerifFont Fira Sans, got %q", fonts.SerifFont)
59+ }
60+ if fonts.MonospaceFont != "Fira Code" {
61+ t.Errorf("expected MonospaceFont Fira Code, got %q", fonts.MonospaceFont)
62+ }
63+ if fonts.GtkFont != "Adwaita Sans" {
64+ t.Errorf("expected GtkFont Adwaita Sans, got %q", fonts.GtkFont)
65+ }
66+ if fonts.DefaultSize != 16 {
67+ t.Errorf("expected DefaultSize 16, got %d", fonts.DefaultSize)
68+ }
69+}
70+
71+// TestResolvedThemePalette_MustBeCSSSafeHex ensures that every color field
72+// in a ResolvedThemePalette passes hex validation.
73+// This is the locked contract: all palette colors in ResolvedTheme are CSS-safe hex.
74+func TestResolvedThemePalette_MustBeCSSSafeHex(t *testing.T) {
75+ // A well-formed palette that SHOULD be produced by resolution.
76+ validPalette := ResolvedThemePalette{
77+ Background: "#0a0a0b",
78+ Surface: "#1a1a1b",
79+ SurfaceVariant: "#2d2d2d",
80+ Text: "#ffffff",
81+ Muted: "#909090",
82+ Accent: "#4ade80",
83+ Border: "#333333",
84+ }
85+
86+ fields := map[string]string{
87+ "Background": validPalette.Background,
88+ "Surface": validPalette.Surface,
89+ "SurfaceVariant": validPalette.SurfaceVariant,
90+ "Text": validPalette.Text,
91+ "Muted": validPalette.Muted,
92+ "Accent": validPalette.Accent,
93+ "Border": validPalette.Border,
94+ }
95+
96+ for name, value := range fields {
97+ if !IsValidHex(value) {
98+ t.Errorf("ResolvedThemePalette.%s = %q is not a valid CSS-safe hex color", name, value)
99+ }
100+ }
101+}
102+
103+// TestResolvedThemePalette_RejectsInvalidColors verifies that any non-hex color
104+// in a palette is detectable by a validation helper.
105+func TestResolvedThemePalette_RejectsInvalidColors(t *testing.T) {
106+ invalidFields := []struct {
107+ name string
108+ value string
109+ }{
110+ {"Background", "rgb(0,0,0)"},
111+ {"Background", "#F"},
112+ {"Surface", "white"},
113+ {"Text", "#GGGGGG"},
114+ {"Accent", "hsl(0,0%,0%)"},
115+ {"Border", ""},
116+ }
117+
118+ for _, tc := range invalidFields {
119+ if IsValidHex(tc.value) {
120+ t.Errorf("field %q value %q should be rejected as invalid hex", tc.name, tc.value)
121+ }
122+ }
123+}
124+
125+// TestThemeModeColors_DefaultsAreCSSSafe ensures the default mode colors are valid hex.
126+func TestThemeModeColors_DefaultsAreCSSSafe(t *testing.T) {
127+ // Per the existing ui/theme palette: defaults are #4A90E2, #FFA500, #9B59B6, #00D4AA
128+ defaults := ThemeModeColors{
129+ PaneMode: "#4A90E2",
130+ TabMode: "#FFA500",
131+ SessionMode: "#9B59B6",
132+ ResizeMode: "#00D4AA",
133+ }
134+
135+ fields := map[string]string{
136+ "PaneMode": defaults.PaneMode,
137+ "TabMode": defaults.TabMode,
138+ "SessionMode": defaults.SessionMode,
139+ "ResizeMode": defaults.ResizeMode,
140+ }
141+
142+ for name, value := range fields {
143+ if !IsValidHex(value) {
144+ t.Errorf("ThemeModeColors.%s = %q is not a valid CSS-safe hex color", name, value)
145+ }
146+ }
147+}
148+
149+// TestResolvedTheme_StructFieldsExist is a compile-time guard and field-inspection test
150+// to verify that the ResolvedTheme struct carries all required fields from the locked contract.
151+func TestResolvedTheme_StructFieldsExist(t *testing.T) {
152+ rt := ResolvedTheme{
153+ LightPalette: ResolvedThemePalette{
154+ Background: "#0a0a0b",
155+ Surface: "#1a1a1b",
156+ },
157+ DarkPalette: ResolvedThemePalette{
158+ Background: "#fafafa",
159+ Surface: "#ffffff",
160+ },
161+ ActivePalette: ResolvedThemePalette{
162+ Background: "#0a0a0b",
163+ },
164+ PrefersDark: true,
165+ ColorSchemeSource: "adwaita",
166+ ThemeSource: ThemeSourceMetadata{Kind: ThemeSourceConfig},
167+ Fonts: DefaultThemeFonts(),
168+ UIScale: 1.0,
169+ ModeColors: ThemeModeColors{
170+ PaneMode: "#4A90E2",
171+ TabMode: "#FFA500",
172+ SessionMode: "#9B59B6",
173+ ResizeMode: "#00D4AA",
174+ },
175+ Warnings: []ThemeWarning{
176+ {Field: "external", Message: "malformed external theme, using last-good"},
177+ },
178+ }
179+
180+ // Verify the struct can be constructed and fields are accessible.
181+ if !rt.PrefersDark {
182+ t.Error("expected PrefersDark to be true")
183+ }
184+ if rt.ColorSchemeSource != "adwaita" {
185+ t.Errorf("expected ColorSchemeSource 'adwaita', got %q", rt.ColorSchemeSource)
186+ }
187+ if rt.ThemeSource.Kind != ThemeSourceConfig {
188+ t.Errorf("expected ThemeSource.Kind 'config', got %q", rt.ThemeSource.Kind)
189+ }
190+ if rt.UIScale != 1.0 {
191+ t.Errorf("expected UIScale 1.0, got %f", rt.UIScale)
192+ }
193+ if len(rt.Warnings) != 1 {
194+ t.Errorf("expected 1 warning, got %d", len(rt.Warnings))
195+ }
196+ if rt.Fonts.DefaultSize != 16 {
197+ t.Errorf("expected DefaultSize 16, got %d", rt.Fonts.DefaultSize)
198+ }
199+}
200+
201+// TestThemeSourceKind_ConstantsAreDistinct verifies the ThemeSourceKind constants
202+// have unique values so that source metadata comparisons work correctly.
203+func TestThemeSourceKind_ConstantsAreDistinct(t *testing.T) {
204+ kinds := map[ThemeSourceKind]bool{
205+ ThemeSourceDefault: true,
206+ ThemeSourceConfig: true,
207+ ThemeSourceExternal: true,
208+ }
209+
210+ if len(kinds) != 3 {
211+ t.Error("ThemeSourceKind constants must be distinct")
212+ }
213+
214+ if ThemeSourceDefault == ThemeSourceExternal {
215+ t.Error("ThemeSourceDefault must not equal ThemeSourceExternal")
216+ }
217+ if ThemeSourceDefault == ThemeSourceConfig {
218+ t.Error("ThemeSourceDefault must not equal ThemeSourceConfig")
219+ }
220+}
221+
222+// TestThemeWarning_FieldsExist verifies ThemeWarning carries the expected shape.
223+func TestThemeWarning_FieldsExist(t *testing.T) {
224+ w := ThemeWarning{
225+ Field: "light_palette.background",
226+ Message: "not a valid hex color",
227+ }
228+
229+ if w.Field == "" {
230+ t.Error("expected ThemeWarning.Field to be non-empty")
231+ }
232+ if w.Message == "" {
233+ t.Error("expected ThemeWarning.Message to be non-empty")
234+ }
235+}
236+
237+// TestExternalTheme_IsDomainPure verifies ExternalTheme can be constructed without any
238+// infrastructure imports (this is a compile-time contract via package structure).
239+func TestExternalTheme_IsDomainPure(t *testing.T) {
240+ light := ColorPalette{
241+ Background: "#0a0a0b",
242+ Surface: "#1a1a1b",
243+ }
244+ dark := ColorPalette{
245+ Background: "#fafafa",
246+ Surface: "#ffffff",
247+ }
248+
249+ ext := ExternalTheme{
250+ Name: "Noctalia Dark",
251+ Provider: "noctalia",
252+ LightPalette: &light,
253+ DarkPalette: &dark,
254+ }
255+
256+ if ext.Name != "Noctalia Dark" {
257+ t.Errorf("expected Name 'Noctalia Dark', got %q", ext.Name)
258+ }
259+ if ext.Provider != "noctalia" {
260+ t.Errorf("expected Provider 'noctalia', got %q", ext.Provider)
261+ }
262+ if ext.LightPalette == nil || ext.DarkPalette == nil {
263+ t.Error("expected non-nil palettes")
264+ }
265+}
266+
267+// TestDefaultThemeFonts_AllFieldsAreSet ensures no zero-value fields in the defaults.
268+func TestDefaultThemeFonts_AllFieldsAreSet(t *testing.T) {
269+ fonts := DefaultThemeFonts()
270+
271+ // Check that all string fields are non-empty
272+ if fonts.SansFont == "" {
273+ t.Error("SansFont should have a default value")
274+ }
275+ if fonts.SerifFont == "" {
276+ t.Error("SerifFont should have a default value")
277+ }
278+ if fonts.MonospaceFont == "" {
279+ t.Error("MonospaceFont should have a default value")
280+ }
281+ if fonts.GtkFont == "" {
282+ t.Error("GtkFont should have a default value")
283+ }
284+ if fonts.DefaultSize <= 0 {
285+ t.Errorf("DefaultSize should be positive, got %d", fonts.DefaultSize)
286+ }
287+}
288+
289+// TestDefaultThemeModeColors_ReturnsSaneDefaults verifies the default mode colors.
290+func TestDefaultThemeModeColors_ReturnsSaneDefaults(t *testing.T) {
291+ mc := DefaultThemeModeColors()
292+
293+ if mc.PaneMode != "#4A90E2" {
294+ t.Errorf("expected PaneMode #4A90E2, got %q", mc.PaneMode)
295+ }
296+ if mc.TabMode != "#FFA500" {
297+ t.Errorf("expected TabMode #FFA500, got %q", mc.TabMode)
298+ }
299+ if mc.SessionMode != "#9B59B6" {
300+ t.Errorf("expected SessionMode #9B59B6, got %q", mc.SessionMode)
301+ }
302+ if mc.ResizeMode != "#00D4AA" {
303+ t.Errorf("expected ResizeMode #00D4AA, got %q", mc.ResizeMode)
304+ }
305+
306+ // All must be valid hex
307+ if !IsValidHex(mc.PaneMode) {
308+ t.Error("default PaneMode should be valid hex")
309+ }
310+ if !IsValidHex(mc.TabMode) {
311+ t.Error("default TabMode should be valid hex")
312+ }
313+ if !IsValidHex(mc.SessionMode) {
314+ t.Error("default SessionMode should be valid hex")
315+ }
316+ if !IsValidHex(mc.ResizeMode) {
317+ t.Error("default ResizeMode should be valid hex")
318+ }
319+}
320+
321+// TestDefaultDarkPalette_ReturnsValidHex verifies the default dark palette is all valid hex.
322+func TestDefaultDarkPalette_ReturnsValidHex(t *testing.T) {
323+ p := DefaultDarkPalette()
324+
325+ fields := map[string]string{
326+ "Background": p.Background,
327+ "Surface": p.Surface,
328+ "SurfaceVariant": p.SurfaceVariant,
329+ "Text": p.Text,
330+ "Muted": p.Muted,
331+ "Accent": p.Accent,
332+ "Border": p.Border,
333+ }
334+ for name, value := range fields {
335+ if value == "" {
336+ t.Errorf("DefaultDarkPalette.%s should not be empty", name)
337+ }
338+ if !IsValidHex(value) {
339+ t.Errorf("DefaultDarkPalette.%s = %q is not valid hex", name, value)
340+ }
341+ }
342+}
343+
344+// TestDefaultLightPalette_ReturnsValidHex verifies the default light palette is all valid hex.
345+func TestDefaultLightPalette_ReturnsValidHex(t *testing.T) {
346+ p := DefaultLightPalette()
347+
348+ fields := map[string]string{
349+ "Background": p.Background,
350+ "Surface": p.Surface,
351+ "SurfaceVariant": p.SurfaceVariant,
352+ "Text": p.Text,
353+ "Muted": p.Muted,
354+ "Accent": p.Accent,
355+ "Border": p.Border,
356+ }
357+ for name, value := range fields {
358+ if value == "" {
359+ t.Errorf("DefaultLightPalette.%s should not be empty", name)
360+ }
361+ if !IsValidHex(value) {
362+ t.Errorf("DefaultLightPalette.%s = %q is not valid hex", name, value)
363+ }
364+ }
365+}
366+
367+// TestValidatePaletteHex_Valid verifies ValidatePaletteHex returns no warnings for valid palettes.
368+func TestValidatePaletteHex_Valid(t *testing.T) {
369+ p := DefaultDarkPalette()
370+ warnings := ValidatePaletteHex(&p, "dark")
371+ if len(warnings) != 0 {
372+ t.Errorf("expected no warnings for valid palette, got %d: %v", len(warnings), warnings)
373+ }
374+}
375+
376+// TestValidatePaletteHex_Invalid verifies ValidatePaletteHex returns warnings for invalid palettes.
377+func TestValidatePaletteHex_Invalid(t *testing.T) {
378+ p := ColorPalette{
379+ Background: "rgb(0,0,0)", // invalid
380+ Surface: "#ffffff",
381+ SurfaceVariant: "not-a-color",
382+ Text: "", // invalid
383+ Muted: "#GGGGGG", // invalid
384+ Accent: "#123", // 3-char shorthand, rejected
385+ Border: "#123456",
386+ }
387+ warnings := ValidatePaletteHex(&p, "test")
388+ if len(warnings) == 0 {
389+ t.Error("expected warnings for invalid palette, got none")
390+ }
391+}
392+
393+// TestValidatePaletteHex_Nil verifies ValidatePaletteHex handles nil.
394+func TestValidatePaletteHex_Nil(t *testing.T) {
395+ warnings := ValidatePaletteHex(nil, "ext")
396+ if len(warnings) == 0 {
397+ t.Error("expected warnings for nil palette")
398+ }
399+}
400+
401+// TestIsPaletteValid verifies IsPaletteValid returns correct truthiness.
402+func TestIsPaletteValid_EdgeCases(t *testing.T) {
403+ if IsPaletteValid(nil) {
404+ t.Error("nil palette should not be valid")
405+ }
406+
407+ valid := DefaultDarkPalette()
408+ if !IsPaletteValid(&valid) {
409+ t.Error("default dark palette should be valid")
410+ }
411+
412+ partial := ColorPalette{
413+ Background: "#000000",
414+ Surface: "#111111",
415+ // missing fields are ""
416+ }
417+ if IsPaletteValid(&partial) {
418+ t.Error("partial palette with empty fields should not be valid")
419+ }
420+}
421+
422+// TestMergePalette_FillsEmpties verifies MergePalette fills empty fields from defaults.
423+func TestMergePalette_FillsEmpties(t *testing.T) {
424+ src := &ColorPalette{
425+ Background: "#000000",
426+ Surface: "", // should be filled
427+ Text: "#ffffff",
428+ }
429+ def := DefaultDarkPalette()
430+ result := MergePalette(src, &def)
431+
432+ if result.Background != "#000000" {
433+ t.Errorf("Background should be from src, got %q", result.Background)
434+ }
435+ if result.Surface != def.Surface {
436+ t.Errorf("Surface should be filled from defaults, got %q", result.Surface)
437+ }
438+ if result.Text != "#ffffff" {
439+ t.Errorf("Text should be from src, got %q", result.Text)
440+ }
441+}
442+
443+// TestMergePalette_NilSrc verifies MergePalette returns defaults when src is nil.
444+func TestMergePalette_NilSrc(t *testing.T) {
445+ def := DefaultDarkPalette()
446+ result := MergePalette(nil, &def)
447+
448+ if result.Background != def.Background {
449+ t.Errorf("expected default Background, got %q", result.Background)
450+ }
451+}
452+
453+// TestMergeFonts_FillsEmpties verifies MergeFonts fills zero fields from defaults.
454+func TestMergeFonts_FillsEmpties(t *testing.T) {
455+ src := &ThemeFonts{
456+ SansFont: "Custom Sans",
457+ // SerifFont empty
458+ DefaultSize: 0, // zero
459+ }
460+ def := DefaultThemeFonts()
461+ result := MergeFonts(src, def)
462+
463+ if result.SansFont != "Custom Sans" {
464+ t.Errorf("SansFont should be from src, got %q", result.SansFont)
465+ }
466+ if result.SerifFont != def.SerifFont {
467+ t.Errorf("SerifFont should be from defaults, got %q", result.SerifFont)
468+ }
469+ if result.DefaultSize != def.DefaultSize {
470+ t.Errorf("DefaultSize should be from defaults, got %d", result.DefaultSize)
471+ }
472+}
473+
474+// TestMergeFonts_NilSrc verifies MergeFonts returns defaults when src is nil.
475+func TestMergeFonts_NilSrc(t *testing.T) {
476+ def := DefaultThemeFonts()
477+ result := MergeFonts(nil, def)
478+
479+ if result.SansFont != def.SansFont {
480+ t.Errorf("expected default SansFont, got %q", result.SansFont)
481+ }
482+}
483+
484+// TestMergeModeColors_FillsEmpties verifies mode color merging.
485+func TestMergeModeColors_FillsEmpties(t *testing.T) {
486+ src := &ThemeModeColors{
487+ PaneMode: "#111111",
488+ // TabMode empty
489+ }
490+ def := DefaultThemeModeColors()
491+ result := MergeModeColors(src, def)
492+
493+ if result.PaneMode != "#111111" {
494+ t.Errorf("PaneMode should be from src, got %q", result.PaneMode)
495+ }
496+ if result.TabMode != def.TabMode {
497+ t.Errorf("TabMode should be from defaults, got %q", result.TabMode)
498+ }
499+}
500+
501+// TestValidateModeColorsHex_ReturnsWarnings verifies invalid mode colors are detected.
502+func TestValidateModeColorsHex_ReturnsWarnings(t *testing.T) {
503+ colors := &ThemeModeColors{
504+ PaneMode: "#111111",
505+ TabMode: "not-a-color",
506+ SessionMode: "#333333",
507+ ResizeMode: "",
508+ }
509+
510+ warnings := ValidateModeColorsHex(colors, "mode_colors")
511+ if len(warnings) != 2 {
512+ t.Fatalf("expected 2 invalid mode color warnings, got %d: %+v", len(warnings), warnings)
513+ }
514+ if IsModeColorsValid(colors) {
515+ t.Error("expected invalid mode colors to be rejected")
516+ }
517+}
518+
519+// TestValidateModeColorsHex_DefaultsAreValid verifies default mode colors satisfy validation.
520+func TestValidateModeColorsHex_DefaultsAreValid(t *testing.T) {
521+ defaults := DefaultThemeModeColors()
522+ if warnings := ValidateModeColorsHex(&defaults, "mode_colors"); len(warnings) != 0 {
523+ t.Fatalf("expected default mode colors to be valid, got warnings: %+v", warnings)
524+ }
525+ if !IsModeColorsValid(&defaults) {
526+ t.Error("expected default mode colors to be valid")
527+ }
528+}
M internal/infrastructure/config/defaults.go +49 -1
@@ -1,5 +1,12 @@
1 package config1 package config
2 2
3+import (
4+ "os"
5+ "path/filepath"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
3 // Default configuration constants10 // Default configuration constants
4 const (11 const (
5 // History defaults12 // History defaults
@@ -14,7 +21,12 @@ const (
14 defaultMaxLogFiles = 100 // session log files21 defaultMaxLogFiles = 100 // session log files
15 22
16 // Appearance defaults23 // Appearance defaults
17- defaultFontSize = 16 // points24+ defaultFontSize = 16 // points
25+ defaultExternalThemeProvider = "noctalia"
26+ defaultExternalThemeFormat = "colors-json"
27+ defaultExternalThemeColorsFilename = "colors.json"
28+ defaultExternalThemeTemplateFormat = "dumber-json"
29+ defaultExternalThemeTemplateFilename = "noctalia-theme.json"
18 30
19 // Workspace defaults31 // Workspace defaults
20 defaultNewPaneURL = "about:blank"32 defaultNewPaneURL = "about:blank"
@@ -104,6 +116,36 @@ func getDefaultLogDir() string {
104 return logDir116 return logDir
105 }117 }
106 118
119+// getDefaultExternalThemePath returns Noctalia's native colors.json path.
120+func getDefaultExternalThemePath() string {
121+ return getDefaultExternalThemePathForFormat(defaultExternalThemeFormat)
122+}
123+
124+func getDefaultExternalThemePathForFormat(format string) string {
125+ switch format {
126+ case defaultExternalThemeTemplateFormat:
127+ return getDefaultExternalThemeTemplatePath()
128+ default:
129+ return getDefaultNoctaliaColorsPath()
130+ }
131+}
132+
133+func getDefaultNoctaliaColorsPath() string {
134+ configHome, err := os.UserConfigDir()
135+ if err != nil {
136+ return ""
137+ }
138+ return filepath.Join(configHome, defaultExternalThemeProvider, defaultExternalThemeColorsFilename)
139+}
140+
141+func getDefaultExternalThemeTemplatePath() string {
142+ configDir, err := GetConfigDir()
143+ if err != nil {
144+ return ""
145+ }
146+ return filepath.Join(configDir, defaultExternalThemeTemplateFilename)
147+}
148+
107 // DefaultConfig returns the default configuration values for dumber.149 // DefaultConfig returns the default configuration values for dumber.
108 func DefaultConfig() *Config {150 func DefaultConfig() *Config {
109 browsingContextDefaults := defaultBrowsingContextConfig()151 browsingContextDefaults := defaultBrowsingContextConfig()
@@ -164,6 +206,12 @@ func DefaultConfig() *Config {
164 Border: "#3f3f46",206 Border: "#3f3f46",
165 },207 },
166 ColorScheme: "default", // default follows system theme208 ColorScheme: "default", // default follows system theme
209+ ExternalTheme: entity.ExternalThemeConfig{
210+ Enabled: false,
211+ Provider: defaultExternalThemeProvider,
212+ Format: defaultExternalThemeFormat,
213+ Path: getDefaultExternalThemePath(),
214+ },
167 },215 },
168 Debug: DebugConfig{216 Debug: DebugConfig{
169 EnableDevTools: true,217 EnableDevTools: true,
A internal/infrastructure/config/external_theme_test.go +285 -0
@@ -0,0 +1,285 @@
1+package config
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/spf13/viper"
11+ "github.com/stretchr/testify/require"
12+)
13+
14+func TestDefaultConfig_ExternalThemeDefaults(t *testing.T) {
15+ cfg := DefaultConfig()
16+
17+ require.False(t, cfg.Appearance.ExternalTheme.Enabled)
18+ require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider)
19+ require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format)
20+ require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(cfg.Appearance.ExternalTheme.Path, 2))
21+}
22+
23+func TestManagerSetDefaults_ExternalThemeViperDefaults(t *testing.T) {
24+ mgr := &Manager{viper: viper.New()}
25+ mgr.setDefaults()
26+
27+ require.False(t, mgr.viper.GetBool("appearance.external_theme.enabled"))
28+ require.Equal(t, "noctalia", mgr.viper.GetString("appearance.external_theme.provider"))
29+ require.Equal(t, "colors-json", mgr.viper.GetString("appearance.external_theme.format"))
30+ require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(
31+ mgr.viper.GetString("appearance.external_theme.path"),
32+ 2,
33+ ))
34+}
35+
36+func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) {
37+ configHome := t.TempDir()
38+ t.Setenv("ENV", "")
39+ t.Setenv("XDG_CONFIG_HOME", configHome)
40+
41+ configDir := filepath.Join(configHome, appName)
42+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
43+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
44+[appearance.external_theme]
45+enabled = true
46+provider = 'noctalia'
47+path = ''
48+`), filePerm))
49+
50+ mgr, err := NewManager()
51+ require.NoError(t, err)
52+ require.NoError(t, mgr.Load())
53+
54+ externalTheme := mgr.Get().Appearance.ExternalTheme
55+ require.True(t, externalTheme.Enabled)
56+ require.Equal(t, "noctalia", externalTheme.Provider)
57+ require.Equal(t, "colors-json", externalTheme.Format)
58+ require.Equal(t, filepath.Join(configHome, "noctalia", "colors.json"), externalTheme.Path)
59+}
60+
61+func TestManagerLoad_ExternalThemeDumberJSONEmptyPathUsesTemplateDefault(t *testing.T) {
62+ configHome := t.TempDir()
63+ t.Setenv("ENV", "")
64+ t.Setenv("XDG_CONFIG_HOME", configHome)
65+
66+ configDir := filepath.Join(configHome, appName)
67+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
68+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
69+[appearance.external_theme]
70+enabled = true
71+provider = 'noctalia'
72+format = 'dumber-json'
73+path = ''
74+`), filePerm))
75+
76+ mgr, err := NewManager()
77+ require.NoError(t, err)
78+ require.NoError(t, mgr.Load())
79+
80+ externalTheme := mgr.Get().Appearance.ExternalTheme
81+ require.True(t, externalTheme.Enabled)
82+ require.Equal(t, "noctalia", externalTheme.Provider)
83+ require.Equal(t, "dumber-json", externalTheme.Format)
84+ require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
85+}
86+
87+func TestValidateConfig_ExternalTheme(t *testing.T) {
88+ tests := []struct {
89+ name string
90+ mutate func(*Config)
91+ wantErr bool
92+ wantText string
93+ }{
94+ {
95+ name: "disabled empty path validates",
96+ mutate: func(cfg *Config) {
97+ cfg.Appearance.ExternalTheme.Path = ""
98+ },
99+ },
100+ {
101+ name: "enabled colors-json with path validates",
102+ mutate: func(cfg *Config) {
103+ cfg.Appearance.ExternalTheme.Enabled = true
104+ cfg.Appearance.ExternalTheme.Format = "colors-json"
105+ cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json"
106+ },
107+ },
108+ {
109+ name: "enabled dumber-json with path validates",
110+ mutate: func(cfg *Config) {
111+ cfg.Appearance.ExternalTheme.Enabled = true
112+ cfg.Appearance.ExternalTheme.Format = "dumber-json"
113+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
114+ },
115+ },
116+ {
117+ name: "enabled empty path uses format-specific default",
118+ mutate: func(cfg *Config) {
119+ cfg.Appearance.ExternalTheme.Enabled = true
120+ cfg.Appearance.ExternalTheme.Path = ""
121+ },
122+ },
123+ {
124+ name: "enabled rejects unsupported provider",
125+ mutate: func(cfg *Config) {
126+ cfg.Appearance.ExternalTheme.Enabled = true
127+ cfg.Appearance.ExternalTheme.Provider = "other"
128+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
129+ },
130+ wantErr: true,
131+ wantText: "appearance.external_theme.provider",
132+ },
133+ {
134+ name: "enabled rejects unsupported format",
135+ mutate: func(cfg *Config) {
136+ cfg.Appearance.ExternalTheme.Enabled = true
137+ cfg.Appearance.ExternalTheme.Format = "toml"
138+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
139+ },
140+ wantErr: true,
141+ wantText: "appearance.external_theme.format",
142+ },
143+ }
144+
145+ for _, tt := range tests {
146+ t.Run(tt.name, func(t *testing.T) {
147+ cfg := DefaultConfig()
148+ tt.mutate(cfg)
149+ normalizeConfig(cfg)
150+
151+ err := validateConfig(cfg)
152+ if tt.wantErr {
153+ require.Error(t, err)
154+ require.Contains(t, err.Error(), tt.wantText)
155+ return
156+ }
157+ require.NoError(t, err)
158+ })
159+ }
160+}
161+
162+func TestNormalizeConfig_ExternalTheme(t *testing.T) {
163+ cfg := DefaultConfig()
164+ cfg.Appearance.ExternalTheme.Provider = " NOCTALIA "
165+ cfg.Appearance.ExternalTheme.Format = " COLORS-JSON "
166+ cfg.Appearance.ExternalTheme.Path = " /tmp/colors.json "
167+
168+ normalizeConfig(cfg)
169+
170+ require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider)
171+ require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format)
172+ require.Equal(t, "/tmp/colors.json", cfg.Appearance.ExternalTheme.Path)
173+
174+ cfg.Appearance.ExternalTheme.Provider = ""
175+ cfg.Appearance.ExternalTheme.Format = ""
176+ normalizeConfig(cfg)
177+ require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider)
178+ require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format)
179+}
180+
181+func TestSchemaProvider_ExternalThemeKeys(t *testing.T) {
182+ keys := NewSchemaProvider().GetSchema()
183+ byKey := map[string]bool{}
184+ for _, key := range keys {
185+ if key.Key == "appearance.external_theme.provider" {
186+ require.Equal(t, SectionAppearance, key.Section)
187+ require.Equal(t, "noctalia", key.Default)
188+ require.Equal(t, []string{"noctalia"}, key.Values)
189+ }
190+ if key.Key == "appearance.external_theme.format" {
191+ require.Equal(t, SectionAppearance, key.Section)
192+ require.Equal(t, "colors-json", key.Default)
193+ require.Equal(t, []string{"colors-json", "dumber-json"}, key.Values)
194+ }
195+ if key.Key == "appearance.external_theme.path" {
196+ require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(key.Default, 2))
197+ }
198+ byKey[key.Key] = true
199+ }
200+
201+ for _, key := range []string{
202+ "appearance.external_theme.enabled",
203+ "appearance.external_theme.provider",
204+ "appearance.external_theme.format",
205+ "appearance.external_theme.path",
206+ } {
207+ require.True(t, byKey[key], "missing schema key %s", key)
208+ }
209+}
210+
211+func TestWriteConfigOrdered_IncludesExternalThemeTable(t *testing.T) {
212+ path := filepath.Join(t.TempDir(), "config.toml")
213+ cfg := DefaultConfig()
214+
215+ require.NoError(t, WriteConfigOrdered(cfg, path))
216+ content, err := os.ReadFile(path)
217+ require.NoError(t, err)
218+ require.Contains(t, string(content), "[appearance.external_theme]")
219+ require.Contains(t, string(content), "enabled = false")
220+ require.Contains(t, string(content), "provider = 'noctalia'")
221+ require.Contains(t, string(content), "format = 'colors-json'")
222+ require.Contains(t, string(content), "colors.json")
223+}
224+
225+func TestBuildSystemviewConfigPayload_ProjectsExternalTheme(t *testing.T) {
226+ cfg := DefaultConfig()
227+ cfg.Appearance.ExternalTheme.Enabled = true
228+ cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json"
229+
230+ payload := BuildSystemviewConfigPayload(cfg, nil)
231+
232+ require.True(t, payload.Appearance.ExternalTheme.Enabled)
233+ require.Equal(t, "noctalia", payload.Appearance.ExternalTheme.Provider)
234+ require.Equal(t, "colors-json", payload.Appearance.ExternalTheme.Format)
235+ require.Equal(t, "/tmp/colors.json", payload.Appearance.ExternalTheme.Path)
236+}
237+
238+func lastPathElements(path string, count int) string {
239+ cleaned := filepath.Clean(path)
240+ parts := make([]string, 0, count)
241+ for len(parts) < count && cleaned != "." && cleaned != string(filepath.Separator) {
242+ parts = append([]string{filepath.Base(cleaned)}, parts...)
243+ cleaned = filepath.Dir(cleaned)
244+ }
245+ return filepath.Join(parts...)
246+}
247+
248+func TestWebUIConfigGatewaySaveWebUIConfig_ExternalThemeRoundTrip(t *testing.T) {
249+ path := filepath.Join(t.TempDir(), "config.toml")
250+ require.NoError(t, os.WriteFile(path, []byte(""), 0o644))
251+
252+ v := viper.New()
253+ v.SetConfigFile(path)
254+ require.NoError(t, v.ReadInConfig())
255+
256+ previousGlobal := globalManager
257+ defer func() { globalManager = previousGlobal }()
258+
259+ mgr := &Manager{config: DefaultConfig(), viper: v}
260+ globalManager = mgr
261+
262+ webCfg := dto.WebUIConfig{
263+ Appearance: buildSystemviewAppearancePayload(DefaultConfig().Appearance),
264+ }
265+ webCfg.Appearance.ExternalTheme = dto.WebUIExternalThemeConfig{
266+ Enabled: true,
267+ Provider: " NOCTALIA ",
268+ Format: " COLORS-JSON ",
269+ Path: " /tmp/colors.json ",
270+ }
271+ webCfg.DefaultUIScale = DefaultConfig().DefaultUIScale
272+ webCfg.DefaultSearchEngine = DefaultConfig().DefaultSearchEngine
273+ webCfg.SearchShortcuts = map[string]dto.SearchShortcut{}
274+ for key, shortcut := range DefaultConfig().SearchShortcuts {
275+ webCfg.SearchShortcuts[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
276+ }
277+
278+ require.NoError(t, NewWebUIConfigGateway(mgr).SaveWebUIConfig(context.Background(), webCfg))
279+
280+ saved := mgr.Get()
281+ require.True(t, saved.Appearance.ExternalTheme.Enabled)
282+ require.Equal(t, "noctalia", saved.Appearance.ExternalTheme.Provider)
283+ require.Equal(t, "colors-json", saved.Appearance.ExternalTheme.Format)
284+ require.Equal(t, "/tmp/colors.json", saved.Appearance.ExternalTheme.Path)
285+}
M internal/infrastructure/config/loader.go +20 -0
@@ -429,6 +429,22 @@ func normalizeEngineConfig(config *Config) {
429 }429 }
430 430
431 func normalizeAppearance(config *Config) {431 func normalizeAppearance(config *Config) {
432+ config.Appearance.ExternalTheme.Provider = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Provider))
433+ config.Appearance.ExternalTheme.Format = strings.ToLower(strings.TrimSpace(config.Appearance.ExternalTheme.Format))
434+ config.Appearance.ExternalTheme.Path = strings.TrimSpace(config.Appearance.ExternalTheme.Path)
435+ if config.Appearance.ExternalTheme.Provider == "" {
436+ config.Appearance.ExternalTheme.Provider = defaultExternalThemeProvider
437+ }
438+ if config.Appearance.ExternalTheme.Format == "" {
439+ config.Appearance.ExternalTheme.Format = defaultExternalThemeFormat
440+ }
441+ if config.Appearance.ExternalTheme.Enabled &&
442+ config.Appearance.ExternalTheme.Provider == defaultExternalThemeProvider &&
443+ isSupportedExternalThemeFormat(config.Appearance.ExternalTheme.Format) &&
444+ config.Appearance.ExternalTheme.Path == "" {
445+ config.Appearance.ExternalTheme.Path = getDefaultExternalThemePathForFormat(config.Appearance.ExternalTheme.Format)
446+ }
447+
432 switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {448 switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
433 case strings.ToLower(ThemePreferDark):449 case strings.ToLower(ThemePreferDark):
434 config.Appearance.ColorScheme = ThemePreferDark450 config.Appearance.ColorScheme = ThemePreferDark
@@ -640,6 +656,10 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
640 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)656 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
641 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)657 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
642 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)658 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
659+ m.viper.SetDefault("appearance.external_theme.enabled", defaults.Appearance.ExternalTheme.Enabled)
660+ m.viper.SetDefault("appearance.external_theme.provider", defaults.Appearance.ExternalTheme.Provider)
661+ m.viper.SetDefault("appearance.external_theme.format", defaults.Appearance.ExternalTheme.Format)
662+ m.viper.SetDefault("appearance.external_theme.path", defaults.Appearance.ExternalTheme.Path)
643 }663 }
644 664
645 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {665 func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
M internal/infrastructure/config/schema_provider.go +30 -0
@@ -175,6 +175,36 @@ func (*SchemaProvider) getAppearanceKeys(defaults *Config) []entity.ConfigKeyInf
175 Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)",175 Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)",
176 Section: SectionAppearance,176 Section: SectionAppearance,
177 },177 },
178+ {
179+ Key: "appearance.external_theme.enabled",
180+ Type: "bool",
181+ Default: fmt.Sprintf("%t", defaults.Appearance.ExternalTheme.Enabled),
182+ Description: "Enable loading theme settings from an external provider",
183+ Section: SectionAppearance,
184+ },
185+ {
186+ Key: "appearance.external_theme.provider",
187+ Type: "string",
188+ Default: defaults.Appearance.ExternalTheme.Provider,
189+ Description: "External theme provider",
190+ Values: []string{"noctalia"},
191+ Section: SectionAppearance,
192+ },
193+ {
194+ Key: "appearance.external_theme.format",
195+ Type: "string",
196+ Default: defaults.Appearance.ExternalTheme.Format,
197+ Description: "External theme file format",
198+ Values: []string{"colors-json", "dumber-json"},
199+ Section: SectionAppearance,
200+ },
201+ {
202+ Key: "appearance.external_theme.path",
203+ Type: "string",
204+ Default: defaults.Appearance.ExternalTheme.Path,
205+ Description: "Path to the external theme file",
206+ Section: SectionAppearance,
207+ },
178 }208 }
179 }209 }
180 210
M internal/infrastructure/config/validation.go +29 -0
@@ -104,9 +104,38 @@ func validateAppearance(config *Config) []string {
104 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {104 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
105 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")105 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
106 }106 }
107+ validationErrors = append(validationErrors, validateExternalTheme(config)...)
107 return validationErrors108 return validationErrors
108 }109 }
109 110
111+func validateExternalTheme(config *Config) []string {
112+ externalTheme := config.Appearance.ExternalTheme
113+ if !externalTheme.Enabled {
114+ return nil
115+ }
116+
117+ var validationErrors []string
118+ if externalTheme.Provider != defaultExternalThemeProvider {
119+ validationErrors = append(validationErrors, "appearance.external_theme.provider must be one of: noctalia")
120+ }
121+ if !isSupportedExternalThemeFormat(externalTheme.Format) {
122+ validationErrors = append(validationErrors, "appearance.external_theme.format must be one of: colors-json, dumber-json")
123+ }
124+ if strings.TrimSpace(externalTheme.Path) == "" {
125+ validationErrors = append(validationErrors, "appearance.external_theme.path is required when external_theme is enabled")
126+ }
127+ return validationErrors
128+}
129+
130+func isSupportedExternalThemeFormat(format string) bool {
131+ switch format {
132+ case defaultExternalThemeFormat, defaultExternalThemeTemplateFormat:
133+ return true
134+ default:
135+ return false
136+ }
137+}
138+
110 func validateSearchEngine(config *Config) []string {139 func validateSearchEngine(config *Config) []string {
111 if config.DefaultSearchEngine == "" {140 if config.DefaultSearchEngine == "" {
112 return []string{"default_search_engine cannot be empty"}141 return []string{"default_search_engine cannot be empty"}
M internal/infrastructure/config/webui_gateway.go +4 -0
@@ -64,6 +64,10 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg dto.WebUIC
64 Accent: cfg.Appearance.DarkPalette.Accent,64 Accent: cfg.Appearance.DarkPalette.Accent,
65 Border: cfg.Appearance.DarkPalette.Border,65 Border: cfg.Appearance.DarkPalette.Border,
66 }66 }
67+ current.Appearance.ExternalTheme.Enabled = cfg.Appearance.ExternalTheme.Enabled
68+ current.Appearance.ExternalTheme.Provider = cfg.Appearance.ExternalTheme.Provider
69+ current.Appearance.ExternalTheme.Format = cfg.Appearance.ExternalTheme.Format
70+ current.Appearance.ExternalTheme.Path = cfg.Appearance.ExternalTheme.Path
67 current.DefaultUIScale = cfg.DefaultUIScale71 current.DefaultUIScale = cfg.DefaultUIScale
68 current.DefaultSearchEngine = cfg.DefaultSearchEngine72 current.DefaultSearchEngine = cfg.DefaultSearchEngine
69 if len(cfg.SearchShortcuts) == 0 {73 if len(cfg.SearchShortcuts) == 0 {
M internal/infrastructure/config/webui_payload.go +6 -0
@@ -58,6 +58,12 @@ func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppe
58 ColorScheme: appearance.ColorScheme,58 ColorScheme: appearance.ColorScheme,
59 LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),59 LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
60 DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),60 DarkPalette: buildSystemviewPalettePayload(appearance.DarkPalette, defaults.DarkPalette),
61+ ExternalTheme: dto.WebUIExternalThemeConfig{
62+ Enabled: appearance.ExternalTheme.Enabled,
63+ Provider: appearance.ExternalTheme.Provider,
64+ Format: appearance.ExternalTheme.Format,
65+ Path: appearance.ExternalTheme.Path,
66+ },
61 }67 }
62 }68 }
63 69
A internal/infrastructure/externaltheme/noctalia/source.go +364 -0
@@ -0,0 +1,364 @@
1+package noctalia
2+
3+import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+
15+ "github.com/bnema/dumber/internal/application/port"
16+ "github.com/bnema/dumber/internal/domain/entity"
17+)
18+
19+const (
20+ providerName = "noctalia"
21+ formatName = formatColorsJSON
22+ formatColorsJSON = "colors-json"
23+ formatDumberJSON = "dumber-json"
24+ colorsJSONName = "Noctalia colors.json"
25+ dumberJSONName = "Noctalia Dumber JSON"
26+)
27+
28+var _ port.ExternalThemeSource = (*FileSource)(nil)
29+
30+// FileSource reads a Noctalia external theme file.
31+//
32+// The default colors-json format reads Noctalia's native colors.json directly.
33+// The dumber-json format remains available for explicit Dumber-specific user templates.
34+type FileSource struct {
35+ mu sync.RWMutex
36+ enabled bool
37+ path string
38+ format string
39+ identity string
40+}
41+
42+// NewFileSource creates a Noctalia colors-json file source.
43+func NewFileSource(enabled bool, path string) *FileSource {
44+ source := &FileSource{}
45+ source.Configure(entity.ExternalThemeConfig{
46+ Enabled: enabled,
47+ Provider: providerName,
48+ Format: formatName,
49+ Path: path,
50+ })
51+ return source
52+}
53+
54+// NewFileSourceFromConfig creates a file source from external theme config.
55+func NewFileSourceFromConfig(cfg entity.ExternalThemeConfig) *FileSource {
56+ source := &FileSource{}
57+ source.Configure(cfg)
58+ return source
59+}
60+
61+// Configure updates the source from a normalized or raw external theme config snapshot.
62+func (s *FileSource) Configure(cfg entity.ExternalThemeConfig) {
63+ provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
64+ enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
65+ path := strings.TrimSpace(cfg.Path)
66+
67+ s.mu.Lock()
68+ defer s.mu.Unlock()
69+ s.enabled = enabled
70+ s.path = path
71+ s.format = format
72+ s.identity = externalThemeIdentity(enabled, provider, format, path)
73+}
74+
75+// ExternalThemeIdentity returns the current normalized external theme identity.
76+func (s *FileSource) ExternalThemeIdentity() string {
77+ s.mu.RLock()
78+ defer s.mu.RUnlock()
79+ return s.identity
80+}
81+
82+// IsEnabled returns whether this source should be used.
83+func (s *FileSource) IsEnabled() bool {
84+ s.mu.RLock()
85+ defer s.mu.RUnlock()
86+ return s.enabled
87+}
88+
89+// Get reads and parses the configured Noctalia theme file.
90+func (s *FileSource) Get(ctx context.Context) (*entity.ExternalTheme, error) {
91+ s.mu.RLock()
92+ enabled := s.enabled
93+ pathConfig := s.path
94+ format := s.format
95+ s.mu.RUnlock()
96+
97+ if !enabled {
98+ return nil, nil
99+ }
100+ if err := ctx.Err(); err != nil {
101+ return nil, err
102+ }
103+
104+ path, err := ExpandPath(pathConfig)
105+ if err != nil {
106+ return nil, err
107+ }
108+ if path == "" {
109+ return nil, errors.New("noctalia theme path is empty")
110+ }
111+
112+ data, err := os.ReadFile(path)
113+ if err != nil {
114+ return nil, fmt.Errorf("read noctalia theme file: %w", err)
115+ }
116+ if ctxErr := ctx.Err(); ctxErr != nil {
117+ return nil, ctxErr
118+ }
119+
120+ switch format {
121+ case formatColorsJSON:
122+ return ParseColorsJSON(data)
123+ case formatDumberJSON:
124+ return ParseDumberJSON(data)
125+ default:
126+ return nil, fmt.Errorf("unsupported noctalia theme format %q", format)
127+ }
128+}
129+
130+// ExpandPath expands environment variables, leading ~, and cleans the path.
131+func ExpandPath(path string) (string, error) {
132+ expanded := os.ExpandEnv(strings.TrimSpace(path))
133+ if expanded == "" {
134+ return "", nil
135+ }
136+ if expanded == "~" || strings.HasPrefix(expanded, "~/") {
137+ home, err := os.UserHomeDir()
138+ if err != nil {
139+ return "", fmt.Errorf("expand home directory: %w", err)
140+ }
141+ if expanded == "~" {
142+ expanded = home
143+ } else {
144+ expanded = filepath.Join(home, expanded[2:])
145+ }
146+ }
147+ return filepath.Clean(expanded), nil
148+}
149+
150+// ParseColorsJSON parses Noctalia's native colors.json file.
151+//
152+// Noctalia colors.json represents the active palette only, so Dumber applies the
153+// mapped palette to both light and dark resolved palettes. The mapper uses
154+// optional mShadow/mHover roles when present to preserve background/surface/input
155+// contrast, while falling back to older required roles for compatibility.
156+func ParseColorsJSON(data []byte) (*entity.ExternalTheme, error) {
157+ var raw colorsThemeJSON
158+ decoder := json.NewDecoder(bytes.NewReader(data))
159+ if err := decoder.Decode(&raw); err != nil {
160+ return nil, fmt.Errorf("parse noctalia colors json: %w", err)
161+ }
162+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
163+ return nil, errors.New("parse noctalia colors json: trailing data")
164+ }
165+
166+ palette, err := raw.toPalette()
167+ if err != nil {
168+ return nil, err
169+ }
170+ light := palette
171+ dark := palette
172+ return &entity.ExternalTheme{
173+ Name: colorsJSONName,
174+ Provider: providerName,
175+ LightPalette: &light,
176+ DarkPalette: &dark,
177+ }, nil
178+}
179+
180+// ParseDumberJSON parses Noctalia user-template-generated Dumber JSON.
181+func ParseDumberJSON(data []byte) (*entity.ExternalTheme, error) {
182+ var raw dumberThemeJSON
183+ decoder := json.NewDecoder(bytes.NewReader(data))
184+ if err := decoder.Decode(&raw); err != nil {
185+ return nil, fmt.Errorf("parse noctalia dumber json: %w", err)
186+ }
187+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
188+ return nil, errors.New("parse noctalia dumber json: trailing data")
189+ }
190+ if raw.Light == nil {
191+ return nil, errors.New("parse noctalia dumber json: missing light palette")
192+ }
193+ if raw.Dark == nil {
194+ return nil, errors.New("parse noctalia dumber json: missing dark palette")
195+ }
196+
197+ light := raw.Light.toEntity()
198+ dark := raw.Dark.toEntity()
199+ if err := validatePalette(&light, "light"); err != nil {
200+ return nil, err
201+ }
202+ if err := validatePalette(&dark, "dark"); err != nil {
203+ return nil, err
204+ }
205+
206+ return &entity.ExternalTheme{
207+ Name: firstNonEmpty(raw.Name, raw.Source, dumberJSONName),
208+ Provider: providerName,
209+ LightPalette: &light,
210+ DarkPalette: &dark,
211+ }, nil
212+}
213+
214+type colorsThemeJSON struct {
215+ MPrimary *string `json:"mPrimary"`
216+ MSurface *string `json:"mSurface"`
217+ MOnSurface *string `json:"mOnSurface"`
218+ MSurfaceVariant *string `json:"mSurfaceVariant"`
219+ MOnSurfaceVariant *string `json:"mOnSurfaceVariant"`
220+ MOutline *string `json:"mOutline"`
221+ MShadow *string `json:"mShadow"`
222+ MHover *string `json:"mHover"`
223+}
224+
225+func (raw colorsThemeJSON) toPalette() (entity.ColorPalette, error) {
226+ surface, err := requiredColorsHex("mSurface", raw.MSurface)
227+ if err != nil {
228+ return entity.ColorPalette{}, err
229+ }
230+ surfaceVariantBase, err := requiredColorsHex("mSurfaceVariant", raw.MSurfaceVariant)
231+ if err != nil {
232+ return entity.ColorPalette{}, err
233+ }
234+ text, err := requiredColorsHex("mOnSurface", raw.MOnSurface)
235+ if err != nil {
236+ return entity.ColorPalette{}, err
237+ }
238+ muted, err := requiredColorsHex("mOnSurfaceVariant", raw.MOnSurfaceVariant)
239+ if err != nil {
240+ return entity.ColorPalette{}, err
241+ }
242+ accent, err := requiredColorsHex("mPrimary", raw.MPrimary)
243+ if err != nil {
244+ return entity.ColorPalette{}, err
245+ }
246+ border, err := requiredColorsHex("mOutline", raw.MOutline)
247+ if err != nil {
248+ return entity.ColorPalette{}, err
249+ }
250+ background, err := optionalColorsHex("mShadow", raw.MShadow, surface)
251+ if err != nil {
252+ return entity.ColorPalette{}, err
253+ }
254+ surfaceVariant, err := optionalColorsHex("mHover", raw.MHover, surfaceVariantBase)
255+ if err != nil {
256+ return entity.ColorPalette{}, err
257+ }
258+
259+ return entity.ColorPalette{
260+ Background: background,
261+ Surface: surface,
262+ SurfaceVariant: surfaceVariant,
263+ Text: text,
264+ Muted: muted,
265+ Accent: accent,
266+ Border: border,
267+ }, nil
268+}
269+
270+func requiredColorsHex(field string, value *string) (string, error) {
271+ if value == nil || *value == "" {
272+ return "", fmt.Errorf("parse noctalia colors json: missing %s", field)
273+ }
274+ return validateColorsHex(field, *value)
275+}
276+
277+func optionalColorsHex(field string, value *string, fallback string) (string, error) {
278+ if value == nil {
279+ return fallback, nil
280+ }
281+ return validateColorsHex(field, *value)
282+}
283+
284+func validateColorsHex(field, value string) (string, error) {
285+ if !entity.IsValidHex(value) {
286+ return "", fmt.Errorf("parse noctalia colors json: %s: not a valid CSS-safe hex color (#RRGGBB)", field)
287+ }
288+ return value, nil
289+}
290+
291+type dumberThemeJSON struct {
292+ Source string `json:"source"`
293+ Mode string `json:"mode"`
294+ Name string `json:"name"`
295+ Light *paletteJSON `json:"light"`
296+ Dark *paletteJSON `json:"dark"`
297+}
298+
299+type paletteJSON struct {
300+ Background string `json:"background"`
301+ Surface string `json:"surface"`
302+ SurfaceVariant string `json:"surface_variant"`
303+ Text string `json:"text"`
304+ Muted string `json:"muted"`
305+ Accent string `json:"accent"`
306+ Border string `json:"border"`
307+}
308+
309+func (p paletteJSON) toEntity() entity.ColorPalette {
310+ return entity.ColorPalette{
311+ Background: p.Background,
312+ Surface: p.Surface,
313+ SurfaceVariant: p.SurfaceVariant,
314+ Text: p.Text,
315+ Muted: p.Muted,
316+ Accent: p.Accent,
317+ Border: p.Border,
318+ }
319+}
320+
321+func validatePalette(p *entity.ColorPalette, prefix string) error {
322+ warnings := entity.ValidatePaletteOverrideHex(p, prefix)
323+ if len(warnings) == 0 {
324+ return nil
325+ }
326+ return fmt.Errorf("parse noctalia dumber json: %s: %s", warnings[0].Field, warnings[0].Message)
327+}
328+
329+func firstNonEmpty(values ...string) string {
330+ for _, value := range values {
331+ if strings.TrimSpace(value) != "" {
332+ return value
333+ }
334+ }
335+ return ""
336+}
337+
338+func normalizeProviderAndFormat(providerInput, formatInput string) (string, string) {
339+ provider := strings.ToLower(strings.TrimSpace(providerInput))
340+ if provider == "" {
341+ provider = providerName
342+ }
343+ format := strings.ToLower(strings.TrimSpace(formatInput))
344+ if format == "" {
345+ format = formatName
346+ }
347+ return provider, format
348+}
349+
350+func isSupportedFormat(format string) bool {
351+ switch format {
352+ case formatColorsJSON, formatDumberJSON:
353+ return true
354+ default:
355+ return false
356+ }
357+}
358+
359+func externalThemeIdentity(enabled bool, provider, format, path string) string {
360+ if !enabled {
361+ return ""
362+ }
363+ return provider + "\x00" + format + "\x00" + strings.TrimSpace(path)
364+}
A internal/infrastructure/externaltheme/noctalia/source_test.go +357 -0
@@ -0,0 +1,357 @@
1+package noctalia
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/domain/entity"
10+)
11+
12+func TestParseColorsJSONValidActivePalette(t *testing.T) {
13+ theme, err := ParseColorsJSON(noctaliaColorsJSON())
14+ if err != nil {
15+ t.Fatalf("ParseColorsJSON() error = %v", err)
16+ }
17+ if theme.Provider != "noctalia" {
18+ t.Fatalf("Provider = %q, want noctalia", theme.Provider)
19+ }
20+ if theme.Name == "" {
21+ t.Fatal("Name is empty, want source metadata")
22+ }
23+ want := entity.ColorPalette{
24+ Background: "#000000",
25+ Surface: "#0c0c0c",
26+ SurfaceVariant: "#282828",
27+ Text: "#ffffff",
28+ Muted: "#a0a0a0",
29+ Accent: "#ffc799",
30+ Border: "#505050",
31+ }
32+ if theme.LightPalette == nil || *theme.LightPalette != want {
33+ t.Fatalf("LightPalette = %+v, want %+v", theme.LightPalette, want)
34+ }
35+ if theme.DarkPalette == nil || *theme.DarkPalette != want {
36+ t.Fatalf("DarkPalette = %+v, want active Noctalia palette %+v", theme.DarkPalette, want)
37+ }
38+}
39+
40+func TestParseColorsJSONDocsExample(t *testing.T) {
41+ path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-colors.json")
42+ data, err := os.ReadFile(path)
43+ if err != nil {
44+ t.Fatalf("ReadFile(%q) error = %v", path, err)
45+ }
46+
47+ theme, err := ParseColorsJSON(data)
48+ if err != nil {
49+ t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
50+ }
51+ if theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
52+ t.Fatalf("parsed docs example = %+v", theme)
53+ }
54+}
55+
56+func TestParseColorsJSONFallsBackWhenNuanceFieldsAreAbsent(t *testing.T) {
57+ theme, err := ParseColorsJSON([]byte(`{
58+ "mPrimary":"#ffc799",
59+ "mSurface":"#0c0c0c",
60+ "mOnSurface":"#ffffff",
61+ "mSurfaceVariant":"#1c1c1c",
62+ "mOnSurfaceVariant":"#a0a0a0",
63+ "mOutline":"#505050"
64+ }`))
65+ if err != nil {
66+ t.Fatalf("ParseColorsJSON() error = %v", err)
67+ }
68+ if theme.DarkPalette.Background != "#0c0c0c" {
69+ t.Fatalf("Background = %q, want mSurface fallback", theme.DarkPalette.Background)
70+ }
71+ if theme.DarkPalette.SurfaceVariant != "#1c1c1c" {
72+ t.Fatalf("SurfaceVariant = %q, want mSurfaceVariant fallback", theme.DarkPalette.SurfaceVariant)
73+ }
74+}
75+
76+func TestParseColorsJSONRequiresMappedFields(t *testing.T) {
77+ cases := map[string]string{
78+ "missing surface": `{"mPrimary":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
79+ "missing surface variant": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
80+ "missing text": `{"mPrimary":"#111111","mSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
81+ "missing muted": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOutline":"#555555"}`,
82+ "missing accent": `{"mSurface":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
83+ "missing border": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOnSurfaceVariant":"#555555"}`,
84+ }
85+ for name, input := range cases {
86+ t.Run(name, func(t *testing.T) {
87+ if _, err := ParseColorsJSON([]byte(input)); err == nil {
88+ t.Fatal("ParseColorsJSON() error = nil, want missing field error")
89+ }
90+ })
91+ }
92+}
93+
94+func TestParseColorsJSONInvalidHexRejection(t *testing.T) {
95+ if _, err := ParseColorsJSON([]byte(`{"mPrimary":"ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050"}`)); err == nil {
96+ t.Fatal("ParseColorsJSON() error = nil, want invalid hex error")
97+ }
98+}
99+
100+func TestParseColorsJSONInvalidOptionalNuanceHexRejection(t *testing.T) {
101+ cases := map[string]string{
102+ "invalid shadow": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mShadow":"000000"}`,
103+ "invalid hover": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mHover":"282828"}`,
104+ }
105+ for name, input := range cases {
106+ t.Run(name, func(t *testing.T) {
107+ if _, err := ParseColorsJSON([]byte(input)); err == nil {
108+ t.Fatal("ParseColorsJSON() error = nil, want optional hex validation error")
109+ }
110+ })
111+ }
112+}
113+
114+func TestParseColorsJSONRejectsTrailingTokens(t *testing.T) {
115+ if _, err := ParseColorsJSON(append(noctaliaColorsJSON(), []byte(` {}`)...)); err == nil {
116+ t.Fatal("ParseColorsJSON() error = nil, want trailing data error")
117+ }
118+}
119+
120+func TestParseDumberJSONValidFullPalette(t *testing.T) {
121+ json := []byte(`{
122+ "name":"Night Theme",
123+ "source":"noctalia-template",
124+ "mode":"dark",
125+ "light":{"background":"#FFFFFF","surface":"#F0F0F0","surface_variant":"#E0E0E0","text":"#111111","muted":"#777777","accent":"#3366CC","border":"#CCCCCC"},
126+ "dark":{"background":"#000000","surface":"#101010","surface_variant":"#202020","text":"#EEEEEE","muted":"#999999","accent":"#AABBCC","border":"#333333"}
127+ }`)
128+
129+ theme, err := ParseDumberJSON(json)
130+ if err != nil {
131+ t.Fatalf("ParseDumberJSON() error = %v", err)
132+ }
133+ if theme.Provider != "noctalia" {
134+ t.Fatalf("Provider = %q, want noctalia", theme.Provider)
135+ }
136+ if theme.Name != "Night Theme" {
137+ t.Fatalf("Name = %q, want metadata name", theme.Name)
138+ }
139+ if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Accent != "#AABBCC" {
140+ t.Fatalf("parsed palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
141+ }
142+}
143+
144+func TestParseDumberJSONDocsExample(t *testing.T) {
145+ path := filepath.Join("..", "..", "..", "..", "docs", "examples", "noctalia-dumber-theme.json")
146+ data, err := os.ReadFile(path)
147+ if err != nil {
148+ t.Fatalf("ReadFile(%q) error = %v", path, err)
149+ }
150+
151+ theme, err := ParseDumberJSON(data)
152+ if err != nil {
153+ t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
154+ }
155+ if theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
156+ t.Fatalf("parsed docs example = %+v", theme)
157+ }
158+}
159+
160+func TestParseDumberJSONPartialPalette(t *testing.T) {
161+ theme, err := ParseDumberJSON([]byte(`{
162+ "light":{"background":"#FFFFFF","accent":"#112233"},
163+ "dark":{"text":"#EEEEEE"}
164+ }`))
165+ if err != nil {
166+ t.Fatalf("ParseDumberJSON() error = %v", err)
167+ }
168+ if theme.LightPalette.Surface != "" || theme.DarkPalette.Background != "" {
169+ t.Fatalf("missing fields should remain empty: %+v / %+v", theme.LightPalette, theme.DarkPalette)
170+ }
171+}
172+
173+func TestParseDumberJSONRequiresLightAndDarkObjects(t *testing.T) {
174+ cases := map[string]string{
175+ "missing light": `{"dark":{}}`,
176+ "missing dark": `{"light":{}}`,
177+ }
178+ for name, input := range cases {
179+ t.Run(name, func(t *testing.T) {
180+ if _, err := ParseDumberJSON([]byte(input)); err == nil {
181+ t.Fatal("ParseDumberJSON() error = nil, want error")
182+ }
183+ })
184+ }
185+}
186+
187+func TestParseDumberJSONMetadataNameFallsBackToSource(t *testing.T) {
188+ theme, err := ParseDumberJSON([]byte(`{"source":"noctalia-template","light":{},"dark":{}}`))
189+ if err != nil {
190+ t.Fatalf("ParseDumberJSON() error = %v", err)
191+ }
192+ if theme.Name != "noctalia-template" {
193+ t.Fatalf("Name = %q, want source fallback", theme.Name)
194+ }
195+}
196+
197+func TestParseDumberJSONValidHexAcceptance(t *testing.T) {
198+ _, err := ParseDumberJSON([]byte(`{"light":{"accent":"#aBc123"},"dark":{"accent":"#ABC123"}}`))
199+ if err != nil {
200+ t.Fatalf("ParseDumberJSON() error = %v", err)
201+ }
202+}
203+
204+func TestParseDumberJSONInvalidHexRejection(t *testing.T) {
205+ if _, err := ParseDumberJSON([]byte(`{"light":{"accent":"ABC123"},"dark":{}}`)); err == nil {
206+ t.Fatal("ParseDumberJSON() error = nil, want invalid hex error")
207+ }
208+}
209+
210+func TestParseDumberJSONMalformedRejection(t *testing.T) {
211+ if _, err := ParseDumberJSON([]byte(`{"light":`)); err == nil {
212+ t.Fatal("ParseDumberJSON() error = nil, want malformed JSON error")
213+ }
214+}
215+
216+func TestParseDumberJSONRejectsTrailingTokens(t *testing.T) {
217+ if _, err := ParseDumberJSON([]byte(`{"light":{},"dark":{}} {}`)); err == nil {
218+ t.Fatal("ParseDumberJSON() error = nil, want trailing data error")
219+ }
220+}
221+
222+func TestExpandPath(t *testing.T) {
223+ t.Setenv("NOCTALIA_THEME_TEST", "theme.json")
224+ home := t.TempDir()
225+ t.Setenv("HOME", home)
226+
227+ got, err := ExpandPath("~/$NOCTALIA_THEME_TEST")
228+ if err != nil {
229+ t.Fatalf("ExpandPath() error = %v", err)
230+ }
231+ want := filepath.Join(home, "theme.json")
232+ if got != want {
233+ t.Fatalf("ExpandPath() = %q, want %q", got, want)
234+ }
235+}
236+
237+func TestFileSourceDisabledBehavior(t *testing.T) {
238+ source := NewFileSource(false, "/does/not/exist")
239+ if source.IsEnabled() {
240+ t.Fatal("IsEnabled() = true, want false")
241+ }
242+ theme, err := source.Get(context.Background())
243+ if err != nil {
244+ t.Fatalf("Get() error = %v", err)
245+ }
246+ if theme != nil {
247+ t.Fatalf("Get() theme = %+v, want nil", theme)
248+ }
249+}
250+
251+func TestFileSourceReadsColorsJSONByDefault(t *testing.T) {
252+ path := filepath.Join(t.TempDir(), "colors.json")
253+ if err := os.WriteFile(path, noctaliaColorsJSON(), 0o600); err != nil {
254+ t.Fatalf("WriteFile() error = %v", err)
255+ }
256+
257+ theme, err := NewFileSource(true, path).Get(context.Background())
258+ if err != nil {
259+ t.Fatalf("Get() error = %v", err)
260+ }
261+ if theme.LightPalette.Accent != "#ffc799" || theme.DarkPalette.Background != "#000000" || theme.DarkPalette.SurfaceVariant != "#282828" {
262+ t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
263+ }
264+}
265+
266+func TestFileSourceReadsDumberJSONWhenConfigured(t *testing.T) {
267+ path := filepath.Join(t.TempDir(), "theme.json")
268+ if err := os.WriteFile(path, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
269+ t.Fatalf("WriteFile() error = %v", err)
270+ }
271+
272+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
273+ Enabled: true,
274+ Provider: "noctalia",
275+ Format: "dumber-json",
276+ Path: path,
277+ })
278+ theme, err := source.Get(context.Background())
279+ if err != nil {
280+ t.Fatalf("Get() error = %v", err)
281+ }
282+ if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Background != "#000000" {
283+ t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
284+ }
285+}
286+
287+func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
288+ colorsPath := filepath.Join(t.TempDir(), "colors.json")
289+ if err := os.WriteFile(colorsPath, noctaliaColorsJSON(), 0o600); err != nil {
290+ t.Fatalf("WriteFile(colors) error = %v", err)
291+ }
292+ dumberPath := filepath.Join(t.TempDir(), "theme.json")
293+ if err := os.WriteFile(dumberPath, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
294+ t.Fatalf("WriteFile(dumber) error = %v", err)
295+ }
296+
297+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{})
298+ if source.IsEnabled() {
299+ t.Fatal("IsEnabled() = true, want false")
300+ }
301+
302+ source.Configure(entity.ExternalThemeConfig{
303+ Enabled: true,
304+ Provider: " NOCTALIA ",
305+ Format: " COLORS-JSON ",
306+ Path: colorsPath,
307+ })
308+ if !source.IsEnabled() {
309+ t.Fatal("IsEnabled() = false for colors-json, want true")
310+ }
311+ if _, err := source.Get(context.Background()); err != nil {
312+ t.Fatalf("Get() colors-json after Configure error = %v", err)
313+ }
314+
315+ source.Configure(entity.ExternalThemeConfig{
316+ Enabled: true,
317+ Provider: "noctalia",
318+ Format: "dumber-json",
319+ Path: dumberPath,
320+ })
321+ if !source.IsEnabled() {
322+ t.Fatal("IsEnabled() = false for dumber-json, want true")
323+ }
324+ if _, err := source.Get(context.Background()); err != nil {
325+ t.Fatalf("Get() dumber-json after Configure error = %v", err)
326+ }
327+
328+ source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "other", Format: "colors-json", Path: colorsPath})
329+ if source.IsEnabled() {
330+ t.Fatal("IsEnabled() = true for unsupported provider, want false")
331+ }
332+ source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "toml", Path: colorsPath})
333+ if source.IsEnabled() {
334+ t.Fatal("IsEnabled() = true for unsupported format, want false")
335+ }
336+}
337+
338+func noctaliaColorsJSON() []byte {
339+ return []byte(`{
340+ "mError": "#ff8080",
341+ "mHover": "#282828",
342+ "mOnError": "#000000",
343+ "mOnHover": "#ffffff",
344+ "mOnPrimary": "#000000",
345+ "mOnSecondary": "#000000",
346+ "mOnSurface": "#ffffff",
347+ "mOnSurfaceVariant": "#a0a0a0",
348+ "mOnTertiary": "#000000",
349+ "mOutline": "#505050",
350+ "mPrimary": "#ffc799",
351+ "mSecondary": "#99ffe4",
352+ "mShadow": "#000000",
353+ "mSurface": "#0c0c0c",
354+ "mSurfaceVariant": "#1c1c1c",
355+ "mTertiary": "#fbadff"
356+ }`)
357+}
A internal/infrastructure/externaltheme/noctalia/watcher.go +190 -0
@@ -0,0 +1,190 @@
1+package noctalia
2+
3+import (
4+ "context"
5+ "errors"
6+ "fmt"
7+ "log/slog"
8+ "os"
9+ "path/filepath"
10+ "sync"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/fsnotify/fsnotify"
15+)
16+
17+const defaultDebounceDelay = 75 * time.Millisecond
18+
19+// FileWatcher watches one Noctalia external theme file.
20+//
21+// It watches the file's parent directory so atomic writes and renames are
22+// observed, then filters events to the configured file path.
23+type FileWatcher struct {
24+ mu sync.Mutex
25+ delay time.Duration
26+ watcher *fsnotify.Watcher
27+ path string
28+ stopCtx context.Context
29+ stopFunc context.CancelFunc
30+ running bool
31+}
32+
33+// NewFileWatcher creates a file watcher with the default debounce delay.
34+func NewFileWatcher() *FileWatcher {
35+ return &FileWatcher{delay: defaultDebounceDelay}
36+}
37+
38+// Start starts watching the configured external theme path.
39+//
40+// Calling Start with the same enabled path while already running is a no-op.
41+// Calling Start with a different enabled path restarts the underlying watcher.
42+// Disabled, unsupported, or empty-path config stops any existing watcher.
43+func (w *FileWatcher) Start(ctx context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
44+ path, enabled, err := watchPathFromConfig(cfg)
45+ if err != nil {
46+ _ = w.Stop()
47+ return err
48+ }
49+ if !enabled || path == "" {
50+ return w.Stop()
51+ }
52+ if onChange == nil {
53+ return errors.New("noctalia file watcher callback is nil")
54+ }
55+
56+ w.mu.Lock()
57+ defer w.mu.Unlock()
58+ if w.running && w.path == path {
59+ return nil
60+ }
61+ if stopErr := w.stopLocked(); stopErr != nil {
62+ return fmt.Errorf("stop previous noctalia theme watcher: %w", stopErr)
63+ }
64+
65+ parent := filepath.Dir(path)
66+ info, statErr := os.Stat(parent)
67+ if statErr != nil {
68+ return fmt.Errorf("watch noctalia theme parent directory: %w", statErr)
69+ } else if !info.IsDir() {
70+ return fmt.Errorf("watch noctalia theme parent directory: %s is not a directory", parent)
71+ }
72+
73+ watcher, err := fsnotify.NewWatcher()
74+ if err != nil {
75+ return fmt.Errorf("create noctalia theme watcher: %w", err)
76+ }
77+ if err := watcher.Add(parent); err != nil {
78+ _ = watcher.Close()
79+ return fmt.Errorf("watch noctalia theme parent directory: %w", err)
80+ }
81+
82+ runCtx, cancel := context.WithCancel(ctx)
83+ w.watcher = watcher
84+ w.path = path
85+ w.stopCtx = runCtx
86+ w.stopFunc = cancel
87+ w.running = true
88+
89+ go w.run(runCtx, watcher, path, onChange)
90+ return nil
91+}
92+
93+// Stop stops the current watcher. It is safe to call repeatedly.
94+func (w *FileWatcher) Stop() error {
95+ w.mu.Lock()
96+ defer w.mu.Unlock()
97+ return w.stopLocked()
98+}
99+
100+func (w *FileWatcher) stopLocked() error {
101+ if !w.running {
102+ return nil
103+ }
104+ if w.stopFunc != nil {
105+ w.stopFunc()
106+ }
107+ err := w.watcher.Close()
108+ w.watcher = nil
109+ w.path = ""
110+ w.stopCtx = nil
111+ w.stopFunc = nil
112+ w.running = false
113+ return err
114+}
115+
116+func (w *FileWatcher) run(ctx context.Context, watcher *fsnotify.Watcher, path string, onChange func()) {
117+ var timer *time.Timer
118+ var timerC <-chan time.Time
119+ defer func() {
120+ if timer != nil {
121+ timer.Stop()
122+ }
123+ w.mu.Lock()
124+ if w.watcher == watcher {
125+ _ = w.stopLocked()
126+ }
127+ w.mu.Unlock()
128+ }()
129+
130+ for {
131+ select {
132+ case <-ctx.Done():
133+ return
134+ case event, ok := <-watcher.Events:
135+ if !ok {
136+ return
137+ }
138+ if !isRelevantFileEvent(event, path) {
139+ continue
140+ }
141+ if timer == nil {
142+ timer = time.NewTimer(w.delay)
143+ timerC = timer.C
144+ } else {
145+ if !timer.Stop() {
146+ select {
147+ case <-timer.C:
148+ default:
149+ }
150+ }
151+ timer.Reset(w.delay)
152+ timerC = timer.C
153+ }
154+ case err, ok := <-watcher.Errors:
155+ if !ok {
156+ return
157+ }
158+ slog.Warn("noctalia theme watcher error", "error", err)
159+ case <-timerC:
160+ timerC = nil
161+ onChange()
162+ }
163+ }
164+}
165+
166+func watchPathFromConfig(cfg entity.ExternalThemeConfig) (string, bool, error) {
167+ provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
168+ enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
169+ if !enabled {
170+ return "", false, nil
171+ }
172+ path, err := ExpandPath(cfg.Path)
173+ if err != nil {
174+ return "", false, err
175+ }
176+ return path, path != "", nil
177+}
178+
179+func isRelevantFileEvent(event fsnotify.Event, path string) bool {
180+ if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
181+ return false
182+ }
183+ return filepath.Clean(event.Name) == path
184+}
185+
186+func (w *FileWatcher) isRunningForTest() bool {
187+ w.mu.Lock()
188+ defer w.mu.Unlock()
189+ return w.running
190+}
A internal/infrastructure/externaltheme/noctalia/watcher_test.go +263 -0
@@ -0,0 +1,263 @@
1+package noctalia
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+func TestFileWatcherStartStopAndContextCancellation(t *testing.T) {
14+ path := writeThemeFile(t, t.TempDir(), "theme.json")
15+ watcher := newFastWatcher()
16+
17+ ctx, cancel := context.WithCancel(context.Background())
18+ if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
19+ t.Fatalf("Start() error = %v", err)
20+ }
21+ if !watcher.isRunningForTest() {
22+ t.Fatal("watcher is not running after Start")
23+ }
24+ if err := watcher.Stop(); err != nil {
25+ t.Fatalf("Stop() error = %v", err)
26+ }
27+ if err := watcher.Stop(); err != nil {
28+ t.Fatalf("second Stop() error = %v", err)
29+ }
30+
31+ if err := watcher.Start(ctx, watcherConfig(path), func() {}); err != nil {
32+ t.Fatalf("Start() after Stop error = %v", err)
33+ }
34+ cancel()
35+ waitUntil(t, func() bool { return !watcher.isRunningForTest() })
36+}
37+
38+func TestFileWatcherStartSamePathDoesNotDuplicateWatcher(t *testing.T) {
39+ dir := t.TempDir()
40+ path := writeThemeFile(t, dir, "theme.json")
41+ watcher := newFastWatcher()
42+ changes := make(chan struct{}, 4)
43+
44+ if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
45+ t.Fatalf("Start() error = %v", err)
46+ }
47+ if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
48+ t.Fatalf("second Start() same path error = %v", err)
49+ }
50+
51+ writeThemeFile(t, dir, "theme.json")
52+ waitForChange(t, changes)
53+ assertNoChange(t, changes, 60*time.Millisecond)
54+}
55+
56+func TestFileWatcherPathChangeRestartsAndDisabledStops(t *testing.T) {
57+ dir := t.TempDir()
58+ first := writeThemeFile(t, dir, "first.json")
59+ second := writeThemeFile(t, dir, "second.json")
60+ watcher := newFastWatcher()
61+ changes := make(chan string, 8)
62+
63+ if err := watcher.Start(context.Background(), watcherConfig(first), func() { changes <- "first" }); err != nil {
64+ t.Fatalf("Start(first) error = %v", err)
65+ }
66+ if err := watcher.Start(context.Background(), watcherConfig(second), func() { changes <- "second" }); err != nil {
67+ t.Fatalf("Start(second) error = %v", err)
68+ }
69+
70+ writeThemeFile(t, dir, "first.json")
71+ assertNoStringChange(t, changes, 80*time.Millisecond)
72+ writeThemeFile(t, dir, "second.json")
73+ if got := waitForStringChange(t, changes); got != "second" {
74+ t.Fatalf("callback = %q, want second", got)
75+ }
76+
77+ cfg := watcherConfig(second)
78+ cfg.Enabled = false
79+ if err := watcher.Start(context.Background(), cfg, func() { changes <- "disabled" }); err != nil {
80+ t.Fatalf("Start(disabled) error = %v", err)
81+ }
82+ if watcher.isRunningForTest() {
83+ t.Fatal("watcher running after disabled config")
84+ }
85+ writeThemeFile(t, dir, "second.json")
86+ assertNoStringChange(t, changes, 80*time.Millisecond)
87+}
88+
89+func TestFileWatcherDebouncesBurstToOneCallback(t *testing.T) {
90+ dir := t.TempDir()
91+ path := writeThemeFile(t, dir, "theme.json")
92+ watcher := newFastWatcher()
93+ changes := make(chan struct{}, 8)
94+
95+ if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
96+ t.Fatalf("Start() error = %v", err)
97+ }
98+ for range 5 {
99+ writeThemeFile(t, dir, "theme.json")
100+ }
101+
102+ waitForChange(t, changes)
103+ assertNoChange(t, changes, 80*time.Millisecond)
104+}
105+
106+func TestFileWatcherAtomicRenameWriteEmitsCallback(t *testing.T) {
107+ dir := t.TempDir()
108+ path := writeThemeFile(t, dir, "theme.json")
109+ watcher := newFastWatcher()
110+ changes := make(chan struct{}, 4)
111+
112+ if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
113+ t.Fatalf("Start() error = %v", err)
114+ }
115+ tmp := filepath.Join(dir, "theme.json.tmp")
116+ if err := os.WriteFile(tmp, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
117+ t.Fatalf("WriteFile(tmp) error = %v", err)
118+ }
119+ if err := os.Rename(tmp, path); err != nil {
120+ t.Fatalf("Rename() error = %v", err)
121+ }
122+
123+ waitForChange(t, changes)
124+}
125+
126+func TestFileWatcherInvalidOrEmptyPathBehavior(t *testing.T) {
127+ watcher := newFastWatcher()
128+ if err := watcher.Start(context.Background(), watcherConfig(""), nil); err != nil {
129+ t.Fatalf("Start(empty path) error = %v", err)
130+ }
131+ if watcher.isRunningForTest() {
132+ t.Fatal("watcher running for empty path")
133+ }
134+ missingParent := filepath.Join(t.TempDir(), "missing", "theme.json")
135+ if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() {}); err == nil {
136+ t.Fatal("Start(missing parent) error = nil, want error")
137+ }
138+}
139+
140+func TestWatchPathFromConfigSupportsNoctaliaFormats(t *testing.T) {
141+ path := filepath.Join(t.TempDir(), "theme.json")
142+ for _, format := range []string{"colors-json", "dumber-json"} {
143+ t.Run(format, func(t *testing.T) {
144+ got, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
145+ Enabled: true,
146+ Provider: " NOCTALIA ",
147+ Format: " " + format + " ",
148+ Path: path,
149+ })
150+ if err != nil {
151+ t.Fatalf("watchPathFromConfig() error = %v", err)
152+ }
153+ if !enabled {
154+ t.Fatal("enabled = false, want true")
155+ }
156+ if got != path {
157+ t.Fatalf("path = %q, want %q", got, path)
158+ }
159+ })
160+ }
161+
162+ _, enabled, err := watchPathFromConfig(entity.ExternalThemeConfig{
163+ Enabled: true,
164+ Provider: "noctalia",
165+ Format: "toml",
166+ Path: path,
167+ })
168+ if err != nil {
169+ t.Fatalf("watchPathFromConfig(unsupported) error = %v", err)
170+ }
171+ if enabled {
172+ t.Fatal("enabled = true for unsupported format, want false")
173+ }
174+}
175+
176+func TestFileWatcherInvalidPathStopsPreviousWatcher(t *testing.T) {
177+ dir := t.TempDir()
178+ path := writeThemeFile(t, dir, "theme.json")
179+ watcher := newFastWatcher()
180+ changes := make(chan struct{}, 4)
181+
182+ if err := watcher.Start(context.Background(), watcherConfig(path), func() { changes <- struct{}{} }); err != nil {
183+ t.Fatalf("Start(valid) error = %v", err)
184+ }
185+ missingParent := filepath.Join(dir, "missing", "theme.json")
186+ if err := watcher.Start(context.Background(), watcherConfig(missingParent), func() { changes <- struct{}{} }); err == nil {
187+ t.Fatal("Start(missing parent) error = nil, want error")
188+ }
189+ if watcher.isRunningForTest() {
190+ t.Fatal("watcher running after invalid path")
191+ }
192+ writeThemeFile(t, dir, "theme.json")
193+ assertNoChange(t, changes, 80*time.Millisecond)
194+}
195+
196+func newFastWatcher() *FileWatcher {
197+ watcher := NewFileWatcher()
198+ watcher.delay = 20 * time.Millisecond
199+ return watcher
200+}
201+
202+func watcherConfig(path string) entity.ExternalThemeConfig {
203+ return entity.ExternalThemeConfig{Enabled: true, Provider: providerName, Format: formatName, Path: path}
204+}
205+
206+func writeThemeFile(t *testing.T, dir, name string) string {
207+ t.Helper()
208+ path := filepath.Join(dir, name)
209+ if err := os.WriteFile(path, []byte(`{"light":{},"dark":{}}`), 0o600); err != nil {
210+ t.Fatalf("WriteFile(%s) error = %v", name, err)
211+ }
212+ return path
213+}
214+
215+func waitForChange(t *testing.T, ch <-chan struct{}) {
216+ t.Helper()
217+ select {
218+ case <-ch:
219+ case <-time.After(500 * time.Millisecond):
220+ t.Fatal("timed out waiting for callback")
221+ }
222+}
223+
224+func assertNoChange(t *testing.T, ch <-chan struct{}, d time.Duration) {
225+ t.Helper()
226+ select {
227+ case <-ch:
228+ t.Fatal("unexpected callback")
229+ case <-time.After(d):
230+ }
231+}
232+
233+func waitForStringChange(t *testing.T, ch <-chan string) string {
234+ t.Helper()
235+ select {
236+ case got := <-ch:
237+ return got
238+ case <-time.After(500 * time.Millisecond):
239+ t.Fatal("timed out waiting for callback")
240+ }
241+ return ""
242+}
243+
244+func assertNoStringChange(t *testing.T, ch <-chan string, d time.Duration) {
245+ t.Helper()
246+ select {
247+ case got := <-ch:
248+ t.Fatalf("unexpected callback %q", got)
249+ case <-time.After(d):
250+ }
251+}
252+
253+func waitUntil(t *testing.T, condition func() bool) {
254+ t.Helper()
255+ deadline := time.Now().Add(500 * time.Millisecond)
256+ for time.Now().Before(deadline) {
257+ if condition() {
258+ return
259+ }
260+ time.Sleep(5 * time.Millisecond)
261+ }
262+ t.Fatal("condition not met before timeout")
263+}
M internal/ui/app.go +103 -31
@@ -3072,6 +3072,11 @@ func (a *App) onShutdown(ctx context.Context) {
3072 3072
3073 // Stop accepting relaunches before teardown starts.3073 // Stop accepting relaunches before teardown starts.
3074 a.closeBrowserLaunchRelayListener()3074 a.closeBrowserLaunchRelayListener()
3075+ if a.deps != nil && a.deps.ExternalThemeWatcher != nil {
3076+ if err := a.deps.ExternalThemeWatcher.Stop(); err != nil {
3077+ log.Warn().Err(err).Msg("failed to stop external theme watcher")
3078+ }
3079+ }
3075 3080
3076 // Cancel context to signal all goroutines3081 // Cancel context to signal all goroutines
3077 a.cancel(errors.New("application shutdown"))3082 a.cancel(errors.New("application shutdown"))
@@ -5001,12 +5006,14 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
5001 func (a *App) initConfigWatcher(ctx context.Context) {5006 func (a *App) initConfigWatcher(ctx context.Context) {
5002 log := logging.FromContext(ctx)5007 log := logging.FromContext(ctx)
5003 5008
5009+ a.syncExternalThemeWatcher(ctx)
5010+
5004 if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil {5011 if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil {
5005- log.Debug().Msg("no config watcher available, skipping")5012+ log.Debug().Msg("no config watcher available, skipping config file watcher")
5006 return5013 return
5007 }5014 }
5008 5015
5009- // Start viper watcher5016+ // Start viper watcher.
5010 if err := a.deps.WatchConfig(); err != nil {5017 if err := a.deps.WatchConfig(); err != nil {
5011 log.Warn().Err(err).Msg("failed to start config watcher")5018 log.Warn().Err(err).Msg("failed to start config watcher")
5012 return5019 return
@@ -5016,6 +5023,7 @@ func (a *App) initConfigWatcher(ctx context.Context) {
5016 // deps.Config is updated in-place before this callback fires.5023 // deps.Config is updated in-place before this callback fires.
5017 a.deps.OnConfigChange(func() {5024 a.deps.OnConfigChange(func() {
5018 cb := glib.SourceFunc(func(_ uintptr) bool {5025 cb := glib.SourceFunc(func(_ uintptr) bool {
5026+ a.syncExternalThemeWatcher(ctx)
5019 a.applyAppearanceConfig(ctx)5027 a.applyAppearanceConfig(ctx)
5020 for _, bw := range a.browserWindows {5028 for _, bw := range a.browserWindows {
5021 if bw == nil {5029 if bw == nil {
@@ -5036,6 +5044,21 @@ func (a *App) initConfigWatcher(ctx context.Context) {
5036 log.Debug().Msg("config watcher initialized")5044 log.Debug().Msg("config watcher initialized")
5037 }5045 }
5038 5046
5047+func (a *App) syncExternalThemeWatcher(ctx context.Context) {
5048+ if a == nil || a.deps == nil || a.deps.Config == nil || a.deps.ExternalThemeWatcher == nil {
5049+ return
5050+ }
5051+ log := logging.FromContext(ctx)
5052+ cfg := a.deps.Config.Appearance.ExternalTheme
5053+ if err := a.deps.ExternalThemeWatcher.Start(ctx, cfg, func() {
5054+ a.dispatchOnMainThread("ui.external_theme_reload", func() {
5055+ a.applyAppearanceConfig(ctx)
5056+ })
5057+ }); err != nil {
5058+ log.Warn().Err(err).Msg("failed to start external theme watcher")
5059+ }
5060+}
5061+
5039 func (a *App) applyAppearanceConfig(ctx context.Context) {5062 func (a *App) applyAppearanceConfig(ctx context.Context) {
5040 log := logging.FromContext(ctx)5063 log := logging.FromContext(ctx)
5041 if a.deps == nil || a.deps.Config == nil {5064 if a.deps == nil || a.deps.Config == nil {
@@ -5057,43 +5080,92 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
5057 a.contentCoord.ApplySettingsToAll(ctx)5080 a.contentCoord.ApplySettingsToAll(ctx)
5058 }5081 }
5059 5082
5060- // Update GTK theme and injected WebUI theme vars5083+ a.applyThemeAppearance(ctx)
5061- if a.deps.Theme != nil {
5062- var display *gdk.Display
5063- if a.mainWindow != nil && a.mainWindow.Window() != nil {
5064- display = a.mainWindow.Window().GetDisplay()
5065- }
5066- a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display)
5067 5084
5068- var inj port.ContentInjector5085+ log.Info().Msg("appearance config updated")
5069- if a.engine != nil {5086+}
5070- inj = a.engine.ContentInjector()
5071- }
5072 5087
5073- if inj != nil {5088+func (a *App) applyThemeAppearance(ctx context.Context) {
5074- findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())5089+ log := logging.FromContext(ctx)
5075- if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {5090+ if a.deps == nil || a.deps.Config == nil || a.deps.Theme == nil {
5076- log.Warn().Err(err).Msg("failed to update find highlight CSS")5091+ return
5077- }5092+ }
5078- }5093+ cfg := a.deps.Config
5079 5094
5080- prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)5095+ var display *gdk.Display
5081- cssText := a.deps.Theme.GetWebUIThemeCSS()5096+ if a.mainWindow != nil && a.mainWindow.Window() != nil {
5082- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {5097+ display = a.mainWindow.Window().GetDisplay()
5083- log.Warn().Err(err).Msg("failed to update WebUI theme CSS")5098+ }
5084- }
5085 5099
5086- if a.contentCoord != nil && inj != nil {5100+ preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"}
5087- a.contentCoord.RefreshInjectedScriptsToAll(ctx)5101+ if a.deps.ColorResolver != nil {
5088- }5102+ preference = a.deps.ColorResolver.Refresh()
5103+ }
5104+ if a.deps.ExternalThemeSource != nil {
5105+ a.deps.ExternalThemeSource.Configure(cfg.Appearance.ExternalTheme)
5106+ }
5107+ resolveThemeUC := a.deps.ResolveThemeUC
5108+ if resolveThemeUC == nil {
5109+ resolveThemeUC = usecase.NewResolveThemeUseCase(a.deps.ExternalThemeSource)
5110+ }
5111+ resolved, err := resolveThemeUC.Refresh(ctx, usecase.ResolveThemeInputFromConfig(
5112+ &cfg.Appearance,
5113+ cfg.DefaultUIScale,
5114+ &cfg.Workspace.Styling,
5115+ preference,
5116+ ))
5117+ if err != nil {
5118+ log.Warn().Err(err).Msg("failed to resolve theme")
5119+ } else {
5120+ logThemeResolution(ctx, resolved.Theme)
5121+ a.deps.Theme.UpdateFromResolved(ctx, resolved.Theme, display)
5122+ a.setupPoolBackgroundColor(ctx)
5123+ }
5124+
5125+ var inj port.ContentInjector
5126+ if a.engine != nil {
5127+ inj = a.engine.ContentInjector()
5128+ }
5089 5129
5090- // Apply WebUI theme to already-loaded dumb:// pages5130+ if inj != nil {
5091- if a.contentCoord != nil {5131+ findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
5092- a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)5132+ if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
5133+ log.Warn().Err(err).Msg("failed to update find highlight CSS")
5093 }5134 }
5094 }5135 }
5095 5136
5096- log.Info().Msg("appearance config updated")5137+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
5138+ cssText := a.deps.Theme.GetWebUIThemeCSS()
5139+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
5140+ log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
5141+ }
5142+
5143+ if a.contentCoord != nil && inj != nil {
5144+ a.contentCoord.RefreshInjectedScriptsToAll(ctx)
5145+ }
5146+
5147+ // Apply WebUI theme to already-loaded dumb:// pages
5148+ if a.contentCoord != nil {
5149+ a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
5150+ }
5151+}
5152+
5153+func logThemeResolution(ctx context.Context, resolved entity.ResolvedTheme) {
5154+ log := logging.FromContext(ctx)
5155+ for _, warning := range resolved.Warnings {
5156+ log.Warn().Str("field", warning.Field).Msg(warning.Message)
5157+ }
5158+ event := log.Info().
5159+ Str("theme_source", string(resolved.ThemeSource.Kind)).
5160+ Str("color_scheme_source", resolved.ColorSchemeSource).
5161+ Bool("prefers_dark", resolved.PrefersDark)
5162+ if resolved.ThemeSource.Provider != "" {
5163+ event = event.Str("provider", resolved.ThemeSource.Provider)
5164+ }
5165+ if resolved.ThemeSource.LastGood {
5166+ event = event.Bool("last_good", true)
5167+ }
5168+ event.Msg("theme resolved")
5097 }5169 }
5098 5170
5099 func (a *App) initFilteringAsync(ctx context.Context) {5171 func (a *App) initFilteringAsync(ctx context.Context) {
M internal/ui/deps.go +6 -3
@@ -28,9 +28,12 @@ type Dependencies struct {
28 OnCrashReportsDetected func([]string)28 OnCrashReportsDetected func([]string)
29 29
30 // Theme and color scheme management30 // Theme and color scheme management
31- Theme *theme.Manager31+ Theme *theme.Manager
32- ColorResolver port.ColorSchemeResolver32+ ResolveThemeUC *usecase.ResolveThemeUseCase
33- AdwaitaDetector port.ToolkitAvailabilityNotifier33+ ExternalThemeSource port.ConfigurableExternalThemeSource
34+ ExternalThemeWatcher port.ExternalThemeWatcher
35+ ColorResolver port.ColorSchemeResolver
36+ AdwaitaDetector port.ToolkitAvailabilityNotifier
34 37
35 // XDG paths38 // XDG paths
36 XDG port.XDGPaths39 XDG port.XDGPaths
A internal/ui/external_theme_reload_test.go +270 -0
@@ -0,0 +1,270 @@
1+package ui
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/mock"
9+ "github.com/stretchr/testify/require"
10+
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12+ "github.com/bnema/dumber/internal/application/usecase"
13+ "github.com/bnema/dumber/internal/domain/entity"
14+ "github.com/bnema/dumber/internal/infrastructure/config"
15+ "github.com/bnema/dumber/internal/shared/syncdispatch"
16+ uitheme "github.com/bnema/dumber/internal/ui/theme"
17+)
18+
19+type recordingExternalThemeWatcher struct {
20+ starts int
21+ stops int
22+ cfg entity.ExternalThemeConfig
23+ trigger func()
24+}
25+
26+func (w *recordingExternalThemeWatcher) Start(_ context.Context, cfg entity.ExternalThemeConfig, onChange func()) error {
27+ w.starts++
28+ w.cfg = cfg
29+ w.trigger = onChange
30+ return nil
31+}
32+
33+func (w *recordingExternalThemeWatcher) Stop() error {
34+ w.stops++
35+ return nil
36+}
37+
38+type mutableExternalThemeSource struct {
39+ cfg entity.ExternalThemeConfig
40+ theme *entity.ExternalTheme
41+ err error
42+}
43+
44+func (s *mutableExternalThemeSource) Configure(cfg entity.ExternalThemeConfig) {
45+ s.cfg = cfg
46+}
47+
48+func (s *mutableExternalThemeSource) ExternalThemeIdentity() string {
49+ if !s.cfg.Enabled {
50+ return ""
51+ }
52+ return s.cfg.Provider + "|" + s.cfg.Format + "|" + s.cfg.Path
53+}
54+
55+func (s *mutableExternalThemeSource) IsEnabled() bool {
56+ return s.cfg.Enabled
57+}
58+
59+func (s *mutableExternalThemeSource) Get(context.Context) (*entity.ExternalTheme, error) {
60+ if !s.IsEnabled() {
61+ return nil, nil
62+ }
63+ return s.theme, s.err
64+}
65+
66+func immediateDispatchForExternalThemeTest(label string, fn func()) syncdispatch.SyncDispatchResult {
67+ fn()
68+ return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchCompleted}
69+}
70+
71+func TestExternalThemeWatcherCallbackAppliesThemeThroughSharedPath(t *testing.T) {
72+ ctx := context.Background()
73+ cfg := config.DefaultConfig()
74+ cfg.Appearance.ColorScheme = "prefer-dark"
75+ cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
76+ Enabled: true,
77+ Provider: "noctalia",
78+ Format: "dumber-json",
79+ Path: "/tmp/theme.json",
80+ }
81+ source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
82+ watcher := &recordingExternalThemeWatcher{}
83+ manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
84+ engine := portmocks.NewMockEngine(t)
85+ var backgroundUpdated bool
86+ engine.EXPECT().UpdateSettings(mock.Anything, mock.Anything).Return(nil).Once()
87+ engine.EXPECT().UpdateAppearance(mock.Anything, mock.Anything, mock.Anything, mock.Anything, mock.Anything).
88+ Run(func(_ context.Context, r, g, b, alpha float64) {
89+ backgroundUpdated = true
90+ require.InDelta(t, 0.0, r, 0.000001)
91+ require.InDelta(t, 0.0, g, 0.000001)
92+ require.InDelta(t, 0.0, b, 0.000001)
93+ require.InDelta(t, 1.0, alpha, 0.000001)
94+ }).
95+ Return(nil).
96+ Once()
97+ engine.EXPECT().ContentInjector().Return(nil).Once()
98+ app := &App{
99+ deps: &Dependencies{
100+ Config: cfg,
101+ Theme: manager,
102+ ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
103+ ExternalThemeSource: source,
104+ ExternalThemeWatcher: watcher,
105+ },
106+ dispatchOnMainThread: immediateDispatchForExternalThemeTest,
107+ engine: engine,
108+ }
109+
110+ app.syncExternalThemeWatcher(ctx)
111+ require.Equal(t, 1, watcher.starts)
112+ require.Equal(t, cfg.Appearance.ExternalTheme, watcher.cfg)
113+ require.NotNil(t, watcher.trigger)
114+
115+ watcher.trigger()
116+
117+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
118+ require.True(t, backgroundUpdated, "theme reload should refresh engine background color")
119+}
120+
121+func TestExternalThemeReloadKeepsLastGoodAndDisablingClearsIt(t *testing.T) {
122+ ctx := context.Background()
123+ cfg := config.DefaultConfig()
124+ cfg.Appearance.ColorScheme = "prefer-dark"
125+ cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
126+ Enabled: true,
127+ Provider: "noctalia",
128+ Format: "dumber-json",
129+ Path: "/tmp/theme.json",
130+ }
131+ source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
132+ manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
133+ app := &App{
134+ deps: &Dependencies{
135+ Config: cfg,
136+ Theme: manager,
137+ ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
138+ ExternalThemeSource: source,
139+ },
140+ dispatchOnMainThread: immediateDispatchForExternalThemeTest,
141+ }
142+
143+ app.applyAppearanceConfig(ctx)
144+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
145+
146+ source.theme = &entity.ExternalTheme{
147+ Name: "broken",
148+ Provider: "noctalia",
149+ LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
150+ DarkPalette: &entity.ColorPalette{Background: "also-bad"},
151+ }
152+ app.applyAppearanceConfig(ctx)
153+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
154+
155+ cfg.Appearance.ExternalTheme.Enabled = false
156+ app.applyAppearanceConfig(ctx)
157+ require.Equal(t, cfg.Appearance.DarkPalette.Background, app.deps.Theme.GetCurrentPalette().Background)
158+}
159+
160+func TestExternalThemeReloadKeepsLastGoodOnReadError(t *testing.T) {
161+ ctx := context.Background()
162+ cfg := config.DefaultConfig()
163+ cfg.Appearance.ColorScheme = "prefer-dark"
164+ cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
165+ Enabled: true,
166+ Provider: "noctalia",
167+ Format: "dumber-json",
168+ Path: "/tmp/theme.json",
169+ }
170+ source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
171+ manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
172+ app := &App{
173+ deps: &Dependencies{
174+ Config: cfg,
175+ Theme: manager,
176+ ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
177+ ExternalThemeSource: source,
178+ },
179+ dispatchOnMainThread: immediateDispatchForExternalThemeTest,
180+ }
181+
182+ app.applyAppearanceConfig(ctx)
183+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
184+
185+ source.theme = nil
186+ source.err = errors.New("temporary read failure")
187+ app.applyAppearanceConfig(ctx)
188+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
189+}
190+
191+func TestExternalThemeReloadRecoversAfterMalformedUpdate(t *testing.T) {
192+ ctx := context.Background()
193+ cfg := config.DefaultConfig()
194+ cfg.Appearance.ColorScheme = "prefer-dark"
195+ cfg.Appearance.ExternalTheme = entity.ExternalThemeConfig{
196+ Enabled: true,
197+ Provider: "noctalia",
198+ Format: "dumber-json",
199+ Path: "/tmp/theme.json",
200+ }
201+ source := &mutableExternalThemeSource{theme: externalThemeWithDarkBackground("#000000")}
202+ manager := uitheme.NewManager(ctx, resolvedThemeForUITest(cfg.Appearance.LightPalette, cfg.Appearance.DarkPalette))
203+ app := &App{
204+ deps: &Dependencies{
205+ Config: cfg,
206+ Theme: manager,
207+ ResolveThemeUC: usecase.NewResolveThemeUseCase(source),
208+ ExternalThemeSource: source,
209+ },
210+ dispatchOnMainThread: immediateDispatchForExternalThemeTest,
211+ }
212+
213+ app.applyAppearanceConfig(ctx)
214+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
215+
216+ source.theme = &entity.ExternalTheme{
217+ Name: "broken",
218+ Provider: "noctalia",
219+ LightPalette: &entity.ColorPalette{Accent: "not-a-hex"},
220+ DarkPalette: &entity.ColorPalette{Background: "also-bad"},
221+ }
222+ app.applyAppearanceConfig(ctx)
223+ require.Equal(t, "#000000", app.deps.Theme.GetCurrentPalette().Background)
224+
225+ source.theme = externalThemeWithDarkBackground("#123456")
226+ app.applyAppearanceConfig(ctx)
227+ require.Equal(t, "#123456", app.deps.Theme.GetCurrentPalette().Background)
228+}
229+
230+func TestOnShutdownStopsExternalThemeWatcher(t *testing.T) {
231+ ctx, cancel := context.WithCancelCause(context.Background())
232+ watcher := &recordingExternalThemeWatcher{}
233+ app := &App{
234+ deps: &Dependencies{ExternalThemeWatcher: watcher},
235+ cancel: cancel,
236+ }
237+
238+ app.onShutdown(ctx)
239+
240+ require.Equal(t, 1, watcher.stops)
241+}
242+
243+func resolvedThemeForUITest(light, dark entity.ColorPalette) entity.ResolvedTheme {
244+ return entity.ResolvedTheme{
245+ LightPalette: light,
246+ DarkPalette: dark,
247+ ActivePalette: dark,
248+ PrefersDark: true,
249+ ColorSchemeSource: "test",
250+ ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
251+ Fonts: entity.DefaultThemeFonts(),
252+ UIScale: 1,
253+ ModeColors: entity.DefaultThemeModeColors(),
254+ }
255+}
256+
257+func externalThemeWithDarkBackground(background string) *entity.ExternalTheme {
258+ return &entity.ExternalTheme{
259+ Name: "Noctalia",
260+ Provider: "noctalia",
261+ LightPalette: &entity.ColorPalette{
262+ Background: "#eeeeee",
263+ Accent: "#112233",
264+ },
265+ DarkPalette: &entity.ColorPalette{
266+ Background: background,
267+ Accent: "#445566",
268+ },
269+ }
270+}
M internal/ui/systemviews/app.go +5 -1
@@ -794,7 +794,11 @@ func (a *App) loadShellTheme(ctx context.Context) {
794 794
795 config, err := a.deps.Config.Current(ctx)795 config, err := a.deps.Config.Current(ctx)
796 if err == nil {796 if err == nil {
797- a.shellTheme = resolveShellTheme(config.Appearance)797+ appearance := config.Appearance
798+ if config.ResolvedAppearance != nil {
799+ appearance = *config.ResolvedAppearance
800+ }
801+ a.shellTheme = resolveShellTheme(appearance)
798 }802 }
799 }803 }
800 804
M internal/ui/systemviews/app_test.go +48 -0
@@ -642,6 +642,12 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
642 Accent: "#66aaff",642 Accent: "#66aaff",
643 Border: "#333333",643 Border: "#333333",
644 },644 },
645+ ExternalTheme: dto.WebUIExternalThemeConfig{
646+ Enabled: true,
647+ Provider: "noctalia",
648+ Format: "dumber-json",
649+ Path: "/tmp/noctalia-dumber.json",
650+ },
645 },651 },
646 Performance: dto.SystemviewPerformancePayload{652 Performance: dto.SystemviewPerformancePayload{
647 Profile: "balanced",653 Profile: "balanced",
@@ -707,6 +713,13 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
707 assert.Contains(t, app.renderedHTML, "Inter")713 assert.Contains(t, app.renderedHTML, "Inter")
708 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")714 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
709 assert.Contains(t, app.renderedHTML, "#ffffff")715 assert.Contains(t, app.renderedHTML, "#ffffff")
716+ assert.Contains(t, app.renderedHTML, "appearance.external_theme.enabled")
717+ assert.Contains(t, app.renderedHTML, "appearance.external_theme.provider")
718+ assert.Contains(t, app.renderedHTML, "appearance.external_theme.format")
719+ assert.Contains(t, app.renderedHTML, "appearance.external_theme.path")
720+ assert.Contains(t, app.renderedHTML, "noctalia")
721+ assert.Contains(t, app.renderedHTML, "dumber-json")
722+ assert.Contains(t, app.renderedHTML, "/tmp/noctalia-dumber.json")
710 assert.Contains(t, app.renderedHTML, "performance.profile")723 assert.Contains(t, app.renderedHTML, "performance.profile")
711 assert.Contains(t, app.renderedHTML, "balanced")724 assert.Contains(t, app.renderedHTML, "balanced")
712 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")725 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
@@ -761,6 +774,13 @@ func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
761 require.NoError(t, app.LoadInitial(context.Background()))774 require.NoError(t, app.LoadInitial(context.Background()))
762 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)775 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
763 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)776 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.reset"`)
777+ assert.Contains(t, app.renderedHTML, `name="external_theme_enabled"`)
778+ assert.Contains(t, app.renderedHTML, `name="external_theme_provider"`)
779+ assert.Contains(t, app.renderedHTML, `name="external_theme_format"`)
780+ assert.Contains(t, app.renderedHTML, `name="external_theme_path"`)
781+ assert.Contains(t, app.renderedHTML, `value="noctalia"`)
782+ assert.Contains(t, app.renderedHTML, `value="dumber-json"`)
783+ assert.Contains(t, app.renderedHTML, `/tmp/noctalia-dumber.json`)
764 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)784 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
765 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)785 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
766 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)786 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
@@ -796,6 +816,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
796 Data: map[string]string{816 Data: map[string]string{
797 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",817 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
798 "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",818 "default_font_size": "18", "default_ui_scale": "1.25", "color_scheme": "prefer-light",
819+ "external_theme_enabled": "yes", "external_theme_provider": " noctalia ", "external_theme_format": " dumber-json ", "external_theme_path": " /tmp/noctalia-dumber.json ",
799 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",820 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
800 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",821 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
801 },822 },
@@ -803,6 +824,10 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
803 assert.True(t, service.calledSave)824 assert.True(t, service.calledSave)
804 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)825 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)826 assert.InEpsilon(t, 1.25, service.savedConfig.DefaultUIScale, 0.001)
827+ assert.True(t, service.savedConfig.Appearance.ExternalTheme.Enabled)
828+ assert.Equal(t, "noctalia", service.savedConfig.Appearance.ExternalTheme.Provider)
829+ assert.Equal(t, "dumber-json", service.savedConfig.Appearance.ExternalTheme.Format)
830+ assert.Equal(t, "/tmp/noctalia-dumber.json", service.savedConfig.Appearance.ExternalTheme.Path)
806 assert.Contains(t, dom.HTML(), "Saved appearance settings")831 assert.Contains(t, dom.HTML(), "Saved appearance settings")
807 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)832 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808 833
@@ -876,6 +901,28 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
876 assert.False(t, service.calledSave)901 assert.False(t, service.calledSave)
877 }902 }
878 903
904+func TestAppLoadInitialConfigRouteUsesResolvedAppearanceOnlyForShellTheme(t *testing.T) {
905+ t.Parallel()
906+
907+ cfg := testConfigPayload()
908+ resolved := cfg.Appearance
909+ resolved.DarkPalette.Background = "#222222"
910+ resolved.DarkPalette.SurfaceVariant = "#444444"
911+ cfg.ResolvedAppearance = &resolved
912+ service := &fakeConfigService{
913+ current: cfg,
914+ keybindings: port.KeybindingsConfig{},
915+ }
916+
917+ app := NewApp(Dependencies{Config: service, LocationURI: "dumb://config"})
918+
919+ require.NoError(t, app.LoadInitial(context.Background()))
920+ assert.Contains(t, app.renderedHTML, `--sv-background: #222222;`)
921+ assert.Contains(t, app.renderedHTML, `--sv-surface-variant: #444444;`)
922+ assert.Contains(t, app.renderedHTML, `value="#111111"`)
923+ assert.Contains(t, app.renderedHTML, `value="#2a2a2a"`)
924+}
925+
879 func testConfigPayload() dto.SystemviewConfigPayload {926 func testConfigPayload() dto.SystemviewConfigPayload {
880 return dto.SystemviewConfigPayload{927 return dto.SystemviewConfigPayload{
881 EngineType: "webkit",928 EngineType: "webkit",
@@ -887,6 +934,7 @@ func testConfigPayload() dto.SystemviewConfigPayload {
887 DefaultFontSize: 16,934 DefaultFontSize: 16,
888 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},935 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
889 DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},936 DarkPalette: dto.ColorPalette{Background: "#111111", Surface: "#1a1a1a", SurfaceVariant: "#2a2a2a", Text: "#f5f5f5", Muted: "#a0a0a0", Accent: "#66aaff", Border: "#333333"},
937+ ExternalTheme: dto.WebUIExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "dumber-json", Path: "/tmp/noctalia-dumber.json"},
890 },938 },
891 DefaultUIScale: 1,939 DefaultUIScale: 1,
892 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",940 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
M internal/ui/systemviews/config.templ +8 -0
@@ -39,6 +39,14 @@ templ AppearanceForm(config dto.SystemviewConfigPayload) {
39 <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>39 <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
40 <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>40 <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
41 </select></label>41 </select></label>
42+ <fieldset class="sv-fieldset">
43+ <legend>External theme</legend>
44+ <p class="sv-meta">Provider noctalia supports colors-json (default Noctalia colors.json) and dumber-json (advanced user template).</p>
45+ <label><span>Enable external theme</span><input name="external_theme_enabled" type="checkbox" checked?={ config.Appearance.ExternalTheme.Enabled }/></label>
46+ <label><span>Provider</span><input name="external_theme_provider" type="text" value={ config.Appearance.ExternalTheme.Provider } placeholder="noctalia"/></label>
47+ <label><span>Format</span><input name="external_theme_format" type="text" value={ config.Appearance.ExternalTheme.Format } placeholder="colors-json"/></label>
48+ <label><span>Path</span><input name="external_theme_path" type="text" value={ config.Appearance.ExternalTheme.Path } placeholder="~/.config/noctalia/colors.json"/></label>
49+ </fieldset>
42 @PaletteFields("Light palette", "light", config.Appearance.LightPalette)50 @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
43 @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)51 @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
44 <div class="sv-button-row sv-form-actions">52 <div class="sv-button-row sv-form-actions">
M internal/ui/systemviews/config_actions.go +13 -0
@@ -102,6 +102,10 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
102 cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"])102 cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"])
103 cfg.Appearance.DefaultFontSize = fontSize103 cfg.Appearance.DefaultFontSize = fontSize
104 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])104 cfg.Appearance.ColorScheme = strings.TrimSpace(data["color_scheme"])
105+ cfg.Appearance.ExternalTheme.Enabled = configCheckboxEnabled(data["external_theme_enabled"])
106+ cfg.Appearance.ExternalTheme.Provider = strings.TrimSpace(data["external_theme_provider"])
107+ cfg.Appearance.ExternalTheme.Format = strings.TrimSpace(data["external_theme_format"])
108+ cfg.Appearance.ExternalTheme.Path = strings.TrimSpace(data["external_theme_path"])
105 cfg.Appearance.LightPalette = paletteFromForm(data, "light")109 cfg.Appearance.LightPalette = paletteFromForm(data, "light")
106 cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")110 cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
107 cfg.DefaultUIScale = uiScale111 cfg.DefaultUIScale = uiScale
@@ -113,6 +117,15 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
113 return nil117 return nil
114 }118 }
115 119
120+func configCheckboxEnabled(value string) bool {
121+ switch strings.ToLower(strings.TrimSpace(value)) {
122+ case "on", "true", "1", "yes":
123+ return true
124+ default:
125+ return false
126+ }
127+}
128+
116 func (a *App) resetAppearanceConfig(ctx context.Context) error {129 func (a *App) resetAppearanceConfig(ctx context.Context) error {
117 cfg, err := a.editableConfig(ctx)130 cfg, err := a.editableConfig(ctx)
118 if err != nil {131 if err != nil {
M internal/ui/systemviews/config_templ.go +327 -278 generated file: collapsed
M internal/ui/theme/css.go +3 -3
@@ -256,7 +256,7 @@ window.standalone-omnibox-window {
256 /* Search entry field */256 /* Search entry field */
257 entry.omnibox-entry,257 entry.omnibox-entry,
258 entry.omnibox-entry > text {258 entry.omnibox-entry > text {
259- background-color: alpha(var(--bg), 0.88);259+ background-color: var(--surface-variant);
260 background-image: none;260 background-image: none;
261 color: var(--text);261 color: var(--text);
262 caret-color: var(--accent);262 caret-color: var(--accent);
@@ -278,7 +278,7 @@ entry.omnibox-entry:focus > text,
278 entry.omnibox-entry:focus-within > text,278 entry.omnibox-entry:focus-within > text,
279 entry.omnibox-entry:focus-visible > text {279 entry.omnibox-entry:focus-visible > text {
280 border-color: var(--accent);280 border-color: var(--accent);
281- background-color: shade(var(--bg), 1.05);281+ background-color: shade(var(--surface-variant), 1.08);
282 background-image: none;282 background-image: none;
283 outline-style: none;283 outline-style: none;
284 outline-width: 0px;284 outline-width: 0px;
@@ -463,7 +463,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus > text,
463 entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text,463 entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text,
464 entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {464 entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
465 border-color: var(--accent);465 border-color: var(--accent);
466- background-color: shade(var(--bg), 1.05);466+ background-color: shade(var(--surface-variant), 1.08);
467 background-image: none;467 background-image: none;
468 }468 }
469 469
M internal/ui/theme/css_test.go +12 -5
@@ -101,20 +101,27 @@ func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
101 }101 }
102 }102 }
103 103
104-func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {104+func TestGenerateCSS_OmniboxSearchEntryUsesControlSurface(t *testing.T) {
105 css := GenerateCSS(DefaultDarkPalette())105 css := GenerateCSS(DefaultDarkPalette())
106 106
107 containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`)107 containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`)
108 if !containerRe.MatchString(css) {108 if !containerRe.MatchString(css) {
109 t.Fatalf("expected omnibox container to use the header surface color")109 t.Fatalf("expected omnibox container to use the header surface color")
110 }110 }
111- entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*alpha\(var\(--bg\),\s*0\.88\);[^}]*background-image:\s*none;`)111+ entryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\s*,\s*entry\.omnibox-entry\s*>\s*text\s*\{[^}]*background-color:\s*var\(--surface-variant\);[^}]*background-image:\s*none;`)
112 if !entryRe.MatchString(css) {112 if !entryRe.MatchString(css) {
113- t.Fatalf("expected omnibox entry and its text node to use the darker background fill")113+ t.Fatalf("expected omnibox entry and its text node to use the control surface fill")
114 }114 }
115- focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--bg\),\s*1\.05\);`)115+ focusedEntryRe := regexp.MustCompile(`(?s)entry\.omnibox-entry:focus[^\{]*,\s*entry\.omnibox-entry:focus-within[^\{]*,\s*entry\.omnibox-entry:focus-visible[^\{]*,\s*entry\.omnibox-entry:focus\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-within\s*>\s*text[^\{]*,\s*entry\.omnibox-entry:focus-visible\s*>\s*text\s*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`)
116 if !focusedEntryRe.MatchString(css) {116 if !focusedEntryRe.MatchString(css) {
117- t.Fatalf("expected focused omnibox entry text node to keep the darker bg-based fill")117+ t.Fatalf("expected focused omnibox entry text node to keep a distinct control-surface fill")
118+ }
119+ bangActiveRe := regexp.MustCompile(`(?s)entry\.omnibox-entry\.omnibox-entry-bang-active[^\{]*\{[^}]*background-color:\s*shade\(var\(--surface-variant\),\s*1\.08\);`)
120+ if !bangActiveRe.MatchString(css) {
121+ t.Fatalf("expected bang-active omnibox entry to keep a distinct control-surface fill")
122+ }
123+ if strings.Contains(css, "background-color: alpha(var(--bg), 0.88);") || strings.Contains(css, "background-color: shade(var(--bg), 1.05);") {
124+ t.Fatalf("expected omnibox entry fills not to derive from the page background")
118 }125 }
119 scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`)126 scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`)
120 if !scrolledRe.MatchString(css) {127 if !scrolledRe.MatchString(css) {
M internal/ui/theme/manager.go +37 -157
@@ -6,7 +6,6 @@ import (
6 "math"6 "math"
7 "strings"7 "strings"
8 8
9- "github.com/bnema/dumber/internal/application/port"
10 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
11 "github.com/bnema/dumber/internal/logging"10 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gdk"11 "github.com/bnema/puregotk/v4/gdk"
@@ -14,106 +13,51 @@ import (
14 )13 )
15 14
16 // Manager handles theme state and CSS application.15 // Manager handles theme state and CSS application.
16+// Theme precedence and fallback are resolved before data reaches this adapter.
17 type Manager struct {17 type Manager struct {
18- scheme string // "light", "dark", "system"18+ prefersDark bool // Resolved dark mode preference
19- prefersDark bool // Resolved dark mode preference19+ lightPalette Palette // Light theme colors
20- lightPalette Palette // Light theme colors20+ darkPalette Palette // Dark theme colors
21- darkPalette Palette // Dark theme colors21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22- uiScale float64 // UI scaling factor (1.0 = 100%)22+ fonts FontConfig
23- fonts FontConfig23+ gtkFont string
24- gtkFont string24+ modeColors ModeColors // Modal mode indicator colors
25- modeColors ModeColors // Modal mode indicator colors25+ cssProvider *gtk.CssProvider
26- cssProvider *gtk.CssProvider26+ appliedFont string
27- appliedFont string
28- colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
29 }27 }
30 28
31-// NewManager creates a new theme manager from configuration.29+// NewManager creates a new theme manager from an already-resolved theme.
32-// The ColorSchemeResolver is required for proper color scheme detection.30+func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
33-//
34-// appearance controls fonts, palette overrides and color scheme.
35-// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
36-// styling controls mode indicator colors; may be nil to use defaults.
37-func NewManager(
38- ctx context.Context,
39- appearance *entity.AppearanceConfig,
40- uiScale float64,
41- styling *entity.WorkspaceStylingConfig,
42- resolver port.ColorSchemeResolver,
43-) *Manager {
44 log := logging.FromContext(ctx)31 log := logging.FromContext(ctx)
45 32
46- // Determine color scheme preference33+ m := &Manager{}
47- scheme := "system"34+ m.applyResolvedTheme(resolved)
48- if appearance != nil && appearance.ColorScheme != "" {
49- scheme = appearance.ColorScheme
50- }
51-
52- // Resolve whether we should use dark mode via resolver
53- pref := resolver.Resolve()
54- prefersDark := pref.PrefersDark
55-
56- // Build palettes from config or defaults
57- var lightPalette, darkPalette Palette
58- if appearance != nil {
59- lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
60- darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
61- } else {
62- lightPalette = DefaultLightPalette()
63- darkPalette = DefaultDarkPalette()
64- }
65-
66- // Get UI scale factor (default to 1.0 if not set)
67- if uiScale <= 0 {
68- uiScale = 1.0
69- }
70-
71- fonts := DefaultFontConfig()
72- gtkFont := DefaultGTKFont()
73- if appearance != nil {
74- fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
75- fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
76- gtkFont = Coalesce(appearance.GtkFont, gtkFont)
77- fonts.GtkFont = gtkFont
78- }
79-
80- // Build mode colors from config
81- modeColors := modeColorsFromStyling(styling)
82 35
83 log.Debug().36 log.Debug().
84- Str("scheme", scheme).37+ Bool("prefers_dark", m.prefersDark).
85- Bool("prefers_dark", prefersDark).38+ Float64("ui_scale", m.uiScale).
86- Float64("ui_scale", uiScale).39+ Str("sans_font", m.fonts.SansFont).
87- Str("sans_font", fonts.SansFont).40+ Str("monospace_font", m.fonts.MonospaceFont).
88- Str("monospace_font", fonts.MonospaceFont).41+ Str("gtk_font", m.gtkFont).
89- Str("gtk_font", gtkFont).
90 Msg("theme manager initialized")42 Msg("theme manager initialized")
91 43
92- return &Manager{44+ return m
93- scheme: scheme,
94- prefersDark: prefersDark,
95- lightPalette: lightPalette,
96- darkPalette: darkPalette,
97- uiScale: uiScale,
98- fonts: fonts,
99- gtkFont: gtkFont,
100- modeColors: modeColors,
101- colorResolver: resolver,
102- }
103 }45 }
104 46
105-// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.47+func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
106-func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {48+ m.prefersDark = resolved.PrefersDark
107- defaults := DefaultModeColors()49+ m.lightPalette = PaletteFromEntity(resolved.LightPalette, false)
108- if styling == nil {50+ m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true)
109- return defaults51+ m.uiScale = resolved.UIScale
52+ if m.uiScale <= 0 {
53+ m.uiScale = 1.0
110 }54 }
111- return ModeColors{55+ m.fonts = FontConfigFromEntity(resolved.Fonts)
112- PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),56+ if m.fonts.GtkFont == "" {
113- TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),57+ m.fonts.GtkFont = DefaultGTKFont()
114- SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
115- ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
116 }58 }
59+ m.gtkFont = m.fonts.GtkFont
60+ m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
117 }61 }
118 62
119 // PrefersDark returns true if dark mode is active.63 // PrefersDark returns true if dark mode is active.
@@ -121,7 +65,7 @@ func (m *Manager) PrefersDark() bool {
121 return m.prefersDark65 return m.prefersDark
122 }66 }
123 67
124-// GetCurrentPalette returns the active palette based on current scheme.68+// GetCurrentPalette returns the active palette based on current preference.
125 func (m *Manager) GetCurrentPalette() Palette {69 func (m *Manager) GetCurrentPalette() Palette {
126 if m.prefersDark {70 if m.prefersDark {
127 return m.darkPalette71 return m.darkPalette
@@ -230,83 +174,19 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
230 Msg("theme CSS applied to display")174 Msg("theme CSS applied to display")
231 }175 }
232 176
233-// SetColorScheme changes the active color scheme at runtime.177+// UpdateFromResolved updates the theme manager state from a resolved theme.
234-func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {178+func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) {
235 log := logging.FromContext(ctx)179 log := logging.FromContext(ctx)
236 180
237- m.scheme = scheme181+ m.applyResolvedTheme(resolved)
238- pref := m.colorResolver.Refresh()
239- m.prefersDark = pref.PrefersDark
240-
241- log.Info().
242- Str("scheme", scheme).
243- Bool("prefers_dark", m.prefersDark).
244- Str("source", pref.Source).
245- Msg("color scheme changed")
246-
247- // Re-apply CSS if display is available
248- if display != nil {
249- m.ApplyToDisplay(ctx, display)
250- }
251-}
252-
253-// UpdateFromConfig updates the theme manager state from new config values.
254-//
255-// appearance controls fonts, palette overrides and color scheme.
256-// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
257-// styling controls mode indicator colors; may be nil to keep existing colors.
258-func (m *Manager) UpdateFromConfig(
259- ctx context.Context,
260- appearance *entity.AppearanceConfig,
261- uiScale float64,
262- styling *entity.WorkspaceStylingConfig,
263- display *gdk.Display,
264-) {
265- log := logging.FromContext(ctx)
266-
267- if appearance == nil {
268- return
269- }
270-
271- // Update scheme and resolve prefersDark via resolver
272- scheme := "system"
273- if appearance.ColorScheme != "" {
274- scheme = appearance.ColorScheme
275- }
276- m.scheme = scheme
277- pref := m.colorResolver.Refresh()
278- m.prefersDark = pref.PrefersDark
279-
280- // Update palettes
281- m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
282- m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
283-
284- // Update UI scale
285- if uiScale > 0 {
286- m.uiScale = uiScale
287- }
288-
289- defaults := DefaultFontConfig()
290- m.gtkFont = Coalesce(appearance.GtkFont, DefaultGTKFont())
291- m.fonts = FontConfig{
292- SansFont: Coalesce(appearance.SansFont, defaults.SansFont),
293- MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
294- GtkFont: m.gtkFont,
295- }
296-
297- // Update mode colors
298- if styling != nil {
299- m.modeColors = modeColorsFromStyling(styling)
300- }
301 182
302 log.Info().183 log.Info().
303- Str("scheme", m.scheme).
304 Bool("prefers_dark", m.prefersDark).184 Bool("prefers_dark", m.prefersDark).
305 Float64("ui_scale", m.uiScale).185 Float64("ui_scale", m.uiScale).
306 Str("sans_font", m.fonts.SansFont).186 Str("sans_font", m.fonts.SansFont).
307 Str("monospace_font", m.fonts.MonospaceFont).187 Str("monospace_font", m.fonts.MonospaceFont).
308 Str("gtk_font", m.gtkFont).188 Str("gtk_font", m.gtkFont).
309- Msg("theme manager updated from config")189+ Msg("theme manager updated from resolved theme")
310 190
311 // Re-apply CSS if display is available191 // Re-apply CSS if display is available
312 if display != nil {192 if display != nil {
M internal/ui/theme/manager_test.go +103 -255
@@ -4,315 +4,163 @@ import (
4 "context"4 "context"
5 "testing"5 "testing"
6 6
7- "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/application/port/mocks"
9 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/stretchr/testify/assert"8 "github.com/stretchr/testify/assert"
11 "github.com/stretchr/testify/require"9 "github.com/stretchr/testify/require"
12 )10 )
13 11
14-func TestNewManager_DarkMode(t *testing.T) {12+func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme {
15- ctx := context.Background()13+ light := entity.ColorPalette{
16- appearance := &entity.AppearanceConfig{14+ Background: "#ffffff",
17- ColorScheme: "default",15+ Surface: "#f8f8f8",
16+ SurfaceVariant: "#eeeeee",
17+ Text: "#111111",
18+ Muted: "#666666",
19+ Accent: "#0055ff",
20+ Border: "#dddddd",
18 }21 }
19-22+ dark := entity.ColorPalette{
20- mockResolver := mocks.NewMockColorSchemeResolver(t)23+ Background: "#101010",
21- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{24+ Surface: "#181818",
22- PrefersDark: true,25+ SurfaceVariant: "#242424",
23- Source: "test",26+ Text: "#f5f5f5",
24- })27+ Muted: "#999999",
25-28+ Accent: "#66aaff",
26- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)29+ Border: "#333333",
27-30+ }
28- assert.True(t, manager.PrefersDark())31+ active := light
29- assert.Equal(t, "default", manager.scheme)32+ if prefersDark {
30-}33+ active = dark
31-34+ }
32-func TestNewManager_LightMode(t *testing.T) {35+ return entity.ResolvedTheme{
33- ctx := context.Background()36+ LightPalette: light,
34- appearance := &entity.AppearanceConfig{37+ DarkPalette: dark,
35- ColorScheme: "default",38+ ActivePalette: active,
39+ PrefersDark: prefersDark,
40+ ColorSchemeSource: "test",
41+ ThemeSource: entity.ThemeSourceMetadata{Kind: entity.ThemeSourceConfig},
42+ Fonts: entity.ThemeFonts{
43+ SansFont: "Inter",
44+ SerifFont: "Georgia",
45+ MonospaceFont: "JetBrains Mono",
46+ GtkFont: "Adwaita Sans",
47+ DefaultSize: 16,
48+ },
49+ UIScale: 1.5,
50+ ModeColors: entity.ThemeModeColors{
51+ PaneMode: "#ff0000",
52+ TabMode: "#00ff00",
53+ SessionMode: "#0000ff",
54+ ResizeMode: "#ffff00",
55+ },
36 }56 }
37-
38- mockResolver := mocks.NewMockColorSchemeResolver(t)
39- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
40- PrefersDark: false,
41- Source: "test",
42- })
43-
44- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
45-
46- assert.False(t, manager.PrefersDark())
47 }57 }
48 58
49-func TestNewManager_WithNilConfig(t *testing.T) {59+func TestNewManager_UsesResolvedThemeFields(t *testing.T) {
50 ctx := context.Background()60 ctx := context.Background()
61+ resolved := resolvedThemeFixture(true)
51 62
52- mockResolver := mocks.NewMockColorSchemeResolver(t)63+ manager := NewManager(ctx, resolved)
53- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
54- PrefersDark: true,
55- Source: "fallback",
56- })
57-
58- manager := NewManager(ctx, nil, 0, nil, mockResolver)
59 64
60 require.NotNil(t, manager)65 require.NotNil(t, manager)
61 assert.True(t, manager.PrefersDark())66 assert.True(t, manager.PrefersDark())
62- assert.Equal(t, "system", manager.scheme)67+ assert.Equal(t, "#ffffff", manager.GetLightPalette().Background)
68+ assert.Equal(t, "#101010", manager.GetDarkPalette().Background)
69+ assert.Equal(t, manager.GetDarkPalette(), manager.GetCurrentPalette())
70+ assert.Equal(t, "#ff0000", manager.GetModeColors().PaneMode)
71+ assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, manager.fonts)
72+ assert.Equal(t, "Adwaita Sans", manager.gtkFont)
73+ assert.InDelta(t, 1.5, manager.uiScale, 0.000001)
63 }74 }
64 75
65-func TestNewManager_UsesConfigScheme(t *testing.T) {76+func TestNewManager_DefaultsInvalidScaleToOne(t *testing.T) {
66 ctx := context.Background()77 ctx := context.Background()
67- appearance := &entity.AppearanceConfig{78+ resolved := resolvedThemeFixture(true)
68- ColorScheme: "prefer-dark",79+ resolved.UIScale = 0
69- }
70-
71- mockResolver := mocks.NewMockColorSchemeResolver(t)
72- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
73- PrefersDark: true,
74- Source: "config",
75- })
76-
77- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
78-
79- assert.Equal(t, "prefer-dark", manager.scheme)
80-}
81-
82-func TestManager_GetCurrentPalette_Dark(t *testing.T) {
83- ctx := context.Background()
84-
85- mockResolver := mocks.NewMockColorSchemeResolver(t)
86- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
87- PrefersDark: true,
88- Source: "test",
89- })
90-
91- manager := NewManager(ctx, nil, 0, nil, mockResolver)
92- palette := manager.GetCurrentPalette()
93-
94- // Dark palette should be returned
95- assert.Equal(t, manager.GetDarkPalette(), palette)
96-}
97-
98-func TestManager_GetCurrentPalette_Light(t *testing.T) {
99- ctx := context.Background()
100-
101- mockResolver := mocks.NewMockColorSchemeResolver(t)
102- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
103- PrefersDark: false,
104- Source: "test",
105- })
106-
107- manager := NewManager(ctx, nil, 0, nil, mockResolver)
108- palette := manager.GetCurrentPalette()
109-
110- // Light palette should be returned
111- assert.Equal(t, manager.GetLightPalette(), palette)
112-}
113-
114-func TestManager_SetColorScheme(t *testing.T) {
115- ctx := context.Background()
116-
117- mockResolver := mocks.NewMockColorSchemeResolver(t)
118- // Initial resolve call
119- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
120- PrefersDark: true,
121- Source: "test",
122- })
123- // Refresh call when SetColorScheme is called
124- mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
125- PrefersDark: false,
126- Source: "config",
127- })
128-
129- manager := NewManager(ctx, nil, 0, nil, mockResolver)
130- assert.True(t, manager.PrefersDark())
131 80
132- // Change scheme - this calls Refresh on resolver81+ manager := NewManager(ctx, resolved)
133- manager.SetColorScheme(ctx, "prefer-light", nil)
134 82
135- assert.False(t, manager.PrefersDark())83+ require.NotNil(t, manager)
136- assert.Equal(t, "prefer-light", manager.scheme)84+ assert.InDelta(t, 1.0, manager.uiScale, 0.000001)
137 }85 }
138 86
139-func TestManager_UpdateFromConfig(t *testing.T) {87+func TestManager_UpdateFromResolvedReplacesResolvedValues(t *testing.T) {
140 ctx := context.Background()88 ctx := context.Background()
141- appearance := &entity.AppearanceConfig{89+ manager := NewManager(ctx, resolvedThemeFixture(true))
142- ColorScheme: "default",90+ updated := resolvedThemeFixture(false)
143- }91+ updated.LightPalette.Background = "#abcdef"
144-92+ updated.DarkPalette.Background = "#123456"
145- mockResolver := mocks.NewMockColorSchemeResolver(t)93+ updated.Fonts.SansFont = "Recursive"
146- // Initial resolve94+ updated.UIScale = 2.0
147- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{95+ updated.ModeColors.PaneMode = "#654321"
148- PrefersDark: true,
149- Source: "test",
150- })
151- // Refresh when UpdateFromConfig is called
152- mockResolver.EXPECT().Refresh().Return(port.ColorSchemePreference{
153- PrefersDark: false,
154- Source: "config",
155- })
156 96
157- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)97+ manager.UpdateFromResolved(ctx, updated, nil)
158- assert.True(t, manager.PrefersDark())
159-
160- // Update with new config
161- newAppearance := &entity.AppearanceConfig{
162- ColorScheme: "prefer-light",
163- }
164- manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil)
165 98
166 assert.False(t, manager.PrefersDark())99 assert.False(t, manager.PrefersDark())
167- assert.Equal(t, "prefer-light", manager.scheme)100+ assert.Equal(t, "#abcdef", manager.GetLightPalette().Background)
168-}101+ assert.Equal(t, "#123456", manager.GetDarkPalette().Background)
169-102+ assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette())
170-func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {103+ assert.Equal(t, "Recursive", manager.fonts.SansFont)
171- ctx := context.Background()104+ assert.InDelta(t, 2.0, manager.uiScale, 0.000001)
172-105+ assert.Equal(t, "#654321", manager.GetModeColors().PaneMode)
173- mockResolver := mocks.NewMockColorSchemeResolver(t)
174- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
175- PrefersDark: true,
176- Source: "test",
177- })
178-
179- manager := NewManager(ctx, nil, 0, nil, mockResolver)
180- initialScheme := manager.scheme
181-
182- // UpdateFromConfig with nil appearance should be a no-op
183- manager.UpdateFromConfig(ctx, nil, 0, nil, nil)
184-
185- assert.Equal(t, initialScheme, manager.scheme)
186 }106 }
187 107
188 func TestManager_GetWebUIThemeCSS(t *testing.T) {108 func TestManager_GetWebUIThemeCSS(t *testing.T) {
189 ctx := context.Background()109 ctx := context.Background()
110+ manager := NewManager(ctx, resolvedThemeFixture(true))
190 111
191- mockResolver := mocks.NewMockColorSchemeResolver(t)
192- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
193- PrefersDark: true,
194- Source: "test",
195- })
196-
197- manager := NewManager(ctx, nil, 0, nil, mockResolver)
198 css := manager.GetWebUIThemeCSS()112 css := manager.GetWebUIThemeCSS()
199 113
200- // Should contain both light and dark variables
201 assert.Contains(t, css, ":root{")114 assert.Contains(t, css, ":root{")
202 assert.Contains(t, css, ".dark{")115 assert.Contains(t, css, ".dark{")
116+ assert.Contains(t, css, "--background: #ffffff")
117+ assert.Contains(t, css, "--background: #101010")
203 }118 }
204 119
205-func TestManager_GetBackgroundRGBA(t *testing.T) {120+func TestManager_GetBackgroundRGBAUsesCurrentPalette(t *testing.T) {
206 ctx := context.Background()121 ctx := context.Background()
122+ manager := NewManager(ctx, resolvedThemeFixture(true))
207 123
208- mockResolver := mocks.NewMockColorSchemeResolver(t)
209- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
210- PrefersDark: true,
211- Source: "test",
212- })
213-
214- manager := NewManager(ctx, nil, 0, nil, mockResolver)
215 r, g, b, a := manager.GetBackgroundRGBA()124 r, g, b, a := manager.GetBackgroundRGBA()
216 125
217- // Should return valid RGBA values (0-1 range)126+ assert.InDelta(t, float32(0x10)/255, r, 0.0001)
218- assert.GreaterOrEqual(t, r, float32(0))127+ assert.InDelta(t, float32(0x10)/255, g, 0.0001)
219- assert.LessOrEqual(t, r, float32(1))128+ assert.InDelta(t, float32(0x10)/255, b, 0.0001)
220- assert.GreaterOrEqual(t, g, float32(0))129+ assert.InDelta(t, float32(1), a, 0.0001)
221- assert.LessOrEqual(t, g, float32(1))
222- assert.GreaterOrEqual(t, b, float32(0))
223- assert.LessOrEqual(t, b, float32(1))
224- assert.GreaterOrEqual(t, a, float32(0))
225- assert.LessOrEqual(t, a, float32(1))
226 }130 }
227 131
228-func TestManager_CustomPalettes(t *testing.T) {132+func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) {
229- ctx := context.Background()133+ palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false)
230- appearance := &entity.AppearanceConfig{
231- LightPalette: entity.ColorPalette{
232- Background: "#ffffff",
233- Text: "#000000",
234- },
235- DarkPalette: entity.ColorPalette{
236- Background: "#000000",
237- Text: "#ffffff",
238- },
239- }
240-
241- mockResolver := mocks.NewMockColorSchemeResolver(t)
242- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
243- PrefersDark: true,
244- Source: "test",
245- })
246-
247- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
248-
249- lightPalette := manager.GetLightPalette()
250- darkPalette := manager.GetDarkPalette()
251 134
252- assert.Equal(t, "#ffffff", lightPalette.Background)135+ assert.Equal(t, "#ffffff", palette.Background)
253- assert.Equal(t, "#000000", lightPalette.Text)136+ assert.Equal(t, "#111111", palette.Text)
254- assert.Equal(t, "#000000", darkPalette.Background)137+ assert.Equal(t, DefaultLightPalette().Success, palette.Success)
255- assert.Equal(t, "#ffffff", darkPalette.Text)138+ assert.Equal(t, DefaultLightPalette().Warning, palette.Warning)
139+ assert.Equal(t, DefaultLightPalette().Destructive, palette.Destructive)
256 }140 }
257 141
258-func TestManager_UIScale(t *testing.T) {142+func TestModeColorsFromEntity(t *testing.T) {
259- ctx := context.Background()143+ modeColors := ModeColorsFromEntity(entity.ThemeModeColors{
260-144+ PaneMode: "#111111",
261- mockResolver := mocks.NewMockColorSchemeResolver(t)145+ TabMode: "#222222",
262- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{146+ SessionMode: "#333333",
263- PrefersDark: true,147+ ResizeMode: "#444444",
264- Source: "test",
265 })148 })
266 149
267- manager := NewManager(ctx, nil, 1.5, nil, mockResolver)150+ assert.Equal(t, "#111111", modeColors.PaneMode)
268-151+ assert.Equal(t, "#222222", modeColors.TabMode)
269- // UI scale should be stored (we can't directly access it, but we can verify152+ assert.Equal(t, "#333333", modeColors.SessionMode)
270- // the manager was created without error)153+ assert.Equal(t, "#444444", modeColors.ResizeMode)
271- require.NotNil(t, manager)
272 }154 }
273 155
274-func TestManager_Fonts(t *testing.T) {156+func TestFontConfigFromEntity(t *testing.T) {
275- ctx := context.Background()157+ fonts := FontConfigFromEntity(entity.ThemeFonts{
276- appearance := &entity.AppearanceConfig{
277 SansFont: "Inter",158 SansFont: "Inter",
278 MonospaceFont: "JetBrains Mono",159 MonospaceFont: "JetBrains Mono",
279 GtkFont: "Adwaita Sans",160 GtkFont: "Adwaita Sans",
280- }
281-
282- mockResolver := mocks.NewMockColorSchemeResolver(t)
283- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
284- PrefersDark: true,
285- Source: "test",
286 })161 })
287 162
288- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)163+ assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts)
289-
290- // Fonts should be stored (manager creation should succeed)
291- require.NotNil(t, manager)
292-}
293-
294-func TestManager_ModeColors(t *testing.T) {
295- ctx := context.Background()
296- styling := &entity.WorkspaceStylingConfig{
297- PaneModeColor: "#ff0000",
298- TabModeColor: "#00ff00",
299- SessionModeColor: "#0000ff",
300- ResizeModeColor: "#ffff00",
301- }
302-
303- mockResolver := mocks.NewMockColorSchemeResolver(t)
304- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
305- PrefersDark: true,
306- Source: "test",
307- })
308-
309- manager := NewManager(ctx, nil, 1.0, styling, mockResolver)
310- modeColors := manager.GetModeColors()
311-
312- assert.Equal(t, "#ff0000", modeColors.PaneMode)
313- assert.Equal(t, "#00ff00", modeColors.TabMode)
314- assert.Equal(t, "#0000ff", modeColors.SessionMode)
315- assert.Equal(t, "#ffff00", modeColors.ResizeMode)
316 }164 }
317 165
318 func TestDefaultGTKFont(t *testing.T) {166 func TestDefaultGTKFont(t *testing.T) {
M internal/ui/theme/palette.go +41 -0
@@ -112,6 +112,47 @@ func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette {
112 }112 }
113 }113 }
114 114
115+// PaletteFromEntity creates a CSS adapter palette from a resolved domain palette.
116+// Resolved palettes already contain validated color tokens; this adapter only adds
117+// semantic status colors used by GTK/WebUI CSS.
118+func PaletteFromEntity(p entity.ColorPalette, isDark bool) Palette {
119+ defaults := DefaultLightPalette()
120+ if isDark {
121+ defaults = DefaultDarkPalette()
122+ }
123+ return Palette{
124+ Background: p.Background,
125+ Surface: p.Surface,
126+ SurfaceVariant: p.SurfaceVariant,
127+ Text: p.Text,
128+ Muted: p.Muted,
129+ Accent: p.Accent,
130+ Border: p.Border,
131+ Success: defaults.Success,
132+ Warning: defaults.Warning,
133+ Destructive: defaults.Destructive,
134+ }
135+}
136+
137+// ModeColorsFromEntity creates CSS adapter mode colors from resolved domain mode colors.
138+func ModeColorsFromEntity(colors entity.ThemeModeColors) ModeColors {
139+ return ModeColors{
140+ PaneMode: colors.PaneMode,
141+ TabMode: colors.TabMode,
142+ SessionMode: colors.SessionMode,
143+ ResizeMode: colors.ResizeMode,
144+ }
145+}
146+
147+// FontConfigFromEntity creates CSS adapter font config from resolved domain fonts.
148+func FontConfigFromEntity(fonts entity.ThemeFonts) FontConfig {
149+ return FontConfig{
150+ SansFont: fonts.SansFont,
151+ MonospaceFont: fonts.MonospaceFont,
152+ GtkFont: fonts.GtkFont,
153+ }
154+}
155+
115 // Coalesce returns the first non-empty string.156 // Coalesce returns the first non-empty string.
116 func Coalesce(values ...string) string {157 func Coalesce(values ...string) string {
117 for _, v := range values {158 for _, v := range values {

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