| 1 | // Package tokens reads the design system's custom properties out of |
| 2 | // its own stylesheets, for the gallery's Tokens page: the structure |
| 3 | // every page shares from css/tokens.css, and one theme's palette from |
| 4 | // css/themes/<name>.css, each as a list of groups of rows a page can |
| 5 | // draw a chip, a bar or a line of type beside. |
| 6 | // |
| 7 | // The parser is rastrillo's designsystem one, and the same regex family |
| 8 | // check/ uses; a stylesheet parser small enough to read twice is cheaper |
| 9 | // than the seam it would take to share one. If the two ever disagree, |
| 10 | // check/ is the authority: it is the one holding a WCAG floor. |
| 11 | package tokens |
| 12 | |
| 13 | import ( |
| 14 | "fmt" |
| 15 | "html/template" |
| 16 | "regexp" |
| 17 | "sort" |
| 18 | "strings" |
| 19 | |
| 20 | "amadan.net/rastrillo/fiesta" |
| 21 | ) |
| 22 | |
| 23 | var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) |
| 24 | |
| 25 | // colourValue matches a value that is a colour on its own, so it can be |
| 26 | // shown as a chip. A shadow (0 8px 24px rgba(…)) is deliberately not |
| 27 | // one: it contains a colour but is not one, and it gets its own preview. |
| 28 | // |
| 29 | // The last two alternatives are for a DERIVED colour: a token whose |
| 30 | // value names other tokens rather than a literal, which is what |
| 31 | // --rst-header-rule is in all three themes (color-mix() in day and |
| 32 | // signal, a bare var() in plain). The chip is painted with |
| 33 | // var(<the token>), never with the value text, so the browser resolves |
| 34 | // the derivation for us and a chip is exactly as correct here as it is |
| 35 | // for a hex. Without these, a derived colour rendered as a row of text |
| 36 | // with a hole where its swatch should be. Both allow nested parens, |
| 37 | // which a color-mix() over two var()s always has. |
| 38 | 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-]+(, .*)?\))$`) |
| 39 | |
| 40 | // blockBody returns the brace-matched contents following header (which |
| 41 | // must end in "{"). Theme files nest at most one level and no value in |
| 42 | // them contains a brace, so depth counting is enough. |
| 43 | func blockBody(css, header string) (string, error) { |
| 44 | i := strings.Index(css, header) |
| 45 | if i < 0 { |
| 46 | return "", fmt.Errorf("no %q block", header) |
| 47 | } |
| 48 | start := i + len(header) |
| 49 | depth := 1 |
| 50 | for j := start; j < len(css); j++ { |
| 51 | switch css[j] { |
| 52 | case '{': |
| 53 | depth++ |
| 54 | case '}': |
| 55 | depth-- |
| 56 | if depth == 0 { |
| 57 | return css[start:j], nil |
| 58 | } |
| 59 | } |
| 60 | } |
| 61 | return "", fmt.Errorf("unterminated %q block", header) |
| 62 | } |
| 63 | |
| 64 | // Row is one custom property as the page shows it. Preview is the |
| 65 | // inline style that makes the token visible: a chip painted with it, a |
| 66 | // bar as wide as it, a line of text set at it. |
| 67 | // |
| 68 | // It is template.CSS because it has to survive html/template's style |
| 69 | // attribute sanitiser, which would otherwise reject var(--rst-…) as an |
| 70 | // unrecognised value and write ZgotmplZ into the page. That is safe |
| 71 | // here and only here: the declaration is built from a token name this |
| 72 | // package just matched against --rst-[a-z0-9-]+ in the design system's |
| 73 | // own stylesheet. Nothing a caller supplies reaches it. |
| 74 | type Row struct { |
| 75 | Name string |
| 76 | Value string |
| 77 | Colour bool // the value is a colour on its own |
| 78 | Shadow bool // the value is a shadow: preview it on a card |
| 79 | Preview template.CSS |
| 80 | } |
| 81 | |
| 82 | // parseTokens extracts every --rst-* declaration in body, sorted by |
| 83 | // name. Last declaration of a name wins, matching CSS's own rule. |
| 84 | func parseTokens(body string) []Row { |
| 85 | seen := map[string]string{} |
| 86 | for _, m := range declPattern.FindAllStringSubmatch(body, -1) { |
| 87 | seen[m[1]] = strings.TrimSpace(m[2]) |
| 88 | } |
| 89 | names := make([]string, 0, len(seen)) |
| 90 | for name := range seen { |
| 91 | names = append(names, name) |
| 92 | } |
| 93 | sort.Strings(names) |
| 94 | rows := make([]Row, 0, len(names)) |
| 95 | for _, name := range names { |
| 96 | v := seen[name] |
| 97 | row := Row{ |
| 98 | Name: name, |
| 99 | Value: v, |
| 100 | Colour: colourValue.MatchString(v), |
| 101 | Shadow: strings.HasPrefix(name, "--rst-shadow"), |
| 102 | } |
| 103 | // Only a value that is a colour gets painted into a chip, and |
| 104 | // only a shadow gets worn by one. Anything else, a future token |
| 105 | // in the light block that is neither, shows its name and value |
| 106 | // with no preview, rather than a chip painted with something |
| 107 | // that is not a colour. |
| 108 | switch { |
| 109 | case row.Shadow: |
| 110 | row.Preview = preview("box-shadow", name) |
| 111 | case row.Colour: |
| 112 | row.Preview = preview("background", name) |
| 113 | } |
| 114 | rows = append(rows, row) |
| 115 | } |
| 116 | return rows |
| 117 | } |
| 118 | |
| 119 | // preview builds one CSS declaration naming a token. See Row's comment |
| 120 | // for why the result is marked safe. |
| 121 | func preview(property, token string) template.CSS { |
| 122 | return template.CSS(property + ": var(" + token + ")") |
| 123 | } |
| 124 | |
| 125 | // Group is a run of related custom properties under one heading. Kind |
| 126 | // picks the shape of the preview the page draws beside each row: |
| 127 | // "colour" (the default), "type", "space", "radius" or "font". |
| 128 | // |
| 129 | // Key names the group and is the same on every page and in every |
| 130 | // language, so a link to a group is a link to the same thing on all of |
| 131 | // them. The gallery writes the heading a reader sees from it; this |
| 132 | // package holds no English. |
| 133 | type Group struct { |
| 134 | Key string |
| 135 | Kind string |
| 136 | Rows []Row |
| 137 | } |
| 138 | |
| 139 | // newGroup starts a group. Every Group comes through here, so none can |
| 140 | // reach a page without a key for its heading and its anchor. |
| 141 | func newGroup(key, kind string) Group { |
| 142 | return Group{Key: key, Kind: kind} |
| 143 | } |
| 144 | |
| 145 | // colourGroups splits a theme's light-block tokens into the seven groups |
| 146 | // the palette is actually authored in, with anything unrecognised |
| 147 | // landing in "other" rather than disappearing: a new token shows up on |
| 148 | // the page the day it is added, in the wrong group at worst. |
| 149 | func colourGroups(rows []Row) []Group { |
| 150 | groups := []struct { |
| 151 | Key string |
| 152 | Prefixes []string |
| 153 | }{ |
| 154 | {"surfaces-and-lines", []string{"--rst-bg", "--rst-surface", "--rst-line", "--rst-header-rule", "--rst-rail-bg"}}, |
| 155 | {"text", []string{"--rst-text"}}, |
| 156 | {"accent", []string{"--rst-accent", "--rst-on-accent"}}, |
| 157 | {"status-tones", []string{"--rst-tone-"}}, |
| 158 | // The depth axis: the light, the sheen, the press strengths and |
| 159 | // the shadows. A number row draws no chip, by Row's rule. |
| 160 | {"depth", []string{"--rst-shadow", "--rst-overlay", "--rst-depth-", "--rst-light-", "--rst-sheen-", "--rst-press-"}}, |
| 161 | // --rst-row-tint- is listed as well as --rst-tint-: the prefix |
| 162 | // match is anchored at the start, so without it the two row |
| 163 | // tints fall through to "other". |
| 164 | // --rst-switch- is the switch's two size sets, lengths beside |
| 165 | // its knob and track colours. |
| 166 | {"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-"}}, |
| 167 | {"type-choices", []string{"--rst-title-", "--rst-heading-", "--rst-label-"}}, |
| 168 | } |
| 169 | out := make([]Group, 0, len(groups)+1) |
| 170 | claimed := map[string]bool{} |
| 171 | for _, g := range groups { |
| 172 | group := newGroup(g.Key, "colour") |
| 173 | for _, row := range rows { |
| 174 | for _, prefix := range g.Prefixes { |
| 175 | if strings.HasPrefix(row.Name, prefix) && !claimed[row.Name] { |
| 176 | claimed[row.Name] = true |
| 177 | group.Rows = append(group.Rows, row) |
| 178 | break |
| 179 | } |
| 180 | } |
| 181 | } |
| 182 | if len(group.Rows) > 0 { |
| 183 | out = append(out, group) |
| 184 | } |
| 185 | } |
| 186 | other := newGroup("other", "colour") |
| 187 | for _, row := range rows { |
| 188 | if !claimed[row.Name] { |
| 189 | other.Rows = append(other.Rows, row) |
| 190 | } |
| 191 | } |
| 192 | if len(other.Rows) > 0 { |
| 193 | out = append(out, other) |
| 194 | } |
| 195 | return out |
| 196 | } |
| 197 | |
| 198 | // Structural pulls the type scale and the spacing steps out of |
| 199 | // css/tokens.css. They are structure, not colour, so they are the same |
| 200 | // on every page; but they belong beside the palette, because a reader |
| 201 | // looking up "how big is small" does not care which file the answer |
| 202 | // lives in. |
| 203 | func Structural() ([]Group, error) { |
| 204 | body, err := blockBody(string(fiesta.TokensCSS()), ":root {") |
| 205 | if err != nil { |
| 206 | return nil, fmt.Errorf("tokens.css: %w", err) |
| 207 | } |
| 208 | rows := parseTokens(body) |
| 209 | pick := func(prefix, property string) []Row { |
| 210 | var out []Row |
| 211 | for _, r := range rows { |
| 212 | if strings.HasPrefix(r.Name, prefix) { |
| 213 | r.Preview = preview(property, r.Name) |
| 214 | out = append(out, r) |
| 215 | } |
| 216 | } |
| 217 | return out |
| 218 | } |
| 219 | // No radius group: the radii live in the themes, so they are part |
| 220 | // of the palette below, not of the structure every page shares. |
| 221 | scale := newGroup("type-scale", "type") |
| 222 | scale.Rows = pick("--rst-fs-", "font-size") |
| 223 | space := newGroup("spacing", "space") |
| 224 | space.Rows = pick("--rst-sp-", "inline-size") |
| 225 | return []Group{scale, space}, nil |
| 226 | } |
| 227 | |
| 228 | // lightHalf resolves a theme value for the light scheme: the first |
| 229 | // argument of a whole-value light-dark(<light>, <dark>) call, or the |
| 230 | // value unchanged when it is not one. The split is paren-aware, because |
| 231 | // both halves are usually rgba() calls carrying commas of their own. |
| 232 | // |
| 233 | // A value that merely contains a light-dark() somewhere inside it, a |
| 234 | // shadow such as "0 8px 24px light-dark(a, b)", is left whole on |
| 235 | // purpose: the page shows shadows as a shadow worn by a card, not as a |
| 236 | // colour chip, so the whole declaration is what a reader needs to see. |
| 237 | func lightHalf(v string) string { |
| 238 | const prefix = "light-dark(" |
| 239 | if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") { |
| 240 | return v |
| 241 | } |
| 242 | inner := v[len(prefix) : len(v)-1] |
| 243 | depth := 0 |
| 244 | for i := 0; i < len(inner); i++ { |
| 245 | switch inner[i] { |
| 246 | case '(': |
| 247 | depth++ |
| 248 | case ')': |
| 249 | depth-- |
| 250 | if depth < 0 { |
| 251 | return v |
| 252 | } |
| 253 | case ',': |
| 254 | if depth == 0 { |
| 255 | return strings.TrimSpace(inner[:i]) |
| 256 | } |
| 257 | } |
| 258 | } |
| 259 | return v |
| 260 | } |
| 261 | |
| 262 | // Palette reads one theme's single :root block: the colours resolved |
| 263 | // to their light halves, the shape tokens as their own group, and the |
| 264 | // type family. Dark values are not on the page: they live in the same |
| 265 | // declaration, and the page says so rather than showing a second set |
| 266 | // of chips nobody can compare side by side anyway. |
| 267 | // |
| 268 | // The type family comes back as a one-row group rather than as a Row |
| 269 | // of its own: a group with one row renders the same bytes (a font |
| 270 | // stack has no preview to draw) and is one fewer special case for the |
| 271 | // page. |
| 272 | func Palette(theme string) ([]Group, error) { |
| 273 | raw, ok := fiesta.ThemeCSS(theme) |
| 274 | if !ok { |
| 275 | return nil, fmt.Errorf("no theme %q", theme) |
| 276 | } |
| 277 | css := string(raw) |
| 278 | // ":root {" cannot match the two toggle rules at the foot of the |
| 279 | // file, whose selectors are ":root[data-theme=…] {". |
| 280 | body, err := blockBody(css, ":root {") |
| 281 | if err != nil { |
| 282 | return nil, fmt.Errorf("themes/%s.css: %w", theme, err) |
| 283 | } |
| 284 | |
| 285 | var palette []Row |
| 286 | var radii []Row |
| 287 | var font Row |
| 288 | for _, row := range parseTokens(body) { |
| 289 | switch { |
| 290 | case row.Name == "--rst-font": |
| 291 | font = row |
| 292 | case strings.HasPrefix(row.Name, "--rst-radius"): |
| 293 | row.Preview = preview("border-radius", row.Name) |
| 294 | radii = append(radii, row) |
| 295 | default: |
| 296 | palette = append(palette, resolveLight(row)) |
| 297 | } |
| 298 | } |
| 299 | groups := colourGroups(palette) |
| 300 | if len(radii) > 0 { |
| 301 | radius := newGroup("radius", "radius") |
| 302 | radius.Rows = radii |
| 303 | groups = append(groups, radius) |
| 304 | } |
| 305 | if font.Name != "" { |
| 306 | family := newGroup("type-family", "font") |
| 307 | family.Rows = []Row{font} |
| 308 | groups = append(groups, family) |
| 309 | } |
| 310 | return groups, nil |
| 311 | } |
| 312 | |
| 313 | // resolveLight rewrites one row to the light scheme: the displayed value |
| 314 | // loses its light-dark() wrapper, and a value that only became a colour |
| 315 | // once resolved (every palette token) gets its chip back. |
| 316 | func resolveLight(row Row) Row { |
| 317 | row.Value = lightHalf(row.Value) |
| 318 | row.Colour = colourValue.MatchString(row.Value) |
| 319 | switch { |
| 320 | case row.Shadow: |
| 321 | row.Preview = preview("box-shadow", row.Name) |
| 322 | case row.Colour: |
| 323 | row.Preview = preview("background", row.Name) |
| 324 | default: |
| 325 | row.Preview = "" |
| 326 | } |
| 327 | return row |
| 328 | } |
| 329 | |