WIP: Calma theme, per-app accents on one ring, Onest
22 files changed,
+749
−315
cmd/oficina-notices/main.go+4 −0credits/fonts.go+13 −0credits/fonts_test.go+28 −0credits/notices/onest-OFL.txt+93 −0credits/notices/onest-provenance.json+11 −0theme/apps.go+131 −0theme/estilo.css+61 −174theme/fonts.go+72 −0theme/fonts/onest-latin.woff2binarytheme/fonts_test.go+44 −0theme/testdata/calendar.css+7 −0theme/testdata/calls.css+7 −0theme/testdata/docs.css+7 −0theme/testdata/fichas.css+7 −0theme/testdata/files.css+7 −0theme/testdata/mail.css+7 −0theme/testdata/memoria.css+7 −0theme/testdata/oficina.css+7 −0theme/testdata/sheets.css+7 −0theme/testdata/slides.css+7 −0theme/theme.go+8 −15theme/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()) |
| } |