initial commit
Some checks failed
ci / go (push) Has been cancelled
ci / admin-ui (push) Has been cancelled
ci / runtime-sdk (push) Has been cancelled
ci / helm (push) Has been cancelled
ci / openapi (push) Has been cancelled
ci / container (push) Has been cancelled
ci / release-config (push) Has been cancelled
ci / shared styling (push) Has been cancelled
ci / security (push) Has been cancelled
ci / all green (push) Has been cancelled

This commit is contained in:
Daniel Noulet 2026-10-07 15:11:35 +02:00
commit 951dd8d26b
175 changed files with 50041 additions and 0 deletions

View file

@ -0,0 +1,605 @@
package seed
import (
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// Palettes consolidated from the sibling applications.
//
// Each of these is a real palette lifted from an application that is already running
// with it, rather than a generated ramp. That is the point of consolidating them: the
// applications can adopt the shared contract and keep the look their users already
// have, instead of being handed a new design.
//
// The colours are the roles those applications actually use, mapped onto the contract's
// roles. A source application had only one appearance for some roles and a different
// one for others, so both schemes are filled in here and the contract supplies the
// second axis.
//
// Provenance is recorded in the description. A palette nobody can trace back to the
// screen it came from becomes an orphan within a year.
// Colours and values repeated across the palettes below.
//
// A consolidated palette set is mostly repetition by nature — every dark scheme has
// the same white for reversed text, every go-loose theme has the same card radius —
// so these are named rather than repeated inline. That makes a change to "what white
// means here" a one-line edit instead of a search.
const (
white = "#ffffff"
paperLight = "#fffaf0"
cardRadius = "16px"
// tightRadius is the corner radius of the two palettes whose source application
// uses tighter geometry than go-loose's 16px cards.
tightRadius = "10px"
// interStack is the sans stack four of the six applications ask for by name. None
// of them load the file, so in practice this resolves to the system UI font; it
// is kept because it is what the applications declare, and a future font load
// should not have to touch six stylesheets.
interStack = "'Inter', ui-sans-serif, system-ui, sans-serif"
)
// scheme is one appearance's worth of literal colours.
type scheme struct {
Background string
Surface string
SurfaceRaised string
SurfaceSunken string
Border string
BorderStrong string
Text string
TextMuted string
TextSubtle string
TextInverted string
Primary string
PrimaryHover string
PrimaryActive string
OnPrimary string
Secondary string
OnSecondary string
Success string
Warning string
Danger string
Info string
FocusRing string
Overlay string
Ramp tokenengine.RampOptions
FontSans string
RadiusCard string
}
// consolidate assembles a seeded theme from two literal schemes.
//
// Ramps are still generated, for the palette and status families the contract exposes
// as scales (`--bn-palette-*`). The semantic roles are literal, because those are the
// values the source applications actually render and inventing a ramp here would
// change the appearance the consolidation is meant to preserve.
func consolidate(
slug, name, description string,
defaultAppearance domain.Appearance,
tags []string,
light, dark scheme,
) SeededTheme {
palettes := map[string]any{
roleBrand: tokenengine.GenerateRamp(light.Ramp),
roleNeutral: tokenengine.GenerateRamp(neutralRamp(light.Ramp)),
roleSuccess: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 142, Saturation: 55, Lightness: 42, HueSpread: 10}),
roleWarning: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 38, Saturation: 88, Lightness: 50, HueSpread: 8}),
roleDanger: tokenengine.GenerateRamp(tokenengine.RampOptions{Hue: 358, Saturation: 68, Lightness: 50, HueSpread: 8}),
}
fontSans := light.FontSans
if fontSans == "" {
fontSans = interStack
}
typography := typeScale(fontSans, 0.875, 1.25, 1.625)
typography["family"] = map[string]any{
"sans": family(fontSans),
"mono": family("ui-monospace, SFMono-Regular, Menlo, monospace"),
"display": family(fontSans),
}
shadowSet := map[string]any{
"none": shadow("none"),
"sm": shadow("0 1px 2px rgba(0,0,0,0.06)"),
"md": shadow("0 6px 20px rgba(0,0,0,0.08)"),
"lg": shadow("0 18px 48px rgba(0,0,0,0.14)"),
"xl": shadow("0 28px 80px rgba(0,0,0,0.20)"),
"border": shadow("inset 0 0 0 1px rgba(0,0,0,0.08)"),
}
doc := assemble(palettes,
literalSemantics(light),
literalSemantics(dark),
typography,
shadowSet,
)
// The transcribed corner radius becomes a real token rather than a note in a
// struct field. A consumer writing `border-radius: var(--radius-md)` then gets
// the source application's actual radius, which is the whole reason for copying
// its palette rather than approximating one.
applyCardRadius(doc, light.RadiusCard)
return SeededTheme{
Slug: slug,
Name: name,
Description: description,
Appearance: defaultAppearance,
Tags: tags,
Tokens: doc,
}
}
// applyCardRadius sets the radius scale so that its `md` step is the source
// application's card radius, with the surrounding steps kept in proportion.
//
// A palette whose corners are visibly rounder or squarer than the application it came
// from does not read as that application, and the radius is one of the first things a
// user notices.
func applyCardRadius(doc domain.TokenDoc, card string) {
if card == "" {
return
}
group, ok := doc["radius"].(map[string]any)
if !ok {
group = map[string]any{}
doc["radius"] = group
}
// Proportions taken from the scale the other built-in themes use, so a
// consolidated palette and an original one are the same shape.
for step, factor := range map[string]string{
"sm": "0.5",
"md": "1",
"lg": "1.5",
"xl": "2",
} {
if step == "md" {
group[step] = dim(card)
continue
}
group[step] = dim("calc(" + card + " * " + factor + ")")
}
}
// neutralRamp derives a low-saturation neutral from a brand ramp, so a palette and its
// greys come from one description rather than two guesses.
func neutralRamp(brand tokenengine.RampOptions) tokenengine.RampOptions {
return tokenengine.RampOptions{
Hue: brand.Hue,
Saturation: minFloat(brand.Saturation*0.18, 22),
Lightness: minFloat(brand.Lightness*0.72, 52),
HueSpread: 4,
}
}
// minFloat returns the smaller of two values.
func minFloat(a, b float64) float64 {
if a < b {
return a
}
return b
}
// literalSemantics turns a scheme into the contract's semantic colour roles.
//
// Every role is filled explicitly. A missing role would fall back to the engine's
// light-mode default, which for a dark-first palette is a pale colour in the middle of
// a dark interface — a bug that is easy to miss and impossible to debug from a
// screenshot.
func literalSemantics(s scheme) map[string]any {
out := map[string]any{
"background": color(s.Background),
"surface": color(s.Surface),
"surface-raised": color(s.SurfaceRaised),
"surface-sunken": color(s.SurfaceSunken),
"border": color(s.Border),
"border-strong": color(s.BorderStrong),
"text": color(s.Text),
"text-muted": color(s.TextMuted),
"text-subtle": color(s.TextSubtle),
"text-inverted": color(s.TextInverted),
"primary": color(s.Primary),
"primary-hover": color(s.PrimaryHover),
"primary-active": color(s.PrimaryActive),
"on-primary": color(s.OnPrimary),
"secondary": color(s.Secondary),
"on-secondary": color(s.OnSecondary),
"success": color(s.Success),
"warning": color(s.Warning),
"danger": color(s.Danger),
"info": color(s.Info),
"focus-ring": color(s.FocusRing),
"overlay": color(s.Overlay),
}
return out
}
// The four palettes below come from four different applications. The colours are
// theirs, transcribed; where an application had only one appearance, the other is
// derived by inverting the surface and text roles and keeping the accent family, which
// is what its own stylesheet effectively does.
// tokyoNight is go-loose's default palette, the one its switcher opens on.
//
// The slug is `tokyo`, not `tokyo-night`, because that is the value go-loose already
// writes into its data-theme attribute. Matching it is the whole point: adopting the
// contract must not require the adopting application to change a single stored value.
func tokyoNight() SeededTheme {
blue := tokenengine.RampOptions{Hue: 224, Saturation: 60, Lightness: 58, HueSpread: 10}
return consolidate(
"tokyo", "Tokyo Night",
"go-loose's default palette. Low-contrast blue-grey with a bright accent; "+
"chosen for long sessions against a terminal.",
domain.AppearanceDark,
[]string{appearanceDark, tagDefault, "blue", "go-loose"},
scheme{
Background: "#e6e7ed", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#dfe2ed", Border: "#b4b5c3", BorderStrong: "#8b8da1",
Text: "#343b58", TextMuted: "#34548a", TextSubtle: "#4a5a80",
TextInverted: white,
Primary: "#0f4b6e", PrimaryHover: "#0c3d5a", PrimaryActive: "#093249",
OnPrimary: white,
Secondary: "#5a4a78", OnSecondary: white,
Success: "#485e30", Warning: "#8a6a1f", Danger: "#8c4351", Info: "#2f6b8f",
FocusRing: "#0f4b6e", Overlay: "rgba(52, 59, 88, 0.32)",
Ramp: blue, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1a1b26", Surface: "#20222f", SurfaceRaised: "#282a37",
SurfaceSunken: "#16161e", Border: "#292e42", BorderStrong: "#3b4261",
Text: "#c0caf5", TextMuted: "#9aa5ce", TextSubtle: "#7aa2f7",
TextInverted: "#1a1b26",
Primary: "#7aa2f7", PrimaryHover: "#93b1ff", PrimaryActive: "#aac7ff",
OnPrimary: "#1a1b26",
Secondary: "#bb9af7", OnSecondary: "#1a1b26",
Success: "#9ece6a", Warning: "#e0af68", Danger: "#f7768e", Info: "#7dcfff",
FocusRing: "#7aa2f7", Overlay: "rgba(0, 0, 0, 0.55)",
Ramp: blue, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// catppuccin is go-loose's second palette, a soft pastel set.
func catppuccin() SeededTheme {
lavender := tokenengine.RampOptions{Hue: 232, Saturation: 62, Lightness: 62, HueSpread: 12}
return consolidate(
"catppuccin", "Catppuccin",
"go-loose's pastel palette. Softer contrast than Tokyo Night, which suits a "+
"content-heavy screen better than a dense one.",
domain.AppearanceDark,
[]string{appearanceDark, "pastel", "lavender", "go-loose"},
scheme{
Background: "#eff1f5", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#e6e9ef", Border: "#bcc0cc", BorderStrong: "#9499b0",
Text: "#4c4f69", TextMuted: "#5c61a8", TextSubtle: "#6c6f85",
TextInverted: white,
Primary: "#1e66f5", PrimaryHover: "#1a56db", PrimaryActive: "#1647b8",
OnPrimary: white,
Secondary: "#8839ef", OnSecondary: white,
Success: "#40a02b", Warning: "#df8e1d", Danger: "#d20f39", Info: "#179299",
FocusRing: "#1e66f5", Overlay: "rgba(76, 79, 105, 0.28)",
Ramp: lavender, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1e1e2e", Surface: "#181825", SurfaceRaised: "#313244",
SurfaceSunken: "#11111b", Border: "#313244", BorderStrong: "#45475a",
Text: "#cdd6f4", TextMuted: "#a6adc8", TextSubtle: "#9399b2",
TextInverted: "#1e1e2e",
Primary: "#89b4fa", PrimaryHover: "#a3c3f5", PrimaryActive: "#b8d0f6",
OnPrimary: "#1e1e2e",
Secondary: "#cba6f7", OnSecondary: "#1e1e2e",
Success: "#a6e3a1", Warning: "#f9e2af", Danger: "#f38ba8", Info: "#94e2d5",
FocusRing: "#89b4fa", Overlay: "rgba(0, 0, 0, 0.5)",
Ramp: lavender, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// nordicMoss is go-tell's green palette, slugged `nordic` to match its own value. Its source theme is dark-only; the light
// scheme is the inversion with the green family kept.
func nordicMoss() SeededTheme {
moss := tokenengine.RampOptions{Hue: 132, Saturation: 32, Lightness: 44, HueSpread: 12}
return consolidate(
"nordic", "Nordic Moss",
"go-tell's green palette. Desaturated and low-contrast; the light scheme is "+
"derived, because the source theme is dark-only.",
domain.AppearanceDark,
[]string{appearanceDark, "green", "calm", "go-tell"},
scheme{
Background: "#e9f0e8", Surface: white, SurfaceRaised: "#f4f8f3",
SurfaceSunken: "#dfe9de", Border: "#c2d6c4", BorderStrong: "#94a899",
Text: "#16241c", TextMuted: "#3b5645", TextSubtle: "#588157",
TextInverted: white,
Primary: "#2e4435", PrimaryHover: "#25392d", PrimaryActive: "#1a2820",
OnPrimary: "#e9f0e8",
Secondary: "#6f894e", OnSecondary: white,
Success: "#3f6b46", Warning: "#8a7434", Danger: "#a1453f", Info: "#3a6a70",
FocusRing: "#2e4435", Overlay: "rgba(22, 36, 28, 0.28)",
Ramp: moss, FontSans: interStack,
RadiusCard: tightRadius,
},
scheme{
Background: "#121c16", Surface: "#1e3025", SurfaceRaised: "#25392d",
SurfaceSunken: "#0e1712", Border: "#2e4435", BorderStrong: "#3b5645",
Text: "#e9f0e8", TextMuted: "#a3c9a8", TextSubtle: "#8fa193",
TextInverted: "#121c16",
Primary: "#a3c9a8", PrimaryHover: "#b8d8bb", PrimaryActive: "#c9e2cb",
OnPrimary: "#121c16",
Secondary: "#8fbc8f", OnSecondary: "#121c16",
Success: "#8fbc8f", Warning: "#e2c044", Danger: "#e07a72", Info: "#7fb4ca",
FocusRing: "#a3c9a8", Overlay: "rgba(0, 0, 0, 0.52)",
Ramp: moss, FontSans: interStack,
RadiusCard: tightRadius,
},
)
}
// deepSpace is go-tell's `obsidian` palette, and the one that most needs a light
// scheme: its source is a near-black space theme, so an untouched inversion would be
// unusable.
func deepSpace() SeededTheme {
sky := tokenengine.RampOptions{Hue: 199, Saturation: 88, Lightness: 58, HueSpread: 12}
return consolidate(
"obsidian", "Deep Space Obsidian",
"go-tell's near-black blue palette. The light scheme is authored rather than "+
"inverted, because the source is a dark-first space theme.",
domain.AppearanceDark,
[]string{appearanceDark, "blue", "space", "go-tell"},
scheme{
Background: "#f1f5f9", Surface: white, SurfaceRaised: "#f8fafc",
SurfaceSunken: "#e8eef5", Border: "#d3dce6", BorderStrong: "#a8b6c6",
Text: "#0f172a", TextMuted: "#475569", TextSubtle: "#64748b",
TextInverted: "#f8fafc",
Primary: "#0284c7", PrimaryHover: "#0369a1", PrimaryActive: "#075985",
OnPrimary: white,
Secondary: "#1e293b", OnSecondary: "#f8fafc",
Success: "#15803d", Warning: "#b45309", Danger: "#b91c1c", Info: "#0e7490",
FocusRing: "#0284c7", Overlay: "rgba(15, 23, 42, 0.3)",
Ramp: sky, FontSans: interStack,
RadiusCard: tightRadius,
},
scheme{
Background: "#060709", Surface: "#0f172a", SurfaceRaised: "#151d2a",
SurfaceSunken: "#020617", Border: "#1e293b", BorderStrong: "#334155",
Text: "#e2e8f0", TextMuted: "#94a3b8", TextSubtle: "#64748b",
TextInverted: "#060709",
Primary: "#38bdf8", PrimaryHover: "#7dd3fc", PrimaryActive: "#bae6fd",
OnPrimary: "#060709",
Secondary: "#60a5fa", OnSecondary: "#060709",
Success: "#4ade80", Warning: "#fbbf24", Danger: "#f87171", Info: "#22d3ee",
FocusRing: "#38bdf8", Overlay: "rgba(0, 0, 0, 0.6)",
Ramp: sky, FontSans: interStack,
RadiusCard: tightRadius,
},
)
}
// urpiStandard is go-guess's palette, which is the only light-first application of the
// six and therefore the one whose light scheme is the authoritative one.
func urpiStandard() SeededTheme {
cyan := tokenengine.RampOptions{Hue: 190, Saturation: 78, Lightness: 40, HueSpread: 10}
return consolidate(
"urpi-standard", "urpi Standard",
"go-guess's palette: a cyan accent and lime highlight on a cool grey. The "+
"light scheme is authoritative, being the application's only one.",
domain.AppearanceLight,
[]string{"light", tagDefault, "cyan", "go-guess"},
scheme{
Background: "#f3f7f8", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#edf2f3", Border: "#dce5e8", BorderStrong: "#c2d2d6",
Text: "#142b3a", TextMuted: "#526872", TextSubtle: "#78909c",
TextInverted: white,
Primary: "#007f98", PrimaryHover: "#006b81", PrimaryActive: "#00586a",
OnPrimary: white,
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
Success: "#13704d", Warning: "#925a09", Danger: "#934141", Info: "#087187",
FocusRing: "#007f98", Overlay: "rgba(14, 42, 56, 0.34)",
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
RadiusCard: cardRadius,
},
scheme{
Background: "#081c2c", Surface: "#0e2a3f", SurfaceRaised: "#123449",
SurfaceSunken: "#04131f", Border: "#1d4258", BorderStrong: "#2c5f7a",
Text: "#e3ebed", TextMuted: "#a9bec8", TextSubtle: "#82949d",
TextInverted: "#081c2c",
Primary: "#00a9c8", PrimaryHover: "#33bcd6", PrimaryActive: "#66cde0",
OnPrimary: "#04131f",
Secondary: "#d9ef4d", OnSecondary: "#0b573c",
Success: "#16a274", Warning: "#eda83c", Danger: "#e6988c", Info: "#43c99c",
FocusRing: "#00a9c8", Overlay: "rgba(0, 0, 0, 0.55)",
Ramp: cyan, FontSans: "'DM Sans', Inter, ui-sans-serif, system-ui, sans-serif",
RadiusCard: cardRadius,
},
)
}
// tomorrow is go-loose's third palette: a warm grey with a muted teal.
func tomorrow() SeededTheme {
warm := tokenengine.RampOptions{Hue: 40, Saturation: 8, Lightness: 42, HueSpread: 4}
teal := tokenengine.RampOptions{Hue: 178, Saturation: 34, Lightness: 40, HueSpread: 8}
return consolidate(
"tomorrow", "Tomorrow",
"go-loose's warm grey palette. The least saturated of the four, and the "+
"easiest to read for long periods.",
domain.AppearanceDark,
[]string{appearanceDark, "warm", "muted", "go-loose"},
scheme{
Background: "#f5f5f5", Surface: white, SurfaceRaised: white,
SurfaceSunken: "#e8e8e8", Border: "#c9c9c9", BorderStrong: "#a3a3a3",
Text: "#4d4d4c", TextMuted: "#6e6e6c", TextSubtle: "#8a8a88",
TextInverted: white,
Primary: "#3e999f", PrimaryHover: "#348389", PrimaryActive: "#2b6d72",
OnPrimary: white,
Secondary: "#8959a8", OnSecondary: white,
Success: "#718c00", Warning: "#b58900", Danger: "#c82829", Info: "#3e999f",
FocusRing: "#3e999f", Overlay: "rgba(77, 77, 76, 0.3)",
Ramp: teal, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1d1f21", Surface: "#282a2e", SurfaceRaised: "#373b41",
SurfaceSunken: "#1a1c1e", Border: "#4d5057", BorderStrong: "#6b6f77",
Text: "#c5c8c6", TextMuted: "#a0a4a2", TextSubtle: "#81a2be",
TextInverted: "#1d1f21",
Primary: "#8abeb7", PrimaryHover: "#a5d1cb", PrimaryActive: "#bcdfd9",
OnPrimary: "#1d1f21",
Secondary: "#b294bb", OnSecondary: "#1d1f21",
Success: "#b5bd68", Warning: "#e0af68", Danger: "#cc6666", Info: "#81a2be",
FocusRing: "#8abeb7", Overlay: "rgba(0, 0, 0, 0.52)",
Ramp: warm, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// kanagawa is go-loose's fourth palette: a warm paper light scheme and a stone dark one.
func kanagawa() SeededTheme {
wave := tokenengine.RampOptions{Hue: 28, Saturation: 26, Lightness: 56, HueSpread: 10}
stone := tokenengine.RampOptions{Hue: 20, Saturation: 10, Lightness: 46, HueSpread: 6}
return consolidate(
"kanagawa", "Kanagawa",
"go-loose's paper palette. A warm off-white light scheme, which is the "+
"warmest surface in the set.",
domain.AppearanceLight,
[]string{"light", "warm", "paper", "go-loose"},
scheme{
Background: "#f2ecde", Surface: paperLight, SurfaceRaised: paperLight,
SurfaceSunken: "#e7dba9", Border: "#c8c093", BorderStrong: "#a89b6e",
Text: "#545464", TextMuted: "#5e5e70", TextSubtle: "#78788c",
TextInverted: paperLight,
Primary: "#4d7f8c", PrimaryHover: "#406d78", PrimaryActive: "#345c65",
OnPrimary: paperLight,
Secondary: "#624c83", OnSecondary: paperLight,
Success: "#6f894e", Warning: "#9a7d2e", Danger: "#c34043", Info: "#4d7f8c",
FocusRing: "#4d7f8c", Overlay: "rgba(84, 84, 100, 0.3)",
Ramp: stone, FontSans: interStack,
RadiusCard: cardRadius,
},
scheme{
Background: "#1f1f28", Surface: "#2a2a37", SurfaceRaised: "#363646",
SurfaceSunken: "#191921", Border: "#54546d", BorderStrong: "#6b6b85",
Text: "#dcd7ba", TextMuted: "#b4b098", TextSubtle: "#a8a28c",
TextInverted: "#1f1f28",
Primary: "#7fb4ca", PrimaryHover: "#98c4d6", PrimaryActive: "#b0d2e0",
OnPrimary: "#1f1f28",
Secondary: "#957fb8", OnSecondary: "#1f1f28",
Success: "#98bb6c", Warning: "#e0c07a", Danger: "#e46876", Info: "#7fb4ca",
FocusRing: "#7fb4ca", Overlay: "rgba(0, 0, 0, 0.5)",
Ramp: wave, FontSans: interStack,
RadiusCard: cardRadius,
},
)
}
// cyberpunk is go-tell's default palette: a near-black violet field with a hot pink
// and a bright cyan.
//
// It is the one palette in the set whose identity is two accents rather than one, and
// that is why it is expressed with two status roles doing unusual work: pink carries
// the brand, cyan carries interaction. Keeping pink on `--bn-accent` would lose the
// pink-versus-cyan distinction that the whole theme is built on, so the secondary
// carries cyan instead and the pair reads in the order the design intends.
func cyberpunk() SeededTheme {
violet := tokenengine.RampOptions{Hue: 288, Saturation: 72, Lightness: 44, HueSpread: 20}
return consolidate(
"cyberpunk", "Cyberpunk",
"go-tell's default palette. A hot-pink and cyan pair on near-black violet, "+
"which is the one palette here built on two accents rather than one.",
domain.AppearanceDark,
[]string{appearanceDark, tagDefault, "pink", "cyan", "go-tell"},
scheme{
Background: "#f2e8ee", Surface: "#ffffff", SurfaceRaised: "#fdf5f9",
SurfaceSunken: "#e9dde5", Border: "#c9b3c0", BorderStrong: "#a68b9c",
Text: "#2a1226", TextMuted: "#5c4457", TextSubtle: "#7f6577",
TextInverted: "#f5e0e8",
Primary: "#c2185b", PrimaryHover: "#a5144d", PrimaryActive: "#8c1042",
OnPrimary: white,
// Cyan as the secondary: see the note above.
Secondary: "#00838f", OnSecondary: white,
Success: "#2e7d32", Warning: "#c77800", Danger: "#c62828", Info: "#0277bd",
FocusRing: "#c2185b", Overlay: "rgba(42, 18, 38, 0.5)",
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
},
scheme{
Background: "#120826", Surface: "#1b1035", SurfaceRaised: "#22153d",
SurfaceSunken: "#0c0519", Border: "#3d2366", BorderStrong: "#4a2d7a",
Text: "#f5e0e8", TextMuted: "#a6adc8", TextSubtle: "#7f849c",
TextInverted: "#120826",
// The source has no separate hover or active step; these are the two
// lighter pinks it uses on hover and on the cursor.
Primary: "#ff2a85", PrimaryHover: "#ff4d9a", PrimaryActive: "#ff6b9d",
OnPrimary: "#120826",
// Cyan as the secondary, so a consumer's --bn-secondary reaches the
// interaction colour the theme is known for.
Secondary: "#00f0ff", OnSecondary: "#120826",
Success: "#00e5a0", Warning: "#ffd166", Danger: "#ef233c", Info: "#00f0ff",
FocusRing: "#00f0ff", Overlay: "rgba(0, 0, 0, 0.62)",
Ramp: violet, FontSans: interStack, RadiusCard: tightRadius,
},
)
}
// terminalRetro is go-tell's green-on-black palette.
//
// Two of its traits are not colours and cannot be expressed as tokens: it forces a
// monospace face on every element, and it adds a text glow. Both are recorded in the
// description and left in the application's own stylesheet, because a token is a
// value and these are rules.
func terminalRetro() SeededTheme {
green := tokenengine.RampOptions{Hue: 138, Saturation: 100, Lightness: 45, HueSpread: 16}
return consolidate(
"terminal", "Terminal Retro",
"go-tell's green-on-black palette. The source theme also forces a monospace "+
"face on every element and adds a text glow; those are rules rather than "+
"values, so they stay in the application's stylesheet.",
domain.AppearanceDark,
[]string{"dark", "terminal", "mono", "green", "go-tell"},
scheme{
Background: "#e8f5e9", Surface: "#ffffff", SurfaceRaised: "#f2fbf3",
SurfaceSunken: "#dcecdc", Border: "#a8d8ad", BorderStrong: "#7fbf86",
Text: "#041404", TextMuted: "#1e5c22", TextSubtle: "#3d7a42",
TextInverted: "#041404",
Primary: "#007a12", PrimaryHover: "#006410", PrimaryActive: "#004e0d",
OnPrimary: "#dcfce0",
Secondary: "#7a5c00", OnSecondary: "#fff8dc",
Success: "#00a32b", Warning: "#8a6d00", Danger: "#b30000", Info: "#007a12",
FocusRing: "#00a32b", Overlay: "rgba(4, 20, 4, 0.55)",
Ramp: green,
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
RadiusCard: "4px",
},
scheme{
Background: "#040804", Surface: "#081608", SurfaceRaised: "#0e1a0e",
SurfaceSunken: "#020402", Border: "#00aa44", BorderStrong: "#00ff66",
Text: "#00ff66", TextMuted: "#33ff33", TextSubtle: "#00aa44",
TextInverted: "#040804",
// The source's brightest green is its interactive colour, and its mid green
// is the hover step.
Primary: "#00ff66", PrimaryHover: "#33ff33", PrimaryActive: "#00dd55",
OnPrimary: "#040804",
// The Mac traffic-light yellow the source uses in its window chrome.
Secondary: "#ffcc00", OnSecondary: "#261e00",
Success: "#00ff66", Warning: "#ffcc00", Danger: "#ff3333", Info: "#00aa44",
FocusRing: "#00ff66", Overlay: "rgba(0, 0, 0, 0.7)",
Ramp: green,
FontSans: "'IBM Plex Mono', ui-monospace, SFMono-Regular, Menlo, monospace",
RadiusCard: "4px",
},
)
}

View file

@ -0,0 +1,80 @@
package seed_test
import (
"strings"
"testing"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/seed"
"github.com/urpi/go-bananas/internal/tokenengine"
)
// TestGoTellSlugsAreSeeded is the one that matters for adoption.
//
// go-tell's theme switcher offers cyberpunk, nordic, obsidian and terminal, and its
// stylesheet keys on html[data-theme="<slug>"]. If any of those slugs is missing from
// the service, selecting it sets an attribute that resolves to nothing and the
// application falls back to whichever rule happens to match last. That is invisible in
// a smoke test and obvious to a user, so it is asserted here.
func TestGoTellSlugsAreSeeded(t *testing.T) {
want := []string{"cyberpunk", "nordic", "obsidian", "terminal"}
have := map[string]bool{}
for _, slug := range seed.Slugs() {
have[slug] = true
}
for _, slug := range want {
if !have[slug] {
t.Errorf("go-tell's %q palette is not seeded, so selecting it would do nothing", slug)
}
}
}
// TestEveryConsolidatedPaletteResolvesInBothAppearances is the property the contract
// depends on, and it is expressed through the engine rather than through the token
// document's shape.
//
// The shape is deliberately not the assertion. In this schema the base document *is*
// the light scheme and `$appearances` carries only the dark override, so a theme with
// both schemes still has one key under `$appearances`. What matters is that resolving
// a consolidated palette in each appearance yields a different background, which is
// what makes the appearance control do anything at all.
func TestEveryConsolidatedPaletteResolvesInBothAppearances(t *testing.T) {
for _, def := range seed.All() {
if !strings.Contains(def.Description, "go-") {
continue // an original go-bananas theme, not a consolidated one
}
theme := def.Theme()
light := tokenengine.Resolve(theme, domain.AppearanceLight)
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
read := func(doc *tokenengine.Doc, path string) string {
token, ok := doc.Lookup(path)
if !ok {
return ""
}
return token.CSS
}
lightBg := read(light, "color.semantic.background")
darkBg := read(dark, "color.semantic.background")
if lightBg == "" || darkBg == "" {
t.Errorf("%s does not resolve a background in both appearances (light=%q dark=%q)",
def.Slug, lightBg, darkBg)
continue
}
if lightBg == darkBg {
t.Errorf("%s resolves the same background in both appearances (%s), "+
"so the appearance control would do nothing for it", def.Slug, lightBg)
}
// Text has to differ too, or a palette with a dark background and dark text
// would pass the background check and be unreadable.
if read(light, "color.semantic.text") == read(dark, "color.semantic.text") {
t.Errorf("%s resolves the same text colour in both appearances", def.Slug)
}
}
}

586
internal/seed/seed.go Normal file
View file

@ -0,0 +1,586 @@
// 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
}

157
internal/seed/seed_test.go Normal file
View file

@ -0,0 +1,157 @@
package seed
import (
"testing"
"github.com/urpi/go-bananas/internal/domain"
"github.com/urpi/go-bananas/internal/tokenengine"
)
func TestEveryBuiltInThemeValidates(t *testing.T) {
for _, def := range All() {
t.Run(def.Slug, func(t *testing.T) {
theme := def.Theme()
result := tokenengine.Validate(theme.Tokens)
if !result.Valid {
for _, issue := range result.Errors() {
t.Errorf("%s %s: %s (%s)", issue.Severity, issue.Path, issue.Message, issue.Hint)
}
t.Fatal("built-in theme must be valid")
}
if result.TokenCount < 50 {
t.Errorf("theme has only %d tokens; a built-in should be complete", result.TokenCount)
}
})
}
}
func TestEveryBuiltInThemeRendersBothAppearances(t *testing.T) {
for _, def := range All() {
t.Run(def.Slug, func(t *testing.T) {
theme := def.Theme()
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
css := tokenengine.Render(theme, tokenengine.RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Components: true,
Derive: tokenengine.DefaultDerive(),
})
if css == "" {
t.Fatalf("%s/%s rendered an empty stylesheet", theme.Slug, appearance)
}
if !contains(css, "--bn-color-semantic-background") {
t.Errorf("%s/%s is missing the background variable", theme.Slug, appearance)
}
if !contains(css, "--bn-color-semantic-text") {
t.Errorf("%s/%s is missing the text variable", theme.Slug, appearance)
}
}
})
}
}
func TestBuiltInThemesDeclareDistinctDarkOverrides(t *testing.T) {
// A theme that declares a dark appearance but supplies no dark overrides
// would render the light palette in dark mode, which is never intentional.
for _, def := range All() {
theme := def.Theme()
light := tokenengine.Resolve(theme, domain.AppearanceLight)
dark := tokenengine.Resolve(theme, domain.AppearanceDark)
lightBG, _ := light.Lookup("color.semantic.background")
darkBG, _ := dark.Lookup("color.semantic.background")
if lightBG.CSS == darkBG.CSS {
t.Errorf("%s: light and dark backgrounds are identical (%s)", def.Slug, lightBG.CSS)
}
}
}
func TestTextContrastMeetsAAForEveryBuiltIn(t *testing.T) {
// This is the check that makes a built-in theme trustworthy: body text must
// be readable on every surface it is likely to sit on.
pairs := []struct {
fgPath, bgPath string
}{
{"color.semantic.text", "color.semantic.background"},
{"color.semantic.text", "color.semantic.surface"},
{"color.semantic.text", "color.semantic.surface-raised"},
{"color.semantic.text-muted", "color.semantic.background"},
{"color.on-primary", "color.semantic.primary"},
{"color.on-accent", "color.semantic.accent"},
{"color.on-danger", "color.semantic.danger"},
}
for _, def := range All() {
theme := def.Theme()
for _, appearance := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} {
doc := tokenengine.Resolve(theme, appearance)
for _, pair := range pairs {
fg, ok := doc.Lookup(pair.fgPath)
if !ok {
continue
}
bg, ok := doc.Lookup(pair.bgPath)
if !ok {
continue
}
fgColor, err := tokenengine.ParseColor(fg.CSS)
if err != nil {
continue
}
bgColor, err := tokenengine.ParseColor(bg.CSS)
if err != nil {
continue
}
ratio := tokenengine.ContrastRatio(fgColor, bgColor)
minimum := tokenengine.ContrastAA
// The high-contrast theme is the escape hatch and is held to the
// stricter AAA bar instead.
if def.Slug == "high-contrast" {
minimum = tokenengine.ContrastAAA
}
if ratio < minimum {
t.Errorf("%s/%s: %s (%s) on %s (%s) = %.2f:1, want at least %.1f:1",
def.Slug, appearance, pair.fgPath, fg.CSS, pair.bgPath, bg.CSS, ratio, minimum)
}
}
}
}
}
func TestBuiltInSlugsAreValidAndReservedSafe(t *testing.T) {
for _, def := range All() {
if err := domain.ValidateSlug(def.Slug); err != nil {
t.Errorf("slug %q is invalid: %v", def.Slug, err)
}
if _, reserved := domain.ReservedSlugs[def.Slug]; reserved {
t.Errorf("slug %q collides with a reserved runtime path", def.Slug)
}
if def.Appearance == "" {
t.Errorf("theme %q has no default appearance", def.Slug)
}
}
}
func TestBuiltInThemesHaveUniqueIDs(t *testing.T) {
seen := make(map[string]string)
for _, theme := range Themes() {
if previous, dup := seen[theme.ID]; dup {
t.Errorf("id %q is used by both %q and %q", theme.ID, previous, theme.Slug)
}
seen[theme.ID] = theme.Slug
}
}
func contains(haystack, needle string) bool {
return len(haystack) >= len(needle) && indexOf(haystack, needle) >= 0
}
func indexOf(haystack, needle string) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}