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
605
internal/seed/consolidated.go
Normal file
605
internal/seed/consolidated.go
Normal 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",
|
||||
},
|
||||
)
|
||||
}
|
||||
80
internal/seed/consolidated_test.go
Normal file
80
internal/seed/consolidated_test.go
Normal 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
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
|
||||
}
|
||||
157
internal/seed/seed_test.go
Normal file
157
internal/seed/seed_test.go
Normal 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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue