Skip to content

Extract the city into an installable package #208

Description

@thalida

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 bugschema.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.

  1. npm workspace; client/ and city/ scaffolded beside api/ and app/, temporary alias, nothing moved
  2. Tier 3 singletons per-instance; the media-load semaphore becomes an explicit shared limiter object
  3. client/ stood up with all nine endpoints; gen-types retargeted; the types/ barrel split three ways; leaf utils
  4. Settings schema into the package, per-instance resolved config and route dispatch
  5. Camera stores merge, CameraMode deleted
  6. Events out; tooltip.ts and tooltipText.ts deleted
  7. Data in: baseUrl/src/branch, scan:* events
  8. Timeline engine in
  9. sceneHandle.ts dissolves into instance methods; SCENE_HANDLE/BACKDROP_HANDLE deleted
  10. Cut the alias; grep "@/" city/src is empty; tests move
  11. Preact out

Step 2 is independently valuable. Step 10 moves ~130 test files and is the
largest block of mechanical work.

Done when

  1. grep "@/" city/src and grep "@/" client/src are empty
  2. Two instances on one page have independent settings, camera, selection, loading state, timeline position and GPU resources
  3. This app's backdrop and scene are two such instances
  4. createCity() runs with no Preact, no localStorage, no app chrome
  5. Every event the app got by reading a global, it gets from a subscription

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions