oficina / estilo Public

Theme: estilo is day, not derived from it — record the ruling, enforce it

Paul, 2026-08-31, verbatim: "Let's have estilo just be day for now, but
we can add oficina specific changes and components to it as we need
to." Records this as decision #6 and a new "Theme" section in
AGENTS.md (mirrored in README.md): estilo's theme is day, identical,
and divergence needs a named need, not a preference — same shape as
the one rule already governing components.

The old TestTokenSetMatchesDay compared token *sets*, so day changing
a value while keeping a token's name would pass silently. Replaced
with TestMatchesDayByteForByte: strips each file's own header comment
(verified byte-identical from :root{} onward beforehand) and asserts
the rest matches the pinned rastrillo module's day.css byte for byte.
On failure it names the specific token and both values, or says
plainly when the divergence isn't in a single declaration, and states
the two-step fix: update estilo.css and record the need in AGENTS.md.

Verified live: changing --rst-accent's light value fails the test with
the token name and both values named; reverting restores green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
Paul Campbell pushed by paul@keymail.dev 01e2888b57aebe3010475b6a2472694df2096c81 parent 982ba41
5 files changed, +185 −79
  • AGENTS.md +42 −0
  • README.md +6 −0
  • theme/estilo.css +14 −13
  • theme/theme.go +6 −6
  • theme/theme_test.go +117 −60
