Merge branch 'sky-grain' into main
sky: grain on its own layer; the placeholder is the sky's colour Found by impeccable's detector on the live pages: the grain Paul chose had faded with the re-weighted clouds layer. Grain is now a layer of neutral specks; haze and clouds SVGs, drawing almost nothing over the placeholder, are no longer linked (still served for cached pages). Reviewed by Claude Fable 5.1 (discussion 16).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
7 files changed,
+77
−37
AGENTS.md+3 −2sky/generate.go+34 −0sky/placeholder.go+2 −1sky/sky.css+18 −17sky/sky.go+15 −14sky/sky_test.go+4 −3sky/testdata/v1-grain.svg+1 −0
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.004s ok amadan.net/oficina/estilo.git/brand 0.002s ok amadan.net/oficina/estilo.git/busy 0.003s ok amadan.net/oficina/estilo.git/callui 0.004s ok amadan.net/oficina/estilo.git/chrome 0.171s ? 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.005s ok amadan.net/oficina/estilo.git/homeconnect 0.007s 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.010s ok amadan.net/oficina/estilo.git/savestatus 0.081s ok amadan.net/oficina/estilo.git/sky 0.019s ok amadan.net/oficina/estilo.git/theme 0.004s
diff --git a/AGENTS.md b/AGENTS.md| index 5da65c9..28c48f9 100644 |
| --- a/AGENTS.md |
| +++ b/AGENTS.md |
| @@ -93,8 +93,9 @@ What Calma is, and where each part lives: |
| `theme.FontHandler`; the theme falls back to the system face without it. |
| - **`brand/`** — the marks, generated from `icons.json` (hues must equal |
| the ring; `brand_test` checks) in web, iPhone and Mac shapes. |
| -- **`sky/`** — the ambient nebula: seeded per team, three kinds of layer |
| - (haze, grained clouds, filaments) in five static files, |
| +- **`sky/`** — the ambient nebula: seeded per team; its colour (haze and |
| + clouds) an inline placeholder painted in the first frame, its grain and |
| + filaments two static files, |
| no script, still for anyone who asks for less motion. **It goes where |
| the suite meets you — the sign-in, the team's home, the front page — |
| and never inside an app.** Inside an app, only the icons carry colour. |
diff --git a/sky/generate.go b/sky/generate.go| index c3a761f..98935fe 100644 |
| --- a/sky/generate.go |
| +++ b/sky/generate.go |
| @@ -162,6 +162,40 @@ func Clouds(id string, t Tone) string { |
| return svg(`<g filter="url(#g)"><g filter="url(#b)">`+b.String()+`</g></g>`, 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(`<filter id="n" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640">`+ |
| + `<feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/>`+ |
| + `<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"/>`+ |
| + `<feComponentTransfer in="w" result="lit"><feFuncA type="linear" slope="%[1]g" intercept="%[2]g"/></feComponentTransfer>`+ |
| + `<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"/>`+ |
| + `<feComponentTransfer in="k" result="shade"><feFuncA type="linear" slope="%[3]g" intercept="%[4]g"/></feComponentTransfer>`+ |
| + `<feMerge result="specks"><feMergeNode in="lit"/><feMergeNode in="shade"/></feMerge>`+ |
| + `<feComposite in="specks" in2="SourceGraphic" operator="in"/></filter>`, |
| + 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, `<ellipse cx="%.0f" cy="%.0f" rx="%.0f" ry="%.0f" fill="#000" opacity="%.2f" transform="rotate(%.1f %.0f %.0f)"/>`, |
| + c.cx, c.cy, c.rx, c.ry, 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(#n)"><g filter="url(#b)">`+b.String()+`</g></g>`, blur+grainOnly) |
| +} |
| + |
| // Haze is the far layer, much softer. |
| func Haze(id string, t Tone) string { |
| var b strings.Builder |
diff --git a/sky/placeholder.go b/sky/placeholder.go| index 5df0a36..b442c94 100644 |
| --- a/sky/placeholder.go |
| +++ b/sky/placeholder.go |
| @@ -15,7 +15,8 @@ import ( |
| // 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. |
| +// the soft nebula the haze and clouds SVGs draw — and it is now the sky's |
| +// colour on its own, with only the grain and filaments over it. |
| // |
| // 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 |
diff --git a/sky/sky.css b/sky/sky.css| index 9eb7c1e..50067a9 100644 |
| --- a/sky/sky.css |
| +++ b/sky/sky.css |
| @@ -37,24 +37,27 @@ |
| mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%); |
| user-select: none; |
| } |
| -/* 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. */ |
| +/* The sky's colour is the placeholder: the haze and clouds computed into a |
| + few pixels and inlined, so it is there in the first frame (placeholder.go |
| + has the measurements against the full SVG drawing: 3/255 light, 8/255 |
| + dark). Stretched to cover as an SVG's xMidYMid slice would, and softened |
| + so no pixel grid shows. The haze and clouds SVGs it replaced were |
| + drawing almost nothing on top of it, so they are no longer linked |
| + (impeccable's detector, 2026-09-29). Over it, two SVG layers carry what |
| + a few pixels cannot: the clouds' grain — faint light and dark specks that |
| + average to nothing — and the filaments. No script. */ |
| [estilo-sky] img { object-fit: cover; } |
| [estilo-sky-layer="placeholder"] { filter: blur(24px); } |
| +[estilo-sky-layer="grain"] { --estilo-sky-k: 1; } |
| +/* The weights haze and clouds had when they were linked, kept for a page |
| + cached from then that still names them. */ |
| [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. 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. */ |
| +/* One placeholder per scheme; show the one that matches. An explicit |
| + Appearance choice ([data-theme]) beats the OS, as it does for estilo's |
| + theme. Both are inline, so neither costs a request. */ |
| [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; } |
| @@ -63,13 +66,11 @@ |
| :root:not([data-theme="light"]) [estilo-sky-tone="dark"] { display: block; } |
| } |
| -/* Drifting: the clouds and the haze breathe past each other, the |
| - filaments slide. Slow enough to notice only if you look. */ |
| +/* Drifting: the sky breathes, the grain with it, and the filaments |
| + slide. Slow enough to notice only if you look. */ |
| @media (prefers-reduced-motion: no-preference) { |
| - [estilo-sky-layer="clouds"] { animation: estilo-sky-breathe-near 64s ease-in-out infinite alternate; } |
| - [estilo-sky-layer="haze"] { animation: estilo-sky-breathe-far 47s ease-in-out infinite alternate; } |
| + [estilo-sky-layer="placeholder"], [estilo-sky-layer="grain"] { animation: estilo-sky-breathe-near 64s ease-in-out infinite alternate; } |
| [estilo-sky-layer="filaments"] { animation: estilo-sky-drift 80s ease-in-out infinite alternate; } |
| } |
| @keyframes estilo-sky-breathe-near { to { transform: translate(-3%, 1.5%) scale(1.06); } } |
| -@keyframes estilo-sky-breathe-far { to { transform: translate(3%, -1.5%) scale(1.08); } } |
| @keyframes estilo-sky-drift { to { transform: translate(-4%, 1%); } } |
diff --git a/sky/sky.go b/sky/sky.go| index 04b643a..b3c2f70 100644 |
| --- a/sky/sky.go |
| +++ b/sky/sky.go |
| @@ -3,13 +3,15 @@ |
| // the sign-in, the team's home, the front page — and never inside an app |
| // (AGENTS.md, decisions table, row 8). |
| // |
| -// 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. |
| +// A sky is generated from its ID (generate.go): a far haze and near clouds |
| +// of every app's colour, a fine grain, and a few white filaments. Its |
| +// colour — the haze and clouds — is painted as the placeholder |
| +// (placeholder.go), 50x20 pixels inline, so it is there in the first frame; |
| +// the grain and filaments are two static SVGs served by Handler. Handler |
| +// still serves the haze and clouds SVGs too, for pages cached before they |
| +// stopped being linked. Light and dark each get their own placeholder; |
| +// sky.css shows the one matching the page's scheme, drifts the sky slowly, |
| +// and holds it still for anyone who asks their system for less motion. |
| // No script. |
| package sky |
| @@ -36,7 +38,7 @@ func Templates() embed.FS { return templateFS } |
| // Prefix is the URL path Handler serves under. The version is the |
| // generator's: a change to what a given ID draws is a new version, so |
| // the year-long cache below never serves a stale sky. TestSkyGoldens holds |
| -// one sky's five files still; when it fails, the drawing changed — bump |
| +// one sky's six files still; when it fails, the drawing changed — bump |
| // the version here, then update the goldens. |
| const Prefix = "/static/estilo/sky/v1/" |
| @@ -46,7 +48,7 @@ const SuiteSeed = "oficina" |
| // Layer is one image of a sky. |
| type Layer struct { |
| - Name string // placeholder, haze, clouds or filaments |
| + Name string // placeholder, grain or filaments (haze and clouds are served, no longer linked) |
| Tone string // light, dark, or "" for both |
| Src string // a URL under Prefix; empty for the placeholder |
| // Inline is the placeholder's data: URI, typed so html/template |
| @@ -65,10 +67,7 @@ func For(seed string) View { |
| return View{Layers: []Layer{ |
| {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: "grain", Src: base + "grain.svg"}, |
| {Name: "filaments", Src: base + "filaments.svg"}, |
| }} |
| } |
| @@ -76,7 +75,7 @@ func For(seed string) View { |
| var idShape = regexp.MustCompile(`^[0-9a-f]{16}$`) |
| // draw returns the SVG for one layer file of sky id, or "" when the name |
| -// is not one of the five. |
| +// is not one of the six. |
| func draw(id, file string) string { |
| switch file { |
| case "haze-light.svg": |
| @@ -87,6 +86,8 @@ func draw(id, file string) string { |
| return Clouds(id, Light) |
| case "clouds-dark.svg": |
| return Clouds(id, Dark) |
| + case "grain.svg": |
| + return Grain(id) |
| case "filaments.svg": |
| return Filaments(id) |
| } |
diff --git a/sky/sky_test.go b/sky/sky_test.go| index adc92c2..e5cecef 100644 |
| --- a/sky/sky_test.go |
| +++ b/sky/sky_test.go |
| @@ -19,7 +19,7 @@ import ( |
| func layers(id string) map[string]string { |
| out := map[string]string{} |
| - for _, f := range []string{"haze-light.svg", "haze-dark.svg", "clouds-light.svg", "clouds-dark.svg", "filaments.svg"} { |
| + for _, f := range []string{"haze-light.svg", "haze-dark.svg", "clouds-light.svg", "clouds-dark.svg", "grain.svg", "filaments.svg"} { |
| out[f] = draw(id, f) |
| } |
| return out |
| @@ -74,12 +74,13 @@ func TestIDIsClosedAndOpaque(t *testing.T) { |
| } |
| } |
| -func TestHandlerServesOnlyTheFiveLayers(t *testing.T) { |
| +func TestHandlerServesOnlyTheSixLayers(t *testing.T) { |
| h := Handler() |
| id := ID("acme") |
| for path, want := range map[string]int{ |
| Prefix + id + "/clouds-dark.svg": 200, |
| Prefix + id + "/filaments.svg": 200, |
| + Prefix + id + "/grain.svg": 200, |
| Prefix + id + "/clouds.svg": 404, |
| Prefix + id + "/haze-light.svg/x": 404, |
| Prefix + "ACME0000000000000/haze-light.svg": 404, |
| @@ -110,7 +111,7 @@ func TestTemplateRendersEveryLayerDecoratively(t *testing.T) { |
| t.Fatal(err) |
| } |
| html := b.String() |
| - if !strings.Contains(html, `aria-hidden="true"`) || strings.Count(html, `alt=""`) != 7 || strings.Count(html, `estilo-sky-tone=`) != 6 || |
| + if !strings.Contains(html, `aria-hidden="true"`) || strings.Count(html, `alt=""`) != 4 || strings.Count(html, `estilo-sky-tone=`) != 2 || |
| 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) |
| } |
diff --git a/sky/testdata/v1-grain.svg b/sky/testdata/v1-grain.svg| new file mode 100644 |
| index 0000000..7ac3bd6 |
| --- /dev/null |
| +++ b/sky/testdata/v1-grain.svg |
| @@ -0,0 +1 @@ |
| +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1600 640" width="1600" height="640" preserveAspectRatio="xMidYMid slice"><defs><filter id="b" x="-50%" y="-50%" width="200%" height="200%"><feGaussianBlur stdDeviation="70"/></filter><filter id="n" filterUnits="userSpaceOnUse" x="0" y="0" width="1600" height="640"><feTurbulence type="fractalNoise" baseFrequency=".85" numOctaves="2" seed="7" stitchTiles="stitch" result="n"/><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"/><feComponentTransfer in="w" result="lit"><feFuncA type="linear" slope="0.68" intercept="-0.34"/></feComponentTransfer><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"/><feComponentTransfer in="k" result="shade"><feFuncA type="linear" slope="-0.68" intercept="0.34"/></feComponentTransfer><feMerge result="specks"><feMergeNode in="lit"/><feMergeNode in="shade"/></feMerge><feComposite in="specks" in2="SourceGraphic" operator="in"/></filter></defs><g filter="url(#n)"><g filter="url(#b)"><ellipse cx="142" cy="349" rx="214" ry="100" fill="#000" opacity="0.72" transform="rotate(22.2 142 349)"/><ellipse cx="40" cy="307" rx="121" ry="112" fill="#000" opacity="0.41" transform="rotate(-11.4 40 307)"/><ellipse cx="52" cy="312" rx="235" ry="82" fill="#000" opacity="0.63" transform="rotate(-13.4 52 312)"/><ellipse cx="154" cy="370" rx="153" ry="145" fill="#000" opacity="0.47" transform="rotate(-19.0 154 370)"/><ellipse cx="406" cy="408" rx="146" ry="97" fill="#000" opacity="0.45" transform="rotate(-12.1 406 408)"/><ellipse cx="539" cy="177" rx="93" ry="105" fill="#000" opacity="0.59" transform="rotate(-14.3 539 177)"/><ellipse cx="301" cy="241" rx="193" ry="103" fill="#000" opacity="0.53" transform="rotate(-5.2 301 241)"/><ellipse cx="605" cy="218" rx="194" ry="120" fill="#000" opacity="0.57" transform="rotate(21.2 605 218)"/><ellipse cx="1002" cy="392" rx="115" ry="101" fill="#000" opacity="0.41" transform="rotate(-6.1 1002 392)"/><ellipse cx="958" cy="328" rx="205" ry="113" fill="#000" opacity="0.41" transform="rotate(-15.5 958 328)"/><ellipse cx="961" cy="284" rx="210" ry="73" fill="#000" opacity="0.39" transform="rotate(-19.4 961 284)"/><ellipse cx="835" cy="233" rx="125" ry="66" fill="#000" opacity="0.33" transform="rotate(12.2 835 233)"/><ellipse cx="825" cy="146" rx="187" ry="122" fill="#000" opacity="0.45" transform="rotate(8.4 825 146)"/><ellipse cx="1200" cy="292" rx="180" ry="90" fill="#000" opacity="0.50" transform="rotate(-4.8 1200 292)"/><ellipse cx="1220" cy="246" rx="267" ry="162" fill="#000" opacity="0.33" transform="rotate(3.7 1220 246)"/><ellipse cx="1313" cy="114" rx="188" ry="120" fill="#000" opacity="0.32" transform="rotate(-6.0 1313 114)"/><ellipse cx="1206" cy="248" rx="197" ry="134" fill="#000" opacity="0.24" transform="rotate(-23.5 1206 248)"/><ellipse cx="1443" cy="170" rx="178" ry="66" fill="#000" opacity="0.65" transform="rotate(-2.1 1443 170)"/></g></g></svg> |
| \ No newline at end of file |