oficina / sheets Public

Use shared Estilo day theme with fingerprinted assets

Paul Campbell pushed by paul@keymail.dev 003b183f4c6d842c2699dfc2d2a98b31b3b34adf parent a75c7a9
7 files changed, +111 −193
  • Makefile +3 −0
  • go.mod +1 −0
  • go.sum +2 −0
  • internal/guardstest/csstokens_test.go +10 −3
  • internal/sheets/render.go +30 −3
  • internal/sheets/static/theme.css +0 −187
  • internal/sheets/theme_assets_test.go +65 −0

CI passed — run details

diff --git a/Makefile b/Makefile
index a142a80..04e4959 100644
--- a/Makefile
+++ b/Makefile
@@ -1,3 +1,6 @@
+comma := ,
+export GOPRIVATE := $(if $(GOPRIVATE),$(GOPRIVATE)$(comma))amadan.net/oficina/estilo.git
+
.PHONY: build release test test-vet test-fmt test-build test-migrations 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 8c1af05..63406a6 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module amadan.net/oficina/sheets
go 1.25.0
require (
+ amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335
github.com/carlosframework/rastrillo v0.23.0
github.com/go-chi/chi/v5 v5.3.2
github.com/xuri/excelize/v2 v2.11.0
diff --git a/go.sum b/go.sum
index a075856..189f1d3 100644
--- a/go.sum
+++ b/go.sum
@@ -1,3 +1,5 @@
+amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335 h1:YogZKYINrPNXRfPedXxOIDbGCMY4ff7yAdohYphEWMg=
+amadan.net/oficina/estilo.git v0.0.0-20260908001419-672a447da335/go.mod h1:0R45/vCUAT+kpS7S6K/ZxBdE8f7YxFN4HT3HyDJ3nPY=
github.com/carlosframework/rastrillo v0.22.0 h1:tG02WAiy2VSYqmLT6kRfYbUaiJd/pBhTIWxxjjsEoI8=
github.com/carlosframework/rastrillo v0.22.0/go.mod h1:pZlrE5F5OhspvZFzef0DrfjHAjA8FXycb9J5/8vc8Tw=
github.com/carlosframework/rastrillo v0.23.0 h1:tKAAlwPt4Pg/rkqk7o6jvV12bbZ0r34oEVpuOlKfX7w=
diff --git a/internal/guardstest/csstokens_test.go b/internal/guardstest/csstokens_test.go
index 043f231..c313fb5 100644
--- a/internal/guardstest/csstokens_test.go
+++ b/internal/guardstest/csstokens_test.go
@@ -7,6 +7,8 @@ import (
"sort"
"strings"
"testing"
+
+ "amadan.net/oficina/estilo.git/theme"
)
// ourStylesheets are the CSS files this app writes. The vendored ones
@@ -17,8 +19,7 @@ var ourStylesheets = []string{
}
// tokenSources are the files that DEFINE rastrillo's tokens: the
-// structural ones and the theme's palette, both copied by
-// `rastrillo doctor` and verified by it.
+// structural ones copied by `rastrillo doctor` and the shared palette.
var tokenSources = []string{
"internal/sheets/static/tokens.css",
"internal/sheets/static/theme.css",
@@ -48,7 +49,13 @@ func TestEveryTokenWeUseExists(t *testing.T) {
defined := map[string]bool{}
for _, rel := range tokenSources {
- body, err := os.ReadFile(filepath.Join(root, rel))
+ var body []byte
+ var err error
+ if rel == "internal/sheets/static/theme.css" {
+ body = theme.CSS()
+ } else {
+ body, err = os.ReadFile(filepath.Join(root, rel))
+ }
if err != nil {
t.Fatalf("guards: read %s: %v — the token sources are what `rastrillo doctor` copies; "+
"if one moved, this test needs updating, not deleting", rel, err)
diff --git a/internal/sheets/render.go b/internal/sheets/render.go
index eaf9f10..27328ca 100644
--- a/internal/sheets/render.go
+++ b/internal/sheets/render.go
@@ -8,6 +8,9 @@ import (
"net/http"
"path"
"strings"
+ "testing/fstest"
+
+ "amadan.net/oficina/estilo.git/theme"
"github.com/carlosframework/rastrillo"
"github.com/carlosframework/rastrillo/ui"
@@ -20,7 +23,7 @@ import (
var templatesFS embed.FS
// staticFS holds everything served under /static: rastrillo's vendored
-// tokens.css, theme.css and rastrillo.js (copied by the framework's own
+// tokens.css and rastrillo.js (copied by the framework's own
// scaffold and verified by `rastrillo doctor`), our own CSS and JS, and
// the vendored grid.
//
@@ -34,7 +37,31 @@ var staticFS embed.FS
// assets fingerprints static files so they can be cached forever and
// still update on an ordinary reload: the URL carries the content hash,
// so the browser has never cached the new one stale.
-var assets = rastrillo.NewAssets(staticFS)
+var servedStaticFS = sharedStaticFiles()
+var assets = rastrillo.NewAssets(servedStaticFS)
+
+func sharedStaticFiles() fs.FS {
+ files := fstest.MapFS{}
+ err := fs.WalkDir(staticFS, "static", func(name string, entry fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if entry.IsDir() {
+ return nil
+ }
+ data, err := staticFS.ReadFile(name)
+ if err != nil {
+ return err
+ }
+ files[name] = &fstest.MapFile{Data: data}
+ return nil
+ })
+ if err != nil {
+ panic(err)
+ }
+ files["static/theme.css"] = &fstest.MapFile{Data: theme.CSS()}
+ return files
+}
// pages is one parsed template tree per page.
//
@@ -153,4 +180,4 @@ func staticHandler() http.Handler { return assets.Handler() }
// staticFiles is the embedded tree, exposed for the guard test that
// walks our JavaScript for the line cap.
-func staticFiles() fs.FS { return staticFS }
+func staticFiles() fs.FS { return servedStaticFS }
diff --git a/internal/sheets/static/theme.css b/internal/sheets/static/theme.css
deleted file mode 100644
index 5da4384..0000000
--- a/internal/sheets/static/theme.css
+++ /dev/null
@@ -1,187 +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.
-
- --rst-header-rule is not in the table either, and deliberately so
- (design doc §6-v2.2): the page header's rule is decoration, not
- structure — the heading's size, weight and spacing carry the
- structure — so no contrast floor applies to it and it is not in
- ui/contrast_test.go's pair table. Do not argue it up to 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);
-
- /* 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. */
- --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-tone-neutral-fg: light-dark(#374151, #c3c9d1);
- --rst-tone-neutral-bg: light-dark(#eef0f2, #262c34);
- --rst-tone-positive-fg: light-dark(#166534, #6ee7a8);
- --rst-tone-positive-bg: light-dark(#dcfce7, #10281c);
- --rst-tone-warning-fg: light-dark(#854d0e, #f0c46a);
- --rst-tone-warning-bg: light-dark(#fef3c7, #2c2110);
- --rst-tone-negative-fg: light-dark(#b91c1c, #f79aa0);
- --rst-tone-negative-bg: light-dark(#fee2e2, #331819);
-
- /* Depth */
- --rst-shadow-pop: 0 8px 24px light-dark(rgba(0, 0, 0, 0.12), rgba(0, 0, 0, 0.5));
- --rst-shadow-knob: 0 1px 2px rgba(0, 0, 0, 0.3);
- --rst-shadow-lift: 0 1px 2px rgba(0, 0, 0, 0.08);
- --rst-overlay: light-dark(rgba(0, 0, 0, 0.45), rgba(0, 0, 0, 0.6));
-}
-
-/* The whole toggle. Setting color-scheme re-resolves every light-dark()
- above, so an explicit choice beats the OS in both directions without
- restating a colour. */
-:root[data-theme="light"] { color-scheme: light; }
-:root[data-theme="dark"] { color-scheme: dark; }
diff --git a/internal/sheets/theme_assets_test.go b/internal/sheets/theme_assets_test.go
new file mode 100644
index 0000000..fd26cbb
--- /dev/null
+++ b/internal/sheets/theme_assets_test.go
@@ -0,0 +1,65 @@
+package sheets
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+
+ "amadan.net/oficina/estilo.git/theme"
+ "github.com/carlosframework/rastrillo/ui"
+)
+
+func TestSharedThemeAssetFingerprintAndCache(t *testing.T) {
+ app := New(Config{})
+ page := do(t, app, httptest.NewRequest(http.MethodGet, "/signin", nil))
+ if page.Code != http.StatusOK {
+ t.Fatalf("signin status %d", page.Code)
+ }
+ day, ok := ui.ThemeCSS("day")
+ if !ok {
+ t.Fatal("selected day missing")
+ }
+ body := func(css []byte) []byte {
+ if bytes.HasPrefix(css, []byte("/*")) {
+ if end := bytes.Index(css, []byte("*/")); end >= 0 {
+ return css[end+2:]
+ }
+ }
+ return css
+ }
+ if !bytes.Equal(body(theme.CSS()), body(day)) {
+ t.Fatal("shared theme differs from selected day")
+ }
+ for _, item := range []struct {
+ name string
+ data []byte
+ }{
+ {"theme", theme.CSS()}, {"tokens", ui.TokensCSS()},
+ } {
+ sum := sha256.Sum256(item.data)
+ hashed := fmt.Sprintf("/static/%s.%x.css", item.name, sum[:8])
+ if !strings.Contains(page.Body.String(), `href="`+hashed+`"`) {
+ t.Fatalf("missing selected %s fingerprint", item.name)
+ }
+ for _, path := range []string{hashed, "/static/" + item.name + ".css", "/static/" + item.name + ".0000000000000000.css"} {
+ res := do(t, app, httptest.NewRequest(http.MethodGet, path, nil))
+ if res.Code != http.StatusOK || !bytes.Equal(res.Body.Bytes(), item.data) {
+ t.Fatalf("%s asset response mismatch status%d", item.name, res.Code)
+ }
+ if !strings.HasPrefix(res.Header().Get("Content-Type"), "text/css") {
+ t.Fatal("asset MIME mismatch")
+ }
+ want := "no-cache"
+ if path == hashed {
+ want = "public, max-age=31536000, immutable"
+ }
+ if res.Header().Get("Cache-Control") != want {
+ t.Fatal("asset cache policy mismatch")
+ }
+ }
+ }
+}