/**
 * The demo: two cities under a real sun and moon, and two studios you can step
 * into.
 *
 * It ships **no real company data**. The markers are fabricated — see
 * `src/adapters/sample.ts`, which says so loudly — because real positions are
 * geocoded and real pipeline status is private, and neither belongs in this
 * repo. When a Tera API is present the same markers arrive from it instead, and
 * the UI says which of the two it is showing.
 *
 * It also runs with no server at all: the sun and moon are computed locally,
 * the traffic is simulated, the office is a data file, the studio hardware is a
 * fixed-step simulator running in this tab. Clone it and it works.
 *
 * ### What this file is, after the interface moved out
 *
 * This file used to hold about forty imperative `element.hidden = condition`
 * decisions with the condition and the write on the same line, spread through a
 * 655-line "Chrome" section, and none of it was testable without a WebGL
 * context. That is now two modules: `ui/chromeState.ts` decides and `ui/mount.ts`
 * writes, and what is left here is the one call between them —
 * `chrome.apply(chromeState(chromeInputs()))` — plus the thing this file is
 * actually for, which is **assembly**: it owns the clock, the access tier, which
 * board is mounted, which building is open, and which of the delivered modules
 * is handed to which scene.
 *
 * The rule that keeps it that way: nothing below this line may write to a DOM
 * node that `mount.ts` owns. Where you want a piece of chrome to change, add a
 * field to `ChromeInputs` and call `renderChrome()`.
 */

import {
  aerialFogMoved,
  collapsedFog,
  createAtmosphere,
  dipFog,
  FOG_DIP_IN_SECONDS,
  FOG_DIP_OUT_SECONDS,
  observe,
  PACIFIC_MARINE_LAYER,
  type AerialFog,
  type Atmosphere,
  type WeatherObservation,
} from "./engine/atmosphere.ts";
import {
  createBoardCache,
  prefetchTarget,
  residentCapacity,
  type BoardCache,
  type PrefetchTier,
} from "./engine/boards.ts";
import {
  activeRung,
  buildLadder,
  handover,
  HANDOVER_STANDOFF_M,
  placesRows,
  REGION_LABELS,
  type LadderRung,
} from "./engine/ladder.ts";
import { officeDaylight, smokeCaption, withHouseLights } from "./interiors/daylight.ts";
import { detailLotMetres } from "./engine/blocks.ts";
import { createScene, type SceneHandle } from "./engine/scene.ts";
import { createEnvironmentRig } from "./engine/environmentRig.ts";
import {
  aircraftDetail,
  regionOf,
  SimulatedFlights,
  withTrafficDial,
  type TrafficDial,
} from "./engine/flights.ts";
import { SatelliteCatalogue, type SatelliteElements } from "./engine/satellites.ts";
import type { Pose } from "./engine/scenekit.ts";
import { createStage, deviceProfile } from "./engine/stage.ts";
import { daylightPhase } from "./engine/solar.ts";
import type { Aircraft, City, Marker, MarkerPalette, Port, View } from "./engine/types.ts";
import CALIFORNIA from "./cities/california.ts";
import { UNIFIED_SOURCES, unifiedCalifornia } from "./cities/unify.ts";
import { reconciledCity } from "./cities/reconcile.ts";
import SAN_FRANCISCO from "./cities/sf.ts";
import SOCAL from "./cities/socal.ts";
import CALIFORNIA_TRANSPORT from "./transport/california.ts";
import {
  aircraftActionsFromPlay,
  cameraRelativePlanar,
  crowActionsFromPlay,
  groundActorActionsFromPlay,
  PlayInputRouter,
  sampleStandardPlayGamepad,
  vehicleActionsFromPlay,
  type StandardPlayGamepadButtons,
} from "./input/play.ts";
import {
  createTeraClient,
  type FireWatch,
  type PresenceWatch,
  mergedTraffic,
  type TrafficSource,
  type WeatherWatch,
} from "./adapters/http.ts";
import { promote, type FireBounds } from "./server/fires.ts";
import type { BirdsBody, FiresBody, RadarBody, VesselsBody } from "./server/wire.ts";
import { createFireLayer } from "./engine/fires.ts";
import { createPortLayer } from "./engine/ports.ts";
import { createVesselLayer } from "./engine/vessels.ts";
import { boardCarriesRaster, precipFactoryFor } from "./engine/precip.ts";
import { createMigrationLayer } from "./engine/migration.ts";
import {
  berthAnchors,
  modelHarbour,
  promoteVessels,
  vesselSummary,
  type BerthAnchor,
  type VesselBounds,
} from "./server/vessels.ts";
import { promoteRadar } from "./server/radar.ts";
import { promoteBirds } from "./server/birds.ts";
import { mountFirePanel, type FirePanelHandle } from "./ui/firePanel.ts";
import {
  SAMPLE_MARKERS,
  SAMPLE_PALETTE,
  SAMPLE_PRESENCE_PALETTE,
  samplePresenceForOfficeAt,
  sampleRoutesFor,
} from "./adapters/sample.ts";
import { OFFICE_SITES, type ShippedOfficeId } from "./offices/sites.ts";
import { authFetch } from "./session.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { randomId } from "./ids.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
import {
  createJourney,
  journeyReducer,
  loadJourneySession,
  saveJourneySession,
  type JourneyCity,
  type JourneyEvent,
  type JourneyState,
} from "./journey/index.ts";
import {
  actorKindForPresence,
  createProfileEditor,
  createDefaultLocalProfile,
  createWebcamCapture,
  createWebcamFaceConsent,
  createWebcamFacePanel,
  createWebcamFaceTexture,
  loadLocalProfile,
  resolveHumanoidAppearance,
  saveLocalProfile,
  type LocalProfile,
  type ProfileEditor,
  type WebcamCaptureController,
  type WebcamFacePanel,
  type WebcamFaceTextureAdapter,
} from "./profile/index.ts";
import type { ActorIdentity } from "./actors/controller.ts";
import { InstancedMesh, SRGBColorSpace, Vector3, VideoTexture } from "three";
import {
  CALIFORNIA_AIR_ROUTE,
  createAircraftPoseSnapshot,
} from "./aircraft/index.ts";
import {
  cityControlMode,
  createControlModeState,
  transitionControlMode,
  type ControlMode,
} from "./play/controlMode.ts";
import {
  createOfficeScreenPanel,
  type MediaSurfaceDescriptor,
  type OfficeScreenPanel,
  type OfficeScreenRemoteStatus,
} from "./media/index.ts";
import type { RemoteMediaState, RemoteOfficeMedia } from "./media/remoteMedia.ts";
/**
 * The interface, as two modules and one call.
 *
 * `chromeState` is pure — no DOM, no THREE, no clock — and `mountChrome` is the
 * only thing in the product that writes to the page. Both are value imports and
 * both belong in the entry chunk: the chrome is on screen before anything else
 * is, including the boot card's own successor.
 */
import {
  chromeState,
  seedPanelOpen,
  seedPlanOpen,
  type ChromeDetail,
  type ChromeInputs,
  type ChromeLayout,
} from "./ui/chromeState.ts";
import { mountChrome, type ChromeHandle } from "./ui/mount.ts";
import { controlForKey, edgeForKey } from "./ui/shortcuts.ts";
import { browserStorage, hasSeenOnboarding } from "./ui/onboarding.ts";
import { playHudKindFor, type AircraftDetailInput, type PlayTelemetry } from "./ui/hud.ts";
/**
 * Three type-only imports and not one value among them, which is what keeps the
 * office and the instruments out of the entry chunk.
 *
 * `import type` is erased before Rollup sees it, so none of these files is an
 * edge in the module graph. The office arrives through `loadOffice()`, the tools
 * through the `await import()` in `boot()`. Turning any of them into a value
 * import silently undoes the split and nothing fails — the bundle just gets big
 * again. The device and vehicle simulators travel with the office for the same
 * reason: renderer-free, but with nothing to say until you are in a studio.
 */
import type { OfficeScene } from "./interiors/officeScene.ts";
import type { Office, Presence } from "./interiors/types.ts";
import type { RobotOperationsDefinition } from "./interiors/robotOperations.ts";
import type { MaterialQuality, MaterialRegistry } from "./assets/materials.ts";
import type { DeviceSource } from "./devices/adapter.ts";
import type { DeviceCommand, DeviceDeclaration, DeviceState } from "./devices/types.ts";
import type { SimulatedVehicleTelemetry } from "./transport/vehicleTelemetry.ts";
// Type-only for the reason above, and it matters more here than it looks:
// `officeMinimap.ts` imports the asset registry as a *value*, to read prop
// footprints, so a value import of it here would drag the furniture catalogue
// back into the entry chunk and quietly undo the split this block exists to
// protect. It arrives with the office, in `loadOffice()`, because it is only
// ever drawn once you are standing in one.
import type { OfficeMinimap } from "./engine/officeMinimap.ts";
import type { Godmode, GodmodeHouseLights, GodmodePlace } from "./tools/index.ts";
import type { PoseEditor } from "./tools/poseEditor.ts";
import type {
  EntityPoseSnapshot,
  InterestCell,
  RealtimeClient,
  ServerRealtimeMessage,
} from "./realtime/index.ts";
import type { PresenceIndicator } from "./realtime/presenceIndicator.ts";

/**
 * One full California, as **one pack** — and now the default.
 *
 * The first attempt at this nested each metro's built scene inside the state
 * board's under a similarity transform. The transform was right and the result
 * was not: two independently built terrains occupied the same ground and
 * interpenetrated. `cities/unify.ts` is the answer that has no seam to stitch —
 * one `City` carrying the state's landform and both metros' detail, so the
 * engine builds one world, one lattice and one terrain mesh. See that file for
 * the measured lattice cost and for the two merges that are not a union.
 *
 * ## Why it is no longer behind a flag
 *
 * Because a flag is a second product, and the whole point was to stop having
 * three. Shipped behind `?one=1` the merged board was something a visitor could
 * not find and did not get: `tera.lumbridgecorp.com` still landed on the Bay
 * Area, the left column still offered two other boards, and clicking one still
 * tore the board down and built another. The board existed and the product did
 * not change.
 *
 * ## The three ways this resolves, and why the escape hatches are shaped so
 *
 * Default — no `?city=`, or `?city=california` — is **one California**.
 *
 * `?city=sf` and `?city=socal` still get the dedicated boards, unchanged. That
 * is deliberate and it is not hedging: twenty-nine capture guards in
 * `scripts/brand-assets` and the `bay-area` and `socal` cells of
 * `scripts/performance-budget.mjs` aim at those boards by name and assert the
 * `data-board` they land on. Making a metro id fall back to the merged board
 * would not degrade those, it would make them silently measure and photograph
 * something else — which is the exact failure the budget harness's own comment
 * says the `data-board` assertion exists to catch. A named board stays a named
 * board.
 *
 * `?one=0` forces the three-board product whatever the city is. One consumer
 * needs it — the `california` budget cell, which is written against the *plain*
 * state board's 440,000 cap and would otherwise measure the merged board that
 * `california-one` already covers — and it is the honest way to say "the old
 * arrangement" without spelling a board id.
 */
const ONE_PARAM = new URLSearchParams(location.search).get("one");
const CITY_PARAM = new URLSearchParams(location.search).get("city");
const ONE_CALIFORNIA =
  ONE_PARAM === "1" ||
  (ONE_PARAM !== "0" && CITY_PARAM !== "sf" && CITY_PARAM !== "socal");

/**
 * The boards on offer — three, or **one**.
 *
 * With `?one=1` this is a single entry, and that is the whole of what makes the
 * merged board a *board* rather than a nicer statewide tier on a three-board
 * product. Everything downstream that decides "is this somewhere else" reads
 * this array:
 *
 *   - `LADDER` is built from it, so every rung's `board` is `california` and
 *     `goToPlace`'s `rung.board !== cityId` switch never fires. Clicking FiDi
 *     moves the camera instead of tearing the board down and building another.
 *   - `PREFETCH_TIERS` and the handover rule get one tier, so there is no
 *     handover: nothing to promote to, nothing to demote from, and no cut to
 *     conceal with a fog dip.
 *   - The office return links and `?city=` fall back to the one board that
 *     exists rather than to a board the visitor was never on.
 *
 * The metro packs are still imported and still merged — `unifiedCalifornia()`
 * reads both — they simply stop being *destinations*. Without the flag nothing
 * here moves, which is what keeps the twenty-nine capture guards and every cell
 * of `scripts/performance-budget.mjs` aiming at the boards they were written
 * against.
 */
