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 {
249249
250250 ctx, browserSession, sessionCleanup := initBrowserSession(ctx, cfg, repos, bootstrapLog)
251251 defer sessionCleanup()
252+ bootstrap.LogResolvedTheme(ctx, initResult.ResolvedTheme)
252253 timer.Mark("session")
253254
254255 log := logging.FromContext(ctx)
@@ -326,6 +327,9 @@ func runStandaloneOmnibox() int {
326327 cfg,
327328 initResult.RuntimeProfile,
328329 initResult.ThemeManager,
330+ initResult.ResolveThemeUC,
331+ initResult.ExternalThemeSource,
332+ initResult.ExternalThemeWatcher,
329333 initResult.ColorResolver,
330334 initResult.AdwaitaDetector,
331335 nil,
@@ -471,7 +475,8 @@ func buildAndConfigureApp(
471475 ) (*ui.App, error) {
472476 uiDeps, err := buildUIDependencies(
473477 ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,
474- initResult.ColorResolver, initResult.AdwaitaDetector,
478+ initResult.ResolveThemeUC, initResult.ExternalThemeSource,
479+ initResult.ExternalThemeWatcher, initResult.ColorResolver, initResult.AdwaitaDetector,
475480 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
476481 )
477482 if err != nil {
@@ -506,11 +511,12 @@ func initStackAndRepos(
506511 if needsEagerDB {
507512 // Parallel phase 2: Database + engine initialize concurrently
508513 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{
509- Ctx: ctx,
510- Config: cfg,
511- RuntimeProfile: initResult.RuntimeProfile,
512- ThemeManager: initResult.ThemeManager,
513- ColorResolver: initResult.ColorResolver,
514+ Ctx: ctx,
515+ Config: cfg,
516+ RuntimeProfile: initResult.RuntimeProfile,
517+ ThemeManager: initResult.ThemeManager,
518+ ExternalThemeSource: initResult.ExternalThemeSource,
519+ ColorResolver: initResult.ColorResolver,
514520 })
515521 if err != nil {
516522 return nil, nil, nil, err
@@ -520,12 +526,13 @@ func initStackAndRepos(
520526
521527 log := logging.FromContext(ctx)
522528 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{
523- Ctx: ctx,
524- Config: cfg,
525- RuntimeProfile: initResult.RuntimeProfile,
526- ThemeManager: initResult.ThemeManager,
527- ColorResolver: initResult.ColorResolver,
528- Logger: *log,
529+ Ctx: ctx,
530+ Config: cfg,
531+ RuntimeProfile: initResult.RuntimeProfile,
532+ ThemeManager: initResult.ThemeManager,
533+ ExternalThemeSource: initResult.ExternalThemeSource,
534+ ColorResolver: initResult.ColorResolver,
535+ Logger: *log,
529536 })
530537 if err != nil {
531538 return nil, nil, nil, err
@@ -834,6 +841,9 @@ func buildUIDependencies(
834841 cfg *config.Config,
835842 runtimeProfile runtimeprofile.Profile,
836843 themeManager *theme.Manager,
844+ resolveThemeUC *usecase.ResolveThemeUseCase,
845+ externalThemeSource port.ConfigurableExternalThemeSource,
846+ externalThemeWatcher port.ExternalThemeWatcher,
837847 colorResolver port.ColorSchemeResolver,
838848 adwaitaDetector port.ToolkitAvailabilityNotifier,
839849 engine port.Engine,
@@ -857,14 +867,17 @@ func buildUIDependencies(
857867 browserLauncher := desktop.NewBrowserLauncher(browserLaunchRelay)
858868
859869 uiDeps := &ui.Dependencies{
860- Ctx: ctx,
861- Config: cfg,
862- InitialURL: initialURL,
863- RestoreSessionID: restoreSessionID,
864- StartupCrashReports: startupCrashReports,
865- Theme: themeManager,
866- ColorResolver: colorResolver,
867- AdwaitaDetector: adwaitaDetector,
870+ Ctx: ctx,
871+ Config: cfg,
872+ InitialURL: initialURL,
873+ RestoreSessionID: restoreSessionID,
874+ StartupCrashReports: startupCrashReports,
875+ Theme: themeManager,
876+ ResolveThemeUC: resolveThemeUC,
877+ ExternalThemeSource: externalThemeSource,
878+ ExternalThemeWatcher: externalThemeWatcher,
879+ ColorResolver: colorResolver,
880+ AdwaitaDetector: adwaitaDetector,
868881 XDG: xdg.New(
869882 runtimeProfile.Mode == runtimeprofile.ModeDev,
870883 bootstrap.ResolveXDGRuntimeDir(runtimeProfile),
M docs/config/index.md +68 -0
@@ -141,6 +141,10 @@ auto_open_on_new_pane = false
141141 | `appearance.monospace_font` | string | `"Fira Code"` | Monospace font |
142142 | `appearance.default_font_size` | int | `16` | Font size in points |
143143 | `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 |
144148
145149 ### Color Palettes
146150
@@ -168,6 +172,70 @@ accent = "#a8a8a8"
168172 border = "#363636"
169173 ```
170174
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+
171239 ## Debug Options
172240
173241 | 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 @@
3333 | `appearance.monospace_font` | string | `Fira Code` | |
3434 | `appearance.default_font_size` | int | `16` | |
3535 | `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 |
3640 | `appearance.light_palette.background` | string | `#f8f8f8` | |
3741 | `appearance.light_palette.surface` | string | `#f2f2f2` | |
3842 | `appearance.light_palette.surface_variant` | string | `#ececec` | |
M internal/application/dto/webui_config.go +25 -20
@@ -1,5 +1,7 @@
11 package dto
22
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
35 // WebUIConfig represents the subset of configuration editable in dumb://config.
46 type WebUIConfig struct {
57 Appearance WebUIAppearanceConfig `json:"appearance"`
@@ -43,7 +45,11 @@ type SystemviewPerformancePayload struct {
4345
4446 // SystemviewConfigPayload is the full JSON shape expected by dumb://config.
4547 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"`
4753 Performance SystemviewPerformancePayload `json:"performance"`
4854 DefaultUIScale float64 `json:"default_ui_scale"`
4955 DefaultSearchEngine string `json:"default_search_engine"`
@@ -66,28 +72,27 @@ type WebUICustomPerformanceConfig struct {
6672 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
6773 }
6874
69-type WebUIAppearanceConfig struct {
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-}
75+type WebUIAppearanceConfig = entity.AppearanceConfig
7976
80-type ColorPalette struct {
81- Background string `json:"background"`
82- Surface string `json:"surface"`
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-}
77+type WebUIExternalThemeConfig = entity.ExternalThemeConfig
78+
79+type ColorPalette = entity.ColorPalette
8980
9081 type SearchShortcut struct {
9182 URL string `json:"url"`
9283 Description string `json:"description"`
9384 }
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 (
66
77 "github.com/bnema/dumber/internal/application/dto"
88 "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
910 )
1011
1112 // ReadSystemviewConfigUseCase exposes read-side systemview config payloads.
1213 type ReadSystemviewConfigUseCase struct {
13- reader port.SystemviewConfigReader
14+ 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+ }
1442 }
1543
1644 // 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 {
1846 if reader == nil {
1947 panic("NewReadSystemviewConfigUseCase: reader is nil")
2048 }
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
2256 }
2357
2458 // ErrNilSystemviewConfigReader is returned when the config reader dependency is nil.
@@ -29,7 +63,11 @@ func (uc *ReadSystemviewConfigUseCase) Current(ctx context.Context) (dto.Systemv
2963 if uc == nil || uc.reader == nil {
3064 return dto.SystemviewConfigPayload{}, ErrNilSystemviewConfigReader
3165 }
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
3371 }
3472
3573 // Default returns the default systemview config payload.
@@ -39,3 +77,49 @@ func (uc *ReadSystemviewConfigUseCase) Default(ctx context.Context) (dto.Systemv
3977 }
4078 return uc.reader.Default(ctx)
4179 }
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 (
2323
2424 // EngineInput holds the input for BuildEngine.
2525 type EngineInput struct {
26- Ctx context.Context
27- Config *config.Config
28- RuntimeProfile runtimeprofile.Profile
29- ThemeManager *theme.Manager
30- ColorResolver port.ColorSchemeResolver
31- Logger zerolog.Logger
26+ Ctx context.Context
27+ Config *config.Config
28+ RuntimeProfile runtimeprofile.Profile
29+ ThemeManager *theme.Manager
30+ ExternalThemeSource port.ExternalThemeSource
31+ ColorResolver port.ColorSchemeResolver
32+ Logger zerolog.Logger
3233 }
3334
3435 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
3536 func BuildEngine(input EngineInput) (port.Engine, error) {
3637 cfg := input.Config
3738 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+ )
3947 buildConfigPayload := func(read func(context.Context) (dto.SystemviewConfigPayload, error)) func() ([]byte, error) {
4048 return func() ([]byte, error) {
4149 payload, err := read(input.Ctx)
@@ -120,6 +128,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
120128 }
121129 }
122130
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+
123140 func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) {
124141 if profile.Engine == "" {
125142 return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType)
M internal/bootstrap/gui.go +77 -28
@@ -11,10 +11,12 @@ import (
1111
1212 "github.com/bnema/dumber/internal/application/port"
1313 "github.com/bnema/dumber/internal/application/usecase"
14+ "github.com/bnema/dumber/internal/domain/entity"
1415 "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1516 "github.com/bnema/dumber/internal/infrastructure/config"
1617 "github.com/bnema/dumber/internal/infrastructure/deps"
1718 "github.com/bnema/dumber/internal/infrastructure/env"
19+ "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
1820 "github.com/bnema/dumber/internal/infrastructure/media"
1921 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
2022 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
@@ -30,11 +32,15 @@ type DatabaseResult struct {
3032
3133 // ParallelInitResult holds the results of parallel initialization phase.
3234 type ParallelInitResult struct {
33- RuntimeProfile runtimeprofile.Profile
34- ThemeManager *theme.Manager
35- ColorResolver port.ColorSchemeResolver
36- AdwaitaDetector *colorscheme.AdwaitaDetector
37- Duration time.Duration
35+ RuntimeProfile runtimeprofile.Profile
36+ ThemeManager *theme.Manager
37+ ResolvedTheme entity.ResolvedTheme
38+ ResolveThemeUC *usecase.ResolveThemeUseCase
39+ ExternalThemeSource port.ConfigurableExternalThemeSource
40+ ExternalThemeWatcher port.ExternalThemeWatcher
41+ ColorResolver port.ColorSchemeResolver
42+ AdwaitaDetector *colorscheme.AdwaitaDetector
43+ Duration time.Duration
3844 }
3945
4046 // DeferredInitResult holds results from deferred initialization checks.
@@ -90,8 +96,9 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
9096 // Returns the first fatal error encountered, or nil with the results.
9197 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
9298 var (
93- dirsErr error
94- wg sync.WaitGroup
99+ dirsErr error
100+ themeErr error
101+ wg sync.WaitGroup
95102 )
96103
97104 profile, err := ResolveRuntimeProfile(input.Config)
@@ -122,14 +129,28 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
122129 dirsErr = resolveWebKitDirs(profile)
123130 }()
124131
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)
126136 var themeManager *theme.Manager
137+ var resolvedTheme entity.ResolvedTheme
127138 go func() {
128139 defer wg.Done()
129- themeManager = theme.NewManager(
130- input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale,
131- &input.Config.Workspace.Styling, resolver,
132- )
140+ preference := resolver.Resolve()
141+ resolved, err := resolveThemeUC.Execute(input.Ctx, usecase.ResolveThemeInputFromConfig(
142+ &input.Config.Appearance,
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)
133154 }()
134155
135156 wg.Wait()
@@ -139,16 +160,42 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
139160 if dirsErr != nil {
140161 return nil, fmt.Errorf("resolve directories: %w", dirsErr)
141162 }
163+ if themeErr != nil {
164+ return nil, fmt.Errorf("resolve theme: %w", themeErr)
165+ }
142166
143167 return &ParallelInitResult{
144- RuntimeProfile: profile,
145- ThemeManager: themeManager,
146- ColorResolver: resolver,
147- AdwaitaDetector: adwaitaDetector,
148- Duration: duration,
168+ RuntimeProfile: profile,
169+ ThemeManager: themeManager,
170+ ResolvedTheme: resolvedTheme,
171+ ResolveThemeUC: resolveThemeUC,
172+ ExternalThemeSource: externalThemeSource,
173+ ExternalThemeWatcher: noctalia.NewFileWatcher(),
174+ ColorResolver: resolver,
175+ AdwaitaDetector: adwaitaDetector,
176+ Duration: duration,
149177 }, nil
150178 }
151179
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+
152199 // RunDeferredInit runs deferred initialization checks off the critical path.
153200 // This includes runtime requirements and media checks.
154201 func RunDeferredInit(input DeferredInitInput) DeferredInitResult {
@@ -275,11 +322,12 @@ type ParallelDBEngineResult struct {
275322
276323 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
277324 type ParallelDBEngineInput struct {
278- Ctx context.Context
279- Config *config.Config
280- RuntimeProfile runtimeprofile.Profile
281- ThemeManager *theme.Manager
282- ColorResolver port.ColorSchemeResolver
325+ Ctx context.Context
326+ Config *config.Config
327+ RuntimeProfile runtimeprofile.Profile
328+ ThemeManager *theme.Manager
329+ ExternalThemeSource port.ExternalThemeSource
330+ ColorResolver port.ColorSchemeResolver
283331 }
284332
285333 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -314,12 +362,13 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult,
314362
315363 // Engine on main thread (GTK requirement)
316364 engine, err := BuildEngine(EngineInput{
317- Ctx: input.Ctx,
318- Config: input.Config,
319- RuntimeProfile: input.RuntimeProfile,
320- ThemeManager: input.ThemeManager,
321- ColorResolver: input.ColorResolver,
322- Logger: *log,
365+ Ctx: input.Ctx,
366+ Config: input.Config,
367+ RuntimeProfile: input.RuntimeProfile,
368+ ThemeManager: input.ThemeManager,
369+ ExternalThemeSource: input.ExternalThemeSource,
370+ ColorResolver: input.ColorResolver,
371+ Logger: *log,
323372 })
324373 if err != nil {
325374 dbRes := <-dbCh
M internal/cli/app.go +41 -2
@@ -13,8 +13,10 @@ import (
1313 "github.com/bnema/dumber/internal/bootstrap"
1414 "github.com/bnema/dumber/internal/cli/styles"
1515 "github.com/bnema/dumber/internal/domain/build"
16+ "github.com/bnema/dumber/internal/domain/entity"
1617 "github.com/bnema/dumber/internal/domain/repository"
1718 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/externaltheme/noctalia"
1820 "github.com/bnema/dumber/internal/infrastructure/favicon"
1921 infralogging "github.com/bnema/dumber/internal/infrastructure/logging"
2022 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
@@ -56,8 +58,9 @@ func NewApp() (*App, error) {
5658 return nil, fmt.Errorf("resolve runtime profile: %w", err)
5759 }
5860
59- // Create theme from config
60- theme := styles.NewTheme(cfg)
61+ // Create theme through the shared resolver. CLI keeps the historical dark default
62+ // unless config explicitly requests a light scheme.
63+ theme := styles.NewThemeFromResolved(resolveCLITheme(cfg))
6164
6265 // Start with a quiet logger. If a browser session is active, we'll attach to it.
6366 logLevel := cfg.Logging.Level
@@ -157,6 +160,42 @@ func (a *App) Close() error {
157160 return nil
158161 }
159162
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+
160199 // Ctx returns the application context with logger.
161200 func (a *App) Ctx() context.Context {
162201 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
44 import (
55 "github.com/charmbracelet/lipgloss"
66
7+ "github.com/bnema/dumber/internal/domain/entity"
78 "github.com/bnema/dumber/internal/infrastructure/config"
89 )
910
@@ -81,6 +82,18 @@ func NewTheme(cfg *config.Config) *Theme {
8182 return NewThemeFromPalette(p)
8283 }
8384
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+
8497 // NewThemeFromPalette creates a Theme from a ColorPalette.
8598 func NewThemeFromPalette(p config.ColorPalette) *Theme {
8699 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
66
77 // AppearanceConfig holds visual appearance settings.
88 type AppearanceConfig struct {
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"`
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"`
13- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"`
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"`
16- ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"`
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"`
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"`
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"`
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"`
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"`
1726 }
1827
1928 // 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 @@
11 package config
22
3+import (
4+ "os"
5+ "path/filepath"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
310 // Default configuration constants
411 const (
512 // History defaults
@@ -14,7 +21,12 @@ const (
1421 defaultMaxLogFiles = 100 // session log files
1522
1623 // Appearance defaults
17- defaultFontSize = 16 // points
24+ defaultFontSize = 16 // points
25+ defaultExternalThemeProvider = "noctalia"
26+ defaultExternalThemeFormat = "colors-json"
27+ defaultExternalThemeColorsFilename = "colors.json"
28+ defaultExternalThemeTemplateFormat = "dumber-json"
29+ defaultExternalThemeTemplateFilename = "noctalia-theme.json"
1830
1931 // Workspace defaults
2032 defaultNewPaneURL = "about:blank"
@@ -104,6 +116,36 @@ func getDefaultLogDir() string {
104116 return logDir
105117 }
106118
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+
107149 // DefaultConfig returns the default configuration values for dumber.
108150 func DefaultConfig() *Config {
109151 browsingContextDefaults := defaultBrowsingContextConfig()
@@ -164,6 +206,12 @@ func DefaultConfig() *Config {
164206 Border: "#3f3f46",
165207 },
166208 ColorScheme: "default", // default follows system theme
209+ ExternalTheme: entity.ExternalThemeConfig{
210+ Enabled: false,
211+ Provider: defaultExternalThemeProvider,
212+ Format: defaultExternalThemeFormat,
213+ Path: getDefaultExternalThemePath(),
214+ },
167215 },
168216 Debug: DebugConfig{
169217 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) {
429429 }
430430
431431 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+
432448 switch strings.ToLower(strings.TrimSpace(config.Appearance.ColorScheme)) {
433449 case strings.ToLower(ThemePreferDark):
434450 config.Appearance.ColorScheme = ThemePreferDark
@@ -640,6 +656,10 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
640656 m.viper.SetDefault("appearance.light_palette", defaults.Appearance.LightPalette)
641657 m.viper.SetDefault("appearance.dark_palette", defaults.Appearance.DarkPalette)
642658 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)
643663 }
644664
645665 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
175175 Description: "Dark theme color palette (background, surface, surface_variant, text, muted, accent, border)",
176176 Section: SectionAppearance,
177177 },
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+ },
178208 }
179209 }
180210
M internal/infrastructure/config/validation.go +29 -0
@@ -104,9 +104,38 @@ func validateAppearance(config *Config) []string {
104104 if config.DefaultUIScale < 0.5 || config.DefaultUIScale > 3.0 {
105105 validationErrors = append(validationErrors, "default_ui_scale must be between 0.5 and 3.0")
106106 }
107+ validationErrors = append(validationErrors, validateExternalTheme(config)...)
107108 return validationErrors
108109 }
109110
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+
110139 func validateSearchEngine(config *Config) []string {
111140 if config.DefaultSearchEngine == "" {
112141 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
6464 Accent: cfg.Appearance.DarkPalette.Accent,
6565 Border: cfg.Appearance.DarkPalette.Border,
6666 }
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
6771 current.DefaultUIScale = cfg.DefaultUIScale
6872 current.DefaultSearchEngine = cfg.DefaultSearchEngine
6973 if len(cfg.SearchShortcuts) == 0 {
M internal/infrastructure/config/webui_payload.go +6 -0
@@ -58,6 +58,12 @@ func buildSystemviewAppearancePayload(appearance AppearanceConfig) dto.WebUIAppe
5858 ColorScheme: appearance.ColorScheme,
5959 LightPalette: buildSystemviewPalettePayload(appearance.LightPalette, defaults.LightPalette),
6060 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+ },
6167 }
6268 }
6369
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) {
30723072
30733073 // Stop accepting relaunches before teardown starts.
30743074 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+ }
30753080
30763081 // Cancel context to signal all goroutines
30773082 a.cancel(errors.New("application shutdown"))
@@ -5001,12 +5006,14 @@ func (a *App) UpdateOmniboxZoom(factor float64) {
50015006 func (a *App) initConfigWatcher(ctx context.Context) {
50025007 log := logging.FromContext(ctx)
50035008
5009+ a.syncExternalThemeWatcher(ctx)
5010+
50045011 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")
50065013 return
50075014 }
50085015
5009- // Start viper watcher
5016+ // Start viper watcher.
50105017 if err := a.deps.WatchConfig(); err != nil {
50115018 log.Warn().Err(err).Msg("failed to start config watcher")
50125019 return
@@ -5016,6 +5023,7 @@ func (a *App) initConfigWatcher(ctx context.Context) {
50165023 // deps.Config is updated in-place before this callback fires.
50175024 a.deps.OnConfigChange(func() {
50185025 cb := glib.SourceFunc(func(_ uintptr) bool {
5026+ a.syncExternalThemeWatcher(ctx)
50195027 a.applyAppearanceConfig(ctx)
50205028 for _, bw := range a.browserWindows {
50215029 if bw == nil {
@@ -5036,6 +5044,21 @@ func (a *App) initConfigWatcher(ctx context.Context) {
50365044 log.Debug().Msg("config watcher initialized")
50375045 }
50385046
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+
50395062 func (a *App) applyAppearanceConfig(ctx context.Context) {
50405063 log := logging.FromContext(ctx)
50415064 if a.deps == nil || a.deps.Config == nil {
@@ -5057,43 +5080,92 @@ func (a *App) applyAppearanceConfig(ctx context.Context) {
50575080 a.contentCoord.ApplySettingsToAll(ctx)
50585081 }
50595082
5060- // Update GTK theme and injected WebUI theme vars
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)
5083+ a.applyThemeAppearance(ctx)
50675084
5068- var inj port.ContentInjector
5069- if a.engine != nil {
5070- inj = a.engine.ContentInjector()
5071- }
5085+ log.Info().Msg("appearance config updated")
5086+}
50725087
5073- if inj != nil {
5074- findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
5075- if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
5076- log.Warn().Err(err).Msg("failed to update find highlight CSS")
5077- }
5078- }
5088+func (a *App) applyThemeAppearance(ctx context.Context) {
5089+ log := logging.FromContext(ctx)
5090+ if a.deps == nil || a.deps.Config == nil || a.deps.Theme == nil {
5091+ return
5092+ }
5093+ cfg := a.deps.Config
50795094
5080- prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
5081- cssText := a.deps.Theme.GetWebUIThemeCSS()
5082- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
5083- log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
5084- }
5095+ var display *gdk.Display
5096+ if a.mainWindow != nil && a.mainWindow.Window() != nil {
5097+ display = a.mainWindow.Window().GetDisplay()
5098+ }
50855099
5086- if a.contentCoord != nil && inj != nil {
5087- a.contentCoord.RefreshInjectedScriptsToAll(ctx)
5088- }
5100+ preference := port.ColorSchemePreference{PrefersDark: a.deps.Theme.PrefersDark(), Source: "theme"}
5101+ if a.deps.ColorResolver != nil {
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+ }
50895129
5090- // Apply WebUI theme to already-loaded dumb:// pages
5091- if a.contentCoord != nil {
5092- a.contentCoord.ApplyWebUIThemeToAll(ctx, a.deps.Theme.PrefersDark(), cssText)
5130+ if inj != nil {
5131+ findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
5132+ if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
5133+ log.Warn().Err(err).Msg("failed to update find highlight CSS")
50935134 }
50945135 }
50955136
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")
50975169 }
50985170
50995171 func (a *App) initFilteringAsync(ctx context.Context) {
M internal/ui/deps.go +6 -3
@@ -28,9 +28,12 @@ type Dependencies struct {
2828 OnCrashReportsDetected func([]string)
2929
3030 // Theme and color scheme management
31- Theme *theme.Manager
32- ColorResolver port.ColorSchemeResolver
33- AdwaitaDetector port.ToolkitAvailabilityNotifier
31+ Theme *theme.Manager
32+ ResolveThemeUC *usecase.ResolveThemeUseCase
33+ ExternalThemeSource port.ConfigurableExternalThemeSource
34+ ExternalThemeWatcher port.ExternalThemeWatcher
35+ ColorResolver port.ColorSchemeResolver
36+ AdwaitaDetector port.ToolkitAvailabilityNotifier
3437
3538 // XDG paths
3639 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) {
794794
795795 config, err := a.deps.Config.Current(ctx)
796796 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)
798802 }
799803 }
800804
M internal/ui/systemviews/app_test.go +48 -0
@@ -642,6 +642,12 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
642642 Accent: "#66aaff",
643643 Border: "#333333",
644644 },
645+ ExternalTheme: dto.WebUIExternalThemeConfig{
646+ Enabled: true,
647+ Provider: "noctalia",
648+ Format: "dumber-json",
649+ Path: "/tmp/noctalia-dumber.json",
650+ },
645651 },
646652 Performance: dto.SystemviewPerformancePayload{
647653 Profile: "balanced",
@@ -707,6 +713,13 @@ func TestAppLoadInitialConfigRouteRendersData(t *testing.T) {
707713 assert.Contains(t, app.renderedHTML, "Inter")
708714 assert.Contains(t, app.renderedHTML, "appearance.light_palette.background")
709715 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")
710723 assert.Contains(t, app.renderedHTML, "performance.profile")
711724 assert.Contains(t, app.renderedHTML, "balanced")
712725 assert.Contains(t, app.renderedHTML, "performance.custom.skia_cpu_threads")
@@ -761,6 +774,13 @@ func TestAppLoadInitialConfigRouteRendersEditControls(t *testing.T) {
761774 require.NoError(t, app.LoadInitial(context.Background()))
762775 assert.Contains(t, app.renderedHTML, `data-sv-action="config.appearance.save"`)
763776 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`)
764784 assert.Contains(t, app.renderedHTML, `data-sv-action="config.search.save"`)
765785 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.create"`)
766786 assert.Contains(t, app.renderedHTML, `data-sv-action="config.searchShortcut.update"`)
@@ -796,6 +816,7 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
796816 Data: map[string]string{
797817 "sans_font": "Inter", "serif_font": "Georgia", "monospace_font": "JetBrains Mono",
798818 "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 ",
799820 "light_background": "#ffffff", "light_surface": "#fafafa", "light_surface_variant": "#eeeeee", "light_text": "#111111", "light_muted": "#666666", "light_accent": "#0055ff", "light_border": "#dddddd",
800821 "dark_background": "#111111", "dark_surface": "#1a1a1a", "dark_surface_variant": "#2a2a2a", "dark_text": "#f5f5f5", "dark_muted": "#a0a0a0", "dark_accent": "#66aaff", "dark_border": "#333333",
801822 },
@@ -803,6 +824,10 @@ func TestAppHandleConfigActionsRefreshesDOM(t *testing.T) {
803824 assert.True(t, service.calledSave)
804825 assert.Equal(t, 18, service.savedConfig.Appearance.DefaultFontSize)
805826 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)
806831 assert.Contains(t, dom.HTML(), "Saved appearance settings")
807832 assert.Contains(t, dom.HTML(), `class="sv-app sv-light"`)
808833
@@ -876,6 +901,28 @@ func TestAppRejectsSearchURLsWithoutPlaceholder(t *testing.T) {
876901 assert.False(t, service.calledSave)
877902 }
878903
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+
879926 func testConfigPayload() dto.SystemviewConfigPayload {
880927 return dto.SystemviewConfigPayload{
881928 EngineType: "webkit",
@@ -887,6 +934,7 @@ func testConfigPayload() dto.SystemviewConfigPayload {
887934 DefaultFontSize: 16,
888935 LightPalette: dto.ColorPalette{Background: "#ffffff", Surface: "#fafafa", SurfaceVariant: "#eeeeee", Text: "#111111", Muted: "#666666", Accent: "#0055ff", Border: "#dddddd"},
889936 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"},
890938 },
891939 DefaultUIScale: 1,
892940 DefaultSearchEngine: "https://duckduckgo.com/?q=%s",
M internal/ui/systemviews/config.templ +8 -0
@@ -39,6 +39,14 @@ templ AppearanceForm(config dto.SystemviewConfigPayload) {
3939 <option value="prefer-light" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-light") }>Prefer light</option>
4040 <option value="prefer-dark" selected?={ colorSchemeSelected(config.Appearance.ColorScheme, "prefer-dark") }>Prefer dark</option>
4141 </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>
4250 @PaletteFields("Light palette", "light", config.Appearance.LightPalette)
4351 @PaletteFields("Dark palette", "dark", config.Appearance.DarkPalette)
4452 <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)
102102 cfg.Appearance.GtkFont = strings.TrimSpace(data["gtk_font"])
103103 cfg.Appearance.DefaultFontSize = fontSize
104104 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"])
105109 cfg.Appearance.LightPalette = paletteFromForm(data, "light")
106110 cfg.Appearance.DarkPalette = paletteFromForm(data, "dark")
107111 cfg.DefaultUIScale = uiScale
@@ -113,6 +117,15 @@ func (a *App) saveAppearanceConfig(ctx context.Context, data map[string]string)
113117 return nil
114118 }
115119
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+
116129 func (a *App) resetAppearanceConfig(ctx context.Context) error {
117130 cfg, err := a.editableConfig(ctx)
118131 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 {
256256 /* Search entry field */
257257 entry.omnibox-entry,
258258 entry.omnibox-entry > text {
259- background-color: alpha(var(--bg), 0.88);
259+ background-color: var(--surface-variant);
260260 background-image: none;
261261 color: var(--text);
262262 caret-color: var(--accent);
@@ -278,7 +278,7 @@ entry.omnibox-entry:focus > text,
278278 entry.omnibox-entry:focus-within > text,
279279 entry.omnibox-entry:focus-visible > text {
280280 border-color: var(--accent);
281- background-color: shade(var(--bg), 1.05);
281+ background-color: shade(var(--surface-variant), 1.08);
282282 background-image: none;
283283 outline-style: none;
284284 outline-width: 0px;
@@ -463,7 +463,7 @@ entry.omnibox-entry.omnibox-entry-bang-active:focus > text,
463463 entry.omnibox-entry.omnibox-entry-bang-active:focus-within > text,
464464 entry.omnibox-entry.omnibox-entry-bang-active:focus-visible > text {
465465 border-color: var(--accent);
466- background-color: shade(var(--bg), 1.05);
466+ background-color: shade(var(--surface-variant), 1.08);
467467 background-image: none;
468468 }
469469
M internal/ui/theme/css_test.go +12 -5
@@ -101,20 +101,27 @@ func TestGenerateCSS_FavoriteRowsOnlyTintTrailingAffordance(t *testing.T) {
101101 }
102102 }
103103
104-func TestGenerateCSS_OmniboxSearchAreaMatchesHeaderSurface(t *testing.T) {
104+func TestGenerateCSS_OmniboxSearchEntryUsesControlSurface(t *testing.T) {
105105 css := GenerateCSS(DefaultDarkPalette())
106106
107107 containerRe := regexp.MustCompile(`(?s)\.omnibox-container\s*\{[^}]*background-color:\s*var\(--surface\);`)
108108 if !containerRe.MatchString(css) {
109109 t.Fatalf("expected omnibox container to use the header surface color")
110110 }
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;`)
112112 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")
114114 }
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\);`)
116116 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")
118125 }
119126 scrolledRe := regexp.MustCompile(`(?s)\.omnibox-scrolled\s*\{[^}]*background-color:\s*var\(--surface\);`)
120127 if !scrolledRe.MatchString(css) {
M internal/ui/theme/manager.go +37 -157
@@ -6,7 +6,6 @@ import (
66 "math"
77 "strings"
88
9- "github.com/bnema/dumber/internal/application/port"
109 "github.com/bnema/dumber/internal/domain/entity"
1110 "github.com/bnema/dumber/internal/logging"
1211 "github.com/bnema/puregotk/v4/gdk"
@@ -14,106 +13,51 @@ import (
1413 )
1514
1615 // Manager handles theme state and CSS application.
16+// Theme precedence and fallback are resolved before data reaches this adapter.
1717 type Manager struct {
18- scheme string // "light", "dark", "system"
19- prefersDark bool // Resolved dark mode preference
20- lightPalette Palette // Light theme colors
21- darkPalette Palette // Dark theme colors
22- uiScale float64 // UI scaling factor (1.0 = 100%)
23- fonts FontConfig
24- gtkFont string
25- modeColors ModeColors // Modal mode indicator colors
26- cssProvider *gtk.CssProvider
27- appliedFont string
28- colorResolver port.ColorSchemeResolver // Resolver for dynamic detection
18+ prefersDark bool // Resolved dark mode preference
19+ lightPalette Palette // Light theme colors
20+ darkPalette Palette // Dark theme colors
21+ uiScale float64 // UI scaling factor (1.0 = 100%)
22+ fonts FontConfig
23+ gtkFont string
24+ modeColors ModeColors // Modal mode indicator colors
25+ cssProvider *gtk.CssProvider
26+ appliedFont string
2927 }
3028
31-// NewManager creates a new theme manager from configuration.
32-// The ColorSchemeResolver is required for proper color scheme detection.
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 {
29+// NewManager creates a new theme manager from an already-resolved theme.
30+func NewManager(ctx context.Context, resolved entity.ResolvedTheme) *Manager {
4431 log := logging.FromContext(ctx)
4532
46- // Determine color scheme preference
47- scheme := "system"
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)
33+ m := &Manager{}
34+ m.applyResolvedTheme(resolved)
8235
8336 log.Debug().
84- Str("scheme", scheme).
85- Bool("prefers_dark", prefersDark).
86- Float64("ui_scale", uiScale).
87- Str("sans_font", fonts.SansFont).
88- Str("monospace_font", fonts.MonospaceFont).
89- Str("gtk_font", gtkFont).
37+ Bool("prefers_dark", m.prefersDark).
38+ Float64("ui_scale", m.uiScale).
39+ Str("sans_font", m.fonts.SansFont).
40+ Str("monospace_font", m.fonts.MonospaceFont).
41+ Str("gtk_font", m.gtkFont).
9042 Msg("theme manager initialized")
9143
92- return &Manager{
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- }
44+ return m
10345 }
10446
105-// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
106-func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
107- defaults := DefaultModeColors()
108- if styling == nil {
109- return defaults
47+func (m *Manager) applyResolvedTheme(resolved entity.ResolvedTheme) {
48+ m.prefersDark = resolved.PrefersDark
49+ m.lightPalette = PaletteFromEntity(resolved.LightPalette, false)
50+ m.darkPalette = PaletteFromEntity(resolved.DarkPalette, true)
51+ m.uiScale = resolved.UIScale
52+ if m.uiScale <= 0 {
53+ m.uiScale = 1.0
11054 }
111- return ModeColors{
112- PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
113- TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
114- SessionMode: Coalesce(styling.SessionModeColor, defaults.SessionMode),
115- ResizeMode: Coalesce(styling.ResizeModeColor, defaults.ResizeMode),
55+ m.fonts = FontConfigFromEntity(resolved.Fonts)
56+ if m.fonts.GtkFont == "" {
57+ m.fonts.GtkFont = DefaultGTKFont()
11658 }
59+ m.gtkFont = m.fonts.GtkFont
60+ m.modeColors = ModeColorsFromEntity(resolved.ModeColors)
11761 }
11862
11963 // PrefersDark returns true if dark mode is active.
@@ -121,7 +65,7 @@ func (m *Manager) PrefersDark() bool {
12165 return m.prefersDark
12266 }
12367
124-// GetCurrentPalette returns the active palette based on current scheme.
68+// GetCurrentPalette returns the active palette based on current preference.
12569 func (m *Manager) GetCurrentPalette() Palette {
12670 if m.prefersDark {
12771 return m.darkPalette
@@ -230,83 +174,19 @@ func (m *Manager) ApplyToDisplay(ctx context.Context, display *gdk.Display) {
230174 Msg("theme CSS applied to display")
231175 }
232176
233-// SetColorScheme changes the active color scheme at runtime.
234-func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gdk.Display) {
177+// UpdateFromResolved updates the theme manager state from a resolved theme.
178+func (m *Manager) UpdateFromResolved(ctx context.Context, resolved entity.ResolvedTheme, display *gdk.Display) {
235179 log := logging.FromContext(ctx)
236180
237- m.scheme = scheme
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- }
181+ m.applyResolvedTheme(resolved)
301182
302183 log.Info().
303- Str("scheme", m.scheme).
304184 Bool("prefers_dark", m.prefersDark).
305185 Float64("ui_scale", m.uiScale).
306186 Str("sans_font", m.fonts.SansFont).
307187 Str("monospace_font", m.fonts.MonospaceFont).
308188 Str("gtk_font", m.gtkFont).
309- Msg("theme manager updated from config")
189+ Msg("theme manager updated from resolved theme")
310190
311191 // Re-apply CSS if display is available
312192 if display != nil {
M internal/ui/theme/manager_test.go +103 -255
@@ -4,315 +4,163 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/application/port/mocks"
97 "github.com/bnema/dumber/internal/domain/entity"
108 "github.com/stretchr/testify/assert"
119 "github.com/stretchr/testify/require"
1210 )
1311
14-func TestNewManager_DarkMode(t *testing.T) {
15- ctx := context.Background()
16- appearance := &entity.AppearanceConfig{
17- ColorScheme: "default",
12+func resolvedThemeFixture(prefersDark bool) entity.ResolvedTheme {
13+ light := entity.ColorPalette{
14+ Background: "#ffffff",
15+ Surface: "#f8f8f8",
16+ SurfaceVariant: "#eeeeee",
17+ Text: "#111111",
18+ Muted: "#666666",
19+ Accent: "#0055ff",
20+ Border: "#dddddd",
1821 }
19-
20- mockResolver := mocks.NewMockColorSchemeResolver(t)
21- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
22- PrefersDark: true,
23- Source: "test",
24- })
25-
26- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
27-
28- assert.True(t, manager.PrefersDark())
29- assert.Equal(t, "default", manager.scheme)
30-}
31-
32-func TestNewManager_LightMode(t *testing.T) {
33- ctx := context.Background()
34- appearance := &entity.AppearanceConfig{
35- ColorScheme: "default",
22+ dark := entity.ColorPalette{
23+ Background: "#101010",
24+ Surface: "#181818",
25+ SurfaceVariant: "#242424",
26+ Text: "#f5f5f5",
27+ Muted: "#999999",
28+ Accent: "#66aaff",
29+ Border: "#333333",
30+ }
31+ active := light
32+ if prefersDark {
33+ active = dark
34+ }
35+ return entity.ResolvedTheme{
36+ LightPalette: light,
37+ DarkPalette: dark,
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+ },
3656 }
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())
4757 }
4858
49-func TestNewManager_WithNilConfig(t *testing.T) {
59+func TestNewManager_UsesResolvedThemeFields(t *testing.T) {
5060 ctx := context.Background()
61+ resolved := resolvedThemeFixture(true)
5162
52- mockResolver := mocks.NewMockColorSchemeResolver(t)
53- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
54- PrefersDark: true,
55- Source: "fallback",
56- })
57-
58- manager := NewManager(ctx, nil, 0, nil, mockResolver)
63+ manager := NewManager(ctx, resolved)
5964
6065 require.NotNil(t, manager)
6166 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)
6374 }
6475
65-func TestNewManager_UsesConfigScheme(t *testing.T) {
76+func TestNewManager_DefaultsInvalidScaleToOne(t *testing.T) {
6677 ctx := context.Background()
67- appearance := &entity.AppearanceConfig{
68- ColorScheme: "prefer-dark",
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())
78+ resolved := resolvedThemeFixture(true)
79+ resolved.UIScale = 0
13180
132- // Change scheme - this calls Refresh on resolver
133- manager.SetColorScheme(ctx, "prefer-light", nil)
81+ manager := NewManager(ctx, resolved)
13482
135- assert.False(t, manager.PrefersDark())
136- assert.Equal(t, "prefer-light", manager.scheme)
83+ require.NotNil(t, manager)
84+ assert.InDelta(t, 1.0, manager.uiScale, 0.000001)
13785 }
13886
139-func TestManager_UpdateFromConfig(t *testing.T) {
87+func TestManager_UpdateFromResolvedReplacesResolvedValues(t *testing.T) {
14088 ctx := context.Background()
141- appearance := &entity.AppearanceConfig{
142- ColorScheme: "default",
143- }
144-
145- mockResolver := mocks.NewMockColorSchemeResolver(t)
146- // Initial resolve
147- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
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- })
89+ manager := NewManager(ctx, resolvedThemeFixture(true))
90+ updated := resolvedThemeFixture(false)
91+ updated.LightPalette.Background = "#abcdef"
92+ updated.DarkPalette.Background = "#123456"
93+ updated.Fonts.SansFont = "Recursive"
94+ updated.UIScale = 2.0
95+ updated.ModeColors.PaneMode = "#654321"
15696
157- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
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)
97+ manager.UpdateFromResolved(ctx, updated, nil)
16598
16699 assert.False(t, manager.PrefersDark())
167- assert.Equal(t, "prefer-light", manager.scheme)
168-}
169-
170-func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
171- ctx := context.Background()
172-
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)
100+ assert.Equal(t, "#abcdef", manager.GetLightPalette().Background)
101+ assert.Equal(t, "#123456", manager.GetDarkPalette().Background)
102+ assert.Equal(t, manager.GetLightPalette(), manager.GetCurrentPalette())
103+ assert.Equal(t, "Recursive", manager.fonts.SansFont)
104+ assert.InDelta(t, 2.0, manager.uiScale, 0.000001)
105+ assert.Equal(t, "#654321", manager.GetModeColors().PaneMode)
186106 }
187107
188108 func TestManager_GetWebUIThemeCSS(t *testing.T) {
189109 ctx := context.Background()
110+ manager := NewManager(ctx, resolvedThemeFixture(true))
190111
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)
198112 css := manager.GetWebUIThemeCSS()
199113
200- // Should contain both light and dark variables
201114 assert.Contains(t, css, ":root{")
202115 assert.Contains(t, css, ".dark{")
116+ assert.Contains(t, css, "--background: #ffffff")
117+ assert.Contains(t, css, "--background: #101010")
203118 }
204119
205-func TestManager_GetBackgroundRGBA(t *testing.T) {
120+func TestManager_GetBackgroundRGBAUsesCurrentPalette(t *testing.T) {
206121 ctx := context.Background()
122+ manager := NewManager(ctx, resolvedThemeFixture(true))
207123
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)
215124 r, g, b, a := manager.GetBackgroundRGBA()
216125
217- // Should return valid RGBA values (0-1 range)
218- assert.GreaterOrEqual(t, r, float32(0))
219- assert.LessOrEqual(t, r, float32(1))
220- assert.GreaterOrEqual(t, g, float32(0))
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))
126+ assert.InDelta(t, float32(0x10)/255, r, 0.0001)
127+ assert.InDelta(t, float32(0x10)/255, g, 0.0001)
128+ assert.InDelta(t, float32(0x10)/255, b, 0.0001)
129+ assert.InDelta(t, float32(1), a, 0.0001)
226130 }
227131
228-func TestManager_CustomPalettes(t *testing.T) {
229- ctx := context.Background()
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()
132+func TestPaletteFromEntityAddsSemanticDefaults(t *testing.T) {
133+ palette := PaletteFromEntity(entity.ColorPalette{Background: "#ffffff", Text: "#111111"}, false)
251134
252- assert.Equal(t, "#ffffff", lightPalette.Background)
253- assert.Equal(t, "#000000", lightPalette.Text)
254- assert.Equal(t, "#000000", darkPalette.Background)
255- assert.Equal(t, "#ffffff", darkPalette.Text)
135+ assert.Equal(t, "#ffffff", palette.Background)
136+ assert.Equal(t, "#111111", palette.Text)
137+ assert.Equal(t, DefaultLightPalette().Success, palette.Success)
138+ assert.Equal(t, DefaultLightPalette().Warning, palette.Warning)
139+ assert.Equal(t, DefaultLightPalette().Destructive, palette.Destructive)
256140 }
257141
258-func TestManager_UIScale(t *testing.T) {
259- ctx := context.Background()
260-
261- mockResolver := mocks.NewMockColorSchemeResolver(t)
262- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
263- PrefersDark: true,
264- Source: "test",
142+func TestModeColorsFromEntity(t *testing.T) {
143+ modeColors := ModeColorsFromEntity(entity.ThemeModeColors{
144+ PaneMode: "#111111",
145+ TabMode: "#222222",
146+ SessionMode: "#333333",
147+ ResizeMode: "#444444",
265148 })
266149
267- manager := NewManager(ctx, nil, 1.5, nil, mockResolver)
268-
269- // UI scale should be stored (we can't directly access it, but we can verify
270- // the manager was created without error)
271- require.NotNil(t, manager)
150+ assert.Equal(t, "#111111", modeColors.PaneMode)
151+ assert.Equal(t, "#222222", modeColors.TabMode)
152+ assert.Equal(t, "#333333", modeColors.SessionMode)
153+ assert.Equal(t, "#444444", modeColors.ResizeMode)
272154 }
273155
274-func TestManager_Fonts(t *testing.T) {
275- ctx := context.Background()
276- appearance := &entity.AppearanceConfig{
156+func TestFontConfigFromEntity(t *testing.T) {
157+ fonts := FontConfigFromEntity(entity.ThemeFonts{
277158 SansFont: "Inter",
278159 MonospaceFont: "JetBrains Mono",
279160 GtkFont: "Adwaita Sans",
280- }
281-
282- mockResolver := mocks.NewMockColorSchemeResolver(t)
283- mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
284- PrefersDark: true,
285- Source: "test",
286161 })
287162
288- manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
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)
163+ assert.Equal(t, FontConfig{SansFont: "Inter", MonospaceFont: "JetBrains Mono", GtkFont: "Adwaita Sans"}, fonts)
316164 }
317165
318166 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 {
112112 }
113113 }
114114
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+
115156 // Coalesce returns the first non-empty string.
116157 func Coalesce(values ...string) string {
117158 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