feat: selectable branded GoSentry color theme

Add a custom Fyne theme derived from the logo and app icon (deep teal
primary, amber accent, branded job-status colors) with light and dark
variants, and let the user choose between it and Fyne's default theme
from Settings. The dark variant uses deep-teal surfaces to echo the app
icon.

The choice is persisted as a new Config.Theme field ("default" /
"gosentry"), applied at startup before the first frame and live-previewed
when picked in Settings. Empty/legacy configs normalize to the default
theme so existing installs keep the original look.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
mixeme
2026-07-26 10:12:28 +03:00
parent eedae3b9f0
commit 84212764e4
8 changed files with 257 additions and 1 deletions
+83
View File
@@ -0,0 +1,83 @@
package ui
import (
"image/color"
"testing"
"gitea.mixdep.ru/mix/gosentry/src/domain"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/theme"
)
// The GoSentry theme must expose the brand colors on the semantically correct
// ColorNames in each variant. These are the touchpoints a user actually sees —
// the primary color drives buttons and the active tab, focus drives the accent —
// so they are worth pinning against accidental edits to the palette maps.
func TestGoSentryThemeBrandColors(t *testing.T) {
th := newGoSentryTheme()
cases := []struct {
name string
color fyne.ThemeColorName
variant int
want color.Color
}{
{"light primary is teal", theme.ColorNamePrimary, 0, brandTeal},
{"light focus is amber", theme.ColorNameFocus, 0, brandAmber},
{"dark primary is lifted teal", theme.ColorNamePrimary, 1, brandTealLight},
{"dark focus is amber", theme.ColorNameFocus, 1, brandAmber},
}
for _, tc := range cases {
variant := theme.VariantLight
if tc.variant == 1 {
variant = theme.VariantDark
}
got := th.Color(tc.color, variant)
if got != tc.want {
t.Errorf("%s: Color(%s) = %v, want %v", tc.name, tc.color, got, tc.want)
}
}
}
// Unbranded color names must fall through to the base theme rather than render as
// zero-value (transparent) colors, so the theme only recolors what it intends to.
func TestGoSentryThemeDelegatesUnbrandedColors(t *testing.T) {
th := newGoSentryTheme()
base := theme.DefaultTheme()
// ScrollBar is not in either override map, so it must match the base theme.
got := th.Color(theme.ColorNameScrollBar, theme.VariantDark)
want := base.Color(theme.ColorNameScrollBar, theme.VariantDark)
if got != want {
t.Errorf("unbranded ColorNameScrollBar = %v, want base %v", got, want)
}
}
// themeFor maps the stored choice to the right theme: the GoSentry choice yields
// the branded teal primary; every other value (including the empty legacy value)
// yields the default theme, whose primary is not the brand teal.
func TestThemeForChoice(t *testing.T) {
gosentry := themeFor(domain.ThemeGoSentry)
if got := gosentry.Color(theme.ColorNamePrimary, theme.VariantLight); got != brandTeal {
t.Errorf("themeFor(gosentry) primary = %v, want brand teal %v", got, brandTeal)
}
for _, choice := range []domain.Theme{domain.ThemeDefault, ""} {
def := themeFor(choice)
if got := def.Color(theme.ColorNamePrimary, theme.VariantLight); got == brandTeal {
t.Errorf("themeFor(%q) should not use the brand teal primary", choice)
}
}
}
// The dropdown label helpers must round-trip, and the empty/legacy value must map
// to the Default label so the select never shows a blank option.
func TestThemeLabelRoundTrip(t *testing.T) {
if got := themeFromLabel(themeLabel(domain.ThemeGoSentry)); got != domain.ThemeGoSentry {
t.Errorf("round-trip gosentry = %q", got)
}
if got := themeFromLabel(themeLabel(domain.ThemeDefault)); got != domain.ThemeDefault {
t.Errorf("round-trip default = %q", got)
}
if got := themeLabel(""); got != themeLabelDefault {
t.Errorf("empty theme label = %q, want %q", got, themeLabelDefault)
}
}