WIP: sky — seeded nebula with grain and filaments
5 files changed,
+477
−0
sky/generate.go+174 −0sky/sky.css+64 −0sky/sky.go+115 −0sky/sky_test.go+115 −0sky/templates/sky.html+9 −0
diff --git a/sky/generate.go b/sky/generate.go| new file mode 100644 |
| index 0000000..6eb65da |
| --- /dev/null |
| +++ b/sky/generate.go |
| @@ -0,0 +1,174 @@ |
| +package sky |
| + |
| +import ( |
| + "crypto/sha256" |
| + "encoding/binary" |
| + "fmt" |
| + "math" |
| + "math/rand/v2" |
| + "strings" |
| + |
| + "amadan.net/oficina/estilo.git/theme" |
| +) |
| + |
| +// The sky is drawn on a 1600x640 canvas and stretched to its box |
| +// (preserveAspectRatio="none"): it is all soft shapes, which stretch |
| +// without anyone being able to tell. |
| +const ( |
| + width = 1600 |
| + height = 640 |
| +) |
| + |
| +// rng is the generator for one stream of one sky. The same ID and stream |
| +// always give the same numbers, on every machine and every Go release |
| +// (ChaCha8 is specified, not an implementation detail). |
| +func rng(id, stream string) *rand.Rand { |
| + sum := sha256.Sum256([]byte("estilo-sky/v1:" + id + ":" + stream)) |
| + var seed [32]byte |
| + copy(seed[:], sum[:]) |
| + return rand.New(rand.NewChaCha8(seed)) |
| +} |
| + |
| +func uniform(r *rand.Rand, lo, hi float64) float64 { return lo + r.Float64()*(hi-lo) } |
| + |
| +// anchor is where one app's colour gathers in the sky. |
| +type anchor struct { |
| + hue, x, y, scale, glow float64 |
| +} |
| + |
| +// anchors spreads every app on the ring across the sky, in ring order so |
| +// neighbours blend into neighbours, from a starting app and in a direction |
| +// the seed chooses; spacing, height and size are jittered. Oficina itself |
| +// is not an anchor: the sky is the apps, together. |
| +func anchors(id string) []anchor { |
| + r := rng(id, "anchors") |
| + var hues []float64 |
| + for _, a := range theme.Apps() { |
| + if a.Slug != "oficina" { |
| + hues = append(hues, a.Hue) |
| + } |
| + } |
| + n := len(hues) |
| + start := r.IntN(n) |
| + hues = append(hues[start:], hues[:start]...) |
| + if r.IntN(2) == 0 { |
| + for i, j := 0, n-1; i < j; i, j = i+1, j-1 { |
| + hues[i], hues[j] = hues[j], hues[i] |
| + } |
| + } |
| + spread := 70 * 7 / float64(n) |
| + out := make([]anchor, n) |
| + for i, h := range hues { |
| + out[i] = anchor{ |
| + hue: h, |
| + x: (float64(i)+.5)/float64(n)*width + uniform(r, -spread, spread), |
| + y: height*(.36+.2*math.Sin(float64(i)*1.3+uniform(r, 0, 6.3))) + uniform(r, -45, 45), |
| + scale: uniform(r, .7, 1.3), |
| + // Some apps glow brighter than others in any one sky; an even |
| + // brightness reads as a rainbow band, not a nebula. |
| + glow: uniform(r, .5, 1), |
| + } |
| + } |
| + return out |
| +} |
| + |
| +// Tone is the scheme a layer is drawn for. |
| +type Tone string |
| + |
| +const ( |
| + Light Tone = "light" |
| + Dark Tone = "dark" |
| +) |
| + |
| +// palette is the nebula's colour at each scheme: pale on a light page, |
| +// deeper and brighter on a dark one (the lab's "as now", Paul 2026-09-29). |
| +func palette(t Tone) (L, C float64) { |
| + if t == Dark { |
| + return .72, .13 |
| + } |
| + return .80, .12 |
| +} |
| + |
| +func svg(body, defs string) string { |
| + return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" preserveAspectRatio="none"><defs>%s</defs>%s</svg>`, |
| + width, height, width, height, defs, body) |
| +} |
| + |
| +// grain is a fine photographic texture laid over the clouds and clipped to |
| +// them: noise, taken to grey and down to a third of its contrast, blended |
| +// in with overlay so it only nudges each pixel. Its region is the whole |
| +// canvas in user space; a bounding-box region would clip the clouds' blur |
| +// to their unblurred outline, a hard edge across the sky. |
| +const grain = `<filter id="g" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">` + |
| + `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>` + |
| + `<feColorMatrix in="n" type="matrix" values=".33 .33 .33 0 0 .33 .33 .33 0 0 .33 .33 .33 0 0 0 0 0 0 1" result="k"/>` + |
| + `<feComponentTransfer in="k" result="c"><feFuncR type="linear" slope=".34" intercept=".33"/><feFuncG type="linear" slope=".34" intercept=".33"/><feFuncB type="linear" slope=".34" intercept=".33"/></feComponentTransfer>` + |
| + `<feComposite in="c" in2="SourceGraphic" operator="in" result="m"/><feBlend in="m" in2="SourceGraphic" mode="overlay"/></filter>` |
| + |
| +// Clouds is the near layer: two or three clouds of each app's colour |
| +// around its anchor, heavily blurred, with grain. |
| +func Clouds(id string, t Tone) string { |
| + r := rng(id, "clouds") |
| + L, C := palette(t) |
| + var b strings.Builder |
| + for _, a := range anchors(id) { |
| + for range 1 + r.IntN(3) { |
| + cx, cy := a.x+uniform(r, -160, 160), a.y+uniform(r, -85, 70) |
| + fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`, |
| + cx, cy, uniform(r, 100, 220)*a.scale, uniform(r, 55, 130)*a.scale, |
| + theme.Hex(L, C, a.hue+uniform(r, -8, 8)), uniform(r, .45, .85)*a.glow, uniform(r, -28, 28), cx, cy) |
| + } |
| + } |
| + blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter>` |
| + return svg(`<g filter="url(#g)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grain) |
| +} |
| + |
| +// Haze is the far layer: one wide, faint glow per app, much softer. |
| +func Haze(id string, t Tone) string { |
| + r := rng(id, "haze") |
| + L, C := palette(t) |
| + var b strings.Builder |
| + for _, a := range anchors(id) { |
| + fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f"/>`, |
| + a.x+uniform(r, -80, 80), a.y+uniform(r, -40, 60), uniform(r, 330, 480), uniform(r, 140, 220), |
| + theme.Hex(L-.02, C, a.hue), uniform(r, .25, .45)*a.glow) |
| + } |
| + blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="110"/></filter>` |
| + return svg(`<g filter="url(#b)">`+b.String()+`</g>`, blur) |
| +} |
| + |
| +// Filaments are fine wisps: a few bundles of near-parallel strands that |
| +// drift apart and together along their length and fade in and out at |
| +// their ends. White, so they read as silk on a light page and as glowing |
| +// gas on a dark one, from one file. |
| +func Filaments(id string) string { |
| + r := rng(id, "filaments") |
| + var lines, grads strings.Builder |
| + for bundle := range 4 { |
| + y0, amp, lam, ph := height*uniform(r, .2, .6), uniform(r, 20, 60), uniform(r, 260, 520), uniform(r, 0, 6.3) |
| + x0, x1 := uniform(r, -100, width*.4), uniform(r, width*.6, width+100) |
| + for k := range 2 + r.IntN(2) { |
| + off, sp := uniform(r, -10, 10), uniform(r, 6, 16) |
| + var pts []string |
| + for x := x0; x <= x1; x += 20 { |
| + y := y0 + amp*math.Sin(x/lam+ph) + (off+sp*float64(k))*(.6+.4*math.Sin(x/190+float64(k))) |
| + pts = append(pts, fmt.Sprintf("%.0f,%.1f", x, y)) |
| + } |
| + fmt.Fprintf(&lines, `<polyline points="%s" stroke="url(#f%d)" stroke-width="%.1f" opacity="%.2f"/>`, |
| + strings.Join(pts, " "), bundle, uniform(r, .8, 1.8), uniform(r, .2, .5)) |
| + } |
| + fmt.Fprintf(&grads, `<linearGradient id="f%d" gradientUnits="userSpaceOnUse" x1="%.0f" x2="%.0f" y1="0" y2="0">`+ |
| + `<stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient>`, |
| + bundle, x0, x1) |
| + } |
| + blur := `<filter id="b" x="-5%" y="-20%" width="110%" height="140%"><feGaussianBlur stdDeviation="2.4"/></filter>` |
| + return svg(`<g fill="none" filter="url(#b)">`+lines.String()+`</g>`, grads.String()+blur) |
| +} |
| + |
| +// ID is the public name of the sky for seed: a digest, so a team's sky |
| +// URL says nothing about the team, and a closed shape (16 lowercase hex |
| +// digits) that the handler can check before it draws anything. |
| +func ID(seed string) string { |
| + sum := sha256.Sum256([]byte("estilo-sky:" + seed)) |
| + return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8])) |
| +} |
diff --git a/sky/sky.css b/sky/sky.css| new file mode 100644 |
| index 0000000..b33a007 |
| --- /dev/null |
| +++ b/sky/sky.css |
| @@ -0,0 +1,64 @@ |
| +/* estilo-sky — Calma's ambient nebula (sky.go). Quiet by default: |
| + --estilo-sky-strength is one number for the whole effect. |
| + |
| + Placement is the host's: [estilo-sky-host] is a positioned, clipped box |
| + whose own content sits above the sky. Move the sky within it with |
| + --estilo-sky-top and --estilo-sky-height (percentages of the host). */ |
| +[estilo-sky-host] { |
| + position: relative; |
| + overflow: hidden; |
| + isolation: isolate; |
| +} |
| +[estilo-sky-host] > :not([estilo-sky]) { |
| + position: relative; |
| + z-index: 1; |
| +} |
| + |
| +[estilo-sky] { |
| + position: absolute; |
| + inset-inline: 0; |
| + top: var(--estilo-sky-top, -6%); |
| + height: var(--estilo-sky-height, 80%); |
| + pointer-events: none; |
| + z-index: 0; |
| + --estilo-sky-strength: 0.5; |
| +} |
| + |
| +/* Each layer overhangs the box so a drift never shows an edge, and fades |
| + in and out vertically so the sky has no top or bottom. */ |
| +[estilo-sky] img { |
| + position: absolute; |
| + left: -8%; |
| + top: -12%; |
| + width: 116%; |
| + height: 124%; |
| + max-width: none; |
| + opacity: calc(var(--estilo-sky-strength) * var(--estilo-sky-gain, 1) * var(--estilo-sky-k, 1)); |
| + -webkit-mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%); |
| + mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%); |
| + user-select: none; |
| +} |
| +[estilo-sky-layer="haze"] { --estilo-sky-k: 0.7; } |
| +[estilo-sky-layer="filaments"] { --estilo-sky-k: 1.3; } |
| + |
| +/* One pair of haze and clouds per scheme; show the one that matches. |
| + An explicit Appearance choice ([data-theme]) beats the OS, as it does |
| + for estilo's theme. Hidden images with loading="lazy" are not fetched. */ |
| +[estilo-sky-tone="dark"] { display: none; --estilo-sky-gain: 1.55; } |
| +:root[data-theme="dark"] [estilo-sky-tone="light"] { display: none; } |
| +:root[data-theme="dark"] [estilo-sky-tone="dark"] { display: block; } |
| +@media (prefers-color-scheme: dark) { |
| + :root:not([data-theme="light"]) [estilo-sky-tone="light"] { display: none; } |
| + :root:not([data-theme="light"]) [estilo-sky-tone="dark"] { display: block; } |
| +} |
| + |
| +/* Drifting: the clouds and the haze breathe past each other, the |
| + filaments slide. Slow enough to notice only if you look. */ |
| +@media (prefers-reduced-motion: no-preference) { |
| + [estilo-sky-layer="clouds"] { animation: estilo-sky-breathe-near 64s ease-in-out infinite alternate; } |
| + [estilo-sky-layer="haze"] { animation: estilo-sky-breathe-far 47s ease-in-out infinite alternate; } |
| + [estilo-sky-layer="filaments"] { animation: estilo-sky-drift 80s ease-in-out infinite alternate; } |
| +} |
| +@keyframes estilo-sky-breathe-near { to { transform: translate(-3%, 1.5%) scale(1.06); } } |
| +@keyframes estilo-sky-breathe-far { to { transform: translate(3%, -1.5%) scale(1.08); } } |
| +@keyframes estilo-sky-drift { to { transform: translate(-4%, 1%); } } |
diff --git a/sky/sky.go b/sky/sky.go| new file mode 100644 |
| index 0000000..20d6508 |
| --- /dev/null |
| +++ b/sky/sky.go |
| @@ -0,0 +1,115 @@ |
| +// Package sky is Calma's ambient nebula: a soft sky of every app's colour, |
| +// seeded, so each team's is its own. It lives where the suite meets you — |
| +// the sign-in, the team's home, the front page — and never inside an app |
| +// (AGENTS.md, decisions table, row 8). |
| +// |
| +// A sky is three layers of static SVG, generated from its ID (generate.go) |
| +// and served by Handler: a far haze, near clouds with a fine grain, and a |
| +// few white filaments. Light and dark each get their own haze and clouds; |
| +// sky.css shows the pair matching the page's scheme, drifts them slowly, |
| +// and holds them still for anyone who asks their system for less motion. |
| +// No script. |
| +package sky |
| + |
| +import ( |
| + "embed" |
| + "net/http" |
| + "regexp" |
| + "strings" |
| +) |
| + |
| +//go:embed sky.css |
| +var css []byte |
| + |
| +//go:embed templates/*.html |
| +var templateFS embed.FS |
| + |
| +// CSS styles [estilo-sky] and [estilo-sky-host]. |
| +func CSS() []byte { return append([]byte(nil), css...) } |
| + |
| +// Templates holds "estilo-sky"; parse it into the page's template set. |
| +func Templates() embed.FS { return templateFS } |
| + |
| +// Prefix is the URL path Handler serves under. The version is the |
| +// generator's: a change to what a given ID draws is a new version, so |
| +// the year-long cache below never serves a stale sky. |
| +const Prefix = "/static/estilo/sky/v1/" |
| + |
| +// SuiteSeed is the sky of pages that belong to no one team: the front |
| +// page and the sign-in. |
| +const SuiteSeed = "oficina" |
| + |
| +// Layer is one image of a sky. |
| +type Layer struct { |
| + Name string // haze, clouds or filaments |
| + Tone string // light, dark, or "" for both |
| + Src string |
| +} |
| + |
| +// View is the data for the "estilo-sky" template. |
| +type View struct{ Layers []Layer } |
| + |
| +// For is the sky for seed — a team's id, or SuiteSeed. |
| +func For(seed string) View { |
| + base := Prefix + ID(seed) + "/" |
| + return View{Layers: []Layer{ |
| + {"haze", "light", base + "haze-light.svg"}, |
| + {"haze", "dark", base + "haze-dark.svg"}, |
| + {"clouds", "light", base + "clouds-light.svg"}, |
| + {"clouds", "dark", base + "clouds-dark.svg"}, |
| + {"filaments", "", base + "filaments.svg"}, |
| + }} |
| +} |
| + |
| +var idShape = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| + |
| +// draw returns the SVG for one layer file of sky id, or "" when the name |
| +// is not one of the five. |
| +func draw(id, file string) string { |
| + switch file { |
| + case "haze-light.svg": |
| + return Haze(id, Light) |
| + case "haze-dark.svg": |
| + return Haze(id, Dark) |
| + case "clouds-light.svg": |
| + return Clouds(id, Light) |
| + case "clouds-dark.svg": |
| + return Clouds(id, Dark) |
| + case "filaments.svg": |
| + return Filaments(id) |
| + } |
| + return "" |
| +} |
| + |
| +// Handler serves GET and HEAD for Prefix + "<id>/<layer>.svg". Mount it |
| +// at Prefix. The ID must be the shape ID produces and the layer one of |
| +// the five; anything else is 404 before anything is drawn. Skies are |
| +// public artwork and need no session; an ID's drawing never changes |
| +// within a version, so it is cached for a year. |
| +func Handler() http.Handler { |
| + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| + rest, ok := strings.CutPrefix(r.URL.Path, Prefix) |
| + id, file, _ := strings.Cut(rest, "/") |
| + if !ok || !idShape.MatchString(id) || strings.Contains(file, "/") { |
| + http.NotFound(w, r) |
| + return |
| + } |
| + body := draw(id, file) |
| + if body == "" { |
| + 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", "image/svg+xml") |
| + w.Header().Set("Cache-Control", "public, max-age=31536000, immutable") |
| + w.Header().Set("X-Content-Type-Options", "nosniff") |
| + if r.Method == http.MethodHead { |
| + return |
| + } |
| + _, _ = w.Write([]byte(body)) |
| + }) |
| +} |
diff --git a/sky/sky_test.go b/sky/sky_test.go| new file mode 100644 |
| index 0000000..c31755d |
| --- /dev/null |
| +++ b/sky/sky_test.go |
| @@ -0,0 +1,115 @@ |
| +package sky |
| + |
| +import ( |
| + "bytes" |
| + "encoding/xml" |
| + "html/template" |
| + "io" |
| + "net/http" |
| + "net/http/httptest" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +func layers(id string) map[string]string { |
| + out := map[string]string{} |
| + for _, f := range []string{"haze-light.svg", "haze-dark.svg", "clouds-light.svg", "clouds-dark.svg", "filaments.svg"} { |
| + out[f] = draw(id, f) |
| + } |
| + return out |
| +} |
| + |
| +// A team's sky is the same every time it is drawn, anywhere, and not the |
| +// same as another team's. |
| +func TestSkiesAreSeededAndDistinct(t *testing.T) { |
| + a, again, b := layers(ID("acme")), layers(ID("acme")), layers(ID("orchard")) |
| + for f := range a { |
| + if a[f] != again[f] { |
| + t.Errorf("%s is not deterministic", f) |
| + } |
| + if a[f] == b[f] { |
| + t.Errorf("%s is the same for two teams", f) |
| + } |
| + } |
| +} |
| + |
| +func TestEveryLayerIsWellFormedSVG(t *testing.T) { |
| + for _, seed := range []string{SuiteSeed, "acme", "a team with spaces", ""} { |
| + for f, body := range layers(ID(seed)) { |
| + if strings.Contains(body, "NaN") || strings.Contains(body, "Inf") { |
| + t.Errorf("%s for %q contains a non-number", f, seed) |
| + } |
| + d := xml.NewDecoder(strings.NewReader(body)) |
| + for { |
| + _, err := d.Token() |
| + if err == io.EOF { |
| + break |
| + } |
| + if err != nil { |
| + t.Errorf("%s for %q is not well-formed: %v", f, seed, err) |
| + break |
| + } |
| + } |
| + } |
| + } |
| +} |
| + |
| +// Every app on the ring has a place in every sky. |
| +func TestEveryAppGathersInTheSky(t *testing.T) { |
| + if got := len(anchors(ID(SuiteSeed))); got != 9 { |
| + t.Errorf("the suite sky has %d anchors, want one per app (9)", got) |
| + } |
| +} |
| + |
| +func TestIDIsClosedAndOpaque(t *testing.T) { |
| + id := ID("acme") |
| + if !idShape.MatchString(id) || strings.Contains(id, "acme") { |
| + t.Errorf("ID(%q) = %q", "acme", id) |
| + } |
| +} |
| + |
| +func TestHandlerServesOnlyTheFiveLayers(t *testing.T) { |
| + h := Handler() |
| + id := ID("acme") |
| + for path, want := range map[string]int{ |
| + Prefix + id + "/clouds-dark.svg": 200, |
| + Prefix + id + "/filaments.svg": 200, |
| + Prefix + id + "/clouds.svg": 404, |
| + Prefix + id + "/haze-light.svg/x": 404, |
| + Prefix + "ACME0000000000000/haze-light.svg": 404, |
| + Prefix + "acme/haze-light.svg": 404, |
| + Prefix + id: 404, |
| + "/static/estilo/sky/v0/" + id + "/haze-light.svg": 404, |
| + } { |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, path, nil)) |
| + if rec.Code != want { |
| + t.Errorf("GET %s = %d, want %d", path, rec.Code, want) |
| + } |
| + if want == 200 && (rec.Header().Get("Content-Type") != "image/svg+xml" || !strings.Contains(rec.Header().Get("Cache-Control"), "immutable")) { |
| + t.Errorf("GET %s headers = %v", path, rec.Header()) |
| + } |
| + } |
| + rec := httptest.NewRecorder() |
| + h.ServeHTTP(rec, httptest.NewRequest(http.MethodDelete, Prefix+id+"/filaments.svg", nil)) |
| + if rec.Code != http.StatusMethodNotAllowed { |
| + t.Errorf("DELETE = %d, want 405", rec.Code) |
| + } |
| +} |
| + |
| +func TestTemplateRendersEveryLayerDecoratively(t *testing.T) { |
| + tmpl := template.Must(template.ParseFS(Templates(), "templates/*.html")) |
| + var b bytes.Buffer |
| + if err := tmpl.ExecuteTemplate(&b, "estilo-sky", For("acme")); err != nil { |
| + t.Fatal(err) |
| + } |
| + html := b.String() |
| + if !strings.Contains(html, `aria-hidden="true"`) || strings.Count(html, `alt=""`) != 5 || strings.Count(html, `estilo-sky-tone=`) != 4 { |
| + t.Errorf("unexpected markup:\n%s", html) |
| + } |
| + for _, l := range For("acme").Layers { |
| + if !strings.Contains(html, `src="`+l.Src+`"`) { |
| + t.Errorf("layer %s missing", l.Src) |
| + } |
| + } |
| +} |
diff --git a/sky/templates/sky.html b/sky/templates/sky.html| new file mode 100644 |
| index 0000000..7106cd6 |
| --- /dev/null |
| +++ b/sky/templates/sky.html |
| @@ -0,0 +1,9 @@ |
| +{{/* estilo-sky: the ambient nebula. Put it first inside an element that |
| + carries estilo-sky-host (which positions it and lifts the element's |
| + other children above it). Decorative: hidden from assistive |
| + technology, and every image has empty alt text. Data: sky.View. */}} |
| +{{define "estilo-sky"}}<div estilo-sky aria-hidden="true"> |
| + {{- range .Layers}} |
| + <img estilo-sky-layer="{{.Name}}"{{with .Tone}} estilo-sky-tone="{{.}}"{{end}} src="{{.Src}}" alt="" loading="lazy" decoding="async"> |
| + {{- end}} |
| +</div>{{end}} |