oficina / estilo Public

theme: estilo's day-derived theme, plus the drift check that keeps it honest

theme/estilo.css declares the same --rst-* custom properties rastrillo's
day.css does, with day's values unchanged. A theme's only contract is
its token set (rastrillo's own design-system guidance, and AGENTS.md's
Vocabulary section) — "day as a base" means inheriting it, not
restyling it, so this file makes no styling decision of its own.
Nowhere here did I change a value I thought should differ; anywhere I
had that instinct, it's a proposal in the branch report instead, because
design decisions are Paul's.

theme.go embeds it behind CSS(), so a consumer gets one stable import
regardless of which rastrillo release estilo currently pins.

theme_test.go is estilo's half of the two-pin drift check AGENTS.md's
Dependency section calls for: rastrillo pins nothing above it, estilo
pins rastrillo, and the apps will pin estilo, which is the frozen-
tokens.css problem one layer down. rastrillo doctor mechanises the
single-pin case; this is the two-pin case. It reads day's token set
straight from ui.ThemeCSS("day") in the pinned rastrillo release — not
a copy — and fails if estilo.css's declared names don't match exactly,
so a rastrillo upgrade that adds, drops or renames a token is caught
here on `go test` rather than surfacing as an undefined var() or a dead
property three consumers away.

It does NOT catch day changing a token's *value* while keeping its
name (the sets still match), drift in tokens.css's structural tokens
(out of scope for a theme file), rastrillo's other themes (estilo
derives from day only), or an app pinning an older estilo than the one
that ran this test. Verified both directions by hand: renaming a token
in estilo.css fails the test with the exact name diff; reverting passes
it again.
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 ce38478ee5b2e8e183ea23a3902515acfac64c35 parent ea83081
3 files changed, +320 −0
  • theme/estilo.css +196 −0
  • theme/theme.go +23 −0
  • theme/theme_test.go +101 −0
