Static safety/lint runner for the BlockNinja codebase. ~25 invariant
checks across Go and frontend sources. Was at git.dev.alexdunmow.com:block/ninja
in backend/cmd/check-safety/ until the 2026-06-06 consolidation moved
the BlockNinja repos under a shared ~/src/blockninja/ parent.
This repo is the standalone extraction:
- Own go.mod (git.dev.alexdunmow.com/block/check-safety, go 1.26.4)
- Vendored internal/{helpers,theme} from CMS (Go's internal/ rule
blocks cross-module imports; vendoring is the workaround)
- CLI contract unchanged: `check-safety <target-dir> [--flags]`
- CMS Makefile shells into ../check-safety for safety-check /
install-safety-checker targets
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
648 lines
19 KiB
Go
648 lines
19 KiB
Go
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 "<name>-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"
|
|
}
|
|
}
|