diff options
| author | Fuwn <[email protected]> | 2026-01-19 23:05:26 -0800 |
|---|---|---|
| committer | Fuwn <[email protected]> | 2026-01-19 23:05:26 -0800 |
| commit | 4cbee4da97dcc2832cd354142aa9909a80070952 (patch) | |
| tree | 338f54b0ee2dd033f5049888b586aa3c77e22c18 /internal/theme | |
| parent | fix: Remove duplicate monitor name validation across groups (diff) | |
| download | kaze-4cbee4da97dcc2832cd354142aa9909a80070952.tar.xz kaze-4cbee4da97dcc2832cd354142aa9909a80070952.zip | |
feat: Add OpenCode-compatible theme loader
Add support for loading and applying OpenCode-compatible themes via URL.
Fetches theme JSON, resolves color references, generates CSS variables
and Tailwind class overrides to apply the theme seamlessly.
Features:
- Add theme_url config field under site section
- Fetch and parse OpenCode theme.json format
- Generate CSS custom properties (--theme-*) for all theme colors
- Generate Tailwind class overrides to apply theme colors
- Support both light and dark modes
- Template.CSS type for safe CSS injection
Example usage:
site:
theme_url: "https://raw.githubusercontent.com/anomalyco/opencode/.../opencode.json"
Theme schema: https://opencode.ai/theme.json
Diffstat (limited to 'internal/theme')
| -rw-r--r-- | internal/theme/theme.go | 287 |
1 files changed, 287 insertions, 0 deletions
diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..cddcaa4 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,287 @@ +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]OpenCodeThemeMode `json:"theme"` +} + +// 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, mode := range ocTheme.Theme { + // Resolve dark mode color + darkColor := resolveColor(mode.Dark, ocTheme.Defs) + resolved.Dark[key] = darkColor + + // Resolve light mode color + lightColor := resolveColor(mode.Light, ocTheme.Defs) + resolved.Light[key] = lightColor + } + + 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; +} +` +} |