Merge branch 'sky-placeholder' into main
sky: an instant placeholder, so the first frame already has the sky A 50x20 PNG per sky and scheme, computed from the same blobs as the SVG layers and inlined as a data: URI, painted first; the SVG layers add the detail at a quarter weight so together they are the sky as designed. Layers no longer lazy. Reviewed by Claude Fable 5.1 (discussion 13).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
6 files changed,
+271
−32
sky/generate.go+45 −13sky/placeholder.go+142 −0sky/sky.css+14 −2sky/sky.go+21 −11sky/sky_test.go+42 −2sky/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.003s ok amadan.net/oficina/estilo.git/callui 0.005s ok amadan.net/oficina/estilo.git/chrome 0.178s ? amadan.net/oficina/estilo.git/cmd/oficina-notices [no test files] ok amadan.net/oficina/estilo.git/credits 0.006s ok amadan.net/oficina/estilo.git/credits/catalog 0.006s ok amadan.net/oficina/estilo.git/homeconnect 0.006s ok amadan.net/oficina/estilo.git/internal/lint 0.006s ? amadan.net/oficina/estilo.git/internal/tokencheck [no test files] ok amadan.net/oficina/estilo.git/menu 0.008s ok amadan.net/oficina/estilo.git/savestatus 0.076s ok amadan.net/oficina/estilo.git/sky 0.021s ok amadan.net/oficina/estilo.git/theme 0.007s
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..5df0a36 |
| --- /dev/null |
| +++ b/sky/placeholder.go |
| @@ -0,0 +1,142 @@ |
| +package sky |
| + |
| +import ( |
| + "bytes" |
| + "encoding/base64" |
| + "image" |
| + "image/color" |
| + "image/png" |
| + "math" |
| + "strconv" |
| + "sync" |
| +) |
| + |
| +// 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 |
| +) |
| + |
| +// placeholders memoises Placeholder: it is pure in (id, tone) and costs |
| +// milliseconds, and a page renders it on every request. |
| +var placeholders sync.Map // map[string]string, key id+"/"+tone |
| + |
| +// Placeholder is the data: URI of the sky's placeholder for one scheme. |
| +func Placeholder(id string, t Tone) string { |
| + key := id + "/" + string(t) |
| + if uri, ok := placeholders.Load(key); ok { |
| + return uri.(string) |
| + } |
| + uri := placeholder(id, t) |
| + placeholders.Store(key, uri) |
| + return uri |
| +} |
| + |
| +func placeholder(id string, t Tone) string { |
| + img := image.NewNRGBA(image.Rect(0, 0, phW, phH)) |
| + haze, clouds := hazeBlobs(id, t), cloudBlobs(id, t) |
| + type paint struct { |
| + bl blob |
| + r, g, b float64 |
| + } |
| + prep := func(bs []blob) []paint { |
| + out := make([]paint, len(bs)) |
| + for i, bl := range bs { |
| + r, g, b := hexRGB(bl.fill) |
| + if phLinear { |
| + r, g, b = lin(r), lin(g), lin(b) |
| + } |
| + out[i] = paint{bl, r, g, b} |
| + } |
| + return out |
| + } |
| + hazeP, cloudsP := prep(haze), prep(clouds) |
| + 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 { |
| + paints []paint |
| + k float64 |
| + }{{hazeP, 0.7}, {cloudsP, 1}} { |
| + for _, p := range layer.paints { |
| + alpha := math.Min(1, phGain[t]*layer.k*p.bl.opacity*p.bl.at(px, py)) |
| + cr, cg, cb := p.r, p.g, p.b |
| + 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..9eb7c1e 100644 |
| --- a/sky/sky.css |
| +++ b/sky/sky.css |
| @@ -37,12 +37,24 @@ |
| 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. |
| An explicit Appearance choice ([data-theme]) beats the OS, as it does |
| - for estilo's theme. Hidden images with loading="lazy" are not fetched. */ |
| + for estilo's theme. The other scheme's two layers are fetched too (the |
| + layers are not lazy, so the first frame never waits on layout); they |
| + are a couple of kilobytes and cached for a year. */ |
| [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; } |
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}} |