const CITIES: { id: string; label: string; city: City }[] = ONE_CALIFORNIA
  ? [{ id: "california", label: "California", city: unifiedCalifornia().city }]
  : [
      { id: "california", label: "California", city: CALIFORNIA },
      { id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
      { id: "socal", label: "SoCal", city: SOCAL },
    ];

/**
 * The board a bare URL lands on. **The detailed one, not the state tier.**
 *
 * ## Why this is not `CITIES[0]`
 *
 * It used to be, implicitly, in three places — a `wantedCity` initialiser, the
 * office's return-link builder and the boot fallback — and the board it named
 * was the 1,919 m-per-unit state tier. So the first thing every visitor saw was
 * the coarsest representation the product owns: seventeen districts over
 * 1063x930 km, no city legible, and a left column whose first offer is a list of
 * somewhere else to go. The detailed boards carry 52 and 47 districts at 94 and
 * 391 m per unit, and they are what the engine is actually for.
 *
 * With the free-camera handover on, the state tier has stopped being a
 * destination and become **what you get when you pull back** — which is the role
 * it is good at, since it is the only board that draws 97.4% of California.
 * Landing on the Bay and zooming out to the state is the same map at two
 * scales. Landing on the state and hunting for a city is three products.
 *
 * ## Why the constant rather than reordering `CITIES`
 *
 * Because `CITIES` order is load-bearing elsewhere: twenty-nine capture guards
 * in `scripts/brand-assets` and every cell in `scripts/performance-budget.mjs`
 * aim with an explicit `?city=`, and the budget harness binds each scene to the
 * `data-board` it asserts precisely because an unknown `?city=` falls back to
 * the first entry rather than failing. Reordering the array to change a landing
 * page would silently re-point that fallback and every consumer that reads the
 * array positionally. A named constant changes the one thing intended.
 *
 * `?city=california` still goes straight to the state board, and nothing about
 * the deep link changed.
 */
/*
 * With one board there is nothing to choose: the argument above is entirely
 * about which of three products a bare URL lands on, and `?one=1` is the answer
 * that there are not three. The landing *pose* is a separate question: the
 * pack still opens its chapter list on `california-overview` so capture and
 * `?city=california` frame the state, and a bare URL passes `openingChapter:
 * "fidi"` so a visitor's first frame is a city they can read.
 */
const DEFAULT_CITY_ID = ONE_CALIFORNIA ? "california" : "sf";

/** The corridor's scale doors, and the office each detailed board arrives near. */
const CALIFORNIA_DESTINATIONS = new Map<string, { cityId: string; officeId: string }>([
  ["los-angeles", { cityId: "socal", officeId: "mateo-court" }],
  ["san-francisco", { cityId: "sf", officeId: "lumbridge-hq" }],
]);

/**
 * The three boards' twenty-six authored chapters as one ordered descent.
 *
 * Built once, at module load, from the same packs `CITIES` holds — so the ladder
 * cannot disagree with the boards about what a chapter is. `PLACES` is the same
 * twenty-four rungs in the order the left column prints them; see
 * `engine/ladder.ts` for why the printed list is grouped by region and the array
 * is not.
 *
 * Neither of these replaces `city.chapters`. The per-board chapter list still
 * exists, still renders into `#chapters`, and still carries the ids, the order,
 * the short labels and the `data-view` attributes that twenty-nine capture
 * guards in `scripts/brand-assets` aim at. The ladder is a **view** over that
 * data and nothing more.
 */
/**
 * The ladder is built from the **reconciled** packs, because `World` is.
 *
 * `world.ts:116` is `this.city = reconciledCity(city)`, so every number the
 * engine draws with — `lngScale`, `verticalExaggeration`, road widths — is the
 * reconciled one. The ladder reads `focus` to derive each rung's stand-off, and
 * a ladder built from the raw packs is a ladder measuring a world nobody is
 * looking at. With every rule off that is the same object by identity and this
 * line is a no-op; with a rule on it is the difference between the rail agreeing
 * with the camera and quietly disagreeing with it.
 *
 * Deliberately calling `reconciledCity` here rather than reading it off a
 * `World`: the ladder is built at module load, before any board is mounted, and
 * it must be — `PLACES` is the navigation and it exists before the first frame.
 */
const LADDER = buildLadder(
  CITIES.map((entry) => ({ id: entry.id, city: reconciledCity(entry.city) })),
);
const PLACES = placesRows(LADDER);

const JOURNEY_SESSION_KEY = "tera:journey:v1";
const restoredJourney = loadJourneySession(sessionStorage, JOURNEY_SESSION_KEY);
let journey: JourneyState = restoredJourney.status === "loaded"
  ? restoredJourney.state
  : createJourney({
      actor: {
        id: "anonymous",
        kind: "crow",
        signedIn: false,
        profile: { displayName: "Guest" },
      },
    });
const LOCAL_PROFILE_KEY = "tera:profile:v2";
let localProfile: LocalProfile | null = null;

function humanoidAppearance() {
  return localProfile ? resolveHumanoidAppearance(localProfile.appearance) : null;
}

function signedInActorIdentity(profile = localProfile): ActorIdentity {
  const appearance = profile ? resolveHumanoidAppearance(profile.appearance) : null;
  return {
    id: access.subject ?? "anonymous",
    displayName: profile?.displayName ?? access.subject ?? "Guest",
    authenticated: access.subject !== null,
    profile: {
      appearance: appearance
        ? {
            skinTone: appearance.skinTone,
            primaryColor: appearance.outfitColor,
            accentColor: appearance.accentColor,
            hairColor: appearance.hairColor,
            bodyShape: appearance.bodyShape,
          }
        : { primaryColor: "#151a20", accentColor: "#f2b134" },
    },
  };
}

function dispatchJourney(event: JourneyEvent): boolean {
  const next = journeyReducer(journey, event);
  if (next === journey) return false;
  journey = next;
  saveJourneySession(sessionStorage, JOURNEY_SESSION_KEY, journey);
  return true;
}

/**
 * Leftover of the three-board product. The journey cannot leave California
 * except through a door; this still exists because `switchCity` for `?city=sf`
 * used to need it, and the events are what old sessions dispatch.
 */
function journeyToCity(city: JourneyCity): void {
  if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
  dispatchJourney({ type: "navigate-to-city", city });
}

/**
 * The buildings this page can walk into.
 *
 * Three, deliberately unalike — a tower above Transbay, a hangar at Alameda
 * Point, an Arts District courtyard — because the thing worth showing is that
 * one engine and one format render all three, and that `OfficeSite` is what
 * makes them feel like different places rather than the same room with
 * different furniture.
 *
 * The loaders stay lazy: a pack eagerly imported would put its furniture in the
 * entry bundle for every visitor who never opens that door.
 */
const OFFICE_LOADERS: Readonly<Record<ShippedOfficeId, () => Promise<{ default: Office }>>> = {
  "lumbridge-hq": () => import("./offices/lumbridge-hq.ts"),
  "frontier-valley": () => import("./offices/frontier-valley.ts"),
  "mateo-court": () => import("./offices/mateo-court.ts"),
};

const OFFICE_OPERATION_LOADERS: Readonly<Partial<Record<
  ShippedOfficeId,
  () => Promise<{ default: RobotOperationsDefinition }>
>>> = {
  "lumbridge-hq": async () => ({
    default: (await import("./offices/operations/lumbridge-hq.ts")).LUMBRIDGE_HQ_ROBOT_OPERATIONS,
  }),
  "mateo-court": async () => ({
    default: (await import("./offices/operations/mateo-court.ts")).MATEO_COURT_ROBOT_OPERATIONS,
  }),
};

const OFFICES = OFFICE_SITES.map((entry) => ({
  ...entry,
  label: entry.name,
  load: OFFICE_LOADERS[entry.id],
  loadOperations: OFFICE_OPERATION_LOADERS[entry.id],
}));

const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");

/**
 * One renderer, one loop, for as long as this page is open.
 *
 * Built here rather than inside `createScene` because a `WebGLRenderer` is a
 * property of the *canvas* and not of the city drawn on it. When each city built
 * its own, every switch abandoned a renderer on the one GL context this page
 * has — and `WebGLRenderer.dispose()` frees no texture at all. `stage.ts` has
 * the measurement and the reading of three's source it comes from.
 *
 * Nothing disposes this: it outlives every city and every office on the page,
 * the same arrangement as `officeMaterials` below.
 */
const stage = createStage(canvas);

/**
 * One environment map for the page, beside the one renderer, for the same
 * reason there is one renderer.
 *
 * A `PMREMGenerator` compiles three shader programs and each cached environment
 * is a half-float render target; both are properties of the GL context, not of
 * the board drawn on it. Building a rig inside `createScene` would allocate a
 * fresh chain per city and orphan the previous one on every switch, which is
 * precisely the arithmetic `stage.ts` records for the renderer itself. So it is
 * built here, handed to both scenes, and each scene calls `release()` on its way
 * out so the rig's ledger does not retain a floor plate nobody can see.
 *
 * Nothing about it is eager: it allocates on the first `apply()`, and the two
 * kinds — a sky for the city, a room for an office — are cached separately, so a
 * page that never opens a door never builds the office blur chain.
 */
const environment = createEnvironmentRig(stage.renderer);

// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
const tera = createTeraClient({ fetch: authFetch });

/**
 * Everything a board owns, bundled so a board can be **kept**.
 *
 * `mountCity` used to tear sixteen module-level variables down before it built
 * the next board, which is why a switch was a full-screen card: the outgoing
 * board was already gone, so there was nothing to draw for the whole build.
 * Measured, that is 65-78% of every pause — 1,711 / 1,079 / 997 ms of cover
 * against only 604 / 372 / 222 ms of blocked main thread, with 68 / 45 / 49
 * frames drawn into the gap. Retention converts a modal into a live picture
 * that freezes briefly, and it costs no triangles, because `Stage` retains and
 * pauses by contract and a paused scene is zero draw calls.
 *
 * The split between what is in here and what is *not* is the whole design:
 *
 *   - **In here** is everything expensive and everything derived from the pack:
 *     the scene handle, the plan view, the atmosphere, the board span, the
 *     harbour model, the traffic dial. All of it survives a switch away and
 *     comes back instantly.
 *   - **Not in here** is everything that is a *subscription*: the weather watch,
 *     the fire watch, the fire panel, the camera's fog listener and the pose
 *     editor. Those belong to the board that is on screen, not to the board that
 *     exists — a `/weather` poll for a city nobody is looking at is a request
 *     nobody needs, and a fire panel mounted per resident board would put two
 *     copies of it in a piece of chrome this file does not own. They are stopped
 *     on the way out of `presentBoard` and started again on the way in.
 *
 * The module-level `city`, `atmosphere`, `minimap` and the rest below are now
 * the **visible** board's, projected out of the record by `adoptBoard`. That is
 * deliberate rather than lazy: it keeps eighty-five `city.` call sites, six
 * render functions and the frame pump reading exactly what they read before, so
 * retention is a change to the lifetime of a board and not to the shape of this
 * file.
 */
interface MountedBoard {
  id: string;
  entry: { id: string; label: string; city: City };
  handle: SceneHandle;
  minimap: Minimap | null;
  atmosphere: Atmosphere;
  /** The board's largest edge in scene units. The fog dip's collapse is a fraction of it. */
  span: number;
  /** Live traffic when the deployment has it; `null` means the simulator. */
  flights: TrafficSource | null;
  trafficDial: TrafficDial;
  /** The rectangle a fire poll is clipped to, or `null` on a board that draws none. */
  fireBounds: FireBounds | null;
  carriesSky: boolean;
  vesselsBody: VesselsBody | null;
  vesselBounds: VesselBounds | null;
  vesselBerths: readonly BerthAnchor[];
  vesselPorts: readonly Port[];
  harbourAtMs: number;
  /** The markers this board was built with, for the plan view on the way back. */
  markers: Marker[];
  /** False until the first time it has been on screen; see `presentBoard`. */
  arrived: boolean;
}

/**
 * The resident boards, least recently shown first.
 *
 * California is pinned because it is the cheapest (5.86 MB of GPU buffers
 * against the Bay Area's 14.12) and the root of every descent. The ceiling is
 * three on a desktop — every board this build has — and two on a handheld,
 * because `deviceProfile()` changes only the pixel ratio and the shadow map, so
 * a phone carries exactly the same resident geometry as a laptop against a much
 * smaller budget. `sf` and `socal` bounds do not intersect and California
 * contains both, so two is provably enough to describe any camera position.
 */
const boards: BoardCache<MountedBoard> = createBoardCache<MountedBoard>({
  pinned: ["california"],
  capacity: residentCapacity(deviceProfile().handheld),
});

/**
 * The board on screen, or `null` before the first one and between a dispose and
 * the next swap. The record behind the `city` handle below.
 */
let visibleBoard: MountedBoard | null = null;
let city: SceneHandle | null = null;
let cityId = "california";
/**
 * The city the user last *asked* for, which is not the same as the one that is
 * mounted or even the one that is being built.
 *
 * `building()` defers its work by two animation frames so the boot card can
 * paint, and a frame under load is not 16 ms — measured at 250 ms on a software
 * rasteriser. Clicking SoCal and then changing your mind inside that window
 * used to hit `if (id === cityId) return` against a `cityId` the deferred
 * `mountCity` had not written yet, so the second click was discarded as
 * redundant and you arrived at the city you had just cancelled. The guard has
 * to be against the intention, and the intention is recorded synchronously in
 * the click handler.
 */
let wantedCity = DEFAULT_CITY_ID;
let office: OfficeScene | null = null;
/** The pack used by `office`; kept separate from the currently selected door. */
let builtOfficeId: string | null = null;
let inside = false;
let controlModeState = createControlModeState();
const playInput = new PlayInputRouter();
let markers: Marker[] = SAMPLE_MARKERS;
let realtimeClient: RealtimeClient | null = null;
let presenceIndicator: PresenceIndicator | null = null;
let stopRealtimeSubscription: (() => void) | null = null;
let realtimeOperation = 0;
let lastRealtimePublishAt = 0;
let realtimePageActive = true;
/** Page-scoped wire identities; auth subject never enters the spatial protocol. */
const realtimeActorId = randomId();
const realtimeVehicleId = randomId();
const realtimeAircraftId = randomId();

/**
 * The buildings you can walk into, as procedural glyphs on the city.
 *
 * The one thing that makes Tera and Spaces read as one product rather than two
 * views sharing a bundle: both packs carry a real `site`, and until these
 * existed that coordinate was known to the lighting and to nothing else.
 *
 * `OFFICE_SITES` rather than the packs, deliberately — a pack is a lazy chunk
 * and the city wants these the instant the board appears. `colorKey` and `glyph`
 * stay opaque to the engine, so a door looks different from a company without
 * the renderer knowing what either means.
 */
const OFFICE_MARKERS: Marker[] = OFFICE_SITES.map((entry) => ({
  id: `office:${entry.id}`,
  label: entry.name,
  colorKey: "office",
  blurb: `${entry.entryCopy} · ${entry.status}`,
  lat: entry.site.lat,
  lng: entry.site.lng,
  // Hand-typed from the street grid, like every other coordinate here. Not a
  // placeholder, so it is drawn as a real address.
  located: true,
  ...(entry.site.exterior ? { glyph: entry.site.exterior } : {}),
}));

/**
 * Whether a marker is a door rather than a company.
 *
 * The id prefix is the whole test, and it is deliberately something no marker
 * off the wire can collide with: `markers/gate.ts` serves rows from a synced
 * database and none of them are namespaced this way.
 */
function officeIdOf(marker: Marker): string | null {
  return marker.id.startsWith("office:") ? marker.id.slice("office:".length) : null;
}

/**
 * Whatever the pointer is over, so a click knows what it clicked.
 *
 * The engine reports picks by hover rather than by click — that is what drives
 * the detail card — so the click handler has no argument of its own and reads
 * this instead. `null` whenever the pointer is over open ground, which is what
 * makes a click on the terrain do nothing.
 */
let hoveredMarker: Marker | null = null;
/**
 * The colour a door is drawn in, which no marker feed knows about.
 *
 * Merged in **here** rather than added to `SAMPLE_PALETTE`, because it is not a
 * sample of anything: a deployment that replaces the whole marker feed with its
 * own palette must still get doors it can see. Amber, so a door reads as this
 * application's navigation rather than as another marker hue.
 */
const OFFICE_PALETTE: MarkerPalette = { office: 0xf5b53f };

let palette: MarkerPalette = { ...SAMPLE_PALETTE, ...OFFICE_PALETTE };
let liveData = false;
/**
 * What this visitor may do. Resolved once in `boot()`; every gate below reads
 * `access.can.*` and nothing else.
 *
 * The pre-boot value is the **closed** one, deliberately. A handler that
 * somehow fires before `resolveAccess()` has settled — a keystroke on a slow
 * connection, a click on a control that is in the document from first paint —
 * should offer a visitor less than they are entitled to and never more. The
 * rule that produces this value, and the SSO bug that once produced it wrongly,
 * are written out in `src/access.ts`.
 */
let access: Access = {
  tier: "anon",
  subject: null,
  signInUrl: null,
  can: capabilitiesFor("anon"),
  feeds: null,
};
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
let minimap: Minimap | null = null;
/**
 * The sky over the city currently on screen, polled while it is on screen.
 *
 * One watch at a time and it belongs to the board, not to the page. Stopping it
 * at the top of `mountCity` is the whole of the cancel-on-switch rule: a
 * `/weather` request for San Francisco that lands after the user has moved to
 * SoCal would otherwise put the marine layer over Long Beach, and it is a
 * request in flight for most of the second in which somebody clicks.
 */
let weatherWatch: WeatherWatch | null = null;
/**
 * The live traffic source, when there is one, kept so the corner label can ask
 * it whether the aircraft on screen were observed. `null` means the simulator,
 * which is never live and does not need asking.
 */
let cityFlights: TrafficSource | null = null;
/**
 * What is burning, polled while a board that draws fire is on screen.
 *
 * A board watch like the weather's, and stopped in the same place for a
 * different reason. The body is not per-region — one statewide answer, one
 * cache key, every viewer — so a late arrival cannot describe the wrong place
 * the way a stale `/weather` can. What it can do is arrive for a board that has
 * been torn down, and `promote()` needs *bounds* to say anything at all.
 *
 * `null` on the Bay Area board and on every keyless clone. See `firesFor`.
 */
let fireWatch: FireWatch | null = null;
/**
 * Stops this board's camera listening for handover and prefetch, or `null`
 * between boards.
 *
 * Aerial fog used to live on the same OrbitControls `change` listener. Drive,
 * crow and aircraft follow-cameras write the pose directly and never fire
 * `change`, so a descent under those stepped the haze at one hertz. Fog now
 * lives on the city's tick — see `refreshAerialFog` — and these listeners are
 * what remains: free-camera promotion and the background lane, both of which
 * are overview-only and still need to know the orbit moved.
 */
let cameraFogWatch: (() => void) | null = null;
/**
 * Last altitude `refreshAerialFog` actually applied at, in metres.
 *
 * Seeded at `-1` so the first sample of a board always counts. Reset in
 * `activateBoard` rather than in `refreshAerialFog`, because a board that has
 * just arrived must not inherit the altitude of the one it replaced.
 */
let lastAerialFogAltitude = -1;
/** The panel that says what the board is showing, and what it is not. */
let firePanel: FirePanelHandle | null = null;
/** The board's own rectangle, held so a poll landing later can be clipped to it. */
let fireBounds: FireBounds | null = null;
/**
 * The last body, kept for the whole page rather than for the board.
 *
 * This is the one place the "not per-region" property of `/fires` pays a
 * visible dividend. The same statewide body answers for every board, so a
 * visitor who switches from California to the Southland can be shown the
 * correct — and correctly *empty* — answer in the same frame as the switch,
 * re-clipped to the new rectangle, instead of reading "nothing has answered"
 * for as long as a fresh request takes. `undefined` means no watch has ever
 * settled; `null` means one settled and the feed refused.
 */
let firesBody: FiresBody | null | undefined;
/**
 * The harbour on the board, and the three sentences beside the three feeds.
 *
 * All of it is per-board and all of it is cleared on the way out of `mountCity`,
 * because — unlike `firesBody`, which is one statewide answer for every board —
 * a harbour is a fact about a rectangle and a raster is clipped to one.
 *
 * `vesselsBody` is **modelled**, not observed: `modelHarbour` builds it from the
 * board's own authored berths and channels, and `promoteVessels` reads it
 * through exactly the gate a live AIS body would go through. The day
 * `GET /vessels` exists, the only line that changes is where this comes from.
 * `harbourAtMs` is the clock that body was built for, so the next refresh is a
 * new *fix* rather than a nudge — see `refreshHarbour`.
 */
let vesselsBody: VesselsBody | null = null;
let vesselBounds: VesselBounds | null = null;
let vesselBerths: readonly BerthAnchor[] = [];
let vesselPorts: readonly Port[] = [];
let harbourAtMs = 0;
/** Whether the board on screen is one a statewide instrument can describe. */
let boardCarriesSky = false;
/**
 * The two sky bodies, and the one request that fetches them.
 *
 * `undefined` is "not asked yet", `null` is "asked and the box refused", and a
 * body carrying `source: "none"` is "asked and the box serves no such feed" —
 * three states with three different sentences, which is the whole reason the
 * gates take a nullable body rather than a field. `skyAskedAtMs` throttles the
 * refetch to the body's own TTL off the once-a-minute clock, so there is no
 * second poller in this file.
 */
let radarBody: RadarBody | null | undefined;
let birdsBody: BirdsBody | null | undefined;
let skyAskedAtMs = 0;
let skyInFlight = false;
/** The three board notes, written by the gates and drawn by `showBoardNotes`. */
let seaNote = "";
let radarNote = "";
let birdsNote = "";
/**
 * The satellite element sets, fetched once for the page rather than once per city.
 *
 * The asymmetry with every other feed here is the physics: an aircraft at
 * 10,000 m is visible for tens of kilometres and the two boards are six hundred
 * apart, but a satellite at 550 km is above the horizon for a circle two
 * thousand kilometres across. So the elements are shared — and the *catalogue*
 * is not, because a `SatelliteCatalogue` is built around an observer, and
 * reusing one would compute the Southland's sky from San Francisco with nothing
 * on screen to say so.
 *
 * A promise rather than a value, so a second city mounted while the first fetch
 * is in the air waits for it instead of starting another. `null` forever on the
 * majority of deployments: `TERA_SATELLITES_SOURCE` is off by default.
 */
let satelliteElements: Promise<SatelliteElements[]> | null = null;
/**
 * The fabricated-traffic dial for the board on screen, rebuilt with every city.
 *
 * Held here rather than inside the godmode closure because the panel is mounted
 * once and the board is not: a dial captured when the panel opened would keep
 * pushing aircraft at a scene that had been disposed two city switches ago.
 */
let trafficDial: TrafficDial | null = null;
/**
 * Whether the satellite layer is drawn, remembered across city switches.
 *
 * A new board builds a new layer, which starts visible, so a god who turned the
 * sky off and then changed city would have it come back on — a setting that
 * quietly undoes itself is worse than one that is not there.
 */
let satellitesVisible = true;
/**
 * The build in progress. Aborting it is what makes a second click on the other
 * city cheap: `createScene` drops the heightfield, resolves `null`, and has
 * allocated no WebGL context for the abandoned board.
 */
let mounting: AbortController | null = null;
/**
 * The build nobody asked for.
 *
 * A strictly lower-priority second lane. It exists because "one map" is a claim
 * about continuity, and continuity is paid for by having the detail already on
 * the GPU when the camera gets there: CONTRACT §1.1 keeps three boards resident
 * on a desktop and pins California, and until now nothing ever *filled* that
 * residency except a visitor waiting through a build.
 *
 * Held separately from `mounting` rather than reusing it, because the two have
 * opposite rights. A foreground build may cancel a background one; a background
 * build may never cancel or queue in front of a foreground one, and it must be
 * abandonable the instant somebody touches anything. Keeping one controller for
 * both would make "abort the build" ambiguous at exactly the moment it matters.
 */
let prefetching: AbortController | null = null;

let godmode: Godmode | null = null;
let poseEditor: PoseEditor | null = null;
/**
 * The office, once somebody has asked for it. Both are `null` until the first
 * `loadOffice()` because both live in a chunk this page does not fetch until
 * then — see `loadOffice` for the arithmetic, and the import block above for
 * what keeps them out of the entry chunk.
 *
 * The registry is one texture set for every office this page ever builds.
 * Signing in while standing in the public office is a `dispose()` and a second
 * `createOfficeScene` at `"full"` — cheap only if both are handed the same
 * registry, because drawing the textures is the expensive part and a registry
 * draws them once. Owned here and disposed nowhere: it outlives every scene
 * that borrows it, and the page teardown takes the process with it.
 */
let officePack: Office | null = null;
let officeMaterials: MaterialRegistry | null = null;
/**
 * Which building the door leads to. Changed by the picker while you are inside.
 *
 * `officePack` is the pack for *this* id and is rebuilt on a switch rather than
 * memoised forever, which is the one-line difference from the arrangement that
 * could only ever hold one office.
 */
let officeId: string = OFFICES[0]?.id ?? "lumbridge-hq";
/**
 * The office's own sky, when its pack says where it stands.
 *
 * A second `Atmosphere` rather than the city's, because the two are at different
 * scales and in different places: the city's is built with `metresPerUnit` near
 * 94 and a fog measured in board spans, and an office runs at 1 m per unit
 * fifty metres across. `null` for a pack with no `site`, which keeps the fixed
 * interior rig and is a supported state rather than a gap.
 */
let officeAtmosphere: Atmosphere | null = null;
let officeScreenPanel: OfficeScreenPanel | null = null;
let sharedScreen: {
  screenId: string;
  stream: MediaStream;
  video: HTMLVideoElement;
  texture: VideoTexture;
  remote: RemoteOfficeMedia | null;
} | null = null;
let remoteViewedScreen: {
  screenId: string;
  officeId: string;
  video: HTMLVideoElement;
  texture: VideoTexture;
  remote: RemoteOfficeMedia;
  bound: boolean;
} | null = null;
let remoteMediaOperation = 0;
let remoteViewerRequestScreenId: string | null = null;
/**
 * The plan panel's other occupant.
 *
 * There are two of these because there are two places, and the panel shows the
 * one you are standing in. Holding the city plan up while the scene in front of
 * it is a floor plate was the bug this pair exists to fix: the single piece of
 * chrome whose whole job is "where am I" was answering about another county.
 *
 * It is built with the office and dies with it, so the office's `Plan`, camera
 * and controls — all three of which it holds by reference — cannot outlive the
 * scene they came from. Which of the two is *mounted* is a separate question
 * from which exist, and `showPlan()` is the only thing that answers it.
 */
let officePlan: OfficeMinimap | null = null;
/**
 * Whether the people currently on the floor came from the deployment or from
 * `sample.ts`.
 *
 * Kept because the difference has to be *said*. Every other gap on this page is
 * a silence — an empty office, a missing scrubber — and a silence you cannot
 * attribute is indistinguishable from a fault; here the failure is the opposite
 * and worse, because fabricated people are not silent. Twenty-five invented
 * names at real desks is a screenshot somebody will take, and it must not be
 * possible to take it without the caption.
 */
let presenceIsSample = false;
/**
 * The deployment's own roster, or `null` while the sample one stands in for it.
 *
 * Held separately from what is on screen because the two are refreshed by
 * different things: a real roster arrives from the API on its own timer, and the
 * sample one is a function of the clock and has to be recomputed whenever the
 * clock moves. Collapsing them would mean either re-rendering a live roster on
 * every scrub or freezing the sample one.
 */
let livePresence: Presence[] | null = null;
/**
 * The running poll of who is in, or `null` when nobody is standing in the room.
 *
 * It belongs to the visit and not to the page, which is the same rule
 * `weatherWatch` follows one line up and for a sharper reason: a roster is
 * requested with a credential and describes people, so a watch left running
 * after somebody stepped back out to the city is a page quietly asking about a
 * room nobody is looking at. Started by `enterOffice`, stopped by `leaveOffice`
 * and by `mountCity`, and there is never more than one.
 */
let presenceWatch: PresenceWatch | null = null;

/**
 * `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
 * two names, and which name you arrived at is the whole difference: one is a
 * map with an office in it, the other is an office with a map behind it. The
 * city is still built underneath either way — that is what makes "← Back to the
 * city" work from the office front door — so this is a policy about where boot
 * *stops*, not about what boot builds.
 */
const OFFICE_HOST = location.hostname.split(".")[0] === "office";

const OPENS_IN_OFFICE =
  OFFICE_HOST || new URLSearchParams(location.search).get("view") === "office";

/**
 * Where the city is, when this page is the office front door.
 *
 * Leaving the office used to swap the scene and leave the address bar saying
 * `office.`, so the URL you would copy and send disagreed with what you were
 * looking at. The city door is therefore this hostname with its first label
 * swapped — a guess only ever made when that label is literally `office`, which
 * is the same comparison that made this the office door. Same registrable
 * domain, same static root, same API; a deployment serving one name and not the
 * other has half-configured itself, and a 404 is the honest failure for that.
 *
 * Anything else — a bare domain, `?view=office` on the city door — derives
 * nothing and keeps the in-page swap.
 */
function cityDoorUrl(cityWanted?: string): string | null {
  if (!OFFICE_HOST) return null;
  const url = new URL(location.href);
  const labels = url.hostname.split(".");
  labels[0] = "tera";
  url.hostname = labels.join(".");
  url.search = "";
  url.hash = "";
  // Carried so that stepping out of the office into SoCal lands in SoCal. The
  // page load is the cost of telling the truth about where you are; arriving in
  // the wrong metro on top of it would not be.
  if (cityWanted !== undefined && cityWanted !== DEFAULT_CITY_ID) {
    url.searchParams.set("city", cityWanted);
  }
  return url.href;
}


// ---- The interface's own state --------------------------------------------

/**
 * The applier, once the page has one.
 *
 * `null` for the handful of statements between this module's first line and
 * `boot()`, which is why every call site is optional-chained rather than
 * asserted: a keystroke that lands in that window should do nothing, not throw.
 */
let chrome: ChromeHandle | null = null;

/**
 * Two pieces of chrome are a *user* decision rather than a media query, and the
 * distinction matters: a media query that hides the plan below 600px also makes
 * `M` do nothing there, which is the width where a plan view is most useful and
 * least affordable. So the width only seeds the initial state, and the moment
 * someone presses the key or the button the viewport stops having an opinion.
 *
 * The seeds themselves live in `ui/chromeState.ts` next to the breakpoints they
 * read, because that is where the reasoning about what a phone can afford is —
 * and because the old inline `window.innerWidth > 600` for the plan was the
 * whole of live defect 10, "the minimap disappears entirely" on a phone.
 */
let panelOpen = seedPanelOpen(window.innerWidth);
let planOpen = seedPlanOpen(window.innerWidth);
let panelChosen = false;
let planChosen = false;

/**
 * Whether this browser has been offered the first-run coach.
 *
 * Read **once**, here, before anything mounts: `mountOnboarding` marks the flag
 * as it appears rather than as it completes, so asking `localStorage` a second
 * time after the coach is up would answer "seen" and take it straight back off
 * the screen. The write belongs to the coach; this is the read, and it is the
 * only one.
 */
let firstVisit = !hasSeenOnboarding(browserStorage());

/**
 * What the detail card is showing: an authored sentence, an observed aircraft,
 * or nothing.
 *
 * A union rather than a string, because the two are not the same kind of thing
 * and flattening the aircraft to a sentence is exactly what used to happen —
 * `showDetail(\`${m.label} — ${m.blurb}\`)` for everything, including a track
 * with five fields and a licence credit on it.
 */
let detail: ChromeDetail | null = null;

// ---- Studio hardware -------------------------------------------------------

/**
 * Where the readings for the room you are standing in come from.
 *
 * One at a time and it belongs to the visit, like `presenceWatch` above it. The
 * choice between the deployment's own device route and the fixed-step simulator
 * in this tab is made inside `devices/adapter.ts` and deliberately not here —
 * that is the one place the anon-first fallback decision lives, and a second
 * copy of it in the app is how the two drift apart.
 */
let deviceSource: DeviceSource | null = null;
/** The latest readings, so the panel and the hardware in the room agree. */
let deviceStates: readonly DeviceState[] = [];

/**
 * The car outside, as a state machine.
 *
 * Seeded from the office id, so a studio's Model X has the same charge, the same
 * cabin temperature and the same parking jitter on every machine and on every
 * reload — a car whose battery was different every time you opened the door
 * would read as noise rather than as a vehicle.
 */
let vehicleTelemetry: SimulatedVehicleTelemetry | null = null;

/**
 * The clock line, already formatted, because this file owns the clock.
 *
 * `instantOverride` is the one thing on the page that can make the rendered
 * instant disagree with the wall clock, and it lives here — so a formatter
 * downstream would have to be handed the override as well as the time, which is
 * two facts to keep in step for one string. Written by `updateSun`, which is
 * also the only thing that reads the sun.
 */
let clockLabel = "";
// ---- Time -----------------------------------------------------------------

/**
 * `null` follows the wall clock. The override exists because the honest answer
 * at 2 a.m. is a very dark city — correct, and not what you want to be looking
 * at while judging whether the sun is in the right place.
 *
 * A whole `Date` and not an hour, which is the change the godmode panel forced
 * and the right shape anyway. The old scrubber wrote an hour onto *today*, so
 * there was no way to ask for the December solstice, and any control that could
 * set a date would have had it silently discarded on the next scrub. One
 * override, one writer, one type that can carry everything the sun depends on.
 */
let instantOverride: Date | null = null;
/**
 * Where the office's house lights take their level from. Godmode's switch, and
 * it survives the office being rebuilt because the panel re-asserts it.
 */
let houseLights: GodmodeHouseLights = "sun";

/**
 * The sun's height as the *fittings* are told it, which is the real one unless
 * somebody has a hand on the switch.
 *
 * Forcing the level by lying about the elevation rather than opening a second
 * path into `luminaires.ts`, and that is the design rather than a shortcut:
 * `OfficeScene.setSolarElevation` feeds the fittings and nothing else, while
 * the rig outside is computed from the real `env` a line later. So this moves
 * the *interior* and leaves the sky, the key light and the fog exactly where the
 * clock put them — which is the whole point of a control that lets you look at
 * the night office in daylight. ±90° is far outside the 0°–6° ramp in
 * `luminaires.ts`, and stays outside it if that band is ever widened.
 */
function houseElevation(actual: number): number {
  return houseLights === "sun" ? actual : houseLights === "on" ? -90 : 90;
}
/**
 * A fabricated sky, or `null` for whatever the deployment reports.
 *
 * Kept next to the instant because it is the same kind of thing — a god-only
 * lie about the inputs, told to see what the renderer does with it — and it
 * takes precedence over the live observation for exactly as long as it is set.
 */
let weatherOverride: WeatherObservation | null = null;

function currentInstant(): Date {
  return instantOverride ?? new Date();
}

/**
 * What the sky is doing, in the order the answers are trusted.
 *
 * The override wins because somebody typed it. Otherwise the live observation,
 * and `null` — nobody was asked — when there is no watch or it has not landed
 * yet. `null` is not "clear": `atmosphere.ts` treats a *reported* clear sky as
 * authority that suppresses the modelled marine layer, so handing it an
 * invented clear day on every failed poll would permanently kill San
 * Francisco's fog on the zero-config box where the local model is all there is.
 * See `WeatherFeed` in `adapters/http.ts`, which is careful about the same
 * distinction from the other side.
 */
function currentWeather(): WeatherObservation | null {
  return weatherOverride ?? weatherWatch?.current().value ?? null;
}

/**
 * The office's rig for the instant being rendered, in the building's own frame.
 *
 * Same clock, same weather and same `Atmosphere` machinery the city runs on —
 * which is the entire point. An office that dimmed on a schedule of its own
 * would be a second sun, and CONTRACT.md §4 exists to say there is one.
 *
 * `officeDaylight` does the two things a room needs and a map does not: it turns
 * the sun into the building's frame using `site.heading`, so the light comes
 * through the windows the pack actually has, and it moves the fog outdoors.
 */
function officeLighting(site: NonNullable<Office["site"]>) {
  const env = observe(site.lat, site.lng, currentInstant(), currentWeather());
  // `officeAtmosphere` is built alongside the pack; falling back to the fixed
  // rig here would be a flicker rather than a fix, so this is only ever called
  // where one exists.
  const state = officeAtmosphere?.apply(env);
  if (!state) return undefined;

  /**
   * The building's own lights, on top of whatever is left of the sun.
   *
   * The order matters and is the only subtle thing here: the daylight
   * adaptation runs first, because it is about the *sun* — which way the
   * building faces and where the weather starts — and the house lights are
   * added to the result, because they are about the building. Doing it the
   * other way round would rotate the interior lighting by the building's
   * heading, which is meaningless: a ceiling does not face a compass point.
   *
   * `office` may not exist yet — this is called once at construction, before
   * there is a scene to ask — in which case the elevation is fed straight to the
   * ramp so the first frame is already correct rather than a lit room fading
   * down or a dark one fading up.
   */
  office?.setSolarElevation(houseElevation(env.sun.elevation));
  const house = office?.houseLevel() ?? houseLevelFor(houseElevation(env.sun.elevation));
  /**
   * And the smoke, which is the third parameter and the only one that comes
   * from another building's problem.
   *
   * It is adapted here, once, for the reason CONTRACT §4 gives: `Atmosphere`
   * owns the rig and `daylight.ts` adapts what it returned, so a room may not
   * reach into the fire layer and form a second opinion about its own sky. The
   * scalar is the drawn set weighted by acreage, distance and wind alignment —
   * `smokeLoadAt` in `engine/fires.ts` — and it is zero whenever nothing is
   * burning, which is most days and is today on the Southland board.
   *
   * Costs no geometry and no draw call: it moves the haze colour, the haze
   * near-distance and the sun's tint, three numbers `officeDaylight` already
   * computed.
   */
  return withHouseLights(officeDaylight(state, site, smokeLoadAtSite(site)), house);
}

/**
 * The lights-on ramp, for the one moment there is no office to ask.
 *
 * Duplicating the two bounds from `luminaires.ts` is a smell and is the lesser
 * of the two available ones: the alternative is building the office scene with
 * a rig computed from a light level it cannot report yet, which means the room
 * is visibly wrong for exactly one frame at every entry. Kept in step by being
 * four lines long and named after the thing it mirrors.
 */
function houseLevelFor(solarElevationDeg: number): number {
  return 1 - Math.min(1, Math.max(0, solarElevationDeg / 6));
}

/**
 * The fog alone, for the camera's clock rather than the sun's.
 *
 * ## The split, and why it is a split rather than a cheaper `setLighting`
 *
 * Two things move at completely different rates and were being served by one
 * call. The **sun** moves on the wall clock, which this file steps once a
 * minute. The **camera** moves on the city's tick: follow cameras write a new
 * pose every frame, damping writes one every frame of a drag, and even throttled
 * to 2% of altitude a single chapter flight gets through a few dozen times.
 * This function is the second of those, and it now sends the only thing that is
 * actually a fact about the camera — how far you can see from where it is
 * standing.
 *
 * What it used to send was the whole rig, and the whole rig is a fan-out: six
 * layer setters, a dirtied `MeshLambertMaterial` in the port yard, a rebuild of
 * the vessel wake instances, and the PMREM environment's fingerprint in
 * `environmentRig.ts`. Not one of those reads a fog distance. Measured over a
 * dolly and a drag on both metro boards, the old path cost 0.14-0.19 ms a step
 * and this one costs about 0.02.
 *
 * ## The trap it also disarms, which is the better reason
 *
 * `environmentRig.ts` decides whether to re-render and re-convolve the sky
 * cubemap by fingerprinting the rig's colours — `sky.horizon` among them — and
 * `interiors/daylight.ts` pins that horizon stop to the fog colour so the sky
 * dome and the haze meet without a seam. Aerial perspective moves distances and
 * no colours, so it does not trip that today; the point of routing the camera
 * through a setter that *cannot carry a colour* is that the next altitude-driven
 * term cannot trip it either. The alternative fix — coarsening the fingerprint
 * until the rebuild stops — hides one instance and leaves the mechanism armed.
 *
 * `updateSun` still passes the camera's view to `apply`, so a clock tick on a
 * board nobody is touching still gets the right fog. `Atmosphere` remains the
 * sole owner of both numbers (CONTRACT §4); this file only chooses which of them
 * a gesture is allowed to move.
 */
function applyCameraFog(): void {
  if (!city || !atmosphere) return;
  // A dip owns this board's fog for the ~0.8 s it runs. A camera event landing
  // inside that window would snap the haze back open mid-transition, which is
  // precisely the seam the dip exists to cover.
  if (fogDip !== null && fogDip.record.handle === city) return;
  const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;
  const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
  city.setAerialFog(
    atmosphere.aerial(env, {
      altitudeMetres: city.cameraAltitudeMetres(),
      standoffMetres: city.cameraStandoffMetres(),
    }),
  );
}

/**
 * Aerial fog, on the city's tick, throttled at 2% of altitude.
 *
 * `applyReliefRamp` is the model: a camera-dependent term that has to see the
 * pose *this frame*, including the pose a follow camera just wrote. Drive,
 * crow and aircraft never fire OrbitControls `change`, so a listener on that
 * event is a 1 Hz clock with extra steps. The throttle is what keeps a chapter
 * flight from recomputing sixty times a second for a number that has not
 * visibly moved.
 */
function refreshAerialFog(): void {
  if (!city) return;
  const altitude = city.cameraAltitudeMetres();
  if (!aerialFogMoved(altitude, lastAerialFogAltitude)) return;
  lastAerialFogAltitude = altitude;
  applyCameraFog();
}

function updateSun() {
  const active = CITIES.find((c) => c.id === cityId)?.city ?? SAN_FRANCISCO;

  // The office follows the same clock, and follows it whether or not you are
  // standing in it — walking back in to a room lit for an hour ago is the
  // failure this avoids.
  const site = officePack?.site;
  if (office && site && officeAtmosphere) {
    const state = officeLighting(site);
    if (state) office.setLighting(state);
  }

  // The roster follows the same clock the sun does. Never over a live answer.
  if (livePresence === null) applyPresence();

  if (!city || !atmosphere) return;
  const env = observe(active.center.lat, active.center.lng, currentInstant(), currentWeather());
  /**
   * The camera's height is the second argument to the rig now, and the reason
   * it comes from the scene rather than from here is that only the scene knows
   * this board's metres per unit and its vertical exaggeration.
   *
   * `atmosphere.ts` turns it into aerial perspective — how far you can see
   * through the air you are above — clamped so the clear-day pair below is the
   * ceiling and never exceeded. At the whole-board pose it saturates and every
   * board renders exactly as it did before, which is why the Bay Area's stills
   * are unaffected by construction. Low down it is what puts haze on a range
   * eighty kilometres up the valley.
   */
  const view = {
    altitudeMetres: city.cameraAltitudeMetres(),
    standoffMetres: city.cameraStandoffMetres(),
  };
  city.setLighting(atmosphere.apply(env, view));
  // `LightingState` carries a fog pair, so a clock tick inside a dip would undo
  // it. Re-asserted rather than suppressed, because the *rest* of the rig — the
  // sun, the sky, the environment map — is still exactly what the clock says.
  if (fogDip !== null && fogDip.record.handle === city) {
    const dip = fogDip;
    const t = dip.seconds <= 0 ? 1 : Math.min(1, dip.elapsed / dip.seconds);
    city.setAerialFog(dipFog(dip.from, dip.to, t));
  }
  city.setSolarElevation(env.sun.elevation);
  /**
   * The board's other three feeds, on the same clock and from the same sun.
   *
   * `env.sun.elevation` is handed to the migration gate rather than letting it
   * ask an ephemeris of its own. A second opinion about where the sun is, taken
   * from a clock the scrubber does not own, is how a night board and a night
   * layer end up disagreeing — and `LightingState.hemisphere` is not the signal
   * either, because `atmosphere.ts` raises the fill after dark and it reads
   * *higher* at midnight than at noon.
   *
   * Both are no-ops on a board without them: `tickHarbour` returns on a board
   * with no port, and `setPrecip`/`setMigration` are no-ops without a layer.
   */
  tickHarbour(currentInstant().getTime());
  if (boardCarriesSky) applySky(env.sun.elevation, Date.now());
  /**
   * `atmosphere.cloudCover(env)` and **not** `currentWeather()?.cloudCover ?? 0`.
   *
   * `null` weather is "nobody was asked", which is not an edge case — it is the
   * default deployment and the configuration this repo is held to. Falling back
   * to zero meant a clean clone's sky was permanently, silently empty.
   * `atmosphere` models a sky when nobody has observed one, and an observed
   * cover still wins outright when there is one.
   */
  city.setCloudCover(atmosphere.cloudCover(env));
  city.setWind(currentWeather()?.windKph ?? null, currentWeather()?.windDirDeg ?? null);
  // The override itself, not `currentInstant()`. Handing over a resolved date
  // would peg the sky to whatever second this ran in, and this runs about once a
  // second — so an unscrubbed sky would advance in visible steps while the
  // aircraft beside it moved smoothly. `null` means "read the clock yourself".
  city.setSkyInstant(instantOverride);
  // The plan view follows the same day the map does. It computes its own
  // palette from this one number rather than reading the rig, because a rig is
  // a set of three.js lights and the minimap has none.
  minimap?.setSolarElevation(env.sun.elevation);

  const el = env.sun.elevation;
  const time = env.time.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
  const moon = env.moon ? ` · moon ${Math.round(env.moon.illuminated * 100)}%` : "";
  // Formatted here rather than in the chrome, because the clock is this file's
  // — `instantOverride` is the one thing on the page that can make it disagree
  // with the wall clock, and a formatter downstream would have to be told.
  clockLabel = `${time} · sun ${el >= 0 ? "+" : ""}${el.toFixed(1)}° · ${daylightPhase(el)}${moon}`;
  renderChrome();
}

// ---- Fire -----------------------------------------------------------------

/**
 * The boards that draw fire.
 *
 * Named rather than derived, and deliberately not "every board": the Bay Area
 * rectangle has never contained a live incident, so a layer there would be a
 * frame-time allowance spent on an empty view every night. California and the
 * Southland are the two rectangles the upstream store actually populates.
 */
const FIRE_BOARDS: ReadonlySet<string> = new Set(["california", "socal"]);

function drawsFire(id: string): boolean {
  return FIRE_BOARDS.has(id) && access.feeds?.fires === true;
}

/**
 * One body, turned into the three things that read it.
 *
 * **The gate is not here and must never be copied here.** `promote()` in
 * `src/server/fires.ts` is the only place that decides what is drawn — the ten
 * acres, the eighty per cent, the prescribed burns, and the twenty-one stale
 * rows the collector never deletes — and this function's whole job is to hand
 * its answer to three consumers unchanged. On today's live store that answer is
 * five marks on California and *nothing at all* on the Southland, from the same
 * body on the same second, and both are correct.
 *
 * `null` is a fourth state and it is the one that has to survive: nothing has
 * answered. The panel says so out loud rather than showing an all-clear, which
 * is the same argument `health.ts` makes for `degraded[]` — a board with no feed
 * behind it and a board with nothing burning on it are indistinguishable without
 * a timestamp.
 */
function applyFires(body: FiresBody | null): void {
  firesBody = body;
  if (fireBounds === null) return;
  const promotion = promote(body, fireBounds);
  city?.setFires(promotion);
  firePanel?.apply(body === null ? null : promotion);
  // The courtyard the fires are actually over. Cheap — `smokeLoadAt` reads the
  // drawn set, which is at most sixty-four rows and is usually none — and only
  // ever a repaint of a rig that was going to be recomputed on the next clock
  // tick anyway.
  if (inside) updateSun();
  renderChrome();
}

/**
 * How much smoke is in the air at a building, from the fires on the board.
 *
 * Zero with no fire layer, zero with nothing drawn, and zero on a board that
 * does not draw fire at all — so the courtyard is bit-identical to today
 * everywhere except under a real fire, which is the property
 * `interiors/daylight.ts` was given a third argument for.
 */
function smokeLoadAtSite(site: NonNullable<Office["site"]>): number {
  return city?.fireSmokeLoadAt(site.lat, site.lng) ?? 0;
}

/**
 * The load below which the building says nothing about smoke.
 *
 * `smokeLoadAt` is continuous and its floor is a real zero, so "greater than
 * zero" looked like the honest test and is not. On today's live board the two
 * ten-acre fires in San Bernardino are 101 km from Mateo Court — inside the
 * layer's 120 km reach — and contribute a load of about **0.0025**: a quarter of
 * one per cent of a haze change, which is nothing anybody can see, under a
 * sentence that would have read "Thin smoke". That is a caption describing
 * something the picture does not contain, which is the failure this whole round
 * is arranged against.
 *
 * So the **rig** still gets the true scalar — continuous, no pop, and at 0.0025
 * indistinguishable from zero — and the **words** wait until there is something
 * to say. Calibrated from the layer's own model rather than by eye: a
 * 500-acre fire 60 km away lands at 0.14 and a 3,600-acre fire 20 km away at
 * 0.60, so this threshold sits an order of magnitude under the smallest fire
 * anybody would call smoke and an order of magnitude over the largest one
 * nobody would.
 */
const SMOKE_STATED_LOAD = 0.08;

/** The room's smoke disclosure, or `null` on a day the room cannot tell. */
function officeSmokeNote(): string | null {
  const site = officePack?.site;
  if (site === undefined) return null;
  const load = smokeLoadAtSite(site);
  return load >= SMOKE_STATED_LOAD ? smokeCaption(load) : null;
}

/**
 * Whether the board's fire panel is on screen right now.
 *
 * Two rules, and the second one is a judgement made from a picture. Outside a
 * building the panel is always up on a board that draws fire, because "nothing
 * is burning here" is a fact about that board and the whole point is that it is
 * *stated* rather than inferred from an absence.
 *
 * **Inside a building it appears only when the fires are actually in that
 * building's sky.** A five-item list of incidents three hundred kilometres away
 * pushed "Back to the city" below the fold of the LA office's panel on the first
 * frame a visitor sees of the room — a board-level instrument crowding out the
 * room's own controls. When the courtyard really does go brown, the list is the
 * explanation for what is on screen and it belongs there; `smokeCaption` in the
 * office note says the same thing in one sentence either way.
 *
 * `#fire-section` is this file's element, not `mount.ts`'s. See where it is
 * mounted.
 */
function showFireSection(): void {
  const section = document.querySelector<HTMLElement>("#fire-section");
  if (section === null) return;
  const site = officePack?.site;
  const wanted =
    firePanel !== null &&
    (!inside || (site !== undefined && smokeLoadAtSite(site) >= SMOKE_STATED_LOAD));
  if (section.hidden === !wanted) return;
  section.hidden = !wanted;
}

// ---- The sea, and the sky over it -----------------------------------------

/**
 * The seed the harbour is modelled from.
 *
 * Fixed, and that is the whole point: two visitors on two continents see the
 * same ships in the same berths, and a capture script that shoots the Southland
 * twice gets the same photograph. Everything downstream of it is a hash of this
 * and a stable string — a berth id, a port id — and never `Math.random`.
 */
const HARBOUR_SEED = 115;

/**
 * The harbour, rebuilt as a **new fix** rather than nudged along.
 *
 * `modelHarbour` stands in for a feed this deployment does not have, and the
 * feed's shape is what sets the cadence here. Upstream listens for thirty
 * seconds every fifteen minutes, so a hull under way has moved about five
 * kilometres between two reports; `engine/vessels.ts` is licensed to dead-reckon
 * along the reported course for exactly that long and then stops. Rebuilding on
 * the same interval is therefore not a smoothing trick — it is the feed's own
 * behaviour — and the small jump a moving hull makes when a new body lands is
 * the jump a real fix makes. Splining it away would be inventing the positions
 * in between, which is the one thing this layer refuses to do.
 *
 * The clock is the app's instant rather than the wall clock, so a scrubbed sky
 * and the harbour under it describe the same moment, and `promoteVessels` is
 * handed the same number so the body's age is what it actually is: zero.
 */
function refreshHarbour(atMs: number): void {
  if (vesselBounds === null) return;
  vesselsBody = modelHarbour(vesselPorts, { seed: HARBOUR_SEED, atMs });
  harbourAtMs = atMs;
  /**
   * Through the gate, never around it.
   *
   * The modelled body is a `VesselsBody` and it goes through the same
   * `promoteVessels` a live AIS body would: the three sentinels are re-checked,
   * the hulls are clipped to this board, a moored one takes its bearing from the
   * berth it is lying on, and the four suppression counters are what
   * `vesselSummary` writes its sentence from. The day `/vessels` exists, the
   * only line in this file that changes is the one above.
   */
  const promotion = promoteVessels(vesselsBody, vesselBounds, vesselBerths, atMs);
  city?.setVessels(promotion.drawn);
  seaNote = vesselSummary(promotion);
}

/** A new fix when the declared interval has passed, in either direction. */
function tickHarbour(atMs: number): void {
  if (vesselBounds === null) return;
  const intervalMs = Math.max(60, vesselsBody?.intervalSeconds ?? 900) * 1000;
  // Absolute, because the clock on this page can be dragged backwards.
  if (Math.abs(atMs - harbourAtMs) < intervalMs) return;
  refreshHarbour(atMs);
}

/**
 * Ask for the rain and the birds, at most one request each per TTL.
 *
 * Gated on `/health`'s `sources`, exactly as the fire watch is: on a deployment
 * that serves neither — which is this repo's default and every clone — nothing
 * is requested at all and the two gates still have something true to say, so the
 * panel reads "no radar feed is configured" rather than an ambiguous silence.
 *
 * There is no watcher class behind this on purpose. Radar is a five-minute
 * composite and BirdCast a ten-minute one; the page already has a once-a-minute
 * clock, and a fourth polling ladder in `adapters/http.ts` to re-ask a question
 * whose answer changes at most twelve times an hour would be machinery bought
 * for nothing.
 */
async function askSky(): Promise<void> {
  if (skyInFlight) return;
  const wantsRadar = access.feeds?.radar === true;
  const wantsBirds = access.feeds?.birds === true;
  if (!wantsRadar && !wantsBirds) return;
  skyInFlight = true;
  skyAskedAtMs = Date.now();
  try {
    const [radar, birds] = await Promise.all([
      wantsRadar ? tera.radar() : Promise.resolve(null),
      wantsBirds ? tera.birds() : Promise.resolve(null),
    ]);
    if (wantsRadar) radarBody = radar;
    if (wantsBirds) birdsBody = birds;
  } finally {
    skyInFlight = false;
  }
  // Straight onto the board rather than at the next minute: a scan that landed
  // is the only thing on this page that can change what the sky looks like
  // without the clock moving.
  updateSun();
}

/**
 * Both sky gates, run against whatever has answered.
 *
 * `nowMs` is the **wall** clock and not the app's instant, because `fetchedAt`
 * is a real timestamp and "last scan 5 minutes old" has to stay true when
 * somebody scrubs the sky to midnight. `solarElevationDeg` is the opposite: it
 * is the elevation the board is *lit* by, because the migration gate is a
 * daylight gate and a layer that disagreed with the sky about whether it is
 * night is precisely the failure that made the first version of it draw nothing
 * at 04:35 while passing its own tests.
 *
 * Both messages are always full sentences, including — especially — on the day
 * nothing is falling and nothing is aloft, which is most days.
 */
function applySky(solarElevationDeg: number, nowMs: number): void {
  const radar = promoteRadar(radarBody, nowMs);
  const birds = promoteBirds(birdsBody, { nowMs, solarElevationDeg });
  city?.setPrecip(radar.field);
  city?.setMigration(birds.field);
  radarNote = radar.message;
  birdsNote = birds.message;
  // The next scan, when this one has expired. `askSky` stamps its own clock
  // before the request, so this cannot re-enter while one is in the air.
  const ttlMs = Math.max(60, radarBody?.ttlSeconds ?? 0, birdsBody?.ttlSeconds ?? 0) * 1000;
  if (nowMs - skyAskedAtMs >= ttlMs) void askSky();
}

/**
 * The two board notes, drawn the way `showFireSection` draws the fire panel.
 *
 * `#sea-section` and `#sky-section` are this file's elements rather than
 * `mount.ts`'s, the same arrangement `#fire-section` and `#presence-host` have:
 * their content is a board's own instrument reading, they are rebuilt per board,
 * and they are hidden outright on a board that has no harbour and no statewide
 * raster. Hidden inside a building for the reason the fire list is: a room's
 * panel belongs to the room.
 */
function showBoardNotes(): void {
  const sea = !inside && vesselBounds !== null ? seaNote : "";
  writeBoardNote("sea-section", [["sea-note", sea]]);
  const sky = !inside && boardCarriesSky;
  writeBoardNote("sky-section", [
    ["radar-note", sky ? radarNote : ""],
    ["birds-note", sky ? birdsNote : ""],
  ]);
}

/** One section, its paragraphs, and the rule that an empty one is not shown. */
function writeBoardNote(
  sectionId: string,
  notes: readonly (readonly [string, string])[],
): void {
  const section = document.querySelector<HTMLElement>(`#${sectionId}`);
  if (section === null) return;
  let any = false;
  for (const [id, text] of notes) {
    if (text !== "") any = true;
    const line = document.querySelector<HTMLElement>(`#${id}`);
    if (line === null) continue;
    // Compared before it is written, like `mount.ts`'s applier: this runs on
    // every `renderChrome` and a `textContent` write is a layout invalidation.
    if (line.textContent !== text) line.textContent = text;
    if (line.hidden === (text !== "")) line.hidden = text === "";
  }
  if (section.hidden === !any) return;
  section.hidden = !any;
}

// ---- Cities ---------------------------------------------------------------

/**
 * Switching boards no longer tears the old one down.
 *
 * The board on screen stays on screen, fully interactive, for the whole of the
 * next board's build; the swap happens afterwards, under a fog dip, and the
 * outgoing board is **kept** so coming back to it costs nothing. See
 * `MountedBoard` for what is retained and what is not, and `engine/boards.ts`
 * for the measurements that say two resident boards are free and three are
 * provably enough.
 *
 * The opaque boot card is now the *first* mount of a session only. A switch
 * after that shows a small non-modal pill and leaves the picture alone, which is
 * the whole of what the owner can feel.
 */
async function mountCity(id: string): Promise<void> {
  const entry = CITIES.find((c) => c.id === id);
  if (!entry || !canvas) return;

  /**
   * Abandon whatever is still building before touching anything else.
   *
   * Two clicks on the board buttons a second apart used to run two heightfields
   * to completion and race to assign `city`; now the first `createScene` sees
   * its signal go and resolves `null` without ever allocating a renderer. The
   * controller is replaced rather than reused because an aborted signal stays
   * aborted, and the new build must not be born cancelled.
   *
   * It does **not** touch a board that is already resident. An abort cancels a
   * build, and a built board belongs to the cache from the moment it lands in
   * it.
   */
  /*
   * The background lane goes first, and the order is the whole guard.
   *
   * `createScene` awaits a heightfield the terrain worker is building; if the
   * prefetch is still holding that worker when a real request arrives, the
   * foreground build queues behind work nobody asked for. Aborting it here —
   * before `mounting?.abort()`, before anything else — is what makes a visitor's
   * click always the most important thing in the process.
   */
  cancelPrefetch();
  mounting?.abort();
  const mount = new AbortController();
  mounting = mount;
  wantedCity = id;

  const resident = boards.get(id);
  if (resident !== null) {
    // Nothing to build and nothing to wait for: the shader programs, the
    // heightfield and every instance buffer are still on the GPU. Measured, a
    // second visit to the Bay Area used to cost 1,664 ms against the first
    // mount's 1,779 — three refcounts programs per material and deletes at
    // zero, so disposal was relinking every shader on the way back.
    await presentBoard(resident);
    return;
  }

  showSwitchProgress(entry.label, null);
  try {
    const record = await buildBoard(entry, mount);
    if (record === null) {
      pendingPlace = null;
      hideSwitchProgress();
      return;
    }
    if (mount.signal.aborted || wantedCity !== id) {
      // Somebody changed their mind while this was in the air. It is a complete,
      // correct board — but nothing asked for it, and parking an unrequested
      // board in a cache with a ceiling would evict one somebody did ask for.
      disposeBoard(record);
      pendingPlace = null;
      hideSwitchProgress();
      return;
    }
    await presentBoard(record);
  } finally {
    /*
     * A finished mount is not a mount in flight.
     *
     * `mounting` used to be replaced and never cleared, which was harmless while
     * the only thing that read it was the next `mountCity` wanting something to
     * abort — an already-resolved controller aborts nothing and costs nothing.
     * It stopped being harmless the moment a second lane asked the obvious
     * question "is a foreground build happening right now?", because the honest
     * answer after the first mount of the session was permanently "yes". The
     * background lane armed exactly never, and the only symptom was a feature
     * that silently did nothing.
     *
     * Guarded on identity: a build that was superseded must not clear the
     * controller belonging to the build that superseded it.
     */
    if (mounting === mount) mounting = null;
  }
}

/**
 * Build a board without touching the one on screen.
 *
 * Everything in here is either expensive or derived from the pack, which is the
 * test for what belongs to a `MountedBoard`. Nothing in here reads `cityId`,
 * `city`, `atmosphere` or `minimap` — those are the *visible* board's and the
 * visible board is still the outgoing one for the whole of this function. That
 * separation is what makes building behind a live picture possible at all, and
 * it is the one invariant to keep if this function grows.
 */
async function buildBoard(
  entry: { id: string; label: string; city: City },
  mount: AbortController,
  options: {
    /**
     * Build without saying so.
     *
     * The background lane needs this and nothing else does. `onProgress` below
     * ends in `bootProgress`, which raises the switch pill — so a board loaded
     * ahead of the camera would put "Building The Bay Area… terrain 42%" on
     * screen over a visitor who did nothing, which is exactly the "you are
     * somewhere else now" chrome that removing the tab strip and the boot card
     * was for. A prefetch that announces itself is worse than no prefetch.
     */
    quiet?: boolean;
  } = {},
): Promise<MountedBoard | null> {
  const id = entry.id;
  /**
   * The sky and the traffic are per-city and are chosen here, before the build,
   * because `flights` is fixed at scene construction.
   *
   * Two gates and only one of them is about the visitor. `can.liveEnvironment`
   * is true for everybody — the sky is not a thing an account can grant. What is
   * load-bearing is `feeds`, the *deployment*, which stops the ordinary box
   * where every source is `none` polling two endpoints forever for a 404.
   *
   * These used to be gated on the *markers* flag, which is a different feed
   * entirely, so a deployment with a real ADS-B receiver and no marker file flew
   * the simulator.
   */
  /*
   * One sky region per detailed source, not one circle around the board.
   *
   * `regionOf` derives its circle from the board's bounds, which is right for a
   * metro board and wrong for the merged one: 402 nautical miles centred on the
   * middle of the state, which the API refuses with `400 bad_request, "Nothing
   * this deployment serves is near 37.3,-119.25"` — correctly, since what it
   * serves is San Francisco and the Southland, five hundred and sixty
   * kilometres apart. `region` stays the board's own circle for everything that
   * wants one shape (the traffic dial, the sample generator); `skyRegions` is
   * what the live feed is actually asked for.
   */
  const region = regionOf(entry.city);
  const skyRegions =
    ONE_CALIFORNIA && entry.id === "california"
      ? UNIFIED_SOURCES.map((source) => regionOf(source))
      : [region];
  // The hand-authored corridors for *this* city. `SAMPLE_ROUTES` was passed
  // unconditionally and all of it is over San Francisco, so the SoCal board's
  // entire sky projected ~590 km off the world and rendered as nothing at all.
  const routes = sampleRoutesFor(entry.city);
  // The public traffic API is region-oriented and intentionally capped around
  // one metro. A state-wide request would either be rejected or become a data
  // vacuum, so California keeps the honest deterministic sky while its two
  // detailed boards continue to use live ADS-B when available.
  const traffic =
    carriesMetroDetail(entry.city) && access.can.liveEnvironment && access.feeds?.flights
      ? skyRegions.length === 1
        ? tera.flights(region, routes)
        : mergedTraffic(skyRegions.map((r) => tera.flights(r, routes)))
      : null;

  /**
   * Started here and awaited below, so the fetch overlaps the heightfield build
   * rather than following it. Gated on the deployment for the same reason the
   * traffic is: a box with no satellite source answers with an empty catalogue,
   * and asking it once per page load for that is a request nobody needs.
   *
   * Not gated on the visitor. There is no `can.` check because there is nothing
   * to grant — the objects in this catalogue broadcast their positions to
   * anybody with a radio, and every element set in it is a US Government work.
   */
  if (satelliteElements === null && access.feeds?.satellites) {
    satelliteElements = tera.satellites();
  }
  const elements = (await satelliteElements) ?? [];
  // Rebuilt per board: the observer is this city's centre. See the note on
  // `satelliteElements` for why only the elements are shared.
  const catalogue =
    elements.length === 0 ? undefined : new SatelliteCatalogue(elements, entry.city.center);
  // The build may have been abandoned while that was in the air.
  if (mount.signal.aborted) {
    traffic?.dispose();
    return null;
  }

  // Wrapped, not replaced: the dial passes the real sky through untouched and
  // concatenates fabricated aircraft after it, so it composes with a live ADS-B
  // feed as readily as with the simulator. `cityFlights` stays the unwrapped
  // source — the corner label asks it whether what is on screen was observed,
  // and the answer is about the feed rather than about the dial.
  const dial = withTrafficDial(traffic ?? new SimulatedFlights(routes), region);
  // Carried across the switch, so a dial somebody set on the last board is still
  // set on this one. The dial it copies from is the *visible* board's, which is
  // still on screen and still interactive while this build runs.
  dial.setExtra(trafficDial?.extra() ?? 0);

  /**
   * Stable destinations are handed to the scene at construction time, not a
   * frame later. A building glyph needs that head start so `blocks.ts` can
   * reserve its footprint before the anonymous one-draw-call skyline is
   * emitted; otherwise both buildings occupy the same address and the useful
   * one is usually hidden inside the random one.
   *
   * A door belongs to the board it stands on. This used to be gated on a
   * hard-coded `id === "sf"`; using the board's own bounds is what keeps Mateo
   * Court on the Southland board and off the Bay Area one. Sample companies
   * stay SF-only because that sample feed is about one city and always was.
   */
  const bounds = entry.city.bounds;
  const doors = OFFICE_MARKERS.filter(
    (m) =>
      m.lat >= bounds.minLat &&
      m.lat <= bounds.maxLat &&
      m.lng >= bounds.minLng &&
      m.lng <= bounds.maxLng,
  );
  /*
   * Bounds **and** metro detail — the doors' rule, plus the feeds' rule.
   *
   * The sample companies are San Francisco's, so on San Francisco's own board
   * they appear and on the Southland's they do not; on a board that contains
   * San Francisco *and draws it at metro fidelity* they appear too, which is
   * what `cities/unify.ts` needed and what `id === "sf"` silently refused.
   *
   * Bounds alone is not enough, and the budget said so within one run: the
   * coarse statewide board's rectangle contains San Francisco, so it picked up
   * forty-four markers it has no business drawing at 1,919 m to the unit and
   * went from 373 draw calls to 417. `carriesMetroDetail` is the same predicate
   * the live feeds use and it is the same question — is this a board on which a
   * building-sized thing means anything.
   */
  const sampleMarkers = (carriesMetroDetail(entry.city) ? markers : []).filter(
    (m) =>
      m.lat >= bounds.minLat &&
      m.lat <= bounds.maxLat &&
      m.lng >= bounds.minLng &&
      m.lng <= bounds.maxLng,
  );
  const initialMarkers = [...sampleMarkers, ...doors];

  /**
   * The two sky layers, decided here so the options object below reads as four
   * sentences rather than as four nested conditionals.
   *
   * Two gates each, and they are the pair `drawsFire` uses. The **board** gate
   * is `boardCarriesRaster`: both feeds are statewide instruments quantised to a
   * quarter of a degree — sixteen WSR-88Ds and fifty-eight counties — and a
   * rectangle ninety kilometres across cannot be described by a cell twenty-seven
   * kilometres wide. It answers true for California and false for the Southland
   * and the Bay, which is the same answer an `id === "california"` would have
   * given and is a fact about the geometry rather than about a name.
   *
   * The **deployment** gate is `access.feeds`, read from `/health`'s `sources`.
   * With no projection configured there is no body, no honest caption beyond the
   * one the gate already writes, and nothing to draw — so the layer is withheld
   * entirely rather than built and left empty, exactly as `fires` is. This repo's
   * own default is `none` for both, which is why the panel sentence matters more
   * than the layer.
   */
  const carriesSky = boardCarriesRaster(entry.city.bounds);
  const precipFactory = carriesSky && access.feeds?.radar === true
    ? precipFactoryFor(entry.city.bounds)
    : null;
  const drawsMigration = carriesSky && access.feeds?.birds === true;
  const handle = await createScene(stage, {
    city: entry.city,
    // Bare URL on one California: land in the city, not on 1,551 km of state.
    // `?city=california` (and every capture shot) still opens on chapters[0].
    ...(ONE_CALIFORNIA && CITY_PARAM === null ? { openingChapter: "fidi" } : {}),
    // The page's one rig, shared with the office. Handed in rather than built
    // per board; see the note where it is constructed.
    environment,
    actor: {
      kind: actorKindForPresence(access.subject !== null, "outdoors"),
      identity: access.subject === null
        ? {
            id: "anonymous",
            displayName: "Guest",
            authenticated: false,
            profile: { appearance: { primaryColor: "#11151a", accentColor: "#f2b134" } },
          }
        : signedInActorIdentity(),
      mode: access.subject === null ? "flight" : "ground",
      position: access.subject === null ? { y: 1_200 } : { y: 0 },
      minFlightAltitude: 20,
      maxFlightAltitude: 1_500,
      ...(access.subject === null
        ? { camera: { distance: 2.55, height: 1.2, targetHeight: 0.1, lookAhead: 1.3 } }
        : {}),
    },
    ...(id === "california"
      ? { actorAnchor: { lat: 35.5, lng: -119.5 } }
      : {}),
    markerPalette: palette,
    markers: initialMarkers,
    ...(id === "california"
      ? {
          roadTraffic: {
            pack: CALIFORNIA_TRANSPORT,
            routeId: "la-sf-us-101",
            count: 14,
            seed: 115,
          },
          aircraft: {
            route: CALIFORNIA_AIR_ROUTE,
            initialPosition: CALIFORNIA_AIR_ROUTE[0],
            initialAltitudeM: CALIFORNIA_AIR_ROUTE[0]?.altitudeM ?? 1_350,
            initialHeadingDeg: 320,
            assistedAltitudeM: 1_500,
            // Corridor altitude is an atlas glyph just like the black cars:
            // literal metres would put the chase camera inside the coarse hills.
            altitudeSceneUnitsPerMetre: 0.01,
            visualSceneUnitsPerMetre: 0.22,
            camera: { distance: 52, height: 18, lookAhead: 18 },
          },
        }
      : {}),
    flights: dial.source,
    ...(catalogue ? { satellites: catalogue } : {}),
    /**
     * Fire, on the two boards where fire happens and nowhere else.
     *
     * Two gates and, unusually here, neither is about the visitor. `drawsFire`
     * is about the *map*: the Bay Area rectangle has held zero incidents on
     * every day this store has existed, and it is the board already spending
     * the largest frame-time allowance in the product, so it does not pay for a
     * layer to draw nothing. `feeds.fires` is about the *deployment*: with no
     * projection configured there is no body to draw and no honest caption to
     * write, and a layer with an empty view is a layer allocated for nothing.
     *
     * Withheld rather than passed-and-emptied, because `scene.ts` builds no
     * group, no material and no draw call when this is absent — see
     * `SceneOptions.fires`.
     */
    ...(drawsFire(id) ? { fires: createFireLayer } : {}),
    /**
     * The port kit, on the one board that declares a port.
     *
     * The ports are `city.ports` — plain authored data, closed over here rather
     * than passed through `SceneOptions`, because `scene.ts` must not have to
     * know what a quay is. Withheld entirely on a board with none, so
     * `engine/ports.ts` never enters the graph of a build that draws no port and
     * a portless board costs no group, no material and no draw call.
     */
    ...(entry.city.ports?.length
      ? { ports: (world, options) => createPortLayer(world, entry.city.ports ?? [], options) }
      : {}),
    /**
     * The hulls, on the same boards as the quays and for the same reason.
     *
     * Gated on the *ports*, not on a feed: a ship is drawn against a berth, a
     * channel and a breakwater, and a hull on a board with none of them is a
     * white shape on open water with nothing to say what it is doing. Withheld
     * rather than passed-and-emptied, like every layer above it.
     *
     * What fills it is `refreshHarbour`, below — a modelled body through the
     * live gate, never a hull invented at the renderer.
     */
    ...(entry.city.ports?.length ? { vessels: createVesselLayer } : {}),
    /** Reflectivity, on a board a quarter-degree cell can describe. */
    ...(precipFactory ? { precip: precipFactory } : {}),
    /** Tonight's migration, on the same board and under the same argument. */
    ...(drawsMigration ? { migration: createMigrationLayer } : {}),
    /**
     * A pin is a hover *and* a click, and an office pin is a door.
     *
     * `onMarkerPick` fires for both — `scenekit`'s picking calls it on hover
     * with the marker and on leave with `null` — so this cannot simply open a
     * building on every call or the office would fly open the moment the pointer
     * crossed a tower. The hover shows the card; the click is a separate
     * listener below, which reads whatever the hover last resolved.
     */
    onMarkerPick: (m) => {
      hoveredMarker = m;
      showDetail(m ? `${m.label}${m.blurb ? ` — ${m.blurb}` : ""}` : null);
    },
    /**
     * The other thing worth pointing at, and the one the owner asked for by
     * name: a card per aeroplane, for anybody who lands on this page.
     *
     * Hover opens it and hover away closes it, which is the same contract the
     * marker card has had since picking existed — there is no click handler
     * here because there is nothing extra a click could mean. See
     * `showAircraftDetail` for where the provenance comes from.
     */
    onAircraftPick: (a) => showAircraftDetail(a),
    /**
     * Aerial fog, on the same pump as the follow cameras that write the pose.
     *
     * Drive, crow and aircraft never fire OrbitControls `change`. Putting the
     * recompute here — after those writes, throttled at 2% of altitude — is
     * what makes a descent under any of them a fog dip instead of a 1 Hz jump.
     */
    onCameraFrame: () => refreshAerialFog(),
    signal: mount.signal,
    /**
     * Built, not shown. `presentBoard` puts it on the stage once the fog has
     * closed over the board the visitor is still looking at — see
     * `SceneOptions.present`.
     */
    present: false,
    // An abandoned build keeps its worker running for a tick or two after the
    // abort; its percentages must not land on the card the new city is using.
    onProgress: (p) => {
      if (options.quiet) return;
      if (!mount.signal.aborted) bootProgress(entry.label, p.fraction, p.onMainThread);
    },
    ...(access.subject !== null
      ? { realtimePeers: { localSceneUnitsPerMetre: entry.city.latScale / 111_320 } }
      : {}),
  });
  if (!handle) {
    /**
     * Superseded. `scene.dispose()` is what normally cancels the traffic
     * source, and there is no scene — so the polling this call started would
     * otherwise outlive the board it was started for, and keep a request in
     * the air for a city nobody is looking at.
     */
    traffic?.dispose();
    return null;
  }
  // A new board builds a new layer, and a new layer starts visible. Reapply
  // whatever the panel last said, or the setting silently undoes itself on the
  // first board switch.
  handle.setSatellitesVisible(satellitesVisible);

  if (carriesSky) void askSky();

  // Fog distances are scene units, so they have to follow the board — 210/460
  // was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
  // Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
  // out on the whole-board view: a fog starting nearer than that is behind the
  // viewer's own shoulder, and every pixel in frame is then at full fog.
  //
  // That last failure used to be catastrophic and is now only bad, and the
  // difference is worth recording because the comment used to claim the worse
  // version. The night fog colour is derived from the sky, the night sky was
  // nearly black, and so a fog plane behind the camera turned the entire map
  // off. `atmosphere.ts` now floors the night ground rig and stops the
  // obscuration convergence subtracting it again, and the night fog here lands
  // around #16203a — aerial perspective that lifts distance rather than a
  // blackout. The clearance is still required: a board flattened to one uniform
  // value is unreadable at any brightness. It is no longer the difference
  // between a map and a black rectangle.
  const [wx, nz] = handle.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
  const [ex, sz] = handle.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
  const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));

  const atmosphere = createAtmosphere({
    lng: entry.city.center.lng,
    metresPerUnit: handle.world.metresPerUnit,
    // Pushed out with the camera. `scene.ts` now lets the orbit reach 2.0 spans
    // so the viewer can get above the satellite dome, and at the old far of 2.8
    // the board sat at 51% fog from that pose — the whole city washing out at
    // exactly the moment the shot is meant to be the city under the
    // constellation. 3.9 keeps it near a fifth, which is the haze it had at the
    // old limit.
    clearFog: { near: span * 1.15, far: span * 3.9 },
    // The floor on how far you can see, and it has to know how big the board
    // is. `minVisibilityM` defaults to 4.5 km, which is honest weather and
    // completely wrong here: this board is ninety-four kilometres across, so
    // real visibility correctly hides three quarters of it and the night view
    // renders as a black rectangle. A map is looked at from outside the
    // atmosphere it is depicting.
    minVisibilityM: span * handle.world.metresPerUnit * 1.6,
    // The marine layer is a fact about the eastern Pacific at this latitude,
    // not a decoration. LA gets its own weather, not San Francisco's fog.
    marineLayer: id === "sf" ? PACIFIC_MARINE_LAYER : null,
  });
  /**
   * Recompute the **fog** when the camera moves, not only when the clock does.
   *
   * Throttled on the altitude itself rather than on time, and driven from the
   * city's tick (`onCameraFrame`) rather than from OrbitControls `change`.
   * Follow cameras write the pose directly and never fire `change`; a chapter
   * flight under damping would otherwise recompute sixty times a second for a
   * number that has not moved. Two per cent of the current altitude is under
   * the threshold at which the fog planes visibly shift, and it collapses a
   * whole chapter flight to a few dozen recomputations.
   *
   * `applyCameraFog` and not `updateSun`, and not `setLighting` either. The
   * clock tick ends in `renderChrome()`, a DOM pass with no business running
   * because somebody dragged the board; and the full rig ends in six layer
   * setters and the environment rig's fingerprint, none of which read a
   * distance. See `applyCameraFog` for the whole argument.
   */

  handle.onChapterChange(() => renderChrome());
  handle.onControlModeChange((mode) => adoptCityControlMode(mode));

  const minimap = createMinimap({
    world: handle.world,
    // The pack the *World* is drawing, not the one the module imported. These
    // are the same object whenever no reconciliation rule is on, and when one
    // is, the plan view drawn from the raw pack is a plan of a different
    // California than the one under the camera.
    city: handle.world.city,
    // The plan stops changing shape when the board does. See `planFrame`.
    frame: planFrame(handle.world.city),
    camera: handle.stageScene.camera,
    controls: handle.stageScene.controls,
    markerPalette: palette,
    // The plan view is a 2D canvas the same size as a phone's thumb, and on a
    // handheld it is drawn at the same ceiling the WebGL renderer uses. One
    // definition of "phone", in `stage.ts`, read by both.
    maxPixelRatio: deviceProfile().maxPixelRatio,
    onSeek(lat, lng) {
      if (!city || city.controlMode() !== "overview") return;
      /**
       * Slide the orbit target and carry the camera with it, keeping the offset
       * between them. A seek is "look over there", not "go to chapter three":
       * snapping to a chapter pose throws away the angle and the distance the
       * user spent the last minute choosing, and doing it from a click on a map
       * is the kind of surprise that stops people clicking on the map.
       *
       * No easing, deliberately. `flyTo` would need a pose, which is the thing
       * being avoided, and an instant move is also the correct answer under
       * `prefers-reduced-motion`.
       */
      const { camera, controls } = city.stageScene;
      const [x, z] = city.world.project(lat, lng);
      const y = city.world.groundAt(lat, lng);
      const dx = camera.position.x - controls.target.x;
      const dy = camera.position.y - controls.target.y;
      const dz = camera.position.z - controls.target.z;
      controls.target.set(x, y, z);
      camera.position.set(x + dx, y + dy, z + dz);
    },
    onHover(info) {
      if (!minimapReadout) return;
      minimapReadout.textContent = info
        ? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
        : "";
    },
  });
  /**
   * The plan's markers, and why this line is easy to lose.
   *
   * While the minimap was a module-level singleton this was `setMarkers` on the
   * one plan there was. Making it per-board moved the construction into this
   * function and the call went with it — except it did not, and the plan shipped
   * with an empty marker layer on every board. It is invisible to every test and
   * to the console: the plan still draws, still shades, still tracks the camera,
   * and simply has no doors on it. The photographs are what found it — the
   * Southland's orange Mateo Court dot and the Bay Area's cluster of sample
   * companies, both present at `bcac6aa` and both gone.
   *
   * A door is one of the two ways anybody finds a studio, so this is the layer
   * that matters most of the three. `MountedBoard.markers` keeps the same array
   * for a board that is evicted and rebuilt.
   */
  minimap.setMarkers(initialMarkers);

  return {
    id,
    entry,
    handle,
    minimap,
    atmosphere,
    span,
    flights: traffic,
    trafficDial: dial,
    fireBounds: drawsFire(id) ? entry.city.bounds : null,
    carriesSky,
    vesselsBody: null,
    vesselBounds: entry.city.ports?.length ? entry.city.bounds : null,
    vesselBerths: entry.city.ports?.length ? berthAnchors(entry.city.ports) : [],
    vesselPorts: entry.city.ports ?? [],
    harbourAtMs: 0,
    markers: initialMarkers,
    arrived: false,
  };
}

/**
 * Make a resident board the visible one, hiding the seam inside a fog dip.
 *
 * ## The order, which is the whole of it
 *
 * The outgoing board's aerial fog collapses toward the camera over
 * `FOG_DIP_OUT_SECONDS`, so the last frame anybody sees of it is a flat field of
 * its own horizon colour. The swap happens in that frame. The incoming board's
 * fog then lifts from the same collapsed pair to whatever `Atmosphere` says the
 * weather is, over `FOG_DIP_IN_SECONDS`.
 *
 * A **fog dip and not a crossfade**, and the argument is measured rather than
 * aesthetic — see `FOG_DIP_OUT_SECONDS` in `engine/atmosphere.ts`, which carries
 * both halves of it: every both-boards-live pairing breaks a desktop triangle
 * cap, and a true dissolve is the one transition that puts the seam's 4.17x
 * vertical deflation and its up-to-4,025 m registration slide on screen
 * simultaneously and in register.
 *
 * `prefers-reduced-motion` collapses the whole thing to a cut, exactly as
 * `arrive()` and `beginOfficeArrival` already do.
 */
async function presentBoard(record: MountedBoard): Promise<void> {
  const outgoing = visibleBoard;
  if (outgoing === record) {
    hideSwitchProgress();
    return;
  }

  const dips = outgoing !== null && !prefersReducedMotion();
  if (dips && outgoing !== null) await runFogDip(outgoing, "out");

  deactivateBoard();
  // The office belongs to the board it was opened from. Kept until here rather
  // than dropped at the top of the switch, so the outgoing board stays whole for
  // the whole of the build behind it.
  disposeLoadedOffice();
  inside = false;
  clearPublishedPlayInput();
  controlModeState = createControlModeState();

  adoptBoard(record);
  stage.setScene(record.handle.stageScene);

  /**
   * The cache is written **here**, at the moment a board becomes the visible
   * one, and not when its build finished.
   *
   * The ordering is load-bearing on a handheld and it was wrong once. With a
   * ceiling of two and California pinned, inserting the incoming board while the
   * outgoing one was still on the stage made the outgoing board the only legal
   * victim — so a Bay-Area-to-Southland switch on a phone disposed the picture
   * the visitor was looking at, blanked the canvas, and then swapped, which is
   * precisely the teardown this whole workstream exists to remove. Writing the
   * cache after `stage.setScene` means the least-recently-*shown* board is the
   * one that has just been hidden, which is both the correct victim and a board
   * nothing is drawing.
   */
  for (const evicted of boards.put(record.id, record)) disposeBoard(evicted);

  activateBoard(record);
  hideSwitchProgress();
  publishCameraHook(record);
  /*
   * Arriving is itself a reason to look ahead.
   *
   * The camera listeners below arm the lane as the visitor moves, but landing on
   * a board is a pose change nothing fired a `change` event for — and it is the
   * single most likely moment for the next board to already be worth having. The
   * old prefetch, if any, was cancelled by `mountCity` before this build began.
   */
  maybePrefetch(record);

  // Before the lift, so the incoming board's first drawn frame is already inside
  // the haze the outgoing one ended in rather than snapping to clear and then
  // fogging up.
  if (dips) record.handle.setAerialFog(collapsedFogFor(record));

  attachCurrentWebcamFace();
  moveRealtimePresence();
  showPlan();
  mountPoseEditor(record.handle);
  refreshGodmodePlace();
  updateSun();
  renderChrome();

  /**
   * The opening move, on the first showing of a board and not on a return.
   *
   * Arriving somewhere is what a first sight of a board is, and it is why every
   * mount used to play one. A *return* is a different thing: the point of
   * keeping a board is that you come back to the pose you left it at, and an
   * arrival would throw that away to re-stage a shot the visitor has already
   * seen. Captures are unaffected — every harness loads a fresh page with
   * `?city=`, which is always a first showing.
   *
   * After `updateSun`, so the first frame is lit by the real sky rather than by
   * the opening climatology the scene was built with, and after `showPlan` and
   * `renderChrome` so the move plays over a finished page.
   */
  const queued = pendingPlace;
  pendingPlace = null;
  if (queued !== null && queued.board === record.id) {
    // A rung on this board was what the switch was *for*, so it replaces the
    // opening move rather than following it — an arrival and then a flight to
    // somewhere else is two camera moves for one click.
    record.arrived = true;
    flyToChapter(queued.id);
  } else if (!record.arrived) {
    record.arrived = true;
    record.handle.arrive();
  }

  if (dips) await runFogDip(record, "in");
}

