oficina / estilo Public

Add base link styling, with a shared defined-token check

base.css is the suite's one base rule: prose links in the accent colour,
underlined, at zero specificity so every component still wins. The
hover and focus rules are wrapped in :where() too, which the spec's
draft left outside it (a stray (0,1,0) that could outrank rastrillo's
own link rules); a test holds every selector to a bare :where().

internal/tokencheck is the shared half of the per-package
...UseDefinedTokens tests.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 5320b6130a751b1bd8692672531298273c849929 parent bd5cd6d
4 files changed, +101 −0
  • base/base.css +15 −0
  • base/base.go +15 −0
  • base/base_test.go +36 −0
  • internal/tokencheck/tokencheck.go +35 −0
diff --git a/base/base.css b/base/base.css
new file mode 100644
index 0000000..9873e71
--- /dev/null
+++ b/base/base.css
@@ -0,0 +1,15 @@
+/* base/base.css: links. Zero specificity on purpose: any component rule wins.
+ Prose links keep their underline, because colour must never carry meaning
+ alone. Chrome links (bar, nav, menus, rows, buttons) set their own colour
+ and text-decoration, and every one of them outranks :where(). */
+:where(a:any-link) {
+ color: var(--rst-accent);
+ text-decoration-line: underline;
+ text-decoration-thickness: 1px;
+ text-underline-offset: 0.15em;
+}
+:where(a:any-link:hover) { text-decoration-thickness: 2px; }
+:where(a:any-link:focus-visible) {
+ outline: 2px solid var(--rst-accent);
+ outline-offset: 2px;
+}
diff --git a/base/base.go b/base/base.go
new file mode 100644
index 0000000..1c58c00
--- /dev/null
+++ b/base/base.go
@@ -0,0 +1,15 @@
+// Package base is the suite's base element styling: today, links. It is
+// the one stylesheet every Oficina app links before any component, and
+// it carries zero specificity so that every rastrillo and estilo
+// component rule still wins without a fight. It contains no strings and
+// no script.
+package base
+
+import _ "embed"
+
+//go:embed base.css
+var css []byte
+
+// CSS returns base.css. Link it after the theme and brand.css and before
+// menu.css and chrome.css.
+func CSS() []byte { return append([]byte(nil), css...) }
diff --git a/base/base_test.go b/base/base_test.go
new file mode 100644
index 0000000..b089153
--- /dev/null
+++ b/base/base_test.go
@@ -0,0 +1,36 @@
+package base
+
+import (
+ "regexp"
+ "testing"
+
+ "amadan.net/oficina/estilo.git/internal/tokencheck"
+)
+
+func TestBaseUsesDefinedTokens(t *testing.T) {
+ missing, n := tokencheck.Undefined(CSS())
+ if n == 0 {
+ t.Fatal("base.css reads no rastrillo token; the check would pass vacuously")
+ }
+ if len(missing) > 0 {
+ t.Fatal("base.css reads undefined tokens:", missing)
+ }
+}
+
+// Every selector in base.css must be wrapped in :where(), so that it has
+// zero specificity and cannot outrank a component's own link colour.
+// A bare "a { color }" here would repaint every menu item, row title and
+// button in the suite.
+func TestBaseRulesHaveZeroSpecificity(t *testing.T) {
+ src := regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAll(CSS(), nil)
+ selectors := regexp.MustCompile(`(?m)^([^{}]+)\{`).FindAllSubmatch(src, -1)
+ if len(selectors) == 0 {
+ t.Fatal("no rules found in base.css")
+ }
+ for _, m := range selectors {
+ sel := string(m[1])
+ if !regexp.MustCompile(`^\s*:where\([^)]*\)\s*$`).MatchString(sel) {
+ t.Errorf("selector %q is not a bare :where(...) and has specificity", sel)
+ }
+ }
+}
diff --git a/internal/tokencheck/tokencheck.go b/internal/tokencheck/tokencheck.go
new file mode 100644
index 0000000..1ab8de4
--- /dev/null
+++ b/internal/tokencheck/tokencheck.go
@@ -0,0 +1,35 @@
+// Package tokencheck is the shared half of every component's
+// "...UseDefinedTokens" test: each var(--rst-*) a component stylesheet
+// reads must be defined by the pinned rastrillo's tokens.css plus
+// estilo's theme. AGENTS.md's two-pin case, for components.
+package tokencheck
+
+import (
+ "regexp"
+
+ "amadan.net/oficina/estilo.git/theme"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+var (
+ definition = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:`)
+ reference = regexp.MustCompile(`var\(\s*(--rst-[a-z0-9-]+)`)
+)
+
+// Undefined returns the rastrillo tokens css reads that neither
+// ui.TokensCSS() nor theme.CSS() defines, and how many references it
+// read in total, so a caller can refuse a stylesheet that reads none
+// (a check over nothing passes vacuously).
+func Undefined(css []byte) (missing []string, references int) {
+ defined := map[string]bool{}
+ for _, m := range definition.FindAllSubmatch(append(append([]byte{}, ui.TokensCSS()...), theme.CSS()...), -1) {
+ defined[string(m[1])] = true
+ }
+ for _, m := range reference.FindAllSubmatch(css, -1) {
+ references++
+ if !defined[string(m[1])] {
+ missing = append(missing, string(m[1]))
+ }
+ }
+ return missing, references
+}