Skip to content

Extract the city into @codecity/city - #210

Open
thalida wants to merge 80 commits into
mainfrom
feat/issue-208-city-package
Open

Extract the city into @codecity/city#210
thalida wants to merge 80 commits into
mainfrom
feat/issue-208-city-package

Conversation

@thalida

@thalida thalida commented Sep 3, 2026

Copy link
Copy Markdown
Owner

Closes #208.

The city becomes @codecity/city, an installable package with a real Preact component, and the app becomes a consumer of it.

What moved

  • packages/city — the renderer, layout, timeline, picker, settings schema and client, behind one entry point plus ./preact, ./testing and ./testing/three subpaths. Preact is an optional peer: the core runs framework-free.
  • packages/app — reorganised into vertical features (features/{city,home,settings}), server reads moved onto TanStack Query in api/, types colocated with what they describe.
  • packages/api — the stream route handlers extracted out of the routers; gen_openapi.py moved into the package.

<City> is a component

Props in, events out: source, manifest, viewState / onViewStateChange, onSelect / onHover / onPick / onFocusRequest, a children overlay slot, and a components override map with DefaultCityTooltip exported beside it — pass null for no tooltip, your own for your own. State is read through hooks (useCityStatus, useCityManifest, useCitySelection, useCityHover, useCityTimeline, useScrub), not mirrored into app signals. Thirteen module-level mirror signals and SCENE_HANDLE are gone.

The city also reports what it is doing rather than leaving the host to infer it from event traffic: CityPhase.Reading with a timelineStage for a history read, reading for the whole of one, scan:cancel / timeline:cancel for a load called off.

bin/audit.py

A committed audit for the gaps tests cannot see: dead code, orphaned modules, misplaced files, vocabulary drift, inference from event traffic, values with several writers, and components no test ever renders. --score grades each package by what it is — library, application, service. Currently 0 findings; 5/5 api, 5/5 app, 10/10 city.

Known state

Timeline entry has been the rough edge and had three defects fixed late: the overlay came down at the read's pack rather than at the read's end, the rows swapped to the scan's mid-entry, and the progress counts were never wired through. It wants a careful look in a browser before this merges — the resting scrub position in particular was reported wrong and I could only verify it at the loader level, where a fresh entry does land on the newest position.

🤖 Generated with Claude Code

https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc

thalida and others added 30 commits August 23, 2026 11:40
Step 1 of #208, the repo shape. Nothing has moved: `city/` and `client/` hold a
`src/index.ts` that exports nothing, and the app builds and tests exactly as
before — 187 files, 2140 cases, the same count as before this commit.

The load-bearing piece is the `@/*` -> `../app/src/*` alias in both packages'
tsconfig and vite config. It is what lets a family of modules move into a
package and keep resolving its app imports while those imports are removed one
at a time. It is deleted at step 10, and step 10 is not done until that deletion
type-checks.

npm workspaces means one lockfile, at the root, so every container that ran
`npm ci` inside `app/` now runs it at the workspace root and picks its workspace
with `-w app`: the Dockerfile's web-builder, the dev app service, vitest,
gentypes, `just lint-app`, the pre-push gate and CI. `app/.npmrc` moves to the
root for the same reason. The pre-push gate's changed-file eslint still cd's to
`app/`, where its config lives.

`app/package-lock.json` is deleted rather than regenerated: a fresh resolve of
its `^` ranges floated 142 transitive versions forward, one of which renamed the
material-icon-theme SVGs 41 test files import. The root lockfile is seeded from
its exact resolutions instead, so this commit changes no dependency version —
the only additions are the three workspace links.

`tsconfig.base.json` holds what all three share. `app/tsconfig.json` keeps only
its own JSX, `paths` and `types`; `tsc --showConfig` for both app configs is
byte-identical to before, so nothing about how the app type-checks moved.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
…ects

Step 1 of #208, the repo shape. Nothing has moved between packages: `src/city`
and `src/client` hold a `src/index.ts` that exports nothing, and the app builds
and tests exactly as before — 187 files, 2140 cases, the same count as on main.

Two changes, one pass, because they rewrite the same ten config files.

`src/` because `bin` sorted between `app` and `city` and `scripts` after
`client`: six top-level directories where four were the product and two were
tooling, interleaved alphabetically and indistinguishable without reading each
one. Now `src/` is the product and `bin`, `scripts` and `.github` are how it
gets built. The runtime image mirrors that layout (`/srv/src/api`) so
pyproject.toml's paths mean one thing on the host and in the container.

`src/app`, `src/city` and `src/client` are three independent npm projects, each
with its own lockfile and node_modules — not workspaces. The app's
package.json, package-lock.json and .npmrc are byte-identical to what they were,
so this commit changes no dependency version. `src/city` depends on
`@codecity/client` through `file:../client`, which is the link the moves in
steps 3 onward will travel.

No shared tsconfig: each project's is self-contained. A base at the repo root
would have re-coupled three projects chosen to be independent, and the vitest
container proved the point by failing to resolve `../../tsconfig.base.json`
through a mount that only carries `src/app`.

The screenshot and demo-video scripts resolved the repo root as two levels up
from `app/scripts`, which is now `src/`. Three call sites, all of which would
have written .github/readme assets into the wrong directory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
Follows a9d1063. The gate is unchanged: 535 api tests, 187 files / 2140 app
cases, ruff and pyright clean, the image builds and serves.

`packages/` over `src/` because these are four independent packages, not one
project's source tree. The test each of them now passes: lifting one into a
repo of its own is a copy, not an untangling.

`packages/api/` is the Python project — pyproject.toml, uv.lock, and its own
README and LICENSE, because hatchling refuses to ship a path outside the
project directory. The importable package is `packages/api/api/`. That doubled
segment is not a choice: Python resolves `import api` by finding a directory
named `api`, and a manifest cannot sit inside the directory it names, so the
two are always separate directories.

I tried three ways around it before accepting it. `sources = { "" = "api" }`
under hatchling builds a correct wheel but dies on `pip install -e .`, which is
what every test and lint run needs. setuptools' `package-dir = { "api" = "src" }`
installs and passes pytest, but pyright reports 147 unresolved imports and 1601
strict errors, because it reads directories off disk and never runs an editable
finder. Both are recorded here so the next person does not repeat them.

`[tool.ruff]`'s `src` key is gone: it fed isort's first-party detection and no
isort rules are enabled, so it was inert. bin/ and scripts/ sit above the api
project and now get stock ruff explicitly (`--isolated`), verified byte-for-byte
identical to what the old repo-wide config produced. The ruff container mounts
them outside /srv so config resolution matches the host.

hatch-vcs gains `search_parent_directories`: the tags are on the repo, two
levels up, and the search still terminates correctly if this directory is ever
lifted out — which a hardcoded `root = "../.."` would not survive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
…oject

`package.json`, `package-lock.json`, `.prettierrc.json` and `.prettierignore`
are gone from the root. Each npm package carries prettier as a devDependency,
its config under the `prettier` key in its own `package.json`, and its own
`.prettierignore`. Only the prettier devDependency is added — no other
resolution moves in any of the three lockfiles.

Consequence, and it is deliberate: `README.md`, `AGENTS.md`, the two compose
files and the three workflows belong to no package, so nothing formats them any
more. They are hand-formatted from here.

The `prettier` compose service becomes `packages`, which typechecks and
format-checks city/ and client/ in one container; the app's format check joins
its existing lint + typecheck run in the vitest service. `just lint` gains
`lint-packages`, and pre-push step 6 changes from "prettier (repo root)" to
"typecheck + prettier (city/, client/)" — still seven steps.

`just setup` no longer installs at the root and now syncs the api venv too, so
one command still bootstraps a fresh clone.

Gate unchanged: 535 api tests, 187 files / 2140 app cases, ruff + pyright clean,
generated types fresh, image builds and serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
`packages/client` is deleted. Every call to the api ends up in
`packages/city/src/client/`, exported as `createClient({ baseUrl })`, and
`packages/app/src/api/` goes away entirely at step 3.

The spec justified a fourth package with "the four app-only endpoints have
nowhere else to live". They do — but splitting on that line leaves two places
that know the wire format, two places a retry policy or an auth header has to
land, and two answers to "how do I reach the backend". One package talks HTTP.

`branches`, `discover`, `config` and `commit` (166 loc) are endpoints the city
never calls itself. They ship from it anyway as separate ESM exports, so a
consumer who only calls `createCity` tree-shakes them away. That is cheaper than
a second fetch layer, in a bundle already carrying three.js.

And the client was never separable in the first place: per D4 the city fetches
its own repo, so the two would always ship and always version together, which is
the smell that says they are one package.

Also written into the spec and plan this pass, all still ahead of any code:
settings arrive through `city.updateSettings(partial)` rather than a reactive
input, because a consumer in React or a bare script tag should not have to
construct someone else's signal to configure a canvas; signals leave the package
entirely, API and internals both, which the 20 `untracked()` calls against 2
`signal()` calls say was always the wrong model; and `three` belongs only to
`packages/city/package.json`, since after the extraction nothing outside
`packages/city/src/` imports it.

