diff --git a/README.md b/README.md index 5d51bb1..2351022 100644 --- a/README.md +++ b/README.md @@ -44,18 +44,32 @@ Conjure ships as a single binary with no runtime dependencies. See the [full documentation](https://conjure.wizardops.dev) on the Conjure website. -## Features and Status - -| # | Feature | Status | -| :-: | ------------------------------------------------- | :----: | -| 1 | Template generation with Go template syntax | Done | -| 2 | Bundle generation (multiple templates at once) | Done | -| 3 | Interactive mode with guided variable prompts | Done | -| 4 | Values files with variable precedence | Done | -| 5 | Local template and bundle repositories | Done | -| 6 | Remote repositories with SHA256 verification | Done | -| 7 | Repository index generation (`conjure repo index`)| Done | -| 8 | Variable types: string, int, bool | Done | +## Features + +- Template configuration files once, generate many times interactivley or in automated workflows. +- Bundle generation (multiple templates at once). Package multiple templates into bundles. +- Interactive mode with guided variable prompts. +- Values files and `--vars` flags with variable precedence. +- Local template and bundle repositories supported. +- Remote repositories support with SHA256 verification over https/https. +- Repository index generation (`conjure repo index`) made easy. +- Support for versioned templates and bundles. + +### Interactive Mode +Run in interactive mode for a more guided configuration creation: + +![generate a redis.conf](./assets/theme-dragon-hoard.gif) + +### Non-interacitve Mode +For power users and automation `--vars` and `--values` / `f` files are supported and can be used in combination with precedence: + +![generate a k8s manifests](./assets/non-interactive.gif) + +## The Conjure Workflow + +1. Someone templates a configruation or set of configurations. +2. That configuration is published to a local or remote registry. +3. Team members consume those templates interactivley or in automated workflows. ## Contributing diff --git a/assets/non-interactive.gif b/assets/non-interactive.gif new file mode 100644 index 0000000..9063650 Binary files /dev/null and b/assets/non-interactive.gif differ diff --git a/assets/non-interactive.tape b/assets/non-interactive.tape new file mode 100644 index 0000000..a3383ac --- /dev/null +++ b/assets/non-interactive.tape @@ -0,0 +1,9 @@ + Output non-interactive.gif + + Set FontSize 14 + Set Width 800 + Set Height 400 + + Type "conjure bundle k8s-web-app -o ./manifests -f k8s-web-app-bundle-example.yaml --var image=docker.io/web" + Enter + Sleep 4s \ No newline at end of file diff --git a/assets/theme-dragon-hoard.gif b/assets/theme-dragon-hoard.gif new file mode 100644 index 0000000..d5ad329 Binary files /dev/null and b/assets/theme-dragon-hoard.gif differ diff --git a/assets/theme-dragon-hoard.tape b/assets/theme-dragon-hoard.tape new file mode 100644 index 0000000..4e31ab0 --- /dev/null +++ b/assets/theme-dragon-hoard.tape @@ -0,0 +1,25 @@ + Output theme-dragon-hoard.gif + + Set FontSize 14 + Set Width 800 + Set Height 400 + + Type "conjure template redis-config -o redis.conf" + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Type "coolpassword" + Sleep 1s + Enter + Sleep 4s diff --git a/assets/theme-enchanted-aurora.gif b/assets/theme-enchanted-aurora.gif new file mode 100644 index 0000000..13d9704 Binary files /dev/null and b/assets/theme-enchanted-aurora.gif differ diff --git a/assets/theme-enchanted-aurora.tape b/assets/theme-enchanted-aurora.tape new file mode 100644 index 0000000..1160afa --- /dev/null +++ b/assets/theme-enchanted-aurora.tape @@ -0,0 +1,26 @@ + Output theme-enchanted-aurora.gif + + Set FontSize 14 + Set Width 800 + Set Height 400 + + Type "conjure template redis-config -o redis.conf" + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Type "coolpassword" + Sleep 1s + Enter + Sleep 4s + diff --git a/assets/theme-runestone-grove.gif b/assets/theme-runestone-grove.gif new file mode 100644 index 0000000..33bc853 Binary files /dev/null and b/assets/theme-runestone-grove.gif differ diff --git a/assets/theme-runestone-grove.tape b/assets/theme-runestone-grove.tape new file mode 100644 index 0000000..e704f7a --- /dev/null +++ b/assets/theme-runestone-grove.tape @@ -0,0 +1,27 @@ + + Output theme-runestone-grove.gif + + Set FontSize 14 + Set Width 800 + Set Height 400 + + Type "conjure template redis-config -o redis.conf" + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Enter + Sleep 1s + Type "coolpassword" + Sleep 1s + Enter + Sleep 4s + diff --git a/cmd/bundle/bundle.go b/cmd/bundle/bundle.go index c95fe1d..7a7f14d 100644 --- a/cmd/bundle/bundle.go +++ b/cmd/bundle/bundle.go @@ -142,7 +142,7 @@ func generateBundle(bundleName, bundleVersion, outputPath string, varsList []str if interactive { var interactiveOverrides map[string]map[string]interface{} - userVariables, interactiveOverrides, err = prompt.CollectBundleVariables(bundleMeta, userVariables) + userVariables, interactiveOverrides, err = prompt.CollectBundleVariables(bundleMeta, userVariables, cfg.ColorTheme) if err != nil { return fmt.Errorf("failed to collect variables: %w", err) } diff --git a/cmd/root.go b/cmd/root.go index a0c81a8..dce7deb 100755 --- a/cmd/root.go +++ b/cmd/root.go @@ -118,6 +118,7 @@ func initConfig() { _ = viper.BindEnv("CACHE_DIR") _ = viper.BindEnv("TEMPLATES_PRIORITY") _ = viper.BindEnv("BUNDLES_PRIORITY") + _ = viper.BindEnv("COLOR_THEME") if err := viper.ReadInConfig(); err == nil { fmt.Fprintln(os.Stderr, "Using config file:", viper.ConfigFileUsed()) diff --git a/cmd/template/template.go b/cmd/template/template.go index 248d255..32b946c 100644 --- a/cmd/template/template.go +++ b/cmd/template/template.go @@ -121,7 +121,7 @@ func generateTemplate(templateName, templateVersion, outputPath string, varsList fmt.Printf("Using metadata: %s\n\n", meta.TemplateDescription) if interactive { - finalVariables, err = prompt.CollectVariables(meta, userVariables) + finalVariables, err = prompt.CollectVariables(meta, userVariables, cfg.ColorTheme) if err != nil { return fmt.Errorf("failed to collect variables: %w", err) } diff --git a/internal/config/config.go b/internal/config/config.go index c567865..3958662 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -8,6 +8,7 @@ import ( "github.com/spf13/viper" "github.com/wizardopstech/conjure/internal/security" + "github.com/wizardopstech/conjure/internal/themes" ) const ( @@ -39,6 +40,7 @@ type Config struct { CacheDir string `mapstructure:"cache_dir"` TemplatesPriority string `mapstructure:"templates_priority"` BundlesPriority string `mapstructure:"bundles_priority"` + ColorTheme string `mapstructure:"color_theme"` } func LoadConfig() (*Config, error) { @@ -156,6 +158,10 @@ func (c *Config) Validate() error { return err } + if !themes.IsValid(c.ColorTheme) { + return fmt.Errorf("invalid color_theme: %q (see https://conjure.wizardops.dev/docs/themes)", c.ColorTheme) + } + return nil } diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 15874ea..4f3a157 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -225,6 +225,54 @@ func TestConfigValidate(t *testing.T) { } } +func TestConfigValidate_ColorTheme(t *testing.T) { + base := Config{ + TemplatesSource: "local", + BundlesSource: "local", + TemplatesLocalDir: "templates", + BundlesLocalDir: "bundles", + CacheDir: ".cache", + TemplatesPriority: "local-first", + BundlesPriority: "local-first", + } + + tests := []struct { + name string + colorTheme string + wantErr bool + }{ + {"empty theme is valid (uses default)", "", false}, + {"arcane-ember is valid", "arcane-ember", false}, + {"moonlit-mana is valid", "moonlit-mana", false}, + {"runestone-grove is valid", "runestone-grove", false}, + {"spellforge is valid", "spellforge", false}, + {"celestial-grimoire is valid", "celestial-grimoire", false}, + {"mystic-marsh is valid", "mystic-marsh", false}, + {"dragon-hoard is valid", "dragon-hoard", false}, + {"enchanted-aurora is valid", "enchanted-aurora", false}, + {"hexfire is valid", "hexfire", false}, + {"potionmaker is valid", "potionmaker", false}, + {"feywild-bloom is valid", "feywild-bloom", false}, + {"storm-sorcerer is valid", "storm-sorcerer", false}, + {"necromancers-ledger is valid", "necromancers-ledger", false}, + {"sunspell-sanctum is valid", "sunspell-sanctum", false}, + {"crystal-familiar is valid", "crystal-familiar", false}, + {"unknown theme is invalid", "rainbow-wizard", true}, + {"uppercase theme is invalid", "Arcane-Ember", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := base + cfg.ColorTheme = tt.colorTheme + err := cfg.Validate() + if (err != nil) != tt.wantErr { + t.Errorf("Config.Validate() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + func TestConfigGetters(t *testing.T) { cfg := Config{ TemplatesSource: "both", diff --git a/internal/prompt/prompt.go b/internal/prompt/prompt.go index b867db1..aab3d74 100644 --- a/internal/prompt/prompt.go +++ b/internal/prompt/prompt.go @@ -7,25 +7,28 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/wizardopstech/conjure/internal/metadata" + "github.com/wizardopstech/conjure/internal/themes" ) -var ( - titleStyle = lipgloss.NewStyle(). - Bold(true). - Foreground(lipgloss.Color("99")) - - promptStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("86")) - - descriptionStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("241")) - - inputStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("219")) +type styles struct { + title lipgloss.Style + prompt lipgloss.Style + description lipgloss.Style + input lipgloss.Style + success lipgloss.Style + required lipgloss.Style +} - successStyle = lipgloss.NewStyle(). - Foreground(lipgloss.Color("42")) -) +func newStyles(t themes.Theme) styles { + return styles{ + title: lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color(t.Title)), + prompt: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Prompt)), + description: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Description)), + input: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Input)), + success: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Success)), + required: lipgloss.NewStyle().Foreground(lipgloss.Color(t.Required)), + } +} type model struct { metadata *metadata.TemplateMetadata @@ -34,15 +37,17 @@ type model struct { currentInput string finished bool err error + styles styles } -func initialModel(meta *metadata.TemplateMetadata) model { +func initialModel(meta *metadata.TemplateMetadata, theme themes.Theme) model { return model{ metadata: meta, currentIndex: 0, values: make(map[string]interface{}), currentInput: "", finished: false, + styles: newStyles(theme), } } @@ -98,23 +103,25 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } func (m model) View() string { + s := m.styles + if m.finished { if m.err != nil { return "" } - return successStyle.Render("✓ All variables collected!\n") + return s.success.Render("✓ All variables collected!\n") } var b strings.Builder // Title - _, _ = b.WriteString(titleStyle.Render(fmt.Sprintf("Template: %s", m.metadata.TemplateName))) + _, _ = b.WriteString(s.title.Render(fmt.Sprintf("Template: %s", m.metadata.TemplateName))) _, _ = b.WriteString("\n") - _, _ = b.WriteString(descriptionStyle.Render(m.metadata.TemplateDescription)) + _, _ = b.WriteString(s.description.Render(m.metadata.TemplateDescription)) _, _ = b.WriteString("\n\n") // Progress - _, _ = b.WriteString(descriptionStyle.Render(fmt.Sprintf("Variable %d of %d", m.currentIndex+1, len(m.metadata.Variables)))) + _, _ = b.WriteString(s.description.Render(fmt.Sprintf("Variable %d of %d", m.currentIndex+1, len(m.metadata.Variables)))) _, _ = b.WriteString("\n\n") // Current variable @@ -124,34 +131,34 @@ func (m model) View() string { // Variable name required := "" if currentVar.Default == "" { - required = lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Render(" *") + required = s.required.Render(" *") } - _, _ = b.WriteString(promptStyle.Render(fmt.Sprintf("%s%s", currentVar.Name, required))) + _, _ = b.WriteString(s.prompt.Render(fmt.Sprintf("%s%s", currentVar.Name, required))) _, _ = b.WriteString("\n") // Description - _, _ = b.WriteString(descriptionStyle.Render(currentVar.Description)) + _, _ = b.WriteString(s.description.Render(currentVar.Description)) _, _ = b.WriteString("\n") // Default value hint if currentVar.Default != "" { - _, _ = b.WriteString(descriptionStyle.Render(fmt.Sprintf("[default: %s]", currentVar.Default))) + _, _ = b.WriteString(s.description.Render(fmt.Sprintf("[default: %s]", currentVar.Default))) _, _ = b.WriteString("\n") } // Input field - _, _ = b.WriteString(inputStyle.Render("> " + m.currentInput + "█")) + _, _ = b.WriteString(s.input.Render("> " + m.currentInput + "█")) _, _ = b.WriteString("\n\n") // Help text - _, _ = b.WriteString(descriptionStyle.Render("Press Enter to continue, Ctrl+C to cancel")) + _, _ = b.WriteString(s.description.Render("* required | Press Enter to continue, Ctrl+C to cancel")) } return b.String() } -func CollectVariables(meta *metadata.TemplateMetadata, existingVars map[string]interface{}) (map[string]interface{}, error) { - m := initialModel(meta) +func CollectVariables(meta *metadata.TemplateMetadata, existingVars map[string]interface{}, colorTheme string) (map[string]interface{}, error) { + m := initialModel(meta, themes.Get(colorTheme)) if existingVars != nil { m.values = existingVars @@ -171,7 +178,7 @@ func CollectVariables(meta *metadata.TemplateMetadata, existingVars map[string]i return final.values, nil } -func CollectBundleVariables(bundleMeta *metadata.BundleMetadata, existingVars map[string]interface{}) (map[string]interface{}, map[string]map[string]interface{}, error) { +func CollectBundleVariables(bundleMeta *metadata.BundleMetadata, existingVars map[string]interface{}, colorTheme string) (map[string]interface{}, map[string]map[string]interface{}, error) { varToTemplates := make(map[string][]string) for _, v := range bundleMeta.SharedVariables { @@ -207,12 +214,12 @@ func CollectBundleVariables(bundleMeta *metadata.BundleMetadata, existingVars ma Variables: allVars, } - vars, err := CollectVariables(tempMeta, existingVars) + vars, err := CollectVariables(tempMeta, existingVars, colorTheme) if err != nil { return nil, nil, err } - overrides, err := CollectTemplateOverrides(bundleMeta, vars) + overrides, err := CollectTemplateOverrides(bundleMeta, vars, colorTheme) if err != nil { return nil, nil, err } @@ -220,9 +227,11 @@ func CollectBundleVariables(bundleMeta *metadata.BundleMetadata, existingVars ma return vars, overrides, nil } -func CollectTemplateOverrides(bundleMeta *metadata.BundleMetadata, currentVars map[string]interface{}) (map[string]map[string]interface{}, error) { +func CollectTemplateOverrides(bundleMeta *metadata.BundleMetadata, currentVars map[string]interface{}, colorTheme string) (map[string]map[string]interface{}, error) { overrides := make(map[string]map[string]interface{}) + s := newStyles(themes.Get(colorTheme)) + templateNames := make([]string, 0) for templateName := range bundleMeta.TemplateVariables { templateNames = append(templateNames, templateName) @@ -238,13 +247,13 @@ func CollectTemplateOverrides(bundleMeta *metadata.BundleMetadata, currentVars m } fmt.Println() - fmt.Println(descriptionStyle.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) - fmt.Println(titleStyle.Render("Template-Specific Overrides")) - fmt.Println(descriptionStyle.Render("Override shared variables for specific templates")) + fmt.Println(s.description.Render("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━")) + fmt.Println(s.title.Render("Template-Specific Overrides")) + fmt.Println(s.description.Render("Override shared variables for specific templates")) fmt.Println() for { - fmt.Print(promptStyle.Render("Add a template-specific override? (y/n): ")) + fmt.Print(s.prompt.Render("Add a template-specific override? (y/n): ")) var response string if _, err := fmt.Scanln(&response); err != nil { break @@ -256,50 +265,50 @@ func CollectTemplateOverrides(bundleMeta *metadata.BundleMetadata, currentVars m } fmt.Println() - fmt.Println(descriptionStyle.Render("Available templates:")) + fmt.Println(s.description.Render("Available templates:")) for i, tmpl := range templateNames { fmt.Printf(" %d. %s\n", i+1, tmpl) } - fmt.Print(promptStyle.Render("Select template (number): ")) + fmt.Print(s.prompt.Render("Select template (number): ")) var templateIdx int if _, err := fmt.Scanln(&templateIdx); err != nil { - fmt.Println(descriptionStyle.Render("Invalid input, skipping...")) + fmt.Println(s.description.Render("Invalid input, skipping...")) continue } if templateIdx < 1 || templateIdx > len(templateNames) { - fmt.Println(descriptionStyle.Render("Invalid selection, skipping...")) + fmt.Println(s.description.Render("Invalid selection, skipping...")) continue } selectedTemplate := templateNames[templateIdx-1] fmt.Println() - fmt.Println(descriptionStyle.Render("Shared variables:")) + fmt.Println(s.description.Render("Shared variables:")) for i, varName := range sharedVarNames { currentVal := currentVars[varName] fmt.Printf(" %d. %s (current: %v)\n", i+1, varName, currentVal) } - fmt.Print(promptStyle.Render("Select variable to override (number): ")) + fmt.Print(s.prompt.Render("Select variable to override (number): ")) var varIdx int if _, err := fmt.Scanln(&varIdx); err != nil { - fmt.Println(descriptionStyle.Render("Invalid input, skipping...")) + fmt.Println(s.description.Render("Invalid input, skipping...")) continue } if varIdx < 1 || varIdx > len(sharedVarNames) { - fmt.Println(descriptionStyle.Render("Invalid selection, skipping...")) + fmt.Println(s.description.Render("Invalid selection, skipping...")) continue } selectedVar := sharedVarNames[varIdx-1] - fmt.Print(promptStyle.Render(fmt.Sprintf("New value for %s in %s: ", selectedVar, selectedTemplate))) + fmt.Print(s.prompt.Render(fmt.Sprintf("New value for %s in %s: ", selectedVar, selectedTemplate))) var newValue string if _, err := fmt.Scanln(&newValue); err != nil { - fmt.Println(descriptionStyle.Render("Invalid input, skipping...")) + fmt.Println(s.description.Render("Invalid input, skipping...")) continue } newValue = strings.TrimSpace(newValue) if newValue == "" { - fmt.Println(descriptionStyle.Render("Empty value, skipping...")) + fmt.Println(s.description.Render("Empty value, skipping...")) continue } @@ -308,13 +317,13 @@ func CollectTemplateOverrides(bundleMeta *metadata.BundleMetadata, currentVars m } overrides[selectedTemplate][selectedVar] = newValue - fmt.Println(successStyle.Render(fmt.Sprintf("✓ Override added: %s.%s = %s", selectedTemplate, selectedVar, newValue))) + fmt.Println(s.success.Render(fmt.Sprintf("✓ Override added: %s.%s = %s", selectedTemplate, selectedVar, newValue))) fmt.Println() } if len(overrides) > 0 { fmt.Println() - fmt.Println(successStyle.Render(fmt.Sprintf("✓ %d template override(s) configured", len(overrides)))) + fmt.Println(s.success.Render(fmt.Sprintf("✓ %d template override(s) configured", len(overrides)))) } return overrides, nil diff --git a/internal/themes/themes.go b/internal/themes/themes.go new file mode 100644 index 0000000..edf9ee5 --- /dev/null +++ b/internal/themes/themes.go @@ -0,0 +1,174 @@ +package themes + +// TargetBackground is the canonical dark background all themes are designed and +// validated against. Every foreground color in this file must achieve a minimum +// WCAG AA contrast ratio of 4.5:1 against this value. +const TargetBackground = "#121212" + +// lipgloss-compatible color values (hex or ANSI 256) for each UI element. +type Theme struct { + Title string + Prompt string + Description string + Input string + Success string + Required string +} + +var Default = Theme{ + Title: "99", + Prompt: "86", + Description: "#9E9E9E", // was "241" (~#626262, 3.07:1 — failed WCAG AA) + Input: "219", + Success: "42", + Required: "196", +} + +var catalog = map[string]Theme{ + "arcane-ember": { + Title: "#E05C2A", + Prompt: "#E5603A", // was #C84B31 (4.02:1 — failed WCAG AA) + Description: "#9E9E9E", // was #7A7A7A (4.36:1 — failed WCAG AA) + Input: "#D4A017", + Success: "#E05C2A", + Required: "#FF3300", + }, + "moonlit-mana": { + Title: "#B39DDB", + Prompt: "#81D4FA", + Description: "#90A4AE", + Input: "#B2EBF2", + Success: "#81D4FA", + Required: "#EF9A9A", + }, + "runestone-grove": { + Title: "#7CB342", + Prompt: "#A5845A", + Description: "#9E9E9E", + Input: "#4DB6AC", + Success: "#7CB342", + Required: "#EF5350", + }, + "spellforge": { + Title: "#FF7043", + Prompt: "#CD5C5C", + Description: "#78909C", + Input: "#CD7F32", + Success: "#FF7043", + Required: "#FF4444", // was #DC143C (3.75:1 — failed WCAG AA) + }, + "celestial-grimoire": { + Title: "#CE4DE4", // was #9C27B0 (2.97:1 — failed WCAG AA) + Prompt: "#F1C40F", + Description: "#C8B89A", + Input: "#4FC3F7", + Success: "#CE4DE4", // was #9C27B0 (2.97:1 — failed WCAG AA) + Required: "#E74C3C", + }, + "mystic-marsh": { + Title: "#558B2F", + Prompt: "#4ECDC4", + Description: "#9E9E9E", + Input: "#B2DFDB", + Success: "#558B2F", + Required: "#EF5350", + }, + "dragon-hoard": { + Title: "#2ECC71", + Prompt: "#E74C3C", + Description: "#808080", + Input: "#F39C12", + Success: "#2ECC71", + Required: "#E74C3C", + }, + "enchanted-aurora": { + Title: "#00BCD4", + Prompt: "#FF4D8C", // was #E91E63 (4.31:1 — failed WCAG AA) + Description: "#7986CB", + Input: "#00E676", + Success: "#00BCD4", + Required: "#FF4081", + }, + "hexfire": { + Title: "#CE4DE4", // was #9C27B0 (2.97:1 — failed WCAG AA) + Prompt: "#F06292", + Description: "#9E9E9E", // was #757575 (4.07:1 — failed WCAG AA) + Input: "#FF8C00", + Success: "#CE4DE4", // was #9C27B0 (2.97:1 — failed WCAG AA) + Required: "#FF1744", + }, + "potionmaker": { + Title: "#00BFA5", + Prompt: "#8BC34A", + Description: "#9E9E9E", + Input: "#C868D8", // was #AB47BC (3.89:1 — failed WCAG AA) + Success: "#00BFA5", + Required: "#FF5252", + }, + "feywild-bloom": { + Title: "#F48FB1", + Prompt: "#CE93D8", + Description: "#A5D6A7", + Input: "#FFCCBC", + Success: "#F48FB1", + Required: "#EF9A9A", + }, + "storm-sorcerer": { + Title: "#2979FF", + Prompt: "#78909C", + Description: "#82A5B4", // was #607D8B (4.29:1 — failed WCAG AA) + Input: "#E3F2FD", + Success: "#2979FF", + Required: "#EF5350", + }, + "necromancers-ledger": { + Title: "#80CBC4", + Prompt: "#66BB6A", + Description: "#9E9E9E", + Input: "#E0E0E0", + Success: "#66BB6A", + Required: "#EF9A9A", + }, + "sunspell-sanctum": { + Title: "#FFB74D", + Prompt: "#F57C00", + Description: "#A1887F", + Input: "#FFD54F", + Success: "#FFB74D", + Required: "#EF5350", + }, + "crystal-familiar": { + Title: "#81D4FA", + Prompt: "#CE93D8", + Description: "#B0BEC5", + Input: "#E1F5FE", + Success: "#81D4FA", + Required: "#F48FB1", + }, +} + +func Get(name string) Theme { + if name == "" { + return Default + } + if t, ok := catalog[name]; ok { + return t + } + return Default +} + +func ValidNames() []string { + names := make([]string, 0, len(catalog)) + for k := range catalog { + names = append(names, k) + } + return names +} + +func IsValid(name string) bool { + if name == "" { + return true + } + _, ok := catalog[name] + return ok +} diff --git a/internal/themes/themes_test.go b/internal/themes/themes_test.go new file mode 100644 index 0000000..d2c5398 --- /dev/null +++ b/internal/themes/themes_test.go @@ -0,0 +1,168 @@ +package themes + +import ( + "fmt" + "math" + "strconv" + "strings" + "testing" +) + +func TestGet_EmptyNameReturnsDefault(t *testing.T) { + got := Get("") + if got != Default { + t.Errorf("Get(\"\") = %v, want Default %v", got, Default) + } +} + +func TestGet_UnknownNameReturnsDefault(t *testing.T) { + got := Get("not-a-real-theme") + if got != Default { + t.Errorf("Get(\"not-a-real-theme\") = %v, want Default %v", got, Default) + } +} + +func TestGet_KnownThemes(t *testing.T) { + for name, want := range catalog { + got := Get(name) + if got != want { + t.Errorf("Get(%q) = %v, want %v", name, got, want) + } + } +} + +func TestGet_ThemeFieldsNonEmpty(t *testing.T) { + for name := range catalog { + th := Get(name) + if th.Title == "" { + t.Errorf("theme %q: Title is empty", name) + } + if th.Prompt == "" { + t.Errorf("theme %q: Prompt is empty", name) + } + if th.Description == "" { + t.Errorf("theme %q: Description is empty", name) + } + if th.Input == "" { + t.Errorf("theme %q: Input is empty", name) + } + if th.Success == "" { + t.Errorf("theme %q: Success is empty", name) + } + if th.Required == "" { + t.Errorf("theme %q: Required is empty", name) + } + } +} + +func TestIsValid(t *testing.T) { + tests := []struct { + name string + input string + want bool + }{ + {"empty string is valid", "", true}, + {"known theme is valid", "arcane-ember", true}, + {"all known themes are valid", "crystal-familiar", true}, + {"unknown theme is invalid", "rainbow-wizard", false}, + {"partial name is invalid", "arcane", false}, + {"uppercase is invalid", "Arcane-Ember", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsValid(tt.input); got != tt.want { + t.Errorf("IsValid(%q) = %v, want %v", tt.input, got, tt.want) + } + }) + } +} + +func TestValidNames_ContainsAll15Themes(t *testing.T) { + names := ValidNames() + if len(names) != 15 { + t.Errorf("ValidNames() returned %d names, want 15", len(names)) + } +} + +func TestValidNames_AllAreValid(t *testing.T) { + for _, name := range ValidNames() { + if !IsValid(name) { + t.Errorf("ValidNames() returned %q which IsValid() rejects", name) + } + } +} + +// verifies every hex foreground color in the catalog achieves +// a minimum 4.5:1 contrast ratio against TargetBackground (WCAG AA for normal text). +// ANSI 256 colors in the Default theme are excluded because their exact rendering +// is terminal-defined and cannot be validated at build time. +func TestWCAGContrast(t *testing.T) { + type field struct { + name string + value string + } + check := func(themeName string, fields []field) { + for _, f := range fields { + if f.value == "" || f.value[0] != '#' { + continue // skip ANSI codes + } + ratio, err := wcagContrast(f.value, TargetBackground) + if err != nil { + t.Errorf("theme %q field %s: invalid color %q: %v", themeName, f.name, f.value, err) + continue + } + if ratio < 4.5 { + t.Errorf("theme %q field %s: color %q has contrast %.2f:1 against %s (need 4.5:1)", + themeName, f.name, f.value, ratio, TargetBackground) + } + } + } + + for name, th := range catalog { + check(name, []field{ + {"Title", th.Title}, + {"Prompt", th.Prompt}, + {"Description", th.Description}, + {"Input", th.Input}, + {"Success", th.Success}, + {"Required", th.Required}, + }) + } +} + +func wcagContrast(fg, bg string) (float64, error) { + lr, err := hexLuminance(fg) + if err != nil { + return 0, fmt.Errorf("fg: %w", err) + } + lb, err := hexLuminance(bg) + if err != nil { + return 0, fmt.Errorf("bg: %w", err) + } + if lr < lb { + lr, lb = lb, lr + } + return (lr + 0.05) / (lb + 0.05), nil +} + +func hexLuminance(hex string) (float64, error) { + hex = strings.TrimPrefix(hex, "#") + if len(hex) != 6 { + return 0, fmt.Errorf("expected 6-char hex, got %q", hex) + } + r, err1 := strconv.ParseInt(hex[0:2], 16, 32) + g, err2 := strconv.ParseInt(hex[2:4], 16, 32) + b, err3 := strconv.ParseInt(hex[4:6], 16, 32) + if err1 != nil || err2 != nil || err3 != nil { + return 0, fmt.Errorf("invalid hex color %q", hex) + } + lin := func(c int64) float64 { + v := float64(c) / 255.0 + if v <= 0.04045 { + return v / 12.92 + } + return math.Pow((v+0.055)/1.055, 2.4) + } + return 0.2126*lin(r) + 0.7152*lin(g) + 0.0722*lin(b), nil +}