oficina / home Public

Share the Home theme through Estilo

Paul Campbell pushed by paul@keymail.dev 6e5f71562a7e47f792e4f1df80e19ad009ecbe32 parent 4013745
7 files changed, +43 −184
  • Makefile +3 −0
  • go.mod +1 −0
  • go.sum +16 −0
  • internal/home/app.go +2 −1
  • internal/home/noscript_test.go +20 −8
  • internal/home/passkey.go +1 −1
  • internal/home/static/theme.css +0 −174

CI passed — run details

diff --git a/Makefile b/Makefile
index 14576af..1c75520 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,6 @@
+private_comma := ,
+export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(private_comma))amadan.net/oficina/estilo.git
+
.PHONY: build test test-vet test-fmt test-build test-go test-race
# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set
diff --git a/go.mod b/go.mod
index 08b036c..fa4657f 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module amadan.net/oficina/home
go 1.25.0
require (
+ amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1
github.com/carlosframework/rastrillo v0.22.0
github.com/go-chi/chi/v5 v5.3.2
github.com/keymaildev/signin v0.1.1
diff --git a/go.sum b/go.sum
index 46d8a82..3c9ca7e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,11 +1,27 @@
+amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1 h1:9Qrzr6xT0QuaTAK4Bq3M8nWDCgkBmXy7H9BR3C+DZOo=
+amadan.net/oficina/estilo.git v0.0.0-20260907201639-532746acdbc1/go.mod h1:AZvUebwQkfBh2d0TIOAO/AViIIsRx774jL7ic8XOckg=
github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327 h1:UQ4AU+BGti3Sy/aLU8KVseYKNALcX9UXY6DfpwQ6J8E=
+github.com/chromedp/cdproto v0.0.0-20250724212937-08a3db8b4327/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
+github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
+github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
+github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
+github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.2 h1:5YQkICvTCSZ25hoRsyJazN0scjzKGiu4VAUc7H1o1nY=
github.com/go-chi/chi/v5 v5.3.2/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2 h1:iizUGZ9pEquQS5jTGkh4AqeeHCMbfbjeb0zMt0aEFzs=
+github.com/go-json-experiment/json v0.0.0-20250725192818-e39067aee2d2/go.mod h1:TiCD2a1pcmjd7YnhGH0f/zKNcCD06B029pHhzV23c2M=
github.com/go-sql-driver/mysql v1.7.0 h1:ueSltNNllEqE3qcWBTD0iQd3IpL/6U+mJxLkazJ7YPc=
github.com/go-sql-driver/mysql v1.7.0/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
+github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
+github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
+github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
+github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
+github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
+github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
diff --git a/internal/home/app.go b/internal/home/app.go
index e827061..318f119 100644
--- a/internal/home/app.go
+++ b/internal/home/app.go
@@ -22,6 +22,7 @@ import (
"strings"
"time"
+ "amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo/csrf"
"github.com/carlosframework/rastrillo/db"
"github.com/carlosframework/rastrillo/passkey"
@@ -220,7 +221,7 @@ func (a *App) Router() http.Handler {
// owns its stylesheet from the moment it lands, and
// `rastrillo doctor --fix` is what reconciles it on an upgrade.
r.Get("/static/tokens.css", serveAsset(staticAsset("tokens.css"), "text/css; charset=utf-8"))
- r.Get("/static/theme.css", serveAsset(staticAsset("theme.css"), "text/css; charset=utf-8"))
+ r.Get("/static/theme.css", serveAsset(theme.CSS(), "text/css; charset=utf-8"))
return r
}
diff --git a/internal/home/noscript_test.go b/internal/home/noscript_test.go
index 51e020d..1b3055b 100644
--- a/internal/home/noscript_test.go
+++ b/internal/home/noscript_test.go
@@ -14,6 +14,7 @@ import (
"strings"
"testing"
+ "amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
"github.com/go-chi/chi/v5"
@@ -380,9 +381,6 @@ func TestConfirmPageRecoveryFormSurvivesScriptStripping(t *testing.T) {
}
}
-// The vendored stylesheets must match the library they came from, so an
-// upgrade that changes them is a visible diff rather than a silent
-// drift. `rastrillo doctor --fix` is what reconciles them.
func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
tokens, err := os.ReadFile("static/tokens.css")
if err != nil {
@@ -392,16 +390,30 @@ func TestVendoredAssetsMatchTheLibrary(t *testing.T) {
t.Error("static/tokens.css differs from ui.TokensCSS(); run `rastrillo doctor --fix`")
}
- theme, err := os.ReadFile("static/theme.css")
- if err != nil {
- t.Fatal(err)
+ stripHeader := func(css []byte) []byte {
+ if bytes.HasPrefix(css, []byte("/*")) {
+ if end := bytes.Index(css, []byte("*/")); end >= 0 {
+ return css[end+2:]
+ }
+ }
+ return css
}
want, ok := ui.ThemeCSS("day")
if !ok {
t.Fatal("ui has no day theme")
}
- if !bytes.Equal(theme, want) {
- t.Error("static/theme.css differs from ui.ThemeCSS(\"day\"); run `rastrillo doctor --fix`")
+ if !bytes.Equal(stripHeader(theme.CSS()), stripHeader(want)) {
+ t.Fatal("Estilo theme differs from selected Rastrillo day")
+ }
+ app := newTestApp(t)
+ for _, host := range []string{directoryHost, credentialHost} {
+ req := httptest.NewRequest(http.MethodGet, "/static/theme.css", nil)
+ req.Host = host
+ rec := httptest.NewRecorder()
+ app.Router().ServeHTTP(rec, req)
+ if rec.Code != http.StatusOK || rec.Header().Get("Content-Type") != "text/css; charset=utf-8" || !bytes.Equal(rec.Body.Bytes(), theme.CSS()) {
+ t.Fatalf("%s: shared theme response mismatch (status %d)", host, rec.Code)
+ }
}
}
diff --git a/internal/home/passkey.go b/internal/home/passkey.go
index a82da68..3966bef 100644
--- a/internal/home/passkey.go
+++ b/internal/home/passkey.go
@@ -10,7 +10,7 @@ import (
"github.com/carlosframework/rastrillo/webauthn"
)
-//go:embed static/passkey.js static/tokens.css static/theme.css
+//go:embed static/passkey.js static/tokens.css
var staticFS embed.FS
// confirmPath is where the Gate sends a first-factor-verified caller who
diff --git a/internal/home/static/theme.css b/internal/home/static/theme.css
deleted file mode 100644
index c3ab91f..0000000
--- a/internal/home/static/theme.css
+++ /dev/null
@@ -1,174 +0,0 @@
-/* day — rastrillo's default theme: an everyday blue on white and grey.
- Structure lives in tokens.css; this file is colour, type and shape.
- rastrillo new writes it once as static/theme.css.
-
- The look: the ordinary, personable interface. A white page, a plain
- grey text scale, one friendly blue that means "this does something",
- and the conventional green/amber/red for status. Nothing here is
- trying to be memorable — day is the theme an app ships on while it
- works out what it wants to look like, and the one that never gets in
- the way of the content. Semantic status colours are functional and
- never the accent, so a pill can never be mistaken for a link.
-
- ── One block, both schemes ───────────────────────────────────────────
- Every token is declared once, as light-dark(<light>, <dark>), under a
- single :root that sets color-scheme: light dark. The browser picks the
- half that matches the resolved scheme, so there is no second copy of
- the palette to keep in sync and no way to author dark half-done. The
- two rules at the bottom are the whole toggle: setting color-scheme on
- :root[data-theme=…] re-resolves every light-dark() in the file at
- once, in both directions, which is why an explicit choice beats the OS
- without restating a single colour.
-
- Tokens with one value in both schemes — the font stack, the radii —
- are written plain, because light-dark(x, x) says nothing twice.
-
- light-dark() is a colour function: it may only stand where a <color>
- is expected. So a shadow token wraps its colour in it and writes the
- geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never
- `light-dark(0 8px 24px a, …)`, which no browser would accept as a
- box-shadow.
-
- ── The shape axis ────────────────────────────────────────────────────
- Radius and shadow are the theme's now, not tokens.css's: a theme that
- can only change colour cannot change how an app feels. day is soft and
- unremarkable — 8px cards, 6px controls, shadows with a wide blur and
- little alpha, the depth of paper resting on paper.
-
- ── 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. If you change a token, recompute its rows.
-
- LIGHT ratio requirement
- --rst-text on --rst-surface 16.91 4.5:1 text
- --rst-text on --rst-bg 16.91 4.5:1 text
- --rst-text on --rst-surface-2 15.77 4.5:1 text
- --rst-text on --rst-accent-soft 15.21 4.5:1 text
- --rst-text-muted on --rst-surface 7.56 4.5:1 text
- --rst-text-muted on --rst-bg 7.56 4.5:1 text
- --rst-text-muted on --rst-surface-2 7.05 4.5:1 text
- --rst-text-muted on --rst-accent-soft 6.80 4.5:1 text
- --rst-text-faint on --rst-surface 5.29 4.5:1 text
- --rst-text-faint on --rst-bg 5.29 4.5:1 text
- --rst-text-faint on --rst-surface-2 4.93 4.5:1 text
- --rst-text-faint on --rst-accent-soft 4.76 4.5:1 text
- --rst-accent on --rst-surface 5.28 4.5:1 text + focus ring
- --rst-accent on --rst-bg 5.28 4.5:1 text + focus ring
- --rst-accent on --rst-surface-2 4.93 4.5:1 text
- --rst-accent on --rst-accent-soft 4.75 4.5:1 text
- --rst-on-accent on --rst-accent 5.28 4.5:1 button label
- --rst-on-accent on --rst-accent-strong 7.18 4.5:1 button label, hover
- --rst-line-strong on --rst-surface 3.63 3:1 control border
- --rst-line-strong on --rst-bg 3.63 3:1 control border
- --rst-line-strong on --rst-surface-2 3.39 3:1 control border
- pill neutral fg on its own tint 9.02 4.5:1 text
- pill positive fg on its own tint 6.49 4.5:1 text
- pill warning fg on its own tint 6.15 4.5:1 text
- pill negative fg on its own tint 5.30 4.5:1 text
- --rst-on-accent on negative fg (fill) 6.47 4.5:1 danger button label
-
- DARK ratio requirement
- --rst-text on --rst-surface 13.74 4.5:1 text
- --rst-text on --rst-bg 15.32 4.5:1 text
- --rst-text on --rst-surface-2 14.63 4.5:1 text
- --rst-text on --rst-accent-soft 13.02 4.5:1 text
- --rst-text-muted on --rst-surface 7.56 4.5:1 text
- --rst-text-muted on --rst-bg 8.43 4.5:1 text
- --rst-text-muted on --rst-surface-2 8.05 4.5:1 text
- --rst-text-muted on --rst-accent-soft 7.16 4.5:1 text
- --rst-text-faint on --rst-surface 6.33 4.5:1 text
- --rst-text-faint on --rst-bg 7.06 4.5:1 text
- --rst-text-faint on --rst-surface-2 6.74 4.5:1 text
- --rst-text-faint on --rst-accent-soft 6.00 4.5:1 text
- --rst-accent on --rst-surface 6.95 4.5:1 text + focus ring
- --rst-accent on --rst-bg 7.75 4.5:1 text + focus ring
- --rst-accent on --rst-surface-2 7.40 4.5:1 text
- --rst-accent on --rst-accent-soft 6.59 4.5:1 text
- --rst-on-accent on --rst-accent 7.86 4.5:1 button label
- --rst-on-accent on --rst-accent-strong 10.29 4.5:1 button label, hover
- --rst-line-strong on --rst-surface 4.26 3:1 control border
- --rst-line-strong on --rst-bg 4.75 3:1 control border
- --rst-line-strong on --rst-surface-2 4.53 3:1 control border
- pill neutral fg on its own tint 8.44 4.5:1 text
- pill positive fg on its own tint 10.18 4.5:1 text
- pill warning fg on its own tint 9.62 4.5:1 text
- pill negative fg on its own tint 7.85 4.5:1 text
- --rst-on-accent on negative fg (fill) 8.99 4.5:1 danger button label
-
- The tightest pair in the set is --rst-accent on --rst-accent-soft in
- light, at 4.75:1 against a 4.5:1 floor. Put a token on a surface not
- listed above and you are guessing; recompute first.
-
- --rst-line is a hairline divider between rows, not a control border or
- a meaningful graphic, so 1.4.11's 3:1 does not apply to it. Control
- borders use --rst-line-strong, which does clear 3:1.
-
- Pagination chips are the one deliberate exception: they are bordered
- with --rst-line rather than --rst-line-strong, because a page strip
- ringed in hard outlines shouts louder than the list it belongs to.
- 1.4.11 is satisfied without that border — each chip's meaning is
- carried by its text label at 4.5:1 or better, and the current page
- adds a filled background, a heavier weight and aria-current on top.
-
- --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and
- --rst-overlay are not in the table: they paint a translucent shadow or
- scrim over whatever sits underneath, not text over a fixed
- background, so a contrast ratio does not apply. The scrim deepens in
- dark, matching --rst-shadow-pop's own jump: black reads lighter
- against a dark backdrop, so the overlay needs more alpha to still
- read as a dimming layer.
- ──────────────────────────────────────────────────────────────────── */
-
-:root {
- color-scheme: light dark;
-
- /* Type: the family only. Sizes and spacing are structural and stay in
- tokens.css. */
- --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-
- /* Shape: soft, ordinary, unremarkable. */
- --rst-radius: 8px;
- --rst-radius-sm: 6px;
- --rst-radius-pill: 999px;
-
- /* Surfaces and lines */
- --rst-bg: light-dark(#ffffff, #111418);
- --rst-surface: light-dark(#ffffff, #1a1f26);
- --rst-surface-2: light-dark(#f6f7f8, #15191f);
- --rst-line: light-dark(#e5e7eb, #2a3038);
- --rst-line-strong: light-dark(#7f8792, #79828e);
-
- /* Text */
- --rst-text: light-dark(#1a1d21, #e8eaed);
- --rst-text-muted: light-dark(#4b5563, #a8b0ba);
- --rst-text-faint: light-dark(#656c78, #98a1ac);
-
- /* Accent */
- --rst-accent: light-dark(#2464e0, #7ba7ff);
- --rst-accent-strong: light-dark(#1a4fc0, #a3c0ff);
- --rst-accent-soft: light-dark(#eef3fe, #17233a);
- --rst-on-accent: light-dark(#ffffff, #0b1220);
-
- /* Status tones */
- --rst-tone-neutral-fg: light-dark(#374151, #c3c9d1);
- --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
- --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
- --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
- --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
- --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
- --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
- --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
-
- /* Depth */
- --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
- --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
- --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
- --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
-}
-
-/* The whole toggle. Setting color-scheme re-resolves every light-dark()
- above, so an explicit choice beats the OS in both directions without
- restating a colour. */
-:root[data-theme="light"] { color-scheme: light; }
-:root[data-theme="dark"] { color-scheme: dark; }