From 9a1420563d710f7f6a08a2270e28cf66bb5d12c1 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Fri, 19 Jun 2026 17:41:37 -0700 Subject: [PATCH 1/3] Let embedders register built-in themes via RegisterBuiltinThemes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add styles.RegisterBuiltinThemes(fs.FS), which adds an embedder-provided theme source (e.g. an embed.FS of themes/*.yaml) to the built-in theme set. Registered themes flow through the exact list/load/merge-onto-default pipeline cagent already uses for its bundled themes, so they are first-class: they appear in ListThemeRefs and the /theme picker, resolve via LoadTheme/ApplyThemeRef, and persist as the user's selection — with no changes to the picker or handlers. Registered sources take precedence over the bundled themes, so an embedder can override a built-in and, in particular, mask "default" with their own (still merged onto cagent's pristine DefaultTheme() base). This lets a downstream CLI/TUI ship its own default and theme set without forking, while cagent core stays domain-agnostic. --- pkg/tui/styles/testdata/themes/embedder.yaml | 4 + pkg/tui/styles/theme.go | 142 +++++++++++++--- pkg/tui/styles/theme_test.go | 169 +++++++++++++++++++ 3 files changed, 292 insertions(+), 23 deletions(-) create mode 100644 pkg/tui/styles/testdata/themes/embedder.yaml diff --git a/pkg/tui/styles/testdata/themes/embedder.yaml b/pkg/tui/styles/testdata/themes/embedder.yaml new file mode 100644 index 000000000..c0520f194 --- /dev/null +++ b/pkg/tui/styles/testdata/themes/embedder.yaml @@ -0,0 +1,4 @@ +version: 1 +name: "Embedder" +colors: + accent: "#FF00AA" diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index c718b839c..e995476bd 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -3,6 +3,7 @@ package styles import ( "embed" "fmt" + "io/fs" "os" "path/filepath" "slices" @@ -38,8 +39,101 @@ var ( builtinRefsCache []string builtinRefsCacheOK bool builtinRefsCacheMu sync.Mutex + + // extraThemeFSes holds additional theme sources contributed by embedders via + // RegisterBuiltinThemes. They are consulted alongside the bundled themes. + extraThemeFSes []fs.FS + extraThemeFSesMu sync.RWMutex ) +// RegisterBuiltinThemes adds an additional source of built-in themes from fsys. +// It must contain theme files at "themes/.yaml" (or .yml) — the same layout +// and partial-override-onto-default semantics as cagent's bundled themes — so an +// embedder typically passes an embed.FS declared with //go:embed themes/*.yaml. +// +// Registered themes are treated exactly like cagent's own built-ins: they appear +// in ListThemeRefs and the theme picker, resolve via LoadTheme/ApplyThemeRef, and +// can be persisted as the user's selection. The reserved "default" ref is ignored +// (it always resolves to cagent's bundled default), and a ref that collides with +// an existing built-in keeps the existing one. +// +// Call this at startup, before applying any persisted theme, so that a persisted +// selection naming a registered theme resolves. +func RegisterBuiltinThemes(fsys fs.FS) error { + if fsys == nil { + return fmt.Errorf("register built-in themes: nil fs") + } + // Surface an unreadable source eagerly rather than at picker time. + if _, err := fs.ReadDir(fsys, "themes"); err != nil { + return fmt.Errorf("register built-in themes: %w", err) + } + + extraThemeFSesMu.Lock() + extraThemeFSes = append(extraThemeFSes, fsys) + extraThemeFSesMu.Unlock() + + // Newly registered themes must appear in subsequent listings. + builtinRefsCacheMu.Lock() + builtinRefsCacheOK = false + builtinRefsCacheMu.Unlock() + + return nil +} + +// registeredThemeFSes returns a snapshot of the embedder-contributed theme +// sources. +func registeredThemeFSes() []fs.FS { + extraThemeFSesMu.RLock() + defer extraThemeFSesMu.RUnlock() + return extraThemeFSes +} + +// readThemeRefsFromFS lists the theme refs (file basenames without extension) +// under the "themes" directory of fsys. +func readThemeRefsFromFS(fsys fs.FS) ([]string, error) { + entries, err := fs.ReadDir(fsys, "themes") + if err != nil { + return nil, err + } + var refs []string + for _, entry := range entries { + if entry.IsDir() { + continue + } + name := entry.Name() + if before, ok := strings.CutSuffix(name, ".yaml"); ok { + refs = append(refs, before) + } else if before, ok := strings.CutSuffix(name, ".yml"); ok { + refs = append(refs, before) + } + } + return refs, nil +} + +// readThemeData returns the raw YAML for ref from fsys, trying .yaml then .yml. +func readThemeData(fsys fs.FS, ref string) ([]byte, bool) { + if data, err := fs.ReadFile(fsys, "themes/"+ref+".yaml"); err == nil { + return data, true + } + if data, err := fs.ReadFile(fsys, "themes/"+ref+".yml"); err == nil { + return data, true + } + return nil, false +} + +// readRegisteredThemeData returns the raw YAML for ref from the first registered +// source that provides it. Registered sources take precedence over cagent's +// bundled themes, so an embedder can override a built-in — including masking +// "default" with their own. +func readRegisteredThemeData(ref string) ([]byte, bool) { + for _, fsys := range registeredThemeFSes() { + if data, ok := readThemeData(fsys, ref); ok { + return data, true + } + } + return nil, false +} + // InvalidateThemeCache clears the theme cache for a specific ref, or all if ref is empty. // This is primarily for testing; the cache is mtime-aware so it auto-invalidates on file changes. func InvalidateThemeCache(ref string) { @@ -267,23 +361,28 @@ func listBuiltinThemeRefs() ([]string, error) { return builtinRefsCache, nil } - var refs []string - - entries, err := builtinThemes.ReadDir("themes") + refs, err := readThemeRefsFromFS(builtinThemes) if err != nil { return nil, fmt.Errorf("reading embedded themes directory: %w", err) } - for _, entry := range entries { - if entry.IsDir() { - continue + // Append themes contributed by embedders, skipping the reserved "default" + // ref and any name that collides with an existing built-in. + seen := make(map[string]bool, len(refs)) + for _, r := range refs { + seen[r] = true + } + for _, fsys := range registeredThemeFSes() { + extraRefs, err := readThemeRefsFromFS(fsys) + if err != nil { + return nil, fmt.Errorf("reading registered themes: %w", err) } - name := entry.Name() - // Accept .yaml and .yml files - if before, ok := strings.CutSuffix(name, ".yaml"); ok { - refs = append(refs, before) - } else if before, ok := strings.CutSuffix(name, ".yml"); ok { - refs = append(refs, before) + for _, r := range extraRefs { + if r == DefaultThemeRef || seen[r] { + continue + } + seen[r] = true + refs = append(refs, r) } } @@ -524,18 +623,15 @@ func validateThemeRef(ref string) error { func loadBuiltinTheme(ref string) (*Theme, error) { base := DefaultTheme() - // Try .yaml first, then .yml - var data []byte - var err error - - yamlPath := "themes/" + ref + ".yaml" - ymlPath := "themes/" + ref + ".yml" - - data, err = builtinThemes.ReadFile(yamlPath) - if err != nil { - data, err = builtinThemes.ReadFile(ymlPath) + // Prefer embedder-registered sources over cagent's bundled themes, so an + // embedder can override a built-in — including masking "default" with their + // own. The override is still merged onto the bundled DefaultTheme() base, so + // a registered theme only needs to specify the fields it changes. + data, ok := readRegisteredThemeData(ref) + if !ok { + data, ok = readThemeData(builtinThemes, ref) } - if err != nil { + if !ok { return nil, fmt.Errorf("built-in theme %q not found", ref) } diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index 7fa40c745..092d17999 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -1,16 +1,185 @@ package styles import ( + "embed" + "io/fs" "os" "path/filepath" "reflect" "testing" + "testing/fstest" "github.com/goccy/go-yaml" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) +// embedderThemes mirrors how a downstream embedder ships themes: a //go:embed +// of a themes/ directory handed to RegisterBuiltinThemes. Here the files live +// under testdata/, so the test re-roots the FS with fs.Sub; an embedder that +// embeds at themes/*.yaml would pass its embed.FS directly. +// +//go:embed testdata/themes/*.yaml +var embedderThemes embed.FS + +// resetThemes clears embedder-registered theme sources and the theme caches +// before and after a test, so tests that register themes stay isolated. +func resetThemes(t *testing.T) { + t.Helper() + reset := func() { + extraThemeFSesMu.Lock() + extraThemeFSes = nil + extraThemeFSesMu.Unlock() + + builtinRefsCacheMu.Lock() + builtinRefsCacheOK = false + builtinRefsCache = nil + builtinRefsCacheMu.Unlock() + + InvalidateThemeCache("") + } + reset() + t.Cleanup(reset) +} + +// TestRegisterBuiltinThemes_EmbedderFlow exercises the full embedder loop: +// register a theme from a real embed.FS, then discover, load, and apply it the +// way a downstream CLI/TUI would. +func TestRegisterBuiltinThemes_EmbedderFlow(t *testing.T) { + resetThemes(t) + original := CurrentTheme() + t.Cleanup(func() { ApplyTheme(original) }) + + themesFS, err := fs.Sub(embedderThemes, "testdata") + require.NoError(t, err) + require.NoError(t, RegisterBuiltinThemes(themesFS)) + + // The registered theme is discoverable and classified like a built-in. + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "embedder") + assert.True(t, IsBuiltinTheme("embedder")) + + // It applies via the embedder entry point, merged onto the default theme so + // unspecified fields are inherited. + applied := ApplyThemeRef("embedder") + require.NotNil(t, applied) + assert.Equal(t, "embedder", applied.Ref) + assert.Equal(t, "Embedder", applied.Name) + assert.Equal(t, "#FF00AA", applied.Colors.Accent) + assert.Equal(t, DefaultTheme().Colors.Background, applied.Colors.Background) + assert.Equal(t, "embedder", CurrentTheme().Ref) +} + +// TestRegisterBuiltinThemes covers core registration: a registered theme is +// listed, classified as built-in, and loads merged onto the default theme. +func TestRegisterBuiltinThemes(t *testing.T) { + resetThemes(t) + + def := DefaultTheme() + + src := fstest.MapFS{ + "themes/branded.yaml": &fstest.MapFile{ + Data: []byte("name: Branded\ncolors:\n accent: \"#FF0000\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "branded") + assert.True(t, IsBuiltinTheme("branded")) + + theme, err := LoadTheme("branded") + require.NoError(t, err) + assert.Equal(t, "Branded", theme.Name) + assert.Equal(t, "branded", theme.Ref) + assert.Equal(t, "#FF0000", theme.Colors.Accent) + assert.Equal(t, def.Colors.Background, theme.Colors.Background) + assert.Equal(t, def.Markdown.Heading, theme.Markdown.Heading) +} + +// TestRegisterBuiltinThemes_MultipleSources verifies the built-in set aggregates +// across more than one registered source, and that the first-registered source +// wins a name collision between two registered sources. +func TestRegisterBuiltinThemes_MultipleSources(t *testing.T) { + resetThemes(t) + + first := fstest.MapFS{ + "themes/alpha.yaml": &fstest.MapFile{Data: []byte("name: Alpha\n")}, + "themes/shared.yaml": &fstest.MapFile{Data: []byte("name: First\n")}, + } + second := fstest.MapFS{ + "themes/beta.yaml": &fstest.MapFile{Data: []byte("name: Beta\n")}, + "themes/shared.yaml": &fstest.MapFile{Data: []byte("name: Second\n")}, + } + require.NoError(t, RegisterBuiltinThemes(first)) + require.NoError(t, RegisterBuiltinThemes(second)) + + refs, err := ListThemeRefs() + require.NoError(t, err) + assert.Contains(t, refs, "alpha") + assert.Contains(t, refs, "beta") + + // Earlier registration wins a collision between two registered sources. + shared, err := LoadTheme("shared") + require.NoError(t, err) + assert.Equal(t, "First", shared.Name) +} + +// TestRegisterBuiltinThemes_OverridesBuiltin verifies a registered source takes +// precedence over a bundled theme of the same name. +func TestRegisterBuiltinThemes_OverridesBuiltin(t *testing.T) { + resetThemes(t) + + src := fstest.MapFS{ + "themes/nord.yaml": &fstest.MapFile{ + Data: []byte("name: NotNord\ncolors:\n accent: \"#123456\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + got, err := LoadTheme("nord") + require.NoError(t, err) + assert.Equal(t, "#123456", got.Colors.Accent) + assert.Equal(t, "NotNord", got.Name) +} + +// TestRegisterBuiltinThemes_MasksDefault verifies an embedder can replace the +// "default" theme; the override merges onto cagent's pristine default base. +func TestRegisterBuiltinThemes_MasksDefault(t *testing.T) { + resetThemes(t) + + cagentDefault := DefaultTheme() + + src := fstest.MapFS{ + "themes/default.yaml": &fstest.MapFile{ + Data: []byte("name: Branded Default\ncolors:\n accent: \"#ABCDEF\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + got, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + assert.Equal(t, "#ABCDEF", got.Colors.Accent) + assert.Equal(t, "Branded Default", got.Name) + assert.Equal(t, cagentDefault.Colors.Background, got.Colors.Background) + + // DefaultTheme() itself remains the pristine merge base. + assert.Equal(t, "Default", DefaultTheme().Name) +} + +// TestRegisterBuiltinThemes_Errors covers eager validation of the source. +func TestRegisterBuiltinThemes_Errors(t *testing.T) { + resetThemes(t) + + assert.Error(t, RegisterBuiltinThemes(nil)) + // A source without a "themes" directory is rejected eagerly. + assert.Error(t, RegisterBuiltinThemes(fstest.MapFS{ + "other/x.yaml": &fstest.MapFile{Data: []byte("{}")}, + })) +} + func TestDefaultThemeRef(t *testing.T) { t.Parallel() From 7cad5565236f3011d2f0eabbae144f1d827e191a Mon Sep 17 00:00:00 2001 From: Sayt-0 Date: Sat, 20 Jun 2026 12:51:57 +0200 Subject: [PATCH 2/3] fix: address review on RegisterBuiltinThemes - lint: use errors.New for the static nil-fs message (perfsprint) and require.Error for the error assertions (testifylint) - docs: the godoc stated the opposite of the implemented behavior. Registered sources take precedence over bundled themes and can mask "default", which is what the PR description and the tests already assert. Rewrite the godoc to match. - bug: RegisterBuiltinThemes invalidated only the refs-list cache, not the parsed-theme cache. Built-in cache entries are treated as permanently valid, so an override of a built-in (or "default") that was loaded before registration was silently ignored. Invalidate the theme cache on registration and add a regression test. --- pkg/tui/styles/theme.go | 19 ++++++++++++---- pkg/tui/styles/theme_test.go | 42 ++++++++++++++++++++++++++++++++++-- 2 files changed, 55 insertions(+), 6 deletions(-) diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index e995476bd..d83008c8f 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -2,6 +2,7 @@ package styles import ( "embed" + "errors" "fmt" "io/fs" "os" @@ -53,15 +54,18 @@ var ( // // Registered themes are treated exactly like cagent's own built-ins: they appear // in ListThemeRefs and the theme picker, resolve via LoadTheme/ApplyThemeRef, and -// can be persisted as the user's selection. The reserved "default" ref is ignored -// (it always resolves to cagent's bundled default), and a ref that collides with -// an existing built-in keeps the existing one. +// can be persisted as the user's selection. Registered sources take precedence +// over the bundled themes, so a registered ref overrides the bundled theme of the +// same name — including masking "default" with the embedder's own. An override is +// still merged onto cagent's pristine DefaultTheme() base, so a registered theme +// only needs to specify the fields it changes; DefaultTheme() itself stays the +// bundled merge base, and each name is listed once. // // Call this at startup, before applying any persisted theme, so that a persisted // selection naming a registered theme resolves. func RegisterBuiltinThemes(fsys fs.FS) error { if fsys == nil { - return fmt.Errorf("register built-in themes: nil fs") + return errors.New("register built-in themes: nil fs") } // Surface an unreadable source eagerly rather than at picker time. if _, err := fs.ReadDir(fsys, "themes"); err != nil { @@ -77,6 +81,13 @@ func RegisterBuiltinThemes(fsys fs.FS) error { builtinRefsCacheOK = false builtinRefsCacheMu.Unlock() + // Drop any theme already resolved under a ref this source overrides (a bundled + // built-in, or "default"), so the registered override/mask wins even when it + // was loaded before registration — built-in cache entries are otherwise treated + // as permanently valid. DefaultTheme()'s own cache is separate and untouched, so + // it stays the pristine merge base. + InvalidateThemeCache("") + return nil } diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index 092d17999..778470568 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -169,13 +169,51 @@ func TestRegisterBuiltinThemes_MasksDefault(t *testing.T) { assert.Equal(t, "Default", DefaultTheme().Name) } +// TestRegisterBuiltinThemes_OverrideAfterLoad verifies a registered override +// still wins when the bundled built-in (and "default") were already loaded — and +// therefore cached — before registration. LoadTheme treats built-in cache entries +// as permanently valid, so registration must drop them; otherwise the override is +// a silent no-op. +func TestRegisterBuiltinThemes_OverrideAfterLoad(t *testing.T) { + resetThemes(t) + + // Warm the theme cache with the bundled built-in and the bundled default. + bundledNord, err := LoadTheme("nord") + require.NoError(t, err) + require.NotEqual(t, "#123456", bundledNord.Colors.Accent) + + bundledDefault, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + require.NotEqual(t, "#ABCDEF", bundledDefault.Colors.Accent) + + src := fstest.MapFS{ + "themes/nord.yaml": &fstest.MapFile{ + Data: []byte("name: NotNord\ncolors:\n accent: \"#123456\"\n"), + }, + "themes/default.yaml": &fstest.MapFile{ + Data: []byte("name: Branded Default\ncolors:\n accent: \"#ABCDEF\"\n"), + }, + } + require.NoError(t, RegisterBuiltinThemes(src)) + + gotNord, err := LoadTheme("nord") + require.NoError(t, err) + assert.Equal(t, "#123456", gotNord.Colors.Accent) + assert.Equal(t, "NotNord", gotNord.Name) + + gotDefault, err := LoadTheme(DefaultThemeRef) + require.NoError(t, err) + assert.Equal(t, "#ABCDEF", gotDefault.Colors.Accent) + assert.Equal(t, "Branded Default", gotDefault.Name) +} + // TestRegisterBuiltinThemes_Errors covers eager validation of the source. func TestRegisterBuiltinThemes_Errors(t *testing.T) { resetThemes(t) - assert.Error(t, RegisterBuiltinThemes(nil)) + require.Error(t, RegisterBuiltinThemes(nil)) // A source without a "themes" directory is rejected eagerly. - assert.Error(t, RegisterBuiltinThemes(fstest.MapFS{ + require.Error(t, RegisterBuiltinThemes(fstest.MapFS{ "other/x.yaml": &fstest.MapFile{Data: []byte("{}")}, })) } From 7a04f7a281e29a70e991cbd03af35ee540212be1 Mon Sep 17 00:00:00 2001 From: Eron Wright Date: Sat, 20 Jun 2026 13:19:20 -0700 Subject: [PATCH 3/3] Make registered theme precedence last-wins When more than one registered source provides the same theme ref, the most recently registered source now wins (last-wins), matching the conventional Register(...) override semantics rather than first-wins. Registered sources still take precedence over cagent's bundled themes. The flip is a single reversed loop in readRegisteredThemeData; the name list is unaffected since a collision yields the same ref regardless of which source backs its data. Also renames the end-to-end test to _Integration to set it apart from the narrower behavior tests. --- pkg/tui/styles/theme.go | 24 ++++++++++++++---------- pkg/tui/styles/theme_test.go | 15 ++++++++------- 2 files changed, 22 insertions(+), 17 deletions(-) diff --git a/pkg/tui/styles/theme.go b/pkg/tui/styles/theme.go index d83008c8f..7399f57e7 100644 --- a/pkg/tui/styles/theme.go +++ b/pkg/tui/styles/theme.go @@ -56,10 +56,12 @@ var ( // in ListThemeRefs and the theme picker, resolve via LoadTheme/ApplyThemeRef, and // can be persisted as the user's selection. Registered sources take precedence // over the bundled themes, so a registered ref overrides the bundled theme of the -// same name — including masking "default" with the embedder's own. An override is -// still merged onto cagent's pristine DefaultTheme() base, so a registered theme -// only needs to specify the fields it changes; DefaultTheme() itself stays the -// bundled merge base, and each name is listed once. +// same name — including masking "default" with the embedder's own. Precedence is +// last-wins: when more than one registered source provides the same ref, a later +// RegisterBuiltinThemes call overrides an earlier one. An override is still merged +// onto cagent's pristine DefaultTheme() base, so a registered theme only needs to +// specify the fields it changes; DefaultTheme() itself stays the bundled merge +// base, and each name is listed once. // // Call this at startup, before applying any persisted theme, so that a persisted // selection naming a registered theme resolves. @@ -132,13 +134,15 @@ func readThemeData(fsys fs.FS, ref string) ([]byte, bool) { return nil, false } -// readRegisteredThemeData returns the raw YAML for ref from the first registered -// source that provides it. Registered sources take precedence over cagent's -// bundled themes, so an embedder can override a built-in — including masking -// "default" with their own. +// readRegisteredThemeData returns the raw YAML for ref from the most recently +// registered source that provides it. Registered sources take precedence over +// cagent's bundled themes, so an embedder can override a built-in — including +// masking "default" with their own — and a later RegisterBuiltinThemes call wins +// a name collision with an earlier one (last-wins). func readRegisteredThemeData(ref string) ([]byte, bool) { - for _, fsys := range registeredThemeFSes() { - if data, ok := readThemeData(fsys, ref); ok { + fses := registeredThemeFSes() + for i := len(fses) - 1; i >= 0; i-- { // reverse: last-registered wins + if data, ok := readThemeData(fses[i], ref); ok { return data, true } } diff --git a/pkg/tui/styles/theme_test.go b/pkg/tui/styles/theme_test.go index 778470568..8dbe95c74 100644 --- a/pkg/tui/styles/theme_test.go +++ b/pkg/tui/styles/theme_test.go @@ -42,10 +42,11 @@ func resetThemes(t *testing.T) { t.Cleanup(reset) } -// TestRegisterBuiltinThemes_EmbedderFlow exercises the full embedder loop: +// TestRegisterBuiltinThemes_Integration exercises the full embedder loop: // register a theme from a real embed.FS, then discover, load, and apply it the -// way a downstream CLI/TUI would. -func TestRegisterBuiltinThemes_EmbedderFlow(t *testing.T) { +// way a downstream CLI/TUI would. The narrower tests below isolate individual +// behaviors (merge, precedence, errors) with synthetic sources. +func TestRegisterBuiltinThemes_Integration(t *testing.T) { resetThemes(t) original := CurrentTheme() t.Cleanup(func() { ApplyTheme(original) }) @@ -100,8 +101,8 @@ func TestRegisterBuiltinThemes(t *testing.T) { } // TestRegisterBuiltinThemes_MultipleSources verifies the built-in set aggregates -// across more than one registered source, and that the first-registered source -// wins a name collision between two registered sources. +// across more than one registered source, and that the later-registered source +// wins a name collision between two registered sources (last-wins). func TestRegisterBuiltinThemes_MultipleSources(t *testing.T) { resetThemes(t) @@ -121,10 +122,10 @@ func TestRegisterBuiltinThemes_MultipleSources(t *testing.T) { assert.Contains(t, refs, "alpha") assert.Contains(t, refs, "beta") - // Earlier registration wins a collision between two registered sources. + // Later registration wins a collision between two registered sources (last-wins). shared, err := LoadTheme("shared") require.NoError(t, err) - assert.Equal(t, "First", shared.Name) + assert.Equal(t, "Second", shared.Name) } // TestRegisterBuiltinThemes_OverridesBuiltin verifies a registered source takes