aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-01-19 23:05:26 -0800
committerFuwn <[email protected]>2026-01-19 23:05:26 -0800
commit4cbee4da97dcc2832cd354142aa9909a80070952 (patch)
tree338f54b0ee2dd033f5049888b586aa3c77e22c18
parentfix: Remove duplicate monitor name validation across groups (diff)
downloadkaze-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
-rw-r--r--config.example.yaml2
-rw-r--r--internal/config/config.go1
-rw-r--r--internal/server/server.go20
-rw-r--r--internal/server/templates/index.html6
-rw-r--r--internal/theme/theme.go287
5 files changed, 314 insertions, 2 deletions
diff --git a/config.example.yaml b/config.example.yaml
index d751ac5..bf3ff0f 100644
--- a/config.example.yaml
+++ b/config.example.yaml
@@ -12,6 +12,8 @@ site:
description: "Service Status Page"
# logo: "https://example.com/logo.svg" # Optional logo URL
# favicon: "https://example.com/favicon.ico" # Optional favicon URL
+ # theme_url: "https://raw.githubusercontent.com/anomalyco/opencode/main/packages/opencode/src/cli/cmd/tui/context/theme/opencode.json"
+ # ^ Optional: URL to OpenCode-compatible theme JSON (see https://opencode.ai/theme.json for schema)
# HTTP server settings
server:
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;
+}
+`
+}