package theme import ( "bytes" "flag" "os" "path/filepath" "regexp" "sort" "strings" "testing" "github.com/carlosframework/rastrillo/ui" ) // Estilo stopped being day on 2026-09-29 (AGENTS.md, decisions table, // row 8): Paul chose Calma. The byte-for-byte gate against day is gone // with that ruling. What replaces it holds Calma to the same bars day was // held to, on its own values: // // - TestDeclaresDaysTokenSet: the same token NAMES as the pinned day, // so rastrillo's tokens.css never meets an undefined var(--rst-*), // and a rastrillo upgrade that adds or drops a token still fails here. // - TestContrastMeetsWCAG: every pair rastrillo's own gate checks, in // both schemes, for estilo.css and for every app's accent. // - TestAccentGoldens: each app's accent block against testdata, so no // colour moves without the diff saying so. Regenerate deliberately // with `go test ./theme -run Goldens -update` and read the diff. var update = flag.Bool("update", false, "rewrite theme/testdata goldens") // The next three are copied verbatim from rastrillo's // ui/contrast_test.go (v0.23.0), per AGENTS.md's "contrast arithmetic // copied verbatim, never reimplemented". The arithmetic itself is // rastrillo's exported ui.ContrastRatio. var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) func parseTokens(body string) map[string]string { out := map[string]string{} for _, m := range declPattern.FindAllStringSubmatch(body, -1) { out[m[1]] = strings.TrimSpace(m[2]) } return out } // splitLightDark evaluates a whole-value light-dark(, ) // call, returning the two halves. It reports false for anything else — // including a value that merely *contains* a light-dark() somewhere // inside it, which is exactly what the shadow tokens look like // ("0 8px 24px light-dark(rgba(…), rgba(…))"): those are not colours in // their own right, are not in the pair table, and are left to both // schemes verbatim rather than half-parsed. // // The split is paren-aware because both halves are usually rgba() calls // full of commas of their own. func splitLightDark(v string) (light, dark string, ok bool) { const prefix = "light-dark(" if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") { return "", "", false } inner := v[len(prefix) : len(v)-1] depth, comma := 0, -1 for i := 0; i < len(inner); i++ { switch inner[i] { case '(': depth++ case ')': depth-- if depth < 0 { return "", "", false // the closing ")" we trimmed was not ours } case ',': if depth == 0 { if comma >= 0 { return "", "", false // three arguments is not light-dark() } comma = i } } } if depth != 0 || comma < 0 { return "", "", false } return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true } // tokensIn parses every :root block in css, later declarations winning, // and splits each value into its two schemes. ForApp's output has two // :root blocks (estilo.css, then the app's accent), exactly as a browser // would cascade them. func tokensIn(css string) map[string]map[string]string { out := map[string]map[string]string{"light": {}, "dark": {}} // Comments first: a comment quoting a declaration ("was --rst-accent: // #123;") must never win the cascade the way it would in this parse. css = regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(css, "") for name, v := range parseTokens(css) { if l, d, ok := splitLightDark(v); ok { out["light"][name], out["dark"][name] = l, d continue } out["light"][name], out["dark"][name] = v, v } return out } func names(css []byte) []string { var out []string for name := range parseTokens(string(css)) { out = append(out, name) } sort.Strings(out) return out } func TestDeclaresDaysTokenSet(t *testing.T) { day, ok := ui.ThemeCSS("day") if !ok { t.Fatal(`ui.ThemeCSS("day") returned false -- rastrillo no longer ships a theme named "day"`) } want, got := names(day), names(css) if strings.Join(want, " ") != strings.Join(got, " ") { t.Fatalf("estilo.css must declare exactly day's token names (tokens.css uses them):\n day: %v\n estilo: %v", want, got) } // The toggle, too: it is the only way an explicit Appearance choice // beats the OS. for _, rule := range []string{`:root[data-theme="light"] { color-scheme: light; }`, `:root[data-theme="dark"] { color-scheme: dark; }`} { if !bytes.Contains(css, []byte(rule)) { t.Errorf("estilo.css lost the scheme toggle %q", rule) } } } func TestContrastMeetsWCAG(t *testing.T) { type pair struct { fg, bg string min float64 why string } // Transcribed verbatim from rastrillo's TestThemeTokenContrastMeetsWCAG. pairs := []pair{ {"--rst-text", "--rst-surface", 4.5, "body text on a card"}, {"--rst-text", "--rst-bg", 4.5, "body text"}, {"--rst-text", "--rst-surface-2", 4.5, "body text on a card"}, {"--rst-text", "--rst-accent-soft", 4.5, "body text on an accent-tinted surface"}, {"--rst-text-muted", "--rst-surface", 4.5, "muted text on a card"}, {"--rst-text-muted", "--rst-bg", 4.5, "muted text"}, {"--rst-text-muted", "--rst-surface-2", 4.5, "muted text on a card"}, {"--rst-text-muted", "--rst-accent-soft", 4.5, "muted text on an accent-tinted surface"}, {"--rst-text-faint", "--rst-surface", 4.5, "faint text on a card ([rst-field-hint])"}, {"--rst-text-faint", "--rst-bg", 4.5, "faint text"}, {"--rst-text-faint", "--rst-surface-2", 4.5, "faint text on a card"}, {"--rst-text-faint", "--rst-accent-soft", 4.5, "faint text on an accent-tinted surface"}, {"--rst-accent", "--rst-surface", 4.5, "text + focus ring"}, {"--rst-accent", "--rst-bg", 4.5, "text + focus ring"}, {"--rst-accent", "--rst-surface-2", 4.5, "text"}, {"--rst-accent", "--rst-accent-soft", 4.5, "text"}, {"--rst-on-accent", "--rst-accent", 4.5, "primary button label"}, {"--rst-on-accent", "--rst-accent-strong", 4.5, "primary button label, hover"}, {"--rst-line-strong", "--rst-surface", 3.0, "control border (1.4.11 boundary, not text)"}, {"--rst-line-strong", "--rst-bg", 3.0, "control border"}, {"--rst-line-strong", "--rst-surface-2", 3.0, "control border"}, {"--rst-tone-neutral-fg", "--rst-tone-neutral-bg", 4.5, "status pill text"}, {"--rst-tone-positive-fg", "--rst-tone-positive-bg", 4.5, "status pill text"}, {"--rst-tone-warning-fg", "--rst-tone-warning-bg", 4.5, "status pill text"}, {"--rst-tone-negative-fg", "--rst-tone-negative-bg", 4.5, "status pill text"}, // Not in the main header tables — documented separately in // tokens.css's [rst-btn~="danger"] comment, which reuses // --rst-tone-negative-fg as a solid fill rather than declaring a // dedicated --rst-danger-* pair. This is the real pair the danger // button's label renders against, so it belongs in the gate anyway. {"--rst-on-accent", "--rst-tone-negative-fg", 4.5, "danger button label"}, } themes := map[string]string{"estilo.css": string(css)} for _, a := range Apps() { full, _ := ForApp(a.Slug) themes[a.Slug] = string(full) } for name, body := range themes { tokens := tokensIn(body) for _, scheme := range []string{"light", "dark"} { t.Run(name+"/"+scheme, func(t *testing.T) { for _, p := range pairs { fg, bg := tokens[scheme][p.fg], tokens[scheme][p.bg] if fg == "" || bg == "" { t.Errorf("%s or %s is not declared", p.fg, p.bg) continue } ratio, err := ui.ContrastRatio(fg, bg) if err != nil { t.Errorf("%s (%s) on %s (%s): %v", p.fg, fg, p.bg, bg, err) continue } if ratio < p.min { t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fg, p.bg, bg, ratio, p.min, p.why) } } }) } } } // estilo.css is written out by hand, so it can carry its own comments; // its accent is Oficina's, and must stay what the ring says Oficina is. func TestSuiteAccentIsOficinas(t *testing.T) { block, _ := AccentCSS("oficina") want, got := parseTokens(string(block)), parseTokens(string(css)) for name, v := range want { if got[name] != v { t.Errorf("estilo.css %s = %q, but the ring gives Oficina %q", name, got[name], v) } } } func TestAccentGoldens(t *testing.T) { for _, a := range Apps() { block, _ := AccentCSS(a.Slug) path := filepath.Join("testdata", a.Slug+".css") if *update { if err := os.WriteFile(path, block, 0o644); err != nil { t.Fatal(err) } continue } want, err := os.ReadFile(path) if err != nil { t.Fatalf("%v (run with -update to create it)", err) } if !bytes.Equal(want, block) { t.Errorf("%s's accent changed:\n want %s\n got %s", a.Name, want, block) } } } func TestTheRingIsClosed(t *testing.T) { for _, slug := range []string{"", "day", "Docs", "docs ", "../docs", "meet"} { if _, ok := ForApp(slug); ok { t.Errorf("ForApp(%q) accepted a slug outside the ring", slug) } } seen := map[string]bool{} for _, a := range Apps() { if seen[a.Slug] || !regexp.MustCompile(`^[a-z]+$`).MatchString(a.Slug) { t.Errorf("ring slug %q is repeated or not a plain word", a.Slug) } seen[a.Slug] = true } }