| 1 | // Package sky is Calma's ambient nebula: a soft sky of every app's colour, |
| 2 | // seeded, so each team's is its own. It lives where the suite meets you — |
| 3 | // the sign-in, the team's home, the front page — and never inside an app |
| 4 | // (AGENTS.md, decisions table, row 8). |
| 5 | // |
| 6 | // A sky is generated from its ID (generate.go): a far haze and near clouds |
| 7 | // of every app's colour, a fine grain, and a few white filaments. Its |
| 8 | // colour — the haze and clouds — is painted as the placeholder |
| 9 | // (placeholder.go), 50x20 pixels inline, so it is there in the first frame; |
| 10 | // the grain and filaments are two static SVGs served by Handler. Handler |
| 11 | // still serves the haze and clouds SVGs too, for pages cached before they |
| 12 | // stopped being linked. Light and dark each get their own placeholder; |
| 13 | // sky.css shows the one matching the page's scheme, drifts the sky slowly, |
| 14 | // and holds it still for anyone who asks their system for less motion. |
| 15 | // No script. |
| 16 | package sky |
| 17 | |
| 18 | import ( |
| 19 | "embed" |
| 20 | "html/template" |
| 21 | "net/http" |
| 22 | "regexp" |
| 23 | "strings" |
| 24 | ) |
| 25 | |
| 26 | //go:embed sky.css |
| 27 | var css []byte |
| 28 | |
| 29 | //go:embed templates/*.html |
| 30 | var templateFS embed.FS |
| 31 | |
| 32 | // CSS styles [estilo-sky] and [estilo-sky-host]. |
| 33 | func CSS() []byte { return append([]byte(nil), css...) } |
| 34 | |
| 35 | // Templates holds "estilo-sky"; parse it into the page's template set. |
| 36 | func Templates() embed.FS { return templateFS } |
| 37 | |
| 38 | // Prefix is the URL path Handler serves under. The version is the |
| 39 | // generator's: a change to what a given ID draws is a new version, so |
| 40 | // the year-long cache below never serves a stale sky. TestSkyGoldens holds |
| 41 | // one sky's six files still; when it fails, the drawing changed — bump |
| 42 | // the version here, then update the goldens. |
| 43 | const Prefix = "/static/estilo/sky/v1/" |
| 44 | |
| 45 | // SuiteSeed is the sky of pages that belong to no one team: the front |
| 46 | // page and the sign-in. |
| 47 | const SuiteSeed = "oficina" |
| 48 | |
| 49 | // Layer is one image of a sky. |
| 50 | type Layer struct { |
| 51 | Name string // placeholder, grain or filaments (haze and clouds are served, no longer linked) |
| 52 | Tone string // light, dark, or "" for both |
| 53 | Src string // a URL under Prefix; empty for the placeholder |
| 54 | // Inline is the placeholder's data: URI, typed so html/template |
| 55 | // passes it (a data: URI in a plain string is sanitised away). Built |
| 56 | // only by Placeholder, never from input. |
| 57 | Inline template.URL |
| 58 | } |
| 59 | |
| 60 | // View is the data for the "estilo-sky" template. |
| 61 | type View struct{ Layers []Layer } |
| 62 | |
| 63 | // For is the sky for seed — a team's id, or SuiteSeed. |
| 64 | func For(seed string) View { |
| 65 | id := ID(seed) |
| 66 | base := Prefix + id + "/" |
| 67 | return View{Layers: []Layer{ |
| 68 | {Name: "placeholder", Tone: "light", Inline: template.URL(Placeholder(id, Light))}, |
| 69 | {Name: "placeholder", Tone: "dark", Inline: template.URL(Placeholder(id, Dark))}, |
| 70 | {Name: "grain", Src: base + "grain.svg"}, |
| 71 | {Name: "filaments", Src: base + "filaments.svg"}, |
| 72 | }} |
| 73 | } |
| 74 | |
| 75 | var idShape = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| 76 | |
| 77 | // draw returns the SVG for one layer file of sky id, or "" when the name |
| 78 | // is not one of the six. |
| 79 | func draw(id, file string) string { |
| 80 | switch file { |
| 81 | case "haze-light.svg": |
| 82 | return Haze(id, Light) |
| 83 | case "haze-dark.svg": |
| 84 | return Haze(id, Dark) |
| 85 | case "clouds-light.svg": |
| 86 | return Clouds(id, Light) |
| 87 | case "clouds-dark.svg": |
| 88 | return Clouds(id, Dark) |
| 89 | case "grain.svg": |
| 90 | return Grain(id) |
| 91 | case "filaments.svg": |
| 92 | return Filaments(id) |
| 93 | } |
| 94 | return "" |
| 95 | } |
| 96 | |
| 97 | // Handler serves GET and HEAD for Prefix + "<id>/<layer>.svg". Mount it |
| 98 | // at Prefix. The ID must be the shape ID produces and the layer one of |
| 99 | // the five; anything else is 404 before anything is drawn. Skies are |
| 100 | // public artwork and need no session; an ID's drawing never changes |
| 101 | // within a version, so it is cached for a year. |
| 102 | func Handler() http.Handler { |
| 103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 104 | rest, ok := strings.CutPrefix(r.URL.Path, Prefix) |
| 105 | id, file, _ := strings.Cut(rest, "/") |
| 106 | if !ok || !idShape.MatchString(id) || strings.Contains(file, "/") { |
| 107 | http.NotFound(w, r) |
| 108 | return |
| 109 | } |
| 110 | body := draw(id, file) |
| 111 | if body == "" { |
| 112 | http.NotFound(w, r) |
| 113 | return |
| 114 | } |
| 115 | if r.Method != http.MethodGet && r.Method != http.MethodHead { |
| 116 | w.Header().Set("Allow", "GET, HEAD") |
| 117 | http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) |
| 118 | return |
| 119 | } |
| 120 | w.Header().Set("Content-Type", "image/svg+xml") |
| 121 | w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| 122 | w.Header().Set("X-Content-Type-Options", "nosniff") |
| 123 | if r.Method == http.MethodHead { |
| 124 | return |
| 125 | } |
| 126 | _, _ = w.Write([]byte(body)) |
| 127 | }) |
| 128 | } |
| 129 | |