Skip to content

Migrate frontend to Preact + signals; decouple fetch / render / view#52

Merged
thalida merged 201 commits into
mainfrom
worktree-preact-migration
Jun 2, 2026
Merged

Migrate frontend to Preact + signals; decouple fetch / render / view#52
thalida merged 201 commits into
mainfrom
worktree-preact-migration

Conversation

@thalida

@thalida thalida commented Jun 2, 2026

Copy link
Copy Markdown
Owner

Summary

Migrates the frontend from the vanilla setup to Preact + @preact/signals, then does a deep cleanup and three architectural refactors so state is signal-driven and the data flow is explicit and traceable. ~200 commits; large but incremental (each step gated + reviewed).

Framework migration (Phase 2)

  • Adopt Preact + signals; components are signal-driven, one-component-per-file, PascalCase; App is composition-only. Removed appLogic.ts, state/runtime/, and window.__* globals.

Cleanup / review2 (3.5.x)

  • View layer reorganized into components/ / views/ / layout/; header decomposed; ControlsPane with partials/.
  • Settings made schema-driven — all 12 stores use a settingSignal field map ↔ DynamicSection node tree; state/settings/ is fully flat; non-tunable values evicted to constants/; reactions auto-routed from per-field route metadata (zero hand-kept signature lists).
  • Backend bakes descendants_ext_breakdown + busyness thresholds (read-not-compute on the client); dead frontend aggregation deleted.
  • Constants/enums consolidated; factories/DOM-scrape/dead barrels removed.

Architecture refactors

  • Fetch ⁄ render decoupleMANIFEST is a written source-of-truth signal (dropped the world.onChange → MANIFEST bridge). useManifestSource (fetch) writes it; useCityScene (render) consumes it and owns the scene. useCity deleted.
  • useManifestSource purity — the fetch hook publishes only canonical signals (MANIFEST, CURRENT_SOURCE, SCAN_PROGRESS, SOURCE_ERROR). SOURCE_INFO / CURRENT_SOURCE_KEY / page URL are derived; the loading overlay, document title, and source picker are reactions (App coordinates the picker off SOURCE_ERROR). No UI calls or write-ordering side-effects in the hook.
  • Fetch ⁄ view decouplecameraRig is source-agnostic; the camera reset on a source change is an explicit, traceable reaction in the view layer. One canonical loadSource() for cold-boot and source-switch; setCurrentSource() commits the source + records recents (fixes cold-boot ?src never landing in recents).

Test plan

  • npm run typecheck — clean
  • npx eslint . — clean
  • npx vitest run2165 passing
  • npm run build — succeeds
  • prettier --check — clean
  • Manual smoke (no automated boot/camera/poll coverage): cold boot ±?src frames correctly; source switch reframes; live-update + settings rebuild do not move the camera (incl. Trees-off); bad source → picker reopens with error; recents includes booted source.

🤖 Generated with Claude Code

thalida and others added 30 commits May 30, 2026 16:28
- Add preact, @preact/signals, @preact/preset-vite
- Wire preset into vite.config.js and vitest.config.js
- tsconfig: jsx: preserve, jsxImportSource: preact, include *.tsx
- Extract main.ts boot flow into src/boot.ts (bootApp + applyPendingTitle + EMPTY_MANIFEST)
- New src/main.tsx renders <App /> into #app
- New src/views/shell/App.tsx is a Phase-2 placeholder: layout JSX + useEffect that calls bootApp
- Collapse index.html <body> to <div id="app"></div> + <script>

Behavior identical to pre-Phase-2 main.ts; only the call site changed
(boot now runs inside Preact's useEffect instead of at module load).

All 2013 tests pass, typecheck clean, build clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Phase 2 commit wrapped the existing layout in <div id="app"></div>
without realizing the existing CSS treated <body> as the flex column
container holding header / #app-body / footer directly. With #app now
sitting between them, those layout rules no longer reached their
targets — produced a large dead area below the city and broke the
right-sidebar / center-pane resize behavior.

Fix: move the flex column rules onto #app (which already has
height: 100% from inheriting body's full height), and drop them from
body. Preact's root container now owns the layout, which is also where
Phase 3 will put it permanently once App.tsx composes its children.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sweeps every nanostore atom/map/computed to @preact/signals equivalents:
- `atom<T>(v)` / `map<T>(v)` → `signal<T>(v)`
- `.get()` → `.value`; `.set(v)` / `.setKey(k, v)` → `.value = v` or spread
- `.subscribe(cb)` / `.listen(cb)` → `effect(() => cb(s.value))`
- `computed([a, b], fn)` → `computed(() => fn(a.value, b.value))`

Centerpiece: `state/persist.ts` rewrites the localStorage bridge to use
signals. `persistAtomPerSource` uses `untracked()` to avoid the write
effect overwriting localStorage before hydration finishes reading from it.

Touches the state surface + every scene/* and runtime consumer + every
relevant test fixture (~99 files, 27 of which directly imported
nanostores). `nanostores` is removed from app/package.json.

INTENTIONAL HALF-DONE STATE: `src/views/**/*.ts` still call `.get()` /
`.subscribe()` on what are now signals, producing 216 typecheck errors
and 28 test failures in `tests/views/*`. Those are Phase 3b scope —
porting views to Preact components reading `.value`. The app does NOT
boot to a usable UI between 3a and 3b; verify at the 3b stop point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds LucideIcon and GemIcon Preact function components alongside
backward-compat makeLucideIcon / makeGemIcon factory shims.
Tests: 1985/2013 passing (28 failures all in panes/shell — pre-existing).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds ExtensionBadge Preact function component alongside backward-compat
makeExtensionBadge factory shim. Tests: 1985/2013 passing (no change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Pure DOM utility — renamed to .tsx; no Preact component wrapper needed
(algorithm takes live HTMLElement refs; Phase 3c/3d callers will use
it as a useEffect helper). Tests: 1985/2013 passing (no change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds FileIcon and FolderIcon Preact function components alongside
backward-compat makeFileIcon / makeFolderIcon factory shims.
Note: the Preact components omit the onerror fallback handler since
JSX img elements use onError — Phase 3c/3d callers should use the
Preact component for full fallback behavior. Factory shims preserve
the original fallback logic. Tests: 1985/2013 passing (no change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds PaneHeader Preact function component with focus/close button slots
alongside backward-compat buildPaneHeader factory shim. Tests: 1985/2013
passing (no change).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renames tooltip.ts → tooltip.tsx. The key fix is TOOLTIP.get() →
TOOLTIP.value in moveTooltip() — TOOLTIP is now a @preact/signals
signal (Phase 3a converted it) so .get() no longer exists.
No Preact component wrapper: the tooltip is driven by Three.js pointer
events, making the imperative API the right interface here.
Tests: 1990/2013 passing (+5 tooltip tests fixed, typecheck 215).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds LoadingOverlay Preact function component (signal-driven, for future
Phase 3c/3d direct JSX usage). Backward-compat createLoadingOverlay()
factory shim retains imperative DOM mutations so synchronous test
assertions in tests/views/components/loadingOverlay.test.ts keep working
without needing Preact async render flush. All 15 loadingOverlay tests
pass. Tests: 1990/2013 passing (no change from prior step).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds SourcePickerComponent Preact function component (signal-driven, for
future Phase 3c/3d direct JSX usage). Backward-compat createSourcePicker()
factory shim retains imperative DOM mutations so synchronous test
assertions in tests/views/components/sourcePicker.test.ts keep working.
All sourcePicker tests pass. Tests: 1990/2013 passing, build green.
typecheck 215 (0 errors in views/components/).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Renamed infoPane.ts -> infoPane.tsx. Added InfoPane Preact function
component (signal-driven manifest prop, useState for fetch state,
dangerouslySetInnerHTML for markdown). Retained buildInfoPane() factory
shim with identical DOM-building logic for coordinator.ts compatibility.

Tests: 1990/2013 pass (23 failures are in shell/other panes, not infoPane).
Typecheck: no infoPane errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed searchPane.ts -> searchPane.tsx. Added SearchPane Preact function
component (signal-driven manifest prop, useState for query, JSX result
list with _highlightJsx helper). Retained buildSearchPane() factory shim
with identical DOM-building logic for coordinator.ts compatibility.

Tests: 1990/2013 pass (23 failures in shell/other panes unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed streetPane.ts -> streetPane.tsx. Fixed BUILDING_PALETTE.get() and
ASPHALT.get() -> .value (signal migration). Added StreetPane Preact function
component (signal-driven state prop). Retained buildStreetPane() factory
shim with DOM-building logic.

Tests: 1994/2013 pass (4 streetPane tests now pass, 19 remain in shell).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed filePreviewPane.ts -> filePreviewPane.tsx. Fixed BUILDING_PALETTE.get()
and ASPHALT.get() -> .value (signal migration; subscribe() still works on
signals). Added FilePreviewPane Preact function component (signal-driven
state prop, dangerouslySetInnerHTML for badge). Retained buildFilePreviewPane()
factory shim with identical DOM-building logic.

Tests: 1999/2013 pass (5 filePreviewPane tests now pass, 14 remain in shell).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Renamed treePane.ts -> treePane.tsx. Added TreePane Preact function
component (signal-driven state prop, JSX wrapper). Retained buildTree()
and buildTreePane() factory exports with identical DOM-building logic
for coordinator.ts compatibility.

No signal .get() calls to fix (treePane had none).
Tests: 1999/2013 pass (14 remain in shell, unchanged).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add CommitPane function component with signal-driven state, useEffect for
lazy body fetch with per-instance cache, and JSX rendering. Keep
buildCommitPane() factory shim for coordinator.ts compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Rename controlsPane.ts → .tsx, add ControlsPane function component as
a host wrapper that mounts the imperative factory DOM. Fix MapLikeStore
interface to use 'get value()' instead of 'get()' to match Signal shape,
fix STREET_TIERS.value and SYNTAX_THEME.value/.value= usages. Also stage
removal of the now-deleted commitPane.ts artifact.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ppHeader

appHeader.ts still uses vanilla DOM-building (full Preact port is 3d
scope) but it ran BEFORE the panes in coordinator's init order, so its
two leftover BUILDING_PALETTE.get() / ASPHALT.get() calls threw on
boot — blocking validation of the now-ported panes.

This is a minimal-diff patch (2 lines, .get() → .value) so the user
can smoke-test 3c without waiting for 3d to land. The rest of
appHeader.ts is unchanged and will be properly ported in 3d.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ct translation

Two bugs from the 3a nanostores→signals sweep, same root cause: an
effect() that calls a helper which reads MORE signals than the effect
author intended to track. `effect()` auto-tracks every `.value` read in
its body, including reads in nested helper calls, whereas nanostores'
`.subscribe()` did not.

1. Right-sidebar flicker on hover (user-reported).

   _selUnsub (line 340) reads picker.selection.value then calls
   _updateFooterFromState(), which read picker.hover.value AND
   picker.selection.value. The hover read inside the helper made
   _selUnsub track hover too, so every hover fired _renderSidebar()
   and re-rendered whichever pane was open.

   Fix: _updateFooterFromState reads hover/selection with .peek().
   Each caller (_selUnsub, _hovUnsub) already tracks the signal it
   cares about explicitly; the helper just needs the current values.

2. Four redundant status effects.

   _liveCfgUnsub / _statusUnsub / _errorUnsub / _stampUnsub each
   pretended to track ONE signal via `void X.value;`, then called
   _refreshStatus() which reads all four. Each effect therefore
   tracked all four, and any change re-fired ALL of them — running
   _refreshStatus() 4× per change. Not user-visible but wrong.

   Fix: one effect(_refreshStatus) that auto-tracks all four via the
   helper's natural .value reads. Idiomatic signals usage.

All 2013 tests pass. typecheck clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppFooter function component reads state from a signal-backed
AppFooterState; sub-components FooterStatusSection / FooterSelectionSection
/ FooterItem / FooterSep render JSX. Backward-compat shim initAppFooter
keeps coordinator.ts working unchanged — it writes to the internal
signal which the Preact component subscribes to via .value.

typecheck clean, 2013/2013 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppHeader composes HeaderLeft + HeaderTitle sub-components using the
PascalCase Preact components from 3b (<LucideIcon>, <GemIcon>,
<ExtensionBadge>) instead of the legacy imperative factories.

Backward-compat shim initAppHeader mounts <HeaderLeft> into #app-header-left
and re-renders <HeaderTitle> into #app-title on each setSelection /
setSourceInfo call. Two-root mount preserves the DOM layout coordinator.ts
expects; Phase 3e will collapse it into a single <AppHeader> mounted
by App.tsx.

typecheck clean, 2013/2013 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
LeftSidebar Preact component renders the activity-bar icon strip as
JSX (no more manual DOM button-building) with proper aria-pressed
typing. signal-backed LeftSidebarState exists for Phase 3e direct use.

Backward-compat shim showLeftSidebar keeps the synchronous DOM-mutation
behavior the original code had so the 6 existing leftSidebar tests
stay green without modification. hideLeftSidebar exported as a no-op
for API symmetry with rightSidebar. Phase 3e will let the Preact
component own pane visibility instead of the shim's direct
_refreshActiveStates() call.

typecheck clean, 2013/2013 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
RightSidebar Preact component + signal-backed RightSidebarState
prepared for Phase 3e direct use (App.tsx will pass a sidebarEl prop
and the component will own pane swap on selection change).

Backward-compat shims showRightSidebar / hideRightSidebar stay
fully synchronous (pure DOM mutations) matching the original
behavior — coordinator.ts calls them on every selection change and
synchronous behavior is what its current contract assumes. Phase 3e
will replace these with direct <RightSidebar> mounting in App.tsx
and let signal reactivity drive pane swap.

typecheck clean, 2013/2013 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Add state/runtime/scene.ts: SCENE_HANDLE signal holding startRenderLoop result
- Add state/runtime/sourceInfo.ts: SOURCE_INFO signal for project label/branch/url
- Add state/runtime/uiState.ts: SOURCE_PICKER and LOADING_OVERLAY signals
- Add views/shell/CenterPane.tsx: wraps canvas + startRenderLoop lifecycle
- Remove createCoordinator() from renderLoop.ts; expose picker/rig/resetView instead
- Replace handle.coordinator.setSourceInfo() in boot.ts with SOURCE_INFO.value writes

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ctly

AppHeader no longer takes a state prop for display data. It derives
picker selection from SCENE_HANDLE.value?.picker and source info from
SOURCE_INFO.value, making it self-sufficient when mounted in App.tsx.
Backward-compat shim (_AppHeaderLegacy) preserved for coordinator path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
AppFooter now derives FooterStatus from LIVE_UPDATES/REBUILD_STATUS/
LAST_UPDATED_AT/LAST_REBUILD_ERROR and selection from
SCENE_HANDLE.value?.picker. Includes 1-second tick via useSignal for
smooth relative-age updates. _AppFooterLegacy preserved for shim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
showLeftSidebar now uses effect() to watch picker.selection/hover and
update tree highlights, and world.onChange to refresh pane manifests.
mountRightSidebarReactions() replaces coordinator's pane-swap logic:
builds the three panes once, then reacts to picker.selection changes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- App.tsx: mounts AppHeader/AppFooter, canvas shell, SourcePickerShell,
  LoadingOverlayShell; calls runAppLogic() in useEffect
- appLogic.ts: extracts manifest streaming + scene init + sidebar wiring
  from boot.ts; uses SOURCE_PICKER/LOADING_OVERLAY/SOURCE_INFO signals
  instead of imperative DOM; exposes dispose()
- state/runtime/serverConfig.ts: SERVER_CONFIG signal for allowLocalRepos
- main.tsx: minimal entry point — pre-paint re-exports + render(<App />)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
All responsibilities redistributed:
- Selection → header/breadcrumb: AppHeader reads picker.selection directly
- Status (LIVE/REBUILD/LAST_UPDATED/LAST_ERROR): AppFooter reads signals
- Hover/selection → footer selection: AppFooter reads picker.hover/selection
- Tree highlights: showLeftSidebar effect() subscriptions
- Pane manifests: showLeftSidebar world.onChange subscription
- Right sidebar pane swap: mountRightSidebarReactions()
- setSourceInfo: SOURCE_INFO signal (written by appLogic.ts)
- document.title: appLogic.ts world.onChange handler

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
thalida and others added 28 commits June 1, 2026 21:00
DOM_IDS earned nothing: CANVAS + APP_TITLE were dead (CenterPane/AppHeader
already use literal id="city" / id="app-title"), and LEFT_SIDEBAR /
RIGHT_SIDEBAR / HOVER_TOOLTIP were each used at a single setter with CSS reading
the literal selector (#left-sidebar, #hover-tooltip — CSS can't import a TS
const). No cross-module JS id lookup exists to keep in sync (the lone
getElementById('app') already uses a literal not in DOM_IDS), so the constant was
pure indirection — and incomplete (no #app, #app-header, #app-footer, …).

Inlined the three setters to literal ids and deleted DOM_IDS. constants/dom.ts
keeps TEXT_INPUT_TAGS (a genuinely shared constant).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
constants/index.ts re-exported 8 of 13 files (missing buildings, camera,
lighting, streets, materialIcons) and imports were split exactly 50/50 between
'@/constants' and '@/constants/<file>' — the classic out-of-date-barrel problem.
Rather than complete it (and still leave the 18 direct imports inconsistent),
removed it: repointed all 18 barrel imports to their specific module
(KEY_BINDINGS→keyboard, LoadingStep*→loadingSteps, PERSISTED_KEYS/STORAGE_PREFIX→
storage, ACTIVITY_BAR_TABS/TabPlacement/MAX_RECENT_SOURCES→ui, TEXT_INPUT_TAGS→
dom, EMPTY_MANIFEST→manifest), splitting the 3 that pulled from two files. All
constants imports are now direct + explicit — no drift, matching the earlier
hooks-barrel removal.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…oSource

Nothing sets or reads git_window anymore — api/cache.py notes the per-entry
git_window field was dropped in cache v10. The url.searchParams.delete('git_window')
only stripped a stale param from pre-v10 bookmarked URLs; same vestigial-migration
class as the removed localStorage cleanup, and the rewrite carries no
backwards-compat mandate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…directly

state/stores/settings/index.ts was complete but bypassed (109 direct imports vs
28 via the barrel), and its big header doc was already stale (described a
config/system, config/components subfolder layout that no longer exists — the
files are flat). Repointed all 30 barrel importers to their specific module(s),
splitting the multi-file ones, deleting index.ts. Matches the constants- and
hooks-barrel removals: all settings imports are now direct + explicit.

Two non-obvious cases:
- renderLoop.ts used a relative path (../state/stores/settings/index).
- controlsPane.test relied on the barrel's `export *` as a side-effect "load
  every settings store" import (so the completeness checks see all registered
  stores) — replaced with explicit side-effect imports of all 12 modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
src/branch/no_cache were hardcoded strings across api/manifest, useCity,
stores/ui, and SourcePicker — and they're a backend contract (the server reads
these exact names). Added constants/urlParams.ts (URL_PARAMS.SRC/BRANCH/NO_CACHE,
const map like PERSISTED_KEYS) and routed every searchParams get/set/has/delete
(and the URLSearchParams object key) through it. Object-literal SourcePayload
props (src/branch) were left alone — those aren't URL params. Wire values
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…anifest

The scene-setup prep (rewrite tree.name to the friendly label; build + install
the building-roof icon atlas) was scattered across useCity's call sites +
renderLoop — each caller had to remember to run _applyDisplayLabel and (for some
paths) loadIconAtlas before applyManifest, and they didn't agree (skeleton +
live-update skipped the atlas). Consolidated both into applyManifest so "apply a
manifest to the scene" is one structured, standardized entry point.

- Display label: rewritten at the top of applyManifest (cheap, idempotent).
- Icon atlas: built inside applyManifest, GATED on the structure-only
  tree_signature so the expensive build (a fetch+draw per unique icon) only runs
  when the file structure changes — settings rebuilds reuse the same manifest
  (skip). A generation guard prevents a superseded build from installing a stale
  atlas. This also fixes a latent bug: live-update polls never refreshed the
  atlas, so new/renamed files showed no roof icons until reload.

Removed _applyDisplayLabel (renderLoop) + loadIconAtlas (useCity) and their now-
dead imports; every applyManifest caller is just applyManifest(m). No
orchestration tests for this path — verified by typecheck + eslint + 2152 tests
(incl. scene tests, which exercise applyManifest) + build; a manual smoke test
(load a repo, switch source, save a setting, trigger a live update) is worth doing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
….onChange bridge)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…T to the scene

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…drives fetch, CenterPane renders

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ene)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…tNewSource (App passes submit handler down)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…c + comment sweep

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two gaps the per-task reviews could not see, surfaced by the whole-diff
integration review of the fetch/render decoupling:

1. [HIGH] Stuck yellow dot on live-update with Trees disabled. world.applyManifest
   set REBUILD_STATUS=Idle only inside the `treesEnabled && bbox` decoration
   block, so with trees off (a normal toggle) the render effect's Rebuilding
   flip was never cleared. Add an else branch that returns to Idle; superseded
   applies still bail via the in-branch early returns and don't clobber a newer
   apply. The old useCity.fetchAndApply reset Idle unconditionally after its
   await — that write was dropped in the split and not reinstated.

2. [LOW] Brief Rebuilding flash on cold boot. streamInitialManifest writes both
   skeleton and final to MANIFEST, but the render effect's `first` guard only
   suppressed the skeleton, so the final (run #2) flashed the dot just as the
   overlay was dismissed. Suppress Rebuilding when it's the first apply OR the
   loading overlay is still up — both guards are needed (overlay alone breaks
   the fast-fetch/slow-scene boot ordering; first alone misses skeleton+final).

No automated coverage for the boot/lifecycle path (per the plan); verified by
typecheck + eslint + full suite (2152) green and queued for the manual smoke test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
refreshManifest had zero callers (the footer/header refresh button is gone);
remove registerRefreshHandler/_refreshHandler/refreshManifest + refreshFromToggle
and simplify tick() (needsRefresh/do-while were only used by the removed path).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add PENDING_SOURCE_LABEL signal; the stream pump sets it from display_root and
clears it when the load settles. useDocumentTitle prefers it over MANIFEST so a
switch shows the new name immediately. Deletes utils/pendingTitle (absorbed).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
P2 left the source-being-loaded label written into TWO stores (PENDING_SOURCE_LABEL
for the title AND LOADING_OVERLAY.pendingLabel via setLoadingPendingLabel) plus a
titleApplied guard that signal-dedup made vestigial. Collapse to one canonical
signal: LoadingOverlay reads PENDING_SOURCE_LABEL directly; remove the overlay's
pendingLabel field + setLoadingPendingLabel; drop the titleApplied guard (signals
dedupe same-value writes; display_root is stable per load).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lumbing

The MANIFEST inversion makes the private lastSignature mirror + setSignature
handle + onLiveUpdatesStarted callback + liveUpdatesRef unnecessary: the loop
reads MANIFEST.peek().signature each tick. setupLiveUpdates() is called once on
mount, no-ops until a source loads, and returns a dispose fn (now torn down on
unmount). applyNewSource loses its live-update opts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…OURCE

Introduce CURRENT_SOURCE ({src,branch}|null) as the applied-source source of
truth, written only by the fetch hook. CURRENT_SOURCE_KEY becomes a computed;
the URL becomes a module effect (replaces syncUrlToSource). The hook stops
imperatively writing the key and the URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SOURCE_INFO (header chip) becomes a computed instead of an imperatively-pushed
signal; resolveBranch moves to utils/sources (pure). The fetch hook stops
writing SOURCE_INFO entirely.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…RESS

Add SCAN_PROGRESS signal + state/loadingReactions (mounted in App). The fetch
layer writes SCAN_PROGRESS (incl. an initial phase:null so the overlay shows
immediately during the initial connect) instead of poking LOADING_OVERLAY
directly; the reaction maps phase/percent/files to the overlay steps. Removes
the last batch of overlay pokes from useManifestSource.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…s UI-free

The fetch hook no longer opens/closes the picker. It writes a canonical
SOURCE_ERROR signal on load failure; App reacts to open the picker (dismissible
derived from whether a city is loaded), opens it on cold boot with no ?src, and
closes+clears it on submit/close. useManifestSource imports nothing from stores/ui.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-boot guard

The first/overlay-visible guard only suppressed the footer "rebuilding" dot
during the overlay-covered initial/switch build. Showing it is truthful (the
world is being built) and it always clears to Idle when applyManifest finishes
(incl. the trees-off else→Idle path), so the guard isn't worth the first latch +
LOADING_OVERLAY coupling. Removed both.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…aRig source-agnostic

cameraRig no longer reads CURRENT_SOURCE_KEY inside its onChange to implicitly
reset the camera. It keeps re-framing only. useCityScene now explicitly resets
the camera (handle.resetView) after a successful apply when CURRENT_SOURCE_KEY
changed from a previous real source — a traceable reaction in the view layer,
no hidden write-ordering dependency.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…Source commits source+recents

Collapse streamInitialManifest + applyNewSource into loadSource(payload), used by
both cold boot and the picker submit. On success it commits via setCurrentSource
(CURRENT_SOURCE + recents with the resolved branch) — which also fixes cold-boot
?src never landing in recents. The live-update poll stays a separate background
op sharing only the MANIFEST sink. Hook is pure fetch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Move the camera-reset comment next to the reset logic in useCityScene's apply
effect (was stacked above the effect). Stale-reference sweep clean:
streamInitialManifest/applyNewSource gone; CURRENT_SOURCE_KEY only in source.ts
(computed) + useCityScene (explicit reset); no SOURCE_KEY.set comment remnants.
Full gate green (2165) + build.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rc framing)

The D1 reset guard skipped the first source (`lastSourceKey !== null`), assuming
cameraRig's one-shot _frameToBbox would frame the initial city. That's unreliable
for the first LOADED source — with no ?src the empty world renders while the
picker is open and spends firstFrame, so the picked city was never framed. Reset
on every source change including the first; _captureFraming targets the latest
(final) city, and the ?src path just gets a harmless redundant snap to the same
pose. Restores pre-D1 behavior (which reset on the first source for no-?src).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
89f8887 reset on the first source but still framed wrong (R needed): loadSource
published skeleton+final then committed CURRENT_SOURCE, so the reframe could fire
on the skeleton apply's .then (placeholder/empty pose) and the final apply (same
key) wouldn't re-reframe. Fix: commit CURRENT_SOURCE BETWEEN the skeleton and the
final publish, and capture the source key at apply-START in useCityScene — so
only the final apply (changed key) reframes, after cameraRig._captureFraming has
the final city. Frames correctly for ?src + cold-boot + switch; live-updates and
settings rebuilds keep the same key → no reframe.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Run the project formatter so the pre-push/CI format:check passes. Formatting-only
(whitespace/quotes/wrapping); typecheck + eslint + 2165 tests all still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@thalida thalida merged commit b1a835a into main Jun 2, 2026
1 check passed
@thalida thalida deleted the worktree-preact-migration branch June 2, 2026 05:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant