rastrillo / fiesta Public

Clone
git clone https://amadan.net/rastrillo/fiesta

Plain git — no account needed to clone.

Download

Download this file

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.
11package tokens
12
13import (
14 "fmt"
15 "html/template"
16 "regexp"
17 "sort"
18 "strings"
19
20 "amadan.net/rastrillo/fiesta"
21)
22
23var 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.
38var 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.
43func 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.
74type 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.
84func 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.
121func 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.
133type 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.
141func 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.
149func 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.
203func 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.
237func 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.
272func 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.
316func 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