fuzzyworld

tower / commit

feat(sky): a sun that crosses the world, on Sydney's days

59c1282713a486ef1e12390a5f1d9223336d1f76 · Pavel Safronov · 2026-09-27 18:28:53 -0700 · parents 54204da9e284

Claude Code usage: this commit ~336.9K out / ~113.5M cached · cost unknown (no per-model trailer) · session 2 tally 1.4K in / 773.4K out / 240.6M cached · usage by commit and session

feat(sky): a sun that crosses the world, on Sydney's days

The swarm gathers into one shoal on the beam and throws its light out
sideways: Tower's sun. It lights at the sun cap at Sydney's sunrise, runs the
length of the world at a steady pace, and goes out at the far cap at Sydney's
sunset; then it is night, with the beam banked to a thousandth. Replaces the
dawn front (D-053). The calendar (city, time zone, day 0) is in the world file.

One clock (src/world/clock.ts), one sky computed from it (src/world/sky.ts),
and one setSky lighting the meshes both 3D views share, so the sun, the rod
(lit from the sun cap to the sun, dark past it) and the light on the ground
are the same inside and from outside, where the exterior used to show a fixed
noon. Windows now draw after the beam and the sun. The map marks the sun and
shades the world by the same curve. Time controls move to a panel section that
works in every view; tour scenes are lit as mornings where they are. The caps'
names are recorded: sun cap; far cap, also star cap. See decisions.md D-074.

Usage: 1.4K in / 773.4K out / 240.6M cached (session tally)
rtk:   906.2K saved, 15.2% over 2758 cmds (global/lifetime)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
 README.md                                      |  63 +++---
 content/world.json                             |   7 +
 decisions.md                                   |  55 ++++++
 design.md                                      |  75 +++++--
 docs/screenshots/desktop-detail.png            | Bin 1250682 -> 1287548 bytes
 docs/screenshots/desktop-exterior-true.png     | Bin 505390 -> 500422 bytes
 docs/screenshots/desktop-exterior.png          | Bin 561140 -> 566286 bytes
 docs/screenshots/desktop-interior-dawn.png     | Bin 495781 -> 0 bytes
 docs/screenshots/desktop-interior-morning.png  | Bin 0 -> 625335 bytes
 docs/screenshots/desktop-interior-night.png    | Bin 410601 -> 405112 bytes
 docs/screenshots/desktop-interior-overhead.png | Bin 738389 -> 777984 bytes
 docs/screenshots/desktop-interior-suncap.png   | Bin 416714 -> 409280 bytes
 docs/screenshots/desktop-interior.png          | Bin 987661 -> 991436 bytes
 docs/screenshots/desktop-map-sightline.png     | Bin 1054561 -> 1104075 bytes
 docs/screenshots/desktop-map.png               | Bin 1164435 -> 1265774 bytes
 docs/screenshots/desktop-tour-canopy.png       | Bin 883427 -> 923797 bytes
 docs/screenshots/desktop-tour-deep.png         | Bin 452117 -> 581412 bytes
 docs/screenshots/desktop-tour-falls.png        | Bin 962903 -> 963669 bytes
 docs/screenshots/desktop-tour-giants.png       | Bin 136963 -> 187082 bytes
 docs/screenshots/desktop-tour-girdle.png       | Bin 743265 -> 846731 bytes
 docs/screenshots/desktop-tour-mesas.png        | Bin 807133 -> 839485 bytes
 docs/screenshots/desktop-tour-narrows.png      | Bin 778268 -> 855575 bytes
 docs/screenshots/desktop-tour-pan.png          | Bin 805218 -> 804898 bytes
 docs/screenshots/desktop-tour-river.png        | Bin 959177 -> 975937 bytes
 docs/screenshots/desktop-tour-ship.png         | Bin 413917 -> 420149 bytes
 docs/screenshots/desktop-tour-squall.png       | Bin 408603 -> 408055 bytes
 docs/screenshots/desktop-tour-storm.png        | Bin 466643 -> 452358 bytes
 docs/screenshots/desktop-tour-summit.png       | Bin 987207 -> 1054421 bytes
 docs/screenshots/mobile-exterior.png           | Bin 119947 -> 119960 bytes
 docs/screenshots/mobile-interior.png           | Bin 289000 -> 286596 bytes
 docs/screenshots/mobile-map.png                | Bin 339659 -> 346189 bytes
 docs/screenshots/mobile-panel.png              | Bin 107250 -> 98089 bytes
 docs/screenshots/overview.png                  | Bin 3059086 -> 3118980 bytes
 index.html                                     |  36 ++--
 src/canon/constants.ts                         |  20 +-
 src/ui/app.ts                                  | 160 ++++++++-------
 src/views/exterior.ts                          |  22 ++-
 src/views/interior-model.ts                    | 244 +++++++++++++++--------
 src/views/interior.ts                          | 261 +++++--------------------
 src/views/map.ts                               |  54 ++++-
 src/views/scenic.ts                            |  28 ++-
 src/world/clock.ts                             | 136 +++++++++++++
 src/world/schema.ts                            |  61 ++++++
 src/world/sky.ts                               | 173 ++++++++++++++++
 test/canon.test.ts                             |   6 -
 test/sky.test.ts                               |  96 +++++++++
 test/world.test.ts                             |   5 +-
 tools/screenshots.mjs                          |  24 +--
 world-generation.md                            |   4 +-
 49 files changed, 1059 insertions(+), 471 deletions(-)

diff --git a/README.md b/README.md
index d1eb183..e6c02e8 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,8 @@ nothing passive would stay in the beam past mid-afternoon. It is a person.
 **generates itself live, in the browser**, from a file of authored features.
 
 Built: the unrolled surface map, Tower from outside, standing inside it and flying
-over it, a detail card for every place, Tower's published weather, permalinks, and
-the sightline probe.
+over it, a detail card for every place, Tower's published weather, a sun that crosses
+the world each day on Sydney's clock, permalinks, and the sightline probe.
 
 **The app is for looking; the world is changed in the repository.** The author writes
 alongside Tower rather than in it, and changes the world by talking it through with
@@ -109,7 +109,7 @@ another machine will show a large one-off divergence rather than a regression.
 | [tour-storm](docs/screenshots/desktop-tour-storm.png) | scenic tour: the published storm on the Sunward Sea, every ship in harbour |
 | [tour-girdle](docs/screenshots/desktop-tour-girdle.png) | scenic tour: nine kilometres over the Girdle |
 | [interior-overhead](docs/screenshots/desktop-interior-overhead.png) | looking up: the far side 100 km overhead is clearer than ground 100 km away |
-| [interior-dawn](docs/screenshots/desktop-interior-dawn.png) | dawn — a lit sky over dark ground, the light still 27 km away |
+| [interior-morning](docs/screenshots/desktop-interior-morning.png) | 06:30 — the sun just out from the sun cap and coming, the rod lit only behind it |
 | [detail](docs/screenshots/desktop-detail.png) | a place selected: its detail card, with what the world made of it |
 
 | Mobile — 390×844 | |
@@ -330,7 +330,7 @@ running after the wind drops. **Ships** sail both ring seas, each on its own lan
 world, riding the waves drawn under it with its mainsail set to the wind. When a gale or
 storm has been published for their sea in time, they stay in harbour — even if Tower has
 since called it off, because people act on what they were told — and a squall announced
-too late catches them at sea. Set the **Day** in the view panel, or run the clock, to move
+too late catches them at sea. Set the **Day** under **Time**, or run the clock, to move
 through the plan.
 
 **Or take the scenic tour.** *Scenic tour* in the view panel flies you round the sights,
@@ -344,39 +344,56 @@ goes five times faster. Speed follows your height above the ground: a brisk walk
 at standing height, and a sea crossed in seconds from ten kilometres up, with nothing
 to set in between.
 
+**The caps have more than one name.** The **sun cap** is the end the beam comes in at. The
+**far cap** is also the **star cap**, since it is the one that shows the stars. More names get
+added as they come into use.
+
 **The far cap is a window** — 100 km of it, looking into deep anti-sun space. By day
 it is simply a disc of blue, because 540 km of air in front of it scatters more
 light than anything behind it sends; the stars are there at noon and unseeable,
-which is a fact about eyes rather than about glass. Turn the light down and they
-come out. The sun cap stays a checkered plate: it faces the sun, and needs glare
-management rather than a view.
+which is a fact about eyes rather than about glass. At night they
+come out. The sun cap is glazed too: it faces the collector, not the sun, so it has
+nothing to manage the glare of (design.md §1.3.0).
 
 The one part of the far cap that is not glass is a **3.2 km mirror** on the axis.
 A quarter of the beam survives all 540 km and arrives collimated, so it lands as
 1.7 MW/m² — 1,200 suns — on a spot a thousandth of the cap's area; the mirror sends
 it back up the column for a second pass, which lifts the beam's utilisation from 76%
-to 94%. You can see it: the rod of light visibly terminates on a bright disc, and
-the disc banks and goes out with the beam.
+to 94%. By day the sun takes the whole beam before it gets there (below), so the mirror
+is lit only at night, by the banked beam: a faint disc where the rod ends.
 
 The checker survives on the far cap as the window's **mullions** — the same polar
 tessellation at the same pitch, so it is still the only object in the world with a
 known cell size and still the only rule anyone has for judging distance. It is just
 read against sky now instead of against a plate.
 
-**Dawn is a line, not a fade.** The beam glows by itself, so when its power comes up
-the sky brightens everywhere at once — but the ground is lit by the diffusers, and
-they hand the light down the axis. So a Tower morning is a **fully lit sky over dark
-ground**, with daylight arriving as a front running deepward from the sun cap at
-225 m/s, crossing all 540 km in 40 minutes. Tower has no latitude; the cycle gives it
-longitude. Scrub the **Time** slider to 06:17 and stand at The Capital to watch it
-come, or tick **Run the clock** and let it.
-
-The light has five settings when the clock is not driving it. **Dark** shuts the 54 TW beam down entirely and is the
-only one that is genuinely black — there is no airlight, no lit ground and no rod
-overhead, and the only thing left in the world is the sky. **Night** banks the beam
-to a thousandth instead of killing it, which is what night here has to be: the beam
-glows by itself, so a lit beam lights the world whatever the mirrors are doing, and
-aiming them away would leave the floor at about 100 W/m² — half of noon.
+**Tower has a sun, and it crosses the world once a day.** The mirrorlets that fly in the
+beam gather into one shoal and throw the beam's light out sideways there, which makes a
+single blazing sun at one point on the beam. It lights at the **sun cap** at sunrise, moves
+at a steady pace down the whole length of the world, and reaches the **far cap** at sunset,
+where it goes out: that is nightfall. Through the night the beam is banked to a thousandth,
+and the next morning the sun lights at the sun cap again. So Tower's sun moves from one end
+of the world to the other, every day.
+
+**Tower keeps Sydney's days.** Its clock is Sydney's, daylight saving included, and its sun
+comes on at Sydney's sunrise and goes out at Sydney's sunset, so the days lengthen and
+shorten with Sydney's seasons — about 45 km/h at the equinoxes, when day 0 falls. The city
+and the date of day 0 are the `calendar` in `content/world.json`.
+
+**Every place has its own noon.** Tower aims the swarm so the sun is as bright seen from
+anywhere as seen from under it, so the ground is lit by how high the sun stands, as it is
+under Earth's. From The Capital, halfway along, the morning sun is low in the sunward sky,
+it passes overhead at midday, and it sinks away deepward in the afternoon; it never sets,
+since 50 km up and 540 km away it is still 5° high. The **beam** glows from the sun cap to
+the sun and is dark beyond it, because the shoal takes all of it. Inside and out it is the
+same sun: the exterior looks in through the windows at the one the interior stands under.
+
+**Time**, in the panel, works in every view: the **Day** (with its date in Sydney), the
+**Time** of day, **Run the clock** and its rate, and the **Light** — how bright Tower runs
+its day. It says when the sun rises and sets and where it is now, and the map marks it.
+**Dark** shuts the 54 TW beam down entirely and is the only setting that is genuinely black —
+no sun, no airlight, no rod, and nothing left in the world but the sky. Twilight, Temperate
+and Full set how bright the day is; night comes by itself, with the clock.
 
 The eye-height slider runs from 1.7 m to 45 km with the local gravity beside it, and
 **Rain** leans 1.5° antispinward. Nothing *inside* rotates, because you, the ground