diff --git a/theme/estilo.css b/theme/estilo.css
new file mode 100644
index 0000000..8c213f3
--- /dev/null
+++ b/theme/estilo.css
@@ -0,0 +1,196 @@
+/* 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.
+ 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.
+
+ Any value a future session proposes changing is a design decision
+ for Paul, not this session — see the "start" branch's report for
+ proposals raised while building this file, none of them applied
+ here.
+
+ The look, inherited from day: the ordinary, personable interface. A
+ white page, a plain grey text scale, one friendly blue that means
+ "this does something", and the conventional green/amber/red for
+ status. Nothing here is trying to be memorable — day is the theme an
+ app ships on while it works out what it wants to look like, and the
+ one that never gets in the way of the content. Semantic status
+ colours are functional and never the accent, so a pill can never be
+ mistaken for a link.
+
+ ── One block, both schemes ───────────────────────────────────────────
+ Every token is declared once, as light-dark(<light>, <dark>), under a
+ single :root that sets color-scheme: light dark. The browser picks the
+ half that matches the resolved scheme, so there is no second copy of
+ the palette to keep in sync and no way to author dark half-done. The
+ two rules at the bottom are the whole toggle: setting color-scheme on
+ :root[data-theme=…] re-resolves every light-dark() in the file at
+ once, in both directions, which is why an explicit choice beats the OS
+ without restating a single colour.
+
+ Tokens with one value in both schemes — the font stack, the radii —
+ are written plain, because light-dark(x, x) says nothing twice.
+
+ light-dark() is a colour function: it may only stand where a <color>
+ is expected. So a shadow token wraps its colour in it and writes the
+ geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never
+ `light-dark(0 8px 24px a, …)`, which no browser would accept as a
+ box-shadow.
+
+ ── The shape axis ────────────────────────────────────────────────────
+ Radius and shadow are the theme's now, not tokens.css's: a theme that
+ can only change colour cannot change how an app feels. day is soft and
+ unremarkable — 8px cards, 6px controls, shadows with a wide blur and
+ little alpha, the depth of paper resting on paper. estilo inherits
+ this shape axis along with the colours; changing it is a proposal, not
+ something this file has done.
+
+ ── WCAG 2.2 AA, measured ─────────────────────────────────────────────
+ Normal text needs 4.5:1; large text and UI component boundaries need
+ 3:1. Every pair below was computed from these exact hex values, in
+ both schemes, by rastrillo for day. Since estilo has not changed a
+ value, the numbers still hold — but they describe day's arithmetic,
+ not a re-derivation done here. If you change a token, recompute its
+ rows.
+
+ LIGHT ratio requirement
+ --rst-text on --rst-surface 16.91 4.5:1 text
+ --rst-text on --rst-bg 16.91 4.5:1 text
+ --rst-text on --rst-surface-2 15.77 4.5:1 text
+ --rst-text on --rst-accent-soft 15.21 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 7.56 4.5:1 text
+ --rst-text-muted on --rst-surface-2 7.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text
+ --rst-text-faint on --rst-surface 5.29 4.5:1 text
+ --rst-text-faint on --rst-bg 5.29 4.5:1 text
+ --rst-text-faint on --rst-surface-2 4.93 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text
+ --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 4.93 4.5:1 text
+ --rst-accent on --rst-accent-soft 4.75 4.5:1 text
+ --rst-on-accent on --rst-accent 5.28 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 3.63 3:1 control border
+ --rst-line-strong on --rst-bg 3.63 3:1 control border
+ --rst-line-strong on --rst-surface-2 3.39 3:1 control border
+ pill neutral fg on its own tint 9.02 4.5:1 text
+ pill positive fg on its own tint 6.49 4.5:1 text
+ pill warning fg on its own tint 6.15 4.5:1 text
+ pill negative fg on its own tint 5.30 4.5:1 text
+ --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label
+
+ DARK ratio requirement
+ --rst-text on --rst-surface 13.74 4.5:1 text
+ --rst-text on --rst-bg 15.32 4.5:1 text
+ --rst-text on --rst-surface-2 14.63 4.5:1 text
+ --rst-text on --rst-accent-soft 13.02 4.5:1 text
+ --rst-text-muted on --rst-surface 7.56 4.5:1 text
+ --rst-text-muted on --rst-bg 8.43 4.5:1 text
+ --rst-text-muted on --rst-surface-2 8.05 4.5:1 text
+ --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text
+ --rst-text-faint on --rst-surface 6.33 4.5:1 text
+ --rst-text-faint on --rst-bg 7.06 4.5:1 text
+ --rst-text-faint on --rst-surface-2 6.74 4.5:1 text
+ --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text
+ --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring
+ --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring
+ --rst-accent on --rst-surface-2 7.40 4.5:1 text
+ --rst-accent on --rst-accent-soft 6.59 4.5:1 text
+ --rst-on-accent on --rst-accent 7.86 4.5:1 button label
+ --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover
+ --rst-line-strong on --rst-surface 4.26 3:1 control border
+ --rst-line-strong on --rst-bg 4.75 3:1 control border
+ --rst-line-strong on --rst-surface-2 4.53 3:1 control border
+ pill neutral fg on its own tint 8.44 4.5:1 text
+ pill positive fg on its own tint 10.18 4.5:1 text
+ pill warning fg on its own tint 9.62 4.5:1 text
+ pill negative fg on its own tint 7.85 4.5:1 text
+ --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label
+
+ The tightest pair in the set is --rst-accent on --rst-accent-soft in
+ light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not
+ listed above and you are guessing; recompute first.
+
+ --rst-line is a hairline divider between rows, not a control border or
+ a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
+ borders use --rst-line-strong, which does clear 3:1.
+
+ Pagination chips are the one deliberate exception: they are bordered
+ with --rst-line rather than --rst-line-strong, because a page strip
+ ringed in hard outlines shouts louder than the list it belongs to.
+ 1.4.11 is satisfied without that border — each chip's meaning is
+ carried by its text label at 4.5:1 or better, and the current page
+ adds a filled background, a heavier weight and aria-current on top.
+
+ --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and
+ --rst-overlay are not in the table: they paint a translucent shadow or
+ scrim over whatever sits underneath, not text over a fixed
+ background, so a contrast ratio does not apply. The scrim deepens in
+ dark, matching --rst-shadow-pop's own jump: black reads lighter
+ against a dark backdrop, so the overlay needs more alpha to still
+ read as a dimming layer.
+ ──────────────────────────────────────────────────────────────────── */
+
+:root {
+ color-scheme: light dark;
+
+ /* Type: the family only. Sizes and spacing are structural and stay in
+ tokens.css. */
+ --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
+
+ /* Shape: soft, ordinary, unremarkable. */
+ --rst-radius: 8px;
+ --rst-radius-sm: 6px;
+ --rst-radius-pill: 999px;
+
+ /* Surfaces and lines */
+ --rst-bg: light-dark(#ffffff, #111418);
+ --rst-surface: light-dark(#ffffff, #1a1f26);
+ --rst-surface-2: light-dark(#f6f7f8, #15191f);
+ --rst-line: light-dark(#e5e7eb, #2a3038);
+ --rst-line-strong: light-dark(#7f8792, #79828e);
+
+ /* Text */
+ --rst-text: light-dark(#1a1d21, #e8eaed);
+ --rst-text-muted: light-dark(#4b5563, #a8b0ba);
+ --rst-text-faint: light-dark(#656c78, #98a1ac);
+
+ /* Accent */
+ --rst-accent: light-dark(#2464e0, #7ba7ff);
+ --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff);
+ --rst-accent-soft: light-dark(#eef3fe, #17233a);
+ --rst-on-accent: light-dark(#ffffff, #0b1220);
+
+ /* Status tones */
+ --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
+ --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
+ --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
+ --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
+ --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
+ --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
+ --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
+ --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
+
+ /* Depth */
+ --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
+ --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
+ --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
+ --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
+}
+
+/* The whole toggle. Setting color-scheme re-resolves every light-dark()
+ above, so an explicit choice beats the OS in both directions without
+ restating a colour. */
+:root[data-theme="light"] { color-scheme: light; }
+:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/theme/theme.go b/theme/theme.go
new file mode 100644
index 0000000..31f4ac3
--- /dev/null
+++ b/theme/theme.go
@@ -0,0 +1,23 @@
+// Package theme is estilo's theme: rastrillo's day theme, declared
+// 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.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.
+package theme
+
+import _ "embed"
+
+//go:embed estilo.css
+var css []byte
+
+// CSS returns estilo's theme stylesheet: rastrillo's day theme, token
+// for token, as of the rastrillo release estilo currently pins (see
+// go.mod). A caller drops it in wherever an app would otherwise write
+// rastrillo's own theme.css.
+func CSS() []byte { return append([]byte(nil), css...) }
diff --git a/theme/theme_test.go b/theme/theme_test.go
new file mode 100644
index 0000000..df7ee79
--- /dev/null
+++ b/theme/theme_test.go
@@ -0,0 +1,101 @@
+package theme
+
+import (
+ "regexp"
+ "sort"
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+// 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*:`)
+
+// tokenSet returns the set of custom-property names declared in css.
+func tokenSet(css []byte) map[string]bool {
+ set := map[string]bool{}
+ for _, m := range tokenDecl.FindAllSubmatch(css, -1) {
+ set[string(m[1])] = true
+ }
+ 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.
+//
+// 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.
+//
+// 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
+// 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) {
+ 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")
+ }
+
+ var missing, extra []string
+ for name := range day {
+ if !estilo[name] {
+ missing = append(missing, name)
+ }
+ }
+ for name := range estilo {
+ if !day[name] {
+ extra = append(extra, name)
+ }
+ }
+ 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())
+ }
+}