(() => {
  const root = document.querySelector("[data-story-experience]");
  const dataNode = document.getElementById("storyExperienceData");
  if (!root || !dataNode) return;

  let model = JSON.parse(dataNode.textContent || "{}");
  let scenes = model.scenes || [];

  const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
  const realMode = root.dataset.mode === "real";
  const replayMode = root.dataset.mode === "replay";
  const simulationMode = root.dataset.mode === "simulation";
  const controllableMode = replayMode || simulationMode;
  const snapshotUrl = root.dataset.snapshotUrl || "";
  const failedImageUrls = new Set();
  const preloadedImageUrls = new Set();
  const state = {
    index: 0,
    playing: true,
    intervalMs: 6200,
    timer: null,
    drawRequest: null,
    pollTimer: null,
    polling: false,
    changeToken: model.changeToken || root.dataset.changeToken || "",
    terminal: model.isTerminal || root.dataset.terminal === "true",
    pollingStoppedReason: "",
    lastSnapshotHttpStatus: "initial",
    consecutiveUnchangedPolls: 0,
    consecutiveFailedPolls: 0,
    lastSuccessfulSnapshotFetch: "Initial",
    currentSnapshot: model,
    previousSnapshot: null,
    currentVersion: 1,
    previousVersion: 0,
    operationQueue: [],
    lastOperations: ["InitialRender"],
    snapshotBuildMs: 0,
    lastRefreshTime: "Initial"
  };
  const dom = {
    stageLabel: root.querySelector("[data-stage-label]"),
    projectName: root.querySelector("[data-project-name]"),
    bookTitle: root.querySelector("[data-book-title]"),
    snapshotMessage: root.querySelector("[data-snapshot-message]"),
    currentScene: root.querySelector("[data-current-scene]"),
    progressPercent: root.querySelector("[data-progress-percent]"),
    elapsedTime: root.querySelector("[data-elapsed-time]"),
    estimatedRemaining: root.querySelector("[data-estimated-remaining]"),
    progressRing: root.querySelector(".story-exp-progress-ring"),
    chapterCount: root.querySelector("[data-chapter-count]"),
    sceneCount: root.querySelector("[data-scene-count]"),
    currentStage: root.querySelector("[data-current-stage]"),
    discoveries: root.querySelector("[data-discoveries]"),
    activityFeed: root.querySelector("[data-activity-feed]"),
    sceneTitle: root.querySelector("[data-scene-title]"),
    sceneSummary: root.querySelector("[data-scene-summary]"),
    visualStage: root.querySelector("[data-visual-stage]"),
    connections: root.querySelector("[data-connections]"),
    characters: root.querySelector("[data-characters]"),
    location: root.querySelector("[data-location]"),
    assets: root.querySelector("[data-assets]"),
    ribbon: root.querySelector("[data-scene-ribbon]"),
    timeline: root.querySelector("[data-timeline]"),
    relationships: root.querySelector("[data-relationships]"),
    knowledge: root.querySelector("[data-knowledge]"),
    observations: root.querySelector("[data-observations]"),
    play: root.querySelector("[data-control='play']"),
    speed: root.querySelector("[data-speed-control]"),
    diagnostics: root.querySelector("[data-diagnostics]"),
    diagnosticsCurrent: root.querySelector("[data-diagnostics-current]"),
    diagnosticsPrevious: root.querySelector("[data-diagnostics-previous]"),
    diagnosticsToken: root.querySelector("[data-diagnostics-token]"),
    diagnosticsQueue: root.querySelector("[data-diagnostics-queue]"),
    diagnosticsRefresh: root.querySelector("[data-diagnostics-refresh]"),
    diagnosticsBuild: root.querySelector("[data-diagnostics-build]"),
    diagnosticsOperations: root.querySelector("[data-diagnostics-operations]"),
    diagnosticValues: root.querySelectorAll("[data-diagnostic-key]"),
    replayChapter: root.querySelector("[data-replay-chapter]"),
    replayScene: root.querySelector("[data-replay-scene]"),
    detailPanel: root.querySelector("[data-detail-panel]"),
    detailClose: root.querySelector("[data-detail-close]"),
    detailTone: root.querySelector("[data-detail-tone]"),
    detailTitle: root.querySelector("[data-detail-title]"),
    detailBody: root.querySelector("[data-detail-body]")
  };

  if (!scenes.length) {
    state.changeToken = "";
    state.currentSnapshot = { ...model, scenes: [] };
    updateDiagnostics();
  }

  function loadSceneState(index) {
    return scenes[Math.max(0, Math.min(scenes.length - 1, index))];
  }

  function transitionToNextState(nextIndex, operations) {
    const previous = loadSceneState(state.index);
    state.index = Math.max(0, Math.min(scenes.length - 1, nextIndex));
    const current = loadSceneState(state.index);
    queueOperations(operations || compareScenes(previous, current));
    preloadSceneImages(current);
    preloadSceneImages(loadSceneState(Math.min(scenes.length - 1, state.index + 1)));
    logImageDiagnostics(current);

    updateProgress(current);
    updateSceneCopy(current);
    updateReplayBrowser(current);
    const layout = sceneLayout(current);
    reconcileCharacters(current, previous, layout);
    reconcileLocation(current, previous);
    reconcileAssets(current, previous, layout);
    reconcileRelationships(current);
    updateKnowledgeThreads(current);
    updateSceneRibbon(current);
    updateTimeline(current);
    updateFeeds(current);
    scheduleConnectionDraw();
    updateDiagnostics();
  }

  function replaceModel(nextModel, snapshotBuildMs) {
    if (!nextModel) return;
    const operations = compareSnapshots(state.currentSnapshot, nextModel);
    if (!operations.length && !nextModel.isTerminal) return;
    state.previousSnapshot = state.currentSnapshot;
    state.currentSnapshot = nextModel;
    state.previousVersion = state.currentVersion;
    state.currentVersion += 1;
    state.operationQueue = operations;
    state.lastOperations = operations.map((operation) => operation.type);
    state.snapshotBuildMs = snapshotBuildMs || 0;
    state.lastRefreshTime = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });

    model = nextModel || model;
    scenes = model.scenes || [];
    state.changeToken = model.changeToken || state.changeToken;
    state.terminal = !!model.isTerminal;
    if (!scenes.length) {
      updateDiagnostics();
      if (state.terminal) stopPolling("terminal model received");
      return;
    }

    state.index = realMode ? authoritativeSceneIndex(scenes) : Math.min(state.index, Math.max(0, scenes.length - 1));
    logSnapshotSynchronisation("snapshot-applied", model, loadSceneState(state.index));
    if (model.projectName) {
      text(dom.projectName, model.projectName);
    }
    if (model.bookTitle) {
      text(dom.bookTitle, model.bookTitle);
    }
    if (model.snapshotMessage) {
      text(dom.snapshotMessage, model.snapshotMessage);
    }
    transitionToNextState(state.index, operations);
    if (state.terminal) stopPolling("terminal model received");
  }

  function updateProgress(scene) {
    const replayText = replayMode ? replayStatusText(scene) : scene.stage;
    text(dom.stageLabel, replayText);
    text(dom.currentScene, scene.sceneNumber);
    animateNumber(dom.progressPercent, scene.progressPercent, "%");
    text(dom.elapsedTime, scene.elapsedTime || "Calculating");
    text(dom.estimatedRemaining, scene.estimatedRemaining);
    text(dom.chapterCount, `${scene.chaptersAnalysed} of ${scene.chapterTotal}`);
    text(dom.sceneCount, `${scene.scenesAnalysed} of ${scene.sceneTotal}`);
    text(dom.currentStage, replayText);
    if (dom.progressRing) dom.progressRing.style.setProperty("--progress-value", `${scene.progressPercent * 3.6}deg`);
  }

  function replayStatusText(scene) {
    if (!scene) return "Replay";
    const stateText = state.playing ? "Replaying" : "Paused at";
    return `${stateText} ${scene.chapterLabel} Scene ${scene.sceneNumber}`;
  }

  function updateSceneCopy(scene) {
    text(dom.sceneSummary, scene.summary);
  }

  function setupReplayBrowser() {
    if (!replayMode || !dom.replayChapter || !dom.replayScene) return;
    const chapters = [];
    const seen = new Set();
    const failedChapters = (root.dataset.failedChapters || "")
      .split(";")
      .map((value) => value.trim())
      .filter(Boolean);
    scenes.forEach((scene) => {
      const key = replayChapterKey(scene);
      if (seen.has(key)) return;
      seen.add(key);
      chapters.push({ key, label: scene.chapterLabel || `Chapter ${scene.chapterNumber || ""}` });
    });

    const replayOptions = chapters
      .map((chapter) => `<option value="${escapeHtml(chapter.key)}">${escapeHtml(chapter.label)}</option>`);
    const gapOptions = failedChapters
      .map((chapter, index) => `<option value="failed-${index}" disabled>${escapeHtml(chapter)}</option>`);
    dom.replayChapter.innerHTML = replayOptions.concat(gapOptions).join("");
    populateReplayScenes(chapters[0]?.key || "");
  }

  function populateReplayScenes(chapterKey) {
    if (!dom.replayScene) return;
    const options = scenes
      .map((scene, index) => ({ scene, index }))
      .filter((item) => replayChapterKey(item.scene) === chapterKey);
    dom.replayScene.innerHTML = options
      .map((item) => `<option value="${item.index}">Scene ${item.scene.sceneNumber} - ${escapeHtml(compactPhrase(item.scene.title || item.scene.summary || "Saved scene", 58))}</option>`)
      .join("");
  }

  function updateReplayBrowser(scene) {
    if (!replayMode || !scene || !dom.replayChapter || !dom.replayScene) return;
    const key = replayChapterKey(scene);
    if (dom.replayChapter.value !== key) {
      dom.replayChapter.value = key;
      populateReplayScenes(key);
    }
    dom.replayScene.value = String(state.index);
  }

  function replayChapterKey(scene) {
    return String(scene?.chapterNumber || scene?.chapterLabel || "unknown");
  }

  function setEntityImage(image, url, resolution, altText) {
    if (!image) return;
    image.alt = altText;
    const fallbackUrl = resolution?.fallbackImageUrl || url;
    const nextUrl = failedImageUrls.has(url) ? fallbackUrl : url;
    image.dataset.fallbackUrl = fallbackUrl || "";
    image.dataset.entityId = resolution?.entityId || "";
    image.dataset.libraryCode = resolution?.stableCode || "";
    image.onerror = () => {
      const fallback = image.dataset.fallbackUrl;
      if (!fallback || image.src.endsWith(fallback)) return;
      failedImageUrls.add(url);
      image.src = fallback;
      scheduleConnectionDraw();
    };
    image.onload = () => scheduleConnectionDraw();
    if (image.getAttribute("src") !== nextUrl) {
      image.src = nextUrl;
    }
  }

  function preloadSceneImages(scene) {
    if (!scene) return;
    collectSceneImages(scene).forEach((url) => {
      if (!url || preloadedImageUrls.has(url) || failedImageUrls.has(url)) return;
      preloadedImageUrls.add(url);
      const image = new Image();
      image.onload = () => {};
      image.onerror = () => failedImageUrls.add(url);
      image.src = url;
    });
  }

  function collectSceneImages(scene) {
    return [
      scene.location?.imagePath,
      ...(scene.characters || []).map((character) => character.imagePath),
      ...(scene.assets || []).map((asset) => asset.imagePath)
    ].filter(Boolean);
  }

  function logImageDiagnostics(scene) {
    if (!root.dataset.imageDiagnostics) return;
    const rows = [];
    const push = (type, item) => {
      const resolution = item?.imageResolution || {};
      rows.push({
        type,
        entityId: item?.id,
        stableCode: resolution.stableCode || item?.libraryCode || "",
        recordId: resolution.illustrationLibraryItemId || null,
        status: resolution.status || "fallback",
        finalImageUrl: resolution.finalImageUrl || item?.imagePath || "",
        fallbackUsed: !!resolution.fallbackUsed,
        assignmentConfidence: resolution.assignmentConfidence || null,
        matchingScore: resolution.matchingScore || null,
        reasonChosen: resolution.reasonChosen || "",
        reasonReassigned: resolution.reasonReassigned || "",
        rejectedCandidates: resolution.rejectedCandidates || "",
        demandStatus: resolution.demandStatus || ""
      });
    };

    push("location", scene.location);
    (scene.characters || []).forEach((character) => push("character", character));
    (scene.assets || []).forEach((asset) => push("asset", asset));
    console.table(rows);
  }

  function reconcileCharacters(scene, previous, layout) {
    const activeIds = new Set(scene.characters.map((character) => character.id));
    scene.characters.forEach((character, index) => {
      let node = dom.characters.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
      if (!node) {
        node = createCharacterNode(character);
        node.classList.add("is-entering");
        dom.characters.append(node);
        settleEntering(node);
      }

      const position = layout.characters.get(character.id) || characterPosition(character, index, scene.povCharacterId, scene.characters);
      node.classList.toggle("is-pov", character.id === scene.povCharacterId);
      node.classList.toggle("is-secondary", character.id !== scene.povCharacterId && character.weight >= 65);
      node.classList.toggle("is-context", character.id !== scene.povCharacterId && character.weight < 65);
      node.style.setProperty("--node-size", `${position.size}px`);
      node.style.setProperty("--x", `${position.x}px`);
      node.style.setProperty("--y", `${position.y}px`);
      node.style.setProperty("--scale", String(position.scale));
      node.style.setProperty("--opacity", String(position.opacity));
      node.style.setProperty("--z", String(position.z));
      node.dataset.layoutRole = position.layoutRole;
      setEntityImage(node.querySelector("img"), character.imagePath, character.imageResolution, `${character.name} prototype portrait`);
      text(node.querySelector("[data-character-name]"), character.name);
      const label = conciseCharacterLabel(character, character.id === scene.povCharacterId);
      text(node.querySelector("[data-character-role]"), label);
      node.title = `${character.name}: ${character.role}${character.relevance ? ` - ${character.relevance}` : ""}`;
    });

    dom.characters.querySelectorAll("[data-character-id]").forEach((node) => {
      if (!activeIds.has(node.dataset.characterId)) {
        node.style.setProperty("--opacity", "0");
        node.style.setProperty("--scale", "0.86");
        window.setTimeout(() => {
          const currentIds = new Set(loadSceneState(state.index).characters.map((character) => character.id));
          if (!currentIds.has(node.dataset.characterId)) node.remove();
        }, reducedMotion ? 0 : 620);
      }
    });
  }

  function reconcileLocation(scene, previous) {
    const changed = previous?.location?.id !== scene.location.id;
    let node = dom.location.querySelector("[data-location-id]");
    if (!node) {
      node = document.createElement("article");
      node.className = "story-location";
      node.innerHTML = `
        <div class="story-location__image"><img alt="${escapeHtml(scene.location.name)} prototype location" /></div>
        <div class="story-location__text">
          <h2></h2>
          <p></p>
        </div>`;
      dom.location.append(node);
      node.classList.add("is-entering");
      settleEntering(node);
    }

    if (changed) {
      node.classList.add("is-changing");
      window.setTimeout(() => node.classList.remove("is-changing"), reducedMotion ? 0 : 420);
    }

    node.dataset.locationId = scene.location.id;
    const position = locationPosition();
    node.style.setProperty("--node-size", `${position.size}px`);
    node.style.setProperty("--x", `${position.x}px`);
    node.style.setProperty("--y", `${position.y}px`);
    setEntityImage(node.querySelector("img"), scene.location.imagePath, scene.location.imageResolution, `${scene.location.name} prototype location`);
    text(node.querySelector("h2"), scene.location.name);
    text(node.querySelector("p"), conciseLocationLabel(scene.location));
    node.title = `${scene.location.name}: ${scene.location.label}`;
  }

  function reconcileAssets(scene, previous, layout) {
    const activeIds = new Set(scene.assets.map((asset) => asset.id));
    scene.assets.forEach((asset, index) => {
      let node = dom.assets.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
      if (!node) {
        node = createAssetNode(asset);
        node.classList.add("is-entering");
        dom.assets.append(node);
        settleEntering(node);
      }

      const position = layout.assets.get(asset.id) || assetPosition(asset, index, scene.assets.length);
      node.style.setProperty("--node-size", `${position.size}px`);
      node.style.setProperty("--x", `${position.x}px`);
      node.style.setProperty("--y", `${position.y}px`);
      node.style.setProperty("--scale", String(position.scale));
      node.style.setProperty("--z", String(position.z));
      node.style.setProperty("--opacity", "1");
      setEntityImage(node.querySelector("img"), asset.imagePath, asset.imageResolution, `${asset.name} prototype asset`);
      text(node.querySelector("[data-asset-name]"), asset.name);
      text(node.querySelector("[data-asset-label]"), conciseAssetLabel(asset));
      node.title = `${asset.name}: ${asset.label}`;
    });

    dom.assets.querySelectorAll("[data-asset-id]").forEach((node) => {
      if (!activeIds.has(node.dataset.assetId)) {
        node.style.setProperty("--opacity", "0");
        node.style.setProperty("--scale", "0.8");
        window.setTimeout(() => {
          const currentIds = new Set(loadSceneState(state.index).assets.map((asset) => asset.id));
          if (!currentIds.has(node.dataset.assetId)) node.remove();
        }, reducedMotion ? 0 : 520);
      }
    });
  }

  function reconcileRelationships(scene) {
    const relationships = scene.relationships.map((relationship) => ({
      id: relationship.id,
      title: relationship.label,
      detail: relationship.state,
      tone: relationship.weight >= 90 ? "confirmed" : relationship.weight >= 70 ? "strengthening" : "emerging"
    }));
    renderInsights(dom.relationships, relationships.slice(0, 1));
  }

  function updateKnowledgeThreads(scene) {
    renderInsights(dom.knowledge, (scene.knowledgeThreads || []).slice(0, 5));
    renderInsights(dom.observations, (scene.observations || []).slice(0, 4));
  }

  function updateSceneRibbon(scene) {
    const visible = visibleSceneWindow();
    const activeIds = new Set(visible.map((item) => item.id));
    visible.forEach((item) => {
      let card = dom.ribbon.querySelector(`[data-scene-id="${cssEscape(item.id)}"]`);
      if (!card) {
        card = document.createElement("article");
        card.className = "story-scene-card is-entering";
        card.dataset.sceneId = item.id;
        card.innerHTML = `<span></span><strong></strong><small></small>`;
        settleEntering(card);
      }
      card.classList.toggle("is-current", item.id === scene.id);
      card.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
      text(card.querySelector("span"), `${abbreviateChapter(item.chapterLabel)} · Scene ${item.sceneNumber}`);
      text(card.querySelector("strong"), compactPhrase(cleanSceneTitle(item.title), 54));
      text(card.querySelector("small"), compactPhrase(item.timelineEvent || item.summary, 48));
      dom.ribbon.append(card);
    });
    removeInactive(dom.ribbon, "[data-scene-id]", "sceneId", activeIds);
  }

  function updateTimeline(scene) {
    const visible = visibleSceneWindow();
    dom.timeline.style.setProperty("--timeline-count", String(visible.length));
    const activeIds = new Set(visible.map((item) => item.id));
    visible.forEach((item) => {
      let marker = dom.timeline.querySelector(`[data-timeline-id="${cssEscape(item.id)}"]`);
      if (!marker) {
        marker = document.createElement("div");
        marker.className = "story-timeline-marker is-entering";
        marker.dataset.timelineId = item.id;
        marker.innerHTML = `<span></span>`;
        settleEntering(marker);
      }
      marker.classList.toggle("is-current", item.id === scene.id);
      marker.classList.toggle("is-complete", item.sceneNumber < scene.sceneNumber);
      marker.classList.remove("is-label-hidden");
      text(marker.querySelector("span"), shortTimelineLabel(item));
      dom.timeline.append(marker);
    });
    removeInactive(dom.timeline, "[data-timeline-id]", "timelineId", activeIds);
  }

  function visibleSceneWindow() {
    const visibleCount = Math.min(5, scenes.length);
    const currentIndex = Math.max(0, Math.min(state.index, scenes.length - 1));
    const start = Math.max(0, Math.min(scenes.length - visibleCount, currentIndex - Math.floor(visibleCount / 2)));
    return scenes.slice(start, start + visibleCount);
  }

  function shortTimelineLabel(item) {
    const value = String(item.timelineLabel || "").trim();
    if (value && !/book\s+\d|alpha flame|chapter/i.test(value) && value.length <= 18) return value;
    return `${abbreviateChapter(item.chapterLabel)} · S${item.sceneNumber}`;
  }

  function abbreviateChapter(value) {
    const match = String(value || "").match(/(\d+(?:\.\d+)?)/);
    return match ? `Ch ${match[1]}` : "Ch";
  }

  function cleanSceneTitle(value) {
    return String(value || "").replace(/^book\s+\d+\s*[:|-]\s*/i, "").replace(/^the alpha flame[^:|-]*[:|-]\s*/i, "").trim();
  }

  function updateFeeds(scene) {
    renderList(dom.discoveries, (scene.latestDiscoveries || []).slice(0, 4));
    renderList(dom.activityFeed, (scene.activityFeed || []).slice(0, 4));
  }

  function renderInsights(container, items) {
    const activeIds = new Set(items.map((item) => item.id));
    items.forEach((item) => {
      let node = container.querySelector(`[data-insight-id="${cssEscape(item.id)}"]`);
      if (!node) {
        node = document.createElement("article");
        node.className = "story-exp-insight is-entering";
        node.dataset.insightId = item.id;
        node.innerHTML = `<strong></strong><p></p>`;
        container.append(node);
        settleEntering(node);
      }

      node.dataset.tone = item.tone || "";
      text(node.querySelector("strong"), item.title);
      text(node.querySelector("p"), item.detail);
      node.title = `${item.title || ""}\n${item.detail || ""}`.trim();
      node.tabIndex = 0;
      node.onclick = () => openDetail(item);
      node.onkeydown = (event) => {
        if (event.key === "Enter" || event.key === " ") {
          event.preventDefault();
          openDetail(item);
        }
      };
    });

    container.querySelectorAll("[data-insight-id]").forEach((node) => {
      if (!activeIds.has(node.dataset.insightId)) {
        node.style.opacity = "0";
        node.style.transform = "translateY(8px)";
        window.setTimeout(() => node.remove(), reducedMotion ? 0 : 360);
      }
    });
  }

  function openDetail(item) {
    if (!dom.detailPanel) return;
    text(dom.detailTone, item.tone || "Story detail");
    text(dom.detailTitle, item.title || "Story detail");
    text(dom.detailBody, item.detail || "");
    dom.detailPanel.hidden = false;
  }

  function closeDetail() {
    if (dom.detailPanel) dom.detailPanel.hidden = true;
  }

  function renderList(container, items) {
    const activeIds = new Set(items.map((item, index) => feedId(item, index)));
    items.forEach((item, index) => {
      const id = feedId(item, index);
      let node = container.querySelector(`[data-feed-id="${cssEscape(id)}"]`);
      if (!node) {
        node = document.createElement("li");
        node.dataset.feedId = id;
        node.className = "is-entering";
        settleEntering(node);
      }
      if (container === dom.discoveries) {
        node.dataset.discoveryType = discoveryType(item);
        node.innerHTML = "<span aria-hidden=\"true\"></span><p></p>";
        text(node.querySelector("p"), item);
      } else {
        node.textContent = item;
      }
      container.append(node);
    });
    removeInactive(container, "[data-feed-id]", "feedId", activeIds);
  }

  function feedId(item, index) {
    return `${index}-${stableHash(item)}`;
  }

  function discoveryType(item) {
    const value = String(item || "").toLowerCase();
    if (value.includes("relationship") || value.includes("trust") || value.includes("conflict")) return "relationship";
    if (value.includes("knowledge") || value.includes("thread") || value.includes("secret")) return "knowledge";
    if (value.includes("location") || value.includes("doweries") || value.includes("flat") || value.includes("stairwell")) return "location";
    if (value.includes("letter") || value.includes("notebook") || value.includes("rucksack") || value.includes("tr6") || value.includes("keys") || value.includes("phone")) return "asset";
    return "character";
  }

  function scheduleConnectionDraw() {
    if (state.drawRequest) {
      window.cancelAnimationFrame(state.drawRequest);
    }

    state.drawRequest = window.requestAnimationFrame(() => {
      state.drawRequest = null;
      drawConnections(loadSceneState(state.index));
    });
  }

  function drawConnections(scene) {
    if (!dom.connections || !dom.visualStage) return;
    const stageRect = dom.visualStage.getBoundingClientRect();
    const drawn = [];
    const activeIds = new Set();
    const occupiedRects = layoutRects(stageRect);

    scene.relationships.forEach((relationship, index) => {
      const source = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.sourceId)}"]`);
      const target = dom.visualStage.querySelector(`[data-character-id="${cssEscape(relationship.targetId)}"]`);
      if (!source || !target) return;

      const start = connectionPoint(source, stageRect, target);
      const end = connectionPoint(target, stageRect, source);
      const obstacles = connectionObstacles(stageRect, source, target);
      const id = relationship.id || `relationship-${relationship.sourceId}-${relationship.targetId}`;
      activeIds.add(id);
      drawn.push(drawConnection(id, start, end, relationshipTone(relationship), relationship.weight, index < 2 ? inlineRelationshipLabel(relationship) : "", index, obstacles, occupiedRects));
    });

    const pov = dom.visualStage.querySelector(`[data-character-id="${cssEscape(scene.povCharacterId)}"]`);
    const location = dom.visualStage.querySelector("[data-location-id]");
    if (pov && location) {
      const id = `presence-${scene.povCharacterId}-${location.dataset.locationId}`;
      activeIds.add(id);
      drawn.push(drawConnection(id, connectionPoint(pov, stageRect, location), connectionPoint(location, stageRect, pov), "presence", 68, "", drawn.length, connectionObstacles(stageRect, pov, location), occupiedRects));
    }

    scene.characters
      .filter((character) => character.id !== scene.povCharacterId && character.weight >= 65)
      .slice(0, 1)
      .forEach((character) => {
        const node = dom.visualStage.querySelector(`[data-character-id="${cssEscape(character.id)}"]`);
        if (node && location) {
          const id = `presence-soft-${character.id}-${location.dataset.locationId}`;
          activeIds.add(id);
          drawn.push(drawConnection(id, connectionPoint(node, stageRect, location), connectionPoint(location, stageRect, node), "presence-soft", character.weight, "", drawn.length, connectionObstacles(stageRect, node, location), occupiedRects));
        }
      });

    scene.assets.forEach((asset, index) => {
      const assetNode = dom.visualStage.querySelector(`[data-asset-id="${cssEscape(asset.id)}"]`);
      if (!assetNode || !location) return;
      const id = `asset-${location.dataset.locationId}-${asset.id}`;
      activeIds.add(id);
      drawn.push(drawConnection(id, connectionPoint(location, stageRect, assetNode), connectionPoint(assetNode, stageRect, location), "evidence", asset.weight, index === 0 ? assetConnectionLabel(asset) : "", drawn.length, connectionObstacles(stageRect, location, assetNode), occupiedRects));
    });

    removeInactiveSvgConnections(activeIds);
  }

  function drawConnection(id, start, end, tone, weight, label, index, obstacles, occupiedRects) {
    let group = dom.connections.querySelector(`[data-connection-id="${cssEscape(id)}"]`);
    const isNew = !group;
    if (!group) {
      group = document.createElementNS("http://www.w3.org/2000/svg", "g");
      group.dataset.connectionId = id;
      group.classList.add("is-entering");
      dom.connections.append(group);
      settleEntering(group);
    }
    group.setAttribute("class", `story-exp-connection-group story-exp-connection-group--${tone}`);

    let path = group.querySelector("path");
    if (!path) {
      path = document.createElementNS("http://www.w3.org/2000/svg", "path");
      path.setAttribute("pathLength", "1");
      path.setAttribute("class", "story-exp-connection");
      group.append(path);
    }
    const route = routeConnection(start, end, index, obstacles);
    const d = `M ${start.x} ${start.y} C ${route.startControl.x} ${route.startControl.y}, ${route.endControl.x} ${route.endControl.y}, ${end.x} ${end.y}`;
    path.setAttribute("d", d);
    path.style.strokeWidth = String(0.9 + Math.min(weight, 100) / 62);

    group.querySelector(".story-exp-connection-label-group")?.remove();
    if (label) {
      let displayLabel = label;
      let labelBox = relationshipLabelBox(displayLabel);
      let labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
      if (!labelPosition) {
        displayLabel = compactPhrase(label, 24);
        labelBox = relationshipLabelBox(displayLabel);
        labelPosition = relationshipLabelPosition(start, route, end, occupiedRects, labelBox);
      }
      if (!labelPosition) {
        return group;
      }
      const labelGroup = document.createElementNS("http://www.w3.org/2000/svg", "g");
      labelGroup.setAttribute("class", "story-exp-connection-label-group");
      const labelNode = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject");
      labelNode.setAttribute("class", "story-exp-connection-label-wrap");
      labelNode.setAttribute("x", String(labelPosition.x - labelBox.width / 2));
      labelNode.setAttribute("y", String(labelPosition.y - labelBox.height / 2));
      labelNode.setAttribute("width", String(labelBox.width));
      labelNode.setAttribute("height", String(labelBox.height));
      labelNode.setAttribute("title", label);
      const labelDiv = document.createElement("div");
      labelDiv.className = "story-exp-connection-label";
      labelDiv.title = label;
      labelDiv.textContent = displayLabel;
      labelNode.append(labelDiv);
      labelGroup.append(labelNode);
      group.append(labelGroup);
      occupiedRects.push(labelRect(labelPosition, labelBox));
    }

    if (isNew) group.classList.add("is-entering");
    return group;
  }

  function createCharacterNode(character) {
    const node = document.createElement("article");
    node.className = "story-character";
    node.dataset.characterId = character.id;
    node.innerHTML = `
      <div class="story-character__image"><img loading="eager" /></div>
      <div class="story-character__text">
        <strong data-character-name></strong>
        <span data-character-role></span>
      </div>`;
    return node;
  }

  function createAssetNode(asset) {
    const node = document.createElement("article");
    node.className = "story-asset";
    node.dataset.assetId = asset.id;
    node.innerHTML = `
      <div class="story-asset__image"><img loading="eager" /></div>
      <div class="story-asset__text">
        <strong data-asset-name></strong>
        <span data-asset-label></span>
      </div>`;
    return node;
  }

  function sceneLayout(scene) {
    const bounds = dom.visualStage?.getBoundingClientRect();
    const width = Math.max(760, bounds?.width || 1040);
    const height = Math.max(500, bounds?.height || 560);
    return {
      characters: characterLayout(scene, width, height),
      assets: assetLayout(scene, width, height)
    };
  }

  function characterLayout(scene, width, height) {
    const positions = new Map();
    const characters = [...scene.characters];
    const pov = characters.find((character) => character.id === scene.povCharacterId) || characters[0];
    const others = characters
      .filter((character) => character.id !== pov?.id)
      .sort((a, b) => b.weight - a.weight);
    const povSize = Math.round(Math.min(198, Math.max(154, width * 0.155)));
    if (pov) {
      positions.set(pov.id, {
        x: clamp(Math.round(width * 0.24 - povSize / 2), 28, width * 0.44 - povSize),
        y: clamp(Math.round(height * 0.28 - povSize / 2), 54, height * 0.54 - povSize),
        size: povSize,
        scale: 1,
        opacity: 1,
        z: 7,
        layoutRole: "pov",
        labelSide: "below"
      });
    }

    const slots = [
      { x: 0.41, y: 0.48, role: "partner" },
      { x: 0.12, y: 0.55, role: "support" },
      { x: 0.11, y: 0.19, role: "support" },
      { x: 0.33, y: 0.70, role: "context" },
      { x: 0.05, y: 0.36, role: "context" }
    ];
    const placed = [...positions.values()];
    others.forEach((character, index) => {
      const contextual = isContextCharacter(character);
      const size = Math.round(contextual
        ? Math.min(108, Math.max(82, 70 + character.weight * 0.42))
        : Math.min(154, Math.max(116, 96 + character.weight * 0.58)));
      const slot = slots[index] || slots[slots.length - 1];
      const desired = {
        x: width * slot.x - size / 2,
        y: height * slot.y - size / 2
      };
      const resolved = avoidNodeCollisions(desired, size, placed, width, height);
      const position = {
        x: resolved.x,
        y: resolved.y,
        size,
        scale: 1,
        opacity: contextual ? 0.76 : 0.94,
        z: contextual ? 3 : 5,
        layoutRole: contextual ? "context" : slot.role,
        labelSide: "below"
      };
      placed.push(position);
      positions.set(character.id, position);
    });

    return positions;
  }

  function characterPosition(character, index, povId, characters) {
    const bounds = dom.visualStage?.getBoundingClientRect();
    const width = Math.max(820, bounds?.width || 1040);
    const height = Math.max(500, bounds?.height || 560);
    const layout = characterLayout({ characters: characters || [character], povCharacterId: povId }, width, height);
    if (layout.has(character.id)) return layout.get(character.id);
    const povSize = Math.min(210, Math.max(166, width * 0.16));

    if (character.id === povId) {
      return {
        x: Math.round(width * 0.25 - povSize / 2),
        y: Math.round(height * 0.25 - povSize / 2),
        size: Math.round(povSize),
        scale: 1,
        opacity: 1
      };
    }

    const important = character.weight >= 70;
    const positions = important
      ? [
          { x: width * 0.12, y: height * 0.16 },
          { x: width * 0.38, y: height * 0.38 },
          { x: width * 0.16, y: height * 0.56 }
        ]
      : [
          { x: width * 0.08, y: height * 0.45 },
          { x: width * 0.39, y: height * 0.66 },
          { x: width * 0.18, y: height * 0.34 }
        ];
    const slot = positions[index % positions.length];
    const size = important
      ? Math.min(176, 136 + (character.weight - 70) * 0.9)
      : Math.min(116, 88 + Math.max(0, character.weight - 24) * 0.45);
    return {
      x: Math.round(slot.x),
      y: Math.round(slot.y),
      size: Math.round(size),
      scale: 1,
      opacity: important ? 0.94 : 0.72
    };
  }

  function locationPosition() {
    const bounds = dom.visualStage?.getBoundingClientRect();
    const width = Math.max(820, bounds?.width || 1040);
    const height = Math.max(500, bounds?.height || 560);
    const size = Math.min(220, Math.max(176, width * 0.18));
    return {
      x: Math.round(width * 0.62 - size / 2),
      y: Math.round(height * 0.46 - size / 2),
      size
    };
  }

  function assetPosition(asset, index, total) {
    const bounds = dom.visualStage?.getBoundingClientRect();
    const width = Math.max(820, bounds?.width || 1040);
    const height = Math.max(500, bounds?.height || 560);
    const anchors = [
      { x: 0.83, y: 0.26 },
      { x: 0.84, y: 0.51 },
      { x: 0.82, y: 0.74 },
      { x: 0.72, y: 0.19 }
    ];
    const anchor = anchors[index % anchors.length];
    const size = Math.min(104, Math.max(68, (64 + Math.min(asset.weight, 100) * 0.42) * 0.9));
    return {
      x: Math.round(width * anchor.x - size / 2),
      y: Math.round(height * anchor.y - size / 2),
      size: Math.round(size),
      scale: 1
    };
  }

  function assetLayout(scene, width, height) {
    const positions = new Map();
    const total = scene.assets.length;
    const anchorsByTotal = {
      1: [{ x: 0.83, y: 0.48 }],
      2: [{ x: 0.83, y: 0.34 }, { x: 0.83, y: 0.62 }],
      3: [{ x: 0.82, y: 0.25 }, { x: 0.85, y: 0.50 }, { x: 0.81, y: 0.75 }],
      4: [{ x: 0.78, y: 0.16 }, { x: 0.90, y: 0.37 }, { x: 0.86, y: 0.62 }, { x: 0.73, y: 0.82 }],
      5: [{ x: 0.76, y: 0.13 }, { x: 0.91, y: 0.30 }, { x: 0.80, y: 0.48 }, { x: 0.92, y: 0.66 }, { x: 0.72, y: 0.84 }]
    };
    const anchors = anchorsByTotal[Math.min(5, Math.max(1, total))] || anchorsByTotal[5];
    const placed = [];
    scene.assets.forEach((asset, index) => {
      const anchor = anchors[index] || anchors[anchors.length - 1];
      const important = index === 0 || asset.weight >= 90;
      const size = Math.round(important
        ? Math.min(104, Math.max(83, (70 + Math.min(asset.weight, 100) * 0.42) * 0.9))
        : Math.min(88, Math.max(68, (58 + Math.min(asset.weight, 100) * 0.32) * 0.9)));
      const desired = {
        x: width * anchor.x - size / 2,
        y: height * anchor.y - size / 2
      };
      const resolved = avoidNodeCollisions(desired, size, placed, width, height);
      const position = { x: resolved.x, y: resolved.y, size, scale: 1, z: important ? 4 : 3 };
      placed.push(position);
      positions.set(asset.id, position);
    });
    return positions;
  }

  function avoidNodeCollisions(desired, size, placed, width, height) {
    const padding = Math.max(16, size * 0.12);
    const candidates = [
      desired,
      { x: desired.x, y: desired.y + size * 0.34 },
      { x: desired.x + size * 0.42, y: desired.y },
      { x: desired.x - size * 0.42, y: desired.y },
      { x: desired.x, y: desired.y - size * 0.34 },
      { x: desired.x + size * 0.34, y: desired.y + size * 0.28 },
      { x: desired.x - size * 0.34, y: desired.y + size * 0.28 }
    ];

    let best = clampPosition(candidates[0], size, width, height);
    let bestScore = Number.POSITIVE_INFINITY;
    candidates.forEach((candidate) => {
      const clamped = clampPosition(candidate, size, width, height);
      const score = placed.reduce((total, other) => {
        const distance = Math.hypot((clamped.x + size / 2) - (other.x + other.size / 2), (clamped.y + size / 2) - (other.y + other.size / 2));
        const minDistance = (size + other.size) * 0.45 + padding;
        return total + Math.max(0, minDistance - distance) * 12;
      }, Math.hypot(clamped.x - desired.x, clamped.y - desired.y));
      if (score < bestScore) {
        bestScore = score;
        best = clamped;
      }
    });
    return best;
  }

  function clampPosition(position, size, width, height) {
    return {
      x: Math.round(clamp(position.x, 18, width - size - 18)),
      y: Math.round(clamp(position.y, 46, height - size - 58))
    };
  }

  function centrePoint(node, parentRect) {
    const rect = node.getBoundingClientRect();
    return {
      x: rect.left - parentRect.left + rect.width / 2,
      y: rect.top - parentRect.top + rect.height / 2
    };
  }

  function connectionPoint(node, parentRect, targetNode) {
    const image = node.querySelector(".story-character__image, .story-location__image, .story-asset__image") || node;
    const centre = centrePoint(image, parentRect);
    const rect = image.getBoundingClientRect();
    const target = targetNode ? centrePoint(targetNode, parentRect) : centre;
    const targetImage = targetNode?.querySelector?.(".story-character__image, .story-location__image, .story-asset__image");
    const targetCentre = targetImage ? centrePoint(targetImage, parentRect) : target;
    const radius = Math.min(rect.width, rect.height) * 0.5;
    const angle = Math.atan2(targetCentre.y - centre.y, targetCentre.x - centre.x);
    return {
      x: centre.x + Math.cos(angle) * radius,
      y: centre.y + Math.sin(angle) * radius
    };
  }

  function routeConnection(start, end, index, obstacles) {
    const mid = { x: (start.x + end.x) / 2, y: (start.y + end.y) / 2 };
    const dx = end.x - start.x;
    const dy = end.y - start.y;
    const length = Math.max(1, Math.hypot(dx, dy));
    const normal = { x: -dy / length, y: dx / length };
    const tangent = { x: dx / length, y: dy / length };
    const base = Math.max(34, Math.min(122, length * 0.2));
    const direction = index % 2 === 0 ? 1 : -1;
    const offsets = [direction * base, -direction * base, direction * base * 1.45, -direction * base * 1.45, 0];
    let best = offsets[0];
    let bestScore = Number.POSITIVE_INFINITY;
    offsets.forEach((offset) => {
      const controls = cubicControls(start, end, tangent, normal, offset, length);
      const samples = [0.18, 0.33, 0.5, 0.67, 0.82].map((t) => pointOnCubic(start, controls.startControl, controls.endControl, end, t));
      const score = samples.reduce((total, point) => total + obstacles.reduce((sum, rect) => sum + pointRectPenalty(point, rect), 0), Math.abs(offset) * 0.04);
      if (score < bestScore) {
        bestScore = score;
        best = offset;
      }
    });
    return cubicControls(start, end, tangent, normal, best, length);
  }

  function cubicControls(start, end, tangent, normal, offset, length) {
    const pull = Math.max(52, Math.min(150, length * 0.34));
    return {
      startControl: {
        x: start.x + tangent.x * pull + normal.x * offset,
        y: start.y + tangent.y * pull + normal.y * offset
      },
      endControl: {
        x: end.x - tangent.x * pull + normal.x * offset,
        y: end.y - tangent.y * pull + normal.y * offset
      },
      normal,
      offset
    };
  }

  function layoutRects(parentRect) {
    return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
      .map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 10));
  }

  function connectionObstacles(parentRect, source, target) {
    const sourceImage = source.querySelector(".story-character__image, .story-location__image, .story-asset__image") || source;
    const targetImage = target.querySelector(".story-character__image, .story-location__image, .story-asset__image") || target;
    return [...dom.visualStage.querySelectorAll(".story-character__image, .story-location__image, .story-asset__image, .story-character__text, .story-location__text, .story-asset__text")]
      .filter((node) => node !== sourceImage && node !== targetImage)
      .map((node) => rectRelativeTo(node, parentRect, node.className.includes("__text") ? 8 : 12));
  }

  function rectRelativeTo(node, parentRect, padding) {
    const rect = node.getBoundingClientRect();
    return {
      left: rect.left - parentRect.left - padding,
      right: rect.right - parentRect.left + padding,
      top: rect.top - parentRect.top - padding,
      bottom: rect.bottom - parentRect.top + padding
    };
  }

  function pointOnQuadratic(start, control, end, t) {
    const inverse = 1 - t;
    return {
      x: inverse * inverse * start.x + 2 * inverse * t * control.x + t * t * end.x,
      y: inverse * inverse * start.y + 2 * inverse * t * control.y + t * t * end.y
    };
  }

  function pointOnCubic(start, startControl, endControl, end, t) {
    const inverse = 1 - t;
    return {
      x: inverse ** 3 * start.x + 3 * inverse ** 2 * t * startControl.x + 3 * inverse * t ** 2 * endControl.x + t ** 3 * end.x,
      y: inverse ** 3 * start.y + 3 * inverse ** 2 * t * startControl.y + 3 * inverse * t ** 2 * endControl.y + t ** 3 * end.y
    };
  }

  function relationshipLabelBox(label) {
    const length = String(label || "").length;
    const width = length > 42 ? 168 : length > 24 ? 144 : 118;
    const lines = Math.min(3, Math.max(1, Math.ceil(length / 24)));
    return { width, height: 18 + lines * 15 };
  }

  function relationshipLabelPosition(start, route, end, occupiedRects, labelBox) {
    const sign = Math.sign(route.offset || 1);
    const bounds = dom.visualStage?.getBoundingClientRect();
    const boundary = {
      left: 6,
      top: 6,
      right: Math.max(10, bounds?.width || 1000) - 6,
      bottom: Math.max(10, bounds?.height || 560) - 6
    };
    const candidates = [0.5, 0.42, 0.58, 0.32, 0.68, 0.24, 0.76].flatMap((t) => {
      const point = pointOnCubic(start, route.startControl, route.endControl, end, t);
      return [38, 62, 88, -42, -68, 112, -96].map((distance) => ({
        x: point.x + route.normal.x * sign * distance,
        y: point.y + route.normal.y * sign * distance - 5
      }));
    });
    const best = candidates.reduce((current, candidate) => {
      const candidateRect = labelRect(candidate, labelBox);
      const boundaryPenalty = candidateRect.left < boundary.left || candidateRect.right > boundary.right || candidateRect.top < boundary.top || candidateRect.bottom > boundary.bottom ? 2000 : 0;
      const score = boundaryPenalty + occupiedRects.reduce((sum, rect) => sum + labelRectPenalty(candidateRect, rect), 0);
      return score < current.score ? { point: candidate, score } : current;
    }, { point: candidates[0], score: Number.POSITIVE_INFINITY });
    return best.score >= 1000 ? null : best.point;
  }

  function labelRect(point, labelBox) {
    return {
      left: point.x - labelBox.width / 2,
      right: point.x + labelBox.width / 2,
      top: point.y - labelBox.height / 2,
      bottom: point.y + labelBox.height / 2
    };
  }

  function labelRectPenalty(labelRect, rect) {
    if (rectsIntersect(labelRect, rect)) return 1000;
    const dx = Math.max(rect.left - labelRect.right, 0, labelRect.left - rect.right);
    const dy = Math.max(rect.top - labelRect.bottom, 0, labelRect.top - rect.bottom);
    return Math.max(0, 30 - Math.hypot(dx, dy));
  }

  function rectsIntersect(a, b) {
    return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
  }

  function pointRectPenalty(point, rect) {
    if (point.x >= rect.left && point.x <= rect.right && point.y >= rect.top && point.y <= rect.bottom) return 1000;
    const dx = Math.max(rect.left - point.x, 0, point.x - rect.right);
    const dy = Math.max(rect.top - point.y, 0, point.y - rect.bottom);
    const distance = Math.hypot(dx, dy);
    return Math.max(0, 48 - distance);
  }

  function conciseCharacterLabel(character, isPov) {
    if (isPov) return "POV";
    const value = `${character.role} ${character.relevance}`.toLowerCase();
    if (value.includes("suspect") || value.includes("threat") || value.includes("opposing")) return "Suspect";
    if (value.includes("witness") || value.includes("neighbour")) return "Witness";
    if (value.includes("ally") || value.includes("protected")) return "Ally";
    if (value.includes("absent") || value.includes("remembered") || value.includes("voice") || value.includes("unseen")) return "Absent";
    if (value.includes("letter") || value.includes("evidence") || value.includes("target")) return "Evidence";
    return compactPhrase(character.role || character.relevance, 18);
  }

  function conciseLocationLabel(location) {
    return compactPhrase(location.label, 30);
  }

  function conciseAssetLabel(asset) {
    return compactPhrase(asset.label, 24);
  }

  function compactPhrase(value, limit) {
    const textValue = String(value || "").replace(/\s+/g, " ").trim();
    return textValue.length > limit ? `${textValue.slice(0, limit - 1).trim()}...` : textValue;
  }

  function isContextCharacter(character) {
    const value = `${character.role} ${character.relevance}`.toLowerCase();
    return character.weight < 65 || value.includes("absent") || value.includes("remembered") || value.includes("unseen") || value.includes("voice");
  }

  function clamp(value, min, max) {
    return Math.min(max, Math.max(min, value));
  }

  function relationshipTone(relationship) {
    const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
    if (stateText.includes("conflict") || stateText.includes("threat") || stateText.includes("obstruction") || stateText.includes("suspect")) return "conflict";
    if (stateText.includes("trust") || stateText.includes("ally") || stateText.includes("protect")) return "support";
    return relationship.weight >= 80 ? "strong" : "uncertain";
  }

  function inlineRelationshipLabel(relationship) {
    const stateText = `${relationship.label} ${relationship.state}`.toLowerCase();
    if (stateText.includes("trust")) return "trust strengthened";
    if (stateText.includes("ally") || stateText.includes("alliance")) return "ally";
    if (stateText.includes("conflict") || stateText.includes("threat")) return "tension increasing";
    if (stateText.includes("suspect") || stateText.includes("question")) return "questions";
    if (stateText.includes("family") || stateText.includes("grief")) return "family";
    if (stateText.includes("motive")) return "motive exposed";
    return relationship.state || relationship.label;
  }

  function assetConnectionLabel(asset) {
    const value = `${asset.name} ${asset.label}`.toLowerCase();
    if (value.includes("letter")) return "evidence";
    if (value.includes("key")) return "access";
    if (value.includes("tr6") || value.includes("car")) return "blocks escape";
    if (value.includes("phone")) return "recorded";
    return "found here";
  }

  function startTimer() {
    if (!scenes.length) return;
    stopTimer();
    if (!state.playing || !controllableMode) return;
    state.timer = window.setInterval(() => {
      const next = state.index >= scenes.length - 1 ? (replayMode ? scenes.length - 1 : 0) : state.index + 1;
      transitionToNextState(next);
      if (replayMode && next >= scenes.length - 1) {
        state.playing = false;
        text(dom.play, "Play");
        stopTimer();
      }
    }, state.intervalMs);
  }

  function stopTimer() {
    if (state.timer) {
      window.clearInterval(state.timer);
      state.timer = null;
    }
  }

  function startPolling() {
    if (!realMode || !snapshotUrl || state.terminal) return;
    stopPolling();
    state.pollingStoppedReason = "";
    state.pollTimer = window.setInterval(pollSnapshot, document.hidden ? 15000 : 7000);
  }

  function stopPolling(reason) {
    if (state.pollTimer) {
      window.clearInterval(state.pollTimer);
      state.pollTimer = null;
    }
    if (reason) state.pollingStoppedReason = reason;
  }

  async function pollSnapshot() {
    if (state.polling || document.hidden || state.terminal) return;
    state.polling = true;
    const requestStarted = performance.now();
    try {
      const response = await fetch(snapshotUrl, { headers: { Accept: "application/json" } });
      state.lastSnapshotHttpStatus = String(response.status);
      if (!response.ok) {
        state.consecutiveFailedPolls += 1;
        updateDiagnostics();
        return;
      }
      const snapshot = await response.json();
      state.consecutiveFailedPolls = 0;
      state.lastSuccessfulSnapshotFetch = new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" });
      if (snapshot.changeToken && snapshot.changeToken !== state.changeToken) {
        state.consecutiveUnchangedPolls = 0;
        replaceModel(snapshot, Math.round(performance.now() - requestStarted));
      } else if (snapshot.isTerminal) {
        state.terminal = true;
        stopPolling("terminal snapshot received");
        updateDiagnostics();
      } else {
        state.consecutiveUnchangedPolls += 1;
        updateDiagnostics();
      }
    } catch {
      state.lastSnapshotHttpStatus = "fetch failed";
      state.consecutiveFailedPolls += 1;
      updateDiagnostics();
    } finally {
      state.polling = false;
    }
  }

  function compareSnapshots(previousSnapshot, nextSnapshot) {
    if (!previousSnapshot) return [{ type: "InitialRender" }];
    const operations = [];
    const previousScene = activeScene(previousSnapshot);
    const nextScene = activeScene(nextSnapshot);

    if ((previousSnapshot.changeToken || "") !== (nextSnapshot.changeToken || "")) {
      operations.push({ type: "SnapshotChanged" });
    }
    if ((previousSnapshot.runStatus || "") !== (nextSnapshot.runStatus || "")) {
      operations.push({ type: "StageChanged" });
    }
    if (!!previousSnapshot.isTerminal !== !!nextSnapshot.isTerminal && nextSnapshot.isTerminal) {
      operations.push({ type: "RunCompleted" });
    }
    if (!previousScene || !nextScene) return operations;

    operations.push(...compareScenes(previousScene, nextScene));
    return compactOperations(operations);
  }

  function compareScenes(previousScene, nextScene) {
    if (!previousScene || !nextScene) return [{ type: "SceneChanged" }];
    const operations = [];
    if (previousScene.progressPercent !== nextScene.progressPercent) operations.push({ type: "ProgressChanged" });
    if (previousScene.stage !== nextScene.stage) operations.push({ type: "StageChanged" });
    if (previousScene.id !== nextScene.id || previousScene.sceneNumber !== nextScene.sceneNumber) operations.push({ type: "SceneChanged" });
    if ((previousScene.scenesAnalysed || 0) !== (nextScene.scenesAnalysed || 0)) operations.push({ type: "TimelineAdvanced" });
    if (previousScene.location?.id !== nextScene.location?.id) operations.push({ type: "LocationChanged" });

    compareEntityLists(previousScene.characters, nextScene.characters, "Character", operations, (before, after) => {
      if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "CharacterImportanceChanged", id: after.id });
      if ((before.imagePath || "") !== (after.imagePath || "")) operations.push({ type: "CharacterImageChanged", id: after.id });
    });
    compareEntityLists(previousScene.assets, nextScene.assets, "Asset", operations, (before, after) => {
      if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "AssetImportanceChanged", id: after.id });
    });
    compareEntityLists(previousScene.relationships, nextScene.relationships, "Relationship", operations, (before, after) => {
      if (Math.abs((before.weight || 0) - (after.weight || 0)) >= 5) operations.push({ type: "RelationshipStrengthChanged", id: after.id });
    });
    compareTextItems(previousScene.knowledgeThreads, nextScene.knowledgeThreads, "KnowledgeThread", operations);
    compareTextItems(previousScene.observations, nextScene.observations, "Observation", operations);
    compareStrings(previousScene.latestDiscoveries, nextScene.latestDiscoveries, "DiscoveryAdded", operations);
    return compactOperations(operations);
  }

  function compareEntityLists(previousItems, nextItems, prefix, operations, comparePersisted) {
    const previousById = keyedById(previousItems);
    const nextById = keyedById(nextItems);
    nextById.forEach((item, id) => {
      if (!previousById.has(id)) {
        operations.push({ type: `${prefix}Added`, id });
      } else {
        comparePersisted(previousById.get(id), item);
      }
    });
    previousById.forEach((item, id) => {
      if (!nextById.has(id)) operations.push({ type: `${prefix}Removed`, id });
    });
  }

  function compareTextItems(previousItems, nextItems, prefix, operations) {
    const previousById = keyedById(previousItems);
    const nextById = keyedById(nextItems);
    nextById.forEach((item, id) => {
      if (!previousById.has(id)) operations.push({ type: `${prefix}Added`, id });
    });
    previousById.forEach((item, id) => {
      if (!nextById.has(id)) operations.push({ type: prefix === "KnowledgeThread" ? "KnowledgeThreadResolved" : `${prefix}Removed`, id });
    });
  }

  function compareStrings(previousItems, nextItems, type, operations) {
    const previousSet = new Set(previousItems || []);
    (nextItems || []).forEach((item) => {
      if (!previousSet.has(item)) operations.push({ type, id: stableHash(item) });
    });
  }

  function activeScene(snapshot) {
    const snapshotScenes = snapshot?.scenes || [];
    return snapshotScenes.find((scene) => scene?.isCurrent) || (snapshotScenes.length ? snapshotScenes[snapshotScenes.length - 1] : null);
  }

  function authoritativeSceneIndex(snapshotScenes) {
    const index = (snapshotScenes || []).findIndex((scene) => scene?.isCurrent);
    return index >= 0 ? index : Math.max(0, (snapshotScenes || []).length - 1);
  }

  function logSnapshotSynchronisation(eventName, snapshot, displayedScene) {
    if (!realMode || !window.console?.info) return;
    const authoritative = activeScene(snapshot);
    console.info("Story Intelligence browser synchronisation", {
      event: eventName,
      changeToken: snapshot?.changeToken || "",
      snapshotSceneId: authoritative?.id || "",
      displayedSceneId: displayedScene?.id || "",
      snapshotSceneNumber: authoritative?.sceneNumber || "",
      displayedSceneNumber: displayedScene?.sceneNumber || ""
    });
  }

  function keyedById(items) {
    return new Map((items || []).filter((item) => item?.id).map((item) => [item.id, item]));
  }

  function compactOperations(operations) {
    const seen = new Set();
    return operations.filter((operation) => {
      const key = `${operation.type}:${operation.id || ""}`;
      if (seen.has(key)) return false;
      seen.add(key);
      return true;
    });
  }

  function queueOperations(operations) {
    const nextOperations = operations?.length ? operations : [{ type: "NoVisualChange" }];
    state.operationQueue = nextOperations;
    state.lastOperations = nextOperations.map((operation) => operation.type);
  }

  function updateDiagnostics() {
    if (!dom.diagnostics) return;
    text(dom.diagnosticsCurrent, state.currentVersion);
    text(dom.diagnosticsPrevious, state.previousVersion);
    text(dom.diagnosticsToken, state.changeToken || "none");
    text(dom.diagnosticsQueue, state.operationQueue.length);
    text(dom.diagnosticsRefresh, state.lastRefreshTime);
    text(dom.diagnosticsBuild, `${state.snapshotBuildMs}ms`);
    text(dom.diagnosticsOperations, state.lastOperations.join(", "));
    const diagnostics = model.diagnostics || {};
    dom.diagnosticValues.forEach((node) => {
      const key = node.dataset.diagnosticKey;
      const value = pollingDiagnosticValue(key, diagnostics);
      text(node, value);
    });
  }

  function pollingDiagnosticValue(key, diagnostics) {
    if (key === "lastSuccessfulSnapshotFetch") return state.lastSuccessfulSnapshotFetch;
    if (key === "lastSnapshotHttpStatus") return state.lastSnapshotHttpStatus;
    if (key === "consecutiveUnchangedPolls") return state.consecutiveUnchangedPolls;
    if (key === "consecutiveFailedPolls") return state.consecutiveFailedPolls;
    if (key === "pollingActive") return state.pollTimer ? "active" : "stopped";
    if (key === "pollingStoppedReason") return state.pollingStoppedReason || "none";
    if (key === "currentPollingInterval") return realMode ? `${document.hidden ? 15000 : 7000}ms` : `${state.intervalMs}ms`;
    if (key === "replayIndex") return replayMode ? state.index + 1 : diagnostics[key];
    if (key === "latestSnapshotAge") return latestSnapshotAge();
    const value = diagnostics[key];
    return value === null || value === undefined || value === "" ? "none" : value;
  }

  function latestSnapshotAge() {
    if (!model.generatedUtc) return "unknown";
    const generated = Date.parse(model.generatedUtc);
    if (Number.isNaN(generated)) return "unknown";
    const seconds = Math.max(0, Math.round((Date.now() - generated) / 1000));
    return seconds < 60 ? `${seconds}s` : `${Math.round(seconds / 60)}m`;
  }

  function animateNumber(node, nextValue, suffix) {
    if (!node) return;
    const previousValue = Number.parseInt(node.textContent, 10);
    if (reducedMotion || Number.isNaN(previousValue)) {
      text(node, `${nextValue}${suffix}`);
      return;
    }
    const start = performance.now();
    const duration = 720;
    const delta = nextValue - previousValue;
    if (delta === 0) {
      text(node, `${nextValue}${suffix}`);
      return;
    }
    const tick = (time) => {
      const progress = clamp((time - start) / duration, 0, 1);
      const eased = 1 - (1 - progress) ** 3;
      text(node, `${Math.round(previousValue + delta * eased)}${suffix}`);
      if (progress < 1) window.requestAnimationFrame(tick);
    };
    window.requestAnimationFrame(tick);
  }

  function removeInactive(container, selector, datasetKey, activeIds) {
    container.querySelectorAll(selector).forEach((node) => {
      if (activeIds.has(node.dataset[datasetKey])) return;
      node.classList.add("is-removing");
      window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
    });
  }

  function removeInactiveSvgConnections(activeIds) {
    dom.connections.querySelectorAll("[data-connection-id]").forEach((node) => {
      if (activeIds.has(node.dataset.connectionId)) return;
      node.classList.add("is-removing");
      window.setTimeout(() => node.remove(), reducedMotion ? 0 : 420);
    });
  }

  function settleEntering(node) {
    if (reducedMotion) {
      node.classList.remove("is-entering");
      return;
    }
    window.requestAnimationFrame(() => node.classList.remove("is-entering"));
  }

  function stableHash(value) {
    const textValue = String(value || "");
    let hash = 0;
    for (let index = 0; index < textValue.length; index += 1) {
      hash = ((hash << 5) - hash + textValue.charCodeAt(index)) | 0;
    }
    return Math.abs(hash).toString(36);
  }

  function text(node, value) {
    if (node) node.textContent = value ?? "";
  }

  function escapeHtml(value) {
    return String(value || "").replace(/[&<>"']/g, (match) => ({
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      "\"": "&quot;",
      "'": "&#039;"
    }[match]));
  }

  function cssEscape(value) {
    return window.CSS?.escape ? window.CSS.escape(value) : String(value).replace(/[^a-zA-Z0-9_-]/g, "\\$&");
  }

  function goToReplayIndex(index, restartPlayback) {
    if (!scenes.length) return;
    transitionToNextState(Math.max(0, Math.min(scenes.length - 1, index)));
    if (restartPlayback) startTimer();
  }

  root.querySelectorAll("[data-control='restart']").forEach((button) => button.addEventListener("click", () => {
    if (!controllableMode) return;
    transitionToNextState(0);
    startTimer();
  }));

  root.querySelectorAll("[data-control='previous']").forEach((button) => button.addEventListener("click", () => {
    if (!controllableMode) return;
    transitionToNextState(state.index <= 0 ? (replayMode ? 0 : scenes.length - 1) : state.index - 1);
    startTimer();
  }));

  root.querySelectorAll("[data-control='next']").forEach((button) => button.addEventListener("click", () => {
    if (!controllableMode) return;
    transitionToNextState(state.index >= scenes.length - 1 ? (replayMode ? scenes.length - 1 : 0) : state.index + 1);
    startTimer();
  }));

  root.querySelectorAll("[data-control='first']").forEach((button) => button.addEventListener("click", () => {
    if (!replayMode) return;
    goToReplayIndex(0, false);
  }));

  root.querySelectorAll("[data-control='last']").forEach((button) => button.addEventListener("click", () => {
    if (!replayMode) return;
    goToReplayIndex(scenes.length - 1, false);
  }));

  dom.play?.addEventListener("click", () => {
    if (!controllableMode) return;
    state.playing = !state.playing;
    text(dom.play, state.playing ? "Pause" : "Play");
    if (state.playing) startTimer(); else stopTimer();
    if (replayMode) updateProgress(loadSceneState(state.index));
  });

  dom.speed?.addEventListener("input", () => {
    if (!controllableMode) return;
    state.intervalMs = Number.parseInt(dom.speed.value, 10) || 6200;
    startTimer();
  });

  dom.detailClose?.addEventListener("click", closeDetail);

  dom.replayChapter?.addEventListener("change", () => {
    if (!replayMode) return;
    populateReplayScenes(dom.replayChapter.value);
    goToReplayIndex(Number.parseInt(dom.replayScene.value, 10) || 0, false);
  });

  dom.replayScene?.addEventListener("change", () => {
    if (!replayMode) return;
    goToReplayIndex(Number.parseInt(dom.replayScene.value, 10) || 0, false);
  });

  document.addEventListener("visibilitychange", () => {
    if (!realMode || state.terminal) return;
    startPolling();
    if (!document.hidden) pollSnapshot();
  });

  window.addEventListener("resize", () => {
    if (!scenes.length) return;
    const current = loadSceneState(state.index);
    const layout = sceneLayout(current);
    reconcileCharacters(current, current, layout);
    reconcileLocation(current, current);
    reconcileAssets(current, current, layout);
    scheduleConnectionDraw();
  });

  if (window.ResizeObserver && dom.visualStage) {
    const observer = new ResizeObserver(() => {
      if (!scenes.length) return;
      const current = loadSceneState(state.index);
      const layout = sceneLayout(current);
      reconcileCharacters(current, current, layout);
      reconcileAssets(current, current, layout);
      scheduleConnectionDraw();
    });
    observer.observe(dom.visualStage);
  }

  if (scenes.length) {
    setupReplayBrowser();
    transitionToNextState(realMode ? authoritativeSceneIndex(scenes) : 0);
    startTimer();
  } else {
    updateDiagnostics();
  }
  startPolling();
  if (realMode && !state.terminal) pollSnapshot();
})();