/**
 * The fog dip in progress, or `null`. Driven by the frame pump.
 *
 * A promise rather than a callback because `presentBoard` reads as a sequence —
 * collapse, swap, lift — and that is exactly what it is.
 */
let fogDip:
  | {
      record: MountedBoard;
      from: AerialFog;
      to: AerialFog;
      elapsed: number;
      seconds: number;
      settle: () => void;
    }
  | null = null;

function runFogDip(record: MountedBoard, phase: "out" | "in"): Promise<void> {
  // Whoever was dipping is superseded, and has to be *told* — a second click
  // inside the first switch's 0.8 s replaces `fogDip`, and an orphaned promise
  // leaves the first `presentBoard` suspended at its own `await` for the rest of
  // the page's life. Settling it lets that call run to its end and find nothing
  // left to do, which is the correct outcome of "last click wins".
  endFogDip();
  const clear = aerialFogFor(record);
  const hidden = collapsedFogFor(record);
  const from = phase === "out" ? clear : hidden;
  const to = phase === "out" ? hidden : clear;
  record.handle.setAerialFog(from);
  return new Promise<void>((resolve) => {
    fogDip = {
      record,
      from,
      to,
      elapsed: 0,
      seconds: phase === "out" ? FOG_DIP_OUT_SECONDS : FOG_DIP_IN_SECONDS,
      settle: resolve,
    };
  });
}

/** One frame of the dip. Called from the app's pump; a no-op when none is running. */
function stepFogDip(dt: number): void {
  const dip = fogDip;
  if (dip === null) return;
  dip.elapsed += dt;
  const t = dip.seconds <= 0 ? 1 : Math.min(1, dip.elapsed / dip.seconds);
  dip.record.handle.setAerialFog(dipFog(dip.from, dip.to, t));
  if (t < 1) return;
  endFogDip();
}

/** Finish whatever dip is running, wherever it had got to. Idempotent. */
function endFogDip(): void {
  const dip = fogDip;
  if (dip === null) return;
  fogDip = null;
  dip.settle();
}

/**
 * How far this board's camera is standing back, in this board's scene units,
 * floored so a camera sitting on its own target still has a fog to collapse to.
 *
 * The floor is a twentieth of the board's span rather than a constant, because a
 * constant would be a distance in whichever board's units it was written for.
 */
function collapsedFogFor(record: MountedBoard): AerialFog {
  const reach =
    record.handle.cameraStandoffMetres() / record.handle.world.metresPerUnit;
  return collapsedFog(Math.max(record.span * 0.05, reach));
}

/**
 * The fog this board would have if nothing were dipping — the pair the clock and
 * the camera agree on.
 *
 * Routed through the board's own `Atmosphere` rather than through the visible
 * one's, because `createAtmosphere` takes `metresPerUnit` and a span-derived
 * clear pair and both are per-board. A board that came back under the wrong
 * atmosphere would render with fog planes tuned for a board twenty times its
 * size, which on California is the difference between a map and a uniform wash.
 */
function aerialFogFor(record: MountedBoard): AerialFog {
  const env = observe(
    record.entry.city.center.lat,
    record.entry.city.center.lng,
    currentInstant(),
    currentWeather(),
  );
  return record.atmosphere.aerial(env, {
    altitudeMetres: record.handle.cameraAltitudeMetres(),
    standoffMetres: record.handle.cameraStandoffMetres(),
  });
}

/**
 * Project a record onto the module-level "the visible board" variables.
 *
 * The one function that decides what `city`, `cityId`, `atmosphere`, `minimap`
 * and the six board readings mean. Everything else in this file reads those and
 * is unchanged by retention, which is the point.
 */
function adoptBoard(record: MountedBoard): void {
  visibleBoard = record;
  city = record.handle;
  cityId = record.id;
  atmosphere = record.atmosphere;
  minimap = record.minimap;
  trafficDial = record.trafficDial;
  cityFlights = record.flights;
  fireBounds = record.fireBounds;
  boardCarriesSky = record.carriesSky;
  vesselsBody = record.vesselsBody;
  vesselBounds = record.vesselBounds;
  vesselBerths = record.vesselBerths;
  vesselPorts = record.vesselPorts;
  harbourAtMs = record.harbourAtMs;
  seaNote = "";
  radarNote = "";
  birdsNote = "";
}

/**
 * Stop everything the *visible* board was subscribed to, and leave the board
 * itself alone.
 *
 * The distinction is the reason retention is safe. A `/weather` request for San
 * Francisco that lands after the visitor has moved to the Southland would put
 * the marine layer over Long Beach; the fire panel is mounted into chrome this
 * file does not own and would otherwise accumulate one copy per resident board;
 * and the camera fog listener is bound to one board's `OrbitControls`. None of
 * those are things a paused board should keep doing. Its geometry, its
 * heightfield, its shader programs and its pose all stay exactly where they are.
 */
function deactivateBoard(): void {
  const record = visibleBoard;
  if (record !== null) {
    record.vesselsBody = vesselsBody;
    record.harbourAtMs = harbourAtMs;
  }
  weatherWatch?.stop();
  weatherWatch = null;
  fireWatch?.stop();
  fireWatch = null;
  firePanel?.dispose();
  firePanel = null;
  cameraFogWatch?.();
  cameraFogWatch = null;
  poseEditor?.destroy();
  poseEditor = null;
}

/** Subscribe the board that has just come on screen. The inverse of `deactivateBoard`. */
function activateBoard(record: MountedBoard): void {
  const { entry } = record;
  const id = record.id;

  /**
   * The weather, started only now that the board is on screen.
   *
   * Deliberately after the build rather than beside it: a poll issued at the
   * top of a two-second heightfield is a request for a city the user may
   * already have left, and the first thing `stop()` would do is throw the
   * answer away. Nothing on screen is waiting for it — `updateSun` runs
   * immediately with `null`, which is climatology, and the observation
   * replaces it when it lands.
   */
  // Weather observations are metro-scoped for the same reason live aircraft
  // are: one honest station cannot describe a 600 km corridor. California uses
  // the local climatology model; the detailed SF and SoCal boards keep their
  // live observations.
  //
  /*
   * And the merged board uses climatology too, which took a 400 in production
   * to notice.
   *
   * `carriesMetroDetail` is true of it — it carries both metros' districts —
   * so it asked, at `entry.city.center`, which is the middle of the *state*:
   * `GET /api/v1/weather?lat=37.30&lng=-119.25` → `400 bad_request, "Nothing
   * this deployment serves is near 37.3,-119.25"`. The API is right. What it
   * serves is San Francisco and the Southland, five hundred and sixty
   * kilometres apart, and the point midway between them is farm country in the
   * Sierra foothills that no station on this deployment describes.
   *
   * The sky already had this problem and solved it by asking per source
   * (`skyRegions`, above). Weather does not get the same answer, because the
   * two feeds are not the same shape: aircraft from two regions *merge* — they
   * are disjoint sets of objects in one sky — and two observations do not. One
   * board cannot be simultaneously foggy at the Golden Gate and clear over
   * Riverside, and picking either one and applying it statewide is a worse lie
   * than the climatology model, which at least varies with latitude and season.
   *
   * So this follows the rule the comment above already states rather than
   * inventing an exception to it: a board that spans more than one served
   * region takes the model. The metros keep their live observations on their
   * own boards, and the day the atmosphere is regional rather than per board,
   * this is the line that changes.
   */
  const spansManyWeatherRegions = ONE_CALIFORNIA && id === "california";
  weatherWatch =
    carriesMetroDetail(entry.city) &&
    !spansManyWeatherRegions &&
    access.can.liveEnvironment &&
    access.feeds?.weather
      ? tera.watchWeather(entry.city.center, () => {
          updateSun();
          renderChrome();
        })
      : null;

  /**
   * And the fire, started the same way and for the same reason.
   *
   * The panel is mounted first and applied with `null` immediately, so the very
   * first frame of a fire board already carries a sentence — "no fire feed has
   * answered" rather than a blank space that a viewer would read as an
   * all-clear. The watch then replaces it within a request.
   *
   * `entry.city.bounds` is handed to both halves and they use it for different
   * things: `promote()` clips the drawn set to the frame, and the panel turns an
   * off-board fire into "342 km north of this frame" instead of "somewhere
   * else". One rectangle, so the picture and the caption cannot disagree.
   */
  // `#fire-section` and `#fire-host` are this file's, not `mount.ts`'s — the
  // same arrangement `#presence-host` has, and the reason the header's rule
  // about not writing to the chrome's DOM is not broken here. `showFireSection`
  // owns the visibility and runs on every `renderChrome`.
  if (drawsFire(id)) {
    fireBounds = entry.city.bounds;
    const fireHost = document.querySelector<HTMLElement>("#fire-host");
    firePanel = fireHost === null ? null : mountFirePanel(fireHost, { bounds: fireBounds });
    // Whatever the page already knows, re-clipped to this board. `undefined` is
    // the first board of the session and is the only case that shows the fault
    // sentence, which is correct: nothing has answered yet.
    if (firesBody === undefined) firePanel?.apply(null);
    else applyFires(firesBody);
    fireWatch = tera.watchFires((body) => applyFires(body));
  }
  showFireSection();

  /**
   * The sea and the sky, in the frame the board comes on screen in.
   *
   * `refreshHarbour` is synchronous — the modelled body is arithmetic over
   * fifteen berths — so the first visible frame of the Southland already has its
   * hulls on it, on a first mount and on a return alike. On a return it is a new
   * *fix* rather than a replay: the clock has moved on while the board was
   * paused, and the harbour is modelled from the instant, not from a tick count.
   */
  if (record.vesselBounds !== null) refreshHarbour(currentInstant().getTime());
  // Asked once per board and then only when the body's own TTL has expired; the
  // promotion that draws it runs on the clock, in `updateSun`, which is also
  // where the sun's elevation comes from.
  if (record.carriesSky) void askSky();

  lastAerialFogAltitude = -1;
  {
    const controls = record.handle.stageScene.controls;
    let lastAltitude = -1;
    const onCameraMoved = () => {
      const altitude = record.handle.cameraAltitudeMetres();
      // Two per cent of the current altitude, same gate the fog uses. Fog
      // itself no longer lives here — follow cameras never fire `change` —
      // but handover and prefetch still should not run sixty times a second
      // under damping.
      if (!aerialFogMoved(altitude, lastAltitude)) return;
      lastAltitude = altitude;
      maybeHandover(record);
      maybePrefetch(record);
    };
    const onDragStart = () => { dragging = true; };
    const onDragEnd = () => {
      dragging = false;
      // The notch that ended the drag is the one that may promote: the rule
      // refuses while a pointer is down, so without this a descent that ends
      // below a threshold would sit there until the next event.
      maybeHandover(record);
      maybePrefetch(record);
    };
    controls.addEventListener("change", onCameraMoved);
    controls.addEventListener("start", onDragStart);
    controls.addEventListener("end", onDragEnd);
    cameraFogWatch = () => {
      controls.removeEventListener("change", onCameraMoved);
      controls.removeEventListener("start", onDragStart);
      controls.removeEventListener("end", onDragEnd);
      dragging = false;
    };
  }
}

/**
 * Whether a pointer is currently on the board.
 *
 * `OrbitControls` fires `start` on the first pointer-down, the first wheel notch
 * and the first pinch, and `end` when it lets go. Swapping a board out from
 * under a finger that is holding it is the one thing a free-camera handover must
 * never do.
 */
let dragging = false;

/**
 * Free-camera promotion across the seam. **On, and `?handover=0` turns it off.**
 *
 * ## Why it is on now
 *
 * Because the owner asked three times why there are three boards, and this is
 * the answer to that question rather than a nicety. A wheel notch over the Bay
 * now promotes to the detailed Bay Area board and a notch back out demotes to
 * the state tier: one map, three scales, entered by moving rather than by
 * picking a product off a strip of tabs. Measured on the deployed bundle before
 * this default changed, promotion fired at the sixteenth notch in from the state
 * pose and the arrival was clean — no boot card, no tab, no click on a name.
 *
 * The two reservations below were real and both are now discharged. The
 * oscillation one had a *cause* rather than a risk — see the arrival guard in
 * `maybeHandover`, which is the whole of it. The coarse-descent one is a
 * property of the packs and not of this rule, and it is recorded as such:
 *
 * ## What is still true, and is now a decision rather than a doubt
 *
 * 97.4% of California's area has no board below 242 km of stand-off. Zooming in
 * over Fresno lands on 806 m lots and 2 km-wide freeway symbols with nothing to
 * hand over to, and the rule correctly refuses. That picture has now been
 * looked at — it is a dark, coarse blur, and it is what the state tier honestly
 * is at that range. The judgement made here is that a visitor who deliberately
 * zooms into the empty middle of the state and finds it coarse has learned
 * something true, and that this costs less than a tab strip that tells every
 * visitor there are three products before they touch anything. Authoring
 * Sacramento, Fresno and the Central Valley is what retires it.
 *
 * ## The original argument, kept because it is the reason the guard exists
 *
 * The rule itself — `handover` in `engine/ladder.ts` — is pure, tested and
 * shipped. What is deliberately not shipped is letting a wheel notch fire it,
 * and the reasons are specific rather than cautious. A camera parked at a
 * threshold oscillates without a hysteresis band and a drag guard, both of which
 * are in the rule and neither of which has been photographed at every pose yet.
 * And 97.4% of California's area has no board below 242 km of stand-off: two
 * notches down from the state pose over Fresno lands on 806 m lots and 2 km-wide
 * freeway symbols with nothing to hand over to, so the rule correctly refuses
 * and the descent has to be *allowed* to look coarse. That is a picture somebody
 * should look at before it is on by default.
 *
 * `?handover=1` turns it on. Every promotion goes through `switchCity`, which is
 * what dispatches the journey event — a board that changed without one leaves
 * the door into Mateo Court rejecting with `wrong-city` while the Southland is
 * visibly on screen, and nothing throws.
 */
const FREE_HANDOVER = new URLSearchParams(location.search).get("handover") !== "0";

/**
 * Whether a board may be built before anybody asks for it.
 *
 * On, and this is the single constant that turns it off — the rollback for the
 * whole background lane, per the plan this was built from. It is a constant and
 * not a query flag deliberately: a query flag is a thing that ships in one state
 * and is measured in another, and every number quoted for this lane was measured
 * with it in the state it ships in.
 */
const PREFETCH_ENABLED = true;

/**
 * Does this pack carry metro detail, as opposed to being the coarse state board?
 *
 * **Asked of the pack, not of its id**, and that distinction is a bug this
 * function exists to have fixed. Live ADS-B and live weather observations were
 * gated on `id !== "california"`, which meant "not the coarse statewide board"
 * on the day it was written and was correct then: live aircraft over a board at
 * 1,919 m to the unit are a glyph problem, and an observation from one station
 * cannot speak for a thousand kilometres of coast.
 *
 * `cities/unify.ts` then made a board that is *both* — the whole state, at metro
 * fidelity, keeping the `california` id deliberately so that the fire gate, the
 * ladder's region table and every `?city=` deep link keep working. It therefore
 * inherited a gate meant for something else and shipped with live traffic and
 * live weather silently off, which is exactly the class of defect that reads as
 * "it feels less alive" and never as an error.
 *
 * `focusRegions` is the honest predicate and needs no new field: the coarse
 * state pack declares none, and every pack that has ground worth drawing at
 * metro resolution declares at least one.
 */
/**
 * A camera the capture harness can plant, exposed on `globalThis`.
 *
 * **Why this exists.** Every instrument in this repo aims through a control a
 * reader also uses — `look.mjs --chapter` clicks a chapter button, `shots.mjs`
 * drives the mode dock — and that is the right default, because a harness with
 * a back door photographs a state no visitor can reach. But it means a board
 * can only be photographed *where a chapter already points*, and the merged
 * board carries California's six: the whole state, the north, two corridors and
 * two doors that leave the board. There is no rung anywhere near a city on it.
 *
 * The cost of that was three failed attempts to photograph the very thing the
 * board exists for — its cities — by clicking the minimap and guessing wheel
 * notches, each of which landed in open ocean or on empty coast. A picture is
 * this project's acceptance instrument; a board that cannot be pointed at is a
 * board whose work cannot be accepted.
 *
 * So: a seek, in the same coordinates a pack is authored in. It moves the
 * camera and nothing else — no mode change, no board change, no state a visitor
 * could not also reach by dragging. It is `globalThis` rather than a module
 * export because the harness talks to a built bundle across a page boundary,
 * and it is deliberately not wired to any UI.
 */
function publishCameraHook(record: MountedBoard): void {
  (globalThis as { __teraCamera?: unknown }).__teraCamera = {
    /**
     * Look at a place from a distance, in the pack's own units: degrees for the
     * target, true metres for the stand-off and the height.
     *
     * `metresPerUnit` for the horizontal and `metresPerUnit / exaggeration` for
     * the vertical, because a stand-off is a plan measurement and a height is
     * not — the same distinction `chapterStandoffMetres` and
     * `chapterAltitudeMetres` are built on, and getting it wrong is a camera
     * under the ground.
     */
    seek(at: { lat: number; lng: number; standoffM?: number; heightM?: number; azimuth?: number }) {
      const world = record.handle.world;
      const scene = record.handle.stageScene;
      const [x, z] = world.project(at.lat, at.lng);
      const ground = record.handle.groundWorldY(at.lat, at.lng);
      const standoff = (at.standoffM ?? 20_000) / world.metresPerUnit;
      const lift =
        ((at.heightM ?? (at.standoffM ?? 20_000) * 0.6) / world.metresPerUnit) *
        world.city.verticalExaggeration * record.handle.reliefScale();
      const azimuth = at.azimuth ?? 0.6;
      scene.controls.target.set(x, ground, z);
      scene.camera.position.set(
        x + Math.sin(azimuth) * standoff,
        ground + lift,
        z + Math.cos(azimuth) * standoff,
      );
      scene.controls.update();
      return { x, z, ground, standoff, lift };
    },
    /**
     * What the city layer is currently drawing: how many lots are packed, and
     * which rung of `DETAIL_LOT_TIERS` they are lotted at.
     *
     * The same argument as `seek`, one floor down. Lot size follows the camera
     * now, and "did the board coarsen when I pulled back" is not a question a
     * triangle total can answer — a total is the terrain and the water and the
     * roads as well, and at the poses that matter those are more than half of
     * it. This reads the answer off the mesh instead. It observes and changes
     * nothing.
     */
    lots() {
      const found: InstancedMesh[] = [];
      record.handle.stageScene.scene.traverse((child) => {
        if (child instanceof InstancedMesh && child.name === "blocks") found.push(child);
      });
      const mesh = found[0];
      if (mesh === undefined) return null;
      const store = mesh.userData.detail as
        | { tier?: number; detailTier?: number | null }
        | undefined;
      /*
       * `detailTier` and not `tier`, because they answer different questions and
       * only one of them is the one being asked. `tier` is an index and is
       * always valid; `detailTier` is `null` at a pose where no metro detail is
       * drawn at all. Reporting `tier` printed "40 m" at the whole-board pose,
       * on a board drawing nothing but 806 m base lots — an instrument lying in
       * the direction of "the fine rung is cheap", which is precisely the
       * direction that would get somebody to spend a budget they do not have.
       */
      const drawn = store?.detailTier ?? null;
      return {
        packed: mesh.count,
        capacity: (mesh.instanceMatrix.array.length / 16) | 0,
        tier: drawn,
        lotMetres: drawn === null ? null : detailLotMetres(drawn),
      };
    },
    board: record.id,
  };
}

function carriesMetroDetail(city: City): boolean {
  return (city.focusRegions?.length ?? 0) > 0;
}

function maybeHandover(record: MountedBoard): void {
  if (!FREE_HANDOVER) return;
  if (inside || fogDip !== null || record !== visibleBoard) return;
  /*
   * The opening move is not a camera position anybody asked for, and reading a
   * stand-off out of it is how this feature spent a round looking broken.
   *
   * `arrivalStart` begins every board's arrival at 1.5x its resting stand-off
   * and `ladder.ts` demotes above 1.15x the handover ceiling, so the first
   * second of *any* board is outside that board's own retention band. Landing on
   * the Bay Area with the flag on therefore bounced straight back to the state
   * board with no input at all — measured, twice, before this line existed. It
   * is not an sf quirk and it is not a tuning problem: 1.5 > 1.15 holds for
   * every board there will ever be, which is why the fix is a guard rather than
   * a number. `handoverArrivalGuard.test.ts` pins that inequality so the guard
   * cannot be deleted as redundant.
   */
  if (record.handle.arriving()) return;
  if (record.handle.controlMode() !== "overview") return;
  const target = record.handle.stageScene.controls.target;
  const [lat, lng] = record.handle.world.unproject(target.x, target.z);
  const next = handover({
    boards: CITIES.map((entry) => ({
      id: entry.id,
      bounds: entry.city.bounds,
      metresPerUnit: 111_320 / entry.city.latScale,
    })),
    current: record.id,
    lat,
    lng,
    standoffM: record.handle.cameraStandoffMetres(),
    dragging,
  });
  if (next !== null && next !== wantedCity) switchCity(next);
}

/**
 * The metro tiers the background lane may load, and the stand-off each takes
 * over at. Derived from the ladder's own handover table, so there is exactly one
 * place in the product that says where a board stops being the right one.
 */
const PREFETCH_TIERS: readonly PrefetchTier[] = CITIES.flatMap((entry) => {
  const handoverStandoffM = HANDOVER_STANDOFF_M[entry.id];
  return handoverStandoffM === undefined
    ? []
    : [{ id: entry.id, handoverStandoffM, bounds: entry.city.bounds }];
});

/**
 * Drop whatever the background lane is doing.
 *
 * Called before every foreground abort and on every board present, because a
 * prefetch that outlives the reason it started is a build competing with a
 * visitor for one terrain worker. `createScene` honours the signal by dropping
 * the heightfield and resolving `null` without allocating a renderer, so an
 * abandoned prefetch costs nothing to abandon.
 */
/**
 * How long a board must be on screen and undisturbed before anything loads
 * behind it, in milliseconds.
 *
 * The board that just arrived is still linking shaders, uploading buffers and
 * settling its first frames; starting another build inside that window is the
 * one way this feature can make the product measurably worse. Deliberately not
 * `requestIdleCallback`: Safari did not ship it until recently and it is exactly
 * the browser most likely to be on the tightest budget, so the deferral is a
 * timer everywhere rather than a good mechanism on some platforms and none on
 * others.
 */
const PREFETCH_IDLE_MS = 2_000;

let prefetchTimer: ReturnType<typeof setTimeout> | null = null;

function cancelPrefetch(): void {
  if (prefetchTimer !== null) {
    clearTimeout(prefetchTimer);
    prefetchTimer = null;
  }
  prefetching?.abort();
  prefetching = null;
}

/**
 * Load the board the camera is heading for, before it is asked for.
 *
 * ## Why this is the whole of "loads in the background"
 *
 * Everything else was already there. `present: false` already exists on
 * `SceneOptions` and is already honoured; `boards` already holds three on a
 * desktop with California pinned; `presentBoard` already runs a fog dip over a
 * cache hit at zero shader links and zero blocked milliseconds. The only missing
 * piece was something that decides to build a board nobody has clicked on, and
 * the reason it was missing is that it is the piece that can hurt: it competes
 * for the terrain worker and it can relight the board on screen.
 *
 * Both hazards are closed rather than hoped about. The competition is closed by
 * `cancelPrefetch()` running before every foreground abort. The relighting is
 * closed in `environmentRig.ts` — an off-stage apply borrows the cached probe
 * instead of convolving a new one and disposing the visible board's — and by
 * `scene.ts` passing `offstage: !presented` on every clock-driven apply.
 *
 * ## Why the decision is not made here
 *
 * `prefetchTarget` is pure, takes scalars, and lives in `boards.ts` with the
 * eviction policy it has to agree with. This function is the effects half: read
 * three numbers off the camera, ask, and act. Keeping the two apart is what lets
 * `prefetchPolicy.test.ts` assert against the real pack bounds — including that
 * the two metros' trigger discs provably cannot overlap — with no DOM, no WebGL
 * and no `three` anywhere near it.
 */
function maybePrefetch(record: MountedBoard): void {
  if (!PREFETCH_ENABLED) return;
  // Never behind a visitor. A foreground build, an office, or a dip in progress
  // all mean something is already happening that matters more than this.
  if (inside || mounting !== null || fogDip !== null) return;
  if (record !== visibleBoard) return;
  if (prefetching !== null) return;

  const target = record.handle.stageScene.controls.target;
  const [lat, lng] = record.handle.world.unproject(target.x, target.z);
  const id = prefetchTarget({
    tiers: PREFETCH_TIERS,
    lat,
    lng,
    standoffM: record.handle.cameraStandoffMetres(),
    resident: boards.ids(),
    visible: visibleBoard?.id ?? null,
    capacity: residentCapacity(deviceProfile().handheld),
    pinned: ["california"],
  });
  if (id === null) return;
  const entry = CITIES.find((candidate) => candidate.id === id);
  if (entry === undefined) return;

  /*
   * Armed now, started later.
   *
   * The timer is what separates "the camera is somewhere a load would be useful"
   * from "and it has been there long enough that nothing else needs the machine".
   * Every gesture cancels it through `cancelPrefetch`, so a visitor sweeping the
   * camera across the state re-arms it repeatedly and starts nothing until they
   * stop — which is the behaviour a background lane has to have to be invisible.
   */
  if (prefetchTimer !== null) clearTimeout(prefetchTimer);
  prefetchTimer = setTimeout(() => {
    prefetchTimer = null;
    startPrefetch(entry);
  }, PREFETCH_IDLE_MS);
}

/** The build itself, once the deferral above has decided it is safe to start. */
function startPrefetch(entry: { id: string; label: string; city: City }): void {
  const id = entry.id;
  // Re-checked after the wait, because two seconds is long enough for every one
  // of these to have changed.
  if (inside || mounting !== null || fogDip !== null) return;
  if (prefetching !== null || boards.has(id) || visibleBoard?.id === id) return;

  const controller = new AbortController();
  prefetching = controller;
  void (async () => {
    const built = await buildBoard(entry, controller, { quiet: true });
    // Four ways this stops mattering between the ask and the answer, and all
    // four have to be checked *after* the await rather than before it: the
    // visitor may have clicked something, the lane may have been cancelled, the
    // board may have arrived by the front door in the meantime, or the cache may
    // no longer have room for it without evicting what is on screen.
    if (built === null) {
      if (prefetching === controller) cancelPrefetch();
      return;
    }
    const stale =
      controller.signal.aborted ||
      prefetching !== controller ||
      boards.has(id) ||
      visibleBoard?.id === id;
    if (stale) {
      disposeBoard(built);
      if (prefetching === controller) prefetching = null;
      return;
    }
    /*
     * Parked, not presented.
     *
     * `presentBoard` writes the cache itself at the moment a board becomes
     * visible; this one never becomes visible, so it writes its own entry — and
     * it goes in as the *least* recently shown, which is what `put` already does
     * for a board that has never been shown. Anything evicted to make room is
     * disposed here, exactly as `presentBoard` does, because the rig's ledger is
     * a strong reference and a board left in it retains the whole scene graph.
     */
    for (const evicted of boards.put(id, built)) disposeBoard(evicted);
    prefetching = null;
  })();
}

/**
 * The rectangle the plan view draws, which is no longer the board's own.
 *
 * ## The problem, stated as a measurement
 *
 * Three boards, three arbitrary rectangles. California's is 9.55 by 10.5
 * degrees, the Bay Area's 0.85 by 0.89, the Southland's 1.08 by 1.66 — which on
 * screen, once longitude is squashed by the cosine of each board's own centre
 * latitude, is a **0.87 portrait, a 0.83 portrait and a 1.28 landscape**. So the
 * plan in the corner was turning through ninety degrees between the Southland
 * and the other two while the world underneath it had not moved at all. That is
 * the loudest "you are somewhere else now" signal on the screen and it fires in
 * the same frame the fog dip is trying to hide everything else.
 *
 * ## The rule, and what it is not
 *
 * Every board is drawn inside a rectangle with **California's proportions**,
 * grown around the board's own bounds until the aspect matches and centred on
 * it. California is the reference because its four edges are the only ones in
 * the product that are a *place* rather than a crop: the two metro rectangles
 * are framings somebody chose, and the state's are the state's.
 *
 * The cost of the rule is honest and small: the Bay Area's frame widens by 5.7%
 * and the Southland's grows 46% taller, so the Southland sits in a band across
 * the middle of its plan with water above and below it — which is, to be fair
 * to it, where the Southland actually is.
 *
 * **This is the shape pinned, not the extent.** The design this comes from asks
 * for the plan to be pinned to California's *bounds* on every board, with a zoom
 * that follows the camera's ground footprint and the relief sourced from
 * whichever board is resident. That is a larger change — the relief raster is
 * built once into an offscreen surface and blitted under a 30 Hz cap and a
 * skip-when-nothing-moved guard, and a zoom means re-rasterising against both —
 * and it is not what shipped here. What shipped is the half of it that answers
 * the complaint: the widget is the same shape on all three boards.
 */