Gate unchanged: 535 api tests, 187 files / 2140 app cases, ruff + pyright clean,
types fresh, image builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
Step 2 of #208, and the one step that is a bug fix rather than a refactor.
Nothing here is a policy problem — a ShaderMaterial belongs to the WebGL
context that compiled it, an icon atlas to the build that produced it, and the
renderer slot had room for exactly one renderer, so a second city overwrote the
first and its facade uploads landed on the wrong context. It stays invisible
today only because the backdrop and the scene live on different routes and
never coexist.

`city/resources.ts` holds what one city owns alone: the building material and
its icon atlas, the gem's glow texture, the renderer registry, and the capture
harness's timeline latch. `createCity` builds one and threads it through
`SceneContext`, so a component reaches its own city's material instead of
reading a module.

`material.ts` becomes `createBuildingMaterial()`. That was the widest change —
`getBuildingMaterial()` had 13 call sites across cellMesh, fader, scrubApply,
the buildings component and four test files. The atlas moves with it, or
`setIconAtlas` would push a build's atlas onto a material it does not belong to.

The media-load limiter stays shared, because what it protects is the page's
connection pool and two cities do not get twice the bandwidth. It becomes an
object rather than three module `let`s, so the sharing is declared and a
consumer can hand one city its own.

Two deviations from the plan, both deliberate:

The layout profiler stays a module singleton. It is off by default, has no
production callers (one bench test), and normally runs inside the per-city
layout worker — only the no-Worker fallback shares it. Interleaved buckets
would make a debug readout confusing, not corrupt a frame, and the fix costs 23
threaded call sites through two hot layout files.

The WebGL2 max-array-layers probe stays global too: it measures the device, not
the city.

`tests/city/twoInstances.test.ts` is the guard — 10 cases asserting distinct
materials, atlases, translucency, renderer registrations, glow textures and
latches, plus that disposing one city leaves the other usable. Proven to bite:
hoisting `_sharedMaterial` back out of the factory turns 4 of them red.

188 files / 2150 cases, up from 187 / 2140 by exactly the new file. ruff +
pyright clean, image builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
Step 3 of #208, first half. `packages/app/src/api/` is gone. All nine endpoint
files live in `packages/city/src/client/` behind one `createClient({ baseUrl })`,
which the package exports, and `grep -rn '\bfetch(' packages/app/src` returns
nothing.

Each endpoint module became a factory closing over the URL builder, so a client
carries its own base instead of a module reading one. `apiUrl.ts` became
`url.ts` and takes its base as an argument: `import.meta.env.BASE_URL` is a
Vite-ism and a package cannot depend on its consumer's bundler. The app resolves
its deploy base in `apiClient.ts` and passes it in. The base is a path, never an
origin — same-origin only, per the spec.

The two per-client caches move with their endpoints: `getServerConfig` and
`getDiscover` were module `let`s, and are now scoped to the client that fetched
them, which is what "cached per baseUrl" always meant.

`fetchSignature` is new. `useManifestSource` was building its poll URL through
the client and then calling `fetch` itself, which is exactly the second way to
reach the backend this step exists to remove.

`URL_PARAMS` moves too — its own comment says the backend reads those exact
names, so it is wire vocabulary, not app routing.

Plumbing: the app declares `@codecity/city` via `file:../city`, and vite and
vitest alias it to the package source, because the package ships TypeScript
rather than a build for as long as the extraction is in flight and Vite will not
transform TS inside node_modules. The vitest and dev containers mount
`packages/city` as a sibling of `/app` so `../city` resolves the same way it
does on the host; the Dockerfile copies both manifests before `npm ci` so npm
can link them.

Still ahead in step 3: the types barrel split, the `gen-types` retarget, and the
leaf utilities. The client reaches those through the temporary `@/` alias for
now, which is what the alias is for.

188 files / 2150 cases, unchanged. ruff + pyright clean, types fresh, image
builds and serves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
…s gone

Step 3 of #208, second half. `packages/app/src/types/index.ts` is deleted and
its 181 importers now name where a type comes from: the wire format and the
geometry the layout produces are `@codecity/city`, and the app keeps only the
shapes it draws with — `types/ui.ts`, `types/controls.ts`, `types/picker.ts`.

