oficina / estilo Public

sky: an instant placeholder, so the first frame already has the sky

Paul, 2026-09-29: the sky took a second to appear and flashed in on every
load. A blurhash would need a script to decode, and these pages run none,
so the sky computes its own: the same blobs as the SVG layers, painted as
Gaussians into a 50x20 PNG and inlined as a data: URI, blended in linear
light as SVG filters blur. Calibrated in Chromium against the layers:
3/255 mean difference in light, 8/255 in dark.

The placeholder paints first and carries most of the colour; the SVG
layers now add the detail at a quarter of their weight, so together they
are the sky as designed. The layers are no longer lazy-loaded: the sky is
at the top of the page, and a lazy image waits for layout. The SVG
drawings are unchanged (goldens), so their cached URLs stay valid.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 841b315ab946edbd2fd2ba1035215d1536f820ec parent 5b2ce83
6 files changed, +240 −31
  • sky/generate.go +45 −13
  • sky/placeholder.go +114 −0
  • sky/sky.css +11 −1
  • sky/sky.go +21 −11
  • sky/sky_test.go +42 −2
  • sky/templates/sky.html +7 −4

CI passed — run details

CI log
=== modcache ===
all modules verified
=== vet ===
CGO_ENABLED=0 go vet ./...
=== fmt ===
=== build ===
CGO_ENABLED=0 go build -buildvcs=false ./...
=== go-test ===
CGO_ENABLED=0 go test -count=1 ./...
ok  	amadan.net/oficina/estilo.git/base	0.005s
ok  	amadan.net/oficina/estilo.git/brand	0.004s
ok  	amadan.net/oficina/estilo.git/callui	0.004s
ok  	amadan.net/oficina/estilo.git/chrome	0.283s
?   	amadan.net/oficina/estilo.git/cmd/oficina-notices	[no test files]
ok  	amadan.net/oficina/estilo.git/credits	0.008s
ok  	amadan.net/oficina/estilo.git/credits/catalog	0.005s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.012s
ok  	amadan.net/oficina/estilo.git/internal/lint	0.005s
?   	amadan.net/oficina/estilo.git/internal/tokencheck	[no test files]
ok  	amadan.net/oficina/estilo.git/menu	0.011s
ok  	amadan.net/oficina/estilo.git/savestatus	0.129s
ok  	amadan.net/oficina/estilo.git/sky	0.133s
ok  	amadan.net/oficina/estilo.git/theme	0.010s
diff --git a/sky/generate.go b/sky/generate.go
index b8dedf6..c3a761f 100644
--- a/sky/generate.go
+++ b/sky/generate.go
@@ -109,33 +109,65 @@ const grain = `<filter id="g" filterUnits="userSpaceOnUse" x="0" y="0" width="16
`<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 {
+// 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 b strings.Builder
+ 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)
- 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)
+ 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, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="%s" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`,
+ c.cx, c.cy, c.rx, c.ry, c.fill, c.opacity, c.rot, c.cx, c.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.
+// Haze is the far layer, 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) {
+ for _, h := range hazeBlobs(id, t) {
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)
+ h.cx, h.cy, h.rx, h.ry, h.fill, h.opacity)
}
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)
diff --git a/sky/placeholder.go b/sky/placeholder.go
new file mode 100644
index 0000000..1a8706b
--- /dev/null
+++ b/sky/placeholder.go
@@ -0,0 +1,114 @@
+package sky
+
+import (
+ "bytes"
+ "encoding/base64"
+ "image"
+ "image/color"
+ "image/png"
+ "math"
+ "strconv"
+)
+
+// The placeholder is the sky in a few pixels, painted into the first
+// frame so the page never shows a blank where the sky will be. It plays
+// blurhash's part without the script blurhash needs to decode: a tiny
+// PNG, inline as a data: URI, that the browser's upscaling smooths into
+// the same soft nebula the SVG layers draw a moment later.
+//
+// It is computed, not rendered: each blob is a blurred ellipse, which is
+// close to a Gaussian, so a pixel is the blobs' Gaussians composited in
+// the order the layers stack (haze, then clouds at full strength).
+// Calibrated 2026-09-29 against the SVG layers, rendered in Chromium on
+// Home's backgrounds: mean channel difference 3/255 in light, 8/255 in
+// dark (most of the rest is the filaments, which the placeholder leaves
+// out). SVG filters blur in linear light, so the blobs are blended in it
+// too; that alone took dark from 12 to 8.
+const (
+ phLinear = true
+ phSpread = 0.65 // a blurred ellipse's Gaussian width, per unit radius
+)
+
+var phGain = map[Tone]float64{Light: 1, Dark: 1.2}
+
+const (
+ phW = 50 // the canvas's 5:2, so object-fit: cover crops it exactly as
+ phH = 20 // the SVG layers' xMidYMid slice crops them
+)
+
+// Placeholder is the data: URI of the sky's placeholder for one scheme.
+func Placeholder(id string, t Tone) string {
+ img := image.NewNRGBA(image.Rect(0, 0, phW, phH))
+ haze, clouds := hazeBlobs(id, t), cloudBlobs(id, t)
+ for y := range phH {
+ for x := range phW {
+ // the pixel's centre on the 1600x640 canvas
+ px, py := (float64(x)+.5)*width/phW, (float64(y)+.5)*height/phH
+ var r, g, b, a float64 // premultiplied, 0..1
+ for _, layer := range []struct {
+ blobs []blob
+ k float64
+ }{{haze, 0.7}, {clouds, 1}} {
+ for _, bl := range layer.blobs {
+ alpha := math.Min(1, phGain[t]*layer.k*bl.opacity*bl.at(px, py))
+ cr, cg, cb := hexRGB(bl.fill)
+ if phLinear {
+ cr, cg, cb = lin(cr), lin(cg), lin(cb)
+ }
+ r = cr*alpha + r*(1-alpha)
+ g = cg*alpha + g*(1-alpha)
+ b = cb*alpha + b*(1-alpha)
+ a = alpha + a*(1-alpha)
+ }
+ }
+ c := color.NRGBA{A: to8(a)}
+ if a > 0 {
+ r, g, b = r/a, g/a, b/a
+ if phLinear {
+ r, g, b = enc(r), enc(g), enc(b)
+ }
+ c.R, c.G, c.B = to8(r), to8(g), to8(b)
+ }
+ img.SetNRGBA(x, y, c)
+ }
+ }
+ var buf bytes.Buffer
+ enc := png.Encoder{CompressionLevel: png.BestCompression}
+ if err := enc.Encode(&buf, img); err != nil {
+ panic(err) // encoding an in-memory NRGBA cannot fail
+ }
+ return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
+}
+
+// at is the blob's coverage at a canvas point: a Gaussian as wide as the
+// ellipse blurred by its filter, in the ellipse's own rotated frame.
+func (bl blob) at(px, py float64) float64 {
+ dx, dy := px-bl.cx, py-bl.cy
+ th := bl.rot * math.Pi / 180
+ u := dx*math.Cos(th) + dy*math.Sin(th)
+ v := -dx*math.Sin(th) + dy*math.Cos(th)
+ sx := math.Hypot(bl.rx*phSpread, bl.blur)
+ sy := math.Hypot(bl.ry*phSpread, bl.blur)
+ return math.Exp(-0.5 * (u*u/(sx*sx) + v*v/(sy*sy)))
+}
+
+func hexRGB(h string) (r, g, b float64) {
+ n, _ := strconv.ParseUint(h[1:], 16, 32)
+ return float64(n>>16&0xff) / 255, float64(n>>8&0xff) / 255, float64(n&0xff) / 255
+}
+
+func to8(v float64) uint8 { return uint8(math.Round(math.Max(0, math.Min(1, v)) * 255)) }
+
+func lin(c float64) float64 {
+ if c <= 0.04045 {
+ return c / 12.92
+ }
+ return math.Pow((c+0.055)/1.055, 2.4)
+}
+
+func enc(c float64) float64 {
+ if c <= 0.0031308 {
+ return c * 12.92
+ }
+ return 1.055*math.Pow(c, 1/2.4) - 0.055
+}
diff --git a/sky/sky.css b/sky/sky.css
index 23df17a..8e2beb0 100644
--- a/sky/sky.css
+++ b/sky/sky.css
@@ -37,7 +37,17 @@
mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%);
user-select: none;
}
-[estilo-sky-layer="haze"] { --estilo-sky-k: 0.7; }
+/* The placeholder paints in the first frame and carries most of the sky's
+ colour; the SVG layers, arriving a moment later, add the detail — the
+ clouds' grain and shape, the filaments — at a quarter of their old
+ weight, so placeholder and layers together are the sky as designed
+ (placeholder.go has the measurements). It is a few pixels, stretched to
+ cover as the SVGs' xMidYMid slice does, and softened so no pixel grid
+ shows. No script, so nothing waits for anything to load. */
+[estilo-sky] img { object-fit: cover; }
+[estilo-sky-layer="placeholder"] { filter: blur(24px); }
+[estilo-sky-layer="haze"] { --estilo-sky-k: 0.175; }
+[estilo-sky-layer="clouds"] { --estilo-sky-k: 0.25; }
[estilo-sky-layer="filaments"] { --estilo-sky-k: 1.3; }
/* One pair of haze and clouds per scheme; show the one that matches.
diff --git a/sky/sky.go b/sky/sky.go
index 0f34022..04b643a 100644
--- a/sky/sky.go
+++ b/sky/sky.go
@@ -3,9 +3,11 @@
// 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;
+// A sky is a placeholder and 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. The placeholder (placeholder.go)
+// is the same sky in 50x20 pixels, inline, so the first frame already has
+// the sky's colour and the layers only bring its detail. 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.
@@ -13,6 +15,7 @@ package sky
import (
"embed"
+ "html/template"
"net/http"
"regexp"
"strings"
@@ -43,9 +46,13 @@ const SuiteSeed = "oficina"
// Layer is one image of a sky.
type Layer struct {
- Name string // haze, clouds or filaments
+ Name string // placeholder, haze, clouds or filaments
Tone string // light, dark, or "" for both
- Src string
+ Src string // a URL under Prefix; empty for the placeholder
+ // Inline is the placeholder's data: URI, typed so html/template
+ // passes it (a data: URI in a plain string is sanitised away). Built
+ // only by Placeholder, never from input.
+ Inline template.URL
}
// View is the data for the "estilo-sky" template.
@@ -53,13 +60,16 @@ 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) + "/"
+ id := ID(seed)
+ base := Prefix + id + "/"
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"},
+ {Name: "placeholder", Tone: "light", Inline: template.URL(Placeholder(id, Light))},
+ {Name: "placeholder", Tone: "dark", Inline: template.URL(Placeholder(id, Dark))},
+ {Name: "haze", Tone: "light", Src: base + "haze-light.svg"},
+ {Name: "haze", Tone: "dark", Src: base + "haze-dark.svg"},
+ {Name: "clouds", Tone: "light", Src: base + "clouds-light.svg"},
+ {Name: "clouds", Tone: "dark", Src: base + "clouds-dark.svg"},
+ {Name: "filaments", Src: base + "filaments.svg"},
}}
}
diff --git a/sky/sky_test.go b/sky/sky_test.go
index af37218..adc92c2 100644
--- a/sky/sky_test.go
+++ b/sky/sky_test.go
@@ -2,9 +2,11 @@ package sky
import (
"bytes"
+ "encoding/base64"
"encoding/xml"
"flag"
"html/template"
+ "image/png"
"io"
"net/http"
"net/http/httptest"
@@ -108,11 +110,12 @@ func TestTemplateRendersEveryLayerDecoratively(t *testing.T) {
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 {
+ if !strings.Contains(html, `aria-hidden="true"`) || strings.Count(html, `alt=""`) != 7 || strings.Count(html, `estilo-sky-tone=`) != 6 ||
+ strings.Count(html, `src="data:image/png;base64,`) != 2 || strings.Contains(html, "ZgotmplZ") || strings.Contains(html, `loading="lazy"`) {
t.Errorf("unexpected markup:\n%s", html)
}
for _, l := range For("acme").Layers {
- if !strings.Contains(html, `src="`+l.Src+`"`) {
+ if l.Src != "" && !strings.Contains(html, `src="`+l.Src+`"`) {
t.Errorf("layer %s missing", l.Src)
}
}
@@ -142,3 +145,40 @@ func TestSkyGoldens(t *testing.T) {
}
}
}
+
+// The placeholder is a small, deterministic PNG per sky and scheme, and it
+// is the sky it stands in for: not blank, and not another team's.
+func TestPlaceholderIsTheSkyInAFewPixels(t *testing.T) {
+ id := ID("acme")
+ for _, tone := range []Tone{Light, Dark} {
+ uri := Placeholder(id, tone)
+ if uri != Placeholder(id, tone) || uri == Placeholder(ID("orchard"), tone) {
+ t.Errorf("%s placeholder is not deterministic per sky", tone)
+ }
+ raw, err := base64.StdEncoding.DecodeString(strings.TrimPrefix(uri, "data:image/png;base64,"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ img, err := png.Decode(bytes.NewReader(raw))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if b := img.Bounds(); b.Dx() != phW || b.Dy() != phH {
+ t.Errorf("placeholder is %v", b)
+ }
+ var opaque int
+ for y := range phH {
+ for x := range phW {
+ if _, _, _, a := img.At(x, y).RGBA(); a > 0x4000 {
+ opaque++
+ }
+ }
+ }
+ if opaque < phW*phH/4 {
+ t.Errorf("%s placeholder is mostly empty (%d of %d pixels)", tone, opaque, phW*phH)
+ }
+ if len(uri) > 4096 {
+ t.Errorf("%s placeholder is %d bytes inline; keep it small", tone, len(uri))
+ }
+ }
+}
diff --git a/sky/templates/sky.html b/sky/templates/sky.html
index 7106cd6..4ac83ab 100644
--- a/sky/templates/sky.html
+++ b/sky/templates/sky.html
@@ -1,9 +1,12 @@
{{/* 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. */}}
+ carries estilo-sky-host. Decorative: hidden from assistive technology,
+ and every image has empty alt text. The placeholder is inline (a data:
+ URI, so the page's img-src must allow data:) and paints in the first
+ frame; the layers are small, cached for a year, and not lazy — the sky
+ is at the top of the page, and a lazy image waits for layout.
+ 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">
+ <img estilo-sky-layer="{{.Name}}"{{with .Tone}} estilo-sky-tone="{{.}}"{{end}} src="{{with .Inline}}{{.}}{{else}}{{.Src}}{{end}}" alt=""{{if .Src}} decoding="async"{{end}}>
{{- end}}
</div>{{end}}