oficina / estilo Public

WIP: Calma theme, per-app accents on one ring, Onest

Paul Campbell pushed by paul@keymail.dev 1fff0bc1c94a6de38b902e931fd0fa0981234ad6 parent 894afdc
22 files changed, +749 −315
  • cmd/oficina-notices/main.go +4 −0
  • credits/fonts.go +13 −0
  • credits/fonts_test.go +28 −0
  • credits/notices/onest-OFL.txt +93 −0
  • credits/notices/onest-provenance.json +11 −0
  • theme/apps.go +131 −0
  • theme/estilo.css +61 −174
  • theme/fonts.go +72 −0
  • theme/fonts/onest-latin.woff2 binary
  • theme/fonts_test.go +44 −0
  • theme/testdata/calendar.css +7 −0
  • theme/testdata/calls.css +7 −0
  • theme/testdata/docs.css +7 −0
  • theme/testdata/fichas.css +7 −0
  • theme/testdata/files.css +7 −0
  • theme/testdata/mail.css +7 −0
  • theme/testdata/memoria.css +7 −0
  • theme/testdata/oficina.css +7 −0
  • theme/testdata/sheets.css +7 −0
  • theme/testdata/slides.css +7 −0
  • theme/theme.go +8 −15
  • theme/theme_test.go +214 −126
