Alex Dunmow cd88c808b0 initial: standalone check-safety module hoisted from CMS
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>
2026-06-06 13:04:02 +08:00

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"
}
}