go-bananas/internal/seed/seed.go

587 lines
22 KiB
Go
Raw Normal View History

2026-10-07 15:11:35 +02:00
// Package seed holds the built-in themes that ship inside the binary.
//
// Reconciliation rule: a built-in theme is (re)created when missing, and
// refreshed only while the stored copy is still at its seed version. Once an
// operator edits a built-in theme its version moves past the seed and it is
// never touched again. This gives operators a safe upgrade path without the
// risk of a deploy silently reverting deliberate work.
//
// Themes are declared in Go rather than as embedded JSON so that palette values
// stay type-checked and the seed stays in step with the engine's schema.
package seed
import (
"fmt"
"strings"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// SeedVersion is bumped whenever a built-in theme's tokens change. The
// reconciler compares it against the stored version to decide whether to
// refresh a built-in copy.
const SeedVersion int64 = 2
// SeededTheme is one built-in theme definition.
type SeededTheme struct {
Slug string
Name string
Description string
Appearance domain.Appearance
Tags []string
Tokens domain.TokenDoc
}
// All returns every built-in theme in a stable order.
func All() []SeededTheme {
return []SeededTheme{
midnight(), daylight(), mono(), highContrast(),
// The palettes consolidated from the sibling applications. They come after
// the original four so the default listing still leads with the house
// styles, and they are all dark-first or light-first as their source was.
urpiStandard(), tokyoNight(), catppuccin(), tomorrow(), kanagawa(),
nordicMoss(), deepSpace(), cyberpunk(), terminalRetro(),
}
}
// Slugs returns the slugs of every built-in theme.
func Slugs() []string {
defs := All()
out := make([]string, len(defs))
for i, d := range defs {
out[i] = d.Slug
}
return out
}
// IsBuiltIn reports whether slug belongs to a built-in theme.
func IsBuiltIn(slug string) bool {
for _, s := range Slugs() {
if s == slug {
return true
}
}
return false
}
// Vocabulary shared by every built-in theme.
//
// These are group names inside a token document and an appearance value, so they
// repeat once per theme by construction. Naming them means the contract layer, the
// renderer and the seeds all agree on one spelling, and a change to one is a
// single-line diff rather than a search across every palette.
const (
roleSuccess = "success"
roleWarning = "warning"
roleDanger = "danger"
roleBrand = "brand"
roleNeutral = "neutral"
appearanceDark = "dark"
// tagDefault marks a palette as a sensible starting point in the selector, rather
// than one that happens to be published.
tagDefault = "default"
)
// ---------------------------------------------------------------- builders --
func leaf(value any, kind tokenengine.Kind) map[string]any {
return map[string]any{tokenengine.TypeKey: string(kind), tokenengine.ValueKey: value}
}
func color(value string) map[string]any { return leaf(value, tokenengine.KindColor) }
func dim(value any) map[string]any { return leaf(value, tokenengine.KindDimension) }
func num(value any) map[string]any { return leaf(value, tokenengine.KindNumber) }
func weight(value any) map[string]any { return leaf(value, tokenengine.KindFontWeight) }
func leading(value any) map[string]any { return leaf(value, tokenengine.KindLineHeight) }
func dur(ms any) map[string]any { return leaf(ms, tokenengine.KindDuration) }
func family(v string) map[string]any { return leaf(v, tokenengine.KindFontFamily) }
func shadow(v string) map[string]any { return leaf(v, tokenengine.KindShadow) }
func bezier(a, b, c, d float64) map[string]any {
return leaf([]any{a, b, c, d}, tokenengine.KindCubicBezier)
}
// scales returns the non-colour groups shared by every built-in theme: the
// spacing scale, radii, borders, motion, z-index, opacity and breakpoints.
// Themes differ in colour and typography only.
func scales() map[string]any {
return map[string]any{
"space": map[string]any{
"0": dim(0), "px": dim(1),
"1": dim(0.25), "2": dim(0.5), "3": dim(0.75),
"4": dim(1), "5": dim(1.5), "6": dim(2),
"8": dim(3), "10": dim(4), "12": dim(6), "16": dim(8),
},
"radius": map[string]any{
"none": dim(0), "sm": dim(0.25), "md": dim(0.5),
"lg": dim(0.75), "xl": dim(1.25), "2xl": dim(1.5), "full": dim(9999),
},
"border": map[string]any{
"width": map[string]any{
"thin": dim(1), "default": dim(1), "thick": dim(2), "focus": dim(3),
},
},
"duration": map[string]any{
"instant": dur(0), "fast": dur(120), "normal": dur(200), "slow": dur(320),
},
"ease": map[string]any{
"linear": bezier(0, 0, 1, 1), "standard": bezier(0.2, 0, 0, 1),
"entrance": bezier(0, 0, 0.2, 1), "exit": bezier(0.4, 0, 1, 1),
},
"zIndex": map[string]any{
"base": num(0), "dropdown": num(1000), "sticky": num(1100),
"overlay": num(1200), "modal": num(1300), "toast": num(1400),
},
"opacity": map[string]any{
"disabled": num(0.45), "muted": num(0.7), "strong": num(0.9),
},
"breakpoint": map[string]any{
"sm": dim(640), "md": dim(768), "lg": dim(1024),
"xl": dim(1280), "2xl": dim(1536),
},
}
}
// typeScale builds the typography groups from a modular scale: each step is
// baseSize * ratio^index, which keeps the ramp mathematically consistent in a
// way that hand-tuned type ramps rarely are.
func typeScale(sans string, baseSize, ratio, lineScale float64) map[string]any {
sizes := make(map[string]any)
steps := []struct {
name string
index float64
}{
{"xs", -2}, {"sm", -1}, {"md", 0}, {"lg", 1}, {"xl", 2}, {"2xl", 3}, {"3xl", 4},
}
for _, s := range steps {
sizes[s.name] = leaf(round2(baseSize*powf(ratio, s.index)), tokenengine.KindFontSize)
}
return map[string]any{
"family": map[string]any{
"sans": family(sans),
"mono": family("ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace"),
"display": family(sans),
},
"size": sizes,
"weight": map[string]any{
"normal": weight(400), "medium": weight(500),
"semibold": weight(600), "bold": weight(700),
},
"lineHeight": map[string]any{
"none": leading(1), "tight": leading(1.2), "snug": leading(1.375),
"normal": leading(1.5), "relaxed": leading(1.65), "loose": leading(2),
},
"letterSpacing": map[string]any{
"tighter": dim(-0.05), "tight": dim(-0.025), "normal": dim(0),
"wide": dim(0.025), "wider": dim(0.05),
},
"style": map[string]any{"normal": leaf("normal", tokenengine.KindFontStyle), "italic": leaf("italic", tokenengine.KindFontStyle)},
"scale": num(round2(ratio)),
"lineScale": num(round2(lineScale)),
}
}
// shadows builds a shadow ramp. Shadows tint towards slate rather than pure
// black, which is the difference between elevation that looks natural and
// elevation that looks like a drop shadow from 2004.
func shadows(strength float64) map[string]any {
tint := "15 23 42"
a := func(multiplier float64) string { return fmt.Sprintf("%.2f", strength*multiplier) }
return map[string]any{
"none": shadow("none"),
"xs": shadow(fmt.Sprintf("0 1px 2px 0 rgb(%s / %s)", tint, a(0.08))),
"sm": shadow(fmt.Sprintf("0 1px 3px 0 rgb(%s / %s), 0 1px 2px -1px rgb(%s / %s)", tint, a(0.12), tint, a(0.08))),
"md": shadow(fmt.Sprintf("0 4px 6px -1px rgb(%s / %s), 0 2px 4px -2px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
"lg": shadow(fmt.Sprintf("0 10px 15px -3px rgb(%s / %s), 0 4px 6px -4px rgb(%s / %s)", tint, a(0.12), tint, a(0.1))),
"xl": shadow(fmt.Sprintf("0 20px 25px -5px rgb(%s / %s), 0 8px 10px -6px rgb(%s / %s)", tint, a(0.14), tint, a(0.1))),
"focus": shadow("0 0 0 3px rgb(99 102 241 / 0.4)"),
"color": leaf(tint, tokenengine.KindColor),
}
}
// semantics assembles the semantic colour group for one scheme.
//
// Every role points at a concrete palette step rather than at an alias token:
// published themes must not be able to change colour underneath a consumer when
// somebody later edits the underlying palette.
func semantics(ramp tokenengine.RampOptions, scheme domain.Appearance, overrides map[string]any) map[string]any {
step := func(shade string) map[string]any { return color(tokenengine.RampStep(ramp, shade)) }
// Scheme-dependent step selection. In a light scheme surfaces are near-white
// and text is near-black; a dark scheme inverts both.
light := scheme == domain.AppearanceLight
pick := func(lightShade, darkShade string) map[string]any {
if light {
return step(lightShade)
}
return step(darkShade)
}
out := map[string]any{
"background": pick("50", "950"),
"surface": pick("50", "900"),
"surface-raised": pick("100", "800"),
"surface-sunken": pick("100", "950"),
"border": pick("200", "800"),
"border-strong": pick("300", "700"),
"text": pick("900", "100"),
"text-muted": pick("700", "300"),
"text-subtle": pick("600", "400"),
"text-inverted": pick("50", "950"),
"primary": step("600"),
"primary-hover": step("700"),
"primary-active": step("800"),
"on-primary": color(foregroundFor(tokenengine.RampStep(ramp, "600"))),
"secondary": pick("200", "800"),
"secondary-hover": pick("300", "700"),
"on-secondary": color(foregroundFor(tokenengine.RampStep(ramp, pickShade(light, "200", "800")))),
"accent": step("500"),
"on-accent": color(foregroundFor(tokenengine.RampStep(ramp, "500"))),
"link": step("600"),
"link-hover": step("700"),
// Status colours are fixed rather than generated: green must mean
// success in every theme, or the interface lies to the user.
"success": color("#16a34a"),
"on-success": color("#f0fdf4"),
"warning": color("#d97706"),
"on-warning": color("#fffbeb"),
"danger": color("#dc2626"),
"on-danger": color("#fef2f2"),
"info": color("#2563eb"),
"on-info": color("#eff6ff"),
"focus-ring": color(tokenengine.RampStep(ramp, "500")),
"overlay": color(pickColor(light, "#0f172a66", "#020617b3")),
"selection": color(tokenengine.RampStep(ramp, pickShade(light, "200", "800"))),
"scrollbar-thumb": pick("300", "700"),
}
for role, value := range overrides {
out[role] = value
}
return out
}
func pickColor(cond bool, whenTrue, whenFalse string) string {
if cond {
return whenTrue
}
return whenFalse
}
func pickShade(light bool, lightShade, darkShade string) string {
if light {
return lightShade
}
return darkShade
}
// foregroundFor returns whichever of black or white has the higher WCAG
// contrast ratio against the given colour.
func foregroundFor(hex string) string {
bg, err := tokenengine.ParseColor(hex)
if err != nil || !bg.Numeric {
// A colour that cannot be parsed cannot have a computed contrast, so white is
// the safe default: it is the readable choice against every ramp in this file.
return white
}
return tokenengine.BestForeground(bg).Hex()
}
// assemble combines the shared scales with a theme's colour and typography.
func assemble(palettes map[string]any, light, dark map[string]any, typography, shadowSet map[string]any) domain.TokenDoc {
doc := domain.TokenDoc{
tokenengine.SchemaKey: tokenengine.SchemaID,
"color": map[string]any{"semantic": light},
"font": typography,
"shadow": shadowSet,
tokenengine.AppearancesKey: map[string]any{
appearanceDark: map[string]any{
"color": map[string]any{"semantic": dark},
},
},
}
colorGroup := doc["color"].(map[string]any)
for name, ramp := range palettes {
colorGroup[name] = ramp
}
for k, v := range scales() {
doc[k] = v
}
return doc
}
// ------------------------------------------------------------------ themes --
// midnight is the default theme: a violet-accented dark interface.
func midnight() SeededTheme {
brand := tokenengine.RampOptions{Hue: 258, Saturation: 84, Lightness: 58, HueSpread: 14}
neutral := tokenengine.RampOptions{Hue: 224, Saturation: 28, Lightness: 46, HueSpread: 8}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 62, Lightness: 44, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 92, Lightness: 50, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 0, Saturation: 74, Lightness: 52, HueSpread: 6}),
}
// The dark scheme leads; the light variant is the derived inversion.
return SeededTheme{
Slug: "midnight",
Name: "Midnight",
Description: "Violet-accented dark interface with a slate neutral ramp. The Go Bananas!! default.",
Appearance: domain.AppearanceDark,
Tags: []string{appearanceDark, tagDefault, "violet", "slate"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, nil),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color(tokenengine.RampStep(brand, "500")),
"primary-hover": color(tokenengine.RampStep(brand, "400")),
"primary-active": color(tokenengine.RampStep(brand, "300")),
"on-primary": color(foregroundFor(tokenengine.RampStep(brand, "500"))),
"accent": color(tokenengine.RampStep(brand, "400")),
"on-accent": color(foregroundFor(tokenengine.RampStep(brand, "400"))),
"link": color(tokenengine.RampStep(brand, "400")),
"link-hover": color(tokenengine.RampStep(brand, "300")),
"focus-ring": color(tokenengine.RampStep(brand, "400")),
"selection": color(tokenengine.RampStep(brand, "800")),
}),
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.875, 1.2, 1.5),
shadows(1.0),
),
}
}
// daylight is a crisp light interface built on the same violet brand hue.
func daylight() SeededTheme {
brand := tokenengine.RampOptions{Hue: 258, Saturation: 78, Lightness: 55, HueSpread: 14}
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 18, Lightness: 48, HueSpread: 6}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 70, Lightness: 40, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 94, Lightness: 48, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 76, Lightness: 50, HueSpread: 6}),
}
return SeededTheme{
Slug: "daylight",
Name: "Daylight",
Description: "High-contrast light interface with generous radii and soft shadows.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "default", "violet", "crisp"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, nil),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color(tokenengine.RampStep(brand, "500")),
"link": color(tokenengine.RampStep(brand, "600")),
"focus-ring": color(tokenengine.RampStep(brand, "500")),
"primary-hover": color(tokenengine.RampStep(brand, "700")),
}),
typeScale("Inter, ui-sans-serif, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif", 0.9375, 1.2, 1.5),
shadows(0.7),
),
}
}
// mono is a typographic, near-greyscale theme for dense interfaces.
func mono() SeededTheme {
brand := tokenengine.RampOptions{Hue: 210, Saturation: 12, Lightness: 45, HueSpread: 4}
neutral := tokenengine.RampOptions{Hue: 220, Saturation: 8, Lightness: 48, HueSpread: 2}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(neutral),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 145, Saturation: 45, Lightness: 38, HueSpread: 4}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 40, Saturation: 60, Lightness: 46, HueSpread: 4}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 62, Lightness: 48, HueSpread: 4}),
}
// A tighter type scale and larger base suits data-dense screens.
typography := typeScale("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif", 0.8125, 1.125, 1.5)
typography["family"] = map[string]any{
"sans": family("'IBM Plex Sans', Inter, ui-sans-serif, system-ui, sans-serif"),
"mono": family("'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace"),
"display": family("'IBM Plex Sans', ui-sans-serif, system-ui, sans-serif"),
}
return SeededTheme{
Slug: "mono",
Name: "Mono",
Description: "Near-greyscale, tight and dense. Built for tables, consoles and data tooling.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "greyscale", "dense", "data"},
Tokens: assemble(palettes,
semantics(neutral, domain.AppearanceLight, map[string]any{
// A greyscale theme still needs a distinguishable accent or
// nothing on screen reads as interactive.
"primary": color("#111827"),
"link": color("#111827"),
"focus-ring": color("#111827"),
}),
semantics(neutral, domain.AppearanceDark, map[string]any{
"primary": color("#f8fafc"),
"link": color("#f8fafc"),
"focus-ring": color("#f8fafc"),
}),
typography,
shadows(0.5),
),
}
}
// highContrast maximises legibility: near-black on white, thick borders, no
// tints, no translucency. It is the theme to ship when accessibility is the
// primary requirement.
func highContrast() SeededTheme {
black := "#000000"
// Reserved high-contrast overrides. The light scheme is pure black on pure
// white; the dark scheme is the exact inversion, because white-on-black is
// just as legible and some users need it for photophobia or low-vision.
hcLight := highContrastRoles(black, white, "#7a3e00", "#004d00", "#8c0000", "#00308c")
hcDark := highContrastRoles(white, black, "#ffd8a8", "#b9f6ca", "#ffc9c9", "#b3d9ff")
brand := tokenengine.RampOptions{Hue: 220, Saturation: 0, Lightness: 50, HueSpread: 0}
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(brand),
roleNeutral: tokenengine.GenerateRamp(brand),
}
// Thicker borders and no shadow blur: at maximum contrast a soft shadow
// disappears, so separation must come from a stroke.
typography := typeScale("Verdana, Tahoma, 'Segoe UI', system-ui, sans-serif", 1, 1.25, 1.6)
shadowSet := map[string]any{
"none": shadow("none"), "xs": shadow("none"), "sm": shadow("none"),
"md": shadow("none"), "lg": shadow("none"), "xl": shadow("none"),
"focus": shadow("0 0 0 3px " + black),
}
return SeededTheme{
Slug: "high-contrast",
Name: "High Contrast",
Description: "Maximum legibility. Pure black on pure white, thick strokes, no translucency. Inverts for a white-on-black variant.",
Appearance: domain.AppearanceLight,
Tags: []string{"light", "dark", "accessibility", "wcag", "aaa"},
Tokens: assemble(palettes, hcLight, hcDark, typography, shadowSet),
}
}
// highContrastRoles builds the full semantic set for a maximum-contrast scheme
// from two anchor colours plus four status colours that keep their hue but are
// darkened or lightened to stay at AAA against the anchor background.
func highContrastRoles(foreground, background, warning, success, danger, info string) map[string]any {
return map[string]any{
"background": color(background),
"surface": color(background),
"surface-raised": color(background),
"surface-sunken": color(background),
"border": color(foreground),
"border-strong": color(foreground),
"text": color(foreground),
"text-muted": color(foreground),
"text-subtle": color(foreground),
"text-inverted": color(background),
"primary": color(foreground),
"primary-hover": color(foreground),
"primary-active": color(foreground),
"on-primary": color(background),
"secondary": color(background),
"secondary-hover": color(background),
"on-secondary": color(foreground),
"accent": color(foreground),
"on-accent": color(background),
"link": color(foreground),
"link-hover": color(foreground),
"focus-ring": color(foreground),
"overlay": color(foreground),
"selection": color(foreground),
"scrollbar-thumb": color(foreground),
"success": color(success),
"on-success": color(background),
"warning": color(warning),
"on-warning": color(background),
"danger": color(danger),
"on-danger": color(background),
"info": color(info),
"on-info": color(background),
}
}
// -------------------------------------------------------------- conversion --
// systemThemeID derives a stable identifier for a built-in theme so that the
// reconciler never has to search by slug.
func systemThemeID(slug string) string {
return "sys_" + strings.ReplaceAll(slug, "-", "_")
}
// Theme converts a seeded definition into a persistable domain theme.
func (s SeededTheme) Theme() *domain.Theme {
return &domain.Theme{
ID: systemThemeID(s.Slug),
Slug: s.Slug,
Name: s.Name,
Description: s.Description,
Status: domain.StatusPublished,
DefaultAppearance: s.Appearance,
Tokens: s.Tokens.Clone(),
Meta: domain.ThemeMeta{
"tags": s.Tags,
"builtIn": true,
"seedVersion": SeedVersion,
"author": "go-bananas",
},
System: true,
Version: SeedVersion,
CreatedBy: "system",
UpdatedBy: "system",
}
}
// Themes converts every seeded definition into persistable domain themes.
func Themes() []*domain.Theme {
defs := All()
out := make([]*domain.Theme, 0, len(defs))
for _, d := range defs {
out = append(out, d.Theme())
}
return out
}
// ------------------------------------------------------------------- maths --
// round2 rounds to two decimal places, which is finer than any CSS length
// resolution and keeps generated values readable.
func round2(v float64) float64 { return float64(int(v*100+0.5)) / 100 }
// powf is integer-step exponentiation, which is all a modular scale needs.
func powf(base, exp float64) float64 {
if exp == 0 {
return 1
}
neg := exp < 0
n := int(exp)
if neg {
n = -n
}
result := 1.0
for i := 0; i < n; i++ {
result *= base
}
if neg {
return 1 / result
}
return result
}