const PLAN_ASPECT = planAspect(CALIFORNIA);

function planAspect(pack: City): number {
  const lat = (pack.bounds.maxLat - pack.bounds.minLat);
  const lng = (pack.bounds.maxLng - pack.bounds.minLng) * Math.cos((pack.center.lat * Math.PI) / 180);
  return lng / lat;
}

function planFrame(pack: City): {
  minLat: number;
  maxLat: number;
  minLng: number;
  maxLng: number;
} {
  const cos = Math.cos((pack.center.lat * Math.PI) / 180);
  let latSpan = pack.bounds.maxLat - pack.bounds.minLat;
  let lngSpan = pack.bounds.maxLng - pack.bounds.minLng;
  const aspect = (lngSpan * cos) / latSpan;
  // Grow the short axis. Never shrink either one: a frame that cropped would put
  // part of the board off its own plan, which is a worse failure than an empty
  // margin and a much less obvious one.
  if (aspect < PLAN_ASPECT) lngSpan = (latSpan * PLAN_ASPECT) / cos;
  else latSpan = (lngSpan * cos) / PLAN_ASPECT;
  const midLat = (pack.bounds.maxLat + pack.bounds.minLat) / 2;
  const midLng = (pack.bounds.maxLng + pack.bounds.minLng) / 2;
  return {
    minLat: midLat - latSpan / 2,
    maxLat: midLat + latSpan / 2,
    minLng: midLng - lngSpan / 2,
    maxLng: midLng + lngSpan / 2,
  };
}

/**
 * Free a board for good.
 *
 * The order matters and `scene.ts` documents why: `SceneHandle.dispose()` takes
 * the scene off the stage first and then walks it, so the renderer still has the
 * bookkeeping that makes `gl.deleteProgram` actually run. It also calls
 * `environmentRig.release(scene)` before anything else frees a texture — the
 * rig's ledger is a **strong** reference, and a disposed board left in it is the
 * whole scene graph retained, which is the exact leak that doc comment was
 * written for. Retention makes this path rarer and therefore easier to get
 * wrong, so it has one caller and one place to look.
 */
function disposeBoard(record: MountedBoard): void {
  // A dip drives `setAerialFog` from the pump every frame, and a handle whose
  // materials have been disposed is not a thing to keep writing to.
  if (fogDip?.record === record) endFogDip();
  if (visibleBoard === record) {
    deactivateBoard();
    visibleBoard = null;
    city = null;
    atmosphere = null;
    minimap = null;
  }
  record.minimap?.dispose();
  record.handle.dispose();
}

/**
 * The opening move for a **room**, and the one place the app owns a camera.
 *
 * The city's arrival lives in `scene.ts`, where the scene owns its own kit. An
 * office has no equivalent seam — `OfficeScene` hands out `camera` and
 * `controls` and offers `flyTo(viewId)` and nothing that takes a pose — so the
 * move is driven from here, off the pump this file already runs.
 *
 * Two rules, and both are about not breaking something that works:
 *
 * **The resting pose is whatever the room already chose.** It is read off the
 * camera at the moment the door opens rather than computed, so it is the pack's
 * `viewpoints[0]` on a first entry and *where you left it* on a second. A
 * viewpoint's `focus.at` is also the walk spawn — `startDeviceFeed`'s
 * neighbour above reads it to place a walker — and the LA office has already
 * been bitten by an arrival shot that stood the camera inside a three-metre
 * entry passage framing two brick walls. The target is not touched here. Only
 * the camera moves, and only away from a pose somebody already checked.
 *
 * **A low pose has nowhere to fly from.** Below `OFFICE_ARRIVAL_MIN_ELEVATION`
 * the viewpoint is an eye-level shot standing in a room, and lifting a camera
 * that is inside a building puts the first second of the arrival inside the
 * ceiling slab. Those rooms simply do not get one.
 */
let officeArrival: { from: Pose; to: Pose; elapsed: number } | null = null;

/**
 * Seconds. The same length as a board's, so the two doors feel like one product
 * — and bounded by the same thing: `scripts/performance-budget.mjs` measures the
 * `office` scene from a three-second warm-up, and a move still running inside
 * the sample window makes its geometry counts stop reproducing. See
 * `ARRIVAL_SECONDS` in `scene.ts` for the argument in full.
 */
const OFFICE_ARRIVAL_SECONDS = 2.5;
/** Below this, in radians, the viewpoint is inside a room and gets no move. */
const OFFICE_ARRIVAL_MIN_ELEVATION = (15 * Math.PI) / 180;
/** How much higher the camera starts, in radians, and the ceiling on where that lands. */
const OFFICE_ARRIVAL_RISE = (11 * Math.PI) / 180;
const OFFICE_ARRIVAL_MAX_ELEVATION = (58 * Math.PI) / 180;
/** How much further out it starts, as a multiple of the resting stand-off. */
const OFFICE_ARRIVAL_STANDOFF = 1.32;
/** How far round the room it swings, in radians. */
const OFFICE_ARRIVAL_YAW = -0.4;

function beginOfficeArrival(scene: OfficeScene): void {
  officeArrival = null;
  if (prefersReducedMotion()) return;
  const target = scene.controls.target.clone();
  const rest = { position: scene.camera.position.clone(), target };
  const dx = rest.position.x - target.x;
  const dy = rest.position.y - target.y;
  const dz = rest.position.z - target.z;
  const reach = Math.hypot(dx, dy, dz);
  if (reach <= 0) return;
  const elevation = Math.asin(Math.max(-1, Math.min(1, dy / reach)));
  if (elevation < OFFICE_ARRIVAL_MIN_ELEVATION) return;
  const raised = Math.min(elevation + OFFICE_ARRIVAL_RISE, OFFICE_ARRIVAL_MAX_ELEVATION);
  const flat = Math.hypot(dx, dz);
  const cos = Math.cos(OFFICE_ARRIVAL_YAW);
  const sin = Math.sin(OFFICE_ARRIVAL_YAW);
  const ax = flat > 0 ? (dx * cos - dz * sin) / flat : 0;
  const az = flat > 0 ? (dx * sin + dz * cos) / flat : 1;
  const startReach = Math.min(reach * OFFICE_ARRIVAL_STANDOFF, scene.controls.maxDistance * 0.995);
  const horizontal = Math.cos(raised) * startReach;
  const from: Pose = {
    target: target.clone(),
    position: new Vector3(
      target.x + ax * horizontal,
      target.y + Math.sin(raised) * startReach,
      target.z + az * horizontal,
    ),
  };
  scene.camera.position.copy(from.position);
  scene.controls.target.copy(from.target);
  scene.controls.update();
  officeArrival = { from, to: rest, elapsed: 0 };
}

/** Any input, any view, any change of mode: the move is over. */
function cancelOfficeArrival(): void {
  officeArrival = null;
}

function stepOfficeArrival(dt: number): void {
  if (officeArrival === null) return;
  if (!inside || !office || controlModeState.mode !== "office-overview") {
    officeArrival = null;
    return;
  }
  officeArrival.elapsed += dt;
  const t = Math.min(1, officeArrival.elapsed / OFFICE_ARRIVAL_SECONDS);
  const e = t < 0.5 ? 4 * t ** 3 : 1 - (-2 * t + 2) ** 3 / 2;
  office.camera.position.lerpVectors(officeArrival.from.position, officeArrival.to.position, e);
  office.controls.target.lerpVectors(officeArrival.from.target, officeArrival.to.target, e);
  office.controls.update();
  if (t >= 1) officeArrival = null;
}

/**
 * Whether this visitor has asked the platform for less movement.
 *
 * The same question `scene.ts` and `scenekit.ts` ask, asked here because this
 * file drives a camera of its own. Read at the moment it is needed rather than
 * cached: the only callers ask once per door, and the query is a property read.
 */
function prefersReducedMotion(): boolean {
  if (typeof window.matchMedia !== "function") return false;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches;
}

/**
 * The app's own frame pump, beside the renderer's.
 *
 * `Stage` owns the render loop and neither scene handle exposes a per-frame
 * hook, so the alternative is adding an `onTick` to both for a handful of call
 * sites. This is the smaller change and it costs nothing measurable: an idle
 * `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the loop
 * keeps running unchanged across a city swap, across the office swap, and
 * during the window where there is no scene at all.
 *
 * What lives on it, and why each is here rather than in a scene:
 *
 * - the two plan widgets, which are 2-D canvases the engine draws;
 * - the pose editor, which is `null` for everyone who is not god;
 * - the studio hardware and the car outside, which are **simulations** rather
 *   than layers — both are fixed-step state machines that the arena also drives,
 *   and putting their clocks in a scene would mean the arena and the renderer
 *   stepping two different copies;
 * - the chrome, but only while a body is under control. See below.
 */
let lastPumpAt = performance.now();
requestAnimationFrame(function pump(now) {
  requestAnimationFrame(pump);
  /**
   * Clamped, because a backgrounded tab comes back with a `dt` of minutes and
   * neither simulator should be asked to catch up on a room nobody was in. Both
   * of them drop the excess internally as well; this is the cheaper half of the
   * same decision, made before the call rather than inside it.
   */
  const dt = Math.min(0.1, Math.max(0, (now - lastPumpAt) / 1000));
  lastPumpAt = now;

  syncJourneyVehicle(now);
  stepFogDip(dt);
  stepOfficeArrival(dt);
  updateLocalPlayerMaps();
  // Only the mounted one. The other is still constructed and still holds a live
  // camera, but its canvas is out of the document, so `clientWidth` is 0, every
  // `resize()` puts it back to `ready = false`, and ticking it would be a
  // function call that exists to do nothing sixty times a second.
  if (inside) officePlan?.tick();
  else minimap?.tick();
  if (inside) {
    // A no-op on the API strategy, which is driven by its own poll — see
    // `devices/adapter.ts`, which owns that choice and nothing above it knows
    // which of the two it got.
    deviceSource?.tick(dt);
    stepVehicleTelemetry(dt);
  }
  poseEditor?.tick();
  publishRealtimePresence(now);
  pollLiveness();
  /**
   * The chrome, on the frames where something in it actually moves.
   *
   * `chromeState` is pure and `mount.apply` compares before it writes, so
   * calling this unconditionally would be correct — and it would also rebuild
   * nothing, sixty times a second, for the ninety-odd percent of a session
   * spent in an orbit camera. The one part of the interface that changes per
   * frame is the play HUD's speed and altitude, and `playHudKindFor` is exactly
   * the test for "is a controller reporting numbers right now". Everything else
   * calls `renderChrome()` at the moment it changes, which is what makes this
   * an optimisation rather than a second source of truth.
   */
  if (playHudKindFor(controlModeState.mode) !== null) renderChrome();
});

function updateLocalPlayerMaps(): void {
  if (!city) return;
  if (inside) {
    minimap?.setPlayer(null);
    const walker = controlModeState.mode === "office-walk" ? office?.walker?.state() : null;
    officePlan?.setPlayer(walker
      ? {
          levelId: walker.levelId,
          x: walker.position.x,
          z: walker.position.z,
          headingRad: Math.atan2(-walker.facing.x, -walker.facing.z),
          kind: walker.actor,
        }
      : null);
    return;
  }
  officePlan?.setPlayer(null);
  if (city.controlMode() === "drive") {
    const state = city.vehicleState();
    minimap?.setPlayer(state
      ? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "vehicle" }
      : null);
    return;
  }
  if (city.controlMode() === "aircraft") {
    const state = city.aircraftState();
    minimap?.setPlayer(state
      ? { lat: state.lat, lng: state.lng, headingDeg: state.headingDeg, kind: "aircraft" }
      : null);
    return;
  }
  if (city.controlMode() === "actor") {
    const state = city.actorState();
    const pack = CITIES.find((candidate) => candidate.id === cityId)?.city;
    if (!state || !pack) {
      minimap?.setPlayer(null);
      return;
    }
    const anchor = cityId === "california" ? { lat: 35.5, lng: -119.5 } : pack.center;
    const [originX, originZ] = city.world.project(anchor.lat, anchor.lng);
    const [lat, lng] = city.world.unproject(
      originX + state.x / city.world.metresPerUnit,
      originZ + state.z / city.world.metresPerUnit,
    );
    minimap?.setPlayer({
      lat,
      lng,
      headingDeg: ((-state.yaw * 180 / Math.PI) % 360 + 360) % 360,
      kind: "actor",
    });
    return;
  }
  minimap?.setPlayer(null);
}

/**
 * Whether the corner label is still telling the truth, once a second.
 *
 * The two live feeds settle on their own schedule and neither has an event to
 * subscribe to: `TrafficSource.live()` flips when a `/flights` body lands
 * inside the region, which is somewhere in the first fifteen seconds, and the
 * weather watch fires its own callback but only when a *poll* settles. A
 * one-second sample is late by nothing anybody can perceive and costs a
 * subtraction on the frames it skips.
 */
let livenessCheckedAt = 0;
let journeySyncedAt = 0;
let previousVehicleProgress: number | null = null;
function pollLiveness() {
  const now = performance.now();
  if (now - livenessCheckedAt < 1000) return;
  livenessCheckedAt = now;
  renderChrome();
}

/** Persist route progress cheaply and turn the hero's wrap into a real scale door. */
function syncJourneyVehicle(now: number): void {
  if (!routeDriveIsActive() || !city || journey.route === null || journey.vehicle === null) {
    previousVehicleProgress = null;
    return;
  }
  const vehicle = city.vehicleState();
  if (!vehicle) return;
  const progress = vehicle.progress;
  const arrived = journey.route.direction === 1
    ? previousVehicleProgress !== null && previousVehicleProgress > 0.94 && progress < 0.06
    : previousVehicleProgress !== null && previousVehicleProgress < 0.06 && progress > 0.94;
  previousVehicleProgress = progress;

  if (arrived) {
    const endpoint = journey.route.direction === 1 ? "san-francisco" : "los-angeles";
    dispatchJourney({ type: "reach-route-endpoint", endpoint });
    dispatchJourney({ type: "exit-vehicle" });
    officeId = endpoint === "san-francisco" ? "lumbridge-hq" : "mateo-court";
    requestControlMode("overview");
    city.flyTo(endpoint === "san-francisco" ? "san-francisco" : "los-angeles");
    renderChrome();
    return;
  }
  if (now - journeySyncedAt < 500) return;
  journeySyncedAt = now;
  dispatchJourney({ type: "update-route-progress", progress });
}

// ---- Office ---------------------------------------------------------------

/**
 * Everyone gets in. The tier picks which building they get, not whether the
 * door opens.
 *
 * The office used to be members-only, and the anonymous view of this site was a
 * map with a greyed-out button on it — the single most interesting thing the
 * project does, visible only as something you cannot have. At `"public"` depth
 * the same shell, the same furniture and the same named viewpoints are built,
 * and the only thing missing is the people. That is withheld because the API
 * refuses occupancy to an anonymous caller, not because this function declined
 * to draw it.
 */
async function enterOffice() {
  if (!city) return;
  // A city can have more than one authored door. Leaving one office parks its
  // scene for a cheap re-entry, but clicking a different door must not reopen
  // that cached building under the new office id. Tear down every resource
  // owned by the old room before loading the requested one; the parked city
  // actor, journey identity and local profile deliberately live elsewhere.
  if (office && builtOfficeId !== officeId) disposeLoadedOffice();
  if (!office) {
    const built = await loadOffice();
    // The chunk arrived after the user had already left for the other city, or
    // it did not arrive at all. Either way there is no room to walk into and
    // `loadOffice` has already said so on the button.
    if (!built || !city) return;
    const { createOfficeScene, createOfficeMinimap, pack, materials, robotOperations } = built;
    const depth = access.can.officeDepth;
    // Keep the remote-avatar renderer behind the authenticated boundary. The
    // public Office door can build its full local scene without downloading it.
    const createOfficePeers = access.subject !== null
      ? (await import("./realtime/scenePeers.ts")).createScenePeers
      : null;

    /**
     * The building's own sky, built before the scene because the scene wants the
     * opening rig and the horizon drop at construction.
     *
     * `metresPerUnit: 1` — an office is authored in metres, and this is the only
     * thing `Atmosphere` needs to know about scale. The fog it computes from
     * that is then thrown away and replaced by `officeDaylight`, because a fog
     * sized for a fifty-metre room is a fog inside the room.
     */
    officeAtmosphere = pack.site
      ? createAtmosphere({
          lng: pack.site.lng,
          metresPerUnit: 1,
          /**
           * No marine layer, deliberately — unlike the city, which gets one.
           *
           * The model is a fact about sea level on this coast, and both of these
           * buildings look *out over* it: one from 188 m up a tower, one across
           * an estuary. Handing it to the office put the observer inside the fog
           * it describes, which greyed the sky to near-white at one in the
           * afternoon and took the view with it. The city keeps its fog; the
           * rooms that look at the city do not stand in it.
           */
          marineLayer: null,
        })
      : null;

    office = createOfficeScene(pack, {
      dom: city.stage.renderer.domElement,
      // The page's one environment map, the same object the city holds. Indoors
      // it is the difference between `deviceMesh`, `chairBase` and the Model X's
      // clearcoat reading as metal and reading as grey plastic.
      environment,
      /**
       * The same aeroplanes the board outside is drawing.
       *
       * `dial.source` rather than the raw feed, so a sky somebody turned up in
       * godmode is the sky in both places; and the *city's* source rather than a
       * second subscription, so the office is a second reader of one feed rather
       * than a second poller of a volunteer-funded API. The office never
       * disposes it — see the note on `OfficeSceneOptions.flights`.
       */
      ...(trafficDial ? { flights: trafficDial.source } : {}),
      /**
       * The car on the apron. `corridor` detail because it is parked and the
       * arrival viewpoint looks at it from across a courtyard: the 22 extra draw
       * calls `follow` buys are mirrors, glass frames and brake calipers, none
       * of which resolve at that range.
       *
       * The seed is the office id rather than a constant, so the two studios do
       * not park identical cars at identical angles — which is the single
       * clearest tell that a scene was generated.
       */
      exteriorVehicle: { detail: "corridor", seed: seedForOffice(officeId) },
      // A visitor owns one local actor. Anonymous visitors are the promised
      // office dog; a signed-in visitor gets the procedural humanoid. The
      // arrival viewpoint is already a pack-authored clear point on a floor,
      // which makes it the honest spawn and keeps coordinates out of the app.
      walker: {
        levelId: pack.viewpoints[0]?.levelId ?? pack.levels[0]?.id ?? "level-1",
        position: pack.viewpoints[0]?.focus.at ?? { x: 0, z: 0 },
        facing: pack.viewpoints[0]
          ? {
              x: -Math.sin(pack.viewpoints[0].focus.rotation),
              z: -Math.cos(pack.viewpoints[0].focus.rotation),
            }
          : { x: 0, z: -1 },
        actor: actorKindForPresence(access.subject !== null, "office") === "dog"
          ? { kind: "anonymous-dog", coatColor: 0x17191c, collarColor: 0xf2b134 }
          : { kind: "humanoid", ...(humanoidAppearance() ?? { outfitColor: 0x151a20, accentColor: 0xf2b134 }) },
      },
      // Only when there is no sky to put behind it. A sited office computes a
      // gradient and a horizon; painting the old flat colour over that is the
      // bug that looks exactly like the sky not working.
      ...(pack.site ? {} : { background: 0x11161c }),
      ...(pack.site ? { lighting: officeLighting(pack.site) } : {}),
      ...(pack.site ? { horizon: { drop: pack.site.elevation } } : {}),
      // Only offices with explicit, validated simulated operations get robots.
      // No geometry-derived random errands and no implication of live work.
      ...(robotOperations ? { robotOperations } : {}),
      depth,
      materials,
      // Ignored entirely at `"public"` depth, where no layer is built to colour.
      presencePalette: SAMPLE_PRESENCE_PALETTE,
      // Two different questions, so two different callbacks. `onPresencePick`
      // answers "who is at this desk"; `onPlacePick` answers only "this is a
      // desk, and it is the fourteenth one" — which is all a stranger is told.
      ...(depth === "full"
        ? { onPresencePick: (p) => showDetail(p ? p.label : null) }
        : { onPlacePick: (place) => showDetail(place ? place.label : null) }),
      ...(createOfficePeers ? { realtimePeers: { create: createOfficePeers } } : {}),
    });
    builtOfficeId = officeId;
    office.onViewChange(() => renderChrome());
    // Registered here rather than on every entry, because this block runs once
    // per office and the scene is retained between visits. The first drag,
    // wheel or pinch ends the opening move wherever it has got to — a camera
    // that finished its arc anyway would be arguing with somebody who has
    // already started using it.
    office.controls.addEventListener("start", cancelOfficeArrival);
    officePlan = buildOfficePlan(createOfficeMinimap, office);
    startDeviceFeed(built.createDeviceSource, office.devices);
    startVehicleTelemetry(built.createSimulatedVehicleTelemetry);
  }
  requestControlMode("overview");
  city.stage.setScene(office);
  inside = true;
  controlModeState = {
    mode: "office-overview",
    revision: controlModeState.revision + 1,
  };
  attachCurrentWebcamFace();
  moveRealtimePresence();
  dispatchJourney({ type: "enter-office", officeId: officeId as "lumbridge-hq" | "frontier-valley" | "mateo-court" });
  showPlan();
  showDetail(null);
  refreshGodmodePlace();
  renderChrome();
  // Once the room is up and the panel beside it is drawn. See
  // `beginOfficeArrival` for the two rules it obeys.
  beginOfficeArrival(office);
  // After the room is on screen, not before. The first ask is a second request
  // and the building is worth looking at while it is in flight; awaiting it here
  // would hold the door shut on a network round trip to draw people into a scene
  // the user cannot see yet.
  watchOccupancy();
}

/**
 * The plan panel, for the office.
 *
 * Built once alongside the scene rather than on every entry, because it reads
 * that scene's camera and controls by reference — a second one built against a
 * disposed office would draw a footprint from a camera nobody is moving.
 *
 * The constructor is passed in rather than imported, for the reason written at
 * the import block: naming the module here as a value would put the furniture
 * catalogue back in the entry chunk.
 */
function buildOfficePlan(
  create: typeof import("./engine/officeMinimap.ts").createOfficeMinimap,
  scene: OfficeScene,
): OfficeMinimap {
  const panel = create({
    plan: scene.plan,
    camera: scene.camera,
    controls: scene.controls,
    // The same ceiling the city plan takes, from the same place. Two 2D canvases
    // in one corner of one page disagreeing about what a phone can afford is the
    // exact drift `deviceProfile` exists in one file to prevent.
    maxPixelRatio: deviceProfile().maxPixelRatio,
    onSeek(x, z) {
      if (controlModeState.mode !== "office-overview") return;
      /**
       * The same move the city plan makes, and for the same reason: slide the
       * orbit target and carry the camera with it, keeping the offset between
       * them. A seek is "look over there", not "go to viewpoint three".
       *
       * The height is deliberately untouched. Indoors that matters more than it
       * does outdoors — the difference between a viewpoint two metres off the
       * floor and one fourteen metres up is the difference between standing in
       * the room and looking down into it, and clicking the plan should not
       * silently move you between those two.
       */
      const { camera, controls } = scene;
      const dx = camera.position.x - controls.target.x;
      const dy = camera.position.y - controls.target.y;
      const dz = camera.position.z - controls.target.z;
      controls.target.set(x, controls.target.y, z);
      camera.position.set(x + dx, controls.target.y + dy, z + dz);
    },
    onHover(info) {
      if (!minimapReadout) return;
      if (!info) {
        minimapReadout.textContent = "";
        return;
      }
      // Metres, not degrees. The city widget prints a latitude and a longitude
      // because that is the address of a place on a map; the address of a place
      // in a building is where it is on the floor, in the units the pack was
      // authored in, so a self-hoster can read a number off this and type it
      // into their own office file.
      const where = `${info.x.toFixed(1)}, ${info.z.toFixed(1)} m`;
      // The storey is named only when there is more than one to be on. On a
      // single-level pack — which is the reference office and will be most
      // packs — "Level 1" on every hover is a word that never changes and
      // therefore never informs.
      const level = scene.plan.levels.length > 1 && info.level ? ` · ${info.level}` : "";
      // The person last, and after the room, because the readout is read left to
      // right as an address getting more specific: the floor, then the room,
      // then who is in it.
      const room = info.room ? ` · ${info.room}` : "";
      const person = info.person ? ` · ${info.person}` : "";
      minimapReadout.textContent = `${where}${level}${room}${person}`;
    },
  });

  /**
   * Once, not per frame.
   *
   * `scene.robots()` hands back a stable array of vectors the robot layer
   * mutates in place, so the plan reads this frame's positions through a
   * reference taken here — the same handshake `officeScene` already makes with
   * `luminaires.setWalkers`. Polling it every frame would allocate nothing and
   * would imply the array were a snapshot, which it is not.
   */
  panel.setRobots(scene.robots());
  return panel;
}

/**
 * Put the plan for wherever you are into the panel.
 *
 * One function rather than a `replaceChildren` at each of the call sites,
 * because the failure it prevents is silent: the panel keeps showing a widget
 * that is still ticking and still correct *about somewhere else*, which looks
 * exactly like a working map.
 */
function showPlan() {
  const widget = inside ? officePlan : minimap;
  if (!minimapFrame || !widget) return;
  minimapFrame.replaceChildren(widget.canvas);
  // The canvas has just re-entered the document with a real box for the first
  // time, or the first time since it left it. Nothing else tells it that: the
  // `ResizeObserver` fires on a change of size, and going from detached to
  // attached at the same size is not one.
  widget.resize();
}

function leaveOffice() {
  if (!city) return;
  // Before the scene swap, so the last thing the watch can do is abort a request
  // rather than publish into a room the user has already left.
  stopWatchingOccupancy();
  disposeOfficeScreenUi();
  clearPublishedPlayInput();
  office?.walker?.setActive(false);
  dispatchJourney({ type: "leave-office" });
  city.stage.setScene(city.stageScene);
  inside = false;
  controlModeState = {
    mode: "overview",
    revision: controlModeState.revision + 1,
  };
  city.setControlMode("overview");
  attachCurrentWebcamFace();
  moveRealtimePresence();
  showPlan();
  showDetail(null);
  refreshGodmodePlace();
  renderChrome();
}

/** Dispose everything whose coordinates or subscriptions belong to one office. */
function disposeLoadedOffice(): void {
  stopWatchingOccupancy();
  disposeOfficeScreenUi();
  // Before the scene, so a reading in flight cannot land on a disposed layer.
  // `stop()` is idempotent and is the only thing that owns a timer down there.
  deviceSource?.stop();
  deviceSource = null;
  deviceStates = [];
  vehicleTelemetry = null;
  officePlan?.dispose();
  officePlan = null;
  office?.dispose();
  office = null;
  builtOfficeId = null;
  officeAtmosphere = null;
}

/**
 * A stable integer per building, for the things that must look the same twice.
 *
 * FNV-1a over the id, which is the same hash the arena's checksums use and is
 * chosen for the same property: it is short, it has no dependencies, and it
 * gives two ids that differ by one character completely different seeds — so
 * `lumbridge-hq` and `mateo-court` genuinely park different cars rather than
 * two that differ in the third decimal.
 */
function seedForOffice(id: string): number {
  let hash = 0x811c9dc5;
  for (let i = 0; i < id.length; i += 1) {
    hash ^= id.charCodeAt(i);
    hash = Math.imul(hash, 0x01000193);
  }
  return hash >>> 0;
}

/**
 * Point the hardware in this room at whatever will answer for it.
 *
 * The choice — the deployment's device route, or the fixed-step simulator in
 * this tab — is made inside `createDeviceSource` and deliberately not here.
 * That is the one place the anon-first fallback lives, and the reason it is not
 * a branch in this file is that the two halves would drift: the route refuses an
 * anonymous read, which is correct, and an anonymous visitor is the audience
 * this product is designed for, so the refusal must produce a working
 * instrument rather than a dead one.
 *
 * `serverHasDevices` is the same short-circuit `access.feeds` performs for
 * weather and markers: a box with no device source answers with an empty body,
 * and asking it once per TTL per tab forever to be told nothing is a request
 * nobody needs.
 *
 * **It is two questions and it used to ask only one.** `access.feeds.devices`
 * says the deployment has a source; `access.can.liveDevices` says this viewer
 * may read it. On cloud-2 the first is true and, for the visitor this product
 * is designed for, the second is false — the route is members-only and answers
 * 401 — so passing the deployment's answer alone sent every anonymous viewer
 * down the API strategy to be refused. `apiSource` then renders `atRest()`
 * forever: a rack of powered-off instruments, in a room the panel beside them
 * describes as running, with an exponential back-off quietly retrying a request
 * that cannot succeed. Both halves, and the fallback is the simulator that was
 * always meant to serve this case.
 */
function startDeviceFeed(
  create: typeof import("./devices/adapter.ts").createDeviceSource,
  declarations: readonly DeviceDeclaration[],
): void {
  deviceSource?.stop();
  deviceStates = [];
  if (declarations.length === 0) {
    deviceSource = null;
    return;
  }
  deviceSource = create({
    declarations,
    client: tera,
    officeId: officePack?.id ?? officeId,
    serverHasDevices: access.feeds?.devices !== false && access.can.liveDevices,
    /**
     * And the tier itself, which is belt and braces on purpose.
     *
     * `can.liveDevices` above is *this app's* answer to "may this viewer read
     * the route", and it is the one CONTRACT §6 names. `viewerTier` is the
     * *fact*, and `adapter.ts` gates on it independently, so a future call site
     * that forgets the capability still cannot ship an anonymous visitor a rack
     * of dead instruments. Two gates for one decision is the right number when
     * one of them is a bug that already shipped once.
     */
    viewerTier: access.tier,
    seed: seedForOffice(officeId),
    onReading: (reading) => {
      deviceStates = reading.states;
      // The hardware in the room and the panel beside it, from one reading, so
      // a lit indicator and a lit control can never disagree about a mic.
      office?.setDeviceStates(reading.states);
      chrome?.applyDeviceStates(reading.states);
    },
  });
  const opening = deviceSource.current();
  deviceStates = opening.states;
  office?.setDeviceStates(opening.states);
  chrome?.applyDeviceStates(opening.states);
}

/**
 * Send one command, and say nothing if it is refused.
 *
 * `normalizeDeviceCommand` has already run inside the panel, so anything that
 * reaches here is well formed for a device this office declared. A `null` back
 * is a *deployment* refusal — an anonymous caller writing to a real room — and
 * the panel is left showing the last reading rather than a control that appears
 * to have worked. The refusal is not narrated because the sign-in offer is
 * already on the same screen.
 */
function sendDeviceCommand(command: DeviceCommand): void {
  void deviceSource?.command(command).then((state) => {
    if (state === null) return;
    deviceStates = deviceStates.map((current) => (current.id === state.id ? state : current));
    office?.setDeviceStates(deviceStates);
    chrome?.applyDeviceStates(deviceStates);
  });
}

/** Bring the studio hardware panel into view, opening the column if it is shut. */
function openDevicePanel(): void {
  panelOpen = true;
  panelChosen = true;
  renderChrome();
  document.querySelector<HTMLElement>("#device-section")?.scrollIntoView({
    block: "nearest",
    // A jump rather than a glide, and not only for `prefers-reduced-motion`: the
    // panel it is scrolling inside has just been opened in the same frame.
    behavior: "auto",
  });
}

/**
 * The car outside, as its own fixed-step simulation.
 *
 * Seeded per office and stepped from the frame pump rather than from the office
 * scene, because this is the *same* module the arena drives — a state machine
 * with a snapshot and a restore, not a render layer. Keeping its clock here is
 * what lets the renderer and the arena hold two independent instances of one
 * implementation instead of two implementations.
 *
 * The ambient temperature is a constant and that is a gap rather than a choice:
 * `WeatherObservation` carries cloud, precipitation, visibility and wind, and no
 * temperature at all, so there is nothing live to couple to. Inventing one from
 * the sun's elevation would be publishing a reading nobody took, which is the
 * one thing the whole telemetry layer is arranged not to do.
 */
function startVehicleTelemetry(
  create: typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry,
): void {
  vehicleTelemetry = create({
    seed: seedForOffice(officeId),
    fixedStepSeconds: VEHICLE_STEP_SECONDS,
    ambientC: DEFAULT_AMBIENT_C,
  });
  office?.setVehicleTelemetry(vehicleTelemetry.current());
}

/** Seconds of simulated vehicle time per fixed step. */
const VEHICLE_STEP_SECONDS = 0.5;
/**
 * The ambient temperature the parked car sits in, in Celsius.
 *
 * A mild Californian afternoon, which is what both studios stand in. See
 * `startVehicleTelemetry` for why it is not read off the weather feed.
 */
const DEFAULT_AMBIENT_C = 19;

let vehicleStepDebt = 0;
/**
 * Advance the car by whole fixed steps, and only ever by whole ones.
 *
 * A fixed-step simulator that is handed a variable `dt` stops being
 * reproducible, and reproducibility is the entire reason this module is shared
 * with the arena. So the frame's elapsed time is accumulated and spent in whole
 * steps, with the remainder carried; `apply` is signature-guarded downstream, so
 * the frames that spend no step cost a comparison.
 */
function stepVehicleTelemetry(dt: number): void {
  const source = vehicleTelemetry;
  if (!source || !office) return;
  vehicleStepDebt = Math.min(vehicleStepDebt + dt, VEHICLE_STEP_SECONDS * 4);
  while (vehicleStepDebt >= VEHICLE_STEP_SECONDS) {
    vehicleStepDebt -= VEHICLE_STEP_SECONDS;
    source.stepFixed();
  }
  office.setVehicleTelemetry(source.current());
}

/**
 * Fetch Spaces.
 *
 * The office is the largest thing in this build that most visitors never open,
 * so it is a chunk the door fetches on the way through rather than bytes every
 * map visitor downloads. Measured entry chunk: 780.18 kB before the split,
 * 720.89 after — smaller than the chunks themselves because three.js is shared
 * and stays where it was.
 *
 * One `Promise.all` because they are one arrival: a pack without its builder is
 * a data file nobody can draw, so the fetches overlap rather than queue.
 *
 * No retry and no cache-busting, deliberately. A failed chunk fetch is a deploy
 * that moved the file under an open tab; the honest answer is to say the door
 * did not open and let the next click try again, which it can, because a
 * rejected dynamic import is not memoised by the browser.
 */
async function loadOffice(): Promise<{
  createOfficeScene: typeof import("./interiors/officeScene.ts").createOfficeScene;
  createOfficeMinimap: typeof import("./engine/officeMinimap.ts").createOfficeMinimap;
  createDeviceSource: typeof import("./devices/adapter.ts").createDeviceSource;
  createSimulatedVehicleTelemetry:
    typeof import("./transport/vehicleTelemetry.ts").createSimulatedVehicleTelemetry;
  pack: Office;
  materials: MaterialRegistry;
  robotOperations: RobotOperationsDefinition | null;
} | null> {
  try {
    // More modules and still one arrival. The plan renderer reads prop
    // footprints off the asset registry, so it is already downstream of the
    // furniture catalogue this chunk exists to hold back — asking for it here
    // costs nothing beyond the module itself, and asking for it anywhere else
    // would cost the whole catalogue in the entry chunk.
    const entry = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
    if (!entry) return null;
    const [interiors, pack, assets, plan, operations, devices, telemetry] = await Promise.all([
      import("./interiors/officeScene.ts"),
      entry.load(),
      import("./assets/materials.ts"),
      import("./engine/officeMinimap.ts"),
      entry.loadOperations?.() ?? Promise.resolve(null),
      // Neither of these imports THREE, the DOM or the network, and neither has
      // anything to say until somebody is standing in a studio — so both travel
      // with the furniture rather than in the bundle every map visitor fetches.
      import("./devices/adapter.ts"),
      import("./transport/vehicleTelemetry.ts"),
    ]);
    // Assigned rather than memoised with `??=`: the memo was what made this
    // single-office forever, quietly serving the first pack fetched for every
    // later request whatever id was asked for.
    officePack = pack.default;
    officeMaterials ??= new assets.MaterialRegistry({ quality: officeMaterialQuality() });
    return {
      createOfficeScene: interiors.createOfficeScene,
      createOfficeMinimap: plan.createOfficeMinimap,
      createDeviceSource: devices.createDeviceSource,
      createSimulatedVehicleTelemetry: telemetry.createSimulatedVehicleTelemetry,
      pack: officePack,
      materials: officeMaterials,
      robotOperations: operations?.default ?? null,
    };
  } catch {
    showDetail("The office did not load. Check the connection and try the door again.");
    return null;
  }
}

/**
 * How much texture a studio may draw, decided by the device rather than by a
 * constant.
 *
 * This said `"high"` for every visitor, which meant `materials.ts` had a fully
 * implemented `low` and `medium` path — different roughness handling, a cheaper
 * glazing fallback, and 256px textures instead of 512 — that **no caller could
 * ever reach**. The documented mobile escape hatch had never actually existed,
 * and a phone was being handed desktop-grade texture memory for a canvas at
 * 1.5× pixel ratio.
 *
 * `medium` rather than `low` on a handheld: `low` drops to flat Lambert with no
 * maps at all, which is the setting that makes an office open on an integrated
 * GPU and is far more than a modern phone needs. `medium` is the same physically
 * shaded materials at half the texture resolution, which is the actual
 * difference a phone can feel.
 *
 * `deviceProfile()` is the one definition of "phone" on this page — the pixel
 * ratio cap, the shadow map size and now the texture budget all read it, so they
 * cannot disagree about what a phone is.
 */
function officeMaterialQuality(): MaterialQuality {
  return deviceProfile().handheld ? "medium" : "high";
}

/** The office's name, for the two bits of chrome that say where you are. */
function officeName(): string {
  return officePack?.name ?? "Spaces";
}

/**
 * Who is in the building, asked for once per entry.
 *
 * Only at full depth, and that is not an optimisation: `createOfficeScene` at
 * `"public"` builds no presence layer at all, so there is nothing to populate
 * and the request would be one the session is going to be refused anyway.
 *
 * The fallback is `markers`' fallback, one room in. An API that answers is
 * believed, including when it answers with nobody — an office where everyone has
 * gone home is a real fact about an office, and overwriting it with invented
 * people to liven up the demo is the one thing this file must never do.
 */
function watchOccupancy() {
  stopWatchingOccupancy();
  const scene = office;
  if (!scene || scene.depth !== "full") return;
  presenceWatch = tera.watchPresence(officePack?.id ?? "lumbridge-hq", (body) => {
    livePresence = body?.people ?? null;
    presenceIsSample = false;
    // The scene may have been torn down between a request going out and coming
    // back — a city switch disposes the office — and writing people into a
    // disposed layer is a use-after-free with a friendly name. The watch's own
    // `stop()` already drops late answers; this is the belt to that brace,
    // because the office can also be *replaced* (signing in rebuilds it at full
    // depth) without the watch having been stopped in between.
    if (office !== scene) return;
    applyPresence();
    renderChrome();
  });
}

function stopWatchingOccupancy() {
  presenceWatch?.stop();
  presenceWatch = null;
  // Or the next building — or the next entry into this one — opens wearing the
  // previous deployment's roster while its own request is still in the air.
  livePresence = null;
}

/**
 * Put whoever is in the building on screen.
 *
 * The live roster if there is one, and otherwise the sample one **as it would be
 * at the instant being rendered**. That second half is what stops the office
 * showing a full complement of seated people at one in the morning, under house
 * lights that came on because the sun is down — which was the least believable
 * thing left in the room once the clock became real.
 *
 * A live answer always wins. An API that says the building is empty is telling
 * the truth about the building, and dressing it with invented people would be
 * the one lie this whole layer is arranged to avoid.
 */
function applyPresence() {
  if (!office || office.depth !== "full") return;
  const fallback = samplePresenceForOfficeAt(officePack?.id ?? officeId, currentInstant());
  const people = livePresence ?? fallback;
  presenceIsSample = livePresence === null && fallback.length > 0;
  office.setPresence(people);
  officePlan?.setPresence(people);
}

// ---- Chrome ---------------------------------------------------------------
//
// Six DOM handles and no more. Everything else on this page belongs to
// `ui/mount.ts`, which is the only module in the product that writes to it —
// these six are the hosts of things `mount` deliberately does not own: two
// widgets built by the engine, one overlay built by `profile/`, one by
// `media/`, and one indicator built by `realtime/`.

const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
const presenceHost = document.querySelector<HTMLElement>("#presence-host");
const webcamFaceIndicator = document.querySelector<HTMLElement>("#webcam-face-indicator");
const profileOverlay = document.querySelector<HTMLElement>("#profile-overlay");
const screensOverlay = document.querySelector<HTMLElement>("#screens-overlay");
let profileEditor: ProfileEditor | null = null;
let webcamFacePanel: WebcamFacePanel | null = null;
let webcamFaceTexture: WebcamFaceTextureAdapter | null = null;
let webcamFaceConsent = createWebcamFaceConsent();
let webcamCapture: WebcamCaptureController | null = null;
/** Whether hosted presence has a widget on screen; the chrome makes room for it. */
let presenceMounted = false;

function availableControlModes() {
  const route = city?.current();
  return {
    insideOffice: inside,
    drive: !inside && city?.vehicleState() !== null &&
      (route === "la-sf-us-101" || route === "la-sf-i-5"),
    actor: !inside && city?.actorState() !== null && access.subject !== null,
    aircraft: !inside && city?.aircraftState() !== null,
    officeWalk: inside && office?.walker !== null && office?.walker !== undefined,
  };
}

/**
 * The same availability, as the list the chrome takes.
 *
 * Two shapes for one fact is a smell and is the lesser one here:
 * `transitionControlMode` was written against the record and is tested against
 * it, and `chromeState` takes a list because a list is what a dock renders. This
 * is the one conversion, in one place, rather than each caller deriving its own.
 */
function availableModes(): ControlMode[] {
  const can = availableControlModes();
  const modes: ControlMode[] = [inside ? "office-overview" : "overview"];
  if (can.drive) modes.push("drive");
  if (can.actor) modes.push("actor");
  if (can.aircraft) modes.push("aircraft");
  if (can.officeWalk) modes.push("office-walk");
  return modes;
}

function clearPublishedPlayInput(): void {
  playInput.clearAll();
  city?.setVehicleActions({});
  city?.setActorActions({});
  city?.setAircraftActions({});
  office?.walker?.setAction({ x: 0, z: 0 });
}

function adoptCityControlMode(mode: ReturnType<typeof cityControlMode>): void {
  if (inside) return;
  const previous = controlModeState.mode;
  if (controlModeState.mode !== mode) {
    clearPublishedPlayInput();
    controlModeState = { mode, revision: controlModeState.revision + 1 };
  }
  if (previous === "overview" && mode !== "overview") closePanelForPlay();
  renderChrome();
}

/** One transaction updates Journey, simulation ownership, input and chrome. */
function requestControlMode(requested: ControlMode): boolean {
  const transition = transitionControlMode(controlModeState, requested, availableControlModes());
  if (transition.changed) clearPublishedPlayInput();
  controlModeState = transition.state;
  const next = transition.state.mode;

  if (inside) {
    city?.setControlMode("overview");
    const walking = next === "office-walk";
    office?.walker?.setActive(walking);
    if (!walking) office?.walker?.setAction({ x: 0, z: 0 });
  } else {
    office?.walker?.setActive(false);
    city?.setControlMode(cityControlMode(next));
  }

  if (next === "drive") {
    const routeId = city?.current();
    if (routeId === "la-sf-us-101" || routeId === "la-sf-i-5") {
      dispatchJourney({ type: "set-mode", mode: "play" });
      if (journey.route?.routeId !== routeId) {
        dispatchJourney({ type: "select-route", routeId, direction: 1 });
      }
      if (!journey.vehicle) dispatchJourney({ type: "enter-vehicle", vehicleId: "model-x-black" });
    }
  } else if (journey.vehicle) {
    dispatchJourney({ type: "exit-vehicle" });
  }
  if (next !== "overview" && next !== "office-overview") {
    dispatchJourney({ type: "set-mode", mode: "play" });
  } else if (!journey.vehicle) {
    dispatchJourney({ type: "set-mode", mode: "observe" });
  }
  if (
    transition.changed &&
    (transition.previous === "overview" || transition.previous === "office-overview") &&
    next !== "overview" && next !== "office-overview"
  ) {
    closePanelForPlay();
  }

  renderChrome();
  publishPlayActions();
  return transition.accepted;
}

/** One authored sentence in the detail card, or nothing. */
function showDetail(text: string | null) {
  detail = text === null ? null : { kind: "text", text };
  renderChrome();
}

/**
 * One aeroplane in the detail card, for anybody at all.
 *
 * Owner decision 2: the flight card is part of the demo an anonymous visitor
 * gets, and there is nothing here an account could grant — an ADS-B position is
 * broadcast unencrypted by the aircraft to anybody with a receiver. What the
 * *deployment* knows and the engine does not is provenance, which is why the
 * live source is asked first: `TrafficSource.detail` carries the transponder
 * address off the wire body and the credit line the feed asks to be shown
 * beside its data, and neither is recoverable from the position alone.
 *
 * The fallback is not a degraded case. It is the zero-config clone, the
 * California corridor board and every aircraft the godmode dial invented, and
 * for all three the honest answer is a card that says "simulated track" rather
 * than a hex address nobody transmitted. `aircraftDetail` defaults `observed` to
 * false precisely so that forgetting to say so cannot produce the other claim.
 */
function showAircraftDetail(aircraft: Aircraft | null): void {
  if (aircraft === null) {
    if (detail?.kind === "aircraft") showDetail(null);
    return;
  }
  const centre = CITIES.find((c) => c.id === cityId)?.city.center;
  const resolved = cityFlights?.detail(aircraft.id)
    ?? aircraftDetail(aircraft, { observed: false, ...(centre ? { from: centre } : {}) });
  const card: AircraftDetailInput = {
    // The hex when the feed gave one; the source's own id otherwise, which the
    // card only ever shows for a track it has already labelled as simulated.
    id: resolved.icao24 ?? resolved.id,
    callsign: resolved.callsign,
    lat: resolved.lat,
    lng: resolved.lng,
    altitude: resolved.altitudeM,
    heading: resolved.headingDeg,
    // The four the feed carries and the simulator does not. All four are `null`
    // for a synthetic track and for a server one version behind, and the card
    // drops the row rather than printing "unknown" — so there is no degraded
    // state to handle here, only a shorter card.
    type: resolved.type,
    registration: resolved.registration,
    groundSpeedKt: resolved.groundSpeedKt,
    verticalRateFpm: resolved.verticalRateFpm,
    synthetic: !resolved.observed,
    attribution: resolved.attribution.length > 0 ? resolved.attribution.join(" · ") : null,
  };
  detail = { kind: "aircraft", aircraft: card };
  renderChrome();
}

/** The board strip: worlds outside a building, buildings inside one. */
function boardTabs() {
  return inside
    ? OFFICES.map((o) => ({ id: o.id, label: o.label, status: o.status }))
    : CITIES.map((c) => ({ id: c.id, label: c.label, status: null }));
}

/**
 * Whether the three-board tab strip is on screen.
 *
 * Off for everybody by default, which is the point of this round: a strip of
 * three tabs naming three *modes* is the most direct statement the interface can
 * make that this is three products rather than one place, and the places list is
 * what replaced it. It is kept rather than deleted because it is still the only
 * control that addresses a board by id, which is exactly what somebody working
 * on a board wants — so `?boards=1` brings it back, and godmode has it always.
 *
 * Inside a building the same strip is the *studio* picker and there is nothing
 * to hide: three studios are three places, and the list below them is the room's
 * viewpoints rather than a second tour.
 */
const BOARD_TABS_FORCED = new URLSearchParams(location.search).get("boards") === "1";

function boardTabsVisible(): boolean {
  return inside || BOARD_TABS_FORCED || access.can.debug;
}

/**
 * The ladder, in the shape the left column takes it.
 *
 * Every rung of all three boards, every time — the list does not change when the
 * board does, because that is the whole idea. What changes is which row is lit.
 */
function placeRows() {
  return PLACES.map((rung) => ({
    key: rung.key,
    id: rung.id,
    board: rung.board,
    region: REGION_LABELS[rung.region],
    label: rung.label,
    standoffM: rung.standoffM,
    description: rung.description,
  }));
}

/**
 * Whatever the mode's controller is reporting, in the shape the HUD formats.
 *
 * Keyed off `playHudKindFor` rather than off a second `switch` on the mode, so
 * the two orbit states are excluded once, in the module that also decides the
 * card's visibility. `null` whenever the controller has nothing to say — a
 * scene that has not built an aircraft cannot report on one — and the HUD is
 * hidden rather than blank in that case.
 */
function playTelemetry(): PlayTelemetry | null {
  switch (playHudKindFor(controlModeState.mode)) {
    case "drive": {
      const state = city?.vehicleState();
      if (!state) return null;
      return {
        kind: "drive",
        speedMps: state.speedMps,
        roadName: state.roadName,
        driveMode: state.mode,
        progress: state.progress,
        camera: city?.vehicleCamera() ?? "chase",
        guardrailContact: state.guardrailContact,
        collisionRisk: state.collisionRisk,
      };
    }
    case "actor": {
      const state = city?.actorState();
      if (!state) return null;
      return {
        kind: "actor",
        actor: state.kind,
        speedMps: state.speedMps,
        altitudeM: Math.max(0, state.y),
        distanceM: state.distanceM,
        pose: state.kind === "crow" ? state.crowPose : state.mode,
        flightEnergy: state.flightEnergy,
        displayName: state.identity.displayName,
        atAltitudeBound: state.altitudeBoundContact !== "none",
      };
    }
    case "aircraft": {
      const state = city?.aircraftState();
      if (!state) return null;
      return {
        kind: "aircraft",
        speedMps: state.speedMps,
        altitudeM: state.altitudeM,
        flightMode: state.mode,
        batteryWh: state.batteryWh,
        batteryCapacityWh: 54_000,
        stalled: state.stalled,
        hardLanding: state.hardLanding,
        envelopeContact: state.envelopeContact,
      };
    }
    case "office-walk": {
      const state = office?.walker?.state();
      if (!state) return null;
      return {
        kind: "office-walk",
        officeLabel: officeName(),
        distanceM: state.distance,
        x: state.position.x,
        z: state.position.z,
      };
    }
    default:
      return null;
  }
}

/** The body under control, for the labels that name it. */
function chromeActor() {
  if (inside) {
    const walker = office?.walker?.state();
    if (!walker) return {};
    return { kind: walker.actor === "anonymous-dog" ? ("dog" as const) : ("humanoid" as const) };
  }
  if (controlModeState.mode === "aircraft") return { flying: true };
  const state = city?.actorState();
  if (!state) return {};
  return { kind: state.kind, flying: state.mode === "flight" };
}

/**
 * Everybody who is owed a credit for what is currently on screen.
 *
 * MET Norway and Open-Meteo publish under CC BY 4.0 and the ADS-B feeds ask to
 * be named for the positions; all of it arrived, and until the `?` sheet started
 * printing it, all of it was read by nobody — a licence obligation plumbed to
 * within one line of being met. The weather override is deliberately excluded:
 * a sky somebody typed is not MET Norway's sky and must not be attributed to
 * them.
 */
function creditLines(): string[] {
  const lines: string[] = [];
  if (weatherOverride === null) lines.push(...(weatherWatch?.current().attribution ?? []));
  lines.push(...(cityFlights?.attribution() ?? []));
  /**
   * The three newest feeds, each named by whatever answered for it.
   *
   * The modelled harbour is in this list for the opposite of the usual reason:
   * its attribution line exists to say that nothing out there is being claimed —
   * "not an observation of any vessel" — and the sheet that prints licence
   * obligations is the right place for a disclaimer of one. Radar and birds
   * carry NWS and BirdCast the day a projection is configured, and carry nothing
   * before that, which is why this reads the body rather than a constant.
   */
  if (vesselBounds !== null) lines.push(...(vesselsBody?.attribution ?? []));
  if (boardCarriesSky) {
    lines.push(...(radarBody?.attribution ?? []));
    lines.push(...(birdsBody?.attribution ?? []));
  }
  return lines.filter((line) => line.trim() !== "");
}

/**
 * Everything the interface needs to know, as one plain object.
 *
 * This function is the whole of the coupling between the application and its
 * chrome. It reads module state and returns data; it writes nothing and it
 * decides nothing — every "should this be visible" question in the product is
 * answered by `chromeState`, and every answer is applied by `mount`. Adding a
 * piece of chrome is a field here, a decision there and a write in the applier,
 * in that order, and never a fortieth `element.hidden =` in this file.
 */
function chromeInputs(): ChromeInputs {
  const entry = CITIES.find((c) => c.id === cityId);
  const selectedOffice = OFFICES.find((o) => o.id === officeId) ?? OFFICES[0];
  const mediaSurfaces = inside && office?.depth === "full" ? office.listMediaSurfaces() : [];
  const robotActivity = inside ? office?.robotActivityInfo() ?? null : null;
  const views: View[] = inside && office ? office.views : city?.chapters ?? [];
  const activeViewId = inside && office ? office.current() : city?.current() ?? null;

  return {
    mode: controlModeState.mode,
    available: availableModes(),
    access: {
      tier: access.tier,
      signInUrl: access.signInUrl,
      subject: access.subject,
      displayName: localProfile?.displayName ?? null,
      hasProfile: localProfile !== null,
    },
    inside,
    officeDepth: inside ? office?.depth ?? null : null,
    viewport: {
      width: window.innerWidth,
      height: window.innerHeight,
      // The class is set by the first touch this page ever sees and never
      // removed, because a laptop with a touchscreen has both and the one the
      // person actually used is the one worth believing.
      coarsePointer:
        document.body.classList.contains("touch-capable") ||
        window.matchMedia?.("(pointer: coarse)").matches === true,
    },
    feeds: {
      markers: liveData,
      // An override is a sky somebody invented, so it retires the claim for as
      // long as it is up: the label is about what is on screen, not about what
      // the deployment could have shown.
      weather: weatherOverride === null && (weatherWatch?.current().live ?? false),
      flights: cityFlights?.live() ?? false,
      weatherOverridden: weatherOverride !== null,
      sampleOccupancy: presenceIsSample,
      credits: creditLines(),
    },
    degraded: access.degraded ?? [],
    devices: inside ? office?.devices ?? [] : [],
    firstVisit,
    panelOpen,
    planOpen,

    board: {
      cityId,
      cityLabel: entry?.city.name ?? "",
      officeId,
      officeLabel: inside ? officeName() : selectedOffice?.label ?? "the studio",
      officeStatus: selectedOffice?.status ?? "building",
      isCalifornia: cityId === "california",
    },
    actor: chromeActor(),
    office: {
      mediaSurfaceCount: mediaSurfaces.length,
      mediaSurfaceActiveCount: mediaSurfaces.filter((surface) => surface.bound).length,
      robotDisclosure: robotActivity?.disclosure ?? null,
      // Recomputed here rather than cached beside the promotion, because the
      // load is a function of *where the building is* as well as what is
      // burning, and this is the one place that knows both.
      smokeNote: officeSmokeNote(),
      walkable: office?.walker != null,
    },
    cameraLive: webcamCapture?.status() === "active",
    presenceVisible: presenceMounted,
    boards: boardTabs(),
    boardsVisible: boardTabsVisible(),
    places: inside ? [] : placeRows(),
    activePlaceKey: activePlace()?.key ?? null,
    views: views.map((view) => ({
      id: view.id,
      number: view.number ?? null,
      shortLabel: view.shortLabel,
      ...(view.description === undefined ? {} : { description: view.description }),
    })),
    activeViewId,
    telemetry: playTelemetry(),
    detail,
    clockLabel,
  };
}

/**
 * Draw the interface. One call, and it replaces forty.
 *
 * Cheap enough to run from the frame pump — `chromeState` is pure arithmetic and
 * string building, and the applier compares before it writes and rebuilds a
 * subtree only when that subtree's signature moves — but it is still called on
 * *changes* rather than unconditionally, and the pump only joins in while a body
 * is under control. See `pumpChrome`.
 *
 * The two plan widgets are updated here rather than inside the applier because
 * neither is chrome: they are canvases the engine draws, holding a live camera,
 * and `mount.ts` is deliberately ignorant of THREE.
 */
function renderChrome(): void {
  // The one place the mode the app *thinks* it is in is reconciled with the mode
  // the scene is actually in. A scene can change it on its own — a chapter that
  // is a driving route puts the city into `drive` — and a dock that disagreed
  // with the camera was the oldest bug in this file.
  if (city) {
    const walking = inside && (office?.walker?.active() ?? false);
    const actual: ControlMode = inside
      ? walking ? "office-walk" : "office-overview"
      : city.controlMode();
    if (controlModeState.mode !== actual) {
      controlModeState = { mode: actual, revision: controlModeState.revision + 1 };
    }
  }
  chrome?.apply(chromeState(chromeInputs()));
  showFireSection();
  showBoardNotes();
  // Each plan rings the entry its own legend is showing as current. Both are
  // updated whichever one is mounted, so the hidden one is already right when it
  // comes back rather than correcting itself a frame after it appears.
  if (city) minimap?.setChapters(city.chapters, city.current());
  officePlan?.setActiveView(office?.current() ?? null);
}

/**
 * Walk out of one building and into another.
 *
 * A full teardown and rebuild rather than a swap, because everything an office
 * scene holds is derived from its pack: the shell, the plan panel, the camera
 * limits, the horizon drop, the light rig, the hardware on the desks and the car
 * on the apron. The expensive part — the texture registry — is deliberately
 * *not* rebuilt, which is the same trick that makes signing in cheap:
 * `officeMaterials` outlives every scene that borrows it, so a switch costs
 * geometry and not the thing that draws the wood grain.
 */
async function switchOffice(id: string) {
  // `entering` is the same guard `toggleOffice` uses, and this has to share it.
  // Without it, two clicks inside the loading window build two office scenes and
  // the first is parked on the stage with nothing holding a reference to it —
  // a whole `OfficeScene`, its geometry and its plan panel, leaked per click.
  if (entering || id === officeId || !OFFICES.some((o) => o.id === id)) return;
  const previous = officeId;
  officeId = id;

  // The roster, screen session, hardware feed, plan and scene all belong to the
  // building being left.
  disposeLoadedOffice();

  entering = true;
  try {
    await building("Opening the door…", () => enterOffice());
  } finally {
    entering = false;
  }

  /**
   * The old room is already gone by the time we find out whether the new one
   * arrived, and a failed chunk fetch is a real case — `loadOffice` says so on
   * the card and returns null.
   *
   * So there is no room on the stage and `inside` still claims there is. Put the
   * viewer back in the city rather than in an empty scene, and put the door back
   * on the building they came from, or the picker is wedged pointing at an
   * office that will not open.
   */
  if (office || !city) return;
  officeId = previous;
  inside = false;
  city.stage.setScene(city.stageScene);
  showPlan();
  renderChrome();
}

// ---- Hosted realtime presence --------------------------------------------

function realtimeInterest(): InterestCell {
  if (inside) return { kind: "office", officeId };
  if (cityId === "california") return { kind: "california-tile", x: 0, y: 0, level: 0 };
  return { kind: "city", cityId: cityId === "socal" ? "socal" : "bay-area" };
}

function degrees(radians: number): number {
  return ((radians * 180 / Math.PI + 540) % 360) - 180;
}

function realtimePose(): EntityPoseSnapshot | null {
  if (access.subject === null) return null;
  const timestampMs = Date.now();

  if (inside) {
    const walker = office?.walker;
    if (!walker) return null;
    const state = walker.state();
    const headingDeg = -degrees(Math.atan2(-state.facing.x, -state.facing.z));
    return {
      entity: "actor",
      actorId: realtimeActorId,
      kind: state.actor === "anonymous-dog" ? "dog" : "humanoid",
      sequence: 0,
      timestampMs,
      pose: {
        space: "local",
        cell: { kind: "office", officeId },
        xM: state.position.x,
        yM: office?.plan.level(state.levelId)?.floorY ?? 0,
        zM: state.position.z,
        headingDeg,
        pitchDeg: 0,
      },
      velocity: {
        xMps: state.action.x * 1.6,
        yMps: 0,
        zMps: state.action.z * 1.6,
        yawDegPerSec: 0,
      },
    };
  }

  const aircraft = city?.aircraftActive() ? city.aircraftState() : null;
  if (aircraft) {
    return createAircraftPoseSnapshot(
      { aircraftId: realtimeAircraftId, pilotActorId: realtimeActorId },
      aircraft,
      0,
      timestampMs,
    );
  }

  const vehicle = journey.vehicle !== null ? city?.vehicleState() : null;
  if (vehicle) {
    const heading = vehicle.headingDeg * Math.PI / 180;
    return {
      entity: "vehicle",
      vehicleId: realtimeVehicleId,
      kind: "model-x",
      driverActorId: realtimeActorId,
      sequence: 0,
      timestampMs,
      pose: {
        space: "geographic",
        lat: vehicle.lat,
        lng: vehicle.lng,
        altitudeM: 0,
        headingDeg: vehicle.headingDeg,
        pitchDeg: 0,
      },
      velocity: {
        xMps: Math.sin(heading) * vehicle.speedMps,
        yMps: 0,
        zMps: Math.cos(heading) * vehicle.speedMps,
        yawDegPerSec: 0,
      },
      steering: vehicle.steering,
      wheelRadians: vehicle.wheelRadians,
    };
  }

  const actor = city?.actorState();
  if (!actor || !city) return null;
  const headingDeg = -degrees(actor.yaw);
  const velocity = {
    xMps: -Math.sin(actor.yaw) * actor.speedMps,
    yMps: actor.verticalSpeedMps,
    // Geographic velocity is east/up/north; scene +Z is south.
    zMps: Math.cos(actor.yaw) * actor.speedMps,
    yawDegPerSec: 0,
  };
  const origin = cityId === "california" ? { lat: 35.5, lng: -119.5 } : city.world.city.center;
  const [originX, originZ] = city.world.project(origin.lat, origin.lng);
  const [lat, lng] = city.world.unproject(
    originX + actor.x / city.world.metresPerUnit,
    originZ + actor.z / city.world.metresPerUnit,
  );
  return {
    entity: "actor",
    actorId: realtimeActorId,
    kind: actor.kind,
    sequence: 0,
    timestampMs,
    pose: {
      space: "geographic",
      lat,
      lng,
      altitudeM: actor.y,
      headingDeg,
      pitchDeg: degrees(actor.pitch),
    },
    velocity,
  };
}

function ownRealtimeEntity(snapshot: EntityPoseSnapshot): boolean {
  if (snapshot.entity === "actor") return snapshot.actorId === realtimeClient?.state().actorId;
  if (snapshot.entity === "vehicle") {
    return snapshot.driverActorId === realtimeClient?.state().actorId || snapshot.vehicleId === realtimeVehicleId;
  }
  return snapshot.pilotActorId === realtimeClient?.state().actorId ||
    snapshot.aircraftId === realtimeAircraftId;
}

function clearRealtimePeers(): void {
  city?.clearRemoteEntities();
  office?.clearRemoteEntities();
  updatePresenceIndicator();
}

function applyRealtimeMessage(message: ServerRealtimeMessage): void {
  if (message.type === "membership-revoked") {
    stopWebcamFace(true);
    clearRealtimePeers();
    return;
  }
  const updates = message.type === "pose-delta"
    ? message.updates
    : message.type === "join-grant"
      ? message.initial
      : message.type === "resume-grant"
        ? message.snapshot
        : [];
  const target = inside ? office : city;
  for (const snapshot of updates) {
    if (!ownRealtimeEntity(snapshot)) target?.upsertRemoteSnapshot(snapshot);
  }
  if (message.type === "pose-delta") {
    for (const id of message.removedEntityIds) {
      if (
        id !== `actor:${realtimeClient?.state().actorId}` &&
        id !== `vehicle:${realtimeVehicleId}` &&
        id !== `aircraft:${realtimeAircraftId}`
      ) {
        target?.removeRemoteEntity(id);
      }
    }
  }
  updatePresenceIndicator();
}

function updatePresenceIndicator(state = realtimeClient?.state()): void {
  if (!presenceIndicator || access.subject === null) return;
  const connection: "connecting" | "live" | "reconnecting" | "offline" =
    state?.status === "streaming" ? "live"
      : state?.status === "reconnecting" ? "reconnecting"
        : state?.status === "left" || state?.status === "disposed" ? "offline"
          : "connecting";
  const nearbyPeerCount = connection === "live"
    ? (inside ? office?.remoteEntityCount() : city?.remoteEntityCount()) ?? 0
    : 0;
  presenceIndicator.update({ signedIn: true, connection, nearbyPeerCount });
}

async function joinRealtimePresence(operation: number): Promise<void> {
  const client = realtimeClient;
  const pose = realtimePose();
  if (!client || !pose) return;
  try {
    const grant = await client.join(realtimeInterest(), pose);
    if (operation !== realtimeOperation) return;
    applyRealtimeMessage(grant);
  } catch {
    if (operation === realtimeOperation) {
      presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
    }
  }
}

function moveRealtimePresence(): void {
  clearRealtimePeers();
  const client = realtimeClient;
  if (!client || access.subject === null) return;
  const operation = ++realtimeOperation;
  if (client.state().sessionId === null) {
    void joinRealtimePresence(operation);
    return;
  }
  updatePresenceIndicator({ ...client.state(), status: "reconnecting" });
  void client.moveInterest(realtimeInterest())
    .then((grant) => {
      if (operation === realtimeOperation) applyRealtimeMessage(grant);
    })
    .catch(() => {
      if (operation === realtimeOperation) {
        presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
      }
    });
}

function publishRealtimePresence(now: number): void {
  if (!realtimeClient || now - lastRealtimePublishAt < 100) return;
  const pose = realtimePose();
  if (!pose || realtimeClient.state().sessionId === null) return;
  lastRealtimePublishAt = now;
  try {
    realtimeClient.publishPose(pose);
  } catch {
    presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 });
  }
}

async function initializeRealtimePresence(): Promise<void> {
  if (!realtimePageActive || access.subject === null || !presenceHost || realtimeClient) return;
  const [{ createRealtimeClient }, { createPresenceIndicator }] = await Promise.all([
    import("./realtime/client.ts"),
    import("./realtime/presenceIndicator.ts"),
  ]);
  // `pagehide` can overtake the lazy chunks. Never construct a fresh client
  // after the page has already run its terminal cleanup.
  if (!realtimePageActive || access.subject === null || realtimeClient) return;
  presenceHost.hidden = false;
  // The class that makes room for it is `chromeState`'s to add, from
  // `presenceVisible` — one place decides what the top-right column contains.
  presenceMounted = true;
  presenceIndicator = createPresenceIndicator({
    container: presenceHost,
    onRetry: () => moveRealtimePresence(),
  });
  presenceIndicator.update({ signedIn: true, connection: "connecting", nearbyPeerCount: 0 });
  realtimeClient = createRealtimeClient({
    actorId: realtimeActorId,
    authenticatedFetch: authFetch,
    sendIntervalMs: 100,
  });
  stopRealtimeSubscription = realtimeClient.subscribe({
    onMessage: applyRealtimeMessage,
    onStateChange: updatePresenceIndicator,
    onError: () => presenceIndicator?.update({ signedIn: true, connection: "offline", nearbyPeerCount: 0 }),
  });
  moveRealtimePresence();
}

window.addEventListener("pagehide", () => {
  realtimePageActive = false;
  stopWebcamFace(true);
  disposeOfficeScreenUi();
  realtimeOperation += 1;
  stopRealtimeSubscription?.();
  stopRealtimeSubscription = null;
  clearRealtimePeers();
  // Page teardown cannot await the leave round trip. Contain a rotated-token
  // or offline failure so navigation never produces an unhandled rejection;
  // the server's disconnect grace still removes the ephemeral session.
  void realtimeClient?.dispose().catch(() => undefined);
  realtimeClient = null;
  presenceIndicator?.dispose();
  presenceIndicator = null;
  // The one thing on this page that owns GPU memory neither scene created. It
  // is disposed by whoever owns the `Stage`, which is this file, and never by a
  // scene — the two kinds of environment are shared between every city and every
  // office that ever existed on this page.
  environment.dispose();
});

function applyProfilePreview(profile: LocalProfile): void {
  if (access.subject === null) return;
  if (inside) {
    const appearance = resolveHumanoidAppearance(profile.appearance);
    office?.walker?.setAppearance({
      kind: "humanoid",
      skinTone: appearance.skinTone,
      outfitColor: appearance.outfitColor,
      accentColor: appearance.accentColor,
      hairColor: appearance.hairColor,
      bodyShape: appearance.bodyShape,
    });
  } else {
    city?.setActorIdentity(signedInActorIdentity(profile));
  }
}

function cameraSupported(): boolean {
  return typeof navigator.mediaDevices?.getUserMedia === "function";
}

/** Attach the one ephemeral texture to whichever signed-in humanoid is current. */
function attachCurrentWebcamFace(): void {
  const texture = webcamFaceTexture?.texture() ?? null;
  if (!texture || access.subject === null) return;
  city?.clearActorFaceTexture();
  office?.walker?.clearFaceTexture();
  if (inside) office?.walker?.attachFaceTexture(texture);
  else city?.attachActorFaceTexture(texture);
}

/**
 * Stop app-owned capture. The texture adapter deliberately never owns tracks,
 * so this boundary must stop every one before dropping the stream reference.
 */
function stopWebcamFace(revoke = false): void {
  city?.clearActorFaceTexture();
  office?.walker?.clearFaceTexture();
  webcamFaceTexture?.clear();
  webcamCapture?.stop();
  if (revoke) webcamFaceConsent.revoke();
  else webcamFaceConsent.stop();
  webcamFaceTexture?.sync();
  webcamFacePanel?.update(cameraSupported() ? "off" : "unsupported");
}

async function startWebcamFace(): Promise<void> {
  if (access.subject === null || !localProfile || !cameraSupported() || webcamCapture?.status() === "active" ||
      webcamCapture?.status() === "requesting") return;
  webcamFacePanel?.update("requesting");
  webcamFaceConsent.requestStart();
  webcamCapture ??= createWebcamCapture({
    acquire: () => navigator.mediaDevices.getUserMedia({
      audio: false,
      video: { facingMode: "user", width: { ideal: 640 }, height: { ideal: 640 } },
    }),
    createVideo: () => {
      const video = document.createElement("video");
      video.muted = true;
      video.playsInline = true;
      video.autoplay = true;
      video.hidden = true;
      document.body.append(video);
      return video;
    },
  });
  let binding: Awaited<ReturnType<WebcamCaptureController["start"]>>;
  try {
    binding = await webcamCapture.start();
  } catch (error) {
    webcamFaceConsent.stop();
    const denied = error instanceof DOMException && (error.name === "NotAllowedError" || error.name === "SecurityError");
    webcamFacePanel?.update(
      "error",
      denied ? "Camera permission was denied. Nothing was captured." : "Camera could not start. Check the device and try again.",
    );
    return;
  }
  // Null means Stop/pagehide overtook an asynchronous permission or play step.
  if (!binding) return;
  if (access.subject === null) {
    stopWebcamFace(true);
    return;
  }
  try {
    const consent = webcamFaceConsent.start(true);
    if (!consent.accepted) throw new Error("camera consent was no longer active");
    webcamFaceTexture ??= createWebcamFaceTexture({ consent: webcamFaceConsent });
    webcamFaceTexture.bind(binding);
    for (const track of binding.stream.getVideoTracks()) {
      track.addEventListener("ended", () => {
        if (webcamCapture?.binding()?.stream === binding.stream) stopWebcamFace();
      }, { once: true });
    }
    attachCurrentWebcamFace();
    webcamFacePanel?.update("active");
    // Native permission UI can restore focus to the page after the promise
    // resolves. Put it back inside the still-open modal on the next frame.
    requestAnimationFrame(() => {
      if (profileEditor?.state().open) webcamFacePanel?.focusStop();
    });
  } catch {
    stopWebcamFace();
    webcamFacePanel?.update("error", "Camera preview could not start. Nothing was retained.");
  }
}

function ensureProfileEditor(): ProfileEditor | null {
  if (profileEditor) return profileEditor;
  if (!profileOverlay || !localProfile || access.subject === null) return null;
  profileEditor = createProfileEditor({
    container: profileOverlay,
    profile: localProfile,
    identityId: access.subject,
    onPreview: applyProfilePreview,
    onSave(profile) {
      localProfile = profile;
      saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, profile);
      dispatchJourney({
        type: "sign-in-actor-swap",
        actor: {
          id: access.subject ?? "anonymous",
          kind: "humanoid",
          signedIn: true,
          profile: { displayName: profile.displayName, color: humanoidAppearance()?.accentColor },
        },
      });
      city?.setActorIdentity(signedInActorIdentity(profile));
      applyProfilePreview(profile);
      renderChrome();
      profileOverlay.hidden = true;
    },
    onCancel() {
      profileOverlay.hidden = true;
    },
  });
  profileEditor.root.addEventListener("keydown", (event) => event.stopPropagation());
  if (webcamFaceIndicator) {
    const host = document.createElement("div");
    profileEditor.root.insertBefore(host, profileEditor.root.querySelector("form")?.nextSibling ?? null);
    webcamFacePanel = createWebcamFacePanel({
      container: host,
      indicatorContainer: webcamFaceIndicator,
      supported: cameraSupported(),
      onStart: startWebcamFace,
      onStop: () => stopWebcamFace(),
    });
    if (webcamCapture?.status() === "active") webcamFacePanel.update("active");
    profileEditor.registerFocusables(webcamFacePanel.focusables);
  }
  return profileEditor;
}

function openProfileEditor(): void {
  if (!localProfile || !profileOverlay) return;
  const editor = ensureProfileEditor();
  if (!editor) return;
  editor.update(localProfile);
  profileOverlay.hidden = false;
  editor.open();
}

function screenBinding(surface: MediaSurfaceDescriptor) {
  return {
    officeId: surface.officeId,
    levelId: surface.levelId,
    roomId: surface.roomId,
    screenId: surface.screenId,
  };
}

function remotePanelStatus(screenId: string, status: OfficeScreenRemoteStatus): void {
  officeScreenPanel?.setRemoteStatus(screenId, status);
  renderChrome();
}

function isRemoteAuthorizationError(error: unknown): boolean {
  return error instanceof Error && /\((?:401|403)\)/.test(error.message);
}

function releaseRemoteViewer(
  status: OfficeScreenRemoteStatus = "off",
  action: "revoke" | "dispose" = "revoke",
): void {
  const viewing = remoteViewedScreen;
  const pendingScreenId = remoteViewerRequestScreenId;
  remoteMediaOperation += 1;
  remoteViewerRequestScreenId = null;
  if (pendingScreenId && pendingScreenId !== viewing?.screenId) {
    remotePanelStatus(pendingScreenId, status);
  }
  if (!viewing) return;
  remoteViewedScreen = null;
  office?.clearMediaSurface(viewing.screenId);
  // `OfficeScreenPanel` owns defensive descriptor snapshots rather than a
  // live view of OfficeScene. Refresh after clear just as the bind path does,
  // otherwise the panel can keep saying "Media active" after remote teardown.
  officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
  viewing.texture.dispose();
  viewing.video.pause();
  viewing.video.srcObject = null;
  remotePanelStatus(viewing.screenId, status);
  const finish = action === "revoke" ? viewing.remote.revoke() : viewing.remote.dispose();
  void finish.catch(() => undefined);
}

function syncRemoteViewerState(
  viewing: NonNullable<typeof remoteViewedScreen>,
  state: RemoteMediaState,
): void {
  if (remoteViewedScreen !== viewing) return;
  if (state.status === "reconnecting") remotePanelStatus(viewing.screenId, "reconnecting");
  else if (state.status === "connecting") remotePanelStatus(viewing.screenId, "connecting");
  else if (state.status === "live") remotePanelStatus(viewing.screenId, "live");
  if (state.hasRemoteVideo && !viewing.bound) {
    // Playback is a consequence of the user's screen-specific opt-in click;
    // the transport itself deliberately never calls `play()`.
    void viewing.video.play().then(() => {
      if (remoteViewedScreen !== viewing || !office || !inside) return;
      viewing.bound = office.bindMediaSurface(
        viewing.screenId,
        { canView: true, optedIn: true },
        viewing.texture,
      );
      officeScreenPanel?.update(office.listMediaSurfaces());
      remotePanelStatus(viewing.screenId, viewing.bound ? "live" : "error");
    }).catch(() => {
      if (remoteViewedScreen === viewing) {
        showDetail("The remote screen arrived, but this browser could not start video playback.");
        releaseRemoteViewer("error", "dispose");
      }
    });
  }
  if (state.status === "stopped" || state.status === "revoked") {
    showDetail(state.status === "revoked" ? "Remote screen access was revoked." : "The remote screen share stopped.");
    releaseRemoteViewer("stopped", "dispose");
  }
}

async function startRemoteViewer(surface: MediaSurfaceDescriptor): Promise<void> {
  if (access.subject === null || !inside || !office || office.depth !== "full") return;
  releaseRemoteViewer("off");
  const operation = ++remoteMediaOperation;
  remoteViewerRequestScreenId = surface.screenId;
  remotePanelStatus(surface.screenId, "connecting");
  showDetail(`Connecting to ${surface.screenId}…`);
  try {
    const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
    const optedIn = officeScreenPanel?.state().optedInScreenIds.includes(surface.screenId) ?? false;
    if (operation !== remoteMediaOperation || access.subject === null || !inside || !office ||
        office.depth !== "full" || officeId !== surface.officeId || !optedIn) return;
    const video = document.createElement("video");
    video.muted = true;
    video.playsInline = true;
    video.autoplay = false;
    const texture = new VideoTexture(video);
    texture.colorSpace = SRGBColorSpace;
    texture.generateMipmaps = false;
    let viewing: NonNullable<typeof remoteViewedScreen>;
    const remote = createRemoteOfficeMedia({
      role: "viewer",
      binding: screenBinding(surface),
      authenticatedFetch: authFetch,
      onStateChange: (state) => { if (viewing) syncRemoteViewerState(viewing, state); },
      onError: (error) => {
        if (remoteViewedScreen !== viewing) return;
        if (isRemoteAuthorizationError(error)) {
          showDetail("Remote screen authorization ended.");
          releaseRemoteViewer("unavailable", "dispose");
        } else {
          remotePanelStatus(surface.screenId, "reconnecting");
        }
      },
    });
    viewing = { screenId: surface.screenId, officeId: surface.officeId, video, texture, remote, bound: false };
    remoteViewerRequestScreenId = null;
    remoteViewedScreen = viewing;
    await remote.startViewer({ viewerOptIn: true, video });
    if (remoteViewedScreen === viewing) showDetail(`Waiting for ${surface.screenId} remote video…`);
  } catch (error) {
    if (operation !== remoteMediaOperation) return;
    remoteViewerRequestScreenId = null;
    releaseRemoteViewer("unavailable", "dispose");
    remotePanelStatus(surface.screenId, "unavailable");
    showDetail(isRemoteAuthorizationError(error)
      ? "Remote screen authorization ended. Sign in again to reconnect."
      : "No authorized remote share is available for that screen.");
  }
}

async function startRemotePresenter(
  surface: MediaSurfaceDescriptor,
  shared: NonNullable<typeof sharedScreen>,
): Promise<void> {
  if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
  remotePanelStatus(surface.screenId, "connecting");
  try {
    const { createRemoteOfficeMedia } = await import("./media/remoteMedia.ts");
    if (access.subject === null || sharedScreen !== shared || !inside || office?.depth !== "full") return;
    const remote = createRemoteOfficeMedia({
      role: "presenter",
      binding: screenBinding(surface),
      authenticatedFetch: authFetch,
      onStateChange(state) {
        if (sharedScreen !== shared) return;
        if (state.status === "live") remotePanelStatus(surface.screenId, "live");
        else if (state.status === "reconnecting") remotePanelStatus(surface.screenId, "reconnecting");
        else if (state.status === "connecting") remotePanelStatus(surface.screenId, "connecting");
        else if (state.status === "stopped" || state.status === "revoked") {
          showDetail("The hosted screen share ended.");
          queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
        }
      },
      onError: (error) => {
        if (sharedScreen !== shared) return;
        if (isRemoteAuthorizationError(error)) {
          showDetail("Screen sharing stopped because authorization ended.");
          queueMicrotask(() => { if (sharedScreen === shared) stopLocalScreenShare(); });
        } else {
          remotePanelStatus(surface.screenId, "reconnecting");
        }
      },
    });
    shared.remote = remote;
    await remote.startPresenter({ consent: { authorized: true, optedIn: true }, stream: shared.stream, video: shared.video });
    if (sharedScreen === shared) showDetail(`Sharing ${surface.screenId} locally and to authorized remote viewers.`);
  } catch (error) {
    if (sharedScreen !== shared) return;
    if (isRemoteAuthorizationError(error)) {
      showDetail("Screen sharing stopped because authorization ended. Sign in again to share.");
      stopLocalScreenShare();
      return;
    }
    void shared.remote?.dispose().catch(() => undefined);
    shared.remote = null;
    remotePanelStatus(surface.screenId, "unavailable");
    showDetail(`Sharing locally to ${surface.screenId}; hosted sharing is unavailable.`);
  }
}

function stopLocalScreenShare(): void {
  const shared = sharedScreen;
  if (!shared) return;
  sharedScreen = null;
  remoteMediaOperation += 1;
  office?.clearMediaSurface(shared.screenId);
  remotePanelStatus(shared.screenId, "stopped");
  void shared.remote?.stop().catch(() => undefined);
  for (const track of shared.stream.getTracks()) track.stop();
  shared.texture.dispose();
  shared.video.pause();
  shared.video.srcObject = null;
  officeScreenPanel?.update(office?.listMediaSurfaces() ?? []);
  renderChrome();
}

function disposeOfficeScreenUi(): void {
  stopLocalScreenShare();
  releaseRemoteViewer("off", "dispose");
  officeScreenPanel?.dispose();
  officeScreenPanel = null;
  if (screensOverlay) screensOverlay.hidden = true;
}

async function startLocalScreenShare(surface: MediaSurfaceDescriptor): Promise<void> {
  if (!office || !inside || office.depth !== "full") return;
  // One authored surface role at a time. A viewer texture must not survive
  // behind a new presenter preview or be cleared later over the presenter.
  releaseRemoteViewer("off");
  if (!navigator.mediaDevices?.getDisplayMedia) {
    showDetail("This browser does not provide tab or window sharing.");
    return;
  }
  let pendingStream: MediaStream | null = null;
  let pendingVideo: HTMLVideoElement | null = null;
  let pendingTexture: VideoTexture | null = null;
  try {
    showDetail("Choose a tab or window. Nothing is captured until you approve the browser prompt.");
    const stream = await navigator.mediaDevices.getDisplayMedia({
      video: {
        displaySurface: "browser",
        width: { ideal: 1_280, max: 1_280 },
        height: { ideal: 720, max: 720 },
        frameRate: { ideal: 15, max: 15 },
      },
      audio: false,
      // Chromium honours these as chooser preferences. Other browsers ignore
      // unknown dictionary members and still require the same explicit prompt.
      monitorTypeSurfaces: "exclude",
      selfBrowserSurface: "exclude",
      surfaceSwitching: "include",
    } as DisplayMediaStreamOptions);
    pendingStream = stream;
    const video = document.createElement("video");
    pendingVideo = video;
    video.muted = true;
    video.playsInline = true;
    video.srcObject = stream;
    await video.play();
    const texture = new VideoTexture(video);
    pendingTexture = texture;
    texture.colorSpace = SRGBColorSpace;
    texture.generateMipmaps = false;
    stopLocalScreenShare();
    if (!office?.bindMediaSurface(surface.screenId, { canView: true, optedIn: true }, texture)) {
      for (const track of stream.getTracks()) track.stop();
      texture.dispose();
      video.pause();
      video.srcObject = null;
      showDetail("That screen is no longer available.");
      return;
    }
    const shared = { screenId: surface.screenId, stream, video, texture, remote: null };
    sharedScreen = shared;
    pendingStream = null;
    pendingVideo = null;
    pendingTexture = null;
    stream.getVideoTracks()[0]?.addEventListener("ended", () => stopLocalScreenShare(), { once: true });
    officeScreenPanel?.update(office.listMediaSurfaces());
    renderChrome();
    showDetail(`Sharing locally to ${surface.screenId}. Use Office screens to stop.`);
    // Anonymous/self-host-only behavior ends here exactly as before. The
    // transport chunk is fetched only for a signed-in explicit share.
    if (access.subject !== null) void startRemotePresenter(surface, shared);
  } catch (error) {
    for (const track of pendingStream?.getTracks() ?? []) track.stop();
    pendingTexture?.dispose();
    if (pendingVideo) {
      pendingVideo.pause();
      pendingVideo.srcObject = null;
    }
    if ((error as DOMException)?.name !== "NotAllowedError") {
      showDetail("The screen preview could not start. Try again from Office screens.");
    }
  }
}

function ensureOfficeScreenPanel(): OfficeScreenPanel | null {
  if (!screensOverlay || !inside || !office || office.depth !== "full") return null;
  if (officeScreenPanel) {
    officeScreenPanel.update(office.listMediaSurfaces());
    return officeScreenPanel;
  }
  officeScreenPanel = createOfficeScreenPanel({
    container: screensOverlay,
    surfaces: office.listMediaSurfaces(),
    onRequestShare: (surface) => { void startLocalScreenShare(surface); },
    onSelect(surface) {
      if ((remoteViewedScreen && remoteViewedScreen.screenId !== surface.screenId) ||
          (remoteViewerRequestScreenId !== null && remoteViewerRequestScreenId !== surface.screenId)) {
        releaseRemoteViewer("off");
      }
    },
    onStopShare: (surface) => {
      if (sharedScreen?.screenId === surface.screenId) stopLocalScreenShare();
      if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
        releaseRemoteViewer("stopped");
      }
    },
    onViewerOptIn(surface, optedIn) {
      if (!optedIn) {
        if (remoteViewedScreen?.screenId === surface.screenId || remoteViewerRequestScreenId === surface.screenId) {
          releaseRemoteViewer("off");
        } else {
          remotePanelStatus(surface.screenId, "off");
        }
      } else if (access.subject !== null) {
        void startRemoteViewer(surface);
      }
    },
  });
  const syncOverlay = () => queueMicrotask(() => {
    if (officeScreenPanel && !officeScreenPanel.state().open) screensOverlay.hidden = true;
  });
  officeScreenPanel.root.addEventListener("click", syncOverlay);
  officeScreenPanel.root.addEventListener("keydown", (event) => {
    event.stopPropagation();
    syncOverlay();
  });
  return officeScreenPanel;
}

function openOfficeScreens(): void {
  const panel = ensureOfficeScreenPanel();
  if (!panel || !screensOverlay) return;
  screensOverlay.hidden = false;
  panel.open();
}

// The `Office screens →` button is bound by `mount`; this is the overlay's own
// backdrop, which is not chrome and stays here with the panel it dismisses.
screensOverlay?.addEventListener("click", (event) => {
  if (event.target !== screensOverlay) return;
  officeScreenPanel?.close();
  screensOverlay.hidden = true;
});

// ---- Navigation -------------------------------------------------------------

/** The views on offer right now — city chapters, or office viewpoints inside. */
function currentViews(): View[] {
  if (inside && office) return office.views;
  return city?.chapters ?? [];
}

function flyToIndex(index: number) {
  const view = currentViews()[index];
  if (!view) return;
  flyToChapter(view.id);
}

/**
 * Go to a named view: a city chapter outside, a viewpoint inside.
 *
 * Split out of `flyToIndex` because the places list and the ladder keys address
 * a rung by **identity**, and a position in a list is the one thing about a
 * chapter that this round is deliberately not allowed to change — twenty-nine
 * capture guards aim at `#chapters .chapter` by index and would silently shoot
 * the wrong frame if it moved. Everything that used to be reached by index still
 * is; nothing new is.
 */
function flyToChapter(viewId: string) {
  const view = currentViews().find((candidate) => candidate.id === viewId);
  if (!view) return;
  if (inside && office) {
    requestControlMode("office-overview");
    cancelOfficeArrival();
    office.flyTo(view.id);
    renderChrome();
  }
  else {
    /*
     * A door is a door only while there is somewhere else to go.
     *
     * `los-angeles` and `san-francisco` carry real authored poses — 38 units at
     * 26 of height, which is a 73 km stand-off over the city — and on the three
     * -board product those poses are never used, because this line intercepts
     * the click and switches boards instead. On the merged board both cities
     * are *on this board*, so the interception is what stops you looking at
     * them: it is the reason a visitor who clicks SF ends up somewhere the
     * merged board is not.
     *
     * Left alone, the fall-through below flies the authored pose, and the
     * offices stay reachable the way they already are on every other board —
     * the amber pins, which `scene.ts` gates on the board's own bounds and the
     * merged board's bounds contain both.
     */
    const destination = cityId === "california" && !ONE_CALIFORNIA
      ? CALIFORNIA_DESTINATIONS.get(view.id)
      : undefined;
    if (destination) {
      requestControlMode("overview");
      officeId = destination.officeId;
      journeyToCity(destination.cityId === "socal" ? "socal" : "bay-area");
      switchCity(destination.cityId);
      return;
    }
    if (view.id === "la-sf-us-101" || view.id === "la-sf-i-5") {
      city?.flyTo(view.id);
      requestControlMode("drive");
      closePanelForPlay();
      renderChrome();
      return;
    }
    requestControlMode("overview");
    city?.flyTo(view.id);
    renderChrome();
  }
}

/**
 * Step out of the office to the city, by whichever route keeps the URL honest.
 *
 * Returns true when it has started a navigation and the caller should stop — the
 * page is on its way out, and anything after this runs against a document that
 * is about to be replaced.
 *
 * The `?view=office` case is the cheap half of the same fix. There is no other
 * host to go to, so the scene swap stands, but the query that put us in the
 * office is dropped from the address bar on the way out. `replaceState` rather
 * than `pushState`: the office and the city are one page, and a back button that
 * walks a history of scene swaps is a back button that does not leave.
 */
function leaveToCity(cityWanted?: string): boolean {
  const url = cityDoorUrl(cityWanted);
  if (url !== null) {
    location.assign(url);
    return true;
  }
  const here = new URL(location.href);
  if (here.searchParams.get("view") === "office") {
    here.searchParams.delete("view");
    history.replaceState(null, "", `${here.pathname}${here.search}${here.hash}`);
  }
  return false;
}

function switchCity(id: string) {
  requestControlMode(inside ? "office-overview" : "overview");
  if (inside && leaveToCity(id)) return;
  if (inside) leaveOffice();
  if (id === wantedCity) return;
  // Arriving at a detailed board should put its local front door under the
  // existing Office button. The California overview keeps whichever building
  // the traveller last visited; it is a scale, not a fourth office location.
  if (id === "socal") officeId = "mateo-court";
  else if (id === "sf" && officeId === "mateo-court") officeId = "lumbridge-hq";
  if (id === "california") {
    if (journey.vehicle) dispatchJourney({ type: "exit-vehicle" });
    if (journey.location.scale === "office") dispatchJourney({ type: "leave-office" });
    if (journey.location.scale !== "california") dispatchJourney({ type: "return-to-california" });
  } else {
    journeyToCity(id === "socal" ? "socal" : "bay-area");
  }
  wantedCity = id;
  /**
   * No boot card, and no `building()` wrapper.
   *
   * `building()` puts an opaque full-screen panel up and holds it until a frame
   * of the new scene has been drawn — correct while the outgoing board was
   * disposed at the top of `mountCity` and there was genuinely nothing to look
   * at, and wrong now that there is. The board you are on stays on screen and
   * stays draggable for the whole build; `mountCity` raises the switch pill and
   * `presentBoard` runs the fog dip. This is the line the owner feels.
   */
  void mountCity(id);
}

function stepCity(delta: number) {
  const at = CITIES.findIndex((c) => c.id === wantedCity);
  const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
  if (next) switchCity(next.id);
}

/**
 * Step one rung of the ladder, in the direction the list reads.
 *
 * `[` and `]` used to step the *board*, three ways round a cycle — which stopped
 * being a true sentence the moment boards stopped being modes. A rung is what
 * the left column now offers, and a rung on another board is reached exactly as
 * a click on it would be.
 */
function stepLadder(delta: number): void {
  if (inside) return;
  const rows = PLACES;
  const at = rows.findIndex((rung) => rung.key === activePlace()?.key);
  const next = rows[Math.min(rows.length - 1, Math.max(0, (at < 0 ? 0 : at) + delta))];
  if (next && next.key !== rows[at]?.key) goToPlace(next);
}

/**
 * The rung the camera is standing on, or `null` inside a building.
 *
 * Recomputed rather than stored: it is a function of one live number
 * (`cameraStandoffMetres`) and one live id (`SceneHandle.current()`), and a
 * cached copy of a derived value is how a highlight and a camera start
 * disagreeing.
 */
function activePlace(): LadderRung | null {
  if (inside || !city) return null;
  return activeRung(LADDER, cityId, city.cameraStandoffMetres(), city.current());
}

/**
 * Go to a rung, on this board or another one.
 *
 * The cross-board case is the whole reason the ladder exists, and it is a
 * renderer change (`?city=` / `?one=0`), not a journey scale. Outdoors is
 * California either way; `switchCity` still updates `wantedCity` so capture
 * deep links keep aiming at the board they named.
 */
function goToPlace(rung: LadderRung): void {
  if (rung.board !== cityId) {
    pendingPlace = rung;
    switchCity(rung.board);
    return;
  }
  flyToChapter(rung.id);
}

/**
 * The rung to fly to once the board it is on has arrived, or `null`.
 *
 * A board switch is asynchronous and a rung is a pose on the board that is
 * coming, so the two cannot be issued in one statement. Cleared by
 * `presentBoard` whichever way the switch went, so a cancelled build cannot
 * leave a flight queued against a board nobody asked for.
 */
let pendingPlace: LadderRung | null = null;

/**
 * Guards the door against the second click.
 *
 * Entering now begins with a network fetch for the Spaces chunk, so the window
 * between the click and the room is wide enough to click in again — and two
 * `enterOffice()` calls in that window build two office scenes, park the second
 * on the stage and leak the first, textures and all. One flag, cleared in a
 * `finally` so a failed fetch does not wedge the door shut.
 */
let entering = false;

async function toggleOffice() {
  if (inside) {
    if (leaveToCity()) return;
    leaveOffice();
    return;
  }
  if (entering) return;
  // Only the first entry fetches or builds anything; after that the office is
  // parked in memory next to the paused city and the swap is a pointer.
  if (office) {
    void enterOffice();
    return;
  }
  entering = true;
  try {
    /*
     * The boot card is the whole of the "something is happening" story now, and
     * it is enough: `building()` puts it up before the fetch and takes it down
     * only once a frame of the new scene is on the glass. The old arrangement
     * also wrote "Opening the office…" onto the door itself, which meant this
     * file reaching into a label `mount.ts` now owns to say something the card
     * in front of it was already saying.
     */
    await building("Fetching the office…", () => enterOffice());
  } finally {
    entering = false;
    // Writes the real label whichever way it went — "← Back to the city" if we
    // are in, the door again if the chunk fetch failed.
    renderChrome();
  }
}