`gen-types` writes `packages/city/src/types/manifest.generated.ts` now, and
`openapi-typescript` moved with the file it generates, `.npmrc` and all — that
config exists solely for its stale peer range. `check-types-fresh`, the gentypes
container and the prettierignore all retargeted in this commit, because missing
the last one is how the freshness check ends up reporting the formatting instead
of the models (#182). It caught exactly that mid-change: prettier reformatted
1192 lines of generated output before the ignore landed.

`picker.ts` stays in the app, against the spec. Moving it pulled `@types/three`
into the package, and with two copies of those declarations in the tree the
app's `THREE.Mesh` stopped being assignable to the package's — 8 errors that are
one bug wearing many hats. Its types describe meshes the app still owns, and it
already imports `buildingIndex` and `cellTile` back across the boundary, so it
moves when they do. The package needs no `three` at all today.

Both lockfiles were regenerated with the pinned npm (11.6.2) in a container:
regenerating them on the host produced a tree `npm ci` rejects, which is the
same @emnapi lockfile-shape mismatch the Dockerfile pins NPM_VERSION for.

188 files / 2150 cases, unchanged. ruff + pyright clean, types fresh, image
builds and serves 30 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
No behaviour change. `types/picker.ts` belongs in @codecity/city and moves there
in step 10 of #208, in the same commit as `src/city/`.

It cannot go first: every shape in it carries a real `THREE.Mesh`, so moving it
alone puts a second copy of @types/three in the tree and the app's `Mesh` stops
being assignable to the package's. Its two remaining imports point back at the
building meshes it describes — both ends of that cycle cross together or neither
does.

Recorded in the file and in the plan's step 10, so the next pass reads it as a
scheduling constraint rather than a decision to revisit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
It imports `from api.app import create_app`, so it cannot run without that
package — it is a script OF the api project, not repo tooling. It was the only
thing in `scripts/`, so that directory is gone and the root is one entry
lighter.

Living inside the project earns it the api's ruff config instead of the
`--isolated` stock treatment `bin/` gets, so the two now differ for the reason
they should: one is under a pyproject, the other is not. `exclude` keeps it out
of the wheel.

`comment-check` targets `packages/api/api packages/api/scripts bin` rather than
`packages/api`, or it walks `.venv` and lints pydantic's comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
… combines

Compose splits per package behind `include:`. `packages/<pkg>/docker-compose.
{dev,test}.yml` define that package's services with paths relative to
themselves, so the api's mount reads `./api:/srv/api` rather than
`./packages/api/api:/srv/api`. The root files no longer know any package's
internals — dev keeps only the wiring (what the app talks to, and what has to be
healthy first), because that belongs to neither package alone.

`.gitignore` splits the same way. Anything one package generates is ignored by
that package, so lifting one out takes its ignores along. The ignored set is
byte-identical before and after, checked with `git status --ignored`.

`.dockerignore` stays at the root — that is what `docker build .` sends — but
now uses globs instead of package paths, which fixed a real leak: it said
`.venv`, matching only the root, while the api's venv now sits at
`packages/api/.venv`. 105MB was one build away from shipping in every context.
Context is 77kB.

Dropped as dead: `.codecity/` and `.superpowers/`, neither written by anything
since the first scaffold commit, and a `.dockerignore` note explaining that
README and LICENSE are *not* ignored — the files hatchling validates are
`packages/api/`'s, and nothing ignored them anyway.

Every comment block in the Dockerfile and all seven compose files is back under
the two-line cap the rest of the repo keeps. YAML and Dockerfiles are linted by
neither eslint nor check-comments.py, which is how they drifted to six-line
paragraphs.

The one image is untouched: `ghcr.io/thalida/codecity`, port 8080, /cache,
entrypoint and healthcheck all identical, so the deploy compose needs nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
`fileKind`, `fileIcons`, `fileIconMap`, `materialIcons`, `fileExtensions`, and
the `buildings`, `gem` and `manifest` constants move into @codecity/city, along
with `utils/manifest`. The app imports them back: a kind badge and a building
have to agree on what kind a file is.

`utils/dates` splits. The parse rule goes — `parseLocalDate`, `parseDateMs`,
`epochDayAt`, `epochDay` — and the seven formatters stay. One rule for both, or
a commit made in the evening gets labelled one day on an axis and the next day
under the handle.

`material-icon-theme` moves with the 169 icons that import it, which turned up
three places that assumed the city had no dependencies of its own: the vitest
and dev containers only ran `npm ci` in the app, and the Dockerfile only
installed the app's. All three now install the city's too.

Vite's fs guard denied them after that — the package is a sibling directory
outside the app's root, and it resolves assets from its own node_modules now.
`server.fs.allow` covers the sibling in both configs.

Worth naming: this makes the Vite requirement explicit rather than new.
`materialIcons` uses `?url` and the shaders already use `?raw` in 20 places, so
consuming @codecity/city means a bundler that understands those. `assets.d.ts`
declares both. Shipping a built package with the assets resolved is what would
lift that, and it belongs with the package's own build, not here.

188 files / 2150 cases, unchanged. Image builds and serves 30 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
Step 4 of #208, first half. Thirteen field files — twenty stores' worth of
declarations — move to `packages/city/src/settings/fields/`, and the
declaration vocabulary (`FieldKind`, `ChangeRoute`, `FieldDef`, `FieldMap`,
`ConfigOf`, `SelectOption`, `coerceField`) to `settings/schema.ts`. All of it is
pure data and pure validation: the city says what is tunable, what each field's
bounds are, and what changing it costs.

The app keeps everything that holds a value. `settingSignal` still builds the
persisted signals, still registers them for Reset-all and the dirty dot, and the
panel still renders over the same declarations — it just reads them from the
package now. `theme`, `syntaxTheme` and `updates` stay where they are: they are
the app's own fields, and they declare themselves with the primitives it
re-exports.

No signals crossed. `packages/city/src/settings/` imports `@preact/signals`
nowhere, which is the point.

The other half of this step does NOT land here, and it is worth being exact
about why. `routeSignature()` iterating a global `_FIELDS` map is only a bug
once two cities hold DIFFERENT settings; today they read one store set, so both
rebuilding on one knob is correct, not cross-fire. Per-instance resolved config
and `city.updateSettings(partial)` need the renderer to own its own config, and
16,660 lines of it are still in `packages/app/src/city/`. Both land with the
move, not before it.

Two plumbing fixes fell out: `tsconfig.node.json` overrides `include`, so it
could not see the package's ambient `?url`/`?raw` declarations, and the field
maps were module-private consts that had to be exported to cross.

188 files / 2150 cases, unchanged. Image builds, lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
All 111 files of it, plus picker.ts and three-augment.d.ts. `packages/app/src/city/`
is gone; what is left of the app is 14,291 lines of chrome against the package's
22,458. `three`, `three-mesh-bvh` and `rbush` move with the code that imports
them, and the app declares none of them.

This is out of plan order on purpose. Steps 5 through 9 all move behaviour that
lives in the renderer — the camera rig, the progress writes, the manifest reads,
the scrub engine, the scene handle. Doing any of them across a package boundary
is harder than doing them inside one, and the two-instance tests those steps
want cannot be written until a city can hold its own state. This unblocks them.

`@/city/*` now resolves into the package from both sides, so roughly 240 import
sites stayed valid. Step 10 rewrites them against the public surface; until then
the alias is doing the same job it has done since step 1.

Four things this turned up, none of them optional:

The barrel is a cycle. `packages/city/src/index.ts` exports createCity, so
importing an enum from it evaluates the whole renderer, which still imports back
into the app — and the enum is undefined by the time a field map reads it. 157
app files and 68 package files now deep-import instead. It resolves itself when
the renderer stops reading app state (steps 6 and 7).

Two copies of a package are two identities. `@preact/signals` is a peer
dependency of the city with exactly one install, the app's, or an effect on one
side never sees a write from the other. `three` was worse: aliasing it to a
DIRECTORY bypassed its exports map, so the tests and the package loaded
different entry files and `expected Vector3 to be an instance of Vector3`.
Aliased to the entry file, deduped in both configs.

TypeScript resolves node_modules from the importing file, so package files
typechecked from the app looked in `/city/node_modules` — a bare volume in the
containers. Explicit `paths` for three, three-mesh-bvh and signals.

Every container override needs the city installed now that it has dependencies:
lint-app, the pre-push gate and both CI steps.

`City.tsx` moves to `components/City/` — it is the Preact wrapper, and step 11
leaves it there.

188 files / 2150 cases, unchanged. ruff + pyright clean, types fresh, image
builds and serves 30 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019Qompn3zaVZ1a6CG8Wd5ia
Step 4 of #208, second half — the half `af6b40c6` deferred until the renderer
had moved. `createCity(canvas, { settings })` takes a plain object over the
package defaults, `city.updateSettings(patch)` is the whole input surface, and
nothing in `packages/city/src` imports `@/state/settings` any more.

`settings/index.ts` names the twenty stores once and derives `CitySettings` and
the stock values from the field maps, so there is no parallel defaults literal.
`settings/store.ts` holds one signal per store, which is what the ~47 component
effects subscribe to; the signals are internal, and step 11 drops the `.value`
without moving a read site. Writes are validated against each field, so a value
the panel could never produce cannot reach the renderer: a test asserting
BOB_AMPLITUDE 7.25 was asserting an impossible setting, since the field declares
max 2.0.

`updateSettings` skips an identical write. A rebuild-routed store notifying on a
no-op change repacks the whole city, which is seconds on a large repo.

The app keeps every value, its persistence and the panel. `CITY_SETTINGS` folds
its twenty signals into one object and `City.tsx` pushes it: values are pushed,
never shared, so two cities on one page can hold different ones.

Both build workers are now pure. Each used to post a config snapshot and write
it back into its own module-level stores before computing; `layoutCity` and
`placeTrees` take config as an argument instead, and the request carries the
sending city's own values across unchanged. `_applySnapshot` is gone from both
ends. `LayoutConfig` is a `Pick<CitySettings, …>` rather than a shape of its
own, so nothing translates key names at the boundary.

Two things this turned up:

`placeTrees`'s `islandGeoOverride: null` was redundant with `ISLAND.ENABLED:
false` — both skip the polygon rejection pass while the island's shape still
sets the sampling extent. Only tests ever passed it. Collapsed into the config.

`gemFaceColors` and `BACKDROP_POSE` were module-level `computed()`s over the
global stores. Both are now per instance: `createGemPalette(settings)` keeps its
memo (and its tests, plus one proving two cities memoize separately), and the
rig builds its own backdrop pose.

The test suite stops mutating global stores to drive components. A test states
the settings it is about and hands them over, or takes a store and calls
`update()` — the same path a Save takes. That deletes a lot of
capture-and-restore hooks, and `resetTreesConfig`/`resetBuildingsConfig` become
`TEST_TREES`/`TEST_BUILDING_DIMENSIONS` patches.

Proved the guard bites: making `createSettingsStore` return a shared singleton
turns four of the new isolation tests red.

188 files / 2159 cases (2150 + 8 two-instance + 3 gem-palette − 2 replaced).
ruff + pyright clean, 535 api tests, lint clean, types fresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 5 of #208. `CameraMode` was never about the camera: it was about there
being two settings stores for one thing, and a rig that had to know which of
them it was reading. With settings per instance, both problems are the same
problem, and it goes away.

`CAMERA` and `HOME_BACKDROP` merge into one field map. `TARGET` ('city' | 'gem')
picks between fitting the whole project and orbiting the gem, and joins
`ELEVATION`, `AZIMUTH`, `DISTANCE_SCALE`, `AUTO_ROTATE` and `ROTATE_SPEED`. Three
of the four things the enum decided evaporate: `_autoRotateFor`, the mode field,
and the two mode-gated reset effects, which collapse into one. The fourth is
`TARGET` doing what the enum did at the one place it was load-bearing.

The panel keeps both groups, and this is the interesting part. The package
declares ONE camera vocabulary; the app keeps TWO sets of values, because it
mounts two cities. `CITY_SETTINGS` feeds the scene, `BACKDROP_SETTINGS` is the
same object with the wallpaper's own camera over the top, and `City.tsx` picks by
variant. So the reader keeps tuning their wallpaper's orbit independently of
their project camera — the thing the merge looked like it would cost — and the
two-instance story stops being a test fixture and becomes the product.

`BACKDROP_CAMERA` ships as an exported preset rather than a branch in the rig,
and `withDefaults()` turns it into a field map so the app's second store gets
the right dirty dot and Reset-all behaviour.

`prefersReducedMotion` stays inside the package, where the rotation effect is
now the sole writer of `controls.autoRotate`: `AUTO_ROTATE: true` means "spin
unless the reader asked for less motion". `setMode(Backdrop, {autoRotate:false})`
becomes `updateSettings({ CAMERA: BACKDROP_CAMERA })` plus
`rig.setAutoRotate(false)` — the capture wants one still frame, which is not a
reader saying they want a still wallpaper, so it is not a settings write.

Two things fell out. The project camera can now spin and orbit the gem, because
those stopped being backdrop-only. And dragging the rotation speed no longer
yanks the pose back to its start azimuth on either camera, which was already
true of the backdrop and now holds for both.

The rig tests are rewritten against settings rather than the enum, including a
new one that drives a wallpaper camera the whole way across its range and
asserts the scene camera has not moved.

188 files / 2161 cases. Lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 6 of #208. Seven writes into the app's progress store and one reach into
its view layer become an emitter on the instance: `build:start` (carrying the
stage list, so a readout has its denominator from the first frame),
`build:stage`, `build:progress`, `build:done`, `build:error`, plus `hover` and
`select`. Nothing in `packages/city/src` writes an app store any more.

`build:done` still fires two rAFs after the meshes land rather than when
applyManifest resolves — that is the whole reason it is an event and not a
return value, and the tests now wait on it directly instead of watching a
global settle.

`attachBuildProgress(handle.on)` is the app's half, and where "whose build is
this?" gets answered. A backdrop city never calls it, so the landing's wallpaper
can build behind the page without moving the readout above the project you are
reading. Three tests cover that, and breaking the unsubscribe turns all three
red.

The tooltip moves out. `interaction/tooltip.ts` and `tooltipText.ts` are gone
from the package, and with them the import of the app's `PaneStats/statItems` —
the one place the renderer reached into the view layer. It lands as
`components/CityTooltip/`, one card per canvas rather than a module-level
element, and it follows the cursor itself: where the pointer is is a DOM fact
the view already has, and the city should not report a position sixty times a
second for it. `hover` is therefore the undebounced pointer target, not the
35ms-committed one the outlines settle on — a tooltip that lags the cursor by a
frame reads as broken.

`scrubLines` turned out to be dead on the way past: `fileStatItems` already
resolves the replayed line count off the path, and wins over the caller's
override. The parameter is gone, and the behaviour it was guarding — the
Timeline count beating the union maximum — gets a real test against the actual
replay in paneStats, which is where it lives now.

`packages/city/src/capture/` moves to the app. It read four app stores and was
only ever loaded from main.tsx: it was app tooling sitting in the package.

`BuildStage` moves into the package as part of the event payload;
`BUILD_STAGE_LABELS` and the whole LoadingStep reduction stay in the app, which
re-exports the enum so nothing else had to move.

`select` is emitted from `picker.setSelection`, not from the two pointer call
sites the plan named: every path to a selection runs through there, so a tree
row and a deep link reach a subscriber exactly as a click does.

188 files / 2168 cases. Lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 7 of #208. `createCity` takes a `baseUrl`, builds its own client, and
`city.loadSource({ src, branch, … })` streams the manifest and applies what
comes back. `MANIFEST` and `CURRENT_SOURCE_KEY` are gone from the package: the
reframe gate now asks whether THIS city's source changed, and leaving Timeline
rebuilds from the manifest this city last applied at HEAD rather than from
whatever the page happened to have open.

Five `scan:*` events carry the load: `scan:start`, `scan:progress` (the server's
own clone/scan events, passed through rather than restated — the overlay's
reduction is written against those phases already), `scan:label`,
`scan:manifest` and `scan:done`/`scan:error`. `attachScanProgress` is the app's
half, attached to the scene city only, so the landing's wallpaper can clone a
different repo behind the page without renaming the project in the header or
moving the readout. That was the plan's stated risk for this step, and it has
two tests.

What this deletes is subtle and worth naming: City.tsx's manifest→scene effect.
The city used to learn what to build by watching a global, which is why one
MANIFEST could only ever describe one city. Now the answer arrives on the same
call that asked the question, and two cities on one page each build what they
were asked to build.

The live-update poll deliberately does NOT go through `loadSource`. A poll is a
refresh: no overlay, and no skeleton, because applying one would animate every
building down to placeholder heights and back on each save. It fetches and
calls `applyManifest` directly, which is the app saying "render this", not
"go and get that".

`loadSource` peeks the handle before awaiting it. Only a cold boot waits; an
unconditional await pushed the stream a microtask out and the overlay with it,
which the tests caught.

`useHomeBackdrop`'s "never write MANIFEST" note is gone. It was a rule to
remember; a city holding its own manifest makes it a fact about the shape.

The app's load tests get a real source loader over a real client through
`stubSceneCity`, so the stream still runs through the stubbed EventSource and
every phase and cancellation assertion still bites.

Package app-imports: 12 kinds at the start of this branch, 4 now — timeline
(step 8), chrome and viewport (step 9), keyboard (step 10).

188 files / 2170 cases. Lint clean, types fresh.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 8 of #208. The timeline engine — mode, bundle, position, the rest points
derived from it, and the per-path replay — moves into the package as
`createTimelineState()`, one per city. A bundle is one repo's history, so the
scrubber had to be one city's: sharing it meant the landing's wallpaper and the
project behind it could not have been at different commits even in principle.
Three tests hold that.

The app's `state/stores/timeline.ts` keeps its name and its exports and stops
holding any value. Every signal in it now reads THROUGH the scene handle to the
city's own engine, and the writers call into it. One source of truth, and it is
the city's: a second copy here would be a second answer to "where is the
scrubber", which is the class of bug this refactor exists to remove. What stays
app-side is the half that was always the app's — `PANE_MANIFEST`,
`PRESENT_PATHS`, the folder rollups the panes render.

A detached engine stands in before the canvas mounts, so a boot-time read
answers "not in Timeline" rather than throwing.

`SETTLED_COMMIT` is derived now, not written. Two tests were setting it by hand
right after moving the scrubber, which the engine does for them once the drag is
over; those lines are gone rather than ported.

`leaveTimelineMode()` is new, and the reason is worth recording: `exit()` drops
the loaded history along with the mode, which is right for a toggle-off and
wrong for the scrubber's re-entry regression, where the whole point is that the
deps did NOT change. Rewriting that test onto `resetTimelineMode` quietly
changed what it was testing; it gets the mode-only flip it meant.

Two things this turned up. `versionKeyFor` is a free function, so it takes the
timeline rather than reaching for one. And `cameraRig.test.ts` had an order
dependence from step 5 — a file-level settings store reset inside one describe
and leaked into the next; it resets per case now, which is what fixed two tests
that had been passing on ordering alone.

Package app-imports: 3 left, all step 9 and 10 — chrome, viewport, keyboard.

188 files / 2173 cases. Lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Steps 9 and 10 of #208, together because the last app import was the thing
standing between them.

`packages/city/src/sceneHandle.ts` becomes `packages/app/src/state/stores/city.ts`,
which is where it always belonged: it held app chrome (`SIDEBAR_COLLAPSED`,
the selection pane) and two slots for cities the app had mounted. What moved to
the package is the half that was the city's — `city.focus(ref, mode)`, the one
place a path or a sha becomes a camera move. It returns whether there was
anything to look at, and the chrome decision on the other side of that boolean
stays in the app. `focusPath` and `goToPath` still differ only in which chrome
they reveal, which was the thing not to collapse.

Two behaviours had to become events, and the distinction between them is the
interesting part. `select` fires when the selection CHANGES. `pick` fires on
every completed pick, including re-picking what is already picked — which is
how you get back to a details pane you closed, and which `select` therefore
cannot express. And `focus` reports the focus key, because a keystroke inside
the canvas is the reader asking the city to look at something, while a consumer
calling `focus()` already knows it asked.

`attachCityChrome(handle.on)` is the app's half, alongside `attachBuildProgress`
and `attachScanProgress`. Three attaches, one shape: the scene city gets all of
them, the landing's wallpaper gets none, and that is the whole of "each instance
has its own chrome".

`createCity`'s `keyboard` option replaces the package reading `OVERLAY_OPEN`:
`false` turns the shortcuts off, and a predicate is asked per keystroke, which
is how a consumer with a modal open keeps the keyboard while it is. The bindings
themselves ship with the renderer now (`city/constants/keyboard.ts`); the app
re-exports them beside its own so the shortcuts panel still lists both.

`city.three` is added as the documented escape hatch.

Two files were still fetching through the APP's client. A facade belongs to the
repo whose building it is on, so they take the city's own — which on a second
city is a different repo entirely.

Then the alias goes. `@/*` no longer maps into `packages/app/src` from either
tsconfig or vite: nothing in `packages/city/src` reaches outside the package.
`@/city/*` stays, resolving to the package's own root, because ~700 import sites
already spell it that way and rewriting them would be churn for its own sake.

`sceneCommands.test.ts` stubs the city at `focus(ref, mode) -> did it land?`
rather than restating what the picker and rig do, and says so: the camera half
is guarded against a real city in builtCity.test.ts.

188 files / 2173 cases. Lint clean, image builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 11 of #208, first half. `createSettingsStore` stops holding signals and
starts holding values: `settings.BUILDINGS.HEIGHT_SCALE` is a plain read, and
`settings.on(stores, listener)` is how a component says which changes it cares
about. `updateSettings` works out which stores actually differ and calls only
the listeners registered against those — once per update, not once per store
that moved.

This is the second sweep promised when the first one landed: `.value` simply
disappears from ~180 read sites, and nothing else about them moves. `on()`
applies immediately as `onSettings` did, so this changes the mechanism and not
the behaviour — a component's "put my settings on the material" is the same code
at construction and on every Save, and having written it once it should not have
to remember to run it.

The hazard here is worth recording, because it is the reason to be careful with
this shape: an effect that tracked `settings.X.value` still COMPILES after the
value goes plain — it just tracks nothing. Nine of them were silently dead, and
the tests caught every one. `onSettings` is deleted rather than ported: with a
per-store subscription there is nothing left for it to wrap.

Two memos over settings went the same way. `createGemPalette` keys its cache on
the config object itself, which the store replaces wholesale on every update, so
identity IS "has the palette changed". And the camera rig compares its four pose
fields by hand rather than re-framing on any CAMERA change, which is what keeps
dragging the rotation speed from yanking a spinning orbit back to its start
azimuth.

One thing that needed care: the rig's pose guard has to start from null, not
from the current key. `on()`'s immediate call is what places the opening pose,
and initialising the guard to the current value swallowed it — two tests said so.

`@preact/signals` is down from 24 files to 21; what is left is the city's own
state (manifest, layout, revisions) and the picker, which is the second half.

187 files / 2169 cases (the onSettings unit test goes with the helper). Lint
clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 11 of #208, second half. `cityState` stops being signals and computeds and
becomes plain values with one explicit publication. An apply swaps the manifest
and layout, recomputes what is derived from them, and THEN says so — once, in a
known order, rather than letting a dependency graph work out an order of its own.

A component asks to hear about the kind of change it redraws for, which is a
much shorter list than "everything it read":

  structure  a non-reuse apply: the bbox, root street, gem anchor and world
             bounds are all fresh
  apply      any apply: manifest, layout and tree placements are fresh
  published  the components have rebuilt off those; this is the city on screen

Fourteen effects become fourteen subscriptions, and each one now states which of
those three it is for instead of leaving it to be inferred from the signals it
happened to touch. The derived geometry is recomputed at two named points rather
than lazily per read — the per-frame readers (framing, picking, the fader) ask
for it constantly.

The one non-obvious dependency is kept explicit: a FOOTPRINT halo widens the
world bbox without moving a building, so a halo change recomputes the structure
and publishes it. That used to happen because `bbox` read `footprintHalo` and
the graph did the rest.

The tests move onto the pipeline rather than poking fields. `seedCityState`
publishes a layout the way an apply does; `republishCity` re-applies it, which
is what a live-update poll does; and `scenicReactivity` — the file whose whole
subject is structure-change versus reuse — now drives that distinction through
`applyManifest` with a matching or differing layout signature, which is where
the decision actually lives. `drivableCityState` is the stub for the remaining
unit tests, whose subject is what a component DOES when the city changes rather
than what decided to change it.

`cityState.test.ts`'s "#62" case was asserting on a field assignment; it now runs
two applies of the same signature against a client that returns real heights the
second time, which is the thing #62 was about.

@preact/signals: 21 files to 20. What is left is the picker's hover/selection
and the timeline's own state — the last two, and the next commit.

187 files / 2169 cases. Lint clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Unused imports the de-signalling orphaned, three `let cs = makeCityState()`
initialisers overwritten before they were read, and two comment blocks over the
house cap. One test that reassigned its city mid-case becomes two cities, which
is what it was actually comparing.

I committed the previous change without reading the lint output. It was clean
on the package and on the API; this is the app half.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 11 of #208, finished. `@preact/signals` is gone from
`packages/city/src` — every file, the peer dependency, and the tsconfig path
that resolved it out of the app's node_modules. What is left is `three`,
`three-mesh-bvh` and `rbush`, which is what the plan said the answer should be.

The picker and the timeline were the last two. Both become plain values with a
subscription, and both distinguish themselves from the city's own publication in
a way worth stating: `settings.on` and `picker.on` apply IMMEDIATELY, because
they report state — a component armed after something is already hovered has to
draw it. `cityState.on` does not, because its three kinds are transitions, and
firing one at construction would claim a publish that never happened.

This turned up a real bug. `PICKER_SELECTION_KEY` was a MODULE-level signal, so
two cities on one page shared a selection: picking a building in the project
would have re-resolved against the landing wallpaper's meshes too. Invisible
today only because a backdrop never selects anything. It is per picker now, and
a test holds it.

Two smaller things fell out of the same change. The selection and its key are
written together, and the key is written even when the target does not change —
a key that resolved to nothing has to clear whether or not there was a selection
to drop. And `_suspendKeyDerive`, the flag raised around every write to stop the
derivation feeding back into itself, is gone: setting a selection from a key and
deriving a key from a selection are two directions, and only a dependency graph
made them the same edge.

`reactiveRebuild` goes too — the supersession helper existed because an effect
cannot await, and there are no effects.

The app is where signals resume, and the seam is explicit: `state/stores/timeline.ts`
holds a revision the city's notifications bump, and every view recomputes off it;
`CITY_HOVER`, `CITY_SELECTION` and `PICKER_SELECTION_KEY` are the app's copies,
kept current by `attachCityChrome`. The chrome renders off those rather than
reaching through the handle.

Two implementation notes for review. The replay stayed lazy — building it for a
long history is expensive and only the scrub and stat readers need it — and
making it eager broke fixtures that never had `deltas`. And a `TimelineState`
must not be spread: its values are getters, so `{...state}` freezes them at that
moment, which is what one test stub was doing.

186 files / 2167 cases. Lint clean, image builds.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The repo root stopped being an npm project in step 1 of #208, so nothing
installs here — but running a package's vitest from the root still drops an
empty node_modules/.vite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Step 10 of #208, the half that was left. `packages/app/tests/city/**` was 91
files testing a package from outside it; they live in `packages/city/tests/**`
now, next to what they cover, and the package runs its own vitest to prove it.
Two suites, same total: 186 files and 2167 cases, unchanged from before the
move.

The client tests came too. `app/tests/api/**` drove `@/apiClient` — the app's
singleton — to test code that has been the package's since step 7. Each file
builds its own client with the base every caller passes.

The benches came too, all four being layout and decoration harnesses. So did
`tests/bench/README.md`, and the two-project vitest split it describes.

**What stayed, and where it went.** Fifteen files reached into app source. Seven
turned out not to need it — they read a default off an app settings signal that
nothing had been feeding the renderer since step 4b, or reset a store the
renderer stopped reading. Those reads came out and the tests followed the rest
across. The other eight test the SEAM, so they belong to the app, and
`tests/city/` was the wrong name for them once the package had a `tests/`
of its own: six are in `tests/integration/`, and `sceneCommands` and
`worldLayoutCache` sit with the store and the reactions they actually cover.

One of the seven was passing vacuously. `createCity`'s "no-ops when no
controller was installed" drove the APP's timeline store and asserted the city
did not tear down — but a city has read its own timeline since step 8, so the
assertion held against any implementation, including a broken one. It drives
`handle.timeline` now.

**The fixtures.** Both packages legitimately need the wire fixtures: a manifest,
a commit series, a scrub bundle. The package owns the types, so the package owns
the fixtures, and it exports them at `@codecity/city/testing` rather than the
app keeping a second copy to drift. Anyone building on this package wants the
same kit — settings, a city state, a picker stub, an EventSource — so it is
a real entry point, not a shortcut. Test-only, and not covered by semver.

The renderer stubs get their own door, `@codecity/city/testing/three`, and
this is load-bearing rather than tidy: a `vi.mock('three')` factory has to await
the module holding its replacement, the barrel reaches the city's source, and
the city's source imports three. Routed through the barrel the app's suite
hangs forever with no output — which is how this was found. The narrow path
keeps the stubs a leaf.

Four dead `@preact/signals` imports in the moved tests fell out of this: the app
resolved them because the app has signals, and the package's own typecheck does
not. Same for a `SCENE_HANDLE` import and three stand-in `signal()` locals that
step 11 stopped using.

`just test-city`, a `city-vitest` compose service, and the CI job and coverage
upload beside the app's. Coverage floors are set from what it measures
(82/68/78/84), a few points under, the way the app's are.

The package's tsconfig had `"types"` declared twice; JSON keeps the last, so the
comment on the first described a rule that was not in force. One declaration
now, and the preact JSX options are gone — there is no JSX here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
AGENTS.md still said `packages/city/` was empty scaffolding and that
`packages/app/src/city/` was a signals-driven mini-app. Neither has been true
for eleven commits, and it is the file an agent reads first, so it now says what
the two packages are: what `@codecity/city` depends on (three, three-mesh-bvh,
rbush, nothing else), that values go in and events come out, that signals live
in the app, and which of the two `tests/` trees a test belongs in.

The trap gets written down with it: `@codecity/city/testing/three` exists
because a `vi.mock('three')` factory awaiting the main barrel deadlocks.

Three file headers named their own old path, and the glsl one pointed at a
`hsl.ts` that has been `utils/color/colors.ts` for a while.

`just test` runs three suites now, not two.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
`just dev` refused to start, and neither vitest, tsc, eslint, prettier nor
`vite build` had anything to say about either cause. I never ran it.

`state/settings/schema.ts` had `export { ChangeRoute, FieldKind };` sitting
ABOVE the import that binds them. ESM hoists, so tsc is right to accept it and
the production build is right to emit it — but prefresh reads a module's exports
before it resolves its imports, and fails the dev server on a name it cannot see
yet. They go straight through from the city now, in the re-export block already
below, which needs no local binding at all.

`facadePanels.ts` reached for `this.timeline` inside a plain function, where
`this` is undefined. Mine, from be12989: that line was a module-level
`scrubbedBlobShaFor(...)` and the timeline move rewrote it as if it were in the
class. The sibling line seven above got `ads.timeline` right.

That one was not cosmetic. It throws from a component's tick(), and
`requestAnimationFrame(frame)` is the LAST statement in frameLoop's frame() —
so the throw stops the loop rescheduling and rendering stops for good. That is
the "empty world" and the missing landing backdrop: the city built fine and then
the first frame that reached a media building killed the loop.

`noImplicitThis` in both packages is the guard. Turned on, it found exactly one
offender, package-wide, and it was this one; the app was already clean. tsc's
default treats `this` in a free function as `any`, which is how a line that
could only ever throw type-checked for nineteen commits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Timeline mode did nothing visible: the tab did not change, the URL did not
change, the world did not change.

`createCity` built the public timeline as `{ ...timeline, installScrubController,
... }`. Every value on a TimelineState is a GETTER, so the spread called each one
once and froze the result. `handle.timeline.mode` answered false for the life of
the city, however many times that city entered Timeline — and everything the app
shows about the mode reads through that one property. The subscriptions still
fired, which is the part that makes this hard to see: the app's revision counter
bumped on every change, every view recomputed, and each one recomputed the same
frozen false.

I wrote this exact hazard down in 2607b39 — "a TimelineState must not be
spread" — having found it in a test stub. I fixed the stub and never looked for
the same shape in production, where it had been since be12989.

`Object.assign(timeline, {...})` instead: the scene controls go ONTO the state,
so what a caller holds is the city's own timeline with more on it.

Two guards, because the bug needed two to be reachable at all.

`tests/timelineApi.test.ts` drives the real `createCity` and asserts the handed-
out timeline reports the mode, bundle and position the city is actually in.
Restoring the spread turns two of its three red, which is the proof it bites.

`app/tests/state/stores/timelineBinding.test.ts` publishes a scene handle and
reads the app's store through it. That configuration had no coverage at all: the
store answers through `SCENE_HANDLE.value?.timeline ?? DETACHED`, and every
existing app timeline test leaves the handle null, so all of them were passing
against the stand-in. They were green whether or not the bridge to a real city
worked, which is not a test of anything.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
`frame()` re-armed on its LAST statement, so a throw anywhere above it skipped
the re-arm and no frame ever ran again. A one-line bug in one component's tick()
stopped the renderer for the life of the page, and all it left behind was a
single console line — which is exactly how the facadePanels crash read as an
empty world rather than as a crash.

The re-arm moves to the top, before any of the work, and the work goes in a
try. Ten consecutive failures stops the loop: one frame is a transient and the
next usually clears it, but re-entering a broken build sixty times a second only
burns the battery, and it should say so.

Every failure is logged, not swallowed. A frame loop that eats exceptions is how
a one-line bug reads as "the renderer got slow" — the consecutive count is what
keeps honesty from becoming sixty identical lines a second.

Three tests, and putting the re-arm back at the bottom turns all three red.

Also on the app side: viewBinding's 22 cases now publish a scene city. The
timeline store answers through `SCENE_HANDLE.value?.timeline ?? DETACHED`, so
with no handle they were reading a stand-in nothing binds to. Breaking the
bridge — `_engine()` returning DETACHED unconditionally — used to leave all 22
green; four of them go red now, and those four are the URL⇄mode reflection that
shipped broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
thalida and others added 29 commits August 30, 2026 10:24
A file-by-file audit of all 151 app source files, against the question: does
this encode knowledge about a repo, a manifest or a timeline that any host
rendering a city would need? Four answers were yes.

  state/scrub.ts            → city/timeline/scrubbed.ts
  TimelineScrubber/scrubberScale.ts → city/timeline/scale.ts
  ReadmeTab/readmeAssets.ts → city/client/readmeAssets.ts
  router/viewParams.ts      → deleted; it had no consumers left at all

scrubbed.ts is which paths exist at a commit and the tree filtered to them;
scale.ts is the date/index mapping a scrub track is drawn on. Both are pure
arithmetic over a manifest and a bundle, and every import in them was already
from the package. readmeAssets builds file URLs from a SourceRef, which is the
package's wire knowledge — it takes the client now instead of importing the
app's.

The rest stay, and the audit says why: almanac.ts, statItems.ts,
tooltipContent.ts, constants/progress.ts and streetStats.ts all read city data
but produce COPY — labels, rows, phrasing — which is this app's. utils/dates
prints dates; the parse rule was already the package's.

And the last mirrors. CITY_STATUS was a copy of city.status, LOADING_SOURCE a
copy of what the city was loading. Both are gone, along with attachCity and the
five attach* functions it bundled: the overlay driver and the build report are
plain reductions now, called by useCityReport with the status the city reports.

state/stores is 38 module signals down to 22, and not one of them is a copy of
something a city already knows. What is left is the server, the viewport, the
modals, the source identity and recents this app persists, and the readout on
this screen.

state/chromeContext.tsx merges into state/stores/chrome.tsx. Two files named
after the same topic, split by mechanism — module signal versus context — is
not a distinction this repo organises by.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
`stores` was a mechanism name holding four unrelated topics, and source.ts was
the proof: the open project, the recents list, the hidden folders, the landing's
wallpaper, a navigation function, and a module-level effect() that NAVIGATED on
import. Six things, one file, named after none of them.

Split by topic, and placed by who reads it:

  state/source.ts     the open project, and the one commit point
  state/recents.ts    persisted; the landing lists, the city view adds
  state/excludes.ts   persisted per repo; they ride in the manifest URL
  state/viewport.ts   device shape
  state/server.ts     SERVER_CONFIG — the footer names the version on both routes

  views/HomeView/backdrop.ts  BACKDROP_CITY + ACTIVE_SOURCE
  views/HomeView/discover.ts  DISCOVER
  views/CityView/chrome.tsx   modals, loading overlay, freshness readout, and
                              the per-city provider
  views/CityView/commands.ts  cityCommands

DISCOVER, BACKDROP_CITY and ACTIVE_SOURCE were app-global and read by one view
each. progress.ts merges into chrome.tsx because it always was chrome: a loading
overlay is a screen element exactly like a modal, and the freshness readout is
what that chrome says about the city.

The navigating effect becomes router/useSourceUrl, mounted by App. A reaction
that calls navigate() from module scope fires on any import that reaches it,
which is a thing to run, not a thing to hold.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
constants/, hooks/, utils/ and types/ are layer names, and they were holding
feature-owned code. The rule that resolves it — and the one Bulletproof React
uses — is that a shared folder is for things more than one feature needs;
anything one feature owns lives in it. Assigned by counting consumers, not by
name:

  views/CityView/hooks/   useCityCommands, useCityReport, useDocumentTitle,
                          useScrub, useShortcutsKey, useTimelineMode,
                          useManifestSource
  views/CityView/field.ts a typed settings-field ref; 10 of its 10 users are
                          the controls pane
  views/HomeView/hooks/   useHomeBackdrop
  state/deep.ts           only the settings layer round-trips values

types/ is deleted. Every type now lives with the code that owns it: SidebarTab,
the activity-bar tabs and the loading-overlay shapes with the chrome that
renders them; SourcePayload and SourceError with the source they describe; the
controls-pane section shapes with the pane that declares them. A types/ folder
plus types inlined everywhere else is two conventions; this repo now has one.

What stays shared is what earned it: keyboard and progress vocabulary, the
dismissable/ellipsis/replay hooks, colour, date and number formatting.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
SERVER_CONFIG and DISCOVER were signals the app wrote into after a fetch, which
is the app keeping its own cache of something it does not own. They are server
state: fetched, cached, refetchable, with a loading and error state of their
own, and that is a solved problem rather than one to hand-roll.

useServerConfig() and useDiscover() replace them, and useServerData — the boot
hook that fetched both and assigned them — is deleted. App splits into a
provider shell and the routes it wraps.

DISCOVER moves with its reader: only the landing lists what the server offers.
SERVER_CONFIG stays app-wide because the footer names the version on both
routes.

The manifest stream is deliberately NOT a query. It streams rather than
resolves, and it is the city's own — <City src=…> owns it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Four things you found, and the rule for each.

Duplicate imports. 48 files imported the same module twice — a value import and
a type import read as two dependencies when they are one. import-x/no-duplicates
with prefer-inline, plus consistent-type-imports with inline-type-imports, both
autofixing, so it cannot come back. There was no import plugin configured at
all, which is why it drifted.

Two hooks with the same name backwards. useUrlSource read the URL and
useSourceUrl wrote it — near-anagrams doing opposite things. They were two
halves of one binding, so they are one file now: router/cityUrl.ts, with
useCityUrl() returning what to show AND where to be, since both come off the
same query string and every caller wanted both. The reflection stays a separate
export because it is an effect, not a read.

constants/progress.ts was not constants: an enum, four tables and six functions
that format a CityStatus. And views/CityView/chrome.tsx, which I had merged, was
358 lines of modals + sidebar + overlay + readout. Both split into
views/CityView/state/: modals, sidebar, overlay, readout, loading, commands —
38 to 214 lines, one topic each.

router/params.ts had an import in the middle of the file, below a second header
comment: two files concatenated at some point. Same shape as the export-above-
import that broke the dev server earlier in this branch.

And useSourceInfo was a one-line wrapper around a function with exactly one
caller — itself. Collapsed into one hook where its callers are.

Everything that talks to the server now lives in api/: the client, the query
cache, and the two reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Measured rather than guessed: 8 of location.ts's 13 exports have no consumer
outside router/, HREF has none at all, and paths.ts imported ROUTE_PATH from
location.ts while location.ts imported ROUTES back from paths.ts. A real
circular import, and the kind that resolves to undefined at module init
depending on which side is reached first.

paths.ts merges into location.ts, where the routes belong: ROUTES and ON_HOME
are about the URL. HREF stops being exported — everything outside reads one of
the derivations. router/ is three files now: the URL, the param names, and this
app's city contract.

The wouter adapters stay, and they earn it. Two lines each, and they are what
makes one writer possible: wouter renders off the signal instead of holding
location of its own, so the boot normalizer and the keyboard predicate the CITY
calls on each keystroke can read the URL outside a render. Handing location to
wouter would mean two writers and a signal that drifts from it.

Swept the whole app for the same class of bug afterwards: 150 modules, zero
runtime import cycles. The two the graph shows between Field and its composite
fields are `import type` on the back edge, so they do not exist at runtime.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Tracing one flow meant opening four top-level folders. The measurement behind
that: of the eleven subtrees in components/, NINE had a single consumer, so the
shared folder was mostly CityView's components hoisted a level up. views/ was a
feature root named for presentation, which is why state never felt like it
belonged there. And there was no composition root — App, main and the providers
sat loose at src/.

So: features. Each one owns its components, hooks and state, and to change a
feature you open one folder.

  app/                main, App, the providers, the routes
  features/city/      the city view — 22 components, 5 hooks, 7 state modules
  features/home/      the landing
  features/settings/  the panel, its fields, its schema and drafts
  components/         13 primitives, each with 2+ feature consumers
  hooks/ utils/ constants/ lib/ api/ router/ state/

The category folders inside components/ go too, by the test of whether the
folder name says anything the component name does not: menus/ScanMenu,
nodes/NodeIcon, loading/LoadingOverlay, panes/CommitPane — none of them do. What
stays nested is compositional: ExplorePane/tabs/TreeTab, CityStage/SelectionChip.

The Timeline toggle flow now touches two folders instead of four, and three of
its four files are in one of them.

157 files moved, 268 import rewrites, 107 file headers repointed at the file
they are actually in. utils/dates.ts had an import ABOVE its header comment —
the third instance of the shape that broke the dev server earlier on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Three suites were testing code that now lives in @codecity/city, and had been
left behind when it moved: the scrub arithmetic (10 cases), the timeline state
machine (6), and the README asset URLs (13). The package had NO tests for any of
them — the code moved and the coverage did not. They move now, driving a real
createTimelineState instead of module functions.

The <City> load props had no tests at all. src, branch, exclude, noCache: I
added the props and never covered them, while the app tests that covered the
equivalent behaviour were the ones about to be deleted. Six cases now hold that
line — what a src loads, what a changed src reloads, what an unrelated prop does
not re-ask, and that a changed exclude re-scans in place rather than reloading.

Writing those found a regression I had introduced. Hiding a folder while the
reader is scrubbing used to route to a bundle refetch, from an effect in
setupLiveUpdates. That effect went with the imperative loader, and nothing
replaced it: refreshSource refuses to run in Timeline (the union city is not a
thing a live scan may replace), so the edit was being silently dropped.

City.refreshSource now means "show me this again" in whichever mode the city is
in: in Timeline it re-reads the history holding the position, rather than
answering by leaving the mode. That is the city's rule, not something each host
should have to know. The guard is verified to bite: reverting the fix turns it
red.

Deleted: the tests for attachCity's mirrors, the imperative loader, and the
timeline binding — all superseded by the package's own hook and two-city suites.
refreshCurrentSource keeps its five cases, against a city it is handed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
activeSource.test.ts covered four subjects that now live in four places: the
open project's identity, the URL reflection, what the city is named, and the
history entry a commit leaves. The reflection is a mounted hook rather than a
module effect, so its tests mount it — and Preact flushes effects on a frame,
which jsdom runs on a ~16ms timer, so a 0ms flush() was reading before the
effect had run at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The toggle writes the URL rather than calling a loader; the announcer and the
chip read the city's own picker; the tooltip tracks the window, so its tests
dispatch there. CityChromeProvider takes an optional value, so a host driving
the chrome from outside — and a test asserting on it — can supply one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The build gets react → preact/compat from @preact/preset-vite; vitest resolves
modules itself, so a QueryClientProvider reached the real react and found no
hooks. Aliased, and the package inlined so the alias applies at all: an
externalised dependency is not transformed, so it never sees it.

renderWithServer seeds the query cache the way a landed fetch would.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Two bugs the sidebar test caught, both mine.

The pane states were useComputed closures over hook values. useComputed
memoizes its callback once, so the closure kept the manifest it saw on the first
render: a live update re-rendered the sidebar and the pane went on showing the
old node. They are plain values now, computed during render, and the panes take
plain state — the parent re-renders when the city reports, which is the whole
reason a signal was buying nothing.

And fakeCity's picker only marked the change; a real city ALSO emits the event.
A component subscribing with city.on('select') passed against a fake that never
told it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The last of it. Two more bugs the conversions surfaced, both worth naming.

overviewTab.test.tsx had TWO vi.mock calls on the same specifier. The second
silently replaced the first, so the focus spies it asserted on were never
installed — the test passed without testing anything. Those cases point a real
city now and assert it was pointed.

And a scrub driven from outside the component was waiting on a 0ms flush. The
city batches its reports to a microtask and Preact re-renders off that, so it
passed alone and raced under parallel load.

twoInstances.test.ts moves to the package, minus the two blocks that existed to
prove a mirror followed only the scene city — a bug class the mirrors' removal
makes structural. The panes' tests re-render rather than poking a signal, which
is what their parent does when the city reports.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The import rule reached the test suite once it was configured, and found what
you would expect after a day of moving files: 83 duplicated imports, 30 dead
ones, and 29 comment blocks over the cap.

The comment blocks are re-wrapped rather than truncated. Truncating is what
broke a doc block's terminator twice today; joining the prose and re-wrapping it
keeps the reason and fits the cap.

consistent-type-imports gets disallowTypeAnnotations: false, because
`importOriginal<typeof import('…')>()` is vitest's own idiom for a partial mock
and there is no import statement to prefer instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Three things you saw, all from one cause: the footer above the city, the missing
repo name entering Timeline, and cancel not going back to Live.

The footer was my CityView split. CityChrome rendered the header AND footer as
one fragment placed before <main>, so document order put the footer at the top.
It wraps the stage now — header above, footer below — which is what "chrome"
means. CityView.test.tsx asserts that order; nothing rendered the view as a
whole, which is why a layout this visible shipped.

The other two were the same mistake. Making the toggle write ?mode=timeline and
letting the city load it inside setViewState orphaned loadTimelineScene — its
only caller left was the capture harness. With it went the app's whole timeline
readout: its rows, the repo name beside them, and a cancel that returns to Live
rather than to the switcher.

My first fix was two drivers and a guard telling one to stand down for the
other, which is precedence between two owners — the thing that keeps going
wrong here. It is ONE overlay. It says a load is happening and how far it has
got; only the vocabulary differs, and the driver holds which of the two it is
showing. The live half and the timeline half are two methods on it.

That collapse is what made it small: `rows` already measures a phase against the
steps the load in flight actually runs, and Building is the row both vocabularies
share — the pack at the end of either. So the timeline path needs no special
casing, just an open-guard, and that guard is verified to bite.

City gains `source`: what it was asked to show. A host naming the repo in its own
chrome had to remember what it asked for, and the city already knew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
You asked why I was listening to the endpoint instead of the city emitting
something. That was the bug, and it was in the package.

loadTimeline emitted `scan:start`. A history read is not a scan — nothing is
walked on disk — so the city was telling every host it was scanning while it
fetched a commit graph, and the app was left inferring the truth from which
events happened to be arriving. Its status said Resolving, so the overlay drew
a live scan's rows over a repo already on screen, with no name beside them and a
cancel that went home.

The city now says what it does. `timeline:start` and `timeline:done` are its
own events, and status carries `CityPhase.Reading` with `timelineStage` as the
detail inside it — exactly what `stage: BuildStage` is inside Building. One
value, one vocabulary, which is what CityStatus already claims to be.

So the app reads it. The driver went back to ONE function taking one status: no
second subscription, no second entry point, no inference. Choosing the rows is
`status.phase === CityPhase.Reading`, and that read is verified load-bearing —
pinning it false fails three tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
You asked what could analyse the repo for the classes of bug you kept finding.
Every one of them has a mechanical signature, so this is a script rather than a
document that goes stale: bin/audit.py, re-runnable, per package.

  dead        an export only tests reach — the feature is gone and the test
              now guards something no reader can get to
  orphaned    an export whose last production caller is a debug harness
  misplaced   a module that imports only another package: logic left in a
              consumer that belongs in the package it is about
  vocabulary  a module emitting under two namespaces, so a host cannot tell
              which thing it is doing
  inference   a consumer deriving control flow from *:progress traffic instead
              of asking for state
  owners      one value written from several modules
  composition a component others assemble but no test renders

It found three real leftovers from the fix I had just made, which is the point:
loadTimeline still emitted `scan:error`, so a FAILED read reported as a failed
scan; the app still subscribed to timeline:progress; and PENDING_SOURCE_LABEL
had three writers. All three are fixed here — timeline:error is the city's own
event now, and the app's whole timeline orchestration is deleted, since
City.refreshSource already knows what re-reading means in each mode.

`--score` grades each package by what it IS. An application is not a failed
library, and a service is not either:

  city (library)      5/6   two instances, one entry, status not events,
                            separable binding, a test kit
  app (application)   2/5   10 modules holding library logic, 32 unrendered
                            compositions
  api (service)       3/5   3 routers over 150 lines, failures untyped

Writing it caught two flaws in itself, both now fixed: dead-code has to be a
repo-wide question (a library's callers are elsewhere, so scoped to one package
its whole surface looks dead), and a helper its own module calls is alive.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
…e thin

Every finding judged, then fixed or recorded.

The two that were real moves: a canvas cannot announce what is picked in it,
and that is true of every host embedding one — SelectionAnnouncer is the
package's now. So is useScrub, which is the same shape as useCityTimeline: a
hook over the package's own arithmetic. The app's whole timeline orchestration
is deleted; City.refreshSource already knows what re-reading means in each mode,
so loadTimelineScene had nothing left to do.

PENDING_SOURCE_LABEL had two writers. The overlay owns what it displays, so the
driver is the only one now, and the label is handed to it with everything else.

api: the 175-line manifest handler and the 96-line timeline handler were each
one nested generator doing the whole job. They are api/scan/stream.py and
api/scan/timeline_stream.py — the READ's shape, beside the scanner — and the
routes are 60 and 68 lines of wire. The repo's own boundary test caught me
importing past the barrel, which is the guard working.

The seven remaining `misplaced` are judged and recorded IN the script, with the
reason each is right where it is: a package cannot read its consumer's bundler,
the page URL is this app's contract, how a person reads a date is presentation.
A smell can be the right answer; writing down why stops the next run re-arguing.

Two flaws in the audit itself, both found by using it: line count is a poor
measure of a fat route (a long file of small handlers is fine, one long handler
is not), and it was walking vendored code, which is how `api` scored 241 named
error types.

  api 5/5   app 4/5   city 5/6

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
You were right that I had missed this, and right that it was more than the one
thing you pointed at. The audit is written up in full against what tldraw
actually does — ten properties, scored — and the four biggest gaps are closed
here.

`components`, the one you named. A bare <City> now draws a hover card; a host
passes `components={{ Tooltip: MyTooltip }}` to replace it or `null` to remove
it, and `DefaultCityTooltip` is exported so it can be wrapped rather than
rebuilt. `'Tooltip' in components` rather than a truthiness check: passing null
is a host asking for none, which is not the same as not having asked.

The app is now a consumer of that, not the owner of a tooltip the package
should have shipped. Its card says what the panes say about the same node, so
hovering and selecting cannot disagree — which is exactly the reason to
override rather than to fork.

`getSnapshot()` / `loadSnapshot()`. What it shows, how it is set up, where the
reader is, as one value. A host storing a session stores one thing rather than
remembering which three calls to make, and a restore uses the manifest it was
given rather than re-fetching: a saved city is the city that was saved.

A README, which the audit calls the biggest gap for adoption. Install, the
ten-line example, what to show, where the reader is, what happened, reading it
from your own chrome, replacing what it draws, adding to the scene, and the
framework-free core.

The audit itself now scores these, so the comparison is re-runnable rather than
a document that rots. city goes 5/6 to 9/10; the remaining NO is 36 test-only
exports.

Still open, and written down rather than pretended away: `components` has one
member where tldraw has thirty, CityExtension is real but undocumented, and
there is no build, version or changelog. Those are for when it is consumed from
outside this repo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The dead sweep's last two were not both dead. NON_TRANSFERABLE was a rule the
README describes and nothing enforced: the groups excluded auto-refresh because
no pane happened to list it, so a setting added to a pane tomorrow would have
started travelling to strangers' machines in silence. It is now applied where
the groups are built, with a test that goes red without it.

_unregisterForTests was a test-only hatch around a registry that could only
grow. Registering with no way out is what leaks a short-lived store into
anyResettable() forever, so markSettingStore now returns its unregister, the
way every other subscription here returns its stop.

The rest were dead and are gone, code and test together: shadeByRatio (the
shader does this on the GPU), layoutConfigOf, ageT, humanSpan, the shortcut and
debug popover openers, CURRENT_SOURCE_KEY, _resetForTests.

Two flaws in the script itself. The composition check asked whether a test named
a component, so every field reached through a tested pane read as uncovered, and
ComponentType<FooProps> read as a tag. It now walks what a test renders through
what those components render, and skips types. That left one honest gap: App,
the composition root, which nothing assembled. It has a suite now.

The surface check graded reaching into city.picker against a threshold of
twelve and printed the count as a failure either way. The real smell is
subscribing to a city and reading its parts in the same module, which is the
hooks hand-rolled. Both sidebars did exactly that, for selection and hover the
package has already published as hooks; the left one no longer needs the city
handle at all.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
Cancel in Timeline did nothing. cancel() aborted the fetch and then the abort
path emitted nothing: timeline:error is deliberately skipped when the signal is
aborted, and nothing else stood in for it. So the status stayed fetching, in
CityPhase.Reading, over a read that had already stopped, and the overlay the
app draws from that status stayed up on top of it.

Live cancel had the same silence and only looked like it worked, because the
app answers it by clearing the URL and leaving the view: the stale status goes
out with the unmount. In Timeline the city stays on screen, so there was
nothing to hide it.

So both loaders now report it. scan:cancel and timeline:cancel are their own
events rather than an error, because nothing went wrong: the status folds them
to fetching false with the city that was on screen still on it, and no failure
for a host to render. Superseding stays silent — a new load calling off the one
it replaced is not the reader backing out, and a host told otherwise would take
its overlay down on the load that just replaced it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The overlay came down mid-entry and the reader watched the rest happen: the
scene still on live buildings, the union manifest shuffling them, then the
scrubber arriving on a commit nobody chose.

build:done was the cause. Entering packs a union city, and the pack reports
like any other, so status went Ready and fetching false while the read still
had to dress the scene, install the scrub controller, and set the position.
timeline:done, which the loader emits after all three, only reported the stream
ending. So the status now holds a read open through its own pack, and
timeline:done is what finishes it.

The overlay's rows had the matching flaw. A read at its pack reports Building,
so the driver read the load as a live one and swapped the history rows for the
scan's mid-entry. Once a read has opened them, they stay the read's until the
load ends.

And the trailing counts were never wired: timelineStageTail was imported by the
driver and never called, because the facts it wants — commits walked, blobs
resolved — were dropped on the way through status. The city carries them now,
in the same counts a scan reports through, and the tail says them beside the
row producing them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
My last commit made the app hold the history rows by remembering it had opened
them, which leaked: leaving Timeline re-loads HEAD, and that scan drew the
read's rows because nothing had closed the overlay in between.

The inference was the mistake, not the bookkeeping. A read's own pack reports
as Building like any other, so `phase` cannot answer "which load is this" for
the whole of one — and a host should not be assembling the answer from two
fields and a memory. The status carries `reading` now, true from the read's
start to its end, and both the driver and build:done ask it.

Past the stream a read has no stage left to report, so the row falls back to
the phase: the pack is Building, which is the row it always was.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The strict cap applies to what a push CHANGES, so relocating styles/ and the
two configs into packages/app/ brought their headers under a rule they had
never been read against. Same edit throughout: keep the one non-obvious why,
drop the restatement of what the CSS below already says.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
The committed file had been hand-formatted somewhere along the way, and
check-types-fresh diffs it byte for byte, so the gate was reporting the
formatting rather than the models. The models are unchanged: normalised, the
two differ only by prettier's leading `|` on two unions. It is in
.prettierignore for exactly this reason (#182).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019cPbrRfRH5cJKVC1UP8vAc
@thalida thalida linked an issue Sep 3, 2026 that may be closed by this pull request
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.

Extract the city into an installable package

1 participant