// Package busy carries rastrillo's busy rule to the Oficina apps (Paul, // 2026-09-29): a submit button's label gives way to a spinner for at // least 650ms, the button disables, its form refuses a second submit, and // Back hands the form back. // // The rule is rastrillo's (ui/busy.js). The apps vendor older rastrillo // files, and moving every app to a newer rastrillo would carry far more // than this, so estilo carries three byte-exact copies instead, each // recorded in provenance.json and checked against it: // // - ShimJS: rastrillo.js as it is now the busy rule has left it. An app // replaces its vendored static/rastrillo.js with these bytes — its old // copy still carries the old busy rule, and two busy rules on one // page fight over every submit. // - JS: busy.js, the rule itself. // - CSS: the spinner-in-place block from rastrillo's tokens.css, for a // tokens.css that predates it. Link it after tokens.css. // // When an app next re-vendors rastrillo for real, it takes all three from // there and drops this package. package busy import ( "crypto/sha256" _ "embed" "encoding/hex" "net/http" "strconv" "strings" ) //go:embed rastrillo.js var shimJS []byte //go:embed busy.js var busyJS []byte //go:embed busy.css var busyCSS []byte // ShimJS is rastrillo.js without the busy rule, for an app's static/. func ShimJS() []byte { return append([]byte(nil), shimJS...) } // JS is busy.js. func JS() []byte { return append([]byte(nil), busyJS...) } // CSS is the spinner-in-place block. func CSS() []byte { return append([]byte(nil), busyCSS...) } // Prefix is the URL path Handler serves under. Its version segment is the // two files' own digest, so new bytes are always a new URL: both are // cached for a year, immutably, and no one has to remember to bump it. var Prefix = func() string { sum := sha256.Sum256(append(append([]byte(nil), busyJS...), busyCSS...)) return "/static/estilo/busy/" + hex.EncodeToString(sum[:6]) + "/" }() // Handler serves GET for Prefix + "busy.js" and Prefix + "busy.css", and // nothing else. Public and sessionless: the sign-in form needs it too. func Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var body []byte var kind string switch strings.TrimPrefix(r.URL.Path, Prefix) { case "busy.js": body, kind = busyJS, "text/javascript; charset=utf-8" case "busy.css": body, kind = busyCSS, "text/css; charset=utf-8" } if body == nil || !strings.HasPrefix(r.URL.Path, Prefix) { 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") w.Header().Set("Content-Length", strconv.Itoa(len(body))) if r.Method == http.MethodHead { return } _, _ = w.Write(body) }) }