The CSS text gates, and every example's vocabulary held to a selector
The gates that read tokens.css, shell.css and the themes as text are ported from rastrillo's ui/ at SEED, every name kept: the embed and structure checks, the one-block theme and scheme-number gates, the motion gate, the WCAG pair table over resolved tokens, the composed button faces, the derived-colour evaluator, the zoom and target floors with the tap inventory, the rake line, the forced-colours and print blocks, the list-grid identity links, the button sample, the stage art under print, and the class-to-attribute twin gates with the CSS reader and the specificity calculator. Where a test read a styleguide sample it reads the example state the manifest's idiom table names, so dropdown is dropdown/menu-idiom; the tap inventory's markers are looked for in the sizing fixture plus every example piece. Two gates hold the examples and the stylesheets to each other. Every rst- attribute, value token and class in every state, the seven layout documents read whole, has a selector in tokens.css or shell.css; two names are written and unstyled on purpose and say why (rst-job, the busy shim's hook; the callout's default info tone). And every attribute, variant and utility class the stylesheets name is written by some state, or is listed with the reason it cannot be: the 32 entries are the calendar, combobox and date-picker markup the scripts draw at run time, the narrow pagination gap and the placeholder avatar no example state reaches, and the retired sidebar drawer. Both preconditions fail on a set of a hundred states or fewer. The search landmark and the sidebar rail foot are pinned on list-bar-search's and layout-sidebar's defaults. The open-tag regexps classSpelling used move out of the browser file so the vocabulary reader builds untagged; resolveWith, whose caller is a later drive, joins the keep list. Nothing already in check is redeclared.
Summary
check/css_test.go (the A list from ui_test.go and the stageart gate), contrast_test.go, contrast_face_test.go, derived_test.go (minus the colour helpers already in check), sizing_test.go (the text tests and the tap inventory over sizing.html plus example.All), header_rule_test.go, depth_media_test.go, rows_test.go, button_test.go, spelling_test.go (the twin gates and the CSS reader); coverage_test.go with TestEveryExampleAttributeAndClassIsStyled, TestEverySelectorAppearsInAnExample (selectorsWithoutAnExample, 32 entries with reasons), TestTheSearchFormSitsInASearchLandmark and TestTheSidebarRailFootIsOneProfileMenu; openTagInFixture and rstAttrInFixture moved untagged; resolveWith on the keep list.
Agent notes
Task 7. Every preservation test passed on its first run (76 by name). The four new tests failed by name on an empty examples.json before passing. Controls in a scratch copy: an example writing rst-nonesuch fails TestEveryExampleAttributeAndClassIsStyled naming it; day's --rst-text at #999 fails TestThemeTokenContrastMeetsWCAG/day/light on eight pairs. Findings: rst-job (job-status, format-ratios) and rst-tone=info (callout) are written and unstyled on purpose, now recorded in unstyledByDesign. make ci exit 0. F15 diff in the report: the two comment strippers differ (weight.go's is string-aware and reshapes whitespace), blockBody and parseTokens agree.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
13 files changed,
+4076
−14
check/button_test.go+68 −0check/contrast_face_test.go+151 −0check/contrast_test.go+466 −0check/coverage_test.go+389 −0check/css_test.go+1170 −0check/depth_media_test.go+66 −0check/derived_test.go+578 −0check/header_rule_test.go+61 −0check/markuphelp_test.go+11 −8check/rig_browser_test.go+1 −6check/rows_test.go+135 −0check/sizing_test.go+509 −0check/spelling_test.go+471 −0
diff --git a/check/button_test.go b/check/button_test.go| new file mode 100644 |
| index 0000000..5f2f567 |
| --- /dev/null |
| +++ b/check/button_test.go |
| @@ -0,0 +1,68 @@ |
| +package check |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// idiomMarkup reads the example state the manifest's idiom table names |
| +// for key, so a test written against rastrillo's Styleguide()[key] |
| +// reads the same markup here. |
| +func idiomMarkup(t *testing.T, key string) string { |
| + t.Helper() |
| + m, err := example.Load(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + name, state, ok := m.Idiom(key) |
| + if !ok { |
| + t.Fatalf("examples.json names no idiom %q", key) |
| + } |
| + markup, err := example.Markup(fiesta.FS(), name, state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return markup |
| +} |
| + |
| +// The button sample is the set that behaves differently, not every |
| +// combination: four variants at the default size, the three sizes on |
| +// primary, each labelled with its size, a link wearing the look, a |
| +// full-width one and a disabled one. The busy state is form-foot's idle/working pair, so it is not |
| +// drawn twice. A combination missing here is a size or variant a |
| +// reader of the gallery never sees on a button. |
| +func TestTheButtonSampleShowsEachDistinctBehaviour(t *testing.T) { |
| + sample := idiomMarkup(t, "button") |
| + for _, want := range []string{ |
| + `<button rst-btn type="button">`, |
| + `<button rst-btn="primary" type="button">`, |
| + `<button rst-btn="ghost" type="button">`, |
| + `<button rst-btn="danger" type="button">`, |
| + `<button rst-btn="primary sm" type="button">`, |
| + `<button rst-btn="primary lg" type="button">`, |
| + `<a rst-btn href="`, |
| + `<button rst-btn="primary block" type="button">`, |
| + `<button rst-btn type="button" disabled>`, |
| + } { |
| + if !strings.Contains(sample, want) { |
| + t.Errorf("the button sample has no %s", want) |
| + } |
| + } |
| + if n := len(regexp.MustCompile(`\srst-btn[\s=>]`).FindAllString(sample, -1)); n != 10 { |
| + t.Errorf("the button sample draws %d buttons, want the 10 that behave differently", n) |
| + } |
| + // The size row names each size on its button, small to large, so a |
| + // reader comparing them does not have to guess which is which: the |
| + // row used to be three buttons all reading "Save". |
| + const sizes = `<p><button rst-btn="primary sm" type="button">Small</button> <button rst-btn="primary" type="button">Default</button> <button rst-btn="primary lg" type="button">Large</button> <a rst-btn href="/orders">View orders</a></p>` |
| + if !strings.Contains(sample, sizes) { |
| + t.Errorf("the button sample has no size row %s", sizes) |
| + } |
| + if strings.Contains(sample, "aria-busy") || strings.Contains(sample, "rst-spin") { |
| + t.Error("the button sample draws the busy state, which form-foot's pair already shows") |
| + } |
| +} |
diff --git a/check/contrast_face_test.go b/check/contrast_face_test.go| new file mode 100644 |
| index 0000000..83aef8d |
| --- /dev/null |
| +++ b/check/contrast_face_test.go |
| @@ -0,0 +1,151 @@ |
| +package check |
| + |
| +import ( |
| + "fmt" |
| + "math" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The contrast gate in contrast_test.go measures token pairs. A button's |
| +// label does not sit on a token: it sits on the fill, under a face |
| +// gradient that lightens the top by --rst-depth-face and darkens the |
| +// bottom by the same, under a sheen while the pointer is on it, and |
| +// over a press that darkens by a wash plus a spot while it is held |
| +// (spec C4, A2). This file composes those points and holds each to |
| +// 4.5:1. The review computed 4.48:1 for a 15% sheen on the light |
| +// primary and the cap came down (A7); if a value fails, lower the shade |
| +// or the sheen, never lighten the fill. |
| + |
| +func faceShadeShape(t *testing.T) { |
| + t.Helper() |
| + if !regexp.MustCompile(`--rst-btn-face: linear-gradient\(rgb\(255 255 255 / var\(--rst-depth-face, 0\)\), rgb\(0 0 0 / var\(--rst-depth-face, 0\)\)\)`).Match(fiesta.TokensCSS()) { |
| + t.Fatal("tokens.css no longer shades a filled face with white at the top and black at the bottom by --rst-depth-face; this test measures that shape") |
| + } |
| +} |
| + |
| +func alphaOf(t *testing.T, v string) float64 { |
| + t.Helper() |
| + v = strings.TrimSpace(v) |
| + if v == "transparent" { |
| + return 0 |
| + } |
| + if strings.HasPrefix(v, "#") { |
| + return 1 |
| + } |
| + m := regexp.MustCompile(`rgba?\([^)]*?[,/]\s*([0-9.]+)\s*\)$`).FindStringSubmatch(v) |
| + if m == nil { |
| + t.Fatalf("cannot read an alpha from %q", v) |
| + } |
| + a, err := strconv.ParseFloat(m[1], 64) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return a |
| +} |
| + |
| +func channelsOf(t *testing.T, v string) [3]float64 { |
| + t.Helper() |
| + v = strings.TrimSpace(v) |
| + if v == "transparent" { |
| + return [3]float64{} |
| + } |
| + if strings.HasPrefix(v, "#") { |
| + r, g, b, err := parseHex(v) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return [3]float64{float64(r), float64(g), float64(b)} |
| + } |
| + m := regexp.MustCompile(`rgba?\(\s*([0-9]+)[,\s]+([0-9]+)[,\s]+([0-9]+)`).FindStringSubmatch(v) |
| + if m == nil { |
| + t.Fatalf("cannot read channels from %q", v) |
| + } |
| + var out [3]float64 |
| + for i := range out { |
| + n, _ := strconv.Atoi(m[i+1]) |
| + out[i] = float64(n) |
| + } |
| + return out |
| +} |
| + |
| +// over composites fg at alpha a over an opaque bg in sRGB, which is what |
| +// a gradient layer does, and returns hex for ContrastRatio. |
| +func over(t *testing.T, bg, fg string, a float64) string { |
| + t.Helper() |
| + B, F := channelsOf(t, bg), channelsOf(t, fg) |
| + var out [3]uint8 |
| + for i := range out { |
| + out[i] = uint8(math.Round(F[i]*a + B[i]*(1-a))) |
| + } |
| + return hexOf(out) |
| +} |
| + |
| +func hexChannels(t *testing.T, v string) string { |
| + t.Helper() |
| + c := channelsOf(t, v) |
| + return fmt.Sprintf("#%02x%02x%02x", int(c[0]), int(c[1]), int(c[2])) |
| +} |
| + |
| +// pressNumber reads a number token, from the dark scheme blocks for |
| +// dark, and fails on anything that is not a number: a value that parsed |
| +// as 0 would make every press look safe. (Not "number": the package |
| +// imports golang.org/x/text/number.) |
| +func pressNumber(t *testing.T, theme, scheme, name string) float64 { |
| + t.Helper() |
| + v := themeTokens(t, theme)["light"][name] |
| + if scheme == "dark" { |
| + css, _ := fiesta.ThemeCSS(theme) |
| + for _, block := range schemeNumberBlocks(t, theme, string(css)) { |
| + if m := regexp.MustCompile(regexp.QuoteMeta(name) + `\s*:\s*([^;]+);`).FindStringSubmatch(block); m != nil { |
| + v = strings.TrimSpace(m[1]) |
| + } |
| + } |
| + } |
| + f, err := strconv.ParseFloat(v, 64) |
| + if err != nil { |
| + t.Fatalf("%s/%s %s = %q is not a number", theme, scheme, name, v) |
| + } |
| + return f |
| +} |
| + |
| +// TestButtonFacesClearContrastAtTheirLightestAndDarkestPoints is C4. |
| +func TestButtonFacesClearContrastAtTheirLightestAndDarkestPoints(t *testing.T) { |
| + faceShadeShape(t) |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, scheme := range []string{"light", "dark"} { |
| + tok := resolvedTokens(t, theme)[scheme] |
| + shade := pressNumber(t, theme, scheme, "--rst-depth-face") |
| + check := func(label, text, face string) { |
| + t.Helper() |
| + r, err := ContrastRatio(text, face) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if r < 4.5 { |
| + t.Errorf("%s/%s %s: %s on the composed face %s = %.2f:1, want >= 4.5:1", theme, scheme, label, text, face, r) |
| + } |
| + t.Logf("%s/%s %s: %.2f:1", theme, scheme, label, r) |
| + } |
| + for _, v := range []struct{ name, fill, hover, text, wash, spot string }{ |
| + {"primary", tok["--rst-accent-fill"], tok["--rst-accent-fill-hover"], tok["--rst-on-accent-fill"], "--rst-press-wash", "--rst-press-spot"}, |
| + {"danger", tok["--rst-danger-fill"], tok["--rst-danger-fill-hover"], tok["--rst-on-danger-fill"], "--rst-press-wash", "--rst-press-spot"}, |
| + } { |
| + lightest := over(t, over(t, v.fill, "#ffffff", shade), "#ffffff", alphaOf(t, tok["--rst-sheen-fill"])) |
| + check(v.name+" lightest (top of the shade, full sheen)", v.text, lightest) |
| + darkest := over(t, over(t, over(t, v.hover, "#000000", shade), "#000000", pressNumber(t, theme, scheme, v.wash)), "#000000", pressNumber(t, theme, scheme, v.spot)) |
| + check(v.name+" darkest (bottom of the shade, whole press)", v.text, darkest) |
| + } |
| + sheen := tok["--rst-sheen-plain"] |
| + check("plain lightest (hovered, full sheen)", tok["--rst-control-text-hover"], over(t, tok["--rst-control-face-hover"], hexChannels(t, sheen), alphaOf(t, sheen))) |
| + check("plain darkest (hovered, whole press)", tok["--rst-control-text-hover"], over(t, over(t, tok["--rst-control-face-hover"], "#000000", pressNumber(t, theme, scheme, "--rst-press-wash-plain")), "#000000", pressNumber(t, theme, scheme, "--rst-press-spot-plain"))) |
| + if tok["--rst-control-face-press"] != "transparent" { |
| + check("ghost pressed", tok["--rst-text"], tok["--rst-control-face-press"]) |
| + } |
| + } |
| + } |
| +} |
diff --git a/check/contrast_test.go b/check/contrast_test.go| new file mode 100644 |
| index 0000000..72a2134 |
| --- /dev/null |
| +++ b/check/contrast_test.go |
| @@ -0,0 +1,466 @@ |
| +package check |
| + |
| +import ( |
| + "math" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// This file is a WCAG 2.2 AA contrast gate over every shipped theme's |
| +// *declared* custom-property values — the pairs a component's markup |
| +// names directly (e.g. "color: var(--rst-text); background: |
| +// var(--rst-bg)"). tokens.css declares no colour of its own; the values |
| +// under test all live in themes/<name>.css. |
| +// |
| +// Since the v2 themes each collapsed to one :root block, every token is |
| +// declared once as light-dark(<light>, <dark>) and this file splits that |
| +// call back into two per-scheme tables, so a theme is gated in light and |
| +// in dark from a single set of declarations. A token whose value is not |
| +// a light-dark() call (the font stack, the radii, a shadow whose two |
| +// schemes are identical) is carried into both tables unchanged. |
| +// |
| +// The WCAG arithmetic itself is not here: contrast ratios come from |
| +// ContrastRatio in colour.go, the same arithmetic rastrillo's colour |
| +// engine is built on, so this gate and the engine cannot drift apart. |
| +// |
| +// LIMITATION, stated explicitly because it is easy to forget: this checks |
| +// token pairs, not the resolved cascade. It parses each theme's :root |
| +// block and computes contrast between the hex values two custom |
| +// properties are *declared* as — it does not parse selectors, does not |
| +// know which element pairs which background in the actual DOM, and does |
| +// not see specificity, :hover, or any other cascade effect. A token can |
| +// pass every check here and still render at the wrong contrast once a |
| +// browser resolves the real cascade: tokens.css's own comment on |
| +// [rst-btn~="danger"]:hover documents exactly that happening — an earlier, |
| +// higher-specificity [rst-btn]:hover rule silently won the label colour |
| +// back to the accent (~1.05:1 on the red fill) at the exact moment a |
| +// user commits to a destructive action, and no token-level check would |
| +// ever have caught it, because both --rst-tone-negative-fg and |
| +// --rst-on-accent were, individually, still declared at a passing ratio |
| +// the whole time. That bug was only caught by reading the rendered |
| +// cascade by hand. Treat a green run of this file as "the palette is |
| +// internally consistent," never as "every rendered pixel passes." |
| + |
| +// colorMixSkip lists custom properties whose *value* uses a CSS function |
| +// this test cannot evaluate (color-mix(), a relative-colour syntax) — |
| +// computing those is out of scope, so they are named here, explicitly, |
| +// rather than silently skipped by a parse failure. light-dark() is no |
| +// longer such a function: splitLightDark below evaluates it, which is |
| +// the whole point of the v2 format. Empty today. If a theme grows a |
| +// value this parser cannot read, name it here with a comment instead of |
| +// weakening the parser to accept it silently. |
| +var colorMixSkip = map[string]bool{ |
| + // (none yet) |
| + // |
| + // --rst-header-rule is NOT listed here, and its absence is the |
| + // point: skipping a token implies it belongs in the pair table and |
| + // could not be evaluated. The header rule does not belong in the |
| + // table at all. day and signal derive it with color-mix(), which |
| + // this parser cannot read — and it is never asked to, because no |
| + // pair below names it. See the note above the pair table. |
| +} |
| + |
| +// declPattern matches one custom-property declaration: "--rst-name: |
| +// value;". Values never contain a semicolon in this file (no font stacks |
| +// with quoted commas-and-semicolons, no data URIs), so splitting on ';' |
| +// is safe here even though it would not be for arbitrary CSS. |
| +var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) |
| + |
| +// parseTokens extracts every --rst-* declaration in body into a map. Only |
| +// the last declaration of a given name wins, matching CSS's own "later |
| +// wins" rule for same-specificity declarations in one block — not that |
| +// a theme file redeclares a name twice in the same block today. |
| +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(<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 |
| +} |
| + |
| +// blockBody returns the brace-matched contents following header (which |
| +// must itself end in "{"), starting the search at css[from:]. Simple |
| +// depth counting is enough — no CSS string literal in a theme file |
| +// contains '{' or '}'. where names the file, so a structure failure says |
| +// which theme drifted. |
| +func blockBody(t *testing.T, where, css, header string, from int) string { |
| + t.Helper() |
| + i := strings.Index(css[from:], header) |
| + if i < 0 { |
| + t.Fatalf("%s structure changed: %q not found from offset %d", where, header, from) |
| + } |
| + start := from + i + len(header) |
| + depth := 1 |
| + for j := start; j < len(css); j++ { |
| + switch css[j] { |
| + case '{': |
| + depth++ |
| + case '}': |
| + depth-- |
| + if depth == 0 { |
| + return css[start:j] |
| + } |
| + } |
| + } |
| + t.Fatalf("%s structure changed: unterminated block for %q", where, header) |
| + return "" |
| +} |
| + |
| +// themeTokens reads the one :root block a v2 theme declares and returns |
| +// it twice: once resolved for the light scheme, once for dark. The |
| +// header ":root {" cannot match the two toggle rules at the bottom of the |
| +// file, whose selectors are ":root[data-theme=…] {". |
| +func themeTokens(t *testing.T, theme string) map[string]map[string]string { |
| + t.Helper() |
| + raw, ok := fiesta.ThemeCSS(theme) |
| + if !ok { |
| + t.Fatalf("fiesta.ThemeCSS(%q) missing", theme) |
| + } |
| + css := string(raw) |
| + where := "themes/" + theme + ".css" |
| + |
| + declared := parseTokens(blockBody(t, where, css, ":root {", 0)) |
| + if len(declared) == 0 { |
| + t.Fatalf("%s declares no --rst- properties in its :root block", where) |
| + } |
| + // day redeclares its hue-derived tokens inside an @supports block |
| + // for relative colour (derived_test.go). Those are what a current |
| + // engine paints, so they win here, as they do in the cascade. |
| + if body, _ := accentSupportsBlock(css); body != "" { |
| + for name, v := range parseTokens(body) { |
| + declared[name] = v |
| + } |
| + } |
| + |
| + light := make(map[string]string, len(declared)) |
| + dark := make(map[string]string, len(declared)) |
| + for name, v := range declared { |
| + if l, d, ok := splitLightDark(v); ok { |
| + light[name], dark[name] = l, d |
| + continue |
| + } |
| + light[name], dark[name] = v, v |
| + } |
| + return map[string]map[string]string{"light": light, "dark": dark} |
| +} |
| + |
| +// TestSplitLightDarkReadsTheV2Format pins the one piece of parsing the |
| +// whole gate rests on. A splitter that quietly stopped recognising |
| +// light-dark() would not fail anything — it would hand both schemes the |
| +// same unsplit string, every hex parse would fail, and the pair table |
| +// would report "add to colorMixSkip" instead of a contrast number. These |
| +// cases say what the format is, including the two shapes that must NOT |
| +// split: a shadow that merely contains a light-dark(), and a plain |
| +// single value. |
| +func TestSplitLightDarkReadsTheV2Format(t *testing.T) { |
| + for _, tt := range []struct { |
| + in string |
| + wantL, wantD string |
| + wantOK bool |
| + }{ |
| + {"light-dark(#ffffff, #111418)", "#ffffff", "#111418", true}, |
| + {"light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6))", "rgba(0, 0, 0, 0.45)", "rgba(0, 0, 0, 0.6)", true}, |
| + {"0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5))", "", "", false}, |
| + {"0 1px 2px rgba(0, 0, 0, 0.3)", "", "", false}, |
| + {"8px", "", "", false}, |
| + {"light-dark(#fff)", "", "", false}, |
| + {"light-dark(#fff, #000, #111)", "", "", false}, |
| + } { |
| + gotL, gotD, ok := splitLightDark(tt.in) |
| + if ok != tt.wantOK || gotL != tt.wantL || gotD != tt.wantD { |
| + t.Errorf("splitLightDark(%q) = (%q, %q, %v), want (%q, %q, %v)", |
| + tt.in, gotL, gotD, ok, tt.wantL, tt.wantD, tt.wantOK) |
| + } |
| + } |
| +} |
| + |
| +// TestContrastMathMatchesDocumentedDangerFillRatios calibrates the WCAG |
| +// arithmetic against numbers tokens.css's own comment already published |
| +// and hand-verified (the [rst-btn~="danger"] comment — the one colour |
| +// commentary that stayed with the component rule): --rst-on-accent on |
| +// --rst-tone-negative-fg, both schemes of the default theme. If this test |
| +// ever fails, suspect the formula before suspecting the published ratios. |
| +// |
| +// The arithmetic it calibrates is ui.ContrastRatio in colour.go — shipped |
| +// code, not a test helper. There is one implementation of the WCAG |
| +// formula in this package: this gate, the colour engine and any app |
| +// gating its own colours all measure with the same one, so these two |
| +// hand-verified ratios calibrate all three. |
| +func TestContrastMathMatchesDocumentedDangerFillRatios(t *testing.T) { |
| + for _, tt := range []struct { |
| + name string |
| + fg, bg string |
| + wantLow float64 // the comment's published value, rounded to 2dp; allow ±0.02 for rounding |
| + wantHigh float64 |
| + }{ |
| + {"light: on-accent on tone-negative-fg", "#ffffff", "#b91c1c", 6.45, 6.49}, |
| + {"dark: on-accent on tone-negative-fg", "#0b1220", "#f79aa0", 8.97, 9.01}, |
| + } { |
| + got, err := ContrastRatio(tt.fg, tt.bg) |
| + if err != nil { |
| + t.Fatalf("%s: %v", tt.name, err) |
| + } |
| + if got < tt.wantLow || got > tt.wantHigh { |
| + t.Errorf("%s: ContrastRatio(%s, %s) = %.2f, want in [%.2f, %.2f] (tokens.css's published ratio)", tt.name, tt.fg, tt.bg, got, tt.wantLow, tt.wantHigh) |
| + } |
| + } |
| +} |
| + |
| +// TestThemeTokenContrastMeetsWCAG is the real gate: the full pair table |
| +// each theme's own "WCAG 2.2 AA, measured" header comment documents, |
| +// enforced at each row's documented floor, in every shipped theme and in |
| +// BOTH schemes. See the file doc comment above for exactly what this does |
| +// and does not verify. |
| +// |
| +// --rst-text-faint is held to the same 4.5:1 body-text floor as |
| +// --rst-text/--rst-text-muted, not AA's lower 3:1 large-text/graphic |
| +// floor: this branch puts it on normal-size, normal-weight text |
| +// ([rst-count-line], [rst-field-hint]) and on [rst-lrow~="head"] (11.5px, |
| +// well under WCAG's ~18pt/14pt-bold "large text" threshold even with its |
| +// letter-spacing and uppercase transform), so 3:1 would be the wrong bar |
| +// for what this token is actually used for. |
| +func TestThemeTokenContrastMeetsWCAG(t *testing.T) { |
| + type pair struct { |
| + fg, bg string |
| + min float64 |
| + why string |
| + } |
| + // Transcribed from each theme's own "WCAG 2.2 AA, measured" header |
| + // table plus the [rst-btn~="danger"] comment's documented pair (not in |
| + // the main table, since it reuses --rst-tone-negative-fg as a solid |
| + // fill rather than declaring a dedicated --rst-danger-* token) — every |
| + // row those tables publish, at its documented floor. If a header |
| + // table grows a row, add it here too; the two are meant to stay in |
| + // lockstep. |
| + // |
| + // --rst-header-rule is deliberately absent. The page header's rule |
| + // is decorative and carries no contrast floor (design doc §6-v2.2): |
| + // the heading's size, weight and spacing carry the structure, the |
| + // rule does not, so 1.4.11's 3:1 was never its bar. signal tints it |
| + // with the accent at 45%, which on a light surface is under 3:1 by |
| + // construction; day and plain make it the line, which is a hairline |
| + // divider and carries no floor either. That is not a defect and |
| + // must not be "fixed" by adding a row here — the ruling says so in |
| + // writing precisely so this argument has already been answered. |
| + 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"}, |
| + // The stat band's delta, which is the first place in this |
| + // system where a tone colours TEXT on a plain card rather than |
| + // filling a pill behind it. The pill pairs above say nothing |
| + // about this one: --rst-tone-positive-fg was chosen to clear |
| + // 4.5:1 on --rst-tone-positive-bg, a tinted ground, and |
| + // --rst-surface is not that ground. |
| + // |
| + // It owes the full 4.5:1 even though the delta's sign already |
| + // carries the meaning and the colour is the second signal. |
| + // 1.4.1 is about meaning; 1.4.3 is about text, and this is |
| + // text. A reader who cannot resolve the hue still has to be |
| + // able to READ the number. |
| + {"--rst-tone-positive-fg", "--rst-surface", 4.5, "a stat's rising delta"}, |
| + {"--rst-tone-negative-fg", "--rst-surface", 4.5, "a stat's falling delta"}, |
| + // 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"}, |
| + // The depth axis's pairs (spec C3, C5, A2). Filled controls read |
| + // their own fill now, so the label pairs are these; the old |
| + // --rst-on-accent on --rst-accent rows stay for the places still |
| + // painted with the accent itself. |
| + {"--rst-on-accent-fill", "--rst-accent-fill", 4.5, "primary button label"}, |
| + {"--rst-on-accent-fill", "--rst-accent-fill-hover", 4.5, "primary button label, hover"}, |
| + {"--rst-on-danger-fill", "--rst-danger-fill", 4.5, "danger button label"}, |
| + {"--rst-on-danger-fill-hover", "--rst-danger-fill-hover", 4.5, "danger button label, hover"}, |
| + {"--rst-text", "--rst-row-tint-hover", 4.5, "a list-row-action's text, hovered"}, |
| + {"--rst-text", "--rst-row-tint-press", 4.5, "a list-row-action's text, pressed"}, |
| + {"--rst-control-text-hover", "--rst-control-face-hover", 4.5, "plain button label, hover"}, |
| + {"--rst-text-muted", "--rst-control-face-hover", 4.5, "a muted label on a hovered plain face"}, |
| + {"--rst-text", "--rst-tint-hover", 4.5, "row text on the hover tint"}, |
| + {"--rst-text", "--rst-tint-press", 4.5, "row text on the press tint"}, |
| + {"--rst-text-muted", "--rst-tint-hover", 4.5, "a row's muted cell on the hover tint"}, |
| + {"--rst-text-muted", "--rst-tint-press", 4.5, "a row's muted cell on the press tint"}, |
| + {"--rst-text-faint", "--rst-tint-hover", 4.5, "a row's faint cell on the hover tint"}, |
| + {"--rst-text-faint", "--rst-tint-press", 4.5, "a row's faint cell on the press tint"}, |
| + {"--rst-accent", "--rst-tint-hover", 4.5, "a link on a hovered row"}, |
| + {"--rst-nav-current-fg", "--rst-nav-current-bg", 4.5, "the current nav item"}, |
| + {"--rst-track-off", "--rst-surface", 3.0, "switch off track (1.4.11 boundary)"}, |
| + {"--rst-track-off", "--rst-bg", 3.0, "switch off track on the page"}, |
| + {"--rst-knob-shade", "--rst-track-off", 3.0, "switch knob on the off track"}, |
| + {"--rst-knob-shade", "--rst-accent-fill", 3.0, "switch knob on the on track"}, |
| + } |
| + |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, scheme := range []string{"light", "dark"} { |
| + // Resolved, not declared: day derives its fills, tints and |
| + // soft ground from the accent (derived_test.go), and a pair |
| + // that names one of those is gated on what the derivation |
| + // lands on, held to Chromium's pixels by the browser suite. |
| + tokens := resolvedTokens(t, theme)[scheme] |
| + t.Run(theme+"/"+scheme, func(t *testing.T) { |
| + for _, p := range pairs { |
| + if colorMixSkip[p.fg] || colorMixSkip[p.bg] { |
| + t.Logf("skipping %s on %s: listed in colorMixSkip", p.fg, p.bg) |
| + continue |
| + } |
| + fgVal, ok := tokens[p.fg] |
| + if !ok { |
| + t.Errorf("token %s is not declared in this theme", p.fg) |
| + continue |
| + } |
| + bgVal, ok := tokens[p.bg] |
| + if !ok { |
| + t.Errorf("token %s is not declared in this theme", p.bg) |
| + continue |
| + } |
| + ratio, err := ContrastRatio(fgVal, bgVal) |
| + if err != nil { |
| + t.Errorf("%s (%s) on %s (%s): %v — add to colorMixSkip if this is a value the parser cannot evaluate", p.fg, fgVal, p.bg, bgVal, err) |
| + continue |
| + } |
| + // Logged in every run, so `go test -v -run |
| + // TestThemeTokenContrastMeetsWCAG` prints the whole |
| + // table a theme's header comment copies: the rows |
| + // there are transcribed from here, never computed |
| + // by hand. |
| + t.Logf("%s on %s = %.2f", p.fg, p.bg, ratio) |
| + if ratio < p.min { |
| + t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fgVal, p.bg, bgVal, ratio, p.min, p.why) |
| + } |
| + } |
| + }) |
| + } |
| + } |
| +} |
| + |
| +// stageArtMix reads the colour tokens.css gives one part of the stage |
| +// art — color-mix(in srgb, var(A) N%, var(B)) — so the check below |
| +// measures what the stylesheet actually says rather than a copy of it. |
| +func stageArtMix(t *testing.T, part string) (a string, p float64, b string) { |
| + t.Helper() |
| + re := regexp.MustCompile(`\[rst-stage-art-` + part + `\] \{[^}]*(?:fill|stroke): color-mix\(in srgb, var\((--rst-[a-z-]+)\) (\d+)%, var\((--rst-[a-z-]+)\)\)`) |
| + m := re.FindStringSubmatch(string(fiesta.TokensCSS())) |
| + if m == nil { |
| + t.Fatalf("tokens.css paints [rst-stage-art-%s] with no color-mix(in srgb, var(--rst-…) N%%, var(--rst-…))", part) |
| + } |
| + n, _ := strconv.Atoi(m[2]) |
| + return m[1], float64(n) / 100, m[3] |
| +} |
| + |
| +// mixSRGB is CSS color-mix(in srgb, a p, b): a per-channel linear blend |
| +// of the two sRGB values. |
| +func mixSRGB(t *testing.T, a, b string, p float64) string { |
| + t.Helper() |
| + ar, ag, ab, err := parseHex(a) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + br, bg, bb, err := parseHex(b) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + mix := func(x, y uint8) uint8 { return uint8(math.Round(float64(x)*p + float64(y)*(1-p))) } |
| + return hexOf([3]uint8{mix(ar, br), mix(ag, bg), mix(ab, bb)}) |
| +} |
| + |
| +// The sign-in card's border is its boundary against the stage art (WCAG |
| +// 1.4.11). The art is drawn by mixing accent and line into the page |
| +// background, so the border must clear 3:1 against each colour the art |
| +// paints, in every theme and scheme — not only against --rst-bg, which |
| +// is all the token-pair gate above can see. Each colour is measured at |
| +// full strength, which the glow only reaches where its translucent |
| +// circles pile up: the worst case, never better than it. If this fails, |
| +// lower the art's percentage in tokens.css; never the floor. |
| +func TestTheSigninCardStandsOutFromTheStageArt(t *testing.T) { |
| + // What follows measures --rst-line-strong. Hold the card to it, or a |
| + // later edit to the card's border leaves this passing about a colour |
| + // nothing paints. |
| + if !regexp.MustCompile(`\[rst-signin\] \{[^}]*border: 1px solid var\(--rst-line-strong\)`).Match(fiesta.TokensCSS()) { |
| + t.Fatal("tokens.css no longer borders [rst-signin] with 1px solid var(--rst-line-strong), the colour this test measures") |
| + } |
| + for _, theme := range fiesta.ThemeNames() { |
| + for _, scheme := range []string{"light", "dark"} { |
| + tok := themeTokens(t, theme)[scheme] |
| + for _, part := range []string{"lines", "glow"} { |
| + a, p, b := stageArtMix(t, part) |
| + behind := mixSRGB(t, tok[a], tok[b], p) |
| + ratio, err := ContrastRatio(tok["--rst-line-strong"], behind) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + if ratio < 3.0 { |
| + t.Errorf("%s/%s: the card border on the art's %s is %.2f:1, want >= 3:1", theme, scheme, part, ratio) |
| + } |
| + } |
| + } |
| + } |
| +} |
diff --git a/check/coverage_test.go b/check/coverage_test.go| new file mode 100644 |
| index 0000000..4407160 |
| --- /dev/null |
| +++ b/check/coverage_test.go |
| @@ -0,0 +1,389 @@ |
| +package check |
| + |
| +import ( |
| + "io/fs" |
| + "regexp" |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| + "amadan.net/rastrillo/fiesta/internal/markup" |
| +) |
| + |
| +// The examples and the stylesheets, held to each other in both |
| +// directions. Every rst- name an example writes has a selector, so a |
| +// state in the gallery cannot carry an attribute nothing styles; and |
| +// every rst- name the stylesheets style has an example, so a rule |
| +// cannot arrive with nothing rendering it. The vocabulary readers |
| +// (rstVocabulary, tokensStyle) are from rastrillo's ui/ui_test.go, |
| +// where the two checks ran over the styleguide samples and the |
| +// layouts; here they run over every example state. |
| + |
| +// ── Reading the examples ───────────────────────────────────────────── |
| + |
| +// exampleState is one state's markup with its address. For a layout |
| +// the markup is the whole document: a shell's vocabulary starts on |
| +// <body> (the stage shell writes <body rst-stage>), which the body's |
| +// contents alone would miss. |
| +type exampleState struct{ Name, State, Markup string } |
| + |
| +// everyState reads every state of every example: the non-document |
| +// states through example.All, and each document entry's states whole, |
| +// through example.Document. It fails for a set of a hundred states or |
| +// fewer: an empty or truncated examples.json would otherwise pass both |
| +// coverage gates by giving them nothing to check. |
| +func everyState(t *testing.T, fsys fs.FS) []exampleState { |
| + t.Helper() |
| + m, err := example.Load(fsys) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pieces, err := example.All(fsys) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var out []exampleState |
| + for _, p := range pieces { |
| + out = append(out, exampleState{p.Name, p.State, p.Markup}) |
| + } |
| + for _, e := range m.Examples { |
| + if !e.Document { |
| + continue |
| + } |
| + for _, s := range e.States { |
| + doc, err := example.Document(fsys, e.Name, s.ID) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + out = append(out, exampleState{e.Name, s.ID, doc}) |
| + } |
| + } |
| + if len(out) <= 100 { |
| + t.Fatalf("read %d example states, want more than 100; a set this small is a broken manifest, not a design system", len(out)) |
| + } |
| + return out |
| +} |
| + |
| +// defaultMarkup reads the state examples.json names as the default of |
| +// the example called name: the whole document for a layout, the body |
| +// for anything else. |
| +func defaultMarkup(t *testing.T, name string) string { |
| + t.Helper() |
| + m, err := example.Load(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + e, ok := m.Entry(name) |
| + if !ok { |
| + t.Fatalf("examples.json has no example %q", name) |
| + } |
| + state, _ := m.Default(name) |
| + read := example.Markup |
| + if e.Document { |
| + read = example.Document |
| + } |
| + out, err := read(fiesta.FS(), name, state) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return out |
| +} |
| + |
| +// ── The vocabulary ─────────────────────────────────────────────────── |
| + |
| +// rstVocabulary is every rst- attribute a piece of markup writes, as |
| +// the selector tokens.css would need for it: "rst-box" for a kind or a |
| +// part, and "rst-lrow~=head" for a variant, one entry per variant in |
| +// the value. The utility classes are a separate vocabulary, read by |
| +// rstClasses. |
| +func rstVocabulary(markup string) map[string]bool { |
| + seen := map[string]bool{} |
| + for _, tag := range openTagInFixture.FindAllString(markup, -1) { |
| + // The class attribute still carries the seven utilities, and |
| + // their names look exactly like an attribute's once the quotes |
| + // are ignored. Take it out of the tag before reading. |
| + tag = classInMarkup.ReplaceAllString(tag, "") |
| + for _, m := range rstAttrInFixture.FindAllStringSubmatch(tag, -1) { |
| + seen[m[1]] = true |
| + for _, variant := range strings.Fields(m[2]) { |
| + seen[m[1]+"~="+variant] = true |
| + } |
| + } |
| + } |
| + return seen |
| +} |
| + |
| +// rstClasses is every rst- token in a class attribute of markup. |
| +func rstClasses(markup string) map[string]bool { |
| + seen := map[string]bool{} |
| + for _, m := range classInMarkup.FindAllStringSubmatch(markup, -1) { |
| + for _, class := range strings.Fields(m[1]) { |
| + if strings.HasPrefix(class, "rst-") { |
| + seen[class] = true |
| + } |
| + } |
| + } |
| + return seen |
| +} |
| + |
| +// qualifiedOnly are the attributes that never appear alone in a |
| +// selector, because they mean nothing without the kind they modify. |
| +// rst-tone is the tone OF a status, a badge or a stat, and every rule |
| +// that reads it names both, [rst-status][rst-tone~="positive"], so a |
| +// bare [rst-tone] would be a selector for a concept the grammar does |
| +// not have. |
| +// |
| +// Only the bare name is exempted. The variants an example writes are |
| +// still required to exist, so rst-tone="lavender" fails here exactly |
| +// as it should. |
| +var qualifiedOnly = map[string]bool{"rst-tone": true} |
| + |
| +// unstyledByDesign are the entries an example writes that the |
| +// stylesheets name no selector for, on purpose, each with the reason. |
| +// Nothing else may be added here: an attribute an example writes and |
| +// nothing styles is a component that renders as the plain element. |
| +var unstyledByDesign = map[string]string{ |
| + "rst-job": "a semantic hook, never a style: the busy shim finds the job by data-poll, and an app keys its own CSS off it", |
| + "rst-tone~=info": "the callout's default tone: the base [rst-callout] rule paints it, and only the three others carry a tone rule", |
| +} |
| + |
| +// tokensStyle reports whether css carries a selector for one entry of |
| +// that vocabulary. |
| +func tokensStyle(css, name string) bool { |
| + if kind, variant, ok := strings.Cut(name, "~="); ok { |
| + return strings.Contains(css, "["+kind+"~=\""+variant+"\"]") |
| + } |
| + return strings.Contains(css, "["+name+"]") |
| +} |
| + |
| +// styledCSS is tokens.css and shell.css together, comments out, so a |
| +// name mentioned in prose does not count as styled. |
| +func styledCSS() string { |
| + return stripCSSComments(string(fiesta.TokensCSS())) + "\n" + stripCSSComments(string(fiesta.ShellCSS())) |
| +} |
| + |
| +// attrInSelector finds one rst- attribute selector, bare or with a |
| +// variant, wherever it sits in a selector. |
| +var attrInSelector = regexp.MustCompile(`\[(rst-[a-z0-9-]+)(?:~="([^"]+)")?\]`) |
| + |
| +// styledVocabulary is every entry the two stylesheets' selectors name: |
| +// each attribute, each attribute-and-variant, and each utility class. |
| +// The other classes are not entries of their own: every one is the |
| +// twin of an attribute selector in the same rule (spelling_test.go), |
| +// so the attribute's example is its example. |
| +func styledVocabulary() map[string]bool { |
| + out := map[string]bool{} |
| + for _, css := range []string{string(fiesta.TokensCSS()), string(fiesta.ShellCSS())} { |
| + for _, rule := range parseCSSRules(css) { |
| + for _, sel := range rule.selectors { |
| + for _, m := range attrInSelector.FindAllStringSubmatch(sel, -1) { |
| + out[m[1]] = true |
| + if m[2] != "" { |
| + out[m[1]+"~="+m[2]] = true |
| + } |
| + } |
| + for _, m := range classInSelector.FindAllStringSubmatch(sel, -1) { |
| + if _, utility := markup.Utilities[m[1]]; utility { |
| + out["."+m[1]] = true |
| + } |
| + } |
| + } |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// ── The gates ──────────────────────────────────────────────────────── |
| + |
| +// TestEveryExampleAttributeAndClassIsStyled: every rst- attribute name, |
| +// every token of an rst- attribute's value, and every rst- class in |
| +// any example state has a selector in tokens.css or shell.css. An |
| +// example that wrote an attribute nothing styles would render as the |
| +// plain element and look, in the gallery, like the design system's |
| +// idea of that component. |
| +func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) { |
| + css := styledCSS() |
| + for _, s := range everyState(t, fiesta.FS()) { |
| + names := make([]string, 0) |
| + for name := range rstVocabulary(s.Markup) { |
| + if _, unstyled := unstyledByDesign[name]; unstyled || qualifiedOnly[name] { |
| + continue |
| + } |
| + if !tokensStyle(css, name) { |
| + names = append(names, name) |
| + } |
| + } |
| + for class := range rstClasses(s.Markup) { |
| + if !strings.Contains(css, "."+class) { |
| + names = append(names, "."+class) |
| + } |
| + } |
| + sort.Strings(names) |
| + for _, name := range names { |
| + t.Errorf("example %s/%s writes %s, which neither tokens.css nor shell.css has a selector for", s.Name, s.State, name) |
| + } |
| + } |
| +} |
| + |
| +// selectorsWithoutAnExample is every entry of the stylesheets' |
| +// vocabulary that no example state writes, each with the reason it |
| +// cannot. Seeded from the gate's first run and reviewed line by line: |
| +// an entry whose reason stops being true is removed, and an entry that |
| +// gains an example fails the gate until it is removed. |
| +var selectorsWithoutAnExample = map[string]string{ |
| + // Drawn by a script at run time, on a page that loads it: an |
| + // example holds the field the script enhances, never the markup it |
| + // draws, so these states are only reachable by the script. |
| + "rst-cal": "drawn by calendar.js at run time, inside a data-rst-date field's picker", |
| + "rst-cal-day": "drawn by calendar.js at run time: one day of the grid", |
| + "rst-cal-grid": "drawn by calendar.js at run time: the grid of days", |
| + "rst-cal-head": "drawn by calendar.js at run time: the weekday head row", |
| + "rst-cal-nav": "drawn by calendar.js at run time: the month-step buttons", |
| + "rst-cal-nav~=next": "drawn by calendar.js at run time: the forward month-step button", |
| + "rst-cal-title": "drawn by calendar.js at run time: the month's name", |
| + "rst-combo": "drawn by select.js at run time, around a data-rst-select field", |
| + "rst-combo-desc": "drawn by select.js at run time: an option's description line", |
| + "rst-combo-empty": "drawn by select.js at run time: the no-match row", |
| + "rst-combo-has-lead": "drawn by select.js at run time: a list whose options carry a lead", |
| + "rst-combo-lead": "drawn by select.js at run time: an option's lead (an avatar or a swatch)", |
| + "rst-combo-list": "drawn by select.js at run time: the popup list", |
| + "rst-combo-option": "drawn by select.js at run time: one option row", |
| + "rst-combo-rows": "drawn by select.js at run time: the scrolling rows inside the list", |
| + "rst-combo-sep": "drawn by select.js at run time: the rule between groups", |
| + "rst-combo-up": "drawn by select.js at run time: the list flipped above the field", |
| + "rst-select-group": "drawn by select.js at run time: an optgroup's heading in the list", |
| + "rst-dtp": "drawn by datetime.js at run time, around a data-rst-date field", |
| + "rst-dtp-hint": "drawn by datetime.js at run time: the parsed-date hint under the field", |
| + "rst-dtp-input": "drawn by datetime.js at run time: the text input it puts in place of the native one", |
| + "rst-dtp-label": "drawn by datetime.js at run time: a quick-pick row's label", |
| + "rst-dtp-list": "drawn by datetime.js at run time: the quick-pick list", |
| + "rst-dtp-pick": "drawn by datetime.js at run time: the button that opens the calendar", |
| + "rst-dtp-quick": "drawn by datetime.js at run time: the quick-pick panel", |
| + "rst-dtp-row": "drawn by datetime.js at run time: one quick-pick row", |
| + "rst-dtp-row~=alt": "drawn by datetime.js at run time: a quick-pick row's alternate shade", |
| + "rst-dtp-row~=set": "drawn by datetime.js at run time: the quick-pick row that is the field's value", |
| + "rst-dtp-set": "drawn by datetime.js at run time: the chosen value's readout", |
| + // Markup a layout or a partial writes only in a state the examples |
| + // do not hold. |
| + "rst-pagination-gap~=narrow": "the ellipsis a phone shows where a run of pages it hides carries no gap of its own; the long-list state puts a gap in every such run, so the partial never adds one", |
| + "rst-person-av~=placeholder": "the empty avatar a layout draws for an app that supplies no profile block; every layout example supplies one", |
| + // Retired, and kept for an app still on it. |
| + "rst-shell-chrome": "the retired sidebar drawer: no layout writes it, and tokens.css keeps its rules for an app that still does (see the comment above them)", |
| +} |
| + |
| +// TestEverySelectorAppearsInAnExample is the other direction: every |
| +// attribute, attribute-and-variant and utility class the stylesheets |
| +// style is written by some example state, or is listed above with the |
| +// reason it is not. A rule that arrives with nothing rendering it |
| +// fails here by name. |
| +func TestEverySelectorAppearsInAnExample(t *testing.T) { |
| + used := map[string]bool{} |
| + for _, s := range everyState(t, fiesta.FS()) { |
| + for name := range rstVocabulary(s.Markup) { |
| + used[name] = true |
| + } |
| + for class := range rstClasses(s.Markup) { |
| + used["."+class] = true |
| + } |
| + } |
| + // The two stylesheets name some 230 attributes, variants and |
| + // utilities between them; far fewer is a reader that stopped |
| + // reading, not a design system that shrank. |
| + styled := styledVocabulary() |
| + if len(styled) < 200 { |
| + t.Fatalf("read %d styled names from the stylesheets; the reader is broken, not the files", len(styled)) |
| + } |
| + var missing []string |
| + for name := range styled { |
| + if used[name] || qualifiedOnly[name] { |
| + continue |
| + } |
| + if why, listed := selectorsWithoutAnExample[name]; listed { |
| + if strings.TrimSpace(why) == "" { |
| + t.Errorf("selectorsWithoutAnExample lists %s with no reason", name) |
| + } |
| + continue |
| + } |
| + missing = append(missing, name) |
| + } |
| + sort.Strings(missing) |
| + for _, name := range missing { |
| + t.Errorf("the stylesheets style %s and no example writes it; add a state, or list it in selectorsWithoutAnExample with the reason", name) |
| + } |
| + for name := range selectorsWithoutAnExample { |
| + switch { |
| + case !styled[name]: |
| + t.Errorf("selectorsWithoutAnExample lists %s, which no stylesheet styles any more; remove it", name) |
| + case used[name]: |
| + t.Errorf("selectorsWithoutAnExample lists %s, and an example writes it now; remove the entry", name) |
| + } |
| + } |
| +} |
| + |
| +// The search form sits inside a <search> landmark, the element HTML |
| +// added for exactly this, and the toolbar strip sizes the landmark |
| +// rather than the form it wraps: the direct-child selector that used |
| +// to reach the form reaches the landmark instead. |
| +func TestTheSearchFormSitsInASearchLandmark(t *testing.T) { |
| + got := defaultMarkup(t, "list-bar-search") |
| + if !strings.Contains(got, "<search><form") || !strings.Contains(got, "</form></search>") { |
| + t.Errorf("the search form is not wrapped in a <search> landmark: %s", got) |
| + } |
| + css := string(fiesta.TokensCSS()) |
| + for _, want := range []string{"[rst-lbar] > search", "[rst-list] > search"} { |
| + if !strings.Contains(css, want) { |
| + t.Errorf("tokens.css has no %q rule; wrapping the form broke the layout it used to size", want) |
| + } |
| + } |
| +} |
| + |
| +// The sidebar rail's foot is one profile menu: an avatar button that |
| +// opens a card holding the person, the language switcher and the |
| +// account's links, in that order, in a wrapper the layout owns. It is |
| +// a menu rather than two blocks laid out flat because on a phone the |
| +// same control sits at the top of the index, and the foot of a long |
| +// index is out of reach. The geometry is measured by the profile |
| +// drive; this pins the markup it needs, read off the sidebar layout's |
| +// default document. |
| +func TestTheSidebarRailFootIsOneProfileMenu(t *testing.T) { |
| + s := defaultMarkup(t, "layout-sidebar") |
| + foot := strings.Index(s, `<div rst-shell-rail-foot>`) |
| + if foot < 0 { |
| + t.Fatal("the sidebar layout has no rail foot; the profile has nothing to sit at the bottom of") |
| + } |
| + nav := strings.Index(s, `<nav rst-shell-nav>`) |
| + if nav < 0 || nav > foot { |
| + t.Error("the rail's nav does not come before its foot: focus order on a desktop must match the rail, top to bottom") |
| + } |
| + // rst-shell-menu, NOT rst-menus: <details name> exclusivity is |
| + // document-wide, and the language menu inside the card is in |
| + // rst-menus, so sharing that group would close the card the moment |
| + // the language menu opened. |
| + const open = `<div rst-shell-rail-foot><details rst-dropdown rst-shell-profile name="rst-shell-menu"><summary><span rst-shell-avatar>` |
| + if !strings.HasPrefix(s[foot:], open) { |
| + t.Errorf("the rail foot does not open with the profile menu and its avatar summary; it reads %.200q", s[foot:]) |
| + } |
| + end := strings.Index(s[foot:], `</details></div>`) |
| + if end < 0 { |
| + t.Fatal("the rail foot never closes its profile menu") |
| + } |
| + card := s[foot : foot+end] |
| + last := -1 |
| + for _, part := range []string{`<div rst-dropdown-menu><div rst-shell-who>`, `<details rst-dropdown rst-locale name="rst-menus">`, `<a href="#">Profile</a>`, `<hr><a href="#">Sign out</a>`} { |
| + at := strings.Index(card, part) |
| + if at < 0 { |
| + t.Errorf("the profile card has no %s", part) |
| + continue |
| + } |
| + if at < last { |
| + t.Errorf("the profile card holds %s out of order: the person, then the language, then the account's links", part) |
| + } |
| + last = at |
| + } |
| + css := string(fiesta.TokensCSS()) |
| + if !strings.Contains(css, `[rst-shell-rail-foot]:has([rst-shell-who]:only-child [rst-person-av~="placeholder"])`) { |
| + t.Error("tokens.css does not hide the placeholder profile with nothing to choose: an app with no profile, no account and no language would get a button opening an empty card") |
| + } |
| +} |
diff --git a/check/css_test.go b/check/css_test.go| new file mode 100644 |
| index 0000000..684fba3 |
| --- /dev/null |
| +++ b/check/css_test.go |
| @@ -0,0 +1,1170 @@ |
| +package check |
| + |
| +import ( |
| + "fmt" |
| + "reflect" |
| + "regexp" |
| + "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 SEED: 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). The CSS readers they share (braceMatchEnd, |
| +// leafRules, selectorList, blockBody) live here and in |
| +// contrast_test.go; 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") |
| + } |
| + for _, want := range []string{ |
| + "--rst-sp-4", "--rst-fs-base", |
| + "var(--rst-bg)", "var(--rst-text)", "var(--rst-accent)", "var(--rst-font)", |
| + "var(--rst-radius)", "var(--rst-shadow-pop)", |
| + "[rst-status]", ".rst-sr-only", |
| + } { |
| + if !strings.Contains(string(css), want) { |
| + t.Errorf("tokens.css is missing %q", want) |
| + } |
| + } |
| + // 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. |
| +func shapeLeaks(css string) ([]string, error) { |
| + const flatten = "--rst-shadow-knob: 0 0 0 transparent; --rst-shadow-knob-hover: 0 0 0 transparent;" |
| + if n := strings.Count(css, flatten); n != 1 { |
| + return nil, fmt.Errorf("tokens.css carries print's knob-shadow flatten %d times, want once, at html:root inside @media print", n) |
| + } |
| + // Every print block (the stage art has a small one of its own before |
| + // the big one): the flatten must sit in an html:root rule of one. |
| + const header = "@media print {" |
| + atRoot := false |
| + for idx := 0; ; { |
| + i := strings.Index(css[idx:], header) |
| + if i < 0 { |
| + break |
| + } |
| + start := idx + i + len(header) |
| + end := braceMatchEnd(css, start) |
| + for _, r := range leafRules(css[start:end]) { |
| + if r.selector == "html:root" && strings.Contains(r.body, flatten) { |
| + atRoot = true |
| + } |
| + } |
| + idx = end + 1 |
| + } |
| + if !atRoot { |
| + return nil, fmt.Errorf("print's knob-shadow flatten is not at html:root inside @media print, the one place it is allowed") |
| + } |
| + structural := strings.Replace(css, flatten, "", 1) |
| + var out []string |
| + for _, gone := range []string{ |
| + "prefers-color-scheme: dark", `:root[data-theme="dark"]`, `:root[data-theme="light"]`, |
| + "--rst-radius:", "--rst-radius-sm:", "--rst-radius-pill:", |
| + "--rst-shadow-pop:", "--rst-shadow-knob:", "--rst-shadow-lift:", "--rst-overlay:", |
| + } { |
| + if strings.Contains(structural, gone) { |
| + out = append(out, gone) |
| + } |
| + } |
| + 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) |
| + } |
| + for _, want := range []string{ |
| + "--rst-bg", "--rst-surface", "--rst-text", "--rst-accent", |
| + "--rst-tone-positive-fg", "--rst-font", "--rst-radius", "--rst-shadow-pop", |
| + "color-scheme: light dark", "light-dark(", |
| + `:root[data-theme="dark"] { color-scheme: dark; }`, |
| + `:root[data-theme="light"] { color-scheme: light; }`, |
| + } { |
| + if !strings.Contains(string(css), want) { |
| + t.Errorf("themes/%s.css is missing %q", name, want) |
| + } |
| + } |
| + // A theme restates nothing per scheme but numbers (A4): |
| + // 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. |
| + 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. |
| + _, outside := accentSupportsBlock(string(css)) |
| + if got := strings.Count(outside, ":root {"); got != 1 { |
| + t.Errorf("themes/%s.css opens %d bare :root blocks, want exactly 1", name, got) |
| + } |
| + for _, prop := range []string{"--rst-bg:", "--rst-text:", "--rst-accent:", "--rst-radius:"} { |
| + if got := strings.Count(string(css), prop); got != 1 { |
| + t.Errorf("themes/%s.css declares %s %d times, want exactly 1 (one block, both schemes)", name, 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. |
| + supports, css := accentSupportsBlock(string(raw)) |
| + decl := regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`) |
| + values := map[string]string{} |
| + for _, m := range decl.FindAllStringSubmatch(css, -1) { |
| + values[m[1]] = strings.TrimSpace(m[2]) |
| + } |
| + for _, prop := range declaredOnce { |
| + // Declarations are "<prop>: value"; uses are "var(<prop>)", so the |
| + // trailing colon counts declarations only. |
| + if got := strings.Count(css, prop+":"); got != 1 { |
| + t.Errorf("themes/%s.css declares %s %d times, want exactly 1", name, prop, got) |
| + } |
| + if got := strings.Count(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(<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{} |
| + |
| +// braceMatchEnd returns the index of the '}' matching the '{' implicitly |
| +// opened at css[start-1] (i.e. depth starts at 1) — tokens.css nests at |
| +// most one level (a media query wrapping plain rules), and no selector or |
| +// value in this file contains a literal brace, so simple depth counting |
| +// is exact here. |
| +func braceMatchEnd(css string, start int) int { |
| + depth := 1 |
| + for j := start; j < len(css); j++ { |
| + switch css[j] { |
| + case '{': |
| + depth++ |
| + case '}': |
| + depth-- |
| + if depth == 0 { |
| + return j |
| + } |
| + } |
| + } |
| + return len(css) |
| +} |
| + |
| +// reducedMotionBlocks returns the raw contents of every |
| +// @media (prefers-reduced-motion: reduce) { ... } block in css, and rest |
| +// = css with those blocks (header, braces and all) removed — so a |
| +// transition-selector search over rest never mistakes a reduce block's |
| +// own "transition: none" for an active, undisabled transition. |
| +func reducedMotionBlocks(css string) (blocks []string, rest string) { |
| + const header = "@media (prefers-reduced-motion: reduce) {" |
| + var b strings.Builder |
| + last, idx := 0, 0 |
| + for { |
| + i := strings.Index(css[idx:], header) |
| + if i < 0 { |
| + break |
| + } |
| + blockStart := idx + i |
| + contentStart := blockStart + len(header) |
| + end := braceMatchEnd(css, contentStart) |
| + blocks = append(blocks, css[contentStart:end]) |
| + b.WriteString(css[last:blockStart]) |
| + last = end + 1 |
| + idx = end + 1 |
| + } |
| + b.WriteString(css[last:]) |
| + return blocks, b.String() |
| +} |
| + |
| +// leafRule is one selector plus its (brace-matched, non-nested) |
| +// declaration body. |
| +type leafRule struct{ selector, body string } |
| + |
| +// leafRulePattern's selector group ([^{}]+) is greedy over everything |
| +// since the previous rule's closing brace, which includes any comment |
| +// sitting between the two rules — for the very first rule in the file, |
| +// that is tokens.css's entire, ~100-line file-header comment. blockCommentPattern |
| +// strips /* ... */ spans (across lines: (?s) makes '.' match '\n') out of |
| +// a raw selector capture before it is trimmed and used as a map key or |
| +// error-message value, so neither is unusably long or noisy. |
| +var blockCommentPattern = regexp.MustCompile(`(?s)/\*.*?\*/`) |
| + |
| +// leafRulePattern extracts every innermost selector{...} rule in css — |
| +// this naturally includes rules nested inside an @media block (matched |
| +// before the wrapping @media's own, still-open brace), since none of |
| +// them nest further themselves. |
| +var leafRulePattern = regexp.MustCompile(`([^{}]+)\{([^{}]*)\}`) |
| + |
| +func leafRules(css string) []leafRule { |
| + var out []leafRule |
| + for _, m := range leafRulePattern.FindAllStringSubmatch(css, -1) { |
| + sel := blockCommentPattern.ReplaceAllString(m[1], "") |
| + // Fields+Join collapses any remaining internal whitespace/newlines |
| + // left by a stripped multi-line comment down to single spaces, on |
| + // top of trimming the ends. |
| + sel = strings.Join(strings.Fields(sel), " ") |
| + out = append(out, leafRule{selector: sel, body: m[2]}) |
| + } |
| + return out |
| +} |
| + |
| +// selectorList splits a (possibly comma-separated) selector into its |
| +// individual, trimmed selectors — ".a, .a::after" becomes [".a", |
| +// ".a::after"], each compared for exact equality elsewhere in this file, |
| +// never by substring: a substring check would let a new, unguarded |
| +// "[rst-tip]" rule pass by riding on "[rst-tip]::after" already being |
| +// listed in a reduce block, which is exactly the false negative this |
| +// gate exists to catch. |
| +func selectorList(sel string) []string { |
| + var out []string |
| + for _, s := range strings.Split(sel, ",") { |
| + if s = strings.TrimSpace(s); s != "" { |
| + out = append(out, s) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// motionDecl is one transition/animation declaration: the CSS property |
| +// name and its (trimmed) value. |
| +type motionDecl struct{ prop, val string } |
| + |
| +// motionPropertyPattern finds every transition/animation declaration in |
| +// a rule body and captures its value, so "transition: none" (the disable |
| +// declaration itself) can be told apart from an actual animated |
| +// property, and so a rule declaring both properties is not silently |
| +// reduced to only the first FindAll would otherwise stop at. |
| +var motionPropertyPattern = regexp.MustCompile(`(?:^|;)\s*(transition|animation)\s*:\s*([^;]+)`) |
| + |
| +func motionDecls(body string) []motionDecl { |
| + var out []motionDecl |
| + for _, m := range motionPropertyPattern.FindAllStringSubmatch(body, -1) { |
| + out = append(out, motionDecl{prop: m[1], val: strings.TrimSpace(m[2])}) |
| + } |
| + return out |
| +} |
| + |
| +// reduceIndex maps an exact selector to the set of properties some |
| +// prefers-reduced-motion: reduce rule disables (declares "none") for |
| +// that exact selector. |
| +type reduceIndex map[string]map[string]bool |
| + |
| +func buildReduceIndex(blocks []string) reduceIndex { |
| + idx := reduceIndex{} |
| + for _, block := range blocks { |
| + for _, rule := range leafRules(block) { |
| + for _, sel := range selectorList(rule.selector) { |
| + for _, d := range motionDecls(rule.body) { |
| + if d.val != "none" { |
| + continue |
| + } |
| + if idx[sel] == nil { |
| + idx[sel] = map[string]bool{} |
| + } |
| + idx[sel][d.prop] = true |
| + } |
| + } |
| + } |
| + } |
| + return idx |
| +} |
| + |
| +// TestReducedMotionDisablesEveryTransition is the motion gate (task 5): |
| +// 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, |
| +// comma-split selector match in selectorList/buildReduceIndex rather than |
| +// a substring check. |
| +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())) |
| +} |
| + |
| +func assertReducedMotion(t *testing.T, name, css string) { |
| + t.Helper() |
| + reduceBlocks, rest := reducedMotionBlocks(css) |
| + if len(reduceBlocks) == 0 { |
| + t.Fatal(name + " declares no @media (prefers-reduced-motion: reduce) block at all") |
| + } |
| + idx := buildReduceIndex(reduceBlocks) |
| + |
| + tested := 0 |
| + for _, rule := range leafRules(rest) { |
| + decls := motionDecls(rule.body) |
| + if len(decls) == 0 { |
| + continue |
| + } |
| + for _, sel := range selectorList(rule.selector) { |
| + for _, d := range decls { |
| + if d.val == "none" { |
| + // Already disabled unconditionally; nothing to gate. |
| + continue |
| + } |
| + tested++ |
| + if reducedMotionAllowlist[sel] { |
| + continue |
| + } |
| + if !idx[sel][d.prop] { |
| + t.Errorf("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)", |
| + sel, d.prop, d.val, d.prop+": none", sel) |
| + } |
| + } |
| + } |
| + } |
| + 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") |
| + } |
| +} |
| + |
| +// 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) { |
| + decl := regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`) |
| + ref := regexp.MustCompile(`var\((--rst-[a-z0-9-]+)\s*\)`) |
| + // Comments out first: a "--rst-x:" in prose would otherwise count as |
| + // a declaration, and these files are mostly prose. |
| + comment := regexp.MustCompile(`(?s)/\*.*?\*/`) |
| + strip := func(b []byte) string { return comment.ReplaceAllString(string(b), "") } |
| + tokens := strip(fiesta.TokensCSS()) |
| + declared := map[string]bool{} |
| + for _, m := range decl.FindAllStringSubmatch(tokens, -1) { |
| + declared[m[1]] = true |
| + } |
| + inEvery := map[string]int{} |
| + for _, name := range fiesta.ThemeNames() { |
| + theme, _ := fiesta.ThemeCSS(name) |
| + seen := map[string]bool{} |
| + for _, m := range decl.FindAllStringSubmatch(strip(theme), -1) { |
| + if !seen[m[1]] { |
| + seen[m[1]] = true |
| + inEvery[m[1]]++ |
| + } |
| + } |
| + } |
| + 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. |
| + sources := map[string]string{"tokens.css": tokens} |
| + for _, name := range fiesta.ThemeNames() { |
| + theme, _ := fiesta.ThemeCSS(name) |
| + sources["themes/"+name+".css"] = strip(theme) |
| + } |
| + for file, css := range sources { |
| + reported := map[string]bool{} |
| + for _, m := range ref.FindAllStringSubmatch(css, -1) { |
| + if !declared[m[1]] && !reported[m[1]] { |
| + reported[m[1]] = true |
| + t.Errorf("%s uses var(%s), which neither tokens.css nor every theme declares", file, m[1]) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Every interactive element renders with a real accessible name |
| +// (spec §10). Checked here rather than per-partial so a new partial |
| + |
| +func TestFormPartialClassesAreStyled(t *testing.T) { |
| + css := 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(css, name) { |
| + t.Errorf("tokens.css has no selector for %q", name) |
| + } |
| + } |
| +} |
| + |
| +func TestRoutesFamilyPartialClassesAreStyled(t *testing.T) { |
| + css := 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(css, name) { |
| + t.Errorf("tokens.css has no selector for %q", name) |
| + } |
| + } |
| +} |
| + |
| +func TestJobStatusPartialClassesAreStyled(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + for _, name := range []string{"rst-spin"} { |
| + if !tokensStyle(css, name) { |
| + t.Errorf("tokens.css has no selector for %q", name) |
| + } |
| + } |
| +} |
| + |
| +// The dropdown's exclusivity (only one open at a time) is the native |
| +// <details name> attribute, not JavaScript — this pins both halves of |
| +// that promise. |
| +func TestDropdownExclusivityIsNative(t *testing.T) { |
| + sample := idiomMarkup(t, "dropdown") |
| + if !strings.Contains(sample, `<details rst-dropdown name=`) { |
| + t.Errorf("dropdown sample's outer <details> carries no name attribute: %s", sample) |
| + } |
| + if strings.Contains(sample, "<script") { |
| + t.Errorf("dropdown sample reaches for <script>; exclusivity must stay native: %s", sample) |
| + } |
| +} |
| + |
| +// cssComment matches a whole /* … */ block, so a check can read |
| +// tokens.css's selectors without its prose about them. |
| +var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`) |
| + |
| +// detailsNamePattern reads the name attribute off a <details> open tag, |
| +// whichever order the attributes are written in. |
| +var detailsNamePattern = regexp.MustCompile(`<details [^>]*name="([^"]*)"`) |
| + |
| +// Every menu the library emits joins one shared exclusivity group by |
| +// default, so opening a row menu closes an open header dropdown without |
| +// a line of script. The default is a value, not an absence: a <details> |
| + |
| +func TestNestedMenuGroupNeverSharesItsParentsName(t *testing.T) { |
| + sample := idiomMarkup(t, "dropdown") |
| + names := detailsNamePattern.FindAllStringSubmatch(sample, -1) |
| + if len(names) != 2 { |
| + t.Fatalf("expected two named <details> 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 <dialog open>: 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{ |
| + `<dialog rst-modal-panel open `, `</dialog>`, |
| + `<div rst-modal-overlay>`, `<div rst-backdrop inert>`, |
| + } { |
| + if !strings.Contains(sample, want) { |
| + t.Errorf("modal sample missing %q: %s", want, sample) |
| + } |
| + } |
| + if strings.Contains(sample, `<div rst-modal-panel>`) { |
| + 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, `<h2 id="`+id[1]+`">`) { |
| + 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. |
| + css := cssComment.ReplaceAllString(string(fiesta.TokensCSS()), "") |
| + i := strings.Index(css, "dialog[rst-modal-panel]") |
| + if i < 0 { |
| + t.Fatal("tokens.css has no scoped dialog reset; the UA dialog block would position the panel absolutely") |
| + } |
| + rule := css[i:] |
| + rule = rule[:strings.Index(rule, "}")] |
| + for _, want := range []string{"position: static", "padding: 0", "margin: 0", "color: inherit"} { |
| + if !strings.Contains(rule, want) { |
| + t.Errorf("the dialog reset does not undo the UA block's %q: %s", want, rule) |
| + } |
| + } |
| + // ::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 search form is inside a <search> landmark, the element HTML added |
| +// for exactly this, and the toolbar strip sizes the landmark rather than |
| +// the form it now wraps — the direct-child selector that used to reach |
| + |
| +func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + if !strings.Contains(css, ":where([rst-page],") { |
| + t.Error("tokens.css :focus-visible scope does not start with [rst-page]") |
| + } |
| +} |
| + |
| +func TestSrOnlyUtilityComesAfterTheRulesItMustBeat(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + srOnly := strings.Index(css, ".rst-sr-only {") |
| + if srOnly < 0 { |
| + t.Fatal("tokens.css no longer defines .rst-sr-only") |
| + } |
| + for _, competitor := range []string{"[rst-input] {", "[rst-btn] {"} { |
| + at := strings.Index(css, competitor) |
| + if at < 0 { |
| + continue // that component may have been renamed; not this test's business |
| + } |
| + if at > srOnly { |
| + t.Errorf("%s is declared after .rst-sr-only; the utility must come last or it loses the cascade to it", competitor) |
| + } |
| + } |
| +} |
| + |
| +// A separator inside a menu panel is an <hr>, and a UA <hr> is a thick |
| +// inset 3D rule that looks nothing like the hairline the menus draw. The |
| +// rule that neutralises it existed but was scoped to one panel — |
| +// [rst-row-menu-panel] — so the topbar account menu, which is a |
| +// [rst-dropdown-menu], rendered the browser's own. The panels are one |
| +// surface with three entry points, so the rule has to name all three. |
| +// |
| +// Selectors, not substrings: "[rst-dropdown-menu] hr" is not satisfied |
| +// by "[rst-dropdown-menu] hr" appearing inside some longer selector that |
| +// does not actually match a menu separator. |
| +// |
| +// Values, not substrings either. An earlier version of this gate asked |
| +// only whether the rule body contained "border-top", which |
| +// `border-top: 0` satisfies while the UA's groove comes straight back. |
| +// It takes BOTH halves to neutralise it — `border: 0` to drop the |
| +// inset 3D box, then a real `border-top` to draw the hairline — so both |
| +// are checked, and the width is checked for being non-zero. |
| +func TestEveryMenuSurfaceStylesItsSeparator(t *testing.T) { |
| + styled := map[string]bool{} |
| + for _, rule := range leafRules(string(fiesta.TokensCSS())) { |
| + decls := declarations(rule.body) |
| + if !resetsBorder(decls["border"]) || !drawsARule(decls["border-top"]) { |
| + continue |
| + } |
| + for _, sel := range selectorList(rule.selector) { |
| + styled[sel] = true |
| + } |
| + } |
| + for _, want := range []string{ |
| + "[rst-row-menu-panel] hr", |
| + "[rst-dropdown-menu] hr", |
| + "[rst-locale] hr", |
| + } { |
| + if !styled[want] { |
| + t.Errorf("no rule both resets the UA border and draws a hairline for %q; that menu renders the browser's own thick inset <hr>", want) |
| + } |
| + } |
| +} |
| + |
| +// declarations splits a rule body into property → value. Last wins, |
| +// as the cascade does within one block. |
| +func declarations(body string) map[string]string { |
| + out := map[string]string{} |
| + for _, d := range strings.Split(body, ";") { |
| + prop, val, ok := strings.Cut(d, ":") |
| + if !ok { |
| + continue |
| + } |
| + out[strings.ToLower(strings.TrimSpace(prop))] = strings.TrimSpace(val) |
| + } |
| + return out |
| +} |
| + |
| +// resetsBorder reports whether a value drops the UA's inset 3D border |
| +// altogether. Only the shorthand does that; a longhand leaves three |
| +// sides of groove behind. |
| +func resetsBorder(val string) bool { |
| + switch strings.ToLower(val) { |
| + case "0", "0px", "none": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// drawsARule reports whether a border-top value paints a visible line: |
| +// a width that is not zero, and a style that is not none. "0", |
| +// "0 solid …" and "1px none …" all fail it. |
| +func drawsARule(val string) bool { |
| + fields := strings.Fields(strings.ToLower(val)) |
| + if len(fields) == 0 { |
| + return false |
| + } |
| + for _, f := range fields { |
| + if f == "none" || f == "hidden" { |
| + return false |
| + } |
| + } |
| + width := fields[0] |
| + if width == "0" { |
| + return false |
| + } |
| + // A bare unit-ful zero — 0px, 0rem, 0em — is still zero. |
| + if num := strings.TrimRight(width, "abcdefghijklmnopqrstuvwxyz%"); num != "" { |
| + if f, err := strconv.ParseFloat(num, 64); err == nil && f == 0 { |
| + return false |
| + } |
| + } |
| + return true |
| +} |
| + |
| +// The sidebar rail's foot is one profile menu: an avatar button that |
| +// opens a card holding the person's name, the language switcher and the |
| +// account's links, in a wrapper the LAYOUT owns, because the blocks |
| +// inside it are app-supplied markup the shell cannot style by class. It |
| +// is a menu rather than the two blocks laid out flat because on a phone |
| +// the same control sits at the top of the index, and the foot of a long |
| +// index is out of reach. The geometry (a drop-up at the foot, a dropdown |
| +// at the top on a phone) is measured in profile_browser_test.go; this |
| + |
| +func TestThemesDeclareIdenticalTokenSets(t *testing.T) { |
| + names := fiesta.ThemeNames() |
| + if len(names) == 0 || names[0] != "day" { |
| + t.Fatalf("ThemeNames = %v; day must exist and come first", names) |
| + } |
| + want := themePropSet(t, "day") |
| + if len(want) == 0 { |
| + t.Fatal("day declares no --rst- properties") |
| + } |
| + for _, shape := range []string{ |
| + "--rst-radius", "--rst-radius-sm", "--rst-radius-pill", |
| + "--rst-shadow-pop", "--rst-shadow-knob", "--rst-shadow-knob-hover", "--rst-shadow-lift", "--rst-overlay", |
| + // The depth axis (spec C2, as amended by A6 and A15). day turns |
| + // it on; plain and signal declare the same names at today's |
| + // values. A name missing from day here is a token tokens.css |
| + // would read through its fallback forever. |
| + "--rst-accent-fill", "--rst-accent-fill-hover", "--rst-accent-fill-edge", "--rst-accent-fill-edge-hover", "--rst-on-accent-fill", |
| + "--rst-danger-fill", "--rst-danger-fill-hover", "--rst-danger-fill-edge", "--rst-danger-fill-edge-hover", "--rst-on-danger-fill", "--rst-on-danger-fill-hover", "--rst-danger-hover-filter", |
| + "--rst-control-face", "--rst-control-face-hover", "--rst-control-face-press", "--rst-control-border", "--rst-control-border-hover", "--rst-control-text-hover", |
| + "--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-depth-hl", "--rst-depth-contact", "--rst-depth-ambient", "--rst-depth-face", "--rst-press-drop", |
| + "--rst-knob", "--rst-knob-shade", "--rst-track-off", |
| + "--rst-switch-w", "--rst-switch-h", "--rst-switch-knob", "--rst-switch-touch-w", "--rst-switch-touch-h", "--rst-switch-touch-knob", |
| + "--rst-tint-hover", "--rst-tint-press", "--rst-tint-tap", "--rst-tint-card-hover", "--rst-tint-tile-hover", "--rst-tint-tile-press", "--rst-tint-group-hover", "--rst-row-tint-hover", "--rst-row-tint-press", "--rst-focus-edge", "--rst-focus-halo", |
| + "--rst-sheen-fill", "--rst-sheen-plain", "--rst-light-rim", "--rst-light-wash", |
| + "--rst-press-wash", "--rst-press-spot", "--rst-press-wash-plain", "--rst-press-spot-plain", |
| + "--rst-title-size", "--rst-title-weight", "--rst-title-track", "--rst-heading-size", |
| + "--rst-label-case", "--rst-label-track", "--rst-rail-bg", |
| + "--rst-nav-current-bg", "--rst-nav-current-fg", |
| + } { |
| + if !want[shape] { |
| + t.Errorf("day does not declare %s; the shape axis is the theme's since v2", shape) |
| + } |
| + } |
| + for _, n := range names[1:] { |
| + got := themePropSet(t, n) |
| + for p := range want { |
| + if !got[p] { |
| + t.Errorf("theme %s is missing %s", n, p) |
| + } |
| + } |
| + for p := range got { |
| + if !want[p] { |
| + t.Errorf("theme %s declares %s, which day does not", n, p) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// themePropSet: every --rst-* name declared anywhere in the theme file. |
| +func themePropSet(t *testing.T, name string) map[string]bool { |
| + t.Helper() |
| + css, ok := fiesta.ThemeCSS(name) |
| + if !ok { |
| + t.Fatalf("fiesta.ThemeCSS(%q) missing", name) |
| + } |
| + out := map[string]bool{} |
| + for _, m := range regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`).FindAllStringSubmatch(string(css), -1) { |
| + out[m[1]] = true |
| + } |
| + return out |
| +} |
| + |
| +// schemeNumberBlocks returns the bodies of a theme's two dark-number |
| +// blocks, or nil when it has none, and fails the test for every problem |
| +// schemeNumberProblems reports. |
| +func schemeNumberBlocks(t *testing.T, name, css string) []string { |
| + t.Helper() |
| + blocks, problems := schemeNumberProblems(name, css) |
| + for _, p := range problems { |
| + t.Error(p) |
| + } |
| + return blocks |
| +} |
| + |
| +// schemeNumberProblems reads a theme file against the shape A4 allows: |
| +// one `:root {` block; the two toggle rules |
| +// `:root[data-theme="light"] { color-scheme: light; }` and |
| +// `:root[data-theme="dark"] { color-scheme: dark; }`; and, together or |
| +// not at all, `:root[data-theme="dark"] { numbers }` and |
| +// `@media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { numbers } }`. |
| +// Every declaration in a number block must be a --rst-* name with a |
| +// bare number (an optional px, em or rem unit), nothing else may be in |
| +// the block, the two blocks must carry identical declarations, and |
| +// every name restated must be declared in :root as a number too. Both |
| +// blocks, because a reader who chose Dark has data-theme and a reader on |
| +// System on a dark OS has only the media query; one block alone hands |
| +// the other reader the light numbers. Nothing beyond those five pieces |
| +// may be in the file: anything else is the second palette block |
| +// light-dark() was adopted to make impossible. |
| +func schemeNumberProblems(name, css string) (blocks []string, problems []string) { |
| + where := "themes/" + name + ".css" |
| + bad := func(format string, args ...any) { problems = append(problems, where+": "+fmt.Sprintf(format, args...)) } |
| + stripped := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(css, "") |
| + const ( |
| + toggleLight = `:root[data-theme="light"] { color-scheme: light; }` |
| + toggleDark = `:root[data-theme="dark"] { color-scheme: dark; }` |
| + ) |
| + if strings.Count(stripped, toggleLight) != 1 || strings.Count(stripped, toggleDark) != 1 { |
| + bad("does not carry the two toggle rules exactly once") |
| + } |
| + rest := strings.Replace(strings.Replace(stripped, toggleLight, "", 1), toggleDark, "", 1) |
| + // One more block is allowed: a relative-colour redeclaration of |
| + // names :root already declares (day's hue-derived fills), so that an |
| + // engine below the floor keeps the :root value instead of a fill |
| + // that resolves to nothing (derived_test.go). Nothing else may be in |
| + // it, and it may not introduce a name. |
| + supports, rest := accentSupportsBlock(rest) |
| + rootRe := regexp.MustCompile(`(?s):root \{(.*?)\n\}`) |
| + rootM := rootRe.FindStringSubmatch(rest) |
| + if rootM == nil { |
| + bad("has no :root block") |
| + return nil, problems |
| + } |
| + root := rootM[1] |
| + rest = strings.Replace(rest, rootM[0], "", 1) |
| + if supports != "" { |
| + declaredInRoot := parseTokens(root) |
| + for name := range parseTokens(supports) { |
| + if _, ok := declaredInRoot[name]; !ok { |
| + bad("redeclares %s in its relative-colour block but never declares it in :root, so an engine without relative colour has no value for it", name) |
| + } |
| + } |
| + if strings.TrimSpace(regexp.MustCompile(`--rst-[a-z0-9-]+\s*:[^;]+;`).ReplaceAllString(supports, "")) != "" { |
| + bad("carries something other than --rst-* declarations in its relative-colour block") |
| + } |
| + } |
| + attr := regexp.MustCompile(`:root\[data-theme="dark"\] \{([^{}]*)\}`).FindAllStringSubmatch(rest, -1) |
| + media := regexp.MustCompile(`@media \(prefers-color-scheme: dark\) \{\s*:root:not\(\[data-theme="light"\]\) \{([^{}]*)\}\s*\}`).FindAllStringSubmatch(rest, -1) |
| + for _, m := range attr { |
| + rest = strings.Replace(rest, m[0], "", 1) |
| + } |
| + for _, m := range media { |
| + rest = strings.Replace(rest, m[0], "", 1) |
| + } |
| + if strings.TrimSpace(rest) != "" { |
| + bad("carries rules beyond :root, the toggles and the two dark-number blocks:\n%s", strings.TrimSpace(rest)) |
| + } |
| + if len(attr) == 0 && len(media) == 0 { |
| + return nil, problems |
| + } |
| + if len(attr) != 1 || len(media) != 1 { |
| + bad("has %d attribute and %d media dark-number blocks, want one of each or none", len(attr), len(media)) |
| + return nil, problems |
| + } |
| + decl := regexp.MustCompile(`^(--rst-[a-z0-9-]+)\s*:\s*(.+)$`) |
| + num := regexp.MustCompile(`^-?[0-9]*\.?[0-9]+(px|em|rem)?$`) |
| + inRoot := parseTokens(root) |
| + read := func(body string) map[string]string { |
| + out := map[string]string{} |
| + // Every declaration is consumed by the numeric grammar, or the |
| + // block is rejected: a stray `color: red;` must not pass because |
| + // the reader only looked for --rst- names. |
| + for _, d := range strings.Split(body, ";") { |
| + d = strings.TrimSpace(d) |
| + if d == "" { |
| + continue |
| + } |
| + m := decl.FindStringSubmatch(d) |
| + if m == nil { |
| + bad("has %q in a scheme block; only --rst-* numbers may be restated per scheme", d) |
| + continue |
| + } |
| + v := strings.TrimSpace(m[2]) |
| + if !num.MatchString(v) { |
| + bad("restates %s as %q in a scheme block; only numbers may be restated per scheme", m[1], v) |
| + } |
| + if rv, ok := inRoot[m[1]]; !ok { |
| + bad("restates %s for dark but never declares it in :root", m[1]) |
| + } else if !num.MatchString(rv) { |
| + bad("declares %s as %q in :root; a number restated per scheme must be a number there too", m[1], rv) |
| + } |
| + out[m[1]] = v |
| + } |
| + return out |
| + } |
| + a, m := read(attr[0][1]), read(media[0][1]) |
| + if !reflect.DeepEqual(a, m) { |
| + bad("two dark-number blocks differ: attribute %v, media %v", a, m) |
| + } |
| + return []string{attr[0][1], media[0][1]}, problems |
| +} |
| + |
| +// TestSchemeNumberGateRejectsWhatItMustReject feeds the gate the shapes |
| +// it exists to refuse, since a gate that only ever sees the shipped |
| +// themes is not known to refuse anything. |
| +func TestSchemeNumberGateRejectsWhatItMustReject(t *testing.T) { |
| + const good = ":root {\n --rst-bg: light-dark(#fff, #000);\n --rst-press-wash: 0.08;\n}\n:root[data-theme=\"light\"] { color-scheme: light; }\n:root[data-theme=\"dark\"] { color-scheme: dark; }\n:root[data-theme=\"dark\"] { --rst-press-wash: 0.18; }\n@media (prefers-color-scheme: dark) { :root:not([data-theme=\"light\"]) { --rst-press-wash: 0.18; } }\n" |
| + if blocks, problems := schemeNumberProblems("good", good); len(problems) != 0 || len(blocks) != 2 { |
| + t.Fatalf("the well-formed theme was refused: %v (%d blocks)", problems, len(blocks)) |
| + } |
| + withSupports := good + "@supports (color: oklch(from red l c h)) {\n :root {\n --rst-bg: light-dark(oklch(from #fff 0.5 c h), #000);\n }\n}\n" |
| + if _, problems := schemeNumberProblems("good", withSupports); len(problems) != 0 { |
| + t.Fatalf("a relative-colour block restating a :root name was refused: %v", problems) |
| + } |
| + for name, css := range map[string]string{ |
| + "a colour in a block": strings.ReplaceAll(good, "--rst-press-wash: 0.18;", "--rst-press-wash: 0.18; color: red;"), |
| + "a colour restated": strings.ReplaceAll(good, "--rst-press-wash: 0.18;", "--rst-bg: #111;"), |
| + "only one block": strings.Replace(good, "@media (prefers-color-scheme: dark) { :root:not([data-theme=\"light\"]) { --rst-press-wash: 0.18; } }\n", "", 1), |
| + "blocks that differ": strings.Replace(good, ":root[data-theme=\"dark\"] { --rst-press-wash: 0.18; }", ":root[data-theme=\"dark\"] { --rst-press-wash: 0.30; }", 1), |
| + "a number not in root": strings.ReplaceAll(good, "--rst-press-wash: 0.18;", "--rst-press-spot: 0.3;"), |
| + "a root default not a number": strings.Replace(good, "--rst-press-wash: 0.08;", "--rst-press-wash: calc(1 - 0.92);", 1), |
| + "an extra rule": good + ".rst-btn { color: red; }\n", |
| + "color-scheme in a block": strings.ReplaceAll(good, "--rst-press-wash: 0.18;", "--rst-press-wash: 0.18; color-scheme: dark;"), |
| + "a supports block with a new name": good + "@supports (color: oklch(from red l c h)) {\n :root {\n --rst-new: #fff;\n }\n}\n", |
| + "a supports block with a rule": good + "@supports (color: oklch(from red l c h)) {\n :root {\n --rst-bg: #fff; color: red;\n }\n}\n", |
| + "a supports block for another feature": good + "@supports (color: oklch(0.5 0.1 20)) {\n :root {\n --rst-bg: #fff;\n }\n}\n", |
| + } { |
| + if _, problems := schemeNumberProblems("bad", css); len(problems) == 0 { |
| + t.Errorf("%s was accepted", name) |
| + } |
| + } |
| +} |
| + |
| +func TestTokensCSSHasNoColourLiterals(t *testing.T) { |
| + // After the split, structural tokens.css may reference colours only |
| + // via var(); bare hex in a *declaration value* means a colour leaked |
| + // back in. Exempt: none — the three rgba() literals that used to live |
| + // here (the switch knob shadow, the seg-tab lift shadow, the modal |
| + // scrim) are gone too, replaced with var(--rst-shadow-knob), |
| + // var(--rst-shadow-lift) and var(--rst-overlay) declared per theme — |
| + // so this gate also matches rgba()/rgb()/hsl() function values, not |
| + // just bare hex, to keep that claim enforced instead of just stated. |
| + decl := regexp.MustCompile(`:\s*[^;]*(#[0-9a-fA-F]{3,6}|rgba?\(|hsl\()`) |
| + // Ink and light (A5): tokens.css may paint pure black or pure white |
| + // at an alpha, where the strength is a number token or a fixed |
| + // fraction. Those two spellings are the only literals allowed; a |
| + // line that mixes one with any other colour function still fails. |
| + achromatic := regexp.MustCompile(`rgb\((0 0 0|255 255 255) / `) |
| + for i, line := range strings.Split(string(fiesta.TokensCSS()), "\n") { |
| + stripped := achromatic.ReplaceAllString(line, "ink(") |
| + if (strings.Contains(stripped, "#") || strings.Contains(stripped, "rgb") || strings.Contains(stripped, "hsl")) && decl.MatchString(stripped) { |
| + t.Errorf("tokens.css line %d declares a colour literal: %s", i+1, strings.TrimSpace(line)) |
| + } |
| + } |
| +} |
| + |
| +// The three shells are real templates, not documentation: each one |
| +// parses with the ui funcs, defines "layout", executes against nil data |
| +// (so a struct-vs-map decision in an app cannot break a shell), and |
| + |
| +func TestErrorPageClassesAreStyled(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + for _, name := range []string{ |
| + "rst-error", "rst-error-status", "rst-error-title", |
| + "rst-error-body", "rst-error-cta", "rst-error-ref", |
| + } { |
| + if !tokensStyle(css, name) { |
| + t.Errorf("tokens.css has no selector for %q", name) |
| + } |
| + } |
| +} |
| + |
| +func TestHiddenButtonsStayHidden(t *testing.T) { |
| + if !strings.Contains(string(fiesta.TokensCSS()), `.rst-btn[hidden], [rst-btn][hidden] { display: none; }`) { |
| + t.Fatal("tokens.css does not keep a [hidden] button hidden") |
| + } |
| +} |
| + |
| +func TestDayTurnsDepthOnAndTheOthersStayFlat(t *testing.T) { |
| + for _, name := range fiesta.ThemeNames() { |
| + for _, scheme := range []string{"light", "dark"} { |
| + tok := themeTokens(t, name)[scheme] |
| + rimIsLine := tok["--rst-light-rim"] == "var(--rst-line)" || tok["--rst-light-rim"] == tok["--rst-line"] |
| + flat := tok["--rst-depth-hl"] == "transparent" && tok["--rst-depth-contact"] == "transparent" && |
| + tok["--rst-depth-ambient"] == "transparent" && tok["--rst-depth-face"] == "0" && tok["--rst-press-drop"] == "0px" && |
| + tok["--rst-sheen-fill"] == "transparent" && tok["--rst-sheen-plain"] == "transparent" && |
| + tok["--rst-light-wash"] == "transparent" && rimIsLine && |
| + tok["--rst-press-wash"] == "0" && tok["--rst-press-spot"] == "0" |
| + if name == "day" { |
| + if flat { |
| + t.Errorf("day/%s is flat; the depth axis is day's to turn on", scheme) |
| + } |
| + if tok["--rst-depth-face"] != "0.04" || tok["--rst-press-drop"] != "1px" { |
| + t.Errorf("day/%s shades a filled face by %q and drops a press by %q, want 0.04 and 1px (A7, A2)", scheme, tok["--rst-depth-face"], tok["--rst-press-drop"]) |
| + } |
| + continue |
| + } |
| + if !flat { |
| + t.Errorf("%s/%s is not flat (A13: plain and signal render as today)", name, scheme) |
| + } |
| + } |
| + } |
| + day := themeTokens(t, "day") |
| + for scheme, want := range map[string]map[string]string{ |
| + "light": {"--rst-accent": "#1f56d6", "--rst-danger-fill": "#c62f2f", "--rst-track-off": "#8a8a95", "--rst-radius": "6px", "--rst-radius-sm": "4px", "--rst-header-rule": "var(--rst-line)"}, |
| + "dark": {"--rst-accent": "#7aa2ff", "--rst-danger-fill": "#c43232", "--rst-track-off": "#71737c", "--rst-radius": "6px", "--rst-radius-sm": "4px", "--rst-header-rule": "var(--rst-line)"}, |
| + } { |
| + for tokName, v := range want { |
| + if day[scheme][tokName] != v { |
| + t.Errorf("day/%s %s = %q, want %q (spec §1, §2, C15, A7)", scheme, tokName, day[scheme][tokName], v) |
| + } |
| + } |
| + } |
| + // The fill is derived from the accent (derived_test.go), so it is pinned |
| + // resolved: the accent's hue at day's fill lightness, within two |
| + // units of the #2257df and #2f61e8 the design was measured with. |
| + resolved := resolvedTokens(t, "day") |
| + for scheme, want := range map[string]string{"light": "#2059dd", "dark": "#3160ea"} { |
| + if got := resolved[scheme]["--rst-accent-fill"]; got != want { |
| + t.Errorf("day/%s --rst-accent-fill resolves to %q, want %q (A7, derived)", scheme, got, want) |
| + } |
| + } |
| + css, _ := fiesta.ThemeCSS("day") |
| + blocks := schemeNumberBlocks(t, "day", string(css)) |
| + if len(blocks) != 2 || !strings.Contains(blocks[0], "--rst-press-wash: 0.18;") { |
| + t.Errorf("day restates no dark press strengths; want --rst-press-wash: 0.18 in both scheme blocks (A2)") |
| + } |
| +} |
| + |
| +// Hidden where a picture of waves would do harm: forced colours paint |
| +// every stroke in the system's text colour at full strength, and a |
| +// printout does not need it. The stylesheet is the only place this can |
| +// happen, because the art carries no style of its own. |
| +func TestStageArtIsHiddenUnderForcedColoursAndInPrint(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + for _, media := range []string{"@media (forced-colors: active)", "@media print"} { |
| + re := regexp.MustCompile(regexp.QuoteMeta(media) + ` \{\s*\.rst-stage-art, \[rst-stage-art\] \{ display: none; \}\s*\}`) |
| + if !re.MatchString(css) { |
| + t.Errorf("tokens.css does not hide [rst-stage-art] under %s", media) |
| + } |
| + } |
| +} |
diff --git a/check/depth_media_test.go b/check/depth_media_test.go| new file mode 100644 |
| index 0000000..752af9c |
| --- /dev/null |
| +++ b/check/depth_media_test.go |
| @@ -0,0 +1,66 @@ |
| +package check |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The two media blocks that undo the depth language where it would do |
| +// harm (spec C6, C17, A17). Each flattens the axis at html:root, so no |
| +// state rule can keep a shadow, a gradient, a tint or a drop; and each |
| +// names the components that need a border or a system colour the |
| +// tokens cannot give them. A token or a component left out keeps a |
| +// shadow forced colours would strip half of, or a tint a printer would |
| +// drop. The summary's edge is in the flat list too: it is a ring drawn |
| +// in the shadow by rules at (0,4,1) and up, so a box-shadow: none at |
| +// the summary's own selector never reaches it, and the token is the |
| +// one thing that wins in every state. |
| +func TestForcedColoursAndPrintFlattenTheAxisAndCoverEveryComponent(t *testing.T) { |
| + css := string(fiesta.TokensCSS()) |
| + flat := []string{"--rst-depth-hl: transparent", "--rst-depth-contact: transparent", "--rst-depth-ambient: transparent", "--rst-depth-face: 0", "--rst-press-drop: 0px", |
| + "--rst-sheen-fill: transparent", "--rst-sheen-plain: transparent", "--rst-light-wash: transparent", "--rst-light-rim: var(--rst-line)", |
| + "--rst-press-wash: 0", "--rst-press-spot: 0", "--rst-press-wash-plain: 0", "--rst-press-spot-plain: 0", |
| + "--rst-tint-hover: var(--rst-surface)", "--rst-tint-press: var(--rst-surface)", "--rst-tint-tap: transparent", "--rst-row-tint-hover: var(--rst-surface)", "--rst-row-tint-press: var(--rst-surface)", "--rst-focus-halo: transparent", |
| + "--rst-summary-edge: transparent", "--rst-summary-edge-hover: transparent"} |
| + printEnd := 0 |
| + for _, block := range []struct { |
| + header string |
| + want []string |
| + }{ |
| + {"@media (forced-colors: active) {", append(flat, "html:root {", "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", |
| + "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "[rst-shell-topbar] [rst-shell-tail] > [rst-shell-nav] a[aria-current]", "[rst-dropdown-menu] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-seg-tabs] a[aria-current]", |
| + `[rst-shell-sidebar][rst-shell-sidebar~="index"] [rst-shell-nav] > a[aria-current]:hover`, `[rst-shell-console][rst-shell-console~="index"] [rst-shell-nav] > a[aria-current]:active`, |
| + "ButtonBorder", "CanvasText", "ButtonText", "Highlight", "HighlightText", "box-shadow: none")}, |
| + {"@media print {", append(flat, "html:root {", "--rst-accent-fill: transparent", "--rst-accent-fill-hover: transparent", "--rst-on-accent-fill: var(--rst-text)", "--rst-danger-fill: transparent", "--rst-on-danger-fill-hover: var(--rst-text)", "--rst-danger-hover-filter: none", "--rst-control-face-hover: transparent", "--rst-control-text-hover: var(--rst-text)", "--rst-knob: var(--rst-text)", |
| + "[rst-btn]", "[rst-box]", "[rst-card]", "[rst-stats]", "[rst-list]", "[rst-input]", "[rst-textarea]", "[rst-search]", "[rst-dropdown]", "[rst-switch-track]", "[rst-switch-track]::after", "[rst-switch]:has(input:checked) [rst-switch-track]", "[rst-tblock] > [rst-tblock-head]:has(> input:checked) > [rst-switch-track]", "[rst-seg-tabs] a[aria-current]", "[rst-pagination] [aria-current=\"page\"]", "[rst-dropdown-menu]", "[rst-row-menu-panel]", |
| + "[rst-shell-sidebar] [rst-shell-nav] a[aria-current]", "[rst-shell-console] [rst-shell-nav] a[aria-current]", "var(--rst-line-strong)", "background-image: none", "box-shadow: none", "outline: 1px solid var(--rst-line-strong)")}, |
| + } { |
| + i := strings.LastIndex(css, block.header) |
| + if i < 0 { |
| + t.Fatalf("tokens.css has no %s block at the end of the file", block.header) |
| + } |
| + body := blockBody(t, "tokens.css", css, block.header, i) |
| + if block.header == "@media print {" { |
| + printEnd = i + len(block.header) + len(body) + 1 |
| + } |
| + for _, w := range block.want { |
| + if !strings.Contains(body, w) { |
| + t.Errorf("the %s block does not carry %q", block.header, w) |
| + } |
| + } |
| + if strings.Contains(body, "!important") { |
| + t.Errorf("the %s block uses !important (C11)", block.header) |
| + } |
| + } |
| + // Nothing that paints a button, a card or a nav item may follow the |
| + // print block: its rules win by order. The search starts after the |
| + // block's closing brace, because the block names those selectors |
| + // itself. |
| + for _, sel := range []string{`[rst-btn~="primary"]`, `[rst-shell-nav] a[aria-current]`, `[rst-box]`} { |
| + if strings.Contains(css[printEnd:], sel+" {") || strings.Contains(css[printEnd:], sel+",") { |
| + t.Errorf("%q is styled after the print block; the print rules rely on source order", sel) |
| + } |
| + } |
| +} |
diff --git a/check/derived_test.go b/check/derived_test.go| new file mode 100644 |
| index 0000000..5052bfe |
| --- /dev/null |
| +++ b/check/derived_test.go |
| @@ -0,0 +1,578 @@ |
| +package check |
| + |
| +import ( |
| + "fmt" |
| + "math" |
| + "regexp" |
| + "strconv" |
| + "strings" |
| + "testing" |
| + "unicode" |
| +) |
| + |
| +// day derives its accent-dependent tokens from --rst-accent: |
| +// the four fills and the soft ground take the accent's hue at day's own |
| +// lightness, with the accent's chroma scaled and capped, as |
| +// oklch(from var(--rst-accent) L C h) inside an @supports block for |
| +// relative colour; the tints, the halo, the plain sheen and the rim |
| +// fold the accent into a surface or into transparent with |
| +// color-mix(in srgb, …); and the tokens that repeat another are a bare |
| +// var(). The contrast gate and the composed-face test read declared |
| +// values, so without an evaluator every pair that names a derived token |
| +// would report "cannot parse" and the gate would be reduced to the |
| +// neutrals. This file is that evaluator: a second implementation of the |
| +// three shapes, in Go, held to Chromium's own pixels by |
| +// TestDaysDerivedTokensReproduceTheirDocumentedColours. |
| +// |
| +// Only the shapes day writes are read. A new shape in a theme fails |
| +// loudly here ("cannot evaluate"), which is the point: a value this |
| +// file cannot evaluate is a value the gate is not checking. |
| +// |
| +// An out-of-gamut relative colour is clipped per channel, as Chromium |
| +// paints it, not chroma-reduced as colour.go's oklchRGB does for the |
| +// colour engine: the drives run in Chromium, and the sweep in |
| +// accent_browser_test.go reads what it paints. The clip itself |
| +// (oklchClip) and canonColours, which the browser drives read computed |
| +// styles through, are in colourhelp_test.go. |
| + |
| +// rgba is a resolved colour: eight-bit channels as floats (so a mix is |
| +// not rounded until it is printed) and an alpha in 0..1. |
| +type rgba struct { |
| + r, g, b, a float64 |
| +} |
| + |
| +// String spells the colour the way the rest of the tests read one: a |
| +// hex when opaque, rgba() otherwise, so ContrastRatio, channelsOf and |
| +// alphaOf all accept it. |
| +func (c rgba) String() string { |
| + r, g, b := uint8(math.Round(c.r)), uint8(math.Round(c.g)), uint8(math.Round(c.b)) |
| + if c.a >= 1 { |
| + return hexOf([3]uint8{r, g, b}) |
| + } |
| + return fmt.Sprintf("rgba(%d, %d, %d, %s)", r, g, b, strconv.FormatFloat(c.a, 'f', -1, 64)) |
| +} |
| + |
| +// accentSupportsRe matches the one block a theme may add beyond its |
| +// :root, the toggles and the dark-number blocks: day's redeclaration of |
| +// its hue-derived tokens where the engine has relative colour syntax. |
| +// The test string is the exact one day.css writes; a block with another |
| +// condition is not this block and the structure gates refuse it. |
| +var accentSupportsRe = regexp.MustCompile(`(?s)@supports \(color: oklch\(from red l c h\)\) \{\s*:root \{(.*?)\n \}\n\}\n?`) |
| + |
| +// accentSupportsBlock returns the body of a theme's relative-colour |
| +// block, or "" when it has none, and the stylesheet with that block |
| +// removed, which is what the one-:root-block gates read. |
| +func accentSupportsBlock(css string) (body, rest string) { |
| + m := accentSupportsRe.FindStringSubmatchIndex(css) |
| + if m == nil { |
| + return "", css |
| + } |
| + return css[m[2]:m[3]], css[:m[0]] + css[m[1]:] |
| +} |
| + |
| +// resolvedTokens is themeTokens with every derived colour evaluated for |
| +// its scheme. A value that is not a colour expression (a font stack, a |
| +// radius, a number, a shadow) is carried through unchanged, so a caller |
| +// that reads numbers from it sees what themeTokens shows. |
| +func resolvedTokens(t *testing.T, theme string) map[string]map[string]string { |
| + t.Helper() |
| + out := map[string]map[string]string{} |
| + for scheme, table := range themeTokens(t, theme) { |
| + res := make(map[string]string, len(table)) |
| + for name, v := range table { |
| + res[name] = resolveValue(v, table) |
| + } |
| + out[scheme] = res |
| + } |
| + return out |
| +} |
| + |
| +// resolveWith evaluates one scheme's table with some tokens overridden, |
| +// which is what an app stylesheet does to :root. The override test |
| +// computes its expectation this way rather than from a second copy of |
| +// the formulas. |
| +func resolveWith(t *testing.T, theme, scheme string, override map[string]string) map[string]string { |
| + t.Helper() |
| + table := map[string]string{} |
| + for k, v := range themeTokens(t, theme)[scheme] { |
| + table[k] = v |
| + } |
| + for k, v := range override { |
| + table[k] = v |
| + } |
| + res := make(map[string]string, len(table)) |
| + for name, v := range table { |
| + res[name] = resolveValue(v, table) |
| + } |
| + return res |
| +} |
| + |
| +// resolveValue evaluates a value only when it is a derivation; a |
| +// declared literal (a hex, "transparent", a number, a shadow) is handed |
| +// back as written, so the tests that compare one by string still see |
| +// what the theme says rather than this file's spelling of it. |
| +func resolveValue(v string, table map[string]string) string { |
| + if !strings.HasPrefix(v, "var(") && !strings.HasPrefix(v, "color-mix(") && !strings.HasPrefix(v, "oklch(from ") { |
| + return v |
| + } |
| + c, err := evalColour(v, table, 0) |
| + if err != nil { |
| + return v |
| + } |
| + return c.String() |
| +} |
| + |
| +var rgbFn = regexp.MustCompile(`^rgba?\(\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*[, ]\s*([0-9.]+)\s*(?:[,/]\s*([0-9.]+%?)\s*)?\)$`) |
| + |
| +// evalColour evaluates one declared value against its scheme's table. |
| +func evalColour(v string, table map[string]string, depth int) (rgba, error) { |
| + v = strings.TrimSpace(v) |
| + if depth > 16 { |
| + return rgba{}, fmt.Errorf("%q: var() chain deeper than 16; a cycle", v) |
| + } |
| + switch { |
| + case v == "transparent": |
| + return rgba{}, nil |
| + case v == "white" || v == "#fff" || v == "#ffffff": |
| + return rgba{255, 255, 255, 1}, nil |
| + case v == "black" || v == "#000" || v == "#000000": |
| + return rgba{0, 0, 0, 1}, nil |
| + case strings.HasPrefix(v, "#"): |
| + r, g, b, err := parseHex(v) |
| + if err != nil { |
| + return rgba{}, err |
| + } |
| + return rgba{float64(r), float64(g), float64(b), 1}, nil |
| + case strings.HasPrefix(v, "rgb"): |
| + m := rgbFn.FindStringSubmatch(v) |
| + if m == nil { |
| + return rgba{}, fmt.Errorf("cannot evaluate %q", v) |
| + } |
| + var n [4]float64 |
| + n[3] = 1 |
| + for i := 0; i < 4 && i < len(m)-1; i++ { |
| + if m[i+1] == "" { |
| + continue |
| + } |
| + f, err := strconv.ParseFloat(strings.TrimSuffix(m[i+1], "%"), 64) |
| + if err != nil { |
| + return rgba{}, err |
| + } |
| + if i == 3 && strings.HasSuffix(m[i+1], "%") { |
| + f /= 100 |
| + } |
| + n[i] = f |
| + } |
| + return rgba{n[0], n[1], n[2], n[3]}, nil |
| + case strings.HasPrefix(v, "var("): |
| + name, fallback, ok := splitVar(v) |
| + if !ok { |
| + return rgba{}, fmt.Errorf("cannot evaluate %q", v) |
| + } |
| + if ref, declared := table[name]; declared { |
| + return evalColour(ref, table, depth+1) |
| + } |
| + if fallback == "" { |
| + return rgba{}, fmt.Errorf("%s is not declared and %q has no fallback", name, v) |
| + } |
| + return evalColour(fallback, table, depth+1) |
| + case strings.HasPrefix(v, "light-dark("): |
| + // Only reachable inside a fallback the scheme split never saw; |
| + // the table is already one scheme's, so there is no right half |
| + // to pick and the value is refused rather than guessed. |
| + return rgba{}, fmt.Errorf("%q: light-dark() inside a derived value is not evaluated per scheme", v) |
| + case strings.HasPrefix(v, "color-mix(in srgb, "): |
| + args := splitArgs(strings.TrimSuffix(strings.TrimPrefix(v, "color-mix(in srgb, "), ")")) |
| + if len(args) != 2 { |
| + return rgba{}, fmt.Errorf("cannot evaluate %q: want two colours", v) |
| + } |
| + a, pa, err := colourAndShare(args[0], table, depth) |
| + if err != nil { |
| + return rgba{}, err |
| + } |
| + b, pb, err := colourAndShare(args[1], table, depth) |
| + if err != nil { |
| + return rgba{}, err |
| + } |
| + return mixSRGBPremultiplied(a, pa, b, pb), nil |
| + case strings.HasPrefix(v, "oklch(from "): |
| + parts := splitSpaces(strings.TrimSuffix(strings.TrimPrefix(v, "oklch("), ")")) |
| + if len(parts) != 5 || parts[0] != "from" { |
| + return rgba{}, fmt.Errorf("cannot evaluate %q: want oklch(from <colour> <L> <C> <H>)", v) |
| + } |
| + from, err := evalColour(parts[1], table, depth+1) |
| + if err != nil { |
| + return rgba{}, err |
| + } |
| + lab := oklabOf(uint8(math.Round(from.r)), uint8(math.Round(from.g)), uint8(math.Round(from.b))) |
| + l0, c0 := lab[0], math.Hypot(lab[1], lab[2]) |
| + h0 := math.Atan2(lab[2], lab[1]) * 180 / math.Pi |
| + if h0 < 0 { |
| + h0 += 360 |
| + } |
| + vars := map[string]float64{"l": l0, "c": c0, "h": h0} |
| + var ch [3]float64 |
| + for i, expr := range parts[2:5] { |
| + f, err := evalChannel(expr, vars) |
| + if err != nil { |
| + return rgba{}, fmt.Errorf("%q: %v", v, err) |
| + } |
| + ch[i] = f |
| + } |
| + rgb := oklchClip(ch[0], ch[1], ch[2]) |
| + return rgba{float64(rgb[0]), float64(rgb[1]), float64(rgb[2]), from.a}, nil |
| + } |
| + return rgba{}, fmt.Errorf("cannot evaluate %q", v) |
| +} |
| + |
| +// splitSpaces splits on top-level spaces, so a channel written as a |
| +// function full of spaces stays one part. |
| +func splitSpaces(s string) []string { |
| + var out []string |
| + depth, start := 0, 0 |
| + for i := 0; i < len(s); i++ { |
| + switch s[i] { |
| + case '(': |
| + depth++ |
| + case ')': |
| + depth-- |
| + case ' ': |
| + if depth == 0 { |
| + if p := strings.TrimSpace(s[start:i]); p != "" { |
| + out = append(out, p) |
| + } |
| + start = i + 1 |
| + } |
| + } |
| + } |
| + if p := strings.TrimSpace(s[start:]); p != "" { |
| + out = append(out, p) |
| + } |
| + return out |
| +} |
| + |
| +// A channel of a relative colour: a number, the origin's l, c or h, or |
| +// CSS math over those, which day writes as calc(), min(), max() and |
| +// cos() with a deg unit. A quantity carries whether it is an angle, so |
| +// cos() reads degrees as degrees and a bare number as radians, as CSS |
| +// does. |
| +type quantity struct { |
| + v float64 |
| + deg bool |
| +} |
| + |
| +func evalChannel(expr string, vars map[string]float64) (float64, error) { |
| + p := &channelParser{src: expr, vars: vars} |
| + q, err := p.sum() |
| + if err != nil { |
| + return 0, err |
| + } |
| + p.skip() |
| + if p.pos != len(p.src) { |
| + return 0, fmt.Errorf("unexpected %q at %d in %q", p.src[p.pos:], p.pos, expr) |
| + } |
| + return q.v, nil |
| +} |
| + |
| +type channelParser struct { |
| + src string |
| + pos int |
| + vars map[string]float64 |
| +} |
| + |
| +func (p *channelParser) skip() { |
| + for p.pos < len(p.src) && p.src[p.pos] == ' ' { |
| + p.pos++ |
| + } |
| +} |
| + |
| +func (p *channelParser) peek() byte { |
| + p.skip() |
| + if p.pos >= len(p.src) { |
| + return 0 |
| + } |
| + return p.src[p.pos] |
| +} |
| + |
| +func (p *channelParser) sum() (quantity, error) { |
| + left, err := p.product() |
| + if err != nil { |
| + return left, err |
| + } |
| + for { |
| + switch p.peek() { |
| + case '+': |
| + p.pos++ |
| + right, err := p.product() |
| + if err != nil { |
| + return left, err |
| + } |
| + left = quantity{left.v + right.v, left.deg || right.deg} |
| + case '-': |
| + p.pos++ |
| + right, err := p.product() |
| + if err != nil { |
| + return left, err |
| + } |
| + left = quantity{left.v - right.v, left.deg || right.deg} |
| + default: |
| + return left, nil |
| + } |
| + } |
| +} |
| + |
| +func (p *channelParser) product() (quantity, error) { |
| + left, err := p.factor() |
| + if err != nil { |
| + return left, err |
| + } |
| + for { |
| + switch p.peek() { |
| + case '*': |
| + p.pos++ |
| + right, err := p.factor() |
| + if err != nil { |
| + return left, err |
| + } |
| + left = quantity{left.v * right.v, left.deg || right.deg} |
| + case '/': |
| + p.pos++ |
| + right, err := p.factor() |
| + if err != nil { |
| + return left, err |
| + } |
| + if right.v == 0 { |
| + return left, fmt.Errorf("division by zero in %q", p.src) |
| + } |
| + left = quantity{left.v / right.v, left.deg} |
| + default: |
| + return left, nil |
| + } |
| + } |
| +} |
| + |
| +func (p *channelParser) factor() (quantity, error) { |
| + switch c := p.peek(); { |
| + case c == '(': |
| + p.pos++ |
| + q, err := p.sum() |
| + if err != nil { |
| + return q, err |
| + } |
| + if p.peek() != ')' { |
| + return q, fmt.Errorf("missing ) in %q", p.src) |
| + } |
| + p.pos++ |
| + return q, nil |
| + case c == '-': |
| + p.pos++ |
| + q, err := p.factor() |
| + return quantity{-q.v, q.deg}, err |
| + case c == '.' || unicode.IsDigit(rune(c)): |
| + start := p.pos |
| + for p.pos < len(p.src) && (p.src[p.pos] == '.' || unicode.IsDigit(rune(p.src[p.pos]))) { |
| + p.pos++ |
| + } |
| + f, err := strconv.ParseFloat(p.src[start:p.pos], 64) |
| + if err != nil { |
| + return quantity{}, err |
| + } |
| + if strings.HasPrefix(p.src[p.pos:], "deg") { |
| + p.pos += 3 |
| + return quantity{f, true}, nil |
| + } |
| + return quantity{f, false}, nil |
| + case unicode.IsLetter(rune(c)): |
| + start := p.pos |
| + for p.pos < len(p.src) && unicode.IsLetter(rune(p.src[p.pos])) { |
| + p.pos++ |
| + } |
| + name := p.src[start:p.pos] |
| + if p.peek() != '(' { |
| + v, ok := p.vars[name] |
| + if !ok { |
| + return quantity{}, fmt.Errorf("unknown channel %q in %q", name, p.src) |
| + } |
| + return quantity{v, false}, nil |
| + } |
| + p.pos++ |
| + var args []quantity |
| + for { |
| + q, err := p.sum() |
| + if err != nil { |
| + return q, err |
| + } |
| + args = append(args, q) |
| + if p.peek() == ',' { |
| + p.pos++ |
| + continue |
| + } |
| + break |
| + } |
| + if p.peek() != ')' { |
| + return quantity{}, fmt.Errorf("missing ) after %s( in %q", name, p.src) |
| + } |
| + p.pos++ |
| + switch name { |
| + case "calc": |
| + if len(args) == 1 { |
| + return args[0], nil |
| + } |
| + case "min", "max": |
| + out := args[0] |
| + for _, a := range args[1:] { |
| + if (name == "min") == (a.v < out.v) { |
| + out = a |
| + } |
| + } |
| + return out, nil |
| + case "cos", "sin": |
| + if len(args) == 1 { |
| + x := args[0].v |
| + if args[0].deg { |
| + x = x * math.Pi / 180 |
| + } |
| + if name == "cos" { |
| + return quantity{math.Cos(x), false}, nil |
| + } |
| + return quantity{math.Sin(x), false}, nil |
| + } |
| + case "abs": |
| + if len(args) == 1 { |
| + return quantity{math.Abs(args[0].v), args[0].deg}, nil |
| + } |
| + } |
| + return quantity{}, fmt.Errorf("cannot evaluate %s() with %d arguments in %q", name, len(args), p.src) |
| + } |
| + return quantity{}, fmt.Errorf("unexpected %q at %d in %q", p.src[p.pos:], p.pos, p.src) |
| +} |
| + |
| +// splitVar reads var(--name) or var(--name, fallback); the fallback is |
| +// everything after the first top-level comma, commas included. |
| +func splitVar(v string) (name, fallback string, ok bool) { |
| + if !strings.HasPrefix(v, "var(") || !strings.HasSuffix(v, ")") { |
| + return "", "", false |
| + } |
| + inner := v[len("var(") : len(v)-1] |
| + parts := splitArgs(inner) |
| + if len(parts) == 0 || !strings.HasPrefix(parts[0], "--") { |
| + return "", "", false |
| + } |
| + if len(parts) == 1 { |
| + return parts[0], "", true |
| + } |
| + return parts[0], strings.TrimSpace(inner[len(parts[0])+1:]), true |
| +} |
| + |
| +// splitArgs splits on top-level commas, so an argument that is itself a |
| +// call full of commas stays whole. |
| +func splitArgs(s string) []string { |
| + var out []string |
| + depth, start := 0, 0 |
| + for i := 0; i < len(s); i++ { |
| + switch s[i] { |
| + case '(': |
| + depth++ |
| + case ')': |
| + depth-- |
| + case ',': |
| + if depth == 0 { |
| + out = append(out, strings.TrimSpace(s[start:i])) |
| + start = i + 1 |
| + } |
| + } |
| + } |
| + return append(out, strings.TrimSpace(s[start:])) |
| +} |
| + |
| +// colourAndShare reads one color-mix() argument, "<colour> [<p>%]"; a |
| +// share of -1 means "whatever is left", which CSS fills in. |
| +func colourAndShare(arg string, table map[string]string, depth int) (rgba, float64, error) { |
| + share := -1.0 |
| + if i := strings.LastIndex(arg, " "); i > 0 && strings.HasSuffix(arg, "%") { |
| + p, err := strconv.ParseFloat(strings.TrimSuffix(arg[i+1:], "%"), 64) |
| + if err != nil { |
| + return rgba{}, 0, err |
| + } |
| + share, arg = p/100, arg[:i] |
| + } |
| + c, err := evalColour(arg, table, depth+1) |
| + return c, share, err |
| +} |
| + |
| +// mixSRGBPremultiplied is color-mix(in srgb, a pa, b pb) as CSS Color 5 |
| +// defines it: the shares normalised, the colours premultiplied by their |
| +// alpha, interpolated, and divided back out, so a mix with transparent |
| +// keeps the opaque colour's channels at the mixed alpha rather than |
| +// sliding them toward black. |
| +func mixSRGBPremultiplied(a rgba, pa float64, b rgba, pb float64) rgba { |
| + switch { |
| + case pa < 0 && pb < 0: |
| + pa, pb = 0.5, 0.5 |
| + case pa < 0: |
| + pa = 1 - pb |
| + case pb < 0: |
| + pb = 1 - pa |
| + } |
| + if sum := pa + pb; sum != 1 && sum > 0 { |
| + pa, pb = pa/sum, pb/sum |
| + } |
| + alpha := a.a*pa + b.a*pb |
| + if alpha == 0 { |
| + return rgba{} |
| + } |
| + mix := func(x, y float64) float64 { return (x*a.a*pa + y*b.a*pb) / alpha } |
| + return rgba{mix(a.r, b.r), mix(a.g, b.g), mix(a.b, b.b), alpha} |
| +} |
| + |
| +// TestDerivedColourEvaluatorReadsDaysShapes pins the evaluator at the |
| +// level a later tidy-up would touch it: each of the three shapes, the |
| +// channel math day writes, the fallback, the premultiplied mix with |
| +// transparent, and the refusal of a shape it does not read. The numbers |
| +// are the ones Chromium painted for the same expressions |
| +// (TestDaysDerivedTokensReproduceTheirDocumentedColours checks the live |
| +// theme against the same pixels). |
| +func TestDerivedColourEvaluatorReadsDaysShapes(t *testing.T) { |
| + table := map[string]string{ |
| + "--rst-accent": "#1f56d6", |
| + "--rst-surface": "#ffffff", |
| + "--rst-mixed": "color-mix(in srgb, var(--rst-accent, #ff0000) 4.5%, var(--rst-surface))", |
| + "--rst-alias": "var(--rst-mixed)", |
| + } |
| + const darkFill = "calc(0.543 - 0.06 * max(0, cos((h - 185) * 1deg) - 0.3)) min(calc(c * 1.5045), 0.215) h" |
| + for _, c := range []struct{ in, want string }{ |
| + {"color-mix(in srgb, var(--rst-accent) 80%, #000000)", "#1945ab"}, |
| + {"color-mix(in srgb, var(--rst-accent) 4.5%, var(--rst-surface))", "#f5f7fd"}, |
| + {"color-mix(in srgb, var(--rst-accent) 22%, transparent)", "rgba(31, 86, 214, 0.22)"}, |
| + {"oklch(from var(--rst-accent) 0.513 0.21 h)", "#2059dd"}, |
| + {"oklch(from var(--rst-accent) 0.513 min(calc(c * 1.0244), 0.21) h)", "#2059dd"}, |
| + {"oklch(from #7aa2ff " + darkFill + ")", "#3160ea"}, |
| + {"oklch(from #14b8a6 " + darkFill + ")", "#00816d"}, |
| + {"oklch(from #14b8a6 0.513 min(calc(c * 1.0244), 0.21) h)", "#007d6d"}, |
| + {"oklch(from #808080 0.513 min(calc(c * 1.0244), 0.21) h)", "#676767"}, |
| + {"var(--rst-alias)", "#f5f7fd"}, |
| + {"var(--rst-missing, #123456)", "#123456"}, |
| + {"var(--rst-missing, color-mix(in srgb, #000000 50%, #ffffff))", "#808080"}, |
| + } { |
| + got, err := evalColour(c.in, table, 0) |
| + if err != nil { |
| + t.Errorf("%s: %v", c.in, err) |
| + continue |
| + } |
| + if got.String() != c.want { |
| + t.Errorf("%s = %s, want %s", c.in, got, c.want) |
| + } |
| + } |
| + for _, bad := range []string{"color-mix(in oklab, var(--rst-accent) 50%, #000)", "oklch(0.5 0.2 260)", "var(--rst-missing)", "hsl(0 0% 0%)", "oklch(from #000 0.5 pow(c, 2) h)", "oklch(from #000 0.5 c)"} { |
| + if _, err := evalColour(bad, table, 0); err == nil { |
| + t.Errorf("%s evaluated; a shape the gate cannot read must be refused, not guessed", bad) |
| + } |
| + } |
| + if got := canonColours("radial-gradient(90px, color(srgb 0.121569 0.337255 0.839216 / 0.09), rgba(0, 0, 0, 0) 70%) oklch(0.513 0.21 263.298)"); got != "radial-gradient(90px, rgba(31, 86, 214, 0.09), rgba(0, 0, 0, 0) 70%) rgb(32, 89, 221)" { |
| + t.Errorf("canonColours = %s", got) |
| + } |
| + body, rest := accentSupportsBlock("a\n@supports (color: oklch(from red l c h)) {\n :root {\n --rst-x: 1;\n }\n}\nb") |
| + if !strings.Contains(body, "--rst-x: 1;") || rest != "a\nb" { |
| + t.Errorf("accentSupportsBlock: body %q rest %q", body, rest) |
| + } |
| +} |
diff --git a/check/header_rule_test.go b/check/header_rule_test.go| new file mode 100644 |
| index 0000000..5c81130 |
| --- /dev/null |
| +++ b/check/header_rule_test.go |
| @@ -0,0 +1,61 @@ |
| +package check |
| + |
| +import ( |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| +) |
| + |
| +// The rake line is retired (design doc §6-v2.2), and "retired" has to |
| +// mean one place. tokens.css is the single stylesheet every rastrillo |
| +// page links, so a pseudo-element declared there once, as content:none, |
| +// is a pseudo-element that draws nothing anywhere — provided nothing |
| +// else in the shipped CSS declares one too. |
| +// |
| +// That last clause is what this test is for. A theme is colour, type |
| +// and shape; a theme that grew a [rst-page-header]::after would put the |
| +// flourish back on one theme and nowhere else, which is both the |
| +// hardest version of this bug to see and the easiest one to write. |
| +// |
| +// The browser half of the claim — that content:none lays out no box, in |
| +// every theme, in both schemes, in ar as well as en — is |
| +// header_rule_browser_test.go's. This is the cheap half, and it runs in |
| +// the ordinary suite. |
| +func TestTheRakeLineIsDeclaredRetiredExactlyOnce(t *testing.T) { |
| + // The rule as it now stands, spelled in both markup vocabularies |
| + // because tokens.css pairs a class selector with every attribute one |
| + // until stage 3 of the migration. |
| + const retired = ".rst-page-header::after, [rst-page-header]::after {\n content: none;\n}" |
| + css := string(fiesta.TokensCSS()) |
| + if got := strings.Count(css, retired); got != 1 { |
| + t.Errorf("tokens.css declares the retired header pseudo-element %d times as\n%s\nwant exactly 1 — the whole point of retiring it in one file is that there is one place to look", got, retired) |
| + } |
| + |
| + // Nothing else may draw one. The pattern is deliberately loose (any |
| + // selector naming a page header, any pseudo-element on it) so a |
| + // flourish reintroduced under ::before, or on the titles, is caught |
| + // as readily as the exact rule that was removed. It stops at a comma |
| + // so a selector LIST is read as the selectors it is, not as one long |
| + // string that happens to start and end in the right places. |
| + headerPseudo := regexp.MustCompile(`(\.rst-page-header|\[rst-page-header)[^{;,]*::(after|before)`) |
| + for _, m := range headerPseudo.FindAllString(css, -1) { |
| + if strings.TrimSpace(m) == ".rst-page-header::after" || strings.TrimSpace(m) == "[rst-page-header]::after" { |
| + continue |
| + } |
| + t.Errorf("tokens.css draws %q on the page header; the header's one decoration is its rule, and that lives on the theme axis as --rst-header-rule", m) |
| + } |
| + for _, name := range fiesta.ThemeNames() { |
| + theme, ok := fiesta.ThemeCSS(name) |
| + if !ok { |
| + t.Fatalf("fiesta.ThemeCSS(%q) missing", name) |
| + } |
| + // A theme file has no selectors at all — it is one :root block |
| + // and two toggle rules — so any mention of the header at all is |
| + // the beginning of this bug. |
| + if strings.Contains(string(theme), "page-header") { |
| + t.Errorf("themes/%s.css mentions the page header. A theme sets --rst-header-rule and stops there: structure is tokens.css's, and a theme that styles a component has started a second stylesheet", name) |
| + } |
| + } |
| +} |
diff --git a/check/markuphelp_test.go b/check/markuphelp_test.go| index 8fd0bb0..b8af9a9 100644 |
| --- a/check/markuphelp_test.go |
| +++ b/check/markuphelp_test.go |
| @@ -7,18 +7,21 @@ import ( |
| "amadan.net/rastrillo/fiesta/internal/markup" |
| ) |
| -// What classSpelling (rig_browser_test.go) reaches in rastrillo's |
| -// untagged ui/markup_v3_test.go, verbatim: the class-to-attribute |
| -// lookup, the two regexes that find a class in a selector and in |
| -// markup, and the comment stripper. The CSS reader beside them |
| -// (parseCSSRules, splitSelectorList, specificity) is ported with the |
| -// gate that uses it. |
| +// What classSpelling (rig_browser_test.go) and the coverage gates |
| +// (coverage_test.go) reach in rastrillo's untagged ui/markup_v3_test.go |
| +// and ui/ui_test.go, verbatim: the class-to-attribute lookup, the |
| +// regexes that find a class in a selector and in markup and an rst- |
| +// attribute in an open tag, and the comment stripper. The CSS reader |
| +// beside them (parseCSSRules, splitSelectorList, specificity) is in |
| +// spelling_test.go with the gate that uses it. |
| func attributeFor(class string) (name, variant string, ok bool) { return markup.Attribute(class) } |
| var ( |
| - classInSelector = regexp.MustCompile(`\.(rst-[A-Za-z0-9_-]+)`) |
| - classInMarkup = regexp.MustCompile(`class="([^"]*)"`) |
| + classInSelector = regexp.MustCompile(`\.(rst-[A-Za-z0-9_-]+)`) |
| + classInMarkup = regexp.MustCompile(`class="([^"]*)"`) |
| + openTagInFixture = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| + rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`) |
| ) |
| func stripCSSComments(s string) string { |
diff --git a/check/rig_browser_test.go b/check/rig_browser_test.go| index 428426a..b2f4564 100644 |
| --- a/check/rig_browser_test.go |
| +++ b/check/rig_browser_test.go |
| @@ -571,11 +571,6 @@ func classSpelling(t *testing.T, html string) string { |
| return out |
| } |
| -var ( |
| - openTagInFixture = regexp.MustCompile(`<[a-zA-Z][^>]*>`) |
| - rstAttrInFixture = regexp.MustCompile(`(?:^|\s)(rst-[a-z0-9-]+)(?:="([^"]*)")?`) |
| -) |
| - |
| // ── Landed ahead of their drives ───────────────────────────────────── |
| // The helpers above arrived before the drives that call them, which |
| @@ -585,7 +580,7 @@ var ( |
| var _ = []any{ |
| requirePointer, tokensJS, typeReading{}, fontsJS, fontReading{}, |
| still, forceState, computed, rect, open, pressFallbackRig, |
| - probe, readTargets, classSpelling, |
| + probe, readTargets, classSpelling, resolveWith, |
| } |
| // ── The fixture server's own test ──────────────────────────────────── |
diff --git a/check/rows_test.go b/check/rows_test.go| new file mode 100644 |
| index 0000000..0474fbb |
| --- /dev/null |
| +++ b/check/rows_test.go |
| @@ -0,0 +1,135 @@ |
| +package check |
| + |
| +import ( |
| + "reflect" |
| + "regexp" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// A list-grid row holds one identity link: the stretched a.rst-nm or |
| +// a[rst-person] that makes the whole row its tap target. Two in one |
| +// row would overlay each other, and the later one would hide the |
| +// earlier link. Read off every example state's markup. |
| + |
| +var markupTag = regexp.MustCompile(`<(/?)([a-zA-Z][a-zA-Z0-9-]*)([^>]*?)(/?)>`) |
| + |
| +var voidElement = map[string]bool{"area": true, "br": true, "col": true, "hr": true, "img": true, "input": true, "link": true, "meta": true, "source": true, "wbr": true} |
| + |
| +var ( |
| + classValue = regexp.MustCompile(`class="([^"]*)"`) |
| + attrNamed = func(name string) *regexp.Regexp { |
| + return regexp.MustCompile(`(?:^|\s)` + name + `(?:="([^"]*)")?(?:\s|$)`) |
| + } |
| + lrowAttr = attrNamed("rst-lrow") |
| + personAttr = attrNamed("rst-person") |
| +) |
| + |
| +// hasClass reports whether attrs carries class token c, exactly. |
| +func hasClass(attrs, c string) bool { |
| + if m := classValue.FindStringSubmatch(attrs); m != nil { |
| + for _, tok := range strings.Fields(m[1]) { |
| + if tok == c { |
| + return true |
| + } |
| + } |
| + } |
| + return false |
| +} |
| + |
| +// isDataRow is a list-grid row in either spelling, not its head row. |
| +func isDataRow(attrs string) bool { |
| + a := " " + attrs + " " |
| + if m := lrowAttr.FindStringSubmatch(a); m != nil { |
| + return !strings.Contains(" "+m[1]+" ", " head ") |
| + } |
| + return hasClass(attrs, "rst-lrow") && !hasClass(attrs, "rst-lrow--head") |
| +} |
| + |
| +// isIdentityLink is a.rst-nm or a person link, in either spelling. |
| +func isIdentityLink(attrs string) bool { |
| + return hasClass(attrs, "rst-nm") || hasClass(attrs, "rst-person") || personAttr.MatchString(" "+attrs+" ") |
| +} |
| + |
| +// identityLinksPerRow returns, for every [rst-lrow] data row in markup, |
| +// how many of its DIRECT children are identity links (a.rst-nm or |
| +// a[rst-person]). A small depth-tracking reader is enough: the samples |
| +// are hand-written, well-formed markup. |
| +func identityLinksPerRow(markup string) []int { |
| + var out []int |
| + type frame struct { |
| + row bool |
| + slot int |
| + } |
| + var stack []frame |
| + for _, m := range markupTag.FindAllStringSubmatch(markup, -1) { |
| + closing, name, attrs, self := m[1] == "/", strings.ToLower(m[2]), m[3], m[4] == "/" |
| + if closing { |
| + if len(stack) > 0 { |
| + stack = stack[:len(stack)-1] |
| + } |
| + continue |
| + } |
| + parentRow := len(stack) > 0 && stack[len(stack)-1].row |
| + if parentRow && name == "a" && isIdentityLink(attrs) { |
| + out[stack[len(stack)-1].slot]++ |
| + } |
| + if self || voidElement[name] { |
| + continue |
| + } |
| + f := frame{} |
| + if isDataRow(attrs) { |
| + f = frame{row: true, slot: len(out)} |
| + out = append(out, 0) |
| + } |
| + stack = append(stack, f) |
| + } |
| + return out |
| +} |
| + |
| +func TestEveryListGridRowHasOneIdentityLink(t *testing.T) { |
| + pieces, err := example.All(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + rows := 0 |
| + for _, p := range pieces { |
| + for i, n := range identityLinksPerRow(p.Markup) { |
| + rows++ |
| + if n > 1 { |
| + t.Errorf("example %s/%s row %d has %d identity links; one per row, or the later overlay hides the earlier link", p.Name, p.State, i, n) |
| + } |
| + } |
| + } |
| + if rows == 0 { |
| + t.Fatal("found no list-grid data rows in the examples; the reader is broken") |
| + } |
| + // The reader itself, both ways. |
| + two := `<div rst-lrow><a class="rst-nm" href="/a">A</a><a rst-person href="/p"><span rst-person-name>P</span></a></div>` |
| + if got := identityLinksPerRow(two); len(got) != 1 || got[0] != 2 { |
| + t.Errorf("a row with two identity links reads as %v, want [2]", got) |
| + } |
| + nested := `<div rst-lrow><span><a class="rst-nm" href="/a">A</a></span><a class="rst-nm" href="/b">B</a></div>` |
| + if got := identityLinksPerRow(nested); len(got) != 1 || got[0] != 1 { |
| + t.Errorf("a nested link counted as a direct child: %v, want [1]", got) |
| + } |
| + // markup-spelling: old-spelling begin — the reader must count the |
| + // class spelling too, which apps still write, so these cases use it. |
| + for _, c := range []struct { |
| + markup string |
| + want []int |
| + }{ |
| + {`<div class="rst-lrow"><a class="rst-nm" href="/a">A</a><a class="rst-person" href="/p">P</a></div>`, []int{2}}, |
| + {`<div rst-lrow=""><a class="rst-nm" href="/a">A</a><a rst-person href="/p">P</a></div>`, []int{2}}, |
| + {`<div rst-lrow="head"><a class="rst-nm" href="/a">A</a></div><div class="rst-lrow rst-lrow--head"><a class="rst-nm" href="/a">A</a></div>`, nil}, |
| + {`<div rst-lrow><a class="rst-nm-extra" href="/a">A</a></div>`, []int{0}}, |
| + // markup-spelling: old-spelling end |
| + } { |
| + if got := identityLinksPerRow(c.markup); !reflect.DeepEqual(got, c.want) { |
| + t.Errorf("identityLinksPerRow(%s) = %v, want %v", c.markup, got, c.want) |
| + } |
| + } |
| +} |
diff --git a/check/sizing_test.go b/check/sizing_test.go| new file mode 100644 |
| index 0000000..6dd55a4 |
| --- /dev/null |
| +++ b/check/sizing_test.go |
| @@ -0,0 +1,509 @@ |
| +package check |
| + |
| +import ( |
| + "os" |
| + "regexp" |
| + "slices" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| +) |
| + |
| +// The text half of the touch-size gates: the zoom floors, the type step, |
| +// the target floor and the tap inventory, read off tokens.css as text. |
| +// The browser half, which measures what the floors produce, reads the |
| +// same inventory from the drives. |
| + |
| +// touchQuery is the one media query every touch-only rule sits in (§1.1 |
| +// of the mobile ergonomics design spec). Written once so a typo in one |
| +// floor cannot quietly scope it to a query nothing matches. |
| +const touchQuery = "@media (pointer: coarse), (max-width: 40rem) {" |
| + |
| +// zoomFloorRule finds a floor: the touch query holding exactly one rule |
| +// whose only declaration is the floor. Read off comment-stripped CSS. |
| +var zoomFloorRule = regexp.MustCompile(`@media \(pointer: coarse\), \(max-width: 40rem\) \{\s*([^{}]+?)\s*\{\s*font-size: max\(1rem, 1em\);\s*\}\s*\}`) |
| + |
| +// bareZoomFloor is the zero-weight floor for an app's own inputs: an |
| +// input with no rst- attribute is sized by the UA stylesheet, which any |
| +// author rule beats, so :where() still lifts it and any app rule that |
| +// sizes it still wins. |
| +const bareZoomFloor = ":where(input:not([type=checkbox]):not([type=radio]):not([type=range]), select, textarea)" |
| + |
| +// TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin is §1.3's position |
| +// gate. Each component floor must come DIRECTLY after the rule that |
| +// resets its font (`font: inherit` resets font-size, so a floor before |
| +// it is switched off) and, for the input, before the rule that makes |
| +// the primary field big (at equal weight the later rule wins, and |
| +// max(1rem, 1em) would pull the 19px field down to 16px — the 1em trap |
| +// this rewrite exists to remove). Moving any floor one rule up or down |
| +// fails here. |
| +func TestTheZoomFloorsSitWhereTheyCannotBeResetOrWin(t *testing.T) { |
| + css := stripCSSComments(string(fiesta.TokensCSS())) |
| + floors := map[string][2]int{} |
| + for _, m := range zoomFloorRule.FindAllStringSubmatchIndex(css, -1) { |
| + sel := collapseSpace(css[m[2]:m[3]]) |
| + if _, dup := floors[sel]; dup { |
| + t.Errorf("two zoom floors for %q", sel) |
| + } |
| + floors[sel] = [2]int{m[0], m[1]} |
| + } |
| + if len(floors) != 4 { |
| + t.Errorf("tokens.css carries %d zoom floors, want 4 (bare elements, input, textarea, search): %v", len(floors), floors) |
| + } |
| + if _, ok := floors[bareZoomFloor]; !ok { |
| + t.Errorf("no bare-element floor spelled %q", bareZoomFloor) |
| + } |
| + if !strings.HasPrefix(bareZoomFloor, ":where(") || !strings.HasSuffix(bareZoomFloor, ")") || specificity(bareZoomFloor) != [3]int{} { |
| + t.Errorf("the bare-element floor is not wholly inside :where(): %v", specificity(bareZoomFloor)) |
| + } |
| + base := func(prelude string) (start, end int) { |
| + loc := regexp.MustCompile(`(?m)^` + regexp.QuoteMeta(prelude) + ` \{`).FindStringIndex(css) |
| + if loc == nil { |
| + t.Fatalf("tokens.css has no top-level rule %q", prelude) |
| + } |
| + close := strings.IndexByte(css[loc[1]:], '}') |
| + return loc[0], loc[1] + close + 1 |
| + } |
| + for _, c := range []struct{ floor, after, before string }{ |
| + {".rst-input, [rst-input]", ".rst-input, [rst-input]", `.rst-input--primary, [rst-input~="primary"]`}, |
| + {".rst-textarea, [rst-textarea]", ".rst-textarea, [rst-textarea]", ""}, |
| + {`.rst-search input[type="search"], [rst-search] input[type="search"]`, `.rst-search input[type="search"], [rst-search] input[type="search"]`, ""}, |
| + } { |
| + at, ok := floors[c.floor] |
| + if !ok { |
| + t.Errorf("no zoom floor for %q", c.floor) |
| + continue |
| + } |
| + _, end := base(c.after) |
| + // A floor above its reset is the dead-floor case itself; slicing |
| + // the gap backwards would panic instead of naming it. |
| + if at[0] < end { |
| + t.Errorf("the floor for %q is not directly after the rule that resets its font: it comes before it, so `font: inherit` switches it off", c.floor) |
| + } else if gap := strings.TrimSpace(css[end:at[0]]); gap != "" { |
| + t.Errorf("the floor for %q is not directly after the rule that resets its font; between them:\n%.200s", c.floor, gap) |
| + } |
| + if c.before != "" { |
| + if start, _ := base(c.before); at[1] > start { |
| + t.Errorf("the floor for %q comes after %q, so it overrides the field that is big on purpose", c.floor, c.before) |
| + } |
| + } |
| + } |
| + // The block this replaces: one pointer-only query sizing the |
| + // components at a weight that beat the primary field. |
| + if strings.Contains(css, "@media (pointer: coarse) {") { |
| + t.Error("tokens.css still has a pointer-only query; every touch rule uses the one query of §1.1") |
| + } |
| +} |
| + |
| +// The type step itself (§1.2), read as text so a desktop-only edit |
| +// cannot move it: the four tokens, in the touch query, at :root. |
| +func TestTheTypeScaleMovesUpOneStepOnSmallOrTouchScreens(t *testing.T) { |
| + css := stripCSSComments(string(fiesta.TokensCSS())) |
| + re := regexp.MustCompile(regexp.QuoteMeta(touchQuery) + `\s*:root\s*\{([^{}]*)\}`) |
| + m := re.FindStringSubmatch(css) |
| + if m == nil { |
| + t.Fatal("no :root block inside the touch query") |
| + } |
| + for _, want := range []string{"--rst-fs-lg: 1.1875rem;", "--rst-fs-base: 1rem;", "--rst-fs-sm: 0.875rem;", "--rst-fs-xs: 0.8125rem;"} { |
| + if !strings.Contains(m[1], want) { |
| + t.Errorf("the touch :root block lacks %q:\n%s", want, m[1]) |
| + } |
| + } |
| + if !regexp.MustCompile(`--rst-tap:\s*2\.75rem;`).MatchString(css) { |
| + t.Error("--rst-tap: 2.75rem is not declared") |
| + } |
| + // The two components that spelled the xs step as a literal paint |
| + // with the token now, or they would stay 11.5px on a phone. |
| + for _, sel := range []string{".rst-lrow--head, [rst-lrow~=\"head\"]", ".rst-tip::after, [rst-tip]::after"} { |
| + loc := strings.Index(css, sel+" {") |
| + if loc < 0 { |
| + t.Fatalf("no rule %q", sel) |
| + } |
| + body := css[loc : loc+strings.IndexByte(css[loc:], '}')] |
| + if strings.Contains(body, "0.71875rem") || !strings.Contains(body, "font-size: var(--rst-fs-xs)") { |
| + t.Errorf("%s does not paint with var(--rst-fs-xs): %s", sel, body) |
| + } |
| + } |
| +} |
| + |
| +// TestTheTargetFloorIsOneToken pins the floor's numbers as text, so a |
| +// desktop-only edit cannot move the phone's floor or the reverse: every |
| +// floor reads --rst-target, 2rem (32px) at :root and raised to --rst-tap |
| +// (44px) by a :root block in the touch query. A floor that read |
| +// --rst-tap directly would be 44px on a desktop too, or, moved out of |
| +// the touch query, the 32px desktop floor would never reach it. |
| +func TestTheTargetFloorIsOneToken(t *testing.T) { |
| + css := stripCSSComments(string(fiesta.TokensCSS())) |
| + root := regexp.MustCompile(`(?m)^:root \{([^{}]*)\}`).FindStringSubmatch(css) |
| + if root == nil || !regexp.MustCompile(`--rst-target:\s*2rem;`).MatchString(root[1]) { |
| + t.Error("the top-level :root does not declare --rst-target: 2rem, the desktop floor") |
| + } |
| + raised := false |
| + for _, m := range regexp.MustCompile(regexp.QuoteMeta(touchQuery)+`\s*:root\s*\{([^{}]*)\}`).FindAllStringSubmatch(css, -1) { |
| + raised = raised || regexp.MustCompile(`--rst-target:\s*var\(--rst-tap\);`).MatchString(m[1]) |
| + } |
| + if !raised { |
| + t.Error("no :root block in the touch query raises --rst-target to var(--rst-tap)") |
| + } |
| + if m := regexp.MustCompile(`(?:min-)?(?:block|inline)-size:\s*var\(--rst-tap\)`).FindString(css); m != "" { |
| + t.Errorf("a rule sizes a control with %q; floors read --rst-target, so the desktop gets its own floor", m) |
| + } |
| + // Each control in the inventory has its floor, read through the |
| + // token, outside the touch query: a floor only the touch query |
| + // holds leaves the control at its old size on a desktop. Named |
| + // rather than counted, so a control that loses its floor fails by |
| + // name, and two floors merged into one rule pass. |
| + everyWidth := withoutTouchQueries(css) |
| + sized := regexp.MustCompile(`(?:^|;)\s*(?:min-)?(?:block|inline)-size:\s*var\(--rst-target\)`) |
| + for _, e := range tapInventory { |
| + if why, ok := floorElsewhere[e.Key]; ok { |
| + if why == "" { |
| + t.Errorf("floorElsewhere names %q with no reason", e.Key) |
| + } |
| + continue |
| + } |
| + found := false |
| + for _, r := range leafRules(everyWidth) { |
| + if !sized.MatchString(r.body) { |
| + continue |
| + } |
| + for _, s := range splitSelectorList(r.selector) { |
| + found = found || strings.Contains(s, e.Key) |
| + } |
| + } |
| + if !found { |
| + t.Errorf("%s (%s): no rule outside the touch query sizes it with var(--rst-target)", e.Idiom, e.Key) |
| + } |
| + } |
| + for key := range floorElsewhere { |
| + found := false |
| + for _, e := range tapInventory { |
| + found = found || e.Key == key |
| + } |
| + if !found { |
| + t.Errorf("floorElsewhere names %q, which is not in the inventory", key) |
| + } |
| + } |
| +} |
| + |
| +// floorBlock cuts the every-width floor block out of tokens.css: the |
| +// css without it, and the block's own text. |
| +func floorBlock(t *testing.T) (without, block string) { |
| + t.Helper() |
| + css := string(fiesta.TokensCSS()) |
| + start := strings.Index(css, "/* ── Target floors, at every width") |
| + end := strings.Index(css, "/* ---------------------------------------------------------------------\n Utilities, last on purpose.") |
| + if start < 0 || end < start { |
| + t.Fatal("tokens.css has no floor block between its heading and the utilities; the markers this test cuts on have moved") |
| + } |
| + return css[:start] + css[end:], css[start:end] |
| +} |
| + |
| +// floorDeclarations is every property the every-width floor block may |
| +// set, with the values it may set it to (none listed: any value) and |
| +// why the floor needs it. The block reaches a desktop, where a control |
| +// already over 32px must come out of it as it went in, so anything that |
| +// is not a size or what makes the size take hold belongs in the touch |
| +// query instead. |
| +var floorDeclarations = map[string]struct { |
| + values []string |
| + why string |
| +}{ |
| + "min-block-size": {nil, "the floor itself"}, |
| + "min-inline-size": {nil, "the floor itself"}, |
| + "block-size": {nil, "the floor, on a control whose size is fixed (an icon button)"}, |
| + "inline-size": {nil, "the floor, on a control whose size is fixed (an icon button)"}, |
| + "box-sizing": {[]string{"border-box"}, "the floor is the whole control; on the content box its padding and border landed on top, and a 33.8px summary became 44.8"}, |
| + "display": {[]string{"inline-flex"}, "min sizes do nothing on an inline box, so a control born inline (a link) becomes an inline-flex box the floor can grow"}, |
| + "align-items": {[]string{"center"}, "centres the content of an inline-flex control in the box the floor grows"}, |
| + "justify-content": {[]string{"center"}, "the same, across: an icon-only link or the row checkbox's 16px box in the label the floor widens"}, |
| + "align-content": {[]string{"center"}, "centres a block's content in the floor without making it a flex box, which would drop the spaces in its label"}, |
| + "column-gap": {nil, "puts back the space a flex box drops between an icon and its word; acts only where the floor made the flex box"}, |
| + "padding-block": {[]string{"0"}, "the search box: its input and clear link are the targets and fill its height at the floor; with its own block padding on top a desktop box grew from 35.6 to 43.6"}, |
| + "align-self": {[]string{"stretch"}, "the search input stretches to the box's height, so the floor reaches the target a tap focuses"}, |
| + "flex": {[]string{"none"}, "the bulk bar's close link, which its flex row shrank past the floor"}, |
| +} |
| + |
| +// TestTheFloorBlockCarriesOnlySizes reads every declaration in the |
| +// every-width floor block against floorDeclarations. The browser check |
| +// beside it (TestTheFloorChangesNothingButSizeOnADesktop) compares the |
| +// properties and boxes it knows to read, so a floor rule that also |
| +// moved a control (position: relative and an inset, a margin) left its |
| +// size and every property it reads alone, and passed. A property is |
| +// either here with its reason, or it goes in the touch query. |
| +func TestTheFloorBlockCarriesOnlySizes(t *testing.T) { |
| + _, block := floorBlock(t) |
| + rules := leafRules(stripCSSComments(block)) |
| + if len(rules) < 30 { |
| + t.Fatalf("read %d rules in the floor block; the parse no longer finds them", len(rules)) |
| + } |
| + used := map[string]bool{} |
| + for _, r := range rules { |
| + for _, d := range strings.Split(r.body, ";") { |
| + if d = strings.TrimSpace(d); d == "" { |
| + continue |
| + } |
| + prop, value, ok := strings.Cut(d, ":") |
| + if !ok { |
| + t.Errorf("%s: cannot read the declaration %q", r.selector, d) |
| + continue |
| + } |
| + prop, value = strings.TrimSpace(prop), strings.TrimSpace(value) |
| + used[prop] = true |
| + allowed, ok := floorDeclarations[prop] |
| + if !ok { |
| + t.Errorf("%s sets %s: %s in the floor block, which reaches a desktop; a declaration that is not a size or what makes one take hold goes in the touch query", firstSelector(r.selector), prop, value) |
| + continue |
| + } |
| + if allowed.values != nil && !slices.Contains(allowed.values, value) { |
| + t.Errorf("%s sets %s: %s in the floor block; the floor needs %s only as %s", firstSelector(r.selector), prop, value, prop, strings.Join(allowed.values, " or ")) |
| + } |
| + } |
| + } |
| + // An entry nothing uses would let the next rule set it unexamined. |
| + for prop := range floorDeclarations { |
| + if !used[prop] { |
| + t.Errorf("floorDeclarations allows %s, which no rule in the floor block sets; take it out", prop) |
| + } |
| + } |
| +} |
| + |
| +// firstSelector names a rule by the first selector in its list, enough |
| +// to find it in the block. |
| +func firstSelector(sel string) string { |
| + if s := splitSelectorList(sel); len(s) > 0 { |
| + return s[0] |
| + } |
| + return sel |
| +} |
| + |
| +// floorElsewhere are the inventory's controls whose size does not come |
| +// from --rst-target, each with where it does come from. |
| +var floorElsewhere = map[string]string{ |
| + "[rst-shell-profile] > summary": "the avatar inside it, with the summary's padding, is the target", |
| + "[rst-shell-profile][open] > summary": "the same avatar, open", |
| + "[rst-shell-nav] > a": "the phone index's rows are 3rem, over the floor, in their own query", |
| + "[rst-shell-back] > a": "its own 2.75rem height is over the floor; only its width reads the token", |
| +} |
| + |
| +// withoutTouchQueries is css with every touch query, header, braces and |
| +// all, cut out. |
| +func withoutTouchQueries(css string) string { |
| + var b strings.Builder |
| + for { |
| + i := strings.Index(css, touchQuery) |
| + if i < 0 { |
| + break |
| + } |
| + end := braceMatchEnd(css, i+len(touchQuery)) |
| + b.WriteString(css[:i]) |
| + css = css[end+1:] |
| + } |
| + b.WriteString(css) |
| + return b.String() |
| +} |
| + |
| +// tapEntry is one row of spec §1.4's table: Key is a substring of the |
| +// attribute spelling of the rules that style the idiom, Marker a string |
| +// the sizing fixture (or the modal sample) must contain for the idiom to |
| +// be measured at all (for controls a script draws at run time, the |
| +// attribute that asks for them), and Probe the selector of the element |
| +// the target drive measures for it. The drive fails if no element |
| +// matching Probe was measured, so an idiom cannot drop out of the |
| +// measurement silently. Probe may be prefixed "narrow:" (rendered only |
| +// below 800px, so not expected at 1024), "modal:" (on the modal page), |
| +// or be "elsewhere:<Test>" for a control a drive of its own measures in |
| +// a state the sizing page never reaches. |
| +type tapEntry struct{ Key, Idiom, Marker, Probe string } |
| + |
| +var tapInventory = []tapEntry{ |
| + {"[rst-btn]", "Buttons, all sizes", "rst-btn", "[rst-btn]"}, |
| + {"[rst-input]", "Inputs and selects", "rst-input", "[rst-input]"}, |
| + {"[rst-textarea]", "Textareas", "rst-textarea", "[rst-textarea]"}, |
| + {"[rst-search]", "Search box", "rst-search", "[rst-search] input[type=search]"}, |
| + {"[rst-search-clear]", "Search box: its clear link", "rst-search-clear", "[rst-search-clear]"}, |
| + {"[rst-ftok] a", "Filter chip's remove link", "rst-ftok", "[rst-ftok] a"}, |
| + {"[rst-help]", "Help link", "rst-help", "[rst-help]"}, |
| + {"[rst-dropdown] > summary", "Dropdown summaries (list-bar, header, account, locale)", "rst-dropdown", "[rst-dropdown] > summary"}, |
| + {"[rst-menu-group] > summary", "Nested menu-group summaries", "rst-menu-group", "[rst-menu-group] > summary"}, |
| + {"[rst-dropdown-menu] a", "Menu items: dropdown", "rst-dropdown-menu", "[rst-dropdown-menu] a"}, |
| + {"[rst-dropdown-menu] button", "Menu items: dropdown buttons", "rst-dropdown-menu", "[rst-dropdown-menu] button"}, |
| + {"[rst-row-menu-panel] a", "Menu items: row menu", "rst-row-menu-panel", "[rst-row-menu-panel] a"}, |
| + {"[rst-row-menu-panel] button", "Menu items: row menu buttons", "rst-row-menu-panel", "[rst-row-menu-panel] button"}, |
| + {"[rst-locale] button", "Menu items: locale", "rst-locale", "[rst-locale] button"}, |
| + {"[rst-combo-option]", "Combobox options", "data-rst-select", "[rst-combo-option]"}, |
| + {"[rst-dtp-row]", "Date-picker rows", "data-rst-date", "[rst-dtp-row]"}, |
| + {"[rst-dtp-pick]", "Date-picker pick button", "data-rst-date", "[rst-dtp-pick]"}, |
| + {"[rst-cal-nav]", "Calendar nav", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"}, |
| + {"[rst-cal-day]", "Calendar days", "data-rst-date", "elsewhere:TestTheCalendarDocksAndItsDaysAreTaps"}, |
| + {"[rst-row-action]", "Row action pill", "rst-row-action", "[rst-row-action]"}, |
| + {"[rst-row-menu] > summary", "Row kebab", "rst-row-menu", "[rst-row-menu] > summary"}, |
| + {"[rst-selbox]", "Row checkbox", "rst-selbox", "[rst-selbox] input"}, |
| + {"[rst-person]", "Standalone person link", "rst-person", "a[rst-person]"}, |
| + {"[rst-pagination] > a", "Pagination chips", "rst-pagination", "[rst-pagination] > a"}, |
| + {"[rst-seg-tabs] a", "Segmented tabs", "rst-seg-tabs", "[rst-seg-tabs] a"}, |
| + {"[rst-switch]", "Switch", "rst-switch", "[rst-switch] input"}, |
| + {"[rst-choice-cards] label", "Choice cards", "rst-choice-cards", "[rst-choice-cards] input"}, |
| + {"[rst-tblock-head]", "Toggle-block head", "rst-tblock-head", "[rst-tblock-head] input"}, |
| + {"[rst-bulkbar-close]", "Bulk bar: close", "rst-bulkbar-close", "[rst-bulkbar-close]"}, |
| + {"[rst-bulkbar-escalate]", "Bulk bar: escalate link", "rst-bulkbar-escalate", "[rst-bulkbar-escalate]"}, |
| + {"[rst-modal-close]", "Modal close", "rst-modal-close", "modal:[rst-modal-close]"}, |
| + {"[rst-modal-panel] > nav a", "Modal panel nav links", "rst-modal-panel", "modal:[rst-modal-panel] > nav a"}, |
| + {"[rst-back-nav] a", "Back-nav link", "rst-back-nav", "[rst-back-nav] a"}, |
| + {"[rst-signin-providers] > a", "Sign-in: the link under the provider buttons", "rst-signin-providers", "[rst-signin-providers] > a"}, |
| + {"[rst-shell-brand]", "Shell: brand", "rst-shell-brand", "[rst-shell-brand]"}, |
| + {"[rst-shell-nav] a", "Shell: nav links", "rst-shell-nav", "[rst-shell-nav] a"}, |
| + {"[rst-shell-menu] > summary", "Shell: Menu summary", "rst-shell-menu", "narrow:[rst-shell-menu] > summary"}, |
| + {"[rst-shell-menu][open] > summary", "Shell: the open Menu summary", "rst-shell-menu", "elsewhere:TestTheCardsControlsAreTaps"}, |
| + {"[rst-shell-account] > summary", "Shell: the account summary inside the card", "rst-shell-account", "[rst-shell-account] > summary"}, |
| + {"[rst-shell-chrome] > summary", "Legacy sidebar drawer summary", "rst-shell-chrome", "narrow:[rst-shell-chrome] > summary"}, |
| + {"[rst-shell-profile] > summary", "Shell: the profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"}, |
| + {"[rst-shell-profile][open] > summary", "Shell: the open profile menu's avatar", "rst-shell-profile", "elsewhere:TestTheProfileMenuOnThePhoneIndex"}, |
| + {"[rst-shell-back] > a", "Shell: the back control", "rst-shell-back", "narrow:[rst-shell-back] > a"}, |
| + {"[rst-shell-nav] > a", "Shell: the index rows (3rem)", "rst-shell-nav", "elsewhere:TestTheIndexRowsRoundEachRunOfLinks"}, |
| +} |
| + |
| +// tapExempt are the interactive rules that are deliberately not held to |
| +// the floor, each with its reason. Nothing else may be added here: a |
| +// control that does not fit the floor gets a rule in the floor block. |
| +var tapExempt = map[string]string{ |
| + "[rst-row-main] > a": "the stretched primary link: the tap target is the whole row (spec §2)", |
| + "[rst-media-name] > a": "a media-grid tile's stretched name link: the tap target is the whole tile, frame and caption", |
| + "a.rst-nm": "the list grid's stretched identity link: the tap target is the whole row (spec §2)", |
| + "[rst-lrow] > a[rst-person]": "the list grid's stretched person link: the tap target is the whole row (spec §2)", |
| + ".rst-lrow > a.rst-person": "the class spelling of the same stretched person link", |
| + "[rst-no-match] a": "a link in running text, WCAG 2.5.8's inline exception (spec §1.4, Exempt)", |
| + ".rst-sr-only:focus-visible": "the hidden search submit: it exists for the keyboard and un-hides on focus; no pointer reaches it", |
| +} |
| + |
| +// iconFree are the inventory's controls that hold no icon of their own, |
| +// so the rule sizing an icon inside a control need not name them. |
| +var iconFree = map[string]string{ |
| + "[rst-input]": "a field: its text, not an icon", |
| + "[rst-textarea]": "a field: its text, not an icon", |
| + "[rst-cal-day]": "a day's number", |
| + "[rst-selbox]": "a checkbox", |
| + "[rst-person]": "an avatar, which is not an icon", |
| + "[rst-shell-profile] > summary": "an avatar, which is not an icon", |
| + "[rst-shell-profile][open] > summary": "an avatar, which is not an icon", |
| + "[rst-shell-menu][open] > summary": "the same summary as [rst-shell-menu] > summary, open", |
| + "[rst-shell-nav] > a": "the index rows, which [rst-shell-nav] a names", |
| +} |
| + |
| +// TestEveryControlSizesTheIconInIt holds the two lists together: an |
| +// icon is 16px inside a control and follows its text everywhere else, |
| +// and "a control" is the inventory the target floor implements. A |
| +// control added to the floor and not to the icon rule would show its |
| +// icon at whatever size its text happens to be, which is what the 16px |
| +// rule exists to stop. |
| +func TestEveryControlSizesTheIconInIt(t *testing.T) { |
| + var sel string |
| + for _, r := range leafRules(stripCSSComments(string(fiesta.TokensCSS()))) { |
| + if strings.HasPrefix(r.selector, ":where(") && strings.Contains(r.selector, ") .icon") && strings.Contains(r.body, "block-size: 1rem") { |
| + sel = collapseSpace(r.selector) |
| + } |
| + } |
| + if sel == "" { |
| + t.Fatal("tokens.css has no :where(...) .icon rule sizing icons in controls at 1rem") |
| + } |
| + for _, e := range tapInventory { |
| + if _, free := iconFree[e.Key]; free { |
| + continue |
| + } |
| + if !strings.Contains(sel, e.Key) { |
| + t.Errorf("%s (%s) is a control the floor holds, and the rule that sizes an icon inside a control does not name it", e.Idiom, e.Key) |
| + } |
| + } |
| + for key := range iconFree { |
| + found := false |
| + for _, e := range tapInventory { |
| + found = found || e.Key == key |
| + } |
| + if !found { |
| + t.Errorf("iconFree names %q, which is not in the inventory", key) |
| + } |
| + } |
| +} |
| + |
| +// endsOnControlElement reports whether a selector's last compound is an |
| +// a, button, summary or label: the rule styles that element. |
| +func endsOnControlElement(sel string) bool { |
| + depth, last := 0, 0 |
| + for i := 0; i < len(sel); i++ { |
| + switch c := sel[i]; c { |
| + case '(', '[': |
| + depth++ |
| + case ')', ']': |
| + depth-- |
| + case ' ', '>', '+', '~': |
| + if depth == 0 { |
| + last = i + 1 |
| + } |
| + } |
| + } |
| + return regexp.MustCompile(`^(a|button|summary|label)($|[^a-zA-Z0-9_-])`).MatchString(strings.TrimSpace(sel[last:])) |
| +} |
| + |
| +// interactiveCSSRules is every innermost rule in css that carries cursor: |
| +// pointer or styles an a, button, summary or label. |
| +func interactiveCSSRules(css string) []leafRule { |
| + var out []leafRule |
| + for _, r := range leafRules(stripCSSComments(css)) { |
| + hit := strings.Contains(r.body, "cursor: pointer") |
| + for _, s := range splitSelectorList(r.selector) { |
| + hit = hit || endsOnControlElement(s) |
| + } |
| + if hit { |
| + out = append(out, r) |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// TestEveryInteractiveRuleIsInTheTapInventory is §10.1's inventory |
| +// gate: a new control cannot arrive in tokens.css without a decision |
| +// about its touch size, because its rule has to name an idiom in the |
| +// table above (or an exemption with a reason), and every idiom in the |
| +// table has to be on the page the browser drive measures. |
| +func TestEveryInteractiveRuleIsInTheTapInventory(t *testing.T) { |
| + rules := interactiveCSSRules(string(fiesta.TokensCSS())) |
| + if len(rules) < 40 { |
| + t.Fatalf("found only %d interactive rules in tokens.css; the reader is broken, not the file", len(rules)) |
| + } |
| + for _, r := range rules { |
| + named := false |
| + for _, s := range splitSelectorList(r.selector) { |
| + for _, e := range tapInventory { |
| + named = named || strings.Contains(s, e.Key) |
| + } |
| + for key := range tapExempt { |
| + named = named || strings.Contains(s, key) |
| + } |
| + } |
| + if !named { |
| + t.Errorf("tokens.css styles a control no row of §1.4's inventory names:\n\t%s\n\tadd a floor rule and a tapInventory row, or say why it is exempt", r.selector) |
| + } |
| + } |
| + // The page the drives measure is the sizing fixture plus every |
| + // example state, the modal among them. |
| + fixture, err := os.ReadFile("testdata/fixtures/sizing.html") |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + pieces, err := example.All(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + var page strings.Builder |
| + page.Write(fixture) |
| + for _, p := range pieces { |
| + page.WriteString(p.Markup) |
| + } |
| + for _, e := range tapInventory { |
| + if !strings.Contains(page.String(), e.Marker) { |
| + t.Errorf("%s (%s): neither the sizing fixture nor any example renders anything carrying %q, so the drive never measures it", e.Idiom, e.Key, e.Marker) |
| + } |
| + } |
| +} |
diff --git a/check/spelling_test.go b/check/spelling_test.go| new file mode 100644 |
| index 0000000..65c96d4 |
| --- /dev/null |
| +++ b/check/spelling_test.go |
| @@ -0,0 +1,471 @@ |
| +package check |
| + |
| +// markup-spelling: old-spelling begin — this whole file is about the |
| +// two spellings and has to be able to write both. It ends at the file's |
| +// end; there is no closing marker. |
| + |
| +// The stage-1 gate for the markup migration (design spec §6-v3). |
| +// |
| +// tokens.css is moving from a class vocabulary to an attribute one: |
| +// `<div rst-box>` where an app writes `class="rst-box"` today. Stage 1 |
| +// is the non-breaking half — every rule names BOTH spellings, so an app |
| +// can begin writing attributes while every app and every shipped |
| +// partial writing classes keeps styling. Stage 2 flips the partials, |
| +// the scaffold, the docs and the examples in one commit; stage 3 drops |
| +// the class selectors before 1.0. |
| +// |
| +// The thing that has to be enforced rather than done once is the |
| +// pairing. A class added to tokens.css in stage 2's absence would |
| +// otherwise arrive with no attribute twin, and nobody would find out |
| +// until an app wrote the attribute and got nothing. So this file parses |
| +// tokens.css and holds the invariant in both directions: every class |
| +// selector has its twin in the same rule, and every attribute selector |
| +// is some class selector's twin — no orphans in either column. |
| +// |
| +// It also checks the part that could go wrong silently. A class and an |
| +// attribute selector both weigh (0,1,0), so a pair is specificity- |
| +// neutral and no cascade moves. That is a fact about CSS, and this file |
| +// computes it per selector rather than trusting it, because the file |
| +// has rules wrapped in :where() (weightless) and rules using :not() and |
| +// :has() (weighted by their heaviest argument), which is exactly where |
| +// a careless twin would shift the cascade and change how an app that |
| +// changed nothing renders. |
| +// |
| +// The string check is necessary and not sufficient: two selectors can |
| +// pair perfectly on paper and still paint differently. The browser |
| +// drive in markup_v3_browser_test.go is the evidence — it renders the |
| +// same fixture in both spellings and requires the computed styles to |
| +// match, property by property, pseudo-elements included. |
| + |
| +import ( |
| + "sort" |
| + "strings" |
| + "testing" |
| + |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/internal/markup" |
| +) |
| + |
| +// ── The grammar ────────────────────────────────────────────────────── |
| +// |
| +// It lives in internal/markup, not here. The stage-2 codemod |
| +// (`rastrillo markup`) translates markup with the same rules this file |
| +// checks tokens.css against, and two implementations of one grammar |
| +// agree until the day they do not. attributeTwin is that package's |
| +// Selector; the exemption list is its Utilities. attributeFor, the |
| +// class-to-attribute lookup, and the two regexps that find a class in |
| +// a selector and in markup are in markuphelp_test.go. |
| + |
| +var classKeepsItsSpelling = markup.Utilities |
| + |
| +func attributeTwin(selector string) string { return markup.Selector(selector) } |
| + |
| +// ── A small CSS reader ─────────────────────────────────────────────── |
| + |
| +// cssRule is one rule's selector list, split on top-level commas, and |
| +// the line its prelude ends on so a failure can be walked to. |
| +type cssRule struct { |
| + selectors []string |
| + line int |
| +} |
| + |
| +// parseCSSRules reads tokens.css into its rules. It is deliberately |
| +// small — comment-aware, string-aware, brace-counting — because |
| +// tokens.css is a hand-written file with no preprocessor, and a |
| +// full CSS parser would be a dependency this file has no need of. The |
| +// preludes it returns include at-rules (@media, @supports); those carry |
| +// no rst- selector and fall out of every check below on their own. |
| +func parseCSSRules(css string) []cssRule { |
| + var rules []cssRule |
| + start, i, n := 0, 0, len(css) |
| + for i < n { |
| + if strings.HasPrefix(css[i:], "/*") { |
| + if j := strings.Index(css[i+2:], "*/"); j >= 0 { |
| + i += 2 + j + 2 |
| + } else { |
| + i = n |
| + } |
| + continue |
| + } |
| + switch c := css[i]; c { |
| + case '"', '\'': |
| + i++ |
| + for i < n && css[i] != c { |
| + if css[i] == '\\' { |
| + i++ |
| + } |
| + i++ |
| + } |
| + i++ |
| + case '{': |
| + rules = append(rules, cssRule{ |
| + selectors: splitSelectorList(stripCSSComments(css[start:i])), |
| + line: 1 + strings.Count(css[:i], "\n"), |
| + }) |
| + i++ |
| + start = i |
| + case '}': |
| + i++ |
| + start = i |
| + default: |
| + i++ |
| + } |
| + } |
| + return rules |
| +} |
| + |
| +// splitSelectorList splits on the commas that separate selectors, |
| +// leaving alone the ones inside :is(), :where(), :not(), :has() and |
| +// attribute values. |
| +func splitSelectorList(prelude string) []string { |
| + var out []string |
| + depth := 0 |
| + cur := strings.Builder{} |
| + for _, r := range prelude { |
| + switch r { |
| + case '(', '[': |
| + depth++ |
| + case ')', ']': |
| + depth-- |
| + case ',': |
| + if depth == 0 { |
| + out = append(out, collapseSpace(cur.String())) |
| + cur.Reset() |
| + continue |
| + } |
| + } |
| + cur.WriteRune(r) |
| + } |
| + if s := collapseSpace(cur.String()); s != "" { |
| + out = append(out, s) |
| + } |
| + return out |
| +} |
| + |
| +func collapseSpace(s string) string { return strings.Join(strings.Fields(s), " ") } |
| + |
| +// ── Specificity ────────────────────────────────────────────────────── |
| + |
| +// specificity computes (ids, classes, elements) for one selector, the |
| +// way the selectors spec does: an attribute selector and a class weigh |
| +// the same, :where() weighs nothing at all, and :is()/:not()/:has() |
| +// weigh as much as their heaviest argument. It exists so the gate can |
| +// say "this pair is specificity-neutral" as a measurement rather than |
| +// as a belief. |
| +func specificity(selector string) [3]int { |
| + var s [3]int |
| + i, n := 0, len(selector) |
| + for i < n { |
| + c := selector[i] |
| + switch { |
| + case c == '#': |
| + s[0]++ |
| + i += 1 + identLen(selector[i+1:]) |
| + case c == '.': |
| + s[1]++ |
| + i += 1 + identLen(selector[i+1:]) |
| + case c == '[': |
| + s[1]++ |
| + i = skipTo(selector, i, '[', ']') |
| + case c == ':': |
| + if i+1 < n && selector[i+1] == ':' { |
| + // A pseudo-element. Never functional in this file. |
| + i += 2 |
| + i += identLen(selector[i:]) |
| + s[2]++ |
| + continue |
| + } |
| + i++ |
| + name := selector[i : i+identLen(selector[i:])] |
| + i += len(name) |
| + if i < n && selector[i] == '(' { |
| + end := skipTo(selector, i, '(', ')') |
| + args := splitSelectorList(selector[i+1 : end-1]) |
| + switch strings.ToLower(name) { |
| + case "where": |
| + // Contributes nothing, by definition. |
| + case "is", "not", "has", "matches", "any": |
| + var best [3]int |
| + for _, a := range args { |
| + if v := specificity(a); heavier(v, best) { |
| + best = v |
| + } |
| + } |
| + s[0] += best[0] |
| + s[1] += best[1] |
| + s[2] += best[2] |
| + default: |
| + // :nth-child(2) and friends: an ordinary pseudo-class. |
| + s[1]++ |
| + } |
| + i = end |
| + continue |
| + } |
| + switch strings.ToLower(name) { |
| + case "before", "after", "first-line", "first-letter": |
| + s[2]++ // the four legacy one-colon pseudo-elements |
| + default: |
| + s[1]++ |
| + } |
| + case c == '*': |
| + i++ |
| + case isIdentByte(c): |
| + i += identLen(selector[i:]) |
| + s[2]++ |
| + default: // whitespace, > + ~ | and anything else that weighs nothing |
| + i++ |
| + } |
| + } |
| + return s |
| +} |
| + |
| +func heavier(a, b [3]int) bool { |
| + for k := 0; k < 3; k++ { |
| + if a[k] != b[k] { |
| + return a[k] > b[k] |
| + } |
| + } |
| + return false |
| +} |
| + |
| +func isIdentByte(c byte) bool { |
| + return c == '-' || c == '_' || c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c >= 0x80 |
| +} |
| + |
| +func identLen(s string) int { |
| + i := 0 |
| + for i < len(s) && isIdentByte(s[i]) { |
| + i++ |
| + } |
| + return i |
| +} |
| + |
| +// skipTo returns the index just past the bracket that closes the one at |
| +// i, minding nesting and quoted attribute values. |
| +func skipTo(s string, i int, open, close byte) int { |
| + depth := 0 |
| + for ; i < len(s); i++ { |
| + switch c := s[i]; c { |
| + case '"', '\'': |
| + i++ |
| + for i < len(s) && s[i] != c { |
| + if s[i] == '\\' { |
| + i++ |
| + } |
| + i++ |
| + } |
| + case open: |
| + depth++ |
| + case close: |
| + depth-- |
| + if depth == 0 { |
| + return i + 1 |
| + } |
| + } |
| + } |
| + return len(s) |
| +} |
| + |
| +// ── The gates ──────────────────────────────────────────────────────── |
| + |
| +// TestEveryClassSelectorHasAnAttributeTwin is the stage-1 invariant. |
| +// For every selector in tokens.css that names an rst- class, the same |
| +// rule must also carry the attribute spelling of that selector — and |
| +// the two must weigh the same, or an app that changed nothing would |
| +// start rendering differently. |
| +func TestEveryClassSelectorHasAnAttributeTwin(t *testing.T) { |
| + assertTwins(t, "tokens.css", string(fiesta.TokensCSS()), 300) |
| +} |
| + |
| +// TestNoAttributeSelectorIsAnOrphan is the same invariant from the |
| +// other end: every rst- attribute selector in tokens.css must be some |
| +// class selector's twin, in its own rule. An attribute rule with no |
| +// class beside it would style the new spelling and not the old one, |
| +// which is the breakage stage 1 exists to avoid. |
| +func TestNoAttributeSelectorIsAnOrphan(t *testing.T) { |
| + assertNoOrphans(t, "tokens.css", string(fiesta.TokensCSS())) |
| +} |
| + |
| +// shell.css is written with exactly one pair, the console bar's |
| +// view-transition-name, and its floor is that one, so the gate cannot |
| +// pass on a shell.css that lost it: the 300 floor tokens.css carries |
| +// would make any small file fail. |
| +func TestShellCSSPairsBothSpellings(t *testing.T) { |
| + assertTwins(t, "shell.css", string(fiesta.ShellCSS()), 1) |
| + assertNoOrphans(t, "shell.css", string(fiesta.ShellCSS())) |
| +} |
| + |
| +// assertTwins holds every rule of css to the pairing rule, and floor is |
| +// the fewest pairs the file may have, so a bulk deletion cannot pass by |
| +// leaving nothing to check. |
| +func assertTwins(t *testing.T, name, css string, floor int) { |
| + t.Helper() |
| + rules := parseCSSRules(css) |
| + if len(rules) == 0 { |
| + t.Fatal(name + " parsed to no rules at all — the reader is broken, not the file") |
| + } |
| + |
| + paired := 0 |
| + for _, rule := range rules { |
| + present := make(map[string]bool, len(rule.selectors)) |
| + for _, sel := range rule.selectors { |
| + present[sel] = true |
| + } |
| + for _, sel := range rule.selectors { |
| + twin := attributeTwin(sel) |
| + if twin == sel { |
| + continue // nothing to translate: a twin, or a utility, or not ours |
| + } |
| + if !present[twin] { |
| + t.Errorf(name+":%d: %q has no attribute twin in its rule.\n"+ |
| + "\tadd %q beside it, or the attribute spelling of this rule styles nothing.\n"+ |
| + "\tthe rule reads: %s", |
| + rule.line, sel, twin, strings.Join(rule.selectors, ", ")) |
| + continue |
| + } |
| + paired++ |
| + if a, b := specificity(sel), specificity(twin); a != b { |
| + t.Errorf(name+":%d: the pair does not weigh the same — %q is %v but %q is %v.\n"+ |
| + "\ta twin that shifts the cascade changes how an app that changed nothing renders.", |
| + rule.line, sel, a, twin, b) |
| + } |
| + } |
| + } |
| + if paired < floor { |
| + t.Errorf("only %d selectors are paired in %s, the floor is %d, so this is a wholesale loss rather than an edit", paired, name, floor) |
| + } |
| + t.Logf("%d class selectors paired with an attribute twin", paired) |
| +} |
| + |
| +func assertNoOrphans(t *testing.T, name, css string) { |
| + t.Helper() |
| + for _, rule := range parseCSSRules(css) { |
| + twins := make(map[string]bool, len(rule.selectors)) |
| + for _, sel := range rule.selectors { |
| + if twin := attributeTwin(sel); twin != sel { |
| + twins[twin] = true |
| + } |
| + } |
| + for _, sel := range rule.selectors { |
| + if !strings.Contains(sel, "[rst-") { |
| + continue |
| + } |
| + if !twins[sel] { |
| + t.Errorf(name+":%d: %q is an attribute selector with no class selector it is the twin of.\n"+ |
| + "\tuntil stage 3 retires the class spelling, every rule styles both.\n"+ |
| + "\tthe rule reads: %s", rule.line, sel, strings.Join(rule.selectors, ", ")) |
| + } |
| + } |
| + } |
| +} |
| + |
| +// TestClassesThatKeepTheirSpellingAreStillReal keeps the exemption list |
| +// honest. Every entry must still be a class in tokens.css (so a stale |
| +// name cannot sit there excusing something that no longer exists), and |
| +// no entry may have quietly grown an attribute form (so "this stays a |
| +// class" stays true). |
| +func TestClassesThatKeepTheirSpellingAreStillReal(t *testing.T) { |
| + css := stripCSSComments(string(fiesta.TokensCSS())) |
| + for class, why := range classKeepsItsSpelling { |
| + if !strings.Contains(css, "."+class) { |
| + t.Errorf("markup.Utilities names %q (%s) and tokens.css has no such class: delete the entry", class, why) |
| + } |
| + flat := "rst-" + strings.ReplaceAll(strings.TrimPrefix(class, "rst-"), "__", "-") |
| + if strings.Contains(css, "["+flat+"]") { |
| + t.Errorf("markup.Utilities names %q (%s) but tokens.css styles [%s]: it is being migrated after all, so drop the exemption", class, why, flat) |
| + } |
| + } |
| + // Stage 1 left one class unpaired for a reason that is not the |
| + // utilities': rst-form__foot and rst-form-foot both existed and |
| + // wanted one attribute. Stage 2 resolved it — the partial's row |
| + // took rst-form-foot, the save bar became rst-form-bar — so the |
| + // exemption is gone and neither old spelling may come back. |
| + for _, gone := range []string{".rst-form__foot", ".rst-form-foot__note"} { |
| + if strings.Contains(css, gone) { |
| + t.Errorf("%s is back in tokens.css: the flip retired it, and it collides with the attribute rst-form-foot already carries", gone) |
| + } |
| + } |
| + // Every name the flip deletes outright must really style nothing, |
| + // or the codemod is removing markup that was doing a job. |
| + for class, why := range markup.Dropped { |
| + if strings.Contains(css, "."+class) { |
| + t.Errorf("markup.Dropped names %q (%s) but tokens.css styles it: deleting it from markup would change what renders", class, why) |
| + } |
| + } |
| +} |
| + |
| +// TestNoTwoClassesWantTheSameAttribute is the check that found the |
| +// rst-form__foot/rst-form-foot collision in the first place. BEM's __ |
| +// flattens to a hyphen, so a __ name and a flat name can land on the |
| +// same attribute — and one attribute cannot carry two different rules. |
| +// Every new name has to be checked against this, not just the ones that |
| +// existed on the day. |
| +func TestNoTwoClassesWantTheSameAttribute(t *testing.T) { |
| + css := stripCSSComments(string(fiesta.TokensCSS())) |
| + seen := map[string][]string{} |
| + for _, m := range classInSelector.FindAllStringSubmatch(css, -1) { |
| + class := m[1] |
| + name, variant, ok := attributeFor(class) |
| + if !ok { |
| + continue |
| + } |
| + key := name |
| + if variant != "" { |
| + key = name + "~=" + variant |
| + } |
| + known := false |
| + for _, had := range seen[key] { |
| + known = known || had == class |
| + } |
| + if !known { |
| + seen[key] = append(seen[key], class) |
| + } |
| + } |
| + for key, classes := range seen { |
| + if len(classes) > 1 { |
| + sort.Strings(classes) |
| + t.Errorf("%v all translate to %q: one attribute cannot style two different rules — rename one, or add it to classKeepsItsSpelling with the reason", classes, key) |
| + } |
| + } |
| +} |
| + |
| +// TestSpecificityReadsSelectorsTheWayTheSpecDoes is the gate's own |
| +// gate. The pairing check leans on specificity() being right about |
| +// :where() (weightless), :not()/:has() (their heaviest argument) and |
| +// attributes (a class's weight); if it quietly returned zero for |
| +// everything, every pair would look neutral and the check would be |
| +// worthless. |
| +func TestSpecificityReadsSelectorsTheWayTheSpecDoes(t *testing.T) { |
| + for _, c := range []struct { |
| + sel string |
| + want [3]int |
| + }{ |
| + {".rst-btn", [3]int{0, 1, 0}}, |
| + {"[rst-btn]", [3]int{0, 1, 0}}, |
| + {`[rst-btn~="primary"]`, [3]int{0, 1, 0}}, |
| + {".rst-btn:hover", [3]int{0, 2, 0}}, |
| + {"dialog.rst-modal-panel", [3]int{0, 1, 1}}, |
| + {"dialog[rst-modal-panel]", [3]int{0, 1, 1}}, |
| + {".rst-page-header::after", [3]int{0, 1, 1}}, |
| + {"[rst-page-header]::after", [3]int{0, 1, 1}}, |
| + {":where(.rst-list) > :where(:first-child)", [3]int{0, 0, 0}}, |
| + {":where([rst-list]) > :where(:first-child)", [3]int{0, 0, 0}}, |
| + {":where(.rst-page, .rst-page-header) :focus-visible", [3]int{0, 1, 0}}, |
| + {".rst-lrow:not(.rst-lrow--head):has(> .rst-nm, > .rst-person):hover", [3]int{0, 4, 0}}, |
| + {`[rst-lrow]:not([rst-lrow~="head"]):has(> .rst-nm, > [rst-person]):hover`, [3]int{0, 4, 0}}, |
| + {".rst-form > *:not(.rst-form__foot, .rst-form-foot)", [3]int{0, 2, 0}}, |
| + {"[rst-form] > *:not(.rst-form__foot, [rst-form-foot])", [3]int{0, 2, 0}}, |
| + {".rst-search input[type=\"search\"]::-webkit-search-cancel-button", [3]int{0, 2, 2}}, |
| + {"body:has(.rst-backdrop)", [3]int{0, 1, 1}}, |
| + {"#id .c e", [3]int{1, 1, 1}}, |
| + } { |
| + if got := specificity(c.sel); got != c.want { |
| + t.Errorf("specificity(%q) = %v, want %v", c.sel, got, c.want) |
| + } |
| + } |
| +} |
| + |
| +// The translation itself — both halves of the table, selectors and |
| +// markup — is pinned in internal/markup's own tests, where the grammar |
| +// now lives. It used to be duplicated here, and a duplicated table is |
| +// two things that agree until one of them is edited. |