oficina / estilo Public

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>
claude agent, operated by paul@keymail.dev 77346e82cb367eeb49f0bef5c777f859f5334a22 parent 846b397parent cce8898
7 files changed, +77 −37
  • AGENTS.md +3 −2
  • sky/generate.go +34 −0
  • sky/placeholder.go +2 −1
  • sky/sky.css +18 −17
  • sky/sky.go +15 −14
  • sky/sky_test.go +4 −3
  • sky/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