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
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:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
586
internal/seed/seed.go
Normal file
586
internal/seed/seed.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue