(() => {
  "use strict";

  // Platform stamp. The iOS app injects a documentStart WKUserScript that
  // sets data-platform="ios" before anything parses (no desktop-chrome
  // flash); ?platform=ios is the browser/QA path — the CSP forbids inline
  // scripts, so it lives here instead of the document head.
  if (
    !document.documentElement.dataset.platform &&
    new URLSearchParams(location.search).get("platform") === "ios"
  ) {
    document.documentElement.dataset.platform = "ios";
  }
  const IS_IOS = document.documentElement.dataset.platform === "ios";

  const ASSET_BASE = "assets/ipod-figma/";
  const SPRITES = {
    bodyStroke: "e18a3a4be55c3cfa1ccdcfa18333ec70b2f0fbca.svg",
    bodyStrokeInner: "9e6b0e3fed276c5a6ea0c690ae087cd3d93dbadc.svg",
    bodySide: "dd0e233f184d98c8a8a67b8fa429f89930e1c548.svg",
    bodySideCap: "71fad770351cf2a3ef45d57dc2d0175ddea5b56b.svg",
    bodyMask: "85a5b676ee8a46f770ed2b33dc30b5b242fb8a8e.svg",
    bodyGlare: "body-glare.svg",
    // The four filter-heavy wheel sprites ship as 6x rasters baked by
    // Scripts/bake-sprites.mjs. WebKit rasterizes SVG filter chains at the
    // sprite's intrinsic viewBox size and upscales the buffer, which is the
    // stair-stepping Geoff flagged twice; a 6x PNG downscales clean at every
    // app size. Filterless layers (labels, seams, wear) stay inline vector.
    wheelOuter: "baked/wheel-outer@6x.png",
    // Pressed states are BAKED (same script): five CSS-gradient attempts at
    // the press shading all misregistered against the sprite rim somewhere
    // ("there is some dumb stroke"). The bake measures the rim off the alpha
    // channel and composites gap shadow / occlusion / catch light in the
    // sprite's own pixels, so the press is a crossfade, not a painting.
    wheelOuterPressed: "baked/wheel-outer-pressed@6x.png",
    wheelSeamA: "88de6805301804ddcad683b982fd4a5dafc779ad.svg",
    wheelSeamB: "504c16ebee42471778ab30cb651826a6062516e5.svg",
    wheelInner: "baked/wheel-face-lit@6x.png",
    wheelGrain: "baked/wheel-grain@6x.png",
    wheelCenter: "baked/wheel-center@6x.png",
    wheelCenterPressed: "baked/wheel-center-pressed@6x.png",
    bodyOverlay: "81a69a3b570adcf36db8812bc63166e5aaa60806.svg",
    bodyMultiply: "e19f2aca4c69b5bddd5b86176126a298ac382589.svg",
    wheelLabels: "548d1807cddce3b77f01ca6ff4982a3890b3509e.svg",
    chevron: "8aa6c09465621300f78ffbfb9080af753fec76fa.svg",
    batteryFrame: "44a758333256a2e879724711bce3c5b08e86b943.svg",
    screenRule: "664cf0720ae3bb87bf547122ffd8a2ccb9e10880.svg",
    wheelWearA: "c353174aeee95042fa2c4e74df60a23bd4663438.svg",
    wheelWearB: "c353174aeee95042fa2c4e74df60a23bd4663438.svg",
    wheelWearC: "d54f678d2ec5e0e9baa8959f1449b5556a2a1fe3.svg",
    wheelWearD: "d54f678d2ec5e0e9baa8959f1449b5556a2a1fe3.svg",
    wheelSmudge: "ipod-fingerprint.png",
    // iOS body chrome: plain 55pt circular-arc bevels regenerated for the
    // phone geometry (Docs/ios-layout-study/gen-bevels.mjs). Inert spans on
    // macOS; ios.css swaps them in for the squircle chrome.
    iosBevelBand: "ios-bevel-band.svg",
    iosBevelLines: "ios-bevel-lines.svg",
  };

  const ROOT_ITEMS = [
    item("music/playlists", "Playlists", "container"),
    item("music/artists", "Artists", "container"),
    item("music/songs", "Songs", "container"),
    item("settings", "Settings", "container"),
    item("about", "About", "action"),
  ];

  const DEMO_LISTS = {
    music: {
      id: "music",
      title: "Music",
      items: [
        item("music/playlists", "Playlists", "container"),
        item("music/artists", "Artists", "container"),
        item("music/albums", "Albums", "container"),
        item("music/songs", "Songs", "container"),
        item("music/genres", "Genres", "container"),
      ],
    },
    "music/songs": {
      id: "music/songs",
      title: "Songs",
      items: [
        item("demo:1", "Everything Counts", "track", "Depeche Mode", true),
        item("demo:2", "Changes", "track", "David Bowie", true),
        item("demo:3", "No Surprises", "track", "Radiohead", true),
        item("demo:4", "Try Again", "track", "Aaliyah", true),
        item("demo:5", "One Piece at a Time", "track", "Johnny Cash", true),
        item("demo:6", "Little by Little", "track", "Oasis", true),
        item("demo:7", "Do It Again", "track", "Steely Dan", true),
      ],
    },
  };
  const DEFAULT_FIRMWARE_SETTINGS = {
    shuffle: "Off",
    repeat: "Off",
    startup: "50%",
    sleep: "Off",
    backlight: "Off",
    contrast: "50%",
    clicker: "On",
    language: "English",
    // iOS only: where the device sits on the phone screen. Bottom is the
    // shipping layout (flush with the glass, wheel under the thumb).
    placement: "Bottom",
  };
  const firmwareSettings = {
    ...DEFAULT_FIRMWARE_SETTINGS,
    ...readStoredSettings(),
  };
  const LEGACY_SETTING_VALUES = {
    contrast: {
      Low: "25%",
      Medium: "50%",
      High: "75%",
    },
    backlight: {
      "1 second": "1 Sec",
      "2 seconds": "2 Sec",
      "3 seconds": "3 Sec",
      "5 seconds": "5 Sec",
    },
    language: {
      German: "Deutsch",
      French: "Français",
      Japanese: "日本語",
    },
  };
  for (const [setting, values] of Object.entries(LEGACY_SETTING_VALUES)) {
    firmwareSettings[setting] = values[firmwareSettings[setting]] || firmwareSettings[setting];
  }
  try {
    if (localStorage.getItem("iosamp-settings-version") !== "2") {
      if (firmwareSettings.backlight === "5 Sec") firmwareSettings.backlight = "Off";
      localStorage.setItem("iosamp-settings-version", "2");
    }
  } catch {
    // A denied/corrupt WebKit data store should not prevent the device boot.
  }

  const root = document.getElementById("iosamp");
  const interactive = document.getElementById("interactive");
  const title = document.getElementById("screen-title");
  const panels = document.getElementById("screen-panels");
  const statusPlayback = document.getElementById("status-playback");
  const batteryBars = document.querySelector(".batteryBars");
  const screenReaderStatus = document.getElementById("screen-reader-status");
  const screenRegion = document.querySelector(".screen");
  const wheel = document.getElementById("wheel-control");
  const wheelRotor = document.querySelector(".wheelRotor");
  const pressSurface = document.getElementById("button-press-surface");
  const selectSurface = document.getElementById("select-press-surface");

  // Clicks run on WebAudio, not <audio> elements: HTMLMediaElement restarts
  // by seeking (currentTime = 0), which lands the sample a media-pipeline
  // round-trip after the detent that asked for it — the biggest single source
  // of "the wheel feels latent". A decoded buffer starts in the next render
  // quantum (~5ms). Declared before the pools below or their bodies hit the
  // const in its temporal dead zone.
  const AudioEngine = window.AudioContext || window.webkitAudioContext;
  const audioContext = AudioEngine
    ? new AudioEngine({ latencyHint: "interactive" })
    : null;
  let audioResumePromise = null;
  let deferredAudioCue = null;
  const resumeAudioContext = () => {
    if (!audioContext || audioContext.state === "running") {
      return Promise.resolve();
    }
    if (audioResumePromise) return audioResumePromise;
    audioResumePromise = audioContext.resume()
      .then(() => {
        const cue = deferredAudioCue;
        deferredAudioCue = null;
        if (audioContext.state === "running") cue?.();
      })
      .catch(() => {
        deferredAudioCue = null;
      })
      .finally(() => {
        audioResumePromise = null;
      });
    return audioResumePromise;
  };
  ["pointerdown", "keydown", "wheel"].forEach((type) => {
    window.addEventListener(type, () => {
      void resumeAudioContext();
    }, {
      capture: true,
      passive: true,
    });
  });

  const clickVoices = audioPool("assets/ipod-wheel-click.wav", 5, 0.5);
  // The centre button and the four ring buttons are different acoustic
  // events on the real hardware (measured off the VEEB recording — see
  // Scripts/ipod-button-audio.mjs): centre is a bodier 4.1kHz-centroid
  // click, the ring quadrants a tighter 5.4kHz snap.
  const centerPressAudio = audioPool("assets/ipod-button-press.wav", 3, 0.72);
  const centerReleaseAudio = audioPool("assets/ipod-button-release.wav", 3, 0.72);
  const ringPressAudio = audioPool("assets/ipod-ring-press.wav", 3, 0.72);
  const ringReleaseAudio = audioPool("assets/ipod-ring-release.wav", 3, 0.72);
  const pressAudio = (buttonID) =>
    buttonID === "select" ? centerPressAudio() : ringPressAudio();
  const releaseAudio = (buttonID) =>
    buttonID === "select" ? centerReleaseAudio() : ringReleaseAudio();

  let stack = [];
  let screen = { type: "splash" };
  let nowPlaying = {
    source: "none",
    title: "Not Playing",
    artist: "",
    album: null,
    elapsed: 0,
    duration: 0,
    isPlaying: false,
  };
  let transport = "system";
  let wheelPointer = null;
  let wheelRect = null;
  let wheelAngle = 0;
  let detentTravel = 0;
  let rowTravel = 0;
  let detentDirection = 0;
  let pushTimer = 0;
  let sleeping = false;
  let startupTimer = 0;
  const pressedButtons = new Set();
  const holdTimers = new Map();
  const consumedHolds = new Set();
  let activeSeekButton = null;
  let resetTimer = 0;
  let backlight = false;
  let backlightTimer = 0;
  let sleepTimer = 0;
  let volume = 50;
  let volumeTimer = 0;
  // The host device's battery, pushed by native. Until a push arrives (or on
  // hardware with no battery — a desktop Mac, the simulator) the meter keeps
  // its drawn full-charge face, which is the CSS fallback.
  let battery = { level: 1, charging: false };
  let nativeVolumeTimer = 0;
  let nativeVolumeLastSent = -Infinity;
  let nativeVolumeInFlight = false;
  let nativeVolumePending = false;
  let previousWheelTime = 0;
  let previousPressDeadline = 0;
  let previousPressTrackID = null;
  let previousRestartPromise = null;
  let menuHoldBacklightTarget = null;
  let scrubActive = false;
  let scrubTimer = 0;
  let nativeSeekTimer = 0;
  let nativeSeekLastSent = -Infinity;
  let nativeSeekInFlight = false;
  let nativeSeekPending = false;
  let nativeSeekQuietUntil = 0;
  let breakout = null;
  let breakoutRaf = 0;
  let clickerEnabled = firmwareSettings.clicker !== "Off";
  let autoSleepTimer = 0;
  let requestCounter = 0;
  const requestNamespace = globalThis.crypto?.randomUUID?.()
    || `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
  let firmwareClosing = false;
  let navigationGeneration = 0;
  let navigationPending = false;
  let navigationPendingToken = 0;
  let inputEpoch = 0;
  let selectedRowRaf = 0;
  let nowPlayingOverflowRaf = 0;
  const pendingMarqueeRestarts = new Set();
  let panelCounter = 0;
  const pending = new Map();
  let holdEnabled = false;
  let readyRetryTimer = 0;
  let aboutMessage = [
    "Related Mac: This Mac",
    "Capacity: —",
    "Available: —",
    "Version: 0.1.0",
    "S/N: —",
    "Not affiliated with Apple.",
  ].join("\n");
  const wakeConsumedButtons = new Set();
  let settingsMutationEpoch = 0;
  const settingMutationVersions = new Map();
  const REPEAT_ONE_UNSUPPORTED =
    "Spotify automation does not support Repeat One.";
  // Scrub mode: Select on Now Playing raises the diamond head, the wheel
  // seeks 1/30 of the track per detent, and the mode times back out after
  // three quiet seconds. The clamp stops 0.25s short of the end so an
  // overshoot cannot run the track out mid-gesture.
  const SCRUB_LINGER_MS = 3_000;
  const SCRUB_STEP = 1 / 30;
  const SCRUB_END_GUARD = 0.25;

  function clearPreviousPressPair() {
    previousPressDeadline = 0;
    previousPressTrackID = null;
    previousRestartPromise = null;
  }

  function beginSettingMutation(setting) {
    const version = (settingMutationVersions.get(setting) || 0) + 1;
    settingMutationVersions.set(setting, version);
    return { epoch: settingsMutationEpoch, version };
  }

  function isCurrentSettingMutation(setting, mutation) {
    return mutation.epoch === settingsMutationEpoch &&
      settingMutationVersions.get(setting) === mutation.version;
  }

  function invalidateSettingMutations() {
    settingsMutationEpoch += 1;
    settingMutationVersions.clear();
  }

  // Sprites are injected as INLINE <svg>, never background-image. WKWebView
  // rasterizes an SVG background-image into an image buffer at the asset's
  // intrinsic 1x size and scales the bitmap up, so every sprite edge shipped
  // blocky on Retina (Geoff, Aug 29: "just look at the pixelation here???").
  // Inline SVG renders in the vector content layer — crisp at every window
  // size by construction. Two constraints the injection must honor:
  //  - Figma exports collide on ids (wheel-face-lit shares paint0_radial_13_146
  //    with the resting wheel face, and several sprites mount more than once);
  //    inline url(#id) resolves document-wide, so every id is suffixed per
  //    instance or gradients/filters cross-link between sprites.
  //  - The image path stretched via background-size: 100% 100% and clipped at
  //    the viewport even where exports declare overflow="visible"; the
  //    injected root gets preserveAspectRatio="none" and .sprite > svg in
  //    device.css supplies the fill-the-span geometry and overflow clip.
  const spriteMarkup = new Map();
  const spritePromises = new Map();
  let spriteInstance = 0;

  function fetchSpriteText(file) {
    let promise = spritePromises.get(file);
    if (!promise) {
      promise = fetch(`${ASSET_BASE}${file}`)
        .then((response) => {
          if (!response.ok) throw new Error(`sprite ${file}: ${response.status}`);
          return response.text();
        })
        .then((text) => {
          spriteMarkup.set(file, text);
          return text;
        });
      spritePromises.set(file, promise);
    }
    return promise;
  }

  function applySprite(node, text) {
    const suffix = `-i${(spriteInstance += 1)}`;
    let svg = text;
    const ids = new Set(
      [...text.matchAll(/\bid="([^"]+)"/g)].map((match) => match[1]),
    );
    for (const id of ids) {
      svg = svg
        .split(`id="${id}"`).join(`id="${id}${suffix}"`)
        .split(`url(#${id})`).join(`url(#${id}${suffix})`)
        .split(`href="#${id}"`).join(`href="#${id}${suffix}"`);
    }
    node.innerHTML = svg;
    node.querySelector("svg")?.setAttribute("preserveAspectRatio", "none");
  }

  function inlineSprite(node, file) {
    // Only vector art needs the inline path — a raster asset (the fingerprint
    // smudge PNG) IS pixels, and injecting its bytes as markup paints nothing.
    if (!file.endsWith(".svg")) {
      node.style.backgroundImage = `url("${ASSET_BASE}${file}")`;
      return;
    }
    const cached = spriteMarkup.get(file);
    if (typeof cached === "string") {
      applySprite(node, cached);
      return;
    }
    fetchSpriteText(file)
      .then((text) => applySprite(node, text))
      .catch(() => {
        // file:// contexts (the QA suite, a plain browser open) refuse fetch;
        // the soft image path beats missing art there.
        node.style.backgroundImage = `url("${ASSET_BASE}${file}")`;
      });
  }

  document.querySelectorAll("[data-sprite]").forEach((node) => {
    const file = SPRITES[node.dataset.sprite];
    if (file) inlineSprite(node, file);
  });
  // Warm every vector sprite so dynamically created rows (list chevrons)
  // inject synchronously instead of popping in a frame after each render.
  for (const file of Object.values(SPRITES)) {
    if (file.endsWith(".svg")) fetchSpriteText(file).catch(() => {});
  }

  window.iosAmpNative = {
    resolve(id, payload) {
      const task = pending.get(id);
      if (!task) return;
      pending.delete(id);
      clearTimeout(task.timeout);
      if (payload.error) task.reject(new Error(payload.error));
      else task.resolve(payload.result);
    },
    foreground() {
      // The return leg of the Music Access dialog's Settings trip. (When a
      // privacy toggle actually flips, iOS relaunches the app and the fresh
      // boot reads the library anyway — this covers the reader who comes
      // back without toggling, plus any grant path that spares the process.)
      refreshMusicAccess();
    },
    battery(value) {
      const level = Number(value?.level);
      if (!Number.isFinite(level)) return;
      battery = {
        level: Math.max(0, Math.min(1, level)),
        charging: value?.charging === true,
      };
      renderBattery();
    },
    nowPlaying(value) {
      const previousTrack = nowPlaying.persistentID;
      const previousPlaybackState = nowPlaying.isPlaying;
      const {
        volume: observedVolume,
        repeat: observedRepeat,
        ...state
      } = value || {};
      if (Number.isFinite(observedVolume)) {
        cancelNativeVolumeSync();
        volume = Math.max(0, Math.min(100, observedVolume));
        nativeVolumeLastSent = performance.now();
        if (screen.type === "volume") {
          panels.lastElementChild?.style.setProperty("--volume", `${volume}%`);
          updateAccessibilityStatus();
        }
      }
      // While the reader is scrubbing (or a seek is still settling in the
      // native player), an observed elapsed value is stale by construction —
      // adopting it would yank the diamond back to wherever the poll last
      // saw the playhead. A track change always wins: the guard only strips
      // elapsed when the push is about the same song.
      if (
        (scrubActive || nativeSeekInFlight || nativeSeekPending ||
          nativeSeekTimer !== 0 || performance.now() < nativeSeekQuietUntil) &&
        "elapsed" in state &&
        (state.persistentID === undefined ||
          state.persistentID === previousTrack)
      ) {
        delete state.elapsed;
      }
      if (
        ["Off", "One", "All"].includes(observedRepeat) &&
        firmwareSettings.repeat !== observedRepeat
      ) {
        firmwareSettings.repeat = observedRepeat;
        storeSettings();
        for (const frame of stack) {
          const row = frame.list?.items?.find(
            (entry) => entry.id === "setting-repeat",
          );
          if (row) row.subtitle = observedRepeat;
        }
        if (
          screen.type === "list" &&
          currentFrame()?.list.id === "settings"
        ) {
          render(false);
        }
      }
      nowPlaying = { ...nowPlaying, ...state };
      if (previousTrack !== nowPlaying.persistentID) {
        clearPreviousPressPair();
        // A new track owns the playhead: drop the scrubber and any seek
        // still queued against the song that just left.
        dropScrub();
        cancelNativeSeekSync();
        nativeSeekQuietUntil = 0;
      }
      const rootChanged = syncRootNowPlaying();
      renderStatus();
      if (
        rootChanged &&
        screen.type === "list" &&
        currentFrame()?.list.id === "root"
      ) {
        render(false);
      } else if (screen.type === "nowPlaying") {
        updateNowPlayingPanel();
        if (
          previousTrack !== nowPlaying.persistentID ||
          previousPlaybackState !== nowPlaying.isPlaying
        ) {
          updateAccessibilityStatus();
        }
      }
      armAutoSleep();
    },
  };

  // --- Device-size choreography ------------------------------------------
  // A size change animates transform only — the cqw layout tree is computed
  // exactly twice (old window, new window), never per frame, and the CSS
  // drop-shadow rides the composited scale instead of being re-stretched to
  // the window bounds the way an AppKit animated frame resize smeared it.
  //   Growing: the native window jumps first; resize listeners run in the
  //   same rendering frame that lays the device out at the new scale, so the
  //   counter-scale snap below paints before anything flashes large, then
  //   glides to 1.
  //   Shrinking: resizeOut() glides the device down inside the old, larger
  //   window first (scaling up out of a smaller window would crop), and the
  //   window jumps once the device already renders at the target size.
  const RESIZE_EASE = "transform 420ms cubic-bezier(0.32, 0.72, 0, 1)";
  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
  // Fractional width, not offsetWidth: the device width is a vw expression,
  // so the true value is fractional and integer rounding skews the
  // counter-scale ratio — the snap frame then misses the compositor's stale
  // frame by up to a pixel, a visible tick at the start of the glide.
  // Computed style, not getBoundingClientRect: the rect is scaled by any
  // transform still held on the frame (the shrink path's resize event fires
  // with scale(target) applied), while computed width is pure layout.
  const measureFrameWidth = () =>
    parseFloat(getComputedStyle(root).width) || root.offsetWidth;
  let frameWidth = measureFrameWidth();
  let frameScaleSettleTimer = 0;
  let holdFrameScaleOnResize = false;

  // The shadow caster is a separate underlay (see app.css), so every scale
  // the choreography puts on .frame must land on it in the same style flush
  // or the shadow visibly detaches from the device mid-animation. Both boxes
  // are concentric and identically sized, so the same center-origin scale is
  // exactly equivalent on each.
  const frameLayers = [
    root,
    document.querySelector("[data-device-shadow]"),
  ].filter(Boolean);
  const setFrameTransform = (transition, transform) => {
    for (const layer of frameLayers) {
      layer.style.transition = transition;
      layer.style.transform = transform;
    }
  };

  let glideStartTimer = 0;

  // When the native window jump can't keep its midpoint (the grown window
  // would cross the dock or menu bar, so setFrameCentered clamps the origin),
  // the device's rest position moves on screen. The native side measures that
  // center shift and reports it through resizeJumped(); holding it here as a
  // translate makes the glide animate position and scale together instead of
  // teleporting by the clamp delta in one frame.
  let jumpOffsetX = 0;
  let jumpOffsetY = 0;
  let heldScale = 1;
  const heldTransform = () =>
    `translate(${jumpOffsetX}px, ${jumpOffsetY}px) scale(${heldScale})`;

  const clearFrameScale = () => {
    clearTimeout(frameScaleSettleTimer);
    frameScaleSettleTimer = 0;
    clearTimeout(glideStartTimer);
    glideStartTimer = 0;
    jumpOffsetX = 0;
    jumpOffsetY = 0;
    heldScale = 1;
    setFrameTransform("", "");
  };

  // The glide out of the counter-scale hold. Cued by the native side once it
  // lifts its snapshot cover (resizeGo below), with a timer fallback so a
  // lost round trip can only delay the glide, never lose it.
  const startResizeGlide = () => {
    if (!glideStartTimer) return;
    clearTimeout(glideStartTimer);
    glideStartTimer = 0;
    setFrameTransform(RESIZE_EASE, "translate(0px, 0px) scale(1)");
    frameScaleSettleTimer = setTimeout(clearFrameScale, 520);
  };

  window.addEventListener("resize", () => {
    const width = measureFrameWidth();
    const previous = frameWidth;
    frameWidth = width;
    if (!previous || !width || previous === width) return;
    // A size change is the user handling the device, so it defers the
    // two-minute idle sleep — and, critically, sleep must never expire while
    // the native snapshot cover is up: the reveal would swap a menu-filled
    // LCD for a blank one in a single frame, which reads as a flash. Sleep
    // only, no backlight: lighting up is reserved for real control presses.
    armAutoSleep();
    if (holdFrameScaleOnResize) {
      // resizeOut() already animated this change; the jump lands on a device
      // that renders at the target size. Usually there is nothing left to
      // show — but if the jump clamped against a screen edge, resizeJumped()
      // holds a translate that still has to glide out. Ordering between this
      // resize event and that message is not guaranteed, so honor whichever
      // arrived first.
      holdFrameScaleOnResize = false;
      heldScale = 1;
      if (jumpOffsetX || jumpOffsetY) {
        clearTimeout(frameScaleSettleTimer);
        frameScaleSettleTimer = 0;
        setFrameTransform("none", heldTransform());
        void root.offsetWidth;
        clearTimeout(glideStartTimer);
        glideStartTimer = setTimeout(startResizeGlide, 1000);
      } else {
        clearFrameScale();
      }
      return;
    }
    if (reducedMotion.matches) {
      clearFrameScale();
      return;
    }
    clearTimeout(frameScaleSettleTimer);
    frameScaleSettleTimer = 0;
    clearTimeout(glideStartTimer);
    // Snap to the old visual size and HOLD — the native side is showing a
    // snapshot of exactly these pixels over the web view, and starting the
    // glide under that cover would make the reveal land mid-flight.
    heldScale = previous / width;
    setFrameTransform("none", heldTransform());
    // Commit the snap so the glide has a from-state.
    void root.offsetWidth;
    // Fallback only. The normal cue is resizeGo() after the native reveal
    // (double-rAF + 100ms settle + 120ms dissolve), and the native side has
    // its own wedge fallback at 800ms that ends the same way. This clock
    // must sit PAST all of that — firing earlier starts the glide under the
    // snapshot cover, and the reveal lands mid-flight (the flicker).
    glideStartTimer = setTimeout(startResizeGlide, 1000);
  });

  window.iosAmpFirmware = {
    resizeOut(ratio) {
      const target = Number(ratio);
      if (
        !Number.isFinite(target) ||
        target <= 0 ||
        target >= 1 ||
        reducedMotion.matches
      ) {
        holdFrameScaleOnResize = true;
        return Promise.resolve();
      }
      return new Promise((resolve) => {
        let done = false;
        const finish = () => {
          if (done) return;
          done = true;
          root.removeEventListener("transitionend", onEnd);
          // Hold the shrunken transform until the native window jump lands;
          // the resize listener clears it in that same rendering frame.
          holdFrameScaleOnResize = true;
          resolve();
        };
        const onEnd = (event) => {
          if (event.target === root && event.propertyName === "transform") {
            finish();
          }
        };
        clearTimeout(frameScaleSettleTimer);
        clearTimeout(glideStartTimer);
        glideStartTimer = 0;
        root.addEventListener("transitionend", onEnd);
        setFrameTransform(RESIZE_EASE, `scale(${target})`);
        // Never leave the native side waiting on a lost transitionend.
        setTimeout(finish, 620);
      });
    },
    resizeGo() {
      startResizeGlide();
    },
    // The native jump couldn't keep the window's midpoint (screen-edge
    // clamp); dx/dy is the CSS-space shift that puts the new layout back on
    // the device's old screen position. Fold it into the held transform so
    // the glide travels position and scale together. Arrives while the
    // snapshot cover is still up, in unguaranteed order relative to this
    // page's own resize event — both orders converge on the same hold.
    resizeJumped(dx, dy) {
      const x = Number(dx);
      const y = Number(dy);
      if (!Number.isFinite(x) || !Number.isFinite(y)) return;
      if (reducedMotion.matches) return;
      jumpOffsetX = x;
      jumpOffsetY = y;
      setFrameTransform("none", heldTransform());
      void root.offsetWidth;
      if (!glideStartTimer) {
        glideStartTimer = setTimeout(startResizeGlide, 1000);
      }
    },
    // Native cancel path: a resize was requested toward the size the window
    // already has (double-press race). Drop any held counter-scale and armed
    // timers so nothing glides later out of a stale hold.
    resizeReset() {
      holdFrameScaleOnResize = false;
      clearFrameScale();
    },
    setGlare(value) {
      const level = Number(value);
      if (!Number.isFinite(level)) return;
      // Slider contract: 0 = no reflection, 1 = the designed look (the
      // notch), 2 = the top end. The shell's wedge rides --glare as its
      // opacity and saturates at 1; the glass copy runs at 0.45 × --glare,
      // so above the notch it keeps strengthening toward 0.9 — over-glare
      // reads as more light on the screen glass.
      document.documentElement.style.setProperty(
        "--glare",
        String(Math.min(2, Math.max(0, level))),
      );
    },
    setShadow(value) {
      const level = Number(value);
      if (!Number.isFinite(level)) return;
      // Slider contract: 0 = no shadow, 1 = the designed look (the notch),
      // 2 = the top end. Below 1 only alpha dims; above 1 the shadow grows
      // — blur fastest so the top end reads as a heavier, softer pool, not
      // an inky copy of the same shape. Ceilings sized to the window's
      // shadow gutters (see .deviceShadow in app.css).
      const s = Math.min(2, Math.max(0, level));
      const over = Math.max(0, s - 1);
      const root = document.documentElement.style;
      root.setProperty("--shadow-alpha", String(Math.min(1, s) + over * 0.5));
      root.setProperty("--shadow-off", String(1 + over * 0.35));
      root.setProperty("--shadow-blur", String(1 + over * 0.8));
    },
    setHold(enabled) {
      holdEnabled = Boolean(enabled);
      root.toggleAttribute("data-hold", holdEnabled);
      document.querySelectorAll("[data-button]").forEach((button) => {
        button.disabled = holdEnabled;
      });
      if (holdEnabled) {
        inputEpoch += 1;
        navigationPending = false;
        navigationPendingToken += 1;
        wheelPointer = null;
        wheelRect = null;
        detentTravel = 0;
        rowTravel = 0;
        detentDirection = 0;
        delete interactive.dataset.dragging;
        for (const timer of holdTimers.values()) clearTimeout(timer);
        holdTimers.clear();
        consumedHolds.clear();
        releaseActiveSeek().catch(() => {});
        clearResetChord();
        pressedButtons.clear();
        wakeConsumedButtons.clear();
        clearPreviousPressPair();
        menuHoldBacklightTarget = null;
        document.querySelectorAll("[data-button][data-pressed]").forEach((button) => {
          delete button.dataset.pressed;
        });
        delete pressSurface.dataset.button;
        delete selectSurface.dataset.active;
        delete pressSurface.dataset.hover;
        delete selectSurface.dataset.hover;
      }
      updateAccessibilityStatus();
    },
    snapshot() {
      return {
        screen: screen.type,
        title: screenTitle(),
        sleeping,
        backlight,
        hold: holdEnabled,
        volume,
        battery: { ...battery },
        scrub: scrubActive,
        detentTravel,
        rowTravel,
        selected: currentItem()?.id || null,
        path: stack.map((frame) => frame.list.id),
        settings: { ...firmwareSettings },
        nowPlaying: { ...nowPlaying },
        breakout: breakout
          ? {
              paddle: breakout.paddle,
              launched: breakout.launched,
              score: breakout.score,
              lives: breakout.lives,
            }
          : null,
      };
    },
  };

  function item(id, title, kind, subtitle = null, playable = false) {
    return {
      id,
      title,
      subtitle,
      kind,
      hasChildren: kind === "container",
      playable,
      checked: null,
    };
  }

  function nativeRequest(method, params = {}) {
    if (firmwareClosing) {
      return Promise.reject(new Error("iosAmp is closing"));
    }
    const handler = window.webkit?.messageHandlers?.iosAmp;
    if (!handler) return fallbackRequest(method, params);
    const id = `${requestNamespace}:r${++requestCounter}`;
    const timeoutMs = method === "playCollection" ||
      method === "setPlaybackSetting"
      ? 65_000
      : method === "getList" || method === "refreshLibrary" || method === "getAbout"
        ? 35_000
        : [
            "ready", "select", "setTransport", "playPause", "pause",
            "skip", "seek", "beginSeek", "endSeek", "setVolume",
          ].includes(method)
          ? 25_000
          : 15_000;
    return new Promise((resolve, reject) => {
      const timeout = setTimeout(() => {
        pending.delete(id);
        reject(new Error(`Native request timed out: ${method}`));
      }, timeoutMs);
      pending.set(id, { resolve, reject, timeout });
      handler.postMessage({ version: 1, id, method, params });
    });
  }

  // A detent the hand can feel. Fire-and-forget rather than a nativeRequest;
  // cap the native handoff so a high-resolution trackpad cannot flood AppKit.
  // AppKit ignores it on trackpads without a haptic engine, so no gate here.
  let lastHapticAt = -Infinity;
  function hapticDetent() {
    const now = performance.now();
    if (now - lastHapticAt < 24) return;
    lastHapticAt = now;
    const handler = window.webkit?.messageHandlers?.iosAmp;
    if (!handler) return;
    handler.postMessage({
      version: 1,
      method: "haptic",
      params: {},
    });
  }

  async function fallbackRequest(method, params) {
    if (method === "ready") return { transport: "system" };
    if (method === "getList") {
      const found = DEMO_LISTS[params.path];
      if (found) return found;
      return {
        id: params.path,
        title: "Empty",
        items: [item("empty", "No music found", "action")],
      };
    }
    if (method === "getSettings") {
      return { transport, alwaysOnTop: false };
    }
    if (method === "getNowPlaying") return nowPlaying;
    if (method === "select") {
      const selected = currentItem();
      nowPlaying = {
        source: "demo",
        persistentID: selected?.id || null,
        title: selected?.title || "Not Playing",
        artist: selected?.subtitle || "",
        elapsed: 0,
        duration: 213,
        isPlaying: true,
      };
      syncRootNowPlaying();
      return { transport: "demo" };
    }
    if (method === "playPause") {
      nowPlaying.isPlaying = !nowPlaying.isPlaying;
    }
    return null;
  }

  // Every cue announces its request; successful source starts separately emit
  // `iosamp-audio-played`, so QA cannot mistake a requested-but-silent cue for
  // working SFX.
  function audioPool(src, count, volume) {
    if (!audioContext) return htmlAudioPool(src, count, volume);
    let buffer = null;
    let fallback = null;
    const playFallback = () => {
      if (!fallback) fallback = htmlVoices(src, count, volume);
      fallback();
    };
    // The app's private iosamp:// origin lets Web Audio fetch bundled samples
    // without granting the page access to arbitrary file:// URLs. Keep an
    // HTMLAudio fallback for decode failures; silence is unacceptable.
    fetch(src)
      .then((response) => response.arrayBuffer())
      .then((data) => audioContext.decodeAudioData(data))
      .then((decoded) => {
        buffer = decoded;
      })
      .catch(() => {});
    const gain = audioContext.createGain();
    gain.gain.value = volume;
    gain.connect(audioContext.destination);
    return () => {
      window.dispatchEvent(new CustomEvent("iosamp-audio", { detail: src }));
      // A first press can beat asynchronous fetch/decode during a cold launch.
      // Use HTMLAudio for that one cue instead of silently dropping it; later
      // cues switch to the decoded low-latency buffer as soon as it is ready.
      if (!buffer) {
        playFallback();
        return;
      }
      const playBuffer = () => {
        if (audioContext.state !== "running") return;
        const voice = audioContext.createBufferSource();
        voice.buffer = buffer;
        voice.connect(gain);
        voice.addEventListener("ended", () => voice.disconnect(), { once: true });
        voice.start();
        window.dispatchEvent(new CustomEvent("iosamp-audio-played", { detail: src }));
      };
      // Resume can settle a few milliseconds after the capture-phase gesture.
      // Preserve exactly one cue through that handoff; queueing every detent
      // would release them as a burst when the context wakes.
      if (audioContext.state !== "running") {
        if (!deferredAudioCue) deferredAudioCue = playBuffer;
        void resumeAudioContext();
        return;
      }
      playBuffer();
    };
  }

  function htmlAudioPool(src, count, volume) {
    const play = htmlVoices(src, count, volume);
    return () => {
      window.dispatchEvent(new CustomEvent("iosamp-audio", { detail: src }));
      play();
    };
  }

  function htmlVoices(src, count, volume) {
    const voices = Array.from({ length: count }, () => {
      const audio = new Audio(src);
      audio.preload = "auto";
      audio.volume = volume;
      return audio;
    });
    let cursor = 0;
    return () => {
      const audio = voices[cursor++ % voices.length];
      audio.currentTime = 0;
      audio.play()
        .then(() => {
          window.dispatchEvent(new CustomEvent("iosamp-audio-played", { detail: src }));
        })
        .catch(() => {});
    };
  }

  function currentFrame() {
    return stack[stack.length - 1] || null;
  }

  function currentItem() {
    const frame = currentFrame();
    return frame?.list.items[frame.selected] || null;
  }

  function listFrame(list) {
    return { list, selected: 0, scroll: 0 };
  }

  const MUSIC_SENTINELS = ["music-empty", "music-request", "music-denied"];

  // On foreground, re-read the library if the reader is looking at a music
  // sentinel or at the Music Access dialog itself. A granted permission
  // dismisses the dialog onto the freshly populated list; anything else
  // leaves the screen exactly where the reader parked it.
  async function refreshMusicAccess() {
    if (screen.type !== "list") return;
    const frame = currentFrame();
    if (!frame) return;
    const onDialog = frame.list.id === "music-access";
    const onSentinel = frame.list.items.some(
      (entry) => MUSIC_SENTINELS.includes(entry.id),
    );
    if (!onDialog && !onSentinel) return;
    const path = onDialog ? frame.list.sourcePath ?? "music" : frame.list.id;
    const generation = ++navigationGeneration;
    let list;
    try {
      list = await nativeRequest("getList", { path });
    } catch {
      return;
    }
    if (generation !== navigationGeneration) return;
    const stillBlocked = list.items.some(
      (entry) => MUSIC_SENTINELS.includes(entry.id),
    );
    if (onDialog) {
      // Still denied: the dialog keeps explaining. Granted: its job is done —
      // land directly on the songs it promised.
      if (stillBlocked) return;
      stack.pop();
    }
    stack[stack.length - 1] = listFrame(list);
    render(false);
  }

  function syncRootNowPlaying() {
    const rootFrame = stack.find((frame) => frame.list.id === "root");
    if (!rootFrame) return false;
    const hasRow = rootFrame.list.items.some((entry) => entry.id === "now-playing");
    const shouldHaveRow = Boolean(nowPlaying.persistentID);
    if (shouldHaveRow && !hasRow) {
      rootFrame.list.items.push(item("now-playing", "Now Playing", "action"));
      return true;
    } else if (!shouldHaveRow && hasRow) {
      rootFrame.list.items = rootFrame.list.items.filter(
        (entry) => entry.id !== "now-playing",
      );
      rootFrame.selected = Math.min(rootFrame.selected, rootFrame.list.items.length - 1);
      return true;
    }
    return false;
  }

  function makeRootFrame() {
    const items = nowPlaying.persistentID
      ? [...ROOT_ITEMS, item("now-playing", "Now Playing", "action")]
      : [...ROOT_ITEMS];
    return listFrame({
      id: "root",
      title: "iPod",
      items,
    });
  }

  async function showRoot(back = false) {
    navigationGeneration += 1;
    clearTimeout(volumeTimer);
    const old = renderPanel();
    stack = [makeRootFrame()];
    screen = { type: "list" };
    renderWithPush(old, back);
    armAutoSleep();
  }

  async function openList(path) {
    const generation = ++navigationGeneration;
    const restoreListFocus =
      document.activeElement?.classList?.contains("listViewport");
    const old = renderPanel();
    showMessage("Loading…");
    try {
      const list = path === "settings"
        ? await settingsList()
        : path === "iosamp-settings"
          ? await iosAmpSettingsList()
        : await nativeRequest("getList", { path });
      if (generation !== navigationGeneration) return;
      const outgoing = panels.firstElementChild || old;
      stack.push(listFrame(list));
      screen = { type: "list" };
      renderWithPush(outgoing, false, restoreListFocus);
    } catch (error) {
      if (generation !== navigationGeneration) return;
      showMessage(error.message || "Couldn’t load music");
    }
  }

  async function settingsList() {
    return {
      id: "settings",
      title: "Settings",
      items: [
        { ...item("setting-shuffle", "Shuffle", "toggle"), subtitle: firmwareSettings.shuffle },
        { ...item("setting-repeat", "Repeat", "action"), subtitle: firmwareSettings.repeat },
        item("setting-backlight", "Backlight Timer", "action"),
        item("setting-contrast", "Contrast", "action"),
        { ...item("setting-clicker", "Clicker", "toggle"), subtitle: firmwareSettings.clicker },
        item("setting-sleep", "Sleep Timer", "action"),
        item("setting-startup", "Startup Volume", "action"),
        item("setting-language", "Language", "action"),
        // The one deliberate divergence from the hardware's own menu on iOS:
        // the phone's screen is much taller than the device, so where the
        // iPod sits on it is a real choice the hardware never had to offer.
        ...(IS_IOS ? [item("setting-placement", "Placement", "action")] : []),
        item("setting-reset", "Reset All Settings", "action"),
        // The "iPod Player" submenu is desktop app chrome: Source picks
        // between the Mac's two transports (the phone has one engine),
        // Always On Top is a window level, and Quit is the Home gesture.
        // Omitting the row leaves Settings exactly the hardware's own menu.
        ...(IS_IOS ? [] : [item("iosamp-settings", "iPod Player", "container")]),
      ],
    };
  }

  async function iosAmpSettingsList() {
    const settings = await nativeRequest("getSettings");
    transport = settings?.transport || transport;
    return {
      id: "iosamp-settings",
      title: "iPod Player",
      items: [
        {
          ...item("iosamp-source", "Source", "action"),
          subtitle: transport === "music" ? "Music" : "System",
        },
        {
          ...item("iosamp-top", "Always On Top", "toggle"),
          subtitle: settings?.alwaysOnTop ? "On" : "Off",
        },
        item("iosamp-quit", "Quit", "action"),
      ],
    };
  }

  function openSettingChoices(setting, label, values) {
    const old = renderPanel();
    const frame = listFrame({
      id: `settings/${setting}`,
      title: label,
      items: values.map((value) => ({
        ...item(`setting-choice:${setting}:${value}`, value, "action"),
      })),
    });
    frame.selected = Math.max(0, values.indexOf(firmwareSettings[setting]));
    frame.scroll = Math.max(0, frame.selected - 5);
    stack.push(frame);
    renderWithPush(old, false);
  }

  function openSettingGauge(setting, label) {
    const old = renderPanel();
    const value = Number.parseInt(firmwareSettings[setting], 10);
    screen = {
      type: "gauge",
      setting,
      label,
      value: Number.isFinite(value) ? value : 50,
      min: 0,
      max: 100,
      step: 5,
      format: (next) => `${next}%`,
    };
    renderWithPush(old, false);
  }

  function showNowPlaying(forward = true) {
    const old = renderPanel();
    screen = { type: "nowPlaying" };
    renderWithPush(old, !forward);
  }

  async function showAbout() {
    const generation = ++navigationGeneration;
    const old = renderPanel();
    try {
      const info = await nativeRequest("getAbout");
      aboutMessage = [
        `Related Mac: ${info?.relatedMac || "This Mac"}`,
        `Capacity: ${info?.capacity || "—"}`,
        `Available: ${info?.available || "—"}`,
        `Version: ${info?.version || "0.1.0"}`,
        `S/N: ${info?.serial || "—"}`,
        "Not affiliated with Apple.",
      ].join("\n");
    } catch {
      // Keep the last known About fields if the native provider is unavailable.
    }
    if (generation !== navigationGeneration) return;
    screen = {
      type: "message",
      title: "About",
      message: aboutMessage,
    };
    renderWithPush(old, false);
  }

  function showMessage(message) {
    screen = { type: "message", title: "", message };
    render(false);
  }

  function firmwareDisplayHasFocus() {
    return document.activeElement === screenRegion ||
      document.activeElement?.classList?.contains("listViewport");
  }

  function focusFirmwareDestination(panel) {
    const list = panel?.matches?.(".listViewport")
      ? panel
      : panel?.querySelector?.(".listViewport");
    (list || screenRegion)?.focus?.({ preventScroll: true });
  }

  function render(animate = false) {
    const restoreFirmwareFocus = firmwareDisplayHasFocus();
    clearTimeout(pushTimer);
    pushTimer = 0;
    renderStatus();
    title.textContent = screenTitle();
    panels.replaceChildren(renderPanel());
    root.dataset.screen = screen.type;
    if (restoreFirmwareFocus) focusFirmwareDestination(panels.lastElementChild);
    prepareSelectedRow();
    prepareNowPlayingOverflow();
    updateAccessibilityStatus();
  }

  function renderWithPush(oldPanel, back, forceListFocus = false) {
    const restoreFirmwareFocus = forceListFocus || firmwareDisplayHasFocus();
    renderStatus();
    title.textContent = screenTitle();
    const next = renderPanel();
    root.dataset.screen = screen.type;
    clearTimeout(pushTimer);
    if (
      !oldPanel ||
      window.matchMedia("(prefers-reduced-motion: reduce)").matches
    ) {
      panels.replaceChildren(next);
      if (restoreFirmwareFocus) focusFirmwareDestination(next);
      prepareSelectedRow();
      prepareNowPlayingOverflow();
      updateAccessibilityStatus();
      return;
    }
    const oldClass = back ? "pushOutRight" : "pushOutLeft";
    const nextClass = back ? "pushInLeft" : "pushInRight";
    oldPanel.setAttribute("aria-hidden", "true");
    oldPanel.classList.add(oldClass);
    next.classList.add(nextClass);
    panels.replaceChildren(oldPanel, next);
    if (restoreFirmwareFocus) focusFirmwareDestination(next);
    prepareSelectedRow();
    prepareNowPlayingOverflow();
    updateAccessibilityStatus();
    pushTimer = setTimeout(() => {
      next.classList.remove(nextClass);
      // Removing only the outgoing panel keeps the already-focused incoming
      // list in the document. Re-inserting `next` here makes WebKit/Chromium
      // drop listbox focus to BODY after every keyboard-driven push.
      oldPanel.remove();
    }, 280);
  }

  function renderStatus() {
    if (sleeping || !nowPlaying.persistentID) {
      statusPlayback.removeAttribute("data-state");
      return;
    }
    statusPlayback.dataset.state = nowPlaying.isPlaying ? "playing" : "paused";
  }

  function renderBattery() {
    if (!batteryBars) return;
    // Four segments, metered like the hardware: a bar lights for any charge
    // inside its quarter (ceil), so only a truly dead battery shows empty.
    const bars = Math.max(0, Math.min(4, Math.ceil(battery.level * 4)));
    batteryBars.style.setProperty("--battery-bars", String(bars));
    // Actively charging runs the firmware's filling animation; a full battery
    // on power reads as charged and holds solid instead.
    if (battery.charging && battery.level < 0.995) {
      batteryBars.setAttribute("data-charging", "");
    } else {
      batteryBars.removeAttribute("data-charging");
    }
  }

  function updateAccessibilityStatus() {
    if (!screenReaderStatus) return;
    let message = "";
    if (holdEnabled) {
      message = "Hold switch on. Controls locked.";
    } else if (sleeping) {
      message = "iPod sleeping. Press a control to wake.";
    } else if (screen.type === "list") {
      const frame = currentFrame();
      const selected = frame?.list.items[frame.selected];
      message = selected
        ? `${frame.list.title}. ${selected.title}. ${frame.selected + 1} of ${frame.list.items.length}.`
        : `${frame?.list.title || "iPod"}. Empty list.`;
    } else if (screen.type === "nowPlaying") {
      const state = nowPlaying.isPlaying ? "Playing" : "Paused";
      message = `${state}. ${nowPlaying.title}${nowPlaying.artist ? ` by ${nowPlaying.artist}` : ""}.`;
    } else if (screen.type === "volume") {
      message = `Volume ${Math.round(volume)} percent.`;
    } else if (screen.type === "gauge") {
      message = `${screen.label} ${screen.format(screen.value)}.`;
    } else if (screen.type === "message") {
      message = [screen.title, screen.message].filter(Boolean).join(". ");
    } else if (screen.type === "welcome") {
      message = WELCOME_LINES.join(". ");
    } else if (screen.type === "breakout") {
      message = "Breakout game.";
    } else if (screen.type === "boot") {
      message = "iPod Player starting.";
    } else {
      message = "iPod Player.";
    }
    if (screenReaderStatus.textContent !== message) {
      screenReaderStatus.textContent = message;
    }
  }

  function screenTitle() {
    if (
      screen.type === "splash" ||
      screen.type === "welcome" ||
      screen.type === "boot" ||
      sleeping
    ) {
      return "";
    }
    if (screen.type === "nowPlaying" || screen.type === "volume") return "Now Playing";
    if (screen.type === "message") return screen.title || "";
    return currentFrame()?.list.title || "iPod";
  }

  function renderPanel() {
    if (sleeping) {
      const panel = document.createElement("div");
      panel.className = "messagePanel";
      return panel;
    }
    if (screen.type === "splash") return splashPanel();
    if (screen.type === "welcome") return welcomePanel();
    if (screen.type === "boot") return bootPanel();
    if (screen.type === "nowPlaying") return nowPlayingPanel();
    if (screen.type === "volume") return volumePanel();
    if (screen.type === "gauge") return gaugePanel();
    if (screen.type === "breakout") return breakoutPanel();
    if (screen.type === "message") return messagePanel(screen.message);
    return listPanel(currentFrame());
  }

  // Between the Don't-steal splash and the root menu: what this is, and the
  // soft legal line Geoff asked for at the start (Aug 27: nostalgia, hope
  // Apple thinks this is chill). Shared by Mac and iOS — same web UI.
  const WELCOME_LINES = [
    "iPod Player",
    "Made for nostalgia.",
    "Not Apple. Hope they're chill.",
  ];
  const WELCOME_HOLD_MS = 12_000;

  // The splash is the web version's boot scene (Geoff, Aug 31: "it has a
  // lightspark logo and a thing that says dont steal music"): the spark in
  // LCD ink over a stepped progress bar, an instant cut to the wordmark,
  // then the first iPod's wrap-sticker line cycling its three languages.
  // Replays every visit; reduced motion lands on the finished screen.
  const INTRO_BOOT_MS = 1500; // COUPLED to the introBootFill duration in app.css
  const INTRO_STICKER_DELAY_MS = 900;
  const INTRO_STICKER_STEP_MS = 2600;
  const INTRO_STICKERS = [
    "Don't steal music.",
    "Ne volez pas la musique.",
    "Bitte keine Musik stehlen.",
  ];
  const SPARK_VIEWBOX = "0 0 53.375 33.561";
  const SPARK_PATH =
    "M18.7335 19.136L2.08161 29.9498H10.7325L23.9066 21.3944V33.5607L29.4668 29.9499V19.136H46.1187L53.3741 14.4243H34.6397L51.2911 3.61077L42.6402 3.61077L29.4668 12.1657V0L23.9066 3.6108V14.4243H7.25533L0 19.136H18.7335Z";
  let splashTimers = [];

  function clearSplashTimers() {
    for (const id of splashTimers) clearTimeout(id);
    splashTimers = [];
  }

  function splashPanel() {
    clearSplashTimers();
    const panel = document.createElement("div");
    panel.className = "introPanel";
    panel.innerHTML = `
      <svg class="introSpark" viewBox="${SPARK_VIEWBOX}" aria-hidden="true">
        <path d="${SPARK_PATH}"></path>
      </svg>
      <div class="introBoot"><span class="introBootFill"></span></div>
      <div class="introWordmark" hidden>iPod Player</div>
      <div class="introSticker">${INTRO_STICKERS[0]}</div>`;
    const boot = panel.querySelector(".introBoot");
    const wordmark = panel.querySelector(".introWordmark");
    const sticker = panel.querySelector(".introSticker");
    const finish = () => {
      boot.remove();
      wordmark.hidden = false;
      sticker.dataset.visible = "";
    };
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      finish();
      return panel;
    }
    let stickerIndex = 0;
    const cycleSticker = () => {
      if (!panel.isConnected) return;
      stickerIndex = (stickerIndex + 1) % INTRO_STICKERS.length;
      sticker.textContent = INTRO_STICKERS[stickerIndex];
      splashTimers.push(setTimeout(cycleSticker, INTRO_STICKER_STEP_MS));
    };
    splashTimers.push(
      setTimeout(() => {
        boot.remove();
        wordmark.hidden = false;
      }, INTRO_BOOT_MS),
      setTimeout(() => {
        if (!panel.isConnected) return;
        sticker.dataset.visible = "";
        splashTimers.push(setTimeout(cycleSticker, INTRO_STICKER_STEP_MS));
      }, INTRO_BOOT_MS + INTRO_STICKER_DELAY_MS)
    );
    return panel;
  }

  function bootPanel() {
    const panel = document.createElement("div");
    panel.className = "splashPanel";
    panel.innerHTML = '<div class="bootMark">iPod Player</div>';
    return panel;
  }

  function welcomePanel() {
    const panel = document.createElement("div");
    panel.className = "welcomePanel";
    panel.innerHTML = WELCOME_LINES.map(
      (line, index) =>
        `<div class="welcomeLine"${index === 0 ? ' data-mark' : ""}>${line}</div>`,
    ).join("");
    return panel;
  }

  function showWelcome(back = false) {
    clearSplashTimers();
    clearTimeout(startupTimer);
    // Capture the live panel from the DOM — calling renderPanel() while
    // still on splash would rebuild the intro and restart its sticker cycle.
    const old = panels.firstElementChild;
    stack = [];
    screen = { type: "welcome" };
    renderWithPush(old, back);
    startupTimer = setTimeout(() => showRoot(false), WELCOME_HOLD_MS);
    armAutoSleep();
  }

  function messagePanel(message) {
    const panel = document.createElement("div");
    panel.className = "messagePanel";
    if (screen.title === "About") panel.classList.add("aboutPanel");
    const copy = document.createElement("div");
    copy.className = message === "Loading…" ? "loadingRow" : "";
    copy.innerText = message;
    panel.append(copy);
    return panel;
  }

  function listPanel(frame) {
    if (!frame) return messagePanel("No menu");
    if (frame.list.message) return dialogPanel(frame);
    const panelID = ++panelCounter;
    const viewport = document.createElement("div");
    viewport.className = "listViewport";
    viewport.tabIndex = 0;
    viewport.setAttribute("role", "listbox");
    viewport.setAttribute("aria-label", frame.list.title);
    const track = document.createElement("div");
    track.className = "listTrack";
    track.style.top = `${-(frame.scroll * 100) / 6}%`;
    const itemCount = frame.list.items.length;
    const layoutCount = Math.max(itemCount, 6);
    track.style.height = `${(layoutCount * 100) / 6}%`;
    track.style.setProperty("--song-count", layoutCount);
    track.dataset.itemCount = String(itemCount);

    // A large library still lives in the model, but it must not become tens of
    // thousands of DOM rows. Keep the visible six plus a generous runway on
    // either side; crossing that runway rebuilds only this small window.
    const virtualized = itemCount > 48;
    const renderStart = virtualized ? Math.max(0, frame.scroll - 8) : 0;
    const renderEnd = virtualized
      ? Math.min(itemCount, frame.scroll + 14)
      : itemCount;
    track.dataset.renderStart = String(renderStart);
    track.dataset.renderEnd = String(renderEnd);
    for (let index = renderStart; index < renderEnd; index += 1) {
      const entry = frame.list.items[index];
      const row = document.createElement("div");
      row.className = "listRow";
      row.id = `iosamp-option-${panelID}-${index}`;
      row.setAttribute("role", "option");
      row.setAttribute("aria-selected", String(index === frame.selected));
      row.dataset.rowIndex = String(index);
      if (virtualized) {
        row.style.position = "absolute";
        row.style.insetInline = "0";
        row.style.top = `${index * 100 / layoutCount}%`;
      }
      if (index === frame.selected) row.dataset.selected = "";
      const label = document.createElement("span");
      label.className = "rowTitle";
      const labelText = document.createElement("span");
      labelText.className = "rowTitleText";
      labelText.textContent = entry.title;
      label.append(labelText);
      row.append(label);
      const value = entry.kind === "toggle" && entry.checked !== null
        ? (entry.checked ? "On" : "Off")
        : entry.kind === "track"
          ? null
          : entry.subtitle;
      if (value) {
        const detail = document.createElement("span");
        detail.className = "settingValue";
        detail.textContent = value;
        row.append(detail);
      }
      if (showsChevron(entry)) {
        const chevron = document.createElement("span");
        chevron.className = "sprite chevron";
        inlineSprite(chevron, SPRITES.chevron);
        row.append(chevron);
      }
      track.append(row);
      if (index === frame.selected) {
        viewport.setAttribute("aria-activedescendant", row.id);
      }
    }
    viewport.append(track);
    if (frame.list.items.length > 6) {
      const scrollbar = document.createElement("span");
      scrollbar.className = "listScrollbar";
      const thumb = document.createElement("span");
      const height = 6 / frame.list.items.length * 100;
      const travel = 100 - height;
      const progress = frame.scroll / (frame.list.items.length - 6);
      thumb.style.height = `${height}%`;
      thumb.style.top = `${progress * travel}%`;
      scrollbar.append(thumb);
      viewport.append(scrollbar);
    }
    return viewport;
  }

  // A firmware dialog: a short explanation up top and two wheel-selectable
  // rows pinned to the LCD's bottom edge. It is still a list frame — the
  // wheel, Select, and Menu all work unmodified — but it renders no
  // .listTrack, so per-detent selection falls back to a full re-render
  // (two rows; free). Only the Music Access screen uses it today.
  function dialogPanel(frame) {
    const panelID = ++panelCounter;
    const viewport = document.createElement("div");
    viewport.className = "listViewport dialogViewport";
    viewport.tabIndex = 0;
    viewport.setAttribute("role", "listbox");
    viewport.setAttribute("aria-label", frame.list.title);
    // Both the body's bottom inset and the rows' height read this count.
    viewport.style.setProperty("--song-count", frame.list.items.length);
    const body = document.createElement("div");
    body.className = "dialogBody";
    body.innerText = frame.list.message;
    viewport.append(body);
    const rows = document.createElement("div");
    rows.className = "dialogRows";
    frame.list.items.forEach((entry, index) => {
      const row = document.createElement("div");
      row.className = "listRow";
      row.id = `iosamp-option-${panelID}-${index}`;
      row.setAttribute("role", "option");
      row.setAttribute("aria-selected", String(index === frame.selected));
      row.dataset.rowIndex = String(index);
      if (index === frame.selected) row.dataset.selected = "";
      const label = document.createElement("span");
      label.className = "rowTitle";
      const labelText = document.createElement("span");
      labelText.className = "rowTitleText";
      labelText.textContent = entry.title;
      label.append(labelText);
      row.append(label);
      rows.append(row);
      if (index === frame.selected) {
        viewport.setAttribute("aria-activedescendant", row.id);
      }
    });
    viewport.append(rows);
    return viewport;
  }

  function prepareSelectedRow() {
    if (selectedRowRaf) return;
    selectedRowRaf = requestAnimationFrame(() => {
      selectedRowRaf = 0;
      const label = panels.lastElementChild?.querySelector(
        ".listRow[data-selected] .rowTitle",
      );
      if (!label) return;
      label.toggleAttribute("data-overflow", label.scrollWidth > label.clientWidth + 1);
    });
  }

  function prepareNowPlayingOverflow(restartSelectors = []) {
    for (const selector of restartSelectors) pendingMarqueeRestarts.add(selector);
    if (nowPlayingOverflowRaf) return;
    nowPlayingOverflowRaf = requestAnimationFrame(() => {
      nowPlayingOverflowRaf = 0;
      const pairs = [
        [".nowPlayingTitle", ".titleScroll"],
        [".nowPlayingArtist", ".nowPlayingArtist > span"],
        [".nowPlayingAlbum", ".nowPlayingAlbum > span"],
      ];
      for (const [containerSelector, contentSelector] of pairs) {
        const container = panels.querySelector(containerSelector);
        const content = panels.querySelector(contentSelector);
        if (!container || !content) continue;
        const overflows = container.scrollWidth > container.clientWidth + 1;
        container.toggleAttribute("data-long", overflows);
        if (
          overflows &&
          pendingMarqueeRestarts.has(contentSelector) &&
          getComputedStyle(content).animationName !== "none"
        ) {
          content.style.animation = "none";
          void content.offsetWidth;
          content.style.removeProperty("animation");
        }
      }
      pendingMarqueeRestarts.clear();
    });
  }

  function showsChevron(entry) {
    return entry.hasChildren ||
      entry.id === "about" ||
      entry.id === "now-playing" ||
      entry.id === "setting-backlight" ||
      entry.id === "setting-contrast" ||
      entry.id === "setting-sleep" ||
      entry.id === "setting-startup" ||
      entry.id === "setting-language" ||
      entry.id === "setting-reset";
  }

  function nowPlayingPanel() {
    const panel = document.createElement("div");
    panel.className = "nowPlaying";
    const progress = nowPlaying.duration > 0
      ? Math.min(100, Math.max(0, nowPlaying.elapsed / nowPlaying.duration * 100))
      : 0;
    panel.style.setProperty("--progress", `${progress}%`);
    const albumText = nowPlaying.album || "";
    const queuePosition = nowPlaying.queuePosition && nowPlaying.queueCount
      ? `${nowPlaying.queuePosition} of ${nowPlaying.queueCount}`
      : "";
    const modeIcons = [
      firmwareSettings.shuffle === "On"
        ? '<i class="shuffleIcon" aria-label="Shuffle"></i>'
        : "",
      firmwareSettings.repeat === "One"
        ? '<i class="repeatIcon" data-one aria-label="Repeat one"></i>'
        : firmwareSettings.repeat === "All"
          ? '<i class="repeatIcon" aria-label="Repeat all"></i>'
          : "",
    ].join("");
    if (scrubActive) panel.dataset.scrub = "";
    panel.innerHTML = `
      <div class="nowPlayingContext"><span>${queuePosition}</span><span class="modeIcons">${modeIcons}</span></div>
      <div class="nowPlayingTitle"><span class="titleScroll"></span></div>
      <div class="nowPlayingArtist"><span></span></div>
      <div class="nowPlayingAlbum"><span></span></div>
      <div class="progressRow">
        <span class="timeReadout">${formatTime(nowPlaying.elapsed)}</span>
        <div class="progressTrack">
          <span class="progressFill"></span>
          <span class="progressHead"></span>
        </div>
      </div>`;
    panel.querySelector(".titleScroll").textContent = nowPlaying.title;
    panel.querySelector(".nowPlayingArtist span").textContent = nowPlaying.artist;
    panel.querySelector(".nowPlayingAlbum span").textContent = albumText;
    return panel;
  }

  function updateNowPlayingPanel() {
    const panel = panels.querySelector(".nowPlaying");
    if (!panel) return;
    const setText = (selector, value) => {
      const node = panel.querySelector(selector);
      if (!node || node.textContent === value) return false;
      node.textContent = value;
      return true;
    };
    const titleChanged = setText(".titleScroll", nowPlaying.title);
    const artistChanged = setText(".nowPlayingArtist span", nowPlaying.artist);
    const albumChanged = setText(".nowPlayingAlbum span", nowPlaying.album || "");
    prepareNowPlayingOverflow([
      ...(titleChanged ? [".titleScroll"] : []),
      ...(artistChanged ? [".nowPlayingArtist > span"] : []),
      ...(albumChanged ? [".nowPlayingAlbum > span"] : []),
    ]);
    const contextNodes = panel.querySelectorAll(".nowPlayingContext span");
    if (contextNodes[0]) {
      contextNodes[0].textContent = nowPlaying.queuePosition && nowPlaying.queueCount
        ? `${nowPlaying.queuePosition} of ${nowPlaying.queueCount}`
        : "";
    }
    const progress = nowPlaying.duration > 0
      ? Math.min(100, Math.max(0, nowPlaying.elapsed / nowPlaying.duration * 100))
      : 0;
    panel.style.setProperty("--progress", `${progress}%`);
    const readout = panel.querySelector(".timeReadout");
    if (readout) {
      const displayedTime = formatTime(nowPlaying.elapsed);
      if (readout.textContent !== displayedTime) {
        readout.textContent = displayedTime;
      }
    }
  }

  function formatTime(seconds) {
    const value = Number.isFinite(seconds) ? Math.max(0, seconds) : 0;
    return `${Math.floor(value / 60)}:${String(Math.floor(value % 60)).padStart(2, "0")}`;
  }

  function volumePanel() {
    const panel = document.createElement("div");
    panel.className = "volumePanel";
    panel.style.setProperty("--volume", `${volume}%`);
    panel.innerHTML = `
      <div class="volumeLabel">Volume</div>
      <div class="volumeTrack"><span class="volumeFill"></span></div>`;
    return panel;
  }

  function gaugePanel() {
    const panel = document.createElement("div");
    panel.className = "volumePanel";
    const range = Math.max(1, screen.max - screen.min);
    const percent = (screen.value - screen.min) / range * 100;
    panel.style.setProperty("--volume", `${percent}%`);
    const isContrast = screen.setting === "contrast";
    panel.innerHTML = `
      <div class="volumeLabel">${screen.label}</div>
      <div class="gaugeRow"${isContrast ? " data-contrast" : ""}>
        ${isContrast ? '<span class="gaugeEndpoint gaugeLow" aria-hidden="true"></span>' : ""}
        <div class="volumeTrack"><span class="volumeFill"></span></div>
        ${isContrast ? '<span class="gaugeEndpoint gaugeHigh" aria-hidden="true"></span>' : ""}
      </div>`;
    return panel;
  }

  function breakoutPanel() {
    const panel = document.createElement("div");
    panel.className = "breakoutPanel";
    const canvas = document.createElement("canvas");
    canvas.width = 160;
    canvas.height = 108;
    panel.append(canvas);
    requestAnimationFrame(() => startBreakout(canvas));
    return panel;
  }

  function moveSelection(direction, distance = 1) {
    if (screen.type !== "list") return;
    clearPreviousPressPair();
    const frame = currentFrame();
    if (!frame?.list.items.length) return;
    const next = Math.max(
      0,
      Math.min(frame.list.items.length - 1, frame.selected + direction * distance),
    );
    if (next === frame.selected) return;
    frame.selected = next;
    frame.scroll = Math.max(next - 5, Math.min(frame.scroll, next));
    if (!updateListSelection(frame)) render(false);
  }

  // A detent moves the highlight in place. Rebuilding the whole panel
  // (panels.replaceChildren) for every row of a 500-song list is where the
  // wheel's felt latency lived — layout, style, and paint for hundreds of
  // rows per detent. Only the highlight, the track offset, and the scrollbar
  // thumb actually change.
  function updateListSelection(frame) {
    const viewport = panels.lastElementChild;
    const track = viewport?.querySelector?.(".listTrack");
    if (
      !track ||
      Number(track.dataset.itemCount) !== frame.list.items.length
    ) return false;
    const nextRow = Array.from(track.children).find(
      (row) => Number(row.dataset.rowIndex) === frame.selected,
    );
    if (!nextRow) return false;
    const previous = track.querySelector(".listRow[data-selected]");
    if (previous && previous !== nextRow) {
      delete previous.dataset.selected;
      previous.setAttribute("aria-selected", "false");
      previous.querySelector(".rowTitle")?.removeAttribute("data-overflow");
    }
    nextRow.dataset.selected = "";
    nextRow.setAttribute("aria-selected", "true");
    viewport.setAttribute("aria-activedescendant", nextRow.id);
    track.style.top = `${-(frame.scroll * 100) / 6}%`;
    const thumb = viewport.querySelector(".listScrollbar > span");
    if (thumb && frame.list.items.length > 6) {
      const height = 6 / frame.list.items.length * 100;
      const progress = frame.scroll / (frame.list.items.length - 6);
      thumb.style.top = `${progress * (100 - height)}%`;
    }
    prepareSelectedRow();
    updateAccessibilityStatus();
    return true;
  }

  function onWheelTravel(delta, time) {
    if (holdEnabled || sleeping || navigationPending) return;
    if (
      wheelPointer === null &&
      previousWheelTime &&
      time - previousWheelTime > 180
    ) {
      detentTravel = 0;
      rowTravel = 0;
      detentDirection = 0;
    }
    if (!delta) return;
    // Sustained wheel travel is continued activity, not one long idle period.
    // Keep the two-minute sleep and timed backlight clocks behind the hand.
    noteActivity();
    // Previous means "restart once, previous song twice." The second press
    // only belongs to the first when the two are consecutive; touching the
    // wheel in between starts a new button sequence.
    clearPreviousPressPair();
    // Travel accumulates SIGNED and is never zeroed on a sign flip. A
    // fingertip's sampled centroid wobbles a fraction of a degree backwards
    // every few frames on glass, and the old "reset the accumulator on any
    // direction change" wiped up to 15° of progress at every wobble — a slow
    // careful turn never reached a detent and the wheel read as dead until
    // the finger lifted and re-swiped fast (bigger per-frame deltas, no sign
    // flips). Measured: a 1° wobble every 12° of forward travel produced
    // ZERO detents across 660° of net rotation. Signed math is also the
    // mechanical truth: a hair backwards subtracts a hair, and a genuine
    // reversal winds back through the remainder before it earns its first
    // opposite click.
    detentTravel += delta;
    // Brick's paddle rides raw angular travel, not the 15° detent grid: a
    // paddle quantized to detents can only cross the 134-unit court in ~34
    // clicks (1.4 wheel revolutions), which is why traversal felt like a
    // slog. 0.75 units/degree puts a full crossing at ~half a revolution and
    // moves the paddle the instant the hand does. The detent loop below still
    // runs for the wheel's own mechanical clicks — the encoder doesn't know
    // a game is up.
    if (screen.type === "breakout" && breakout) {
      breakout.paddle = Math.max(
        13,
        Math.min(147, breakout.paddle + delta * 0.75),
      );
    }
    const elapsed = previousWheelTime ? Math.max(1, time - previousWheelTime) : 100;
    const speed = Math.abs(delta) / elapsed * 1000;
    const listLength = screen.type === "list"
      ? currentFrame()?.list.items.length || 0
      : 0;
    // Keep one row per 15° through an ordinary hand turn. Acceleration is for
    // a deliberate spin: 600°/s is about 1.7 revolutions per second, while
    // 1,200°/s requires a genuinely fast throw. The earlier 360/720 thresholds
    // doubled normal browsing and made the mechanical wheel feel twitchy.
    const acceleration = listLength > 6
      ? speed > 1_200
        ? 4
        : speed > 600
          ? 2
          : 1
      : 1;
    let feltDetents = 0;
    while (Math.abs(detentTravel) >= 15) {
      const direction = Math.sign(detentTravel);
      detentTravel -= direction * 15;
      if (direction !== detentDirection) {
        // A real reversal starts a fresh row-acceleration run; the signed
        // travel above already made the reversal itself earn this click.
        rowTravel = 0;
        detentDirection = direction;
      }
      if (feltDetents < 2) {
        if (clickerEnabled) clickVoices();
        hapticDetent();
        feltDetents += 1;
      }
      if (screen.type === "list") {
        rowTravel += 15;
        const degreesPerRow = 15 / acceleration;
        while (rowTravel >= degreesPerRow) {
          rowTravel -= degreesPerRow;
          moveSelection(direction, 1);
        }
      } else if (screen.type === "nowPlaying" && scrubActive) {
        // The wheel is modal on Now Playing: with the scrubber up, detents
        // seek instead of adjusting volume. Each detent re-arms the linger.
        if (nowPlaying.duration > 0) {
          const step = nowPlaying.duration * SCRUB_STEP;
          const limit = Math.max(0, nowPlaying.duration - SCRUB_END_GUARD);
          nowPlaying.elapsed = Math.max(
            0,
            Math.min(limit, nowPlaying.elapsed + direction * step),
          );
          holdScrub();
          scheduleNativeSeek();
          updateNowPlayingPanel();
        }
      } else if (screen.type === "nowPlaying" || screen.type === "volume") {
        volume = Math.max(0, Math.min(100, volume + direction * 2));
        const alreadyShowingVolume = screen.type === "volume";
        screen = { type: "volume" };
        scheduleNativeVolume();
        if (alreadyShowingVolume) {
          panels.lastElementChild?.style.setProperty("--volume", `${volume}%`);
          updateAccessibilityStatus();
        } else {
          render(false);
        }
        clearTimeout(volumeTimer);
        volumeTimer = setTimeout(() => {
          screen = { type: "nowPlaying" };
          render(false);
        }, 900);
      } else if (screen.type === "gauge") {
        screen.value = Math.max(
          screen.min,
          Math.min(screen.max, screen.value + direction * screen.step),
        );
        firmwareSettings[screen.setting] = screen.format(screen.value);
        storeSettings();
        applyFirmwareSetting(screen.setting);
        const panel = panels.lastElementChild;
        const range = Math.max(1, screen.max - screen.min);
        const percent = (screen.value - screen.min) / range * 100;
        panel?.style.setProperty("--volume", `${percent}%`);
        updateAccessibilityStatus();
      }
    }
    previousWheelTime = time;
  }

  // AppleScript volume writes are much more expensive than painting the LCD.
  // Keep visual response detent-for-detent, but cap native writes at 12.5/s
  // with a trailing update so a long trackpad gesture cannot queue hundreds
  // of stale volume commands ahead of Play/Pause.
  function cancelNativeVolumeSync() {
    clearTimeout(nativeVolumeTimer);
    nativeVolumeTimer = 0;
    nativeVolumePending = false;
  }

  function scheduleNativeVolume() {
    if (firmwareClosing || sleeping) return;
    if (nativeVolumeInFlight) {
      nativeVolumePending = true;
      return;
    }
    const elapsed = performance.now() - nativeVolumeLastSent;
    if (elapsed >= 80) {
      sendNativeVolume();
      return;
    }
    if (nativeVolumeTimer) return;
    nativeVolumeTimer = setTimeout(sendNativeVolume, 80 - elapsed);
  }

  async function sendNativeVolume() {
    clearTimeout(nativeVolumeTimer);
    nativeVolumeTimer = 0;
    if (firmwareClosing || sleeping) {
      nativeVolumePending = false;
      return;
    }
    if (nativeVolumeInFlight) {
      nativeVolumePending = true;
      return;
    }
    const sentVolume = volume;
    nativeVolumeInFlight = true;
    nativeVolumePending = false;
    nativeVolumeLastSent = performance.now();
    try {
      await nativeRequest("setVolume", { volume: sentVolume });
    } catch {
      // The LCD remains responsive even if its external player disappeared.
    } finally {
      nativeVolumeInFlight = false;
      if (firmwareClosing || sleeping) {
        nativeVolumePending = false;
        return;
      }
      if (nativeVolumePending || volume !== sentVolume) {
        const delay = Math.max(0, 80 - (performance.now() - nativeVolumeLastSent));
        nativeVolumeTimer = setTimeout(sendNativeVolume, delay);
      }
    }
  }

  function holdScrub() {
    scrubActive = true;
    clearTimeout(scrubTimer);
    scrubTimer = setTimeout(dropScrub, SCRUB_LINGER_MS);
    const panel = panels.querySelector(".nowPlaying");
    if (panel) panel.dataset.scrub = "";
  }

  function dropScrub() {
    clearTimeout(scrubTimer);
    scrubTimer = 0;
    if (!scrubActive) return;
    scrubActive = false;
    const panel = panels.querySelector(".nowPlaying");
    if (panel) delete panel.dataset.scrub;
  }

  // Native seek mirrors the volume sync above: detent-for-detent on the LCD,
  // coalesced native writes so a long scrub cannot queue hundreds of stale
  // seek commands ahead of Play/Pause.
  function cancelNativeSeekSync() {
    clearTimeout(nativeSeekTimer);
    nativeSeekTimer = 0;
    nativeSeekPending = false;
  }

  function scheduleNativeSeek() {
    if (firmwareClosing || sleeping) return;
    if (nativeSeekInFlight) {
      nativeSeekPending = true;
      return;
    }
    const elapsed = performance.now() - nativeSeekLastSent;
    if (elapsed >= 80) {
      sendNativeSeek();
      return;
    }
    if (nativeSeekTimer) return;
    nativeSeekTimer = setTimeout(sendNativeSeek, 80 - elapsed);
  }

  async function sendNativeSeek() {
    clearTimeout(nativeSeekTimer);
    nativeSeekTimer = 0;
    if (firmwareClosing || sleeping) {
      nativeSeekPending = false;
      return;
    }
    if (nativeSeekInFlight) {
      nativeSeekPending = true;
      return;
    }
    const sentSeconds = nowPlaying.elapsed;
    nativeSeekInFlight = true;
    nativeSeekPending = false;
    nativeSeekLastSent = performance.now();
    try {
      await nativeRequest("seek", { seconds: sentSeconds });
    } catch {
      // The LCD remains responsive even if its external player disappeared.
    } finally {
      nativeSeekInFlight = false;
      // The player's next poll can still report the pre-seek playhead;
      // hold the stale-elapsed guard open past one poll cycle.
      nativeSeekQuietUntil = performance.now() + 1_200;
      if (firmwareClosing || sleeping) {
        nativeSeekPending = false;
        return;
      }
      if (
        nativeSeekPending ||
        (scrubActive && nowPlaying.elapsed !== sentSeconds)
      ) {
        const delay = Math.max(0, 80 - (performance.now() - nativeSeekLastSent));
        nativeSeekTimer = setTimeout(sendNativeSeek, delay);
      }
    }
  }

  function pointerAngle(event) {
    // The wheel cannot move mid-drag, so the rect is cached at pointerdown —
    // getBoundingClientRect forces layout, and the rotation write just above
    // each call dirties style at pointer-move rate.
    const box = wheelRect || wheel.getBoundingClientRect();
    return Math.atan2(
      event.clientY - (box.top + box.height / 2),
      event.clientX - (box.left + box.width / 2),
    ) * 180 / Math.PI;
  }

  function shortestTurn(from, to) {
    return ((to - from + 540) % 360) - 180;
  }

  function pointInWheel(event) {
    const box = wheel.getBoundingClientRect();
    const x = event.clientX - (box.left + box.width / 2);
    const y = event.clientY - (box.top + box.height / 2);
    const ratio = Math.hypot(x, y) / (Math.min(box.width, box.height) / 2);
    return ratio >= 0.36 && ratio <= 1;
  }

  interactive.addEventListener("pointerdown", (event) => {
    if (
      event.button !== 0 ||
      (event.pointerType !== "mouse" && event.isPrimary === false)
    ) return;
    if (event.target.closest("button")) return;
    // Chassis, LCD, and perimeter clicks never reach here in the app — the
    // window hit-tests them itself and drags synchronously. Hold and firmware
    // sleep lock the wheel, not the host window.
    if (!pointInWheel(event)) return;
    if (holdEnabled || sleeping) return;
    noteActivity();
    event.preventDefault();
    wheelRect = wheel.getBoundingClientRect();
    wheelPointer = pointerAngle(event);
    detentTravel = 0;
    detentDirection = 0;
    previousWheelTime = 0;
    interactive.dataset.dragging = "";
    try {
      interactive.setPointerCapture(event.pointerId);
    } catch {
      // Synthetic accessibility/test pointers may not own an active pointer.
    }
  });

  interactive.addEventListener("pointermove", (event) => {
    if (wheelPointer === null) return;
    event.preventDefault();
    const next = pointerAngle(event);
    const delta = shortestTurn(wheelPointer, next);
    wheelPointer = next;
    wheelAngle += delta;
    setWheelTurn();
    onWheelTravel(delta, event.timeStamp);
  });

  // The rotation lives on the rotor itself, not the document root — a custom
  // property written to the root invalidates style for the whole tree at
  // pointer-move rate; on the rotor it dirties one composited element.
  function setWheelTurn() {
    wheelRotor?.style.setProperty("--wheel-turn", `${wheelAngle.toFixed(2)}deg`);
  }

  function finishWheel(event) {
    if (wheelPointer === null) return;
    wheelPointer = null;
    wheelRect = null;
    detentTravel = 0;
    rowTravel = 0;
    detentDirection = 0;
    previousWheelTime = 0;
    delete interactive.dataset.dragging;
    if (interactive.hasPointerCapture(event.pointerId)) {
      interactive.releasePointerCapture(event.pointerId);
    }
  }
  interactive.addEventListener("pointerup", finishWheel);
  interactive.addEventListener("pointercancel", finishWheel);
  interactive.addEventListener(
    "wheel",
    (event) => {
      event.preventDefault();
      if (holdEnabled || sleeping) return;
      noteActivity();
      // Trackpad and mouse-wheel scrolling are first-class wheel input.
      // 0.5°/px puts one 15° detent at 30px of scroll — a light two-finger
      // drag steps rows immediately instead of winding up (the old 0.16
      // needed ~94px per row and read as latency). The ±45° cap (three
      // detents per event) keeps a momentum flick fast without letting one
      // coalesced event teleport the highlight.
      const pixelsPerUnit = event.deltaMode === WheelEvent.DOM_DELTA_LINE
        ? 16
        : event.deltaMode === WheelEvent.DOM_DELTA_PAGE
          ? interactive.clientHeight
          : 1;
      const delta = Math.max(
        -45,
        Math.min(45, event.deltaY * pixelsPerUnit * 0.5),
      );
      wheelAngle += delta;
      setWheelTurn();
      onWheelTravel(delta, event.timeStamp);
    },
    { passive: false },
  );

  const keyboardPressedButtons = new Set();
  const suppressedKeyboardClicks = new Set();
  const holdDescriptions = {
    menu: "Hold Enter or Space for the backlight.",
    prev: "Hold Enter or Space to rewind.",
    next: "Hold Enter or Space to fast-forward.",
    play: "Hold Enter or Space to power off.",
    select: "Hold Enter or Space on About to open Breakout.",
  };

  function clearResetChord() {
    if (resetTimer) clearTimeout(resetTimer);
    resetTimer = 0;
  }

  function armResetChord() {
    if (
      resetTimer ||
      !pressedButtons.has("menu") ||
      !pressedButtons.has("play")
    ) return;
    for (const id of ["menu", "play"]) {
      clearTimeout(holdTimers.get(id));
      holdTimers.delete(id);
      consumedHolds.add(id);
    }
    resetTimer = setTimeout(() => {
      resetTimer = 0;
      if (pressedButtons.has("menu") && pressedButtons.has("play")) {
        resetDevice();
      }
    }, 10_000);
  }

  function releaseActiveSeek(exceptButton = null) {
    if (!activeSeekButton || activeSeekButton === exceptButton) {
      return Promise.resolve();
    }
    activeSeekButton = null;
    return nativeRequest("endSeek");
  }

  function beginButtonPress(button) {
    const buttonID = button.dataset.button;
    if (holdEnabled || pressedButtons.has(buttonID)) return false;
    if (buttonID !== "prev") clearPreviousPressPair();
    const wokeDevice = sleeping;
    if (buttonID === "menu" && !wokeDevice) {
      // A timed Backlight setting makes any button press illuminate the LCD.
      // Remember the state before that activity so a Menu hold still toggles
      // the state the user actually saw when the gesture began.
      menuHoldBacklightTarget = !backlight;
    }
    if (wokeDevice) {
      wake();
      wakeConsumedButtons.add(buttonID);
    } else {
      noteActivity();
    }
    pressedButtons.add(buttonID);
    button.dataset.pressed = "";
    if (buttonID === "select") selectSurface.dataset.active = "";
    else pressSurface.dataset.button = buttonID;
    pressAudio(buttonID);
    if (!wokeDevice) {
      const delay = holdDelay(buttonID);
      if (delay !== null) {
        holdTimers.set(buttonID, setTimeout(() => beginHold(buttonID), delay));
      }
      armResetChord();
    }
    return true;
  }

  function finishButtonPress(button, cancelled) {
    const buttonID = button.dataset.button;
    if (!pressedButtons.has(buttonID)) return;
    clearTimeout(holdTimers.get(buttonID));
    holdTimers.delete(buttonID);
    if (activeSeekButton === buttonID) releaseActiveSeek().catch(() => {});
    if (buttonID === "menu" || buttonID === "play") clearResetChord();
    pressedButtons.delete(buttonID);
    delete button.dataset.pressed;
    if (!pressedButtons.size) {
      delete pressSurface.dataset.button;
      delete selectSurface.dataset.active;
    } else {
      const remaining = [...pressedButtons];
      const remainingSelect = remaining.includes("select");
      selectSurface.toggleAttribute("data-active", remainingSelect);
      const remainingRing = remaining.find((id) => id !== "select");
      if (remainingRing) pressSurface.dataset.button = remainingRing;
      else delete pressSurface.dataset.button;
    }
    releaseAudio(buttonID);
    const wokeDevice = wakeConsumedButtons.delete(buttonID);
    if (!cancelled && !wokeDevice && !consumedHolds.has(buttonID)) {
      runInput(() => shortPress(buttonID));
    }
    consumedHolds.delete(buttonID);
    if (buttonID === "menu") menuHoldBacklightTarget = null;
  }

  document.querySelectorAll("[data-button]").forEach((button) => {
    const buttonID = button.dataset.button;
    button.setAttribute("aria-description", holdDescriptions[buttonID]);
    button.setAttribute("aria-keyshortcuts", "Shift+Enter");
    // Hover lights the actual wheel art through the press surfaces' sector
    // masks (device.css) — the hit targets themselves paint nothing. The CSS
    // hover rules sit behind (hover:hover)/(pointer:fine), so touch setups
    // carry the attribute inertly.
    const hoverSurface = buttonID === "select" ? selectSurface : pressSurface;
    button.addEventListener("pointerenter", () => {
      hoverSurface.dataset.hover = buttonID;
    });
    button.addEventListener("pointerleave", () => {
      if (hoverSurface.dataset.hover === buttonID) delete hoverSurface.dataset.hover;
    });
    button.addEventListener("pointerdown", (event) => {
      if (
        event.button !== 0 ||
        (event.pointerType !== "mouse" && event.isPrimary === false)
      ) return;
      event.preventDefault();
      if (!beginButtonPress(button)) return;
      try {
        button.setPointerCapture(event.pointerId);
      } catch {
        // Synthetic accessibility/test pointers may not own an active pointer.
      }
    });
    const finishPointerPress = (event, cancelled) => {
      finishButtonPress(button, cancelled);
      if (button.hasPointerCapture(event.pointerId)) {
        button.releasePointerCapture(event.pointerId);
      }
    };
    button.addEventListener("pointerup", (event) => {
      finishPointerPress(event, false);
    });
    button.addEventListener("pointercancel", (event) => {
      finishPointerPress(event, true);
    });
    button.addEventListener("keydown", (event) => {
      if (event.key !== "Enter" && event.key !== " ") return;
      event.preventDefault();
      const wasSleeping = sleeping;
      if (event.repeat || !beginButtonPress(button)) return;
      keyboardPressedButtons.add(buttonID);
      if (event.shiftKey && holdDelay(buttonID) !== null && !wasSleeping) {
        clearTimeout(holdTimers.get(buttonID));
        holdTimers.delete(buttonID);
        beginHold(buttonID);
      }
    });
    button.addEventListener("keyup", (event) => {
      if (
        (event.key !== "Enter" && event.key !== " ") ||
        !keyboardPressedButtons.delete(buttonID)
      ) return;
      event.preventDefault();
      suppressedKeyboardClicks.add(buttonID);
      setTimeout(() => suppressedKeyboardClicks.delete(buttonID), 0);
      finishButtonPress(button, false);
    });
    button.addEventListener("blur", () => {
      if (!keyboardPressedButtons.delete(buttonID)) return;
      finishButtonPress(button, true);
    });
    button.addEventListener("click", (event) => {
      if (suppressedKeyboardClicks.delete(buttonID)) {
        event.preventDefault();
        return;
      }
      // Pointer input is completed by finishButtonPress. A zero-detail click
      // is VoiceOver activation and must operate the button that owns focus.
      if (event.detail !== 0 || holdEnabled) return;
      event.preventDefault();
      pressAudio(buttonID);
      releaseAudio(buttonID);
      if (sleeping) {
        wake();
        return;
      }
      noteActivity();
      runInput(() => shortPress(buttonID));
    });
  });

  function beginHold(button) {
    consumedHolds.add(button);
    if (button === "play") {
      powerOff();
      return;
    }
    if (button === "menu") {
      setBacklight(menuHoldBacklightTarget ?? !backlight);
      if (screenReaderStatus) {
        screenReaderStatus.textContent = `Backlight ${backlight ? "on" : "off"}.`;
      }
      return;
    }
    if (button === "next" || button === "prev") {
      if (navigationPending) return;
      // With no loaded song the transport keys are inert, as they are on an
      // empty iPod. Do not turn an idle button hold into a Music.app error.
      if (!nowPlaying.persistentID) return;
      const direction = button === "next" ? 1 : -1;
      clearPreviousPressPair();
      activeSeekButton = button;
      nativeRequest("beginSeek", { direction }).catch(() => {});
      return;
    }
    if (button === "select" && screen.type === "message" && screen.title === "About") {
      showBreakout();
    }
  }

  function holdDelay(button) {
    if (button === "next" || button === "prev") return 500;
    if (button === "menu" || button === "play") return 2_000;
    if (button === "select" && screen.type === "message" && screen.title === "About") {
      return 5_000;
    }
    return null;
  }

  async function runNavigation(operation) {
    if (navigationPending) return;
    navigationPending = true;
    const pendingToken = ++navigationPendingToken;
    try {
      return await operation();
    } finally {
      if (pendingToken === navigationPendingToken) {
        navigationPending = false;
      }
    }
  }

  async function shortPress(button) {
    if (holdEnabled) return;
    if (button !== "prev") clearPreviousPressPair();
    if (screen.type === "splash") {
      clearTimeout(startupTimer);
      showWelcome(false);
      return;
    }
    if (screen.type === "welcome" || screen.type === "boot") {
      clearTimeout(startupTimer);
      await showRoot(false);
      return;
    }
    if (navigationPending && button !== "menu") return;
    if (button === "menu") {
      releaseActiveSeek().catch(() => {});
      noteActivity();
      goBack();
      return;
    }
    if (["play", "prev", "next", "select"].includes(button)) {
      // The newest explicit control owns transport state. A held scan must
      // finish before Play/Pause, Skip, or a new selection is dispatched.
      await releaseActiveSeek().catch(() => {});
    }
    if (button === "play") {
      if (screen.type === "list") {
        const selected = currentItem();
        if (selected?.id.startsWith("music/") || selected?.kind === "track") {
          await runNavigation(() => playHighlighted(selected));
          return;
        }
      }
      if (!nowPlaying.persistentID) return;
      await nativeRequest("playPause");
      return;
    }
    if (button === "next" || button === "prev") {
      if (!nowPlaying.persistentID) return;
      if (
        button === "prev" &&
        previousPressTrackID === nowPlaying.persistentID &&
        performance.now() <= previousPressDeadline
      ) {
        const pairedTrackID = previousPressTrackID;
        const restartPromise = previousRestartPromise;
        clearPreviousPressPair();
        // A second Previous only skips if the first restart was accepted.
        // This also preserves native request order when the second press
        // arrives while that restart Apple event is still in flight.
        if (restartPromise) await restartPromise;
        if (nowPlaying.persistentID !== pairedTrackID) return;
        await nativeRequest("skip", { direction: -1 });
      } else if (button === "prev") {
        clearPreviousPressPair();
        const restartTrackID = nowPlaying.persistentID;
        const restartPromise = nativeRequest("seek", { seconds: 0 });
        previousPressTrackID = restartTrackID;
        previousPressDeadline = performance.now() + 800;
        previousRestartPromise = restartPromise;
        let state;
        try {
          state = await restartPromise;
        } catch (error) {
          if (previousRestartPromise === restartPromise) {
            clearPreviousPressPair();
          }
          throw error;
        }
        if (previousRestartPromise === restartPromise) {
          previousRestartPromise = null;
        }
        transport = state?.transport || transport;
        if (
          state?.nowPlaying &&
          nowPlaying.persistentID === restartTrackID
        ) {
          nowPlaying = { ...nowPlaying, ...state.nowPlaying };
        } else if (nowPlaying.persistentID !== restartTrackID) {
          clearPreviousPressPair();
        }
        if (screen.type === "nowPlaying") render(false);
      } else {
        clearPreviousPressPair();
        await nativeRequest("skip", { direction: 1 });
      }
      return;
    }
    if (button === "select") {
      await runNavigation(async () => {
        if (screen.type === "breakout" && breakout) {
          breakout.launched = true;
        } else if (screen.type === "gauge") {
          goBack();
        } else if (screen.type === "nowPlaying" || screen.type === "volume") {
          // Center on Now Playing never restarts the song: it toggles the
          // scrubber. The diamond head appears on the progress bar, wheel
          // detents seek, and the mode times back out after SCRUB_LINGER_MS.
          clearTimeout(volumeTimer);
          const leavingVolume = screen.type === "volume";
          screen = { type: "nowPlaying" };
          if (leavingVolume) render(false);
          if (scrubActive) {
            dropScrub();
          } else if (nowPlaying.duration > 0) {
            holdScrub();
          }
        } else {
          await selectCurrent();
        }
      });
    }
  }

  function runInput(operation) {
    const epoch = inputEpoch;
    Promise.resolve()
      .then(operation)
      .catch((error) => {
        if (epoch !== inputEpoch) return;
        if (error?.message?.startsWith("Superseded native request:")) return;
        navigationGeneration += 1;
        showMessage(error?.message || "Couldn’t complete that action");
      });
  }

  function goBack() {
    inputEpoch += 1;
    navigationPending = false;
    navigationPendingToken += 1;
    navigationGeneration += 1;
    clearTimeout(volumeTimer);
    dropScrub();
    if (screen.type === "breakout") {
      stopBreakout();
      screen = {
        type: "message",
        title: "About",
        message: aboutMessage,
      };
      render(false);
      armAutoSleep();
      return;
    }
    if (
      screen.type === "nowPlaying" ||
      screen.type === "volume" ||
      screen.type === "message" ||
      screen.type === "gauge"
    ) {
      if (stack.length) {
        const old = renderPanel();
        screen = { type: "list" };
        renderWithPush(old, true);
      } else {
        showRoot(true);
      }
      return;
    }
    if (screen.type !== "list") return;
    if (stack.length <= 1) {
      // Menu at the root steps one further up — the intro splash (Geoff,
      // Aug 29: "If I'm on this screen and I click menu i want to go back
      // to intro screen"). Same contract as launch: Don't-steal splash,
      // then the nostalgia welcome, then the root menu.
      const old = renderPanel();
      stack = [];
      screen = { type: "splash" };
      renderWithPush(old, true);
      clearTimeout(startupTimer);
      startupTimer = setTimeout(() => showWelcome(false), 15_000);
      return;
    }
    const old = renderPanel();
    stack.pop();
    renderWithPush(old, true);
  }

  async function selectCurrent() {
    if (screen.type !== "list") return;
    const selected = currentItem();
    if (!selected || selected.id === "empty") return;
    if (selected.id === "now-playing") {
      showNowPlaying(true);
      return;
    }
    if (selected.id === "music-empty" || selected.id === "music-request") {
      // First run this fires the system permission prompt; an authorized but
      // empty library just rescans. Either way the shelf rebuilds in place.
      const generation = ++navigationGeneration;
      const frame = currentFrame();
      if (!frame) return;
      const list = await nativeRequest("refreshLibrary", {
        path: frame.list.id,
      });
      if (generation !== navigationGeneration) return;
      stack[stack.length - 1] = listFrame(list);
      render(false);
      return;
    }
    if (selected.id === "music-denied") {
      // iOS never re-prompts a denied app — Settings is the only door, and a
      // surprise teleport out of the iPod deserves an explanation first. This
      // is the firmware's own dialog idiom: a titled screen, a short body,
      // and two wheel-selectable rows (the Reset screen's shape).
      const frame = currentFrame();
      const old = renderPanel();
      stack.push(listFrame({
        id: "music-access",
        title: "Music Access",
        sourcePath: frame?.list.id ?? "music",
        // Four lines of Chicago is the LCD's whole budget above the two
        // action rows — this is measured copy, not a draft. Name the exact
        // Settings toggle; the title and row already carry the why.
        message: "Turn on \u201cMedia & Apple Music\u201d in Settings and " +
          "your songs appear here.",
        items: [
          item("music-open-settings", "Open Settings", "action"),
          item("music-cancel", "Cancel", "action"),
        ],
      }));
      renderWithPush(old, false);
      return;
    }
    if (selected.id === "music-open-settings") {
      // The dialog stays up while the reader is away: if they return without
      // granting, the explanation is still standing; if they grant, the
      // foreground hook (or iOS's own relaunch after a privacy flip) lands
      // them on the freshly populated list.
      nativeRequest("openSettings").catch(() => {});
      return;
    }
    if (selected.id === "music-cancel") {
      goBack();
      return;
    }
    if (selected.id === "settings" || selected.hasChildren) {
      await openList(selected.id);
      return;
    }
    if (selected.id === "about") {
      await showAbout();
      return;
    }
    if (selected.id.startsWith("setting-choice:")) {
      const [, setting, ...valueParts] = selected.id.split(":");
      firmwareSettings[setting] = valueParts.join(":");
      storeSettings();
      applyFirmwareSetting(setting);
      goBack();
      return;
    }
    if (selected.id === "setting-backlight") {
      openSettingChoices("backlight", "Backlight", [
        "Off", "1 Sec", "2 Sec", "3 Sec", "5 Sec", "Always On",
      ]);
      return;
    }
    if (selected.id === "setting-sleep") {
      openSettingChoices("sleep", "Sleep", [
        "Off", "5 minutes", "10 minutes", "15 minutes", "20 minutes", "30 minutes",
        "60 minutes",
      ]);
      return;
    }
    if (selected.id === "setting-language") {
      openSettingChoices("language", "Language", [
        "English", "Deutsch", "Français", "日本語",
      ]);
      return;
    }
    if (selected.id === "setting-placement") {
      openSettingChoices("placement", "Placement", [
        "Top", "Middle", "Bottom",
      ]);
      return;
    }
    if (selected.id === "setting-contrast") {
      openSettingGauge("contrast", "Contrast");
      return;
    }
    if (selected.id === "setting-startup") {
      openSettingGauge("startup", "Startup Volume");
      return;
    }
    if (selected.id === "setting-reset") {
      const old = renderPanel();
      stack.push(listFrame({
        id: "settings/reset",
        title: "Reset",
        items: [
          item("reset-cancel", "Cancel", "action"),
          item("reset-confirm", "Reset", "action"),
        ],
      }));
      renderWithPush(old, false);
      return;
    }
    if (selected.id === "reset-cancel") {
      goBack();
      return;
    }
    if (selected.id === "reset-confirm") {
      invalidateSettingMutations();
      Object.assign(firmwareSettings, DEFAULT_FIRMWARE_SETTINGS);
      clickerEnabled = true;
      storeSettings();
      setBacklight(false);
      clearTimeout(sleepTimer);
      synchronizePlaybackSettings();
      applyFirmwareSetting("contrast");
      applyFirmwareSetting("placement");
      stack.pop();
      stack[stack.length - 1] = listFrame(await settingsList());
      render(false);
      return;
    }
    if (selected.id === "iosamp-source") {
      const generation = ++navigationGeneration;
      const requestedTransport = selected.subtitle === "Music" ? "system" : "music";
      const state = await nativeRequest("setTransport", {
        transport: requestedTransport,
      });
      if (generation !== navigationGeneration) return;
      transport = state?.transport || requestedTransport;
      if (Number.isFinite(state?.volume)) volume = state.volume;
      if (state?.nowPlaying) nowPlaying = { ...nowPlaying, ...state.nowPlaying };
      synchronizePlaybackSettings();
      syncRootNowPlaying();
      selected.subtitle = transport === "music" ? "Music" : "System";
      render(false);
      return;
    }
    if (selected.id === "iosamp-top") {
      const generation = ++navigationGeneration;
      const enabled = selected.subtitle !== "On";
      await nativeRequest("setAlwaysOnTop", { enabled });
      if (generation !== navigationGeneration) return;
      selected.subtitle = enabled ? "On" : "Off";
      render(false);
      return;
    }
    if (selected.id === "iosamp-quit") {
      await nativeRequest("quit");
      return;
    }
    if (selected.kind === "toggle") {
      const setting = selected.id.replace("setting-", "");
      const mutation = beginSettingMutation(setting);
      const previous = selected.subtitle;
      selected.subtitle = selected.subtitle === "On" ? "Off" : "On";
      if (selected.id === "setting-clicker") clickerEnabled = selected.subtitle === "On";
      firmwareSettings[setting] = selected.subtitle;
      storeSettings();
      try {
        await applyFirmwareSetting(setting, true);
      } catch (error) {
        if (!isCurrentSettingMutation(setting, mutation)) return;
        selected.subtitle = previous;
        firmwareSettings[setting] = previous;
        if (setting === "clicker") clickerEnabled = previous === "On";
        storeSettings();
        throw error;
      }
      render(false);
      return;
    }
    if (selected.id === "setting-repeat") {
      const mutation = beginSettingMutation("repeat");
      const previous = selected.subtitle;
      cycleSetting(selected);
      try {
        await applyFirmwareSetting("repeat", true);
      } catch (error) {
        if (!isCurrentSettingMutation("repeat", mutation)) return;
        selected.subtitle = previous;
        firmwareSettings.repeat = previous;
        storeSettings();
        throw error;
      }
      render(false);
      return;
    }
    if (selected.playable || selected.kind === "track") {
      // Center on a song starts that song in the list being browsed. Treating
      // it as a one-item queue made Select and Play disagree and broke the
      // firmware's Previous/Next context.
      await playHighlighted(selected);
      return;
    }
  }

  async function playHighlighted(selected) {
    const generation = ++navigationGeneration;
    let collectionPath = selected.hasChildren ? selected.id : currentFrame()?.list.id;
    let collectionIndex = selected.hasChildren ? 0 : currentFrame()?.selected || 0;
    let candidate = selected;
    const visited = new Set();
    while (candidate?.hasChildren && !visited.has(candidate.id)) {
      visited.add(candidate.id);
      collectionPath = candidate.id;
      const list = await nativeRequest("getList", { path: candidate.id });
      if (generation !== navigationGeneration) return;
      collectionIndex = list?.items?.findIndex(
        (entry) => entry.playable || entry.hasChildren,
      ) ?? -1;
      candidate = collectionIndex >= 0 ? list.items[collectionIndex] : null;
    }
    if (!candidate || (!candidate.playable && candidate.kind !== "track")) return;
    const state = collectionPath
      ? await nativeRequest("playCollection", {
          path: collectionPath,
          id: candidate.id,
          index: collectionIndex,
          volume,
          shuffle: firmwareSettings.shuffle,
          repeat: firmwareSettings.repeat,
        })
      : await nativeRequest("select", {
          id: candidate.id,
          volume,
          shuffle: firmwareSettings.shuffle,
          repeat: firmwareSettings.repeat,
        });
    if (generation !== navigationGeneration) return;
    transport = state?.transport || transport;
    if (state?.nowPlaying) nowPlaying = { ...nowPlaying, ...state.nowPlaying };
    showNowPlaying(true);
  }

  function sleep() {
    inputEpoch += 1;
    navigationPending = false;
    navigationPendingToken += 1;
    navigationGeneration += 1;
    clearTimeout(startupTimer);
    startupTimer = 0;
    clearTimeout(pushTimer);
    pushTimer = 0;
    clearTimeout(autoSleepTimer);
    clearTimeout(volumeTimer);
    cancelNativeVolumeSync();
    cancelNativeSeekSync();
    clearTimeout(backlightTimer);
    dropScrub();
    for (const timer of holdTimers.values()) clearTimeout(timer);
    holdTimers.clear();
    consumedHolds.clear();
    wakeConsumedButtons.clear();
    pressedButtons.clear();
    menuHoldBacklightTarget = null;
    document.querySelectorAll("[data-button][data-pressed]").forEach((button) => {
      delete button.dataset.pressed;
    });
    delete pressSurface.dataset.button;
    delete selectSurface.dataset.active;
    delete pressSurface.dataset.hover;
    delete selectSurface.dataset.hover;
    releaseActiveSeek().catch(() => {});
    clearResetChord();
    wheelPointer = null;
    wheelRect = null;
    detentTravel = 0;
    rowTravel = 0;
    detentDirection = 0;
    previousWheelTime = 0;
    delete interactive.dataset.dragging;
    setBacklight(false);
    if (screen.type === "breakout") stopBreakout();
    sleeping = true;
    root.dataset.sleeping = "";
    // No render here: replacing the panel would blank the LCD in the same
    // frame, which reads as a glitch. data-sleeping fades the still-present
    // pixels out like the passive-matrix panel relaxing; the stale DOM under
    // the faded screen is invisible, and every wake path rebuilds it.
    updateAccessibilityStatus();
    nativeRequest("setFirmwareAwake", { awake: false }).catch(() => {});
  }

  function setBacklight(enabled) {
    clearTimeout(backlightTimer);
    backlight = enabled;
    root.toggleAttribute("data-backlight", enabled);
    if (!enabled || firmwareSettings.backlight === "Always On") return;
    const seconds = Number.parseInt(firmwareSettings.backlight, 10);
    if (Number.isFinite(seconds) && seconds > 0) {
      backlightTimer = setTimeout(() => setBacklight(false), seconds * 1_000);
    }
  }

  function showBreakout() {
    screen = { type: "breakout" };
    render(false);
    armAutoSleep();
  }

  function startBreakout(canvas) {
    stopBreakout();
    if (!canvas.isConnected || screen.type !== "breakout") return;
    const context = canvas.getContext("2d");
    if (!context) return;
    // The game thinks in 160×108 LCD units, but a 160×108 backing store
    // stretched across the panel is where the chunky score text came from —
    // every glyph rasterized at 1/4 resolution while the rest of the LCD is
    // crisp DOM text. Match the backing store to the rendered size × DPR and
    // scale the context, so ChicagoFLF rasterizes at device resolution like
    // its DOM neighbors. The observer keeps that true across size changes.
    const fitBackingStore = () => {
      const rect = canvas.getBoundingClientRect();
      const scale = Math.max(
        1,
        (rect.width * (window.devicePixelRatio || 1)) / 160,
      );
      const width = Math.round(160 * scale);
      const height = Math.round(108 * scale);
      if (canvas.width !== width || canvas.height !== height) {
        canvas.width = width;
        canvas.height = height;
      }
      if (breakout) breakout.scale = canvas.width / 160;
    };
    const resizeObserver = new ResizeObserver(fitBackingStore);
    resizeObserver.observe(canvas);
    const bricks = [];
    for (let row = 0; row < 4; row += 1) {
      for (let column = 0; column < 8; column += 1) {
        bricks.push({ x: column * 20 + 1, y: row * 7 + 2, live: true });
      }
    }
    breakout = {
      context,
      bricks,
      resizeObserver,
      scale: 1,
      paddle: 80,
      ballX: 80,
      ballY: 91,
      dx: 1.25,
      dy: -1.4,
      launched: false,
      lives: 3,
      score: 0,
      level: 1,
      previous: performance.now(),
    };
    fitBackingStore();
    drawBreakout();
  }

  function drawBreakout(time = performance.now()) {
    if (!breakout || screen.type !== "breakout") return;
    const game = breakout;
    const dt = Math.min(2, Math.max(0.25, (time - game.previous) / 16.67));
    game.previous = time;
    if (game.launched) {
      game.ballX += game.dx * dt;
      game.ballY += game.dy * dt;
      if (game.ballX <= 2 || game.ballX >= 158) game.dx *= -1;
      if (game.ballY <= 1) game.dy = Math.abs(game.dy);
      if (
        game.ballY >= 96 &&
        game.ballY <= 101 &&
        Math.abs(game.ballX - game.paddle) <= 14 &&
        game.dy > 0
      ) {
        game.dy = -Math.abs(game.dy);
        game.dx += (game.ballX - game.paddle) * 0.035;
      }
      for (const brick of game.bricks) {
        if (
          brick.live &&
          game.ballX >= brick.x &&
          game.ballX <= brick.x + 18 &&
          game.ballY >= brick.y + 8 &&
          game.ballY <= brick.y + 13
        ) {
          brick.live = false;
          game.score += 1;
          game.dy *= -1;
          clickVoices();
          break;
        }
      }
      if (game.bricks.every((brick) => !brick.live)) {
        game.level += 1;
        for (const brick of game.bricks) brick.live = true;
        game.dx *= 1.08;
        game.dy = -Math.abs(game.dy) * 1.08;
      }
      if (game.ballY > 108) {
        game.lives -= 1;
        if (game.lives <= 0) {
          game.lives = 3;
          game.score = 0;
          game.level = 1;
          for (const brick of game.bricks) brick.live = true;
        }
        game.launched = false;
        game.ballX = game.paddle;
        game.ballY = 91;
        game.dx = 1.25;
        game.dy = -1.4;
      }
    } else {
      game.ballX = game.paddle;
    }
    const context = game.context;
    // Re-stated every frame: a backing-store resize resets the transform.
    context.setTransform(game.scale, 0, 0, game.scale, 0, 0);
    context.clearRect(0, 0, 160, 108);
    context.fillStyle = "#282425";
    context.font = "7px ChicagoFLF";
    context.textAlign = "left";
    context.fillText(`SCORE ${game.score}`, 2, 7);
    context.textAlign = "right";
    context.fillText(`BALLS ${game.lives}`, 158, 7);
    for (const brick of game.bricks) {
      if (brick.live) context.fillRect(brick.x, brick.y + 8, 18, 5);
    }
    context.fillRect(game.paddle - 13, 99, 26, 3);
    context.fillRect(Math.round(game.ballX) - 2, Math.round(game.ballY) - 2, 4, 4);
    if (!game.launched) {
      context.font = "8px ChicagoFLF";
      context.textAlign = "center";
      context.fillText("SELECT TO START", 80, 62);
    }
    breakoutRaf = requestAnimationFrame(drawBreakout);
  }

  function stopBreakout() {
    cancelAnimationFrame(breakoutRaf);
    breakoutRaf = 0;
    breakout?.resizeObserver?.disconnect();
    breakout = null;
  }

  function wake() {
    sleeping = false;
    nativeRequest("setFirmwareAwake", { awake: true }).catch(() => {});
    root.removeAttribute("data-sleeping");
    stack = [makeRootFrame()];
    screen = { type: "list" };
    applyStartupVolume();
    if (firmwareSettings.backlight !== "Off") setBacklight(true);
    render(false);
    armAutoSleep();
  }

  function powerOff() {
    nowPlaying.isPlaying = false;
    sleep();
    // A simultaneous seek ends first; pause is queued last so powering off
    // cannot accidentally resume playback.
    nativeRequest("pause").catch(() => {});
  }

  function applyFirmwareSetting(setting, reportErrors = false) {
    if (setting === "backlight") {
      if (firmwareSettings.backlight === "Off") setBacklight(false);
      else if (backlight || firmwareSettings.backlight === "Always On") setBacklight(true);
      return;
    }
    if (setting === "sleep") {
      clearTimeout(sleepTimer);
      const minutes = Number.parseInt(firmwareSettings.sleep, 10);
      if (Number.isFinite(minutes) && minutes > 0) {
        sleepTimer = setTimeout(() => {
          firmwareSettings.sleep = "Off";
          storeSettings();
          powerOff();
        }, minutes * 60_000);
      }
      return;
    }
    if (setting === "startup") {
      // Startup Volume is stored here and applied only when the device boots/wakes.
      return;
    }
    if (setting === "contrast") {
      const contrast = Number.parseInt(firmwareSettings.contrast, 10);
      const normalized = Number.isFinite(contrast) ? contrast : 50;
      root.style.setProperty("--lcd-contrast", String(0.75 + normalized * 0.005));
      return;
    }
    if (setting === "placement") {
      // ios.css positions the frame off this stamp; macOS has no rule for it
      // (the window IS the placement there), so the write is inert on desktop.
      document.documentElement.dataset.placement =
        (firmwareSettings.placement || "Bottom").toLowerCase();
      return;
    }
    if (setting === "shuffle" || setting === "repeat") {
      const request = nativeRequest("setPlaybackSetting", {
        setting,
        value: firmwareSettings[setting],
      });
      if (reportErrors) return request;
      request.catch((error) => {
        if (
          setting === "repeat" &&
          firmwareSettings.repeat === "One" &&
          error?.message === REPEAT_ONE_UNSUPPORTED
        ) {
          // Spotify's public scripting API has one Boolean repeat flag, so it
          // cannot represent the iPod's distinct Repeat One state. Never leave
          // the LCD claiming a mode the active player did not accept.
          firmwareSettings.repeat = "Off";
          storeSettings();
          for (const frame of stack) {
            const row = frame.list?.items?.find(
              (entry) => entry.id === "setting-repeat",
            );
            if (row) row.subtitle = "Off";
          }
          nativeRequest("setPlaybackSetting", {
            setting: "repeat",
            value: "Off",
          }).catch(() => {});
          if (screen.type === "list" || screen.type === "nowPlaying") {
            render(false);
          }
        }
      });
      return request;
    }
    return Promise.resolve();
  }

  function resetDevice() {
    inputEpoch += 1;
    invalidateSettingMutations();
    navigationPending = false;
    navigationPendingToken += 1;
    navigationGeneration += 1;
    for (const timer of holdTimers.values()) clearTimeout(timer);
    holdTimers.clear();
    consumedHolds.clear();
    wakeConsumedButtons.clear();
    pressedButtons.clear();
    menuHoldBacklightTarget = null;
    document.querySelectorAll("[data-button][data-pressed]").forEach((button) => {
      delete button.dataset.pressed;
    });
    delete pressSurface.dataset.button;
    delete selectSurface.dataset.active;
    delete pressSurface.dataset.hover;
    delete selectSurface.dataset.hover;
    releaseActiveSeek().catch(() => {});
    clearResetChord();
    clearTimeout(backlightTimer);
    clearTimeout(sleepTimer);
    firmwareSettings.sleep = "Off";
    storeSettings();
    clearTimeout(volumeTimer);
    cancelNativeVolumeSync();
    cancelNativeSeekSync();
    clearTimeout(autoSleepTimer);
    dropScrub();
    clearPreviousPressPair();
    wheelPointer = null;
    wheelRect = null;
    detentTravel = 0;
    rowTravel = 0;
    detentDirection = 0;
    previousWheelTime = 0;
    delete interactive.dataset.dragging;
    stopBreakout();
    nowPlaying.isPlaying = false;
    nativeRequest("pause").catch(() => {});
    sleeping = false;
    applyStartupVolume();
    root.removeAttribute("data-sleeping");
    backlight = false;
    root.removeAttribute("data-backlight");
    if (firmwareSettings.backlight !== "Off") setBacklight(true);
    stack = [];
    screen = { type: "boot" };
    render(false);
    clearTimeout(startupTimer);
    startupTimer = setTimeout(() => showRoot(false), 5_000);
  }

  function armAutoSleep() {
    clearTimeout(autoSleepTimer);
    if (sleeping || nowPlaying.isPlaying || screen.type === "breakout") return;
    autoSleepTimer = setTimeout(sleep, 120_000);
  }

  function noteActivity() {
    armAutoSleep();
    if (firmwareSettings.backlight !== "Off") setBacklight(true);
  }

  function applyStartupVolume(syncNative = true) {
    const startupVolume = Number.parseInt(firmwareSettings.startup, 10);
    if (!Number.isFinite(startupVolume)) return;
    volume = startupVolume;
    if (syncNative) sendNativeVolume();
  }

  function restoreLocalFirmwareSettings() {
    clickerEnabled = firmwareSettings.clicker !== "Off";
    firmwareSettings.sleep = "Off";
    storeSettings();
    applyFirmwareSetting("contrast");
    applyFirmwareSetting("backlight");
    applyFirmwareSetting("placement");
    applyStartupVolume(false);
  }

  async function synchronizePlaybackSettings() {
    // These are cancellable native queue mutations. Sending them together
    // made Repeat supersede Shuffle before Shuffle could start; serialize the
    // pair while still letting a later direct user action interrupt either.
    await applyFirmwareSetting("shuffle").catch(() => {});
    await applyFirmwareSetting("repeat").catch(() => {});
    // `volume` is the value captured for this boot. Re-reading the Startup
    // Volume preference here would apply a new preference edited while a slow
    // native ready request was still in flight, rather than on the next boot.
    sendNativeVolume();
  }

  function cycleSetting(selected) {
    const values = {
      "setting-repeat": ["Off", "One", "All"],
      "setting-startup": ["25%", "50%", "75%", "100%"],
      "setting-sleep": ["Off", "5 minutes", "10 minutes", "15 minutes", "20 minutes", "30 minutes", "60 minutes"],
      "setting-backlight": ["Off", "1 Sec", "2 Sec", "3 Sec", "5 Sec", "Always On"],
      "setting-language": ["English", "Deutsch", "Français", "日本語"],
    }[selected.id];
    if (!values) return;
    const index = Math.max(0, values.indexOf(selected.subtitle));
    selected.subtitle = values[(index + 1) % values.length];
    firmwareSettings[selected.id.replace("setting-", "")] = selected.subtitle;
    storeSettings();
  }

  function readStoredSettings() {
    try {
      const parsed = JSON.parse(
        localStorage.getItem("iosamp-firmware-settings") || "{}",
      );
      if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return {};
      const allowed = {
        shuffle: ["Off", "On"],
        repeat: ["Off", "One", "All"],
        sleep: [
          "Off", "5 minutes", "10 minutes", "15 minutes", "20 minutes",
          "30 minutes", "60 minutes",
        ],
        backlight: [
          "Off", "1 Sec", "2 Sec", "3 Sec", "5 Sec", "Always On",
          "1 second", "2 seconds", "3 seconds", "5 seconds",
        ],
        clicker: ["Off", "On"],
        language: [
          "English", "Deutsch", "Français", "日本語",
          "German", "French", "Japanese",
        ],
        placement: ["Top", "Middle", "Bottom"],
      };
      const clean = {};
      for (const [key, values] of Object.entries(allowed)) {
        if (values.includes(parsed[key])) clean[key] = parsed[key];
      }
      for (const key of ["startup", "contrast"]) {
        if (key === "contrast" && ["Low", "Medium", "High"].includes(parsed[key])) {
          clean[key] = { Low: "25%", Medium: "50%", High: "75%" }[parsed[key]];
          continue;
        }
        if (typeof parsed[key] !== "string" || !/^\d{1,3}%$/.test(parsed[key])) {
          continue;
        }
        const percent = Number.parseInt(parsed[key], 10);
        if (percent >= 0 && percent <= 100) clean[key] = `${percent}%`;
      }
      return clean;
    } catch {
      return {};
    }
  }

  function storeSettings() {
    try {
      localStorage.setItem("iosamp-firmware-settings", JSON.stringify(firmwareSettings));
    } catch {
      // Keep the live setting even when persistence is unavailable.
    }
  }

  // Local display/click settings do not depend on Music, and must still
  // restore if the native bridge is unavailable. External-player mutations
  // wait for ready; they read the current live settings and boot-captured
  // volume, so a delayed reply cannot roll back a newer wheel/button command.
  restoreLocalFirmwareSettings();
  function bootstrapNative(attempt = 0) {
    nativeRequest("ready")
      .then((state) => {
        clearTimeout(readyRetryTimer);
        readyRetryTimer = 0;
        transport = state?.transport || "system";
        if (state?.nowPlaying) window.iosAmpNative.nowPlaying(state.nowPlaying);
        synchronizePlaybackSettings();
      })
      .catch(() => {
        if (firmwareClosing || attempt >= 2) return;
        readyRetryTimer = setTimeout(
          () => bootstrapNative(attempt + 1),
          attempt === 0 ? 500 : 1_500,
        );
      });
  }
  bootstrapNative();

  render(false);
  // The launch splash holds 15s (Geoff, Aug 29: "stay up for 15sec unless
  // you tap anything") — then the nostalgia welcome, then the menu. Any tap
  // still advances one step. The 5s wake-from-sleep boot is untouched and
  // still lands on the root menu directly.
  startupTimer = setTimeout(() => showWelcome(false), 15_000);
  armAutoSleep();

  // Startup screens hold for their window, but any click on the device that
  // isn't one of the six controls (which consume their own press to advance)
  // clicks through one step immediately.
  function dismissStartup() {
    if (screen.type === "splash") {
      clearTimeout(startupTimer);
      showWelcome(false);
      return;
    }
    if (screen.type === "welcome" || screen.type === "boot") {
      clearTimeout(startupTimer);
      void showRoot(false);
    }
  }

  window.addEventListener(
    "pointerdown",
    (event) => {
      if (event.target instanceof Element && event.target.closest("[data-button]")) return;
      dismissStartup();
    },
    { capture: true },
  );

  // In the app, chassis and LCD clicks are the window's drag handles and never
  // reach WebKit. The window calls this for a press that didn't move it.
  window.iosAmpChassisClick = dismissStartup;

  window.addEventListener("keydown", (event) => {
    if (holdEnabled) {
      // Hold locks the five front controls and wheel, not the host Mac. Let
      // menu equivalents such as Option-H (release Hold), Command-Q, and the
      // size shortcuts continue to AppKit while suppressing only firmware
      // input keys.
      if (
        event.key.startsWith("Arrow") ||
        event.key === "Enter" ||
        event.key === "Escape" ||
        event.key === "Backspace" ||
        event.key === " "
      ) {
        event.preventDefault();
      }
      return;
    }
    if (
      event.target instanceof Element &&
      event.target.closest("[data-button]") &&
      (event.key === "Enter" || event.key === " ")
    ) {
      // Preserve native button activation so Menu/Previous/Next do not get
      // remapped to the global Select/Play shortcuts.
      return;
    }
    if (
      sleeping
    ) {
      if (
        event.key === "Enter" ||
        event.key === "Escape" ||
        event.key === "Backspace" ||
        event.key === " "
      ) {
        event.preventDefault();
        wake();
      } else if (event.key.startsWith("Arrow")) {
        event.preventDefault();
      }
      return;
    }
    if (event.key.startsWith("Arrow")) {
      event.preventDefault();
      noteActivity();
      const direction = screen.type === "list"
        ? (event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1)
        : (event.key === "ArrowUp" || event.key === "ArrowRight" ? 1 : -1);
      wheelAngle += direction * 15;
      setWheelTurn();
      if (screen.type === "list") {
        if (clickerEnabled) clickVoices();
        hapticDetent();
        moveSelection(direction, 1);
      } else {
        onWheelTravel(direction * 15, event.timeStamp);
      }
      return;
    }
    if (event.repeat) return;
    if (event.key === "Enter") {
      event.preventDefault();
      noteActivity();
      runInput(() => shortPress("select"));
    } else if (event.key === "Escape" || event.key === "Backspace") {
      event.preventDefault();
      runInput(() => shortPress("menu"));
    } else if (event.key === " ") {
      event.preventDefault();
      noteActivity();
      runInput(() => shortPress("play"));
    }
  });

  window.addEventListener("pagehide", () => {
    releaseActiveSeek().catch(() => {});
    nativeRequest("setFirmwareAwake", { awake: false }).catch(() => {});
    firmwareClosing = true;
    clearTimeout(startupTimer);
    clearTimeout(pushTimer);
    clearTimeout(autoSleepTimer);
    clearTimeout(volumeTimer);
    cancelNativeVolumeSync();
    cancelNativeSeekSync();
    clearTimeout(backlightTimer);
    clearTimeout(sleepTimer);
    clearTimeout(scrubTimer);
    clearResetChord();
    clearTimeout(readyRetryTimer);
    for (const timer of holdTimers.values()) clearTimeout(timer);
    holdTimers.clear();
    stopBreakout();
    for (const task of pending.values()) {
      clearTimeout(task.timeout);
      task.reject(new Error("iosAmp closed"));
    }
    pending.clear();
    audioContext?.close().catch(() => {});
  }, { once: true });
})();
