From e68473ebb88288de656160efa941293c801820e5 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 5 Jul 2026 21:16:05 +0900 Subject: [PATCH 1/3] feat(shared): theme skins (dark/light/high-contrast) + --skin flag + :skin palette MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ship 3 built-in themes and let users drop custom YAML skins under ~/.config/ota/skins. Dark reproduces the current palette; light inverts; high-contrast is attribute-only for NO_COLOR compat. - internal/tui/shared/skins.go: SkinDef/SkinTone YAML types + LoadSkin loader (embedded first, then ~/.config/ota/skins/.yaml) - internal/tui/shared/skins/{dark,light,high_contrast}.yaml embedded - shared.ActiveTokens/SetActiveSkin under RWMutex for hot-swap - Deps.Skin threaded through wire → app.New; --skin CLI flag - :skin palette command (aliased :theme) for runtime swap - Every screen's activeTokens() delegates to shared.ActiveTokens() - skins_test.go + app/skin_palette_test.go cover the flow --- cmd/ota/main.go | 2 + cmd/ota/wire.go | 4 + internal/app/app.go | 48 +++- internal/app/skin_palette_test.go | 87 +++++++ internal/tui/apps/apps.go | 5 +- internal/tui/authenticators/list.go | 2 +- internal/tui/groups/groups.go | 9 +- internal/tui/logs/logs.go | 5 +- internal/tui/overlay/overlay.go | 3 +- .../testdata/golden/help_screen_users.txt | 53 ++-- .../testdata/golden/palette_default.txt | 1 + internal/tui/policies/policies.go | 5 +- internal/tui/rules/rules.go | 5 +- internal/tui/shared/skins.go | 236 ++++++++++++++++++ internal/tui/shared/skins/dark.yaml | 25 ++ internal/tui/shared/skins/high_contrast.yaml | 26 ++ internal/tui/shared/skins/light.yaml | 24 ++ internal/tui/shared/skins_test.go | 171 +++++++++++++ internal/tui/simpleres/simpleres.go | 2 +- internal/tui/users/list.go | 5 +- 20 files changed, 656 insertions(+), 62 deletions(-) create mode 100644 internal/app/skin_palette_test.go create mode 100644 internal/tui/shared/skins.go create mode 100644 internal/tui/shared/skins/dark.yaml create mode 100644 internal/tui/shared/skins/high_contrast.yaml create mode 100644 internal/tui/shared/skins/light.yaml create mode 100644 internal/tui/shared/skins_test.go diff --git a/cmd/ota/main.go b/cmd/ota/main.go index 2cf8be7..36133c5 100644 --- a/cmd/ota/main.go +++ b/cmd/ota/main.go @@ -57,6 +57,7 @@ func run(args []string, stdout, stderr *os.File) int { pollSec = fs.Int("poll-interval", 0, "override Logs tail poll interval in seconds") showVersion = fs.Bool("version", false, "print version and exit") checkMode = fs.Bool("check", false, "probe Okta API once and print a plain-text diagnostic (no TUI)") + skinName = fs.String("skin", "", "theme skin name (dark / light / high-contrast, or a custom skin under ~/.config/ota/skins)") ) if err := fs.Parse(args); err != nil { // flag.ErrHelp is not an error — user asked for help. @@ -103,6 +104,7 @@ func run(args []string, stdout, stderr *os.File) int { TokenEnv: *tokenEnv, Debug: *debugMode, PollSec: *pollSec, + Skin: *skinName, }) var rootModel tea.Model = wireModel diff --git a/cmd/ota/wire.go b/cmd/ota/wire.go index 4f0fd9e..465bc36 100644 --- a/cmd/ota/wire.go +++ b/cmd/ota/wire.go @@ -29,6 +29,7 @@ type WireInput struct { TokenEnv string Debug bool PollSec int + Skin string } // Wire is the single explicit dependency-assembly point. It loads config, @@ -134,6 +135,9 @@ func Wire(ctx context.Context, in WireInput) (app.Model, config.Config, error) { // override via cfg.OktaStatusEndpoint when self-hosted Okta // orgs run a different statuspage (rare). OktaStatusEndpoint: oktastatus.DefaultEndpoint, + // MW-10 — forward the CLI `--skin` flag. Empty string falls + // back to the default dark palette. + Skin: in.Skin, }) return model, cfg, nil } diff --git a/internal/app/app.go b/internal/app/app.go index 6bb74ae..7d4adb4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,7 +3,6 @@ package app import ( "log/slog" "net/url" - "os" "strings" "time" @@ -387,6 +386,12 @@ type Deps struct { // outbound HTTP. main.go sets it to the public default. OktaStatusEndpoint string + // Skin is the initial theme skin name (MW-10). Empty resolves + // to "dark". Loaded via shared.LoadSkin — falls back to the + // built-in dark palette (with a warning to the logger) if the + // name is unknown. + Skin string + // Optional initial state for tests / direct embedding. InitialScreen Screen } @@ -522,6 +527,14 @@ type Model struct { // New constructs the App Shell. The initial screen is materialized eagerly // so Init() can return its first Cmd directly. func New(deps Deps) Model { + // MW-10 — resolve the initial skin. Unknown names fall through + // to whatever init() left in place (dark), with a warning so + // operators see why their `--skin=nope` didn't take effect. + if skin := strings.TrimSpace(deps.Skin); skin != "" { + if _, err := shared.SetActiveSkin(skin); err != nil && deps.Logger != nil { + deps.Logger.Warn("skin load failed; falling back to dark", "skin", skin, "err", err.Error()) + } + } m := Model{ deps: deps, active: deps.InitialScreen, @@ -2309,14 +2322,12 @@ func tenantFromOrgURL(orgURL string) string { return parsed.Host } -// activeTokens picks the token set. NO_COLOR forces Monochrome. -// Otherwise an OTA_THEME env var override (when set to "dark" / -// "light" / "high-contrast" / "monochrome") wins; absent that, -// COLORFGBG-based detection picks Light on light terminals and -// falls back to Dark. Called per View() so a runtime toggle takes -// effect immediately. Issue #U12 v0.2.5. +// activeTokens picks the token set. MW-10 — thin passthrough to the +// shared active-skin state so `:skin ` / --skin updates +// take effect on the next render. NO_COLOR still short-circuits +// inside shared.ActiveTokens. func activeTokens() shared.Tokens { - return shared.PickTheme(shared.ResolveTheme(os.Getenv("OTA_THEME"))) + return shared.ActiveTokens() } // Active reports the active resource screen (useful for tests / wiring). @@ -2858,6 +2869,16 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, toastCmdInfo("select a user before running :xray") } return m, openXRayCmd(user) + case paletteCmdSkin: + // MW-10 — swap the active skin. Empty arg → usage toast. + name := strings.TrimSpace(arg) + if name == "" { + return m, toastCmdInfo("usage: :skin — try " + strings.Join(shared.BuiltinSkinNames(), " / ")) + } + if _, err := shared.SetActiveSkin(name); err != nil { + return m, toastCmdError(ErrorMsg{Err: err}) + } + return m, toastCmdInfo("skin: " + name) } return m, nil case tea.KeyBackspace: @@ -2934,6 +2955,9 @@ func paletteCommandPool() []string { // MW-1 — `:xray` opens the user-scoped dependency tree. "xray", "apilog", + // MW-10 — `:skin` hot-swap. Autocomplete stops at the verb; + // the operator supplies the name after a space. + "skin", "help", "quit", } } @@ -3234,6 +3258,9 @@ const ( // Arg may carry "local" / "utc" to force a specific mode instead // of toggling. paletteCmdTimezone + // paletteCmdSkin hot-swaps the active theme skin (MW-10). The + // skin name lives in the `arg` return slot. + paletteCmdSkin ) // UnmaskFieldMsg / MaskAllMsg are re-exported from the shared msgs @@ -3287,6 +3314,11 @@ func resolvePaletteCommand(raw string) (kind paletteCmdKind, screen Screen, arg return paletteCmdTimezone, 0, "local", true case "tz utc", "timezone utc": return paletteCmdTimezone, 0, "utc", true + case "skin", "theme": + // MW-10 — `:skin ` hot-swaps the active palette. + // Missing arg is a no-op-with-toast (handled by the caller + // via ok=true + empty arg → toast). + return paletteCmdSkin, 0, rest, true } // Direct policy-type routes (issue #165). The verb arg field // carries the canonical PolicyType so the App Shell can build a diff --git a/internal/app/skin_palette_test.go b/internal/app/skin_palette_test.go new file mode 100644 index 0000000..8d22a00 --- /dev/null +++ b/internal/app/skin_palette_test.go @@ -0,0 +1,87 @@ +package app_test + +// MW-10 — palette + Deps.Skin smoke tests. Pins the contract that +// - `app.New(Deps{Skin: "light"})` swaps the shared active skin +// - `:skin light` typed through the palette overlay hot-swaps and +// leaves the app on its current screen (no navigation side-effect) +// - `:skin nope` errors — the previous active skin stays put + +import ( + "testing" + + tea "github.com/charmbracelet/bubbletea" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tedilabs/ota/internal/app" + "github.com/tedilabs/ota/internal/tui/shared" +) + +func Test_Deps_Skin_SetsActiveSkin(t *testing.T) { + // Restore the default afterwards so this test can't leak into + // other packages sharing the same shared.SetActiveSkin state. + t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + + _ = app.New(app.Deps{ + InitialScreen: app.ScreenUsers, + Skin: "light", + }) + assert.Equal(t, "light", shared.ActiveSkinName(), + "app.New(Deps{Skin:\"light\"}) must call SetActiveSkin") +} + +func Test_Palette_SkinCommand_HotSwaps(t *testing.T) { + t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + + _, err := shared.SetActiveSkin("dark") + require.NoError(t, err) + + m := app.New(app.Deps{InitialScreen: app.ScreenUsers}) + got := drivePalette(t, m, "skin light") + + assert.Equal(t, "users", app.ActiveScreenName(got), + ":skin must not change the active screen") + assert.Equal(t, "light", shared.ActiveSkinName(), + ":skin light must swap the active skin") +} + +func Test_Palette_SkinCommand_UnknownName(t *testing.T) { + t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + + _, err := shared.SetActiveSkin("dark") + require.NoError(t, err) + + m := app.New(app.Deps{InitialScreen: app.ScreenUsers}) + _ = drivePalette(t, m, "skin nope-does-not-exist") + + assert.Equal(t, "dark", shared.ActiveSkinName(), + ":skin nope must leave the previous active skin untouched") +} + +// drivePalette opens the palette, types cmd, hits Enter, and drains +// the resulting Cmd chain. Returns the resulting Model. +func drivePalette(t *testing.T, m app.Model, cmd string) app.Model { + t.Helper() + + updated, c := m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(":")}) + model := updated.(app.Model) + if c != nil { + if msg := c(); msg != nil { + updated, _ = model.Update(msg) + model = updated.(app.Model) + } + } + for _, r := range cmd { + updated, _ = model.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}}) + model = updated.(app.Model) + } + updated, c = model.Update(tea.KeyMsg{Type: tea.KeyEnter}) + model = updated.(app.Model) + if c != nil { + if msg := c(); msg != nil { + updated, _ = model.Update(msg) + model = updated.(app.Model) + } + } + return model +} diff --git a/internal/tui/apps/apps.go b/internal/tui/apps/apps.go index bedd735..89f1372 100644 --- a/internal/tui/apps/apps.go +++ b/internal/tui/apps/apps.go @@ -721,10 +721,7 @@ func (m ListModel) observedColumnWidths() []int { } func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // --- Detail ------------------------------------------------------------------ diff --git a/internal/tui/authenticators/list.go b/internal/tui/authenticators/list.go index a8838b7..2f7e4da 100644 --- a/internal/tui/authenticators/list.go +++ b/internal/tui/authenticators/list.go @@ -585,7 +585,7 @@ func fetchAuthsCmd(port domain.AuthenticatorsPort) tea.Cmd { // activeTokens picks the active theme. Mirrors the App Shell helper. func activeTokens() shared.Tokens { - return shared.PickTheme(shared.ResolveTheme("")) + return shared.ActiveTokens() } var _ tea.Model = ListModel{} diff --git a/internal/tui/groups/groups.go b/internal/tui/groups/groups.go index b3c12f8..562c96a 100644 --- a/internal/tui/groups/groups.go +++ b/internal/tui/groups/groups.go @@ -1219,12 +1219,11 @@ func strconvI(n int) string { return string(buf[i:]) } -// activeTokens picks the right token set per NO_COLOR. +// activeTokens picks the right token set. MW-10 — routed through +// shared.ActiveTokens so `:skin` / --skin hot-swaps land on the next +// render. NO_COLOR still short-circuits inside ActiveTokens. func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // now returns the current time, preferring the injected clock. diff --git a/internal/tui/logs/logs.go b/internal/tui/logs/logs.go index 9d2ac91..74cc91b 100644 --- a/internal/tui/logs/logs.go +++ b/internal/tui/logs/logs.go @@ -1292,10 +1292,7 @@ func visibleLenLog(s string) int { // activeTokens picks the right token set per NO_COLOR. func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // now returns the injected clock or wall time. diff --git a/internal/tui/overlay/overlay.go b/internal/tui/overlay/overlay.go index 6833030..3f864f9 100644 --- a/internal/tui/overlay/overlay.go +++ b/internal/tui/overlay/overlay.go @@ -22,7 +22,7 @@ var paletteHints = []string{ ":network-zones", ":authorization-servers", ":api-tokens", ":administrators", ":profile", ":search", ":filter", ":unmask", ":mask", ":raw", ":refresh", ":about", ":ratelimit", ":errors", ":healthcheck", - ":apilog", ":tz", ":debug", ":help", ":quit", + ":apilog", ":tz", ":debug", ":skin", ":help", ":quit", } // paletteHintsStripped is paletteHints with the leading ":" removed — @@ -475,6 +475,7 @@ func paletteHelpEntries() []helpEntry { {":tz", "toggle timezone (Local ↔ UTC)"}, {":unmask ", "reveal a masked field"}, {":mask", "re-mask PII fields"}, + {":skin ", "swap theme skin (dark / light / high-contrast)"}, {":help", "this overlay"}, {":quit", "quit ota"}, } diff --git a/internal/tui/overlay/testdata/golden/help_screen_users.txt b/internal/tui/overlay/testdata/golden/help_screen_users.txt index 0d1c4c0..79eb0ff 100644 --- a/internal/tui/overlay/testdata/golden/help_screen_users.txt +++ b/internal/tui/overlay/testdata/golden/help_screen_users.txt @@ -1,26 +1,27 @@ -╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Help · Users List │ -├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ -│ Press Esc to close │ -│ │ -│ ── Resource ── │ ── General ── │ ── Navigation ── │ ── Palette ── │ -│ Enter / d open detail (all attributes) │ : open command palette │ j / k cursor down / up │ :users Users │ -│ e edit profile form │ / incremental search (lists) │ h / l scroll columns left / right │ :groups Groups │ -│ s change status (pick from valid transitions) │ ? this help │ gg / G top / bottom │ :group-rules Group Rules │ -│ x XRay: Groups → Rules / Policies / Apps │ ~ Okta API call timeline overlay │ Ctrl-d / Ctrl-u half-page down / up │ :policies Policies │ -│ l open Logs scoped to this user │ a resource action menu │ Ctrl-f / Ctrl-b page down / up │ :apps Apps │ -│ Shift+S sort by STATUS │ l open Logs scoped to current resource │ │ :authenticators Authenticators │ -│ Shift+N sort by NAME (login) │ z toggle timezone (Local ↔ UTC) │ │ :logs System Log │ -│ Shift+L sort by LAST LOGIN │ R refresh active screen │ │ :network-zones Network Zones │ -│ Shift+C sort by CREATED / CHANGED │ Esc back · cancel mode · close overlay │ │ :authorization-servers Authorization Servers │ -│ :reset-password send reset-password email │ q close screen / quit (with confirm) │ │ :api-tokens API Tokens │ -│ :unlock clear LOCKED_OUT state │ Ctrl-c soft quit (tail confirm) │ │ :administrators Administrators │ -│ :reset-mfa remove enrolled MFA factors │ Ctrl-l force redraw │ │ :apilog API timeline overlay │ -│ :xray XRay tree (uses cursor user) │ │ │ :tz toggle timezone (Local ↔ UTC) │ -│ │ │ │ :unmask reveal a masked field │ -│ │ │ │ :mask re-mask PII fields │ -│ │ │ │ :help this overlay │ -│ │ │ │ :quit quit ota │ -│ │ -│ close · filter │ -╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Help · Users List │ +├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ Press Esc to close │ +│ │ +│ ── Resource ── │ ── General ── │ ── Navigation ── │ ── Palette ── │ +│ Enter / d open detail (all attributes) │ : open command palette │ j / k cursor down / up │ :users Users │ +│ e edit profile form │ / incremental search (lists) │ h / l scroll columns left / right │ :groups Groups │ +│ s change status (pick from valid transitions) │ ? this help │ gg / G top / bottom │ :group-rules Group Rules │ +│ x XRay: Groups → Rules / Policies / Apps │ ~ Okta API call timeline overlay │ Ctrl-d / Ctrl-u half-page down / up │ :policies Policies │ +│ l open Logs scoped to this user │ a resource action menu │ Ctrl-f / Ctrl-b page down / up │ :apps Apps │ +│ Shift+S sort by STATUS │ l open Logs scoped to current resource │ │ :authenticators Authenticators │ +│ Shift+N sort by NAME (login) │ z toggle timezone (Local ↔ UTC) │ │ :logs System Log │ +│ Shift+L sort by LAST LOGIN │ R refresh active screen │ │ :network-zones Network Zones │ +│ Shift+C sort by CREATED / CHANGED │ Esc back · cancel mode · close overlay │ │ :authorization-servers Authorization Servers │ +│ :reset-password send reset-password email │ q close screen / quit (with confirm) │ │ :api-tokens API Tokens │ +│ :unlock clear LOCKED_OUT state │ Ctrl-c soft quit (tail confirm) │ │ :administrators Administrators │ +│ :reset-mfa remove enrolled MFA factors │ Ctrl-l force redraw │ │ :apilog API timeline overlay │ +│ :xray XRay tree (uses cursor user) │ │ │ :tz toggle timezone (Local ↔ UTC) │ +│ │ │ │ :unmask reveal a masked field │ +│ │ │ │ :mask re-mask PII fields │ +│ │ │ │ :skin swap theme skin (dark / light / high-contrast) │ +│ │ │ │ :help this overlay │ +│ │ │ │ :quit quit ota │ +│ │ +│ close · filter │ +╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/tui/overlay/testdata/golden/palette_default.txt b/internal/tui/overlay/testdata/golden/palette_default.txt index 7f530cb..4775d47 100644 --- a/internal/tui/overlay/testdata/golden/palette_default.txt +++ b/internal/tui/overlay/testdata/golden/palette_default.txt @@ -28,6 +28,7 @@ │ :apilog │ │ :tz │ │ :debug │ +│ :skin │ │ :help │ │ :quit │ │ │ diff --git a/internal/tui/policies/policies.go b/internal/tui/policies/policies.go index 77e5965..1499b6e 100644 --- a/internal/tui/policies/policies.go +++ b/internal/tui/policies/policies.go @@ -665,10 +665,7 @@ func max(a, b int) int { // activeTokens picks the token set per NO_COLOR. func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // now returns the injected clock or wall time. diff --git a/internal/tui/rules/rules.go b/internal/tui/rules/rules.go index 7e41ece..1c10fac 100644 --- a/internal/tui/rules/rules.go +++ b/internal/tui/rules/rules.go @@ -867,10 +867,7 @@ func countInvalid(rules []domain.GroupRule) int { // activeTokens picks the token set per NO_COLOR. func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // now returns the injected clock or wall time. diff --git a/internal/tui/shared/skins.go b/internal/tui/shared/skins.go new file mode 100644 index 0000000..4dc3993 --- /dev/null +++ b/internal/tui/shared/skins.go @@ -0,0 +1,236 @@ +package shared + +import ( + "embed" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "github.com/charmbracelet/lipgloss" + "gopkg.in/yaml.v3" +) + +// MW-10 — theme skin system. A SkinDef is the on-disk (YAML) form of +// a Tokens palette. Three built-ins ship embedded (dark, light, +// high-contrast); operators can drop custom skins into +// ~/.config/ota/skins/.yaml and select them via `--skin ` +// or `:skin `. + +//go:embed skins/*.yaml +var builtinSkinsFS embed.FS + +// SkinTone is one Tokens field's serialised form — foreground / background +// colour plus a handful of SGR attributes. Every field is optional so a +// skin can express fg-only, bg-only, or attribute-only tones. +type SkinTone struct { + FG string `yaml:"fg,omitempty"` + BG string `yaml:"bg,omitempty"` + Bold bool `yaml:"bold,omitempty"` + Faint bool `yaml:"faint,omitempty"` + Underline bool `yaml:"underline,omitempty"` + Reverse bool `yaml:"reverse,omitempty"` + Italic bool `yaml:"italic,omitempty"` +} + +// SkinDef is the YAML shape of a skin: a name plus one tone per Tokens +// field, keyed by the snake_case field name (see toneKeys below). +type SkinDef struct { + Name string `yaml:"name"` + Tones map[string]SkinTone `yaml:"tones"` +} + +// Style materialises this tone into a lipgloss.Style. +func (t SkinTone) Style() lipgloss.Style { + s := lipgloss.NewStyle() + if t.FG != "" { + s = s.Foreground(lipgloss.Color(t.FG)) + } + if t.BG != "" { + s = s.Background(lipgloss.Color(t.BG)) + } + if t.Bold { + s = s.Bold(true) + } + if t.Faint { + s = s.Faint(true) + } + if t.Underline { + s = s.Underline(true) + } + if t.Reverse { + s = s.Reverse(true) + } + if t.Italic { + s = s.Italic(true) + } + return s +} + +// Tokens converts the SkinDef into a fully-populated Tokens set. Missing +// tones fall back to plain (unstyled) so partial skins render legibly. +func (d SkinDef) Tokens() Tokens { + tone := func(k string) lipgloss.Style { return d.Tones[k].Style() } + return Tokens{ + BG: tone("bg"), + FG: tone("fg"), + Muted: tone("muted"), + Header: tone("header"), + Accent: tone("accent"), + Primary: tone("primary"), + Success: tone("success"), + Warning: tone("warning"), + Danger: tone("danger"), + Info: tone("info"), + Magenta: tone("magenta"), + BadgeSys: tone("badge_sys"), + BadgeRule: tone("badge_rule"), + BadgeLarge: tone("badge_large"), + BadgeUnmask: tone("badge_unmask"), + RowCursor: tone("row_cursor"), + RowDanger: tone("row_danger"), + RowWarning: tone("row_warning"), + RowMuted: tone("row_muted"), + RowChanged: tone("row_changed"), + } +} + +// builtinSkins is the fixed name → embedded-file map. Filenames on disk +// use underscores so YAML anchors read naturally; the lookup name is +// hyphenated to match ThemeName / CLI convention. +var builtinSkins = map[string]string{ + "dark": "skins/dark.yaml", + "light": "skins/light.yaml", + "high-contrast": "skins/high_contrast.yaml", +} + +// BuiltinSkinNames returns the embedded skin names in a stable order — +// used by palette autocomplete and by tests iterating every builtin. +func BuiltinSkinNames() []string { return []string{"dark", "light", "high-contrast"} } + +// LoadSkin resolves a skin name to its Tokens. Search order: +// 1. Embedded skins (dark / light / high-contrast). +// 2. ~/.config/ota/skins/.yaml on disk. +// +// Returns an error when neither source has the name — callers should +// surface a toast so the operator sees why nothing changed. +func LoadSkin(name string) (Tokens, error) { + def, err := LoadSkinDef(name) + if err != nil { + return Tokens{}, err + } + return def.Tokens(), nil +} + +// LoadSkinDef is LoadSkin's structural counterpart — returns the raw +// SkinDef so callers (tests, YAML round-trip) can inspect it before +// materialising Tokens. +func LoadSkinDef(name string) (SkinDef, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return SkinDef{}, fmt.Errorf("skin name is empty") + } + if path, ok := builtinSkins[trimmed]; ok { + data, err := builtinSkinsFS.ReadFile(path) + if err != nil { + return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + } + return parseSkinDef(data, trimmed) + } + path, err := userSkinPath(trimmed) + if err != nil { + return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + } + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return SkinDef{}, fmt.Errorf("skin %q not found (looked in embedded skins and %s)", trimmed, path) + } + return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + } + return parseSkinDef(data, trimmed) +} + +func parseSkinDef(data []byte, want string) (SkinDef, error) { + var def SkinDef + if err := yaml.Unmarshal(data, &def); err != nil { + return SkinDef{}, fmt.Errorf("skin %q: parse: %w", want, err) + } + if def.Name == "" { + def.Name = want + } + return def, nil +} + +// userSkinPath maps a skin name to its expected filesystem location. +// Honours XDG_CONFIG_HOME then falls back to $HOME/.config/ota/skins. +func userSkinPath(name string) (string, error) { + base := os.Getenv("XDG_CONFIG_HOME") + if base == "" { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + base = filepath.Join(home, ".config") + } + return filepath.Join(base, "ota", "skins", name+".yaml"), nil +} + +// --- Active skin state --------------------------------------------------- +// +// Screens read the live Tokens via ActiveTokens() every View() so a +// runtime skin swap (`:skin light`) reflects on the next frame. The +// state is mutable-under-mutex so goroutines racing on Update / View +// stay safe. +var ( + activeMu sync.RWMutex + activeTokens Tokens + activeName = "dark" +) + +func init() { + if tk, err := LoadSkin("dark"); err == nil { + activeTokens = tk + } else { + // Fallback to the compiled-in Dark() so the app is never left + // with a zero-value Tokens if embed misbehaves. + activeTokens = Dark() + } +} + +// SetActiveSkin loads name and — on success — makes it the active +// Tokens for subsequent ActiveTokens() calls. Returns the loaded +// Tokens plus any error so callers can surface a toast on failure +// without mutating state. +func SetActiveSkin(name string) (Tokens, error) { + tk, err := LoadSkin(name) + if err != nil { + return Tokens{}, err + } + activeMu.Lock() + activeTokens = tk + activeName = strings.TrimSpace(name) + activeMu.Unlock() + return tk, nil +} + +// ActiveTokens returns the live Tokens. NO_COLOR wins — the palette +// swap is a no-op under monochrome (Lipgloss strips SGR anyway, but +// keeping Monochrome() explicit means the Row* attribute fallbacks +// still apply). +func ActiveTokens() Tokens { + if MonochromeEnabled() { + return Monochrome() + } + activeMu.RLock() + defer activeMu.RUnlock() + return activeTokens +} + +// ActiveSkinName reports the currently-loaded skin identifier. +func ActiveSkinName() string { + activeMu.RLock() + defer activeMu.RUnlock() + return activeName +} diff --git a/internal/tui/shared/skins/dark.yaml b/internal/tui/shared/skins/dark.yaml new file mode 100644 index 0000000..72e14bf --- /dev/null +++ b/internal/tui/shared/skins/dark.yaml @@ -0,0 +1,25 @@ +# Default dark skin — reproduces the historical shared.Dark() palette +# so existing snapshots/goldens stay bit-for-bit identical when +# --skin=dark is selected. +name: dark +tones: + bg: {bg: "#0b0f14"} + fg: {fg: "#d8dee9"} + muted: {fg: "#5c6a7a"} + header: {fg: "#88c0d0", bold: true} + accent: {fg: "#81a1c1"} + primary: {fg: "#5e81ac"} + success: {fg: "#a3be8c"} + warning: {fg: "#ebcb8b"} + danger: {fg: "#bf616a", bold: true} + info: {fg: "#88c0d0"} + magenta: {fg: "#b48ead"} + badge_sys: {fg: "#d8dee9", bg: "#4c566a"} + badge_rule: {fg: "#000000", bg: "#a3be8c"} + badge_large: {fg: "#000000", bg: "#ebcb8b"} + badge_unmask: {fg: "#ffffff", bg: "#bf616a", bold: true} + row_cursor: {fg: "#88c0d0", bg: "#2e3440", bold: true} + row_danger: {fg: "#f0d4d6", bg: "#4c1f21"} + row_warning: {fg: "#f5e7c1", bg: "#4a3a17"} + row_muted: {fg: "#7a8290", bg: "#2a2f38"} + row_changed: {fg: "#d4ecf0", bg: "#1f3d4c"} diff --git a/internal/tui/shared/skins/high_contrast.yaml b/internal/tui/shared/skins/high_contrast.yaml new file mode 100644 index 0000000..aecaa6e --- /dev/null +++ b/internal/tui/shared/skins/high_contrast.yaml @@ -0,0 +1,26 @@ +# High-contrast skin — pure black/white palette that leans on SGR +# attributes (bold / reverse / underline) for role recognition so it +# renders legibly under NO_COLOR too. Every role stays distinct via +# attribute shape rather than hue. +name: high-contrast +tones: + bg: {bg: "#000000"} + fg: {fg: "#ffffff"} + muted: {fg: "#ffffff", faint: true} + header: {fg: "#ffffff", bold: true} + accent: {fg: "#ffffff", bold: true, underline: true} + primary: {fg: "#ffffff", underline: true} + success: {fg: "#ffffff", bold: true} + warning: {fg: "#ffffff", bold: true, underline: true} + danger: {fg: "#ffffff", bold: true, reverse: true} + info: {fg: "#ffffff", underline: true} + magenta: {fg: "#ffffff", bold: true} + badge_sys: {fg: "#000000", bg: "#ffffff"} + badge_rule: {fg: "#000000", bg: "#ffffff", bold: true} + badge_large: {fg: "#000000", bg: "#ffffff", underline: true} + badge_unmask: {fg: "#000000", bg: "#ffffff", bold: true, reverse: true} + row_cursor: {fg: "#000000", bg: "#ffffff", bold: true} + row_danger: {fg: "#ffffff", bold: true, underline: true} + row_warning: {fg: "#ffffff", bold: true} + row_muted: {fg: "#ffffff", faint: true} + row_changed: {fg: "#ffffff", underline: true} diff --git a/internal/tui/shared/skins/light.yaml b/internal/tui/shared/skins/light.yaml new file mode 100644 index 0000000..e23941f --- /dev/null +++ b/internal/tui/shared/skins/light.yaml @@ -0,0 +1,24 @@ +# Light skin — inverse of dark for white-background terminals. Reproduces +# the historical shared.Light() palette so existing snapshots stay stable. +name: light +tones: + bg: {bg: "#fdf6e3"} + fg: {fg: "#1c2733"} + muted: {fg: "#6c7a89"} + header: {fg: "#1f6f8b", bold: true} + accent: {fg: "#1f6f8b"} + primary: {fg: "#214b73"} + success: {fg: "#3f7d3f"} + warning: {fg: "#a17317"} + danger: {fg: "#a8323a", bold: true} + info: {fg: "#1f6f8b"} + magenta: {fg: "#7a3a76"} + badge_sys: {fg: "#1c2733", bg: "#d3d8dc"} + badge_rule: {fg: "#ffffff", bg: "#3f7d3f"} + badge_large: {fg: "#ffffff", bg: "#a17317"} + badge_unmask: {fg: "#ffffff", bg: "#a8323a", bold: true} + row_cursor: {fg: "#214b73", bg: "#cfe1f0", bold: true} + row_danger: {fg: "#5a1d22", bg: "#f9d3d6"} + row_warning: {fg: "#5b4111", bg: "#f5e7c1"} + row_muted: {fg: "#6c7a89", bg: "#e6e9ec"} + row_changed: {fg: "#1f4f5b", bg: "#cfe6e9"} diff --git a/internal/tui/shared/skins_test.go b/internal/tui/shared/skins_test.go new file mode 100644 index 0000000..91785e8 --- /dev/null +++ b/internal/tui/shared/skins_test.go @@ -0,0 +1,171 @@ +package shared_test + +// MW-10 — theme skin system tests. Verifies that: +// - every built-in skin loads without error and yields a populated Tokens +// - built-in skins are visually distinct (no accidental duplicate) +// - YAML round-trip (marshal → unmarshal → materialise) preserves the +// palette bit-for-bit +// - SetActiveSkin swaps the live tokens without touching NO_COLOR + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/tedilabs/ota/internal/tui/shared" +) + +func Test_LoadSkin_AllBuiltinsResolve(t *testing.T) { + os.Unsetenv("NO_COLOR") + for _, name := range shared.BuiltinSkinNames() { + tk, err := shared.LoadSkin(name) + require.NoError(t, err, "LoadSkin(%q) must succeed", name) + require.NotNil(t, tk, "LoadSkin(%q) returned zero Tokens", name) + } +} + +func Test_LoadSkinDef_UnknownReturnsError(t *testing.T) { + _, err := shared.LoadSkinDef("does-not-exist-anywhere") + require.Error(t, err, "unknown skin name must error") +} + +func Test_LoadSkinDef_EmptyNameErrors(t *testing.T) { + _, err := shared.LoadSkinDef("") + require.Error(t, err, "empty skin name must error") +} + +// Each built-in skin must be visually distinct — at minimum the tone +// tables differ. Otherwise the "3 built-in themes" promise is a lie. +// Compares raw SkinTone maps rather than lipgloss.Render output so the +// test doesn't depend on the runtime colour profile (tests run under +// Ascii and would otherwise strip every ANSI code). +func Test_LoadSkin_BuiltinsAreDistinct(t *testing.T) { + os.Unsetenv("NO_COLOR") + fingerprints := map[string]string{} + for _, name := range shared.BuiltinSkinNames() { + def, err := shared.LoadSkinDef(name) + require.NoError(t, err) + fp := def.Tones["fg"].FG + "|" + def.Tones["bg"].BG + "|" + def.Tones["danger"].FG + fingerprints[name] = fp + } + seen := map[string]string{} + for name, fp := range fingerprints { + if prev, ok := seen[fp]; ok { + t.Fatalf("skins %q and %q have identical palette fingerprints — must differ", prev, name) + } + seen[fp] = name + } +} + +// YAML round-trip: marshalling a SkinDef back to YAML and re-parsing it +// must yield an identical tone map. Guards against a future SkinTone +// field addition that forgets `yaml:` tags. +func Test_SkinDef_YAMLRoundTrip(t *testing.T) { + os.Unsetenv("NO_COLOR") + for _, name := range shared.BuiltinSkinNames() { + def, err := shared.LoadSkinDef(name) + require.NoError(t, err) + + out, err := yaml.Marshal(def) + require.NoError(t, err, "marshal %q", name) + + var reparsed shared.SkinDef + require.NoError(t, yaml.Unmarshal(out, &reparsed), "unmarshal %q", name) + + assert.Equal(t, def.Name, reparsed.Name, "skin %q name preserved", name) + assert.Equal(t, def.Tones, reparsed.Tones, + "skin %q tone map survives round-trip", name) + } +} + +// SetActiveSkin swaps the live tokens and ActiveTokens reflects it. +// Uses the underlying SkinDef fingerprint (not lipgloss.Render output) +// so the assertion works regardless of the runtime colour profile. +func Test_SetActiveSkin_SwapsLiveTokens(t *testing.T) { + os.Unsetenv("NO_COLOR") + // Restore whatever was live before the test — otherwise a stray + // `light` leaks into every subsequent test in this package. + t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + + darkDef, err := shared.LoadSkinDef("dark") + require.NoError(t, err) + lightDef, err := shared.LoadSkinDef("light") + require.NoError(t, err) + require.NotEqual(t, darkDef.Tones["fg"].FG, lightDef.Tones["fg"].FG, + "dark and light must ship different fg tones") + + _, err = shared.SetActiveSkin("dark") + require.NoError(t, err) + assert.Equal(t, "dark", shared.ActiveSkinName()) + + _, err = shared.SetActiveSkin("light") + require.NoError(t, err) + assert.Equal(t, "light", shared.ActiveSkinName()) +} + +// SetActiveSkin on an unknown name must leave the previous active +// skin untouched — a failed swap should not blank the palette. +func Test_SetActiveSkin_UnknownLeavesActiveIntact(t *testing.T) { + os.Unsetenv("NO_COLOR") + t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + + _, err := shared.SetActiveSkin("dark") + require.NoError(t, err) + + _, err = shared.SetActiveSkin("nonexistent-xyz") + require.Error(t, err, "unknown skin must error") + + assert.Equal(t, "dark", shared.ActiveSkinName(), + "failed skin swap must not mutate active skin name") +} + +// NO_COLOR beats the active skin — ActiveTokens must ignore the +// swapped skin and return the Monochrome palette. +func Test_ActiveTokens_NOCOLOR_TrumpsSkin(t *testing.T) { + t.Cleanup(func() { + os.Unsetenv("NO_COLOR") + _, _ = shared.SetActiveSkin("dark") + }) + _, err := shared.SetActiveSkin("light") + require.NoError(t, err) + + t.Setenv("NO_COLOR", "1") + // Under NO_COLOR the FG/BG lipgloss styles come from Monochrome() + // — verify by comparing the foreground descriptor to the one + // Monochrome() produces (both are NoColor{}, distinct from any + // coloured skin's Color("#xxxxxx")). + assert.Equal(t, shared.Monochrome().FG.GetForeground(), shared.ActiveTokens().FG.GetForeground(), + "NO_COLOR must force Monochrome foreground") + assert.Equal(t, shared.Monochrome().Danger.GetBold(), shared.ActiveTokens().Danger.GetBold(), + "NO_COLOR must force Monochrome attribute stack") +} + +// LoadSkin from a user-config path succeeds when the file exists. +func Test_LoadSkin_UserConfigPath(t *testing.T) { + dir := t.TempDir() + skinDir := dir + "/ota/skins" + require.NoError(t, os.MkdirAll(skinDir, 0o755)) + + yamlBody := `name: nord-lite +tones: + fg: {fg: "#eceff4"} + bg: {bg: "#2e3440"} + danger: {fg: "#bf616a", bold: true} +` + require.NoError(t, os.WriteFile(skinDir+"/nord-lite.yaml", []byte(yamlBody), 0o644)) + t.Setenv("XDG_CONFIG_HOME", dir) + + def, err := shared.LoadSkinDef("nord-lite") + require.NoError(t, err) + assert.Equal(t, "nord-lite", def.Name) + assert.Equal(t, "#eceff4", def.Tones["fg"].FG) + assert.Equal(t, "#bf616a", def.Tones["danger"].FG) + assert.True(t, def.Tones["danger"].Bold) + + tk, err := shared.LoadSkin("nord-lite") + require.NoError(t, err) + assert.NotNil(t, tk) +} diff --git a/internal/tui/simpleres/simpleres.go b/internal/tui/simpleres/simpleres.go index 5b5882a..e0d5366 100644 --- a/internal/tui/simpleres/simpleres.go +++ b/internal/tui/simpleres/simpleres.go @@ -629,7 +629,7 @@ func (m Model[T]) CursorItem() T { // activeTokens picks the token set per the active theme. Routed // through PickTheme so monochrome / light / high-contrast all work. func activeTokens() shared.Tokens { - return shared.PickTheme(shared.ResolveTheme("")) + return shared.ActiveTokens() } // formatTimeRel formats a time as a relative duration ("2h ago"), diff --git a/internal/tui/users/list.go b/internal/tui/users/list.go index d538a73..02e6f57 100644 --- a/internal/tui/users/list.go +++ b/internal/tui/users/list.go @@ -2114,10 +2114,7 @@ func (m ListModel) now() time.Time { // activeTokens picks the right token set per NO_COLOR. func activeTokens() shared.Tokens { - if shared.MonochromeEnabled() { - return shared.Monochrome() - } - return shared.Dark() + return shared.ActiveTokens() } // visible applies the active filter (case-insensitive substring match on From 3d88fb7a49fb9bb5e598d0e6173f2d0b1f332fc4 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 5 Jul 2026 21:24:04 +0900 Subject: [PATCH 2/3] =?UTF-8?q?refactor(shared):=20rename=20skin=20?= =?UTF-8?q?=E2=86=92=20theme=20throughout=20MW-10?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Unify terminology on 'theme'. skin was an intermediate name; every type, file, flag, palette command, and yaml key now says theme. - shared: SkinDef→ThemeDef, SkinTone→ThemeTone, LoadSkin→LoadTheme, SetActiveSkin→SetActiveTheme, ActiveSkin→ActiveTheme - files: internal/tui/shared/{skins.go, skins_test.go, skins/} → {themes.go, themes_test.go, themes/} - CLI: --skin → --theme - palette: :skin dropped (was aliased already); :theme is the canonical - Deps.Skin → Deps.Theme, wire.go + app.go threaded through - app: paletteCmdSkin → paletteCmdTheme, internal/app/skin_palette_test.go → theme_palette_test.go - overlay: palette hints + help entries updated; goldens regenerated --- cmd/ota/main.go | 4 +- cmd/ota/wire.go | 6 +- internal/app/app.go | 44 +++++----- ..._palette_test.go => theme_palette_test.go} | 34 +++---- internal/tui/groups/groups.go | 2 +- internal/tui/overlay/overlay.go | 4 +- .../testdata/golden/help_screen_users.txt | 54 ++++++------ .../testdata/golden/palette_default.txt | 2 +- internal/tui/shared/{skins.go => themes.go} | 88 +++++++++---------- .../tui/shared/{skins => themes}/dark.yaml | 4 +- .../{skins => themes}/high_contrast.yaml | 2 +- .../tui/shared/{skins => themes}/light.yaml | 2 +- .../shared/{skins_test.go => themes_test.go} | 74 ++++++++-------- 13 files changed, 160 insertions(+), 160 deletions(-) rename internal/app/{skin_palette_test.go => theme_palette_test.go} (65%) rename internal/tui/shared/{skins.go => themes.go} (65%) rename internal/tui/shared/{skins => themes}/dark.yaml (89%) rename internal/tui/shared/{skins => themes}/high_contrast.yaml (94%) rename internal/tui/shared/{skins => themes}/light.yaml (92%) rename internal/tui/shared/{skins_test.go => themes_test.go} (64%) diff --git a/cmd/ota/main.go b/cmd/ota/main.go index 36133c5..c27eea6 100644 --- a/cmd/ota/main.go +++ b/cmd/ota/main.go @@ -57,7 +57,7 @@ func run(args []string, stdout, stderr *os.File) int { pollSec = fs.Int("poll-interval", 0, "override Logs tail poll interval in seconds") showVersion = fs.Bool("version", false, "print version and exit") checkMode = fs.Bool("check", false, "probe Okta API once and print a plain-text diagnostic (no TUI)") - skinName = fs.String("skin", "", "theme skin name (dark / light / high-contrast, or a custom skin under ~/.config/ota/skins)") + themeName = fs.String("theme", "", "theme theme name (dark / light / high-contrast, or a custom theme under ~/.config/ota/themes)") ) if err := fs.Parse(args); err != nil { // flag.ErrHelp is not an error — user asked for help. @@ -104,7 +104,7 @@ func run(args []string, stdout, stderr *os.File) int { TokenEnv: *tokenEnv, Debug: *debugMode, PollSec: *pollSec, - Skin: *skinName, + Theme: *themeName, }) var rootModel tea.Model = wireModel diff --git a/cmd/ota/wire.go b/cmd/ota/wire.go index 465bc36..6447458 100644 --- a/cmd/ota/wire.go +++ b/cmd/ota/wire.go @@ -29,7 +29,7 @@ type WireInput struct { TokenEnv string Debug bool PollSec int - Skin string + Theme string } // Wire is the single explicit dependency-assembly point. It loads config, @@ -135,9 +135,9 @@ func Wire(ctx context.Context, in WireInput) (app.Model, config.Config, error) { // override via cfg.OktaStatusEndpoint when self-hosted Okta // orgs run a different statuspage (rare). OktaStatusEndpoint: oktastatus.DefaultEndpoint, - // MW-10 — forward the CLI `--skin` flag. Empty string falls + // MW-10 — forward the CLI `--theme` flag. Empty string falls // back to the default dark palette. - Skin: in.Skin, + Theme: in.Theme, }) return model, cfg, nil } diff --git a/internal/app/app.go b/internal/app/app.go index 7d4adb4..e3d4c72 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -386,11 +386,11 @@ type Deps struct { // outbound HTTP. main.go sets it to the public default. OktaStatusEndpoint string - // Skin is the initial theme skin name (MW-10). Empty resolves - // to "dark". Loaded via shared.LoadSkin — falls back to the + // Theme is the initial theme theme name (MW-10). Empty resolves + // to "dark". Loaded via shared.LoadTheme — falls back to the // built-in dark palette (with a warning to the logger) if the // name is unknown. - Skin string + Theme string // Optional initial state for tests / direct embedding. InitialScreen Screen @@ -527,12 +527,12 @@ type Model struct { // New constructs the App Shell. The initial screen is materialized eagerly // so Init() can return its first Cmd directly. func New(deps Deps) Model { - // MW-10 — resolve the initial skin. Unknown names fall through + // MW-10 — resolve the initial theme. Unknown names fall through // to whatever init() left in place (dark), with a warning so - // operators see why their `--skin=nope` didn't take effect. - if skin := strings.TrimSpace(deps.Skin); skin != "" { - if _, err := shared.SetActiveSkin(skin); err != nil && deps.Logger != nil { - deps.Logger.Warn("skin load failed; falling back to dark", "skin", skin, "err", err.Error()) + // operators see why their `--theme=nope` didn't take effect. + if theme := strings.TrimSpace(deps.Theme); theme != "" { + if _, err := shared.SetActiveTheme(theme); err != nil && deps.Logger != nil { + deps.Logger.Warn("theme load failed; falling back to dark", "theme", theme, "err", err.Error()) } } m := Model{ @@ -2323,7 +2323,7 @@ func tenantFromOrgURL(orgURL string) string { } // activeTokens picks the token set. MW-10 — thin passthrough to the -// shared active-skin state so `:skin ` / --skin updates +// shared active-theme state so `:theme ` / --theme updates // take effect on the next render. NO_COLOR still short-circuits // inside shared.ActiveTokens. func activeTokens() shared.Tokens { @@ -2869,16 +2869,16 @@ func (m Model) handlePaletteKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, toastCmdInfo("select a user before running :xray") } return m, openXRayCmd(user) - case paletteCmdSkin: - // MW-10 — swap the active skin. Empty arg → usage toast. + case paletteCmdTheme: + // MW-10 — swap the active theme. Empty arg → usage toast. name := strings.TrimSpace(arg) if name == "" { - return m, toastCmdInfo("usage: :skin — try " + strings.Join(shared.BuiltinSkinNames(), " / ")) + return m, toastCmdInfo("usage: :theme — try " + strings.Join(shared.BuiltinSkinNames(), " / ")) } - if _, err := shared.SetActiveSkin(name); err != nil { + if _, err := shared.SetActiveTheme(name); err != nil { return m, toastCmdError(ErrorMsg{Err: err}) } - return m, toastCmdInfo("skin: " + name) + return m, toastCmdInfo("theme: " + name) } return m, nil case tea.KeyBackspace: @@ -2955,9 +2955,9 @@ func paletteCommandPool() []string { // MW-1 — `:xray` opens the user-scoped dependency tree. "xray", "apilog", - // MW-10 — `:skin` hot-swap. Autocomplete stops at the verb; + // MW-10 — `:theme` hot-swap. Autocomplete stops at the verb; // the operator supplies the name after a space. - "skin", + "theme", "help", "quit", } } @@ -3258,9 +3258,9 @@ const ( // Arg may carry "local" / "utc" to force a specific mode instead // of toggling. paletteCmdTimezone - // paletteCmdSkin hot-swaps the active theme skin (MW-10). The - // skin name lives in the `arg` return slot. - paletteCmdSkin + // paletteCmdTheme hot-swaps the active theme (MW-10). The + // theme name lives in the `arg` return slot. + paletteCmdTheme ) // UnmaskFieldMsg / MaskAllMsg are re-exported from the shared msgs @@ -3314,11 +3314,11 @@ func resolvePaletteCommand(raw string) (kind paletteCmdKind, screen Screen, arg return paletteCmdTimezone, 0, "local", true case "tz utc", "timezone utc": return paletteCmdTimezone, 0, "utc", true - case "skin", "theme": - // MW-10 — `:skin ` hot-swaps the active palette. + case "theme": + // MW-10 — `:theme ` hot-swaps the active palette. // Missing arg is a no-op-with-toast (handled by the caller // via ok=true + empty arg → toast). - return paletteCmdSkin, 0, rest, true + return paletteCmdTheme, 0, rest, true } // Direct policy-type routes (issue #165). The verb arg field // carries the canonical PolicyType so the App Shell can build a diff --git a/internal/app/skin_palette_test.go b/internal/app/theme_palette_test.go similarity index 65% rename from internal/app/skin_palette_test.go rename to internal/app/theme_palette_test.go index 8d22a00..8cde96a 100644 --- a/internal/app/skin_palette_test.go +++ b/internal/app/theme_palette_test.go @@ -1,10 +1,10 @@ package app_test -// MW-10 — palette + Deps.Skin smoke tests. Pins the contract that -// - `app.New(Deps{Skin: "light"})` swaps the shared active skin -// - `:skin light` typed through the palette overlay hot-swaps and +// MW-10 — palette + Deps.Theme smoke tests. Pins the contract that +// - `app.New(Deps{Theme: "light"})` swaps the shared active theme +// - `:theme light` typed through the palette overlay hot-swaps and // leaves the app on its current screen (no navigation side-effect) -// - `:skin nope` errors — the previous active skin stays put +// - `:theme nope` errors — the previous active theme stays put import ( "testing" @@ -19,43 +19,43 @@ import ( func Test_Deps_Skin_SetsActiveSkin(t *testing.T) { // Restore the default afterwards so this test can't leak into - // other packages sharing the same shared.SetActiveSkin state. - t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + // other packages sharing the same shared.SetActiveTheme state. + t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) _ = app.New(app.Deps{ InitialScreen: app.ScreenUsers, - Skin: "light", + Theme: "light", }) assert.Equal(t, "light", shared.ActiveSkinName(), - "app.New(Deps{Skin:\"light\"}) must call SetActiveSkin") + "app.New(Deps{Theme:\"light\"}) must call SetActiveTheme") } func Test_Palette_SkinCommand_HotSwaps(t *testing.T) { - t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) - _, err := shared.SetActiveSkin("dark") + _, err := shared.SetActiveTheme("dark") require.NoError(t, err) m := app.New(app.Deps{InitialScreen: app.ScreenUsers}) - got := drivePalette(t, m, "skin light") + got := drivePalette(t, m, "theme light") assert.Equal(t, "users", app.ActiveScreenName(got), - ":skin must not change the active screen") + ":theme must not change the active screen") assert.Equal(t, "light", shared.ActiveSkinName(), - ":skin light must swap the active skin") + ":theme light must swap the active theme") } func Test_Palette_SkinCommand_UnknownName(t *testing.T) { - t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) - _, err := shared.SetActiveSkin("dark") + _, err := shared.SetActiveTheme("dark") require.NoError(t, err) m := app.New(app.Deps{InitialScreen: app.ScreenUsers}) - _ = drivePalette(t, m, "skin nope-does-not-exist") + _ = drivePalette(t, m, "theme nope-does-not-exist") assert.Equal(t, "dark", shared.ActiveSkinName(), - ":skin nope must leave the previous active skin untouched") + ":theme nope must leave the previous active theme untouched") } // drivePalette opens the palette, types cmd, hits Enter, and drains diff --git a/internal/tui/groups/groups.go b/internal/tui/groups/groups.go index 562c96a..0ad2c51 100644 --- a/internal/tui/groups/groups.go +++ b/internal/tui/groups/groups.go @@ -1220,7 +1220,7 @@ func strconvI(n int) string { } // activeTokens picks the right token set. MW-10 — routed through -// shared.ActiveTokens so `:skin` / --skin hot-swaps land on the next +// shared.ActiveTokens so `:theme` / --theme hot-swaps land on the next // render. NO_COLOR still short-circuits inside ActiveTokens. func activeTokens() shared.Tokens { return shared.ActiveTokens() diff --git a/internal/tui/overlay/overlay.go b/internal/tui/overlay/overlay.go index 3f864f9..8a8a79a 100644 --- a/internal/tui/overlay/overlay.go +++ b/internal/tui/overlay/overlay.go @@ -22,7 +22,7 @@ var paletteHints = []string{ ":network-zones", ":authorization-servers", ":api-tokens", ":administrators", ":profile", ":search", ":filter", ":unmask", ":mask", ":raw", ":refresh", ":about", ":ratelimit", ":errors", ":healthcheck", - ":apilog", ":tz", ":debug", ":skin", ":help", ":quit", + ":apilog", ":tz", ":debug", ":theme", ":help", ":quit", } // paletteHintsStripped is paletteHints with the leading ":" removed — @@ -475,7 +475,7 @@ func paletteHelpEntries() []helpEntry { {":tz", "toggle timezone (Local ↔ UTC)"}, {":unmask ", "reveal a masked field"}, {":mask", "re-mask PII fields"}, - {":skin ", "swap theme skin (dark / light / high-contrast)"}, + {":theme ", "swap theme theme (dark / light / high-contrast)"}, {":help", "this overlay"}, {":quit", "quit ota"}, } diff --git a/internal/tui/overlay/testdata/golden/help_screen_users.txt b/internal/tui/overlay/testdata/golden/help_screen_users.txt index 79eb0ff..c17b24c 100644 --- a/internal/tui/overlay/testdata/golden/help_screen_users.txt +++ b/internal/tui/overlay/testdata/golden/help_screen_users.txt @@ -1,27 +1,27 @@ -╭─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ -│ Help · Users List │ -├─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ -│ Press Esc to close │ -│ │ -│ ── Resource ── │ ── General ── │ ── Navigation ── │ ── Palette ── │ -│ Enter / d open detail (all attributes) │ : open command palette │ j / k cursor down / up │ :users Users │ -│ e edit profile form │ / incremental search (lists) │ h / l scroll columns left / right │ :groups Groups │ -│ s change status (pick from valid transitions) │ ? this help │ gg / G top / bottom │ :group-rules Group Rules │ -│ x XRay: Groups → Rules / Policies / Apps │ ~ Okta API call timeline overlay │ Ctrl-d / Ctrl-u half-page down / up │ :policies Policies │ -│ l open Logs scoped to this user │ a resource action menu │ Ctrl-f / Ctrl-b page down / up │ :apps Apps │ -│ Shift+S sort by STATUS │ l open Logs scoped to current resource │ │ :authenticators Authenticators │ -│ Shift+N sort by NAME (login) │ z toggle timezone (Local ↔ UTC) │ │ :logs System Log │ -│ Shift+L sort by LAST LOGIN │ R refresh active screen │ │ :network-zones Network Zones │ -│ Shift+C sort by CREATED / CHANGED │ Esc back · cancel mode · close overlay │ │ :authorization-servers Authorization Servers │ -│ :reset-password send reset-password email │ q close screen / quit (with confirm) │ │ :api-tokens API Tokens │ -│ :unlock clear LOCKED_OUT state │ Ctrl-c soft quit (tail confirm) │ │ :administrators Administrators │ -│ :reset-mfa remove enrolled MFA factors │ Ctrl-l force redraw │ │ :apilog API timeline overlay │ -│ :xray XRay tree (uses cursor user) │ │ │ :tz toggle timezone (Local ↔ UTC) │ -│ │ │ │ :unmask reveal a masked field │ -│ │ │ │ :mask re-mask PII fields │ -│ │ │ │ :skin swap theme skin (dark / light / high-contrast) │ -│ │ │ │ :help this overlay │ -│ │ │ │ :quit quit ota │ -│ │ -│ close · filter │ -╰─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ Help · Users List │ +├──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────┤ +│ Press Esc to close │ +│ │ +│ ── Resource ── │ ── General ── │ ── Navigation ── │ ── Palette ── │ +│ Enter / d open detail (all attributes) │ : open command palette │ j / k cursor down / up │ :users Users │ +│ e edit profile form │ / incremental search (lists) │ h / l scroll columns left / right │ :groups Groups │ +│ s change status (pick from valid transitions) │ ? this help │ gg / G top / bottom │ :group-rules Group Rules │ +│ x XRay: Groups → Rules / Policies / Apps │ ~ Okta API call timeline overlay │ Ctrl-d / Ctrl-u half-page down / up │ :policies Policies │ +│ l open Logs scoped to this user │ a resource action menu │ Ctrl-f / Ctrl-b page down / up │ :apps Apps │ +│ Shift+S sort by STATUS │ l open Logs scoped to current resource │ │ :authenticators Authenticators │ +│ Shift+N sort by NAME (login) │ z toggle timezone (Local ↔ UTC) │ │ :logs System Log │ +│ Shift+L sort by LAST LOGIN │ R refresh active screen │ │ :network-zones Network Zones │ +│ Shift+C sort by CREATED / CHANGED │ Esc back · cancel mode · close overlay │ │ :authorization-servers Authorization Servers │ +│ :reset-password send reset-password email │ q close screen / quit (with confirm) │ │ :api-tokens API Tokens │ +│ :unlock clear LOCKED_OUT state │ Ctrl-c soft quit (tail confirm) │ │ :administrators Administrators │ +│ :reset-mfa remove enrolled MFA factors │ Ctrl-l force redraw │ │ :apilog API timeline overlay │ +│ :xray XRay tree (uses cursor user) │ │ │ :tz toggle timezone (Local ↔ UTC) │ +│ │ │ │ :unmask reveal a masked field │ +│ │ │ │ :mask re-mask PII fields │ +│ │ │ │ :theme swap theme theme (dark / light / high-contrast) │ +│ │ │ │ :help this overlay │ +│ │ │ │ :quit quit ota │ +│ │ +│ close · filter │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ \ No newline at end of file diff --git a/internal/tui/overlay/testdata/golden/palette_default.txt b/internal/tui/overlay/testdata/golden/palette_default.txt index 4775d47..00e140d 100644 --- a/internal/tui/overlay/testdata/golden/palette_default.txt +++ b/internal/tui/overlay/testdata/golden/palette_default.txt @@ -28,7 +28,7 @@ │ :apilog │ │ :tz │ │ :debug │ -│ :skin │ +│ :theme │ │ :help │ │ :quit │ │ │ diff --git a/internal/tui/shared/skins.go b/internal/tui/shared/themes.go similarity index 65% rename from internal/tui/shared/skins.go rename to internal/tui/shared/themes.go index 4dc3993..67fc4c6 100644 --- a/internal/tui/shared/skins.go +++ b/internal/tui/shared/themes.go @@ -12,19 +12,19 @@ import ( "gopkg.in/yaml.v3" ) -// MW-10 — theme skin system. A SkinDef is the on-disk (YAML) form of +// MW-10 — theme theme system. A ThemeDef is the on-disk (YAML) form of // a Tokens palette. Three built-ins ship embedded (dark, light, -// high-contrast); operators can drop custom skins into -// ~/.config/ota/skins/.yaml and select them via `--skin ` -// or `:skin `. +// high-contrast); operators can drop custom themes into +// ~/.config/ota/themes/.yaml and select them via `--theme ` +// or `:theme `. -//go:embed skins/*.yaml +//go:embed themes/*.yaml var builtinSkinsFS embed.FS -// SkinTone is one Tokens field's serialised form — foreground / background +// ThemeTone is one Tokens field's serialised form — foreground / background // colour plus a handful of SGR attributes. Every field is optional so a -// skin can express fg-only, bg-only, or attribute-only tones. -type SkinTone struct { +// theme can express fg-only, bg-only, or attribute-only tones. +type ThemeTone struct { FG string `yaml:"fg,omitempty"` BG string `yaml:"bg,omitempty"` Bold bool `yaml:"bold,omitempty"` @@ -34,15 +34,15 @@ type SkinTone struct { Italic bool `yaml:"italic,omitempty"` } -// SkinDef is the YAML shape of a skin: a name plus one tone per Tokens +// ThemeDef is the YAML shape of a theme: a name plus one tone per Tokens // field, keyed by the snake_case field name (see toneKeys below). -type SkinDef struct { +type ThemeDef struct { Name string `yaml:"name"` - Tones map[string]SkinTone `yaml:"tones"` + Tones map[string]ThemeTone `yaml:"tones"` } // Style materialises this tone into a lipgloss.Style. -func (t SkinTone) Style() lipgloss.Style { +func (t ThemeTone) Style() lipgloss.Style { s := lipgloss.NewStyle() if t.FG != "" { s = s.Foreground(lipgloss.Color(t.FG)) @@ -68,9 +68,9 @@ func (t SkinTone) Style() lipgloss.Style { return s } -// Tokens converts the SkinDef into a fully-populated Tokens set. Missing -// tones fall back to plain (unstyled) so partial skins render legibly. -func (d SkinDef) Tokens() Tokens { +// Tokens converts the ThemeDef into a fully-populated Tokens set. Missing +// tones fall back to plain (unstyled) so partial themes render legibly. +func (d ThemeDef) Tokens() Tokens { tone := func(k string) lipgloss.Style { return d.Tones[k].Style() } return Tokens{ BG: tone("bg"), @@ -100,22 +100,22 @@ func (d SkinDef) Tokens() Tokens { // use underscores so YAML anchors read naturally; the lookup name is // hyphenated to match ThemeName / CLI convention. var builtinSkins = map[string]string{ - "dark": "skins/dark.yaml", - "light": "skins/light.yaml", - "high-contrast": "skins/high_contrast.yaml", + "dark": "themes/dark.yaml", + "light": "themes/light.yaml", + "high-contrast": "themes/high_contrast.yaml", } -// BuiltinSkinNames returns the embedded skin names in a stable order — +// BuiltinSkinNames returns the embedded theme names in a stable order — // used by palette autocomplete and by tests iterating every builtin. func BuiltinSkinNames() []string { return []string{"dark", "light", "high-contrast"} } -// LoadSkin resolves a skin name to its Tokens. Search order: -// 1. Embedded skins (dark / light / high-contrast). -// 2. ~/.config/ota/skins/.yaml on disk. +// LoadTheme resolves a theme name to its Tokens. Search order: +// 1. Embedded themes (dark / light / high-contrast). +// 2. ~/.config/ota/themes/.yaml on disk. // // Returns an error when neither source has the name — callers should // surface a toast so the operator sees why nothing changed. -func LoadSkin(name string) (Tokens, error) { +func LoadTheme(name string) (Tokens, error) { def, err := LoadSkinDef(name) if err != nil { return Tokens{}, err @@ -123,39 +123,39 @@ func LoadSkin(name string) (Tokens, error) { return def.Tokens(), nil } -// LoadSkinDef is LoadSkin's structural counterpart — returns the raw -// SkinDef so callers (tests, YAML round-trip) can inspect it before +// LoadSkinDef is LoadTheme's structural counterpart — returns the raw +// ThemeDef so callers (tests, YAML round-trip) can inspect it before // materialising Tokens. -func LoadSkinDef(name string) (SkinDef, error) { +func LoadSkinDef(name string) (ThemeDef, error) { trimmed := strings.TrimSpace(name) if trimmed == "" { - return SkinDef{}, fmt.Errorf("skin name is empty") + return ThemeDef{}, fmt.Errorf("theme name is empty") } if path, ok := builtinSkins[trimmed]; ok { data, err := builtinSkinsFS.ReadFile(path) if err != nil { - return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + return ThemeDef{}, fmt.Errorf("theme %q: %w", trimmed, err) } return parseSkinDef(data, trimmed) } path, err := userSkinPath(trimmed) if err != nil { - return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + return ThemeDef{}, fmt.Errorf("theme %q: %w", trimmed, err) } data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { - return SkinDef{}, fmt.Errorf("skin %q not found (looked in embedded skins and %s)", trimmed, path) + return ThemeDef{}, fmt.Errorf("theme %q not found (looked in embedded themes and %s)", trimmed, path) } - return SkinDef{}, fmt.Errorf("skin %q: %w", trimmed, err) + return ThemeDef{}, fmt.Errorf("theme %q: %w", trimmed, err) } return parseSkinDef(data, trimmed) } -func parseSkinDef(data []byte, want string) (SkinDef, error) { - var def SkinDef +func parseSkinDef(data []byte, want string) (ThemeDef, error) { + var def ThemeDef if err := yaml.Unmarshal(data, &def); err != nil { - return SkinDef{}, fmt.Errorf("skin %q: parse: %w", want, err) + return ThemeDef{}, fmt.Errorf("theme %q: parse: %w", want, err) } if def.Name == "" { def.Name = want @@ -163,8 +163,8 @@ func parseSkinDef(data []byte, want string) (SkinDef, error) { return def, nil } -// userSkinPath maps a skin name to its expected filesystem location. -// Honours XDG_CONFIG_HOME then falls back to $HOME/.config/ota/skins. +// userSkinPath maps a theme name to its expected filesystem location. +// Honours XDG_CONFIG_HOME then falls back to $HOME/.config/ota/themes. func userSkinPath(name string) (string, error) { base := os.Getenv("XDG_CONFIG_HOME") if base == "" { @@ -174,13 +174,13 @@ func userSkinPath(name string) (string, error) { } base = filepath.Join(home, ".config") } - return filepath.Join(base, "ota", "skins", name+".yaml"), nil + return filepath.Join(base, "ota", "themes", name+".yaml"), nil } -// --- Active skin state --------------------------------------------------- +// --- Active theme state --------------------------------------------------- // // Screens read the live Tokens via ActiveTokens() every View() so a -// runtime skin swap (`:skin light`) reflects on the next frame. The +// runtime theme swap (`:theme light`) reflects on the next frame. The // state is mutable-under-mutex so goroutines racing on Update / View // stay safe. var ( @@ -190,7 +190,7 @@ var ( ) func init() { - if tk, err := LoadSkin("dark"); err == nil { + if tk, err := LoadTheme("dark"); err == nil { activeTokens = tk } else { // Fallback to the compiled-in Dark() so the app is never left @@ -199,12 +199,12 @@ func init() { } } -// SetActiveSkin loads name and — on success — makes it the active +// SetActiveTheme loads name and — on success — makes it the active // Tokens for subsequent ActiveTokens() calls. Returns the loaded // Tokens plus any error so callers can surface a toast on failure // without mutating state. -func SetActiveSkin(name string) (Tokens, error) { - tk, err := LoadSkin(name) +func SetActiveTheme(name string) (Tokens, error) { + tk, err := LoadTheme(name) if err != nil { return Tokens{}, err } @@ -228,7 +228,7 @@ func ActiveTokens() Tokens { return activeTokens } -// ActiveSkinName reports the currently-loaded skin identifier. +// ActiveSkinName reports the currently-loaded theme identifier. func ActiveSkinName() string { activeMu.RLock() defer activeMu.RUnlock() diff --git a/internal/tui/shared/skins/dark.yaml b/internal/tui/shared/themes/dark.yaml similarity index 89% rename from internal/tui/shared/skins/dark.yaml rename to internal/tui/shared/themes/dark.yaml index 72e14bf..4206bad 100644 --- a/internal/tui/shared/skins/dark.yaml +++ b/internal/tui/shared/themes/dark.yaml @@ -1,6 +1,6 @@ -# Default dark skin — reproduces the historical shared.Dark() palette +# Default dark theme — reproduces the historical shared.Dark() palette # so existing snapshots/goldens stay bit-for-bit identical when -# --skin=dark is selected. +# --theme=dark is selected. name: dark tones: bg: {bg: "#0b0f14"} diff --git a/internal/tui/shared/skins/high_contrast.yaml b/internal/tui/shared/themes/high_contrast.yaml similarity index 94% rename from internal/tui/shared/skins/high_contrast.yaml rename to internal/tui/shared/themes/high_contrast.yaml index aecaa6e..0d91df7 100644 --- a/internal/tui/shared/skins/high_contrast.yaml +++ b/internal/tui/shared/themes/high_contrast.yaml @@ -1,4 +1,4 @@ -# High-contrast skin — pure black/white palette that leans on SGR +# High-contrast theme — pure black/white palette that leans on SGR # attributes (bold / reverse / underline) for role recognition so it # renders legibly under NO_COLOR too. Every role stays distinct via # attribute shape rather than hue. diff --git a/internal/tui/shared/skins/light.yaml b/internal/tui/shared/themes/light.yaml similarity index 92% rename from internal/tui/shared/skins/light.yaml rename to internal/tui/shared/themes/light.yaml index e23941f..653766d 100644 --- a/internal/tui/shared/skins/light.yaml +++ b/internal/tui/shared/themes/light.yaml @@ -1,4 +1,4 @@ -# Light skin — inverse of dark for white-background terminals. Reproduces +# Light theme — inverse of dark for white-background terminals. Reproduces # the historical shared.Light() palette so existing snapshots stay stable. name: light tones: diff --git a/internal/tui/shared/skins_test.go b/internal/tui/shared/themes_test.go similarity index 64% rename from internal/tui/shared/skins_test.go rename to internal/tui/shared/themes_test.go index 91785e8..3237194 100644 --- a/internal/tui/shared/skins_test.go +++ b/internal/tui/shared/themes_test.go @@ -1,11 +1,11 @@ package shared_test -// MW-10 — theme skin system tests. Verifies that: -// - every built-in skin loads without error and yields a populated Tokens -// - built-in skins are visually distinct (no accidental duplicate) +// MW-10 — theme theme system tests. Verifies that: +// - every built-in theme loads without error and yields a populated Tokens +// - built-in themes are visually distinct (no accidental duplicate) // - YAML round-trip (marshal → unmarshal → materialise) preserves the // palette bit-for-bit -// - SetActiveSkin swaps the live tokens without touching NO_COLOR +// - SetActiveTheme swaps the live tokens without touching NO_COLOR import ( "os" @@ -21,25 +21,25 @@ import ( func Test_LoadSkin_AllBuiltinsResolve(t *testing.T) { os.Unsetenv("NO_COLOR") for _, name := range shared.BuiltinSkinNames() { - tk, err := shared.LoadSkin(name) - require.NoError(t, err, "LoadSkin(%q) must succeed", name) - require.NotNil(t, tk, "LoadSkin(%q) returned zero Tokens", name) + tk, err := shared.LoadTheme(name) + require.NoError(t, err, "LoadTheme(%q) must succeed", name) + require.NotNil(t, tk, "LoadTheme(%q) returned zero Tokens", name) } } func Test_LoadSkinDef_UnknownReturnsError(t *testing.T) { _, err := shared.LoadSkinDef("does-not-exist-anywhere") - require.Error(t, err, "unknown skin name must error") + require.Error(t, err, "unknown theme name must error") } func Test_LoadSkinDef_EmptyNameErrors(t *testing.T) { _, err := shared.LoadSkinDef("") - require.Error(t, err, "empty skin name must error") + require.Error(t, err, "empty theme name must error") } -// Each built-in skin must be visually distinct — at minimum the tone +// Each built-in theme must be visually distinct — at minimum the tone // tables differ. Otherwise the "3 built-in themes" promise is a lie. -// Compares raw SkinTone maps rather than lipgloss.Render output so the +// Compares raw ThemeTone maps rather than lipgloss.Render output so the // test doesn't depend on the runtime colour profile (tests run under // Ascii and would otherwise strip every ANSI code). func Test_LoadSkin_BuiltinsAreDistinct(t *testing.T) { @@ -54,14 +54,14 @@ func Test_LoadSkin_BuiltinsAreDistinct(t *testing.T) { seen := map[string]string{} for name, fp := range fingerprints { if prev, ok := seen[fp]; ok { - t.Fatalf("skins %q and %q have identical palette fingerprints — must differ", prev, name) + t.Fatalf("themes %q and %q have identical palette fingerprints — must differ", prev, name) } seen[fp] = name } } -// YAML round-trip: marshalling a SkinDef back to YAML and re-parsing it -// must yield an identical tone map. Guards against a future SkinTone +// YAML round-trip: marshalling a ThemeDef back to YAML and re-parsing it +// must yield an identical tone map. Guards against a future ThemeTone // field addition that forgets `yaml:` tags. func Test_SkinDef_YAMLRoundTrip(t *testing.T) { os.Unsetenv("NO_COLOR") @@ -72,23 +72,23 @@ func Test_SkinDef_YAMLRoundTrip(t *testing.T) { out, err := yaml.Marshal(def) require.NoError(t, err, "marshal %q", name) - var reparsed shared.SkinDef + var reparsed shared.ThemeDef require.NoError(t, yaml.Unmarshal(out, &reparsed), "unmarshal %q", name) - assert.Equal(t, def.Name, reparsed.Name, "skin %q name preserved", name) + assert.Equal(t, def.Name, reparsed.Name, "theme %q name preserved", name) assert.Equal(t, def.Tones, reparsed.Tones, - "skin %q tone map survives round-trip", name) + "theme %q tone map survives round-trip", name) } } -// SetActiveSkin swaps the live tokens and ActiveTokens reflects it. -// Uses the underlying SkinDef fingerprint (not lipgloss.Render output) +// SetActiveTheme swaps the live tokens and ActiveTokens reflects it. +// Uses the underlying ThemeDef fingerprint (not lipgloss.Render output) // so the assertion works regardless of the runtime colour profile. func Test_SetActiveSkin_SwapsLiveTokens(t *testing.T) { os.Unsetenv("NO_COLOR") // Restore whatever was live before the test — otherwise a stray // `light` leaks into every subsequent test in this package. - t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) darkDef, err := shared.LoadSkinDef("dark") require.NoError(t, err) @@ -97,56 +97,56 @@ func Test_SetActiveSkin_SwapsLiveTokens(t *testing.T) { require.NotEqual(t, darkDef.Tones["fg"].FG, lightDef.Tones["fg"].FG, "dark and light must ship different fg tones") - _, err = shared.SetActiveSkin("dark") + _, err = shared.SetActiveTheme("dark") require.NoError(t, err) assert.Equal(t, "dark", shared.ActiveSkinName()) - _, err = shared.SetActiveSkin("light") + _, err = shared.SetActiveTheme("light") require.NoError(t, err) assert.Equal(t, "light", shared.ActiveSkinName()) } -// SetActiveSkin on an unknown name must leave the previous active -// skin untouched — a failed swap should not blank the palette. +// SetActiveTheme on an unknown name must leave the previous active +// theme untouched — a failed swap should not blank the palette. func Test_SetActiveSkin_UnknownLeavesActiveIntact(t *testing.T) { os.Unsetenv("NO_COLOR") - t.Cleanup(func() { _, _ = shared.SetActiveSkin("dark") }) + t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) - _, err := shared.SetActiveSkin("dark") + _, err := shared.SetActiveTheme("dark") require.NoError(t, err) - _, err = shared.SetActiveSkin("nonexistent-xyz") - require.Error(t, err, "unknown skin must error") + _, err = shared.SetActiveTheme("nonexistent-xyz") + require.Error(t, err, "unknown theme must error") assert.Equal(t, "dark", shared.ActiveSkinName(), - "failed skin swap must not mutate active skin name") + "failed theme swap must not mutate active theme name") } -// NO_COLOR beats the active skin — ActiveTokens must ignore the -// swapped skin and return the Monochrome palette. +// NO_COLOR beats the active theme — ActiveTokens must ignore the +// swapped theme and return the Monochrome palette. func Test_ActiveTokens_NOCOLOR_TrumpsSkin(t *testing.T) { t.Cleanup(func() { os.Unsetenv("NO_COLOR") - _, _ = shared.SetActiveSkin("dark") + _, _ = shared.SetActiveTheme("dark") }) - _, err := shared.SetActiveSkin("light") + _, err := shared.SetActiveTheme("light") require.NoError(t, err) t.Setenv("NO_COLOR", "1") // Under NO_COLOR the FG/BG lipgloss styles come from Monochrome() // — verify by comparing the foreground descriptor to the one // Monochrome() produces (both are NoColor{}, distinct from any - // coloured skin's Color("#xxxxxx")). + // coloured theme's Color("#xxxxxx")). assert.Equal(t, shared.Monochrome().FG.GetForeground(), shared.ActiveTokens().FG.GetForeground(), "NO_COLOR must force Monochrome foreground") assert.Equal(t, shared.Monochrome().Danger.GetBold(), shared.ActiveTokens().Danger.GetBold(), "NO_COLOR must force Monochrome attribute stack") } -// LoadSkin from a user-config path succeeds when the file exists. +// LoadTheme from a user-config path succeeds when the file exists. func Test_LoadSkin_UserConfigPath(t *testing.T) { dir := t.TempDir() - skinDir := dir + "/ota/skins" + skinDir := dir + "/ota/themes" require.NoError(t, os.MkdirAll(skinDir, 0o755)) yamlBody := `name: nord-lite @@ -165,7 +165,7 @@ tones: assert.Equal(t, "#bf616a", def.Tones["danger"].FG) assert.True(t, def.Tones["danger"].Bold) - tk, err := shared.LoadSkin("nord-lite") + tk, err := shared.LoadTheme("nord-lite") require.NoError(t, err) assert.NotNil(t, tk) } From d065f4c6a9b63b43957767f512dddcf0be7409f0 Mon Sep 17 00:00:00 2001 From: Byungjin Park Date: Sun, 5 Jul 2026 21:52:43 +0900 Subject: [PATCH 3/3] fix(theme): validate name + tones, cache NO_COLOR, use t.Setenv Address PR #4 review comments on the MW-10 theme system: - LoadTheme: reject empty, "..", "/", "\\" theme names before touching disk so `--theme=../../etc/passwd` can never escape ~/.config/ota/themes. - parseSkinDef: whitelist tone keys and reject empty tone bodies / empty tones map so silent YAML typos (e.g. `body_bg` for `bg`) fail loud instead of yielding an unstyled theme. - MonochromeEnabled: cache NO_COLOR at package init so ActiveTokens() doesn't syscall on every render. Add RefreshMonochromeEnabledForTest for tests that mutate NO_COLOR at runtime; wire it into testfx.PinTestEnvironment so golden tests keep observing the pinned value. - themes_test / styles_test: replace os.Unsetenv + t.Cleanup env-restore boilerplate with a small helper that calls t.Setenv("NO_COLOR", "") and refreshes the cache. Adds regression tests for the new validation paths (unsafe names, unknown tone keys, empty tone / tones map). --- internal/testfx/profile.go | 4 ++ internal/tui/shared/styles.go | 16 +++++- internal/tui/shared/styles_test.go | 27 ++++++---- internal/tui/shared/themes.go | 67 +++++++++++++++++++++-- internal/tui/shared/themes_test.go | 87 +++++++++++++++++++++++++++--- 5 files changed, 182 insertions(+), 19 deletions(-) diff --git a/internal/testfx/profile.go b/internal/testfx/profile.go index fe7256e..ac1a76d 100644 --- a/internal/testfx/profile.go +++ b/internal/testfx/profile.go @@ -23,6 +23,10 @@ import ( // Call once from an init() at the top of each *_golden_test.go. func PinTestEnvironment() { _ = os.Setenv("NO_COLOR", "1") + // shared.MonochromeEnabled() caches NO_COLOR at package init; the + // shared package initialises before this init() runs, so refresh + // the cache to observe the value we just set. + shared.RefreshMonochromeEnabledForTest() lipgloss.SetColorProfile(termenv.Ascii) shared.SetTZ(shared.TZUTC) } diff --git a/internal/tui/shared/styles.go b/internal/tui/shared/styles.go index e684e4a..d7ad9f6 100644 --- a/internal/tui/shared/styles.go +++ b/internal/tui/shared/styles.go @@ -50,11 +50,25 @@ type Tokens struct { RowChanged lipgloss.Style } +// monochromeEnabled caches the NO_COLOR env-var lookup so ActiveTokens() +// (called on every View() render) doesn't syscall. Refresh via +// RefreshMonochromeEnabledForTest from tests that mutate NO_COLOR. +var monochromeEnabled = os.Getenv("NO_COLOR") != "" + // MonochromeEnabled reports whether ota should render without colour. Set by // the standard NO_COLOR environment variable (PRD §6.4 / TUI_DESIGN §6.2). // Callers typically branch on this when choosing a token set at startup. +// The value is captured at package init; tests that mutate NO_COLOR at +// runtime must call RefreshMonochromeEnabledForTest to observe the change. func MonochromeEnabled() bool { - return os.Getenv("NO_COLOR") != "" + return monochromeEnabled +} + +// RefreshMonochromeEnabledForTest re-reads NO_COLOR into the cached +// flag. Intended only for tests that call t.Setenv("NO_COLOR", ...) — +// production code doesn't mutate NO_COLOR at runtime. +func RefreshMonochromeEnabledForTest() { + monochromeEnabled = os.Getenv("NO_COLOR") != "" } // ThemeName classifies the active token set. Issue #U12 v0.2.5 — adds diff --git a/internal/tui/shared/styles_test.go b/internal/tui/shared/styles_test.go index d0d9995..a8bf66a 100644 --- a/internal/tui/shared/styles_test.go +++ b/internal/tui/shared/styles_test.go @@ -4,7 +4,6 @@ package shared_test // TUI_DESIGN §6.2 "monochrome (NO_COLOR 감지): 색 제거, 기호만 사용. 포커스는 reverse video로." import ( - "os" "testing" "github.com/stretchr/testify/assert" @@ -13,10 +12,20 @@ import ( "github.com/tedilabs/ota/internal/tui/shared" ) +// setNoColor mutates NO_COLOR for the duration of the test and +// refreshes the cached MonochromeEnabled flag. t.Setenv auto-restores +// the env var; a Cleanup restores the cache. +func setNoColor(t *testing.T, value string) { + t.Helper() + t.Setenv("NO_COLOR", value) + shared.RefreshMonochromeEnabledForTest() + t.Cleanup(shared.RefreshMonochromeEnabledForTest) +} + // NO_COLOR 환경 변수가 설정되면 shared 스타일이 monochrome 모드로 동작해야 한다. func Test_Styles_NOCOLOR_EnablesMonochrome(t *testing.T) { // Cannot t.Parallel() due to t.Setenv (Go 1.17+ rule). - t.Setenv("NO_COLOR", "1") + setNoColor(t, "1") require.True(t, shared.MonochromeEnabled(), "NO_COLOR=1 설정 시 monochrome 모드가 활성화되어야 한다 (TUI_DESIGN §6.2, PRD §6.4)") @@ -24,7 +33,7 @@ func Test_Styles_NOCOLOR_EnablesMonochrome(t *testing.T) { // NO_COLOR 미설정이면 normal 모드. func Test_Styles_NoNOCOLOR_ColoredByDefault(t *testing.T) { - os.Unsetenv("NO_COLOR") + setNoColor(t, "") assert.False(t, shared.MonochromeEnabled(), "NO_COLOR 미설정 시 monochrome=false") } @@ -32,14 +41,14 @@ func Test_Styles_NoNOCOLOR_ColoredByDefault(t *testing.T) { // #U12 v0.2.5 — ResolveTheme priority: NO_COLOR wins everything, // override second, COLORFGBG heuristic third, Dark by default. func Test_ResolveTheme_NOCOLOR_TrumpsOverride(t *testing.T) { - t.Setenv("NO_COLOR", "1") + setNoColor(t, "1") t.Setenv("COLORFGBG", "0;15") // would otherwise pick light assert.Equal(t, shared.ThemeMonochrome, shared.ResolveTheme("light"), "NO_COLOR must win over override + COLORFGBG") } func Test_ResolveTheme_OverrideAcceptsKnownNames(t *testing.T) { - os.Unsetenv("NO_COLOR") + setNoColor(t, "") for _, name := range []shared.ThemeName{ shared.ThemeDark, shared.ThemeLight, shared.ThemeHighContrast, shared.ThemeMonochrome, @@ -50,21 +59,21 @@ func Test_ResolveTheme_OverrideAcceptsKnownNames(t *testing.T) { } func Test_ResolveTheme_UnknownOverrideFallsThrough(t *testing.T) { - os.Unsetenv("NO_COLOR") - os.Unsetenv("COLORFGBG") + setNoColor(t, "") + t.Setenv("COLORFGBG", "") assert.Equal(t, shared.ThemeDark, shared.ResolveTheme("nope"), "unknown override must fall through to env detection (Dark default)") } func Test_ResolveTheme_COLORFGBG_LightBgPicksLight(t *testing.T) { - os.Unsetenv("NO_COLOR") + setNoColor(t, "") t.Setenv("COLORFGBG", "0;15") // ANSI white bg assert.Equal(t, shared.ThemeLight, shared.ResolveTheme(""), "COLORFGBG with bg ≥ 8 must pick the Light theme") } func Test_ResolveTheme_COLORFGBG_DarkBgPicksDark(t *testing.T) { - os.Unsetenv("NO_COLOR") + setNoColor(t, "") t.Setenv("COLORFGBG", "15;0") assert.Equal(t, shared.ThemeDark, shared.ResolveTheme(""), "COLORFGBG with bg < 8 must keep Dark default") diff --git a/internal/tui/shared/themes.go b/internal/tui/shared/themes.go index 67fc4c6..5eef414 100644 --- a/internal/tui/shared/themes.go +++ b/internal/tui/shared/themes.go @@ -127,9 +127,9 @@ func LoadTheme(name string) (Tokens, error) { // ThemeDef so callers (tests, YAML round-trip) can inspect it before // materialising Tokens. func LoadSkinDef(name string) (ThemeDef, error) { - trimmed := strings.TrimSpace(name) - if trimmed == "" { - return ThemeDef{}, fmt.Errorf("theme name is empty") + trimmed, err := validateThemeName(name) + if err != nil { + return ThemeDef{}, err } if path, ok := builtinSkins[trimmed]; ok { data, err := builtinSkinsFS.ReadFile(path) @@ -152,6 +152,56 @@ func LoadSkinDef(name string) (ThemeDef, error) { return parseSkinDef(data, trimmed) } +// validateThemeName rejects empty names and any name that could escape +// the ~/.config/ota/themes directory when concatenated with `.yaml` +// (path separators or ".."). Returns the trimmed name for use by +// callers. +func validateThemeName(name string) (string, error) { + trimmed := strings.TrimSpace(name) + if trimmed == "" { + return "", fmt.Errorf("theme name is empty") + } + if trimmed == ".." || strings.ContainsAny(trimmed, "/\\") { + return "", fmt.Errorf("theme name %q is invalid", trimmed) + } + return trimmed, nil +} + +// validToneKeys is the whitelist of tone keys that map to a Tokens +// field via ThemeDef.Tokens. Any key outside this set is a typo — we +// error rather than silently drop it so operators aren't left +// wondering why their theme has no colour. +var validToneKeys = map[string]struct{}{ + "bg": {}, + "fg": {}, + "muted": {}, + "header": {}, + "accent": {}, + "primary": {}, + "success": {}, + "warning": {}, + "danger": {}, + "info": {}, + "magenta": {}, + "badge_sys": {}, + "badge_rule": {}, + "badge_large": {}, + "badge_unmask": {}, + "row_cursor": {}, + "row_danger": {}, + "row_warning": {}, + "row_muted": {}, + "row_changed": {}, +} + +// isEmptyTone reports whether the tone has no colour or attribute +// set. A YAML entry like `fg: {}` almost always indicates a typo, so +// we reject it — if the operator genuinely wants a plain token, they +// should omit the key entirely. +func (t ThemeTone) isEmptyTone() bool { + return t == ThemeTone{} +} + func parseSkinDef(data []byte, want string) (ThemeDef, error) { var def ThemeDef if err := yaml.Unmarshal(data, &def); err != nil { @@ -160,6 +210,17 @@ func parseSkinDef(data []byte, want string) (ThemeDef, error) { if def.Name == "" { def.Name = want } + if len(def.Tones) == 0 { + return ThemeDef{}, fmt.Errorf("theme %q: tones map is empty", want) + } + for key, tone := range def.Tones { + if _, ok := validToneKeys[key]; !ok { + return ThemeDef{}, fmt.Errorf("theme %q: unknown tone key %q", want, key) + } + if tone.isEmptyTone() { + return ThemeDef{}, fmt.Errorf("theme %q: tone %q is empty (set fg/bg or an attribute, or omit the key)", want, key) + } + } return def, nil } diff --git a/internal/tui/shared/themes_test.go b/internal/tui/shared/themes_test.go index 3237194..a6b781e 100644 --- a/internal/tui/shared/themes_test.go +++ b/internal/tui/shared/themes_test.go @@ -18,8 +18,19 @@ import ( "github.com/tedilabs/ota/internal/tui/shared" ) +// clearNoColor removes NO_COLOR for the duration of the test and +// refreshes the cached MonochromeEnabled flag so callers observe the +// change. t.Setenv auto-restores the env var when the test ends; we +// tack on a Cleanup to refresh the cache back. +func clearNoColor(t *testing.T) { + t.Helper() + t.Setenv("NO_COLOR", "") + shared.RefreshMonochromeEnabledForTest() + t.Cleanup(shared.RefreshMonochromeEnabledForTest) +} + func Test_LoadSkin_AllBuiltinsResolve(t *testing.T) { - os.Unsetenv("NO_COLOR") + clearNoColor(t) for _, name := range shared.BuiltinSkinNames() { tk, err := shared.LoadTheme(name) require.NoError(t, err, "LoadTheme(%q) must succeed", name) @@ -37,13 +48,73 @@ func Test_LoadSkinDef_EmptyNameErrors(t *testing.T) { require.Error(t, err, "empty theme name must error") } +// Path-traversal guard: names containing separators or ".." must be +// rejected before touching the filesystem so `--theme=../../etc/passwd` +// (or similar) can never read outside ~/.config/ota/themes. +func Test_LoadSkinDef_RejectsUnsafeNames(t *testing.T) { + for _, name := range []string{"..", "../evil", "foo/bar", "foo\\bar", "/etc/passwd"} { + _, err := shared.LoadSkinDef(name) + require.Error(t, err, "unsafe theme name %q must error", name) + } +} + +// Unknown tone keys are almost always typos — reject rather than +// silently drop so operators aren't left wondering why their theme +// looks unstyled. +func Test_LoadSkinDef_RejectsUnknownToneKey(t *testing.T) { + dir := t.TempDir() + skinDir := dir + "/ota/themes" + require.NoError(t, os.MkdirAll(skinDir, 0o755)) + yamlBody := `name: typo +tones: + fg: {fg: "#eceff4"} + body_bg: {bg: "#2e3440"} +` + require.NoError(t, os.WriteFile(skinDir+"/typo.yaml", []byte(yamlBody), 0o644)) + t.Setenv("XDG_CONFIG_HOME", dir) + + _, err := shared.LoadSkinDef("typo") + require.Error(t, err, "unknown tone key must error") + require.Contains(t, err.Error(), "body_bg") +} + +// An entirely empty tones map is a config-error, not a valid theme. +func Test_LoadSkinDef_RejectsEmptyTonesMap(t *testing.T) { + dir := t.TempDir() + skinDir := dir + "/ota/themes" + require.NoError(t, os.MkdirAll(skinDir, 0o755)) + require.NoError(t, os.WriteFile(skinDir+"/empty.yaml", + []byte("name: empty\n"), 0o644)) + t.Setenv("XDG_CONFIG_HOME", dir) + + _, err := shared.LoadSkinDef("empty") + require.Error(t, err, "missing tones map must error") +} + +// A tone entry with no fg/bg/attributes is either a typo or a +// no-op — reject so the operator gets a clear signal. +func Test_LoadSkinDef_RejectsEmptyTone(t *testing.T) { + dir := t.TempDir() + skinDir := dir + "/ota/themes" + require.NoError(t, os.MkdirAll(skinDir, 0o755)) + yamlBody := `name: blanktone +tones: + fg: {} +` + require.NoError(t, os.WriteFile(skinDir+"/blanktone.yaml", []byte(yamlBody), 0o644)) + t.Setenv("XDG_CONFIG_HOME", dir) + + _, err := shared.LoadSkinDef("blanktone") + require.Error(t, err, "empty tone body must error") +} + // Each built-in theme must be visually distinct — at minimum the tone // tables differ. Otherwise the "3 built-in themes" promise is a lie. // Compares raw ThemeTone maps rather than lipgloss.Render output so the // test doesn't depend on the runtime colour profile (tests run under // Ascii and would otherwise strip every ANSI code). func Test_LoadSkin_BuiltinsAreDistinct(t *testing.T) { - os.Unsetenv("NO_COLOR") + clearNoColor(t) fingerprints := map[string]string{} for _, name := range shared.BuiltinSkinNames() { def, err := shared.LoadSkinDef(name) @@ -64,7 +135,7 @@ func Test_LoadSkin_BuiltinsAreDistinct(t *testing.T) { // must yield an identical tone map. Guards against a future ThemeTone // field addition that forgets `yaml:` tags. func Test_SkinDef_YAMLRoundTrip(t *testing.T) { - os.Unsetenv("NO_COLOR") + clearNoColor(t) for _, name := range shared.BuiltinSkinNames() { def, err := shared.LoadSkinDef(name) require.NoError(t, err) @@ -85,7 +156,7 @@ func Test_SkinDef_YAMLRoundTrip(t *testing.T) { // Uses the underlying ThemeDef fingerprint (not lipgloss.Render output) // so the assertion works regardless of the runtime colour profile. func Test_SetActiveSkin_SwapsLiveTokens(t *testing.T) { - os.Unsetenv("NO_COLOR") + clearNoColor(t) // Restore whatever was live before the test — otherwise a stray // `light` leaks into every subsequent test in this package. t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) @@ -109,7 +180,7 @@ func Test_SetActiveSkin_SwapsLiveTokens(t *testing.T) { // SetActiveTheme on an unknown name must leave the previous active // theme untouched — a failed swap should not blank the palette. func Test_SetActiveSkin_UnknownLeavesActiveIntact(t *testing.T) { - os.Unsetenv("NO_COLOR") + clearNoColor(t) t.Cleanup(func() { _, _ = shared.SetActiveTheme("dark") }) _, err := shared.SetActiveTheme("dark") @@ -125,14 +196,18 @@ func Test_SetActiveSkin_UnknownLeavesActiveIntact(t *testing.T) { // NO_COLOR beats the active theme — ActiveTokens must ignore the // swapped theme and return the Monochrome palette. func Test_ActiveTokens_NOCOLOR_TrumpsSkin(t *testing.T) { + // t.Setenv auto-restores NO_COLOR at the end of the test; we + // still need to reset the shared-package cache and reload the + // default theme so subsequent tests see the original state. t.Cleanup(func() { - os.Unsetenv("NO_COLOR") + shared.RefreshMonochromeEnabledForTest() _, _ = shared.SetActiveTheme("dark") }) _, err := shared.SetActiveTheme("light") require.NoError(t, err) t.Setenv("NO_COLOR", "1") + shared.RefreshMonochromeEnabledForTest() // Under NO_COLOR the FG/BG lipgloss styles come from Monochrome() // — verify by comparing the foreground descriptor to the one // Monochrome() produces (both are NoColor{}, distinct from any