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>
4 files changed,
+101
−0
base/base.css+15 −0base/base.go+15 −0base/base_test.go+36 −0internal/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 |
| +} |