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 | |
| 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')
| -rw-r--r-- | internal/config/config.go | 1 | ||||
| -rw-r--r-- | internal/server/server.go | 20 | ||||
| -rw-r--r-- | internal/server/templates/index.html | 6 | ||||
| -rw-r--r-- | internal/theme/theme.go | 287 |
4 files changed, 312 insertions, 2 deletions
diff --git a/internal/config/config.go b/internal/config/config.go index f3c35e3..68a88f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -40,6 +40,7 @@ type SiteConfig struct { Description string `yaml:"description"` Logo string `yaml:"logo"` Favicon string `yaml:"favicon"` + ThemeURL string `yaml:"theme_url"` // URL to OpenCode-compatible theme JSON } // ServerConfig contains HTTP server settings diff --git a/internal/server/server.go b/internal/server/server.go index 8681873..6d76fa0 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -17,6 +17,7 @@ import ( "github.com/Fuwn/kaze/internal/config" "github.com/Fuwn/kaze/internal/monitor" "github.com/Fuwn/kaze/internal/storage" + "github.com/Fuwn/kaze/internal/theme" ) //go:embed templates/*.html @@ -126,8 +127,9 @@ type PageData struct { TickMode string // ping, minute, hour, day TickCount int ShowThemeToggle bool - Timezone string // Timezone for display - UseBrowserTimezone bool // Use client-side timezone conversion + Timezone string // Timezone for display + UseBrowserTimezone bool // Use client-side timezone conversion + ThemeCSS template.CSS // OpenCode theme CSS (safe CSS) } // GroupData contains data for a monitor group @@ -190,6 +192,19 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { return } + // Load OpenCode theme if configured + var themeCSS template.CSS + if s.config.Site.ThemeURL != "" { + resolvedTheme, err := theme.LoadTheme(s.config.Site.ThemeURL) + if err != nil { + s.logger.Warn("failed to load theme", "url", s.config.Site.ThemeURL, "error", err) + } else if resolvedTheme != nil { + // Generate CSS variables and Tailwind mappings + cssString := resolvedTheme.GenerateCSS() + resolvedTheme.GenerateTailwindMappings() + themeCSS = template.CSS(cssString) + } + } + // Build page data data := PageData{ Site: s.config.Site, @@ -198,6 +213,7 @@ func (s *Server) handleIndex(w http.ResponseWriter, r *http.Request) { ShowThemeToggle: s.config.Display.ShowThemeToggle != nil && *s.config.Display.ShowThemeToggle, Timezone: s.config.Display.Timezone, UseBrowserTimezone: s.config.Display.Timezone == "Browser", + ThemeCSS: themeCSS, } overallUp := true diff --git a/internal/server/templates/index.html b/internal/server/templates/index.html index db4c61a..6bdfeff 100644 --- a/internal/server/templates/index.html +++ b/internal/server/templates/index.html @@ -11,6 +11,12 @@ <link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎐</text></svg>"> {{end}} <link rel="stylesheet" href="/static/style.css"> + {{if .ThemeCSS}} + <style> + /* OpenCode Theme */ +{{.ThemeCSS}} + </style> + {{end}} <script> // Theme detection if (localStorage.theme === 'dark' || (!('theme' in localStorage) && window.matchMedia('(prefers-color-scheme: dark)').matches)) { 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; +} +` +} |