/**
 * The only thing in this product that writes to the page.
 *
 * `chromeState.ts` decides; this applies. The split is the whole point of both
 * files: before them, `main.ts` held about forty `element.hidden = someCondition`
 * lines with the condition and the write on the same line, so there was no way to
 * assert a decision without a browser and no way to change a write without
 * reading the decision. Here the decision arrives as a plain object and every
 * DOM mutation in the product is in this file, in one pass, in reading order.
 *
 * `integration` makes one call — `chrome.apply(chromeState(inputs))` — where it
 * used to make forty.
 *
 * ### What this owns, and what it deliberately does not
 *
 * It owns the chrome: the left column, the top-right stack, the rail, the dock,
 * the play HUD, the touch surface, the shortcuts dialog, the onboarding coach and
 * the device panel. It mounts the last three, because all three are *chrome that
 * appears and disappears* and something has to own their lifetimes.
 *
 * It does not own the canvas, the scene, the boot card, the profile editor or the
 * office-screen panel. The boot card in particular stays exactly where it is —
 * inline in `index.html`, painting before a single module has been fetched, which
 * is the only reason the page is not blank for the ~2.3 s it takes to build the
 * Bay Area heightfield.
 *
 * ### Idempotence
 *
 * `apply` is called on every legend render and may be called on every frame. Text
 * is compared before it is written, and the two lists are rebuilt only when their
 * signature changes, because `replaceChildren` on the chapter list every frame
 * would destroy focus and restart every transition on it. Attribute writes are
 * left unconditional: they are cheap, and the browser already elides a write of
 * the value that is there.
 */

import { PointerStick } from "../input/pointerStick.ts";
import type { PlayDigitalControl, PlayEdgeControl } from "../input/play.ts";
import type { ControlMode } from "../play/controlMode.ts";
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import type { ChromeLayout, ChromeState } from "./chromeState.ts";
import { BREAKPOINT } from "./tokens.ts";
import { mountDevicePanel } from "./devicePanel.ts";
import type { DevicePanelHandle } from "./devicePanel.ts";
import { renderAircraftDetail } from "./hud.ts";
import { browserStorage, mountOnboarding } from "./onboarding.ts";
import type { OnboardingHandle, OnboardingStorage } from "./onboarding.ts";
import { renderShortcutSheet } from "./shortcuts.ts";
import type { ShortcutSheetHandles } from "./shortcuts.ts";

// ---- Handlers -------------------------------------------------------------

/**
 * Everything the chrome can ask the application to do.
 *
 * Every member is optional, and that is deliberate rather than lax: the
 * `integration` workstream adopts this file by moving its call sites over a few
 * at a time, and a required member would mean the first move needs all of them.
 * An unwired control is inert, which is the correct behaviour for one — nothing
 * here throws because a handler is missing.
 */
export interface ChromeHandlers {
  onSelectBoard?(id: string): void;
  onSelectView?(id: string, index: number): void;
  /**
   * A rung of the places list, by its ladder key.
   *
   * By key and not by index, unlike `onSelectView` — a place is addressed by
   * identity because it may be on a board that is not the one on screen, and
   * because the list it lives in is ordered by stand-off rather than by the
   * order a pack happened to author its chapters in.
   */
  onSelectPlace?(key: string): void;
  onEnter?(): void;
  onWalk?(): void;
  onFly?(): void;
  onScreens?(): void;
  onDevices?(): void;
  onDeviceCommand?(command: DeviceCommand): void;
  onMode?(mode: ControlMode): void;
  /** The `☰` button. The argument is the state being moved *to*. */
  onTogglePanel?(open: boolean): void;
  /** The rail's plan button and the touch pad's Map button. */
  onTogglePlan?(open: boolean): void;
  onShortcuts?(open: boolean): void;
  onDismissDetail?(): void;
  onCharacter?(): void;
  /** The phone's scrim behind the panel sheet. */
  onScrim?(): void;
  /** A held touch control went down or up. */
  onTouchHold?(control: PlayDigitalControl, pressed: boolean): void;
  /** A one-shot touch control fired. */
  onTouchEdge?(edge: PlayEdgeControl): void;
  /** The analogue stick moved, or was released (`null`). */
  onStick?(axes: { moveX: number; moveY: number } | null): void;
  onOnboardingFinished?(reason: "finished" | "skipped"): void;
  /**
   * The viewport crossed one of the two published breakpoints.
   *
   * `resize` already re-seeded the plan and, for no stated reason, not the
   * panel — so rotating a tablet from portrait to landscape left the panel
   * closed over a layout where it is furniture and there is no toggle to reopen
   * it with. A `matchMedia` change listener is the right shape for this anyway:
   * it fires once per crossing rather than on every pixel of a drag, and it is
   * the same 600/900 pair `tokens.ts` publishes and `deviceProfile()` keys off.
   */
  onLayoutChange?(layout: ChromeLayout): void;
}

