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 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 }