go-bananas/internal/tokenengine/css.go
Daniel Noulet 951dd8d26b
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
initial commit
2026-10-07 15:11:35 +02:00

617 lines
23 KiB
Go

package tokenengine
import (
"fmt"
"strings"
"github.com/urpi/go-bananas/internal/domain"
)
// LayerName is the cascade layer the generated component styles are placed in.
// Everything the engine emits is layered so that a consuming application's own
// unlayered CSS always wins the cascade without needing !important.
const LayerName = "bananas"
// RenderOptions controls stylesheet generation.
type RenderOptions struct {
// Slug scopes the theme to an attribute selector so that several themes can
// coexist on one page. When empty the tokens are emitted on :root instead.
Slug string
// Appearance is the scheme to render as the default for the selector.
Appearance domain.Appearance
// ApplyToRoot also emits the tokens on :root, making the theme the page
// default. Ignored when Slug is set unless ForceRoot is true.
ApplyToRoot bool
// ForceRoot emits :root rules even when Slug is set.
ForceRoot bool
// AutoDark adds a prefers-color-scheme: dark block that swaps in the dark
// tokens when the slug selector is not explicitly pinned to light.
AutoDark bool
// Components appends the ready-made component layer.
Components bool
// Derive computes engine-supplied tokens (foreground pairs, interaction
// states, tinted surfaces) for values the author did not define.
Derive DeriveOptions
// Minify strips comments and collapses whitespace.
Minify bool
// SourceURL appends a source comment pointing at the runtime URL the
// stylesheet was generated from, which makes browser devtools navigable.
SourceURL string
}
// DeriveOptions selects which engine-computed token families to emit.
type DeriveOptions struct {
// Foreground adds `--bn-color-on-*` roles derived by WCAG contrast.
Foreground bool
// Interaction adds `-hover` and `-active` variants for interactive roles.
Interaction bool
// Tint adds subtle tinted surfaces derived from a role's own hue.
Tint bool
}
// DefaultDerive returns the derivation set used when a caller does not care.
func DefaultDerive() DeriveOptions {
return DeriveOptions{Foreground: true, Interaction: true, Tint: true}
}
// RenderOptionsFrom builds options from a theme with sensible defaults for the
// given delivery channel.
func RenderOptionsFrom(theme *domain.Theme, appearance domain.Appearance) RenderOptions {
return RenderOptions{
Slug: theme.Slug,
Appearance: appearance,
Derive: DefaultDerive(),
Components: true,
}
}
// pair is one CSS custom property declaration.
type pair struct {
name string
value string
}
// Render produces the stylesheet for a theme.
func Render(theme *domain.Theme, opts RenderOptions) string {
appearance := opts.Appearance
if appearance == "" {
appearance = theme.DefaultAppearance
}
var b strings.Builder
if !opts.Minify {
writeHeader(&b, theme, opts, appearance)
}
light := Resolve(theme, domain.AppearanceLight)
dark := Resolve(theme, domain.AppearanceDark)
// The scoped rule carries the theme's *default* appearance, so that setting a
// single attribute is enough to get the theme the author designed. The
// alternate scheme is emitted under its own appearance attribute.
base := light
alternate := dark
if appearance == domain.AppearanceDark {
base, alternate = dark, light
}
baseDecls := renderDeclarations(base, opts, appearance)
alternateDecls := renderDeclarations(alternate, opts, opposite(appearance))
darkDecls := renderDeclarations(dark, opts, domain.AppearanceDark)
rootSelector := opts.ApplyToRoot || opts.ForceRoot || opts.Slug == ""
scope := themeSelector(opts.Slug)
if !opts.Minify {
fmt.Fprintf(&b, "@layer %s.base;\n", LayerName)
}
// The default scheme.
switch {
case rootSelector && opts.Slug != "":
// Emit on :root so the theme works with zero configuration, and again on
// the scoped selector so a page can host several themes side by side.
writeRule(&b, ":root", baseDecls, opts.Minify)
writeRule(&b, scope, baseDecls, opts.Minify)
case rootSelector:
writeRule(&b, ":root", baseDecls, opts.Minify)
default:
writeRule(&b, scope, baseDecls, opts.Minify)
}
// The alternate scheme, selected explicitly. This is what a consumer's theme
// switcher sets, and it is the only selector needed to override the default.
if opts.Slug != "" {
writeRule(&b, darkScopeSelector(opts.Slug, opposite(appearance)), alternateDecls, opts.Minify)
} else {
writeRule(&b, ":root["+AppearanceAttribute+"="+string(opposite(appearance))+"], :root."+darkClass,
alternateDecls, opts.Minify)
}
// A `.dark` ancestor class covers the common Tailwind and shadcn convention
// where dark mode is toggled by a class on <html> rather than an attribute.
// It always means dark, regardless of the theme's default.
if opts.Slug != "" && appearance != domain.AppearanceDark {
fmt.Fprintf(&b, ".dark %s, %s.%s {\n", scope, scope, darkClass)
writeDeclarations(&b, darkDecls, opts.Minify, "")
b.WriteString("}\n\n")
}
if opts.AutoDark {
writeAutoDark(&b, scope, darkDecls, opts.Minify)
}
if opts.Components {
writeComponents(&b, opts)
}
if !opts.Minify && opts.SourceURL != "" {
fmt.Fprintf(&b, "/*# sourceURL=%s */\n", opts.SourceURL)
}
return strings.TrimRight(b.String(), "\n") + "\n"
}
// opposite returns the other colour scheme.
func opposite(a domain.Appearance) domain.Appearance {
if a == domain.AppearanceDark {
return domain.AppearanceLight
}
return domain.AppearanceDark
}
// writeHeader emits a provenance comment. It is what makes a generated
// stylesheet debuggable: it names the theme, version and generation time.
func writeHeader(b *strings.Builder, theme *domain.Theme, opts RenderOptions, appearance domain.Appearance) {
fmt.Fprintf(b, "/*!\n")
fmt.Fprintf(b, " * Go Bananas!! theme: %s\n", theme.Name)
fmt.Fprintf(b, " * slug: %s | version: %d | status: %s | appearance: %s\n",
theme.Slug, theme.Version, theme.Status, appearance)
fmt.Fprintf(b, " * variables are prefixed %q; override them in an unlayered rule to win the cascade\n", VariablePrefix)
fmt.Fprintf(b, " * generated: %s\n", theme.UpdatedAt.UTC().Format(domain.TimeFormat))
fmt.Fprintf(b, " */\n")
}
// themeSelector returns the attribute selector that scopes a theme.
func themeSelector(slug string) string {
return fmt.Sprintf("[%s=%q]", ThemeAttribute, selectorSafe(slug))
}
// darkScopeSelector returns the selector for the dark scheme of a scoped theme.
func darkScopeSelector(slug string, appearance domain.Appearance) string {
base := themeSelector(slug)
return fmt.Sprintf("%s[%s=%s]", base, AppearanceAttribute, appearance)
}
// writeAutoDark emits the prefers-color-scheme block. The `:not()` guard makes
// the media query yield to an explicit appearance attribute so that a manual
// override always beats the OS preference.
func writeAutoDark(b *strings.Builder, scope string, decls []pair, minify bool) {
b.WriteString("@media (prefers-color-scheme: dark) {\n")
var selector string
if scope == ":root" {
selector = fmt.Sprintf(":root:not([%s=%s])", AppearanceAttribute, domain.AppearanceLight)
} else {
selector = fmt.Sprintf("%s:not([%s=%s]):not(.dark)", scope, AppearanceAttribute, domain.AppearanceLight)
}
inner := " "
writeRuleIndent(b, selector, decls, minify, inner)
b.WriteString("}\n\n")
}
// renderDeclarations builds the ordered declaration list for one appearance.
func renderDeclarations(doc *Doc, opts RenderOptions, appearance domain.Appearance) []pair {
decls := make([]pair, 0, len(doc.Tokens)+32)
seen := make(map[string]struct{}, len(doc.Tokens))
add := func(name, value string) {
if _, dup := seen[name]; dup {
return
}
seen[name] = struct{}{}
decls = append(decls, pair{name: name, value: value})
}
for _, t := range doc.Tokens {
add(VariableName(t.Path), t.CSS)
}
if opts.Derive.Foreground {
for _, d := range deriveForeground(doc, appearance) {
add(d.name, d.value)
}
}
if opts.Derive.Interaction {
for _, d := range deriveInteraction(doc, appearance) {
add(d.name, d.value)
}
}
if opts.Derive.Tint {
for _, d := range deriveTints(doc, appearance) {
add(d.name, d.value)
}
}
return decls
}
// deriveForeground produces the `--bn-color-on-*` family. For every palette
// step and every semantic role that is a solid colour, it picks black or white
// whichever has the higher WCAG contrast ratio. Authors who want an explicit
// value simply define it: derived names are only emitted when absent.
func deriveForeground(doc *Doc, appearance domain.Appearance) []pair {
out := make([]pair, 0, 48)
for _, t := range doc.Tokens {
if t.Kind != KindColor || !strings.HasPrefix(t.Path, "color.") {
continue
}
// Skip roles that are not solid backgrounds and skip anything already
// spelled `on-*`.
rest := strings.TrimPrefix(t.Path, "color.")
if strings.HasPrefix(rest, "on-") {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric || col.A < 0.5 {
continue
}
if _, exists := doc.Lookup("color.on-" + rest); exists {
continue
}
out = append(out, pair{
name: VariableName("color.on-" + rest),
value: BestForeground(col).Hex(),
})
}
return out
}
// interactionRatios are the mix steps used to derive hover and active states.
// Hover moves 8% toward the foreground, active 16%: subtle enough to read as
// the same colour, strong enough to be perceptible.
var (
hoverRatio = 0.08
activeRatio = 0.16
)
// interactiveRoles are the semantic roles that get state variants. They are the
// ones a component stylesheet can reasonably apply :hover and :active to.
var interactiveRoles = []string{
"semantic.primary",
"semantic.secondary",
"semantic.accent",
"semantic.success",
"semantic.warning",
"semantic.danger",
"semantic.info",
"semantic.surface",
"semantic.surface-raised",
}
// deriveInteraction produces `-hover`, `-active` and `-subtle` companions for
// the interactive semantic roles that exist in the document.
func deriveInteraction(doc *Doc, appearance domain.Appearance) []pair {
out := make([]pair, 0, 48)
for _, role := range interactiveRoles {
t, ok := doc.Lookup("color." + role)
if !ok || t.Kind != KindColor {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric {
continue
}
// In a dark scheme the base colour is already light, so "emphasis" means
// getting lighter, not darker.
for _, suffix := range []struct {
name string
amount float64
}{{"hover", hoverRatio}, {"active", activeRatio}} {
path := "color." + role + "-" + suffix.name
if _, exists := doc.Lookup(path); exists {
continue
}
out = append(out, pair{
name: VariableName(path),
value: col.LightenDark(suffix.amount, appearance == domain.AppearanceDark).Hex(),
})
}
if _, exists := doc.Lookup("color." + role + "-subtle"); !exists {
out = append(out, pair{
name: VariableName("color." + role + "-subtle"),
value: col.WithAlpha(0.12).Hex(),
})
}
}
return out
}
// tintRoles are the solid roles that get very light/dark companions for badge,
// callout and hover-row backgrounds.
var tintRoles = []string{
"semantic.primary",
"semantic.success",
"semantic.warning",
"semantic.danger",
"semantic.info",
}
// deriveTints mixes a role toward its scheme's background, producing a surface
// that reads as "this role, but quiet".
func deriveTints(doc *Doc, appearance domain.Appearance) []pair {
background, ok := doc.Lookup("color.semantic.background")
if !ok {
return nil
}
bg, err := ParseColor(background.CSS)
if err != nil || !bg.Numeric {
return nil
}
out := make([]pair, 0, 16)
for _, role := range tintRoles {
t, exists := doc.Lookup("color." + role)
if !exists || t.Kind != KindColor {
continue
}
col, err := ParseColor(t.CSS)
if err != nil || !col.Numeric {
continue
}
for _, suffix := range []struct {
name string
amount float64
}{{"tint", 0.14}, {"tint-strong", 0.26}} {
path := "color." + role + "-" + suffix.name
if _, exists := doc.Lookup(path); exists {
continue
}
out = append(out, pair{
name: VariableName(path),
value: bg.Mix(col, suffix.amount).Hex(),
})
}
}
return out
}
// writeRule emits a complete CSS rule.
func writeRule(b *strings.Builder, selector string, decls []pair, minify bool) {
writeRuleIndent(b, selector, decls, minify, "")
}
// writeRuleIndent emits a CSS rule with every line prefixed by indent.
func writeRuleIndent(b *strings.Builder, selector string, decls []pair, minify bool, indent string) {
if len(decls) == 0 {
return
}
b.WriteString(indent)
b.WriteString(selector)
b.WriteString(" {\n")
writeDeclarations(b, decls, minify, indent+" ")
b.WriteString(indent)
b.WriteString("}\n")
if !minify {
b.WriteString("\n")
}
}
// writeDeclarations emits the body of a rule. In minified mode the whole body is
// written on one line with no indentation, which is what a minifier would
// produce and keeps the output byte-identical to what a CDN would serve.
func writeDeclarations(b *strings.Builder, decls []pair, minify bool, indent string) {
if minify {
indent = ""
}
for _, d := range decls {
b.WriteString(indent)
b.WriteString(d.name)
b.WriteString(":")
if !minify {
b.WriteString(" ")
}
b.WriteString(d.value)
b.WriteString(";")
if !minify {
b.WriteByte('\n')
}
}
}
// writeComponents appends the ready-made component layer. It is intentionally
// small: enough for a host application to look correct immediately, expressed
// entirely in terms of the generated variables so that re-theming is free.
func writeComponents(b *strings.Builder, opts RenderOptions) {
fmt.Fprintf(b, "@layer %s.components {\n", LayerName)
componentCSS(b, opts.Minify)
b.WriteString("}\n")
}
// componentRules is the body of the component layer. It uses only generated
// variables plus layout primitives that carry no colour of their own.
const componentRules = `
.bn-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--bn-space-2, 0.5rem);
padding: var(--bn-space-2, 0.5rem) var(--bn-space-4, 1rem);
border: var(--bn-border-width-default, 1px) solid transparent;
border-radius: var(--bn-radius-md, 0.375rem);
background: var(--bn-color-semantic-primary, currentColor);
color: var(--bn-color-on-primary, #fff);
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
font-size: var(--bn-font-size-sm, 0.875rem);
font-weight: var(--bn-font-weight-medium, 500);
line-height: var(--bn-line-height-normal, 1.5);
cursor: pointer;
text-decoration: none;
transition: background-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease),
border-color var(--bn-duration-fast, 150ms) var(--bn-ease-standard, ease);
}
.bn-btn:hover { background: var(--bn-color-semantic-primary-hover, var(--bn-color-semantic-primary)); }
.bn-btn:active { background: var(--bn-color-semantic-primary-active, var(--bn-color-semantic-primary)); }
.bn-btn:focus-visible {
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
outline-offset: var(--bn-space-1, 0.25rem);
}
.bn-btn[disabled] { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
.bn-btn--secondary {
background: var(--bn-color-semantic-secondary, transparent);
color: var(--bn-color-semantic-text, inherit);
border-color: var(--bn-color-semantic-border, currentColor);
}
.bn-btn--secondary:hover { background: var(--bn-color-semantic-secondary-hover, transparent); }
.bn-btn--ghost { background: transparent; color: var(--bn-color-semantic-primary, inherit); }
.bn-btn--ghost:hover { background: var(--bn-color-semantic-primary-subtle, transparent); }
.bn-btn--danger { background: var(--bn-color-semantic-danger, currentColor); color: var(--bn-color-on-danger, #fff); }
.bn-btn--sm { padding: var(--bn-space-1, 0.25rem) var(--bn-space-3, 0.75rem); font-size: var(--bn-font-size-xs, 0.75rem); }
.bn-btn--lg { padding: var(--bn-space-3, 0.75rem) var(--bn-space-6, 1.5rem); font-size: var(--bn-font-size-md, 1rem); }
.bn-card {
background: var(--bn-color-semantic-surface, Canvas);
color: var(--bn-color-semantic-text, CanvasText);
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent);
border-radius: var(--bn-radius-lg, 0.5rem);
box-shadow: var(--bn-shadow-sm, none);
padding: var(--bn-space-6, 1.5rem);
font-family: var(--bn-font-family-sans, system-ui, sans-serif);
}
.bn-card--raised { background: var(--bn-color-semantic-surface-raised, Canvas); box-shadow: var(--bn-shadow-md, none); }
.bn-card__header { margin-bottom: var(--bn-space-4, 1rem); font-size: var(--bn-font-size-lg, 1.125rem); font-weight: var(--bn-font-weight-semibold, 600); }
.bn-card__body { font-size: var(--bn-font-size-sm, 0.875rem); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-input,
.bn-select,
.bn-textarea {
width: 100%;
padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem);
border: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, currentColor);
border-radius: var(--bn-radius-md, 0.375rem);
background: var(--bn-color-semantic-background, Canvas);
color: var(--bn-color-semantic-text, CanvasText);
font-family: inherit;
font-size: var(--bn-font-size-sm, 0.875rem);
line-height: var(--bn-line-height-normal, 1.5);
}
.bn-input:focus-visible,
.bn-select:focus-visible,
.bn-textarea:focus-visible {
outline: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-focus-ring, currentColor);
outline-offset: var(--bn-space-1, 0.25rem);
border-color: var(--bn-color-semantic-primary, currentColor);
}
.bn-input::placeholder { color: var(--bn-color-semantic-text-subtle, inherit); opacity: 1; }
.bn-input:disabled { opacity: var(--bn-opacity-disabled, 0.5); cursor: not-allowed; }
.bn-label { display: block; margin-bottom: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); font-weight: var(--bn-font-weight-medium, 500); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-field { margin-bottom: var(--bn-space-4, 1rem); }
.bn-field__error { margin-top: var(--bn-space-1, 0.25rem); font-size: var(--bn-font-size-xs, 0.75rem); color: var(--bn-color-semantic-danger, red); }
.bn-badge {
display: inline-flex;
align-items: center;
gap: var(--bn-space-1, 0.25rem);
padding: var(--bn-space-1, 0.25rem) var(--bn-space-2, 0.5rem);
border-radius: var(--bn-radius-full, 9999px);
font-size: var(--bn-font-size-xs, 0.75rem);
font-weight: var(--bn-font-weight-medium, 500);
background: var(--bn-color-semantic-primary-subtle, transparent);
color: var(--bn-color-semantic-primary, inherit);
}
.bn-badge--success { background: var(--bn-color-semantic-success-tint, transparent); color: var(--bn-color-semantic-success, inherit); }
.bn-badge--warning { background: var(--bn-color-semantic-warning-tint, transparent); color: var(--bn-color-semantic-warning, inherit); }
.bn-badge--danger { background: var(--bn-color-semantic-danger-tint, transparent); color: var(--bn-color-semantic-danger, inherit); }
.bn-badge--neutral { background: var(--bn-color-semantic-surface-raised, transparent); color: var(--bn-color-semantic-text-muted, inherit); }
.bn-alert {
display: flex;
gap: var(--bn-space-3, 0.75rem);
padding: var(--bn-space-4, 1rem);
border-radius: var(--bn-radius-md, 0.375rem);
border-left: var(--bn-border-width-thick, 3px) solid var(--bn-color-semantic-info, currentColor);
background: var(--bn-color-semantic-info-tint, transparent);
color: var(--bn-color-semantic-text, inherit);
font-size: var(--bn-font-size-sm, 0.875rem);
}
.bn-alert--success { border-color: var(--bn-color-semantic-success, currentColor); background: var(--bn-color-semantic-success-tint, transparent); }
.bn-alert--warning { border-color: var(--bn-color-semantic-warning, currentColor); background: var(--bn-color-semantic-warning-tint, transparent); }
.bn-alert--danger { border-color: var(--bn-color-semantic-danger, currentColor); background: var(--bn-color-semantic-danger-tint, transparent); }
.bn-link { color: var(--bn-color-semantic-primary, currentColor); text-decoration: underline; text-underline-offset: var(--bn-space-1, 0.25rem); }
.bn-link:hover { color: var(--bn-color-semantic-primary-hover, inherit); }
.bn-table { width: 100%; border-collapse: collapse; font-size: var(--bn-font-size-sm, 0.875rem); }
.bn-table th { text-align: left; font-weight: var(--bn-font-weight-semibold, 600); color: var(--bn-color-semantic-text-muted, inherit); padding: var(--bn-space-2, 0.5rem) var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-default, 1px) solid var(--bn-color-semantic-border, transparent); }
.bn-table td { padding: var(--bn-space-3, 0.75rem); border-bottom: var(--bn-border-width-thin, 1px) solid var(--bn-color-semantic-border, transparent); }
.bn-table tbody tr:hover { background: var(--bn-color-semantic-surface-raised, transparent); }
.bn-stack { display: flex; flex-direction: column; gap: var(--bn-space-4, 1rem); }
.bn-cluster { display: flex; flex-wrap: wrap; align-items: center; gap: var(--bn-space-2, 0.5rem); }
.bn-divider { height: var(--bn-border-width-thin, 1px); background: var(--bn-color-semantic-border, transparent); border: 0; }
`
// componentCSS writes the component layer, optionally stripped of comments and
// blank lines.
func componentCSS(b *strings.Builder, minify bool) {
css := componentRules
if minify {
var out strings.Builder
for _, line := range strings.Split(css, "\n") {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
out.WriteString(trimmed)
out.WriteByte('\n')
}
css = out.String()
}
b.WriteString(css)
}
// Render variant identifiers. A variant is a named, cacheable option set: the
// stylesheet a browser fetches and the compact stylesheet a server-side
// renderer inlines are different artefacts and must not share a cache entry.
const (
// VariantFull is the complete stylesheet: tokens, derived tokens and the
// component layer.
VariantFull = "full"
// VariantTokens is tokens and derived tokens only, for applications that
// ship their own components.
VariantTokens = "tokens"
// VariantMinified is the compact, comment-free stylesheet for production.
VariantMinified = "minified"
)
// VariantNames lists the render variants in a stable order.
func VariantNames() []string { return []string{VariantFull, VariantTokens, VariantMinified} }
// Variant identifies the option set that produced a stylesheet. It is part of
// the cache key so two variants can never be confused.
func (o RenderOptions) Variant() string {
switch {
case o.Minify:
return VariantMinified
case o.Components:
return VariantFull
default:
return VariantTokens
}
}
// OptionsForVariant returns the render options for a named variant.
func OptionsForVariant(variant string, slug string, appearance domain.Appearance) RenderOptions {
opts := RenderOptions{
Slug: slug,
Appearance: appearance,
Derive: DefaultDerive(),
}
switch variant {
case VariantMinified:
opts.Minify = true
opts.Components = true
case VariantFull:
opts.Components = true
default:
opts.Components = false
}
return opts
}