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 37 commits into
Open
A city that owns its state and its scene, and a loading overlay that waits for the whole world#207thalida wants to merge 37 commits into
thalida wants to merge 37 commits into
Conversation
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>
…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>
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>
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>
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>
@/types held two different things under one barrel — the contract with the server (Manifest, the generated schema) and the city's own model (Building, Street, CityLayout, the tween and fade kinds). Nothing outside city/ used the second half except five CityView panes, which is the chrome around a city reading what it is made of. The model is scene/types now, re-exported beside SceneContext and the picker targets, so a component imports one place. @/types is what the backend sends, which the panes read as much as the city does — and the generated file stays exactly where `just gen-types` writes it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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.
Idlealready meant "a frame carrying these meshes has been presented" (seecity/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 logwalk, which is the longest stage by far. So the list says which wait it is: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
/cityunmounts 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.cityOnScreenis 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:
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.CURRENT_SOURCE_KEY, the other city's projectMANIFEST, putting the opened project's city on the landingThe first attempt at this fixed the coupling but kept a role: an
isWorldflag, 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:
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/currentManifestare 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.markSceneGoneis gone. It isreport.markGone(), the same channel as every other status write.BACKDROP_HANDLEis gone. The landing feeds its city asourcesignal 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.whenCityOnScreenis 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 isreport.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.id="city"is gone. Two mounted cities would both have claimed it.Two smaller ones on the way past.
isOpenedSource()collapses three hand-rolledCURRENT_SOURCE+sameSourceIdentitycomparisons. 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 calledaway.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 scrubposition, the scene handle, and the fetch layer's load generation. Two cities
meant one repo between them.
All of it belongs to a
CitySessionnow — a class holding classes: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
(
setTimelineRefreshHandlerand friends) that used to bridge them, since bothsides 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), aSILENT_BUILD_REPORTERfor a citynobody was listening to, a zero-argument
subjectKey(), aconst ONE_CITY = 'city'standing in for a city that had no name. Every one of those existed topaper 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,reportissession.progress,subjectKeyissession.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
HomeBackdropis acomponent that renders
<City>rather than a hook returning parts to assemble.tests/city/twoCities.test.tsmounts the two side by side and asserts neithercan see the other's status, camera, timeline or manifest.
The chrome reads its city from a provider, not an import.
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.tsis an adapter pointed at onesession, 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'scommands.The picker's selection key moved onto the picker that owns the selection.
LIVE_UPDATES_ACTIVEand the settings-transfer excludes part became questionsabout 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:
installScrubControllerthrew on a city withno 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.
But its LOOK was still the app's
A session held everything about a city except what it looked like. Fifty files
under
city/readBUILDINGS.value,TREES.value,GEM.valuestraight offthe settings panel's signals, so two cities on screen could not differ and one
Save re-packed both.
CityConfigis one city's settings: a signal per section that follows the paneluntil that city is given its own, and a
signature(route)over its own fields,so the rebuild reaction watches the city it is attached to. Every read moved:
the components take
ctx.config, and the helpers below them take the valuesthey draw with.
Two workers were the proof of how deep it went. Both the tree-placement worker
and the layout worker wrote the global stores before calling the pure code,
because the algorithms read them as module singletons. They take a config now,
which is what they were already being sent.
buildings/material.tswas the other kind of sharing: a module-levelShaderMaterialand icon atlas, so two cities drew with one fog colour, oneoutline width, one window emission, and whichever atlas built last. It is a
BuildingMaterialper city, made from that city's config.gemFaceColorswasthe same shape — one memo over the global palette — and is one memo per city.
The decoration and layout goldens are unchanged through all of it, which is what
says no building, tree or street moved.
And it reached out for the app
A city imported the app's chrome store: a canvas click opened a pane, a
keystroke collapsed the sidebar, a modal holding the keyboard was read off
OVERLAY_OPEN. Mounted anywhere else it was reaching for furniture that doesnot exist.
CityChromeis the port —keyboardBusy,showDetails,revealCity— andstate/cityChrome.tsis codecity's answer to it. A session given none asks fornothing, which is what the landing's backdrop now runs as.
The loader read
ROUTE_PARAMSitself, so a session could only ever be loaded byan address bar. Following the URL is the adapter's job, and it already owned the
other two halves of that binding:
attachRouteLoadmoved there, and the loaderkeeps
boot(view). Which surfaced a real bug — App attached it twice, soarriving at
/cityran two scans and let the generation counter drop the first.RECENTSwas being read inside the loader just to label the overlay, which twocities would have shared: whoever opens a city passes a label instead.
So city/ is a module now
Everything a city is lives in one folder, split by the question it answers:
Named for what they hold:
scene/statewas a second "state" besidesession/,so it is
scene/build— what the last apply MADE.onSettingsisonConfig,because it takes a config section and not a store.
context.tsxisCityProvider.tsx.Its own backend calls, its settings field definitions, its excludes, its picker
types and its model —
Building,Street,CityLayout— came in with it.Every non-city user of those turned out to be a CityView pane, which is the
chrome around a city reading what it is made of; 55 app files import from the
package now.
What is left going outward is the server's contract (
@/types), vocabulary thewhole app shares, and two files of generic machinery. No store, no router, no
component.
city/README.mdlists it in full.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
Citywastaken by the Three.js handle. The renderer layer had already voted on what it is
(
SceneComponent×31,SceneContext×27,SceneHandle); the handle was theoutlier holding the domain word hostage.
CitySession.scene→CityScenecreateCityScene()useCity()underCityProviderstate/city/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 intoFACADE_PANELS.md,which already documents that component. Why the texture array pages, the repo
that overflowed
MAX_ARRAY_TEXTURE_LAYERS, thetexStorage3Dallocation bugbehind
dataReady = false, and what happens to an upload that beats therenderer 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:
createdAgepinned at exactly 1.000 across five years of scrubbing while
modifiedAgetracked.
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:
CREATED_HALF_LIFE_DAYSMODIFIED_HALF_LIFE_DAYSHALF_LIFE_DAYS)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.
commitDateRangesis off the wire, and with it the slowest step ofassembling 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 productioncaller at all, only its own tests. Same idea, left over.
stats.commitDatesfed one expression:max(newestCommit, scanDate)as themoment trees age against. The scan date is always present (required on the
model, stamped by
wrap_manifest, union manifests included), so the fallbackcould not fire. That left the
max, which only fires on a commit dated afterthe scan — and then ages every tree in the city by that skew, when
recencyTalready clamps the one odd commit by itself. Gone, with its model and the walk
that filled it.
oldestCommit/newestCommitstay: the almanac names those.AgeRangecarried three fields to serve one read, so it isAgeMoment, anumber, 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.tsmounts two cities at once and runs two sessionsside 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.
Two more in that file for the second kind of sharing: override one session's
trees and the other's values and its rebuild signature stay put, and two
sessions' building materials are different objects with different atlases.
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.
The comment cap needed one change to survive this: it runs over what a push
changes, so renaming ~245 files made one restructure answer for a backlog of 82
blocks it never wrote. A byte-identical move is skipped now; anything with a
real edit still counts, and the blocks in files this branch did touch are
trimmed here.
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.
Nor is
city/a package you couldnpm i. Lifting it means carrying the 22modules it still imports, or declaring them as peer dependencies — the server's
contract, shared vocabulary, and generic machinery. The part that matters is
that none of them is the app: what a city needs from its host is three methods.
One behavior change worth knowing
A landing visited before you have ever opened a project now frames its wallpaper.
The
CURRENT_SOURCE_KEYguard used to leave that case on the boot pose, sincethere was no key for it to change.