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 covers its box, cropping // rather than stretching (xMidYMid slice): on a phone a squashed canvas // turns clouds into streaks and filaments into zigzags, where a crop just // shows fewer apps' colours, at their true shapes. 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). The drawing built // from them is stable for a given binary; across architectures a // coordinate can differ in its last place (Go may fuse a multiply-add on // arm64 and not on amd64), which a browser cannot see. 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(`%s%s`, 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 = `` + `` + `` + `` + `` // blob is one soft shape of the sky: an ellipse of one colour, blurred. // The SVG layers draw blobs; Placeholder paints the same blobs, roughly, // into a few pixels. type blob struct { cx, cy, rx, ry, rot, opacity, blur float64 fill string } // cloudBlobs is the near layer: two or three clouds of each app's colour // around its anchor. func cloudBlobs(id string, t Tone) []blob { r := rng(id, "clouds") L, C := palette(t) var out []blob 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) rx, ry := uniform(r, 100, 220)*a.scale, uniform(r, 55, 130)*a.scale fill := theme.Hex(L, C, a.hue+uniform(r, -8, 8)) op := uniform(r, .45, .85) * a.glow rot := uniform(r, -28, 28) out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, rot: rot, opacity: op, blur: 70, fill: fill}) } } return out } // hazeBlobs is the far layer: one wide, faint glow per app. func hazeBlobs(id string, t Tone) []blob { r := rng(id, "haze") L, C := palette(t) var out []blob for _, a := range anchors(id) { cx, cy := a.x+uniform(r, -80, 80), a.y+uniform(r, -40, 60) rx, ry := uniform(r, 330, 480), uniform(r, 140, 220) fill := theme.Hex(L-.02, C, a.hue) op := uniform(r, .25, .45) * a.glow out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, opacity: op, blur: 110, fill: fill}) } return out } // Clouds is the near layer, heavily blurred, with grain. func Clouds(id string, t Tone) string { var b strings.Builder for _, c := range cloudBlobs(id, t) { fmt.Fprintf(&b, ``, c.cx, c.cy, c.rx, c.ry, c.fill, c.opacity, c.rot, c.cx, c.cy) } blur := `` return svg(``+b.String()+``, blur+grain) } // grainOnly is the clouds' grain as a texture of its own: faint white // specks where the noise is above its middle and faint black ones where it // is below, kept only where the clouds are (their alpha). Drawn with // ordinary blending, so it barely moves the average brightness (a fraction // of a level; the dark specks show more on a light sky, the light ones on // a dark one) — an overlay blend showed its grey wherever the sky beneath // was thin, and lifted the whole dark sky. grainAmp is each speck's most. const grainAmp = 0.34 var grainOnly = fmt.Sprintf(``+ ``+ ``+ ``+ ``+ ``+ ``+ ``, 2*grainAmp, -grainAmp, -2*grainAmp, grainAmp) // Grain is the clouds' texture as a layer of its own. When the placeholder // took over the sky's colour, the clouds layer that carried the grain went // faint and then unlinked (impeccable's detector, and a zoomed look, // 2026-09-29); on its own layer the grain keeps the strength Paul chose. One file for // both schemes: it is the clouds' shape, not their colour. func Grain(id string) string { var b strings.Builder for _, c := range cloudBlobs(id, Light) { fmt.Fprintf(&b, ``, c.cx, c.cy, c.rx, c.ry, c.opacity, c.rot, c.cx, c.cy) } blur := `` return svg(``+b.String()+``, blur+grainOnly) } // Haze is the far layer, much softer. func Haze(id string, t Tone) string { var b strings.Builder for _, h := range hazeBlobs(id, t) { fmt.Fprintf(&b, ``, h.cx, h.cy, h.rx, h.ry, h.fill, h.opacity) } blur := `` return svg(``+b.String()+``, 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, ``, strings.Join(pts, " "), bundle, uniform(r, .8, 1.8), uniform(r, .2, .5)) } fmt.Fprintf(&grads, ``+ ``, bundle, x0, x1) } blur := `` return svg(``+lines.String()+``, 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])) }