package check import ( "fmt" "reflect" "regexp" "slices" "strconv" "strings" "testing" "amadan.net/rastrillo/fiesta" ) // The gates that read tokens.css, shell.css and the themes as text, // ported from rastrillo's ui/ui_test.go at the seed commit: what the // stylesheets embed, the one-block theme structure, the motion and // scheme gates, the selectors the components rely on, and the markup // contracts the examples hold (the dropdown's native exclusivity, // the modal as a rendered-open dialog). They read the stylesheets // with the readers the gates share: cssSpan and the bracket reader in // markuphelp_test.go, declarationsOf in baseline_test.go, leafRules // here, cssRules and splitSelectorList in spelling_test.go, and // themeTokens in contrast_test.go. None searches a stylesheet's text // for what it must hold. The tests over markup read the example state // the manifest's idiom table names. func TestTokensCSSIsEmbedded(t *testing.T) { css := fiesta.TokensCSS() if len(css) == 0 { t.Fatal("fiesta.TokensCSS() is empty") } // Each is read where a browser reads it, so one only a comment // mentions is missing: a declared token, a var() a value reads, // a selector. decls, _ := parseDeclarations(t, "tokens.css", string(css)) declared, reads := map[string]bool{}, map[string]bool{} for _, d := range decls { declared[d.Property] = true for _, ref := range varRefs(d.Value) { reads["var("+ref.name+")"] = true } } attributes, classes := styledAttributes(t, string(css)), styledClasses(t, string(css)) for _, want := range []string{"--rst-sp-4", "--rst-fs-base"} { if !declared[want] { t.Errorf("tokens.css does not declare %s", want) } } for _, want := range []string{"var(--rst-bg)", "var(--rst-text)", "var(--rst-accent)", "var(--rst-font)", "var(--rst-radius)", "var(--rst-shadow-pop)"} { if !reads[want] { t.Errorf("tokens.css never reads %s", want) } } if !attributes["[rst-status]"] || !classes["rst-sr-only"] { t.Errorf("tokens.css has no [rst-status] or .rst-sr-only selector") } // The colour blocks are the theme's now. tokens.css naming one again // means a palette leaked back into the structural file, where a // theme swap could no longer reach it. The shape tokens joined them // in v2: a "--rst-radius:" or "--rst-shadow-pop:" *declaration* here // would silently outrank nothing and confuse everything, since every // theme declares its own. leaks, err := shapeLeaks(string(css)) if err != nil { t.Fatal(err) } for _, gone := range leaks { t.Errorf("tokens.css declares %q; colour and shape belong in themes/", gone) } } // shapeLeaks returns every colour-block or shape-token declaration // tokens.css must not carry. The one allowance is print's flatten of // the knob's two shadow tokens (their hover lift outranks the knob's // print reset), at html:root inside @media print and nowhere else; an // error when that flatten is missing, doubled or elsewhere. Everything // else is searched everywhere, the print and forced-colours blocks // included: a radius there would override a theme's geometry. // // It reads rules with the declaration reader. Read as text, the // flatten counted where it sat in a string at html:root, with the real // one gone, and the print block it had to sit in was cut by counting // braces. func shapeLeaks(css string) ([]string, error) { rules, err := readLeafRules(css) if err != nil { return nil, err } const none = "0 0 0 transparent" flatten := func(r leafRule) bool { return r.sets("--rst-shadow-knob", none) && r.sets("--rst-shadow-knob-hover", none) } var allowed *leafRule flattens := 0 for i, r := range rules { if flatten(r) { flattens++ if r.selector == "html:root" && slices.Contains(strings.Split(r.context, " > "), "@media print") { allowed = &rules[i] } } } if flattens != 1 { return nil, fmt.Errorf("tokens.css carries print's knob-shadow flatten %d times, want once, at html:root inside @media print", flattens) } if allowed == nil { return nil, fmt.Errorf("print's knob-shadow flatten is not at html:root inside @media print, the one place it is allowed") } shape := []string{ "--rst-radius", "--rst-radius-sm", "--rst-radius-pill", "--rst-shadow-pop", "--rst-shadow-knob", "--rst-shadow-lift", "--rst-overlay", } leaked := map[string]bool{} for i, r := range rules { scheme := strings.ReplaceAll(r.context+" "+r.selector, " ", "") for _, gone := range []string{"prefers-color-scheme:dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`} { if strings.Contains(scheme, gone) { leaked[gone] = true } } for _, d := range r.decls { if &rules[i] == allowed && strings.HasPrefix(d[0], "--rst-shadow-knob") { continue } leaked[d[0]+":"] = true } } var out []string for _, gone := range []string{"prefers-color-scheme:dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`} { if leaked[gone] { out = append(out, gone) } } for _, p := range shape { if leaked[p+":"] { out = append(out, p+":") } } return out, nil } // TestTheShapeGateSeesInsideThePrintAndForcedBlocks (switch review 6): // the allowance is the knob flatten at its place, not the blocks. Radii // at html:root inside print and inside forced colours are leaks like // any other, and the flatten under another selector in print is an // error. The shipped tokens.css is the control. func TestTheShapeGateSeesInsideThePrintAndForcedBlocks(t *testing.T) { css := string(fiesta.TokensCSS()) if leaks, err := shapeLeaks(css); err != nil || len(leaks) != 0 { t.Fatalf("the shipped tokens.css: leaks %v, err %v; want none", leaks, err) } const radii = "\n html:root { --rst-radius: 0; --rst-radius-pill: 0; }" for _, m := range []struct{ name, header string }{{"print", "@media print {"}, {"forced colours", "@media (forced-colors: active) {"}} { mutant := strings.Replace(css, m.header, m.header+radii, 1) if mutant == css { t.Fatalf("%s: the block is not where this test expects it", m.name) } leaks, err := shapeLeaks(mutant) if err != nil { t.Fatal(err) } if strings.Join(leaks, " ") != "--rst-radius: --rst-radius-pill:" { t.Errorf("%s block with radii at html:root: the gate reports %v; want --rst-radius: and --rst-radius-pill:", m.name, leaks) } } const flatten = "--rst-shadow-knob: 0 0 0 transparent; --rst-shadow-knob-hover: 0 0 0 transparent;" moved := strings.Replace(css, flatten, "", 1) moved = strings.Replace(moved, "@media print {", "@media print {\n .rst-switch__track::after { "+flatten+" }", 1) if moved == css || !strings.Contains(moved, flatten) { t.Fatal("the knob flatten is not where this test expects it") } if _, err := shapeLeaks(moved); err == nil { t.Error("the knob flatten moved off html:root in print passed the gate") } } // Every shipped theme is a whole theme: the palette, the type family, // the shape tokens, and the one-block structure the contrast gate parses. func TestThemeCSSIsEmbedded(t *testing.T) { for _, name := range fiesta.ThemeNames() { css, ok := fiesta.ThemeCSS(name) if !ok { t.Fatalf("fiesta.ThemeCSS(%q) reports missing, but it is in fiesta.ThemeNames()", name) } // Read as declarations, so a token or a toggle that is only in // a comment or a string is missing. where := "themes/" + name + ".css" decls := themeDeclarations(t, where, string(css)) root := declaredAt(decls, "", ":root") for _, want := range []string{ "--rst-bg", "--rst-surface", "--rst-text", "--rst-accent", "--rst-tone-positive-fg", "--rst-font", "--rst-radius", "--rst-shadow-pop", } { if _, ok := root[want]; !ok { t.Errorf("%s declares no %s at :root", where, want) } } if root["color-scheme"] != "light dark" { t.Errorf("%s sets color-scheme %q at :root, want light dark", where, root["color-scheme"]) } pairs := 0 for _, v := range root { if _, _, ok := splitLightDark(v); ok { pairs++ } } if pairs == 0 { t.Errorf("%s declares no light-dark() pair at :root", where) } // A theme restates nothing per scheme but numbers: // light-dark() cannot wrap a number, so the press strengths are // written once in :root for light and again in two blocks for // dark, and those blocks hold --rst-* numbers, the same numbers // in both, each also declared in :root. Anything else in the // file beyond :root and the two toggle rules is the second // palette block light-dark() was adopted to make impossible. // The toggles are read there too. schemeNumberBlocks(t, name, string(css)) // day's relative-colour block (derived_test.go) nests a :root of // its own; outside it the palette is one block. bare := 0 for _, r := range leafRules(t, string(css)) { if r.context == "" && r.selector == ":root" { bare++ } } if bare != 1 { t.Errorf("%s opens %d bare :root blocks, want exactly 1", where, bare) } for _, prop := range []string{"--rst-bg", "--rst-text", "--rst-accent", "--rst-radius"} { got := 0 for _, d := range decls { if d.Property == prop { got++ } } if got != 1 { t.Errorf("%s declares %s: %d times, want exactly 1 (one block, both schemes)", where, prop, got) } } } for _, bad := range []string{"nope", "", "day.css", "../tokens"} { if _, ok := fiesta.ThemeCSS(bad); ok { t.Errorf("fiesta.ThemeCSS(%q) reports a theme that is not shipped", bad) } } } // Both schemes are authored, never inverted. Before v2 that meant // counting three copies of every colour block; now it means every themed // colour is declared exactly once, as a light-dark() call with two // distinct-or-deliberate halves. A theme that wrote a bare hex where a // light-dark() belongs has a dark scheme that is just the light one // showing through, which is the modern shape of the same rot. // // The shadows and the overlay are exempt from the light-dark() // requirement but not from being declared: a shadow whose two schemes // are genuinely identical (day's knob and lift) is allowed to say so // once, and light-dark() may only wrap the colour inside a shadow value // anyway, never the whole thing. func TestBothSchemesAreAuthoredInEveryTheme(t *testing.T) { needsLightDark := []string{ "--rst-bg", "--rst-surface", "--rst-surface-2", "--rst-line", "--rst-line-strong", "--rst-text", "--rst-text-muted", "--rst-text-faint", "--rst-accent", "--rst-accent-strong", "--rst-accent-soft", "--rst-on-accent", "--rst-tone-neutral-fg", "--rst-tone-neutral-bg", "--rst-tone-positive-fg", "--rst-tone-positive-bg", "--rst-tone-warning-fg", "--rst-tone-warning-bg", "--rst-tone-negative-fg", "--rst-tone-negative-bg", "--rst-overlay", "--rst-accent-fill", "--rst-accent-fill-hover", "--rst-accent-fill-edge", "--rst-accent-fill-edge-hover", "--rst-danger-fill", "--rst-danger-fill-hover", "--rst-danger-fill-edge", "--rst-danger-fill-edge-hover", "--rst-control-face", "--rst-control-face-hover", "--rst-control-border", "--rst-control-border-hover", "--rst-control-text-hover", "--rst-knob-shade", "--rst-track-off", "--rst-tint-hover", "--rst-tint-press", "--rst-row-tint-hover", "--rst-row-tint-press", "--rst-nav-current-bg", "--rst-nav-current-fg", } // --rst-header-rule is declared once but is NOT in needsLightDark: // it is derived from tokens the two schemes already split, so one // color-mix() (or, in plain, one var()) covers both. Wrapping a // derived value in light-dark() would reintroduce exactly the second // hand-authored colour per scheme the derivation exists to avoid. // // The depth axis's one-value tokens are here too: the numbers, the // transparent faces and edges (--rst-control-face-press, the ghost // and summary faces are transparent in two themes, so a light-dark() // requirement would force a colour where none is painted), and the // type choices. The press strengths are in neither list: they are // declared in :root and restated in the two dark-number blocks, and // schemeNumberBlocks holds those. declaredOnce := append([]string{ "--rst-font", "--rst-radius", "--rst-radius-sm", "--rst-radius-pill", "--rst-shadow-pop", "--rst-shadow-knob", "--rst-shadow-knob-hover", "--rst-shadow-lift", "--rst-header-rule", "--rst-on-accent-fill", "--rst-on-danger-fill", "--rst-on-danger-fill-hover", "--rst-danger-hover-filter", "--rst-knob", "--rst-control-face-press", "--rst-ghost-edge", "--rst-ghost-face-hover", "--rst-ghost-edge-hover", "--rst-summary-face", "--rst-summary-face-hover", "--rst-summary-edge", "--rst-summary-edge-hover", "--rst-summary-radius", "--rst-tint-tap", "--rst-tint-card-hover", // The tile and group tints are the surface or transparent in // the flat themes (a tile had no hover, so its hover is its // surface), so they are declared once, not per scheme. "--rst-tint-tile-hover", "--rst-tint-tile-press", "--rst-tint-group-hover", "--rst-depth-hl", "--rst-depth-contact", "--rst-depth-ambient", "--rst-depth-face", "--rst-press-drop", "--rst-focus-edge", "--rst-focus-halo", "--rst-sheen-fill", "--rst-sheen-plain", "--rst-light-rim", "--rst-light-wash", "--rst-title-size", "--rst-title-weight", "--rst-title-track", "--rst-heading-size", "--rst-label-case", "--rst-label-track", "--rst-rail-bg", // The switch's two size sets: lengths, the same in both schemes. "--rst-switch-w", "--rst-switch-h", "--rst-switch-knob", "--rst-switch-touch-w", "--rst-switch-touch-h", "--rst-switch-touch-knob", }, needsLightDark...) for _, name := range fiesta.ThemeNames() { raw, ok := fiesta.ThemeCSS(name) if !ok { t.Fatalf("fiesta.ThemeCSS(%q) missing", name) } // The relative-colour block redeclares a handful of day's tokens // (derived_test.go); the once-only count reads the file without // it, and the block may restate a name at most once. Read as // declarations, so one in a comment or a string is not counted. values := map[string]string{} count, supports := map[string]int{}, map[string]int{} for _, d := range themeDeclarations(t, "themes/"+name+".css", string(raw)) { if d.Context == accentSupports { supports[d.Property]++ continue } count[d.Property]++ values[d.Property] = fullValue(d) } for _, prop := range declaredOnce { if got := count[prop]; got != 1 { t.Errorf("themes/%s.css declares %s %d times, want exactly 1", name, prop, got) } if got := supports[prop]; got > 1 { t.Errorf("themes/%s.css redeclares %s %d times in its relative-colour block, want at most 1", name, prop, got) } } for _, prop := range needsLightDark { // A derived value is exempt the way --rst-header-rule is: a // bare var() of a token declared in this theme, or a // color-mix(), reads tokens the schemes already split, and // wrapping it in light-dark() would be the second // hand-authored colour per scheme the derivation exists to // avoid. What passes here: day's tint aliases, focus edge and // halo, and plain's and signal's fills, which read their // accent. day's fills and soft ground are light-dark() pairs // (of relative colours, in the @supports block) and are // split like any other. if v := values[prop]; strings.HasPrefix(v, "color-mix(") { continue } else if name, _, ok := splitVar(v); ok { if _, declared := values[name]; !declared { t.Errorf("themes/%s.css: %s reads %s, which this theme does not declare", name, prop, name) } continue } light, dark, ok := splitLightDark(values[prop]) if !ok { t.Errorf("themes/%s.css: %s is %q, want a whole-value light-dark(, )", name, prop, values[prop]) continue } if light == dark { t.Errorf("themes/%s.css: %s declares %s for both schemes; that is an inverted dark theme wearing light-dark()'s clothes", name, prop, light) } } } } // reducedMotionAllowlist names selectors that carry a transition/animation // with no matching disable under @media (prefers-reduced-motion: reduce), // found pre-existing at the time this gate was added. The gate came // alone; it did not silently fix CSS it did not write. Empty today — // every transition tokens.css declares (rst-caret, rst-switch-track and // its ::after, rst-tip::after) already has a reduce-block "transition: // none" counterpart, so nothing needs listing. If a future change adds a // transition without one, this test fails; add the selector here only // with a comment explaining why it is deliberately left un-disabled, not // as a way to silence the failure. var reducedMotionAllowlist = map[string]bool{} // leafRule is one style rule, or one keyframe, as declarationsOf reads // it: the at-rules around it, joined as a declaration's Context is; // top, the top-level statement it sits in, counted from 0, so source // order between rules can be read off it; its selector list as written, // whitespace collapsed; and its declarations as cssDeclarations reads // them. A gate reads a rule's declarations here rather than its text, // so a "cursor: pointer" inside a string is not one. type leafRule struct { context string top int selector string decls [][3]string } // value is what the rule sets prop to, as the cascade reads one block: // the last !important declaration of it, else the last. func (r leafRule) value(prop string) (string, bool) { value, found, important := "", false, false for _, d := range r.decls { if d[0] == prop && (d[2] != "" || !important) { value, found, important = d[1], true, d[2] != "" } } return value, found } // sets reports whether the rule sets prop to value. func (r leafRule) sets(prop, value string) bool { v, ok := r.value(prop) return ok && v == value } // readLeafRules is every style rule and keyframe in css, nested in an // at-rule or not, read with the declaration reader. A pattern over // braces cut [rst-snippet] { --rst-x: "}"; cursor: pointer; } at the // quoted brace, and a reader of braces alone took a { in a rule's // block for a rule of its own and dropped the one around it. This one // refuses what declarationsOf refuses, a nested rule among them. func readLeafRules(css string) ([]leafRule, error) { r := cssReader{} bare, err := cssWithoutComments(css) if err == nil { err = r.rules(bare, nil) } if err != nil { return nil, err } return r.leaves, nil } // leafRules is readLeafRules failing the test on a stylesheet it // refuses. func leafRules(t testing.TB, css string) []leafRule { t.Helper() rules, err := readLeafRules(css) if err != nil { t.Fatal(err) } return rules } // A brace inside a string is text to a browser, so it neither ends a // rule nor starts one. Cut at the quoted } instead, the first rule // below loses its cursor: pointer, and the tap inventory never sees // the control it styles. func TestLeafRulesReadABraceInAStringAsText(t *testing.T) { const css = `[rst-snippet] { --rst-x: "}"; cursor: pointer; } [rst-a] { content: '{'; color: red } @media (x) { [rst-b] { cursor: pointer } }` want := []leafRule{ {top: 0, selector: "[rst-snippet]", decls: [][3]string{{"--rst-x", `"}"`, ""}, {"cursor", "pointer", ""}}}, {top: 1, selector: "[rst-a]", decls: [][3]string{{"content", "'{'", ""}, {"color", "red", ""}}}, {context: "@media (x)", top: 2, selector: "[rst-b]", decls: [][3]string{{"cursor", "pointer", ""}}}, } if got := leafRules(t, css); !reflect.DeepEqual(got, want) { t.Errorf("leafRules(%q) =\n%+v\nwant\n%+v", css, got, want) } var controls []string for _, r := range interactiveCSSRules(t, css) { controls = append(controls, r.selector) } if want := []string{"[rst-snippet]", "[rst-b]"}; !reflect.DeepEqual(controls, want) { t.Errorf("interactiveCSSRules(%q) finds %q, want %q", css, controls, want) } // A { in a rule's block is a nested rule to a browser, which still // applies the rule around it. A reader of braces took the nested // one for the leaf and lost [rst-c]'s cursor: pointer, so the tap // inventory never saw the control. It is refused instead. if rules, err := readLeafRules(`[rst-c] { cursor: pointer; [rst-d] { color: red } }`); err == nil { t.Errorf("readLeafRules read a nested rule as %+v, want an error", rules) } // Within one block an !important declaration wins over a later // plain one. r := leafRule{decls: [][3]string{{"cursor", "pointer", "!important"}, {"cursor", "auto", ""}}} if v, _ := r.value("cursor"); v != "pointer" { t.Errorf("value(cursor) of %+v = %q, want pointer", r.decls, v) } } // TestReducedMotionDisablesEveryTransition is the motion gate: // every selector that declares a real transition or animation outside a // prefers-reduced-motion block must have that exact selector — not a // selector merely containing or contained by it — disable that exact // same property (transition disabled by "transition: none", animation by // "animation: none") somewhere inside a // @media (prefers-reduced-motion: reduce) block. Two failure modes this // specifically guards against, both found by mutation during review: // declaring a *different* motion property on an already-listed selector // (selector reappearing is not enough — the property must actually be // neutralized), and a new selector merely overlapping textually with one // already covered ("[rst-tip]" vs "[rst-tip]::after") — hence the exact // selector match in motionProblems rather than a substring check. // // This gate is the early warning, not the authority. It finds a // disable but does not check that the disable wins: a later rule for // the same selector, or an !important one, beats a disable that came // first, and this gate still passes. Telling which declaration wins // means ordering and weighing the whole cascade, and a copy of that in // Go would be one more reader to get wrong. The authority is the six // parts of TestNothingMovesUnderReducedMotion // (reduced_motion_browser_test.go), which ask the browser what every // element on every example page computes under reduced motion. This // one runs in a second with no browser, and names the selector to fix. func TestReducedMotionDisablesEveryTransition(t *testing.T) { assertReducedMotion(t, "tokens.css", string(fiesta.TokensCSS())) } // shell.css is the other stylesheet a shell links, and every rule in it // animates: the gate holds it to the same exact-selector rule, unchanged // in what it accepts, so the slide cannot escape it. func TestShellCSSReducedMotionDisablesEveryAnimation(t *testing.T) { assertReducedMotion(t, "shell.css", string(fiesta.ShellCSS())) } // The motion gate reads declarations as a browser does. A quoted brace // is text, so "{" in one reduce rule and "}" in a later rule do not // pair up and carry .missed into the reduce block; a quoted // "transition: none" is a string, not a disable; and TRANSITION is // transition. Each hole lets a transition run under reduced motion. The last stylesheet is the // control: its one transition is disabled, so the gate passes. func TestTheMotionGateHasNoKnownHoles(t *testing.T) { for _, c := range []struct{ css, missed string }{ {`.ok { transition: opacity 1s; } @media (prefers-reduced-motion: reduce) { .ok { transition: none; } .marker { --rst-open: "{"; } } .missed { transition: opacity 1s; } .marker { --rst-close: "}"; }`, ".missed"}, {`.moving { transition: opacity 1s; } @media (prefers-reduced-motion: reduce) { .moving { --rst-proof: "; transition: none;"; } }`, ".moving"}, // A property name is not case-sensitive to a browser. {`.loud { TRANSITION: opacity 1s; } @media (prefers-reduced-motion: reduce) { .other { transition: none; } }`, ".loud"}, } { problems, _, err := motionProblems(c.css) named := false for _, p := range problems { named = named || strings.Contains(p, `"`+c.missed+`"`) } if err == nil && !named { t.Errorf("the motion gate passes %s, which still moves under reduced motion:\n%s", c.missed, c.css) } } const control = `.ok { transition: opacity 1s; } @media (prefers-reduced-motion: reduce) { .ok { transition: none; } }` if problems, tested, err := motionProblems(control); err != nil || len(problems) != 0 || tested != 1 { t.Errorf("the motion gate on a disabled transition: %v, %d checked, err %v; want no problems and 1 checked", problems, tested, err) } } // braceMatchEnd reads a quoted brace as text, so the block it finds // ends at the brace that closes it, not at one inside a string. func TestBraceMatchEndReadsAStringAsText(t *testing.T) { const css = `@media x { a { b: "}"; } c { d: "{"; } } e { f: "}" }` start := strings.Index(css, "{") + 1 want := strings.Index(css, "} } e") + 2 if got, err := braceMatchEnd(css, start); err != nil || got != want { t.Errorf("braceMatchEnd(%q, %d) = %d, %v; want %d", css, start, got, err, want) } // A brace that closes the wrong bracket, or none, is an error, not // the end of the file. for _, css := range []string{`{ a: ( }`, `{ a: "}"`} { if end, err := braceMatchEnd(css, 1); err == nil { t.Errorf("braceMatchEnd(%q, 1) = %d, want an error", css, end) } } } func assertReducedMotion(t *testing.T, name, css string) { t.Helper() problems, tested, err := motionProblems(css) if err != nil { t.Fatalf("%s: %v", name, err) } for _, p := range problems { t.Error(p) } if tested == 0 { t.Fatal("found no transition/animation declarations to check outside reduce blocks — the parser likely broke, not that " + name + " lost every transition") } } // motionProblems is the motion gate over one stylesheet: a problem for // each selector's transition or animation that no reduce block // disables, and how many it checked. It reads the stylesheet with // declarationsOf, so a brace or a "transition: none" inside a string // is text, as it is to a browser, and a selector list is split where // a browser splits it. func motionProblems(css string) (problems []string, tested int, err error) { decls, err := declarationsOf("", css) if err != nil { return nil, 0, err } const reduce = "@media (prefers-reduced-motion: reduce)" inReduce := func(d declaration) bool { return slices.Contains(strings.Split(d.Context, " > "), reduce) } motion := func(d declaration) bool { return d.Property == "transition" || d.Property == "animation" } type disable struct{ selector, property string } disabled := map[disable]bool{} for _, d := range decls { if inReduce(d) && motion(d) && d.Value == "none" { disabled[disable{d.Selector, d.Property}] = true } } if !slices.ContainsFunc(decls, inReduce) { return nil, 0, fmt.Errorf("declares no %s block at all", reduce) } for _, d := range decls { // none is already disabled unconditionally; nothing to gate. if !motion(d) || inReduce(d) || d.Value == "none" { continue } tested++ if reducedMotionAllowlist[d.Selector] || disabled[disable{d.Selector, d.Property}] { continue } problems = append(problems, fmt.Sprintf("selector %q declares %s: %s with no exact-selector %q: none under prefers-reduced-motion: reduce (add one, or add %q to reducedMotionAllowlist with a reason)", d.Selector, d.Property, d.Value, d.Property+": none", d.Selector)) } return problems, tested, nil } // The one non-partial asset this package ships gets the same bar as the // partials and the vendored icons. func TestTokensCSSIsSelfContained(t *testing.T) { reachOut := []string{"@import", "url(", "http://", "https://", "//fonts", "src:"} css := string(fiesta.TokensCSS()) for _, bad := range reachOut { if strings.Contains(css, bad) { t.Errorf("tokens.css reaches outside the page (%q)", bad) } } // The promise spans both scaffolded stylesheets: a theme is where a // webfont would be most tempting, and it is exactly as banned there. for _, name := range fiesta.ThemeNames() { theme, ok := fiesta.ThemeCSS(name) if !ok { t.Fatalf("fiesta.ThemeCSS(%q) missing", name) } for _, bad := range reachOut { if strings.Contains(string(theme), bad) { t.Errorf("themes/%s.css reaches outside the page (%q)", name, bad) } } } } // A var() naming a token nobody declares resolves to the property's // initial value with no error anywhere — the date picker's muted text // shipped as full-strength body colour that way, through a misspelt // --rst-muted. So every fallback-less reference in tokens.css must be // declared by tokens.css itself or by every theme; a reference that // carries a fallback is an app-settable hook (--rst-cols) and is fine // undeclared. func TestEveryTokenReferenceIsDeclared(t *testing.T) { // Read as declarations, not text: a "--rst-x:" in prose or in a // string would otherwise count as a declaration, and these files // are mostly prose. var() is read with varRefs, so var( --rst-x ) // spaced is still a reference without a fallback. tokens, _ := parseDeclarations(t, "tokens.css", string(fiesta.TokensCSS())) sheets := map[string][]declaration{"tokens.css": tokens} for _, name := range fiesta.ThemeNames() { theme, _ := fiesta.ThemeCSS(name) where := "themes/" + name + ".css" sheets[where] = themeDeclarations(t, where, string(theme)) } declared := map[string]bool{} inEvery := map[string]int{} for file, decls := range sheets { seen := map[string]bool{} for _, d := range decls { if !strings.HasPrefix(d.Property, "--rst-") { continue } if file == "tokens.css" { declared[d.Property] = true } else if !seen[d.Property] { seen[d.Property] = true inEvery[d.Property]++ } } } for tok, n := range inEvery { if n == len(fiesta.ThemeNames()) { declared[tok] = true } } // A theme derives tokens from tokens (--rst-header-rule mixes the // accent), so its references are held to the same bar. for _, file := range sortedKeys(sheets) { reported := map[string]bool{} for _, d := range sheets[file] { for _, ref := range varRefs(d.Value) { if !ref.fallback && strings.HasPrefix(ref.name, "--rst-") && !declared[ref.name] && !reported[ref.name] { reported[ref.name] = true t.Errorf("%s uses var(%s), which neither tokens.css nor every theme declares", file, ref.name) } } } } } // varRefs reads a var() as a browser does. A pattern over the text // took var(--rst-a) quoted in a string for a reference, missed // var( --rst-b ) for its spacing, and so could pass a token nobody // declares or fail one nobody reads. func TestVarRefsReadAStringAsText(t *testing.T) { got := varRefs(`"var(--rst-a)" var( --rst-b ) var(--rst-c, var(--rst-d)) VAR(--rst-e,)`) want := []varRef{{"--rst-b", false}, {"--rst-c", true}, {"--rst-d", false}, {"--rst-e", true}} if !reflect.DeepEqual(got, want) { t.Errorf("varRefs = %+v, want %+v", got, want) } } // varRef is one var() a value reads: the property it names, and // whether it carries a fallback. type varRef struct { name string fallback bool } // varRefs is every var() in value, a var() inside another's fallback // included, read with cssSpan and braceMatchEnd: a var( inside a // string is text, and spacing inside the brackets changes nothing. func varRefs(value string) []varRef { var out []varRef for i := 0; i < len(value); { end, _, err := cssSpan(value, i) if err != nil { return out } if strings.EqualFold(value[i:end], "var") && end < len(value) && value[end] == '(' { close, err := braceMatchEnd(value, end+1) if err != nil { return out } out = append(out, readVarRef(strings.TrimLeft(value[end+1:close], " \t\n\r\f"))) // Read on inside the brackets: a fallback can hold a var() // of its own. end++ } i = end } return out } // readVarRef reads what is inside one var(): the name it starts with, // and whether a comma outside any bracket or string follows. func readVarRef(inner string) varRef { var ref varRef depth := 0 for j := 0; j < len(inner); { end, _, err := cssSpan(inner, j) if err != nil { break } if j == 0 { ref.name = inner[:end] } switch inner[j] { case '(', '[': depth++ case ')', ']': depth-- case ',': ref.fallback = ref.fallback || depth == 0 } j = end } return ref } // Same drift check again, for the form family: field, field-select, // field-textarea, field-check and choice-field between them can emit // every one of these, and every one is spelled as the examples spell it. func TestFormPartialClassesAreStyled(t *testing.T) { attributes := styledAttributes(t, string(fiesta.TokensCSS())) for _, name := range []string{ "rst-field", "rst-field-label", "rst-field-hint", "rst-field-help", "rst-field-error", "rst-input", "rst-input~=short", "rst-input~=primary", "rst-switch", "rst-switch-track", "rst-choice", "rst-choice-cards", "rst-choice-title", "rst-choice-desc", "rst-seg-tabs", } { if !tokensStyle(attributes, name) { t.Errorf("tokens.css has no selector for %q", name) } } } // Same drift check again, direct rather than sample-driven: the classes // rastrillo's partials (confirm-form, back-nav, notice, form-error, // bulk-bar) emit themselves, so there is no arbitrary caller-composed // body for an example to carry them — their examples already hold the // partials' fixtures, and this pins // that every class they can render resolves to a tokens.css selector. func TestRoutesFamilyPartialClassesAreStyled(t *testing.T) { attributes := styledAttributes(t, string(fiesta.TokensCSS())) for _, name := range []string{ "rst-btn~=ghost", "rst-btn~=danger", "rst-back-nav", "rst-notice", "rst-form-error", "rst-bulkbar", "rst-bulkbar-close", "rst-bulkbar-count", "rst-bulkbar-escalate", } { if !tokensStyle(attributes, name) { t.Errorf("tokens.css has no selector for %q", name) } } } func TestJobStatusPartialClassesAreStyled(t *testing.T) { attributes := styledAttributes(t, string(fiesta.TokensCSS())) for _, name := range []string{"rst-spin"} { if !tokensStyle(attributes, name) { t.Errorf("tokens.css has no selector for %q", name) } } } // The dropdown's exclusivity (only one open at a time) is the native //
attribute, not JavaScript — this pins both halves of // that promise. func TestDropdownExclusivityIsNative(t *testing.T) { sample := dropdownSample(t) if !strings.Contains(sample, `
carries no name attribute: %s", sample) } if strings.Contains(sample, "; exclusivity must stay native: %s", sample) } } // detailsNamePattern reads the name attribute off a
open tag, // whichever order the attributes are written in. var detailsNamePattern = regexp.MustCompile(`
]*name="([^"]*)"`) // The submenu trap, pinned.
exclusivity is DOCUMENT-wide, // not sibling-scoped: two elements sharing a name close each other even // when one is inside the other. A nested rst-menu-group that inherited // its parent's group would therefore close the parent the instant it // opened — the submenu flashes and the whole menu vanishes under the // pointer. The nested sample must carry a name of its own, and it must // not be the parent's. func TestNestedMenuGroupNeverSharesItsParentsName(t *testing.T) { sample := dropdownSample(t) names := detailsNamePattern.FindAllStringSubmatch(sample, -1) if len(names) != 2 { t.Fatalf("expected two named
in the dropdown sample, found %d: %s", len(names), sample) } if !strings.Contains(sample, "rst-menu-group") { t.Fatalf("the dropdown sample no longer nests an rst-menu-group: %s", sample) } if names[0][1] == names[1][1] { t.Errorf("the nested rst-menu-group shares its parent's group %q; opening it would close the parent", names[0][1]) } } // The modal panel is : the rendered-open, non-modal dialog, // which is exactly what a modal-as-a-URL already was. Nothing calls // showModal(), so nothing enters the top layer, ::backdrop never paints, // and the overlay div stays the scrim. The UA's own dialog block has to // be undone or the panel lays out absolutely positioned with 1em of // padding. func TestModalPanelIsARenderedOpenDialog(t *testing.T) { sample := idiomMarkup(t, "modal") for _, want := range []string{ ``, `
`, `
`, } { if !strings.Contains(sample, want) { t.Errorf("modal sample missing %q: %s", want, sample) } } if strings.Contains(sample, `
`) { t.Errorf("the modal panel is still a div: %s", sample) } // A dialog role with no accessible name is announced as "dialog" and // nothing else, and fails axe's aria-dialog-name — a failure this // vocabulary created for itself the moment the panel stopped being a // div. The name comes from the panel's own heading rather than a // string in this library, so it is already in the page's language. id := regexp.MustCompile(`aria-labelledby="([^"]+)"`).FindStringSubmatch(sample) if id == nil { t.Fatalf("the dialog has no accessible name: %s", sample) } if !strings.Contains(sample, `

