618 lines
23 KiB
Go
618 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
|
||
|
|
}
|