587 lines
22 KiB
Go
587 lines
22 KiB
Go
|
|
// 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
|
||
|
|
}
|