`) { t.Errorf("aria-labelledby=%q points at no heading in the panel: %s", id[1], sample) } // Comments stripped first: this file explains ::backdrop and names // the reset rule in prose, and a check that cannot tell a selector // from a sentence about one is not a check. // Read as rules: a check that cannot tell a selector from a // sentence about one, or from a string, is not a check. css := stripCSSComments(t, string(fiesta.TokensCSS())) var reset *leafRule for _, r := range leafRules(t, css) { if slices.ContainsFunc(splitSelectorList(r.selector), func(s string) bool { return strings.Contains(s, "dialog[rst-modal-panel]") }) { reset = &r break } } if reset == nil { t.Fatal("tokens.css has no scoped dialog reset; the UA dialog block would position the panel absolutely") } for _, want := range [][2]string{{"position", "static"}, {"padding", "0"}, {"margin", "0"}, {"color", "inherit"}} { if !reset.sets(want[0], want[1]) { t.Errorf("the dialog reset does not undo the UA block's %s: %s: %s { %v }", want[0], want[1], reset.selector, reset.decls) } } // ::backdrop belongs to the top layer, which a rendered-open dialog // never reaches. Styling it would be dead CSS that reads as if the // scrim came from the element. if strings.Contains(css, "::backdrop") { t.Error("tokens.css styles ::backdrop; a rendered-open dialog never paints one — the overlay div is the scrim") } } // The focus ring covers the whole app column, so a hand-rolled control // inside [rst-page] no longer restates the outline. func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { sels := ruleSelectors(t, string(fiesta.TokensCSS())) if !slices.ContainsFunc(sels, func(s string) bool { return strings.HasPrefix(s, ":where([rst-page],") }) { t.Error("tokens.css :focus-visible scope does not start with [rst-page]") } } // .rst-sr-only and the component rules it has to beat are all single // class selectors, so the cascade falls to source order: declared before // them it loses, and anything carrying both classes ends up a full-width // absolutely-positioned box hidden only by clip-path. That is how a // hidden