export interface ChromeHandle {
  apply(state: ChromeState): void;
  /** Live device readings, which arrive on their own timer rather than per frame. */
  applyDeviceStates(states: readonly DeviceState[]): void;
  openShortcuts(): void;
  closeShortcuts(): void;
  shortcutsOpen(): boolean;
  dispose(): void;
}

export interface ChromeMountOptions extends ChromeHandlers {
  /** First-run storage. Defaults to `localStorage`, guarded; pass a fake in a test. */
  storage?: OnboardingStorage | null;
}

// ---- Small helpers --------------------------------------------------------

function setText(element: Element | null, text: string): void {
  if (element === null) return;
  if (element.textContent !== text) element.textContent = text;
}

function setHidden(element: HTMLElement | null, hidden: boolean): void {
  if (element === null) return;
  element.hidden = hidden;
}

function setPressed(element: Element | null, pressed: boolean): void {
  element?.setAttribute("aria-pressed", String(pressed));
}

/** The held control a touch button feeds, by its DOM id. Mirrors `chromeState`. */
const TOUCH_HOLDS: Readonly<Record<string, PlayDigitalControl>> = {
  "touch-primary": "primary",
  "touch-secondary": "secondary",
  "touch-pitch-up": "pitch-up",
  "touch-pitch-down": "pitch-down",
};

const TOUCH_EDGES: Readonly<Record<string, PlayEdgeControl>> = {
  "touch-assist": "assist",
  "touch-reset": "reset",
  "touch-camera": "camera",
};

// ---- Mount ----------------------------------------------------------------

/**
 * Bind the chrome to a document (or to any subtree, which is what makes this
 * testable against a fake DOM) and return the applier.
 */

/**
 * Tell the places rail whether it is clipping rows, and which end.
 *
 * The rail is capped at `min(22rem, 42vh)` because twenty-four rungs is taller
 * than a phone. A cap on a list of variable-height rows lands mid-row, and a
 * row sliced through its own text reads as a rendering fault rather than as an
 * invitation to scroll. The stylesheet answers that with a fade at whichever
 * end has more list behind it, so the half-row is visibly the *edge of a
 * scroll* and not a broken button.
 *
 * Every measurement is guarded, because this module is deliberately tested with
 * no DOM at all — `ui/mount.test.ts` drives it through a `FakeElement` that has
 * no layout and therefore no `scrollHeight`. A missing number means "nothing is
 * known about the geometry", which must produce no attribute rather than
 * `data-overflow="NaN"`.
 */
export function markOverflow(el: HTMLElement | null): void {
  if (el === null) return;
  const { scrollTop, clientHeight, scrollHeight } = el as unknown as Record<string, unknown>;
  if (
    typeof scrollTop !== "number" ||
    typeof clientHeight !== "number" ||
    typeof scrollHeight !== "number" ||
    !Number.isFinite(scrollTop) ||
    !Number.isFinite(clientHeight) ||
    !Number.isFinite(scrollHeight)
  ) {
    return;
  }
  // A pixel of slack. Fractional layout means a list scrolled fully to the
  // bottom routinely reports scrollTop + clientHeight one part in a thousand
  // short of scrollHeight, and a fade that never switches off is a fade that
  // permanently dims a row nothing is hiding.
  const SLACK = 1;
  const above = scrollTop > SLACK;
  const below = scrollTop + clientHeight < scrollHeight - SLACK;
  const edge = above && below ? "both" : above ? "top" : below ? "bottom" : "none";
  el.setAttribute?.("data-overflow", edge);
}

