package theme import ( "fmt" "strings" ) // FontFile represents a font file for CSS generation type FontFile struct { Family string Weight string Style string URL string Format string // "woff2", "woff", "truetype" } // GenerateCSS generates CSS variable declarations from theme settings func GenerateCSS(theme *Theme, googleFontURLs []string, fontFaces []FontFile) string { var css strings.Builder // Google Fonts imports for _, url := range googleFontURLs { fmt.Fprintf(&css, "@import url('%s');\n", url) } // Custom font-face declarations for _, ff := range fontFaces { fmt.Fprintf(&css, `@font-face { font-family: '%s'; src: url('%s') format('%s'); font-weight: %s; font-style: %s; font-display: swap; } `, ff.Family, ff.URL, ff.Format, ff.Weight, ff.Style) } if len(googleFontURLs) > 0 || len(fontFaces) > 0 { css.WriteString("\n") } // Light mode (default) - :root css.WriteString(":root {\n") writeColorVars(&css, theme.LightColors) writeCustomColorVars(&css, theme.CustomColors, "light") writeTypographyVars(&css, theme.Typography) writeSpacingVars(&css, theme.Spacing) writeEffectsVars(&css, theme.Effects) writeButtonVars(&css, theme.Buttons, theme.Spacing) css.WriteString("}\n\n") // Dark mode — two selectors: // .dark — shadcn convention (used by bidbuddy plugin) // [data-theme$="-dark"] — convention for DaisyUI-based plugins // Plugin dark themes MUST use the "-dark" suffix to pick up // dark color vars (e.g. data-theme="playground-dark"). Names like // "dark-mode" do NOT match — only a trailing "-dark" does. css.WriteString(".dark, [data-theme$=\"-dark\"] {\n") writeColorVars(&css, theme.DarkColors) writeCustomColorVars(&css, theme.CustomColors, "dark") css.WriteString("}\n\n") // Button class definitions WriteButtonClasses(&css, theme.Buttons) return css.String() } func writeColorVars(css *strings.Builder, colors *ColorScheme) { if colors == nil { return } writeVar(css, "background", colors.Background) writeVar(css, "foreground", colors.Foreground) writeVar(css, "card", colors.Card) writeVar(css, "card-foreground", colors.CardForeground) writeVar(css, "popover", colors.Popover) writeVar(css, "popover-foreground", colors.PopoverForeground) writeVar(css, "primary", colors.Primary) writeVar(css, "primary-foreground", colors.PrimaryForeground) writeVar(css, "secondary", colors.Secondary) writeVar(css, "secondary-foreground", colors.SecondaryForeground) writeVar(css, "muted", colors.Muted) writeVar(css, "muted-foreground", colors.MutedForeground) writeVar(css, "accent", colors.Accent) writeVar(css, "accent-foreground", colors.AccentForeground) writeVar(css, "destructive", colors.Destructive) writeVar(css, "destructive-foreground", colors.DestructiveForeground) writeVar(css, "border", colors.Border) writeVar(css, "input", colors.Input) writeVar(css, "ring", colors.Ring) } // writeCustomColorVars writes CSS variables for user-defined custom colors func writeCustomColorVars(css *strings.Builder, customColors []*CustomColor, mode string) { for _, color := range customColors { if color == nil || color.Name == "" { continue } varName := "color-" + color.Name var value string if mode == "dark" { value = color.DarkValue } else { value = color.LightValue } if value != "" { writeVar(css, varName, value) } } } func writeTypographyVars(css *strings.Builder, typography *ThemeTypography) { if typography == nil { return } // Font families headingStack := getFontStack(typography.FontHeading, "sans-serif") bodyStack := getFontStack(typography.FontBody, "sans-serif") monoStack := getFontStack(typography.FontMono, "monospace") writeVar(css, "font-heading", headingStack) writeVar(css, "font-body", bodyStack) writeVar(css, "font-sans", bodyStack) writeVar(css, "font-mono", monoStack) // Font size base as rem if typography.FontSizeBase != "" { writeVar(css, "font-size-base", typography.FontSizeBase+"px") } if typography.LineHeightBase != "" { writeVar(css, "line-height-base", typography.LineHeightBase) } // Font weight base writeVar(css, "font-weight-base", getFontWeight(typography.FontWeightBase)) } func writeSpacingVars(css *strings.Builder, spacing *ThemeSpacing) { if spacing == nil { return } // Radius values if spacing.Radius != "" { writeVar(css, "radius", spacing.Radius+"rem") } // Button radius (or pill mode) writeVar(css, "button-radius", getButtonRadius(spacing)) // Card radius if spacing.CardRadius != "" { writeVar(css, "card-radius", spacing.CardRadius+"rem") } // Input radius if spacing.InputRadius != "" { writeVar(css, "input-radius", spacing.InputRadius+"rem") } // Border width writeVar(css, "border-width", getBorderWidth(spacing.BorderWidth)) // Container width writeVar(css, "container-width", getContainerWidth(spacing.ContainerWidth)) // Spacing scale writeVar(css, "spacing-scale", getSpacingScale(spacing.SpacingScale)) } func writeEffectsVars(css *strings.Builder, effects *ThemeEffects) { if effects == nil { return } // Shadow intensity as decimal (0-1) intensity := float64(effects.ShadowIntensity) / 100.0 writeVar(css, "shadow-intensity", fmt.Sprintf("%.2f", intensity)) // Shadow color (HSL without function) if effects.ShadowColor != "" { writeVar(css, "shadow-color", effects.ShadowColor) } // Shadow blur multiplier writeVar(css, "shadow-blur-mult", getShadowBlurMult(effects.ShadowBlur)) // Elevation preset (generates multiple shadow vars) writeElevationVars(css, effects.ElevationPreset, intensity, effects.ShadowColor) // Transition speed writeVar(css, "transition-speed", getTransitionSpeed(effects.TransitionSpeed)) // Hover scale (for transform effects) writeVar(css, "hover-scale", getHoverScale(effects.HoverIntensity)) // Reduce motion flag if effects.ReduceMotion { writeVar(css, "motion-reduce", "reduce") } else { writeVar(css, "motion-reduce", "no-preference") } } // getButtonRadius returns button radius - uses pill (9999px) if enabled, else buttonRadius func getButtonRadius(spacing *ThemeSpacing) string { if spacing.PillButtons { return "9999px" } if spacing.ButtonRadius != "" { return spacing.ButtonRadius + "rem" } return "0.375rem" } // getBorderWidth converts preset to CSS value func getBorderWidth(preset string) string { switch preset { case "thin": return "1px" case "thick": return "3px" default: // "normal" return "2px" } } // getContainerWidth converts preset to CSS max-width func getContainerWidth(preset string) string { switch preset { case "narrow": return "1024px" case "wide": return "1536px" case "full": return "100%" default: // "normal" return "1280px" } } // getSpacingScale converts preset to multiplier func getSpacingScale(preset string) string { switch preset { case "compact": return "0.875" case "relaxed": return "1.25" default: // "normal" return "1" } } // getFontWeight converts preset to CSS font-weight func getFontWeight(preset string) string { switch preset { case "light": return "300" case "medium": return "500" default: // "normal" return "400" } } // getShadowBlurMult converts blur preset to multiplier func getShadowBlurMult(preset string) string { switch preset { case "sharp": return "0.5" case "soft": return "1.5" default: // "normal" return "1" } } // getTransitionSpeed converts preset to duration func getTransitionSpeed(preset string) string { switch preset { case "fast": return "150ms" case "slow": return "500ms" default: // "normal" return "300ms" } } // getHoverScale converts intensity to CSS scale value func getHoverScale(preset string) string { switch preset { case "subtle": return "1.01" case "bold": return "1.05" default: // "normal" return "1.02" } } // writeElevationVars writes shadow CSS vars based on elevation preset func writeElevationVars(css *strings.Builder, preset string, intensity float64, shadowColor string) { if shadowColor == "" { shadowColor = "0 0% 0%" } // Base opacity values that will be multiplied by intensity var smOp, mdOp, lgOp float64 var smBlur, mdBlur, lgBlur int var smY, mdY, lgY int switch preset { case "flat": smOp, mdOp, lgOp = 0.02, 0.03, 0.05 smBlur, mdBlur, lgBlur = 1, 2, 4 smY, mdY, lgY = 0, 1, 2 case "floating": smOp, mdOp, lgOp = 0.08, 0.12, 0.18 smBlur, mdBlur, lgBlur = 4, 8, 16 smY, mdY, lgY = 2, 6, 12 case "hovering": smOp, mdOp, lgOp = 0.12, 0.18, 0.25 smBlur, mdBlur, lgBlur = 6, 12, 24 smY, mdY, lgY = 4, 10, 20 default: // "raised" smOp, mdOp, lgOp = 0.05, 0.08, 0.12 smBlur, mdBlur, lgBlur = 2, 4, 8 smY, mdY, lgY = 1, 3, 6 } // Apply intensity multiplier smOp *= intensity mdOp *= intensity lgOp *= intensity writeVar(css, "shadow-sm", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", smY, smBlur, shadowColor, smOp)) writeVar(css, "shadow-md", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", mdY, mdBlur, shadowColor, mdOp)) writeVar(css, "shadow-lg", fmt.Sprintf("0 %dpx %dpx hsl(%s / %.3f)", lgY, lgBlur, shadowColor, lgOp)) } func writeVar(css *strings.Builder, name, value string) { if value != "" { fmt.Fprintf(css, " --%s: %s;\n", name, value) } } // writeButtonVars writes CSS variables for button types func writeButtonVars(css *strings.Builder, buttons *ThemeButtons, spacing *ThemeSpacing) { if buttons == nil { return } // Write built-in button types writeButtonTypeVars(css, "primary", buttons.Primary, spacing) writeButtonTypeVars(css, "secondary", buttons.Secondary, spacing) writeButtonTypeVars(css, "outline", buttons.Outline, spacing) writeButtonTypeVars(css, "ghost", buttons.Ghost, spacing) writeButtonTypeVars(css, "link", buttons.Link, spacing) writeButtonTypeVars(css, "destructive", buttons.Destructive, spacing) // Write custom button types for name, config := range buttons.Custom { writeButtonTypeVars(css, name, config, spacing) } } // WriteButtonClasses writes the button CSS class definitions to be appended after :root func WriteButtonClasses(css *strings.Builder, buttons *ThemeButtons) { // Base button class css.WriteString(` /* Base button styles */ .btn { display: inline-flex; align-items: center; justify-content: center; padding: 0.5rem 1rem; font-size: 1rem; font-weight: 500; border-radius: var(--button-radius, 0.375rem); transition: all 150ms ease; cursor: pointer; text-decoration: none; } /* Button sizes */ .btn-sm { padding: 0.375rem 0.75rem; font-size: 0.875rem; } .btn-md { padding: 0.5rem 1rem; font-size: 1rem; } .btn-lg { padding: 0.75rem 1.5rem; font-size: 1.125rem; } /* Theme-aware button types */ .btn-primary { background: var(--btn-primary-bg, hsl(var(--primary))); color: var(--btn-primary-text, hsl(var(--primary-foreground))); border: var(--btn-primary-border-width, 0) solid var(--btn-primary-border, transparent); border-radius: var(--btn-primary-radius, var(--button-radius, 0.375rem)); box-shadow: var(--btn-primary-shadow, none); } .btn-primary:hover { background: var(--btn-primary-hover-bg, hsl(var(--primary) / 0.9)); opacity: 0.9; } .btn-secondary { background: var(--btn-secondary-bg, hsl(var(--secondary))); color: var(--btn-secondary-text, hsl(var(--secondary-foreground))); border: var(--btn-secondary-border-width, 0) solid var(--btn-secondary-border, transparent); border-radius: var(--btn-secondary-radius, var(--button-radius, 0.375rem)); box-shadow: var(--btn-secondary-shadow, none); } .btn-secondary:hover { background: var(--btn-secondary-hover-bg, hsl(var(--secondary) / 0.8)); } .btn-outline { background: var(--btn-outline-bg, transparent); color: var(--btn-outline-text, hsl(var(--primary))); border: var(--btn-outline-border-width, 1px) solid var(--btn-outline-border, hsl(var(--border))); border-radius: var(--btn-outline-radius, var(--button-radius, 0.375rem)); } .btn-outline:hover { background: var(--btn-outline-hover-bg, hsl(var(--accent))); color: var(--btn-outline-hover-text, hsl(var(--accent-foreground))); } .btn-ghost { background: var(--btn-ghost-bg, transparent); color: var(--btn-ghost-text, hsl(var(--foreground))); border: var(--btn-ghost-border-width, 0) solid transparent; border-radius: var(--btn-ghost-radius, var(--button-radius, 0.375rem)); } .btn-ghost:hover { background: var(--btn-ghost-hover-bg, hsl(var(--accent))); color: var(--btn-ghost-hover-text, hsl(var(--accent-foreground))); } .btn-link { background: transparent; color: var(--btn-link-text, hsl(var(--primary))); border: none; padding: 0; text-decoration: underline; text-underline-offset: 4px; } .btn-link:hover { opacity: 0.8; } .btn-destructive { background: var(--btn-destructive-bg, hsl(var(--destructive))); color: var(--btn-destructive-text, hsl(var(--destructive-foreground))); border: var(--btn-destructive-border-width, 0) solid transparent; border-radius: var(--btn-destructive-radius, var(--button-radius, 0.375rem)); } .btn-destructive:hover { background: var(--btn-destructive-hover-bg, hsl(var(--destructive) / 0.9)); } `) // Write custom button type classes if buttons != nil { for name := range buttons.Custom { writeButtonTypeClass(css, name) } } } // writeButtonTypeClass writes CSS class for a custom button type func writeButtonTypeClass(css *strings.Builder, typeName string) { prefix := "btn-" + typeName fmt.Fprintf(css, ` .%s { background: var(--%s-bg, hsl(var(--primary))); color: var(--%s-text, hsl(var(--primary-foreground))); border: var(--%s-border-width, 0) solid var(--%s-border, transparent); border-radius: var(--%s-radius, var(--button-radius, 0.375rem)); box-shadow: var(--%s-shadow, none); } .%s:hover { background: var(--%s-hover-bg, hsl(var(--primary) / 0.9)); } `, prefix, prefix, prefix, prefix, prefix, prefix, prefix, prefix, prefix) } // writeButtonTypeVars writes CSS variables for a single button type func writeButtonTypeVars(css *strings.Builder, typeName string, config *ButtonTypeConfig, spacing *ThemeSpacing) { if config == nil { return } prefix := "btn-" + typeName // Background color - resolve var references to hsl() if config.BgColor != "" { writeVar(css, prefix+"-bg", resolveButtonColor(config.BgColor)) } // Text color if config.TextColor != "" { writeVar(css, prefix+"-text", resolveButtonColor(config.TextColor)) } // Border color if config.BorderColor != "" { writeVar(css, prefix+"-border", resolveButtonColor(config.BorderColor)) } else { writeVar(css, prefix+"-border", "transparent") } // Border width if config.BorderWidth != "" && config.BorderWidth != "0" { writeVar(css, prefix+"-border-width", config.BorderWidth+"px") } else { writeVar(css, prefix+"-border-width", "0") } // Radius - check for pill mode override from spacing if config.Radius == "pill" || (spacing != nil && spacing.PillButtons) { writeVar(css, prefix+"-radius", "9999px") } else if config.Radius != "" { writeVar(css, prefix+"-radius", config.Radius+"rem") } else if spacing != nil && spacing.ButtonRadius != "" { writeVar(css, prefix+"-radius", spacing.ButtonRadius+"rem") } else { writeVar(css, prefix+"-radius", "0.375rem") } // Shadow if config.Shadow != "" && config.Shadow != "none" { writeVar(css, prefix+"-shadow", "var(--shadow-"+config.Shadow+")") } else { writeVar(css, prefix+"-shadow", "none") } // Hover background if config.HoverBg != "" { writeVar(css, prefix+"-hover-bg", resolveButtonColor(config.HoverBg)) } // Hover text if config.HoverText != "" { writeVar(css, prefix+"-hover-text", resolveButtonColor(config.HoverText)) } } // resolveButtonColor converts button color values to CSS // Handles: "var(--primary)" -> "hsl(var(--primary))" // // "var(--primary)/90" -> "hsl(var(--primary) / 0.9)" // "hsl(var(--primary))" -> "hsl(var(--primary))" (passthrough) // "transparent" -> "transparent" // "#ff5500" -> "#ff5500" (passthrough) // HSL value -> "hsl(value)" func resolveButtonColor(color string) string { if color == "" || color == "transparent" { return color } // Already wrapped in hsl() - pass through as-is if strings.HasPrefix(color, "hsl(") { return color } // Hex colors - pass through as-is if strings.HasPrefix(color, "#") { return color } // Handle var() references with optional opacity if strings.HasPrefix(color, "var(") { // Check for /opacity suffix: "var(--primary)/90" if idx := strings.LastIndex(color, "/"); idx > 0 { varPart := strings.TrimSpace(color[:idx]) opacityPart := strings.TrimSpace(color[idx+1:]) // Convert opacity from 0-100 to 0-1 if needed if len(opacityPart) <= 3 { // Assume it's a percentage like "90" return fmt.Sprintf("hsl(%s / %s%%)", varPart, opacityPart) } return fmt.Sprintf("hsl(%s / %s)", varPart, opacityPart) } // Simple var() reference return fmt.Sprintf("hsl(%s)", color) } // Already an HSL value (e.g., "173 80% 40%") return fmt.Sprintf("hsl(%s)", color) } // getFontStack returns a CSS font stack for the given font reference // fontRef formats: // - "system" -> system font stack // - "google:Inter" -> 'Inter', fallback // - "template:gotham:Gotham" -> 'Gotham', fallback // - "upload:uuid" -> looked up from DB, but here we just use the family name func getFontStack(fontRef string, fallback string) string { if fontRef == "" || fontRef == "system" { return getSystemFontStack(fallback) } parts := strings.SplitN(fontRef, ":", 2) if len(parts) < 2 { return getSystemFontStack(fallback) } source := parts[0] switch source { case "system": // System fonts allow specifying the category after the colon if len(parts) >= 2 && parts[1] != "" { return getSystemFontStack(parts[1]) } return getSystemFontStack(fallback) case "google": family := parts[1] return fmt.Sprintf("'%s', %s", family, getSystemFontStack(fallback)) case "template": // template:gotham:Gotham -> family is "Gotham" subparts := strings.SplitN(parts[1], ":", 2) if len(subparts) >= 2 { return fmt.Sprintf("'%s', %s", subparts[1], getSystemFontStack(fallback)) } return fmt.Sprintf("'%s', %s", parts[1], getSystemFontStack(fallback)) case "upload": // Uploaded fonts should already have the family name resolved in the reference if len(parts) >= 2 && parts[1] != "" { return fmt.Sprintf("'%s', %s", parts[1], getSystemFontStack(fallback)) } return getSystemFontStack(fallback) default: return getSystemFontStack(fallback) } } func getSystemFontStack(category string) string { switch category { case "serif": return "ui-serif, Georgia, Cambria, 'Times New Roman', Times, serif" case "monospace": return "ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace" default: // sans-serif return "ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif" } } // GetFontFormat returns the format string for a font file extension func GetFontFormat(filename string) string { lower := strings.ToLower(filename) switch { case strings.HasSuffix(lower, ".woff2"): return "woff2" case strings.HasSuffix(lower, ".woff"): return "woff" case strings.HasSuffix(lower, ".ttf"): return "truetype" case strings.HasSuffix(lower, ".otf"): return "opentype" default: return "woff2" } }