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>
176 lines
4.4 KiB
Go
176 lines
4.4 KiB
Go
package theme
|
|
|
|
import (
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
)
|
|
|
|
// HSLToHex converts an HSL string like "220 14% 96%" to a hex color like "#f4f5f7".
|
|
// This is needed for email templates since email clients don't support CSS variables.
|
|
func HSLToHex(hsl string) string {
|
|
h, s, l, err := parseHSL(hsl)
|
|
if err != nil {
|
|
// Return a fallback color on parse error
|
|
return "#000000"
|
|
}
|
|
|
|
r, g, b := hslToRGB(h, s, l)
|
|
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
|
}
|
|
|
|
// parseHSL parses an HSL string like "220 14% 96%" into h (0-360), s (0-1), l (0-1).
|
|
func parseHSL(hsl string) (h, s, l float64, err error) {
|
|
// Trim and normalize spaces
|
|
hsl = strings.TrimSpace(hsl)
|
|
parts := strings.Fields(hsl)
|
|
|
|
if len(parts) != 3 {
|
|
return 0, 0, 0, fmt.Errorf("invalid HSL format: expected 3 parts, got %d", len(parts))
|
|
}
|
|
|
|
// Parse hue (0-360 degrees)
|
|
h, err = strconv.ParseFloat(parts[0], 64)
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("invalid hue value: %s", parts[0])
|
|
}
|
|
|
|
// Parse saturation (0-100%)
|
|
satStr := strings.TrimSuffix(parts[1], "%")
|
|
s, err = strconv.ParseFloat(satStr, 64)
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("invalid saturation value: %s", parts[1])
|
|
}
|
|
s = s / 100.0 // Convert to 0-1 range
|
|
|
|
// Parse lightness (0-100%)
|
|
lightStr := strings.TrimSuffix(parts[2], "%")
|
|
l, err = strconv.ParseFloat(lightStr, 64)
|
|
if err != nil {
|
|
return 0, 0, 0, fmt.Errorf("invalid lightness value: %s", parts[2])
|
|
}
|
|
l = l / 100.0 // Convert to 0-1 range
|
|
|
|
return h, s, l, nil
|
|
}
|
|
|
|
// hslToRGB converts HSL values to RGB.
|
|
// h: 0-360, s: 0-1, l: 0-1
|
|
// Returns r, g, b in 0-255 range.
|
|
func hslToRGB(h, s, l float64) (r, g, b uint8) {
|
|
// Normalize hue to 0-1 range
|
|
h = h / 360.0
|
|
|
|
var rFloat, gFloat, bFloat float64
|
|
|
|
if s == 0 {
|
|
// Achromatic (gray)
|
|
rFloat = l
|
|
gFloat = l
|
|
bFloat = l
|
|
} else {
|
|
var q float64
|
|
if l < 0.5 {
|
|
q = l * (1 + s)
|
|
} else {
|
|
q = l + s - l*s
|
|
}
|
|
p := 2*l - q
|
|
|
|
rFloat = hueToRGB(p, q, h+1.0/3.0)
|
|
gFloat = hueToRGB(p, q, h)
|
|
bFloat = hueToRGB(p, q, h-1.0/3.0)
|
|
}
|
|
|
|
r = uint8(math.Round(rFloat * 255))
|
|
g = uint8(math.Round(gFloat * 255))
|
|
b = uint8(math.Round(bFloat * 255))
|
|
|
|
return r, g, b
|
|
}
|
|
|
|
// hueToRGB is a helper function for HSL to RGB conversion.
|
|
func hueToRGB(p, q, t float64) float64 {
|
|
if t < 0 {
|
|
t += 1
|
|
}
|
|
if t > 1 {
|
|
t -= 1
|
|
}
|
|
|
|
if t < 1.0/6.0 {
|
|
return p + (q-p)*6*t
|
|
}
|
|
if t < 1.0/2.0 {
|
|
return q
|
|
}
|
|
if t < 2.0/3.0 {
|
|
return p + (q-p)*(2.0/3.0-t)*6
|
|
}
|
|
return p
|
|
}
|
|
|
|
// ThemeColorsToEmail converts theme color settings (stored as HSL) to EmailColors (hex).
|
|
// This function takes the color map from theme settings and returns hex colors for email inlining.
|
|
func ThemeColorsToEmail(colors map[string]string) map[string]string {
|
|
result := make(map[string]string)
|
|
|
|
colorKeys := []string{
|
|
"primary", "primary-foreground",
|
|
"secondary", "secondary-foreground",
|
|
"background", "foreground",
|
|
"muted", "muted-foreground",
|
|
"border",
|
|
"card", "card-foreground",
|
|
"accent", "accent-foreground",
|
|
"destructive", "destructive-foreground",
|
|
}
|
|
|
|
for _, key := range colorKeys {
|
|
if hsl, ok := colors[key]; ok && hsl != "" {
|
|
result[key] = HSLToHex(hsl)
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// ColorSchemeToEmailColors converts a theme ColorScheme (HSL strings) to hex colors for email inlining.
|
|
// Email clients don't support CSS variables, so colors must be inlined as hex.
|
|
func ColorSchemeToEmailColors(cs *ColorScheme) EmailColors {
|
|
if cs == nil {
|
|
return EmailColors{}
|
|
}
|
|
|
|
return EmailColors{
|
|
Primary: HSLToHex(cs.Primary),
|
|
PrimaryForeground: HSLToHex(cs.PrimaryForeground),
|
|
Secondary: HSLToHex(cs.Secondary),
|
|
SecondaryForeground: HSLToHex(cs.SecondaryForeground),
|
|
Background: HSLToHex(cs.Background),
|
|
Foreground: HSLToHex(cs.Foreground),
|
|
Muted: HSLToHex(cs.Muted),
|
|
MutedForeground: HSLToHex(cs.MutedForeground),
|
|
Border: HSLToHex(cs.Border),
|
|
Card: HSLToHex(cs.Card),
|
|
CardForeground: HSLToHex(cs.CardForeground),
|
|
}
|
|
}
|
|
|
|
// EmailColors contains theme colors converted to hex for email inlining.
|
|
// Mirrors templates.EmailColors structure but lives in theme package for conversion.
|
|
type EmailColors struct {
|
|
Primary string
|
|
PrimaryForeground string
|
|
Secondary string
|
|
SecondaryForeground string
|
|
Background string
|
|
Foreground string
|
|
Muted string
|
|
MutedForeground string
|
|
Border string
|
|
Card string
|
|
CardForeground string
|
|
}
|