oficina / estilo Public

Answer the branch review (Claude Fable 5.1)

- sky: the host no longer repositions its children (a sticky bar stays
  sticky); the sky sits at z-index -1 inside the host's own stacking
  context. Strength is read with a fallback so a host can set it. The
  canvas covers its box (xMidYMid slice) instead of stretching, so a
  phone crops rather than squashes. Goldens pin one sky's five files; a
  change there means bumping Prefix's version.
- theme: fonts are served under a versioned prefix (/fonts/v1/), since
  both are cached for a year. The contrast parse strips comments first.
  Oficina sharing Docs' hue is recorded as deliberate.
- Makefile: test-modcache removes exactly what go mod verify names
  (dirs and zips, escaped paths included).
- Prose: the day rulings are marked superseded; gate lists and chrome
  comments no longer say day.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by paul@keymail.dev 7a1a376ad2ab84368b815f7561a3fad02bde5a7b parent 683ccd5
17 files changed, +87 −30
  • AGENTS.md +7 −2
  • Makefile +10 −6
  • README.md +2 −2
  • chrome/chrome.css +1 −1
  • chrome/chrome.go +1 −1
  • sky/generate.go +9 −5
  • sky/sky.css +9 −10
  • sky/sky.go +3 −1
  • sky/sky_test.go +28 −0
  • sky/testdata/v1-clouds-dark.svg +1 −0
  • sky/testdata/v1-clouds-light.svg +1 −0
  • sky/testdata/v1-filaments.svg +1 −0
  • sky/testdata/v1-haze-dark.svg +1 −0
  • sky/testdata/v1-haze-light.svg +1 −0
  • theme/apps.go +5 −0
  • theme/fonts.go +4 −2
  • theme/theme_test.go +3 −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.003s
