go-bananas/internal/tokenengine/contract.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

311 lines
12 KiB
Go

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
// <html>; 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 <link> 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 <html>; 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
}