Restore the nine SEED doc comments in css_test.go, and hold the unstyled map to the element it names
The ranges cut from ui_test.go for css_test.go started at each func line, so nine tests arrived without their doc comments and with the tail of the previous test's instead. A script put SEED's block back above each of the nine and deleted the five fragments, and a second script diffed every declaration's doc in every ported file against e14b745: the only differences left are three references to things Fiesta does not have (Go partials, allPartials(), a rastrillo docs path) and three plan ids, reworded. The example gate matches a class as a whole token now, through the selectors' own class list, so .rst-m no longer passes on the strength of .rst-m-hide. unstyledByDesign names the element each entry applies on: the callout's default info tone is exempt on a callout only, and the map is checked both ways after the sweep, like the other. The two entries that are coverage gaps say so in their reasons. The manifest readers sit together in markuphelp_test.go, and the default reader fails by name when an example has no default state.
Summary
css_test.go: SEED's doc comments back on nine ported tests, the five fragments cut from other tests' comments deleted, three rastrillo-only references reworded, one plan id gone; coverage_test.go: classes matched as whole tokens, unstyledByDesign keyed to the element it applies on and checked in both directions, the two coverage-gap entries say so; the manifest readers (manifest, idiomMarkup, defaultMarkup) together in markuphelp_test.go with the default's ok checked; the tap-inventory comment says what it reads.
Agent notes
Task 7 fix round 1. A script diffed every ported declaration's doc comment against SEED: 132 identical, 6 differ, each a rastrillo reference or a plan id, listed in the report. Control in a scratch copy: class="rst-m" on an example element passed the old substring check and fails the fixed gate naming .rst-m. go test ./check/ ok; make ci exit 0.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
5 files changed,
+193
−102
check/button_test.go+0 −23check/coverage_test.go+84 −52check/css_test.go+48 −25check/markuphelp_test.go+58 −0check/sizing_test.go+3 −2
diff --git a/check/button_test.go b/check/button_test.go| index 5f2f567..5ca0012 100644 |
| --- a/check/button_test.go |
| +++ b/check/button_test.go |
| @@ -4,31 +4,8 @@ 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 |
diff --git a/check/coverage_test.go b/check/coverage_test.go| index 4407160..3c78151 100644 |
| --- a/check/coverage_test.go |
| +++ b/check/coverage_test.go |
| @@ -66,31 +66,6 @@ func everyState(t *testing.T, fsys fs.FS) []exampleState { |
| 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 |
| @@ -101,15 +76,24 @@ func defaultMarkup(t *testing.T, name string) string { |
| 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 |
| - } |
| + for name := range tagVocabulary(tag) { |
| + seen[name] = true |
| + } |
| + } |
| + return seen |
| +} |
| + |
| +// tagVocabulary is rstVocabulary for one open tag. |
| +func tagVocabulary(tag string) map[string]bool { |
| + seen := map[string]bool{} |
| + // 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 |
| @@ -142,11 +126,23 @@ 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", |
| +// On names the attribute the element must also carry for the entry to |
| +// apply, so the callout's default tone is exempt on a callout and |
| +// nowhere else; "" is any element. Nothing else may be added here: an |
| +// attribute an example writes and nothing styles is a component that |
| +// renders as the plain element. The gate holds the map in both |
| +// directions: an entry the stylesheets style, or one no example writes |
| +// where it applies, fails. |
| +var unstyledByDesign = map[string]struct{ On, Why 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": {"rst-callout", "the callout's default tone: the base [rst-callout] rule paints it, and only the three others carry a tone rule"}, |
| +} |
| + |
| +// unstyledHere reports whether name is exempt on an element whose |
| +// vocabulary is names. |
| +func unstyledHere(name string, names map[string]bool) bool { |
| + u, ok := unstyledByDesign[name] |
| + return ok && (u.On == "" || names[u.On]) |
| } |
| // tokensStyle reports whether css carries a selector for one entry of |
| @@ -164,6 +160,18 @@ func styledCSS() string { |
| return stripCSSComments(string(fiesta.TokensCSS())) + "\n" + stripCSSComments(string(fiesta.ShellCSS())) |
| } |
| +// styledClasses is every rst- class the selectors of css name, whole: |
| +// a class is styled when it is a selector's class token, not when it |
| +// is the start of one (.rst-m-hide styles no element whose class is |
| +// rst-m). |
| +func styledClasses(css string) map[string]bool { |
| + out := map[string]bool{} |
| + for _, m := range classInSelector.FindAllStringSubmatch(css, -1) { |
| + out[m[1]] = true |
| + } |
| + return out |
| +} |
| + |
| // 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-]+)(?:~="([^"]+)")?\]`) |
| @@ -205,26 +213,49 @@ func styledVocabulary() map[string]bool { |
| // idea of that component. |
| func TestEveryExampleAttributeAndClassIsStyled(t *testing.T) { |
| css := styledCSS() |
| + classes := styledClasses(css) |
| + exempted := map[string]bool{} |
| 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) |
| + missing := map[string]bool{} |
| + for _, tag := range openTagInFixture.FindAllString(s.Markup, -1) { |
| + names := tagVocabulary(tag) |
| + for name := range names { |
| + if qualifiedOnly[name] { |
| + continue |
| + } |
| + if unstyledHere(name, names) { |
| + exempted[name] = true |
| + continue |
| + } |
| + if !tokensStyle(css, name) { |
| + missing[name] = true |
| + } |
| } |
| } |
| for class := range rstClasses(s.Markup) { |
| - if !strings.Contains(css, "."+class) { |
| - names = append(names, "."+class) |
| + if !classes[class] { |
| + missing["."+class] = true |
| } |
| } |
| + names := make([]string, 0, len(missing)) |
| + for name := range missing { |
| + names = append(names, name) |
| + } |
| 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) |
| } |
| } |
| + for name, u := range unstyledByDesign { |
| + switch { |
| + case strings.TrimSpace(u.Why) == "": |
| + t.Errorf("unstyledByDesign lists %s with no reason", name) |
| + case tokensStyle(css, name): |
| + t.Errorf("unstyledByDesign lists %s, and the stylesheets style it now; remove the entry", name) |
| + case !exempted[name]: |
| + t.Errorf("unstyledByDesign lists %s, and no example writes it where the entry applies; remove the entry", name) |
| + } |
| + } |
| } |
| // selectorsWithoutAnExample is every entry of the stylesheets' |
| @@ -265,10 +296,11 @@ var selectorsWithoutAnExample = map[string]string{ |
| "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", |
| + // Coverage gaps, not exemptions: markup a page can hold that no |
| + // example state shows yet. Rastrillo's gallery shows neither either. |
| + // Each goes when a state is added for it. |
| + "rst-pagination-gap~=narrow": "no example state shows this yet; Rastrillo's gallery has none either. It is the ellipsis a phone shows where a run of pages it hides carries no gap of its own, as a pagination with every page listed would write; the long-list state puts a gap in every such run", |
| + "rst-person-av~=placeholder": "no example state shows this yet; Rastrillo's gallery has none either. It is the empty avatar a layout draws for an app with no profile block, and every layout example supplies one; the markup exists in testdata/fixtures/profile_browser_test", |
| // 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)", |
| } |
diff --git a/check/css_test.go b/check/css_test.go| index 684fba3..4ebdf8f 100644 |
| --- a/check/css_test.go |
| +++ b/check/css_test.go |
| @@ -446,7 +446,7 @@ func buildReduceIndex(blocks []string) reduceIndex { |
| return idx |
| } |
| -// TestReducedMotionDisablesEveryTransition is the motion gate (task 5): |
| +// TestReducedMotionDisablesEveryTransition is the motion gate: |
| // every selector that declares a real transition or animation outside a |
| // prefers-reduced-motion block must have that exact selector — not a |
| // selector merely containing or contained by it — disable that exact |
| @@ -585,9 +585,9 @@ func TestEveryTokenReferenceIsDeclared(t *testing.T) { |
| } |
| } |
| -// Every interactive element renders with a real accessible name |
| -// (spec §10). Checked here rather than per-partial so a new partial |
| - |
| +// Same drift check again, for the form family: field, field-select, |
| +// field-textarea, field-check and choice-field between them can emit |
| +// every one of these, and every one is spelled as the examples spell it. |
| func TestFormPartialClassesAreStyled(t *testing.T) { |
| css := string(fiesta.TokensCSS()) |
| for _, name := range []string{ |
| @@ -603,6 +603,12 @@ func TestFormPartialClassesAreStyled(t *testing.T) { |
| } |
| } |
| +// Same drift check again, direct rather than sample-driven: the classes |
| +// rastrillo's partials (confirm-form, back-nav, notice, form-error, |
| +// bulk-bar) emit themselves, so there is no arbitrary caller-composed |
| +// body for an example to carry them — their examples already hold the |
| +// partials' fixtures, and this pins |
| +// that every class they can render resolves to a tokens.css selector. |
| func TestRoutesFamilyPartialClassesAreStyled(t *testing.T) { |
| css := string(fiesta.TokensCSS()) |
| for _, name := range []string{ |
| @@ -646,10 +652,13 @@ var cssComment = regexp.MustCompile(`(?s)/\*.*?\*/`) |
| // 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> |
| - |
| +// The submenu trap, pinned. <details name> exclusivity is DOCUMENT-wide, |
| +// not sibling-scoped: two elements sharing a name close each other even |
| +// when one is inside the other. A nested rst-menu-group that inherited |
| +// its parent's group would therefore close the parent the instant it |
| +// opened — the submenu flashes and the whole menu vanishes under the |
| +// pointer. The nested sample must carry a name of its own, and it must |
| +// not be the parent's. |
| func TestNestedMenuGroupNeverSharesItsParentsName(t *testing.T) { |
| sample := idiomMarkup(t, "dropdown") |
| names := detailsNamePattern.FindAllStringSubmatch(sample, -1) |
| @@ -718,10 +727,8 @@ func TestModalPanelIsARenderedOpenDialog(t *testing.T) { |
| } |
| } |
| -// 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 |
| - |
| +// The focus ring covers the whole app column, so a hand-rolled control |
| +// inside [rst-page] no longer restates the outline. |
| func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { |
| css := string(fiesta.TokensCSS()) |
| if !strings.Contains(css, ":where([rst-page],") { |
| @@ -729,6 +736,12 @@ func TestFocusRingScopeIncludesThePageColumn(t *testing.T) { |
| } |
| } |
| +// .rst-sr-only and the component rules it has to beat are all single |
| +// class selectors, so the cascade falls to source order: declared before |
| +// them it loses, and anything carrying both classes ends up a full-width |
| +// absolutely-positioned box hidden only by clip-path. That is how a |
| +// hidden <select> nearly shipped at full width — the utility must come |
| +// last, and this stops it drifting back. |
| func TestSrOnlyUtilityComesAfterTheRulesItMustBeat(t *testing.T) { |
| css := string(fiesta.TokensCSS()) |
| srOnly := strings.Index(css, ".rst-sr-only {") |
| @@ -836,15 +849,15 @@ func drawsARule(val string) bool { |
| 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 |
| - |
| +// Every theme file must declare exactly the same set of --rst-* |
| +// properties as day, the reference theme. A theme that forgets a token |
| +// does not fail loudly at render time — it silently falls back to |
| +// whatever the cascade already had, which in a scaffolded app is |
| +// nothing at all, so the affected element renders unstyled rather than |
| +// wrong. Since v2 that set includes the shape tokens (the radii and the |
| +// four depth values), which used to be tokens.css's and are now part of |
| +// what a theme has to answer for. Names only: values are the theme's |
| +// whole point, and their contrast is contrast_test.go's job. |
| func TestThemesDeclareIdenticalTokenSets(t *testing.T) { |
| names := fiesta.ThemeNames() |
| if len(names) == 0 || names[0] != "day" { |
| @@ -1082,10 +1095,8 @@ func TestTokensCSSHasNoColourLiterals(t *testing.T) { |
| } |
| } |
| -// 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 |
| - |
| +// The same drift check the other partial families get: every class the |
| +// error page emits has a rule in tokens.css. |
| func TestErrorPageClassesAreStyled(t *testing.T) { |
| css := string(fiesta.TokensCSS()) |
| for _, name := range []string{ |
| @@ -1098,12 +1109,24 @@ func TestErrorPageClassesAreStyled(t *testing.T) { |
| } |
| } |
| +// A button's own display rule would otherwise beat the user agent's |
| +// [hidden] rule, and the passkey door would show before its script |
| +// decided it can work. |
| 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") |
| } |
| } |
| +// The depth axis is a theme decision: day turns it on, and plain and |
| +// signal prove a theme can switch it off (spec §3, A13). Flat means the |
| +// highlight, the shadows, the sheen and the wash are transparent, the |
| +// face does not shade, the press has no strength and no drop, and the |
| +// rim is the line: declared as var(--rst-line), so an app's override of |
| +// the line reaches it (Astra A2), or as the line's own value. Pinned by |
| +// value for day because its numbers were measured (A7); pinned flat |
| +// for the others because "renders as today" is a promise a later theme |
| +// edit could quietly break. |
| func TestDayTurnsDepthOnAndTheOthersStayFlat(t *testing.T) { |
| for _, name := range fiesta.ThemeNames() { |
| for _, scheme := range []string{"light", "dark"} { |
diff --git a/check/markuphelp_test.go b/check/markuphelp_test.go| index b8af9a9..2686784 100644 |
| --- a/check/markuphelp_test.go |
| +++ b/check/markuphelp_test.go |
| @@ -3,10 +3,68 @@ package check |
| import ( |
| "regexp" |
| "strings" |
| + "testing" |
| + "amadan.net/rastrillo/fiesta" |
| + "amadan.net/rastrillo/fiesta/example" |
| "amadan.net/rastrillo/fiesta/internal/markup" |
| ) |
| +// ── Reading the examples by name ───────────────────────────────────── |
| + |
| +// manifest reads examples/examples.json from the embedded tree. |
| +func manifest(t *testing.T) *example.Manifest { |
| + t.Helper() |
| + m, err := example.Load(fiesta.FS()) |
| + if err != nil { |
| + t.Fatal(err) |
| + } |
| + return m |
| +} |
| + |
| +// 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() |
| + name, state, ok := manifest(t).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 |
| +} |
| + |
| +// 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 := manifest(t) |
| + e, ok := m.Entry(name) |
| + if !ok { |
| + t.Fatalf("examples.json has no example %q", name) |
| + } |
| + state, ok := m.Default(name) |
| + if !ok || state == "" { |
| + t.Fatalf("examples.json names no default state for %q", 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 spelling grammar's helpers ─────────────────────────────────── |
| + |
| // 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 |
diff --git a/check/sizing_test.go b/check/sizing_test.go| index 6dd55a4..89f17b3 100644 |
| --- a/check/sizing_test.go |
| +++ b/check/sizing_test.go |
| @@ -486,8 +486,9 @@ func TestEveryInteractiveRuleIsInTheTapInventory(t *testing.T) { |
| 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. |
| + // Where SEED looked in its sizing fixture plus the modal sample, this |
| + // looks in the sizing fixture plus every example piece, the modal's |
| + // among them: a marker no example carries is one no drive can reach. |
| fixture, err := os.ReadFile("testdata/fixtures/sizing.html") |
| if err != nil { |
| t.Fatal(err) |