ok  	amadan.net/oficina/estilo.git/brand	0.002s
ok  	amadan.net/oficina/estilo.git/callui	0.004s
ok  	amadan.net/oficina/estilo.git/chrome	0.157s
?   	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.007s
ok  	amadan.net/oficina/estilo.git/homeconnect	0.006s
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.008s
ok  	amadan.net/oficina/estilo.git/savestatus	0.075s
ok  	amadan.net/oficina/estilo.git/sky	0.008s
ok  	amadan.net/oficina/estilo.git/theme	0.007s
diff --git a/AGENTS.md b/AGENTS.md
index fcc3b4a..5da65c9 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,6 +9,10 @@
> as we need to."* This settles what "as its base" above meant: estilo's
> theme **is** `day`, not derived from it or based on it — identical.
> See "Theme" below.
+>
+> **Superseded 2026-09-29.** Paul chose Calma: estilo's theme is now the
+> suite's own, not `day` (decisions table, row 8; "Theme" below). The two
+> rulings above are kept as the history of how estilo started.
The CARLOS suite's design-system variant. See `README.md` for what it is.
@@ -48,7 +52,7 @@ empty repo invites the opposite.)
| 3 | **Resolved: use released Rastrillo v0.23.0** — §Dependency | Pinning a `main` pseudo-version | This session, 2026-08-30 |
| 4 | **Estilo's own components are written in the bare-attribute grammar, and estilo defines their styling** — §Vocabulary | `class="rst-*"` (right for an app consuming rastrillo's CSS, wrong for a library shipping its own) | Paul, 2026-08-30 |
| 5 | **The colour engine stays in rastrillo's `ui`** | Moving `Pair` and allocation here | ui-guidelines-52, 2026-08-30 |
-| 6 | **Estilo's theme is `day`, unchanged — not "derived from" or "based on" it. Divergence is allowed but must be driven by a named need, not a preference** — §Theme | Restyling on the strength of "day as a base"; changing a token because it "would look better" | Paul, 2026-08-31 |
+| 6 | *(Superseded by 8.)* **Estilo's theme is `day`, unchanged — not "derived from" or "based on" it. Divergence is allowed but must be driven by a named need, not a preference** — §Theme | Restyling on the strength of "day as a base"; changing a token because it "would look better" | Paul, 2026-08-31 |
| 7 | **Private module path is `amadan.net/oficina/estilo.git`**, using authenticated Git and `GOPRIVATE` on every builder; checked consumer `go.sum` records integrity. Preserve this path when discovery support changes. | Public visibility solely to obtain Go metadata; local filesystem replaces; a later cosmetic module rename | Overnight coordinator, 2026-09-07; Claude Fable 5.1 branch review |
| 8 | **Estilo's theme is Calma, the suite's own — no longer `day`.** Every app's colour sits on one ring of equal OKLCH lightness (`theme/apps.go`); each app is themed with its own accent (`theme.ForApp`). The marks are Calma's (Fanned Oficina mark, app icons with a glow of their own hue) and the suite's ambient effect is a seeded nebula (`sky`), placed at the sign-in, the team's home and the front page, **never inside an app** — §Theme | Keeping `day` "as a base"; one hue per app chosen by eye (Mail and Files would not fit); rim light, cores and motes in the sky | Paul, 2026-09-28/29, after six rounds of design directions: *"Calma is by far the best"*; *"Grain and filaments. No cores, no rim lights. Then go."* |
@@ -89,7 +93,8 @@ 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 static layers,
+- **`sky/`** — the ambient nebula: seeded per team, three kinds of layer
+ (haze, grained clouds, filaments) in five 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/Makefile b/Makefile
index c295840..2818201 100644
--- a/Makefile
+++ b/Makefile
@@ -8,7 +8,7 @@ build:
# The gate, split into named targets so CI can report them individually
# (.amadan/ci.d calls these one at a time, each its own step). `make
-# test` runs exactly the same four things in the same order, so the
+# test` runs exactly the same five things in the same order, so the
# local gate and CI stay ONE definition rather than two lists that
# drift apart. Debugging CI is running `make test`.
#
@@ -21,7 +21,11 @@ build:
test: test-modcache test-vet test-fmt test-build test-go
# Checks the module cache holds exactly what go.sum names, and re-fetches
-# any module Go reports as modified or missing. Written down because of
+# any module Go reports as modified: it removes the exact directory or zip
+# go mod verify names (so a module path with capitals, which lives at an
+# escaped directory name, is removed too) and downloads again. Anything
+# verify reports that is not a modified file is left alone, and the final
+# verify still fails on it. Written down because of
# 2026-09-29: a CI runner's copy of rastrillo v0.23.0 had lost
# sessions/migrations/, so `go vet` failed on main and every branch alike
# for a reason no commit here could fix. The cache is a cache; a damaged
@@ -29,10 +33,10 @@ test: test-modcache test-vet test-fmt test-build test-go
test-modcache:
@out=$$(go mod verify 2>&1) && { echo "$$out"; exit 0; }; \
echo "$$out"; \
- for m in $$(echo "$$out" | awk '/: (dir has been modified|missing)/ { v = $$2; sub(/:$$/, "", v); print $$1 "@" v }'); do \
- d="$$(go env GOMODCACHE)/$$m"; \
- echo "re-fetching $$m"; \
- chmod -R u+w "$$d" 2>/dev/null; rm -rf "$$d"; \
+ for p in $$(echo "$$out" | awk -F'[()]' '/(dir|zip) has been modified \(/ { print $$2 }'); do \
+ echo "removing damaged $$p"; \
+ if [ -d "$$p" ]; then chmod -R u+w "$$p" 2>/dev/null; rm -rf "$$p"; \
+ else rm -f "$$p" "$${p%.zip}.ziphash"; fi; \
done; \
go mod download && go mod verify
diff --git a/README.md b/README.md
index 82f3efe..faa622d 100644
--- a/README.md
+++ b/README.md
@@ -43,5 +43,5 @@ shared component gallery yet.
## Building
-`make test` is the gate: `go vet`, a `gofmt` check, `go build`, then
-`go test`. `savestatus`'s own tests run under Node.
+`make test` is the gate: a module-cache check (`go mod verify`, re-fetching
+anything damaged), `go vet`, a `gofmt` check, `go build`, then `go test`. `savestatus`'s own tests run under Node.
diff --git a/chrome/chrome.css b/chrome/chrome.css
index 5670e8f..4a3fe5d 100644
--- a/chrome/chrome.css
+++ b/chrome/chrome.css
@@ -107,7 +107,7 @@
:root:has([estilo-shell~="fill"]) { scrollbar-gutter: auto; }
/* The editable title slot: the open object's title, renamed in place. The
- input is a bordered control, so it keeps day's radius; its block size is
+ input is a bordered control, so it keeps the theme's radius; its block size is
fixed so an input in the bar can never grow the bar. */
[estilo-bar-title] {
display: flex;
diff --git a/chrome/chrome.go b/chrome/chrome.go
index ef49e6a..8bbf752 100644
--- a/chrome/chrome.go
+++ b/chrome/chrome.go
@@ -260,7 +260,7 @@ func Appearance(r *http.Request) Mode {
}
// ThemeAttr is the value for <html data-theme>: "light" or "dark", and
-// empty for System, when the layout omits the attribute and day follows
+// empty for System, when the layout omits the attribute and the theme follows
// the operating system.
func ThemeAttr(m Mode) string {
switch m {
diff --git a/sky/generate.go b/sky/generate.go
index 6eb65da..b8dedf6 100644
--- a/sky/generate.go
+++ b/sky/generate.go
@@ -11,9 +11,10 @@ import (
"amadan.net/oficina/estilo.git/theme"
)
-// The sky is drawn on a 1600x640 canvas and stretched to its box
-// (preserveAspectRatio="none"): it is all soft shapes, which stretch
-// without anyone being able to tell.
+// The sky is drawn on a 1600x640 canvas and covers its box, cropping
+// rather than stretching (xMidYMid slice): on a phone a squashed canvas
+// turns clouds into streaks and filaments into zigzags, where a crop just
+// shows fewer apps' colours, at their true shapes.
const (
width = 1600
height = 640
@@ -21,7 +22,10 @@ const (
// rng is the generator for one stream of one sky. The same ID and stream
// always give the same numbers, on every machine and every Go release
-// (ChaCha8 is specified, not an implementation detail).
+// (ChaCha8 is specified, not an implementation detail). The drawing built
+// from them is stable for a given binary; across architectures a
+// coordinate can differ in its last place (Go may fuse a multiply-add on
+// arm64 and not on amd64), which a browser cannot see.
func rng(id, stream string) *rand.Rand {
sum := sha256.Sum256([]byte("estilo-sky/v1:" + id + ":" + stream))
var seed [32]byte
@@ -90,7 +94,7 @@ func palette(t Tone) (L, C float64) {
}
func svg(body, defs string) string {
- return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" preserveAspectRatio="none"><defs>%s</defs>%s</svg>`,
+ return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 %d %d" width="%d" height="%d" preserveAspectRatio="xMidYMid slice"><defs>%s</defs>%s</svg>`,
width, height, width, height, defs, body)
}
diff --git a/sky/sky.css b/sky/sky.css
index b33a007..23df17a 100644
--- a/sky/sky.css
+++ b/sky/sky.css
@@ -1,18 +1,18 @@
/* estilo-sky — Calma's ambient nebula (sky.go). Quiet by default:
--estilo-sky-strength is one number for the whole effect.
- Placement is the host's: [estilo-sky-host] is a positioned, clipped box
- whose own content sits above the sky. Move the sky within it with
- --estilo-sky-top and --estilo-sky-height (percentages of the host). */
+ Placement is the host's: [estilo-sky-host] is a positioned, clipped box,
+ and a stacking context of its own, so the sky (z-index -1) paints above
+ the host's background and beneath everything else in it, without
+ touching how the host's other children are positioned — a sticky bar
+ stays sticky. Move the sky with --estilo-sky-top and
+ --estilo-sky-height, and set --estilo-sky-strength (0.5, Quiet) on the
+ host or anywhere above it. */
[estilo-sky-host] {
position: relative;
overflow: hidden;
isolation: isolate;
}
-[estilo-sky-host] > :not([estilo-sky]) {
- position: relative;
- z-index: 1;
-}
[estilo-sky] {
position: absolute;
@@ -20,8 +20,7 @@
top: var(--estilo-sky-top, -6%);
height: var(--estilo-sky-height, 80%);
pointer-events: none;
- z-index: 0;
- --estilo-sky-strength: 0.5;
+ z-index: -1;
}
/* Each layer overhangs the box so a drift never shows an edge, and fades
@@ -33,7 +32,7 @@
width: 116%;
height: 124%;
max-width: none;
- opacity: calc(var(--estilo-sky-strength) * var(--estilo-sky-gain, 1) * var(--estilo-sky-k, 1));
+ opacity: calc(var(--estilo-sky-strength, 0.5) * var(--estilo-sky-gain, 1) * var(--estilo-sky-k, 1));
-webkit-mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%);
mask-image: linear-gradient(#0000, #000 22%, #000 58%, #0000 92%);
user-select: none;
diff --git a/sky/sky.go b/sky/sky.go
index 20d6508..0f34022 100644
--- a/sky/sky.go
+++ b/sky/sky.go
@@ -32,7 +32,9 @@ 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.
+// 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
+// the version here, then update the goldens.
const Prefix = "/static/estilo/sky/v1/"
// SuiteSeed is the sky of pages that belong to no one team: the front
diff --git a/sky/sky_test.go b/sky/sky_test.go
index c31755d..62fdd18 100644
--- a/sky/sky_test.go
+++ b/sky/sky_test.go
@@ -3,10 +3,13 @@ package sky
import (
"bytes"
"encoding/xml"
+ "flag"
"html/template"
"io"
"net/http"
"net/http/httptest"
+ "os"
+ "path/filepath"
"strings"
"testing"
)
@@ -113,3 +116,28 @@ func TestTemplateRendersEveryLayerDecoratively(t *testing.T) {
}
}
}
+
+var update = flag.Bool("update", false, "rewrite sky/testdata goldens (and bump Prefix's version)")
+
+// TestSkyGoldens pins what one ID draws. A drawing change — to generate.go,
+// or to the ring's hues in estilo's theme, which the sky is made of — fails
+// here, and a failure here means Prefix's version must change: browsers
+// hold each file for a year under its versioned URL.
+func TestSkyGoldens(t *testing.T) {
+ for file, body := range layers(ID("golden")) {
+ path := filepath.Join("testdata", strings.TrimPrefix(Prefix, "/static/estilo/sky/")[:2]+"-"+file)
+ if *update {
+ if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ continue
+ }
+ want, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("%v (run with -update after bumping Prefix)", err)
+ }
+ if string(want) != body {
+ t.Errorf("%s draws differently from its golden: bump Prefix's version, then -update", file)
+ }
+ }
+}
diff --git a/sky/testdata/v1-clouds-dark.svg b/sky/testdata/v1-clouds-dark.svg
new file mode 100644
index 0000000..26255fa
--- /dev/null
+++ b/sky/testdata/v1-clouds-dark.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="g" 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=".33 .33 .33 0 0 .33 .33 .33 0 0 .33 .33 .33 0 0 0 0 0 0 1" result="k"/><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></defs><g filter="url(#g)"><g filter="url(#b)"><ellipse cx="142" cy="349" rx="214" ry="100" fill="#84b65d" opacity="0.72" transform="rotate(22.2 142 349)"/><ellipse cx="40" cy="307" rx="121" ry="112" fill="#7ab865" opacity="0.41" transform="rotate(-11.4 40 307)"/><ellipse cx="52" cy="312" rx="235" ry="82" fill="#79b867" opacity="0.63" transform="rotate(-13.4 52 312)"/><ellipse cx="154" cy="370" rx="153" ry="145" fill="#10bdb1" opacity="0.47" transform="rotate(-19.0 154 370)"/><ellipse cx="406" cy="408" rx="146" ry="97" fill="#05bfa8" opacity="0.45" transform="rotate(-12.1 406 408)"/><ellipse cx="539" cy="177" rx="93" ry="105" fill="#25b4e2" opacity="0.59" transform="rotate(-14.3 539 177)"/><ellipse cx="301" cy="241" rx="193" ry="103" fill="#05b8d4" opacity="0.53" transform="rotate(-5.2 301 241)"/><ellipse cx="605" cy="218" rx="194" ry="120" fill="#83a0f6" opacity="0.57" transform="rotate(21.2 605 218)"/><ellipse cx="1002" cy="392" rx="115" ry="101" fill="#b390e9" opacity="0.41" transform="rotate(-6.1 1002 392)"/><ellipse cx="958" cy="328" rx="205" ry="113" fill="#b68fe7" opacity="0.41" transform="rotate(-15.5 958 328)"/><ellipse cx="961" cy="284" rx="210" ry="73" fill="#af91eb" opacity="0.39" transform="rotate(-19.4 961 284)"/><ellipse cx="835" cy="233" rx="125" ry="66" fill="#dc82ba" opacity="0.33" transform="rotate(12.2 835 233)"/><ellipse cx="825" cy="146" rx="187" ry="122" fill="#d883c2" opacity="0.45" transform="rotate(8.4 825 146)"/><ellipse cx="1200" cy="292" rx="180" ry="90" fill="#eb8278" opacity="0.50" transform="rotate(-4.8 1200 292)"/><ellipse cx="1220" cy="246" rx="267" ry="162" fill="#e48c52" opacity="0.33" transform="rotate(3.7 1220 246)"/><ellipse cx="1313" cy="114" rx="188" ry="120" fill="#e28d4d" opacity="0.32" transform="rotate(-6.0 1313 114)"/><ellipse cx="1206" cy="248" rx="197" ry="134" fill="#df9047" opacity="0.24" transform="rotate(-23.5 1206 248)"/><ellipse cx="1443" cy="170" rx="178" ry="66" fill="#ca9d33" opacity="0.65" transform="rotate(-2.1 1443 170)"/></g></g></svg>
\ No newline at end of file
diff --git a/sky/testdata/v1-clouds-light.svg b/sky/testdata/v1-clouds-light.svg
new file mode 100644
index 0000000..003dcdc
--- /dev/null
+++ b/sky/testdata/v1-clouds-light.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="g" 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=".33 .33 .33 0 0 .33 .33 .33 0 0 .33 .33 .33 0 0 0 0 0 0 1" result="k"/><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></defs><g filter="url(#g)"><g filter="url(#b)"><ellipse cx="142" cy="349" rx="214" ry="100" fill="#9fce7d" opacity="0.72" transform="rotate(22.2 142 349)"/><ellipse cx="40" cy="307" rx="121" ry="112" fill="#97d084" opacity="0.41" transform="rotate(-11.4 40 307)"/><ellipse cx="52" cy="312" rx="235" ry="82" fill="#95d185" opacity="0.63" transform="rotate(-13.4 52 312)"/><ellipse cx="154" cy="370" rx="153" ry="145" fill="#4ad6ca" opacity="0.47" transform="rotate(-19.0 154 370)"/><ellipse cx="406" cy="408" rx="146" ry="97" fill="#52d7c1" opacity="0.45" transform="rotate(-12.1 406 408)"/><ellipse cx="539" cy="177" rx="93" ry="105" fill="#5acdf8" opacity="0.59" transform="rotate(-14.3 539 177)"/><ellipse cx="301" cy="241" rx="193" ry="103" fill="#4bd1ed" opacity="0.53" transform="rotate(-5.2 301 241)"/><ellipse cx="605" cy="218" rx="194" ry="120" fill="#a3bbff" opacity="0.57" transform="rotate(21.2 605 218)"/><ellipse cx="1002" cy="392" rx="115" ry="101" fill="#cbabff" opacity="0.41" transform="rotate(-6.1 1002 392)"/><ellipse cx="958" cy="328" rx="205" ry="113" fill="#ceaafd" opacity="0.41" transform="rotate(-15.5 958 328)"/><ellipse cx="961" cy="284" rx="210" ry="73" fill="#c7adff" opacity="0.39" transform="rotate(-19.4 961 284)"/><ellipse cx="835" cy="233" rx="125" ry="66" fill="#f49ed2" opacity="0.33" transform="rotate(12.2 835 233)"/><ellipse cx="825" cy="146" rx="187" ry="122" fill="#f09fd9" opacity="0.45" transform="rotate(8.4 825 146)"/><ellipse cx="1200" cy="292" rx="180" ry="90" fill="#ffa196" opacity="0.50" transform="rotate(-4.8 1200 292)"/><ellipse cx="1220" cy="246" rx="267" ry="162" fill="#fba773" opacity="0.33" transform="rotate(3.7 1220 246)"/><ellipse cx="1313" cy="114" rx="188" ry="120" fill="#f9a96f" opacity="0.32" transform="rotate(-6.0 1313 114)"/><ellipse cx="1206" cy="248" rx="197" ry="134" fill="#f6ab6a" opacity="0.24" transform="rotate(-23.5 1206 248)"/><ellipse cx="1443" cy="170" rx="178" ry="66" fill="#e1b75c" opacity="0.65" transform="rotate(-2.1 1443 170)"/></g></g></svg>
\ No newline at end of file
diff --git a/sky/testdata/v1-filaments.svg b/sky/testdata/v1-filaments.svg
new file mode 100644
index 0000000..1716e51
--- /dev/null
+++ b/sky/testdata/v1-filaments.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><linearGradient id="f0" gradientUnits="userSpaceOnUse" x1="301" x2="1461" y1="0" y2="0"><stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="f1" gradientUnits="userSpaceOnUse" x1="61" x2="1690" y1="0" y2="0"><stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="f2" gradientUnits="userSpaceOnUse" x1="409" x2="1443" y1="0" y2="0"><stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient><linearGradient id="f3" gradientUnits="userSpaceOnUse" x1="379" x2="1065" y1="0" y2="0"><stop offset=".05" stop-color="#fff" stop-opacity="0"/><stop offset=".45" stop-color="#fff"/><stop offset=".55" stop-color="#fff"/><stop offset=".95" stop-color="#fff" stop-opacity="0"/></linearGradient><filter id="b" x="-5%" y="-20%" width="110%" height="140%"><feGaussianBlur stdDeviation="2.4"/></filter></defs><g fill="none" filter="url(#b)"><polyline points="301,232.2 321,233.3 341,234.3 361,235.1 381,235.8 401,236.3 421,236.7 441,236.9 461,237.0 481,237.0 501,236.8 521,236.4 541,235.9 561,235.3 581,234.6 601,233.7 621,232.7 641,231.6 661,230.3 681,228.9 701,227.5 721,225.9 741,224.3 761,222.5 781,220.7 801,218.8 821,216.8 841,214.8 861,212.7 881,210.6 901,208.4 921,206.2 941,203.9 961,201.7 981,199.4 1001,197.2 1021,194.9 1041,192.7 1061,190.4 1081,188.2 1101,186.1 1121,183.9 1141,181.8 1161,179.8 1181,177.8 1201,175.8 1221,173.9 1241,172.1 1261,170.4 1281,168.7 1301,167.1 1321,165.7 1341,164.3 1361,162.9 1381,161.7 1401,160.6 1421,159.6 1441,158.7" stroke="url(#f0)" stroke-width="1.2" opacity="0.45"/><polyline points="301,238.3 321,238.9 341,239.5 361,239.8 381,240.1 401,240.2 421,240.1 441,240.0 461,239.7 481,239.3 501,238.8 521,238.1 541,237.4 561,236.6 581,235.7 601,234.6 621,233.5 641,232.4 661,231.1 681,229.8 701,228.5 721,227.0 741,225.5 761,224.0 781,222.5 801,220.8 821,219.2 841,217.5 861,215.8 881,214.1 901,212.3 921,210.6 941,208.8 961,207.0 981,205.1 1001,203.3 1021,201.5 1041,199.6 1061,197.7 1081,195.9 1101,194.0 1121,192.2 1141,190.3 1161,188.5 1181,186.6 1201,184.8 1221,183.0 1241,181.2 1261,179.5 1281,177.8 1301,176.1 1321,174.4 1341,172.9 1361,171.3 1381,169.8 1401,168.4 1421,167.1 1441,165.8" stroke="url(#f0)" stroke-width="1.7" opacity="0.50"/><polyline points="61,409.6 81,409.0 101,408.2 121,407.4 141,406.3 161,405.2 181,403.9 201,402.6 221,401.1 241,399.5 261,397.8 281,396.1 301,394.2 321,392.3 341,390.3 361,388.2 381,386.0 401,383.8 421,381.5 441,379.2 461,376.8 481,374.4 501,372.0 521,369.5 541,367.0 561,364.5 581,362.0 601,359.4 621,356.9 641,354.4 661,351.9 681,349.3 701,346.9 721,344.4 741,342.0 761,339.6 781,337.2 801,334.9 821,332.7 841,330.5 861,328.3 881,326.3 901,324.3 921,322.4 941,320.6 961,318.8 981,317.2 1001,315.7 1021,314.3 1041,313.0 1061,311.8 1081,310.7 1101,309.8 1121,308.9 1141,308.2 1161,307.7 1181,307.3 1201,307.0 1221,306.9 1241,306.9 1261,307.1 1281,307.4 1301,307.8 1321,308.4 1341,309.2 1361,310.1 1381,311.2 1401,312.3 1421,313.7 1441,315.2 1461,316.8 1481,318.5 1501,320.4 1521,322.3 1541,324.4 1561,326.7 1581,329.0 1601,331.4 1621,333.9 1641,336.4 1661,339.1 1681,341.8" stroke="url(#f1)" stroke-width="0.8" opacity="0.25"/><polyline points="61,430.7 81,430.4 101,429.9 121,429.1 141,428.1 161,427.0 181,425.6 201,424.0 221,422.2 241,420.2 261,418.1 281,415.8 301,413.3 321,410.7 341,407.9 361,405.0 381,402.1 401,399.0 421,395.9 441,392.7 461,389.4 481,386.2 501,382.9 521,379.6 541,376.3 561,373.1 581,369.9 601,366.7 621,363.6 641,360.6 661,357.7 681,354.9 701,352.2 721,349.6 741,347.1 761,344.8 781,342.6 801,340.5 821,338.6 841,336.8 861,335.2 881,333.7 901,332.3 921,331.1 941,330.0 961,329.1 981,328.3 1001,327.6 1021,327.0 1041,326.6 1061,326.3 1081,326.1 1101,326.0 1121,326.0 1141,326.1 1161,326.2 1181,326.5 1201,326.8 1221,327.2 1241,327.7 1261,328.2 1281,328.8 1301,329.5 1321,330.2 1341,331.0 1361,331.8 1381,332.7 1401,333.7 1421,334.7 1441,335.7 1461,336.8 1481,338.0 1501,339.2 1521,340.5 1541,341.9 1561,343.3 1581,344.8 1601,346.3 1621,347.9 1641,349.6 1661,351.4 1681,353.3" stroke="url(#f1)" stroke-width="1.7" opacity="0.35"/><polyline points="409,279.1 429,277.2 449,275.4 469,273.4 489,271.4 509,269.4 529,267.3 549,265.1 569,262.9 589,260.7 609,258.5 629,256.2 649,253.9 669,251.6 689,249.3 709,247.1 729,244.8 749,242.6 769,240.3 789,238.1 809,236.0 829,233.9 849,231.8 869,229.8 889,227.8 909,225.9 929,224.1 949,222.3 969,220.7 989,219.1 1009,217.5 1029,216.1 1049,214.8 1069,213.5 1089,212.4 1109,211.3 1129,210.4 1149,209.5 1169,208.8 1189,208.2 1209,207.6 1229,207.2 1249,206.9 1269,206.7 1289,206.6 1309,206.6 1329,206.7 1349,206.9 1369,207.2 1389,207.7 1409,208.2 1429,208.8" stroke="url(#f2)" stroke-width="1.4" opacity="0.29"/><polyline points="409,285.9 429,283.6 449,281.1 469,278.7 489,276.2 509,273.7 529,271.1 549,268.6 569,266.0 589,263.5 609,261.0 629,258.6 649,256.2 669,253.8 689,251.5 709,249.3 729,247.1 749,245.0 769,243.0 789,241.1 809,239.2 829,237.5 849,235.8 869,234.3 889,232.8 909,231.4 929,230.1 949,228.9 969,227.8 989,226.7 1009,225.8 1029,224.9 1049,224.1 1069,223.4 1089,222.7 1109,222.1 1129,221.6 1149,221.2 1169,220.7 1189,220.4 1209,220.1 1229,219.9 1249,219.7 1269,219.5 1289,219.4 1309,219.4 1329,219.4 1349,219.4 1369,219.5 1389,219.7 1409,219.9 1429,220.2" stroke="url(#f2)" stroke-width="1.7" opacity="0.32"/><polyline points="409,286.5 429,284.0 449,281.6 469,279.3 489,277.1 509,275.0 529,273.0 549,271.1 569,269.3 589,267.6 609,266.1 629,264.6 649,263.3 669,262.1 689,261.0 709,260.0 729,259.0 749,258.2 769,257.4 789,256.7 809,256.0 829,255.4 849,254.8 869,254.2 889,253.6 909,253.0 929,252.4 949,251.8 969,251.1 989,250.4 1009,249.7 1029,248.9 1049,248.1 1069,247.2 1089,246.2 1109,245.2 1129,244.1 1149,243.0 1169,241.8 1189,240.6 1209,239.4 1229,238.1 1249,236.8 1269,235.5 1289,234.2 1309,233.0 1329,231.7 1349,230.6 1369,229.4 1389,228.4 1409,227.4 1429,226.6" stroke="url(#f2)" stroke-width="1.4" opacity="0.35"/><polyline points="379,364.0 399,362.5 419,360.9 439,359.4 459,357.9 479,356.3 499,354.8 519,353.3 539,351.9 559,350.4 579,349.0 599,347.6 619,346.3 639,344.9 659,343.7 679,342.5 699,341.3 719,340.2 739,339.1 759,338.2 779,337.2 799,336.4 819,335.6 839,334.9 859,334.2 879,333.6 899,333.1 919,332.7 939,332.4 959,332.1 979,331.9 999,331.8 1019,331.8 1039,331.9 1059,332.1" stroke="url(#f3)" stroke-width="1.4" opacity="0.39"/><polyline points="379,373.4 399,371.4 419,369.4 439,367.4 459,365.4 479,363.4 499,361.4 519,359.4 539,357.5 559,355.6 579,353.8 599,352.1 619,350.4 639,348.8 659,347.3 679,345.8 699,344.5 719,343.3 739,342.2 759,341.2 779,340.3 799,339.5 819,338.9 839,338.3 859,337.9 879,337.6 899,337.4 919,337.4 939,337.4 959,337.6 979,337.8 999,338.2 1019,338.7 1039,339.2 1059,339.9" stroke="url(#f3)" stroke-width="1.3" opacity="0.20"/></g></svg>
\ No newline at end of file
diff --git a/sky/testdata/v1-haze-dark.svg b/sky/testdata/v1-haze-dark.svg
new file mode 100644
index 0000000..5214fcf
--- /dev/null
+++ b/sky/testdata/v1-haze-dark.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="110"/></filter></defs><g filter="url(#b)"><ellipse cx="124" cy="267" rx="435" ry="202" fill="#71b262" opacity="0.32"/><ellipse cx="272" cy="336" rx="466" ry="215" fill="#08b7a1" opacity="0.22"/><ellipse cx="417" cy="308" rx="366" ry="147" fill="#0cb0d4" opacity="0.42"/><ellipse cx="704" cy="255" rx="390" ry="208" fill="#719def" opacity="0.38"/><ellipse cx="827" cy="320" rx="430" ry="167" fill="#ae8ae1" opacity="0.24"/><ellipse cx="995" cy="226" rx="365" ry="156" fill="#d57cb5" opacity="0.18"/><ellipse cx="1069" cy="373" rx="448" ry="164" fill="#e47b7c" opacity="0.19"/><ellipse cx="1414" cy="182" rx="421" ry="212" fill="#db8747" opacity="0.19"/><ellipse cx="1535" cy="107" rx="366" ry="168" fill="#be9a2a" opacity="0.30"/></g></svg>
\ No newline at end of file
diff --git a/sky/testdata/v1-haze-light.svg b/sky/testdata/v1-haze-light.svg
new file mode 100644
index 0000000..5de2c59
--- /dev/null
+++ b/sky/testdata/v1-haze-light.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="110"/></filter></defs><g filter="url(#b)"><ellipse cx="124" cy="267" rx="435" ry="202" fill="#8dca80" opacity="0.32"/><ellipse cx="272" cy="336" rx="466" ry="215" fill="#4ad0ba" opacity="0.22"/><ellipse cx="417" cy="308" rx="366" ry="147" fill="#4ac9ec" opacity="0.42"/><ellipse cx="704" cy="255" rx="390" ry="208" fill="#91b7fe" opacity="0.38"/><ellipse cx="827" cy="320" rx="430" ry="167" fill="#c6a5f7" opacity="0.24"/><ellipse cx="995" cy="226" rx="365" ry="156" fill="#ec98cd" opacity="0.18"/><ellipse cx="1069" cy="373" rx="448" ry="164" fill="#fb9797" opacity="0.19"/><ellipse cx="1414" cy="182" rx="421" ry="212" fill="#f2a269" opacity="0.19"/><ellipse cx="1535" cy="107" rx="366" ry="168" fill="#d5b455" opacity="0.30"/></g></svg>
\ No newline at end of file
diff --git a/theme/apps.go b/theme/apps.go
index 1768630..57ba51e 100644
--- a/theme/apps.go
+++ b/theme/apps.go
@@ -19,6 +19,11 @@ type App struct {
// with the widest gap across the yellow-greens, which go muddy at this
// lightness). Paul, 2026-09-29, with Mail and Files still to come.
//
+// Oficina and Docs share 262 on purpose: the suite's own accent, used
+// where no app is in charge (sign-in, the team's home), is the ring's blue,
+// and Docs is where that blue started. Oficina's mark is every hue, not
+// this one.
+//
// This table is the one source of the suite's colours: the accent blocks
// below, and brand/icons.json's hues (brand_test checks they agree).
var apps = []App{
diff --git a/theme/fonts.go b/theme/fonts.go
index 96015a7..c2fd6f3 100644
--- a/theme/fonts.go
+++ b/theme/fonts.go
@@ -14,8 +14,10 @@ import (
//go:embed fonts/onest-latin.woff2
var fontFS embed.FS
-// FontPrefix is the URL path FontHandler serves under.
-const FontPrefix = "/static/estilo/fonts/"
+// FontPrefix is the URL path FontHandler serves under. Its version moves
+// whenever either file's bytes do (a new subset, a newer Onest, a changed
+// unicode-range): browsers hold both for a year under this path.
+const FontPrefix = "/static/estilo/fonts/v1/"
// fontCSS declares Onest for the Latin range; a character outside it
// falls through to the system face in --rst-font, glyph by glyph.
diff --git a/theme/theme_test.go b/theme/theme_test.go
index 3d74ec2..f80aace 100644
--- a/theme/theme_test.go
+++ b/theme/theme_test.go
@@ -91,6 +91,9 @@ func splitLightDark(v string) (light, dark string, ok bool) {
// would cascade them.
func tokensIn(css string) map[string]map[string]string {
out := map[string]map[string]string{"light": {}, "dark": {}}
+ // Comments first: a comment quoting a declaration ("was --rst-accent:
+ // #123;") must never win the cascade the way it would in this parse.
+ css = regexp.MustCompile(`(?s)/\*.*?\*/`).ReplaceAllString(css, "")
for name, v := range parseTokens(css) {
if l, d, ok := splitLightDark(v); ok {
out["light"][name], out["dark"][name] = l, d