sky: grain on a layer of its own, so the placeholder change can't take it
Paul chose grain for the sky. When the SVG clouds were re-weighted to a quarter over the new placeholder, their grain went down with them — impeccable's detector flagged the layers as near-invisible, and a zoomed look confirmed the grain was gone. Grain is now its own layer: faint light and dark specks from the same noise, kept to the clouds' shape and drawn with ordinary blending, so it averages to no change in brightness (an overlay blend lifted the dark sky wherever the sky beneath was thin). Measured against the approved sky: 2.7/255 light, 9.5/255 dark — as before, with the grain restored. The five existing drawings are unchanged (goldens), so no version bump; grain.svg is a new file.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+43
−3
sky/generate.go+33 −0sky/sky.css+4 −1sky/sky.go+3 −0sky/sky_test.go+2 −2sky/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.005s ok amadan.net/oficina/estilo.git/busy 0.003s ok amadan.net/oficina/estilo.git/callui 0.005s ok amadan.net/oficina/estilo.git/chrome 0.216s ? 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.007s ok amadan.net/oficina/estilo.git/homeconnect 0.007s 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.010s ok amadan.net/oficina/estilo.git/savestatus 0.102s ok amadan.net/oficina/estilo.git/sky 0.025s ok amadan.net/oficina/estilo.git/theme 0.007s
diff --git a/sky/generate.go b/sky/generate.go| index c3a761f..43697ec 100644 |
| --- a/sky/generate.go |
| +++ b/sky/generate.go |
| @@ -162,6 +162,39 @@ 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 averages to no change in brightness over any |
| +// backdrop — an overlay blend would show its grey wherever the sky beneath |
| +// is thin, and lift 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. The clouds layer |
| +// now sits over the placeholder at a quarter of its weight, which took its |
| +// grain down with it (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/sky.css b/sky/sky.css| index 9eb7c1e..de6d33b 100644 |
| --- a/sky/sky.css |
| +++ b/sky/sky.css |
| @@ -48,6 +48,9 @@ |
| [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; } |
| +/* The clouds' grain, on its own layer so the re-weighting above does not |
| + take it away: faint light and dark specks that average to nothing. */ |
| +[estilo-sky-layer="grain"] { --estilo-sky-k: 1; } |
| [estilo-sky-layer="filaments"] { --estilo-sky-k: 1.3; } |
| /* One pair of haze and clouds per scheme; show the one that matches. |
| @@ -66,7 +69,7 @@ |
| /* Drifting: the clouds and the haze breathe past each other, 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="clouds"], [estilo-sky-layer="grain"] { 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="filaments"] { animation: estilo-sky-drift 80s ease-in-out infinite alternate; } |
| } |
diff --git a/sky/sky.go b/sky/sky.go| index 04b643a..db8b749 100644 |
| --- a/sky/sky.go |
| +++ b/sky/sky.go |
| @@ -69,6 +69,7 @@ func For(seed string) View { |
| {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"}, |
| }} |
| } |
| @@ -87,6 +88,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..b388c54 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 |
| @@ -110,7 +110,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=""`) != 8 || 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) |
| } |
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 |