export function mountChrome(
  root: Document | HTMLElement,
  handlers: ChromeMountOptions,
): ChromeHandle {
  const doc: Document =
    "createElement" in root && typeof (root as Document).createElement === "function"
      ? (root as Document)
      : ((root as HTMLElement).ownerDocument as Document);
  const scope: ParentNode = root;
  const body: HTMLElement | null = doc.body ?? null;

  const find = <T extends HTMLElement>(id: string): T | null =>
    (scope.querySelector(`#${id}`) as T | null) ?? null;

  // ---- The elements, resolved once ---------------------------------------
  const panel = find("panel");
  const panelToggle = find<HTMLButtonElement>("panel-toggle");
  const panelToggleLabel = find("panel-toggle-label");
  const scrim = find("scrim");
  const title = find("title");
  const subtitle = find("subtitle");
  const clock = find("clock");
  const boardNav = find("cities");
  const boardTitle = find("boards-title");
  const enterButton = find<HTMLButtonElement>("enter");
  const walkButton = find<HTMLButtonElement>("walk");
  const flyButton = find<HTMLButtonElement>("fly");
  const screensButton = find<HTMLButtonElement>("screens");
  const devicesButton = find<HTMLButtonElement>("devices");
  const deviceSection = find("device-section");
  const deviceHost = find("device-host");
  const officeInvite = find("office-invite");
  const officeNote = find("office-note");
  const chapterNav = find("chapters");
  const chapterSection = find("chapters-section");
  const boardSection = find("boards-section");
  const placeNav = find("places");
  const placeSection = find("places-section");
  const blurb = find("blurb");
  const blurbSection = find("blurb-section");

  const tier = find("tier");
  const tierLabel = find("tier-label");
  const tierWho = find("tier-who");
  const tierSignIn = find<HTMLAnchorElement>("tier-signin");
  const tierCharacter = find<HTMLButtonElement>("tier-character");
  const tierAdds = find("tier-adds");
  const corner = find("corner");
  const minimapReadout = find("minimap-readout");

  const modeDock = find("mode-dock");
  const playHud = find("play-hud");
  const playHudMode = find("play-hud-mode");
  const playHudPrimary = find("play-hud-primary");
  const playHudStatus = find("play-hud-status");

  const rail = find("rail");
  const detail = find("detail");
  const detailText = find("detail-text");
  const detailClose = find<HTMLButtonElement>("detail-close");
  const hint = find("hint");
  const planToggle = find<HTMLButtonElement>("plan-toggle");
  const helpButton = find<HTMLButtonElement>("help");

  const touchControls = find("touch-play-controls");
  const stick = find("play-stick");
  const stickKnob = find("play-stick-knob");
  const stickLabel = find("play-stick-label");
  const touchMap = find<HTMLButtonElement>("touch-map");

  const source = find("source");
  const shortcuts = find("shortcuts");
  const shortcutsBody = find("shortcuts-body");
  const shortcutsClose = find<HTMLButtonElement>("shortcuts-close");

  const onboardingHost = find("onboarding-host") ?? body;

  // ---- Mutable bits ------------------------------------------------------
  let latest: ChromeState | null = null;
  let boardSignature = "";
  let viewSignature = "";
  let placeSignature = "";
  /** The rung that was last scrolled into view, so a steady camera does not scroll. */
  let scrolledPlaceKey = "";
  let deviceSignature = "";
  /**
   * Four more signatures, for the four rebuilds that are not lists of buttons.
   *
   * The header's idempotence claim covers "the two lists"; these were rebuilt
   * unconditionally, which was survivable while `apply` ran once per legend
   * render and is not now that `integration` drives it from the frame pump in
   * every play mode. Each of the four ends in `replaceChildren`, so an
   * unguarded rebuild at 60 Hz discards and re-allocates the whole subtree
   * sixty times a second — and for the `?` sheet that is about thirty rows of
   * `<dt>`/`<dd>` for content that changes when the tier does, i.e. never.
   */
  let hintSignature = "";
  let inviteSignature = "";
  let detailSignature = "";
  let sheetSignature = "";
  let devicePanel: DevicePanelHandle | null = null;
  let onboarding: OnboardingHandle | null = null;
  let sheet: ShortcutSheetHandles | null = null;
  let appliedBodyClasses: readonly string[] = [];
  let disposed = false;
  const cleanups: (() => void)[] = [];

  function on(
    target: EventTarget | null,
    type: string,
    listener: (event: Event) => void,
  ): void {
    if (target === null) return;
    target.addEventListener(type, listener);
    cleanups.push(() => target.removeEventListener(type, listener));
  }

  // ---- Static wiring -----------------------------------------------------

  // The rail's own scroll is the other thing that moves its edges. `apply`
  // cannot see it: the list is rebuilt only when a rung's identity or highlight
  // changes, and a reader dragging the column changes neither.
  on(placeNav, "scroll", () => {
    markOverflow(placeNav as HTMLElement);
  });

  on(panelToggle, "click", () => {
    handlers.onTogglePanel?.(!(latest?.panelOpen ?? true));
  });
  on(scrim, "click", () => {
    handlers.onScrim?.();
    handlers.onTogglePanel?.(false);
  });
  on(enterButton, "click", () => handlers.onEnter?.());
  on(walkButton, "click", () => handlers.onWalk?.());
  on(flyButton, "click", () => handlers.onFly?.());
  on(screensButton, "click", () => handlers.onScreens?.());
  on(devicesButton, "click", () => handlers.onDevices?.());
  on(detailClose, "click", () => handlers.onDismissDetail?.());
  on(tierCharacter, "click", () => handlers.onCharacter?.());
  on(planToggle, "click", () => handlers.onTogglePlan?.(!(latest?.planOpen ?? true)));
  on(touchMap, "click", () => handlers.onTogglePlan?.(!(latest?.planOpen ?? true)));
  on(helpButton, "click", () => (shortcutsOpen() ? closeShortcuts() : openShortcuts()));
  on(shortcutsClose, "click", () => closeShortcuts());
  on(shortcuts, "click", (event) => {
    // The backdrop, not the sheet. A click on the card itself must not close it.
    if (event.target === shortcuts) closeShortcuts();
  });

  on(modeDock, "click", (event) => {
    const target = event.target;
    if (!(target !== null && typeof target === "object" && "closest" in target)) return;
    const button = (target as Element).closest("[data-control-mode]");
    const mode = button?.getAttribute("data-control-mode");
    if (mode !== null && mode !== undefined) handlers.onMode?.(mode as ControlMode);
  });

  on(boardNav, "click", (event) => {
    const target = event.target;
    if (!(target !== null && typeof target === "object" && "closest" in target)) return;
    const id = (target as Element).closest("[data-board]")?.getAttribute("data-board");
    if (id !== null && id !== undefined) handlers.onSelectBoard?.(id);
  });

  on(chapterNav, "click", (event) => {
    const target = event.target;
    if (!(target !== null && typeof target === "object" && "closest" in target)) return;
    const button = (target as Element).closest("[data-view]");
    const id = button?.getAttribute("data-view");
    const index = Number(button?.getAttribute("data-view-index") ?? "-1");
    if (id !== null && id !== undefined && index >= 0) handlers.onSelectView?.(id, index);
  });

  on(placeNav, "click", (event) => {
    const target = event.target;
    if (!(target !== null && typeof target === "object" && "closest" in target)) return;
    const key = (target as Element).closest("[data-place]")?.getAttribute("data-place");
    if (key !== null && key !== undefined) handlers.onSelectPlace?.(key);
  });

  // ---- The touch action pad ----------------------------------------------
  //
  // Held buttons are bound on the pointer rather than on click, because a click
  // is a press *and* a release and a sprint button that fires both at once
  // sprints for zero milliseconds. Capture means a finger that slides off the
  // button still releases it, which is the failure that leaves a control stuck
  // on until the next press.
  for (const [id, control] of Object.entries(TOUCH_HOLDS)) {
    const button = find<HTMLButtonElement>(id);
    if (button === null) continue;
    const release = (event: Event) => {
      setPressed(button, false);
      handlers.onTouchHold?.(control, false);
      event.preventDefault();
    };
    on(button, "pointerdown", (event) => {
      const pointer = event as PointerEvent;
      button.setPointerCapture?.(pointer.pointerId);
      setPressed(button, true);
      handlers.onTouchHold?.(control, true);
      event.preventDefault();
    });
    on(button, "pointerup", release);
    on(button, "pointercancel", release);
    on(button, "lostpointercapture", release);
  }

  for (const [id, edge] of Object.entries(TOUCH_EDGES)) {
    const button = find<HTMLButtonElement>(id);
    on(button, "click", () => handlers.onTouchEdge?.(edge));
  }

  // ---- The analogue stick -------------------------------------------------
  //
  // `input/pointerStick.ts` has held a real analogue stick since the play modes
  // landed and `main.ts` was the only thing that ever drove it. Moving the
  // binding here is what lets the stick be shown by a state decision rather than
  // by an imperative branch, which is the whole reason a phone in VIEW mode used
  // to have no visible way to move.
  // ---- Breakpoint crossings ----------------------------------------------
  //
  // Guarded, because `matchMedia` does not exist in a test document and this
  // module has to stay mountable against one.
  const media = (globalThis as { matchMedia?: (query: string) => MediaQueryList }).matchMedia;
  if (typeof media === "function" && handlers.onLayoutChange !== undefined) {
    const phone = media.call(globalThis, `(max-width: ${BREAKPOINT.phone}px)`);
    const compact = media.call(globalThis, `(max-width: ${BREAKPOINT.compact}px)`);
    const announce = (): void => {
      handlers.onLayoutChange?.(phone.matches ? "phone" : compact.matches ? "compact" : "desktop");
    };
    for (const query of [phone, compact]) {
      query.addEventListener?.("change", announce);
      cleanups.push(() => query.removeEventListener?.("change", announce));
    }
  }

  const pointerStick = new PointerStick();
  function paintKnob(axes: { moveX: number; moveY: number } | null): void {
    if (stickKnob === null) return;
    const radius = 26;
    const x = axes === null ? 0 : axes.moveX * radius;
    const y = axes === null ? 0 : -axes.moveY * radius;
    stickKnob.style.setProperty("--stick-x", `${x.toFixed(1)}px`);
    stickKnob.style.setProperty("--stick-y", `${y.toFixed(1)}px`);
  }
  if (stick !== null) {
    on(stick, "pointerdown", (event) => {
      const pointer = event as PointerEvent;
      const rect = stick.getBoundingClientRect();
      const axes = pointerStick.begin(pointer.pointerId, pointer.clientX, pointer.clientY, {
        left: rect.left,
        top: rect.top,
        width: rect.width,
        height: rect.height,
      });
      if (axes === null) return;
      stick.setPointerCapture?.(pointer.pointerId);
      stick.classList.add("active");
      paintKnob(axes);
      handlers.onStick?.(axes);
      event.preventDefault();
    });
    on(stick, "pointermove", (event) => {
      const pointer = event as PointerEvent;
      const axes = pointerStick.move(pointer.pointerId, pointer.clientX, pointer.clientY);
      if (axes === null) return;
      paintKnob(axes);
      handlers.onStick?.(axes);
      event.preventDefault();
    });
    const end = (event: Event) => {
      const pointer = event as PointerEvent;
      if (!pointerStick.end(pointer.pointerId)) return;
      stick.classList.remove("active");
      paintKnob(null);
      handlers.onStick?.(null);
    };
    on(stick, "pointerup", end);
    on(stick, "pointercancel", end);
    on(stick, "lostpointercapture", end);
  }

  // ---- The shortcuts dialog ----------------------------------------------

  function shortcutsOpen(): boolean {
    return shortcuts !== null && shortcuts.hidden === false;
  }

  function openShortcuts(): void {
    if (shortcuts === null || shortcutsOpen()) return;
    shortcuts.hidden = false;
    shortcutsClose?.focus?.();
    handlers.onShortcuts?.(true);
  }

  function closeShortcuts(): void {
    if (shortcuts === null || !shortcutsOpen()) return;
    shortcuts.hidden = true;
    helpButton?.focus?.();
    handlers.onShortcuts?.(false);
  }

  // ---- apply --------------------------------------------------------------

  function apply(state: ChromeState): void {
    if (disposed) return;
    latest = state;

    // ---- <body> classes, as one diff ----
    if (body !== null) {
      for (const name of appliedBodyClasses) {
        if (!state.bodyClasses.includes(name)) body.classList.remove(name);
      }
      for (const name of state.bodyClasses) body.classList.add(name);
      appliedBodyClasses = state.bodyClasses;
    }

    // ---- Left column ----
    setHidden(panel, !state.panelVisible);
    setHidden(panelToggle, !state.panelToggleVisible);
    panelToggle?.setAttribute("aria-expanded", String(state.panelOpen));
    setText(panelToggleLabel, state.panelToggleLabel);
    setHidden(scrim, !state.scrimVisible);
    setText(title, state.panelTitle);
    setText(subtitle, state.panelSubtitle);
    setText(clock, state.clockLabel);
    setText(boardTitle, state.boardPicker === "office" ? "Studios" : "Boards");

    const nextBoardSignature = state.boards
      .map((board) => `${board.id}:${board.label}:${board.status ?? ""}:${board.active}`)
      .join("|");
    if (boardNav !== null && nextBoardSignature !== boardSignature) {
      boardSignature = nextBoardSignature;
      boardNav.replaceChildren(
        ...state.boards.map((board) => {
          const button = doc.createElement("button");
          button.type = "button";
          // `aria-pressed` rather than a class: these buttons choose exactly one
          // active board, and the stylesheet keys off the attribute so the
          // visual state and the announced state cannot drift apart.
          button.className = board.status ? "board board--office" : "board";
          button.setAttribute("data-board", board.id);
          button.setAttribute("aria-pressed", String(board.active));
          const name = doc.createElement("span");
          name.className = "board__name";
          name.textContent = board.label;
          button.append(name);
          if (board.status) {
            const status = doc.createElement("span");
            status.className = "board__status t-caps";
            status.textContent = board.status;
            button.append(status);
          }
          return button;
        }),
      );
    }

    setHidden(enterButton, !state.enterVisible);
    setText(enterButton, state.enterLabel);
    setHidden(walkButton, !state.walkVisible);
    setPressed(walkButton, state.walkPressed);
    setText(walkButton, state.walkLabel);
    setHidden(flyButton, !state.flyVisible);
    setPressed(flyButton, state.flyPressed);
    setText(flyButton, state.flyLabel);
    setHidden(screensButton, !state.screensVisible);
    setText(screensButton, state.screensLabel);
    setHidden(devicesButton, !state.devicePanelVisible);
    setText(devicesButton, state.devicePanelLabel);

    setHidden(officeInvite, !state.officeInviteVisible);
    const nextInviteSignature = state.officeInviteVisible
      ? `${state.officeInviteText}|${state.officeInviteLinkLabel ?? ""}|${state.signInHref ?? ""}`
      : "";
    if (state.officeInviteVisible && officeInvite !== null && nextInviteSignature !== inviteSignature) {
      inviteSignature = nextInviteSignature;
      officeInvite.replaceChildren(doc.createTextNode(`${state.officeInviteText} `));
      if (state.officeInviteLinkLabel !== null && state.signInHref !== null) {
        const link = doc.createElement("a");
        link.href = state.signInHref;
        link.textContent = state.officeInviteLinkLabel;
        officeInvite.append(link, doc.createTextNode("."));
      }
    } else if (!state.officeInviteVisible) {
      inviteSignature = "";
    }
    setHidden(officeNote, !state.officeNoteVisible);
    setText(officeNote, state.officeNoteText);

    setHidden(boardSection, !state.boardsVisible);

    /**
     * The places list, rebuilt only when a row's identity or its highlight moves.
     *
     * The highlight is in the signature deliberately: it is what makes the list
     * an altimeter, and it changes as the camera descends rather than only when
     * somebody clicks. Everything else about a row is a pure function of the
     * ladder, which is built once at module load, so in the steady state this
     * comparison is a string equality and nothing else.
     */
    setHidden(placeSection, !state.placesVisible);
    const nextPlaceSignature = state.places
      .map((place) => `${place.key}:${place.region ?? ""}:${place.key9 ?? ""}:${place.active}`)
      .join("|");
    if (placeNav !== null && nextPlaceSignature !== placeSignature) {
      placeSignature = nextPlaceSignature;
      const rows: Node[] = [];
      for (const place of state.places) {
        if (place.region !== null) {
          const heading = doc.createElement("p");
          heading.className = "places__region";
          heading.textContent = place.region;
          rows.push(heading);
        }
        const button = doc.createElement("button");
        button.type = "button";
        button.className = "place";
        // Two attributes, because the two questions are different: which rung
        // this is, and which board it would take you to. The second is what lets
        // a stylesheet or a test tell a cross-board rung from a local one
        // without parsing the first.
        button.setAttribute("data-place", place.key);
        button.setAttribute("data-place-board", place.board);
        button.setAttribute("aria-pressed", String(place.active));
        const name = doc.createElement("span");
        name.className = "place__name";
        name.textContent = place.label;
        const standoff = doc.createElement("span");
        standoff.className = "place__standoff";
        standoff.textContent = place.standoff;
        if (place.key9 !== null) {
          const key = doc.createElement("span");
          key.className = "num";
          key.textContent = place.key9;
          button.append(key);
        }
        button.append(name, standoff);
        rows.push(button);
      }
      placeNav.replaceChildren(...rows);
      markOverflow(placeNav as HTMLElement);
    }

    /**
     * Keep the lit rung in view as the camera descends.
     *
     * Guarded on the key rather than run every apply, because `scrollIntoView`
     * on an element that is already in view still costs a layout — and this
     * runs from a once-a-second poll. `block: "nearest"` scrolls the list and
     * never the page: the panel is a column on a desktop and a bottom sheet on
     * a phone, and a rung dragging the whole document sideways is the failure
     * that arrangement is most prone to.
     */
    const litPlace = state.places.find((place) => place.active) ?? null;
    if (placeNav !== null && litPlace !== null && litPlace.key !== scrolledPlaceKey) {
      scrolledPlaceKey = litPlace.key;
      // Walked rather than selected. A `[data-place="…"]` selector would need
      // `CSS.escape` for a key that is caller-supplied text, and `CSS` is a
      // browser global this module's tests do not have — the whole point of the
      // `FakeElement` harness in `ui/mount.test.ts` is that this file runs
      // without a DOM. Twenty-four children is a walk nobody can measure.
      const row = [...placeNav.children].find(
        (child) => child.getAttribute?.("data-place") === litPlace.key,
      );
      (row as HTMLElement | undefined)?.scrollIntoView?.({ block: "nearest" });
      markOverflow(placeNav as HTMLElement);
    }

    setHidden(chapterSection, !state.chaptersVisible);
    setHidden(chapterNav, !state.chaptersVisible);
    const nextViewSignature = state.views
      .map((view) => `${view.id}:${view.number}:${view.label}:${view.active}`)
      .join("|");
    if (chapterNav !== null && nextViewSignature !== viewSignature) {
      viewSignature = nextViewSignature;
      chapterNav.replaceChildren(
        ...state.views.map((view, index) => {
          const button = doc.createElement("button");
          button.type = "button";
          button.className = "chapter";
          button.setAttribute("data-view", view.id);
          button.setAttribute("data-view-index", String(index));
          button.setAttribute("aria-pressed", String(view.active));
          const number = doc.createElement("span");
          number.className = "num";
          number.textContent = view.number;
          const label = doc.createElement("span");
          label.textContent = view.label;
          button.append(number, label);
          return button;
        }),
      );
    }
    setHidden(blurbSection, !state.blurbVisible);
    setHidden(blurb, !state.blurbVisible);
    setText(blurb, state.blurbText);

    // ---- The device panel ----
    //
    // Rebuilt only when the *set of declarations* changes, which is once per
    // office. Readings arrive through `applyDeviceStates` on the feed's own
    // timer, because a mixing desk that rebuilt its sliders every frame would
    // throw away the drag in progress on one of them.
    setHidden(deviceSection, !state.devicePanelVisible);
    const nextDeviceSignature = state.deviceDeclarations.map((d) => d.id).join("|");
    if (nextDeviceSignature !== deviceSignature) {
      deviceSignature = nextDeviceSignature;
      devicePanel?.dispose();
      devicePanel = null;
      if (deviceHost !== null && state.deviceDeclarations.length > 0) {
        devicePanel = mountDevicePanel(deviceHost, {
          declarations: state.deviceDeclarations,
          onCommand: (command) => handlers.onDeviceCommand?.(command),
        });
      }
    }

    // ---- Top right ----
    setHidden(tier, !state.tierVisible);
    tier?.setAttribute("data-tier", state.tierTone);
    setText(tierLabel, state.tierLabel);
    setText(tierWho, state.whoLabel ?? "");
    setHidden(tierWho, state.whoLabel === null);
    setHidden(tierCharacter, !state.characterVisible);
    setHidden(tierSignIn, !state.signInVisible);
    if (tierSignIn !== null && state.signInHref !== null) {
      tierSignIn.href = state.signInHref;
      setText(tierSignIn, state.signInLabel);
    }
    setHidden(tierAdds, state.tierAdds === null);
    setText(tierAdds, state.tierAdds ?? "");
    setHidden(corner, !state.minimapVisible);
    setHidden(minimapReadout, !state.minimapReadoutVisible);

    // ---- The dock ----
    setHidden(modeDock, !state.modeDockVisible);
    for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ??
      []) {
      const mode = button.getAttribute("data-control-mode");
      const entry = state.modeButtons.find((candidate) => candidate.mode === mode);
      button.hidden = entry === undefined || !entry.visible;
      button.setAttribute("aria-pressed", String(entry?.pressed === true));
    }

    // ---- The play HUD ----
    setHidden(playHud, !state.playHudVisible);
    if (state.playHud !== null) {
      setText(playHudMode, state.playHud.mode);
      setText(playHudPrimary, state.playHud.primary);
      setText(playHudStatus, state.playHud.status);
      playHudStatus?.classList.toggle("warning", state.playHud.warning);
    }

    // ---- The rail ----
    setHidden(rail, !state.railVisible);
    setHidden(detail, !state.detailVisible);
    if (state.detail !== null && detailText !== null) {
      if (state.detail.kind === "text") {
        // `setText` is already a compare-then-write, so the text card needs no
        // signature of its own; the aircraft card is a subtree and does.
        detailSignature = "";
        setText(detailText, state.detail.text);
      } else {
        const view = state.detail.view;
        const nextDetailSignature = [
          view.title,
          view.subtitle,
          view.attribution ?? "",
          ...view.rows.map((row) => `${row.label}=${row.value}`),
        ].join("|");
        if (nextDetailSignature !== detailSignature) {
          detailSignature = nextDetailSignature;
          renderAircraftDetail(detailText, view);
        }
      }
    } else {
      detailSignature = "";
    }
    setHidden(helpButton, !state.helpVisible);
    setText(helpButton, state.touchFirst ? "Guide" : "?");
    setHidden(planToggle, !state.planToggleVisible);
    setPressed(planToggle, state.planTogglePressed);
    setPressed(touchMap, state.planTogglePressed);
    setHidden(hint, !state.railHintsVisible);
    const nextHintSignature = state.railHintsVisible
      ? state.railHints.map((entry) => `${entry.id}:${entry.keys.join("+")}:${entry.label}`).join("|")
      : "";
    if (hint !== null && state.railHintsVisible && nextHintSignature !== hintSignature) {
      hintSignature = nextHintSignature;
      hint.replaceChildren(
        ...state.railHints.map((entry) => {
          const chip = doc.createElement("span");
          chip.className = "hint__chip";
          for (const key of entry.keys) {
            if (key === "…") {
              chip.append(doc.createTextNode("–"));
              continue;
            }
            const cap = doc.createElement("kbd");
            cap.textContent = key;
            chip.append(cap);
          }
          const label = doc.createElement("span");
          label.className = "hint__label";
          // The rail is one line over a photograph. The full sentence lives in
          // the `?` sheet; this is the clause before the first dash.
          label.textContent = entry.label.split(" — ")[0] ?? entry.label;
          chip.append(label);
          return chip;
        }),
      );
    }

    // ---- Touch ----
    setHidden(touchControls, !state.touchControlsVisible);
    setHidden(stick, !state.stickVisible);
    setText(stickLabel, state.stickLabel);
    stick?.setAttribute("aria-label", `${state.stickLabel} joystick. Drag in any direction.`);
    for (const action of state.touchActions) {
      const button = find<HTMLButtonElement>(action.id);
      if (button === null) continue;
      button.hidden = !action.visible;
      setText(button, action.label);
    }

    // ---- Honesty ----
    setHidden(source, !state.sourceVisible);
    setText(source, state.sourceLabel);
    source?.classList.toggle("live", state.sourceLive);

    // ---- The `?` sheet, rebuilt from KEYMAP ----
    if (shortcutsBody !== null) {
      const inputs = {
        coarsePointer: state.touchFirst,
        inside: state.inside,
        god: state.tierTone === "god",
        degraded: state.degradedVisible ? state.degradedLines : [],
        credits: state.credits,
      };
      // Everything the sheet draws is a function of these five, and four of them
      // change at most a handful of times in a session. Without the guard the
      // whole sheet — about thirty rows — is rebuilt on every frame of every
      // play mode for a card that is usually not even on screen.
      const nextSheetSignature = [
        inputs.coarsePointer,
        inputs.inside,
        inputs.god,
        inputs.degraded.join("|"),
        inputs.credits.join("|"),
      ].join("~");
      if (sheet === null) {
        sheetSignature = nextSheetSignature;
        sheet = renderShortcutSheet(doc, inputs);
        shortcutsBody.replaceChildren(sheet.root);
      } else if (nextSheetSignature !== sheetSignature) {
        sheetSignature = nextSheetSignature;
        sheet.apply(inputs);
      }
    }

    // ---- Onboarding ----
    if (state.onboardingVisible && onboarding === null && onboardingHost !== null) {
      onboarding = mountOnboarding(onboardingHost, {
        coarsePointer: state.touchFirst,
        storage: handlers.storage === undefined ? browserStorage() : handlers.storage,
        onFinish: (reason) => handlers.onOnboardingFinished?.(reason),
      });
    } else if (!state.onboardingVisible && onboarding !== null) {
      onboarding.dispose();
      onboarding = null;
    }

    // ---- The canvas's own description ----
    scope.querySelector("#scene")?.setAttribute("aria-label", state.canvasLabel);
  }

  return {
    apply,
    applyDeviceStates(states) {
      devicePanel?.apply(states);
    },
    openShortcuts,
    closeShortcuts,
    shortcutsOpen,
    dispose() {
      if (disposed) return;
      disposed = true;
      for (const cleanup of cleanups) cleanup();
      cleanups.length = 0;
      devicePanel?.dispose();
      devicePanel = null;
      onboarding?.dispose();
      onboarding = null;
      sheet = null;
      if (body !== null) {
        for (const name of appliedBodyClasses) body.classList.remove(name);
      }
      appliedBodyClasses = [];
    },
  };
}
