package theme import ( "encoding/json" "fmt" "io" "net/http" "strings" "time" ) // OpenCodeTheme represents an OpenCode-compatible theme JSON structure type OpenCodeTheme struct { Schema string `json:"$schema"` Defs map[string]string `json:"defs"` Theme map[string]json.RawMessage `json:"theme"` // Can be string or object } // OpenCodeThemeMode represents dark/light mode values for a theme property type OpenCodeThemeMode struct { Dark string `json:"dark"` Light string `json:"light"` } // ResolvedTheme contains the resolved color values for both modes type ResolvedTheme struct { Dark map[string]string Light map[string]string } // LoadTheme fetches and parses an OpenCode theme from a URL func LoadTheme(themeURL string) (*ResolvedTheme, error) { if themeURL == "" { return nil, nil // No theme specified, use default } // Fetch the theme JSON client := &http.Client{ Timeout: 10 * time.Second, } resp, err := client.Get(themeURL) if err != nil { return nil, fmt.Errorf("failed to fetch theme from %s: %w", themeURL, err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("failed to fetch theme: HTTP %d", resp.StatusCode) } body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read theme response: %w", err) } // Parse the theme JSON var ocTheme OpenCodeTheme if err := json.Unmarshal(body, &ocTheme); err != nil { return nil, fmt.Errorf("failed to parse theme JSON: %w", err) } // Resolve color references resolved := &ResolvedTheme{ Dark: make(map[string]string), Light: make(map[string]string), } for key, rawValue := range ocTheme.Theme { // Try to parse as object first (light/dark mode) var mode OpenCodeThemeMode if err := json.Unmarshal(rawValue, &mode); err == nil && (mode.Dark != "" || mode.Light != "") { // It's an object with dark/light properties darkColor := resolveColor(mode.Dark, ocTheme.Defs) resolved.Dark[key] = darkColor lightColor := resolveColor(mode.Light, ocTheme.Defs) resolved.Light[key] = lightColor } else { // It's a simple string (dark-only theme) var colorRef string if err := json.Unmarshal(rawValue, &colorRef); err != nil { continue // Skip invalid values } // Use the same color for both light and dark color := resolveColor(colorRef, ocTheme.Defs) resolved.Dark[key] = color resolved.Light[key] = color } } return resolved, nil } // resolveColor resolves a color value, handling references to defs func resolveColor(value string, defs map[string]string) string { // If it starts with #, it's already a color if strings.HasPrefix(value, "#") { return value } // Otherwise, look it up in defs if color, ok := defs[value]; ok { return color } // Fallback to the value itself return value } // GenerateCSS generates CSS custom properties from a resolved theme func (t *ResolvedTheme) GenerateCSS() string { if t == nil { return "" } var css strings.Builder // Generate CSS variables for dark mode css.WriteString(":root.dark {\n") for key, color := range t.Dark { cssVar := toCSSVariableName(key) css.WriteString(fmt.Sprintf(" %s: %s;\n", cssVar, color)) } css.WriteString("}\n\n") // Generate CSS variables for light mode css.WriteString(":root, :root.light {\n") for key, color := range t.Light { cssVar := toCSSVariableName(key) css.WriteString(fmt.Sprintf(" %s: %s;\n", cssVar, color)) } css.WriteString("}\n") return css.String() } // toCSSVariableName converts a theme key to a CSS variable name // e.g., "background" -> "--theme-background" func toCSSVariableName(key string) string { // Convert camelCase to kebab-case var result strings.Builder result.WriteString("--theme-") for i, c := range key { if i > 0 && c >= 'A' && c <= 'Z' { result.WriteRune('-') result.WriteRune(c + 32) // Convert to lowercase } else { result.WriteRune(c) } } return result.String() } // GetColorMapping returns a mapping of semantic names to CSS variable names func GetColorMapping() map[string]string { return map[string]string{ // Status colors "status-up": "var(--theme-success)", "status-degraded": "var(--theme-warning)", "status-down": "var(--theme-error)", // Text colors "text-primary": "var(--theme-text)", "text-secondary": "var(--theme-text-muted)", // Background colors "bg-primary": "var(--theme-background)", "bg-secondary": "var(--theme-background-panel)", "bg-tertiary": "var(--theme-background-element)", // Border colors "border-primary": "var(--theme-border)", "border-subtle": "var(--theme-border-subtle)", "border-active": "var(--theme-border-active)", // Accent colors "accent-primary": "var(--theme-primary)", "accent-secondary": "var(--theme-secondary)", } } // GenerateTailwindMappings generates CSS that maps theme variables to Tailwind classes func (t *ResolvedTheme) GenerateTailwindMappings() string { if t == nil { return "" } return ` /* Override Tailwind classes with OpenCode theme colors */ /* Background colors */ .bg-white, .dark .bg-neutral-900 { background-color: var(--theme-background) !important; } .bg-neutral-50, .bg-neutral-100, .dark .bg-neutral-950, .dark .bg-neutral-800 { background-color: var(--theme-background-panel) !important; } .bg-neutral-200, .dark .bg-neutral-700 { background-color: var(--theme-background-element) !important; } /* Text colors */ .text-neutral-900, .text-neutral-950, .dark .text-white, .dark .text-neutral-50 { color: var(--theme-text) !important; } .text-neutral-500, .text-neutral-600, .text-neutral-700, .dark .text-neutral-300, .dark .text-neutral-400, .dark .text-neutral-500 { color: var(--theme-text-muted) !important; } /* Border colors */ .border-neutral-200, .border-neutral-300, .dark .border-neutral-700, .dark .border-neutral-800 { border-color: var(--theme-border) !important; } .divide-neutral-200, .dark .divide-neutral-800 { border-color: var(--theme-border) !important; } /* Status colors - up/success */ .bg-emerald-500.rounded-full, .bg-emerald-500.flex-shrink-0 { background-color: var(--theme-success) !important; } .text-emerald-500, .text-emerald-600, .dark .text-emerald-400 { color: var(--theme-success) !important; } /* Status colors - down/error */ .bg-red-500.rounded-full, .bg-red-500.flex-shrink-0 { background-color: var(--theme-error) !important; } .text-red-500, .text-red-600, .dark .text-red-400 { color: var(--theme-error) !important; } /* Status colors - degraded/warning */ .bg-yellow-500.rounded-full, .bg-yellow-500.flex-shrink-0 { background-color: var(--theme-warning) !important; } .text-yellow-500, .text-yellow-600, .dark .text-yellow-400 { color: var(--theme-warning) !important; } /* Neutral/unknown status */ .bg-neutral-400.rounded-full { background-color: var(--theme-text-muted) !important; } /* Hover states */ .hover\:bg-neutral-100:hover, .dark .hover\:bg-neutral-900:hover { background-color: var(--theme-background-element) !important; } .hover\:bg-neutral-200:hover, .dark .hover\:bg-neutral-800:hover { background-color: var(--theme-border) !important; } /* Link colors */ a.text-blue-600, a.dark\:text-blue-400 { color: var(--theme-primary) !important; } ` }