Skip to content

A city that owns its state and its scene, and a loading overlay that waits for the whole world - #207

Open
thalida wants to merge 36 commits into
mainfrom
fix/loading-overlay-lifetime
Open

A city that owns its state and its scene, and a loading overlay that waits for the whole world#207
thalida wants to merge 36 commits into
mainfrom
fix/loading-overlay-lifetime

Conversation

@thalida

@thalida thalida commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Two reports: sometimes re-opening a repo showed no loading modal at all, and sometimes the modal lifted while trees were still popping in. Neither was in the overlay component, and chasing them turned up a third thing (re-opening never asked the server) and a fourth (the landing's wallpaper and the city route were the same city as far as app state was concerned).

The modal lifted early on purpose

A cold scan streams three cities: structure, then per-file metadata, then git history. The overlay came down at metadata, on the reasoning that history "only adds decorations to an already-correct city". But commits are what trees are made of, so that reveal handed you a finished-looking city that then grew trees under you.

The overlay's lifetime is one rule now: up while a world is coming, down once that world is on screen. Idle already meant "a frame carrying these meshes has been presented" (see city/render/LOADING.md); it is now the only thing that takes the overlay down. The stream ending is the middle of the wait, not the end of it.

The cost is real and worth naming: on a cold scan of a big repo the overlay now sits through the git log walk, which is the longest stage by far. So the list says which wait it is:

Resolving source  ✓
Cloning           ✓
Scanning files    ✓
Sketching layout  ✓
Reading history   ›          <- new row
Building city                   67% trees

Cancel is where it always was. A cached open never sees that row light up: one complete manifest, no history stage.

The modal that never appeared

Re-opening the project you were just in never streamed anything, so nothing raised the overlay. Leaving /city unmounts the canvas, though, so coming back re-packs the whole city from scratch: seconds of real work behind a blank stage.

A build with nothing on screen behind it now raises the overlay by itself, showing the build row alone (nothing was fetched, so the clone and scan rows would be rows for work nobody did). progress.cityOnScreen is what "on screen" means, and unmounting the canvas clears it. A rebuild under a city, a settings Save or a live update, stays the footer's to report.

Re-opening never asked the server

Worse than the missing modal: it never asked. The committed source matched the URL, so the route effect returned early and the city was repacked from the manifest already in memory. Come back an hour and a few commits later and you were looking at the old repo.

Nothing on this machine can know whether a scan still holds. The server decides that, off its own cache, per ask. Leaving the city route arms the next open as a fresh one, and the guard now covers only what it was written for: the branch a remote commit puts in the URL while you stay put. The in-hand manifest is dropped when no city is on screen, so a re-open packs the scan it just asked for rather than building the stale one first and the fresh one a moment later.

Every city is a whole city

The landing's wallpaper and the project you opened are two cities from one composer, and the composer reached straight into app state. The wallpaper:

  • called markIdle, so "a city is on screen" stayed true for a canvas the landing had already thrown away. That is what made the missing modal intermittent: the next open saw a city already up and skipped its overlay.
  • drove the build-stage readouts
  • refit its camera off CURRENT_SOURCE_KEY, the other city's project
  • pruned its selection off the global scrubber
  • on a Timeline exit, repacked itself from MANIFEST, putting the opened project's city on the landing
  • and on a settings Save, packed the opened project into itself

The first attempt at this fixed the coupling but kept a role: an isWorld flag, two named binding bundles, and a component that branched on a variant to choose a camera, a reporter, a handle slot, a rebuild strategy, a manifest to read, and whether to clear state on unmount. That was worse than what it replaced, because it looked principled.

There is no role now, and no config to assemble either. A city renders a session, and there is one code path:

<City session={session} opaque />

<City session={backdrop} cameraMode={CameraMode.Backdrop} label="Decorative 3D city." />

Two cities are two sessions and nothing else. Camera behaviour is the only thing left that differs between those two lines, because it is the only thing that is genuinely about this rendering rather than about the city.

Most of the branches turned out not to need a config value at all, because the city already knew the answer:

  • rebuildScene / currentManifest are gone. A city re-packs what it is showing (handle.repack, handle.manifest), so the settings reactions take a city and ask it, instead of being handed a manifest and a strategy per variant. A union city under a scrubber reassembles instead, which the timeline binding owns.
  • markSceneGone is gone. It is report.markGone(), the same channel as every other status write.
  • BACKDROP_HANDLE is gone. The landing feeds its city a source signal and reads its own reporter for "it painted", so it needs no global slot, and "written only once it has actually painted" is now literally true.
  • whenCityOnScreen is gone. It was a promise wrapping an effect wrapping a microtask, watching the global status from outside. WebGL has no "the pixels landed" callback, but the composer already knows which revision's frame it presented, so it hands out that promise directly: handle.whenOnScreen(). It is report.markIdle's moment asked rather than told, which is the split between the two: the reporter is the city telling whoever mounted it, the handle is whoever holds it asking.
  • The canvas id="city" is gone. Two mounted cities would both have claimed it.

Two smaller ones on the way past. isOpenedSource() collapses three hand-rolled CURRENT_SOURCE + sameSourceIdentity comparisons. And the route-load flag is named for the fact it tracks: you arrive at the city route, and arriving asks the server, where before it was a boolean called away.

And the state a city is made of was still the app's

A city instance being self-contained did not make two of them possible, because
everything a city is made of was a module singleton: MANIFEST,
CURRENT_SOURCE, the scan and rebuild status, the timeline and its scrub
position, the scene handle, and the fetch layer's load generation. Two cities
meant one repo between them.

All of it belongs to a CitySession now — a class holding classes:

class CitySession {
  manifest = new ManifestStore()
  source   = new SourceStore(this.manifest)
  progress = new ProgressStore(this.manifest, this.source)
  timeline = new TimelineStore(this.manifest)
  scene    = signal<CityScene | null>(null)

  commands     = new CityCommands(this)   // the verbs the chrome sends its scene
  load         = new CityLoader(this)     // the scan, its cancel, the live poll
  timelineMode = new TimelineMode(this)   // entering, scrubbing, leaving

  bindings(cameraMode?) {  }             // everything a scene is wired to
}

Each store takes the stores it reads, so the dependency graph is written down in
that constructor order rather than being ambient. The loader and the Timeline
controller are per session too — which deleted the handler injection
(setTimelineRefreshHandler and friends) that used to bridge them, since both
sides can now reach each other through the session they are in.

They were factory functions returning interfaces at first, wired together by
free helpers: cityPropsFor(session), a SILENT_BUILD_REPORTER for a city
nobody was listening to, a zero-argument subjectKey(), a const ONE_CITY = 'city' standing in for a city that had no name. Every one of those existed to
paper over a city that was not a thing you could hold. Holding one deletes them:
session.bindings() is the single way to instance a city, report is
session.progress, subjectKey is session.source.key.

That in turn is what let the landing's backdrop stop being special. It gets a
session of its own — it is a second city, showing a different repo — so its
build reports into stores the chrome never reads, and HomeBackdrop is a
component that renders <City> rather than a hook returning parts to assemble.
tests/city/twoCities.test.ts mounts the two side by side and asserts neither
can see the other's status, camera, timeline or manifest.

The chrome reads its city from a provider, not an import.

<CityProvider session={session}>   // one session today
  <CityHeader /> <CitySidebarLeft /> <CityStage /></CityProvider>

That is the part that makes a side-by-side view a new view rather than another
refactor: wrap each column in its own session and the same components work
unchanged. The URL follows: router/urlBinding.ts is an adapter pointed at one
session, so a column view can point it at the focused column or at none.

A session is a place, not an identity. Opening a new source swaps what it
holds and keeps the scene rendering it — a column is somewhere you point at
repos over time.

Everything else falls out. SCENE_HANDLE's verbs became a session's commands.
The picker's selection key moved onto the picker that owns the selection.
LIVE_UPDATES_ACTIVE and the settings-transfer excludes part became questions
about a session rather than about the app. The city internals that read the
timeline directly (facade panels, the picker, the scrub controller) take it
through SceneContext.

Two real bugs surfaced on the way: installScrubController threw on a city with
no timeline binding (a city nobody scrubs cannot be handed a scrubber, so it
no-ops), and the module-level selection key let a test seed state before the
picker that resolves it existed.

One word, one meaning

A project is the city being rendered, so having both words meant one idea with
two names — and the reason "project" got invented at all is that City was
taken by the Three.js handle. The renderer layer had already voted on what it is
(SceneComponent ×31, SceneContext ×27, SceneHandle); the handle was the
outlier holding the domain word hostage.

CitySession the city you have open, and everything it is made of
.sceneCityScene canvas, renderer, camera, picker — from createCityScene()
useCity() under CityProvider what the chrome calls
state/city/ where the session lives

Nothing named "project" is left in the code, and nothing named "city" means the
renderer.

Renaming the factory touched one word of prose in five files this branch had no
other business in, and the comment cap only covers what a push changes — so that
word pulled their pre-existing over-length blocks into scope. The right answer
was not to skip the rename but to put long form where it belongs:
facadePanelTextureArray.ts's 31-line header moved into FACADE_PANELS.md,
which already documents that component. Why the texture array pages, the repo
that overflowed MAX_ARRAY_TEXTURE_LAYERS, the texStorage3D allocation bug
behind dataReady = false, and what happens to an upload that beats the
renderer are all still written down — as prose you can read, with the code
pointing at it.

And one signal was not measuring time at all

Reported separately, same shape of bug: the grime streaks did not change as you
scrubbed, while colour and window light did.

They were the one date-driven signal that was not a function of now. Colour
decays from the last edit against the moment on screen; grime instead ranked
a file inside the repo's created span — where its birthday falls between the
oldest and newest file present. A scrub barely moves that span, so the streaks
were frozen by construction. Measured before touching anything: createdAge
pinned at exactly 1.000 across five years of scrubbing while modifiedAge
tracked.

Grime stays a creation-date signal — how long the building has stood, so
one laid down in 2019 and edited yesterday is grimy and vivid at once. Only its
reference changed, to the same hyperbolic curve colour and tree growth already
use, read at the scrubbed moment.

Which means a building has two clocks, not two effects that each own one.
They sit together above the factors they drive, named for their dates rather
than for whatever they happen to weather today:

drives today
CREATED_HALF_LIFE_DAYS 365d grime, the age-lean
MODIFIED_HALF_LIFE_DAYS 90d (was HALF_LIFE_DAYS) colour, window light

Two rather than one because standing a year and going untouched a year are not
the same span, and a slider labelled Color should not silently re-grime a
city. The rename resets a customised colour half-life to its default, which is
deliberate.

Every other date-driven visual was already relative to now — tree height, canopy
size, firefly orbits. What rankings remain rank counts (files per commit,
commits per day), which is right: a file count does not age.

What that made dead

Ranking against a span was the only reason three things existed.

commitDateRanges is off the wire, and with it the slowest step of
assembling a bundle: it walked every file present at every commit — minutes on a
large repo — and had to report progress from inside itself because it could not
fit on one step of the readout. Assembly is three steps now, not four.

ageT — a commit's rank inside the repo's commit span — had no production
caller at all, only its own tests. Same idea, left over.

stats.commitDates fed one expression: max(newestCommit, scanDate) as the
moment trees age against. The scan date is always present (required on the
model, stamped by wrap_manifest, union manifests included), so the fallback
could not fire. That left the max, which only fires on a commit dated after
the scan — and then ages every tree in the city by that skew, when recencyT
already clamps the one odd commit by itself. Gone, with its model and the walk
that filled it. oldestCommit / newestCommit stay: the almanac names those.

AgeRange carried three fields to serve one read, so it is AgeMoment, a
number, and what a tree ages against is one day.

Two fixtures were leaning on that fallback by omitting a scan date — including
the decoration golden. They pass the newest commit explicitly now, which is what
the fallback handed them, and the golden came back bit-identical: the proof
this changes nothing a real manifest sees.

What holds it

The grime fix has its own guard: the same building must weather further the
later you scrub, with no new files needed. Broken back to the ranking, it goes
red.

tests/city/twoCities.test.ts mounts two cities at once and runs two sessions
side by side: one scanning while the other is idle, one scrubbing while the
other is live, different manifests, and neither one's source recognising the
other's. Share a single store between sessions and it goes red; re-couple the
city defaults and its other four cases go red.

Every other guard here was broken and watched go red before being restored: the
overlay holding through the history stream, the overlay a stream-less build
raises, Timeline's reveal waiting for its frame, and the re-open that has to ask
again.

What this does not do

There is no multi-city UI. One session is created, the URL is pointed at it, and
the layout is the single column it always was. What changed is that a second one
is now a thing you can build rather than a thing the state layer forbids.

One behavior change worth knowing

A landing visited before you have ever opened a project now frames its wallpaper.
The CURRENT_SOURCE_KEY guard used to leave that case on the boot pose, since
there was no key for it to change.

thalida and others added 7 commits August 22, 2026 12:49
Two ways it lied. It lifted as soon as the heights landed, while git
history was still streaming: history is where commits come from, and
commits are the trees, so the reveal handed you a city that then grew
trees under your eyes. And re-opening the project you were just in
never streams at all, so nothing raised it: the manifest was already in
hand, the canvas had been thrown away with the route, and the rebuild
that followed ran behind a blank stage.

So the overlay's lifetime is one rule now: up while a world is coming,
down once that world is ON SCREEN. The stream ending is only the middle
of that wait. A build with nothing on screen behind it raises the
overlay on its own, with the build row alone, since nothing was fetched
and the fetch rows would be rows for work nobody did. Timeline's reveal
waits for the same frame rather than one rAF past the pack.

"Reading history" is its own row: the git walk is the longest stage of a
cold scan, and the list would otherwise sit on Sketching layout through
minutes of it.

CITY_ON_SCREEN is what "on screen" means, and unmounting the canvas
clears it. The landing's wallpaper builds through the same pipeline but
is not the world, so it reports through a silent BuildReporter: a
finished wallpaper claiming the world is up outlived the canvas it was
drawn on, which is what made the missing overlay intermittent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The doc already owned "when the city is actually on screen"; the overlay's
lifetime is the same question one layer up, including the part where the
landing's wallpaper is not the world.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
That view is gone: the landing navigates and the overlay is the only
surface this column is rendered in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…he other

The landing's wallpaper and the project you opened are two cities built by
one composer, and the composer reached straight into app state: it marked
the world Idle, drove the build readouts, refit its camera off
CURRENT_SOURCE_KEY, pruned selections off the global scrub, and on a
Timeline exit repacked itself from MANIFEST. So the landing's wallpaper
was telling the app the world was on screen while its canvas was already
gone, and a settings Save on the landing packed the opened project into
the wallpaper.

Everything a city touches outside itself is now injected. CityBindings
carries three things — a BuildReporter, a subject key, and a timeline
binding — and a city given none of them reads and writes no app state at
all: it builds, frames itself once and disposes in silence. city/bindings.ts
is the only file that says which signals mean "the project you opened".
The wallpaper is the same city with nothing bound.

The settings reactions were the other half: one attach per city, so they
take the city's own manifest and its own reporter rather than reading
MANIFEST and the world's status directly.

twoCities.test.ts mounts both at once and holds the line: the unbound one
builds without moving the status, without claiming to be on screen, and
a Timeline exit and a source switch pass it by. Each of its four cases
goes red if the defaults are re-coupled.

One behavior change falls out: a landing visited before any project was
ever opened now frames its wallpaper, where the CURRENT_SOURCE_KEY guard
used to leave it on the boot pose.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Re-opening the project you were just in loaded nothing: the committed
source matched the URL, so the route effect returned and the city was
repacked from the manifest already in memory. Come back an hour and a few
commits later and you were looking at the old repo, with nothing to say so.

Nothing on this machine can know whether a scan still holds — the server
decides that, off its own cache, per ask. So leaving the city route arms
the next open as a fresh one; the guard now only covers what it was
written for, the branch a commit puts in the URL while you stay put.

The manifest in hand is dropped when there is no city on screen, so a
re-open packs the scan it just asked for instead of building the stale one
first and the fresh one a moment later.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… role

`isWorld` was the wrong idea and it spread. The component branched on a
variant to pick a camera, a reporter, a handle slot, a rebuild strategy,
a manifest to read and whether to clear state on unmount, and two of
those branches were unreadable ternaries inside an options object.

There is no role now. A city takes one config and there is one code path:

    <City {...OPENED_PROJECT} opaque />
    <City source={backdrop.source} report={backdrop.report}
          cameraMode={CameraMode.Backdrop} label="Decorative 3D city." />

Leave a field out and that part is off, which is what makes two of them
independent. Most of the branches did not need a config value at all,
because the city already knew the answer:

- rebuildScene / currentManifest: gone. A city re-packs what IT is
  showing (`handle.repack`, `handle.manifest`), so the settings reactions
  take the city and ask it, instead of being handed a manifest and a
  strategy per variant. The union city under a scrubber reassembles
  instead, which is the timeline binding's business, not the caller's.
- markSceneGone: gone, it is `report.markGone()` — the same channel as
  every other status write.
- BACKDROP_HANDLE: gone. The landing feeds its city a `source` signal and
  reads its own reporter for "it painted", so it needs no global slot.
- The canvas id: gone. Two mounted cities would both have been #city.

whenCityOnScreen watched the global status through a promise wrapping an
effect wrapping a microtask. It is `handle.whenOnScreen()` now: the
composer already knows which revision's frame has been presented, since
WebGL has no callback for it, so it hands out that promise directly.

isOpenedSource collapses three hand-rolled CURRENT_SOURCE comparisons,
and the route-load flag says what it tracks (askedThisVisit) instead of
naming a mood (away).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`askedThisVisit` named a period nobody had defined. The fact is simpler:
you arrive at the city route, and arriving asks the server. Moving around
inside it does not, because the only identity change there is the branch
a commit resolves into the URL.

And whenOnScreen vs report.markIdle is one moment with two exits, which
now says so where you'd meet it: the reporter is the city telling whoever
mounted it, the handle is whoever holds it asking.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thalida thalida changed the title A loading overlay that waits for the whole world, and two cities that can't hear each other One way to instance a city, and a loading overlay that waits for the whole world Aug 22, 2026
thalida and others added 6 commits August 22, 2026 15:02
…globals

MANIFEST, CURRENT_SOURCE, the scan and rebuild status, the timeline and
its scrub position, the scene handle and the fetch layer's generation
were all module singletons, so however independent a city instance was,
two of them shared one project's worth of state.

Every one of those now belongs to a ProjectSession. The four stores are
factories taking the stores they read (manifest -> source -> progress,
manifest -> timeline), which makes the dependency graph explicit instead
of ambient. The loader and the timeline controller are per session too,
so the load generation, the abort controller and the injected
refresh/boot handlers are that session's — and the handler injection
(setTimelineRefreshHandler and friends) is gone, since both sides can
reach each other through the session they belong to.

The chrome reads its project from a provider, not an import: wrap the app
in one session today, wrap each column of a side-by-side view in its own
later, and the same components work either way. The URL is now one
adapter pointed at one session (router/urlBinding), rather than the thing
that owns which project exists.

Everything else falls out of that. SCENE_HANDLE's verbs became a
session's commands. PICKER_SELECTION_KEY moved onto the picker that owns
the selection. LIVE_UPDATES_ACTIVE and the settings-transfer excludes
part became questions about a session rather than about the app. The city
internals that read the timeline directly (facade panels, the picker, the
scrub controller and friends) take it through SceneContext.

src typechecks; the test suite is converted in the commit after this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every test that touched project state made the globals and reset them
between cases; each now makes its own session, which is what the shape
is for. Component tests render inside a ProjectProvider, the way the app
mounts its views, and the ones that spied on a module (loadSource,
loadTimelineScene, the scene commands) spy on that session's own instead.

Two things the conversion turned up, both real:

- installScrubController threw on a city with no timeline binding, since
  it reached through the binding for the store. A city nobody scrubs
  can't be handed a scrubber, so it no-ops.
- the picker's selection key was module-level, so "hydrate a key and
  watch it re-resolve" could seed it before the picker existed. The key
  belongs to the picker that resolves it, and hydration is a rebuild.

twoCities.test.ts now runs two sessions side by side: one scanning while
the other is idle, one scrubbing while the other is live, different
manifests, and neither one's source recognising the other's. Sharing a
single store between sessions turns it red.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CityView stopped calling attachViewUrlReactions (the URL adapter attaches
it now) and OverviewTab's commands moved to the row that uses them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The gate lints every file the branch changed, not just the last commit's,
so the new stores' headers had to earn their lines like everything else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bulk prettier run over src/ caught manifest.generated.ts, which is raw
generator output and prettierignored for exactly this reason: reformatted,
check-types-fresh diffs the formatting instead of the models. Restored
byte-for-byte; nothing read it differently either way.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
"Project" reads like a thing you have one of per repo, and the type never
said otherwise. Opening a new source swaps what a session holds and keeps
the city that renders it, which is the behaviour a column view wants: a
column is somewhere you point at repos over time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thalida thalida changed the title One way to instance a city, and a loading overlay that waits for the whole world One session per project, one way to instance a city, and a loading overlay that waits for the whole world Aug 22, 2026
thalida and others added 3 commits August 22, 2026 16:20
The product is codecity, so "city" is what you open, and its source and
manifest are what a city is MADE of, not a separate concern beside it. But
the Three.js handle had taken the word, so the container for everything a
city needs ended up named ProjectSession — and "project" and "city" were
two names for one idea.

The renderer layer had already voted on what it is: SceneComponent,
SceneContext, SceneHandle, sceneHandle.ts. The handle was the odd one out.

So the handle is a CityScene, created by createCityScene, held at
session.scene, and awaited with whenScene. The container is a CitySession,
made by createCitySession, read through useCity() under a CityProvider,
and living in state/city/. Nothing named "project" is left in the code.

Which makes the sentence that started this read right: loading a new
source puts a different city in the session, and the scene drawing it
stays where it is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Renaming the factory too rewrote one word of prose in five files this
branch had no other business in — and the gate's comment cap only covers
what a push changes, so that one word pulled 25 pre-existing blocks into
scope, including facadePanelTextureArray's header on WebGL2 paging and the
texStorage3D allocation bug behind it. Trimming that to four lines to pass
a lint rule is a worse codebase than a factory whose name says city while
its return type says scene, which is anyway what it makes: a city, drawn.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
createCityScene everywhere, including the five files whose prose named the
old factory. Their pre-existing comments were over the cap, and I used
their value as a reason to skip the rename — wrong call: the rule is that
architecture goes in a README and the code stays terse, not that long
comments earn an exemption by being good.

So facadePanelTextureArray's header moved into FACADE_PANELS.md, which
already documents this component: why the texture array pages at all, the
repo that overflowed MAX_ARRAY_TEXTURE_LAYERS, the texStorage3D
allocation bug behind `dataReady = false`, why flipY is off on both
textures, and what happens to an upload that beats the renderer. The code
now says which of those applies and points at the doc.

The other four keep their one non-obvious why in two lines each. Nothing
was dropped, only moved to where it can be read as a whole.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@thalida thalida changed the title One session per project, one way to instance a city, and a loading overlay that waits for the whole world A city that owns its state and its scene, and a loading overlay that waits for the whole world Aug 22, 2026
thalida and others added 11 commits August 22, 2026 16:48
Grime stood still through a scrub. It was the one date-driven signal that
was not a function of now: colour and window light decay from the last
edit against the moment shown, but grime ranked a file inside the repo's
created span — where its birthday falls between the oldest and newest
file present. Scrub five years without a file being created and that rank
never moves, so the streaks are frozen by construction. Measured before
changing anything: createdAge pinned at 1.000 across five years while
modifiedAge tracked.

Grime stays a CREATION-date signal — how long the building has stood, so
one laid down in 2019 and edited yesterday is grimy and vivid at once.
Only its reference changes: the same hyperbolic curve colour and tree
growth already use, read at the scrubbed moment.

Which means a building has two clocks, not two effects with clocks. They
sit together above the factors they drive, named for their dates rather
than what they happen to weather today: CREATED_HALF_LIFE_DAYS (365) for
standing, MODIFIED_HALF_LIFE_DAYS (90, was HALF_LIFE_DAYS) for neglect.
Two, because standing a year and going untouched a year are not the same
span, and one slider labelled Color should not silently re-grime a city.

The created span is gone from the client: nothing ranks any more, so
ScrubFrame drops minCreated/createdSpread and the controller stops
threading commitDateRanges through.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
commitDateRanges existed for grime's ranking, and grime ages against the
scrubbed moment now, so nothing consumed it. It was also the slowest step
of assembling a bundle — it walked every file present at every commit,
minutes on a big repo, and reported from inside itself because it could
not fit on one step of the readout. Assembly is three steps now, not four.

Its type went with it (DateRangeMs), and so did a stats re-export in the
frontend that named a different model with almost the same name and had no
importers. `stats.commitDates` stays: trees read it, though only for the
newest commit, which stands in as "now" when there is no scan date.

Which left ageT — a commit's rank inside the repo's commit span — with no
production caller at all, and AgeRange carrying three fields to serve it.
Both gone; what a tree measures against is one moment, so the type says
that and nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d it

stats.commitDates fed one thing: max(newestCommit, scanDate) as the moment
trees are aged against. The scan date is always there — required on the
model, stamped by wrap_manifest, union manifests included — so the
newest-commit fallback could not fire outside a null manifest, which has
no trees anyway. That left the max, which only fires on a commit dated
after the scan, and then ages EVERY tree in the city by the skew;
recencyT already clamps the one odd commit to age 0 by itself.

So commitDates is gone, along with the CommitDateRange model, the walk
that filled it, and the fixtures that carried it. oldestCommit and
newestCommit stay: the almanac names them, with their shas.

What a tree ages against is one day, so AgeRange (three fields, one read)
became AgeMoment, a number, and computeAgeRange became ageMoment(scannedAt).
createFirefliesScrub took a scannedAt it no longer reads; that went too.

Two fixtures leaned on the fallback by omitting the scan date, including
the decoration golden. They pass the newest commit explicitly now, which
is what the fallback handed them: the golden stays bit-identical, which is
the proof this changes nothing a real manifest sees.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pieces a city is made of were factory functions returning interfaces,
wired together by helpers with nothing to hold: cityPropsFor, a silent
reporter, a zero-arg subjectKey and a constant standing in for a city that
had no name. Reading it meant tracing which of those a caller had passed.

They are classes now. A CitySession holds ManifestStore, SourceStore,
ProgressStore and TimelineStore, plus the CityLoader that fetches it, the
TimelineMode that scrubs it and the CityCommands the chrome sends its
scene. It is also what a scene is wired to: session.bindings() is the one
way to instance a city, so there is nothing left to pass by hand.

<City session={...}> follows from that, and with it the last of the fakes.
The landing's backdrop gets a session of its own rather than borrowing the
opened city's halves, which is the shape it should always have had: it is
a second city, showing a different repo. HomeBackdrop is now a component
that renders <City> instead of a hook returning parts to assemble.

Also folds two hand-rolled effect-and-resolve dances into utils/until.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CityBindings was, field for field, "a session plus a camera mode": report
was session.progress, subjectKey was session.source.key, the timeline
binding was session.timeline plus two calls back into the session. So the
scene takes the session — createCityScene(canvas, session) — and both
binding types are gone. SceneContext carries the TimelineStore itself,
which is all any component ever read through the wrapper.

Camera mode moves onto the session with it. It is not a fact about a
rendering: the landing's city IS a turntable, the opened one IS something
you fly around, and a session is exactly one of those.

Three things that follow:

- <City> takes a session and a label. `opaque` was one background colour
  behind a prop; it is on .city-canvas now, where the fade the landing
  applies covers it anyway.
- applyManifest calls markRebuilding itself. Applying IS rebuilding, so
  three callers were saying so on its behalf and a fourth could forget.
  The two that stay mark it before work of their own, not before an apply.
- City's local `scene` mirrored session.scene for teardown. The session is
  where it lives; teardown reads it there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every visual setting under city/ was read off a module singleton, so two
cities on screen could not differ and one Save re-packed both. CityConfig
is one city's settings: a signal per section that follows the panel until
that city is given its own value, and a signature() over its own fields,
so the rebuild reaction watches the city it is attached to. The renderer
reaches it through SceneContext.config — the read sites move next.

Also drops activeSourceOf, which folded two cities into one answer from
inside a store: the lists take what to highlight, and the landing (the one
place two cities meet) decides. SourceStore.set is showing a repo;
commit() is that plus remembering you opened it, which is why the backdrop
can now hold its own source without landing in recents.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The trees subtree read TREES, FOOTPRINT and ISLAND off module singletons,
which the placement worker had to fake by WRITING those globals before
calling placeTrees. Now placeTrees takes the values, the worker passes the
snapshot it already had, and the client reads it from the city's config
per request. The renderer and the hover outline follow that city's TREES
signal, so a Save still refreshes them live.

islandGeoOverride: null is gone with it. Sampling always needed a side
count and the polygon pass already keyed off ENABLED, so "no island" is
one flag, not a second way to say it.

Three copies of makePrePickerCtx become one fixture. The decoration golden
is unchanged, which is what says none of this moved a tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same move as the forest, one layer up: the firefly field read FIREFLIES
and TREES off the module singletons, at construction and again on every
scrub frame. It takes this city's signals now, so an orb is sized against
the same tree config the canopy it hangs on grew from, and a Save still
refreshes the uniforms live.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Same move through the rest of the scenery: ISLAND, SCENE, FOOTPRINT,
RUINS, STREETS, STREET_TIERS, GEM, GEM_SIZING, REPO_LABEL, BLOOM and
BUILDING_DIMENSIONS come from ctx.config now. Components that already had
a SceneContext just read it there; the module-level builders (the island
material, the merged sidewalk and asphalt meshes, street labels, the root
gem, the path line) take the values they paint with.

gemFaceColors was a module-level memo over the global store, so two cities
shared one palette cache. It is gemFaceColorsFor(config.GEM) — one memo per
city, made where the gem is.

Three more hand-rolled SceneContext literals in tests become the shared
fixture, which is why they didn't need a config field adding by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment blocks orphaned by the makePrePickerCtx dedupe, and imports the
config move made dead. The gate lints every file a push changes, not just
the ones still dirty, which is where these surfaced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
buildings/material.ts held a module-level ShaderMaterial and icon atlas,
so two cities on screen drew with the same uniforms — one fog colour, one
outline width, one window emission — and whichever atlas built last. It is
a BuildingMaterial class now, made from the city's config in the composer
and reached through SceneContext, with the atlas the build produces pushed
into that city's own.

The rest of the subtree follows the same rule: the cell assembly, the cell
mesh writer, the colour curves, the facade panels, the fader, the scrub
apply, the tweens and the outline all take what they draw with. color.ts
in particular now takes the palette, so the two half-life clocks belong to
the city being coloured rather than to the app.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
thalida and others added 9 commits August 22, 2026 20:44
The build's own reads (whether to grow trees, the halo, the ground buffer),
the bloom pass, the per-frame scrub state and the rainbow chase all take
this city's values now rather than the panel's. getWorldBounds takes the
buffer, rainbowRgbAt takes the chase, and both were being read from a
module singleton by pure helpers several layers down.

Leaves the layout worker, the camera rig and the capture harness, which
are the last three.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The layout worker wrote the four settings stores before calling layoutCity,
the same trick the tree worker used, because the algorithm read them as
globals. layoutCity takes a LayoutConfig now, the worker passes the one it
was already sent, and the client reads it off the city per request. The
dimension helpers take what they measure with, so a scrubbed building is
re-sized against the city it is in.

The camera rig follows: its opening pose, the backdrop turntable and the
gem radius come from the city being framed. Which makes the capture
harness the first real user of the override seam — `just hero-image` poses
its own city through session.config.override rather than writing the
panel's CAMERA and leaving it changed.

city/ no longer reads a single settings singleton. The one remaining
import names a section to override, not a value to read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The config test proves two cities can look different. This proves they do
not share the GPU state that draws them, which is what a module-level
ShaderMaterial and icon atlas quietly did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The renderer imported the app's chrome store: a canvas click opened a
pane, a keystroke collapsed the sidebar, a modal being open was read off
OVERLAY_OPEN. Mounted anywhere else, it was reaching for furniture that
does not exist.

CityChrome is the port — keyboardBusy, showDetails, revealCity — and
SessionChrome in state/ is codecity's answer to it. CityCommands moves to
the session layer with it, since moving panes and drawers is what it does
as much as moving the camera.

statItems was the other reach: a pure formatter living inside a component
folder, which is why the tooltip imported a pane's module to build its
numbers. It is utils/statItems, and PaneStatItem is StatItem in types/ui.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The loader read ROUTE_PARAMS itself, so a session could only ever be
loaded by an address bar: a second city in a column had no way to be told
what to open. Following the URL is the adapter's job, and it already owned
the other two halves of that binding, so attachRouteLoad moves there and
the loader keeps boot(view) — load what this describes.

Which surfaced a real bug: App attached it twice, once through
attachUrlBinding and again through useCityLoader, so arriving at /city ran
two scans and let the generation counter drop the first. It cannot happen
again: session.load has no route-following surface to attach.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Everything a city is now lives in city/, split by the question it answers:

  City.tsx / CityProvider.tsx   the Preact surface: render one, share one
  session/   what a city IS     session, config, its settings fields, stores,
                                loader, timelineMode, commands, rebuildOnSave
  scene/     what DRAWS it      the composer, build, components, layout,
                                render, interaction, scrub, debug

Named for what they hold, since that was the actual complaint: scene/state
was a second "state" beside session/, and it is scene/build — what the last
apply MADE. onSettings is onConfig, because it takes this city's config
section and not a settings store. The tooltip's three files are a folder.
Constants that were living in utils/ (polygon offset, chunk size, buffer
strides, render orders) are in constants/.

And the coupling is gone, not just moved. city/ imports no store, no
router, and no component. The settings FIELDS came with it — they describe
what a city has, so the panel reads them from the city rather than the city
reading them from the panel. Excludes came too (they are per-repo, which
is a city fact). Recents did not: the loader was reaching into the app's
list for an overlay label, so a caller that knows the label passes one and
that is that. The app's boot reads (server config, discover) moved to App,
where they always belonged.

What is left, in full: api/, types/, utils/, constants/, and two files of
settings machinery. See city/README.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The comment cap runs over what a push changes, because the rule arrived
long after the comments did. Renaming ~245 files put every one of them in
scope, so a restructure was answering for the whole backlog: 82 blocks it
never wrote. A byte-identical move (R100) changes no comment, so it is
skipped; anything with a real edit still counts, and the 28 blocks in the
files this branch did touch are trimmed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ants

Every non-city user of these turned out to be a CityView pane — the chrome
AROUND a city — so they were city-domain sitting outside it. The manifest,
timeline, file and fingerprint endpoints are session/api/. The picker's
target types are scene/types/picker, no longer re-exported from @/types.
constants/buildings and constants/gem had no user outside city at all.

The app imports them from the city now, which is the direction that was
backwards.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant