package tokenengine import ( "fmt" "strings" "github.com/urpi/go-bananas/internal/domain" ) // The two-attribute contract. // // Every application this service feeds has its own way of naming "which palette" and // "which appearance", and only two of them have both. Rather than ask each of them to // rename its call sites, the engine emits one contract and an alias layer under it: // // :root[data-theme='tokyo-night'][data-mode='dark'] { // --bn-bg: #1a1b26; // --bn-surface: #20222f; // /* ... the short names the applications already use ... */ // } // // /* Compatibility: applications that already use these names keep working. */ // :root[data-theme='tokyo-night'][data-mode='dark'] { // --bg: var(--bn-bg); // --surface: var(--bn-surface); // /* ... */ // } // // Two attribute names are accepted, because two generations of applications exist. The // short pair (`data-theme`, `data-mode`) is what go-loose and go-mad already put on // ; the prefixed pair is what this engine has always emitted. A rule matching // either is the whole point: an application adopts the contract by adding one link. const ( // ContractThemeAttribute is the short theme attribute. ContractThemeAttribute = "data-theme" // ContractModeAttribute is the short appearance attribute. ContractModeAttribute = "data-mode" ) // ContractMode is a storage key and DOM value for the appearance axis. It matches the // attribute so there is one word for the concept in the CSS, the DOM and localStorage. type ContractMode = domain.Appearance // contractRole is one short name standing for a full token path. type contractRole struct { // Name is the short custom property, without the prefix. Name string // Path is the dotted token path whose value it carries. Path string // Fallback is emitted when the theme does not define Path, so the contract is // never silently incomplete. A missing alias makes an application render with an // inherited value from the host page, which is worse than an explicit default. Fallback string } // contractRoles is the shared vocabulary. The names are chosen to match the tokens the // applications already use, so the alias layer below is a rename rather than a // translation. // // The colour roles are spelled out rather than derived, because a derived name // (`--bn-color-semantic-background` shortened by rule) would be guessable but not // predictable, and a consumer cannot be asked to remember which abbreviation applies. var contractRoles = []contractRole{ // Surfaces, from the page background up to the most raised. {"bg", "color.semantic.background", "#ffffff"}, {"surface", "color.semantic.surface", "#ffffff"}, {"surface-raised", "color.semantic.surface-raised", "#f8fafc"}, {"surface-sunken", "color.semantic.surface-sunken", "#eef2f6"}, {"overlay", "color.semantic.overlay", "rgba(15, 23, 42, 0.5)"}, // Text, in descending emphasis. {"text", "color.semantic.text", "#0f172a"}, {"muted", "color.semantic.text-muted", "#475569"}, {"subtle", "color.semantic.text-subtle", "#64748b"}, {"inverted", "color.semantic.text-inverted", "#ffffff"}, // Borders. {"border", "color.semantic.border", "#e2e8f0"}, {"border-strong", "color.semantic.border-strong", "#cbd5e1"}, // The accent. Two names for one role, because "primary" is what a token author // calls it and "accent" is what a component author looks for. {"accent", "color.semantic.primary", "#2563eb"}, {"accent-hover", "color.semantic.primary-hover", "#1d4ed8"}, {"accent-active", "color.semantic.primary-active", "#1e40af"}, {"on-accent", "color.semantic.on-primary", "#ffffff"}, {"secondary", "color.semantic.secondary", "#e2e8f0"}, {"on-secondary", "color.semantic.on-secondary", "#0f172a"}, // Status. {"good", "color.semantic.success", "#16a34a"}, {"warning", "color.semantic.warning", "#d97706"}, {"danger", "color.semantic.danger", "#dc2626"}, {"info", "color.semantic.info", "#0284c7"}, {"focus", "color.semantic.focus-ring", "#2563eb"}, } // contractFamilies are the token groups that are already short enough to alias // wholesale. Geometry, spacing and typography have no sensible per-role renaming, so // the whole group is re-exported under its own prefix rather than picked apart. var contractFamilies = []string{"radius", "space", "font", "shadow", "border.width", "duration", "ease", "zIndex", "opacity", "breakpoint"} // legacyAliases are the unprefixed names the applications already use, mapped to the // contract name that replaces them. // // This is the compatibility half of the contract. Emitting it means an application // adopts the shared styling by adding one and deleting its own :root block, with // no call site edited. Dropping it means the unprefixed custom properties stop existing // at all, which is a coordinated change across every application. var legacyAliases = map[string]string{ "bg": "bg", "surface": "surface", "surface2": "surface-raised", "text": "text", "muted": "muted", "line": "border", "accent": "accent", "accent2": "secondary", "good": "good", "danger": "danger", } // contractSelector returns the selector matching a theme and mode under either // attribute convention. // // The two groups are comma-separated rather than a compound selector, so an // application that uses one convention still gets the values. // // Each group appears twice: once rooted at :root, and once bare. The bare form is // what makes a palette adoptable by any element rather than only by the document // root, which is what a theme preview is. A preview has to sit next to the palette // it is previewing, inside a control that renders into a shadow root, where :root // matches nothing -- a stylesheet in the document does not select into a shadow tree // at all. Emitting the bare form lets the preview carry data-theme and pick the // palette up, so it shows the theme rather than a swatch of it. func contractSelector(slug string, mode domain.Appearance) string { return fmt.Sprintf(":root[%s=%s][%s=%s], :root[%s=%s][%s=%s], [%s=%s][%s=%s], [%s=%s][%s=%s]", ContractThemeAttribute, selectorValue(slug), ContractModeAttribute, selectorValue(string(mode)), ThemeAttribute, selectorValue(slug), AppearanceAttribute, selectorValue(string(mode)), ContractThemeAttribute, selectorValue(slug), ContractModeAttribute, selectorValue(string(mode)), ThemeAttribute, selectorValue(slug), AppearanceAttribute, selectorValue(string(mode))) } // contractDefaultSelector matches a palette with no explicit appearance. // // This exists because requiring both attributes is a trap. An application that // declares only `data-theme` -- which is what go-tell did, and what any application // written before the appearance axis existed still does -- would match no rule at all // and render entirely on its fallbacks, with no error anywhere. Matching the palette // alone and treating the absence of an appearance as "the designer's choice" is what // the axis means anyway, and it is what the theme's own default appearance says. // // As with contractSelector, the bare form follows the :root one so a preview can // carry the palette without an appearance, and inherit the designer's choice with it. func contractDefaultSelector(slug string) string { return fmt.Sprintf(":root[%s=%s], :root[%s=%s], [%s=%s], [%s=%s]", ContractThemeAttribute, selectorValue(slug), ThemeAttribute, selectorValue(slug), ContractThemeAttribute, selectorValue(slug), ThemeAttribute, selectorValue(slug)) } // selectorValue quotes a value for use inside an attribute selector. func selectorValue(v string) string { return fmt.Sprintf("%q", v) } // Contract renders the contract layer for one theme and appearance. // // It emits the short --bn-* names and the unprefixed aliases. It does not emit the // theme's own tokens: those are already in the stylesheet, and duplicating them would // double the payload for no gain. This is the layer that makes a long token path // reachable under a name a component author would guess. func Contract(theme *domain.Theme, mode domain.Appearance) string { doc := Resolve(theme, mode) var b strings.Builder fmt.Fprintf(&b, "@layer %s.base;\n", LayerName) writeContractRule(&b, contractSelector(theme.Slug, mode), doc) return b.String() } // ContractAll renders the contract layer for every theme at once, for both modes. // // This is what makes a theme selector instant. Selecting a palette becomes a change to // one attribute rather than a fetch, because every palette is already in the document. // The cost is that the file grows with the number of published themes, which is why it // is served separately from theme.css: a single-theme consumer should not download the // others. func ContractAll(themes []*domain.Theme) string { var b strings.Builder fmt.Fprintf(&b, "/*!\n * Go Bananas!! shared token contract\n") fmt.Fprintf(&b, " * %d theme(s), both appearances each.\n", len(themes)) fmt.Fprintf(&b, " * Set %s and %s on ; nothing is fetched when you switch.\n", ContractThemeAttribute, ContractModeAttribute) fmt.Fprintf(&b, " */\n") fmt.Fprintf(&b, "@layer %s.base;\n", LayerName) for _, theme := range themes { // The default first, then the explicit overrides. Order matters only as a // readability property: the mode selectors carry two attributes against the // default's one, so they win on specificity regardless of source order. def := theme.DefaultAppearance if def == "" { def = domain.AppearanceDark } writeContractRule(&b, contractDefaultSelector(theme.Slug), Resolve(theme, def)) for _, mode := range []domain.Appearance{domain.AppearanceLight, domain.AppearanceDark} { writeContractRule(&b, contractSelector(theme.Slug, mode), Resolve(theme, mode)) } } return b.String() } // writeContractRule writes one selector's declarations and its alias block. func writeContractRule(b *strings.Builder, selector string, doc *Doc) { decls := make([]string, 0, len(contractRoles)*2) for _, role := range contractRoles { value := role.Fallback if token, ok := doc.Lookup(role.Path); ok && token.CSS != "" { value = token.CSS } decls = append(decls, VariablePrefix+"-"+role.Name+": "+value+";") } // The families whose names are already short are re-exported rather than // re-derived, so `--bn-radius-md` is the same variable the theme defines and an // override in the theme's own stylesheet still wins. for _, family := range contractFamilies { prefix := family + "." for _, token := range doc.Tokens { if !strings.HasPrefix(token.Path, prefix) { continue } name := VariablePrefix + "-" + strings.ReplaceAll(token.Path, ".", "-") if token.CSS != "" { decls = append(decls, name+": "+token.CSS+";") } } } if len(decls) == 0 { return } b.WriteString(selector + " {\n") for _, d := range decls { b.WriteString(" " + d + "\n") } // The alias block, in the same rule so it is impossible to emit one without the // other. Aliases reference the contract names rather than repeating the values, so // there is exactly one place a value is written. aliases := make([]string, 0, len(legacyAliases)) for legacy, target := range legacyAliases { aliases = append(aliases, fmt.Sprintf("--%s: var(%s-%s);", legacy, VariablePrefix, target)) } // Sorted, because a map iteration order would make the output differ between // builds and defeat any caching or diffing of this file. sortStrings(aliases) for _, a := range aliases { b.WriteString(" " + a + "\n") } b.WriteString("}\n\n") } // sortStrings sorts in place. It exists so this file does not need to import sort for // one call. func sortStrings(s []string) { for i := 1; i < len(s); i++ { for j := i; j > 0 && s[j] < s[j-1]; j-- { s[j], s[j-1] = s[j-1], s[j] } } } // ContractManifestEntry describes one theme in the contract, for a selector to render // without having to parse CSS. type ContractManifestEntry struct { Slug string `json:"slug"` Name string `json:"name"` Version int64 `json:"version"` // DefaultMode is the appearance the theme was designed for. It is what a // consumer shows before the user expresses a preference. DefaultMode string `json:"defaultMode"` } // ContractManifest returns the entries a theme selector needs. func ContractManifest(themes []*domain.Theme) []ContractManifestEntry { out := make([]ContractManifestEntry, 0, len(themes)) for _, t := range themes { mode := t.DefaultAppearance if mode == "" { mode = domain.AppearanceDark } out = append(out, ContractManifestEntry{ Slug: t.Slug, Name: t.Name, Version: t.Version, DefaultMode: string(mode), }) } return out }