diff --git a/cmd/oficina-notices/main.go b/cmd/oficina-notices/main.go
index 2c844d8..f7f78f9 100644
--- a/cmd/oficina-notices/main.go
+++ b/cmd/oficina-notices/main.go
@@ -19,6 +19,7 @@ func main() {
entry := flag.String("package", "", "production Go package")
excludes := flag.String("exclude", "", "comma-separated first-party module paths")
lucide := flag.Bool("lucide", false, "include vendored Lucide and Feather notices")
+ onest := flag.Bool("onest", false, "include the Onest font notice (apps that mount theme.FontHandler)")
flag.Parse()
if *entry == "" || flag.NArg() != 0 {
fmt.Fprintln(os.Stderr, "oficina-notices: -package is required")
@@ -61,6 +62,9 @@ func main() {
if *lucide {
inventory.Components = append(inventory.Components, credits.Lucide())
}
+ if *onest {
+ inventory.Components = append(inventory.Components, credits.Onest())
+ }
sort.Slice(inventory.Components, func(i, j int) bool { return inventory.Components[i].Name < inventory.Components[j].Name })
encoder := json.NewEncoder(os.Stdout)
encoder.SetIndent("", " ")
diff --git a/credits/fonts.go b/credits/fonts.go
new file mode 100644
index 0000000..3cd35f1
--- /dev/null
+++ b/credits/fonts.go
@@ -0,0 +1,13 @@
+package credits
+
+import _ "embed"
+
+//go:embed notices/onest-OFL.txt
+var onestNotice string
+
+// Onest supplies the SIL Open Font License notice for Calma's typeface,
+// which estilo's theme package embeds and serves (theme/fonts.go). An app
+// that mounts theme.FontHandler lists it on its credits page.
+func Onest() Component {
+ return Component{Name: "Onest", SourceURL: "https://github.com/googlefonts/onest", Notices: []Notice{{Name: "OFL.txt", Text: onestNotice}}}
+}
diff --git a/credits/fonts_test.go b/credits/fonts_test.go
new file mode 100644
index 0000000..08b943c
--- /dev/null
+++ b/credits/fonts_test.go
@@ -0,0 +1,28 @@
+package credits
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "os"
+ "testing"
+)
+
+func TestOnestNoticeMatchesRecordedProvenance(t *testing.T) {
+ raw, err := os.ReadFile("notices/onest-provenance.json")
+ if err != nil {
+ t.Fatal(err)
+ }
+ var record struct {
+ Source string `json:"source"`
+ SHA256 string `json:"sha256"`
+ Bytes int `json:"bytes"`
+ }
+ if err := json.Unmarshal(raw, &record); err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256([]byte(onestNotice))
+ if record.Source != "https://raw.githubusercontent.com/google/fonts/d0754ee7cddf8ba879f1f8884e3ca2b5e1b100f8/ofl/onest/OFL.txt" || record.Bytes != len(onestNotice) || record.SHA256 != hex.EncodeToString(sum[:]) {
+ t.Fatal("Onest notice differs from original provenance")
+ }
+}
diff --git a/credits/notices/onest-OFL.txt b/credits/notices/onest-OFL.txt
new file mode 100644
index 0000000..b6f4f91
--- /dev/null
+++ b/credits/notices/onest-OFL.txt
@@ -0,0 +1,93 @@
+Copyright 2021 The Onest Project Authors (https://github.com/googlefonts/onest)
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+https://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded,
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/credits/notices/onest-provenance.json b/credits/notices/onest-provenance.json
new file mode 100644
index 0000000..eceaf11
--- /dev/null
+++ b/credits/notices/onest-provenance.json
@@ -0,0 +1,11 @@
+{
+ "source": "https://raw.githubusercontent.com/google/fonts/d0754ee7cddf8ba879f1f8884e3ca2b5e1b100f8/ofl/onest/OFL.txt",
+ "sha256": "071195d8806e226faeee60259c28ca67b458227af5195a73f5cfcab06e3003bc",
+ "bytes": 4384,
+ "font": {
+ "source": "https://fonts.gstatic.com/s/onest/v11/gNMKW3F-SZuj7xmf-HYoEoey.woff2",
+ "file": "theme/fonts/onest-latin.woff2",
+ "sha256": "cc61cbd632bb677cd55f0eec9ebdd3e1525c5f30904cbf0769e2ac50d0fba992",
+ "subset": "latin"
+ }
+}
diff --git a/theme/apps.go b/theme/apps.go
new file mode 100644
index 0000000..1768630
--- /dev/null
+++ b/theme/apps.go
@@ -0,0 +1,131 @@
+package theme
+
+import (
+ "fmt"
+ "math"
+ "strings"
+)
+
+// App is one product of the suite and its place on Calma's colour ring.
+type App struct {
+ Slug string // closed set; see Apps
+ Name string // the product's name, as a person reads it
+ Hue float64 // OKLCH hue, degrees
+}
+
+// apps is the ring. Every app's colour is the same OKLCH lightness and
+// chroma at a different hue, so no app is louder than another; the hues
+// are spaced evenly to the eye rather than to the protractor (34-50°,
+// with the widest gap across the yellow-greens, which go muddy at this
+// lightness). Paul, 2026-09-29, with Mail and Files still to come.
+//
+// This table is the one source of the suite's colours: the accent blocks
+// below, and brand/icons.json's hues (brand_test checks they agree).
+var apps = []App{
+ {"oficina", "Oficina", 262},
+ {"docs", "Docs", 262},
+ {"memoria", "Memoria", 302},
+ {"mail", "Mail", 342},
+ {"calendar", "Calendar", 20},
+ {"slides", "Slides", 56},
+ {"fichas", "Fichas", 90},
+ {"sheets", "Sheets", 140},
+ {"files", "Files", 180},
+ {"calls", "Calls", 220},
+}
+
+// Apps returns the ring, Oficina first.
+func Apps() []App { return append([]App(nil), apps...) }
+
+// Lookup returns the app for slug. The set is closed: a slug that is not
+// in the ring is not a theme, whatever else it is.
+func Lookup(slug string) (App, bool) {
+ for _, a := range apps {
+ if a.Slug == slug {
+ return a, true
+ }
+ }
+ return App{}, false
+}
+
+// The accent's four tokens, as OKLCH lightness and chroma; the hue is the
+// app's. Light: a deep accent that carries white text; dark: a light one
+// that carries near-black. Chroma is the most each hue can hold at that
+// lightness inside sRGB, up to these values (see Hex).
+var accentSpec = [2]struct{ accent, strong, soft [2]float64 }{
+ {accent: [2]float64{0.49, 0.15}, strong: [2]float64{0.43, 0.14}, soft: [2]float64{0.955, 0.025}},
+ {accent: [2]float64{0.76, 0.12}, strong: [2]float64{0.84, 0.09}, soft: [2]float64{0.29, 0.045}},
+}
+
+// AccentCSS is the block that re-declares the accent for one app. It
+// follows estilo.css in the cascade, so the app's colour wins everywhere
+// the suite's would have shown.
+func AccentCSS(slug string) ([]byte, bool) {
+ a, ok := Lookup(slug)
+ if !ok {
+ return nil, false
+ }
+ hex := func(lc [2]float64) string { return Hex(lc[0], lc[1], a.Hue) }
+ l, d := accentSpec[0], accentSpec[1]
+ var b strings.Builder
+ fmt.Fprintf(&b, "/* The accent for %s: hue %g on Calma's ring (estilo theme/apps.go). */\n", a.Name, a.Hue)
+ b.WriteString(":root {\n")
+ fmt.Fprintf(&b, " --rst-accent: light-dark(%s, %s);\n", hex(l.accent), hex(d.accent))
+ fmt.Fprintf(&b, " --rst-accent-strong: light-dark(%s, %s);\n", hex(l.strong), hex(d.strong))
+ fmt.Fprintf(&b, " --rst-accent-soft: light-dark(%s, %s);\n", hex(l.soft), hex(d.soft))
+ b.WriteString(" --rst-on-accent: light-dark(#ffffff, #111418);\n")
+ b.WriteString("}\n")
+ return []byte(b.String()), true
+}
+
+// ForApp is the whole theme for one app: estilo.css with that app's
+// accent after it. Serve it as the app's /static/theme.css.
+func ForApp(slug string) ([]byte, bool) {
+ accent, ok := AccentCSS(slug)
+ if !ok {
+ return nil, false
+ }
+ out := append(CSS(), '\n')
+ return append(out, accent...), true
+}
+
+// Hex converts an OKLCH colour to sRGB hex, taking chroma down in small
+// steps until the colour fits in sRGB, so the hue and lightness stay true
+// and only the saturation gives. (Clipping each channel instead would
+// shift the hue, which is the one thing the ring promises.)
+func Hex(L, C, h float64) string {
+ for C > 0 {
+ if r, g, b := oklchToLinear(L, C, h); inGamut(r) && inGamut(g) && inGamut(b) {
+ break
+ }
+ C -= 0.002
+ }
+ r, g, b := oklchToLinear(L, math.Max(C, 0), h)
+ return fmt.Sprintf("#%02x%02x%02x", encode(r), encode(g), encode(b))
+}
+
+func inGamut(c float64) bool { return c >= -1e-4 && c <= 1+1e-4 }
+
+// oklchToLinear is Björn Ottosson's OKLab to linear sRGB.
+func oklchToLinear(L, C, h float64) (r, g, b float64) {
+ rad := h * math.Pi / 180
+ a, bb := C*math.Cos(rad), C*math.Sin(rad)
+ l := cube(L + 0.3963377774*a + 0.2158037573*bb)
+ m := cube(L - 0.1055613458*a - 0.0638541728*bb)
+ s := cube(L - 0.0894841775*a - 1.2914855480*bb)
+ return 4.0767416621*l - 3.3077115913*m + 0.2309699292*s,
+ -1.2684380046*l + 2.6097574011*m - 0.3413193965*s,
+ -0.0041960863*l - 0.7034186147*m + 1.7076147010*s
+}
+
+func cube(x float64) float64 { return x * x * x }
+
+func encode(c float64) int {
+ c = math.Max(0, math.Min(1, c))
+ if c <= 0.0031308 {
+ c *= 12.92
+ } else {
+ c = 1.055*math.Pow(c, 1/2.4) - 0.055
+ }
+ return int(math.Round(c * 255))
+}
diff --git a/theme/estilo.css b/theme/estilo.css
index 9782398..0d28537 100644
--- a/theme/estilo.css
+++ b/theme/estilo.css
@@ -1,186 +1,73 @@
-/* estilo — the CARLOS suite's shared theme. It IS rastrillo's day
- (v0.23.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, 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. Constraints worth knowing before that
- conversation are recorded in AGENTS.md under "Known constraints";
- none of them are 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.
+/* estilo — the Oficina suite's theme: Calma.
+
+ Calma replaced rastrillo's day on 2026-09-29, by Paul's decision after
+ six rounds of design directions (AGENTS.md, decisions table, row 8).
+ Until then estilo WAS day, byte for byte; it is now its own theme, and
+ theme_test.go gates it on its own terms: the same token set day
+ declares (so rastrillo's tokens.css finds every name it uses), every
+ contrast pair rastrillo gates, in both schemes — for this file AND for
+ every app's accent block (apps.go) — and a golden per app so no value
+ moves without the diff saying so.
+
+ The look: quiet, even and exact. A cool off-white page with white
+ cards, one grey text scale, soft 12px shapes with a long low shadow.
+ Colour belongs to the apps: every app's hue sits on one ring of equal
+ OKLCH lightness, so no app shouts over another, and the interface
+ tints to the app you are in (apps.go — --rst-accent and friends are
+ re-declared per app). This file's own accent is Oficina's, the suite's,
+ used where no app is in charge (sign-in, the team's home).
+
+ Status tones are day's, unchanged: they are functional, not brand, and
+ a pill must 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.
+ As day: every colour is light-dark(<light>, <dark>) under one :root
+ with color-scheme: light dark, and the two [data-theme] rules at the
+ bottom are the whole toggle.
+
+ ── WCAG 2.2 AA ───────────────────────────────────────────────────────
+ Every pair rastrillo's own gate checks is checked by
+ TestContrastMeetsWCAG, for this file and each app's accent, light and
+ dark, through rastrillo's ui.ContrastRatio. The tightest, at the time
+ of writing: faint text on an accent-tinted surface, 4.63:1 (Files,
+ dark); a control border on the sunk surface, 3.2:1 (light).
──────────────────────────────────────────────────────────────────── */
: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;
+ /* Type: the family only. Sizes and spacing stay in tokens.css. Onest
+ where it is served (fonts.go), the system face where it is not. */
+ --rst-font: "Onest", system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
- /* Shape: soft, ordinary, unremarkable. */
- --rst-radius: 8px;
- --rst-radius-sm: 6px;
+ /* Shape: soft and exact. */
+ --rst-radius: 12px;
+ --rst-radius-sm: 8px;
--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);
+ /* Surfaces and lines: a cool off-white page, white cards, a sunk grey. */
+ --rst-bg: light-dark(#f5f6f8, #15171c);
+ --rst-surface: light-dark(#ffffff, #1c1f26);
+ --rst-surface-2: light-dark(#eceef2, #23262e);
+ --rst-line: light-dark(#e3e6eb, #2d313a);
+ --rst-line-strong: light-dark(#7f8591, #7a818e);
- /* The page header's rule, derived rather than authored: the accent
- folded into the line at 18%, so a theme that changes its accent
- gets a matching rule with no second value to keep in step. day's
- is barely there — a hairline you would call grey until you put it
- beside plain's. Decorative: it carries no contrast floor. */
+ /* The page header's rule: the accent folded into the line, as day. */
--rst-header-rule: color-mix(in oklab, var(--rst-accent) 18%, var(--rst-line));
/* 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-text: light-dark(#262a33, #eceef2);
+ --rst-text-muted: light-dark(#565c69, #b4bac6);
+ --rst-text-faint: light-dark(#626875, #8f96a3);
+
+ /* Accent: Oficina's, hue 262 on the ring (apps.go). Each app
+ re-declares these four. */
+ --rst-accent: light-dark(#2e5bb4, #87b1fd);
+ --rst-accent-strong: light-dark(#214a9b, #afcbfe);
+ --rst-accent-soft: light-dark(#e9f1ff, #1f2b42);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+
+ /* Status tones: day's, unchanged. */
--rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
--rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
--rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
@@ -190,11 +77,11 @@
--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));
+ /* Depth: a long, low, cool shadow; paper lifted, not stacked. */
+ --rst-shadow-pop: 0 18px 44px -14px light-dark(rgba(20, 24, 33, 0.28), rgba(0, 0, 0, 0.6));
+ --rst-shadow-knob: 0 1px 2px rgba(20, 24, 33, 0.3);
+ --rst-shadow-lift: 0 1px 1px rgba(20, 24, 33, 0.04), 0 14px 36px -14px light-dark(rgba(20, 24, 33, 0.22), rgba(0, 0, 0, 0.55));
+ --rst-overlay: light-dark(rgba(20, 24, 33, 0.45), rgba(0, 0, 0, 0.6));
}
/* The whole toggle. Setting color-scheme re-resolves every light-dark()
diff --git a/theme/fonts.go b/theme/fonts.go
new file mode 100644
index 0000000..96015a7
--- /dev/null
+++ b/theme/fonts.go
@@ -0,0 +1,72 @@
+package theme
+
+import (
+ "embed"
+ "net/http"
+ "strings"
+)
+
+// Onest is Calma's typeface: the Latin subset of the variable font
+// (weights 300-800), SIL Open Font License 1.1. Provenance and the
+// licence are in credits/notices/onest-*; list them on the app's credits
+// page with credits.Onest().
+
+//go:embed fonts/onest-latin.woff2
+var fontFS embed.FS
+
+// FontPrefix is the URL path FontHandler serves under.
+const FontPrefix = "/static/estilo/fonts/"
+
+// fontCSS declares Onest for the Latin range; a character outside it
+// falls through to the system face in --rst-font, glyph by glyph.
+const fontCSS = `/* Onest, Calma's typeface (estilo theme/fonts.go). */
+@font-face {
+ font-family: "Onest";
+ font-style: normal;
+ font-weight: 300 800;
+ font-display: swap;
+ src: url("` + FontPrefix + `onest-latin.woff2") format("woff2");
+ unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
+}
+`
+
+// FontCSS is the @font-face stylesheet, served by FontHandler as
+// FontPrefix + "fonts.css". Link it before theme.css. Without it the
+// theme still works, in the system face.
+func FontCSS() []byte { return []byte(fontCSS) }
+
+// FontHandler serves GET and HEAD for FontPrefix + "fonts.css" and
+// FontPrefix + "onest-latin.woff2". Mount it at FontPrefix; anything else
+// under it is 404. Public, sessionless, and the bytes never change under
+// a name, so both are cached for a year.
+func FontHandler() http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ name := strings.TrimPrefix(r.URL.Path, FontPrefix)
+ var body []byte
+ var kind string
+ switch {
+ case !strings.HasPrefix(r.URL.Path, FontPrefix):
+ case name == "fonts.css":
+ body, kind = FontCSS(), "text/css; charset=utf-8"
+ case name == "onest-latin.woff2":
+ body, _ = fontFS.ReadFile("fonts/onest-latin.woff2")
+ kind = "font/woff2"
+ }
+ if body == nil {
+ http.NotFound(w, r)
+ return
+ }
+ if r.Method != http.MethodGet && r.Method != http.MethodHead {
+ w.Header().Set("Allow", "GET, HEAD")
+ http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed)
+ return
+ }
+ w.Header().Set("Content-Type", kind)
+ w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ if r.Method == http.MethodHead {
+ return
+ }
+ _, _ = w.Write(body)
+ })
+}
diff --git a/theme/fonts/onest-latin.woff2 b/theme/fonts/onest-latin.woff2
new file mode 100644
index 0000000..2712c93
Binary files /dev/null and b/theme/fonts/onest-latin.woff2 differ
diff --git a/theme/fonts_test.go b/theme/fonts_test.go
new file mode 100644
index 0000000..5ed6360
--- /dev/null
+++ b/theme/fonts_test.go
@@ -0,0 +1,44 @@
+package theme
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+)
+
+func TestFontHandlerServesOnlyItsTwoFiles(t *testing.T) {
+ h := FontHandler()
+ for path, want := range map[string]int{
+ FontPrefix + "fonts.css": 200,
+ FontPrefix + "onest-latin.woff2": 200,
+ FontPrefix + "onest.woff2": 404,
+ FontPrefix + "../theme/estilo.css": 404,
+ FontPrefix: 404,
+ "/static/other/onest-latin.woff2": 404,
+ } {
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil))
+ if rec.Code != want {
+ t.Errorf("GET %s = %d, want %d", path, rec.Code, want)
+ }
+ }
+ rec := httptest.NewRecorder()
+ h.ServeHTTP(rec, httptest.NewRequest(http.MethodPost, FontPrefix+"fonts.css", nil))
+ if rec.Code != http.StatusMethodNotAllowed {
+ t.Errorf("POST = %d, want 405", rec.Code)
+ }
+}
+
+// The embedded font is the one the provenance record names.
+func TestOnestIsTheRecordedFile(t *testing.T) {
+ body, err := fontFS.ReadFile("fonts/onest-latin.woff2")
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(body)
+ if got := hex.EncodeToString(sum[:]); got != "cc61cbd632bb677cd55f0eec9ebdd3e1525c5f30904cbf0769e2ac50d0fba992" {
+ t.Fatalf("onest-latin.woff2 is not the file credits/notices/onest-provenance.json records (sha256 %s)", got)
+ }
+}
diff --git a/theme/testdata/calendar.css b/theme/testdata/calendar.css
new file mode 100644
index 0000000..82a1dbc
--- /dev/null
+++ b/theme/testdata/calendar.css
@@ -0,0 +1,7 @@
+/* The accent for Calendar: hue 20 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#a4323b, #f49191);
+ --rst-accent-strong: light-dark(#8c232e, #ffb4b3);
+ --rst-accent-soft: light-dark(#feebea, #3f2222);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/calls.css b/theme/testdata/calls.css
new file mode 100644
index 0000000..e4971cf
--- /dev/null
+++ b/theme/testdata/calls.css
@@ -0,0 +1,7 @@
+/* The accent for Calls: hue 220 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#056b83, #42c2e6);
+ --rst-accent-strong: light-dark(#01596d, #84d9f3);
+ --rst-accent-soft: light-dark(#dff5fc, #0b303b);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/docs.css b/theme/testdata/docs.css
new file mode 100644
index 0000000..0470ff3
--- /dev/null
+++ b/theme/testdata/docs.css
@@ -0,0 +1,7 @@
+/* The accent for Docs: hue 262 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#2e5bb4, #87b1fd);
+ --rst-accent-strong: light-dark(#214a9b, #afcbfe);
+ --rst-accent-soft: light-dark(#e9f1ff, #1f2b42);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/fichas.css b/theme/testdata/fichas.css
new file mode 100644
index 0000000..5dae5b1
--- /dev/null
+++ b/theme/testdata/fichas.css
@@ -0,0 +1,7 @@
+/* The accent for Fichas: hue 90 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#765d00, #cfad4e);
+ --rst-accent-strong: light-dark(#634d00, #e1c885);
+ --rst-accent-soft: light-dark(#f7f0de, #342a0e);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/files.css b/theme/testdata/files.css
new file mode 100644
index 0000000..7d56a47
--- /dev/null
+++ b/theme/testdata/files.css
@@ -0,0 +1,7 @@
+/* The accent for Files: hue 180 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#047062, #42cab4);
+ --rst-accent-strong: light-dark(#005d51, #85decd);
+ --rst-accent-soft: light-dark(#dff6f1, #0b332c);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/mail.css b/theme/testdata/mail.css
new file mode 100644
index 0000000..e130702
--- /dev/null
+++ b/theme/testdata/mail.css
@@ -0,0 +1,7 @@
+/* The accent for Mail: hue 342 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#963578, #e592c6);
+ --rst-accent-strong: light-dark(#802765, #f4b4db);
+ --rst-accent-soft: light-dark(#fdeaf5, #3b2232);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/memoria.css b/theme/testdata/memoria.css
new file mode 100644
index 0000000..850147d
--- /dev/null
+++ b/theme/testdata/memoria.css
@@ -0,0 +1,7 @@
+/* The accent for Memoria: hue 302 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#7146a6, #bf9ef1);
+ --rst-accent-strong: light-dark(#5f378e, #d5bdfc);
+ --rst-accent-soft: light-dark(#f3edff, #2f263e);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/oficina.css b/theme/testdata/oficina.css
new file mode 100644
index 0000000..bd0c77f
--- /dev/null
+++ b/theme/testdata/oficina.css
@@ -0,0 +1,7 @@
+/* The accent for Oficina: hue 262 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#2e5bb4, #87b1fd);
+ --rst-accent-strong: light-dark(#214a9b, #afcbfe);
+ --rst-accent-soft: light-dark(#e9f1ff, #1f2b42);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/sheets.css b/theme/testdata/sheets.css
new file mode 100644
index 0000000..1556cdc
--- /dev/null
+++ b/theme/testdata/sheets.css
@@ -0,0 +1,7 @@
+/* The accent for Sheets: hue 140 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#26730e, #87c47a);
+ --rst-accent-strong: light-dark(#1a6001, #acd9a2);
+ --rst-accent-soft: light-dark(#e8f5e5, #1f311b);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/testdata/slides.css b/theme/testdata/slides.css
new file mode 100644
index 0000000..9362585
--- /dev/null
+++ b/theme/testdata/slides.css
@@ -0,0 +1,7 @@
+/* The accent for Slides: hue 56 on Calma's ring (estilo theme/apps.go). */
+:root {
+ --rst-accent: light-dark(#924b00, #eb9c62);
+ --rst-accent-strong: light-dark(#793e02, #f9bc91);
+ --rst-accent-soft: light-dark(#feece0, #3c2514);
+ --rst-on-accent: light-dark(#ffffff, #111418);
+}
diff --git a/theme/theme.go b/theme/theme.go
index 32f8d28..b02e36d 100644
--- a/theme/theme.go
+++ b/theme/theme.go
@@ -1,14 +1,8 @@
-// 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.
-//
-// 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. 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 is estilo's theme: Calma, the Oficina suite's own
+// (AGENTS.md, "Theme"). estilo.css declares the same --rst-* custom
+// properties rastrillo's day does, so tokens.css works unchanged, with
+// Calma's values; apps.go re-declares the accent for each app on Calma's
+// colour ring.
package theme
import _ "embed"
@@ -16,8 +10,7 @@ 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.
+// CSS returns the suite's theme with Oficina's own accent: for pages no
+// single app is in charge of (sign-in, the team's home). An app serves
+// ForApp(<its slug>) instead.
func CSS() []byte { return append([]byte(nil), css...) }
diff --git a/theme/theme_test.go b/theme/theme_test.go
index bceafd2..3d74ec2 100644
--- a/theme/theme_test.go
+++ b/theme/theme_test.go
@@ -2,7 +2,9 @@ package theme
import (
"bytes"
- "fmt"
+ "flag"
+ "os"
+ "path/filepath"
"regexp"
"sort"
"strings"
@@ -11,148 +13,234 @@ import (
"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
-// 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*$`)
-
-// 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])] = string(m[2])
- }
- return set
+// Estilo stopped being day on 2026-09-29 (AGENTS.md, decisions table,
+// row 8): Paul chose Calma. The byte-for-byte gate against day is gone
+// with that ruling. What replaces it holds Calma to the same bars day was
+// held to, on its own values:
+//
+// - TestDeclaresDaysTokenSet: the same token NAMES as the pinned day,
+// so rastrillo's tokens.css never meets an undefined var(--rst-*),
+// and a rastrillo upgrade that adds or drops a token still fails here.
+// - TestContrastMeetsWCAG: every pair rastrillo's own gate checks, in
+// both schemes, for estilo.css and for every app's accent.
+// - TestAccentGoldens: each app's accent block against testdata, so no
+// colour moves without the diff saying so. Regenerate deliberately
+// with `go test ./theme -run Goldens -update` and read the diff.
+
+var update = flag.Bool("update", false, "rewrite theme/testdata goldens")
+
+// The next three are copied verbatim from rastrillo's
+// ui/contrast_test.go (v0.23.0), per AGENTS.md's "contrast arithmetic
+// copied verbatim, never reimplemented". The arithmetic itself is
+// rastrillo's exported ui.ContrastRatio.
+
+var declPattern = regexp.MustCompile(`(--rst-[a-z0-9-]+)\s*:\s*([^;]+);`)
+
+func parseTokens(body string) map[string]string {
+ out := map[string]string{}
+ for _, m := range declPattern.FindAllStringSubmatch(body, -1) {
+ out[m[1]] = strings.TrimSpace(m[2])
+ }
+ return out
}
-// 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.
+// splitLightDark evaluates a whole-value light-dark(<light>, <dark>)
+// call, returning the two halves. It reports false for anything else —
+// including a value that merely *contains* a light-dark() somewhere
+// inside it, which is exactly what the shadow tokens look like
+// ("0 8px 24px light-dark(rgba(…), rgba(…))"): those are not colours in
+// their own right, are not in the pair table, and are left to both
+// schemes verbatim rather than half-parsed.
//
-// 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("*/"):]
+// The split is paren-aware because both halves are usually rgba() calls
+// full of commas of their own.
+func splitLightDark(v string) (light, dark string, ok bool) {
+ const prefix = "light-dark("
+ if !strings.HasPrefix(v, prefix) || !strings.HasSuffix(v, ")") {
+ return "", "", false
+ }
+ inner := v[len(prefix) : len(v)-1]
+ depth, comma := 0, -1
+ for i := 0; i < len(inner); i++ {
+ switch inner[i] {
+ case '(':
+ depth++
+ case ')':
+ depth--
+ if depth < 0 {
+ return "", "", false // the closing ")" we trimmed was not ours
+ }
+ case ',':
+ if depth == 0 {
+ if comma >= 0 {
+ return "", "", false // three arguments is not light-dark()
+ }
+ comma = i
+ }
+ }
+ }
+ if depth != 0 || comma < 0 {
+ return "", "", false
+ }
+ return strings.TrimSpace(inner[:comma]), strings.TrimSpace(inner[comma+1:]), true
}
-// 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:
-// - 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 TestMatchesDayByteForByte(t *testing.T) {
- dayCSS, ok := ui.ThemeCSS("day")
+// tokensIn parses every :root block in css, later declarations winning,
+// and splits each value into its two schemes. ForApp's output has two
+// :root blocks (estilo.css, then the app's accent), exactly as a browser
+// would cascade them.
+func tokensIn(css string) map[string]map[string]string {
+ out := map[string]map[string]string{"light": {}, "dark": {}}
+ for name, v := range parseTokens(css) {
+ if l, d, ok := splitLightDark(v); ok {
+ out["light"][name], out["dark"][name] = l, d
+ continue
+ }
+ out["light"][name], out["dark"][name] = v, v
+ }
+ return out
+}
+
+func names(css []byte) []string {
+ var out []string
+ for name := range parseTokens(string(css)) {
+ out = append(out, name)
+ }
+ sort.Strings(out)
+ return out
+}
+
+func TestDeclaresDaysTokenSet(t *testing.T) {
+ day, ok := ui.ThemeCSS("day")
if !ok {
t.Fatal(`ui.ThemeCSS("day") returned false -- rastrillo no longer ships a theme named "day"`)
}
+ want, got := names(day), names(css)
+ if strings.Join(want, " ") != strings.Join(got, " ") {
+ t.Fatalf("estilo.css must declare exactly day's token names (tokens.css uses them):\n day: %v\n estilo: %v", want, got)
+ }
+ // The toggle, too: it is the only way an explicit Appearance choice
+ // beats the OS.
+ for _, rule := range []string{`:root[data-theme="light"] { color-scheme: light; }`, `:root[data-theme="dark"] { color-scheme: dark; }`} {
+ if !bytes.Contains(css, []byte(rule)) {
+ t.Errorf("estilo.css lost the scheme toggle %q", rule)
+ }
+ }
+}
- day := stripHeaderComment(dayCSS)
- estilo := stripHeaderComment(css)
+func TestContrastMeetsWCAG(t *testing.T) {
+ type pair struct {
+ fg, bg string
+ min float64
+ why string
+ }
+ // Transcribed verbatim from rastrillo's TestThemeTokenContrastMeetsWCAG.
+ pairs := []pair{
+ {"--rst-text", "--rst-surface", 4.5, "body text on a card"},
+ {"--rst-text", "--rst-bg", 4.5, "body text"},
+ {"--rst-text", "--rst-surface-2", 4.5, "body text on a card"},
+ {"--rst-text", "--rst-accent-soft", 4.5, "body text on an accent-tinted surface"},
+ {"--rst-text-muted", "--rst-surface", 4.5, "muted text on a card"},
+ {"--rst-text-muted", "--rst-bg", 4.5, "muted text"},
+ {"--rst-text-muted", "--rst-surface-2", 4.5, "muted text on a card"},
+ {"--rst-text-muted", "--rst-accent-soft", 4.5, "muted text on an accent-tinted surface"},
+ {"--rst-text-faint", "--rst-surface", 4.5, "faint text on a card ([rst-field-hint])"},
+ {"--rst-text-faint", "--rst-bg", 4.5, "faint text"},
+ {"--rst-text-faint", "--rst-surface-2", 4.5, "faint text on a card"},
+ {"--rst-text-faint", "--rst-accent-soft", 4.5, "faint text on an accent-tinted surface"},
+ {"--rst-accent", "--rst-surface", 4.5, "text + focus ring"},
+ {"--rst-accent", "--rst-bg", 4.5, "text + focus ring"},
+ {"--rst-accent", "--rst-surface-2", 4.5, "text"},
+ {"--rst-accent", "--rst-accent-soft", 4.5, "text"},
+ {"--rst-on-accent", "--rst-accent", 4.5, "primary button label"},
+ {"--rst-on-accent", "--rst-accent-strong", 4.5, "primary button label, hover"},
+ {"--rst-line-strong", "--rst-surface", 3.0, "control border (1.4.11 boundary, not text)"},
+ {"--rst-line-strong", "--rst-bg", 3.0, "control border"},
+ {"--rst-line-strong", "--rst-surface-2", 3.0, "control border"},
+ {"--rst-tone-neutral-fg", "--rst-tone-neutral-bg", 4.5, "status pill text"},
+ {"--rst-tone-positive-fg", "--rst-tone-positive-bg", 4.5, "status pill text"},
+ {"--rst-tone-warning-fg", "--rst-tone-warning-bg", 4.5, "status pill text"},
+ {"--rst-tone-negative-fg", "--rst-tone-negative-bg", 4.5, "status pill text"},
+ // Not in the main header tables — documented separately in
+ // tokens.css's [rst-btn~="danger"] comment, which reuses
+ // --rst-tone-negative-fg as a solid fill rather than declaring a
+ // dedicated --rst-danger-* pair. This is the real pair the danger
+ // button's label renders against, so it belongs in the gate anyway.
+ {"--rst-on-accent", "--rst-tone-negative-fg", 4.5, "danger button label"},
+ }
- if bytes.Equal(day, estilo) {
- return
+ themes := map[string]string{"estilo.css": string(css)}
+ for _, a := range Apps() {
+ full, _ := ForApp(a.Slug)
+ themes[a.Slug] = string(full)
+ }
+ for name, body := range themes {
+ tokens := tokensIn(body)
+ for _, scheme := range []string{"light", "dark"} {
+ t.Run(name+"/"+scheme, func(t *testing.T) {
+ for _, p := range pairs {
+ fg, bg := tokens[scheme][p.fg], tokens[scheme][p.bg]
+ if fg == "" || bg == "" {
+ t.Errorf("%s or %s is not declared", p.fg, p.bg)
+ continue
+ }
+ ratio, err := ui.ContrastRatio(fg, bg)
+ if err != nil {
+ t.Errorf("%s (%s) on %s (%s): %v", p.fg, fg, p.bg, bg, err)
+ continue
+ }
+ if ratio < p.min {
+ t.Errorf("%s (%s) on %s (%s) = %.2f:1, want >= %.1f:1 (%s)", p.fg, fg, p.bg, bg, ratio, p.min, p.why)
+ }
+ }
+ })
+ }
}
+}
- // 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)
+// estilo.css is written out by hand, so it can carry its own comments;
+// its accent is Oficina's, and must stay what the ring says Oficina is.
+func TestSuiteAccentIsOficinas(t *testing.T) {
+ block, _ := AccentCSS("oficina")
+ want, got := parseTokens(string(block)), parseTokens(string(css))
+ for name, v := range want {
+ if got[name] != v {
+ t.Errorf("estilo.css %s = %q, but the ring gives Oficina %q", name, got[name], v)
+ }
+ }
+}
- var changed, missing, extra []string
- for name, dv := range dayTokens {
- ev, ok := estiloTokens[name]
- if !ok {
- missing = append(missing, name)
+func TestAccentGoldens(t *testing.T) {
+ for _, a := range Apps() {
+ block, _ := AccentCSS(a.Slug)
+ path := filepath.Join("testdata", a.Slug+".css")
+ if *update {
+ if err := os.WriteFile(path, block, 0o644); err != nil {
+ t.Fatal(err)
+ }
continue
}
- if ev != dv {
- changed = append(changed, name)
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("%v (run with -update to create it)", err)
+ }
+ if !bytes.Equal(want, block) {
+ t.Errorf("%s's accent changed:\n want %s\n got %s", a.Name, want, block)
+ }
+ }
+}
+
+func TestTheRingIsClosed(t *testing.T) {
+ for _, slug := range []string{"", "day", "Docs", "docs ", "../docs", "meet"} {
+ if _, ok := ForApp(slug); ok {
+ t.Errorf("ForApp(%q) accepted a slug outside the ring", slug)
}
}
- for name := range estiloTokens {
- if _, ok := dayTokens[name]; !ok {
- extra = append(extra, name)
+ seen := map[string]bool{}
+ for _, a := range Apps() {
+ if seen[a.Slug] || !regexp.MustCompile(`^[a-z]+$`).MatchString(a.Slug) {
+ t.Errorf("ring slug %q is repeated or not a plain word", a.Slug)
}
+ seen[a.Slug] = true
}
- sort.Strings(changed)
- sort.Strings(missing)
- sort.Strings(extra)
-
- var b strings.Builder
- b.WriteString("theme/estilo.css has drifted from rastrillo's day.css (the version 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())
}