Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
127 commits
Select commit Hold shift + click to select a range
0f752d9
docs: add MARS.md, the standing brief for the second body
Alchez Aug 3, 2026
c104a54
docs: verify the Mars licensing — no ESA obligation
Alchez Aug 3, 2026
8fc8ae1
refactor(web): derive dev tile-archive paths from the work tree inste…
Alchez Aug 3, 2026
f0cf216
feat(web): add the tile address grammar — body, layer and a content-d…
Alchez Aug 3, 2026
721950b
feat(web): route both tile grammars through one resolver, in dev and …
Alchez Aug 3, 2026
78612a9
feat(web): the client asks the addressed shape, and the traffic panel…
Alchez Aug 3, 2026
31b0616
refactor(web): the registry is the only place an archive is named, an…
Alchez Aug 3, 2026
588b496
feat(web): admit Mars to the registry, and derive the directory cache…
Alchez Aug 3, 2026
374997f
feat(pipeline): the registry learns the difference between the grid's…
Alchez Aug 3, 2026
2405be1
feat(pipeline): admit Mars to the body registry, on Earth's spheres a…
Alchez Aug 3, 2026
a541099
refactor(pipeline): the grid resolution and the cut ceiling come from…
Alchez Aug 3, 2026
c0b33b5
refactor(pipeline): the vertical exaggeration is the body's, and the …
Alchez Aug 3, 2026
64f6606
refactor(pipeline): the shade pass measures in ground metres, on the …
Alchez Aug 3, 2026
530d03b
feat(pipeline): which surface layers a body has is the body's answer,…
Alchez Aug 3, 2026
a522dfd
refactor(pipeline): the caps ask the body before the disk, and measur…
Alchez Aug 3, 2026
6f4dc9e
feat(pipeline): the Mars DEM acquisition recipe, pinned to the editio…
Alchez Aug 3, 2026
6758662
feat(pipeline): the planet stage declares what it emitted, so absence…
Alchez Aug 4, 2026
33635c0
docs(web): name the technique the sabotage harness implements
Alchez Aug 4, 2026
dba684f
feat(pipeline): Mars enters by declaring its blend EPSG:4326, and dec…
Alchez Aug 4, 2026
cfb52eb
feat(pipeline): the packer asks which planet's pyramid it is packing
Alchez Aug 4, 2026
c96d16e
refactor(pipeline): one home for the vectors seven modules share, and…
Alchez Aug 4, 2026
66aefe3
refactor(pipeline): the hero pipeline's stages address the store, not…
Alchez Aug 4, 2026
72c10a4
fix(tests): the layer gates are proven against a source the test make…
Alchez Aug 4, 2026
d59bf98
fix(tests): the store probe reads the segments the repo chose, not th…
Alchez Aug 4, 2026
061244c
build(lint): ruff joins pyright at the gate, and the dead imports go …
Alchez Aug 4, 2026
5fb99b5
build(lint): the rules that catch defects, and the one open file they…
Alchez Aug 4, 2026
bffc674
build(lint): the last of the ruff sweep — modernisation, the decorati…
Alchez Aug 4, 2026
24464e7
build(lint): oxlint joins the web gate, and the four findings that ne…
Alchez Aug 4, 2026
c7bc4ac
build(lint): the findings that needed a decision, and the fallback no…
Alchez Aug 4, 2026
8a689be
build(lint): the two mechanical sweeps, and the suppressions that wer…
Alchez Aug 4, 2026
9739d89
build(worker): the tile Worker compiles the shared modules at the sit…
Alchez Aug 4, 2026
2075a2f
fix(pipeline): every outbound request carries an identity, and one sc…
Alchez Aug 4, 2026
93a90c6
feat(pipeline): a body says whether it publishes polar caps, and Mars…
Alchez Aug 4, 2026
784d196
feat(web): Mars publishes a relief pyramid, and the zoom ceiling beco…
Alchez Aug 4, 2026
064876d
refactor(web): one archive-header check against the registry, replaci…
Alchez Aug 4, 2026
3d49f45
refactor(web): the relief sources leave the page and take their zoom …
Alchez Aug 4, 2026
8bce25d
feat(web): a body declares whether it ships caps, borders and heroes,…
Alchez Aug 4, 2026
07c9960
refactor(web): the globe becomes a component, and a page decides only…
Alchez Aug 4, 2026
5849bc7
feat(web): the globe asks the registry what its body draws, and ?bare…
Alchez Aug 4, 2026
0e25c1d
docs(web): the terrain and countries ceilings say they are Earth's, a…
Alchez Aug 4, 2026
34af480
feat(web): the tier ladder routes to the body you are on, and Mars ge…
Alchez Aug 4, 2026
8ce8518
fix(tooling): a mutation left in a single-file root is found, so --re…
Alchez Aug 4, 2026
45b751b
fix(web): the scale ruler measures on the body's own sphere, not on E…
Alchez Aug 4, 2026
8d9822e
feat(web): Mars gets the globe, and every guard that named one page l…
Alchez Aug 5, 2026
2fd07b7
docs: the site's renders go CC BY-SA 4.0, and the licence stops being…
Alchez Aug 5, 2026
30532f1
refactor(pipeline): the ramp is a per-body input, and the two consume…
Alchez Aug 5, 2026
94d7295
test(pipeline): a third module cannot reach around the look to Earth'…
Alchez Aug 5, 2026
02c5378
feat(web): the globe's air belongs to the body, and a body may declar…
Alchez Aug 5, 2026
5a97c65
feat(pipeline): the pass harness sizes its memory cap from the body, …
Alchez Aug 5, 2026
6afb561
feat(pipeline): Mars draws at 20x, judged on the sphere rather than d…
Alchez Aug 5, 2026
cdeb9b4
refactor(pipeline): a colour ramp runs between two ends, and only Ear…
Alchez Aug 5, 2026
b1c0fff
feat(pipeline): Mars draws its own colours, and the About page says w…
Alchez Aug 5, 2026
a55fc84
docs(pipeline): a look loop judges the composite, and the registry sa…
Alchez Aug 5, 2026
bab13fa
fix(web): a body fetches its own polar-cap manifest, and the served p…
Alchez Aug 5, 2026
21baa6a
test: a capless body is synthetic now, so no guard borrows its other …
Alchez Aug 5, 2026
983febe
feat(pipeline): Mars renders its own polar caps, and the memory cap g…
Alchez Aug 5, 2026
d852412
docs: the Mars brief catches up to a ratified 20× and a pyramid that …
Alchez Aug 5, 2026
7338fe6
test: the ramp-origin guard states its raster set instead of reading …
Alchez Aug 5, 2026
6fb3420
fix(pipeline): the served root is read at call time, so a redirected …
Alchez Aug 6, 2026
c39a566
refactor(pipeline): the perennial-ice layer stops being named for Ear…
Alchez Aug 6, 2026
f418c95
refactor(pipeline): the cap's perennial ice becomes a per-body produc…
Alchez Aug 6, 2026
dc86b34
docs(mars): the PSA states no licence, so the brief stops calling the…
Alchez Aug 7, 2026
13457b5
docs(pipeline): the composite's freshness recipe names the constants …
Alchez Aug 7, 2026
e676d6b
feat(web): Mars's chrome comes from its own ramp, and Earth's accent …
Alchez Aug 7, 2026
6fefb44
fix(web): Mars keeps its eye-ratified accents, and the accent guard c…
Alchez Aug 7, 2026
39b0409
feat(pipeline): the cap texture disc answers to the mesh it feeds, an…
Alchez Aug 7, 2026
62355c0
feat(pipeline): Mars's ice extent gets an acquirer, and its freshness…
Alchez Aug 7, 2026
aa20ac8
refactor(pipeline): the atomic download gets its own home, and its se…
Alchez Aug 7, 2026
d7bedb9
refactor(pipeline): the composite recipe records only what a body's o…
Alchez Aug 7, 2026
3ed9fed
docs(process): the cap is sized by the caps alone, and the memory ins…
Alchez Aug 7, 2026
08befad
refactor(pipeline): freshness stops living inside the planet shader
Alchez Aug 7, 2026
ad0f6c8
fix(pipeline): terrain-RGB stops knowing the sphere the projection mo…
Alchez Aug 7, 2026
f71adf9
docs(pipeline): one concept gets one home, and an instrument to find …
Alchez Aug 7, 2026
9c92b59
refactor(pipeline): the layer vocabulary gets a table, and leaves the…
Alchez Aug 7, 2026
38d75eb
refactor(pipeline): surface layers get per-body producers, and the di…
Alchez Aug 7, 2026
83fe526
feat(pipeline): a vector reaches its grid through one owner, and a bu…
Alchez Aug 7, 2026
ce78154
feat(pipeline): OMEGA gains a recorded origin, and its label answers …
Alchez Aug 7, 2026
a0b8ef2
feat(pipeline): Mars's ice gains its albedo grading, and its feather …
Alchez Aug 7, 2026
f6b200f
feat(mars): the polar ice gets an acquired field to grade against, an…
Alchez Aug 8, 2026
584e6f6
refactor: the only reproducer of Mars's ice levels becomes a tracked …
Alchez Aug 8, 2026
30336d7
feat(pipeline): Mars registers its ice at both tiers, and a build-tim…
Alchez Aug 8, 2026
c8bc3a4
feat(pipeline): the ice white becomes the layer's own, and Mars stops…
Alchez Aug 8, 2026
d669c62
test: five mutation cases for the ice white, including the union bran…
Alchez Aug 8, 2026
b27611a
feat(scripts): the ice white gets its oracle, and it tests the ratio …
Alchez Aug 8, 2026
d7d7c17
feat(web): Mars credits its ice sources, and only the publisher that …
Alchez Aug 8, 2026
0be71ce
feat(tiles): the Martian poles are smoothed where MOLA's orbit never …
Alchez Aug 8, 2026
bd70421
feat(tiles): Mars cuts to z7, and the ceiling's reason stops being a …
Alchez Aug 9, 2026
423d54f
fix(tiles): z7 reaches the browser, and three guards plus a pixel cou…
Alchez Aug 9, 2026
175c581
docs(mars): the z7 record with per-row provenance, five numbered phas…
Alchez Aug 9, 2026
7771011
docs(tiles): the ceiling stops being restated in prose, and the brows…
Alchez Aug 9, 2026
0845fba
feat(mars): the gazetteer acquirer, pinned to its members and carryin…
Alchez Aug 9, 2026
51b72c7
feat(mars): the named features fold onto one meridian window and cut …
Alchez Aug 9, 2026
1d3c826
fix(tiles): a Mars zoom drift stops sending the reader to Earth's con…
Alchez Aug 9, 2026
9bbb7ba
refactor(tiles): the MVT transport contract leaves Earth's country mo…
Alchez Aug 9, 2026
b3f4429
refactor(tiles): an archive's inner layer names get a per-body owner,…
Alchez Aug 9, 2026
42d6e45
refactor(tiles): the layer segment names a role, and the retired word…
Alchez Aug 9, 2026
8e573e5
feat(mars): the named features reach the globe as interaction data, a…
Alchez Aug 9, 2026
7c2a7fb
feat(mars): a hover or a tap names the feature that reads at this sca…
Alchez Aug 10, 2026
1c28e53
refactor(globe): the detail card takes content rather than a country,…
Alchez Aug 10, 2026
6db8887
feat(mars): the named features become a list the frontend holds, and …
Alchez Aug 10, 2026
77af378
feat(mars): a feature's framing zoom is the scale at which it reads, …
Alchez Aug 10, 2026
7350324
feat(mars): a pick flies to the feature and the card names it, as Ear…
Alchez Aug 10, 2026
4d748a8
feat(mars): the card sends a reader to the entry it is quoting
Alchez Aug 10, 2026
b4ee8e3
feat(mars): the lite route becomes the gazetteer
Alchez Aug 10, 2026
b38a8c3
feat(mars): a typed query reaches a feature, and the published spelli…
Alchez Aug 11, 2026
661083f
feat(mars): the globe takes a typed query, and the card it opens is t…
Alchez Aug 11, 2026
59ec4f1
fix(globe): the frame group leads the rail, the chrome shares one ins…
Alchez Aug 11, 2026
7d77f7b
docs: every stale sentence predates Mars's second product, and the st…
Alchez Aug 11, 2026
f7610c3
refactor(globe): the DEM source declares the depth it is handed, not …
Alchez Aug 11, 2026
79f1ab4
refactor(tiles): the elevation cut descends from the body's own maste…
Alchez Aug 11, 2026
d9a7e74
feat(tiles): Mars publishes terrain, and the last unpublished pair wa…
Alchez Aug 11, 2026
097b71b
fix(tiles): the elevation encode stops depending on latitude, so the …
Alchez Aug 11, 2026
caacd73
fix(pipeline): the antimeridian column is filled from across the seam…
Alchez Aug 11, 2026
b2d1515
fix(pipeline): a near-meridian edge is a cut only when it has a twin …
Alchez Aug 11, 2026
0ca837e
feat(globe): mars is ratified for terrain, and the guards that borrow…
Alchez Aug 11, 2026
3841eac
chore(tiles): mars ships under new keys, and the version to bump is a…
Alchez Aug 11, 2026
0f4e631
fix(pipeline): a derived layer records the recipe it was derived unde…
Alchez Aug 11, 2026
ca548ff
chore(tiles): earth ships under new keys after the re-cut, and relief…
Alchez Aug 11, 2026
656f6bf
chore(web): the wrangler cooldown exclusions go, now that both releas…
Alchez Aug 11, 2026
eab438a
chore(pipeline): rasterio 1.5.1 silences the numpy deprecation, and i…
Alchez Aug 11, 2026
bfa01a6
docs(deploy): the numbered steps follow the order the prose mandates,…
Alchez Aug 11, 2026
52d480c
chore(web): maplibre 6.3.0, with the vendored patch re-derived becaus…
Alchez Aug 12, 2026
ab0ba8d
feat(perf): a capture takes its arm from the document, and the map se…
Alchez Aug 12, 2026
f269213
feat(perf): the arm becomes a URL flag, and the flag reports which so…
Alchez Aug 12, 2026
127024d
perf(globe): tile images arrive as bitmaps
Alchez Aug 12, 2026
c5392b6
feat(perf): the overlay measures over time rather than at an instant,…
Alchez Aug 13, 2026
03f31f8
docs(mars): the geologic units are closed on their own schema, and ba…
Alchez Aug 13, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,13 @@ jobs:
- name: Type-check (pyright)
run: uv run pyright

# The other half of the static gate. No path argument and no `select`: the rule set is
# ruff's own default, configured in [tool.ruff.lint], and the scan follows .gitignore so the
# data store and the venv are out without being named. Placed after pyright because the two
# overlap least at the top — a type error is usually the more informative first failure.
- name: Lint (ruff)
run: uv run ruff check

# The venv is Python 3.14, but palette.py + the two bpy scripts must stay importable
# under Blender's bundled 3.13.9. This re-checks exactly those files at 3.13 so
# 3.14-only syntax can't leak into a Blender-shared module. File list lives in the
Expand Down Expand Up @@ -88,9 +95,22 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Type-check (astro check)
# TWO PROGRAMS, and `pnpm check` runs both. `astro check` excludes worker/ on purpose —
# @cloudflare/workers-types redefines fetch/Response and breaks the site's program — so the
# Worker has its own tsconfig, and for a while nothing ran it. Nothing would have noticed
# either: wrangler's esbuild strips types without checking them, so a deploy ships regardless.
- name: Type-check (astro check + the tile Worker)
run: pnpm check

# The web half of the static gate, and the counterpart to `ruff` in the job above: the two
# type-checkers ask whether the types line up, oxlint asks whether the code says what it
# means. No path argument — it walks from web/ and follows .gitignore, so it reaches all 95
# sources including the eight .astro pages and the Worker, which `astro check` excludes.
# Placed after the type-check for the same reason ruff sits after pyright, and before the
# ~100 MB browser download because it takes milliseconds.
- name: Lint (oxlint)
run: pnpm lint

