initial commit
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
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
This commit is contained in:
commit
951dd8d26b
175 changed files with 50041 additions and 0 deletions
311
internal/tokenengine/contract.go
Normal file
311
internal/tokenengine/contract.go
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
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
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue