Extract the city into @codecity/city - #210
Open
thalida wants to merge 80 commits into
Open
Conversation
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
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,./testingand./testing/threesubpaths. 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 inapi/, types colocated with what they describe.packages/api— the stream route handlers extracted out of the routers;gen_openapi.pymoved into the package.<City>is a componentProps in, events out:
source,manifest,viewState/onViewStateChange,onSelect/onHover/onPick/onFocusRequest, achildrenoverlay slot, and acomponentsoverride map withDefaultCityTooltipexported beside it — passnullfor 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 andSCENE_HANDLEare gone.The city also reports what it is doing rather than leaving the host to infer it from event traffic:
CityPhase.Readingwith atimelineStagefor a history read,readingfor the whole of one,scan:cancel/timeline:cancelfor a load called off.bin/audit.pyA 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.
--scoregrades 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