diff --git a/content/world.json b/content/world.json
index 53c641f..0dcdc5b 100644
--- a/content/world.json
+++ b/content/world.json
@@ -1,6 +1,13 @@
 {
   "version": 2,
   "seed": 1729,
+  "calendar": {
+    "city": "Sydney",
+    "timeZone": "Australia/Sydney",
+    "latitude": -33.8688,
+    "longitude": 151.2093,
+    "day0": "2026-09-23"
+  },
   "ground": {
     "baseM": 60,
     "reliefM": 260,
diff --git a/decisions.md b/decisions.md
index 9b5b162..bd1a4b9 100644
--- a/decisions.md
+++ b/decisions.md
@@ -169,6 +169,9 @@ revisits). Derivation makes the whole app answer coherently to a changed radius.
 
 ## D-008 — Diffusers are a 1.2 cm swarm; the beam glows from air, not from them
 
+*Amended by D-074: individually the mirrorlets stay invisible, but by day the swarm
+gathers into one shoal on purpose, and that shoal is Tower's sun.*
+
 **Decided.** 78 billion 1.2 cm mirrorlets, and the beam's visible glow is
 atmospheric scattering that does not depend on them.
 
@@ -1404,6 +1407,8 @@ and that spot is a mirror, not glass.
 
 ## D-046 — Night is the beam banked, not the swarm aimed away
 
+*Still true under D-074, where night begins when the sun goes out at the far cap.*
+
 The obvious way to make night in a habitat lit by a steerable mirror swarm is to
 aim the swarm off. It does not work here, and §1.3's own numbers say so.
 
@@ -1601,6 +1606,8 @@ just came out — and it now says so where it sits.
 
 ## D-053 — The day is a front, not a dimmer
 
+*Superseded by D-074: the day is a sun that crosses the world, not a front.*
+
 The obvious day-night cycle is a global fade between the light settings. It would
 have worked and it would have thrown away the most interesting thing about how this
 world is lit.
@@ -2191,6 +2198,7 @@ front of the seas: drawn first, it wrote its depth, and the water behind it fail
 depth test — so looking in at the sun cap showed the Sunward Sea's bed, brown, instead of
 the sea. See-through windows now draw at 2.5, after the water and the falls and before the
 beam, and the exterior's rays share that order so they still sort against the glass.
+*D-074 moved them to 4, after the beam and the sun, so both show from outside too.*
 
 ---
 
@@ -2426,3 +2434,50 @@ still listed in the others.
 **One origin, shared storage.** The branches are paths on one site, so localStorage and
 the IndexedDB world cache are shared. That is harmless — the cache is keyed by the world
 and the build — but it means a remembered view or camera follows you between branches.
+
+---
+
+## D-074 — Tower has a sun, and keeps Sydney's days
+
+**Decided** by the author, 2026-09-27. Built. Supersedes D-053; amends D-008.
+
+**A sun at one point on the beam, crossing the world once a day.** The swarm gathers into
+one shoal on the axis and throws the beam's light out sideways there: a single blazing
+point, Tower's sun. It lights at the sun cap at sunrise, moves at a steady speed down the
+whole axis, and goes out at the far cap at sunset. Night follows, with the beam banked to
+a thousandth as D-046 has it, and the next morning the sun lights at the sun cap again.
+This replaces D-053's dawn front, and it turns D-008's aim — no mirrorlet may read as a
+sun — into its exception: Tower wants exactly one.
+
+**Sydney's days.** Tower keeps an Earth city's clock and its sunrises and sunsets, and the
+author chose Sydney. The clock is Sydney's local time, daylight saving included; day 0 is
+23 September 2026, the southern spring equinox, so the calendar starts on a 12-hour day and
+lengthens toward December. Sunrise and sunset come from NOAA's solar position equations,
+within a few minutes of the published tables (test/sky.test.ts). The city and the date of
+day 0 are a `calendar` in `content/world.json`, so both can be changed.
+
+**How it lights the world.** Tower aims the shoal's mirrorlets so the sun is as bright seen
+from anywhere as from beneath it, so the ground is lit by how squarely it faces the sun, as
+under Earth's — not by the inverse square of a lamp 50 km up, which would leave most of
+the world in twilight all day. Each place has its own noon when the sun passes overhead;
+the sun never sets, since from one cap the sun at the other is still 5° up. The shoal takes
+the whole beam where it gathers, so the rod glows from the sun cap to the sun and is dark
+past it, and the far cap's mirror is lit only by the banked beam at night.
+
+**Consistency, inside and out, was the author's main requirement.** So there is one of
+everything: one world clock (`src/world/clock.ts`), one sky computed from it
+(`src/world/sky.ts`), and one `setSky` that lights the meshes both 3D views share. The sun is
+one of those meshes, lit by the same haze integral as everything else, and every lit surface
+goes through one shader function, `direct`. The exterior used to render the inside at a
+fixed noon; it now follows the clock like the interior does, the windows draw after the
+beam and the sun so both show through the glass, and the rays from the collector dim when
+the beam is banked. The map marks the sun and shades the world by the same light curve.
+
+**What changed in the app.** The Time controls — day, time, run the clock, its rate, and how
+bright Tower runs its day — moved out of the interior's options into a Time section that
+works in every view. The Light menu lost "Night", which the clock now brings; "Dark" still
+shuts the beam down. Scenic tour scenes with no reason to choose an hour are shown in the
+morning where they are, the sun 60 km sunward and about 40° up.
+
+**The caps have more than one name.** The sun cap; the far cap, also the star cap. The author
+may use any of them, and more are added as they come into use.
diff --git a/design.md b/design.md
index f09a72d..d5c2f86 100644
--- a/design.md
+++ b/design.md
@@ -162,6 +162,17 @@ that cost energy for no reason but preference.
 mirrors put any intensity on any patch of ground at any time. Dawn, dusk, moving
 cloud shadows and seasons are compositing problems, not optical ones.
 
+**Together they make Tower's sun** (D-074). Individually invisible, the swarm gathers
+by day into a single shoal on the axis and throws the beam's light out sideways from
+there — one blazing point 3.2 km across, which is Tower's sun. The shoal moves: it
+forms at the sun cap at sunrise and flies the length of the world by sunset, where it
+disperses (§1.3.4). Each mirrorlet in it is aimed, so Tower sends as much of the sun's
+light toward ground far away as toward ground beneath it, and the sun looks equally
+bright from everywhere, as Earth's does; the ground is lit by how high it stands. A
+shoal dense enough to take the whole beam leaves none past it, so **the rod glows from
+the sun cap to the sun and is dark beyond it.** This is the one exception to the rule
+above that the diffusers must never read as a sun: Tower wants exactly one.
+
 **Aiming resolution is human-sized.** A 1.2 cm mirror is diffraction-limited to
 4.6×10⁻⁵ rad, so its reflection lands as a **2.3 m spot** on the floor 50 km below.
 Tower can put a glint on one person that someone five metres away cannot see. The
@@ -198,6 +209,11 @@ Tower's sun system requires physics that does not exist. See `decisions.md` D-00
 
 #### 1.3.2 Night, and the difference between banked and off
 
+**Night is the sun going out and the beam banked** (D-074). The sun reaches the far
+cap at sunset and the shoal disperses; the beam is then banked for the night, and
+comes back up for the sun to light at the sun cap in the morning. Banking is not
+optional, for the reason below.
+
 **Night cannot be made by aiming the swarm away.** The glow above belongs to the
 beam and the air, not to the diffusers, so a fully lit beam lights the world
 whatever the mirrorlets are doing: 779 W/m²/sr over a strip 3.65° wide running
@@ -221,6 +237,10 @@ this is a state Tower can survive and not one it can sit in.
 
 #### 1.3.3 The far cap mirror, and where the last quarter of the beam goes
 
+*Since D-074 this is the night's account.* By day the sun's shoal takes the whole beam
+wherever it is, so nothing reaches the far cap until sunset; the mirror works on the
+banked beam of the night, and on whatever passes the shoal at the day's ends.
+
 The swarm has optical depth 1.41, so **24% of the beam survives all 540 km** — and
 it survives *collimated*, in the beam's own 7.96 km² cross-section. That is the
 whole shape of the problem: not 13 TW spread over a 7,854 km² cap, but **1.7 MW/m²
@@ -243,27 +263,36 @@ the cavity closed at both ends it is.
 Aiming light at a region to warm it is a separate lever and stays one (§1.9.4). The
 mirror is about not wasting the beam; the desert is about where the weather goes.
 
-#### 1.3.4 The day, and why dawn has a direction
+#### 1.3.4 The day: a sun that crosses the world
+
+*Rewritten for D-074; it replaces the dawn front of D-053.*
 
 Tower's day is **24 hours**, chosen for the people living under it — nothing
 physical offers one, since the spin is 7.5 minutes and has nothing to do with light.
-
-**Dawn cannot be a dimmer, and it cannot be the swarm either.** §1.3 gives the glow
-to the beam and the air, so the sky, the airlight and the rod overhead all brighten
-together the moment beam power comes up; there is no way to have one end of the axis
-light before the other. But the *ground* is lit by the diffusers, and they can put
-the light wherever they like. So Tower brings the day up as a **front running
-deepward from the sun cap**, 540 km in 40 minutes — **225 m/s**, near Tower's own rim
-speed, and slow enough to stand and watch arrive.
-
-The result is a morning nothing on Earth has: **a fully lit sky over dark ground**,
-with a line of daylight coming toward you along the world. §1.9.4 says Tower has no
-latitude. The cycle gives it **longitude** — 40 minutes of it, sun cap to far cap.
-
-Dusk runs the same way, so the light withdraws in the direction it arrived and the
-sun-cap end always has the shorter day by a hair. Nothing forces that; the
-alternative was a dusk that retreats, and a consistent direction of time reads as
-designed rather than as an oscillation.
+**Tower keeps an Earth city's days: Sydney's.** Its clock is Sydney's, daylight saving
+included, and its sunrise and sunset are Sydney's, so the days lengthen and shorten
+with Sydney's seasons. Day 0 is the southern spring equinox, 23 September 2026. The
+city and that date are the `calendar` in the world file, and can be changed there.
+
+**The sun crosses the world once a day.** It lights at the **sun cap** at sunrise,
+travels at a steady speed down the axis, and reaches the **far cap** at sunset, where
+it goes out. At the equinox that is 540 km in 12 hours 7 minutes — **45 km/h**, a sun
+you can watch move; at midwinter the day is 9 h 54 min and it runs at 55 km/h, and at
+midsummer 14 h 24 min and 37 km/h. It takes a quarter of an hour to light and a quarter
+of an hour to go out, which is the shoal gathering and dispersing. Then it is night
+(§1.3.2) until the next sunrise.
+
+**Every place has its own noon.** The sun is 50 km overhead when it passes, so each
+place along the world has its midday at a different hour: the sun-cap end in the early
+morning, The Capital at about midday, the far-cap end towards evening. Away from
+overhead the sun stands lower — 45° at 50 km off, 11° at 270 km — and the ground is lit
+by how high it stands (§1.3.1). It never sets: from one cap, the sun at the other is
+still 5° up. Morning is a low sun in the sunward sky, evening a low sun deepward. §1.9.4
+says Tower has no latitude; the sun gives it **longitude**, and a direction for its days.
+
+**The same sun inside and out.** The sun is a point on the beam, lit by the beam, and it
+is drawn once and seen by both views — from the floor, and in through the windows from
+outside — with the same haze between it and the eye.
 
 ### 1.4 Sealed hull, and the cap windows
 
@@ -283,6 +312,9 @@ The only views out are at the **end caps**, and they are peculiar:
   sky.** It is the only moving sky anyone in Tower ever sees — and because the axis
   is horizontal from the floor, it wheels about a point on the **horizon** rather
   than overhead.
+- **The caps have more than one name.** The **sun cap** is the end the beam enters by.
+  The **far cap** is also the **star cap**: it is the one with the stars in it. Both are
+  used, and more names are added as they come into use (author, 2026-09-27).
 - The **far cap is glazed whole** — a 100 km window looking into deep anti-sun
   space, not a wall with windows at its foot (D-045). Its structure survives as the
   window's **mullions**, on the same polar tessellation the checker used and at the
@@ -335,8 +367,11 @@ So the interior sky behaves like this, and the shader must reproduce it:
   reads as endless in the axial direction and closed in the circumferential one.
 - **The ground curves up on both sides** into that same blue, meeting overhead.
 - **The beam** is a self-luminous scattering shaft 3.65° wide at 779 W/m²/sr —
-  render it as a bright sunlit cloud, not as a light source with a halo. With the
-  hull sealed, it is the only thing in the sky that is not land.
+  render it as a bright sunlit cloud, not as a light source with a halo. By day it
+  runs from the sun cap to the sun and stops there (§1.3.1).
+- **The sun** is the other thing in the sky that is not land: a blazing disc on the
+  beam, 3.65° across from directly beneath and smaller from afar, with glare round it
+  (§1.3.4). With the hull sealed, the rod and the sun are all there is.
 
 ### 1.6 Orbit and attitude
 
diff --git a/docs/screenshots/desktop-detail.png b/docs/screenshots/desktop-detail.png
index 17d3746..7327688 100644
Binary files a/docs/screenshots/desktop-detail.png and b/docs/screenshots/desktop-detail.png differ
diff --git a/docs/screenshots/desktop-exterior-true.png b/docs/screenshots/desktop-exterior-true.png
index e247291..1f04b64 100644
Binary files a/docs/screenshots/desktop-exterior-true.png and b/docs/screenshots/desktop-exterior-true.png differ
diff --git a/docs/screenshots/desktop-exterior.png b/docs/screenshots/desktop-exterior.png
index f687484..ec22ff8 100644
Binary files a/docs/screenshots/desktop-exterior.png and b/docs/screenshots/desktop-exterior.png differ
diff --git a/docs/screenshots/desktop-interior-dawn.png b/docs/screenshots/desktop-interior-dawn.png
deleted file mode 100644
index e15008f..0000000
Binary files a/docs/screenshots/desktop-interior-dawn.png and /dev/null differ
diff --git a/docs/screenshots/desktop-interior-morning.png b/docs/screenshots/desktop-interior-morning.png
new file mode 100644
index 0000000..3cbb2e1
Binary files /dev/null and b/docs/screenshots/desktop-interior-morning.png differ
diff --git a/docs/screenshots/desktop-interior-night.png b/docs/screenshots/desktop-interior-night.png
index df8da57..92adc1f 100644
Binary files a/docs/screenshots/desktop-interior-night.png and b/docs/screenshots/desktop-interior-night.png differ
diff --git a/docs/screenshots/desktop-interior-overhead.png b/docs/screenshots/desktop-interior-overhead.png
index 796f32a..9e5c485 100644
Binary files a/docs/screenshots/desktop-interior-overhead.png and b/docs/screenshots/desktop-interior-overhead.png differ
diff --git a/docs/screenshots/desktop-interior-suncap.png b/docs/screenshots/desktop-interior-suncap.png
index 061ccf8..b1dd0a0 100644
Binary files a/docs/screenshots/desktop-interior-suncap.png and b/docs/screenshots/desktop-interior-suncap.png differ
diff --git a/docs/screenshots/desktop-interior.png b/docs/screenshots/desktop-interior.png
index 8434ad3..8840367 100644
Binary files a/docs/screenshots/desktop-interior.png and b/docs/screenshots/desktop-interior.png differ
diff --git a/docs/screenshots/desktop-map-sightline.png b/docs/screenshots/desktop-map-sightline.png
index dfafb75..6078db5 100644
Binary files a/docs/screenshots/desktop-map-sightline.png and b/docs/screenshots/desktop-map-sightline.png differ
diff --git a/docs/screenshots/desktop-map.png b/docs/screenshots/desktop-map.png
index 6ce3a76..cb7555b 100644
Binary files a/docs/screenshots/desktop-map.png and b/docs/screenshots/desktop-map.png differ
diff --git a/docs/screenshots/desktop-tour-canopy.png b/docs/screenshots/desktop-tour-canopy.png
index 184ae76..2e9b6d6 100644
Binary files a/docs/screenshots/desktop-tour-canopy.png and b/docs/screenshots/desktop-tour-canopy.png differ
diff --git a/docs/screenshots/desktop-tour-deep.png b/docs/screenshots/desktop-tour-deep.png
index 4720c77..0cfb424 100644
Binary files a/docs/screenshots/desktop-tour-deep.png and b/docs/screenshots/desktop-tour-deep.png differ
diff --git a/docs/screenshots/desktop-tour-falls.png b/docs/screenshots/desktop-tour-falls.png
index 44046a4..6d8c460 100644
Binary files a/docs/screenshots/desktop-tour-falls.png and b/docs/screenshots/desktop-tour-falls.png differ
diff --git a/docs/screenshots/desktop-tour-giants.png b/docs/screenshots/desktop-tour-giants.png
index 4fd7541..6002d77 100644
Binary files a/docs/screenshots/desktop-tour-giants.png and b/docs/screenshots/desktop-tour-giants.png differ
diff --git a/docs/screenshots/desktop-tour-girdle.png b/docs/screenshots/desktop-tour-girdle.png
index 6f3e838..346036d 100644
Binary files a/docs/screenshots/desktop-tour-girdle.png and b/docs/screenshots/desktop-tour-girdle.png differ
diff --git a/docs/screenshots/desktop-tour-mesas.png b/docs/screenshots/desktop-tour-mesas.png
index f3e063a..2bfd0e3 100644
Binary files a/docs/screenshots/desktop-tour-mesas.png and b/docs/screenshots/desktop-tour-mesas.png differ
diff --git a/docs/screenshots/desktop-tour-narrows.png b/docs/screenshots/desktop-tour-narrows.png
index 583e288..36d2464 100644
Binary files a/docs/screenshots/desktop-tour-narrows.png and b/docs/screenshots/desktop-tour-narrows.png differ
diff --git a/docs/screenshots/desktop-tour-pan.png b/docs/screenshots/desktop-tour-pan.png
index 8badcc4..04b545c 100644
Binary files a/docs/screenshots/desktop-tour-pan.png and b/docs/screenshots/desktop-tour-pan.png differ
diff --git a/docs/screenshots/desktop-tour-river.png b/docs/screenshots/desktop-tour-river.png
index 005d3a8..ec72fb8 100644
Binary files a/docs/screenshots/desktop-tour-river.png and b/docs/screenshots/desktop-tour-river.png differ
diff --git a/docs/screenshots/desktop-tour-ship.png b/docs/screenshots/desktop-tour-ship.png
index 33686b8..cce9bfb 100644
Binary files a/docs/screenshots/desktop-tour-ship.png and b/docs/screenshots/desktop-tour-ship.png differ
diff --git a/docs/screenshots/desktop-tour-squall.png b/docs/screenshots/desktop-tour-squall.png
index e5a79a9..cb77510 100644
Binary files a/docs/screenshots/desktop-tour-squall.png and b/docs/screenshots/desktop-tour-squall.png differ
diff --git a/docs/screenshots/desktop-tour-storm.png b/docs/screenshots/desktop-tour-storm.png
index 9a79c5f..94d972a 100644
Binary files a/docs/screenshots/desktop-tour-storm.png and b/docs/screenshots/desktop-tour-storm.png differ
diff --git a/docs/screenshots/desktop-tour-summit.png b/docs/screenshots/desktop-tour-summit.png
index a38452e..82123a8 100644
Binary files a/docs/screenshots/desktop-tour-summit.png and b/docs/screenshots/desktop-tour-summit.png differ
diff --git a/docs/screenshots/mobile-exterior.png b/docs/screenshots/mobile-exterior.png
index c2c0499..1cfa5b6 100644
Binary files a/docs/screenshots/mobile-exterior.png and b/docs/screenshots/mobile-exterior.png differ
diff --git a/docs/screenshots/mobile-interior.png b/docs/screenshots/mobile-interior.png
index d4361e7..631ac70 100644
Binary files a/docs/screenshots/mobile-interior.png and b/docs/screenshots/mobile-interior.png differ
diff --git a/docs/screenshots/mobile-map.png b/docs/screenshots/mobile-map.png
index d4dc4c2..8ed8efd 100644
Binary files a/docs/screenshots/mobile-map.png and b/docs/screenshots/mobile-map.png differ
diff --git a/docs/screenshots/mobile-panel.png b/docs/screenshots/mobile-panel.png
index 0316332..0e525b6 100644
Binary files a/docs/screenshots/mobile-panel.png and b/docs/screenshots/mobile-panel.png differ
diff --git a/docs/screenshots/overview.png b/docs/screenshots/overview.png
index 9f03b6d..18aee12 100644
Binary files a/docs/screenshots/overview.png and b/docs/screenshots/overview.png differ
diff --git a/index.html b/index.html
index 48f5c6a..316a923 100644
--- a/index.html
+++ b/index.html
@@ -73,20 +73,6 @@
         <p class="hint">A new sight every thirty seconds — flying down a river, circling a
           waterfall or a summit, following a ship through weather. <b>N</b> skips ahead;
           <b>Esc</b>, a drag or a flight key stops it where it is.</p>
-        <label>Day <input id="f-day" type="number" min="0" step="1" value="0" /></label>
-        <label>Time <span id="time-value" class="dim"></span>
-          <input id="f-time" type="range" min="0" max="1439" step="1" value="720" /></label>
-        <label class="row"><input id="f-cycle" type="checkbox" /> Run the clock</label>
-        <label>Rate <span id="cycle-value" class="dim"></span>
-          <input id="f-cycle-rate" type="range" min="0" max="4" step="1" value="2" /></label>
-        <label>Light
-          <select id="f-light">
-            <option value="dark">Dark — beam shut down</option>
-            <option value="night">Night — 0.3 W/m²</option>
-            <option value="twilight">Twilight — 30 W/m²</option>
-            <option value="temperate" selected>Temperate — 200 W/m²</option>
-            <option value="full">Full — 319 W/m²</option>
-          </select></label>
         <p class="hint"><b>One finger looks. Two fingers move</b> — drag with two
           and the ground follows them, pinch to zoom in like a pair of binoculars.
           With a mouse: drag to look, shift-drag to move, scroll to zoom.
@@ -100,11 +86,31 @@
       </div>
     </details>
 
+    <details class="sec" id="time-section" open>
+      <summary>Time <span id="time-now" class="dim"></span></summary>
+      <p class="hint" id="sun-now"></p>
+      <label>Day <span id="day-value" class="dim"></span>
+        <input id="f-day" type="number" min="0" step="1" value="0" /></label>
+      <label>Time <span id="time-value" class="dim"></span>
+        <input id="f-time" type="range" min="0" max="1439" step="1" value="720" /></label>
+      <label class="row"><input id="f-cycle" type="checkbox" /> Run the clock</label>
+      <label>Rate <span id="cycle-value" class="dim"></span>
+        <input id="f-cycle-rate" type="range" min="0" max="4" step="1" value="2" /></label>
+      <label>Light <span class="dim">— how bright Tower runs its day</span>
+        <select id="f-light">
+          <option value="dark">Dark — beam shut down</option>
+          <option value="twilight">Twilight — 30 W/m²</option>
+          <option value="temperate" selected>Temperate — 200 W/m²</option>
+          <option value="full">Full — 319 W/m²</option>
+        </select></label>
+      <p class="hint" id="time-hint"></p>
+    </details>
+
     <details class="sec" id="weather-section">
       <summary>Weather <span id="weather-now" class="dim"></span></summary>
       <p class="hint">Tower publishes its weather plans so people can plan around
         them, and changes them when it sees fit. Times are world time, counted from
-        day 0; run the clock in the interior view to move through them.</p>
+        day 0; run the clock under Time to move through them.</p>
       <ul id="weather-board" class="board"></ul>
     </details>
 
diff --git a/src/canon/constants.ts b/src/canon/constants.ts
index e9e3ec9..c08c20e 100644
--- a/src/canon/constants.ts
+++ b/src/canon/constants.ts
@@ -175,23 +175,17 @@ export const BRIGHTNESS_NIGHT_W_M2 = BRIGHTNESS_FULL_W_M2 * NIGHT_BEAM_FRACTION
 /**
  * Tower's day. Nothing physical sets it — there is no rotation to inherit one from,
  * since the spin is 7.5 minutes and has nothing to do with light. It is chosen for
- * the people living under it, so it is Earth's.
+ * the people living under it, so it is Earth's: Tower keeps an Earth city's clock and
+ * its sunrises and sunsets (D-074, src/world/sky.ts).
  */
 export const DAY_LENGTH_S = 86_400
 /**
- * How long the light takes to cross the world at dawn, and again at dusk.
- *
- * The swarm cannot brighten everywhere at once and would not want to: the beam
- * enters at the sun cap and the diffusers hand it down the axis, so the natural
- * thing — and the only thing that gives a world with no east a direction for its
- * mornings — is to bring it up as a front running deepward. 40 minutes over 540 km
- * is **225 m/s**, which is a shadow line moving at about the rim speed of Tower
- * itself, and slow enough to stand and watch arrive. D-053.
+ * How long Tower's sun takes to light after sunrise, and to go out before sunset: the
+ * swarm gathering into its shoal at the sun cap, and dispersing at the far cap. D-074.
  */
-export const DAWN_SWEEP_S = 2400
-export const DAWN_FRONT_SPEED_M_S = LENGTH_M / DAWN_SWEEP_S
-/** The lag between the sun cap's morning and the far cap's — Tower's only longitude. */
-export const DAY_SWEEP_FRACTION = DAWN_SWEEP_S / DAY_LENGTH_S
+export const SUN_RAMP_H = 0.25
+/** The sun's radius on the axis: the beam's own, since it is the beam that is lit up. */
+export const SUN_RADIUS_M = BEAM_RADIUS_M
 
 // --------------------------------------------------------- structure (design.md §1.1)
 /**
diff --git a/src/ui/app.ts b/src/ui/app.ts
index 233abe0..039f5c3 100644
--- a/src/ui/app.ts
+++ b/src/ui/app.ts
@@ -15,7 +15,9 @@ import { hoursOf, occurrences, timeOf } from '../world/weather.js'
 import { KIND_COLOR } from '../views/palette.js'
 import { TowerMap } from '../views/map.js'
 import type { ExteriorView } from '../views/exterior.js'
-import type { InteriorView, Lighting } from '../views/interior.js'
+import type { InteriorView } from '../views/interior.js'
+import { clock } from '../world/clock.js'
+import { clockText, dateLabel, isDial, skyAt } from '../world/sky.js'
 import { CIRCUMFERENCE_M, G0, LENGTH_M, RADIUS_M, SPIN_PERIOD_S, gravityAtHeight } from '../canon/constants.js'
 import { eyeHeightFromSlider, sliderFromEyeHeight } from './scales.js'
 
@@ -246,6 +248,7 @@ export class App {
     speed.oninput = apply
     apply()
     this.buildInteriorControls()
+    this.buildTimeControls()
   }
 
   /**
@@ -263,24 +266,6 @@ export class App {
     }
     this.showEye(eyeHeightFromSlider(Number(eye.value) / 1000))
 
-    /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */
-    const RATES = [1, 60, 900, 3600, 14400]
-    const time = $<HTMLInputElement>('f-time')
-    const showTime = () => {
-      const m = Number(time.value)
-      $('time-value').textContent =
-        `— ${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`
-    }
-    time.oninput = () => {
-      showTime()
-      $<HTMLSelectElement>('f-light').disabled = true
-      if (!this.interior) return
-      this.interior.clockDriven = true
-      this.interior.setDayPhase(Number(time.value) / 1440)
-      this.renderStanding()
-    }
-    showTime()
-
     $('f-tour').onclick = () => {
       const v = this.interior
       if (!v) return
@@ -293,34 +278,92 @@ export class App {
       this.renderStanding()
     }
 
+  }
+
+  /**
+   * The world clock's controls (clock.ts). They are the panel's, not a view's: every view
+   * shows the same moment, so the time is set once for all of them (D-074).
+   */
+  private buildTimeControls(): void {
+    const cal = this.world.calendar
+    $('time-hint').textContent =
+      `Tower keeps ${cal.city}'s clock and its days. Its sun lights at the sun cap at ` +
+      `${cal.city}'s sunrise, runs the length of the world at a steady pace, and goes out ` +
+      `at the far cap at ${cal.city}'s sunset; then it is night, with the beam banked to a ` +
+      `thousandth.`
+    $<HTMLInputElement>('f-time').oninput = (e) =>
+      clock.setHourOfDay(Number((e.target as HTMLInputElement).value) / 60)
     const day = $<HTMLInputElement>('f-day')
     day.onchange = () => {
       const v = Number(day.value)
-      if (!Number.isFinite(v) || v < 0 || !this.interior) return
-      this.interior.setDay(v)
-      this.renderStanding()
+      if (Number.isFinite(v) && v >= 0) clock.setDay(v)
     }
-
-    const cycle = $<HTMLInputElement>('f-cycle')
+    /** World seconds per real second, slowest first: a Tower day in 24 min at 60x. */
+    const RATES = [1, 60, 900, 3600, 14400]
     const rate = $<HTMLInputElement>('f-cycle-rate')
-    const applyCycle = () => {
+    const saved = RATES.indexOf(clock.rate)
+    if (saved >= 0) rate.value = String(saved)
+    const applyRate = () => {
       const r = RATES[Number(rate.value)]!
-      $('cycle-value').textContent =
-        `— ${r}x, a day in ${fmtDuration(86_400 / r)}`
-      $<HTMLSelectElement>('f-light').disabled = cycle.checked || this.interior?.clockDriven === true
-      if (!this.interior) return
-      this.interior.cycleRate = r
-      this.interior.setCycling(cycle.checked)
+      $('cycle-value').textContent = `— ${r}x, a day in ${fmtDuration(86_400 / r)}`
+      clock.setRate(r)
     }
-    cycle.onchange = applyCycle
-    rate.oninput = applyCycle
-    applyCycle()
-
+    rate.oninput = applyRate
+    applyRate()
+    const cycle = $<HTMLInputElement>('f-cycle')
+    cycle.onchange = () => clock.setRunning(cycle.checked)
     const light = $<HTMLSelectElement>('f-light')
-    light.onchange = () => this.interior?.setLighting(light.value as Lighting)
+    light.value = clock.dial
+    light.onchange = () => { if (isDial(light.value)) clock.setDial(light.value) }
+    // A running clock changes every frame; the panel follows it at most once a frame.
+    let queued = false
+    clock.subscribe(() => {
+      if (queued) return
+      queued = true
+      requestAnimationFrame(() => { queued = false; this.renderTime() })
+    })
+    this.renderTime()
   }
 
+  /** Where the map's sun marker was last drawn, so a running clock redraws it only when it moves. */
+  private mapSunKm = NaN
+
   /**
+   * Everything that follows the clock: the panel's time, where the sun is, the weather board
+   * and its sea state, the map's sun marker and the interior's readout.
+   */
+  private renderTime(): void {
+    const cal = this.world.calendar
+    const sky = skyAt(cal, clock.hours, clock.dial)
+    $('time-now').textContent = `— day ${sky.day}, ${clockText(sky.hourOfDay)}`
+    $('day-value').textContent = `— ${dateLabel(cal, sky.day)}, ${cal.city}`
+    const { sunriseH, sunsetH } = sky.times
+    const speed = LENGTH_M / 1000 / (sunsetH - sunriseH)
+    const where = sky.sunZ !== null
+      ? `The sun is ${km(sky.sunZ)} from the sun cap, moving deepward at ${speed.toFixed(0)} km/h.`
+      : clock.dial === 'dark' ? 'The beam is shut down: no sun, and no night light either.'
+      : 'Night: the sun is out, and the beam banked to a thousandth.'
+    $('sun-now').textContent = `Sunrise ${clockText(sunriseH)}, sunset ${clockText(sunsetH)}. ${where}`
+    const day = $<HTMLInputElement>('f-day')
+    if (document.activeElement !== day && day.value !== String(sky.day)) day.value = String(sky.day)
+    const t = $<HTMLInputElement>('f-time')
+    const mins = Math.floor(sky.hourOfDay * 60) % 1440
+    if (document.activeElement !== t && Number(t.value) !== mins) t.value = String(mins)
+    $('time-value').textContent = `— ${clockText(sky.hourOfDay)}`
+    const cycle = $<HTMLInputElement>('f-cycle')
+    if (cycle.checked !== clock.running) cycle.checked = clock.running
+    this.renderWeather()
+    const sunKm = sky.sunZ === null ? NaN : Math.round(sky.sunZ / 500) / 2
+    if (!Object.is(sunKm, this.mapSunKm)) {
+      this.mapSunKm = sunKm
+      this.map.sunZ = sky.sunZ
+      if (this.view === 'map') this.map.draw()
+    }
+    if (this.view === 'interior') this.renderStanding()
+  }
+
+  /**
+   * The exterior renderer is built on first use.  /**
    * The exterior renderer is built on first use. It pulls in Three.js and starts an
    * animation loop, neither of which a reader who only ever opens the map should pay
    * for.
@@ -402,9 +445,6 @@ export class App {
       const eye = $<HTMLInputElement>('f-eye')
       eye.value = String(Math.round(sliderFromEyeHeight(this.interior.eyeM) * 1000))
       eye.dispatchEvent(new Event('input'))
-      $<HTMLSelectElement>('f-light').value = this.interior.lighting
-      $<HTMLInputElement>('f-cycle').checked = this.interior.cycling
-      $<HTMLSelectElement>('f-light').disabled = this.interior.cycling
     }
     this.interior.resize()
     this.interior.setPlaces(this.world.features.filter((p) => p.geometry.type !== 'band'))
@@ -704,20 +744,9 @@ export class App {
 
   // --------------------------------------------------------------- weather
 
-  /**
-   * World time, in hours since the start of day 0. The interior view owns the clock; when
-   * it has not been opened this session, the time it last saved stands in (interior.ts).
-   */
+  /** World time, in hours since the start of day 0, from the one clock (clock.ts). */
   private worldHours(): number {
-    if (this.interior) return this.interior.worldHours
-    try {
-      const s = JSON.parse(localStorage.getItem('tower.interior') ?? '{}') as Record<string, unknown>
-      const day = typeof s['day'] === 'number' && s['day'] >= 0 ? Math.floor(s['day']) : 0
-      const phase = typeof s['phase'] === 'number' && s['phase'] >= 0 && s['phase'] < 1 ? s['phase'] : 0.5
-      return day * 24 + phase * 24
-    } catch {
-      return 12
-    }
+    return clock.hours
   }
 
   /** The quarter-hour the board was last drawn for, so a running clock redraws it rarely. */
@@ -877,17 +906,6 @@ export class App {
     ].join('\n')
   }
 
-  /**
-   * Put the panel's clock where the world's clock actually is.
-   *
-   * The running cycle advances `dayPhase` inside the view, and the readout was
-   * reading it off there every frame while the slider sat wherever it had last
-   * been dragged — two clocks in the same panel, disagreeing, one of them wrong.
-   *
-   * The slider is left alone while it has focus, so this cannot fight a drag in
-   * progress, and it writes nothing when the value has not changed: `input` on a
-   * range is not free, and the label rebuild would otherwise run every frame.
-   */
   /**
    * The eye-height slider follows the view, as the clock does: keyboard flight changes
    * the height from inside the view, and a slider left where it was would be a second
@@ -910,22 +928,12 @@ export class App {
       `${(gravityAtHeight(m) / G0).toFixed(3)} g`
   }
 
-  private syncTime(): void {
+  /** The tour button says what pressing it will do. */
+  private syncTour(): void {
     const v = this.interior
     if (!v) return
     const tour = v.touring ? 'Stop the tour' : 'Scenic tour'
     if ($('f-tour').textContent !== tour) $('f-tour').textContent = tour
-    const day = $<HTMLInputElement>('f-day')
-    if (document.activeElement !== day && day.value !== String(v.day)) day.value = String(v.day)
-    // The board and the sea-state readout are functions of the clock.
-    this.renderWeather()
-    const t = $<HTMLInputElement>('f-time')
-    if (document.activeElement === t) return
-    const mins = Math.round((((v.dayPhase % 1) + 1) % 1) * 1440) % 1440
-    if (Number(t.value) === mins) return
-    t.value = String(mins)
-    $('time-value').textContent =
-      `— ${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
   }
 
   /**
@@ -936,7 +944,7 @@ export class App {
   private renderStanding(): void {
     const v = this.interior
     if (!v) return
-    this.syncTime()
+    this.syncTour()
     this.syncEye()
     const here = v.at
     const opposite = { theta: here.theta + Math.PI, z: here.z }
diff --git a/src/views/exterior.ts b/src/views/exterior.ts
index f670805..8b7d7f1 100644
--- a/src/views/exterior.ts
+++ b/src/views/exterior.ts
@@ -23,6 +23,9 @@ import {
 import { type SurfacePoint, mapThetaOrigin, normalizeTheta } from '../canon/geometry.js'
 import { terrain } from '../terrain/service.js'
 import { InteriorModel, STAR_GLSL, WINDOW_RENDER_ORDER } from './interior-model.js'
+import { defaultCalendar } from '../world/schema.js'
+import { clock } from '../world/clock.js'
+import { skyAt } from '../world/sky.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
@@ -118,6 +121,9 @@ export class ExteriorView {
   private hullMat: MeshStandardMaterial
   /** The inside of Tower, the same meshes and shaders the interior view stands in. */
   private model: InteriorModel
+  /** The converging rays from the collector, dimmed with the beam at night. */
+  private rays: LineSegments | null = null
+  private frames = 0
   private worldToTower = new Matrix4()
   private eyeInTower = new Vector3()
   private terrainMap: Texture | null = null
@@ -448,7 +454,7 @@ export class ExteriorView {
     const beam = new BufferGeometry()
     beam.setAttribute('position', new Float32BufferAttribute(verts, 3))
     beam.setAttribute('color', new Float32BufferAttribute(cols, 4))
-    const rays = new LineSegments(beam, new LineBasicMaterial({
+    const rays = this.rays = new LineSegments(beam, new LineBasicMaterial({
       vertexColors: true, transparent: true,
       blending: AdditiveBlending, depthWrite: false,
     }))
@@ -712,6 +718,7 @@ export class ExteriorView {
       this.worldToTower.copy(this.tower.matrixWorld).invert()
       this.model.setFrame(this.worldToTower)
       this.model.setCamera(this.eyeInTower.copy(this.camera.position).applyMatrix4(this.worldToTower))
+      this.applySky()
       this.renderer.render(this.scene, this.camera)
       this.updateLabels()
       requestAnimationFrame(tick)
@@ -722,4 +729,17 @@ export class ExteriorView {
   stop(): void {
     this.running = false
   }
+
+  /**
+   * The same sky the interior lights itself by (sky.ts, D-074): the sun where the clock
+   * has it, seen in through the windows, and the rod lit from the sun cap to it. The rays
+   * from the collector carry the beam, so they dim when Tower banks it for the night.
+   */
+  private applySky(): void {
+    const sky = skyAt(terrain().world?.calendar ?? defaultCalendar(), clock.hours, clock.dial)
+    this.model.setSky(sky)
+    if (this.frames++ % 10 === 0) this.model.setWeather(clock.hours)
+    const beam = sky.dial === 'dark' ? 0 : Math.max(sky.power, 0.04)
+    ;(this.rays!.material as LineBasicMaterial).color.setScalar(beam)
+  }
 }
diff --git a/src/views/interior-model.ts b/src/views/interior-model.ts
index caefab9..f566d1b 100644
--- a/src/views/interior-model.ts
+++ b/src/views/interior-model.ts
@@ -15,13 +15,16 @@
 import type { Object3D, Texture } from 'three'
 import {
   AdditiveBlending, BackSide, BufferAttribute, BufferGeometry, CircleGeometry, Color, CylinderGeometry,
-  DoubleSide, FrontSide, Group, InstancedMesh, Matrix4, Mesh, RingGeometry, ShaderMaterial, Vector3, Vector4,
+  DoubleSide, FrontSide, Group, InstancedMesh, Matrix4, Mesh, RingGeometry, ShaderMaterial, SphereGeometry,
+  Vector3, Vector4,
 } from 'three'
 import {
-  BEAM_RADIUS_M, COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M,
+  BEAM_RADIUS_M, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2,
+  COLLECTOR_DIAMETER_M, COLLECTOR_RIM_M, COLLECTOR_STANDOFF_M,
   HULL_DEPTH_M, LENGTH_M, OMEGA_RAD_S, RADIUS_M, RAYLEIGH_FLOOR_PER_M,
-  SPECIFIC_GAS_CONSTANT, TEMPERATURE_K,
+  SPECIFIC_GAS_CONSTANT, SUN_RADIUS_M, TEMPERATURE_K,
 } from '../canon/constants.js'
+import type { Sky } from '../world/sky.js'
 import { PALETTE_GLSL } from '../terrain/palette.js'
 import { LOWEST_GROUND_M } from '../terrain/tile.js'
 import { fallSheet } from '../terrain/falls.js'
@@ -34,6 +37,9 @@ import { TerrainLOD, type TerrainLODOptions } from './terrain-lod.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
+/** The sun's radius, and how far out its glow is drawn, km. */
+const SUN_R = SUN_RADIUS_M / KM
+const SUN_GLOW_R = 24
 const L = LENGTH_M / KM
 const BEAM_R = BEAM_RADIUS_M / KM
 /** Wall depth in km. The far cap's window is set this far into its own frame. */
@@ -161,6 +167,27 @@ vec4 windowColour(vec3 face, vec3 solid, float cover) {
 }
 `
 
+/**
+ * Tower's light (design.md §1.3.4, D-074). By day it comes from the sun: a point on the
+ * axis `uSun.x` km from the sun cap, lit `uSun.y` of the way up. Tower aims the swarm
+ * so the sun is as bright seen from anywhere as from under it, so a surface is lit by how
+ * squarely it faces the sun — as under Earth's — and each place has its noon when the sun
+ * passes over it. By night the sun is out and the beam is banked, and what light there is
+ * comes from the whole rod overhead, `uSun.z` of it.
+ *
+ * Every lit surface in both views goes through `direct`, which is what keeps the sun seen
+ * from the floor and the sun seen in through a window the same sun.
+ */
+const SUN_GLSL = /* glsl */`
+uniform vec3 uSun;
+vec3 sunPos() { return vec3(0.0, 0.0, uSun.x); }
+vec3 sunDir(vec3 p) { return normalize(sunPos() - p); }
+vec3 upAt(vec3 p) { return -normalize(vec3(p.xy, 0.0)); }
+float direct(vec3 p, vec3 n) {
+  return uSun.y * max(dot(n, sunDir(p)), 0.0) + uSun.z * max(dot(n, upAt(p)), 0.0);
+}
+`
+
 /**
  * A terrain tile (world-generation.md §6). Vertices arrive already on the cylinder,
  * relative to the tile's centre, with normals in the tower's frame and material
@@ -193,16 +220,13 @@ void main() {
 const GROUND_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec3 vNormal2;
 varying vec4 vMatA;
 varying vec4 vMatB;
 varying vec2 vExtra;
 ${HAZE_GLSL}
+${SUN_GLSL}
 ${PALETTE_GLSL}
 
 /** Cheap value noise, to keep the ground from being flat colour. */
@@ -263,19 +287,9 @@ void main() {
   float grain = vnoise(surf * 173.0) * 0.55 + vnoise(rot * 611.0) * 0.45;
   base *= 1.0 + (grain - 0.5) * 0.12 * near * land;
 
-  // The beam is a line source on the axis, so the light comes from straight up.
-  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
-  float lambert = clamp(dot(normalize(vNormal2), -radial), 0.0, 1.0);
-  /**
-   * The dawn front (D-053). The beam's own glow comes up everywhere at once — it is
-   * one beam — but the diffusers hand the light down the axis, so the *ground*
-   * lights as a line running deepward at 225 m/s.
-   */
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
-
-  // The rest of the world is lit land, and lit land is what fills the shadows.
-  vec3 lit = base * (0.34 + 0.80 * lambert) * uExposure * daylight;
+  // Lit by the sun wherever on the axis it is (SUN_GLSL). The rest of the world is lit
+  // land, and lit land is what fills the shadows.
+  vec3 lit = base * (0.34 + 0.80 * direct(vWorld, normalize(vNormal2))) * uExposure;
 
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
@@ -417,14 +431,11 @@ void main() {
 const WATER_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec4 vWater;
 varying vec4 vFlow;
 ${HAZE_GLSL}
+${SUN_GLSL}
 ${WAVES_GLSL}
 
 // Periodic every 256 cells, which is what lets surfaceMetres' reference step without a seam.
@@ -479,9 +490,7 @@ void main() {
   // Schlick, with water's 2% at normal incidence.
   float fresnel = 0.02 + 0.98 * pow(1.0 - cosV, 5.0);
 
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
-  float light = uExposure * daylight;
+  float light = uExposure * (0.4 + 0.6 * direct(vWorld, up));
 
   // Deep water is dark blue-green; what it reflects inside Tower is the far side of the
   // world, which from here is haze-blue land.
@@ -491,11 +500,13 @@ void main() {
   vec3 sky = mix(uHaze, vec3(0.30, 0.45, 0.56), 0.35) * light * 0.95;
   vec3 col = mix(body, sky, fresnel);
 
-  // The beam is a line down the axis: water glints where it mirrors the line back.
+  // Water glints where it mirrors the sun back — and by night the banked rod, a line
+  // down the axis. The rod's is kept faint: at full strength a rippled river wore the
+  // glitter as a white snake.
   vec3 refl = reflect(view, n);
   float toAxis = max(0.0, dot(normalize(vec3(refl.xy, 0.0) + 1e-6), up));
-  // Kept faint: at full strength a rippled river wore the glitter as a white snake.
-  col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light;
+  col += vec3(1.0, 0.95, 0.85) * pow(toAxis, 5000.0) * 0.45 * light * uSun.z;
+  col += vec3(1.0, 0.96, 0.88) * pow(max(dot(refl, sunDir(vWorld)), 0.0), 2000.0) * 2.5 * uSun.y * uExposure;
 
   // Foam: rapids and falls, and the edge where the water is only centimetres deep.
   float depth = vWater.x;
@@ -580,14 +591,11 @@ void main() {
 const CANOPY_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec3 vCanopy;
 varying float vLift;
 ${HAZE_GLSL}
+${SUN_GLSL}
 float canopyHash(vec2 p) {
   return fract(sin(dot(mod(p, 512.0), vec2(37.1, 91.7))) * 24634.6345);
 }
@@ -600,7 +608,7 @@ void main() {
   vec3 n = normalize(cross(dFdx(vWorld), dFdy(vWorld)));
   vec3 radial = normalize(vec3(vWorld.xy, 0.0));
   if (dot(n, -radial) < 0.0) n = -n;
-  float lambert = clamp(dot(n, -radial), 0.0, 1.0);
+  float lambert = direct(vWorld, n);
 
   /**
    * Seen from underneath — standing in the wood, looking out through it — a solid skin is
@@ -627,10 +635,8 @@ void main() {
   vec3 base = mix(broadleaf, conifer, clamp(vCanopy.z, 0.0, 1.0));
   // Thinner stands let the floor show through, so they are lighter than closed canopy.
   base *= 1.0 + 0.35 * (1.0 - clamp(vCanopy.y, 0.0, 1.0));
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
   // Under the canopy the light is what has come through it: green, and a tenth as much.
-  vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure * daylight;
+  vec3 lit = base * (under ? 0.55 : 0.22 + 0.90 * lambert) * uExposure;
 
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
@@ -679,27 +685,21 @@ void main() {
 const TREE_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec3 vN;
 varying float vPart;
 varying float vTint;
 ${HAZE_GLSL}
+${SUN_GLSL}
 ${FOLIAGE_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
-  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
   vec3 n = normalize(vN);
-  // The beam is overhead, so a crown is lit on top and its own underside is its shadow.
-  float lambert = clamp(dot(n, -radial), 0.0, 1.0);
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
+  // A crown is lit on the side facing the sun, and its other side is its shadow.
+  float lambert = direct(vWorld, n);
   // Ambient is high for foliage: under a crown the light is what the crowns around it have
   // bounced, and a canopy with none of that reads as pitch black rather than as shade.
-  vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure * daylight;
+  vec3 lit = foliageColour(vPart, vTint) * (0.34 + 0.85 * lambert) * uExposure;
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -743,14 +743,11 @@ void main() {
 const CARD_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec2 vUv;
 varying vec2 vCard;
 ${HAZE_GLSL}
+${SUN_GLSL}
 ${FOLIAGE_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
@@ -788,9 +785,9 @@ void main() {
   if (kind < 2.5 && v < (kind > 1.5 ? 0.55 : kind > 0.5 ? 0.16 : 0.4)) part = 0.0;
   // Lit from overhead: the top of a crown is bright and its foot is in its own shade.
   float shade = 0.22 + 0.52 * smoothstep(0.0, 0.9, v);
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
-  vec3 lit = foliageColour(part, vCard.y) * shade * uExposure * daylight;
+  // As bright as a tree mesh's crown at the handover: the same sun, the same curve.
+  float sun = (0.34 + 0.85 * direct(vWorld, upAt(vWorld))) / 1.19;
+  vec3 lit = foliageColour(part, vCard.y) * shade * sun * uExposure;
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -821,22 +818,17 @@ const SHIP_FRAG = /* glsl */`
 #include <common>
 #include <logdepthbuf_pars_fragment>
 uniform vec3 uColour;
-uniform float uFrontZ;
-uniform float uFrontWidth;
-uniform float uBelowFront;
-uniform float uAboveFront;
 varying vec3 vWorld;
 varying vec3 vN;
 ${HAZE_GLSL}
+${SUN_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
-  vec3 radial = normalize(vec3(vWorld.xy, 0.0));
-  // Both faces of a sail are lit: the beam is overhead, whichever way it has swung.
-  float lambert = abs(dot(normalize(vN), -radial));
-  float side = smoothstep(uFrontZ - uFrontWidth, uFrontZ, vWorld.z);
-  float daylight = mix(uBelowFront, uAboveFront, side);
+  vec3 n = normalize(vN);
+  // Both faces of a sail are lit, whichever way it has swung.
+  float lambert = uSun.y * abs(dot(n, sunDir(vWorld))) + uSun.z * abs(dot(n, upAt(vWorld)));
   // Three.Color already converts its hex to linear.
-  vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure * daylight;
+  vec3 lit = uColour * (0.34 + 0.8 * lambert) * uExposure;
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(lit * T + uHaze * uExposure * (1.0 - T), 1.0);
   #include <colorspace_fragment>
@@ -952,6 +944,7 @@ uniform float uHeightM;
 varying vec3 vWorld;
 varying vec2 vUv;
 ${HAZE_GLSL}
+${SUN_GLSL}
 float fHash(vec2 p) { return fract(sin(dot(p, vec2(12.9898, 78.233))) * 43758.5453); }
 float fNoise(vec2 p) {
   vec2 i = floor(p), f = fract(p);
@@ -967,7 +960,8 @@ void main() {
   float streak = fNoise(vec2(vUv.x * 18.0, s * 3.0)) * 0.6 + fNoise(vec2(vUv.x * 55.0, s * 9.0)) * 0.4;
   float edge = smoothstep(0.0, 0.12, vUv.x) * smoothstep(1.0, 0.88, vUv.x);
   float alpha = (0.55 + 0.45 * streak) * edge * (1.0 - 0.6 * vUv.y);
-  vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak);
+  vec3 col = vec3(0.86, 0.9, 0.93) * uExposure * (0.75 + 0.35 * streak)
+    * (0.45 + 0.6 * direct(vWorld, upAt(vWorld)));
   vec3 T = towerTransmittance(uCam, vWorld);
   gl_FragColor = vec4(col * T + uHaze * uExposure * (1.0 - T), alpha);
   #include <colorspace_fragment>
@@ -1196,6 +1190,7 @@ uniform vec3 uTint;
 uniform sampler2D uGlazing;
 varying vec3 vWorld;
 ${HAZE_GLSL}
+${SUN_GLSL}
 ${STAR_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
@@ -1224,7 +1219,9 @@ void main() {
   float rc = length(vWorld.xy) / ${R.toFixed(1)};
   float mirror = 1.0 - smoothstep(${(MIRROR_UV_RADIUS * 0.86).toFixed(5)},
                                   ${MIRROR_UV_RADIUS.toFixed(5)}, rc);
-  vec3 glow = mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6;
+  // By day the sun's shoal takes the whole beam before it gets here (D-074): only the
+  // banked beam of the night reaches the mirror.
+  vec3 glow = mirror * vec3(1.0, 0.96, 0.88) * uExposure * 2.6 * uSun.z;
   gl_FragColor = windowColour(face + glow, mullion + glow, frame.a);
   #include <colorspace_fragment>
 }
@@ -1256,14 +1253,57 @@ uniform vec3 uBeam;
 varying vec3 vWorld;
 varying vec3 vNormal2;
 ${HAZE_GLSL}
+${SUN_GLSL}
 void main() {
   #include <logdepthbuf_fragment>
   vec3 view = normalize(vWorld - uCam);
   // Additive white clips long before the limb, so the falloff has to start early
   // or the shaft renders as a hard-edged slab instead of a glowing rod.
   float through = pow(clamp(abs(dot(vNormal2, view)), 0.0, 1.0), 1.1);
+  /**
+   * By day the sun's shoal throws the whole beam out sideways where it gathers (D-074), so
+   * the rod glows from the sun cap to the sun and is dark past it. By night the banked
+   * beam runs the whole length again.
+   */
+  float spent = smoothstep(uSun.x - 1.5, uSun.x + 1.5, vWorld.z) * uSun.y;
   vec3 T = towerTransmittance(uCam, vWorld);
-  gl_FragColor = vec4(uBeam * uExposure * through * T, 1.0);
+  gl_FragColor = vec4(uBeam * uExposure * through * (1.0 - spent) * T, 1.0);
+  #include <colorspace_fragment>
+}
+`
+
+/**
+ * Tower's sun (D-074): the swarm gathered into one shoal on the beam, throwing the beam's
+ * light out sideways. It is drawn on a sphere well outside the disc, and every pixel works
+ * out how close its line of sight passes to the sun's centre: inside the disc it is far
+ * brighter than white, and outside it falls off as glare. One mesh, so the disc and its
+ * glow cannot drift apart, and the same haze as everything else between it and the eye —
+ * which is what makes it the same sun from the floor, from the air, and through a window.
+ */
+const SUN_FRAG = /* glsl */`
+#include <common>
+#include <logdepthbuf_pars_fragment>
+varying vec3 vWorld;
+${HAZE_GLSL}
+${SUN_GLSL}
+void main() {
+  #include <logdepthbuf_fragment>
+  // Each pixel once: the near face from outside the glow, the far face from inside it.
+  bool outside = distance(uCam, sunPos()) > ${SUN_GLOW_R.toFixed(1)};
+  if (gl_FrontFacing != outside) discard;
+  vec3 v = normalize(vWorld - uCam);
+  vec3 oc = sunPos() - uCam;
+  float along = dot(oc, v);
+  if (along <= 0.0) discard;
+  // How far the line of sight passes from the sun's centre, km.
+  float d = length(oc - v * along);
+  float disc = 1.0 - smoothstep(${(SUN_R * 0.9).toFixed(3)}, ${SUN_R.toFixed(3)}, d);
+  float glare = 0.9 * exp(-max(d - ${SUN_R.toFixed(3)}, 0.0) / 1.6)
+              + 0.22 * exp(-d / 7.0);
+  glare *= 1.0 - smoothstep(${(SUN_GLOW_R * 0.6).toFixed(1)}, ${SUN_GLOW_R.toFixed(1)}, d);
+  vec3 T = towerTransmittance(uCam, sunPos());
+  vec3 col = vec3(1.0, 0.96, 0.86) * (disc * 40.0 + glare * uExposure) * uSun.y;
+  gl_FragColor = vec4(col * T, 1.0);
   #include <colorspace_fragment>
 }
 `
@@ -1281,7 +1321,7 @@ export interface InteriorModelOptions {
 }
 
 /** Where the see-through windows draw among the transparent things (see `buildCaps`). */
-export const WINDOW_RENDER_ORDER = 2.5
+export const WINDOW_RENDER_ORDER = 4
 
 export class InteriorModel {
   /** Every material here, so a caller can set a shared uniform in one loop. */
@@ -1291,6 +1331,8 @@ export class InteriorModel {
   readonly canopyMat: ShaderMaterial
   /** The trees near the eye, and the cards beyond them (forest-view.ts). */
   readonly forest: ForestView
+  /** Tower's sun, moved along the axis by `setSky`. */
+  readonly sun: Mesh
   /** Every waterfall in the world, rebuilt when the world is. */
   readonly falls = new Group()
   /** The generated ground: a quadtree of tiles, refined around the eye. */
@@ -1337,7 +1379,8 @@ export class InteriorModel {
     this.hulls = fleet(hullGeometry(), 0x4a3526, 'hulls')
     this.sails = fleet(sailGeometry(), 0xe6ddc8, 'sails')
     this.jibs = fleet(jibGeometry(), 0xe6ddc8, 'jibs')
-    this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam()]
+    this.sun = this.buildSun()
+    this.meshes = [this.ground, this.falls, this.forest.group, this.hulls, this.sails, this.jibs, ...this.buildCaps(), this.buildBeam(), this.sun]
     terrain().onWorld(() => this.buildFalls())
     if (terrain().compiled) this.buildFalls()
   }
@@ -1370,11 +1413,12 @@ export class InteriorModel {
       // Copy the shared uniforms from the ground, so a sheet built mid-session starts in
       // the light of the moment rather than at the defaults.
       const donor = this.groundMat.uniforms
-      for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime', 'uFrontZ', 'uBelowFront', 'uAboveFront']) {
+      for (const key of ['uExposure', 'uStarGain', 'uSpin', 'uTime']) {
         m.uniforms[key]!.value = donor[key]!.value
       }
       ;(m.uniforms['uHaze']!.value as Color).copy(donor['uHaze']!.value as Color)
       ;(m.uniforms['uCam']!.value as Vector3).copy(donor['uCam']!.value as Vector3)
+      ;(m.uniforms['uSun']!.value as Vector3).copy(donor['uSun']!.value as Vector3)
       ;(m.uniforms['uFrame']!.value as Matrix4).copy(donor['uFrame']!.value as Matrix4)
       const mesh = new Mesh(g, m)
       mesh.name = `fall ${f.river}`
@@ -1531,11 +1575,8 @@ export class InteriorModel {
       // Seconds, for anything that moves: the water.
       uTime: { value: 0 },
       uStarGain: { value: 0 },
-      // Only the ground declares these; carried here so one loop can set them.
-      uFrontZ: { value: 0 },
-      uFrontWidth: { value: 24 },
-      uBelowFront: { value: 1 },
-      uAboveFront: { value: 1 },
+      // The sun's position (km), how lit it is, and the banked rod's share (SUN_GLSL).
+      uSun: { value: new Vector3(L / 2, 1, 0) },
     }
   }
 
@@ -1587,12 +1628,13 @@ export class InteriorModel {
     }))
     reveal.name = 'bore'
     /**
-     * From outside, a window is drawn after the water (1) and the falls (2) behind it.
-     * Left at 0 it went first, wrote its depth, and every sea behind the glass failed the
-     * depth test — so looking in at the sun cap showed the Sunward Sea's bed, brown, where
-     * the sea should have been. It still goes before the beam (3), which is therefore
-     * hidden behind the glass as it always has been. The exterior's own transparent things
-     * share this order (exterior.ts), so they still sort against the glass by distance.
+     * From outside, a window is drawn after everything behind it: the water (1), the
+     * falls (2), the beam (3) and the sun (3.5). Left at 0 it went first, wrote its depth,
+     * and whatever was behind the glass failed the depth test — looking in at the sun cap
+     * showed the Sunward Sea's bed, brown, where the sea should have been, and the rod and
+     * the sun were missing from a view that has to show the same sky as the one inside
+     * (D-074). The exterior's own transparent things share this order (exterior.ts), so they
+     * still sort against the glass by distance.
      */
     if (this.opts.seeThrough) sun.renderOrder = far.renderOrder = WINDOW_RENDER_ORDER
     return [sun, far, reveal]
@@ -1617,4 +1659,42 @@ export class InteriorModel {
     beam.renderOrder = 3
     return beam
   }
+
+  /** After the beam it sits in, and before the windows, which are in front of it from outside. */
+  private buildSun(): Mesh {
+    const m = this.material(FLAT_VERT, SUN_FRAG, { side: DoubleSide, additive: true, uniforms: {} })
+    const sun = new Mesh(new SphereGeometry(SUN_GLOW_R, 48, 24), m)
+    sun.name = 'sun'
+    sun.renderOrder = 3.5
+    sun.frustumCulled = false
+    return sun
+  }
+
+  /**
+   * Light the model for a moment of Tower's day (sky.ts). Both views call this with the
+   * same sky, so the sun, the rod and the light on the ground agree inside and out.
+   *
+   * Eyes adapt, so twilight is not 6.7x darker than temperate to look at: the exponent
+   * compresses the canon watts into something a screen can show while keeping the ordering
+   * honest. The stars are the other half of that — always exactly as bright as they are,
+   * and whether you can see them depends on what the eye is set to: full at night, 2% at
+   * temperate, which is the honest answer to why nobody looks out at lunchtime.
+   */
+  setSky(sky: Sky): { exposure: number; haze: Color } {
+    const exposure = (sky.watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
+    const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(sky.watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
+    const haze = new Color(sky.watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
+    const zKm = (sky.sunZ ?? 0) / KM
+    // The banked rod lights the night; with the beam shut down there is not even that.
+    const rod = sky.dial === 'dark' ? 0 : 1 - sky.power
+    for (const m of this.materials) {
+      m.uniforms['uExposure']!.value = exposure
+      m.uniforms['uStarGain']!.value = starGain
+      ;(m.uniforms['uHaze']!.value as Color).copy(haze)
+      ;(m.uniforms['uSun']!.value as Vector3).set(zKm, sky.power, rod)
+    }
+    this.sun.position.set(0, 0, zKm)
+    this.sun.visible = sky.power > 0
+    return { exposure, haze }
+  }
 }
diff --git a/src/views/interior.ts b/src/views/interior.ts
index 12da44f..c0282e0 100644
--- a/src/views/interior.ts
+++ b/src/views/interior.ts
@@ -21,49 +21,27 @@ import {
   Color, Group, Mesh, PerspectiveCamera, Scene, ShaderMaterial, Vector3, WebGLRenderer,
 } from 'three'
 import {
-  BEAM_RADIUS_M, BRIGHTNESS_FULL_W_M2, DAY_LENGTH_S, DAY_SWEEP_FRACTION,
-  BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2, BRIGHTNESS_TWILIGHT_W_M2, G0,
-  LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight,
+  BEAM_RADIUS_M, G0, LENGTH_M, OMEGA_RAD_S, RADIUS_M, gravityAtHeight,
 } from '../canon/constants.js'
 import { type SurfacePoint, normalizeTheta } from '../canon/geometry.js'
-import { type Feature, anchorOf } from '../world/schema.js'
+import { type Feature, anchorOf, defaultCalendar } from '../world/schema.js'
+import { clock } from '../world/clock.js'
+import { type Sky, clockText, dateLabel, skyAt } from '../world/sky.js'
 import { terrain } from '../terrain/service.js'
 import { EYE_MAX_M, EYE_MIN_M } from '../ui/scales.js'
-import { HAZE_DAY, HAZE_DUSK, InteriorModel, K_GREEN } from './interior-model.js'
+import { HAZE_DAY, InteriorModel, K_GREEN } from './interior-model.js'
 import { DESKTOP_LOD, PHONE_LOD } from './terrain-lod.js'
 import { type Pose, SCENE_S, type ScenePlan, planTour } from './scenic.js'
 
 const KM = 1000
 const R = RADIUS_M / KM
-const L = LENGTH_M / KM
 
-/**
- * The five settings, darkest first.
- *
- * `dark` is the beam shut down entirely and is the only one that is genuinely
- * black — with 54 TW off there is no airlight, no lit ground and no rod overhead,
- * and the only thing left in the world is the sky through the far cap. `night` is
- * the beam banked rather than killed, which is what night has to be here: §1.3
- * gives the glow to the beam and the air rather than to the diffusers, so aiming
- * the swarm away would not darken anything (D-046).
- */
 /** The pinwheel's period, the one interval in Tower you can time against the sky. */
 const fmtSpin = (): string => {
   const s = (2 * Math.PI) / OMEGA_RAD_S
   return `${Math.floor(s / 60)} min ${Math.round(s % 60)} s`
 }
 
-export const LIGHTING = {
-  dark: 0,
-  night: BRIGHTNESS_NIGHT_W_M2,
-  twilight: BRIGHTNESS_TWILIGHT_W_M2,
-  temperate: BRIGHTNESS_TEMPERATE_W_M2,
-  full: BRIGHTNESS_FULL_W_M2,
-} as const
-export type Lighting = keyof typeof LIGHTING
-export const isLighting = (v: unknown): v is Lighting =>
-  typeof v === 'string' && v in LIGHTING
-
 /**
  * How far a two-finger drag can reach, as a multiple of eye height, and as a
  * hard ceiling in kilometres.
@@ -133,7 +111,8 @@ interface TourState {
   /** Held at one moment, for the screenshot tour: no fades, no time passing. */
   frozen: boolean
   /** The clock as it was, given back when the tour stops. */
-  saved: { day: number; dayPhase: number; cycling: boolean }
+  /** The clock as the tour found it, to hand back when it stops. */
+  saved: { hours: number; running: boolean }
 }
 
 /**
@@ -221,27 +200,10 @@ export class InteriorView {
   /** Elevation angle, radians. Clamped just short of straight up and down. */
   pitch = 0
   fovDeg = 60
-  lighting: Lighting = 'temperate'
   /** Seconds this view has run, for the water. */
   private time = 0
   /** Phase of the star pinwheel, radians. The only thing in the view that turns. */
   private spin = 0
-  /** Time of day, 0..1. 0 is midnight, 0.5 is noon. */
-  dayPhase = 0.5
-  /**
-   * Which day it is. The calendar starts at day 0 (D-062), and the weather plan and the
-   * ships are pure functions of day and hour, so this is the only clock they need.
-   */
-  day = 0
-  /** Whether the clock is running, and how many world seconds pass per real one. */
-  cycling = false
-  cycleRate = 900
-  /**
-   * Whether the clock, rather than the Light menu, is deciding the light. Set by
-   * the panel: ticking the box or touching the time slider hands control over.
-   */
-  clockDriven = false
-
   private labels: { place: Feature; el: HTMLSpanElement }[] = []
   /** The scenic tour, while one is playing (scenic.ts). */
   private tour: TourState | null = null
@@ -414,163 +376,46 @@ export class InteriorView {
     return Math.abs(around) + Math.abs(dz) < 1e-6 ? this.yaw : Math.atan2(around, dz)
   }
 
-  /**
-   * Where the day is, as a light level and a front position.
-   *
-   * Dawn and dusk are the interesting part and they are not a dimmer. The beam
-   * glows by itself (§1.3, D-046), so its power — and with it the sky, the airlight
-   * and the rod overhead — has to come up globally; there is no way to make one end
-   * of the axis brighter than the other. What the swarm *can* do is choose where to
-   * put the light once it is there, and it hands it down the world as a front. So a
-   * Tower morning is a bright sky over dark ground, with a line of daylight coming
-   * toward you at 225 m/s.
-   */
-  private sampleDay(): { watts: number; frontZ: number; below: number; above: number; kind: 'dawn' | 'dusk' | null } {
-    const p = ((this.dayPhase % 1) + 1) % 1
-    const sweep = DAY_SWEEP_FRACTION
-    const DAWN = 0.25
-    const DUSK = 0.75
-    const smooth = (e0: number, e1: number, x: number): number => {
-      const t = Math.min(1, Math.max(0, (x - e0) / (e1 - e0)))
-      return t * t * (3 - 2 * t)
-    }
-    // Daylight everywhere, ignoring the front: up through dawn, down through dusk.
-    const up = smooth(DAWN, DAWN + sweep, p)
-    const down = smooth(DUSK, DUSK + sweep, p)
-    const day = up - down
-    // Noon is Earth clear-sky full; the rest of the day sits nearer temperate.
-    const shape = day * (0.62 + 0.38 * Math.sin(Math.PI * Math.min(1, Math.max(0, (p - DAWN) / (DUSK - DAWN)))))
-    const watts = BRIGHTNESS_NIGHT_W_M2 + (BRIGHTNESS_FULL_W_M2 - BRIGHTNESS_NIGHT_W_M2) * shape
-
-    /**
-     * The front only exists while it is crossing. `below`/`above` are multipliers on
-     * the ground's share of the light, so the side that has not had its morning yet
-     * stays dark under a sky that is already bright.
-     */
-    let frontZ = -1e6, below = 1, above = 1
-    let kind: 'dawn' | 'dusk' | null = null
-    const nightShare = 0.04
-    if (p >= DAWN && p < DAWN + sweep) {
-      frontZ = ((p - DAWN) / sweep) * L
-      below = 1
-      above = nightShare
-      kind = 'dawn'
-    } else if (p >= DUSK && p < DUSK + sweep) {
-      frontZ = ((p - DUSK) / sweep) * L
-      below = nightShare
-      above = 1
-      kind = 'dusk'
-    }
-    return { watts, frontZ, below, above, kind }
+  /** Tower's sky right now: the shared clock, read against the world's calendar (sky.ts). */
+  get sky(): Sky {
+    return skyAt(terrain().world?.calendar ?? defaultCalendar(), clock.hours, clock.dial)
   }
 
   /**
-   * Where the dawn line is relative to the observer, when there is one. Naming the
-   * distance is the point: a front 90 km deepward arrives in about seven minutes,
-   * and that is a thing you can wait for.
+   * Where the sun is from here, when it is up: how high, and how far along the axis.
+   * Naming the distance is the point — each place has its noon when the sun passes
+   * over it, and a sun 90 km sunward and coming is a morning you can wait out.
    */
-  private frontNote(): string {
-    const { frontZ, kind } = this.sampleDay()
-    if (!kind) return ''
-    /**
-     * Sign matters and is easy to get backwards: z counts deepward from the sun
-     * cap, and the front always travels deepward. So a front at a *lower* z than
-     * the observer is sunward of them and still coming; a higher one has already
-     * gone past.
-     */
-    const d = frontZ - this.at.z / KM
-    if (Math.abs(d) < 1) return ` · the ${kind} line is here`
-    return d < 0
-      ? ` · ${kind} ${(-d).toFixed(0)} km sunward, coming`
-      : ` · ${kind} passed, ${d.toFixed(0)} km deepward`
+  private sunNote(sky: Sky): string {
+    const cal = terrain().world?.calendar ?? defaultCalendar()
+    if (sky.sunZ === null) {
+      const next = sky.hourOfDay < sky.times.sunriseH ? 'today' : 'tomorrow'
+      return ` · night, ${cal.city} sunrise ${next}`
+    }
+    const along = (sky.sunZ - this.at.z) / KM
+    const up = (RADIUS_M - this.elevationM - this.eyeM) / KM
+    const elevation = (Math.atan2(up, Math.abs(along)) * 180) / Math.PI
+    if (Math.abs(along) < 1) return ' · the sun is overhead'
+    return ` · sun ${elevation.toFixed(0)}° up, ${Math.abs(along).toFixed(0)} km ${along < 0 ? 'sunward' : 'deepward'}`
   }
 
-  /** Human clock, for the readout. Tower keeps Earth's 24 hours (D-053). */
+  /** Human clock, for the readout: the calendar city's date and time (D-074). */
   get clock(): string {
-    const secs = (((this.dayPhase % 1) + 1) % 1) * DAY_LENGTH_S
-    const h = Math.floor(secs / 3600)
-    const m = Math.floor((secs % 3600) / 60)
-    return `day ${this.day}, ${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}`
+    const cal = terrain().world?.calendar ?? defaultCalendar()
+    return `day ${clock.day}, ${dateLabel(cal, clock.day)}, ${clockText(clock.hourOfDay)}`
   }
 
-  /** Hours since the start of day 0: what the weather and the ships are functions of. */
+  /** Hours since the start of day 0: what the weather, the ships and the sun follow. */
   get worldHours(): number {
-    return this.day * 24 + (((this.dayPhase % 1) + 1) % 1) * 24
-  }
-
-  setDay(day: number): void {
-    this.day = Math.max(0, Math.floor(day))
-    this.model.setWeather(this.worldHours)
-    this.cb.onLook()
-    this.queueSave()
-  }
-
-  /**
-   * Put the clock at a given time of day. Works whether or not the cycle is
-   * running — the front and the light level are a function of the phase, not of
-   * anything animating, so scrubbing is the same operation as ticking.
-   */
-  setDayPhase(p: number): void {
-    this.dayPhase = ((p % 1) + 1) % 1
-    this.model.setWeather(this.worldHours)
-    this.applyLighting()
-    this.cb.onLook()
-    this.queueSave()
-  }
-
-  setCycling(on: boolean): void {
-    this.cycling = on
-    if (on) this.clockDriven = true
-    if (on) this.applyLighting()
-    else {
-      // Hand the manual settings back an unswept world.
-      for (const m of this.shared) {
-        m.uniforms['uBelowFront']!.value = 1
-        m.uniforms['uAboveFront']!.value = 1
-      }
-      this.applyLighting()
-    }
-    this.queueSave()
+    return clock.hours
   }
 
+  /** Light the world for the clock's moment. Every frame, so a running clock is smooth. */
   private applyLighting(): void {
-    /**
-     * Eyes adapt, so twilight is not 6.7x darker than temperate to look at. The
-     * exponent compresses the canon watts into something a screen can show while
-     * keeping the ordering and the ratios honest.
-     */
-    const day = this.clockDriven ? this.sampleDay() : null
-    const watts = day ? day.watts : LIGHTING[this.lighting]
-    if (day) {
-      for (const m of this.shared) {
-        m.uniforms['uFrontZ']!.value = day.frontZ
-        m.uniforms['uBelowFront']!.value = day.below
-        m.uniforms['uAboveFront']!.value = day.above
-      }
-    }
-    const e = (watts / BRIGHTNESS_TEMPERATE_W_M2) ** 0.35
-    /**
-     * The other half of "eyes adapt": the stars are always exactly as bright as
-     * they are, and whether you can see them depends entirely on what your eyes
-     * are currently set to. Full at night and below, gone by noon — 2% at
-     * temperate, which is the honest answer to why nobody looks out at lunchtime.
-     */
-    const starGain = (BRIGHTNESS_NIGHT_W_M2 / Math.max(watts, BRIGHTNESS_NIGHT_W_M2)) ** 0.6
-    const haze = new Color(watts <= BRIGHTNESS_TWILIGHT_W_M2 ? HAZE_DUSK : HAZE_DAY)
-    for (const m of this.shared) {
-      m.uniforms['uExposure']!.value = e
-      m.uniforms['uStarGain']!.value = starGain
-        ; (m.uniforms['uHaze']!.value as Color).copy(haze)
-    }
+    const { exposure, haze } = this.model.setSky(this.sky)
     // Scaled, so `dark` clears to black rather than to a dusk-coloured sky that
     // nothing in the scene is bright enough to justify.
-    this.renderer.setClearColor(haze.clone().multiplyScalar(e), 1)
-  }
-
-  setLighting(l: Lighting): void {
-    this.lighting = l
-    this.applyLighting()
-    this.queueSave()
+    this.renderer.setClearColor(haze.clone().multiplyScalar(exposure), 1)
   }
 
   // ------------------------------------------------------------------- labels
@@ -846,7 +691,6 @@ export class InteriorView {
       localStorage.setItem(STATE_KEY, JSON.stringify({
         theta: this.at.theta, z: this.at.z, eye: this.eyeM,
         yaw: this.yaw, pitch: this.pitch, fov: this.fovDeg,
-        day: this.day, phase: this.dayPhase,
       }))
     } catch {
       // Not persisting where you stood is survivable.
@@ -872,11 +716,7 @@ export class InteriorView {
       if (yaw !== null) this.yaw = normalizeTheta(yaw)
       this.pitch = num(s['pitch'], -1.55, 1.55) ?? this.pitch
       this.fovDeg = num(s['fov'], 3, 90) ?? this.fovDeg
-      this.day = Math.floor(num(s['day'], 0, 1e6) ?? this.day)
-      this.dayPhase = num(s['phase'], 0, 1) ?? this.dayPhase
-      if (isLighting(s['lighting'])) {
-        this.lighting = s['lighting']
-      }
+      // The time lived here once; the shared clock picks up whatever it was (clock.ts).
     } catch {
       // Unreadable storage just means standing in the capital again.
     }
@@ -904,7 +744,7 @@ export class InteriorView {
         `looking    ${deg(heading).toFixed(0)}° ${compass}, ${deg(this.pitch) >= 0 ? '+' : ''}${deg(this.pitch).toFixed(0)}°`,
         `field      ${this.fovDeg.toFixed(0)}°`,
         `beam       ${deg(beamAngle).toFixed(2)}° wide overhead`,
-        ...(this.clockDriven ? [`time       ${this.clock}${this.frontNote()}`] : []),
+        `time       ${this.clock}${this.sunNote(this.sky)}`,
         // The pinwheel replaced the rain as the spin's evidence here (D-049), so
         // the readout names the period you can actually time against the sky.
         `sky turns  once in ${fmtSpin()}, about the axis`,
@@ -1015,15 +855,13 @@ export class InteriorView {
         theta: this.at.theta, z: this.at.z, eyeM: this.eyeM,
         yaw: this.yaw, pitch: this.pitch, fovDeg: this.fovDeg,
         groundKm: this.groundKm(this.at),
-        lighting: this.lighting, day: this.day, dayPhase: this.dayPhase, clockDriven: this.clockDriven,
+        dial: clock.dial, hours: clock.hours, running: clock.running,
       },
       uniforms: {
         uCam: (this.groundMat.uniforms['uCam']!.value as Vector3).toArray(),
         uExposure: this.groundMat.uniforms['uExposure']!.value,
         uHaze: (this.groundMat.uniforms['uHaze']!.value as Color).getHex(),
-        uFrontZ: this.groundMat.uniforms['uFrontZ']!.value,
-        uBelowFront: this.groundMat.uniforms['uBelowFront']!.value,
-        uAboveFront: this.groundMat.uniforms['uAboveFront']!.value,
+        uSun: (this.groundMat.uniforms['uSun']!.value as Vector3).toArray(),
         uSpin: this.groundMat.uniforms['uSpin']!.value,
         uStarGain: this.groundMat.uniforms['uStarGain']!.value,
       },
@@ -1070,14 +908,8 @@ export class InteriorView {
      * once every 7 min 29 s about the one motionless star on the axis. It is the
      * only unambiguous evidence of the spin anywhere in the view (D-045).
      */
-      if (this.cycling && !this.tour) {
-        const next = this.dayPhase + (dt * this.cycleRate) / DAY_LENGTH_S
-        // Midnight turns the calendar over.
-        this.day += Math.floor(next)
-        this.dayPhase = next % 1
-        this.applyLighting()
-        this.cb.onLook()
-      }
+      // The clock runs by itself (clock.ts); the light follows it every frame.
+      this.applyLighting()
       this.spin = (this.spin + OMEGA_RAD_S * dt) % (2 * Math.PI)
       this.time += dt
       // The sea state changes over hours; a few times a second is plenty.
@@ -1157,11 +989,13 @@ export class InteriorView {
   startTour(fromId?: string): boolean {
     const plans = this.planTour()
     if (!plans.length) return false
-    const saved = this.tour?.saved ?? { day: this.day, dayPhase: this.dayPhase, cycling: this.cycling }
+    const saved = this.tour?.saved ?? { hours: clock.hours, running: clock.running }
     this.tour = {
       plans, index: Math.max(0, plans.findIndex((p) => p.id === fromId)), t: 0,
       phase: 'out', fade: 0, waited: 0, frozen: false, saved,
     }
+    // Each scene sets its own time; a running clock would fight it.
+    clock.setRunning(false)
     this.cb.onLook()
     return true
   }
@@ -1179,9 +1013,8 @@ export class InteriorView {
     this.tourFade.hidden = true
     this.tourCaption.hidden = true
     // The clock goes back to where it was; the observer stays where the tour left them.
-    this.day = tour.saved.day
-    this.dayPhase = tour.saved.dayPhase
-    this.cycling = tour.saved.cycling
+    clock.set(tour.saved.hours)
+    clock.setRunning(tour.saved.running)
     this.model.setWeather(this.worldHours)
     this.applyLighting()
     this.place()
@@ -1197,8 +1030,9 @@ export class InteriorView {
     const plans = this.planTour()
     const index = plans.findIndex((p) => p.id === id)
     if (index < 0) return null
-    const saved = this.tour?.saved ?? { day: this.day, dayPhase: this.dayPhase, cycling: this.cycling }
+    const saved = this.tour?.saved ?? { hours: clock.hours, running: clock.running }
     this.tour = { plans, index, t, phase: 'play', fade: 0, waited: 0, frozen: true, saved }
+    clock.setRunning(false)
     this.applyScene(plans[index]!, t)
     this.tourFade.hidden = true
     this.tourCaption.hidden = true
@@ -1248,10 +1082,9 @@ export class InteriorView {
   /** Put the observer and the clock where a scene has them at `t` seconds. */
   private applyScene(plan: ScenePlan, t: number): void {
     const hours = plan.hours + t / 3600
-    this.day = Math.floor(hours / 24)
-    this.dayPhase = (hours - this.day * 24) / 24
+    clock.set(hours)
     this.model.setWeather(hours)
-    if (this.clockDriven) this.applyLighting()
+    this.applyLighting()
     this.applyPose(plan.pose(t))
   }
 
diff --git a/src/views/map.ts b/src/views/map.ts
index 7a1b9ce..d6ac0bf 100644
--- a/src/views/map.ts
+++ b/src/views/map.ts
@@ -9,7 +9,7 @@
  * Unrolling a cylinder is exact — no projection distortion anywhere on this map.
  */
 import {
-  CIRCUMFERENCE_M, LENGTH_M,
+  CIRCUMFERENCE_M, LENGTH_M, RADIUS_M,
 } from '../canon/constants.js'
 import { landStripCount, slatCount, slatWidth } from '../canon/slats.js'
 import {
@@ -42,6 +42,11 @@ export class TowerMap {
   showTerrain = true
   /** Place names and the caps' captions. The scale bar stays: it is a measure, not a label. */
   showLabels = true
+  /**
+   * Where Tower's sun is, metres from the sun cap, or null at night (sky.ts). The map
+   * shades the world by the light it throws, with the same curve the 3D views light by.
+   */
+  sunZ: number | null = null
 
   private panning: { x: number; y: number; ox: number; oy: number } | null = null
   private moved = false
@@ -263,6 +268,7 @@ export class TowerMap {
     ctx.fillRect(0, 0, width, height)
 
     this.drawSurface()
+    this.drawDaylight()
     this.drawCaps()
     this.drawPlaces()
     this.drawProbe()
@@ -364,6 +370,52 @@ export class TowerMap {
     }
   }
 
+  /**
+   * The day on the map: a soft darkening wherever the sun is low, and a line where it is
+   * overhead. Ground in the 3D views is lit as 0.34 + 0.8·sin(elevation) of its noon, so the
+   * shade here follows the same curve, held to a third so the map stays a map.
+   */
+  private drawDaylight(): void {
+    const ctx = this.ctx
+    const [x0, y0] = this.worldToScreen(0, 0)
+    const w = LENGTH_M * this.scale
+    const h = CIRCUMFERENCE_M * this.scale
+    if (this.sunZ === null) {
+      ctx.fillStyle = 'rgba(4,6,14,0.34)'
+      ctx.fillRect(x0, y0, w, h)
+      return
+    }
+    const grad = ctx.createLinearGradient(x0, 0, x0 + w, 0)
+    for (let i = 0; i <= 24; i++) {
+      const z = (i / 24) * LENGTH_M
+      const sin = RADIUS_M / Math.hypot(RADIUS_M, z - this.sunZ)
+      const light = (0.34 + 0.8 * sin) / 1.14
+      grad.addColorStop(i / 24, `rgba(4,6,14,${(0.34 * (1 - light)).toFixed(3)})`)
+    }
+    ctx.fillStyle = grad
+    ctx.fillRect(x0, y0, w, h)
+
+    const [sx] = this.worldToScreen(this.sunZ, 0)
+    const glow = ctx.createLinearGradient(sx - 40, 0, sx + 40, 0)
+    glow.addColorStop(0, 'rgba(255,236,190,0)')
+    glow.addColorStop(0.5, 'rgba(255,236,190,0.22)')
+    glow.addColorStop(1, 'rgba(255,236,190,0)')
+    ctx.fillStyle = glow
+    ctx.fillRect(sx - 40, y0, 80, h)
+    ctx.lineWidth = 2
+    ctx.strokeStyle = 'rgba(255,240,205,0.9)'
+    ctx.beginPath()
+    ctx.moveTo(sx, y0)
+    ctx.lineTo(sx, y0 + h)
+    ctx.stroke()
+    if (!this.showLabels) return
+    ctx.font = '11px ui-monospace, SFMono-Regular, Menlo, monospace'
+    ctx.fillStyle = 'rgba(255,240,205,0.95)'
+    ctx.textAlign = 'center'
+    ctx.fillText('SUN', sx, y0 + h + 14)
+    ctx.textAlign = 'left'
+  }
+
   private drawCaps(): void {
     const ctx = this.ctx
     const [, y0] = this.worldToScreen(0, 0)
diff --git a/src/views/scenic.ts b/src/views/scenic.ts
index 7ff3171..4376f96 100644
--- a/src/views/scenic.ts
+++ b/src/views/scenic.ts
@@ -10,14 +10,15 @@
  * Pure functions, no Three.js: the interior view plays the scenes, the screenshot tour
  * photographs them at a fixed moment, and the tests fly every one over the real world.
  */
-import { CIRCUMFERENCE_M, RADIUS_M } from '../canon/constants.js'
+import { CIRCUMFERENCE_M, LENGTH_M, RADIUS_M } from '../canon/constants.js'
 import type { CFall, CompiledWorld } from '../terrain/compile.js'
 import type { CDesert } from '../terrain/desert.js'
 import { type Line, nearestA, smoothLine } from '../terrain/shapes.js'
 import { forEachTree, type Tree } from '../terrain/forest.js'
 import { lanesOf, shipAt, shipsAt, type ShipLane } from '../terrain/ships.js'
-import type { Feature, Sea, World } from '../world/schema.js'
+import { type Feature, type Sea, type World, anchorOf } from '../world/schema.js'
 import { HARBOUR_LEAD_H, occurrences } from '../world/weather.js'
+import { hourWhenSunAt } from '../world/sky.js'
 
 /** How long each scene runs, seconds. */
 export const SCENE_S = 30
@@ -53,9 +54,22 @@ export interface Scene {
   prepare: (w: SceneWorld) => ScenePlan | null
 }
 
-/** Noon on day 0: a calm, published-nothing day, for scenes with no reason to pick another. */
+/**
+ * Noon on day 0: a calm, published-nothing day, for scenes with no reason to pick another.
+ * Such a scene is not shown at noon, though, but in the morning where it is (planTour).
+ */
 const NOON = 12
 
+/**
+ * The hour on day 0 when the sun is 60 km sunward of `z` and coming: about 40° up, which
+ * is high enough to be full day and low enough for the ground to have relief (D-074).
+ * Kept clear of the caps, where the sun is still lighting or already going out.
+ */
+function morningOver(w: SceneWorld, z: number): number {
+  const sunZ = Math.min(LENGTH_M - 25_000, Math.max(25_000, z - 60_000))
+  return hourWhenSunAt(w.world.calendar, 0, sunZ)
+}
+
 // ------------------------------------------------------------------ geometry
 
 interface At { z: number; a: number; altM: number }
@@ -235,10 +249,13 @@ export const SCENES: Scene[] = [
     id: 'ship',
     prepare: (w) => {
       const sea = w.world.features.find((f): f is Sea => f.kind === 'sea' && lanesOf(f, w.world.seed).length > 0)
-      const lane = sea && biggestShip(w.world, sea.id, NOON)
+      // Chosen before the ship, since the ship is wherever it is at that hour.
+      const hours = sea ? morningOver(w, sea.geometry.type === 'band'
+        ? (sea.geometry.zFrom + sea.geometry.zTo) / 2 : anchorOf(sea).z) : NOON
+      const lane = sea && biggestShip(w.world, sea.id, hours)
       if (!sea || !lane) return null
       return {
-        id: 'ship', hours: NOON, pose: chase(w, lane, NOON, 50),
+        id: 'ship', hours, pose: chase(w, lane, hours, 50),
         title: `Following a ${Math.round(lane.lengthM)} m ship round ${sea.name}`,
       }
     },
@@ -493,4 +510,5 @@ export const SCENES: Scene[] = [
 /** Every scene the world can show, in tour order. */
 export function planTour(w: SceneWorld): ScenePlan[] {
   return SCENES.flatMap((s) => s.prepare(w) ?? [])
+    .map((p) => (p.hours === NOON ? { ...p, hours: morningOver(w, p.pose(0).z) } : p))
 }
diff --git a/src/world/clock.ts b/src/world/clock.ts
new file mode 100644
index 0000000..4b3a857
--- /dev/null
+++ b/src/world/clock.ts
@@ -0,0 +1,136 @@
+/**
+ * The world clock: one for the whole app, so every view shows the same moment (D-074).
+ *
+ * Hours count from the start of day 0 on the calendar city's clock (sky.ts). The weather
+ * plan, the ships and the sun are all functions of it, and nothing else keeps time. It
+ * runs on its own animation frame while running, whichever view is open, and remembers
+ * where it was across reloads.
+ */
+import { type Dial, isDial } from './sky.js'
+
+const KEY = 'tower.clock'
+/** Where the interior view kept the time before there was a clock of its own. */
+const OLD_KEY = 'tower.interior'
+const SAVE_MS = 400
+
+type Listener = () => void
+
+export class WorldClock {
+  /** Hours since day 0 began. */
+  hours = 12
+  running = false
+  /** World seconds per real second. */
+  rate = 900
+  /** How bright Tower runs its day. */
+  dial: Dial = 'temperate'
+  private listeners = new Set<Listener>()
+  private last = 0
+  private frame = 0
+  private saveTimer: ReturnType<typeof setTimeout> | null = null
+
+  constructor(private storage: Pick<Storage, 'getItem' | 'setItem'> | null = safeStorage()) {
+    this.restore()
+  }
+
+  get day(): number {
+    return Math.floor(this.hours / 24)
+  }
+
+  get hourOfDay(): number {
+    return this.hours - this.day * 24
+  }
+
+  subscribe(fn: Listener): () => void {
+    this.listeners.add(fn)
+    return () => this.listeners.delete(fn)
+  }
+
+  set(hours: number): void {
+    this.hours = Math.max(0, hours)
+    this.changed()
+  }
+
+  setDay(day: number): void {
+    this.set(Math.max(0, Math.floor(day)) * 24 + this.hourOfDay)
+  }
+
+  setHourOfDay(hour: number): void {
+    this.set(this.day * 24 + Math.min(24 - 1e-6, Math.max(0, hour)))
+  }
+
+  setDial(dial: Dial): void {
+    this.dial = dial
+    this.changed()
+  }
+
+  setRate(rate: number): void {
+    this.rate = rate
+    this.changed()
+  }
+
+  setRunning(on: boolean): void {
+    if (this.running === on) return
+    this.running = on
+    if (on) {
+      this.last = performance.now()
+      this.frame = requestAnimationFrame((t) => this.tick(t))
+    } else {
+      cancelAnimationFrame(this.frame)
+    }
+    this.changed()
+  }
+
+  private tick(now: number): void {
+    if (!this.running) return
+    const dt = Math.min(0.1, (now - this.last) / 1000)
+    this.last = now
+    this.hours += (dt * this.rate) / 3600
+    this.changed()
+    this.frame = requestAnimationFrame((t) => this.tick(t))
+  }
+
+  private changed(): void {
+    for (const fn of this.listeners) fn()
+    if (this.saveTimer) clearTimeout(this.saveTimer)
+    this.saveTimer = setTimeout(() => this.save(), SAVE_MS)
+  }
+
+  private save(): void {
+    try {
+      this.storage?.setItem(KEY, JSON.stringify({ hours: this.hours, rate: this.rate, dial: this.dial }))
+    } catch { /* not remembering the time is survivable */ }
+  }
+
+  /** Every field is checked, and a clock saved by the interior view is picked up. */
+  private restore(): void {
+    try {
+      const num = (v: unknown, lo: number, hi: number): number | null =>
+        typeof v === 'number' && Number.isFinite(v) && v >= lo && v <= hi ? v : null
+      const raw = this.storage?.getItem(KEY)
+      if (raw) {
+        const s = JSON.parse(raw) as Record<string, unknown>
+        this.hours = num(s['hours'], 0, 1e7) ?? this.hours
+        this.rate = num(s['rate'], 1, 1e6) ?? this.rate
+        if (isDial(s['dial'])) this.dial = s['dial']
+        return
+      }
+      const old = this.storage?.getItem(OLD_KEY)
+      if (!old) return
+      const s = JSON.parse(old) as Record<string, unknown>
+      const day = Math.floor(num(s['day'], 0, 1e6) ?? 0)
+      const phase = num(s['phase'], 0, 1) ?? 0.5
+      this.hours = day * 24 + phase * 24
+      if (isDial(s['lighting'])) this.dial = s['lighting']
+    } catch { /* unreadable storage just means noon on day 0 */ }
+  }
+}
+
+function safeStorage(): Storage | null {
+  try {
+    return typeof localStorage === 'undefined' ? null : localStorage
+  } catch {
+    return null
+  }
+}
+
+export const clock = new WorldClock()
diff --git a/src/world/schema.ts b/src/world/schema.ts
index 98f9fb4..087bd6c 100644
--- a/src/world/schema.ts
+++ b/src/world/schema.ts
@@ -249,8 +249,35 @@ export interface World {
   features: Feature[]
   /** Tower's published schedule of wind (world-generation.md §4.9). */
   weather: WeatherPlan
+  /** Which Earth city Tower keeps its days with (design.md §1.3.4). */
+  calendar: Calendar
 }
 
+// --------------------------------------------------------------------- calendar
+
+/**
+ * The Earth city whose days Tower keeps (design.md §1.3.4, D-074). Tower's clock is that
+ * city's clock, and its sun comes on at the sun cap at the city's sunrise and goes out at
+ * the far cap at the city's sunset, so Tower's days lengthen and shorten with its seasons.
+ */
+export interface Calendar {
+  city: string
+  /** The city's IANA time zone, for its clock and its daylight saving. */
+  timeZone: string
+  /** Degrees, north positive. */
+  latitude: number
+  /** Degrees, east positive. */
+  longitude: number
+  /** The city's date on day 0, YYYY-MM-DD. */
+  day0: string
+}
+
+/** Sydney, from the southern spring equinox of 2026 (author, 2026-09-27). */
+export const defaultCalendar = (): Calendar => ({
+  city: 'Sydney', timeZone: 'Australia/Sydney', latitude: -33.8688, longitude: 151.2093,
+  day0: '2026-09-23',
+})
+
 // ---------------------------------------------------------------------- weather
 
 /**
@@ -309,6 +336,7 @@ export const defaultGround = (): Ground => ({ baseM: 60, reliefM: 260, notes: ''
 
 export const emptyWorld = (): World => ({
   version: WORLD_VERSION, seed: 1, ground: defaultGround(), features: [], weather: emptyWeather(),
+  calendar: defaultCalendar(),
 })
 
 /** Which shapes a kind may take, the usual one first. */
@@ -424,6 +452,7 @@ export function parseWorld(raw: unknown): World {
     }
   }
   world.weather = parseWeather(src.weather)
+  world.calendar = parseCalendar(src.calendar)
   if (!Array.isArray(src.features)) return world
   const seen = new Set<string>()
   for (const f of src.features as unknown as Record<string, unknown>[]) {
@@ -435,6 +464,37 @@ export function parseWorld(raw: unknown): World {
   return world
 }
 
+/** A calendar with anything unusable in it falls back to Sydney field by field. */
+function parseCalendar(raw: unknown): Calendar {
+  const d = defaultCalendar()
+  if (!raw || typeof raw !== 'object') return d
+  const c = raw as Record<string, unknown>
+  const zone = typeof c['timeZone'] === 'string' && validTimeZone(c['timeZone']) ? c['timeZone'] : d.timeZone
+  const day0 = typeof c['day0'] === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(c['day0'])
+    && !Number.isNaN(Date.parse(`${c['day0']}T00:00:00Z`)) ? c['day0'] : d.day0
+  return {
+    city: str(c['city']).trim() || d.city,
+    timeZone: zone,
+    latitude: Math.max(-66, Math.min(66, num(c['latitude'], d.latitude))),
+    longitude: wrapLongitude(num(c['longitude'], d.longitude)),
+    day0,
+  }
+}
+
+/** Into -180..180, leaving a longitude already there exactly as it was written. */
+function wrapLongitude(lon: number): number {
+  return lon >= -180 && lon <= 180 ? lon : ((((lon + 180) % 360) + 360) % 360) - 180
+}
+
+function validTimeZone(zone: string): boolean {
+  try {
+    new Intl.DateTimeFormat('en', { timeZone: zone })
+    return true
+  } catch {
+    return false
+  }
+}
+
 function parseFeature(f: Record<string, unknown> | null, seen: Set<string>): Feature | null {
   if (!f || typeof f !== 'object') return null
   const geometry = parseGeometry(f['geometry'])
@@ -649,6 +709,7 @@ export function toPublicWorld(world: World): World {
     seed: world.seed,
     // Tower publishes its plans: the weather is public, revisions and their reasons too.
     weather: world.weather,
+    calendar: world.calendar,
     ground: { ...world.ground, notes: '' },
     features: world.features
       .filter((f) => f.public)
diff --git a/src/world/sky.ts b/src/world/sky.ts
new file mode 100644
index 0000000..c6bf602
--- /dev/null
+++ b/src/world/sky.ts
@@ -0,0 +1,173 @@
+/**
+ * Tower's sky at a moment: where its sun is, and how bright the world is. See design.md
+ * §1.3.4 and decisions.md D-074.
+ *
+ * Tower keeps an Earth city's days. Its sun — the mirrorlet swarm gathered into one
+ * shoal on the beam — lights at the sun cap at that city's sunrise, runs the length of
+ * the world at a steady pace, and goes out at the far cap at the city's sunset. Then it
+ * is night until the next sunrise, with the beam banked to a thousandth (§1.3.2).
+ *
+ * Everything here is a pure function of the calendar and the clock, and both 3D views
+ * read the same answer, so the sun seen from the floor and the sun seen in through a
+ * window are the same sun.
+ */
+import {
+  BRIGHTNESS_FULL_W_M2, BRIGHTNESS_NIGHT_W_M2, BRIGHTNESS_TEMPERATE_W_M2,
+  BRIGHTNESS_TWILIGHT_W_M2, LENGTH_M, SUN_RAMP_H,
+} from '../canon/constants.js'
+import type { Calendar } from './schema.js'
+
+/**
+ * How bright Tower runs its day (design.md §1.3, "Illumination is a dial"). `dark` is
+ * the beam shut down entirely: no sun, no rod, only the stars through the far cap.
+ */
+export const DIALS = {
+  dark: 0,
+  twilight: BRIGHTNESS_TWILIGHT_W_M2,
+  temperate: BRIGHTNESS_TEMPERATE_W_M2,
+  full: BRIGHTNESS_FULL_W_M2,
+} as const
+export type Dial = keyof typeof DIALS
+export const isDial = (v: unknown): v is Dial => typeof v === 'string' && v in DIALS
+
+export interface SunTimes {
+  /** Local clock hours, 0..24, in the calendar's city. */
+  sunriseH: number
+  sunsetH: number
+}
+
+export interface Sky {
+  /** Hours since day 0 began, on the city's clock. */
+  hours: number
+  day: number
+  /** The hour of the day, 0..24. */
+  hourOfDay: number
+  times: SunTimes
+  /**
+   * Where the sun is on the axis, metres from the sun cap — or null at night. It moves
+   * at a constant speed from the sun cap at sunrise to the far cap at sunset.
+   */
+  sunZ: number | null
+  /** How lit the sun is, 0..1: it comes up over SUN_RAMP_H after sunrise and goes out
+   *  over the same before sunset. */
+  power: number
+  /** What the world's light amounts to, W/m² mean, for the eye's adaptation. */
+  watts: number
+  dial: Dial
+}
+
+// ---------------------------------------------------------------------- calendar
+
+/** The calendar's date for a world day, as UTC midnight of that date. */
+export function dateOf(cal: Calendar, day: number): Date {
+  const d = new Date(`${cal.day0}T00:00:00Z`)
+  d.setUTCDate(d.getUTCDate() + Math.floor(day))
+  return d
+}
+
+const WEEKDAYS = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
+const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
+
+/**
+ * "Wed 23 Sep 2026": a world day as the city would write it. Spelled out here rather than
+ * by `toLocaleDateString`, whose abbreviations differ between browsers.
+ */
+export function dateLabel(cal: Calendar, day: number): string {
+  const d = dateOf(cal, day)
+  return `${WEEKDAYS[d.getUTCDay()]} ${d.getUTCDate()} ${MONTHS[d.getUTCMonth()]} ${d.getUTCFullYear()}`
+}
+
+/** The city's offset from UTC at an instant, in hours — daylight saving included. */
+export function utcOffsetH(zone: string, at: Date): number {
+  const parts = new Intl.DateTimeFormat('en-US', {
+    timeZone: zone, hourCycle: 'h23',
+    year: 'numeric', month: 'numeric', day: 'numeric', hour: 'numeric', minute: 'numeric',
+  }).formatToParts(at)
+  const get = (t: string) => Number(parts.find((p) => p.type === t)!.value)
+  const local = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'))
+  return (local - Math.floor(at.getTime() / 60_000) * 60_000) / 3_600_000
+}
+
+// --------------------------------------------------------------------- the sun
+
+const RAD = Math.PI / 180
+
+/**
+ * Sunrise and sunset in the calendar's city on a world day, local clock hours.
+ *
+ * NOAA's general solar position equations: the equation of time and the declination
+ * from the fractional year, and the hour angle at which the sun's centre is 0.833°
+ * below the horizon (refraction and the sun's radius). Good to a minute or two, which
+ * is far finer than anyone in Tower will check.
+ */
+export function sunTimes(cal: Calendar, day: number): SunTimes {
+  // Both views ask every frame, and the time-zone lookup is not free.
+  const key = `${cal.timeZone}|${cal.latitude}|${cal.longitude}|${cal.day0}|${Math.floor(day)}`
+  const known = timesCache.get(key)
+  if (known) return known
+  const times = computeSunTimes(cal, day)
+  if (timesCache.size > 512) timesCache.clear()
+  timesCache.set(key, times)
+  return times
+}
+const timesCache = new Map<string, SunTimes>()
+
+function computeSunTimes(cal: Calendar, day: number): SunTimes {
+  const date = dateOf(cal, day)
+  const start = Date.UTC(date.getUTCFullYear(), 0, 1)
+  const doy = Math.round((date.getTime() - start) / 86_400_000) + 1
+  const g = ((2 * Math.PI) / 365) * (doy - 1)
+  const eqTimeMin = 229.18 * (0.000075 + 0.001868 * Math.cos(g) - 0.032077 * Math.sin(g)
+    - 0.014615 * Math.cos(2 * g) - 0.040849 * Math.sin(2 * g))
+  const decl = 0.006918 - 0.399912 * Math.cos(g) + 0.070257 * Math.sin(g)
+    - 0.006758 * Math.cos(2 * g) + 0.000907 * Math.sin(2 * g)
+    - 0.002697 * Math.cos(3 * g) + 0.00148 * Math.sin(3 * g)
+  const lat = cal.latitude * RAD
+  const cosH = Math.cos(90.833 * RAD) / (Math.cos(lat) * Math.cos(decl)) - Math.tan(lat) * Math.tan(decl)
+  // The calendar keeps to latitudes where the sun always rises and sets (schema.ts).
+  const haDeg = Math.acos(Math.max(-1, Math.min(1, cosH))) / RAD
+  const riseUtcMin = 720 - 4 * (cal.longitude + haDeg) - eqTimeMin
+  const setUtcMin = 720 - 4 * (cal.longitude - haDeg) - eqTimeMin
+  // The offset at local noon: sunrise and sunset never straddle a daylight-saving change.
+  const offset = utcOffsetH(cal.timeZone, new Date(date.getTime() + (12 - cal.longitude / 15) * 3_600_000))
+  const local = (utcMin: number) => (((utcMin / 60 + offset) % 24) + 24) % 24
+  return { sunriseH: local(riseUtcMin), sunsetH: local(setUtcMin) }
+}
+
+const smooth = (x: number): number => {
+  const t = Math.min(1, Math.max(0, x))
+  return t * t * (3 - 2 * t)
+}
+
+/** Tower's sky at `hours` since day 0 began, run at `dial`. */
+export function skyAt(cal: Calendar, hours: number, dial: Dial): Sky {
+  const day = Math.floor(hours / 24)
+  const hourOfDay = hours - day * 24
+  const times = sunTimes(cal, day)
+  const { sunriseH, sunsetH } = times
+  const up = hourOfDay >= sunriseH && hourOfDay <= sunsetH && dial !== 'dark'
+  const progress = (hourOfDay - sunriseH) / (sunsetH - sunriseH)
+  const power = up
+    ? smooth((hourOfDay - sunriseH) / SUN_RAMP_H) * smooth((sunsetH - hourOfDay) / SUN_RAMP_H)
+    : 0
+  const night = dial === 'dark' ? 0 : BRIGHTNESS_NIGHT_W_M2
+  return {
+    hours, day, hourOfDay, times,
+    sunZ: up ? Math.min(LENGTH_M, Math.max(0, progress * LENGTH_M)) : null,
+    power,
+    watts: night + (DIALS[dial] - night) * power,
+    dial,
+  }
+}
+
+/** The hour on `day` at which the sun is over `z` metres from the sun cap. */
+export function hourWhenSunAt(cal: Calendar, day: number, z: number): number {
+  const { sunriseH, sunsetH } = sunTimes(cal, day)
+  return sunriseH + (sunsetH - sunriseH) * Math.min(1, Math.max(0, z / LENGTH_M))
+}
+
+/** "05:58" */
+export const clockText = (hour: number): string => {
+  const mins = Math.round(hour * 60) % 1440
+  return `${String(Math.floor(mins / 60)).padStart(2, '0')}:${String(mins % 60).padStart(2, '0')}`
+}
diff --git a/test/canon.test.ts b/test/canon.test.ts
index 5e5ef13..c27351e 100644
--- a/test/canon.test.ts
+++ b/test/canon.test.ts
@@ -137,12 +137,6 @@ describe('§1.3 sun system', () => {
     near(C.BEAM_UTILISATION_ONE_PASS, 0.756, 0.005)
     near(C.BEAM_UTILISATION_TWO_PASS, 0.940, 0.005)
   })
-  it('the dawn front crosses the world at 225 m/s, about Tower rim speed', () => {
-    near(C.DAWN_FRONT_SPEED_M_S, 225, 1)
-    // 40 minutes of a 24 hour day: the far cap's morning lags the sun cap's by
-    // that much, which is the only longitude a world with no east has.
-    near(C.DAY_SWEEP_FRACTION * 24 * 60, 40, 0.1)
-  })
   it('night is the beam banked to a thousandth, 0.32 W/m²', () => {
     near(C.BRIGHTNESS_NIGHT_W_M2, 0.32, 0.01)
     near(C.NIGHT_BEAM_FRACTION, 0.001, 1e-9)
diff --git a/test/sky.test.ts b/test/sky.test.ts
new file mode 100644
index 0000000..f40afb8
--- /dev/null
+++ b/test/sky.test.ts
@@ -0,0 +1,96 @@
+import { describe, expect, it } from 'vitest'
+import { LENGTH_M } from '../src/canon/constants.js'
+import { defaultCalendar } from '../src/world/schema.js'
+import { dateLabel, hourWhenSunAt, skyAt, sunTimes, utcOffsetH } from '../src/world/sky.js'
+
+const sydney = defaultCalendar()
+/** The world day on which Sydney has this date (day 0 is 2026-09-23). */
+const dayOf = (iso: string) =>
+  Math.round((Date.parse(`${iso}T00:00:00Z`) - Date.parse(`${sydney.day0}T00:00:00Z`)) / 86_400_000)
+const hm = (h: number, m: number) => h + m / 60
+/** Within four minutes: the published tables round, and the formula is good to one or two. */
+const nearMinutes = (actual: number, expected: number) =>
+  expect(Math.abs(actual - expected) * 60).toBeLessThan(4)
+
+describe("Tower keeps Sydney's days (D-074)", () => {
+  it('starts on the spring equinox, 2026', () => {
+    expect(dateLabel(sydney, 0)).toContain('23 Sep 2026')
+  })
+
+  it("rises and sets with Sydney's shortest and longest days", () => {
+    // Geoscience Australia: 21 Jun 2027, 07:00–16:54 AEST; 21 Dec 2026, 05:41–20:05 AEDT.
+    const winter = sunTimes(sydney, dayOf('2027-06-21'))
+    nearMinutes(winter.sunriseH, hm(7, 0))
+    nearMinutes(winter.sunsetH, hm(16, 54))
+    const summer = sunTimes(sydney, dayOf('2026-12-21'))
+    nearMinutes(summer.sunriseH, hm(5, 41))
+    nearMinutes(summer.sunsetH, hm(20, 5))
+  })
+
+  it('keeps daylight saving: the clock moves, the sun does not', () => {
+    expect(utcOffsetH('Australia/Sydney', new Date('2026-09-23T02:00:00Z'))).toBe(10)
+    expect(utcOffsetH('Australia/Sydney', new Date('2026-12-21T02:00:00Z'))).toBe(11)
+    // Either side of the switch on 4 Oct 2026, sunrise jumps by about an hour.
+    const before = sunTimes(sydney, dayOf('2026-10-03')).sunriseH
+    const after = sunTimes(sydney, dayOf('2026-10-05')).sunriseH
+    expect(after - before).toBeGreaterThan(0.9)
+  })
+})
+
+describe('the sun runs the length of the world, sunrise to sunset', () => {
+  const day = dayOf('2026-12-21')
+  const { sunriseH, sunsetH } = sunTimes(sydney, day)
+
+  it('lights at the sun cap and goes out at the far cap', () => {
+    expect(skyAt(sydney, day * 24 + sunriseH + 0.01, 'temperate').sunZ).toBeLessThan(1000)
+    expect(skyAt(sydney, day * 24 + sunsetH - 0.01, 'temperate').sunZ).toBeGreaterThan(LENGTH_M - 1000)
+  })
+
+  it('moves at a steady pace: halfway through the day it is halfway along', () => {
+    const mid = skyAt(sydney, day * 24 + (sunriseH + sunsetH) / 2, 'temperate')
+    expect(mid.sunZ).toBeCloseTo(LENGTH_M / 2, -2)
+    expect(mid.power).toBe(1)
+    expect(mid.watts).toBeCloseTo(200)
+  })
+
+  it('is off at night, with the beam banked to a thousandth', () => {
+    const night = skyAt(sydney, day * 24 + 23, 'temperate')
+    expect(night.sunZ).toBeNull()
+    expect(night.power).toBe(0)
+    expect(night.watts).toBeCloseTo(0.32, 2)
+  })
+
+  it('comes up and goes out over a quarter of an hour, not instantly', () => {
+    const early = skyAt(sydney, day * 24 + sunriseH + 0.125, 'temperate')
+    expect(early.power).toBeGreaterThan(0.2)
+    expect(early.power).toBeLessThan(0.8)
+  })
+
+  it('shuts down entirely when the dial says dark', () => {
+    const dark = skyAt(sydney, day * 24 + 12, 'dark')
+    expect(dark.sunZ).toBeNull()
+    expect(dark.watts).toBe(0)
+  })
+
+  it('can be asked when it will be over a place', () => {
+    const h = hourWhenSunAt(sydney, day, LENGTH_M / 4)
+    expect(skyAt(sydney, day * 24 + h, 'full').sunZ).toBeCloseTo(LENGTH_M / 4, -2)
+  })
+})
+
+describe('the calendar is in the world file', () => {
+  it('reads the committed one: Sydney, from the equinox', async () => {
+    const { readFileSync } = await import('node:fs')
+    const { parseWorld } = await import('../src/world/schema.js')
+    const cal = parseWorld(JSON.parse(readFileSync('content/world.json', 'utf8'))).calendar
+    expect(cal).toEqual(sydney)
+  })
+  it('falls back field by field when something in it is unusable', async () => {
+    const { parseWorld } = await import('../src/world/schema.js')
+    const cal = parseWorld({ calendar: { city: 'Perth', timeZone: 'Not/AZone', day0: 'soon', latitude: -31.95 } }).calendar
+    expect(cal.city).toBe('Perth')
+    expect(cal.timeZone).toBe('Australia/Sydney')
+    expect(cal.day0).toBe('2026-09-23')
+    expect(cal.latitude).toBe(-31.95)
+  })
+})
diff --git a/test/world.test.ts b/test/world.test.ts
index 6e6389a..143d38e 100644
--- a/test/world.test.ts
+++ b/test/world.test.ts
@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest'
 import {
-  type Feature, FEATURE_KINDS, anchorOf, geometryTypesFor, isAreaKind, isLineKind,
+  type Feature, FEATURE_KINDS, anchorOf, defaultCalendar, geometryTypesFor, isAreaKind, isLineKind,
   isTracedKind, makeId, parseWorld, terrainSignature, toPublicWorld, withKind,
 } from '../src/world/schema.js'
 import { readFileSync } from 'node:fs'
@@ -166,7 +166,8 @@ describe('the committed world loads without losing anything', () => {
 })
 
 describe('the public export is the privacy boundary', () => {
-  const world = { version: 2, seed: 1, weather: { cycleDays: 0, events: [] }, ground: { baseM: 60, reliefM: 260, notes: 'GROUND SECRET' },
+  const world = { version: 2, seed: 1, weather: { cycleDays: 0, events: [] }, calendar: defaultCalendar(),
+    ground: { baseM: 60, reliefM: 260, notes: 'GROUND SECRET' },
     features: [
       place({ id: 'a', name: 'Shown', notes: 'SECRET', blurb: 'visible' }),
       place({ id: 'b', name: 'Hidden', public: false, notes: 'ALSO SECRET' }),
diff --git a/tools/screenshots.mjs b/tools/screenshots.mjs
index 84c50fd..eff7759 100644
--- a/tools/screenshots.mjs
+++ b/tools/screenshots.mjs
@@ -477,7 +477,7 @@ const SHOTS = [
       // The Capital is 270 km from the far cap, which is far enough to see the
       // whole 100 km window and near enough that some of the sky survives the air.
       await pick(page, 'The Capital')
-      await withPanel(page, () => page.selectOption('#f-light', 'night'))
+      await withPanel(page, () => setTime(page, 23 * 60))
       await settle(page, 1600)
     },
   },
@@ -488,7 +488,7 @@ const SHOTS = [
     run: async (page) => {
       await openView(page, 'interior')
       await pick(page, 'The Capital')
-      await withPanel(page, () => page.selectOption('#f-light', 'night'))
+      await withPanel(page, () => setTime(page, 23 * 60))
       // Turn to face sunward. 246 px is 45° at the default field, and the drag has
       // to stay inside the viewport, so it goes round in four passes rather than
       // one 982 px sweep that would run off the edge and drop the pointer capture.
@@ -501,19 +501,15 @@ const SHOTS = [
     },
   },
   {
-    id: 'interior-dawn',
-    caption: 'Interior — dawn: a lit sky over dark ground, and the light still 27 km away',
+    id: 'interior-morning',
+    caption: 'Interior — 06:30: the sun just out from the sun cap and coming, the rod lit only behind it',
     on: ['desktop'],
     run: async (page) => {
       await openView(page, 'interior')
       await pick(page, 'The Capital')
-      await withPanel(page, async () => {
-        // 06:17. The dawn front leaves the sun cap at 06:00 and takes 40 minutes to
-        // cross the world, so at The Capital's z 270 km it is 27 km short and still
-        // coming — which is the whole point of the shot.
-        await page.locator('#f-time').fill('378')
-        await page.locator('#f-time').dispatchEvent('input')
-      })
+      // Day 0 is the equinox: sunrise 05:44, so by 06:30 the sun is 34 km out from the
+      // sun cap and 236 km sunward of The Capital, 12° up (D-074).
+      await withPanel(page, () => setTime(page, 6 * 60 + 30))
       const s = await stage(page)
       for (let i = 0; i < 4; i++) {
         await drag(page, [s.x + s.w * 0.8, s.y + s.h * 0.5], [s.x + s.w * 0.8 - 246, s.y + s.h * 0.5])
@@ -534,6 +530,12 @@ const SHOTS = [
   },
 ]
 
+/** Set the world clock's time of day, in minutes, the way a reader would: with the slider. */
+async function setTime(page, minutes) {
+  await page.locator('#f-time').fill(String(minutes))
+  await page.locator('#f-time').dispatchEvent('input')
+}
+
 /**
  * Every page asks for two terrain workers and the compiled-world cache. Several pages run
  * at once, and each opening a full pool of six and compiling the whole world — erosion
diff --git a/world-generation.md b/world-generation.md
index 96db9da..377599e 100644
--- a/world-generation.md
+++ b/world-generation.md
@@ -747,8 +747,8 @@ computed rather than loaded. Distant tiles average to the same colours, so there
 no seam between near and far.
 
 Every new material — terrain, water, trees, buildings, ships — includes
-`HAZE_GLSL` and the shared uniforms (`uExposure`, `uFrontZ` …), so haze, the dawn
-front and night apply to all of them uniformly. This is a hard rule: a material
+`HAZE_GLSL`, `SUN_GLSL` and the shared uniforms (`uExposure`, `uSun` …), so haze, the
+sun and night apply to all of them uniformly (D-074). This is a hard rule: a material
 without it will glow through 200 km of air.
 
 ### 6.3 Water, scatter, ships