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