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(``,
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]))
}