The city is the product, and it currently only runs inside this app. app/src/city/
reaches into app state 120 times: twelve settings stores, the progress store, the
chrome store, the manifest and source stores. Nothing else can mount it, and two
copies of it cannot coexist without fighting.
Extract it into @codecity/city, installable, with an API client beside it.
const city = createCity(canvas, {
baseUrl: '/api',
src: 'github.com/thalida/codecity',
branch: 'main',
settings: SETTINGS,
});
city.on('build:stage', ...) // you draw the loading screen
city.on('select', ...) // you draw the sidebar
city.on('hover', ...) // you draw the tooltip
A canvas, an API base, a repo, and settings. It fetches, builds, and reports what
it is doing. Everything else is yours.
Repo shape
api/ Python backend (unchanged)
client/ @codecity/client generated types + HTTP/SSE ~700 loc
city/ @codecity/city the scene ~21,000 loc
app/ @codecity/web this site
city -> client, app -> client + city.
The fourth directory exists because of four endpoints. branches, discover,
config and commit are only ever called by the app, and with three directories
they have nowhere to live but a leftover app/src/api/, leaving the app with two
different ways to call one backend: its own folder, plus imports reaching through
a 3D renderer. One createClient({ baseUrl }) also means a retry policy or an
auth header later lands in one place instead of two.
What "independent" has to mean
The home backdrop and the city scene become two instances of the same package,
and neither can touch the other. That is the acceptance test, because today they
share almost everything.
App globals the city writes. Seven progress call sites, so the backdrop's
build drives this app's loading overlay. chrome.openSelectionPane, so clicking
the backdrop opens the app's sidebar. All become events.
App globals the city reads. MANIFEST, CURRENT_SOURCE_KEY, eight timeline
signals, IS_PHONE, and ~70 settings reads. All become constructor config.
useHomeBackdrop.ts already carries the scar tissue for this: "Applies the
manifest STRAIGHT TO THE SCENE, never writing MANIFEST."
Module singletons inside the city. These are the ones that bite hardest, and
they are invisible today only because the two cities live on different routes and
never coexist:
| Location |
State |
facadePanelTextureArray.ts:58 |
let _renderer + a global registerRenderer(), one slot for one WebGLRenderer |
material.ts:28,32,50 |
one ShaderMaterial, one icon atlas, globally; a material is bound to a GL context |
gem/mesh.ts:45 |
one DataTexture |
tooltip.ts:11 |
one DOM node |
shots.ts:74, profiling.ts:14 |
a latch and an accumulator |
The moment two cities are on screen, that is corruption that reads like a driver
bug. Worth fixing whether or not the rest of this proceeds.
Scratch vectors, the content-keyed memo caches, THREE.ShaderChunk registration
and the AudioContext are genuinely global and stay that way.
Decisions
|
|
| Timeline |
scrub engine in the package, mode-switching UI in the app. It is currently split down the middle, with state/stores/timeline.ts importing from city and handing PathTimelines back through installScrubController. |
| three.js |
city.three escape hatch for the capture harness and diagnostics; everything else wrapped. |
| Tooltip |
becomes a hover event. This also kills tooltipText.ts, the only file importing @/components/panes/PaneStats/statItems — the one backwards view import in the package. |
| Fetching |
the package fetches, through @codecity/client. |
| Loading |
the package emits stage facts; the app owns every label and overlay, so loading screens can be swapped freely. |
| Keyboard |
the package ships default bindings, keyboard: false disables them. A package that needs its host to wire up Esc is not independent. |
| Preact |
none. three, three-mesh-bvh, rbush, @preact/signals-core. City.tsx stays here as a ~40-line wrapper. |
| Camera |
CAMERA and HOME_BACKDROP merge into one store and CameraMode is deleted. Three of the four things the enum decides exist only because there are two stores. |
Settings
One reactive settings field. The package owns the schema, the app owns the
values, persistence and panel.
scene: settings = SETTINGS
backdrop: settings = computed(() => ({ ...SETTINGS.value, ...BACKDROP_PRESET }))
Shared base plus per-instance overrides is a computed on the consumer's side,
so the package needs no second concept for it.
Route dispatch moves inside the instance: you write a value, the instance decides
what it costs. This fixes a live bug — schema.ts:207 routeSignature()
iterates a global map, so both instances subscribe to the same signature and
touching one Rebuild knob rebuilds every mounted city.
Same-origin only
baseUrl is a path (/api), never an origin. SameSiteApiMiddleware
(api/core/middleware.py:167) 403s cross-site /api requests on purpose, and its
docstring says the missing CORS headers are the point. Accepting an arbitrary
origin reopens that deliberately and is its own issue.
Sequence
Ordered so the tree stays green and each step commits on its own.
- npm workspace;
client/ and city/ scaffolded beside api/ and app/, temporary alias, nothing moved
- Tier 3 singletons per-instance; the media-load semaphore becomes an explicit shared limiter object
client/ stood up with all nine endpoints; gen-types retargeted; the types/ barrel split three ways; leaf utils
- Settings schema into the package, per-instance resolved config and route dispatch
- Camera stores merge,
CameraMode deleted
- Events out;
tooltip.ts and tooltipText.ts deleted
- Data in:
baseUrl/src/branch, scan:* events
- Timeline engine in
sceneHandle.ts dissolves into instance methods; SCENE_HANDLE/BACKDROP_HANDLE deleted
- Cut the alias;
grep "@/" city/src is empty; tests move
- Preact out
Step 2 is independently valuable. Step 10 moves ~130 test files and is the
largest block of mechanical work.
Done when
grep "@/" city/src and grep "@/" client/src are empty
- Two instances on one page have independent settings, camera, selection, loading state, timeline position and GPU resources
- This app's backdrop and scene are two such instances
createCity() runs with no Preact, no localStorage, no app chrome
- Every event the app got by reading a global, it gets from a subscription
The city is the product, and it currently only runs inside this app.
app/src/city/reaches into app state 120 times: twelve settings stores, the progress store, the
chrome store, the manifest and source stores. Nothing else can mount it, and two
copies of it cannot coexist without fighting.
Extract it into
@codecity/city, installable, with an API client beside it.A canvas, an API base, a repo, and settings. It fetches, builds, and reports what
it is doing. Everything else is yours.
Repo shape
city -> client,app -> client + city.The fourth directory exists because of four endpoints.
branches,discover,configandcommitare only ever called by the app, and with three directoriesthey have nowhere to live but a leftover
app/src/api/, leaving the app with twodifferent ways to call one backend: its own folder, plus imports reaching through
a 3D renderer. One
createClient({ baseUrl })also means a retry policy or anauth header later lands in one place instead of two.
What "independent" has to mean
The home backdrop and the city scene become two instances of the same package,
and neither can touch the other. That is the acceptance test, because today they
share almost everything.
App globals the city writes. Seven
progresscall sites, so the backdrop'sbuild drives this app's loading overlay.
chrome.openSelectionPane, so clickingthe backdrop opens the app's sidebar. All become events.
App globals the city reads.
MANIFEST,CURRENT_SOURCE_KEY, eight timelinesignals,
IS_PHONE, and ~70 settings reads. All become constructor config.useHomeBackdrop.tsalready carries the scar tissue for this: "Applies themanifest STRAIGHT TO THE SCENE, never writing MANIFEST."
Module singletons inside the city. These are the ones that bite hardest, and
they are invisible today only because the two cities live on different routes and
never coexist:
facadePanelTextureArray.ts:58let _renderer+ a globalregisterRenderer(), one slot for oneWebGLRenderermaterial.ts:28,32,50ShaderMaterial, one icon atlas, globally; a material is bound to a GL contextgem/mesh.ts:45DataTexturetooltip.ts:11shots.ts:74,profiling.ts:14The moment two cities are on screen, that is corruption that reads like a driver
bug. Worth fixing whether or not the rest of this proceeds.
Scratch vectors, the content-keyed memo caches,
THREE.ShaderChunkregistrationand the
AudioContextare genuinely global and stay that way.Decisions
state/stores/timeline.tsimporting from city and handingPathTimelines back throughinstallScrubController.city.threeescape hatch for the capture harness and diagnostics; everything else wrapped.hoverevent. This also killstooltipText.ts, the only file importing@/components/panes/PaneStats/statItems— the one backwards view import in the package.@codecity/client.keyboard: falsedisables them. A package that needs its host to wire up Esc is not independent.three,three-mesh-bvh,rbush,@preact/signals-core.City.tsxstays here as a ~40-line wrapper.CAMERAandHOME_BACKDROPmerge into one store andCameraModeis deleted. Three of the four things the enum decides exist only because there are two stores.Settings
One reactive
settingsfield. The package owns the schema, the app owns thevalues, persistence and panel.
Shared base plus per-instance overrides is a
computedon the consumer's side,so the package needs no second concept for it.
Route dispatch moves inside the instance: you write a value, the instance decides
what it costs. This fixes a live bug —
schema.ts:207routeSignature()iterates a global map, so both instances subscribe to the same signature and
touching one Rebuild knob rebuilds every mounted city.
Same-origin only
baseUrlis a path (/api), never an origin.SameSiteApiMiddleware(
api/core/middleware.py:167) 403s cross-site/apirequests on purpose, and itsdocstring says the missing CORS headers are the point. Accepting an arbitrary
origin reopens that deliberately and is its own issue.
Sequence
Ordered so the tree stays green and each step commits on its own.
client/andcity/scaffolded besideapi/andapp/, temporary alias, nothing movedclient/stood up with all nine endpoints;gen-typesretargeted; thetypes/barrel split three ways; leaf utilsCameraModedeletedtooltip.tsandtooltipText.tsdeletedbaseUrl/src/branch,scan:*eventssceneHandle.tsdissolves into instance methods;SCENE_HANDLE/BACKDROP_HANDLEdeletedgrep "@/" city/srcis empty; tests moveStep 2 is independently valuable. Step 10 moves ~130 test files and is the
largest block of mechanical work.
Done when
grep "@/" city/srcandgrep "@/" client/srcare emptycreateCity()runs with no Preact, no localStorage, no app chrome