oficina / estilo Public

WIP: sky — seeded nebula with grain and filaments

Paul Campbell pushed by paul@keymail.dev 2ea9e8cd59bdabb22b727c61c71d1b52f84c421e parent 1fff0bc
5 files changed, +477 −0
  • sky/generate.go +174 −0
  • sky/sky.css +64 −0
  • sky/sky.go +115 −0
  • sky/sky_test.go +115 −0
  • sky/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}}