oficina / estilo Public

Clone
git clone https://amadan.net/oficina/estilo

Plain git — no account needed to clone.

Download

Download this file

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.
16package sky
17
18import (
19 "embed"
20 "html/template"
21 "net/http"
22 "regexp"
23 "strings"
24)
25
26//go:embed sky.css
27var css []byte
28
29//go:embed templates/*.html
30var templateFS embed.FS
31
32// CSS styles [estilo-sky] and [estilo-sky-host].
33func CSS() []byte { return append([]byte(nil), css...) }
34
35// Templates holds "estilo-sky"; parse it into the page's template set.
36func 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.
43const 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.
47const SuiteSeed = "oficina"
48
49// Layer is one image of a sky.
50type 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.
61type View struct{ Layers []Layer }
62
63// For is the sky for seed — a team's id, or SuiteSeed.
64func 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
75var 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.
79func 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.
102func 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