aboutsummaryrefslogtreecommitdiff
path: root/internal/theme
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 /internal/theme
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
Diffstat (limited to 'internal/theme')
-rw-r--r--internal/theme/theme.go287
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;
+}
+`
+}