diff --git a/AGENTS.md b/AGENTS.md
index 6422345..306d789 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -4,6 +4,12 @@
> Oficina apps are going to share a design system, estilo makes sense and
> should be started, with rastrillo's `day` theme as its base.
+> **Ruled, same day.** Paul, verbatim: *"Let's have estilo just be day
+> for now, but we can add oficina specific changes and components to it
+> as we need to."* This settles what "as its base" above meant: estilo's
+> theme **is** `day`, not derived from it or based on it — identical.
+> See "Theme" below.
+
The CARLOS suite's design-system variant. See `README.md` for what it is.
This file is binding. Where it disagrees with a general skill, this file
@@ -42,6 +48,7 @@ empty repo invites the opposite.)
| 3 | **No Go code until the next rastrillo release** — §Dependency | Pinning a `main` pseudo-version | This session, 2026-08-30 |
| 4 | **Estilo's own components are written in the bare-attribute grammar, and estilo defines their styling** — §Vocabulary | `class="rst-*"` (right for an app consuming rastrillo's CSS, wrong for a library shipping its own) | Paul, 2026-08-30 |
| 5 | **The colour engine stays in rastrillo's `ui`** | Moving `Pair` and allocation here | ui-guidelines-52, 2026-08-30 |
+| 6 | **Estilo's theme is `day`, unchanged — not "derived from" or "based on" it. Divergence is allowed but must be driven by a named need, not a preference** — §Theme | Restyling on the strength of "day as a base"; changing a token because it "would look better" | Paul, 2026-08-31 |
**Two callers at design time is the bar for *extraction*. It says nothing
about *destination*.** Both consuming apps were told to propose their
@@ -51,6 +58,41 @@ the two apart.
---
+## Theme — RULED, and the shape of every divergence after it
+
+> **Paul, 2026-08-31, verbatim:** *"Let's have estilo just be day for
+> now, but we can add oficina specific changes and components to it as
+> we need to."*
+
+**Estilo's theme is `day`.** Not derived from it, not based on it —
+identical, in every declaration and every value. `theme/theme_test.go`
+enforces this: `TestMatchesDayByteForByte` compares `theme/estilo.css`
+byte-for-byte against the pinned rastrillo module's `ui/themes/day.css`,
+ignoring only each file's own header comment (they legitimately differ —
+estilo's names this file and the test; day's doesn't know estilo exists).
+
+**Divergence is allowed, on the same terms as the one rule above.** Two
+specs are guesses, two implementations are facts: estilo is a home for a
+suite primitive until a second app adopts it, not an interface designed
+ahead of a caller. A theme token is the same shape — `day` is the starting
+position until a real, named need says a token has to differ, not an
+invitation to restyle because a session preferred something else.
+"It would look better" is not a need. "Sheets' grid requires a denser row
+token that `day` does not declare" is.
+
+**So the bar for the first person changing or adding a token:** name the
+app, name the requirement `day` doesn't meet, and in the same change —
+
+- update `theme/estilo.css` to the new value, and
+- record the need as a new row in this file's decisions table above.
+
+`TestMatchesDayByteForByte` fails on exactly that change until both are
+done. That is the enforcement of this rule, not a formality standing in
+its way — do not loosen the test back to a token-set-only comparison to
+get around it.
+
+---
+
## Dependency — RULED, and check it before adding an import
Estilo needs `ui.ThemeCSS`, `ThemeNames`, `Layout`, `LayoutNames`. Verified
diff --git a/README.md b/README.md
index a617fef..a32ff44 100644
--- a/README.md
+++ b/README.md
@@ -4,6 +4,12 @@
> Oficina apps are going to share a design system, estilo makes sense and
> should be started, with rastrillo's `day` theme as its base.
+> **Ruled, same day.** Paul, verbatim: *"Let's have estilo just be day
+> for now, but we can add oficina specific changes and components to it
+> as we need to."* Estilo's theme **is** `day` — identical, not derived
+> from it or based on it — until a named need says otherwise. See
+> `AGENTS.md`, "Theme."
+
The CARLOS suite's design-system variant. Components shared by
[Sheets](https://amadan.net/oficina/sheets),
[Docs](https://amadan.net/oficina/docs) and the
diff --git a/theme/estilo.css b/theme/estilo.css
index 8c213f3..7e106ad 100644
--- a/theme/estilo.css
+++ b/theme/estilo.css
@@ -1,17 +1,18 @@
-/* estilo — the CARLOS suite's shared theme, derived from rastrillo's
- day (v0.22.0). Structure lives in rastrillo's tokens.css, unchanged;
- this file is colour, type and shape, carried across from day token
- for token, value for value.
-
- A theme's only contract is its token set (AGENTS.md, "Vocabulary" —
- also rastrillo's own design-system guidance): estilo declares every
- name day declares. "day as a base" means inheriting it, not
- restyling it, so nothing below has been changed from day's values.
+/* estilo — the CARLOS suite's shared theme. It IS rastrillo's day
+ (v0.22.0), not derived from it and not based on it — identical, in
+ every declaration and every value (Paul's ruling, AGENTS.md,
+ "Theme"). Structure lives in rastrillo's tokens.css, unchanged; this
+ file is colour, type and shape, carried across from day token for
+ token, value for value.
+
Nothing in this file is estilo's opinion — it is day's, carried
- across. theme_test.go checks the two token sets stay equal; if a
- rastrillo upgrade adds, drops or renames a token, that test is where
- it is caught, not silently in Docs, Sheets or another consumer three
- pins away.
+ across, and it stays that way until a named need (an app, a
+ requirement day doesn't meet) says otherwise and that need is
+ recorded in AGENTS.md's decisions table. theme_test.go checks the
+ two files stay byte-identical past this header comment; if a
+ rastrillo upgrade adds, drops, renames or simply reweights a token,
+ that test is where it is caught, not silently in Docs, Sheets or
+ another consumer three pins away.
Any value a future session proposes changing is a design decision
for Paul, not this session — see the "start" branch's report for
diff --git a/theme/theme.go b/theme/theme.go
index 31f4ac3..32f8d28 100644
--- a/theme/theme.go
+++ b/theme/theme.go
@@ -2,13 +2,13 @@
// under estilo's own name so estilo's consumers get one stable import
// regardless of which rastrillo release estilo currently pins.
//
-// The only contract a theme carries is its token set (AGENTS.md,
-// "Vocabulary"; rastrillo's own design-system guidance says the same).
+// Estilo's theme *is* day (AGENTS.md, "Theme") — not derived from it,
+// not based on it, identical, until a named need says a token has to
+// differ and that need is recorded in AGENTS.md's decisions table.
// estilo.css declares the same --rst-* custom properties day.css does,
-// with the same values — "day as a base" means inheriting it, not
-// restyling it. TestTokenSetMatchesDay is the gate that keeps the two
-// in step; see it for what a rastrillo upgrade does and does not get
-// caught by.
+// with the same values. TestMatchesDayByteForByte is the gate that
+// keeps the two in step; see it for what a rastrillo upgrade does and
+// does not get caught by.
package theme
import _ "embed"
diff --git a/theme/theme_test.go b/theme/theme_test.go
index df7ee79..98aa6fd 100644
--- a/theme/theme_test.go
+++ b/theme/theme_test.go
@@ -1,6 +1,8 @@
package theme
import (
+ "bytes"
+ "fmt"
"regexp"
"sort"
"strings"
@@ -10,92 +12,147 @@ import (
)
// tokenDecl matches a custom-property declaration at the start of a
-// line, e.g. " --rst-accent: light-dark(...);" -- capturing the name,
-// not the value. It deliberately does not match var(--rst-accent)
-// *uses*, only declarations, so a theme that merely references another
-// token doesn't get counted as declaring it.
-var tokenDecl = regexp.MustCompile(`(?m)^\s*(--rst-[A-Za-z0-9-]+)\s*:`)
+// line, e.g. " --rst-accent: light-dark(...);" -- capturing the name
+// and the value separately. It deliberately does not match
+// var(--rst-accent) *uses*, only declarations, so a theme that merely
+// references another token doesn't get counted as declaring it.
+var tokenDecl = regexp.MustCompile(`(?m)^\s*(--rst-[A-Za-z0-9-]+)\s*:\s*(.+?);\s*$`)
-// tokenSet returns the set of custom-property names declared in css.
-func tokenSet(css []byte) map[string]bool {
- set := map[string]bool{}
+// tokenValues returns the custom-property declarations in css as a
+// name->value map (the value is the raw declaration text, e.g.
+// "light-dark(#2464e0, #7ba7ff)", not further parsed).
+func tokenValues(css []byte) map[string]string {
+ set := map[string]string{}
for _, m := range tokenDecl.FindAllSubmatch(css, -1) {
- set[string(m[1])] = true
+ set[string(m[1])] = string(m[2])
}
return set
}
-// TestTokenSetMatchesDay is estilo's half of the two-pin drift check
-// AGENTS.md calls for under "Dependency": rastrillo pins nothing above
-// it, estilo pins rastrillo, and the apps will pin estilo. day.css'
-// own comment says a theme's whole contract is its token set --
-// declare every name day declares -- so this test reads day's token
-// set straight from the rastrillo release estilo currently imports
-// (via ui.ThemeCSS("day"), not a copy) and fails if it and estilo.css
-// ever disagree.
+// stripHeaderComment removes the single leading "/* ... */" comment a
+// theme file opens with. estilo.css's and day.css's each explain their
+// own provenance there and legitimately differ -- estilo's names
+// AGENTS.md and this test, day's doesn't know estilo exists. Every
+// byte after that comment is the theme itself, and "estilo is day"
+// (AGENTS.md, "Theme") is a claim about exactly that span.
//
-// What this catches: rastrillo shipping a release where day adds a
-// token estilo hasn't picked up, drops one estilo still declares, or
-// renames one -- exactly the "adds or renames a token" case from
-// AGENTS.md, caught here on `go test` after a `go get -u` of
-// rastrillo, rather than surfacing as an undefined var(--rst-foo) or a
-// dead custom property in Docs, Sheets or a third consumer.
+// It only strips a comment that starts at byte 0; a file that doesn't
+// open with one is returned unchanged, so a missing or rewritten
+// header shows up as an ordinary byte diff below rather than silently
+// comparing the wrong span.
+func stripHeaderComment(css []byte) []byte {
+ if !bytes.HasPrefix(css, []byte("/*")) {
+ return css
+ }
+ end := bytes.Index(css, []byte("*/"))
+ if end == -1 {
+ return css
+ }
+ return css[end+len("*/"):]
+}
+
+// TestMatchesDayByteForByte is estilo's enforcement of the ruling
+// recorded in AGENTS.md under "Theme": estilo's theme *is* day, not
+// derived from it or based on it -- identical, in every declaration
+// and every value. Paul, 2026-08-31, verbatim: "Let's have estilo just
+// be day for now, but we can add oficina specific changes and
+// components to it as we need to." Until a named need is recorded
+// there, there is nothing to diverge on, so this test asserts
+// byte-for-byte identity between theme/estilo.css and the pinned
+// rastrillo module's ui/themes/day.css, past each file's own header
+// comment (see stripHeaderComment) -- not just a matching token set,
+// which would miss day changing a token's value while keeping its
+// name.
+//
+// A rastrillo upgrade that changes day -- adds, drops or renames a
+// token, or simply reweights one of its values -- fails this test on
+// `go test` (after a `go get -u` of rastrillo), rather than surfacing
+// later as an undefined var(--rst-foo), a dead custom property, or a
+// silently stale colour in Docs, Sheets or a third consumer.
+//
+// A deliberate divergence -- estilo choosing on purpose to differ from
+// day -- ALSO fails this test, and that is the point: the rule is not
+// "this never fires," it is "a divergence needs a named need." Making
+// this pass again means updating theme/estilo.css to the new value AND
+// recording the need in AGENTS.md's decisions table in the same
+// change. Do not loosen this back to a token-set-only comparison to
+// make it pass without doing both.
//
// What this does NOT catch:
-// - day changing a token's *value* while keeping its name. The two
-// sets still match; estilo.css silently keeps the old colour until
-// someone re-derives it from the new release. (Values are a design
-// decision, not something this test could resolve on its own even
-// if it noticed.)
-// - Drift in tokens.css itself -- the structural tokens (spacing,
-// type scale, component rules) are not part of a theme file and
-// are out of scope here.
-// - rastrillo's other shipped themes (plain, signal): estilo derives
-// from day only, per Paul's ruling, so this test never looks at
-// them.
-// - A consumer app pinning an *older* estilo than the one that ran
+// - A rastrillo upgrade landing without anyone running `go test`
+// afterward. This fires on the next test run, not the moment
+// upstream changes; nothing here watches rastrillo continuously.
+// - Drift in tokens.css's structural tokens (spacing, type scale,
+// component rules) -- those aren't part of a theme file and are
+// out of scope for this test.
+// - rastrillo's other shipped themes (plain, signal): estilo is day,
+// per Paul's ruling, so this test never looks at them.
+// - A consumer app pinning an older estilo than the one that ran
// this test. This test runs at estilo's own module boundary; it
// says nothing about which estilo version an app has pinned.
-func TestTokenSetMatchesDay(t *testing.T) {
+func TestMatchesDayByteForByte(t *testing.T) {
dayCSS, ok := ui.ThemeCSS("day")
if !ok {
t.Fatal(`ui.ThemeCSS("day") returned false -- rastrillo no longer ships a theme named "day"`)
}
- day := tokenSet(dayCSS)
- if len(day) == 0 {
- t.Fatal("parsed zero --rst-* tokens out of rastrillo's day.css; the parser or the upstream file shape has changed")
- }
- estilo := tokenSet(css)
- if len(estilo) == 0 {
- t.Fatal("parsed zero --rst-* tokens out of estilo.css; the parser or this file's shape has changed")
+ day := stripHeaderComment(dayCSS)
+ estilo := stripHeaderComment(css)
+
+ if bytes.Equal(day, estilo) {
+ return
}
- var missing, extra []string
- for name := range day {
- if !estilo[name] {
+ // Diagnose at the token level first: that is almost always what
+ // changed, and the failure should name it directly rather than
+ // dumping a raw byte diff on whoever reads the test output.
+ dayTokens := tokenValues(day)
+ estiloTokens := tokenValues(estilo)
+
+ var changed, missing, extra []string
+ for name, dv := range dayTokens {
+ ev, ok := estiloTokens[name]
+ if !ok {
missing = append(missing, name)
+ continue
+ }
+ if ev != dv {
+ changed = append(changed, name)
}
}
- for name := range estilo {
- if !day[name] {
+ for name := range estiloTokens {
+ if _, ok := dayTokens[name]; !ok {
extra = append(extra, name)
}
}
+ sort.Strings(changed)
sort.Strings(missing)
sort.Strings(extra)
- if len(missing) > 0 || len(extra) > 0 {
- var b strings.Builder
- b.WriteString("estilo.css's token set has drifted from rastrillo day's:\n")
- if len(missing) > 0 {
- b.WriteString(" day declares, estilo is missing: " + strings.Join(missing, ", ") + "\n")
- }
- if len(extra) > 0 {
- b.WriteString(" estilo declares, day no longer has: " + strings.Join(extra, ", ") + "\n")
- }
- b.WriteString("A theme's only contract is its token set (AGENTS.md, \"Vocabulary\") -- " +
- "update theme/estilo.css to match, recomputing any contrast rows the change touches.")
- t.Fatal(b.String())
+ var b strings.Builder
+ b.WriteString("theme/estilo.css has drifted from rastrillo's day.css (v0.22.0, as pinned in go.mod), past the header comment:\n")
+ for _, name := range changed {
+ fmt.Fprintf(&b, " %s: day has %q, estilo has %q\n", name, dayTokens[name], estiloTokens[name])
+ }
+ for _, name := range missing {
+ b.WriteString(" day declares, estilo is missing: " + name + "\n")
+ }
+ for _, name := range extra {
+ b.WriteString(" estilo declares, day no longer has: " + name + "\n")
+ }
+ if len(changed) == 0 && len(missing) == 0 && len(extra) == 0 {
+ // The bytes differ but no single --rst-* declaration line
+ // does -- the divergence is in something the token regex
+ // doesn't parse (color-scheme, the two [data-theme] toggle
+ // rules, or plain formatting). Say so rather than reporting
+ // three empty lists and leaving the reader to guess.
+ b.WriteString(" no single --rst-* declaration differs; the divergence is elsewhere in the file " +
+ "(the toggle rules, color-scheme, or formatting) -- diff theme/estilo.css against rastrillo's " +
+ "ui/themes/day.css by hand.\n")
}
+ b.WriteString("If this is an unintended rastrillo upgrade, update theme/estilo.css to match day. " +
+ "If this is a deliberate divergence, that is allowed but not free: update theme/estilo.css to the " +
+ "new value AND record the need in AGENTS.md's decisions table in the same change -- \"it would look " +
+ "better\" is not a need, \"<app> requires <token> which day does not declare\" is.")
+ t.Fatal(b.String())
}