detached 9068ecf

Merge branch 'main' into fix/favicon-specificity

commit
9068ecf19531b7545b865bf6899b9152ac0ef838
author
bnema · · 2026-06-10 19:47 UTC
committer
GitHub · · 2026-06-10 19:47 UTC
merge of
09043d6 + 1ce5cd4 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
49 files · +6117 -838
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 +72 -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` for `colors-json`; built-in dumber-json template path for `dumber-json` | Path to the external theme JSON file |
144148
145149 ### Color Palettes
146150
@@ -168,6 +172,74 @@ accent = "#a8a8a8"
168172 border = "#363636"
169173 ```
170174
175+### External Theme: Noctalia
176+
177+Dumber can integrate with 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`.
207+
208+`mShadow` and `mHover` are optional nuance roles. When present, they are validated and used to preserve Dumber's background/surface/input contrast.
209+
210+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.
211+
212+#### Advanced: `dumber-json` user template
213+
214+`dumber-json` remains supported for an explicit Dumber-specific Noctalia user template:
215+
216+```toml
217+[appearance.external_theme]
218+enabled = true
219+provider = "noctalia"
220+format = "dumber-json"
221+# Optional for dumber-json. Defaults to $XDG_CONFIG_HOME/dumber/noctalia-theme.json.
222+path = "~/.config/dumber/noctalia-theme.json"
223+```
224+
225+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.
226+
227+Supported palette fields are:
228+
229+- `background`
230+- `surface`
231+- `surface_variant`
232+- `text`
233+- `muted`
234+- `accent`
235+- `border`
236+
237+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.
238+
239+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).
240+
241+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.
242+
171243 ## Debug Options
172244
173245 | 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 a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path |
3640 | `appearance.light_palette.background` | string | `#f8f8f8` | |
3741 | `appearance.light_palette.surface` | string | `#f2f2f2` | |
3842 | `appearance.light_palette.surface_variant` | string | `#ececec` | |
M go.mod +5 -5
@@ -22,9 +22,9 @@ require (
2222 github.com/spf13/cobra v1.10.2
2323 github.com/spf13/viper v1.21.0
2424 github.com/stretchr/testify v1.11.1
25- golang.org/x/image v0.41.0
26- golang.org/x/net v0.55.0
27- golang.org/x/sys v0.45.0
25+ golang.org/x/image v0.42.0
26+ golang.org/x/net v0.56.0
27+ golang.org/x/sys v0.46.0
2828 gopkg.in/yaml.v3 v3.0.1
2929 )
3030
@@ -75,8 +75,8 @@ require (
7575 go.yaml.in/yaml/v3 v3.0.4 // indirect
7676 golang.org/x/exp v0.0.0-20260508232706-74f9aab9d74a // indirect
7777 golang.org/x/mod v0.36.0 // indirect
78- golang.org/x/sync v0.20.0 // indirect
79- golang.org/x/text v0.37.0 // indirect
78+ golang.org/x/sync v0.21.0 // indirect
79+ golang.org/x/text v0.38.0 // indirect
8080 golang.org/x/tools v0.45.0 // indirect
8181 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
8282 modernc.org/sqlite v1.50.1 // indirect
M go.sum +10 -10 generated file: collapsed
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 +63 -0
@@ -0,0 +1,63 @@
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+}
51+
52+func TestNewThemeFromResolvedFallsBackToDefaultDarkPalette(t *testing.T) {
53+ resolved := entity.ResolvedTheme{}
54+ palette := styles.DefaultDarkPalette()
55+
56+ theme := styles.NewThemeFromResolved(resolved)
57+
58+ require.Equal(t, lipgloss.Color(palette.Background), theme.Background)
59+ require.Equal(t, lipgloss.Color(palette.Surface), theme.Surface)
60+ require.Equal(t, lipgloss.Color(palette.Text), theme.Text)
61+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Accent)
62+ require.Equal(t, lipgloss.Color(palette.Accent), theme.Success)
63+}
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 +307 -0
@@ -0,0 +1,307 @@
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.Empty(t, mgr.viper.GetString("appearance.external_theme.path"))
31+}
32+
33+func TestManagerLoad_ExternalThemeEnabledEmptyPathUsesDefaultColorsJSON(t *testing.T) {
34+ configHome := t.TempDir()
35+ t.Setenv("ENV", "")
36+ t.Setenv("XDG_CONFIG_HOME", configHome)
37+
38+ configDir := filepath.Join(configHome, appName)
39+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
40+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
41+[appearance.external_theme]
42+enabled = true
43+provider = 'noctalia'
44+path = ''
45+`), filePerm))
46+
47+ mgr, err := NewManager()
48+ require.NoError(t, err)
49+ require.NoError(t, mgr.Load())
50+
51+ externalTheme := mgr.Get().Appearance.ExternalTheme
52+ require.True(t, externalTheme.Enabled)
53+ require.Equal(t, "noctalia", externalTheme.Provider)
54+ require.Equal(t, "colors-json", externalTheme.Format)
55+ require.Equal(t, filepath.Join(configHome, "noctalia", "colors.json"), externalTheme.Path)
56+}
57+
58+func TestManagerLoad_ExternalThemeDumberJSONEmptyPathUsesTemplateDefault(t *testing.T) {
59+ configHome := t.TempDir()
60+ t.Setenv("ENV", "")
61+ t.Setenv("XDG_CONFIG_HOME", configHome)
62+
63+ configDir := filepath.Join(configHome, appName)
64+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
65+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
66+[appearance.external_theme]
67+enabled = true
68+provider = 'noctalia'
69+format = 'dumber-json'
70+path = ''
71+`), filePerm))
72+
73+ mgr, err := NewManager()
74+ require.NoError(t, err)
75+ require.NoError(t, mgr.Load())
76+
77+ externalTheme := mgr.Get().Appearance.ExternalTheme
78+ require.True(t, externalTheme.Enabled)
79+ require.Equal(t, "noctalia", externalTheme.Provider)
80+ require.Equal(t, "dumber-json", externalTheme.Format)
81+ require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
82+}
83+
84+func TestManagerLoad_ExternalThemeDumberJSONOmittedPathUsesTemplateDefault(t *testing.T) {
85+ configHome := t.TempDir()
86+ t.Setenv("ENV", "")
87+ t.Setenv("XDG_CONFIG_HOME", configHome)
88+
89+ configDir := filepath.Join(configHome, appName)
90+ require.NoError(t, os.MkdirAll(configDir, dirPerm))
91+ require.NoError(t, os.WriteFile(filepath.Join(configDir, "config.toml"), []byte(`
92+[appearance.external_theme]
93+enabled = true
94+provider = 'noctalia'
95+format = 'dumber-json'
96+`), filePerm))
97+
98+ mgr, err := NewManager()
99+ require.NoError(t, err)
100+ require.NoError(t, mgr.Load())
101+
102+ externalTheme := mgr.Get().Appearance.ExternalTheme
103+ require.True(t, externalTheme.Enabled)
104+ require.Equal(t, "noctalia", externalTheme.Provider)
105+ require.Equal(t, "dumber-json", externalTheme.Format)
106+ require.Equal(t, filepath.Join(configDir, defaultExternalThemeTemplateFilename), externalTheme.Path)
107+}
108+
109+func TestValidateConfig_ExternalTheme(t *testing.T) {
110+ tests := []struct {
111+ name string
112+ mutate func(*Config)
113+ wantErr bool
114+ wantText string
115+ }{
116+ {
117+ name: "disabled empty path validates",
118+ mutate: func(cfg *Config) {
119+ cfg.Appearance.ExternalTheme.Path = ""
120+ },
121+ },
122+ {
123+ name: "enabled colors-json with path validates",
124+ mutate: func(cfg *Config) {
125+ cfg.Appearance.ExternalTheme.Enabled = true
126+ cfg.Appearance.ExternalTheme.Format = "colors-json"
127+ cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json"
128+ },
129+ },
130+ {
131+ name: "enabled dumber-json with path validates",
132+ mutate: func(cfg *Config) {
133+ cfg.Appearance.ExternalTheme.Enabled = true
134+ cfg.Appearance.ExternalTheme.Format = "dumber-json"
135+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
136+ },
137+ },
138+ {
139+ name: "enabled empty path uses format-specific default",
140+ mutate: func(cfg *Config) {
141+ cfg.Appearance.ExternalTheme.Enabled = true
142+ cfg.Appearance.ExternalTheme.Path = ""
143+ },
144+ },
145+ {
146+ name: "enabled rejects unsupported provider",
147+ mutate: func(cfg *Config) {
148+ cfg.Appearance.ExternalTheme.Enabled = true
149+ cfg.Appearance.ExternalTheme.Provider = "other"
150+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
151+ },
152+ wantErr: true,
153+ wantText: "appearance.external_theme.provider",
154+ },
155+ {
156+ name: "enabled rejects unsupported format",
157+ mutate: func(cfg *Config) {
158+ cfg.Appearance.ExternalTheme.Enabled = true
159+ cfg.Appearance.ExternalTheme.Format = "toml"
160+ cfg.Appearance.ExternalTheme.Path = "/tmp/theme.json"
161+ },
162+ wantErr: true,
163+ wantText: "appearance.external_theme.format",
164+ },
165+ }
166+
167+ for _, tt := range tests {
168+ t.Run(tt.name, func(t *testing.T) {
169+ cfg := DefaultConfig()
170+ tt.mutate(cfg)
171+ normalizeConfig(cfg)
172+
173+ err := validateConfig(cfg)
174+ if tt.wantErr {
175+ require.Error(t, err)
176+ require.Contains(t, err.Error(), tt.wantText)
177+ return
178+ }
179+ require.NoError(t, err)
180+ })
181+ }
182+}
183+
184+func TestNormalizeConfig_ExternalTheme(t *testing.T) {
185+ cfg := DefaultConfig()
186+ cfg.Appearance.ExternalTheme.Provider = " NOCTALIA "
187+ cfg.Appearance.ExternalTheme.Format = " COLORS-JSON "
188+ cfg.Appearance.ExternalTheme.Path = " /tmp/colors.json "
189+
190+ normalizeConfig(cfg)
191+
192+ require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider)
193+ require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format)
194+ require.Equal(t, "/tmp/colors.json", cfg.Appearance.ExternalTheme.Path)
195+
196+ cfg.Appearance.ExternalTheme.Provider = ""
197+ cfg.Appearance.ExternalTheme.Format = ""
198+ normalizeConfig(cfg)
199+ require.Equal(t, "noctalia", cfg.Appearance.ExternalTheme.Provider)
200+ require.Equal(t, "colors-json", cfg.Appearance.ExternalTheme.Format)
201+}
202+
203+func TestSchemaProvider_ExternalThemeKeys(t *testing.T) {
204+ keys := NewSchemaProvider().GetSchema()
205+ byKey := map[string]bool{}
206+ for _, key := range keys {
207+ if key.Key == "appearance.external_theme.provider" {
208+ require.Equal(t, SectionAppearance, key.Section)
209+ require.Equal(t, "noctalia", key.Default)
210+ require.Equal(t, []string{"noctalia"}, key.Values)
211+ }
212+ if key.Key == "appearance.external_theme.format" {
213+ require.Equal(t, SectionAppearance, key.Section)
214+ require.Equal(t, "colors-json", key.Default)
215+ require.Equal(t, []string{"colors-json", "dumber-json"}, key.Values)
216+ }
217+ if key.Key == "appearance.external_theme.path" {
218+ require.Equal(t, filepath.Join("noctalia", "colors.json"), lastPathElements(key.Default, 2))
219+ }
220+ byKey[key.Key] = true
221+ }
222+
223+ for _, key := range []string{
224+ "appearance.external_theme.enabled",
225+ "appearance.external_theme.provider",
226+ "appearance.external_theme.format",
227+ "appearance.external_theme.path",
228+ } {
229+ require.True(t, byKey[key], "missing schema key %s", key)
230+ }
231+}
232+
233+func TestWriteConfigOrdered_IncludesExternalThemeTable(t *testing.T) {
234+ path := filepath.Join(t.TempDir(), "config.toml")
235+ cfg := DefaultConfig()
236+
237+ require.NoError(t, WriteConfigOrdered(cfg, path))
238+ content, err := os.ReadFile(path)
239+ require.NoError(t, err)
240+ require.Contains(t, string(content), "[appearance.external_theme]")
241+ require.Contains(t, string(content), "enabled = false")
242+ require.Contains(t, string(content), "provider = 'noctalia'")
243+ require.Contains(t, string(content), "format = 'colors-json'")
244+ require.Contains(t, string(content), "colors.json")
245+}
246+
247+func TestBuildSystemviewConfigPayload_ProjectsExternalTheme(t *testing.T) {
248+ cfg := DefaultConfig()
249+ cfg.Appearance.ExternalTheme.Enabled = true
250+ cfg.Appearance.ExternalTheme.Path = "/tmp/colors.json"
251+
252+ payload := BuildSystemviewConfigPayload(cfg, nil)
253+
254+ require.True(t, payload.Appearance.ExternalTheme.Enabled)
255+ require.Equal(t, "noctalia", payload.Appearance.ExternalTheme.Provider)
256+ require.Equal(t, "colors-json", payload.Appearance.ExternalTheme.Format)
257+ require.Equal(t, "/tmp/colors.json", payload.Appearance.ExternalTheme.Path)
258+}
259+
260+func lastPathElements(path string, count int) string {
261+ cleaned := filepath.Clean(path)
262+ parts := make([]string, 0, count)
263+ for len(parts) < count && cleaned != "." && cleaned != string(filepath.Separator) {
264+ parts = append([]string{filepath.Base(cleaned)}, parts...)
265+ cleaned = filepath.Dir(cleaned)
266+ }
267+ return filepath.Join(parts...)
268+}
269+
270+func TestWebUIConfigGatewaySaveWebUIConfig_ExternalThemeRoundTrip(t *testing.T) {
271+ path := filepath.Join(t.TempDir(), "config.toml")
272+ require.NoError(t, os.WriteFile(path, []byte(""), 0o644))
273+
274+ v := viper.New()
275+ v.SetConfigFile(path)
276+ require.NoError(t, v.ReadInConfig())
277+
278+ previousGlobal := globalManager
279+ defer func() { globalManager = previousGlobal }()
280+
281+ mgr := &Manager{config: DefaultConfig(), viper: v}
282+ globalManager = mgr
283+
284+ webCfg := dto.WebUIConfig{
285+ Appearance: buildSystemviewAppearancePayload(DefaultConfig().Appearance),
286+ }
287+ webCfg.Appearance.ExternalTheme = dto.WebUIExternalThemeConfig{
288+ Enabled: true,
289+ Provider: " NOCTALIA ",
290+ Format: " COLORS-JSON ",
291+ Path: " /tmp/colors.json ",
292+ }
293+ webCfg.DefaultUIScale = DefaultConfig().DefaultUIScale
294+ webCfg.DefaultSearchEngine = DefaultConfig().DefaultSearchEngine
295+ webCfg.SearchShortcuts = map[string]dto.SearchShortcut{}
296+ for key, shortcut := range DefaultConfig().SearchShortcuts {
297+ webCfg.SearchShortcuts[key] = dto.SearchShortcut{URL: shortcut.URL, Description: shortcut.Description}
298+ }
299+
300+ require.NoError(t, NewWebUIConfigGateway(mgr).SaveWebUIConfig(context.Background(), webCfg))
301+
302+ saved := mgr.Get()
303+ require.True(t, saved.Appearance.ExternalTheme.Enabled)
304+ require.Equal(t, "noctalia", saved.Appearance.ExternalTheme.Provider)
305+ require.Equal(t, "colors-json", saved.Appearance.ExternalTheme.Format)
306+ require.Equal(t, "/tmp/colors.json", saved.Appearance.ExternalTheme.Path)
307+}
M internal/infrastructure/config/loader.go +19 -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,9 @@ 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)
643662 }
644663
645664 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 +1 -0
@@ -64,6 +64,7 @@ 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 = cfg.Appearance.ExternalTheme
6768 current.DefaultUIScale = cfg.DefaultUIScale
6869 current.DefaultSearchEngine = cfg.DefaultSearchEngine
6970 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 +368 -0
@@ -0,0 +1,368 @@
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+ identityPath := path
67+ if expandedPath, err := ExpandPath(path); err == nil {
68+ identityPath = expandedPath
69+ }
70+
71+ s.mu.Lock()
72+ defer s.mu.Unlock()
73+ s.enabled = enabled
74+ s.path = path
75+ s.format = format
76+ s.identity = externalThemeIdentity(enabled, provider, format, identityPath)
77+}
78+
79+// ExternalThemeIdentity returns the current normalized external theme identity.
80+func (s *FileSource) ExternalThemeIdentity() string {
81+ s.mu.RLock()
82+ defer s.mu.RUnlock()
83+ return s.identity
84+}
85+
86+// IsEnabled returns whether this source should be used.
87+func (s *FileSource) IsEnabled() bool {
88+ s.mu.RLock()
89+ defer s.mu.RUnlock()
90+ return s.enabled
91+}
92+
93+// Get reads and parses the configured Noctalia theme file.
94+func (s *FileSource) Get(ctx context.Context) (*entity.ExternalTheme, error) {
95+ s.mu.RLock()
96+ enabled := s.enabled
97+ pathConfig := s.path
98+ format := s.format
99+ s.mu.RUnlock()
100+
101+ if !enabled {
102+ return nil, nil
103+ }
104+ if err := ctx.Err(); err != nil {
105+ return nil, err
106+ }
107+
108+ path, err := ExpandPath(pathConfig)
109+ if err != nil {
110+ return nil, err
111+ }
112+ if path == "" {
113+ return nil, errors.New("noctalia theme path is empty")
114+ }
115+
116+ data, err := os.ReadFile(path)
117+ if err != nil {
118+ return nil, fmt.Errorf("read noctalia theme file: %w", err)
119+ }
120+ if ctxErr := ctx.Err(); ctxErr != nil {
121+ return nil, ctxErr
122+ }
123+
124+ switch format {
125+ case formatColorsJSON:
126+ return ParseColorsJSON(data)
127+ case formatDumberJSON:
128+ return ParseDumberJSON(data)
129+ default:
130+ return nil, fmt.Errorf("unsupported noctalia theme format %q", format)
131+ }
132+}
133+
134+// ExpandPath expands environment variables, leading ~, and cleans the path.
135+func ExpandPath(path string) (string, error) {
136+ expanded := os.ExpandEnv(strings.TrimSpace(path))
137+ if expanded == "" {
138+ return "", nil
139+ }
140+ if expanded == "~" || strings.HasPrefix(expanded, "~/") {
141+ home, err := os.UserHomeDir()
142+ if err != nil {
143+ return "", fmt.Errorf("expand home directory: %w", err)
144+ }
145+ if expanded == "~" {
146+ expanded = home
147+ } else {
148+ expanded = filepath.Join(home, expanded[2:])
149+ }
150+ }
151+ return filepath.Clean(expanded), nil
152+}
153+
154+// ParseColorsJSON parses Noctalia's native colors.json file.
155+//
156+// Noctalia colors.json represents the active palette only, so Dumber applies the
157+// mapped palette to both light and dark resolved palettes. The mapper uses
158+// optional mShadow/mHover roles when present to preserve background/surface/input
159+// contrast, while falling back to older required roles for compatibility.
160+func ParseColorsJSON(data []byte) (*entity.ExternalTheme, error) {
161+ var raw colorsThemeJSON
162+ decoder := json.NewDecoder(bytes.NewReader(data))
163+ if err := decoder.Decode(&raw); err != nil {
164+ return nil, fmt.Errorf("parse noctalia colors json: %w", err)
165+ }
166+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
167+ return nil, errors.New("parse noctalia colors json: trailing data")
168+ }
169+
170+ palette, err := raw.toPalette()
171+ if err != nil {
172+ return nil, err
173+ }
174+ light := palette
175+ dark := palette
176+ return &entity.ExternalTheme{
177+ Name: colorsJSONName,
178+ Provider: providerName,
179+ LightPalette: &light,
180+ DarkPalette: &dark,
181+ }, nil
182+}
183+
184+// ParseDumberJSON parses Noctalia user-template-generated Dumber JSON.
185+func ParseDumberJSON(data []byte) (*entity.ExternalTheme, error) {
186+ var raw dumberThemeJSON
187+ decoder := json.NewDecoder(bytes.NewReader(data))
188+ if err := decoder.Decode(&raw); err != nil {
189+ return nil, fmt.Errorf("parse noctalia dumber json: %w", err)
190+ }
191+ if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
192+ return nil, errors.New("parse noctalia dumber json: trailing data")
193+ }
194+ if raw.Light == nil {
195+ return nil, errors.New("parse noctalia dumber json: missing light palette")
196+ }
197+ if raw.Dark == nil {
198+ return nil, errors.New("parse noctalia dumber json: missing dark palette")
199+ }
200+
201+ light := raw.Light.toEntity()
202+ dark := raw.Dark.toEntity()
203+ if err := validatePalette(&light, "light"); err != nil {
204+ return nil, err
205+ }
206+ if err := validatePalette(&dark, "dark"); err != nil {
207+ return nil, err
208+ }
209+
210+ return &entity.ExternalTheme{
211+ Name: firstNonEmpty(raw.Name, raw.Source, dumberJSONName),
212+ Provider: providerName,
213+ LightPalette: &light,
214+ DarkPalette: &dark,
215+ }, nil
216+}
217+
218+type colorsThemeJSON struct {
219+ MPrimary *string `json:"mPrimary"`
220+ MSurface *string `json:"mSurface"`
221+ MOnSurface *string `json:"mOnSurface"`
222+ MSurfaceVariant *string `json:"mSurfaceVariant"`
223+ MOnSurfaceVariant *string `json:"mOnSurfaceVariant"`
224+ MOutline *string `json:"mOutline"`
225+ MShadow *string `json:"mShadow"`
226+ MHover *string `json:"mHover"`
227+}
228+
229+func (raw colorsThemeJSON) toPalette() (entity.ColorPalette, error) {
230+ surface, err := requiredColorsHex("mSurface", raw.MSurface)
231+ if err != nil {
232+ return entity.ColorPalette{}, err
233+ }
234+ surfaceVariantBase, err := requiredColorsHex("mSurfaceVariant", raw.MSurfaceVariant)
235+ if err != nil {
236+ return entity.ColorPalette{}, err
237+ }
238+ text, err := requiredColorsHex("mOnSurface", raw.MOnSurface)
239+ if err != nil {
240+ return entity.ColorPalette{}, err
241+ }
242+ muted, err := requiredColorsHex("mOnSurfaceVariant", raw.MOnSurfaceVariant)
243+ if err != nil {
244+ return entity.ColorPalette{}, err
245+ }
246+ accent, err := requiredColorsHex("mPrimary", raw.MPrimary)
247+ if err != nil {
248+ return entity.ColorPalette{}, err
249+ }
250+ border, err := requiredColorsHex("mOutline", raw.MOutline)
251+ if err != nil {
252+ return entity.ColorPalette{}, err
253+ }
254+ background, err := optionalColorsHex("mShadow", raw.MShadow, surface)
255+ if err != nil {
256+ return entity.ColorPalette{}, err
257+ }
258+ surfaceVariant, err := optionalColorsHex("mHover", raw.MHover, surfaceVariantBase)
259+ if err != nil {
260+ return entity.ColorPalette{}, err
261+ }
262+
263+ return entity.ColorPalette{
264+ Background: background,
265+ Surface: surface,
266+ SurfaceVariant: surfaceVariant,
267+ Text: text,
268+ Muted: muted,
269+ Accent: accent,
270+ Border: border,
271+ }, nil
272+}
273+
274+func requiredColorsHex(field string, value *string) (string, error) {
275+ if value == nil || *value == "" {
276+ return "", fmt.Errorf("parse noctalia colors json: missing %s", field)
277+ }
278+ return validateColorsHex(field, *value)
279+}
280+
281+func optionalColorsHex(field string, value *string, fallback string) (string, error) {
282+ if value == nil {
283+ return fallback, nil
284+ }
285+ return validateColorsHex(field, *value)
286+}
287+
288+func validateColorsHex(field, value string) (string, error) {
289+ if !entity.IsValidHex(value) {
290+ return "", fmt.Errorf("parse noctalia colors json: %s: not a valid CSS-safe hex color (#RRGGBB)", field)
291+ }
292+ return value, nil
293+}
294+
295+type dumberThemeJSON struct {
296+ Source string `json:"source"`
297+ Mode string `json:"mode"`
298+ Name string `json:"name"`
299+ Light *paletteJSON `json:"light"`
300+ Dark *paletteJSON `json:"dark"`
301+}
302+
303+type paletteJSON struct {
304+ Background string `json:"background"`
305+ Surface string `json:"surface"`
306+ SurfaceVariant string `json:"surface_variant"`
307+ Text string `json:"text"`
308+ Muted string `json:"muted"`
309+ Accent string `json:"accent"`
310+ Border string `json:"border"`
311+}
312+
313+func (p paletteJSON) toEntity() entity.ColorPalette {
314+ return entity.ColorPalette{
315+ Background: p.Background,
316+ Surface: p.Surface,
317+ SurfaceVariant: p.SurfaceVariant,
318+ Text: p.Text,
319+ Muted: p.Muted,
320+ Accent: p.Accent,
321+ Border: p.Border,
322+ }
323+}
324+
325+func validatePalette(p *entity.ColorPalette, prefix string) error {
326+ warnings := entity.ValidatePaletteOverrideHex(p, prefix)
327+ if len(warnings) == 0 {
328+ return nil
329+ }
330+ return fmt.Errorf("parse noctalia dumber json: %s: %s", warnings[0].Field, warnings[0].Message)
331+}
332+
333+func firstNonEmpty(values ...string) string {
334+ for _, value := range values {
335+ if strings.TrimSpace(value) != "" {
336+ return value
337+ }
338+ }
339+ return ""
340+}
341+
342+func normalizeProviderAndFormat(providerInput, formatInput string) (string, string) {
343+ provider := strings.ToLower(strings.TrimSpace(providerInput))
344+ if provider == "" {
345+ provider = providerName
346+ }
347+ format := strings.ToLower(strings.TrimSpace(formatInput))
348+ if format == "" {
349+ format = formatName
350+ }
351+ return provider, format
352+}
353+
354+func isSupportedFormat(format string) bool {
355+ switch format {
356+ case formatColorsJSON, formatDumberJSON:
357+ return true
358+ default:
359+ return false
360+ }
361+}
362+
363+func externalThemeIdentity(enabled bool, provider, format, path string) string {
364+ if !enabled {
365+ return ""
366+ }
367+ return provider + "\x00" + format + "\x00" + strings.TrimSpace(path)
368+}
A internal/infrastructure/externaltheme/noctalia/source_test.go +391 -0
@@ -0,0 +1,391 @@
1+package noctalia
2+
3+import (
4+ "context"
5+ "os"
6+ "path/filepath"
7+ "runtime"
8+ "testing"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+func TestParseColorsJSONValidActivePalette(t *testing.T) {
14+ theme, err := ParseColorsJSON(noctaliaColorsJSON())
15+ if err != nil {
16+ t.Fatalf("ParseColorsJSON() error = %v", err)
17+ }
18+ if theme.Provider != "noctalia" {
19+ t.Fatalf("Provider = %q, want noctalia", theme.Provider)
20+ }
21+ if theme.Name == "" {
22+ t.Fatal("Name is empty, want source metadata")
23+ }
24+ want := entity.ColorPalette{
25+ Background: "#000000",
26+ Surface: "#0c0c0c",
27+ SurfaceVariant: "#282828",
28+ Text: "#ffffff",
29+ Muted: "#a0a0a0",
30+ Accent: "#ffc799",
31+ Border: "#505050",
32+ }
33+ if theme.LightPalette == nil || *theme.LightPalette != want {
34+ t.Fatalf("LightPalette = %+v, want %+v", theme.LightPalette, want)
35+ }
36+ if theme.DarkPalette == nil || *theme.DarkPalette != want {
37+ t.Fatalf("DarkPalette = %+v, want active Noctalia palette %+v", theme.DarkPalette, want)
38+ }
39+}
40+
41+func TestParseColorsJSONDocsExample(t *testing.T) {
42+ path := docsExamplePath(t, "noctalia-colors.json")
43+ data, err := os.ReadFile(path)
44+ if err != nil {
45+ t.Fatalf("ReadFile(%q) error = %v", path, err)
46+ }
47+
48+ theme, err := ParseColorsJSON(data)
49+ if err != nil {
50+ t.Fatalf("ParseColorsJSON(docs example) error = %v", err)
51+ }
52+ if theme == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.DarkPalette.Accent == "" {
53+ t.Fatalf("parsed docs example = %+v", theme)
54+ }
55+}
56+
57+func TestParseColorsJSONFallsBackWhenNuanceFieldsAreAbsent(t *testing.T) {
58+ theme, err := ParseColorsJSON([]byte(`{
59+ "mPrimary":"#ffc799",
60+ "mSurface":"#0c0c0c",
61+ "mOnSurface":"#ffffff",
62+ "mSurfaceVariant":"#1c1c1c",
63+ "mOnSurfaceVariant":"#a0a0a0",
64+ "mOutline":"#505050"
65+ }`))
66+ if err != nil {
67+ t.Fatalf("ParseColorsJSON() error = %v", err)
68+ }
69+ if theme.DarkPalette.Background != "#0c0c0c" {
70+ t.Fatalf("Background = %q, want mSurface fallback", theme.DarkPalette.Background)
71+ }
72+ if theme.DarkPalette.SurfaceVariant != "#1c1c1c" {
73+ t.Fatalf("SurfaceVariant = %q, want mSurfaceVariant fallback", theme.DarkPalette.SurfaceVariant)
74+ }
75+}
76+
77+func TestParseColorsJSONRequiresMappedFields(t *testing.T) {
78+ cases := map[string]string{
79+ "missing surface": `{"mPrimary":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
80+ "missing surface variant": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
81+ "missing text": `{"mPrimary":"#111111","mSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
82+ "missing muted": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOutline":"#555555"}`,
83+ "missing accent": `{"mSurface":"#111111","mOnSurface":"#222222","mSurfaceVariant":"#333333","mOnSurfaceVariant":"#444444","mOutline":"#555555"}`,
84+ "missing border": `{"mPrimary":"#111111","mSurface":"#222222","mOnSurface":"#333333","mSurfaceVariant":"#444444","mOnSurfaceVariant":"#555555"}`,
85+ }
86+ for name, input := range cases {
87+ t.Run(name, func(t *testing.T) {
88+ if _, err := ParseColorsJSON([]byte(input)); err == nil {
89+ t.Fatal("ParseColorsJSON() error = nil, want missing field error")
90+ }
91+ })
92+ }
93+}
94+
95+func TestParseColorsJSONInvalidHexRejection(t *testing.T) {
96+ if _, err := ParseColorsJSON([]byte(`{"mPrimary":"ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050"}`)); err == nil {
97+ t.Fatal("ParseColorsJSON() error = nil, want invalid hex error")
98+ }
99+}
100+
101+func TestParseColorsJSONInvalidOptionalNuanceHexRejection(t *testing.T) {
102+ cases := map[string]string{
103+ "invalid shadow": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mShadow":"000000"}`,
104+ "invalid hover": `{"mPrimary":"#ffc799","mSurface":"#0c0c0c","mOnSurface":"#ffffff","mSurfaceVariant":"#1c1c1c","mOnSurfaceVariant":"#a0a0a0","mOutline":"#505050","mHover":"282828"}`,
105+ }
106+ for name, input := range cases {
107+ t.Run(name, func(t *testing.T) {
108+ if _, err := ParseColorsJSON([]byte(input)); err == nil {
109+ t.Fatal("ParseColorsJSON() error = nil, want optional hex validation error")
110+ }
111+ })
112+ }
113+}
114+
115+func TestParseColorsJSONRejectsTrailingTokens(t *testing.T) {
116+ if _, err := ParseColorsJSON(append(noctaliaColorsJSON(), []byte(` {}`)...)); err == nil {
117+ t.Fatal("ParseColorsJSON() error = nil, want trailing data error")
118+ }
119+}
120+
121+func TestParseDumberJSONValidFullPalette(t *testing.T) {
122+ json := []byte(`{
123+ "name":"Night Theme",
124+ "source":"noctalia-template",
125+ "mode":"dark",
126+ "light":{"background":"#FFFFFF","surface":"#F0F0F0","surface_variant":"#E0E0E0","text":"#111111","muted":"#777777","accent":"#3366CC","border":"#CCCCCC"},
127+ "dark":{"background":"#000000","surface":"#101010","surface_variant":"#202020","text":"#EEEEEE","muted":"#999999","accent":"#AABBCC","border":"#333333"}
128+ }`)
129+
130+ theme, err := ParseDumberJSON(json)
131+ if err != nil {
132+ t.Fatalf("ParseDumberJSON() error = %v", err)
133+ }
134+ if theme.Provider != "noctalia" {
135+ t.Fatalf("Provider = %q, want noctalia", theme.Provider)
136+ }
137+ if theme.Name != "Night Theme" {
138+ t.Fatalf("Name = %q, want metadata name", theme.Name)
139+ }
140+ if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Accent != "#AABBCC" {
141+ t.Fatalf("parsed palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
142+ }
143+}
144+
145+func TestParseDumberJSONDocsExample(t *testing.T) {
146+ path := docsExamplePath(t, "noctalia-dumber-theme.json")
147+ data, err := os.ReadFile(path)
148+ if err != nil {
149+ t.Fatalf("ReadFile(%q) error = %v", path, err)
150+ }
151+
152+ theme, err := ParseDumberJSON(data)
153+ if err != nil {
154+ t.Fatalf("ParseDumberJSON(docs example) error = %v", err)
155+ }
156+ if theme == nil || theme.LightPalette == nil || theme.DarkPalette == nil || theme.Provider != "noctalia" || theme.LightPalette.Background == "" || theme.DarkPalette.Background == "" {
157+ t.Fatalf("parsed docs example = %+v", theme)
158+ }
159+}
160+
161+func TestParseDumberJSONPartialPalette(t *testing.T) {
162+ theme, err := ParseDumberJSON([]byte(`{
163+ "light":{"background":"#FFFFFF","accent":"#112233"},
164+ "dark":{"text":"#EEEEEE"}
165+ }`))
166+ if err != nil {
167+ t.Fatalf("ParseDumberJSON() error = %v", err)
168+ }
169+ if theme.LightPalette.Surface != "" || theme.DarkPalette.Background != "" {
170+ t.Fatalf("missing fields should remain empty: %+v / %+v", theme.LightPalette, theme.DarkPalette)
171+ }
172+}
173+
174+func TestParseDumberJSONRequiresLightAndDarkObjects(t *testing.T) {
175+ cases := map[string]string{
176+ "missing light": `{"dark":{}}`,
177+ "missing dark": `{"light":{}}`,
178+ }
179+ for name, input := range cases {
180+ t.Run(name, func(t *testing.T) {
181+ if _, err := ParseDumberJSON([]byte(input)); err == nil {
182+ t.Fatal("ParseDumberJSON() error = nil, want error")
183+ }
184+ })
185+ }
186+}
187+
188+func TestParseDumberJSONMetadataNameFallsBackToSource(t *testing.T) {
189+ theme, err := ParseDumberJSON([]byte(`{"source":"noctalia-template","light":{},"dark":{}}`))
190+ if err != nil {
191+ t.Fatalf("ParseDumberJSON() error = %v", err)
192+ }
193+ if theme.Name != "noctalia-template" {
194+ t.Fatalf("Name = %q, want source fallback", theme.Name)
195+ }
196+}
197+
198+func TestParseDumberJSONValidHexAcceptance(t *testing.T) {
199+ _, err := ParseDumberJSON([]byte(`{"light":{"accent":"#aBc123"},"dark":{"accent":"#ABC123"}}`))
200+ if err != nil {
201+ t.Fatalf("ParseDumberJSON() error = %v", err)
202+ }
203+}
204+
205+func TestParseDumberJSONInvalidHexRejection(t *testing.T) {
206+ if _, err := ParseDumberJSON([]byte(`{"light":{"accent":"ABC123"},"dark":{}}`)); err == nil {
207+ t.Fatal("ParseDumberJSON() error = nil, want invalid hex error")
208+ }
209+}
210+
211+func TestParseDumberJSONMalformedRejection(t *testing.T) {
212+ if _, err := ParseDumberJSON([]byte(`{"light":`)); err == nil {
213+ t.Fatal("ParseDumberJSON() error = nil, want malformed JSON error")
214+ }
215+}
216+
217+func TestParseDumberJSONRejectsTrailingTokens(t *testing.T) {
218+ if _, err := ParseDumberJSON([]byte(`{"light":{},"dark":{}} {}`)); err == nil {
219+ t.Fatal("ParseDumberJSON() error = nil, want trailing data error")
220+ }
221+}
222+
223+func TestExpandPath(t *testing.T) {
224+ t.Setenv("NOCTALIA_THEME_TEST", "theme.json")
225+ home := t.TempDir()
226+ t.Setenv("HOME", home)
227+
228+ got, err := ExpandPath("~/$NOCTALIA_THEME_TEST")
229+ if err != nil {
230+ t.Fatalf("ExpandPath() error = %v", err)
231+ }
232+ want := filepath.Join(home, "theme.json")
233+ if got != want {
234+ t.Fatalf("ExpandPath() = %q, want %q", got, want)
235+ }
236+}
237+
238+func TestFileSourceIdentityUsesExpandedPath(t *testing.T) {
239+ home := t.TempDir()
240+ t.Setenv("HOME", home)
241+
242+ path := filepath.Join(home, ".config", "noctalia", "colors.json")
243+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
244+ Enabled: true,
245+ Provider: "noctalia",
246+ Format: "colors-json",
247+ Path: path,
248+ })
249+ absoluteIdentity := source.ExternalThemeIdentity()
250+
251+ source.Configure(entity.ExternalThemeConfig{
252+ Enabled: true,
253+ Provider: "noctalia",
254+ Format: "colors-json",
255+ Path: "~/.config/noctalia/colors.json",
256+ })
257+ if source.ExternalThemeIdentity() != absoluteIdentity {
258+ t.Fatalf("ExternalThemeIdentity() = %q, want %q", source.ExternalThemeIdentity(), absoluteIdentity)
259+ }
260+}
261+
262+func TestFileSourceDisabledBehavior(t *testing.T) {
263+ source := NewFileSource(false, "/does/not/exist")
264+ if source.IsEnabled() {
265+ t.Fatal("IsEnabled() = true, want false")
266+ }
267+ theme, err := source.Get(context.Background())
268+ if err != nil {
269+ t.Fatalf("Get() error = %v", err)
270+ }
271+ if theme != nil {
272+ t.Fatalf("Get() theme = %+v, want nil", theme)
273+ }
274+}
275+
276+func TestFileSourceReadsColorsJSONByDefault(t *testing.T) {
277+ path := filepath.Join(t.TempDir(), "colors.json")
278+ if err := os.WriteFile(path, noctaliaColorsJSON(), 0o600); err != nil {
279+ t.Fatalf("WriteFile() error = %v", err)
280+ }
281+
282+ theme, err := NewFileSource(true, path).Get(context.Background())
283+ if err != nil {
284+ t.Fatalf("Get() error = %v", err)
285+ }
286+ if theme.LightPalette.Accent != "#ffc799" || theme.DarkPalette.Background != "#000000" || theme.DarkPalette.SurfaceVariant != "#282828" {
287+ t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
288+ }
289+}
290+
291+func TestFileSourceReadsDumberJSONWhenConfigured(t *testing.T) {
292+ path := filepath.Join(t.TempDir(), "theme.json")
293+ if err := os.WriteFile(path, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
294+ t.Fatalf("WriteFile() error = %v", err)
295+ }
296+
297+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{
298+ Enabled: true,
299+ Provider: "noctalia",
300+ Format: "dumber-json",
301+ Path: path,
302+ })
303+ theme, err := source.Get(context.Background())
304+ if err != nil {
305+ t.Fatalf("Get() error = %v", err)
306+ }
307+ if theme.LightPalette.Background != "#FFFFFF" || theme.DarkPalette.Background != "#000000" {
308+ t.Fatalf("Get() palettes = %+v / %+v", theme.LightPalette, theme.DarkPalette)
309+ }
310+}
311+
312+func TestFileSourceConfigureUpdatesSettings(t *testing.T) {
313+ colorsPath := filepath.Join(t.TempDir(), "colors.json")
314+ if err := os.WriteFile(colorsPath, noctaliaColorsJSON(), 0o600); err != nil {
315+ t.Fatalf("WriteFile(colors) error = %v", err)
316+ }
317+ dumberPath := filepath.Join(t.TempDir(), "theme.json")
318+ if err := os.WriteFile(dumberPath, []byte(`{"light":{"background":"#FFFFFF"},"dark":{"background":"#000000"}}`), 0o600); err != nil {
319+ t.Fatalf("WriteFile(dumber) error = %v", err)
320+ }
321+
322+ source := NewFileSourceFromConfig(entity.ExternalThemeConfig{})
323+ if source.IsEnabled() {
324+ t.Fatal("IsEnabled() = true, want false")
325+ }
326+
327+ source.Configure(entity.ExternalThemeConfig{
328+ Enabled: true,
329+ Provider: " NOCTALIA ",
330+ Format: " COLORS-JSON ",
331+ Path: colorsPath,
332+ })
333+ if !source.IsEnabled() {
334+ t.Fatal("IsEnabled() = false for colors-json, want true")
335+ }
336+ if _, err := source.Get(context.Background()); err != nil {
337+ t.Fatalf("Get() colors-json after Configure error = %v", err)
338+ }
339+
340+ source.Configure(entity.ExternalThemeConfig{
341+ Enabled: true,
342+ Provider: "noctalia",
343+ Format: "dumber-json",
344+ Path: dumberPath,
345+ })
346+ if !source.IsEnabled() {
347+ t.Fatal("IsEnabled() = false for dumber-json, want true")
348+ }
349+ if _, err := source.Get(context.Background()); err != nil {
350+ t.Fatalf("Get() dumber-json after Configure error = %v", err)
351+ }
352+
353+ source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "other", Format: "colors-json", Path: colorsPath})
354+ if source.IsEnabled() {
355+ t.Fatal("IsEnabled() = true for unsupported provider, want false")
356+ }
357+ source.Configure(entity.ExternalThemeConfig{Enabled: true, Provider: "noctalia", Format: "toml", Path: colorsPath})
358+ if source.IsEnabled() {
359+ t.Fatal("IsEnabled() = true for unsupported format, want false")
360+ }
361+}
362+
363+func docsExamplePath(t *testing.T, name string) string {
364+ t.Helper()
365+ _, file, _, ok := runtime.Caller(0)
366+ if !ok {
367+ t.Fatal("runtime.Caller(0) failed")
368+ }
369+ return filepath.Join(filepath.Dir(file), "..", "..", "..", "..", "docs", "examples", name)
370+}
371+
372+func noctaliaColorsJSON() []byte {
373+ return []byte(`{
374+ "mError": "#ff8080",
375+ "mHover": "#282828",
376+ "mOnError": "#000000",
377+ "mOnHover": "#ffffff",
378+ "mOnPrimary": "#000000",
379+ "mOnSecondary": "#000000",
380+ "mOnSurface": "#ffffff",
381+ "mOnSurfaceVariant": "#a0a0a0",
382+ "mOnTertiary": "#000000",
383+ "mOutline": "#505050",
384+ "mPrimary": "#ffc799",
385+ "mSecondary": "#99ffe4",
386+ "mShadow": "#000000",
387+ "mSurface": "#0c0c0c",
388+ "mSurfaceVariant": "#1c1c1c",
389+ "mTertiary": "#fbadff"
390+ }`)
391+}
A internal/infrastructure/externaltheme/noctalia/watcher.go +197 -0
@@ -0,0 +1,197 @@
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+ if stopErr := w.Stop(); stopErr != nil {
47+ return errors.Join(err, fmt.Errorf("stop noctalia theme watcher: %w", stopErr))
48+ }
49+ return err
50+ }
51+ if !enabled || path == "" {
52+ return w.Stop()
53+ }
54+ if onChange == nil {
55+ return errors.New("noctalia file watcher callback is nil")
56+ }
57+
58+ w.mu.Lock()
59+ defer w.mu.Unlock()
60+ if w.running && w.path == path {
61+ return nil
62+ }
63+ if stopErr := w.stopLocked(); stopErr != nil {
64+ return fmt.Errorf("stop previous noctalia theme watcher: %w", stopErr)
65+ }
66+
67+ parent := filepath.Dir(path)
68+ info, statErr := os.Stat(parent)
69+ if statErr != nil {
70+ return fmt.Errorf("watch noctalia theme parent directory: %w", statErr)
71+ } else if !info.IsDir() {
72+ return fmt.Errorf("watch noctalia theme parent directory: %s is not a directory", parent)
73+ }
74+
75+ watcher, err := fsnotify.NewWatcher()
76+ if err != nil {
77+ return fmt.Errorf("create noctalia theme watcher: %w", err)
78+ }
79+ if err := watcher.Add(parent); err != nil {
80+ if closeErr := watcher.Close(); closeErr != nil {
81+ return errors.Join(
82+ fmt.Errorf("watch noctalia theme parent directory: %w", err),
83+ fmt.Errorf("close noctalia theme watcher after add failure: %w", closeErr),
84+ )
85+ }
86+ return fmt.Errorf("watch noctalia theme parent directory: %w", err)
87+ }
88+
89+ runCtx, cancel := context.WithCancel(ctx)
90+ w.watcher = watcher
91+ w.path = path
92+ w.stopCtx = runCtx
93+ w.stopFunc = cancel
94+ w.running = true
95+
96+ go w.run(runCtx, watcher, path, onChange)
97+ return nil
98+}
99+
100+// Stop stops the current watcher. It is safe to call repeatedly.
101+func (w *FileWatcher) Stop() error {
102+ w.mu.Lock()
103+ defer w.mu.Unlock()
104+ return w.stopLocked()
105+}
106+
107+func (w *FileWatcher) stopLocked() error {
108+ if !w.running {
109+ return nil
110+ }
111+ if w.stopFunc != nil {
112+ w.stopFunc()
113+ }
114+ err := w.watcher.Close()
115+ w.watcher = nil
116+ w.path = ""
117+ w.stopCtx = nil
118+ w.stopFunc = nil
119+ w.running = false
120+ return err
121+}
122+
123+func (w *FileWatcher) run(ctx context.Context, watcher *fsnotify.Watcher, path string, onChange func()) {
124+ var timer *time.Timer
125+ var timerC <-chan time.Time
126+ defer func() {
127+ if timer != nil {
128+ timer.Stop()
129+ }
130+ w.mu.Lock()
131+ if w.watcher == watcher {
132+ _ = w.stopLocked()
133+ }
134+ w.mu.Unlock()
135+ }()
136+
137+ for {
138+ select {
139+ case <-ctx.Done():
140+ return
141+ case event, ok := <-watcher.Events:
142+ if !ok {
143+ return
144+ }
145+ if !isRelevantFileEvent(event, path) {
146+ continue
147+ }
148+ if timer == nil {
149+ timer = time.NewTimer(w.delay)
150+ timerC = timer.C
151+ } else {
152+ if !timer.Stop() {
153+ select {
154+ case <-timer.C:
155+ default:
156+ }
157+ }
158+ timer.Reset(w.delay)
159+ timerC = timer.C
160+ }
161+ case err, ok := <-watcher.Errors:
162+ if !ok {
163+ return
164+ }
165+ slog.Warn("noctalia theme watcher error", "error", err)
166+ case <-timerC:
167+ timerC = nil
168+ onChange()
169+ }
170+ }
171+}
172+
173+func watchPathFromConfig(cfg entity.ExternalThemeConfig) (string, bool, error) {
174+ provider, format := normalizeProviderAndFormat(cfg.Provider, cfg.Format)
175+ enabled := cfg.Enabled && provider == providerName && isSupportedFormat(format)
176+ if !enabled {
177+ return "", false, nil
178+ }
179+ path, err := ExpandPath(cfg.Path)
180+ if err != nil {
181+ return "", false, err
182+ }
183+ return path, path != "", nil
184+}
185+
186+func isRelevantFileEvent(event fsnotify.Event, path string) bool {
187+ if event.Op&(fsnotify.Write|fsnotify.Create|fsnotify.Rename|fsnotify.Remove) == 0 {
188+ return false
189+ }
190+ return filepath.Clean(event.Name) == path
191+}
192+
193+func (w *FileWatcher) isRunningForTest() bool {
194+ w.mu.Lock()
195+ defer w.mu.Unlock()
196+ return w.running
197+}
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+ panic("unreachable")
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
@@ -292,7 +292,56 @@ func AppearanceForm(config dto.SystemviewConfigPayload) templ.Component {
292292 return templ_7745c5c3_Err
293293 }
294294 }
295- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Prefer dark</option></select></label>")
295+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 14, ">Prefer dark</option></select></label><fieldset class=\"sv-fieldset\"><legend>External theme</legend><p class=\"sv-meta\">Provider noctalia supports colors-json (default Noctalia colors.json) and dumber-json (advanced user template).</p><label><span>Enable external theme</span><input name=\"external_theme_enabled\" type=\"checkbox\"")
296+ if templ_7745c5c3_Err != nil {
297+ return templ_7745c5c3_Err
298+ }
299+ if config.Appearance.ExternalTheme.Enabled {
300+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, " checked")
301+ if templ_7745c5c3_Err != nil {
302+ return templ_7745c5c3_Err
303+ }
304+ }
305+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "></label> <label><span>Provider</span><input name=\"external_theme_provider\" type=\"text\" value=\"")
306+ if templ_7745c5c3_Err != nil {
307+ return templ_7745c5c3_Err
308+ }
309+ var templ_7745c5c3_Var14 string
310+ templ_7745c5c3_Var14, templ_7745c5c3_Err = templ.ResolveAttributeValue(config.Appearance.ExternalTheme.Provider)
311+ if templ_7745c5c3_Err != nil {
312+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 46, Col: 129}
313+ }
314+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var14)
315+ if templ_7745c5c3_Err != nil {
316+ return templ_7745c5c3_Err
317+ }
318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "\" placeholder=\"noctalia\"></label> <label><span>Format</span><input name=\"external_theme_format\" type=\"text\" value=\"")
319+ if templ_7745c5c3_Err != nil {
320+ return templ_7745c5c3_Err
321+ }
322+ var templ_7745c5c3_Var15 string
323+ templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.ResolveAttributeValue(config.Appearance.ExternalTheme.Format)
324+ if templ_7745c5c3_Err != nil {
325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 47, Col: 123}
326+ }
327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var15)
328+ if templ_7745c5c3_Err != nil {
329+ return templ_7745c5c3_Err
330+ }
331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "\" placeholder=\"colors-json\"></label> <label><span>Path</span><input name=\"external_theme_path\" type=\"text\" value=\"")
332+ if templ_7745c5c3_Err != nil {
333+ return templ_7745c5c3_Err
334+ }
335+ var templ_7745c5c3_Var16 string
336+ templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(config.Appearance.ExternalTheme.Path)
337+ if templ_7745c5c3_Err != nil {
338+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 48, Col: 117}
339+ }
340+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
341+ if templ_7745c5c3_Err != nil {
342+ return templ_7745c5c3_Err
343+ }
344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "\" placeholder=\"~/.config/noctalia/colors.json\"></label></fieldset>")
296345 if templ_7745c5c3_Err != nil {
297346 return templ_7745c5c3_Err
298347 }
@@ -304,7 +353,7 @@ func AppearanceForm(config dto.SystemviewConfigPayload) templ.Component {
304353 if templ_7745c5c3_Err != nil {
305354 return templ_7745c5c3_Err
306355 }
307- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 15, "<div class=\"sv-button-row sv-form-actions\"><button class=\"sv-button\" type=\"submit\">")
356+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "<div class=\"sv-button-row sv-form-actions\"><button class=\"sv-button\" type=\"submit\">")
308357 if templ_7745c5c3_Err != nil {
309358 return templ_7745c5c3_Err
310359 }
@@ -312,7 +361,7 @@ func AppearanceForm(config dto.SystemviewConfigPayload) templ.Component {
312361 if templ_7745c5c3_Err != nil {
313362 return templ_7745c5c3_Err
314363 }
315- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 16, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.appearance.reset\" data-sv-confirm=\"Reset appearance settings to defaults?\">")
364+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.appearance.reset\" data-sv-confirm=\"Reset appearance settings to defaults?\">")
316365 if templ_7745c5c3_Err != nil {
317366 return templ_7745c5c3_Err
318367 }
@@ -320,7 +369,7 @@ func AppearanceForm(config dto.SystemviewConfigPayload) templ.Component {
320369 if templ_7745c5c3_Err != nil {
321370 return templ_7745c5c3_Err
322371 }
323- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 17, "</button></div></form>")
372+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "</button></div></form>")
324373 if templ_7745c5c3_Err != nil {
325374 return templ_7745c5c3_Err
326375 }
@@ -344,207 +393,207 @@ func PaletteFields(title string, prefix string, palette dto.ColorPalette) templ.
344393 }()
345394 }
346395 ctx = templ.InitializeContext(ctx)
347- templ_7745c5c3_Var14 := templ.GetChildren(ctx)
348- if templ_7745c5c3_Var14 == nil {
349- templ_7745c5c3_Var14 = templ.NopComponent
396+ templ_7745c5c3_Var17 := templ.GetChildren(ctx)
397+ if templ_7745c5c3_Var17 == nil {
398+ templ_7745c5c3_Var17 = templ.NopComponent
350399 }
351400 ctx = templ.ClearChildren(ctx)
352- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 18, "<fieldset class=\"sv-fieldset\"><legend>")
401+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "<fieldset class=\"sv-fieldset\"><legend>")
353402 if templ_7745c5c3_Err != nil {
354403 return templ_7745c5c3_Err
355404 }
356- var templ_7745c5c3_Var15 string
357- templ_7745c5c3_Var15, templ_7745c5c3_Err = templ.JoinStringErrs(title)
405+ var templ_7745c5c3_Var18 string
406+ templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.JoinStringErrs(title)
358407 if templ_7745c5c3_Err != nil {
359- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 53, Col: 17}
408+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 61, Col: 17}
360409 }
361- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var15))
410+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var18))
362411 if templ_7745c5c3_Err != nil {
363412 return templ_7745c5c3_Err
364413 }
365- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 19, "</legend> <label><span>Background</span><input name=\"")
414+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "</legend> <label><span>Background</span><input name=\"")
366415 if templ_7745c5c3_Err != nil {
367416 return templ_7745c5c3_Err
368417 }
369- var templ_7745c5c3_Var16 string
370- templ_7745c5c3_Var16, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_background")
418+ var templ_7745c5c3_Var19 string
419+ templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_background")
371420 if templ_7745c5c3_Err != nil {
372- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 54, Col: 68}
421+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 62, Col: 68}
373422 }
374- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var16)
423+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
375424 if templ_7745c5c3_Err != nil {
376425 return templ_7745c5c3_Err
377426 }
378- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 20, "\" type=\"text\" value=\"")
427+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" type=\"text\" value=\"")
379428 if templ_7745c5c3_Err != nil {
380429 return templ_7745c5c3_Err
381430 }
382- var templ_7745c5c3_Var17 string
383- templ_7745c5c3_Var17, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Background)
431+ var templ_7745c5c3_Var20 string
432+ templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Background)
384433 if templ_7745c5c3_Err != nil {
385- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 54, Col: 109}
434+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 62, Col: 109}
386435 }
387- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var17)
436+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
388437 if templ_7745c5c3_Err != nil {
389438 return templ_7745c5c3_Err
390439 }
391- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 21, "\" required></label> <label><span>Surface</span><input name=\"")
440+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" required></label> <label><span>Surface</span><input name=\"")
392441 if templ_7745c5c3_Err != nil {
393442 return templ_7745c5c3_Err
394443 }
395- var templ_7745c5c3_Var18 string
396- templ_7745c5c3_Var18, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_surface")
444+ var templ_7745c5c3_Var21 string
445+ templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_surface")
397446 if templ_7745c5c3_Err != nil {
398- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 55, Col: 62}
447+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 63, Col: 62}
399448 }
400- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var18)
449+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
401450 if templ_7745c5c3_Err != nil {
402451 return templ_7745c5c3_Err
403452 }
404- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 22, "\" type=\"text\" value=\"")
453+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" type=\"text\" value=\"")
405454 if templ_7745c5c3_Err != nil {
406455 return templ_7745c5c3_Err
407456 }
408- var templ_7745c5c3_Var19 string
409- templ_7745c5c3_Var19, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Surface)
457+ var templ_7745c5c3_Var22 string
458+ templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Surface)
410459 if templ_7745c5c3_Err != nil {
411- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 55, Col: 100}
460+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 63, Col: 100}
412461 }
413- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var19)
462+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
414463 if templ_7745c5c3_Err != nil {
415464 return templ_7745c5c3_Err
416465 }
417- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 23, "\" required></label> <label><span>Surface variant</span><input name=\"")
466+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" required></label> <label><span>Surface variant</span><input name=\"")
418467 if templ_7745c5c3_Err != nil {
419468 return templ_7745c5c3_Err
420469 }
421- var templ_7745c5c3_Var20 string
422- templ_7745c5c3_Var20, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_surface_variant")
470+ var templ_7745c5c3_Var23 string
471+ templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_surface_variant")
423472 if templ_7745c5c3_Err != nil {
424- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 56, Col: 78}
473+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 64, Col: 78}
425474 }
426- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var20)
475+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
427476 if templ_7745c5c3_Err != nil {
428477 return templ_7745c5c3_Err
429478 }
430- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 24, "\" type=\"text\" value=\"")
479+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" type=\"text\" value=\"")
431480 if templ_7745c5c3_Err != nil {
432481 return templ_7745c5c3_Err
433482 }
434- var templ_7745c5c3_Var21 string
435- templ_7745c5c3_Var21, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.SurfaceVariant)
483+ var templ_7745c5c3_Var24 string
484+ templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.SurfaceVariant)
436485 if templ_7745c5c3_Err != nil {
437- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 56, Col: 123}
486+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 64, Col: 123}
438487 }
439- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var21)
488+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
440489 if templ_7745c5c3_Err != nil {
441490 return templ_7745c5c3_Err
442491 }
443- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 25, "\" required></label> <label><span>Text</span><input name=\"")
492+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" required></label> <label><span>Text</span><input name=\"")
444493 if templ_7745c5c3_Err != nil {
445494 return templ_7745c5c3_Err
446495 }
447- var templ_7745c5c3_Var22 string
448- templ_7745c5c3_Var22, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_text")
496+ var templ_7745c5c3_Var25 string
497+ templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_text")
449498 if templ_7745c5c3_Err != nil {
450- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 57, Col: 56}
499+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 65, Col: 56}
451500 }
452- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var22)
501+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
453502 if templ_7745c5c3_Err != nil {
454503 return templ_7745c5c3_Err
455504 }
456- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 26, "\" type=\"text\" value=\"")
505+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" type=\"text\" value=\"")
457506 if templ_7745c5c3_Err != nil {
458507 return templ_7745c5c3_Err
459508 }
460- var templ_7745c5c3_Var23 string
461- templ_7745c5c3_Var23, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Text)
509+ var templ_7745c5c3_Var26 string
510+ templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Text)
462511 if templ_7745c5c3_Err != nil {
463- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 57, Col: 91}
512+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 65, Col: 91}
464513 }
465- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var23)
514+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
466515 if templ_7745c5c3_Err != nil {
467516 return templ_7745c5c3_Err
468517 }
469- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 27, "\" required></label> <label><span>Muted</span><input name=\"")
518+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" required></label> <label><span>Muted</span><input name=\"")
470519 if templ_7745c5c3_Err != nil {
471520 return templ_7745c5c3_Err
472521 }
473- var templ_7745c5c3_Var24 string
474- templ_7745c5c3_Var24, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_muted")
522+ var templ_7745c5c3_Var27 string
523+ templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_muted")
475524 if templ_7745c5c3_Err != nil {
476- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 58, Col: 58}
525+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 66, Col: 58}
477526 }
478- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var24)
527+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
479528 if templ_7745c5c3_Err != nil {
480529 return templ_7745c5c3_Err
481530 }
482- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 28, "\" type=\"text\" value=\"")
531+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" type=\"text\" value=\"")
483532 if templ_7745c5c3_Err != nil {
484533 return templ_7745c5c3_Err
485534 }
486- var templ_7745c5c3_Var25 string
487- templ_7745c5c3_Var25, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Muted)
535+ var templ_7745c5c3_Var28 string
536+ templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Muted)
488537 if templ_7745c5c3_Err != nil {
489- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 58, Col: 94}
538+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 66, Col: 94}
490539 }
491- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var25)
540+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
492541 if templ_7745c5c3_Err != nil {
493542 return templ_7745c5c3_Err
494543 }
495- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 29, "\" required></label> <label><span>Accent</span><input name=\"")
544+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "\" required></label> <label><span>Accent</span><input name=\"")
496545 if templ_7745c5c3_Err != nil {
497546 return templ_7745c5c3_Err
498547 }
499- var templ_7745c5c3_Var26 string
500- templ_7745c5c3_Var26, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_accent")
548+ var templ_7745c5c3_Var29 string
549+ templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_accent")
501550 if templ_7745c5c3_Err != nil {
502- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 59, Col: 60}
551+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 67, Col: 60}
503552 }
504- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var26)
553+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
505554 if templ_7745c5c3_Err != nil {
506555 return templ_7745c5c3_Err
507556 }
508- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 30, "\" type=\"text\" value=\"")
557+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" type=\"text\" value=\"")
509558 if templ_7745c5c3_Err != nil {
510559 return templ_7745c5c3_Err
511560 }
512- var templ_7745c5c3_Var27 string
513- templ_7745c5c3_Var27, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Accent)
561+ var templ_7745c5c3_Var30 string
562+ templ_7745c5c3_Var30, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Accent)
514563 if templ_7745c5c3_Err != nil {
515- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 59, Col: 97}
564+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 67, Col: 97}
516565 }
517- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var27)
566+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var30)
518567 if templ_7745c5c3_Err != nil {
519568 return templ_7745c5c3_Err
520569 }
521- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 31, "\" required></label> <label><span>Border</span><input name=\"")
570+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "\" required></label> <label><span>Border</span><input name=\"")
522571 if templ_7745c5c3_Err != nil {
523572 return templ_7745c5c3_Err
524573 }
525- var templ_7745c5c3_Var28 string
526- templ_7745c5c3_Var28, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_border")
574+ var templ_7745c5c3_Var31 string
575+ templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(prefix + "_border")
527576 if templ_7745c5c3_Err != nil {
528- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 60, Col: 60}
577+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 68, Col: 60}
529578 }
530- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var28)
579+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
531580 if templ_7745c5c3_Err != nil {
532581 return templ_7745c5c3_Err
533582 }
534- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 32, "\" type=\"text\" value=\"")
583+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "\" type=\"text\" value=\"")
535584 if templ_7745c5c3_Err != nil {
536585 return templ_7745c5c3_Err
537586 }
538- var templ_7745c5c3_Var29 string
539- templ_7745c5c3_Var29, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Border)
587+ var templ_7745c5c3_Var32 string
588+ templ_7745c5c3_Var32, templ_7745c5c3_Err = templ.ResolveAttributeValue(palette.Border)
540589 if templ_7745c5c3_Err != nil {
541- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 60, Col: 97}
590+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 68, Col: 97}
542591 }
543- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var29)
592+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var32)
544593 if templ_7745c5c3_Err != nil {
545594 return templ_7745c5c3_Err
546595 }
547- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 33, "\" required></label></fieldset>")
596+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "\" required></label></fieldset>")
548597 if templ_7745c5c3_Err != nil {
549598 return templ_7745c5c3_Err
550599 }
@@ -568,25 +617,25 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component {
568617 }()
569618 }
570619 ctx = templ.InitializeContext(ctx)
571- templ_7745c5c3_Var30 := templ.GetChildren(ctx)
572- if templ_7745c5c3_Var30 == nil {
573- templ_7745c5c3_Var30 = templ.NopComponent
620+ templ_7745c5c3_Var33 := templ.GetChildren(ctx)
621+ if templ_7745c5c3_Var33 == nil {
622+ templ_7745c5c3_Var33 = templ.NopComponent
574623 }
575624 ctx = templ.ClearChildren(ctx)
576- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 34, "<form class=\"sv-card-form\" data-sv-action=\"config.search.save\"><label><span>Default search engine</span><input name=\"default_search_engine\" type=\"text\" value=\"")
625+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "<form class=\"sv-card-form\" data-sv-action=\"config.search.save\"><label><span>Default search engine</span><input name=\"default_search_engine\" type=\"text\" value=\"")
577626 if templ_7745c5c3_Err != nil {
578627 return templ_7745c5c3_Err
579628 }
580- var templ_7745c5c3_Var31 string
581- templ_7745c5c3_Var31, templ_7745c5c3_Err = templ.ResolveAttributeValue(config.DefaultSearchEngine)
629+ var templ_7745c5c3_Var34 string
630+ templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(config.DefaultSearchEngine)
582631 if templ_7745c5c3_Err != nil {
583- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 66, Col: 125}
632+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 74, Col: 125}
584633 }
585- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var31)
634+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
586635 if templ_7745c5c3_Err != nil {
587636 return templ_7745c5c3_Err
588637 }
589- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 35, "\" placeholder=\"https://duckduckgo.com/?q=%s\" required></label> <button class=\"sv-button\" type=\"submit\">")
638+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "\" placeholder=\"https://duckduckgo.com/?q=%s\" required></label> <button class=\"sv-button\" type=\"submit\">")
590639 if templ_7745c5c3_Err != nil {
591640 return templ_7745c5c3_Err
592641 }
@@ -594,7 +643,7 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component {
594643 if templ_7745c5c3_Err != nil {
595644 return templ_7745c5c3_Err
596645 }
597- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 36, "</button></form><div class=\"sv-group\"><div class=\"sv-group-header\"><h4>Search shortcuts</h4></div><p class=\"sv-meta\">Shortcut URLs must include the %s placeholder for the search query.</p><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.create\"><label><span>Key</span><input name=\"key\" type=\"text\" placeholder=\"ddg\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" placeholder=\"https://duckduckgo.com/?q=%s\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" placeholder=\"DuckDuckGo\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
646+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "</button></form><div class=\"sv-group\"><div class=\"sv-group-header\"><h4>Search shortcuts</h4></div><p class=\"sv-meta\">Shortcut URLs must include the %s placeholder for the search query.</p><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.create\"><label><span>Key</span><input name=\"key\" type=\"text\" placeholder=\"ddg\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" placeholder=\"https://duckduckgo.com/?q=%s\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" placeholder=\"DuckDuckGo\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
598647 if templ_7745c5c3_Err != nil {
599648 return templ_7745c5c3_Err
600649 }
@@ -602,7 +651,7 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component {
602651 if templ_7745c5c3_Err != nil {
603652 return templ_7745c5c3_Err
604653 }
605- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 37, "</button></form>")
654+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "</button></form>")
606655 if templ_7745c5c3_Err != nil {
607656 return templ_7745c5c3_Err
608657 }
@@ -613,7 +662,7 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component {
613662 return templ_7745c5c3_Err
614663 }
615664 } else {
616- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 38, "<ul class=\"sv-list\">")
665+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "<ul class=\"sv-list\">")
617666 if templ_7745c5c3_Err != nil {
618667 return templ_7745c5c3_Err
619668 }
@@ -623,12 +672,12 @@ func SearchSettings(config dto.SystemviewConfigPayload) templ.Component {
623672 return templ_7745c5c3_Err
624673 }
625674 }
626- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 39, "</ul>")
675+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "</ul>")
627676 if templ_7745c5c3_Err != nil {
628677 return templ_7745c5c3_Err
629678 }
630679 }
631- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 40, "</div>")
680+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "</div>")
632681 if templ_7745c5c3_Err != nil {
633682 return templ_7745c5c3_Err
634683 }
@@ -652,64 +701,64 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
652701 }()
653702 }
654703 ctx = templ.InitializeContext(ctx)
655- templ_7745c5c3_Var32 := templ.GetChildren(ctx)
656- if templ_7745c5c3_Var32 == nil {
657- templ_7745c5c3_Var32 = templ.NopComponent
704+ templ_7745c5c3_Var35 := templ.GetChildren(ctx)
705+ if templ_7745c5c3_Var35 == nil {
706+ templ_7745c5c3_Var35 = templ.NopComponent
658707 }
659708 ctx = templ.ClearChildren(ctx)
660- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 41, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.update\"><input type=\"hidden\" name=\"key\" value=\"")
709+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "<li class=\"sv-list-row\"><form class=\"sv-inline-form\" data-sv-action=\"config.searchShortcut.update\"><input type=\"hidden\" name=\"key\" value=\"")
661710 if templ_7745c5c3_Err != nil {
662711 return templ_7745c5c3_Err
663712 }
664- var templ_7745c5c3_Var33 string
665- templ_7745c5c3_Var33, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
713+ var templ_7745c5c3_Var36 string
714+ templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
666715 if templ_7745c5c3_Err != nil {
667- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 94, Col: 55}
716+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 102, Col: 55}
668717 }
669- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var33)
718+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
670719 if templ_7745c5c3_Err != nil {
671720 return templ_7745c5c3_Err
672721 }
673- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 42, "\"> <label><span>Key</span><input name=\"new_key\" type=\"text\" value=\"")
722+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\"> <label><span>Key</span><input name=\"new_key\" type=\"text\" value=\"")
674723 if templ_7745c5c3_Err != nil {
675724 return templ_7745c5c3_Err
676725 }
677- var templ_7745c5c3_Var34 string
678- templ_7745c5c3_Var34, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
726+ var templ_7745c5c3_Var37 string
727+ templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
679728 if templ_7745c5c3_Err != nil {
680- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 95, Col: 80}
729+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 103, Col: 80}
681730 }
682- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var34)
731+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
683732 if templ_7745c5c3_Err != nil {
684733 return templ_7745c5c3_Err
685734 }
686- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 43, "\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" value=\"")
735+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\" required></label> <label><span>URL</span><input name=\"url\" type=\"text\" value=\"")
687736 if templ_7745c5c3_Err != nil {
688737 return templ_7745c5c3_Err
689738 }
690- var templ_7745c5c3_Var35 string
691- templ_7745c5c3_Var35, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.URL)
739+ var templ_7745c5c3_Var38 string
740+ templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.URL)
692741 if templ_7745c5c3_Err != nil {
693- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 96, Col: 76}
742+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 104, Col: 76}
694743 }
695- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var35)
744+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
696745 if templ_7745c5c3_Err != nil {
697746 return templ_7745c5c3_Err
698747 }
699- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 44, "\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" value=\"")
748+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "\" pattern=\".*%s.*\" required></label> <label><span>Description</span><input name=\"description\" type=\"text\" value=\"")
700749 if templ_7745c5c3_Err != nil {
701750 return templ_7745c5c3_Err
702751 }
703- var templ_7745c5c3_Var36 string
704- templ_7745c5c3_Var36, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Description)
752+ var templ_7745c5c3_Var39 string
753+ templ_7745c5c3_Var39, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Description)
705754 if templ_7745c5c3_Err != nil {
706- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 97, Col: 100}
755+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 105, Col: 100}
707756 }
708- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var36)
757+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var39)
709758 if templ_7745c5c3_Err != nil {
710759 return templ_7745c5c3_Err
711760 }
712- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 45, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
761+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "\"></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
713762 if templ_7745c5c3_Err != nil {
714763 return templ_7745c5c3_Err
715764 }
@@ -717,33 +766,33 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
717766 if templ_7745c5c3_Err != nil {
718767 return templ_7745c5c3_Err
719768 }
720- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 46, "</button> <button class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" type=\"button\" data-sv-action=\"config.searchShortcut.delete\" data-key=\"")
769+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, "</button> <button class=\"sv-button sv-button-secondary sv-button-danger sv-button-icon-only\" type=\"button\" data-sv-action=\"config.searchShortcut.delete\" data-key=\"")
721770 if templ_7745c5c3_Err != nil {
722771 return templ_7745c5c3_Err
723772 }
724- var templ_7745c5c3_Var37 string
725- templ_7745c5c3_Var37, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
773+ var templ_7745c5c3_Var40 string
774+ templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(shortcut.Key)
726775 if templ_7745c5c3_Err != nil {
727- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 99, Col: 169}
776+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 107, Col: 169}
728777 }
729- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var37)
778+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
730779 if templ_7745c5c3_Err != nil {
731780 return templ_7745c5c3_Err
732781 }
733- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 47, "\" data-sv-confirm=\"Delete this search shortcut?\" aria-label=\"")
782+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, "\" data-sv-confirm=\"Delete this search shortcut?\" aria-label=\"")
734783 if templ_7745c5c3_Err != nil {
735784 return templ_7745c5c3_Err
736785 }
737- var templ_7745c5c3_Var38 string
738- templ_7745c5c3_Var38, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete search shortcut " + shortcut.Key)
786+ var templ_7745c5c3_Var41 string
787+ templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue("Delete search shortcut " + shortcut.Key)
739788 if templ_7745c5c3_Err != nil {
740- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 99, Col: 272}
789+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 107, Col: 272}
741790 }
742- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var38)
791+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
743792 if templ_7745c5c3_Err != nil {
744793 return templ_7745c5c3_Err
745794 }
746- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 48, "\">")
795+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, "\">")
747796 if templ_7745c5c3_Err != nil {
748797 return templ_7745c5c3_Err
749798 }
@@ -751,7 +800,7 @@ func SearchShortcutItem(shortcut searchShortcutRow) templ.Component {
751800 if templ_7745c5c3_Err != nil {
752801 return templ_7745c5c3_Err
753802 }
754- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 49, "</button></form></li>")
803+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, "</button></form></li>")
755804 if templ_7745c5c3_Err != nil {
756805 return templ_7745c5c3_Err
757806 }
@@ -775,178 +824,178 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
775824 }()
776825 }
777826 ctx = templ.InitializeContext(ctx)
778- templ_7745c5c3_Var39 := templ.GetChildren(ctx)
779- if templ_7745c5c3_Var39 == nil {
780- templ_7745c5c3_Var39 = templ.NopComponent
827+ templ_7745c5c3_Var42 := templ.GetChildren(ctx)
828+ if templ_7745c5c3_Var42 == nil {
829+ templ_7745c5c3_Var42 = templ.NopComponent
781830 }
782831 ctx = templ.ClearChildren(ctx)
783832 customProfile := performanceProfileSelected(config.Performance.Profile, "custom")
784- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 50, "<form class=\"sv-card-form sv-config-form\" data-sv-action=\"config.performance.save\" data-sv-performance-form><label><span>Profile</span><select name=\"profile\" data-sv-performance-profile><option value=\"default\"")
833+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, "<form class=\"sv-card-form sv-config-form\" data-sv-action=\"config.performance.save\" data-sv-performance-form><label><span>Profile</span><select name=\"profile\" data-sv-performance-profile><option value=\"default\"")
785834 if templ_7745c5c3_Err != nil {
786835 return templ_7745c5c3_Err
787836 }
788837 if performanceProfileSelected(config.Performance.Profile, "default") {
789- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 51, " selected")
838+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, " selected")
790839 if templ_7745c5c3_Err != nil {
791840 return templ_7745c5c3_Err
792841 }
793842 }
794- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 52, ">Default</option> <option value=\"balanced\"")
843+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, ">Default</option> <option value=\"balanced\"")
795844 if templ_7745c5c3_Err != nil {
796845 return templ_7745c5c3_Err
797846 }
798847 if performanceProfileSelected(config.Performance.Profile, "balanced") {
799- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 53, " selected")
848+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, " selected")
800849 if templ_7745c5c3_Err != nil {
801850 return templ_7745c5c3_Err
802851 }
803852 }
804- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 54, ">Balanced</option> <option value=\"lite\"")
853+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, ">Balanced</option> <option value=\"lite\"")
805854 if templ_7745c5c3_Err != nil {
806855 return templ_7745c5c3_Err
807856 }
808857 if performanceProfileSelected(config.Performance.Profile, "lite") {
809- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 55, " selected")
858+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, " selected")
810859 if templ_7745c5c3_Err != nil {
811860 return templ_7745c5c3_Err
812861 }
813862 }
814- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 56, ">Lite</option> <option value=\"max\"")
863+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, ">Lite</option> <option value=\"max\"")
815864 if templ_7745c5c3_Err != nil {
816865 return templ_7745c5c3_Err
817866 }
818867 if performanceProfileSelected(config.Performance.Profile, "max") {
819- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 57, " selected")
868+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " selected")
820869 if templ_7745c5c3_Err != nil {
821870 return templ_7745c5c3_Err
822871 }
823872 }
824- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 58, ">Max</option> <option value=\"custom\"")
873+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, ">Max</option> <option value=\"custom\"")
825874 if templ_7745c5c3_Err != nil {
826875 return templ_7745c5c3_Err
827876 }
828877 if performanceProfileSelected(config.Performance.Profile, "custom") {
829- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 59, " selected")
878+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, " selected")
830879 if templ_7745c5c3_Err != nil {
831880 return templ_7745c5c3_Err
832881 }
833882 }
834- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 60, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")
883+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, ">Custom</option></select></label><p class=\"sv-meta\">Choose Custom to edit individual performance values.</p><label><span>Skia CPU threads</span><input name=\"skia_cpu_threads\" type=\"number\" min=\"0\" max=\"8\" value=\"")
835884 if templ_7745c5c3_Err != nil {
836885 return templ_7745c5c3_Err
837886 }
838- var templ_7745c5c3_Var40 string
839- templ_7745c5c3_Var40, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
887+ var templ_7745c5c3_Var43 string
888+ templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.SkiaCPUThreads))
840889 if templ_7745c5c3_Err != nil {
841- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 115, Col: 164}
890+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 164}
842891 }
843- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var40)
892+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
844893 if templ_7745c5c3_Err != nil {
845894 return templ_7745c5c3_Err
846895 }
847- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 61, "\"")
896+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, "\"")
848897 if templ_7745c5c3_Err != nil {
849898 return templ_7745c5c3_Err
850899 }
851900 if !customProfile {
852- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 62, " disabled")
901+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, " disabled")
853902 if templ_7745c5c3_Err != nil {
854903 return templ_7745c5c3_Err
855904 }
856905 }
857- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 63, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")
906+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " data-sv-performance-custom></label> <label><span>Skia GPU threads</span><input name=\"skia_gpu_threads\" type=\"number\" min=\"-1\" max=\"8\" value=\"")
858907 if templ_7745c5c3_Err != nil {
859908 return templ_7745c5c3_Err
860909 }
861- var templ_7745c5c3_Var41 string
862- templ_7745c5c3_Var41, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
910+ var templ_7745c5c3_Var44 string
911+ templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.SkiaGPUThreads))
863912 if templ_7745c5c3_Err != nil {
864- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 116, Col: 165}
913+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 165}
865914 }
866- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var41)
915+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
867916 if templ_7745c5c3_Err != nil {
868917 return templ_7745c5c3_Err
869918 }
870- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 64, "\"")
919+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, "\"")
871920 if templ_7745c5c3_Err != nil {
872921 return templ_7745c5c3_Err
873922 }
874923 if !customProfile {
875- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 65, " disabled")
924+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, " disabled")
876925 if templ_7745c5c3_Err != nil {
877926 return templ_7745c5c3_Err
878927 }
879928 }
880- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 66, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")
929+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " data-sv-performance-custom></label> <label><span>Web process memory MB</span><input name=\"web_process_memory_mb\" type=\"number\" min=\"0\" max=\"16384\" value=\"")
881930 if templ_7745c5c3_Err != nil {
882931 return templ_7745c5c3_Err
883932 }
884- var templ_7745c5c3_Var42 string
885- templ_7745c5c3_Var42, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
933+ var templ_7745c5c3_Var45 string
934+ templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.WebProcessMemoryMB))
886935 if templ_7745c5c3_Err != nil {
887- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 117, Col: 182}
936+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 125, Col: 182}
888937 }
889- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var42)
938+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var45)
890939 if templ_7745c5c3_Err != nil {
891940 return templ_7745c5c3_Err
892941 }
893- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 67, "\"")
942+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, "\"")
894943 if templ_7745c5c3_Err != nil {
895944 return templ_7745c5c3_Err
896945 }
897946 if !customProfile {
898- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 68, " disabled")
947+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, " disabled")
899948 if templ_7745c5c3_Err != nil {
900949 return templ_7745c5c3_Err
901950 }
902951 }
903- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 69, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")
952+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " data-sv-performance-custom></label> <label><span>Network process memory MB</span><input name=\"network_process_memory_mb\" type=\"number\" min=\"0\" max=\"4096\" value=\"")
904953 if templ_7745c5c3_Err != nil {
905954 return templ_7745c5c3_Err
906955 }
907- var templ_7745c5c3_Var43 string
908- templ_7745c5c3_Var43, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
956+ var templ_7745c5c3_Var46 string
957+ templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.NetworkProcessMemoryMB))
909958 if templ_7745c5c3_Err != nil {
910- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 118, Col: 193}
959+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 126, Col: 193}
911960 }
912- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var43)
961+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var46)
913962 if templ_7745c5c3_Err != nil {
914963 return templ_7745c5c3_Err
915964 }
916- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 70, "\"")
965+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, "\"")
917966 if templ_7745c5c3_Err != nil {
918967 return templ_7745c5c3_Err
919968 }
920969 if !customProfile {
921- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 71, " disabled")
970+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, " disabled")
922971 if templ_7745c5c3_Err != nil {
923972 return templ_7745c5c3_Err
924973 }
925974 }
926- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 72, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")
975+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, " data-sv-performance-custom></label> <label><span>WebView pool prewarm</span><input name=\"webview_pool_prewarm\" type=\"number\" min=\"0\" max=\"20\" value=\"")
927976 if templ_7745c5c3_Err != nil {
928977 return templ_7745c5c3_Err
929978 }
930- var templ_7745c5c3_Var44 string
931- templ_7745c5c3_Var44, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
979+ var templ_7745c5c3_Var47 string
980+ templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.ResolveAttributeValue(formatConfigInt(config.Performance.Custom.WebViewPoolPrewarm))
932981 if templ_7745c5c3_Err != nil {
933- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 119, Col: 177}
982+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 127, Col: 177}
934983 }
935- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var44)
984+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var47)
936985 if templ_7745c5c3_Err != nil {
937986 return templ_7745c5c3_Err
938987 }
939- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 73, "\"")
988+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "\"")
940989 if templ_7745c5c3_Err != nil {
941990 return templ_7745c5c3_Err
942991 }
943992 if !customProfile {
944- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 74, " disabled")
993+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, " disabled")
945994 if templ_7745c5c3_Err != nil {
946995 return templ_7745c5c3_Err
947996 }
948997 }
949- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 75, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")
998+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, " data-sv-performance-custom></label> <button class=\"sv-button\" type=\"submit\">")
950999 if templ_7745c5c3_Err != nil {
9511000 return templ_7745c5c3_Err
9521001 }
@@ -954,69 +1003,69 @@ func PerformanceSettings(config dto.SystemviewConfigPayload) templ.Component {
9541003 if templ_7745c5c3_Err != nil {
9551004 return templ_7745c5c3_Err
9561005 }
957- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 76, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
1006+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "</button></form><div class=\"sv-stat-grid\"><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
9581007 if templ_7745c5c3_Err != nil {
9591008 return templ_7745c5c3_Err
9601009 }
961- var templ_7745c5c3_Var45 string
962- templ_7745c5c3_Var45, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
1010+ var templ_7745c5c3_Var48 string
1011+ templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.CPUThreads))
9631012 if templ_7745c5c3_Err != nil {
964- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 123, Col: 113}
1013+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 131, Col: 113}
9651014 }
966- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var45))
1015+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
9671016 if templ_7745c5c3_Err != nil {
9681017 return templ_7745c5c3_Err
9691018 }
970- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 77, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
1019+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "</span><span class=\"sv-stat-label\">CPU threads</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
9711020 if templ_7745c5c3_Err != nil {
9721021 return templ_7745c5c3_Err
9731022 }
974- var templ_7745c5c3_Var46 string
975- templ_7745c5c3_Var46, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
1023+ var templ_7745c5c3_Var49 string
1024+ templ_7745c5c3_Var49, templ_7745c5c3_Err = templ.JoinStringErrs(formatConfigInt(config.Performance.Hardware.TotalRAMMB))
9761025 if templ_7745c5c3_Err != nil {
977- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 124, Col: 113}
1026+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 132, Col: 113}
9781027 }
979- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var46))
1028+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var49))
9801029 if templ_7745c5c3_Err != nil {
9811030 return templ_7745c5c3_Err
9821031 }
983- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 78, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
1032+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "</span><span class=\"sv-stat-label\">RAM MB</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
9841033 if templ_7745c5c3_Err != nil {
9851034 return templ_7745c5c3_Err
9861035 }
987- var templ_7745c5c3_Var47 string
988- templ_7745c5c3_Var47, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
1036+ var templ_7745c5c3_Var50 string
1037+ templ_7745c5c3_Var50, templ_7745c5c3_Err = templ.JoinStringErrs(config.Performance.Hardware.GPUName)
9891038 if templ_7745c5c3_Err != nil {
990- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 125, Col: 93}
1039+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 133, Col: 93}
9911040 }
992- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var47))
1041+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var50))
9931042 if templ_7745c5c3_Err != nil {
9941043 return templ_7745c5c3_Err
9951044 }
996- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 79, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
1045+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</span><span class=\"sv-stat-label\">GPU</span></div><div class=\"sv-stat-card\"><span class=\"sv-stat-value\">")
9971046 if templ_7745c5c3_Err != nil {
9981047 return templ_7745c5c3_Err
9991048 }
1000- var templ_7745c5c3_Var48 string
1001- templ_7745c5c3_Var48, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
1049+ var templ_7745c5c3_Var51 string
1050+ templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(config.EngineType)
10021051 if templ_7745c5c3_Err != nil {
1003- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 126, Col: 75}
1052+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 134, Col: 75}
10041053 }
1005- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var48))
1054+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
10061055 if templ_7745c5c3_Err != nil {
10071056 return templ_7745c5c3_Err
10081057 }
1009- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 80, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")
1058+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "</span><span class=\"sv-stat-label\">Engine</span></div></div>")
10101059 if templ_7745c5c3_Err != nil {
10111060 return templ_7745c5c3_Err
10121061 }
10131062 if !engineIsWebKit(config.EngineType) {
1014- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 81, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")
1063+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "<p class=\"sv-meta\">Performance tuning applies only to the WebKit engine.</p>")
10151064 if templ_7745c5c3_Err != nil {
10161065 return templ_7745c5c3_Err
10171066 }
10181067 } else {
1019- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 82, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")
1068+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"sv-meta\">Performance profile changes may require restarting the browser to affect existing engine processes.</p>")
10201069 if templ_7745c5c3_Err != nil {
10211070 return templ_7745c5c3_Err
10221071 }
@@ -1041,9 +1090,9 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
10411090 }()
10421091 }
10431092 ctx = templ.InitializeContext(ctx)
1044- templ_7745c5c3_Var49 := templ.GetChildren(ctx)
1045- if templ_7745c5c3_Var49 == nil {
1046- templ_7745c5c3_Var49 = templ.NopComponent
1093+ templ_7745c5c3_Var52 := templ.GetChildren(ctx)
1094+ if templ_7745c5c3_Var52 == nil {
1095+ templ_7745c5c3_Var52 = templ.NopComponent
10471096 }
10481097 ctx = templ.ClearChildren(ctx)
10491098 groups := configKeybindingGroups(keybindings)
@@ -1058,7 +1107,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
10581107 return templ_7745c5c3_Err
10591108 }
10601109 } else {
1061- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 83, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")
1110+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "<div class=\"sv-button-row\"><button class=\"sv-button sv-button-secondary sv-button-danger\" type=\"button\" data-sv-action=\"config.keybinding.resetAll\" data-sv-confirm=\"Reset all keybindings to defaults?\">")
10621111 if templ_7745c5c3_Err != nil {
10631112 return templ_7745c5c3_Err
10641113 }
@@ -1066,7 +1115,7 @@ func ConfigKeybindings(keybindings port.KeybindingsConfig) templ.Component {
10661115 if templ_7745c5c3_Err != nil {
10671116 return templ_7745c5c3_Err
10681117 }
1069- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 84, "</button></div>")
1118+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</button></div>")
10701119 if templ_7745c5c3_Err != nil {
10711120 return templ_7745c5c3_Err
10721121 }
@@ -1097,48 +1146,48 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
10971146 }()
10981147 }
10991148 ctx = templ.InitializeContext(ctx)
1100- templ_7745c5c3_Var50 := templ.GetChildren(ctx)
1101- if templ_7745c5c3_Var50 == nil {
1102- templ_7745c5c3_Var50 = templ.NopComponent
1149+ templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1150+ if templ_7745c5c3_Var53 == nil {
1151+ templ_7745c5c3_Var53 = templ.NopComponent
11031152 }
11041153 ctx = templ.ClearChildren(ctx)
1105- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 85, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")
1154+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<div class=\"sv-group\"><div class=\"sv-group-header\"><div><h4>")
11061155 if templ_7745c5c3_Err != nil {
11071156 return templ_7745c5c3_Err
11081157 }
1109- var templ_7745c5c3_Var51 string
1110- templ_7745c5c3_Var51, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
1158+ var templ_7745c5c3_Var54 string
1159+ templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupTitle(group))
11111160 if templ_7745c5c3_Err != nil {
1112- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 155, Col: 37}
1161+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 163, Col: 37}
11131162 }
1114- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var51))
1163+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
11151164 if templ_7745c5c3_Err != nil {
11161165 return templ_7745c5c3_Err
11171166 }
1118- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 86, "</h4>")
1167+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</h4>")
11191168 if templ_7745c5c3_Err != nil {
11201169 return templ_7745c5c3_Err
11211170 }
11221171 if keybindingGroupMeta(group) != "" {
1123- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 87, "<p class=\"sv-meta\">")
1172+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "<p class=\"sv-meta\">")
11241173 if templ_7745c5c3_Err != nil {
11251174 return templ_7745c5c3_Err
11261175 }
1127- var templ_7745c5c3_Var52 string
1128- templ_7745c5c3_Var52, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
1176+ var templ_7745c5c3_Var55 string
1177+ templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingGroupMeta(group))
11291178 if templ_7745c5c3_Err != nil {
1130- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 157, Col: 52}
1179+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 165, Col: 52}
11311180 }
1132- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var52))
1181+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
11331182 if templ_7745c5c3_Err != nil {
11341183 return templ_7745c5c3_Err
11351184 }
1136- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 88, "</p>")
1185+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "</p>")
11371186 if templ_7745c5c3_Err != nil {
11381187 return templ_7745c5c3_Err
11391188 }
11401189 }
1141- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 89, "</div></div><div class=\"sv-group-body\">")
1190+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</div></div><div class=\"sv-group-body\">")
11421191 if templ_7745c5c3_Err != nil {
11431192 return templ_7745c5c3_Err
11441193 }
@@ -1148,7 +1197,7 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
11481197 return templ_7745c5c3_Err
11491198 }
11501199 } else {
1151- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 90, "<ul class=\"sv-list\">")
1200+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "<ul class=\"sv-list\">")
11521201 if templ_7745c5c3_Err != nil {
11531202 return templ_7745c5c3_Err
11541203 }
@@ -1158,12 +1207,12 @@ func ConfigKeybindingGroup(group renderedKeybindingGroup) templ.Component {
11581207 return templ_7745c5c3_Err
11591208 }
11601209 }
1161- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 91, "</ul>")
1210+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "</ul>")
11621211 if templ_7745c5c3_Err != nil {
11631212 return templ_7745c5c3_Err
11641213 }
11651214 }
1166- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 92, "</div></div>")
1215+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</div></div>")
11671216 if templ_7745c5c3_Err != nil {
11681217 return templ_7745c5c3_Err
11691218 }
@@ -1187,38 +1236,38 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
11871236 }()
11881237 }
11891238 ctx = templ.InitializeContext(ctx)
1190- templ_7745c5c3_Var53 := templ.GetChildren(ctx)
1191- if templ_7745c5c3_Var53 == nil {
1192- templ_7745c5c3_Var53 = templ.NopComponent
1239+ templ_7745c5c3_Var56 := templ.GetChildren(ctx)
1240+ if templ_7745c5c3_Var56 == nil {
1241+ templ_7745c5c3_Var56 = templ.NopComponent
11931242 }
11941243 ctx = templ.ClearChildren(ctx)
1195- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 93, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")
1244+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<li class=\"sv-list-row\"><div class=\"sv-keybinding-row\"><div><strong>")
11961245 if templ_7745c5c3_Err != nil {
11971246 return templ_7745c5c3_Err
11981247 }
1199- var templ_7745c5c3_Var54 string
1200- templ_7745c5c3_Var54, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
1248+ var templ_7745c5c3_Var57 string
1249+ templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingDescription(binding))
12011250 if templ_7745c5c3_Err != nil {
1202- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 179, Col: 44}
1251+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 187, Col: 44}
12031252 }
1204- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var54))
1253+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var57))
12051254 if templ_7745c5c3_Err != nil {
12061255 return templ_7745c5c3_Err
12071256 }
1208- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 94, "</strong><p class=\"sv-meta\">")
1257+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</strong><p class=\"sv-meta\">")
12091258 if templ_7745c5c3_Err != nil {
12101259 return templ_7745c5c3_Err
12111260 }
1212- var templ_7745c5c3_Var55 string
1213- templ_7745c5c3_Var55, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
1261+ var templ_7745c5c3_Var58 string
1262+ templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingAction(binding))
12141263 if templ_7745c5c3_Err != nil {
1215- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 180, Col: 50}
1264+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 188, Col: 50}
12161265 }
1217- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var55))
1266+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var58))
12181267 if templ_7745c5c3_Err != nil {
12191268 return templ_7745c5c3_Err
12201269 }
1221- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 95, "</p><div class=\"sv-binding-keys\">")
1270+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "</p><div class=\"sv-binding-keys\">")
12221271 if templ_7745c5c3_Err != nil {
12231272 return templ_7745c5c3_Err
12241273 }
@@ -1226,25 +1275,25 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
12261275 if templ_7745c5c3_Err != nil {
12271276 return templ_7745c5c3_Err
12281277 }
1229- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 96, "<span class=\"sv-badge\">")
1278+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "<span class=\"sv-badge\">")
12301279 if templ_7745c5c3_Err != nil {
12311280 return templ_7745c5c3_Err
12321281 }
1233- var templ_7745c5c3_Var56 string
1234- templ_7745c5c3_Var56, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
1282+ var templ_7745c5c3_Var59 string
1283+ templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.JoinStringErrs(keybindingStatus(binding))
12351284 if templ_7745c5c3_Err != nil {
1236- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 183, Col: 55}
1285+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 55}
12371286 }
1238- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var56))
1287+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ.EscapeString(templ_7745c5c3_Var59))
12391288 if templ_7745c5c3_Err != nil {
12401289 return templ_7745c5c3_Err
12411290 }
1242- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 97, "</span> ")
1291+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "</span> ")
12431292 if templ_7745c5c3_Err != nil {
12441293 return templ_7745c5c3_Err
12451294 }
12461295 if keybindingIsCustom(binding) && len(binding.DefaultKeys) > 0 {
1247- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 98, "<span class=\"sv-meta\">default:</span>")
1296+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "<span class=\"sv-meta\">default:</span>")
12481297 if templ_7745c5c3_Err != nil {
12491298 return templ_7745c5c3_Err
12501299 }
@@ -1253,46 +1302,46 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
12531302 return templ_7745c5c3_Err
12541303 }
12551304 }
1256- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 99, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")
1305+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "</div></div><form class=\"sv-inline-form\" data-sv-action=\"config.keybinding.set\"><input type=\"hidden\" name=\"mode\" value=\"")
12571306 if templ_7745c5c3_Err != nil {
12581307 return templ_7745c5c3_Err
12591308 }
1260- var templ_7745c5c3_Var57 string
1261- templ_7745c5c3_Var57, templ_7745c5c3_Err = templ.ResolveAttributeValue(group.Mode)
1309+ var templ_7745c5c3_Var60 string
1310+ templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(group.Mode)
12621311 if templ_7745c5c3_Err != nil {
1263- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 191, Col: 55}
1312+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 199, Col: 55}
12641313 }
1265- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var57)
1314+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
12661315 if templ_7745c5c3_Err != nil {
12671316 return templ_7745c5c3_Err
12681317 }
1269- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 100, "\"> <input type=\"hidden\" name=\"action\" value=\"")
1318+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\"> <input type=\"hidden\" name=\"action\" value=\"")
12701319 if templ_7745c5c3_Err != nil {
12711320 return templ_7745c5c3_Err
12721321 }
1273- var templ_7745c5c3_Var58 string
1274- templ_7745c5c3_Var58, templ_7745c5c3_Err = templ.ResolveAttributeValue(binding.Action)
1322+ var templ_7745c5c3_Var61 string
1323+ templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(binding.Action)
12751324 if templ_7745c5c3_Err != nil {
1276- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 192, Col: 61}
1325+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 200, Col: 61}
12771326 }
1278- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var58)
1327+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
12791328 if templ_7745c5c3_Err != nil {
12801329 return templ_7745c5c3_Err
12811330 }
1282- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 101, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")
1331+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "\"> <label><span>Keys</span><input name=\"keys\" type=\"text\" value=\"")
12831332 if templ_7745c5c3_Err != nil {
12841333 return templ_7745c5c3_Err
12851334 }
1286- var templ_7745c5c3_Var59 string
1287- templ_7745c5c3_Var59, templ_7745c5c3_Err = templ.ResolveAttributeValue(keybindingKeysValue(binding.Keys))
1335+ var templ_7745c5c3_Var62 string
1336+ templ_7745c5c3_Var62, templ_7745c5c3_Err = templ.ResolveAttributeValue(keybindingKeysValue(binding.Keys))
12881337 if templ_7745c5c3_Err != nil {
1289- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 193, Col: 100}
1338+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 201, Col: 100}
12901339 }
1291- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var59)
1340+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var62)
12921341 if templ_7745c5c3_Err != nil {
12931342 return templ_7745c5c3_Err
12941343 }
1295- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 102, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
1344+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 107, "\" placeholder=\"ctrl+h, ctrl+shift+h\" required></label> <button class=\"sv-button sv-button-secondary\" type=\"submit\">")
12961345 if templ_7745c5c3_Err != nil {
12971346 return templ_7745c5c3_Err
12981347 }
@@ -1300,33 +1349,33 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
13001349 if templ_7745c5c3_Err != nil {
13011350 return templ_7745c5c3_Err
13021351 }
1303- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 103, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")
1352+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 108, "</button> <button class=\"sv-button sv-button-secondary\" type=\"button\" data-sv-action=\"config.keybinding.reset\" data-mode=\"")
13041353 if templ_7745c5c3_Err != nil {
13051354 return templ_7745c5c3_Err
13061355 }
1307- var templ_7745c5c3_Var60 string
1308- templ_7745c5c3_Var60, templ_7745c5c3_Err = templ.ResolveAttributeValue(group.Mode)
1356+ var templ_7745c5c3_Var63 string
1357+ templ_7745c5c3_Var63, templ_7745c5c3_Err = templ.ResolveAttributeValue(group.Mode)
13091358 if templ_7745c5c3_Err != nil {
1310- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 195, Col: 127}
1359+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 203, Col: 127}
13111360 }
1312- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var60)
1361+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var63)
13131362 if templ_7745c5c3_Err != nil {
13141363 return templ_7745c5c3_Err
13151364 }
1316- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 104, "\" data-action=\"")
1365+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 109, "\" data-action=\"")
13171366 if templ_7745c5c3_Err != nil {
13181367 return templ_7745c5c3_Err
13191368 }
1320- var templ_7745c5c3_Var61 string
1321- templ_7745c5c3_Var61, templ_7745c5c3_Err = templ.ResolveAttributeValue(binding.Action)
1369+ var templ_7745c5c3_Var64 string
1370+ templ_7745c5c3_Var64, templ_7745c5c3_Err = templ.ResolveAttributeValue(binding.Action)
13221371 if templ_7745c5c3_Err != nil {
1323- return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 195, Col: 158}
1372+ return templ.Error{Err: templ_7745c5c3_Err, FileName: `config.templ`, Line: 203, Col: 158}
13241373 }
1325- _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var61)
1374+ _, templ_7745c5c3_Err = templ_7745c5c3_Buffer.WriteString(templ_7745c5c3_Var64)
13261375 if templ_7745c5c3_Err != nil {
13271376 return templ_7745c5c3_Err
13281377 }
1329- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 105, "\">")
1378+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 110, "\">")
13301379 if templ_7745c5c3_Err != nil {
13311380 return templ_7745c5c3_Err
13321381 }
@@ -1334,7 +1383,7 @@ func ConfigKeybinding(group renderedKeybindingGroup, binding renderedKeybinding)
13341383 if templ_7745c5c3_Err != nil {
13351384 return templ_7745c5c3_Err
13361385 }
1337- templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 106, "</button></form></div></li>")
1386+ templ_7745c5c3_Err = templruntime.WriteString(templ_7745c5c3_Buffer, 111, "</button></form></div></li>")
13381387 if templ_7745c5c3_Err != nil {
13391388 return templ_7745c5c3_Err
13401389 }
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