oficina / estilo Public

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

Plain git — no account needed to clone.

Download

Download this file

1package sky
2
3import (
4 "crypto/sha256"
5 "encoding/binary"
6 "fmt"
7 "math"
8 "math/rand/v2"
9 "strings"
10
11 "amadan.net/oficina/estilo.git/theme"
12)
13
14// The sky is drawn on a 1600x640 canvas and covers its box, cropping
15// rather than stretching (xMidYMid slice): on a phone a squashed canvas
16// turns clouds into streaks and filaments into zigzags, where a crop just
17// shows fewer apps' colours, at their true shapes.
18const (
19 width = 1600
20 height = 640
21)
22
23// rng is the generator for one stream of one sky. The same ID and stream
24// always give the same numbers, on every machine and every Go release
25// (ChaCha8 is specified, not an implementation detail). The drawing built
26// from them is stable for a given binary; across architectures a
27// coordinate can differ in its last place (Go may fuse a multiply-add on
28// arm64 and not on amd64), which a browser cannot see.
29func rng(id, stream string) *rand.Rand {
30 sum := sha256.Sum256([]byte("estilo-sky/v1:" + id + ":" + stream))
31 var seed [32]byte
32 copy(seed[:], sum[:])
33 return rand.New(rand.NewChaCha8(seed))
34}
35
36func uniform(r *rand.Rand, lo, hi float64) float64 { return lo + r.Float64()*(hi-lo) }
37
38// anchor is where one app's colour gathers in the sky.
39type anchor struct {
40 hue, x, y, scale, glow float64
41}
42
43// anchors spreads every app on the ring across the sky, in ring order so
44// neighbours blend into neighbours, from a starting app and in a direction
45// the seed chooses; spacing, height and size are jittered. Oficina itself
46// is not an anchor: the sky is the apps, together.
47func anchors(id string) []anchor {
48 r := rng(id, "anchors")
49 var hues []float64
50 for _, a := range theme.Apps() {
51 if a.Slug != "oficina" {
52 hues = append(hues, a.Hue)
53 }
54 }
55 n := len(hues)
56 start := r.IntN(n)
57 hues = append(hues[start:], hues[:start]...)
58 if r.IntN(2) == 0 {
59 for i, j := 0, n-1; i < j; i, j = i+1, j-1 {
60 hues[i], hues[j] = hues[j], hues[i]
61 }
62 }
63 spread := 70 * 7 / float64(n)
64 out := make([]anchor, n)
65 for i, h := range hues {
66 out[i] = anchor{
67 hue: h,
68 x: (float64(i)+.5)/float64(n)*width + uniform(r, -spread, spread),
69 y: height*(.36+.2*math.Sin(float64(i)*1.3+uniform(r, 0, 6.3))) + uniform(r, -45, 45),
70 scale: uniform(r, .7, 1.3),
71 // Some apps glow brighter than others in any one sky; an even
72 // brightness reads as a rainbow band, not a nebula.
73 glow: uniform(r, .5, 1),
74 }
75 }
76 return out
77}
78
79// Tone is the scheme a layer is drawn for.
80type Tone string
81
82const (
83 Light Tone = "light"
84 Dark Tone = "dark"
85)
86
87// palette is the nebula's colour at each scheme: pale on a light page,
88// deeper and brighter on a dark one (the lab's "as now", Paul 2026-09-29).
89func palette(t Tone) (L, C float64) {
90 if t == Dark {
91 return .72, .13
92 }
93 return .80, .12
94}
95
96func svg(body, defs string) string {
97 return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" preserveAspectRatio="xMidYMid slice"><defs>%s</defs>%s</svg>`,
98 width, height, width, height, defs, body)
99}
100
101// grain is a fine photographic texture laid over the clouds and clipped to
102// them: noise, taken to grey and down to a third of its contrast, blended
103// in with overlay so it only nudges each pixel. Its region is the whole
104// canvas in user space; a bounding-box region would clip the clouds' blur
105// to their unblurred outline, a hard edge across the sky.
106const grain = `<filter id="g" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">` +
107 `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>` +
108 `<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"/>` +
109 `<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>` +
110 `<feComposite in="c" in2="SourceGraphic" operator="in" result="m"/><feBlend in="m" in2="SourceGraphic" mode="overlay"/></filter>`
111
112// blob is one soft shape of the sky: an ellipse of one colour, blurred.
113// The SVG layers draw blobs; Placeholder paints the same blobs, roughly,
114// into a few pixels.
115type blob struct {
116 cx, cy, rx, ry, rot, opacity, blur float64
117 fill string
118}
119
120// cloudBlobs is the near layer: two or three clouds of each app's colour
121// around its anchor.
122func cloudBlobs(id string, t Tone) []blob {
123 r := rng(id, "clouds")
124 L, C := palette(t)
125 var out []blob
126 for _, a := range anchors(id) {
127 for range 1 + r.IntN(3) {
128 cx, cy := a.x+uniform(r, -160, 160), a.y+uniform(r, -85, 70)
129 rx, ry := uniform(r, 100, 220)*a.scale, uniform(r, 55, 130)*a.scale
130 fill := theme.Hex(L, C, a.hue+uniform(r, -8, 8))
131 op := uniform(r, .45, .85) * a.glow
132 rot := uniform(r, -28, 28)
133 out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, rot: rot, opacity: op, blur: 70, fill: fill})
134 }
135 }
136 return out
137}
138
139// hazeBlobs is the far layer: one wide, faint glow per app.
140func hazeBlobs(id string, t Tone) []blob {
141 r := rng(id, "haze")
142 L, C := palette(t)
143 var out []blob
144 for _, a := range anchors(id) {
145 cx, cy := a.x+uniform(r, -80, 80), a.y+uniform(r, -40, 60)
146 rx, ry := uniform(r, 330, 480), uniform(r, 140, 220)
147 fill := theme.Hex(L-.02, C, a.hue)
148 op := uniform(r, .25, .45) * a.glow
149 out = append(out, blob{cx: cx, cy: cy, rx: rx, ry: ry, opacity: op, blur: 110, fill: fill})
150 }
151 return out
152}
153
154// Clouds is the near layer, heavily blurred, with grain.
155func Clouds(id string, t Tone) string {
156 var b strings.Builder
157 for _, c := range cloudBlobs(id, t) {
158 fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`,
159 c.cx, c.cy, c.rx, c.ry, c.fill, c.opacity, c.rot, c.cx, c.cy)
160 }
161 blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter>`
162 return svg(`<g filter="url(#g)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grain)
163}
164
165// grainOnly is the clouds' grain as a texture of its own: faint white
166// specks where the noise is above its middle and faint black ones where it
167// is below, kept only where the clouds are (their alpha). Drawn with
168// ordinary blending, so it barely moves the average brightness (a fraction
169// of a level; the dark specks show more on a light sky, the light ones on
170// a dark one) — an overlay blend showed its grey wherever the sky beneath
171// was thin, and lifted the whole dark sky. grainAmp is each speck's most.
172const grainAmp = 0.34
173
174var grainOnly = fmt.Sprintf(`<filter id="n" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">`+
175 `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>`+
176 `<feColorMatrix in="n" type="matrix" values="0 0 0 0 1 0 0 0 0 1 0 0 0 0 1 .33 .33 .33 0 0" result="w"/>`+
177 `<feComponentTransfer in="w" result="lit"><feFuncA type="linear" slope="%[1]g" intercept="%[2]g"/></feComponentTransfer>`+
178 `<feColorMatrix in="n" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 .33 .33 .33 0 0" result="k"/>`+
179 `<feComponentTransfer in="k" result="shade"><feFuncA type="linear" slope="%[3]g" intercept="%[4]g"/></feComponentTransfer>`+
180 `<feMerge result="specks"><feMergeNode in="lit"/><feMergeNode in="shade"/></feMerge>`+
181 `<feComposite in="specks" in2="SourceGraphic" operator="in"/></filter>`,
182 2*grainAmp, -grainAmp, -2*grainAmp, grainAmp)
183
184// Grain is the clouds' texture as a layer of its own. When the placeholder
185// took over the sky's colour, the clouds layer that carried the grain went
186// faint and then unlinked (impeccable's detector, and a zoomed look,
187// 2026-09-29); on its own layer the grain keeps the strength Paul chose. One file for
188// both schemes: it is the clouds' shape, not their colour.
189func Grain(id string) string {
190 var b strings.Builder
191 for _, c := range cloudBlobs(id, Light) {
192 fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="#000" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`,
193 c.cx, c.cy, c.rx, c.ry, c.opacity, c.rot, c.cx, c.cy)
194 }
195 blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter>`
196 return svg(`<g filter="url(#n)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grainOnly)
197}
198
199// Haze is the far layer, much softer.
200func Haze(id string, t Tone) string {
201 var b strings.Builder
202 for _, h := range hazeBlobs(id, t) {
203 fmt.Fprintf(&b, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f"/>`,
204 h.cx, h.cy, h.rx, h.ry, h.fill, h.opacity)
205 }
206 blur := `<filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="110"/></filter>`
207 return svg(`<g filter="url(#b)">`+b.String()+`</g>`, blur)
208}
209
210// Filaments are fine wisps: a few bundles of near-parallel strands that
211// drift apart and together along their length and fade in and out at
212// their ends. White, so they read as silk on a light page and as glowing
213// gas on a dark one, from one file.
214func Filaments(id string) string {
215 r := rng(id, "filaments")
216 var lines, grads strings.Builder
217 for bundle := range 4 {
218 y0, amp, lam, ph := height*uniform(r, .2, .6), uniform(r, 20, 60), uniform(r, 260, 520), uniform(r, 0, 6.3)
219 x0, x1 := uniform(r, -100, width*.4), uniform(r, width*.6, width+100)
220 for k := range 2 + r.IntN(2) {
221 off, sp := uniform(r, -10, 10), uniform(r, 6, 16)
222 var pts []string
223 for x := x0; x <= x1; x += 20 {
224 y := y0 + amp*math.Sin(x/lam+ph) + (off+sp*float64(k))*(.6+.4*math.Sin(x/190+float64(k)))
225 pts = append(pts, fmt.Sprintf("%.0f,%.1f", x, y))
226 }
227 fmt.Fprintf(&lines, `<polyline points="%s" stroke="url(#f%d)" stroke-width="%.1f" opacity="%.2f"/>`,
228 strings.Join(pts, " "), bundle, uniform(r, .8, 1.8), uniform(r, .2, .5))
229 }
230 fmt.Fprintf(&grads, `<linearGradient id="f%d" gradientUnits="userSpaceOnUse" x1="%.0f" x2="%.0f" y1="0" y2="0">`+
231 `<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>`,
232 bundle, x0, x1)
233 }
234 blur := `<filter id="b" x="-5%" y="-20%" width="110%" height="140%"><feGaussianBlur stdDeviation="2.4"/></filter>`
235 return svg(`<g fill="none" filter="url(#b)">`+lines.String()+`</g>`, grads.String()+blur)
236}
237
238// ID is the public name of the sky for seed: a digest, so a team's sky
239// URL says nothing about the team, and a closed shape (16 lowercase hex
240// digits) that the handler can check before it draws anything.
241func ID(seed string) string {
242 sum := sha256.Sum256([]byte("estilo-sky:" + seed))
243 return fmt.Sprintf("%016x", binary.BigEndian.Uint64(sum[:8]))
244}
245