/** Switch between the authored dollhouse camera and the local possessed actor. */
function toggleOfficeWalk(): boolean {
  if (!inside) {
    if (!city?.actorState()) return false;
    const active = city.controlMode() !== "actor";
    requestControlMode(active ? "actor" : "overview");
    if (active) closePanelForPlay();
    renderChrome();
    return true;
  }
  const walker = office?.walker;
  if (!walker) return false;
  const active = !walker.active();
  requestControlMode(active ? "office-walk" : "office-overview");
  if (active) closePanelForPlay();
  renderChrome();
  return true;
}

function toggleAircraft(): boolean {
  if (inside || cityId !== "california" || !city?.aircraftState()) return false;
  const active = city.controlMode() !== "aircraft";
  requestControlMode(active ? "aircraft" : "overview");
  if (active) closePanelForPlay();
  renderChrome();
  return true;
}

/**
 * Clicking a building on the city walks into it.
 *
 * On the canvas rather than on anything the engine owns, because the engine
 * reports picks by *hover* — `onMarkerPick` fires as the pointer crosses a pin
 * and again with `null` as it leaves — so there is no click event to hang this
 * on down there. `hoveredMarker` is whatever that hover last resolved, which is
 * exactly what a click on the same pixel means.
 *
 * Guarded on not already being inside: the city's canvas is the office's canvas
 * too, they share one renderer, and a stray click on the floor of a room should
 * not re-enter the building you are standing in.
 */
canvas.addEventListener("click", () => {
  if (inside) return;
  const marker = hoveredMarker;
  if (!marker) return;
  const id = officeIdOf(marker);
  if (id === null || entering) return;
  entering = true;
  officeId = id;
  void building(`Opening ${marker.label}…`, () => enterOffice()).finally(() => {
    entering = false;
    renderChrome();
  });
});

// ---- Panels, plan and overlays ----------------------------------------------

/**
 * Get the left column out of the way when a body takes over.
 *
 * Only on a phone, where `#panel` is a bottom sheet covering a third of the
 * screen: possessing an actor and then being unable to see it is not a trade
 * anybody would make, and the sheet has a toggle to bring it back. Above that
 * width the column is furniture beside the map rather than on top of it, and
 * closing it would be taking away a thing that costs nothing.
 *
 * `panelChosen` is set for the same reason a keypress sets it: once anything has
 * had an opinion about this panel, the viewport stops having one.
 *
 * The plan view is deliberately **not** closed here any more. It used to be, and
 * the reason was that on a phone it was a bottom sheet in exactly the place the
 * joystick now occupies; `ui/chromeState.ts` moved it to a glanceable corner and
 * closing it is now taking the map away at the moment somebody is navigating.
 */
function closePanelForPlay(): void {
  if (window.innerWidth > 600) return;
  panelOpen = false;
  panelChosen = true;
}

/**
 * One body, two ways in, and the second one is the point.
 *
 * This lived inline in the `M` branch of the keydown handler and was reachable
 * from nowhere else, which made the plan view unreachable on any touch device:
 * a phone starts without `M` and had no button. `#plan-toggle` and the touch
 * pad's Map button are that way in, and both arrive here through `mount`'s
 * `onTogglePlan`, so the key and the buttons cannot disagree about the state.
 */
function togglePlan(): void {
  setPlanOpen(!planOpen);
}

function setPlanOpen(open: boolean): void {
  planOpen = open;
  planChosen = true;
  renderChrome();
}

function setPanelOpen(open: boolean): void {
  panelOpen = open;
  panelChosen = true;
  renderChrome();
}

/**
 * The viewport crossed a published breakpoint — a rotation, a window drag, a
 * tablet turning over.
 *
 * Both seeds are re-run, and that "both" is the fix: `resize` used to re-seed
 * the plan and, for no stated reason, not the panel, so rotating a tablet from
 * portrait to landscape left the sheet closed over a layout wide enough that the
 * toggle which would reopen it is not drawn. Either is skipped once its own
 * control has been touched.
 */
function adoptLayout(_layout: ChromeLayout): void {
  if (!panelChosen) panelOpen = seedPanelOpen(window.innerWidth);
  if (!planChosen) planOpen = seedPlanOpen(window.innerWidth);
  renderChrome();
}

function routeDriveIsActive(): boolean {
  const state = !inside ? city?.vehicleState() : null;
  return state !== null && state !== undefined && city?.controlMode() === "drive" &&
    city.current() === state.routeId;
}

function toggleVehicleCamera(): boolean {
  if (!routeDriveIsActive() || !city) return false;
  city.setVehicleCamera(city.vehicleCamera() === "driver" ? "chase" : "driver");
  return true;
}

function cameraForward(
  camera: { position: { x: number; z: number } },
  controls: { target: { x: number; z: number } },
) {
  return { x: controls.target.x - camera.position.x, z: controls.target.z - camera.position.z };
}

function publishPlayActions(): boolean {
  const input = playInput.snapshot();
  const requests = playInput.consumeRequests();
  if (routeDriveIsActive() && city) {
    city.setVehicleActions({
      ...vehicleActionsFromPlay(input),
      modeRequest: requests.has("assist") ? "assisted" : "none",
      reset: requests.has("reset"),
    });
    if (requests.has("camera")) toggleVehicleCamera();
    return true;
  }
  const walker = inside && controlModeState.mode === "office-walk" ? office?.walker : null;
  if (walker?.active() && office) {
    walker.setAction(cameraRelativePlanar(input, cameraForward(office.camera, office.controls)));
    return true;
  }
  if (!inside && city?.controlMode() === "actor") {
    const state = city.actorState();
    if (!state) return false;
    if (state.kind === "crow" && state.mode === "flight") {
      city.setActorActions({
        ...crowActionsFromPlay(input), modeRequest: "none", kindRequest: "none", reset: false,
      });
    } else {
      city.setActorActions({
        ...groundActorActionsFromPlay(
          input,
          state.yaw,
          cameraForward(city.stageScene.camera, city.stageScene.controls),
        ),
        pitch: 0, climb: 0, glide: false, modeRequest: "none", kindRequest: "none", reset: false,
      });
    }
    return true;
  }
  if (!inside && city?.controlMode() === "aircraft") {
    city.setAircraftActions({
      ...aircraftActionsFromPlay(input),
      modeRequest: requests.has("assist") ? "assisted" : "none",
      reset: requests.has("reset"),
    });
    return true;
  }
  return false;
}

window.addEventListener("keyup", (event) => {
  const control = controlForKey(event.key);
  if (!control) return;
  playInput.setDigital("keyboard", control, false);
  if (publishPlayActions()) event.preventDefault();
});

function releaseAllPlayInput(): void {
  clearPublishedPlayInput();
  publishPlayActions();
}
window.addEventListener("blur", releaseAllPlayInput);
document.addEventListener("visibilitychange", () => {
  if (document.visibilityState !== "visible") releaseAllPlayInput();
});

let gamepadButtons: StandardPlayGamepadButtons = { assist: false, reset: false, camera: false };
function pollPlayGamepad() {
  try {
    const pad = navigator.getGamepads?.().find((candidate) => candidate !== null);
    if (pad) {
      const sample = sampleStandardPlayGamepad(pad, gamepadButtons);
      gamepadButtons = sample.buttons;
      playInput.clearSource("gamepad");
      playInput.setAxes("gamepad", sample.axes);
      for (const control of sample.digital) playInput.setDigital("gamepad", control, true);
      for (const request of sample.requests) playInput.request(request);
      publishPlayActions();
    } else {
      playInput.clearSource("gamepad");
      gamepadButtons = { assist: false, reset: false, camera: false };
    }
  } catch {
    // Some privacy-hardened browsers expose the method but throw until a pad
    // has produced a trusted event. Keyboard/touch remain fully functional.
  }
  requestAnimationFrame(pollPlayGamepad);
}
requestAnimationFrame(pollPlayGamepad);

window.addEventListener("pointerdown", (event) => {
  if (event.pointerType === "touch") document.body.classList.add("touch-capable");
}, { capture: true });

/**
 * Keyboard access to everything the pointer can reach.
 *
 * Bound to `window` rather than to the canvas, because the canvas is only
 * focusable by accident and a shortcut that stops working when you tab to the
 * legend is worse than no shortcut. The guard is the usual one: a keystroke that
 * lands in a text field or on the plan view's own arrow-key handler belongs to
 * that control, not to this.
 *
 * Every binding below is resolved through `ui/shortcuts.ts`, which is also what
 * renders the `?` sheet — so a key that works and a key that is documented are
 * now the same list. They were not: the sheet had no row for `G` while `g` was
 * bound to glide *and* inserted at runtime meaning godmode, and Space was
 * documented as "Handbrake while driving" while being the generic primary in
 * every mode.
 */
window.addEventListener("keydown", (event) => {
  if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
  const target = event.target;
  if (
    target instanceof HTMLInputElement ||
    target instanceof HTMLSelectElement ||
    target instanceof HTMLTextAreaElement ||
    (target instanceof HTMLElement && target.isContentEditable)
  ) {
    return;
  }

  if (event.key === "Escape") {
    // Through the applier rather than at `#shortcuts.hidden`, so the focus
    // return and the `onShortcuts` callback stay correct whichever way the
    // sheet was opened.
    if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts();
    else if (inside) {
      if (!leaveToCity()) leaveOffice();
    }
    else showDetail(null);
    return;
  }
  if (event.key === "?") {
    if (chrome?.shortcutsOpen() === true) chrome.closeShortcuts();
    else chrome?.openShortcuts();
    event.preventDefault();
    return;
  }
  if (event.key >= "1" && event.key <= "9") {
    /**
     * A digit addresses whatever list is on screen.
     *
     * Inside a building that is still the viewpoint list. Outside it, the places
     * list replaced the chapter list, so a digit that flew to the current
     * board's nth chapter would be flying to a row nobody can see — and on the
     * Bay Area board, 1-9 would silently mean nine different places from the
     * nine printed in the column.
     */
    if (inside) flyToIndex(Number(event.key) - 1);
    else {
      const rung = PLACES[Number(event.key) - 1];
      if (rung) goToPlace(rung);
    }
    return;
  }
  if (event.key === "[") {
    if (boardTabsVisible()) stepCity(-1);
    else stepLadder(-1);
    return;
  }
  if (event.key === "]") {
    if (boardTabsVisible()) stepCity(1);
    else stepLadder(1);
    return;
  }
  const lower = event.key.toLowerCase();
  const control = controlForKey(event.key);
  if (control) {
    playInput.setDigital("keyboard", control, true);
    if (publishPlayActions()) {
      event.preventDefault();
      return;
    }
  }
  /**
   * The three one-shot requests — assist, reset, camera — from the same table
   * the held controls come from.
   *
   * This was three hand-written `lower === "p"`-shaped branches, each carrying
   * its own copy of "which modes is this meaningful in". The mode test is still
   * here, because it is a fact about the simulation rather than about the
   * keyboard: only a drive has a camera to swap and only a drive or a flight has
   * an assist to resume, and a `P` that silently queued a request in overview
   * would be a key that does nothing while claiming to do something.
   */
  const edge = edgeForKey(event.key);
  if (edge !== null) {
    const driving = routeDriveIsActive();
    const flying = city?.controlMode() === "aircraft";
    const meaningful = edge === "camera" ? driving : driving || flying;
    if (meaningful) {
      playInput.request(edge);
      publishPlayActions();
      event.preventDefault();
      return;
    }
  }
  if (lower === "v" && toggleOfficeWalk()) {
    event.preventDefault();
    return;
  }
  if (lower === "m") {
    togglePlan();
    return;
  }
  if (lower === "o") void toggleOffice();
});

// ---- Time -------------------------------------------------------------------

/**
 * One writer for one override.
 *
 * The `#hour` slider is gone: `capabilitiesFor` hands `timeControl` and `debug`
 * to the same tier, so the only person who could see the scrubber was the person
 * who can open the godmode panel — and it wrote an hour onto *today*, silently
 * discarding whatever date the panel had set.
 *
 * The clock line stays visible to everyone; a map that will not say what time it
 * is showing is worse than one you cannot scrub. This gate is belt and braces:
 * "no control" and "no override" are two different facts, and the renderer
 * depends on the second.
 */
function applyTimeControl() {
  if (!access.can.timeControl) instantOverride = null;
}

// ---- Instruments ------------------------------------------------------------

/**
 * The godmode panel and the pose editor, for the one visitor in a deployment
 * who has them.
 *
 * **Constructed, not hidden.** Everything in this section is behind
 * `access.can.debug`, and for anyone else the result is no element, no
 * `<style>`, no key binding and no bytes: `src/tools/` is reached only through
 * the `await import()` below, so a non-god browser never fetches the chunk. A
 * hidden instrument is still an instrument you shipped to a stranger.
 */

/** Cached across boards: the module is fetched once, the tool is rebuilt per city. */
let poseEditorFactory: typeof import("./tools/poseEditor.ts").createPoseEditor | null = null;
let poseDockBody: HTMLElement | null = null;
let poseDockOpen = true;

/**
 * Where the panel thinks it is standing.
 *
 * `null` while there is no board. The stage travels with the place even though
 * there is only ever one, because what the panel is told is *which board these
 * counters are about* — handing it the stage separately would let it print
 * SoCal's draw calls under the Bay Area's name for the length of a switch.
 *
 * `marineStrength` is deliberately not wired: it is private to `atmosphere.ts`,
 * and the panel prints "no hook wired" rather than a guess.
 */
function godmodePlace(): GodmodePlace | null {
  if (!city) return null;
  const entry = CITIES.find((c) => c.id === cityId);
  const active = entry?.city ?? SAN_FRANCISCO;
  const world = city.world;
  const label = entry?.label ?? cityId;
  return {
    // The office is a different board for accounting: it is the ledger key, and
    // its draw calls are not the city's.
    label: inside ? `${label} · office` : label,
    lat: active.center.lat,
    lng: active.center.lng,
    stage: city.stage,
    metresPerUnit: world.metresPerUnit,
    unproject: (x, z) => world.unproject(x, z),
  };
}

/** `setPlace` when there is a place. Called on every board and room change. */
function refreshGodmodePlace() {
  const place = godmodePlace();
  if (place) godmode?.setPlace(place);
}

async function mountGodmode() {
  if (!access.can.debug || godmode) return;
  const place = godmodePlace();
  if (!place) return;

  const [tools, poses] = await Promise.all([
    import("./tools/index.ts"),
    import("./tools/poseEditor.ts"),
  ]);
  poseEditorFactory = poses.createPoseEditor;

  godmode = tools.createGodmode({
    container: document.body,
    initial: { instant: currentInstant(), place },
    onTimeChange(instant) {
      instantOverride = instant;
      updateSun();
    },
    onWeatherOverride(w) {
      weatherOverride = w;
      updateSun();
      // The corner label has to stop claiming live weather the moment the sky
      // on screen is one somebody typed.
      renderChrome();
    },
    /**
     * Both dials read through the module-level handles rather than closing over
     * a board, because the panel outlives the city: it is mounted once and every
     * later `mountCity` swaps `city` and `trafficDial` underneath it. A closure
     * over the board that was current when the panel opened would go on driving
     * a disposed scene after the first city switch.
     */
    /**
     * Read through the module-level `office`, never closed over — the panel is
     * mounted once and every `switchOffice` replaces the scene underneath it.
     */
    office: {
      onHouseLights(mode) {
        houseLights = mode;
        // `officeLighting` is the only caller of `setSolarElevation` and
        // `updateSun` is the only caller of that, so this is the whole apply.
        updateSun();
      },
      onRobotsVisible(visible) {
        office?.setRobotsVisible(visible);
      },
      onCeilingsVisible(visible) {
        office?.setCeilingsVisible(visible);
      },
      read() {
        // Only while a room is actually on the stage. Out in the city the office
        // is paused and kept, and a dimmer aimed at a scene nobody is rendering
        // is exactly the dead panel this section is arranged to avoid.
        if (!inside || !office) return null;
        return {
          id: officeId,
          depth: office.depth,
          houseLevel: office.houseLevel(),
          robots: office.robots().length,
        };
      },
    },
    sky: {
      onExtraTraffic(count) {
        trafficDial?.setExtra(count);
      },
      onSatellitesVisible(visible) {
        satellitesVisible = visible;
        city?.setSatellitesVisible(visible);
      },
      read() {
        const counts = city?.satelliteCounts();
        return {
          extraTraffic: trafficDial?.extra() ?? 0,
          trafficIsLive: cityFlights?.live() ?? false,
          // `total: 0` is a board with no catalogue, which is not the same as a
          // catalogue with nothing above the horizon — the panel says so.
          satellites: counts && counts.total > 0 ? counts : null,
        };
      },
    },
  });

  // The city was built before this chunk arrived, so its pose editor is built
  // here; every later board gets one from `mountCity`.
  if (city) mountPoseEditor(city);
  // The godmode row in the `?` sheet is no longer inserted from here: it is a
  // `KEYMAP` entry scoped to the god tier and rendered by `renderShortcutSheet`,
  // which is the same table the key handler reads. The hand-written row said
  // `G`, and `G` is glide.
  renderChrome();
}

/**
 * The pose editor, rebuilt with the board.
 *
 * It holds a `World`, a camera and a controls, all three of which die with the
 * city — the same reason the plan view is rebuilt rather than re-pointed.
 */
function mountPoseEditor(handle: SceneHandle) {
  if (!access.can.debug || !poseEditorFactory) return;
  const dock = instrumentDock();
  poseEditor = poseEditorFactory({
    container: dock,
    allowed: access.can.debug,
    world: handle.world,
    camera: handle.stageScene.camera,
    controls: handle.stageScene.controls,
    /**
     * A cut rather than a flight, for now.
     *
     * The tool is forbidden from writing the camera itself and asks for
     * `SceneKit.flyTo`, which `SceneHandle` does not expose — it offers
     * `flyTo(chapterId)` and nothing that takes a pose. Moving both ends of the
     * orbit at once is exactly what the plan view's `onSeek` does a few hundred
     * lines up and it lands on the right pose; what it does not do is ease, so
     * re-flying a captured chapter jumps. `scene.ts` is another agent's file
     * this week; the two-line addition that upgrades this is in the handoff.
     */
    flyTo: (pose: Pose) => {
      handle.stageScene.camera.position.copy(pose.position);
      handle.stageScene.controls.target.copy(pose.target);
      handle.stageScene.controls.update();
    },
    existingChapters: handle.chapters,
  });
  poseEditor.setVisible(poseDockOpen);
}

/**
 * The dock, built once and reused across boards.
 *
 * Bottom right, above the key-hint rail and below the plan view, which is the
 * one column of this layout with room in it. Its geometry used to be set here as
 * element styles rather than as a rule, because every visitor downloads
 * `index.html` and a `#pose-dock { … }` sitting in it that can never match is
 * the one trace this arrangement would otherwise leave behind. That reason still
 * holds and the styles have moved anyway: `tools/godmode.ts` injects its own
 * stylesheet, that stylesheet is fetched only behind `access.can.debug`, and a
 * rule in it can carry the `env(safe-area-inset-bottom)` and the phone
 * breakpoint an inline style could not.
 */
function instrumentDock(): HTMLElement {
  if (poseDockBody) return poseDockBody;

  const dock = document.createElement("aside");
  dock.id = "pose-dock";
  dock.setAttribute("aria-label", "Pose editor");

  const toggle = document.createElement("button");
  toggle.type = "button";
  toggle.className = "help";
  toggle.setAttribute("aria-expanded", "true");

  const body = document.createElement("div");

  function applyDock() {
    toggle.textContent = poseDockOpen ? "poses ▾" : "poses ▴";
    toggle.setAttribute("aria-expanded", String(poseDockOpen));
    body.hidden = !poseDockOpen;
    // Collapsed is genuinely idle: `setVisible(false)` is what stops the tool
    // reading the camera on every frame of a map nobody is authoring against.
    poseEditor?.setVisible(poseDockOpen);
  }

  toggle.addEventListener("click", () => {
    poseDockOpen = !poseDockOpen;
    applyDock();
  });

  dock.append(toggle, body);
  document.body.append(dock);
  poseDockBody = body;
  applyDock();
  return body;
}

// ---- The boot card ----------------------------------------------------------

const bootCard = document.querySelector<HTMLElement>("#boot");
const bootStep = document.querySelector<HTMLElement>("#boot-step");
const switchPill = document.querySelector<HTMLElement>("#switching");
const switchStep = document.querySelector<HTMLElement>("#switching-step");

/**
 * What a board switch says now that it has stopped covering the screen.
 *
 * The boot card is the *first* board of a session and nothing else. Every switch
 * after it builds behind a board that is still on screen and still draggable, so
 * the only honest thing left to say is that something is happening — and a pill
 * in the bottom corner says it without taking the picture away. It never takes a
 * pointer event, which is the point: the board underneath stays usable.
 *
 * `fraction` is the heightfield's, straight off the Worker, and `null` before it
 * has reported anything.
 */
function showSwitchProgress(label: string, fraction: number | null): void {
  if (switchStep === null || switchPill === null) return;
  // Never over the boot card. On the first mount of the session the card is the
  // whole story and a second progress affordance under it is noise.
  if (bootCard?.hidden === false) return;
  const text =
    fraction === null
      ? `Building ${label}…`
      : `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
  if (switchStep.textContent !== text) switchStep.textContent = text;
  if (switchPill.hidden) switchPill.hidden = false;
}

function hideSwitchProgress(): void {
  if (switchPill !== null && !switchPill.hidden) switchPill.hidden = true;
}

/**
 * Wait until the browser has actually put pixels on the glass.
 *
 * Writing to `textContent` and then immediately building a heightfield paints
 * nothing: the style change and the two seconds of synchronous work are in the
 * same task, so the frame the user sees is the one *after* the work. Two
 * `requestAnimationFrame`s straddle a paint, which is the whole trick — a
 * single one still runs before it.
 */
function painted(): Promise<void> {
  return new Promise((resolve) => {
    requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
  });
}

/**
 * Wait until the renderer has actually drawn the thing that was being built.
 *
 * `painted()` is not enough: the heightfield builds in a Worker, so
 * `createScene` resolves while the main thread is idle and two animation frames
 * go by in 33 ms — long before the first WebGL draw. The card faded on an empty
 * canvas. `renderer.info.render.frame` is the renderer counting its own draws
 * and is the one witness a fast frame cannot fool.
 *
 * The timeout bounds a promise that would otherwise never settle: an abandoned
 * build never draws, and the boot card must not outlive it.
 */
function drawn(timeoutMs = 4000): Promise<void> {
  const from = stage.renderer.info.render.frame;
  const deadline = performance.now() + timeoutMs;
  return new Promise((resolve) => {
    requestAnimationFrame(function wait() {
      if (stage.renderer.info.render.frame > from || performance.now() > deadline) {
        void painted().then(resolve);
        return;
      }
      requestAnimationFrame(wait);
    });
  });
}

/**
 * Which `building()` call the boot card belongs to.
 *
 * A generation counter rather than a boolean because two builds can overlap:
 * clicking SoCal while the Bay Area is still building leaves the first
 * `building()` running, and it must not fade a card the second one is using.
 * Last one in owns it.
 */
let bootGeneration = 0;

/**
 * Run something slow with the boot card up and a sentence saying what it is.
 *
 * Naming the work is most of the value: a blank page for two seconds reads as
 * broken, and "Building the Bay Area…" for two seconds reads as busy. The
 * percentage on top of it comes from `bootProgress`, which the heightfield
 * drives directly from the Worker.
 *
 * `work` may be synchronous or not. It became "or not" when `createScene` did,
 * and the flattening matters: without the `await` here the card faded about
 * 800 ms in, while the city was still building, because a promise is a truthy
 * value that returns instantly.
 */
async function building<T>(label: string, work: () => T | Promise<T>): Promise<T> {
  const generation = ++bootGeneration;
  if (bootStep) bootStep.textContent = label;
  if (bootCard) {
    bootCard.hidden = false;
    bootCard.classList.remove("done");
  }
  await painted();
  const result = await work();
  await drawn();
  // Superseded while we were building: the card belongs to a later call now and
  // fading it would uncover a city that does not exist yet.
  if (generation !== bootGeneration) return result;
  bootCard?.classList.add("done");
  window.setTimeout(() => {
    if (bootCard?.classList.contains("done")) bootCard.hidden = true;
  }, 300);
  return result;
}

/**
 * The heightfield's progress, on the card that is already on screen.
 *
 * Nothing is written while the build is on the main thread, and that is not an
 * oversight: `onMainThread` means the page is frozen for the duration, so every
 * one of these writes would land in the same task and exactly one of them —
 * the last — would ever be seen. The fallback build is the case where the
 * static label is all the honesty available.
 *
 * The caller is responsible for not reporting an abandoned build; see the
 * `onProgress` passed by `mountCity`.
 */
function bootProgress(label: string, fraction: number, onMainThread: boolean) {
  if (onMainThread) return;
  // Two destinations and exactly one of them is ever up: the card on the first
  // mount of the session, the pill on every switch after it.
  if (bootCard?.hidden === false) {
    if (bootStep) bootStep.textContent = `Building ${label}… terrain ${Math.round(fraction * 100)}%`;
    return;
  }
  showSwitchProgress(label, fraction);
}

// ---- Boot -----------------------------------------------------------------

/**
 * Bind the interface to the page, once.
 *
 * Every handler is one line and every one of them ends in a function that
 * already existed — that is the measure of whether the chrome extraction
 * worked. `mount` owns the listeners, the joystick, the shortcuts dialog, the
 * onboarding coach and the device panel from here on; this file owns what the
 * buttons mean.
 */
function mountInterface(): ChromeHandle {
  return mountChrome(document, {
    onEnter: () => void toggleOffice(),
    onWalk: () => { toggleOfficeWalk(); },
    onFly: () => { toggleAircraft(); },
    onScreens: () => openOfficeScreens(),
    onDevices: () => openDevicePanel(),
    onDeviceCommand: (command) => sendDeviceCommand(command),
    onSelectBoard: (id) => {
      if (inside) void switchOffice(id);
      else switchCity(id);
    },
    onSelectView: (_id, index) => flyToIndex(index),
    onSelectPlace: (key) => {
      const rung = LADDER.find((candidate) => candidate.key === key);
      if (rung) goToPlace(rung);
    },
    onMode: (mode) => {
      requestControlMode(mode);
      if (mode !== "overview" && mode !== "office-overview") closePanelForPlay();
      renderChrome();
    },
    onTogglePanel: (open) => setPanelOpen(open),
    onTogglePlan: (open) => setPlanOpen(open),
    onDismissDetail: () => showDetail(null),
    onCharacter: () => openProfileEditor(),
    // A held touch button is one source per control rather than one per pointer:
    // `mount` captures the pointer and guarantees the matching release, so the
    // per-pointer bookkeeping this file used to do has an owner now.
    onTouchHold: (control, pressed) => {
      playInput.setDigital(`touch:${control}`, control, pressed);
      publishPlayActions();
    },
    onTouchEdge: (edge) => {
      playInput.request(edge);
      publishPlayActions();
    },
    onStick: (axes) => {
      if (axes === null) playInput.clearSource("stick");
      else playInput.setAxes("stick", axes);
      publishPlayActions();
    },
    onOnboardingFinished: () => {
      // The coach has recorded itself as seen; this is the app agreeing, so the
      // next `renderChrome` does not put it straight back up.
      firstVisit = false;
      renderChrome();
    },
    onLayoutChange: (layout) => adoptLayout(layout),
  });
}

/**
 * Access first, then data, then the board.
 *
 * The order is load-bearing in both directions and it used to be wrong. Markers
 * were fetched before the tier was known, which is a request an anonymous
 * visitor should not be making; and both decisions have to be settled before
 * the *first* `mountCity`, because `markerPalette` is fixed at scene
 * construction — the sample palette's keys are not the API's — and the flight
 * source is chosen in the same call.
 *
 * Everything about the API remains optional. No server means the bundled sample
 * set, the simulated traffic, the local hardware simulator, and a label at the
 * bottom of the screen saying which of them you are looking at.
 */
async function boot() {
  chrome = mountInterface();
  renderChrome();

  if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
  access = await resolveAccess();
  if (access.subject !== null) {
    const loadedProfile = loadLocalProfile(sessionStorage, LOCAL_PROFILE_KEY);
    localProfile = loadedProfile.status === "loaded"
      ? loadedProfile.profile
      : createDefaultLocalProfile(access.subject, access.subject);
    if (loadedProfile.status !== "loaded" || loadedProfile.migrated) {
      saveLocalProfile(sessionStorage, LOCAL_PROFILE_KEY, localProfile);
    }
  } else {
    localProfile = null;
  }
  dispatchJourney({
    type: "sign-in-actor-swap",
    actor: access.subject === null
      ? {
          id: "anonymous",
          kind: "crow",
          signedIn: false,
          profile: { displayName: "Guest" },
        }
      : {
          id: access.subject,
          kind: "humanoid",
          signedIn: true,
          profile: {
            displayName: localProfile?.displayName ?? access.subject,
            color: humanoidAppearance()?.accentColor,
          },
        },
  });
  applyTimeControl();
  renderChrome();

  // Both gates, for the reason `mountCity` gives at length: the tier says
  // whether this visitor may ask, `feeds` says whether there is anything to
  // ask. A box with `markers: "none"` serves the public empty body to everyone,
  // so the request buys a round trip and lands on the same sample set.
  if (access.can.liveMarkers && access.feeds?.markers) {
    try {
      const feed = await tera.markers();
      markers = feed.value;
      // The caller's palette, plus the door colour it cannot know about.
      palette = { ...feed.palette, ...OFFICE_PALETTE };
      liveData = feed.live;
    } catch {
      // A missing API is the self-host default, not an error.
    }
  }

  /**
   * Which metro to build, from the URL if it names one this build has.
   *
   * The parameter exists to make leaving the office lossless — `office.` sends
   * you to `tera.?city=socal` rather than dropping you in the Bay Area — but it
   * is read unconditionally, because a deep link to a city is a reasonable thing
   * to want on its own and a parameter that only works when it arrives from one
   * particular page is a trap. An unknown id falls back rather than failing:
   * `?city=paris` on a build with two cities in it should show a city, not a
   * black screen.
   */
  const wanted = new URLSearchParams(location.search).get("city");
  const fallback = CITIES.find((c) => c.id === DEFAULT_CITY_ID) ?? CITIES[0];
  const first = CITIES.find((c) => c.id === wanted) ?? fallback;
  await building(`Building ${first?.label ?? "the city"}…`, () =>
    mountCity(first?.id ?? DEFAULT_CITY_ID),
  );

  // There must be a mounted scene before remote snapshots have anywhere to go.
  // Anonymous and zero-server builds retain their existing local-only path.
  // Hosted presence is an enhancement: a failed lazy chunk or unavailable API
  // must not stop profile/tools initialization after the world is already live.
  await initializeRealtimePresence().catch(() => undefined);

  // The instruments, after the first board, because the panel reads a live
  // stage and there is not one before this line.
  await mountGodmode();

  // The `office.` front door. The city is already standing behind this, so the
  // back button is a scene swap and not a rebuild.
  if (OPENS_IN_OFFICE) await building("Fetching the office…", () => enterOffice());

  /**
   * The wall clock, once a minute.
   *
   * Skipped entirely while an override is up — the whole point of an override
   * is that the map has stopped following the clock — and the panel is told the
   * new instant only when it is not the one choosing it, so its own readouts
   * stay pinned to what is being rendered rather than fighting it.
   */
  window.setInterval(() => {
    if (instantOverride !== null) return;
    updateSun();
    godmode?.setInstant(new Date());
  }, 60_000);
}

void boot();
