oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

1package theme
2
3import (
4 "bytes"
5 "flag"
6 "os"
7 "path/filepath"
8 "regexp"
9 "sort"
10 "strings"
11 "testing"
12
13 "github.com/carlosframework/rastrillo/ui"
14)
15
16// Estilo stopped being day on 2026-09-29 (AGENTS.md, decisions table,
17// row 8): Paul chose Calma. The byte-for-byte gate against day is gone
18// with that ruling. What replaces it holds Calma to the same bars day was
19// held to, on its own values:
20//
21// - TestDeclaresDaysTokenSet: the same token NAMES as the pinned day,
22// so rastrillo's tokens.css never meets an undefined var(--rst-*),
23// and a rastrillo upgrade that adds or drops a token still fails here.
24// - TestContrastMeetsWCAG: every pair rastrillo's own gate checks, in
25// both schemes, for estilo.css and for every app's accent.
26// - TestAccentGoldens: each app's accent block against testdata, so no
27// colour moves without the diff saying so. Regenerate deliberately
28// with `go test ./theme -run Goldens -update` and read the diff.
29
30var update = flag.Bool("update", false, "rewrite theme/testdata goldens")
31
32// The next three are copied verbatim from rastrillo's
33// ui/contrast_test.go (v0.23.0), per AGENTS.md's "contrast arithmetic
34// copied verbatim, never reimplemented". The arithmetic itself is
35// rastrillo's exported ui.ContrastRatio.
36
37var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`)
38
39func parseTokens(body string) map[string]string {
40 out := map[string]string{}
41 for _, m := range declPattern.FindAllStringSubmatch(body, -1) {
42 out[m[1]] = strings.TrimSpace(m[2])
43 }
44 return out
45}
46
47// splitLightDark evaluates a whole-value light-dark(<light>, <dark>)
48// call, returning the two halves. It reports false for anything else —
49// including a value that merely *contains* a light-dark() somewhere
50// inside it, which is exactly what the shadow tokens look like
51// ("0 8px 24px light-dark(rgba(…), rgba(…))"): those are not colours in
52// their own right, are not in the pair table, and are left to both
53// schemes verbatim rather than half-parsed.
54//
55// The split is paren-aware because both halves are usually rgba() calls
56// full of commas of their own.
57func splitLightDark(v string) (light, dark string, ok bool) {
58 const prefix = "light-dark("
59 if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") {
60 return "", "", false
61 }
62 inner := v[len(prefix) : len(v)-1]
63 depth, comma := 0, -1
64 for i := 0; i < len(inner); i++ {
65 switch inner[i] {
66 case '(':
67 depth++
68 case ')':
69 depth--
70 if depth < 0 {
71 return "", "", false // the closing ")" we trimmed was not ours
72 }
73 case ',':
74 if depth == 0 {
75 if comma >= 0 {
76 return "", "", false // three arguments is not light-dark()
77 }
78 comma = i
79 }
80 }
81 }
82 if depth != 0 || comma < 0 {
83 return "", "", false
84 }
85 return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true
86}
87
88// tokensIn parses every :root block in css, later declarations winning,
89// and splits each value into its two schemes. ForApp's output has two
90// :root blocks (estilo.css, then the app's accent), exactly as a browser
91// would cascade them.
92func tokensIn(css string) map[string]map[string]string {
93 out := map[string]map[string]string{"light": {}, "dark": {}}
94 // Comments first: a comment quoting a declaration ("was --rst-accent:
95 // #123;") must never win the cascade the way it would in this parse.
96 css = regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(css, "")
97 for name, v := range parseTokens(css) {
98 if l, d, ok := splitLightDark(v); ok {
99 out["light"][name], out["dark"][name] = l, d
100 continue
101 }
102 out["light"][name], out["dark"][name] = v, v
103 }
104 return out
105}
106
107func names(css []byte) []string {
108 var out []string
109 for name := range parseTokens(string(css)) {
110 out = append(out, name)
111 }
112 sort.Strings(out)
113 return out
114}
115
116func TestDeclaresDaysTokenSet(t *testing.T) {
117 day, ok := ui.ThemeCSS("day")
118 if !ok {
119 t.Fatal(`ui.ThemeCSS("day") returned false -- rastrillo no longer ships a theme named "day"`)
120 }
121 want, got := names(day), names(css)
122 if strings.Join(want, " ") != strings.Join(got, " ") {
123 t.Fatalf("estilo.css must declare exactly day's token names (tokens.css uses them):\n day: %v\n estilo: %v", want, got)
124 }
125 // The toggle, too: it is the only way an explicit Appearance choice
126 // beats the OS.
127 for _, rule := range []string{`:root[data-theme="light"] { color-scheme: light; }`, `:root[data-theme="dark"] { color-scheme: dark; }`} {
128 if !bytes.Contains(css, []byte(rule)) {
129 t.Errorf("estilo.css lost the scheme toggle %q", rule)
130 }
131 }
132}
133
134func TestContrastMeetsWCAG(t *testing.T) {
135 type pair struct {
136 fg, bg string
137 min float64
138 why string
139 }
140 // Transcribed verbatim from rastrillo's TestThemeTokenContrastMeetsWCAG.
141 pairs := []pair{
142 {"--rst-text", "--rst-surface", 4.5, "body text on a card"},
143 {"--rst-text", "--rst-bg", 4.5, "body text"},
144 {"--rst-text", "--rst-surface-2", 4.5, "body text on a card"},
145 {"--rst-text", "--rst-accent-soft", 4.5, "body text on an accent-tinted surface"},
146 {"--rst-text-muted", "--rst-surface", 4.5, "muted text on a card"},
147 {"--rst-text-muted", "--rst-bg", 4.5, "muted text"},
148 {"--rst-text-muted", "--rst-surface-2", 4.5, "muted text on a card"},
149 {"--rst-text-muted", "--rst-accent-soft", 4.5, "muted text on an accent-tinted surface"},
150 {"--rst-text-faint", "--rst-surface", 4.5, "faint text on a card ([rst-field-hint])"},
151 {"--rst-text-faint", "--rst-bg", 4.5, "faint text"},
152 {"--rst-text-faint", "--rst-surface-2", 4.5, "faint text on a card"},
153 {"--rst-text-faint", "--rst-accent-soft", 4.5, "faint text on an accent-tinted surface"},
154 {"--rst-accent", "--rst-surface", 4.5, "text + focus ring"},
155 {"--rst-accent", "--rst-bg", 4.5, "text + focus ring"},
156 {"--rst-accent", "--rst-surface-2", 4.5, "text"},
157 {"--rst-accent", "--rst-accent-soft", 4.5, "text"},
158 {"--rst-on-accent", "--rst-accent", 4.5, "primary button label"},
159 {"--rst-on-accent", "--rst-accent-strong", 4.5, "primary button label, hover"},
160 {"--rst-line-strong", "--rst-surface", 3.0, "control border (1.4.11 boundary, not text)"},
161 {"--rst-line-strong", "--rst-bg", 3.0, "control border"},
162 {"--rst-line-strong", "--rst-surface-2", 3.0, "control border"},
163 {"--rst-tone-neutral-fg", "--rst-tone-neutral-bg", 4.5, "status pill text"},
164 {"--rst-tone-positive-fg", "--rst-tone-positive-bg", 4.5, "status pill text"},
165 {"--rst-tone-warning-fg", "--rst-tone-warning-bg", 4.5, "status pill text"},
166 {"--rst-tone-negative-fg", "--rst-tone-negative-bg", 4.5, "status pill text"},
167 // Not in the main header tables — documented separately in
168 // tokens.css's [rst-btn~="danger"] comment, which reuses
169 // --rst-tone-negative-fg as a solid fill rather than declaring a
170 // dedicated --rst-danger-* pair. This is the real pair the danger
171 // button's label renders against, so it belongs in the gate anyway.
172 {"--rst-on-accent", "--rst-tone-negative-fg", 4.5, "danger button label"},
173 }
174
175 themes := map[string]string{"estilo.css": string(css)}
176 for _, a := range Apps() {
177 full, _ := ForApp(a.Slug)
178 themes[a.Slug] = string(full)
179 }
180 for name, body := range themes {
181 tokens := tokensIn(body)
182 for _, scheme := range []string{"light", "dark"} {
183 t.Run(name+"/"+scheme, func(t *testing.T) {
184 for _, p := range pairs {
185 fg, bg := tokens[scheme][p.fg], tokens[scheme][p.bg]
186 if fg == "" || bg == "" {
187 t.Errorf("%s or %s is not declared", p.fg, p.bg)
188 continue
189 }
190 ratio, err := ui.ContrastRatio(fg, bg)
191 if err != nil {
192 t.Errorf("%s (%s) on %s (%s): %v", p.fg, fg, p.bg, bg, err)
193 continue
194 }
195 if ratio < p.min {
196 t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fg, p.bg, bg, ratio, p.min, p.why)
197 }
198 }
199 })
200 }
201 }
202}
203
204// estilo.css is written out by hand, so it can carry its own comments;
205// its accent is Oficina's, and must stay what the ring says Oficina is.
206func TestSuiteAccentIsOficinas(t *testing.T) {
207 block, _ := AccentCSS("oficina")
208 want, got := parseTokens(string(block)), parseTokens(string(css))
209 for name, v := range want {
210 if got[name] != v {
211 t.Errorf("estilo.css %s = %q, but the ring gives Oficina %q", name, got[name], v)
212 }
213 }
214}
215
216func TestAccentGoldens(t *testing.T) {
217 for _, a := range Apps() {
218 block, _ := AccentCSS(a.Slug)
219 path := filepath.Join("testdata", a.Slug+".css")
220 if *update {
221 if err := os.WriteFile(path, block, 0o644); err != nil {
222 t.Fatal(err)
223 }
224 continue
225 }
226 want, err := os.ReadFile(path)
227 if err != nil {
228 t.Fatalf("%v (run with -update to create it)", err)
229 }
230 if !bytes.Equal(want, block) {
231 t.Errorf("%s's accent changed:\n want %s\n got %s", a.Name, want, block)
232 }
233 }
234}
235
236func TestTheRingIsClosed(t *testing.T) {
237 for _, slug := range []string{"", "day", "Docs", "docs ", "../docs", "meet"} {
238 if _, ok := ForApp(slug); ok {
239 t.Errorf("ForApp(%q) accepted a slug outside the ring", slug)
240 }
241 }
242 seen := map[string]bool{}
243 for _, a := range Apps() {
244 if seen[a.Slug] || !regexp.MustCompile(`^[a-z]+$`).MatchString(a.Slug) {
245 t.Errorf("ring slug %q is repeated or not a plain word", a.Slug)
246 }
247 seen[a.Slug] = true
248 }
249}
250