# The vitest `browser` project launches a real chromium; `pnpm install` fetches the
# playwright NPM package but never its browser binaries, so without this the project
# cannot start and the run reports `28 passed (30)` with an unhandled launch error.
Expand Down
30 changes: 18 additions & 12 deletions ART.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@
bathymetry, data-driven snow.
- The system of record is code: shared constants in `pipeline/render/palette.py`, hero-only
constants in `pipeline/render/scene_build.py`, tile levers in `pipeline/tile/shade.py`
(`KNOBS`). Canonical renders: `blender/renders/heroes/<country>.png` (203 posters — 204 countries are in scope, Kiribati deferred).
(`KNOBS`). The ramp constants below are Earth's unless a row says otherwise, and they are
assembled into a named `Look` that the shading path resolves **per body** — every lever's cost in
this file is Earth's cost. `KNOBS` is the exception that is NOT per body: tone, saturation and the
highlight ceiling apply to every planet alike, which is a shared house style rather than an
oversight, but it means a body cannot soften its own highlights without that changing first. Canonical renders: `blender/renders/heroes/<country>.png` (203 posters — 204 countries are in scope, Kiribati deferred).
- The A/Bs and rationale behind every value live in HISTORY (cited by § heading); this file is
the operational view.

Expand Down Expand Up @@ -54,14 +58,15 @@ Cost: hero sweep **~10–13 h** + tile restage **~29 min** + caps auto-restage *

