oficina / memoria Public

Tell the rst-sr-only class from the attribute in the design-system gate

The gate exempted the bare word rst-sr-only, so it waved through
<p rst-sr-only> along with the class="rst-sr-only" it was written for.
tokens.css styles only .rst-sr-only, so the attribute spelling puts the
text it was meant to hide on screen and the gate stayed green (Astra,
memoria #24).

Class-only utilities are now removed from class="…" values before the
scan and nowhere else, so the class passes and any attribute use fails
like every other unstyled name. The scan moved into attrNames so
TestClassOnlyUtilityPassesOnlyAsAClass can drive it directly; it failed
on all five attribute spellings before the change. Mutation-checked:
rewriting index.html's label as <label rst-sr-only> turns
TestEveryTemplateAttributeIsInTheDesignSystem red.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev e5c19d9aa059a8703be7c10a101f13edae7ab404 parent aa1d2e3
1 file changed, +133 −35
  • internal/memoriatest/gates_test.go +133 −35

CI passed — run details

CI log
=== vet ===
go vet ./...
go vet -tags browser ./internal/memoriatest/
=== fmt ===
=== test ===
go test ./...
?   	amadan.net/oficina/memoria/cmd/memoria	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria	4.380s
ok  	amadan.net/oficina/memoria/internal/memoria/chunk	0.008s
ok  	amadan.net/oficina/memoria/internal/memoria/fact	0.785s
?   	amadan.net/oficina/memoria/internal/memoria/icons	[no test files]
ok  	amadan.net/oficina/memoria/internal/memoria/index	0.042s
ok  	amadan.net/oficina/memoria/internal/memoria/notion	0.024s
ok  	amadan.net/oficina/memoria/internal/memoria/provider	1.137s
ok  	amadan.net/oficina/memoria/internal/memoria/query	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/seal	0.004s
ok  	amadan.net/oficina/memoria/internal/memoria/sibling	3.032s
ok  	amadan.net/oficina/memoria/internal/memoriatest	2.374s
?   	amadan.net/oficina/memoria/scripts/writeestilo	[no test files]
?   	amadan.net/oficina/memoria/tools/bench	[no test files]
?   	amadan.net/oficina/memoria/tools/drive	[no test files]
?   	amadan.net/oficina/memoria/tools/seed	[no test files]
?   	amadan.net/oficina/memoria/tools/vendorassets	[no test files]
=== migration-check ===
go tool rastrillo migration check
rastrillo migration check: models and migrations agree
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go
index 3519071..2d3c5c5 100644
--- a/internal/memoriatest/gates_test.go
+++ b/internal/memoriatest/gates_test.go
@@ -828,6 +828,21 @@ func bytesContainsFold(hay []byte, needle string) bool {
return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle))
}
+// designCSS is every stylesheet the templates are styled by, as one
+// string.
+func designCSS(t *testing.T) string {
+ t.Helper()
+ css := ""
+ for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} {
+ raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name))
+ if err != nil {
+ t.Fatalf("read %s: %v", name, err)
+ }
+ css += string(raw)
+ }
+ return css
+}
+
// Every `rst-` attribute a template writes is one the stylesheets
// actually style.
//
@@ -856,14 +871,7 @@ func bytesContainsFold(hay []byte, needle string) bool {
// write rst-callout="danger" again -> red
// misspell rst-callout-body -> red
func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
- css := ""
- for _, name := range []string{"tokens.css", "theme.css", "memoria.css"} {
- raw, err := os.ReadFile(filepath.Join("..", "memoria", "static", name))
- if err != nil {
- t.Fatalf("read %s: %v", name, err)
- }
- css += string(raw)
- }
+ css := designCSS(t)
// **Hooks, not styles**, and the list is derived rather than typed:
// anything one of the vendored scripts reads is a hook by definition,
@@ -896,12 +904,7 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
// now memoria.css's [rst-muted]; the Sources page's rst-list-item and
// rst-list-title are [rst-sources] rows; rst-facts is styled; the
// header's rst-brand and the Delete button's rst-danger are gone.
- unstyled := map[string]bool{
- "rst-lede": true,
- "rst-steps": true,
- "rst-inline": true,
- "rst-bg": true,
- }
+ unstyled := templateUnstyled
// **Values this app writes that the design system does not define.**
// Same treatment and same reason as the list above: named, dated, and
@@ -922,17 +925,8 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
"rst-btn=quiet": true,
"rst-btn=compact": true,
}
- exempt := map[string]bool{
- "rst-withheld": true, // a test hook for §6.5's notice block
- // A class-only utility, written class="rst-sr-only" (tokens.css's
- // .rst-sr-only; utilities keep the class spelling). This gate reads
- // rst- words in class values too, and select.js's mention of it
- // used to satisfy it; select.js went when the app moved off
- // rastrillo's frozen path (no page links it).
- "rst-sr-only": true,
- }
-
- attr := regexp.MustCompile(`rst-[a-z0-9-]+`)
+ exempt := templateExempt
+ attr := rstWord
// **Whole tokens, never substrings**, and this is not fussiness. The
// first version tested membership with strings.Contains, and
@@ -957,11 +951,7 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
// rst-mono, … rst-cell-mut"), memoria.css says `rst-cell-mut` written
// as an attribute selects nothing, and AGENTS.md says utilities stay
// classes. The gate said they were fine.
- selector := regexp.MustCompile(`\[(rst-[a-z0-9-]+)`)
- cssAttrs := map[string]bool{}
- for _, m := range selector.FindAllStringSubmatch(css, -1) {
- cssAttrs[m[1]] = true
- }
+ cssAttrs := attrSelectors(css)
jsAttrs := tokens(js)
dir := filepath.Join("..", "memoria", "templates")
entries, err := os.ReadDir(dir)
@@ -977,11 +967,8 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
if err != nil {
t.Fatal(err)
}
- // Native details groups use name values, not styled attributes.
- // Mutation-checked 2026-09-11: rst-not-a-real-attribute still fails.
- markup := strings.ReplaceAll(string(raw), `name="rst-menus"`, `name="menu-group"`)
- for _, m := range attr.FindAllString(markup, -1) {
- if exempt[m] || unstyled[m] || seen[m] || jsAttrs[m] {
+ for _, m := range attrNames(string(raw)) {
+ if unstyled[m] || seen[m] || jsAttrs[m] {
continue
}
seen[m] = true
@@ -1099,3 +1086,114 @@ func TestEveryTemplateAttributeIsInTheDesignSystem(t *testing.T) {
}
}
}
+
+// rstWord is one rst- name, wherever it is written.
+var rstWord = regexp.MustCompile(`rst-[a-z0-9-]+`)
+
+// templateExempt names rst- words the design-system gate does not hold
+// to the stylesheets at all.
+var templateExempt = map[string]bool{
+ "rst-withheld": true, // a test hook for §6.5's notice block
+}
+
+// classOnlyUtilities are tokens.css utilities that keep the class
+// spelling and have no attribute twin ("Utilities keep the class
+// spelling and are not paired"). Inside a class="…" value they are
+// correct and the gate skips them; anywhere else they style nothing and
+// the gate reports them like any other unstyled name. This gate reads
+// rst- words in class values too, and select.js's mention of
+// rst-sr-only used to satisfy it; select.js went when the app moved off
+// rastrillo's frozen path (no page links it).
+var classOnlyUtilities = map[string]bool{
+ "rst-sr-only": true,
+}
+
+// classValue is one class attribute and its value, either quote.
+var classValue = regexp.MustCompile(`(\sclass=)("[^"]*"|'[^']*')`)
+
+// **Pre-existing attributes that style nothing and no script reads.**
+// See TestEveryTemplateAttributeIsInTheDesignSystem.
+var templateUnstyled = map[string]bool{
+ "rst-lede": true,
+ "rst-steps": true,
+ "rst-inline": true,
+ "rst-bg": true,
+}
+
+// attrSelectors is the stylesheets' ATTRIBUTE vocabulary: the names
+// written as `[rst-…` selectors, never the class spelling.
+func attrSelectors(css string) map[string]bool {
+ out := map[string]bool{}
+ for _, m := range regexp.MustCompile(`\[(rst-[a-z0-9-]+)`).FindAllStringSubmatch(css, -1) {
+ out[m[1]] = true
+ }
+ return out
+}
+
+// attrNames is every rst- name a template writes that the gate must
+// find in the stylesheets' attribute vocabulary, exemptions removed.
+func attrNames(markup string) []string {
+ // Native details groups use name values, not styled attributes.
+ // Mutation-checked 2026-09-11: rst-not-a-real-attribute still fails.
+ markup = strings.ReplaceAll(markup, `name="rst-menus"`, `name="menu-group"`)
+ // Class-only utilities leave the class values they belong in, and
+ // only those: written as an attribute they are still scanned.
+ markup = classValue.ReplaceAllStringFunc(markup, func(m string) string {
+ return rstWord.ReplaceAllStringFunc(m, func(w string) string {
+ if classOnlyUtilities[w] {
+ return ""
+ }
+ return w
+ })
+ })
+ var out []string
+ for _, m := range rstWord.FindAllString(markup, -1) {
+ if templateExempt[m] {
+ continue
+ }
+ out = append(out, m)
+ }
+ return out
+}
+
+// A class-only utility is exempt as a CLASS and only as a class.
+// tokens.css styles .rst-sr-only and has no [rst-sr-only] twin, so
+// `<p rst-sr-only>` renders on screen — the very text the utility was
+// meant to hide. The exemption used to be a bare word, which waved that
+// through along with the class it was written for (Astra, memoria #24).
+func TestClassOnlyUtilityPassesOnlyAsAClass(t *testing.T) {
+ cssAttrs := attrSelectors(designCSS(t))
+ if cssAttrs["rst-sr-only"] {
+ t.Fatal("the stylesheets now select [rst-sr-only]; the attribute spelling is styled and this test's premise is gone")
+ }
+ unstyled := func(markup string) []string {
+ var out []string
+ for _, m := range attrNames(markup) {
+ if !cssAttrs[m] && !templateUnstyled[m] {
+ out = append(out, m)
+ }
+ }
+ return out
+ }
+ for _, ok := range []string{
+ `<label for="q" class="rst-sr-only">Search</label>`,
+ `<p class="rst-sr-only" role="status"></p>`,
+ `<p class="hint rst-sr-only big">x</p>`,
+ `<p class='rst-sr-only'>x</p>`,
+ } {
+ if got := unstyled(ok); len(got) != 0 {
+ t.Errorf("%s: the gate rejects %v; a class-only utility written as a class is correct", ok, got)
+ }
+ }
+ for _, bad := range []string{
+ `<p rst-sr-only>x</p>`,
+ `<p rst-sr-only="">x</p>`,
+ `<p rst-sr-only class="hint">x</p>`,
+ `<p class="hint" rst-sr-only>x</p>`,
+ `<p data-x="rst-sr-only">x</p>`,
+ } {
+ if got := unstyled(bad); len(got) != 1 || got[0] != "rst-sr-only" {
+ t.Errorf("%s: the gate reports %v, want [rst-sr-only]: written as an attribute the utility styles nothing", bad, got)
+ }
+ }
+}