// Package tokens reads the design system's custom properties out of // its own stylesheets, for the gallery's Tokens page: the structure // every page shares from css/tokens.css, and one theme's palette from // css/themes/.css, each as a list of groups of rows a page can // draw a chip, a bar or a line of type beside. // // The parser is rastrillo's designsystem one, and the same regex family // check/ uses; a stylesheet parser small enough to read twice is cheaper // than the seam it would take to share one. If the two ever disagree, // check/ is the authority: it is the one holding a WCAG floor. package tokens import ( "fmt" "html/template" "regexp" "sort" "strings" "amadan.net/rastrillo/fiesta" ) var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) // colourValue matches a value that is a colour on its own, so it can be // shown as a chip. A shadow (0 8px 24px rgba(…)) is deliberately not // one: it contains a colour but is not one, and it gets its own preview. // // The last two alternatives are for a DERIVED colour: a token whose // value names other tokens rather than a literal, which is what // --rst-header-rule is in all three themes (color-mix() in day and // signal, a bare var() in plain). The chip is painted with // var(), never with the value text, so the browser resolves // the derivation for us and a chip is exactly as correct here as it is // for a hex. Without these, a derived colour rendered as a row of text // with a hole where its swatch should be. Both allow nested parens, // which a color-mix() over two var()s always has. var colourValue = regexp.MustCompile(`^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgba?\([^()]*\)|hsla?\([^()]*\)|color-mix\(.*\)|oklch\(from .*\)|var\(--rst-[a-z0-9-]+(, .*)?\))$`) // blockBody returns the brace-matched contents following header (which // must end in "{"). Theme files nest at most one level and no value in // them contains a brace, so depth counting is enough. func blockBody(css, header string) (string, error) { i := strings.Index(css, header) if i < 0 { return "", fmt.Errorf("no %q block", header) } start := i + len(header) depth := 1 for j := start; j < len(css); j++ { switch css[j] { case '{': depth++ case '}': depth-- if depth == 0 { return css[start:j], nil } } } return "", fmt.Errorf("unterminated %q block", header) } // Row is one custom property as the page shows it. Preview is the // inline style that makes the token visible: a chip painted with it, a // bar as wide as it, a line of text set at it. // // It is template.CSS because it has to survive html/template's style // attribute sanitiser, which would otherwise reject var(--rst-…) as an // unrecognised value and write ZgotmplZ into the page. That is safe // here and only here: the declaration is built from a token name this // package just matched against --rst-[a-z0-9-]+ in the design system's // own stylesheet. Nothing a caller supplies reaches it. type Row struct { Name string Value string Colour bool // the value is a colour on its own Shadow bool // the value is a shadow: preview it on a card Preview template.CSS } // parseTokens extracts every --rst-* declaration in body, sorted by // name. Last declaration of a name wins, matching CSS's own rule. func parseTokens(body string) []Row { seen := map[string]string{} for _, m := range declPattern.FindAllStringSubmatch(body, -1) { seen[m[1]] = strings.TrimSpace(m[2]) } names := make([]string, 0, len(seen)) for name := range seen { names = append(names, name) } sort.Strings(names) rows := make([]Row, 0, len(names)) for _, name := range names { v := seen[name] row := Row{ Name: name, Value: v, Colour: colourValue.MatchString(v), Shadow: strings.HasPrefix(name, "--rst-shadow"), } // Only a value that is a colour gets painted into a chip, and // only a shadow gets worn by one. Anything else, a future token // in the light block that is neither, shows its name and value // with no preview, rather than a chip painted with something // that is not a colour. switch { case row.Shadow: row.Preview = preview("box-shadow", name) case row.Colour: row.Preview = preview("background", name) } rows = append(rows, row) } return rows } // preview builds one CSS declaration naming a token. See Row's comment // for why the result is marked safe. func preview(property, token string) template.CSS { return template.CSS(property + ": var(" + token + ")") } // Group is a run of related custom properties under one heading. Kind // picks the shape of the preview the page draws beside each row: // "colour" (the default), "type", "space", "radius" or "font". // // Key names the group and is the same on every page and in every // language, so a link to a group is a link to the same thing on all of // them. The gallery writes the heading a reader sees from it; this // package holds no English. type Group struct { Key string Kind string Rows []Row } // newGroup starts a group. Every Group comes through here, so none can // reach a page without a key for its heading and its anchor. func newGroup(key, kind string) Group { return Group{Key: key, Kind: kind} } // colourGroups splits a theme's light-block tokens into the seven groups // the palette is actually authored in, with anything unrecognised // landing in "other" rather than disappearing: a new token shows up on // the page the day it is added, in the wrong group at worst. func colourGroups(rows []Row) []Group { groups := []struct { Key string Prefixes []string }{ {"surfaces-and-lines", []string{"--rst-bg", "--rst-surface", "--rst-line", "--rst-header-rule", "--rst-rail-bg"}}, {"text", []string{"--rst-text"}}, {"accent", []string{"--rst-accent", "--rst-on-accent"}}, {"status-tones", []string{"--rst-tone-"}}, // The depth axis: the light, the sheen, the press strengths and // the shadows. A number row draws no chip, by Row's rule. {"depth", []string{"--rst-shadow", "--rst-overlay", "--rst-depth-", "--rst-light-", "--rst-sheen-", "--rst-press-"}}, // --rst-row-tint- is listed as well as --rst-tint-: the prefix // match is anchored at the start, so without it the two row // tints fall through to "other". // --rst-switch- is the switch's two size sets, lengths beside // its knob and track colours. {"controls", []string{"--rst-danger-", "--rst-on-danger-", "--rst-control-", "--rst-ghost-", "--rst-summary-", "--rst-knob", "--rst-track-", "--rst-switch-", "--rst-tint-", "--rst-row-tint-", "--rst-focus-", "--rst-nav-current-"}}, {"type-choices", []string{"--rst-title-", "--rst-heading-", "--rst-label-"}}, } out := make([]Group, 0, len(groups)+1) claimed := map[string]bool{} for _, g := range groups { group := newGroup(g.Key, "colour") for _, row := range rows { for _, prefix := range g.Prefixes { if strings.HasPrefix(row.Name, prefix) && !claimed[row.Name] { claimed[row.Name] = true group.Rows = append(group.Rows, row) break } } } if len(group.Rows) > 0 { out = append(out, group) } } other := newGroup("other", "colour") for _, row := range rows { if !claimed[row.Name] { other.Rows = append(other.Rows, row) } } if len(other.Rows) > 0 { out = append(out, other) } return out } // Structural pulls the type scale and the spacing steps out of // css/tokens.css. They are structure, not colour, so they are the same // on every page; but they belong beside the palette, because a reader // looking up "how big is small" does not care which file the answer // lives in. func Structural() ([]Group, error) { body, err := blockBody(string(fiesta.TokensCSS()), ":root {") if err != nil { return nil, fmt.Errorf("tokens.css: %w", err) } rows := parseTokens(body) pick := func(prefix, property string) []Row { var out []Row for _, r := range rows { if strings.HasPrefix(r.Name, prefix) { r.Preview = preview(property, r.Name) out = append(out, r) } } return out } // No radius group: the radii live in the themes, so they are part // of the palette below, not of the structure every page shares. scale := newGroup("type-scale", "type") scale.Rows = pick("--rst-fs-", "font-size") space := newGroup("spacing", "space") space.Rows = pick("--rst-sp-", "inline-size") return []Group{scale, space}, nil } // lightHalf resolves a theme value for the light scheme: the first // argument of a whole-value light-dark(, ) call, or the // value unchanged when it is not one. The split is paren-aware, because // both halves are usually rgba() calls carrying commas of their own. // // A value that merely contains a light-dark() somewhere inside it, a // shadow such as "0 8px 24px light-dark(a, b)", is left whole on // purpose: the page shows shadows as a shadow worn by a card, not as a // colour chip, so the whole declaration is what a reader needs to see. func lightHalf(v string) string { const prefix = "light-dark(" if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") { return v } inner := v[len(prefix) : len(v)-1] depth := 0 for i := 0; i < len(inner); i++ { switch inner[i] { case '(': depth++ case ')': depth-- if depth < 0 { return v } case ',': if depth == 0 { return strings.TrimSpace(inner[:i]) } } } return v } // Palette reads one theme's single :root block: the colours resolved // to their light halves, the shape tokens as their own group, and the // type family. Dark values are not on the page: they live in the same // declaration, and the page says so rather than showing a second set // of chips nobody can compare side by side anyway. // // The type family comes back as a one-row group rather than as a Row // of its own: a group with one row renders the same bytes (a font // stack has no preview to draw) and is one fewer special case for the // page. func Palette(theme string) ([]Group, error) { raw, ok := fiesta.ThemeCSS(theme) if !ok { return nil, fmt.Errorf("no theme %q", theme) } css := string(raw) // ":root {" cannot match the two toggle rules at the foot of the // file, whose selectors are ":root[data-theme=…] {". body, err := blockBody(css, ":root {") if err != nil { return nil, fmt.Errorf("themes/%s.css: %w", theme, err) } var palette []Row var radii []Row var font Row for _, row := range parseTokens(body) { switch { case row.Name == "--rst-font": font = row case strings.HasPrefix(row.Name, "--rst-radius"): row.Preview = preview("border-radius", row.Name) radii = append(radii, row) default: palette = append(palette, resolveLight(row)) } } groups := colourGroups(palette) if len(radii) > 0 { radius := newGroup("radius", "radius") radius.Rows = radii groups = append(groups, radius) } if font.Name != "" { family := newGroup("type-family", "font") family.Rows = []Row{font} groups = append(groups, family) } return groups, nil } // resolveLight rewrites one row to the light scheme: the displayed value // loses its light-dark() wrapper, and a value that only became a colour // once resolved (every palette token) gets its chip back. func resolveLight(row Row) Row { row.Value = lightHalf(row.Value) row.Colour = colourValue.MatchString(row.Value) switch { case row.Shadow: row.Preview = preview("box-shadow", row.Name) case row.Colour: row.Preview = preview("background", row.Name) default: row.Preview = "" } return row }