| Lever | Value | Section |
|---|---|---|
| `LAND_STOPS` (+ `LAND_MAX_M`) | 6 stops, 0–6000 m | § Land color ramp |
| `LAND_STOPS` (+ `LAND_MAX_M`) | 6 stops; Earth's domain 0–6000 m, but the ENDS are the body's — a `Surface` carries `origin_m` and `extreme_m`, and only Earth's origin is the datum | § Land color ramp |
| `MARS_LAND_STOPS` | 6 stops over −6000…+6100 m, sharing no colour object with Earth. Hue measured against a global Viking mosaic (G/R 0.654, vs the 0.780 Earth's ramp was shipping); level taken only in part, landing at **0.71× Earth's shipped land**, because the mosaic's 2.07× darker reading is mostly its own tone curve and an uncorrected haze floor. Monotone in luminance BY CHOICE — Mars is really brightest at both ends, so fidelity would put Hellas and Olympus in one colour | § Land color ramp |
| `SEA_STOPS` (+ `SEA_MIN_M`) | 6 shelf-weighted stops, 0…−6000 m | § Sea color ramp |
| `LAKE_STOPS` (+ `LAKE_MAX_M`) | 3 stops; stop 0 == `WATER_RGB`, far end 1642 m (Baikal) | § Inland water |
| `WATER_RGB` | `8EC6C4` — pinned relationally: sea surface +7% | § Inland water |
| `SNOW_RGB` / `SNOW_SHADOW_RGB` | `E8F1F6` / `B0C7DB` | § Snow |
| `ICE_RGB` / `ICE_SHADOW_RGB` | `D4E4F0` / `9CB8D2` | § Sea ice |
| `SUN_ALT_DEG` | 45.0 — hero X-tilt and tile `alt` both derive from it | § Sun altitude |
| `EXAGGERATION` | 15.0 — hero displacement + tile `EXAG` both import it (the copy pair was collapsed) | § Vertical exaggeration |
| `EXAGGERATION` | 15.0 — the hero's and the region preview's; the tiles read `Body.exaggeration`, pinned equal to it for Earth | § Vertical exaggeration |
| `LUT_STEP_M` | 1.0 m — ramp LUT resolution (fidelity, not hue) | § Land color ramp |

### Hero only (`scene_build.py`, `render_prep.py`) — the 203-country sweep, ~10–13 h
Expand All @@ -87,7 +92,7 @@ Cost: hero sweep **~10–13 h** + tile restage **~29 min** + caps auto-restage *
|---|---|---|---|
| `fill_strength` | 0.15 | hillshade ~46 min | § Fill sun — TILES |
| `shadow_strength` / `shadow_reach` | 0.0 (off; +~2.1 h when on) / 300 px | hillshade | § Fill sun — TILES |
| tile `EXAG` (shade_planet.py) | 15 — imports `palette.EXAGGERATION` (shared, no longer a copy pair) | hillshade | § Vertical exaggeration |
| `exaggeration` (bodies.py) | 15 on Earth — a per-body field; tiles AND caps read it, pinned equal to `palette.EXAGGERATION` | hillshade | § Vertical exaggeration |
| `ambient` / `ambient_knee` | 0.50 / 0.30 | composite ~29 min | § Ambient floor |
| `hi` / `exposure` / `saturation` / `warmth` | 1.12 / 1.05 / 1.18 / 0.06 | composite | § Ambient floor |
| `shadow_warmth` | 0.55 | composite | § Hero → tile parameter map |
Expand Down Expand Up @@ -117,24 +122,25 @@ Cost: hero sweep **~10–13 h** + tile restage **~29 min** + caps auto-restage *
| `SIMPLIFY_DEG` (countries_geojson.py) | 0.002° — guard-tested sub-pixel vs `Z8_RES` | regen, seconds |
| `COORDINATE_PRECISION` (both geojson emitters) | 4 (countries) / 5 (borders) | regen, seconds |
| highlight look (web/src/lib/countryHighlight.ts) | `HIGHLIGHT_GOLD` `#eca834`, casing `#1c140c`, wash 0.16 | `pnpm build` | § Borders |
| border line ramps (earth.astro `inkWidth`/`casingWidth`) | 0.6→1.7 px ink, 2.2→5.0 px casing over z1–8 | `pnpm build` | § Borders |
| hover name chip (earth.astro `.country-chip`) | top-centre pill on `.globe-home`'s treatment, `var(--serif)` 1.05 rem, 0.4 rem dot taking `HIGHLIGHT_GOLD` from the constant (never a CSS copy); no fade, hidden under `(hover: none)` and while the detail card is open | `pnpm build` |
| globe camera flatness (earth.astro `VERTICAL_FIELD_OF_VIEW_DEG`) | 15° — tested band 5–15 (MapLibre default 36.87° reads as low-orbit fisheye; 5° ≈ the hero's orthographic camera) | `pnpm build` |
| on-map credit + chrome placement (earth.astro `CREDITS`, global.css `.view-bar`) | credit is an **ⓘ** linking to /about, folded into the centred `.view-bar` as its own segment — the bottom edge carries ONE floating element, not three. Still a MapLibre `AttributionControl`, constructed **`compact: false`**: any source declaring `attribution` is still picked up automatically (joined with `\|`), while MapLibre's own ⓘ — a baked-in black SVG that CSS cannot recolour — is never shown at any width. Ours is the same glyph redrawn with `currentColor`, its **viewBox cropped to the ink** (`4 4 12 12`, not `0 0 20 20`): the untrimmed box is 40% margin and renders ~60% the size of the octicon beside it. `title` + `aria-label` are mandatory — an icon-only link has no accessible name without them | `pnpm build` |
| border line ramps (Globe.astro `inkWidth`/`casingWidth`) | 0.6→1.7 px ink, 2.2→5.0 px casing over z1–8 | `pnpm build` | § Borders |
| hover name chip (Globe.astro `.country-chip`) | top-centre pill on `.globe-home`'s treatment, `var(--serif)` 1.05 rem, 0.4 rem dot taking `HIGHLIGHT_GOLD` from the constant (never a CSS copy); no fade, hidden under `(hover: none)` and while the detail card is open | `pnpm build` |
| globe camera flatness (Globe.astro `VERTICAL_FIELD_OF_VIEW_DEG`) | 15° — tested band 5–15 (MapLibre default 36.87° reads as low-orbit fisheye; 5° ≈ the hero's orthographic camera) | `pnpm build` |
| on-map credit + chrome placement (Globe.astro `CREDITS`, global.css `.view-bar`) | credit is an **ⓘ** linking to /about, folded into the centred `.view-bar` as its own segment — the bottom edge carries ONE floating element, not three. Still a MapLibre `AttributionControl`, constructed **`compact: false`**: any source declaring `attribution` is still picked up automatically (joined with `\|`), while MapLibre's own ⓘ — a baked-in black SVG that CSS cannot recolour — is never shown at any width. Ours is the same glyph redrawn with `currentColor`, its **viewBox cropped to the ink** (`4 4 12 12`, not `0 0 20 20`): the untrimmed box is 40% margin and renders ~60% the size of the octicon beside it. `title` + `aria-label` are mandatory — an icon-only link has no accessible name without them | `pnpm build` |
| view bar geometry + phone collapse (global.css `.view-bar`, Base.astro) | centres by `inset-inline: 0` + auto margins, **never** `left: 50%` + `translateX` — the transform applies after layout, so that form caps the bar's width at half the viewport and wraps it early. Below **600 px** it collapses to `[tune \| ⓘ]`, persisted, defaulting closed; the credit sits OUTSIDE `.view-bar-items` so a collapse can never hide it. `is-collapsible` must track the trigger's render condition exactly, or pages without a trigger lose their controls. An expanded bar reaches **141 px at 320 px**, so the scale bar drops via `body.viewbar-open` rather than the lift chasing it. MapLibre's CSS is injected at runtime, so overrides must out-specify, not just follow | `pnpm build` |
| globe atmosphere (web/src/lib/skyAtmosphere.ts) | `atmosphere-blend` **0.7 at z≤3 → 0.15 at z≥6**, geometric, as a zoom expression MapLibre evaluates per frame. Not a halo: the same uniform lays aerial perspective over the ground, and the overview-tuned 0.7 clips 23.8% of a pitched z7 frame to ≥254. `?sky=off\|<floor>` sweeps it | `pnpm build` |
| globe atmosphere (web/src/lib/skyAtmosphere.ts) | `atmosphere-blend` **0.7 at z≤3 → 0.15 at z≥6**, geometric, as a zoom expression MapLibre evaluates per frame. Not a halo: the same uniform lays aerial perspective over the ground, and the overview-tuned 0.7 clips 23.8% of a pitched z7 frame to ≥254. `?sky=off\|<floor>` sweeps it. The three COLOURS are the body's (`lib/bodies.ts`) and a body may declare none, in which case no sky is set at all; the numbers here are the ramp's | `pnpm build` |
| terrain displacement (web/src/lib/terrainSource.ts) | **15× at z≤3 decaying geometrically to 2.5× at z8**, written to `terrain.exaggeration` per frame — **never** via `setTerrain`, which rebuilds the Terrain + RenderToTexture and only destroys on removal. Rides the **`full` tier**; `?terrain=N` forces it on at any tier and `?terrain=off` removes only the geometry. The floor is **derived, not preferred**: elevation change goes as ~`width^0.5` while the facet halves per level, so slope holds constant only at 0.707 per level. Declared **`tileSize: 128` over a z0–8 pyramid** (one decision — 512's DEM sits at `camera − 2` and could never reach z7), and **`terrainSkirtLength: "none"`**, which trades MapLibre's seam-tearing for tiny black specks that land only on drastic elevation change | `pnpm build` |
| terrain polar feather (pipeline/tile/terrain_rgb.py `FEATHER_LAT_LO/HI`) | encoded elevation ramps to zero over **78°–85°** by smoothstep, the geometric twin of the alpha feather `polarCaps.ts` already applies across the same band. Not optional: MapLibre does **not** drape custom layers onto the terrain mesh, so displaced tiles under an undisplaced cap would open a geometric seam — worst in the south, where that band is 2–3 km of Antarctic ice. Smoothstep rather than linear because this multiplies *geometry*, and a slope discontinuity is a visible crease where an alpha one is not | terrain re-cut, ~41 min |

## Levers (play with these)

### Vertical exaggeration — global constant, per-country number

- Baseline **15× globally** (`EXAGGERATION`, `palette.py` — imported by `render_prep` + `shade_planet`). The number in the scene is
- Baseline **15× globally** (`EXAGGERATION`, `palette.py` — imported by `render_prep`; the tiles and caps read `Body.exaggeration`). The number in the scene is
per-frame: `displacement_scale = 15 ÷ (extent_w_m / 2)` because the plane is always 2 units
wide (India 8.0e-6, Nepal 3.3e-5, Sri Lanka 1.0e-4 — docs/framing-math.md). Copying one
country's scale onto another multiplies exaggeration by the frame-width ratio.
- **Adjust globally:** `EXAGGERATION` in `palette.py`, then regenerate frame.json per country
- **Adjust globally:** `EXAGGERATION` in `palette.py` AND `exaggeration` on the body in `bodies.py` — a
test fails if you move one alone — then regenerate frame.json per country
(delete + rerun; hand-edit pinned ones). **Adjust one country:** edit `displacement_scale` in
its frame.json — legitimate only as a recorded pathology override; per-country drama
re-litigates the series promise (same border, two posters, same mountain height).
Expand Down Expand Up @@ -221,7 +227,7 @@ Cost: hero sweep **~10–13 h** + tile restage **~29 min** + caps auto-restage *

| Tile | Hero | Relationship |
|---|---|---|
| `EXAG` (shade_planet.py) | `render_prep` displacement (both import `palette.EXAGGERATION` 15.0) | **now import-shared** — the copy pair was collapsed into `palette` |
| `exaggeration` (bodies.py) | `render_prep` displacement (imports `palette.EXAGGERATION` 15.0) | **pinned, not shared** — the tiles must vary per body, so a test holds Earth's field equal to the hero's constant |
| `KNOBS["alt"]` 45°, azimuth 315° | `SUN_ROTATION` X = `90 − SUN_ALT_DEG`, NW | both derive from `palette.SUN_ALT_DEG` |
| `KNOBS["fill_strength"]` 0.15 | `FILL_STRENGTH 0.45 / SUN_STRENGTH 3.0` | the ratio, ported exactly |
| `FILL_ALTITUDE 60°` / `FILL_AZIMUTH 135°` | `FILL_ROTATION` | identical geometry |
Expand Down
Loading